@coaction/yjs 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,13 +8,11 @@ A Coaction integration tool for Yjs.
8
8
 
9
9
  ## Installation
10
10
 
11
- You can install it via npm, yarn or pnpm.
12
-
13
11
  ```sh
14
12
  npm install coaction @coaction/yjs
15
13
  ```
16
14
 
17
- ## Usage
15
+ ## Quick Start
18
16
 
19
17
  ```ts
20
18
  import { create } from 'coaction';
@@ -35,6 +33,109 @@ const store = create(
35
33
  );
36
34
  ```
37
35
 
36
+ ## Sync Model
37
+
38
+ - The binding stores state under `doc.getMap(key).get('state')`.
39
+ - `state` is persisted as nested `Y.Map` / `Y.Array` structures.
40
+ - Local Coaction updates are synced to Yjs.
41
+ - Remote Yjs updates are replayed back into Coaction.
42
+ - Writes are diff-based against the last synced local snapshot to reduce overwrite risk in concurrent edits.
43
+
44
+ ## Conflict Semantics
45
+
46
+ - Concurrent updates to different fields can converge through Yjs merge behavior.
47
+ - Concurrent updates to the same field follow Yjs conflict semantics (effectively last-writer-wins for scalar fields).
48
+ - If you need commutative semantics (for example counters), use CRDT-native field modeling in Yjs for that field.
49
+
50
+ ## Provider Integration
51
+
52
+ `@coaction/yjs` only binds a Coaction store to a `Y.Doc`. Network transport is provided by your Yjs provider (for example `y-websocket`, `y-webrtc`, or a custom provider).
53
+
54
+ ```ts
55
+ import { create } from 'coaction';
56
+ import { bindYjs } from '@coaction/yjs';
57
+ import { Doc } from 'yjs';
58
+ import { WebsocketProvider } from 'y-websocket';
59
+
60
+ const doc = new Doc();
61
+ const provider = new WebsocketProvider('wss://your-server', 'room-id', doc);
62
+
63
+ const store = create((set) => ({
64
+ count: 0,
65
+ increment() {
66
+ set((draft) => {
67
+ draft.count += 1;
68
+ });
69
+ }
70
+ }));
71
+
72
+ const binding = bindYjs(store, {
73
+ doc,
74
+ key: 'counter'
75
+ });
76
+
77
+ // later
78
+ binding.destroy();
79
+ provider.destroy();
80
+ store.destroy();
81
+ ```
82
+
83
+ ## API
84
+
85
+ ### `bindYjs(store, options?)`
86
+
87
+ Binds a Coaction store to Yjs and returns a binding object.
88
+
89
+ - `options.doc?: Y.Doc`
90
+ - Reuse an existing doc for multi-peer collaboration.
91
+ - If omitted, an internal `Y.Doc` is created.
92
+ - `options.key?: string`
93
+ - Root map key for this store namespace.
94
+ - Default: `coaction:${store.name}`.
95
+
96
+ Returns:
97
+
98
+ - `doc: Y.Doc`
99
+ - `map: Y.Map<any>`
100
+ - `syncNow(): void`
101
+ - Pushes the current local state to Yjs immediately.
102
+ - `destroy(): void`
103
+ - Unsubscribes observers and releases resources.
104
+ - Destroys `doc` only when it was internally created.
105
+
106
+ ### `yjs(options?)`
107
+
108
+ Middleware wrapper around `bindYjs`. It also wires cleanup into `store.destroy()`.
109
+
110
+ ## State Requirements
111
+
112
+ - Keep the synced state plain and serializable.
113
+ - Methods and getters are Coaction runtime behavior and are not the synchronization payload.
114
+ - Prefer a plain object at the root of your store state.
115
+
116
+ ## Compatibility and Limits
117
+
118
+ - Not supported in `store.share === 'client'` mode.
119
+ - Very large or highly volatile state trees can produce heavy update traffic.
120
+ - Cleanup is required: always call `binding.destroy()` (or `store.destroy()` when using middleware).
121
+
122
+ ## Migration Notes
123
+
124
+ - Current storage shape uses `Y.Map`/`Y.Array` for nested state.
125
+ - If old data stored `state` as a plain object, the binding migrates it to Yjs structures automatically.
126
+ - If you read Yjs state directly, do not assume `map.get('state').count`; `state` is usually a `Y.Map`.
127
+
128
+ ## Troubleshooting
129
+
130
+ - `setState cannot be called within the updater`
131
+ - Indicates re-entrant updates in your app flow. Avoid nested synchronous state loops.
132
+ - State not syncing across peers
133
+ - Verify all peers use the same room/doc provider and same `key`.
134
+ - Unexpected stale updates
135
+ - Ensure `destroy()` is called for old bindings to avoid duplicate observers.
136
+ - High update volume
137
+ - Reduce write frequency (debounce/throttle app-level updates if needed).
138
+
38
139
  ## Documentation
39
140
 
40
- You can find the documentation [here](https://github.com/unadlib/coaction).
141
+ You can find the project documentation [here](https://github.com/unadlib/coaction).
package/dist/index.d.mts CHANGED
@@ -11,8 +11,20 @@ type YjsBinding<T extends object> = {
11
11
  map: Y.Map<any>;
12
12
  syncNow: () => void;
13
13
  destroy: () => void;
14
+ __unsafeTestOnly__?: {
15
+ applyRemoteOperations: (operations: Array<{
16
+ type: 'set' | 'delete';
17
+ path: Array<string | number>;
18
+ value?: unknown;
19
+ }>) => void;
20
+ };
21
+ };
22
+ declare const __unsafeTestOnly__: {
23
+ getYValueAtPath: (root: Y.Map<unknown>, path: Array<string | number>) => unknown;
24
+ setAtPath: (target: any, path: Array<string | number>, value: unknown) => void;
25
+ deleteAtPath: (target: any, path: Array<string | number>) => void;
14
26
  };
15
27
  declare const bindYjs: <T extends object>(store: Store<T>, options?: YjsBindingOptions) => YjsBinding<T>;
16
28
  declare const yjs: <T extends object>(options?: YjsBindingOptions) => Middleware<T>;
17
29
 
18
- export { type YjsBinding, type YjsBindingOptions, bindYjs, yjs };
30
+ export { type YjsBinding, type YjsBindingOptions, __unsafeTestOnly__, bindYjs, yjs };
package/dist/index.d.ts CHANGED
@@ -11,8 +11,20 @@ type YjsBinding<T extends object> = {
11
11
  map: Y.Map<any>;
12
12
  syncNow: () => void;
13
13
  destroy: () => void;
14
+ __unsafeTestOnly__?: {
15
+ applyRemoteOperations: (operations: Array<{
16
+ type: 'set' | 'delete';
17
+ path: Array<string | number>;
18
+ value?: unknown;
19
+ }>) => void;
20
+ };
21
+ };
22
+ declare const __unsafeTestOnly__: {
23
+ getYValueAtPath: (root: Y.Map<unknown>, path: Array<string | number>) => unknown;
24
+ setAtPath: (target: any, path: Array<string | number>, value: unknown) => void;
25
+ deleteAtPath: (target: any, path: Array<string | number>) => void;
14
26
  };
15
27
  declare const bindYjs: <T extends object>(store: Store<T>, options?: YjsBindingOptions) => YjsBinding<T>;
16
28
  declare const yjs: <T extends object>(options?: YjsBindingOptions) => Middleware<T>;
17
29
 
18
- export { type YjsBinding, type YjsBindingOptions, bindYjs, yjs };
30
+ export { type YjsBinding, type YjsBindingOptions, __unsafeTestOnly__, bindYjs, yjs };
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  // index.ts
32
32
  var index_exports = {};
33
33
  __export(index_exports, {
34
+ __unsafeTestOnly__: () => __unsafeTestOnly__,
34
35
  bindYjs: () => bindYjs,
35
36
  yjs: () => yjs
36
37
  });
@@ -39,18 +40,313 @@ module.exports = __toCommonJS(index_exports);
39
40
  // src/index.ts
40
41
  var src_exports = {};
41
42
  __export(src_exports, {
43
+ __unsafeTestOnly__: () => __unsafeTestOnly__,
42
44
  bindYjs: () => bindYjs,
43
45
  yjs: () => yjs
44
46
  });
45
47
  var Y = __toESM(require("yjs"));
46
48
  __reExport(src_exports, require("yjs"));
47
- var ORIGIN = /* @__PURE__ */ Symbol("coaction-yjs");
49
+ var STATE_KEY = "state";
48
50
  function clone(value) {
49
51
  if (typeof structuredClone === "function") {
50
52
  return structuredClone(value);
51
53
  }
52
54
  return JSON.parse(JSON.stringify(value));
53
55
  }
56
+ function isPlainObject(value) {
57
+ if (typeof value !== "object" || value === null) {
58
+ return false;
59
+ }
60
+ const prototype = Object.getPrototypeOf(value);
61
+ return prototype === Object.prototype || prototype === null;
62
+ }
63
+ function toPlainObject(value) {
64
+ const next = {};
65
+ value.forEach((item, key) => {
66
+ next[key] = toPlainValue(item);
67
+ });
68
+ return next;
69
+ }
70
+ function toPlainArray(value) {
71
+ return value.toArray().map((item) => toPlainValue(item));
72
+ }
73
+ function toPlainValue(value) {
74
+ if (value instanceof Y.Map) {
75
+ return toPlainObject(value);
76
+ }
77
+ if (value instanceof Y.Array) {
78
+ return toPlainArray(value);
79
+ }
80
+ return value;
81
+ }
82
+ function createYMap(value) {
83
+ const next = new Y.Map();
84
+ for (const [key, item] of Object.entries(value)) {
85
+ next.set(key, toYValue(item));
86
+ }
87
+ return next;
88
+ }
89
+ function createYArray(value) {
90
+ const next = new Y.Array();
91
+ if (value.length > 0) {
92
+ next.insert(
93
+ 0,
94
+ value.map((item) => toYValue(item))
95
+ );
96
+ }
97
+ return next;
98
+ }
99
+ function toYValue(value) {
100
+ if (Array.isArray(value)) {
101
+ return createYArray(value);
102
+ }
103
+ if (isPlainObject(value)) {
104
+ return createYMap(value);
105
+ }
106
+ if (typeof value === "object" && value !== null) {
107
+ return clone(value);
108
+ }
109
+ return value;
110
+ }
111
+ function isEqualValue(left, right) {
112
+ if (Object.is(left, right)) {
113
+ return true;
114
+ }
115
+ if (Array.isArray(left) && Array.isArray(right)) {
116
+ if (left.length !== right.length) {
117
+ return false;
118
+ }
119
+ for (let index = 0; index < left.length; index += 1) {
120
+ if (!isEqualValue(left[index], right[index])) {
121
+ return false;
122
+ }
123
+ }
124
+ return true;
125
+ }
126
+ if (isPlainObject(left) && isPlainObject(right)) {
127
+ const leftKeys = Object.keys(left);
128
+ const rightKeys = Object.keys(right);
129
+ if (leftKeys.length !== rightKeys.length) {
130
+ return false;
131
+ }
132
+ for (const key of leftKeys) {
133
+ if (!Object.prototype.hasOwnProperty.call(right, key)) {
134
+ return false;
135
+ }
136
+ if (!isEqualValue(left[key], right[key])) {
137
+ return false;
138
+ }
139
+ }
140
+ return true;
141
+ }
142
+ return false;
143
+ }
144
+ function syncObjectToYMap(target, previous, source) {
145
+ const keys = /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(source)]);
146
+ for (const key of keys) {
147
+ const hasPrevious = Object.prototype.hasOwnProperty.call(previous, key);
148
+ const hasNext = Object.prototype.hasOwnProperty.call(source, key);
149
+ if (!hasNext) {
150
+ target.delete(key);
151
+ continue;
152
+ }
153
+ const previousValue = hasPrevious ? previous[key] : void 0;
154
+ const nextValue = source[key];
155
+ if (hasPrevious && isEqualValue(previousValue, nextValue)) {
156
+ continue;
157
+ }
158
+ const current = target.get(key);
159
+ if (Array.isArray(nextValue)) {
160
+ if (Array.isArray(previousValue) && current instanceof Y.Array) {
161
+ syncArrayToYArray(current, previousValue, nextValue);
162
+ } else {
163
+ target.set(key, createYArray(nextValue));
164
+ }
165
+ continue;
166
+ }
167
+ if (isPlainObject(nextValue)) {
168
+ if (isPlainObject(previousValue) && current instanceof Y.Map) {
169
+ syncObjectToYMap(current, previousValue, nextValue);
170
+ } else {
171
+ target.set(key, createYMap(nextValue));
172
+ }
173
+ continue;
174
+ }
175
+ const normalized = typeof nextValue === "object" && nextValue !== null ? clone(nextValue) : nextValue;
176
+ if (!Object.is(current, normalized)) {
177
+ target.set(key, normalized);
178
+ }
179
+ }
180
+ }
181
+ function syncArrayToYArray(target, previous, source) {
182
+ if (target.length > source.length) {
183
+ target.delete(source.length, target.length - source.length);
184
+ }
185
+ const maxLength = Math.max(previous.length, source.length);
186
+ for (let index = 0; index < maxLength; index += 1) {
187
+ const hasPrevious = index < previous.length;
188
+ const hasNext = index < source.length;
189
+ if (!hasNext) {
190
+ continue;
191
+ }
192
+ const previousValue = hasPrevious ? previous[index] : void 0;
193
+ const nextValue = source[index];
194
+ if (hasPrevious && isEqualValue(previousValue, nextValue)) {
195
+ continue;
196
+ }
197
+ if (index >= target.length) {
198
+ target.insert(index, [toYValue(nextValue)]);
199
+ continue;
200
+ }
201
+ const current = target.get(index);
202
+ if (Array.isArray(nextValue)) {
203
+ if (Array.isArray(previousValue) && current instanceof Y.Array) {
204
+ syncArrayToYArray(current, previousValue, nextValue);
205
+ } else {
206
+ target.delete(index, 1);
207
+ target.insert(index, [createYArray(nextValue)]);
208
+ }
209
+ continue;
210
+ }
211
+ if (isPlainObject(nextValue)) {
212
+ if (isPlainObject(previousValue) && current instanceof Y.Map) {
213
+ syncObjectToYMap(current, previousValue, nextValue);
214
+ } else {
215
+ target.delete(index, 1);
216
+ target.insert(index, [createYMap(nextValue)]);
217
+ }
218
+ continue;
219
+ }
220
+ const normalized = typeof nextValue === "object" && nextValue !== null ? clone(nextValue) : nextValue;
221
+ if (!Object.is(current, normalized)) {
222
+ target.delete(index, 1);
223
+ target.insert(index, [normalized]);
224
+ }
225
+ }
226
+ }
227
+ function isSetStateReentryError(error) {
228
+ return error instanceof Error && error.message === "setState cannot be called within the updater";
229
+ }
230
+ function cloneForStore(value) {
231
+ if (typeof value === "object" && value !== null) {
232
+ return clone(value);
233
+ }
234
+ return value;
235
+ }
236
+ function toPathKey(path) {
237
+ return path.map((segment) => `${typeof segment}:${String(segment)}`).join("|");
238
+ }
239
+ function compactOperations(operations) {
240
+ const deduplicated = /* @__PURE__ */ new Map();
241
+ for (const operation of operations) {
242
+ const key = toPathKey(operation.path);
243
+ if (deduplicated.has(key)) {
244
+ deduplicated.delete(key);
245
+ }
246
+ deduplicated.set(key, operation);
247
+ }
248
+ return Array.from(deduplicated.values()).sort(
249
+ (left, right) => left.path.length - right.path.length
250
+ );
251
+ }
252
+ function getYValueAtPath(root, path) {
253
+ let current = root;
254
+ for (const segment of path) {
255
+ if (current instanceof Y.Map) {
256
+ current = current.get(String(segment));
257
+ continue;
258
+ }
259
+ if (current instanceof Y.Array) {
260
+ const index = typeof segment === "number" ? segment : Number.parseInt(segment, 10);
261
+ if (!Number.isInteger(index)) {
262
+ return void 0;
263
+ }
264
+ current = current.get(index);
265
+ continue;
266
+ }
267
+ return void 0;
268
+ }
269
+ return current;
270
+ }
271
+ function setAtPath(target, path, value) {
272
+ if (path.length === 0) {
273
+ return;
274
+ }
275
+ let current = target;
276
+ for (let index = 0; index < path.length - 1; index += 1) {
277
+ const segment = path[index];
278
+ const nextSegment = path[index + 1];
279
+ const nextValue = current[segment];
280
+ if (typeof nextValue !== "object" || nextValue === null) {
281
+ current[segment] = typeof nextSegment === "number" ? [] : {};
282
+ }
283
+ current = current[segment];
284
+ }
285
+ const leaf = path[path.length - 1];
286
+ current[leaf] = cloneForStore(value);
287
+ }
288
+ function deleteAtPath(target, path) {
289
+ if (path.length === 0) {
290
+ return;
291
+ }
292
+ let current = target;
293
+ for (let index = 0; index < path.length - 1; index += 1) {
294
+ current = current[path[index]];
295
+ if (typeof current !== "object" || current === null) {
296
+ return;
297
+ }
298
+ }
299
+ const leaf = path[path.length - 1];
300
+ if (Array.isArray(current) && typeof leaf === "number") {
301
+ if (leaf >= 0 && leaf < current.length) {
302
+ current.splice(leaf, 1);
303
+ }
304
+ return;
305
+ }
306
+ delete current[leaf];
307
+ }
308
+ function collectRemoteOperations(events, stateMap) {
309
+ const operations = [];
310
+ for (const event of events) {
311
+ if (event instanceof Y.YMapEvent) {
312
+ for (const changedKey of event.keysChanged) {
313
+ const path = [...event.path, changedKey];
314
+ const keyChange = event.changes.keys.get(changedKey);
315
+ if (keyChange?.action === "delete") {
316
+ operations.push({
317
+ type: "delete",
318
+ path
319
+ });
320
+ continue;
321
+ }
322
+ operations.push({
323
+ type: "set",
324
+ path,
325
+ value: toPlainValue(getYValueAtPath(stateMap, path))
326
+ });
327
+ }
328
+ continue;
329
+ }
330
+ if (event instanceof Y.YArrayEvent) {
331
+ const path = [...event.path];
332
+ operations.push({
333
+ type: "set",
334
+ path,
335
+ value: toPlainValue(getYValueAtPath(stateMap, path))
336
+ });
337
+ }
338
+ }
339
+ return operations;
340
+ }
341
+ var __unsafeTestOnly__ = {
342
+ getYValueAtPath: (root, path) => getYValueAtPath(root, path),
343
+ setAtPath: (target, path, value) => {
344
+ setAtPath(target, path, value);
345
+ },
346
+ deleteAtPath: (target, path) => {
347
+ deleteAtPath(target, path);
348
+ }
349
+ };
54
350
  var bindYjs = (store, options = {}) => {
55
351
  if (store.share === "client") {
56
352
  throw new Error("Yjs binding is not supported in client store mode.");
@@ -58,48 +354,214 @@ var bindYjs = (store, options = {}) => {
58
354
  const doc = options.doc ?? new Y.Doc();
59
355
  const key = options.key ?? `coaction:${store.name}`;
60
356
  const map = doc.getMap(key);
357
+ const localOrigin = /* @__PURE__ */ Symbol(`coaction-yjs:${store.name}`);
358
+ let destroyed = false;
359
+ let syncingFromYjs = false;
360
+ let lastSyncedState = (() => {
361
+ const pureState = clone(store.getPureState());
362
+ return isPlainObject(pureState) ? pureState : {};
363
+ })();
364
+ let flushScheduled = false;
365
+ let pendingSnapshot = null;
366
+ let pendingOperations = [];
367
+ const applyRemoteState = (state) => {
368
+ const next = clone(state);
369
+ syncingFromYjs = true;
370
+ try {
371
+ store.setState(next);
372
+ const pureState = clone(store.getPureState());
373
+ lastSyncedState = isPlainObject(pureState) ? pureState : {};
374
+ } finally {
375
+ syncingFromYjs = false;
376
+ }
377
+ };
378
+ const applyRemoteOperations = (operations) => {
379
+ if (operations.length === 0) {
380
+ return;
381
+ }
382
+ syncingFromYjs = true;
383
+ try {
384
+ store.setState((draft) => {
385
+ const mutableDraft = draft;
386
+ for (const operation of operations) {
387
+ if (operation.type === "set") {
388
+ setAtPath(mutableDraft, operation.path, operation.value);
389
+ } else {
390
+ deleteAtPath(mutableDraft, operation.path);
391
+ }
392
+ }
393
+ });
394
+ const pureState = clone(store.getPureState());
395
+ lastSyncedState = isPlainObject(pureState) ? pureState : {};
396
+ } finally {
397
+ syncingFromYjs = false;
398
+ }
399
+ };
400
+ const getStateMap = () => {
401
+ const state = map.get(STATE_KEY);
402
+ if (state instanceof Y.Map) {
403
+ return state;
404
+ }
405
+ return null;
406
+ };
407
+ const scheduleFlushFromYjs = () => {
408
+ if (destroyed || flushScheduled) {
409
+ return;
410
+ }
411
+ flushScheduled = true;
412
+ queueMicrotask(flushFromYjs);
413
+ };
414
+ const flushFromYjs = () => {
415
+ flushScheduled = false;
416
+ if (destroyed) {
417
+ return;
418
+ }
419
+ if (pendingSnapshot) {
420
+ const snapshot = pendingSnapshot;
421
+ pendingSnapshot = null;
422
+ pendingOperations = [];
423
+ try {
424
+ applyRemoteState(snapshot);
425
+ } catch (error) {
426
+ if (isSetStateReentryError(error)) {
427
+ pendingSnapshot = snapshot;
428
+ setTimeout(scheduleFlushFromYjs, 0);
429
+ return;
430
+ }
431
+ throw error;
432
+ }
433
+ }
434
+ if (pendingOperations.length === 0) {
435
+ return;
436
+ }
437
+ const operations = compactOperations(pendingOperations);
438
+ pendingOperations = [];
439
+ try {
440
+ applyRemoteOperations(operations);
441
+ } catch (error) {
442
+ if (isSetStateReentryError(error)) {
443
+ pendingOperations = [...operations, ...pendingOperations];
444
+ setTimeout(scheduleFlushFromYjs, 0);
445
+ return;
446
+ }
447
+ throw error;
448
+ }
449
+ };
450
+ const enqueueSnapshot = (snapshot) => {
451
+ pendingSnapshot = snapshot;
452
+ pendingOperations = [];
453
+ scheduleFlushFromYjs();
454
+ };
455
+ const enqueueOperations = (operations) => {
456
+ if (operations.length === 0) {
457
+ return;
458
+ }
459
+ if (!pendingSnapshot) {
460
+ pendingOperations.push(...operations);
461
+ }
462
+ scheduleFlushFromYjs();
463
+ };
61
464
  const syncNow = () => {
465
+ if (destroyed || syncingFromYjs) {
466
+ return;
467
+ }
62
468
  const pureState = clone(store.getPureState());
469
+ if (!isPlainObject(pureState)) {
470
+ return;
471
+ }
63
472
  doc.transact(() => {
64
- map.set("state", pureState);
65
- }, ORIGIN);
473
+ syncObjectToYMap(stateMap, lastSyncedState, pureState);
474
+ }, localOrigin);
475
+ lastSyncedState = pureState;
66
476
  };
67
- const syncFromYjs = () => {
68
- const incoming = map.get("state");
69
- if (typeof incoming !== "object" || incoming === null) {
477
+ const stateObserver = (events, transaction) => {
478
+ if (transaction.origin === localOrigin) {
70
479
  return;
71
480
  }
72
- store.setState(clone(incoming));
481
+ enqueueOperations(collectRemoteOperations(events, stateMap));
73
482
  };
74
- if (map.has("state")) {
75
- syncFromYjs();
483
+ let stateMap;
484
+ const existingStateMap = getStateMap();
485
+ if (existingStateMap) {
486
+ stateMap = existingStateMap;
487
+ applyRemoteState(toPlainObject(stateMap));
76
488
  } else {
77
- syncNow();
489
+ const currentState = map.get(STATE_KEY);
490
+ if (isPlainObject(currentState)) {
491
+ stateMap = createYMap(currentState);
492
+ doc.transact(() => {
493
+ map.set(STATE_KEY, stateMap);
494
+ }, localOrigin);
495
+ applyRemoteState(currentState);
496
+ } else {
497
+ const pureState = clone(store.getPureState());
498
+ stateMap = createYMap(isPlainObject(pureState) ? pureState : {});
499
+ doc.transact(() => {
500
+ map.set(STATE_KEY, stateMap);
501
+ }, localOrigin);
502
+ }
78
503
  }
504
+ stateMap.observeDeep(stateObserver);
79
505
  const observer = (event) => {
80
- if (event.transaction.origin === ORIGIN) {
506
+ if (event.transaction.origin === localOrigin) {
507
+ return;
508
+ }
509
+ if (!event.keysChanged.has(STATE_KEY)) {
81
510
  return;
82
511
  }
83
- if (event.keysChanged.has("state")) {
84
- syncFromYjs();
512
+ const nextStateMap = getStateMap();
513
+ if (nextStateMap) {
514
+ if (stateMap !== nextStateMap) {
515
+ stateMap.unobserveDeep(stateObserver);
516
+ stateMap = nextStateMap;
517
+ stateMap.observeDeep(stateObserver);
518
+ }
519
+ enqueueSnapshot(toPlainObject(nextStateMap));
520
+ return;
521
+ }
522
+ const currentState = map.get(STATE_KEY);
523
+ if (isPlainObject(currentState)) {
524
+ const migrated = createYMap(currentState);
525
+ doc.transact(() => {
526
+ map.set(STATE_KEY, migrated);
527
+ }, localOrigin);
528
+ if (stateMap !== migrated) {
529
+ stateMap.unobserveDeep(stateObserver);
530
+ stateMap = migrated;
531
+ stateMap.observeDeep(stateObserver);
532
+ }
533
+ enqueueSnapshot(currentState);
85
534
  }
86
535
  };
87
536
  map.observe(observer);
88
537
  const unsubscribe = store.subscribe(() => {
89
538
  syncNow();
90
539
  });
91
- return {
540
+ const binding = {
92
541
  doc,
93
542
  map,
94
543
  syncNow,
95
544
  destroy: () => {
545
+ if (destroyed) {
546
+ return;
547
+ }
548
+ destroyed = true;
96
549
  unsubscribe();
97
550
  map.unobserve(observer);
551
+ stateMap.unobserveDeep(stateObserver);
98
552
  if (!options.doc) {
99
553
  doc.destroy();
100
554
  }
101
555
  }
102
556
  };
557
+ if (process.env.NODE_ENV === "test") {
558
+ binding.__unsafeTestOnly__ = {
559
+ applyRemoteOperations: (operations) => {
560
+ applyRemoteOperations(operations);
561
+ }
562
+ };
563
+ }
564
+ return binding;
103
565
  };
104
566
  var yjs = (options = {}) => (store) => {
105
567
  const binding = bindYjs(store, options);
@@ -115,6 +577,7 @@ var yjs = (options = {}) => (store) => {
115
577
  __reExport(index_exports, src_exports, module.exports);
116
578
  // Annotate the CommonJS export names for ESM import in node:
117
579
  0 && (module.exports = {
580
+ __unsafeTestOnly__,
118
581
  bindYjs,
119
582
  yjs
120
583
  });
package/dist/index.mjs CHANGED
@@ -19,6 +19,7 @@ var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "defau
19
19
  // index.ts
20
20
  var index_exports = {};
21
21
  __export(index_exports, {
22
+ __unsafeTestOnly__: () => __unsafeTestOnly__,
22
23
  bindYjs: () => bindYjs,
23
24
  yjs: () => yjs
24
25
  });
@@ -26,19 +27,314 @@ __export(index_exports, {
26
27
  // src/index.ts
27
28
  var src_exports = {};
28
29
  __export(src_exports, {
30
+ __unsafeTestOnly__: () => __unsafeTestOnly__,
29
31
  bindYjs: () => bindYjs,
30
32
  yjs: () => yjs
31
33
  });
32
34
  __reExport(src_exports, yjs_star);
33
35
  import * as Y from "yjs";
34
36
  import * as yjs_star from "yjs";
35
- var ORIGIN = /* @__PURE__ */ Symbol("coaction-yjs");
37
+ var STATE_KEY = "state";
36
38
  function clone(value) {
37
39
  if (typeof structuredClone === "function") {
38
40
  return structuredClone(value);
39
41
  }
40
42
  return JSON.parse(JSON.stringify(value));
41
43
  }
44
+ function isPlainObject(value) {
45
+ if (typeof value !== "object" || value === null) {
46
+ return false;
47
+ }
48
+ const prototype = Object.getPrototypeOf(value);
49
+ return prototype === Object.prototype || prototype === null;
50
+ }
51
+ function toPlainObject(value) {
52
+ const next = {};
53
+ value.forEach((item, key) => {
54
+ next[key] = toPlainValue(item);
55
+ });
56
+ return next;
57
+ }
58
+ function toPlainArray(value) {
59
+ return value.toArray().map((item) => toPlainValue(item));
60
+ }
61
+ function toPlainValue(value) {
62
+ if (value instanceof Y.Map) {
63
+ return toPlainObject(value);
64
+ }
65
+ if (value instanceof Y.Array) {
66
+ return toPlainArray(value);
67
+ }
68
+ return value;
69
+ }
70
+ function createYMap(value) {
71
+ const next = new Y.Map();
72
+ for (const [key, item] of Object.entries(value)) {
73
+ next.set(key, toYValue(item));
74
+ }
75
+ return next;
76
+ }
77
+ function createYArray(value) {
78
+ const next = new Y.Array();
79
+ if (value.length > 0) {
80
+ next.insert(
81
+ 0,
82
+ value.map((item) => toYValue(item))
83
+ );
84
+ }
85
+ return next;
86
+ }
87
+ function toYValue(value) {
88
+ if (Array.isArray(value)) {
89
+ return createYArray(value);
90
+ }
91
+ if (isPlainObject(value)) {
92
+ return createYMap(value);
93
+ }
94
+ if (typeof value === "object" && value !== null) {
95
+ return clone(value);
96
+ }
97
+ return value;
98
+ }
99
+ function isEqualValue(left, right) {
100
+ if (Object.is(left, right)) {
101
+ return true;
102
+ }
103
+ if (Array.isArray(left) && Array.isArray(right)) {
104
+ if (left.length !== right.length) {
105
+ return false;
106
+ }
107
+ for (let index = 0; index < left.length; index += 1) {
108
+ if (!isEqualValue(left[index], right[index])) {
109
+ return false;
110
+ }
111
+ }
112
+ return true;
113
+ }
114
+ if (isPlainObject(left) && isPlainObject(right)) {
115
+ const leftKeys = Object.keys(left);
116
+ const rightKeys = Object.keys(right);
117
+ if (leftKeys.length !== rightKeys.length) {
118
+ return false;
119
+ }
120
+ for (const key of leftKeys) {
121
+ if (!Object.prototype.hasOwnProperty.call(right, key)) {
122
+ return false;
123
+ }
124
+ if (!isEqualValue(left[key], right[key])) {
125
+ return false;
126
+ }
127
+ }
128
+ return true;
129
+ }
130
+ return false;
131
+ }
132
+ function syncObjectToYMap(target, previous, source) {
133
+ const keys = /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(source)]);
134
+ for (const key of keys) {
135
+ const hasPrevious = Object.prototype.hasOwnProperty.call(previous, key);
136
+ const hasNext = Object.prototype.hasOwnProperty.call(source, key);
137
+ if (!hasNext) {
138
+ target.delete(key);
139
+ continue;
140
+ }
141
+ const previousValue = hasPrevious ? previous[key] : void 0;
142
+ const nextValue = source[key];
143
+ if (hasPrevious && isEqualValue(previousValue, nextValue)) {
144
+ continue;
145
+ }
146
+ const current = target.get(key);
147
+ if (Array.isArray(nextValue)) {
148
+ if (Array.isArray(previousValue) && current instanceof Y.Array) {
149
+ syncArrayToYArray(current, previousValue, nextValue);
150
+ } else {
151
+ target.set(key, createYArray(nextValue));
152
+ }
153
+ continue;
154
+ }
155
+ if (isPlainObject(nextValue)) {
156
+ if (isPlainObject(previousValue) && current instanceof Y.Map) {
157
+ syncObjectToYMap(current, previousValue, nextValue);
158
+ } else {
159
+ target.set(key, createYMap(nextValue));
160
+ }
161
+ continue;
162
+ }
163
+ const normalized = typeof nextValue === "object" && nextValue !== null ? clone(nextValue) : nextValue;
164
+ if (!Object.is(current, normalized)) {
165
+ target.set(key, normalized);
166
+ }
167
+ }
168
+ }
169
+ function syncArrayToYArray(target, previous, source) {
170
+ if (target.length > source.length) {
171
+ target.delete(source.length, target.length - source.length);
172
+ }
173
+ const maxLength = Math.max(previous.length, source.length);
174
+ for (let index = 0; index < maxLength; index += 1) {
175
+ const hasPrevious = index < previous.length;
176
+ const hasNext = index < source.length;
177
+ if (!hasNext) {
178
+ continue;
179
+ }
180
+ const previousValue = hasPrevious ? previous[index] : void 0;
181
+ const nextValue = source[index];
182
+ if (hasPrevious && isEqualValue(previousValue, nextValue)) {
183
+ continue;
184
+ }
185
+ if (index >= target.length) {
186
+ target.insert(index, [toYValue(nextValue)]);
187
+ continue;
188
+ }
189
+ const current = target.get(index);
190
+ if (Array.isArray(nextValue)) {
191
+ if (Array.isArray(previousValue) && current instanceof Y.Array) {
192
+ syncArrayToYArray(current, previousValue, nextValue);
193
+ } else {
194
+ target.delete(index, 1);
195
+ target.insert(index, [createYArray(nextValue)]);
196
+ }
197
+ continue;
198
+ }
199
+ if (isPlainObject(nextValue)) {
200
+ if (isPlainObject(previousValue) && current instanceof Y.Map) {
201
+ syncObjectToYMap(current, previousValue, nextValue);
202
+ } else {
203
+ target.delete(index, 1);
204
+ target.insert(index, [createYMap(nextValue)]);
205
+ }
206
+ continue;
207
+ }
208
+ const normalized = typeof nextValue === "object" && nextValue !== null ? clone(nextValue) : nextValue;
209
+ if (!Object.is(current, normalized)) {
210
+ target.delete(index, 1);
211
+ target.insert(index, [normalized]);
212
+ }
213
+ }
214
+ }
215
+ function isSetStateReentryError(error) {
216
+ return error instanceof Error && error.message === "setState cannot be called within the updater";
217
+ }
218
+ function cloneForStore(value) {
219
+ if (typeof value === "object" && value !== null) {
220
+ return clone(value);
221
+ }
222
+ return value;
223
+ }
224
+ function toPathKey(path) {
225
+ return path.map((segment) => `${typeof segment}:${String(segment)}`).join("|");
226
+ }
227
+ function compactOperations(operations) {
228
+ const deduplicated = /* @__PURE__ */ new Map();
229
+ for (const operation of operations) {
230
+ const key = toPathKey(operation.path);
231
+ if (deduplicated.has(key)) {
232
+ deduplicated.delete(key);
233
+ }
234
+ deduplicated.set(key, operation);
235
+ }
236
+ return Array.from(deduplicated.values()).sort(
237
+ (left, right) => left.path.length - right.path.length
238
+ );
239
+ }
240
+ function getYValueAtPath(root, path) {
241
+ let current = root;
242
+ for (const segment of path) {
243
+ if (current instanceof Y.Map) {
244
+ current = current.get(String(segment));
245
+ continue;
246
+ }
247
+ if (current instanceof Y.Array) {
248
+ const index = typeof segment === "number" ? segment : Number.parseInt(segment, 10);
249
+ if (!Number.isInteger(index)) {
250
+ return void 0;
251
+ }
252
+ current = current.get(index);
253
+ continue;
254
+ }
255
+ return void 0;
256
+ }
257
+ return current;
258
+ }
259
+ function setAtPath(target, path, value) {
260
+ if (path.length === 0) {
261
+ return;
262
+ }
263
+ let current = target;
264
+ for (let index = 0; index < path.length - 1; index += 1) {
265
+ const segment = path[index];
266
+ const nextSegment = path[index + 1];
267
+ const nextValue = current[segment];
268
+ if (typeof nextValue !== "object" || nextValue === null) {
269
+ current[segment] = typeof nextSegment === "number" ? [] : {};
270
+ }
271
+ current = current[segment];
272
+ }
273
+ const leaf = path[path.length - 1];
274
+ current[leaf] = cloneForStore(value);
275
+ }
276
+ function deleteAtPath(target, path) {
277
+ if (path.length === 0) {
278
+ return;
279
+ }
280
+ let current = target;
281
+ for (let index = 0; index < path.length - 1; index += 1) {
282
+ current = current[path[index]];
283
+ if (typeof current !== "object" || current === null) {
284
+ return;
285
+ }
286
+ }
287
+ const leaf = path[path.length - 1];
288
+ if (Array.isArray(current) && typeof leaf === "number") {
289
+ if (leaf >= 0 && leaf < current.length) {
290
+ current.splice(leaf, 1);
291
+ }
292
+ return;
293
+ }
294
+ delete current[leaf];
295
+ }
296
+ function collectRemoteOperations(events, stateMap) {
297
+ const operations = [];
298
+ for (const event of events) {
299
+ if (event instanceof Y.YMapEvent) {
300
+ for (const changedKey of event.keysChanged) {
301
+ const path = [...event.path, changedKey];
302
+ const keyChange = event.changes.keys.get(changedKey);
303
+ if (keyChange?.action === "delete") {
304
+ operations.push({
305
+ type: "delete",
306
+ path
307
+ });
308
+ continue;
309
+ }
310
+ operations.push({
311
+ type: "set",
312
+ path,
313
+ value: toPlainValue(getYValueAtPath(stateMap, path))
314
+ });
315
+ }
316
+ continue;
317
+ }
318
+ if (event instanceof Y.YArrayEvent) {
319
+ const path = [...event.path];
320
+ operations.push({
321
+ type: "set",
322
+ path,
323
+ value: toPlainValue(getYValueAtPath(stateMap, path))
324
+ });
325
+ }
326
+ }
327
+ return operations;
328
+ }
329
+ var __unsafeTestOnly__ = {
330
+ getYValueAtPath: (root, path) => getYValueAtPath(root, path),
331
+ setAtPath: (target, path, value) => {
332
+ setAtPath(target, path, value);
333
+ },
334
+ deleteAtPath: (target, path) => {
335
+ deleteAtPath(target, path);
336
+ }
337
+ };
42
338
  var bindYjs = (store, options = {}) => {
43
339
  if (store.share === "client") {
44
340
  throw new Error("Yjs binding is not supported in client store mode.");
@@ -46,48 +342,214 @@ var bindYjs = (store, options = {}) => {
46
342
  const doc = options.doc ?? new Y.Doc();
47
343
  const key = options.key ?? `coaction:${store.name}`;
48
344
  const map = doc.getMap(key);
345
+ const localOrigin = /* @__PURE__ */ Symbol(`coaction-yjs:${store.name}`);
346
+ let destroyed = false;
347
+ let syncingFromYjs = false;
348
+ let lastSyncedState = (() => {
349
+ const pureState = clone(store.getPureState());
350
+ return isPlainObject(pureState) ? pureState : {};
351
+ })();
352
+ let flushScheduled = false;
353
+ let pendingSnapshot = null;
354
+ let pendingOperations = [];
355
+ const applyRemoteState = (state) => {
356
+ const next = clone(state);
357
+ syncingFromYjs = true;
358
+ try {
359
+ store.setState(next);
360
+ const pureState = clone(store.getPureState());
361
+ lastSyncedState = isPlainObject(pureState) ? pureState : {};
362
+ } finally {
363
+ syncingFromYjs = false;
364
+ }
365
+ };
366
+ const applyRemoteOperations = (operations) => {
367
+ if (operations.length === 0) {
368
+ return;
369
+ }
370
+ syncingFromYjs = true;
371
+ try {
372
+ store.setState((draft) => {
373
+ const mutableDraft = draft;
374
+ for (const operation of operations) {
375
+ if (operation.type === "set") {
376
+ setAtPath(mutableDraft, operation.path, operation.value);
377
+ } else {
378
+ deleteAtPath(mutableDraft, operation.path);
379
+ }
380
+ }
381
+ });
382
+ const pureState = clone(store.getPureState());
383
+ lastSyncedState = isPlainObject(pureState) ? pureState : {};
384
+ } finally {
385
+ syncingFromYjs = false;
386
+ }
387
+ };
388
+ const getStateMap = () => {
389
+ const state = map.get(STATE_KEY);
390
+ if (state instanceof Y.Map) {
391
+ return state;
392
+ }
393
+ return null;
394
+ };
395
+ const scheduleFlushFromYjs = () => {
396
+ if (destroyed || flushScheduled) {
397
+ return;
398
+ }
399
+ flushScheduled = true;
400
+ queueMicrotask(flushFromYjs);
401
+ };
402
+ const flushFromYjs = () => {
403
+ flushScheduled = false;
404
+ if (destroyed) {
405
+ return;
406
+ }
407
+ if (pendingSnapshot) {
408
+ const snapshot = pendingSnapshot;
409
+ pendingSnapshot = null;
410
+ pendingOperations = [];
411
+ try {
412
+ applyRemoteState(snapshot);
413
+ } catch (error) {
414
+ if (isSetStateReentryError(error)) {
415
+ pendingSnapshot = snapshot;
416
+ setTimeout(scheduleFlushFromYjs, 0);
417
+ return;
418
+ }
419
+ throw error;
420
+ }
421
+ }
422
+ if (pendingOperations.length === 0) {
423
+ return;
424
+ }
425
+ const operations = compactOperations(pendingOperations);
426
+ pendingOperations = [];
427
+ try {
428
+ applyRemoteOperations(operations);
429
+ } catch (error) {
430
+ if (isSetStateReentryError(error)) {
431
+ pendingOperations = [...operations, ...pendingOperations];
432
+ setTimeout(scheduleFlushFromYjs, 0);
433
+ return;
434
+ }
435
+ throw error;
436
+ }
437
+ };
438
+ const enqueueSnapshot = (snapshot) => {
439
+ pendingSnapshot = snapshot;
440
+ pendingOperations = [];
441
+ scheduleFlushFromYjs();
442
+ };
443
+ const enqueueOperations = (operations) => {
444
+ if (operations.length === 0) {
445
+ return;
446
+ }
447
+ if (!pendingSnapshot) {
448
+ pendingOperations.push(...operations);
449
+ }
450
+ scheduleFlushFromYjs();
451
+ };
49
452
  const syncNow = () => {
453
+ if (destroyed || syncingFromYjs) {
454
+ return;
455
+ }
50
456
  const pureState = clone(store.getPureState());
457
+ if (!isPlainObject(pureState)) {
458
+ return;
459
+ }
51
460
  doc.transact(() => {
52
- map.set("state", pureState);
53
- }, ORIGIN);
461
+ syncObjectToYMap(stateMap, lastSyncedState, pureState);
462
+ }, localOrigin);
463
+ lastSyncedState = pureState;
54
464
  };
55
- const syncFromYjs = () => {
56
- const incoming = map.get("state");
57
- if (typeof incoming !== "object" || incoming === null) {
465
+ const stateObserver = (events, transaction) => {
466
+ if (transaction.origin === localOrigin) {
58
467
  return;
59
468
  }
60
- store.setState(clone(incoming));
469
+ enqueueOperations(collectRemoteOperations(events, stateMap));
61
470
  };
62
- if (map.has("state")) {
63
- syncFromYjs();
471
+ let stateMap;
472
+ const existingStateMap = getStateMap();
473
+ if (existingStateMap) {
474
+ stateMap = existingStateMap;
475
+ applyRemoteState(toPlainObject(stateMap));
64
476
  } else {
65
- syncNow();
477
+ const currentState = map.get(STATE_KEY);
478
+ if (isPlainObject(currentState)) {
479
+ stateMap = createYMap(currentState);
480
+ doc.transact(() => {
481
+ map.set(STATE_KEY, stateMap);
482
+ }, localOrigin);
483
+ applyRemoteState(currentState);
484
+ } else {
485
+ const pureState = clone(store.getPureState());
486
+ stateMap = createYMap(isPlainObject(pureState) ? pureState : {});
487
+ doc.transact(() => {
488
+ map.set(STATE_KEY, stateMap);
489
+ }, localOrigin);
490
+ }
66
491
  }
492
+ stateMap.observeDeep(stateObserver);
67
493
  const observer = (event) => {
68
- if (event.transaction.origin === ORIGIN) {
494
+ if (event.transaction.origin === localOrigin) {
495
+ return;
496
+ }
497
+ if (!event.keysChanged.has(STATE_KEY)) {
69
498
  return;
70
499
  }
71
- if (event.keysChanged.has("state")) {
72
- syncFromYjs();
500
+ const nextStateMap = getStateMap();
501
+ if (nextStateMap) {
502
+ if (stateMap !== nextStateMap) {
503
+ stateMap.unobserveDeep(stateObserver);
504
+ stateMap = nextStateMap;
505
+ stateMap.observeDeep(stateObserver);
506
+ }
507
+ enqueueSnapshot(toPlainObject(nextStateMap));
508
+ return;
509
+ }
510
+ const currentState = map.get(STATE_KEY);
511
+ if (isPlainObject(currentState)) {
512
+ const migrated = createYMap(currentState);
513
+ doc.transact(() => {
514
+ map.set(STATE_KEY, migrated);
515
+ }, localOrigin);
516
+ if (stateMap !== migrated) {
517
+ stateMap.unobserveDeep(stateObserver);
518
+ stateMap = migrated;
519
+ stateMap.observeDeep(stateObserver);
520
+ }
521
+ enqueueSnapshot(currentState);
73
522
  }
74
523
  };
75
524
  map.observe(observer);
76
525
  const unsubscribe = store.subscribe(() => {
77
526
  syncNow();
78
527
  });
79
- return {
528
+ const binding = {
80
529
  doc,
81
530
  map,
82
531
  syncNow,
83
532
  destroy: () => {
533
+ if (destroyed) {
534
+ return;
535
+ }
536
+ destroyed = true;
84
537
  unsubscribe();
85
538
  map.unobserve(observer);
539
+ stateMap.unobserveDeep(stateObserver);
86
540
  if (!options.doc) {
87
541
  doc.destroy();
88
542
  }
89
543
  }
90
544
  };
545
+ if (process.env.NODE_ENV === "test") {
546
+ binding.__unsafeTestOnly__ = {
547
+ applyRemoteOperations: (operations) => {
548
+ applyRemoteOperations(operations);
549
+ }
550
+ };
551
+ }
552
+ return binding;
91
553
  };
92
554
  var yjs = (options = {}) => (store) => {
93
555
  const binding = bindYjs(store, options);
@@ -102,6 +564,7 @@ var yjs = (options = {}) => (store) => {
102
564
  // index.ts
103
565
  __reExport(index_exports, src_exports);
104
566
  export {
567
+ __unsafeTestOnly__,
105
568
  bindYjs,
106
569
  yjs
107
570
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coaction/yjs",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "A Coaction integration tool for Yjs",
5
5
  "keywords": [
6
6
  "state",
@@ -38,7 +38,7 @@
38
38
  "url": "https://github.com/unadlib/coaction/issues"
39
39
  },
40
40
  "peerDependencies": {
41
- "coaction": "^1.0.1",
41
+ "coaction": "^1.1.0",
42
42
  "yjs": "^13.6.0"
43
43
  },
44
44
  "peerDependenciesMeta": {
@@ -50,7 +50,7 @@
50
50
  }
51
51
  },
52
52
  "devDependencies": {
53
- "coaction": "^1.0.1",
53
+ "coaction": "^1.1.0",
54
54
  "yjs": "^13.6.27"
55
55
  },
56
56
  "publishConfig": {