@coaction/yjs 1.0.1 → 1.2.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,320 @@ 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";
50
+ var scheduleMicrotask = (callback) => {
51
+ if (typeof queueMicrotask === "function") {
52
+ queueMicrotask(callback);
53
+ return;
54
+ }
55
+ Promise.resolve().then(callback);
56
+ };
48
57
  function clone(value) {
49
58
  if (typeof structuredClone === "function") {
50
59
  return structuredClone(value);
51
60
  }
52
61
  return JSON.parse(JSON.stringify(value));
53
62
  }
63
+ function isPlainObject(value) {
64
+ if (typeof value !== "object" || value === null) {
65
+ return false;
66
+ }
67
+ const prototype = Object.getPrototypeOf(value);
68
+ return prototype === Object.prototype || prototype === null;
69
+ }
70
+ function toPlainObject(value) {
71
+ const next = {};
72
+ value.forEach((item, key) => {
73
+ next[key] = toPlainValue(item);
74
+ });
75
+ return next;
76
+ }
77
+ function toPlainArray(value) {
78
+ return value.toArray().map((item) => toPlainValue(item));
79
+ }
80
+ function toPlainValue(value) {
81
+ if (value instanceof Y.Map) {
82
+ return toPlainObject(value);
83
+ }
84
+ if (value instanceof Y.Array) {
85
+ return toPlainArray(value);
86
+ }
87
+ return value;
88
+ }
89
+ function createYMap(value) {
90
+ const next = new Y.Map();
91
+ for (const [key, item] of Object.entries(value)) {
92
+ next.set(key, toYValue(item));
93
+ }
94
+ return next;
95
+ }
96
+ function createYArray(value) {
97
+ const next = new Y.Array();
98
+ if (value.length > 0) {
99
+ next.insert(
100
+ 0,
101
+ value.map((item) => toYValue(item))
102
+ );
103
+ }
104
+ return next;
105
+ }
106
+ function toYValue(value) {
107
+ if (Array.isArray(value)) {
108
+ return createYArray(value);
109
+ }
110
+ if (isPlainObject(value)) {
111
+ return createYMap(value);
112
+ }
113
+ if (typeof value === "object" && value !== null) {
114
+ return clone(value);
115
+ }
116
+ return value;
117
+ }
118
+ function isEqualValue(left, right) {
119
+ if (Object.is(left, right)) {
120
+ return true;
121
+ }
122
+ if (Array.isArray(left) && Array.isArray(right)) {
123
+ if (left.length !== right.length) {
124
+ return false;
125
+ }
126
+ for (let index = 0; index < left.length; index += 1) {
127
+ if (!isEqualValue(left[index], right[index])) {
128
+ return false;
129
+ }
130
+ }
131
+ return true;
132
+ }
133
+ if (isPlainObject(left) && isPlainObject(right)) {
134
+ const leftKeys = Object.keys(left);
135
+ const rightKeys = Object.keys(right);
136
+ if (leftKeys.length !== rightKeys.length) {
137
+ return false;
138
+ }
139
+ for (const key of leftKeys) {
140
+ if (!Object.prototype.hasOwnProperty.call(right, key)) {
141
+ return false;
142
+ }
143
+ if (!isEqualValue(left[key], right[key])) {
144
+ return false;
145
+ }
146
+ }
147
+ return true;
148
+ }
149
+ return false;
150
+ }
151
+ function syncObjectToYMap(target, previous, source) {
152
+ const keys = /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(source)]);
153
+ for (const key of keys) {
154
+ const hasPrevious = Object.prototype.hasOwnProperty.call(previous, key);
155
+ const hasNext = Object.prototype.hasOwnProperty.call(source, key);
156
+ if (!hasNext) {
157
+ target.delete(key);
158
+ continue;
159
+ }
160
+ const previousValue = hasPrevious ? previous[key] : void 0;
161
+ const nextValue = source[key];
162
+ if (hasPrevious && isEqualValue(previousValue, nextValue)) {
163
+ continue;
164
+ }
165
+ const current = target.get(key);
166
+ if (Array.isArray(nextValue)) {
167
+ if (Array.isArray(previousValue) && current instanceof Y.Array) {
168
+ syncArrayToYArray(current, previousValue, nextValue);
169
+ } else {
170
+ target.set(key, createYArray(nextValue));
171
+ }
172
+ continue;
173
+ }
174
+ if (isPlainObject(nextValue)) {
175
+ if (isPlainObject(previousValue) && current instanceof Y.Map) {
176
+ syncObjectToYMap(current, previousValue, nextValue);
177
+ } else {
178
+ target.set(key, createYMap(nextValue));
179
+ }
180
+ continue;
181
+ }
182
+ const normalized = typeof nextValue === "object" && nextValue !== null ? clone(nextValue) : nextValue;
183
+ if (!Object.is(current, normalized)) {
184
+ target.set(key, normalized);
185
+ }
186
+ }
187
+ }
188
+ function syncArrayToYArray(target, previous, source) {
189
+ if (target.length > source.length) {
190
+ target.delete(source.length, target.length - source.length);
191
+ }
192
+ const maxLength = Math.max(previous.length, source.length);
193
+ for (let index = 0; index < maxLength; index += 1) {
194
+ const hasPrevious = index < previous.length;
195
+ const hasNext = index < source.length;
196
+ if (!hasNext) {
197
+ continue;
198
+ }
199
+ const previousValue = hasPrevious ? previous[index] : void 0;
200
+ const nextValue = source[index];
201
+ if (hasPrevious && isEqualValue(previousValue, nextValue)) {
202
+ continue;
203
+ }
204
+ if (index >= target.length) {
205
+ target.insert(index, [toYValue(nextValue)]);
206
+ continue;
207
+ }
208
+ const current = target.get(index);
209
+ if (Array.isArray(nextValue)) {
210
+ if (Array.isArray(previousValue) && current instanceof Y.Array) {
211
+ syncArrayToYArray(current, previousValue, nextValue);
212
+ } else {
213
+ target.delete(index, 1);
214
+ target.insert(index, [createYArray(nextValue)]);
215
+ }
216
+ continue;
217
+ }
218
+ if (isPlainObject(nextValue)) {
219
+ if (isPlainObject(previousValue) && current instanceof Y.Map) {
220
+ syncObjectToYMap(current, previousValue, nextValue);
221
+ } else {
222
+ target.delete(index, 1);
223
+ target.insert(index, [createYMap(nextValue)]);
224
+ }
225
+ continue;
226
+ }
227
+ const normalized = typeof nextValue === "object" && nextValue !== null ? clone(nextValue) : nextValue;
228
+ if (!Object.is(current, normalized)) {
229
+ target.delete(index, 1);
230
+ target.insert(index, [normalized]);
231
+ }
232
+ }
233
+ }
234
+ function isSetStateReentryError(error) {
235
+ return error instanceof Error && error.message === "setState cannot be called within the updater";
236
+ }
237
+ function cloneForStore(value) {
238
+ if (typeof value === "object" && value !== null) {
239
+ return clone(value);
240
+ }
241
+ return value;
242
+ }
243
+ function toPathKey(path) {
244
+ return path.map((segment) => `${typeof segment}:${String(segment)}`).join("|");
245
+ }
246
+ function compactOperations(operations) {
247
+ const deduplicated = /* @__PURE__ */ new Map();
248
+ for (const operation of operations) {
249
+ const key = toPathKey(operation.path);
250
+ if (deduplicated.has(key)) {
251
+ deduplicated.delete(key);
252
+ }
253
+ deduplicated.set(key, operation);
254
+ }
255
+ return Array.from(deduplicated.values()).sort(
256
+ (left, right) => left.path.length - right.path.length
257
+ );
258
+ }
259
+ function getYValueAtPath(root, path) {
260
+ let current = root;
261
+ for (const segment of path) {
262
+ if (current instanceof Y.Map) {
263
+ current = current.get(String(segment));
264
+ continue;
265
+ }
266
+ if (current instanceof Y.Array) {
267
+ const index = typeof segment === "number" ? segment : Number.parseInt(segment, 10);
268
+ if (!Number.isInteger(index)) {
269
+ return void 0;
270
+ }
271
+ current = current.get(index);
272
+ continue;
273
+ }
274
+ return void 0;
275
+ }
276
+ return current;
277
+ }
278
+ function setAtPath(target, path, value) {
279
+ if (path.length === 0) {
280
+ return;
281
+ }
282
+ let current = target;
283
+ for (let index = 0; index < path.length - 1; index += 1) {
284
+ const segment = path[index];
285
+ const nextSegment = path[index + 1];
286
+ const nextValue = current[segment];
287
+ if (typeof nextValue !== "object" || nextValue === null) {
288
+ current[segment] = typeof nextSegment === "number" ? [] : {};
289
+ }
290
+ current = current[segment];
291
+ }
292
+ const leaf = path[path.length - 1];
293
+ current[leaf] = cloneForStore(value);
294
+ }
295
+ function deleteAtPath(target, path) {
296
+ if (path.length === 0) {
297
+ return;
298
+ }
299
+ let current = target;
300
+ for (let index = 0; index < path.length - 1; index += 1) {
301
+ current = current[path[index]];
302
+ if (typeof current !== "object" || current === null) {
303
+ return;
304
+ }
305
+ }
306
+ const leaf = path[path.length - 1];
307
+ if (Array.isArray(current) && typeof leaf === "number") {
308
+ if (leaf >= 0 && leaf < current.length) {
309
+ current.splice(leaf, 1);
310
+ }
311
+ return;
312
+ }
313
+ delete current[leaf];
314
+ }
315
+ function collectRemoteOperations(events, stateMap) {
316
+ const operations = [];
317
+ for (const event of events) {
318
+ if (event instanceof Y.YMapEvent) {
319
+ for (const changedKey of event.keysChanged) {
320
+ const path = [...event.path, changedKey];
321
+ const keyChange = event.changes.keys.get(changedKey);
322
+ if (keyChange?.action === "delete") {
323
+ operations.push({
324
+ type: "delete",
325
+ path
326
+ });
327
+ continue;
328
+ }
329
+ operations.push({
330
+ type: "set",
331
+ path,
332
+ value: toPlainValue(getYValueAtPath(stateMap, path))
333
+ });
334
+ }
335
+ continue;
336
+ }
337
+ if (event instanceof Y.YArrayEvent) {
338
+ const path = [...event.path];
339
+ operations.push({
340
+ type: "set",
341
+ path,
342
+ value: toPlainValue(getYValueAtPath(stateMap, path))
343
+ });
344
+ }
345
+ }
346
+ return operations;
347
+ }
348
+ var __unsafeTestOnly__ = {
349
+ getYValueAtPath: (root, path) => getYValueAtPath(root, path),
350
+ setAtPath: (target, path, value) => {
351
+ setAtPath(target, path, value);
352
+ },
353
+ deleteAtPath: (target, path) => {
354
+ deleteAtPath(target, path);
355
+ }
356
+ };
54
357
  var bindYjs = (store, options = {}) => {
55
358
  if (store.share === "client") {
56
359
  throw new Error("Yjs binding is not supported in client store mode.");
@@ -58,48 +361,214 @@ var bindYjs = (store, options = {}) => {
58
361
  const doc = options.doc ?? new Y.Doc();
59
362
  const key = options.key ?? `coaction:${store.name}`;
60
363
  const map = doc.getMap(key);
364
+ const localOrigin = /* @__PURE__ */ Symbol(`coaction-yjs:${store.name}`);
365
+ let destroyed = false;
366
+ let syncingFromYjs = false;
367
+ let lastSyncedState = (() => {
368
+ const pureState = clone(store.getPureState());
369
+ return isPlainObject(pureState) ? pureState : {};
370
+ })();
371
+ let flushScheduled = false;
372
+ let pendingSnapshot = null;
373
+ let pendingOperations = [];
374
+ const applyRemoteState = (state) => {
375
+ const next = clone(state);
376
+ syncingFromYjs = true;
377
+ try {
378
+ store.setState(next);
379
+ const pureState = clone(store.getPureState());
380
+ lastSyncedState = isPlainObject(pureState) ? pureState : {};
381
+ } finally {
382
+ syncingFromYjs = false;
383
+ }
384
+ };
385
+ const applyRemoteOperations = (operations) => {
386
+ if (operations.length === 0) {
387
+ return;
388
+ }
389
+ syncingFromYjs = true;
390
+ try {
391
+ store.setState((draft) => {
392
+ const mutableDraft = draft;
393
+ for (const operation of operations) {
394
+ if (operation.type === "set") {
395
+ setAtPath(mutableDraft, operation.path, operation.value);
396
+ } else {
397
+ deleteAtPath(mutableDraft, operation.path);
398
+ }
399
+ }
400
+ });
401
+ const pureState = clone(store.getPureState());
402
+ lastSyncedState = isPlainObject(pureState) ? pureState : {};
403
+ } finally {
404
+ syncingFromYjs = false;
405
+ }
406
+ };
407
+ const getStateMap = () => {
408
+ const state = map.get(STATE_KEY);
409
+ if (state instanceof Y.Map) {
410
+ return state;
411
+ }
412
+ return null;
413
+ };
414
+ const scheduleFlushFromYjs = () => {
415
+ if (destroyed || flushScheduled) {
416
+ return;
417
+ }
418
+ flushScheduled = true;
419
+ scheduleMicrotask(flushFromYjs);
420
+ };
421
+ const flushFromYjs = () => {
422
+ flushScheduled = false;
423
+ if (destroyed) {
424
+ return;
425
+ }
426
+ if (pendingSnapshot) {
427
+ const snapshot = pendingSnapshot;
428
+ pendingSnapshot = null;
429
+ pendingOperations = [];
430
+ try {
431
+ applyRemoteState(snapshot);
432
+ } catch (error) {
433
+ if (isSetStateReentryError(error)) {
434
+ pendingSnapshot = snapshot;
435
+ setTimeout(scheduleFlushFromYjs, 0);
436
+ return;
437
+ }
438
+ throw error;
439
+ }
440
+ }
441
+ if (pendingOperations.length === 0) {
442
+ return;
443
+ }
444
+ const operations = compactOperations(pendingOperations);
445
+ pendingOperations = [];
446
+ try {
447
+ applyRemoteOperations(operations);
448
+ } catch (error) {
449
+ if (isSetStateReentryError(error)) {
450
+ pendingOperations = [...operations, ...pendingOperations];
451
+ setTimeout(scheduleFlushFromYjs, 0);
452
+ return;
453
+ }
454
+ throw error;
455
+ }
456
+ };
457
+ const enqueueSnapshot = (snapshot) => {
458
+ pendingSnapshot = snapshot;
459
+ pendingOperations = [];
460
+ scheduleFlushFromYjs();
461
+ };
462
+ const enqueueOperations = (operations) => {
463
+ if (operations.length === 0) {
464
+ return;
465
+ }
466
+ if (!pendingSnapshot) {
467
+ pendingOperations.push(...operations);
468
+ }
469
+ scheduleFlushFromYjs();
470
+ };
61
471
  const syncNow = () => {
472
+ if (destroyed || syncingFromYjs) {
473
+ return;
474
+ }
62
475
  const pureState = clone(store.getPureState());
476
+ if (!isPlainObject(pureState)) {
477
+ return;
478
+ }
63
479
  doc.transact(() => {
64
- map.set("state", pureState);
65
- }, ORIGIN);
480
+ syncObjectToYMap(stateMap, lastSyncedState, pureState);
481
+ }, localOrigin);
482
+ lastSyncedState = pureState;
66
483
  };
67
- const syncFromYjs = () => {
68
- const incoming = map.get("state");
69
- if (typeof incoming !== "object" || incoming === null) {
484
+ const stateObserver = (events, transaction) => {
485
+ if (transaction.origin === localOrigin) {
70
486
  return;
71
487
  }
72
- store.setState(clone(incoming));
488
+ enqueueOperations(collectRemoteOperations(events, stateMap));
73
489
  };
74
- if (map.has("state")) {
75
- syncFromYjs();
490
+ let stateMap;
491
+ const existingStateMap = getStateMap();
492
+ if (existingStateMap) {
493
+ stateMap = existingStateMap;
494
+ applyRemoteState(toPlainObject(stateMap));
76
495
  } else {
77
- syncNow();
496
+ const currentState = map.get(STATE_KEY);
497
+ if (isPlainObject(currentState)) {
498
+ stateMap = createYMap(currentState);
499
+ doc.transact(() => {
500
+ map.set(STATE_KEY, stateMap);
501
+ }, localOrigin);
502
+ applyRemoteState(currentState);
503
+ } else {
504
+ const pureState = clone(store.getPureState());
505
+ stateMap = createYMap(isPlainObject(pureState) ? pureState : {});
506
+ doc.transact(() => {
507
+ map.set(STATE_KEY, stateMap);
508
+ }, localOrigin);
509
+ }
78
510
  }
511
+ stateMap.observeDeep(stateObserver);
79
512
  const observer = (event) => {
80
- if (event.transaction.origin === ORIGIN) {
513
+ if (event.transaction.origin === localOrigin) {
81
514
  return;
82
515
  }
83
- if (event.keysChanged.has("state")) {
84
- syncFromYjs();
516
+ if (!event.keysChanged.has(STATE_KEY)) {
517
+ return;
518
+ }
519
+ const nextStateMap = getStateMap();
520
+ if (nextStateMap) {
521
+ if (stateMap !== nextStateMap) {
522
+ stateMap.unobserveDeep(stateObserver);
523
+ stateMap = nextStateMap;
524
+ stateMap.observeDeep(stateObserver);
525
+ }
526
+ enqueueSnapshot(toPlainObject(nextStateMap));
527
+ return;
528
+ }
529
+ const currentState = map.get(STATE_KEY);
530
+ if (isPlainObject(currentState)) {
531
+ const migrated = createYMap(currentState);
532
+ doc.transact(() => {
533
+ map.set(STATE_KEY, migrated);
534
+ }, localOrigin);
535
+ if (stateMap !== migrated) {
536
+ stateMap.unobserveDeep(stateObserver);
537
+ stateMap = migrated;
538
+ stateMap.observeDeep(stateObserver);
539
+ }
540
+ enqueueSnapshot(currentState);
85
541
  }
86
542
  };
87
543
  map.observe(observer);
88
544
  const unsubscribe = store.subscribe(() => {
89
545
  syncNow();
90
546
  });
91
- return {
547
+ const binding = {
92
548
  doc,
93
549
  map,
94
550
  syncNow,
95
551
  destroy: () => {
552
+ if (destroyed) {
553
+ return;
554
+ }
555
+ destroyed = true;
96
556
  unsubscribe();
97
557
  map.unobserve(observer);
558
+ stateMap.unobserveDeep(stateObserver);
98
559
  if (!options.doc) {
99
560
  doc.destroy();
100
561
  }
101
562
  }
102
563
  };
564
+ if (process.env.NODE_ENV === "test") {
565
+ binding.__unsafeTestOnly__ = {
566
+ applyRemoteOperations: (operations) => {
567
+ applyRemoteOperations(operations);
568
+ }
569
+ };
570
+ }
571
+ return binding;
103
572
  };
104
573
  var yjs = (options = {}) => (store) => {
105
574
  const binding = bindYjs(store, options);
@@ -115,6 +584,7 @@ var yjs = (options = {}) => (store) => {
115
584
  __reExport(index_exports, src_exports, module.exports);
116
585
  // Annotate the CommonJS export names for ESM import in node:
117
586
  0 && (module.exports = {
587
+ __unsafeTestOnly__,
118
588
  bindYjs,
119
589
  yjs
120
590
  });
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,321 @@ __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";
38
+ var scheduleMicrotask = (callback) => {
39
+ if (typeof queueMicrotask === "function") {
40
+ queueMicrotask(callback);
41
+ return;
42
+ }
43
+ Promise.resolve().then(callback);
44
+ };
36
45
  function clone(value) {
37
46
  if (typeof structuredClone === "function") {
38
47
  return structuredClone(value);
39
48
  }
40
49
  return JSON.parse(JSON.stringify(value));
41
50
  }
51
+ function isPlainObject(value) {
52
+ if (typeof value !== "object" || value === null) {
53
+ return false;
54
+ }
55
+ const prototype = Object.getPrototypeOf(value);
56
+ return prototype === Object.prototype || prototype === null;
57
+ }
58
+ function toPlainObject(value) {
59
+ const next = {};
60
+ value.forEach((item, key) => {
61
+ next[key] = toPlainValue(item);
62
+ });
63
+ return next;
64
+ }
65
+ function toPlainArray(value) {
66
+ return value.toArray().map((item) => toPlainValue(item));
67
+ }
68
+ function toPlainValue(value) {
69
+ if (value instanceof Y.Map) {
70
+ return toPlainObject(value);
71
+ }
72
+ if (value instanceof Y.Array) {
73
+ return toPlainArray(value);
74
+ }
75
+ return value;
76
+ }
77
+ function createYMap(value) {
78
+ const next = new Y.Map();
79
+ for (const [key, item] of Object.entries(value)) {
80
+ next.set(key, toYValue(item));
81
+ }
82
+ return next;
83
+ }
84
+ function createYArray(value) {
85
+ const next = new Y.Array();
86
+ if (value.length > 0) {
87
+ next.insert(
88
+ 0,
89
+ value.map((item) => toYValue(item))
90
+ );
91
+ }
92
+ return next;
93
+ }
94
+ function toYValue(value) {
95
+ if (Array.isArray(value)) {
96
+ return createYArray(value);
97
+ }
98
+ if (isPlainObject(value)) {
99
+ return createYMap(value);
100
+ }
101
+ if (typeof value === "object" && value !== null) {
102
+ return clone(value);
103
+ }
104
+ return value;
105
+ }
106
+ function isEqualValue(left, right) {
107
+ if (Object.is(left, right)) {
108
+ return true;
109
+ }
110
+ if (Array.isArray(left) && Array.isArray(right)) {
111
+ if (left.length !== right.length) {
112
+ return false;
113
+ }
114
+ for (let index = 0; index < left.length; index += 1) {
115
+ if (!isEqualValue(left[index], right[index])) {
116
+ return false;
117
+ }
118
+ }
119
+ return true;
120
+ }
121
+ if (isPlainObject(left) && isPlainObject(right)) {
122
+ const leftKeys = Object.keys(left);
123
+ const rightKeys = Object.keys(right);
124
+ if (leftKeys.length !== rightKeys.length) {
125
+ return false;
126
+ }
127
+ for (const key of leftKeys) {
128
+ if (!Object.prototype.hasOwnProperty.call(right, key)) {
129
+ return false;
130
+ }
131
+ if (!isEqualValue(left[key], right[key])) {
132
+ return false;
133
+ }
134
+ }
135
+ return true;
136
+ }
137
+ return false;
138
+ }
139
+ function syncObjectToYMap(target, previous, source) {
140
+ const keys = /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(source)]);
141
+ for (const key of keys) {
142
+ const hasPrevious = Object.prototype.hasOwnProperty.call(previous, key);
143
+ const hasNext = Object.prototype.hasOwnProperty.call(source, key);
144
+ if (!hasNext) {
145
+ target.delete(key);
146
+ continue;
147
+ }
148
+ const previousValue = hasPrevious ? previous[key] : void 0;
149
+ const nextValue = source[key];
150
+ if (hasPrevious && isEqualValue(previousValue, nextValue)) {
151
+ continue;
152
+ }
153
+ const current = target.get(key);
154
+ if (Array.isArray(nextValue)) {
155
+ if (Array.isArray(previousValue) && current instanceof Y.Array) {
156
+ syncArrayToYArray(current, previousValue, nextValue);
157
+ } else {
158
+ target.set(key, createYArray(nextValue));
159
+ }
160
+ continue;
161
+ }
162
+ if (isPlainObject(nextValue)) {
163
+ if (isPlainObject(previousValue) && current instanceof Y.Map) {
164
+ syncObjectToYMap(current, previousValue, nextValue);
165
+ } else {
166
+ target.set(key, createYMap(nextValue));
167
+ }
168
+ continue;
169
+ }
170
+ const normalized = typeof nextValue === "object" && nextValue !== null ? clone(nextValue) : nextValue;
171
+ if (!Object.is(current, normalized)) {
172
+ target.set(key, normalized);
173
+ }
174
+ }
175
+ }
176
+ function syncArrayToYArray(target, previous, source) {
177
+ if (target.length > source.length) {
178
+ target.delete(source.length, target.length - source.length);
179
+ }
180
+ const maxLength = Math.max(previous.length, source.length);
181
+ for (let index = 0; index < maxLength; index += 1) {
182
+ const hasPrevious = index < previous.length;
183
+ const hasNext = index < source.length;
184
+ if (!hasNext) {
185
+ continue;
186
+ }
187
+ const previousValue = hasPrevious ? previous[index] : void 0;
188
+ const nextValue = source[index];
189
+ if (hasPrevious && isEqualValue(previousValue, nextValue)) {
190
+ continue;
191
+ }
192
+ if (index >= target.length) {
193
+ target.insert(index, [toYValue(nextValue)]);
194
+ continue;
195
+ }
196
+ const current = target.get(index);
197
+ if (Array.isArray(nextValue)) {
198
+ if (Array.isArray(previousValue) && current instanceof Y.Array) {
199
+ syncArrayToYArray(current, previousValue, nextValue);
200
+ } else {
201
+ target.delete(index, 1);
202
+ target.insert(index, [createYArray(nextValue)]);
203
+ }
204
+ continue;
205
+ }
206
+ if (isPlainObject(nextValue)) {
207
+ if (isPlainObject(previousValue) && current instanceof Y.Map) {
208
+ syncObjectToYMap(current, previousValue, nextValue);
209
+ } else {
210
+ target.delete(index, 1);
211
+ target.insert(index, [createYMap(nextValue)]);
212
+ }
213
+ continue;
214
+ }
215
+ const normalized = typeof nextValue === "object" && nextValue !== null ? clone(nextValue) : nextValue;
216
+ if (!Object.is(current, normalized)) {
217
+ target.delete(index, 1);
218
+ target.insert(index, [normalized]);
219
+ }
220
+ }
221
+ }
222
+ function isSetStateReentryError(error) {
223
+ return error instanceof Error && error.message === "setState cannot be called within the updater";
224
+ }
225
+ function cloneForStore(value) {
226
+ if (typeof value === "object" && value !== null) {
227
+ return clone(value);
228
+ }
229
+ return value;
230
+ }
231
+ function toPathKey(path) {
232
+ return path.map((segment) => `${typeof segment}:${String(segment)}`).join("|");
233
+ }
234
+ function compactOperations(operations) {
235
+ const deduplicated = /* @__PURE__ */ new Map();
236
+ for (const operation of operations) {
237
+ const key = toPathKey(operation.path);
238
+ if (deduplicated.has(key)) {
239
+ deduplicated.delete(key);
240
+ }
241
+ deduplicated.set(key, operation);
242
+ }
243
+ return Array.from(deduplicated.values()).sort(
244
+ (left, right) => left.path.length - right.path.length
245
+ );
246
+ }
247
+ function getYValueAtPath(root, path) {
248
+ let current = root;
249
+ for (const segment of path) {
250
+ if (current instanceof Y.Map) {
251
+ current = current.get(String(segment));
252
+ continue;
253
+ }
254
+ if (current instanceof Y.Array) {
255
+ const index = typeof segment === "number" ? segment : Number.parseInt(segment, 10);
256
+ if (!Number.isInteger(index)) {
257
+ return void 0;
258
+ }
259
+ current = current.get(index);
260
+ continue;
261
+ }
262
+ return void 0;
263
+ }
264
+ return current;
265
+ }
266
+ function setAtPath(target, path, value) {
267
+ if (path.length === 0) {
268
+ return;
269
+ }
270
+ let current = target;
271
+ for (let index = 0; index < path.length - 1; index += 1) {
272
+ const segment = path[index];
273
+ const nextSegment = path[index + 1];
274
+ const nextValue = current[segment];
275
+ if (typeof nextValue !== "object" || nextValue === null) {
276
+ current[segment] = typeof nextSegment === "number" ? [] : {};
277
+ }
278
+ current = current[segment];
279
+ }
280
+ const leaf = path[path.length - 1];
281
+ current[leaf] = cloneForStore(value);
282
+ }
283
+ function deleteAtPath(target, path) {
284
+ if (path.length === 0) {
285
+ return;
286
+ }
287
+ let current = target;
288
+ for (let index = 0; index < path.length - 1; index += 1) {
289
+ current = current[path[index]];
290
+ if (typeof current !== "object" || current === null) {
291
+ return;
292
+ }
293
+ }
294
+ const leaf = path[path.length - 1];
295
+ if (Array.isArray(current) && typeof leaf === "number") {
296
+ if (leaf >= 0 && leaf < current.length) {
297
+ current.splice(leaf, 1);
298
+ }
299
+ return;
300
+ }
301
+ delete current[leaf];
302
+ }
303
+ function collectRemoteOperations(events, stateMap) {
304
+ const operations = [];
305
+ for (const event of events) {
306
+ if (event instanceof Y.YMapEvent) {
307
+ for (const changedKey of event.keysChanged) {
308
+ const path = [...event.path, changedKey];
309
+ const keyChange = event.changes.keys.get(changedKey);
310
+ if (keyChange?.action === "delete") {
311
+ operations.push({
312
+ type: "delete",
313
+ path
314
+ });
315
+ continue;
316
+ }
317
+ operations.push({
318
+ type: "set",
319
+ path,
320
+ value: toPlainValue(getYValueAtPath(stateMap, path))
321
+ });
322
+ }
323
+ continue;
324
+ }
325
+ if (event instanceof Y.YArrayEvent) {
326
+ const path = [...event.path];
327
+ operations.push({
328
+ type: "set",
329
+ path,
330
+ value: toPlainValue(getYValueAtPath(stateMap, path))
331
+ });
332
+ }
333
+ }
334
+ return operations;
335
+ }
336
+ var __unsafeTestOnly__ = {
337
+ getYValueAtPath: (root, path) => getYValueAtPath(root, path),
338
+ setAtPath: (target, path, value) => {
339
+ setAtPath(target, path, value);
340
+ },
341
+ deleteAtPath: (target, path) => {
342
+ deleteAtPath(target, path);
343
+ }
344
+ };
42
345
  var bindYjs = (store, options = {}) => {
43
346
  if (store.share === "client") {
44
347
  throw new Error("Yjs binding is not supported in client store mode.");
@@ -46,48 +349,214 @@ var bindYjs = (store, options = {}) => {
46
349
  const doc = options.doc ?? new Y.Doc();
47
350
  const key = options.key ?? `coaction:${store.name}`;
48
351
  const map = doc.getMap(key);
352
+ const localOrigin = /* @__PURE__ */ Symbol(`coaction-yjs:${store.name}`);
353
+ let destroyed = false;
354
+ let syncingFromYjs = false;
355
+ let lastSyncedState = (() => {
356
+ const pureState = clone(store.getPureState());
357
+ return isPlainObject(pureState) ? pureState : {};
358
+ })();
359
+ let flushScheduled = false;
360
+ let pendingSnapshot = null;
361
+ let pendingOperations = [];
362
+ const applyRemoteState = (state) => {
363
+ const next = clone(state);
364
+ syncingFromYjs = true;
365
+ try {
366
+ store.setState(next);
367
+ const pureState = clone(store.getPureState());
368
+ lastSyncedState = isPlainObject(pureState) ? pureState : {};
369
+ } finally {
370
+ syncingFromYjs = false;
371
+ }
372
+ };
373
+ const applyRemoteOperations = (operations) => {
374
+ if (operations.length === 0) {
375
+ return;
376
+ }
377
+ syncingFromYjs = true;
378
+ try {
379
+ store.setState((draft) => {
380
+ const mutableDraft = draft;
381
+ for (const operation of operations) {
382
+ if (operation.type === "set") {
383
+ setAtPath(mutableDraft, operation.path, operation.value);
384
+ } else {
385
+ deleteAtPath(mutableDraft, operation.path);
386
+ }
387
+ }
388
+ });
389
+ const pureState = clone(store.getPureState());
390
+ lastSyncedState = isPlainObject(pureState) ? pureState : {};
391
+ } finally {
392
+ syncingFromYjs = false;
393
+ }
394
+ };
395
+ const getStateMap = () => {
396
+ const state = map.get(STATE_KEY);
397
+ if (state instanceof Y.Map) {
398
+ return state;
399
+ }
400
+ return null;
401
+ };
402
+ const scheduleFlushFromYjs = () => {
403
+ if (destroyed || flushScheduled) {
404
+ return;
405
+ }
406
+ flushScheduled = true;
407
+ scheduleMicrotask(flushFromYjs);
408
+ };
409
+ const flushFromYjs = () => {
410
+ flushScheduled = false;
411
+ if (destroyed) {
412
+ return;
413
+ }
414
+ if (pendingSnapshot) {
415
+ const snapshot = pendingSnapshot;
416
+ pendingSnapshot = null;
417
+ pendingOperations = [];
418
+ try {
419
+ applyRemoteState(snapshot);
420
+ } catch (error) {
421
+ if (isSetStateReentryError(error)) {
422
+ pendingSnapshot = snapshot;
423
+ setTimeout(scheduleFlushFromYjs, 0);
424
+ return;
425
+ }
426
+ throw error;
427
+ }
428
+ }
429
+ if (pendingOperations.length === 0) {
430
+ return;
431
+ }
432
+ const operations = compactOperations(pendingOperations);
433
+ pendingOperations = [];
434
+ try {
435
+ applyRemoteOperations(operations);
436
+ } catch (error) {
437
+ if (isSetStateReentryError(error)) {
438
+ pendingOperations = [...operations, ...pendingOperations];
439
+ setTimeout(scheduleFlushFromYjs, 0);
440
+ return;
441
+ }
442
+ throw error;
443
+ }
444
+ };
445
+ const enqueueSnapshot = (snapshot) => {
446
+ pendingSnapshot = snapshot;
447
+ pendingOperations = [];
448
+ scheduleFlushFromYjs();
449
+ };
450
+ const enqueueOperations = (operations) => {
451
+ if (operations.length === 0) {
452
+ return;
453
+ }
454
+ if (!pendingSnapshot) {
455
+ pendingOperations.push(...operations);
456
+ }
457
+ scheduleFlushFromYjs();
458
+ };
49
459
  const syncNow = () => {
460
+ if (destroyed || syncingFromYjs) {
461
+ return;
462
+ }
50
463
  const pureState = clone(store.getPureState());
464
+ if (!isPlainObject(pureState)) {
465
+ return;
466
+ }
51
467
  doc.transact(() => {
52
- map.set("state", pureState);
53
- }, ORIGIN);
468
+ syncObjectToYMap(stateMap, lastSyncedState, pureState);
469
+ }, localOrigin);
470
+ lastSyncedState = pureState;
54
471
  };
55
- const syncFromYjs = () => {
56
- const incoming = map.get("state");
57
- if (typeof incoming !== "object" || incoming === null) {
472
+ const stateObserver = (events, transaction) => {
473
+ if (transaction.origin === localOrigin) {
58
474
  return;
59
475
  }
60
- store.setState(clone(incoming));
476
+ enqueueOperations(collectRemoteOperations(events, stateMap));
61
477
  };
62
- if (map.has("state")) {
63
- syncFromYjs();
478
+ let stateMap;
479
+ const existingStateMap = getStateMap();
480
+ if (existingStateMap) {
481
+ stateMap = existingStateMap;
482
+ applyRemoteState(toPlainObject(stateMap));
64
483
  } else {
65
- syncNow();
484
+ const currentState = map.get(STATE_KEY);
485
+ if (isPlainObject(currentState)) {
486
+ stateMap = createYMap(currentState);
487
+ doc.transact(() => {
488
+ map.set(STATE_KEY, stateMap);
489
+ }, localOrigin);
490
+ applyRemoteState(currentState);
491
+ } else {
492
+ const pureState = clone(store.getPureState());
493
+ stateMap = createYMap(isPlainObject(pureState) ? pureState : {});
494
+ doc.transact(() => {
495
+ map.set(STATE_KEY, stateMap);
496
+ }, localOrigin);
497
+ }
66
498
  }
499
+ stateMap.observeDeep(stateObserver);
67
500
  const observer = (event) => {
68
- if (event.transaction.origin === ORIGIN) {
501
+ if (event.transaction.origin === localOrigin) {
69
502
  return;
70
503
  }
71
- if (event.keysChanged.has("state")) {
72
- syncFromYjs();
504
+ if (!event.keysChanged.has(STATE_KEY)) {
505
+ return;
506
+ }
507
+ const nextStateMap = getStateMap();
508
+ if (nextStateMap) {
509
+ if (stateMap !== nextStateMap) {
510
+ stateMap.unobserveDeep(stateObserver);
511
+ stateMap = nextStateMap;
512
+ stateMap.observeDeep(stateObserver);
513
+ }
514
+ enqueueSnapshot(toPlainObject(nextStateMap));
515
+ return;
516
+ }
517
+ const currentState = map.get(STATE_KEY);
518
+ if (isPlainObject(currentState)) {
519
+ const migrated = createYMap(currentState);
520
+ doc.transact(() => {
521
+ map.set(STATE_KEY, migrated);
522
+ }, localOrigin);
523
+ if (stateMap !== migrated) {
524
+ stateMap.unobserveDeep(stateObserver);
525
+ stateMap = migrated;
526
+ stateMap.observeDeep(stateObserver);
527
+ }
528
+ enqueueSnapshot(currentState);
73
529
  }
74
530
  };
75
531
  map.observe(observer);
76
532
  const unsubscribe = store.subscribe(() => {
77
533
  syncNow();
78
534
  });
79
- return {
535
+ const binding = {
80
536
  doc,
81
537
  map,
82
538
  syncNow,
83
539
  destroy: () => {
540
+ if (destroyed) {
541
+ return;
542
+ }
543
+ destroyed = true;
84
544
  unsubscribe();
85
545
  map.unobserve(observer);
546
+ stateMap.unobserveDeep(stateObserver);
86
547
  if (!options.doc) {
87
548
  doc.destroy();
88
549
  }
89
550
  }
90
551
  };
552
+ if (process.env.NODE_ENV === "test") {
553
+ binding.__unsafeTestOnly__ = {
554
+ applyRemoteOperations: (operations) => {
555
+ applyRemoteOperations(operations);
556
+ }
557
+ };
558
+ }
559
+ return binding;
91
560
  };
92
561
  var yjs = (options = {}) => (store) => {
93
562
  const binding = bindYjs(store, options);
@@ -102,6 +571,7 @@ var yjs = (options = {}) => (store) => {
102
571
  // index.ts
103
572
  __reExport(index_exports, src_exports);
104
573
  export {
574
+ __unsafeTestOnly__,
105
575
  bindYjs,
106
576
  yjs
107
577
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coaction/yjs",
3
- "version": "1.0.1",
3
+ "version": "1.2.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.2.0",
42
42
  "yjs": "^13.6.0"
43
43
  },
44
44
  "peerDependenciesMeta": {
@@ -50,8 +50,8 @@
50
50
  }
51
51
  },
52
52
  "devDependencies": {
53
- "coaction": "^1.0.1",
54
- "yjs": "^13.6.27"
53
+ "yjs": "^13.6.27",
54
+ "coaction": "1.2.0"
55
55
  },
56
56
  "publishConfig": {
57
57
  "access": "public",