Skip to content

Commit 3ab6e11

Browse files
committed
feat(composables): add usePatches
1 parent 6c68fc1 commit 3ab6e11

9 files changed

Lines changed: 254 additions & 0 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Vue utilities
22

3+
## Composables
4+
5+
A collection of useful Vue composables.
6+
7+
- Package: [packages/composables](packages/composables)
8+
- Docs: [packages/composables/README.md](packages/composables/README.md)
9+
10+
Install:
11+
12+
```bash
13+
pnpm add @vingy/composables
14+
```
15+
316
## Vuebugger
417

518
Vue Devtools helper for debugging composables and reactive state. Add `debug()` to expose values in Devtools without production overhead.

packages/composables/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Composables
2+
3+
A collection of Vue composables for managing optimistic updates.
4+
5+
## Features
6+
7+
- Apply local patches to state while preserving them when server updates arrive
8+
- Automatic cleanup of redundant patches that match server state
9+
- Immutable updates powered by [Mutative](https://mutative.js.org/)
10+
11+
## Quick Start
12+
13+
```bash
14+
pnpm add @vingy/composables
15+
```
16+
17+
## Usage
18+
19+
### `usePatches`
20+
21+
Manage optimistic updates that automatically clean up when server data arrives.
22+
23+
```ts
24+
import { usePatches } from '@vingy/composables'
25+
26+
const { data, patch, reset } = usePatches({
27+
count: 0,
28+
name: 'John',
29+
})
30+
31+
// Apply optimistic update
32+
patch((draft) => {
33+
draft.count++
34+
})
35+
36+
// When server update arrives, matching patches are removed
37+
data.value = serverResponse
38+
39+
// Or reset all patches manually
40+
reset()
41+
```
42+
43+
**Returns:**
44+
45+
- `data` - Reactive state with patches applied
46+
- `patches` - Current patches (readonly)
47+
- `patch(fn)` - Add optimistic update
48+
- `removePatch(fn)` - Remove specific patch
49+
- `reset()` - Clear all patches

packages/composables/package.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "@vingy/composables",
3+
"version": "0.0.0",
4+
"description": "A collection of useful composables.",
5+
"keywords": [
6+
"composable",
7+
"composition-api",
8+
"state",
9+
"vue"
10+
],
11+
"license": "MIT",
12+
"author": "vingy",
13+
"repository": {
14+
"type": "git",
15+
"url": "https://github.com/vinpogo/vue-utils"
16+
},
17+
"files": [
18+
"dist"
19+
],
20+
"type": "module",
21+
"sideEffects": false,
22+
"main": "./dist/index.mjs",
23+
"types": "./dist/index.d.mts",
24+
"exports": {
25+
".": {
26+
"types": "./dist/index.d.mts",
27+
"import": "./dist/index.mjs"
28+
}
29+
},
30+
"publishConfig": {
31+
"access": "public"
32+
},
33+
"scripts": {
34+
"build": "tsdown",
35+
"dev": "tsdown -w"
36+
},
37+
"dependencies": {
38+
"mutative": "catalog:"
39+
},
40+
"devDependencies": {},
41+
"peerDependencies": {
42+
"vue": "catalog:"
43+
},
44+
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
45+
}

packages/composables/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { usePatches } from './usePatches'
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { create, type Draft } from 'mutative'
2+
import {
3+
readonly,
4+
ref,
5+
toValue,
6+
watchEffect,
7+
type MaybeRefOrGetter,
8+
type Ref,
9+
} from 'vue'
10+
11+
type Patch<T> = (data: Draft<T>) => void
12+
13+
export const usePatches = <
14+
TState extends Record<string | number | symbol, any>,
15+
>(
16+
initialData: MaybeRefOrGetter<TState>,
17+
) => {
18+
const data = ref(toValue(initialData)) as Ref<TState>
19+
20+
const patches = ref<Patch<TState>[]>([]) as Ref<
21+
Patch<TState>[]
22+
>
23+
const patch = (patch: Patch<TState>) =>
24+
patches.value.push(patch)
25+
const removePatch = (patch: Patch<TState>) =>
26+
(patches.value = patches.value.filter(
27+
(p) => p !== patch,
28+
))
29+
const reset = () => (patches.value = [])
30+
31+
const flush = () => {
32+
let d = data.value
33+
patches.value.forEach((patch) => {
34+
const patchedData = create<TState>(d, (draft) => {
35+
patch(draft)
36+
})
37+
if (patchedData === d) {
38+
removePatch(patch)
39+
}
40+
d = patchedData
41+
})
42+
43+
data.value = d
44+
}
45+
46+
watchEffect(() => {
47+
flush()
48+
})
49+
50+
return {
51+
data,
52+
patches: readonly(patches),
53+
patch,
54+
removePatch,
55+
reset,
56+
}
57+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { nextTick } from 'vue'
3+
import { usePatches } from './usePatches'
4+
5+
describe('patches', () => {
6+
it('no patches returns initial data', () => {
7+
const initialData = { foo: 'bar' }
8+
const { data } = usePatches(initialData)
9+
10+
expect(data.value).toEqual(initialData)
11+
})
12+
13+
it('applies patch to data', async () => {
14+
const initialData = { foo: 'bar' }
15+
const { data, patch } = usePatches(initialData)
16+
patch((data) => (data.foo = 'new value'))
17+
18+
await nextTick()
19+
20+
expect(data.value.foo).toBe('new value')
21+
})
22+
23+
it('applies multiple patches to data', async () => {
24+
const initialData = { foo: 'bar', fo: 'baz' }
25+
const { data, patch } = usePatches(initialData)
26+
patch((data) => (data.foo = 'new value'))
27+
patch((data) => (data.fo = 'another value'))
28+
29+
await nextTick()
30+
expect(data.value.foo).toBe('new value')
31+
expect(data.value.fo).toBe('another value')
32+
})
33+
34+
it('automatically cleans redundant patches', async () => {
35+
const initialData = { foo: 'bar', bar: 'baz' }
36+
const { data, patches, patch } = usePatches(initialData)
37+
38+
patch((data) => (data.foo = 'new value'))
39+
patch((data) => (data.bar = 'new value'))
40+
data.value = { foo: 'race condition', bar: 'new value' }
41+
42+
await nextTick()
43+
expect(patches.value).toHaveLength(1)
44+
expect(data.value).toEqual({
45+
foo: 'new value',
46+
bar: 'new value',
47+
})
48+
})
49+
50+
it('removes all patches', async () => {
51+
const initialData = { foo: 'bar', bar: 'baz' }
52+
const { data, patches, patch, reset } =
53+
usePatches(initialData)
54+
patch((data) => (data.foo = 'new value'))
55+
patch((data) => (data.bar = 'new value'))
56+
reset()
57+
58+
await nextTick()
59+
expect(patches.value).toHaveLength(0)
60+
expect(data.value).toEqual({ foo: 'bar', bar: 'baz' })
61+
})
62+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'tsdown'
2+
3+
export default defineConfig({
4+
dts: true,
5+
entry: 'src/index.ts',
6+
format: 'esm',
7+
inlineOnly: false,
8+
})

pnpm-lock.yaml

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ catalogs:
1313
'@vitest/browser-playwright': 4.1.2
1414
'@vue/devtools-api': ^8.0.5
1515
'@vue/tsconfig': ^0.8.1
16+
mutative: ^1.3.0
1617
oxfmt: ^0.43.0
1718
oxlint: ^1.58.0
1819
pinia: ^3.0.4

0 commit comments

Comments
 (0)