Skip to content

Commit bb064c0

Browse files
committed
fix(improve): fix some issue and improve features
1 parent 09906e7 commit bb064c0

File tree

8 files changed

+419
-32
lines changed

8 files changed

+419
-32
lines changed

.github/workflows/nodejs.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ jobs:
1212
node-version: [20.x, 22.x]
1313

1414
steps:
15-
- uses: actions/checkout@v1
15+
- uses: actions/checkout@v4
1616
- name: Use Node.js ${{ matrix.node-version }}
17-
uses: actions/setup-node@v1
17+
uses: actions/setup-node@v4
1818
with:
1919
node-version: ${{ matrix.node-version }}
20+
cache: 'yarn'
2021
- name: yarn install, build, and test
2122
run: |
22-
yarn install
23+
yarn install --frozen-lockfile
2324
yarn build
2425
yarn test
2526
env:

README.md

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,26 @@ const yMap = doc.getMap('myData');
136136
const binder = bind<MyDataType>(yMap);
137137
```
138138

139+
### `createBinder(source, initialState, options?)`
140+
141+
Creates a binder with initial state in one call. This is a convenience function that combines `bind()` and initialization.
142+
143+
**Parameters:**
144+
145+
- `source`: `Y.Map<any> | Y.Array<any>` - The Yjs data type to bind
146+
- `initialState`: `S` - The initial state to set
147+
- `options?`: `Options<S>` - Optional configuration
148+
149+
**Returns:** `Binder<S>` - A binder instance with the initial state applied
150+
151+
**Example:**
152+
153+
```typescript
154+
const doc = new Y.Doc();
155+
const yMap = doc.getMap('myData');
156+
const binder = createBinder(yMap, { count: 0, items: [] });
157+
```
158+
139159
### Binder API
140160

141161
#### `binder.get()`
@@ -161,7 +181,7 @@ binder.update((state) => {
161181
});
162182
```
163183

164-
#### `binder.subscribe(fn)`
184+
#### `binder.subscribe(fn, options?)`
165185

166186
Subscribes to state changes. The callback is invoked when:
167187

@@ -171,14 +191,22 @@ Subscribes to state changes. The callback is invoked when:
171191
**Parameters:**
172192

173193
- `fn`: `(snapshot: S) => void` - Callback function that receives the new snapshot
194+
- `options?`: `SubscribeOptions` - Optional subscription configuration
195+
- `immediate?: boolean` - If true, calls the listener immediately with current snapshot
174196

175197
**Returns:** `UnsubscribeFn` - A function to unsubscribe
176198

177199
```typescript
200+
// Basic subscription
178201
const unsubscribe = binder.subscribe((snapshot) => {
179202
console.log('State changed:', snapshot);
180203
});
181204

205+
// Subscribe with immediate execution
206+
binder.subscribe((snapshot) => {
207+
console.log('Current state:', snapshot);
208+
}, { immediate: true });
209+
182210
// Later...
183211
unsubscribe();
184212
```
@@ -470,6 +498,54 @@ interface Options<S extends Snapshot> {
470498
- **Transactions**: Updates are wrapped in Yjs transactions automatically for optimal performance
471499
- **Unsubscribe**: Always call `unbind()` when done to prevent memory leaks
472500

501+
## Collaboration Semantics
502+
503+
`mutative-yjs` implements smart collaboration semantics to preserve changes from multiple collaborators:
504+
505+
### Array Element Replacement
506+
507+
When replacing array elements with objects, the library performs **incremental updates** instead of delete+insert:
508+
509+
```typescript
510+
// If both old and new values are objects
511+
binder.update((state) => {
512+
state.items[0] = { ...state.items[0], name: 'Updated' };
513+
});
514+
// → Updates properties in-place, preserving other collaborators' changes
515+
```
516+
517+
This prevents the "lost update" problem discussed in [immer-yjs#1](https://github.com/sep2/immer-yjs/issues/1).
518+
519+
### Circular Update Protection
520+
521+
The library uses transaction origins to prevent circular updates:
522+
523+
```typescript
524+
const binder = bind(yMap);
525+
526+
binder.subscribe((snapshot) => {
527+
// Safe: won't cause infinite loop
528+
if (snapshot.count < 10) {
529+
binder.update((state) => {
530+
state.count++;
531+
});
532+
}
533+
});
534+
```
535+
536+
### Circular Reference Detection
537+
538+
The library detects and rejects circular object references:
539+
540+
```typescript
541+
const circular: any = { a: 1 };
542+
circular.self = circular;
543+
544+
binder.update((state) => {
545+
state.data = circular; // ❌ Throws: "Circular reference detected"
546+
});
547+
```
548+
473549
## Examples
474550

475551
Check out the [test file](./test/indext.test.ts) for comprehensive examples including:
@@ -494,7 +570,7 @@ Contributions are welcome! Please feel free to submit a Pull Request.
494570

495571
## Related Projects
496572

497-
- [Mutative](https://github.com/unadlibjs/mutative) - Efficient immutable updates with a mutable API
573+
- [Mutative](https://github.com/unadlib/mutative) - Efficient immutable updates with a mutable API
498574
- [Yjs](https://github.com/yjs/yjs) - A CRDT framework for building collaborative applications
499575

500576
## Acknowledgments

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
{
22
"name": "mutative-yjs",
33
"description": "A library for building Yjs collaborative web applications with Mutative",
4-
"version": "0.1.0",
4+
"version": "0.2.0",
55
"type": "module",
66
"author": "unadlib",
77
"repository": {
88
"type": "git",
99
"url": "https://github.com/mutativejs/mutative-yjs.git"
1010
},
1111
"source": "./src/index.ts",
12-
"main": "./dist/index.esm.js",
12+
"main": "./dist/index.cjs.js",
13+
"module": "./dist/index.esm.js",
1314
"types": "./dist/index.d.ts",
1415
"unpkg": "./dist/index.umd.js",
1516
"exports": {

src/bind.ts

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ import {
1212

1313
export type Snapshot = JSONObject | JSONArray;
1414

15+
/**
16+
* Applies Yjs events to a base object.
17+
* IMPORTANT: `base` must be a Mutative draft object. Direct mutations
18+
* are safe only within a Mutative draft context.
19+
* @param base The draft object to mutate (from Mutative's create)
20+
* @param event The Yjs event describing the change
21+
*/
1522
function applyYEvent<T extends JSONValue>(base: T, event: Y.YEvent<any>) {
1623
if (event instanceof Y.YMapEvent && isJSONObject(base)) {
1724
const source = event.target as Y.Map<any>;
@@ -74,7 +81,7 @@ function defaultApplyPatch(target: Y.Map<any> | Y.Array<any>, patch: Patch) {
7481

7582
if (!path.length) {
7683
if (op !== PATCH_REPLACE) {
77-
notImplemented();
84+
notImplemented(`Cannot apply ${op} operation to root level`);
7885
}
7986

8087
if (target instanceof Y.Map && isJSONObject(value)) {
@@ -84,9 +91,11 @@ function defaultApplyPatch(target: Y.Map<any> | Y.Array<any>, patch: Patch) {
8491
}
8592
} else if (target instanceof Y.Array && isJSONArray(value)) {
8693
target.delete(0, target.length);
87-
target.push(value.map(toYDataType));
94+
target.push(value.map((v) => toYDataType(v)));
8895
} else {
89-
notImplemented();
96+
notImplemented(
97+
`Cannot replace root of type ${target.constructor.name} with value type ${typeof value}`
98+
);
9099
}
91100

92101
return;
@@ -116,20 +125,39 @@ function defaultApplyPatch(target: Y.Map<any> | Y.Array<any>, patch: Patch) {
116125
base.insert(property, [toYDataType(value)]);
117126
break;
118127
case PATCH_REPLACE:
119-
base.delete(property);
120-
base.insert(property, [toYDataType(value)]);
128+
// If both old and new values are objects, try incremental update
129+
// to preserve other collaborators' changes
130+
const oldValue = base.get(property);
131+
if (oldValue instanceof Y.Map && isJSONObject(value)) {
132+
// Incremental update: update properties instead of replacing
133+
oldValue.clear();
134+
Object.entries(value).forEach(([k, v]) => {
135+
oldValue.set(k, toYDataType(v));
136+
});
137+
} else {
138+
// For primitives or type changes, do full replacement
139+
base.delete(property, 1);
140+
base.insert(property, [toYDataType(value)]);
141+
}
121142
break;
122143
case PATCH_REMOVE:
123-
base.delete(property);
144+
base.delete(property, 1);
124145
break;
125146
}
126147
} else if (base instanceof Y.Array && property === 'length') {
127148
if (value < base.length) {
149+
// Shrink array
128150
const diff = base.length - value;
129151
base.delete(value, diff);
152+
} else if (value > base.length) {
153+
// Expand array with null values
154+
const toAdd = new Array(value - base.length).fill(null);
155+
base.push(toAdd);
130156
}
131157
} else {
132-
notImplemented();
158+
notImplemented(
159+
`Unsupported patch operation: ${op} on ${base?.constructor?.name ?? 'unknown'}.${String(property)}`
160+
);
133161
}
134162
}
135163

@@ -148,18 +176,27 @@ function applyUpdate<S extends Snapshot>(
148176
fn: UpdateFn<S>,
149177
applyPatch: typeof defaultApplyPatch,
150178
patchesOptions: PatchesOptions
151-
) {
152-
const [, patches] = create(snapshot, fn, {
179+
): S {
180+
const [nextState, patches] = create(snapshot, fn, {
153181
enablePatches: patchesOptions,
154182
});
155183
for (const patch of patches) {
156184
applyPatch(source, patch);
157185
}
186+
return nextState;
158187
}
159188

160189
export type ListenerFn<S extends Snapshot> = (snapshot: S) => void;
161190
export type UnsubscribeFn = () => void;
162191

192+
export type SubscribeOptions = {
193+
/**
194+
* If true, the listener will be called immediately with the current snapshot.
195+
* @default false
196+
*/
197+
immediate?: boolean;
198+
};
199+
163200
export type Binder<S extends Snapshot> = {
164201
/**
165202
* Release the binder.
@@ -181,8 +218,10 @@ export type Binder<S extends Snapshot> = {
181218
* Subscribe to snapshot update, fired when:
182219
* 1. User called update(fn).
183220
* 2. y.js source.observeDeep() fired.
221+
* @param fn Listener function that receives the new snapshot
222+
* @param options Optional configuration for subscription behavior
184223
*/
185-
subscribe: (fn: ListenerFn<S>) => UnsubscribeFn;
224+
subscribe: (fn: ListenerFn<S>, options?: SubscribeOptions) => UnsubscribeFn;
186225
};
187226

188227
export type Options<S extends Snapshot> = {
@@ -210,6 +249,8 @@ export type Options<S extends Snapshot> = {
210249
* @param source The y.js data type to bind.
211250
* @param options Change default behavior, can be omitted.
212251
*/
252+
const MUTATIVE_YJS_ORIGIN = Symbol('mutative-yjs');
253+
213254
export function bind<S extends Snapshot>(
214255
source: Y.Map<any> | Y.Array<any>,
215256
options?: Options<S>
@@ -220,12 +261,18 @@ export function bind<S extends Snapshot>(
220261

221262
const subscription = new Set<ListenerFn<S>>();
222263

223-
const subscribe = (fn: ListenerFn<S>) => {
264+
const subscribe = (fn: ListenerFn<S>, options?: SubscribeOptions) => {
224265
subscription.add(fn);
266+
if (options?.immediate) {
267+
fn(get());
268+
}
225269
return () => void subscription.delete(fn);
226270
};
227271

228-
const observer = (events: Y.YEvent<any>[]) => {
272+
const observer = (events: Y.YEvent<any>[], transaction: Y.Transaction) => {
273+
// Skip events originated from this binder to prevent circular updates
274+
if (transaction.origin === MUTATIVE_YJS_ORIGIN) return;
275+
229276
snapshot = applyYEvents(get(), events);
230277
subscription.forEach((fn) => fn(get()));
231278
};
@@ -256,13 +303,17 @@ export function bind<S extends Snapshot>(
256303
}
257304

258305
const doApplyUpdate = () => {
259-
applyUpdate(source, get(), fn, applyPatch, patchesOptionsInOption);
306+
snapshot = applyUpdate(source, get(), fn, applyPatch, patchesOptionsInOption);
260307
};
261308

262309
if (doc) {
263-
Y.transact(doc, doApplyUpdate);
310+
Y.transact(doc, doApplyUpdate, MUTATIVE_YJS_ORIGIN);
311+
// Notify subscribers after transaction since observer skips our origin
312+
subscription.forEach((fn) => fn(get()));
264313
} else {
314+
// Without doc, manually update snapshot and notify subscribers
265315
doApplyUpdate();
316+
subscription.forEach((fn) => fn(get()));
266317
}
267318
};
268319

src/helpers.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as Y from 'yjs';
2+
import { bind, type Binder, type Options } from './bind';
3+
import type { Snapshot } from './bind';
4+
5+
/**
6+
* Creates a binder with initial state in one call.
7+
* This is a convenience function that combines bind() and update().
8+
*
9+
* @param source The Yjs data type to bind
10+
* @param initialState The initial state to set
11+
* @param options Optional configuration for the binder
12+
* @returns A binder instance with the initial state applied
13+
*
14+
* @example
15+
* ```ts
16+
* const doc = new Y.Doc();
17+
* const map = doc.getMap('data');
18+
* const binder = createBinder(map, { count: 0, items: [] });
19+
* ```
20+
*/
21+
export function createBinder<S extends Snapshot>(
22+
source: Y.Map<any> | Y.Array<any>,
23+
initialState: S,
24+
options?: Options<S>
25+
): Binder<S> {
26+
const binder = bind<S>(source, options);
27+
binder.update(() => {
28+
// Use type assertion to return the initial state
29+
// This is safe because we're initializing the state
30+
return initialState as any;
31+
});
32+
return binder;
33+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from './bind';
22
export * from './types';
33
export { applyJsonArray, applyJsonObject } from './util';
4+
export type { SubscribeOptions } from './bind';
5+
export { createBinder } from './helpers';

0 commit comments

Comments
 (0)