@pylonsync/loro 0.22.38 → 0.22.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/loro",
3
- "version": "0.22.38",
3
+ "version": "0.22.39",
4
4
  "description": "Pylon's local-first React layer — wraps loro-crdt with a registry, useLoroDoc hook, and the binary WS wire-format decoder that mirrors crates/router/encode_crdt_frame.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -12,14 +12,15 @@
12
12
  ".": "./src/index.ts",
13
13
  "./wire": "./src/wire.ts",
14
14
  "./registry": "./src/registry.ts",
15
- "./prosemirror": "./src/prosemirror.ts"
15
+ "./prosemirror": "./src/prosemirror/index.ts",
16
+ "./prosemirror/react": "./src/prosemirror/react.ts"
16
17
  },
17
18
  "scripts": {
18
19
  "check": "tsc -p tsconfig.json --noEmit"
19
20
  },
20
21
  "dependencies": {
21
- "@pylonsync/react": "0.22.38",
22
- "@pylonsync/sync": "0.22.38",
22
+ "@pylonsync/react": "0.22.39",
23
+ "@pylonsync/sync": "0.22.39",
23
24
  "loro-crdt": "^1.10.6"
24
25
  },
25
26
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import { db, getBaseUrl, getReactStorage, storageKey } from "@pylonsync/react";
28
28
  import { pylonFetch, type SyncEngine } from "@pylonsync/sync";
29
29
  import { globalRegistry, LoroRegistry } from "./registry";
30
+ import { CrdtOutbox, type OutboxState } from "./outbox";
30
31
 
31
32
  export { LoroRegistry, globalRegistry } from "./registry";
32
33
  export {
@@ -36,6 +37,8 @@ export {
36
37
  CRDT_FRAME_UPDATE,
37
38
  } from "./wire";
38
39
  export type { CrdtFrame } from "./wire";
40
+ export { CrdtOutbox, isRetryable } from "./outbox";
41
+ export type { OutboxSend, OutboxState, OutboxOptions } from "./outbox";
39
42
 
40
43
  // ---------------------------------------------------------------------------
41
44
  // Sync engine ↔ registry wiring
@@ -73,12 +76,61 @@ function ensureAttached(): void {
73
76
  // lingering with stale state until the tab closes.
74
77
  unsubscribeRowEviction = sync.addRowEvictionListener(
75
78
  (entity: string, rowId: string) => {
79
+ disposeOutbox(entity, rowId);
76
80
  globalRegistry.evict(entity, rowId);
77
81
  },
78
82
  );
83
+ attachPageListeners();
79
84
  attachedSync = sync;
80
85
  }
81
86
 
87
+ // ---------------------------------------------------------------------------
88
+ // Outboxes — one per row doc, sending its local edits (see ./outbox.ts)
89
+ //
90
+ // Created the first time a component reads the row's doc and kept as long
91
+ // as the doc: an editor that unmounts must not drop edits still in flight.
92
+ // A row eviction (policy revoked) disposes both.
93
+ // ---------------------------------------------------------------------------
94
+
95
+ const outboxes = new Map<string, { doc: LoroDoc; outbox: CrdtOutbox }>();
96
+
97
+ function outboxKey(entity: string, rowId: string): string {
98
+ return `${entity}:${rowId}`;
99
+ }
100
+
101
+ function outboxFor(entity: string, rowId: string, doc: LoroDoc): CrdtOutbox {
102
+ const key = outboxKey(entity, rowId);
103
+ const existing = outboxes.get(key);
104
+ if (existing && existing.doc === doc) return existing.outbox;
105
+ existing?.outbox.dispose();
106
+ const outbox = new CrdtOutbox(doc, (update, { keepalive }) =>
107
+ pushCrdtUpdate(entity, rowId, update, keepalive),
108
+ );
109
+ // A state change re-renders the row's readers (useLoroSendState).
110
+ outbox.subscribe(() => globalRegistry.touch(entity, rowId));
111
+ outboxes.set(key, { doc, outbox });
112
+ return outbox;
113
+ }
114
+
115
+ function disposeOutbox(entity: string, rowId: string): void {
116
+ const key = outboxKey(entity, rowId);
117
+ outboxes.get(key)?.outbox.dispose();
118
+ outboxes.delete(key);
119
+ }
120
+
121
+ let pageListenersAttached = false;
122
+
123
+ function attachPageListeners(): void {
124
+ if (pageListenersAttached || typeof window === "undefined") return;
125
+ pageListenersAttached = true;
126
+ window.addEventListener("online", () => {
127
+ for (const { outbox } of outboxes.values()) outbox.retryNow();
128
+ });
129
+ window.addEventListener("pagehide", () => {
130
+ for (const { outbox } of outboxes.values()) outbox.flushOnHide();
131
+ });
132
+ }
133
+
82
134
  // ---------------------------------------------------------------------------
83
135
  // React hooks
84
136
  // ---------------------------------------------------------------------------
@@ -133,7 +185,24 @@ export function useLoroDoc(entity: string, id: string): LoroDoc {
133
185
  globalRegistry.subscribe(entity, id, notify);
134
186
  const getVersion = () => globalRegistry.version(entity, id);
135
187
  useSyncExternalStore(subscribe, getVersion, getVersion);
136
- return globalRegistry.doc(entity, id);
188
+ const doc = globalRegistry.doc(entity, id);
189
+ // Local edits (an editor binding's included) reach the server through
190
+ // the row's outbox.
191
+ useEffect(() => {
192
+ outboxFor(entity, id, doc);
193
+ }, [entity, id, doc]);
194
+ return doc;
195
+ }
196
+
197
+ /**
198
+ * Whether this tab's edits to a row have reached the server, for a
199
+ * "Saving" indicator or an error notice. See {@link OutboxState}.
200
+ * `rejected` means the server refused them (for example the person lost
201
+ * edit access); they stay in this tab only.
202
+ */
203
+ export function useLoroSendState(entity: string, id: string): OutboxState {
204
+ useLoroDoc(entity, id);
205
+ return outboxes.get(outboxKey(entity, id))?.outbox.state ?? "idle";
137
206
  }
138
207
 
139
208
  /**
@@ -235,11 +304,6 @@ export function useCollabText(
235
304
  const value = resolveLoroText(doc, field)?.toString() ?? "";
236
305
  const setValue = (next: string): void => {
237
306
  const text = getLoroText(doc, field);
238
- // Capture the version vector BEFORE the mutation so we can ship
239
- // exactly the new ops to the server (incremental delta, not the
240
- // whole snapshot). Loro's `export({mode: "update", from: vv})`
241
- // returns the bytes the server hasn't seen.
242
- const beforeVv = doc.oplogVersion();
243
307
  const len = text.length;
244
308
  if (len > 0) {
245
309
  text.delete(0, len);
@@ -248,14 +312,9 @@ export function useCollabText(
248
312
  text.insert(0, next);
249
313
  }
250
314
  doc.commit();
251
- // Optimistic render: notify every consumer of this row NOW.
315
+ // Optimistic render: notify every consumer of this row NOW. The
316
+ // row's outbox sends the edit.
252
317
  globalRegistry.touch(entity, id);
253
-
254
- const update = doc.export({ mode: "update", from: beforeVv });
255
- if (update.length === 0) {
256
- return; // No-op (e.g. setValue called with the same value)
257
- }
258
- void pushCrdtUpdate(entity, id, update);
259
318
  };
260
319
  return [value, setValue];
261
320
  }
@@ -343,20 +402,16 @@ export function useCollabTextarea<
343
402
  const prev = domValue.current;
344
403
  if (next === prev) return;
345
404
  const { index, deleteCount, insert } = spliceDiff(prev, next);
346
- // Resolve the container per keystroke (see resolveLoroText) and
347
- // capture the version vector BEFORE mutating so we ship exactly the
348
- // new ops.
405
+ // Resolve the container per keystroke (see resolveLoroText).
349
406
  const text = getLoroText(doc, field);
350
- const beforeVv = doc.oplogVersion();
351
407
  if (deleteCount > 0) text.delete(index, deleteCount);
352
408
  if (insert.length > 0) text.insert(index, insert);
353
409
  doc.commit();
354
410
  domValue.current = next;
355
411
  // Optimistic render: the preview (and any other consumer of this
356
- // row) updates on the local commit, not on the server echo.
412
+ // row) updates on the local commit, not on the server echo. The row's
413
+ // outbox sends the edit.
357
414
  globalRegistry.touch(entity, id);
358
- const update = doc.export({ mode: "update", from: beforeVv });
359
- if (update.length > 0) void pushCrdtUpdate(entity, id, update);
360
415
  };
361
416
 
362
417
  return { ref, value, onInput };
@@ -416,29 +471,27 @@ async function pushCrdtUpdate(
416
471
  entity: string,
417
472
  id: string,
418
473
  update: Uint8Array,
474
+ keepalive: boolean,
419
475
  ): Promise<void> {
420
- try {
421
- // Server acknowledges with {ok: true}. The merged-state broadcast
422
- // arrives over the WS, applied automatically by the registry — no
423
- // need to read the response body here, but pylonFetch surfaces
424
- // structured errors so failed pushes log instead of vanishing.
425
- await pylonFetch(
426
- {
427
- baseUrl: getBaseUrl(),
428
- getToken: () =>
429
- (getReactStorage().get(storageKey("token")) ?? undefined) as
430
- | string
431
- | undefined,
432
- },
433
- `/api/crdt/${encodeURIComponent(entity)}/${encodeURIComponent(id)}`,
434
- {
435
- method: "POST",
436
- json: { update: bytesToHex(update) },
437
- },
438
- );
439
- } catch (err) {
440
- console.warn(`[loro] CRDT push failed for ${entity}/${id}:`, err);
441
- }
476
+ // Server acknowledges with {ok: true}. The merged-state broadcast
477
+ // arrives over the WS, applied automatically by the registry. A
478
+ // failure throws (PylonHttpError carries the status); the outbox
479
+ // decides whether to retry.
480
+ await pylonFetch(
481
+ {
482
+ baseUrl: getBaseUrl(),
483
+ getToken: () =>
484
+ (getReactStorage().get(storageKey("token")) ?? undefined) as
485
+ | string
486
+ | undefined,
487
+ },
488
+ `/api/crdt/${encodeURIComponent(entity)}/${encodeURIComponent(id)}`,
489
+ {
490
+ method: "POST",
491
+ json: { update: bytesToHex(update) },
492
+ keepalive,
493
+ },
494
+ );
442
495
  }
443
496
 
444
497
  function bytesToHex(bytes: Uint8Array): string {
@@ -455,6 +508,8 @@ function bytesToHex(bytes: Uint8Array): string {
455
508
  * need to call it.
456
509
  */
457
510
  export function detachLoro(): void {
511
+ for (const { outbox } of outboxes.values()) outbox.dispose();
512
+ outboxes.clear();
458
513
  if (unsubscribeBinaryHandler) {
459
514
  unsubscribeBinaryHandler();
460
515
  unsubscribeBinaryHandler = null;
@@ -0,0 +1,312 @@
1
+ // CrdtOutbox against real LoroDocs. A fake server holds its own doc, merges
2
+ // what clients send, and relays each merge to the other clients, the way
3
+ // POST /api/crdt and the binary broadcast do.
4
+ import { describe, expect, test } from "bun:test";
5
+ import { LoroDoc, LoroMap } from "loro-crdt";
6
+ import { Schema } from "prosemirror-model";
7
+ import { schema as basic } from "prosemirror-schema-basic";
8
+ import { CrdtOutbox, isRetryable, type OutboxSend } from "./outbox";
9
+ import { readProseMirror, writeProseMirror } from "./prosemirror/index";
10
+
11
+ const tick = () => new Promise((r) => setTimeout(r, 0));
12
+
13
+ async function settle(): Promise<void> {
14
+ for (let i = 0; i < 20; i++) await tick();
15
+ }
16
+
17
+ function httpError(status: number): Error & { status: number } {
18
+ return Object.assign(new Error(`status ${status}`), { status });
19
+ }
20
+
21
+ class FakeServer {
22
+ readonly doc = new LoroDoc();
23
+ readonly received: Uint8Array[] = [];
24
+ private readonly clients = new Set<LoroDoc>();
25
+
26
+ connect(client: LoroDoc): void {
27
+ client.import(this.doc.export({ mode: "snapshot" }));
28
+ this.clients.add(client);
29
+ }
30
+
31
+ /** A send that merges into the server doc and relays to every client. */
32
+ sender(): OutboxSend {
33
+ return async (update) => {
34
+ this.received.push(update);
35
+ this.doc.import(update);
36
+ const merged = this.doc.export({ mode: "snapshot" });
37
+ for (const c of this.clients) c.import(merged);
38
+ };
39
+ }
40
+ }
41
+
42
+ function text(doc: LoroDoc): string {
43
+ return doc.getText("t").toString();
44
+ }
45
+
46
+ describe("CrdtOutbox", () => {
47
+ test("a local edit reaches the server and the other client", async () => {
48
+ const server = new FakeServer();
49
+ const a = new LoroDoc();
50
+ const b = new LoroDoc();
51
+ server.connect(a);
52
+ server.connect(b);
53
+ const outbox = new CrdtOutbox(a, server.sender());
54
+ a.getText("t").insert(0, "hello");
55
+ a.commit();
56
+ await settle();
57
+ expect(text(server.doc)).toBe("hello");
58
+ expect(text(b)).toBe("hello");
59
+ expect(outbox.state).toBe("idle");
60
+ expect(outbox.hasUnsent()).toBe(false);
61
+ });
62
+
63
+ test("two clients editing at once converge", async () => {
64
+ const server = new FakeServer();
65
+ const a = new LoroDoc();
66
+ const b = new LoroDoc();
67
+ server.connect(a);
68
+ server.connect(b);
69
+ new CrdtOutbox(a, server.sender());
70
+ new CrdtOutbox(b, server.sender());
71
+ a.getText("t").insert(0, "left ");
72
+ a.commit();
73
+ b.getText("t").insert(0, "right");
74
+ b.commit();
75
+ await settle();
76
+ expect(text(a)).toBe(text(b));
77
+ expect(text(server.doc)).toBe(text(a));
78
+ expect(text(a)).toContain("left");
79
+ expect(text(a)).toContain("right");
80
+ });
81
+
82
+ test("commits made during a request go out together in the next one", async () => {
83
+ const server = new FakeServer();
84
+ const a = new LoroDoc();
85
+ server.connect(a);
86
+ let release: () => void = () => {};
87
+ let calls = 0;
88
+ const send: OutboxSend = async (update, opts) => {
89
+ calls += 1;
90
+ if (calls === 1) await new Promise<void>((r) => (release = r));
91
+ await server.sender()(update, opts);
92
+ };
93
+ const outbox = new CrdtOutbox(a, send);
94
+ a.getText("t").insert(0, "a");
95
+ a.commit();
96
+ await tick();
97
+ expect(outbox.state).toBe("sending");
98
+ for (const ch of ["b", "c", "d"]) {
99
+ a.getText("t").insert(a.getText("t").length, ch);
100
+ a.commit();
101
+ }
102
+ await tick();
103
+ expect(calls).toBe(1);
104
+ release();
105
+ await settle();
106
+ expect(calls).toBe(2);
107
+ expect(text(server.doc)).toBe("abcd");
108
+ });
109
+
110
+ test("a failed request is sent again, with the edits made since", async () => {
111
+ const server = new FakeServer();
112
+ const a = new LoroDoc();
113
+ server.connect(a);
114
+ const timers: Array<() => void> = [];
115
+ let fail = true;
116
+ const send: OutboxSend = async (update, opts) => {
117
+ if (fail) throw new Error("network down");
118
+ await server.sender()(update, opts);
119
+ };
120
+ const outbox = new CrdtOutbox(a, send, {
121
+ setTimer: (fn) => timers.push(fn),
122
+ clearTimer: () => {},
123
+ });
124
+ a.getText("t").insert(0, "one ");
125
+ a.commit();
126
+ await settle();
127
+ expect(outbox.state).toBe("retrying");
128
+ expect(text(server.doc)).toBe("");
129
+ a.getText("t").insert(4, "two");
130
+ a.commit();
131
+ await settle();
132
+ expect(timers.length).toBe(1); // no second request while waiting
133
+ fail = false;
134
+ timers.shift()!();
135
+ await settle();
136
+ expect(text(server.doc)).toBe("one two");
137
+ expect(outbox.state).toBe("idle");
138
+ });
139
+
140
+ test("retry delays double up to the limit", async () => {
141
+ const a = new LoroDoc();
142
+ const delays: number[] = [];
143
+ const timers: Array<() => void> = [];
144
+ const outbox = new CrdtOutbox(a, async () => Promise.reject(httpError(503)), {
145
+ retryBaseMs: 100,
146
+ retryMaxMs: 300,
147
+ setTimer: (fn, ms) => {
148
+ delays.push(ms);
149
+ timers.push(fn);
150
+ },
151
+ clearTimer: () => {},
152
+ });
153
+ a.getText("t").insert(0, "x");
154
+ a.commit();
155
+ for (let i = 0; i < 4; i++) {
156
+ await settle();
157
+ timers.shift()!();
158
+ }
159
+ await settle();
160
+ expect(delays).toEqual([100, 200, 300, 300, 300]);
161
+ expect(outbox.state).toBe("retrying");
162
+ });
163
+
164
+ test("retryNow skips the wait", async () => {
165
+ const server = new FakeServer();
166
+ const a = new LoroDoc();
167
+ server.connect(a);
168
+ let fail = true;
169
+ const outbox = new CrdtOutbox(
170
+ a,
171
+ async (u, o) => {
172
+ if (fail) throw new Error("offline");
173
+ await server.sender()(u, o);
174
+ },
175
+ { setTimer: () => "handle", clearTimer: () => {} },
176
+ );
177
+ a.getText("t").insert(0, "x");
178
+ a.commit();
179
+ await settle();
180
+ fail = false;
181
+ outbox.retryNow();
182
+ await settle();
183
+ expect(text(server.doc)).toBe("x");
184
+ });
185
+
186
+ test("a refused edit stops the outbox", async () => {
187
+ const a = new LoroDoc();
188
+ let calls = 0;
189
+ const outbox = new CrdtOutbox(a, async () => {
190
+ calls += 1;
191
+ throw httpError(403);
192
+ });
193
+ a.getText("t").insert(0, "x");
194
+ a.commit();
195
+ await settle();
196
+ expect(outbox.state).toBe("rejected");
197
+ expect((outbox.error as { status: number }).status).toBe(403);
198
+ a.getText("t").insert(1, "y");
199
+ a.commit();
200
+ await settle();
201
+ expect(calls).toBe(1);
202
+ expect(outbox.hasUnsent()).toBe(true);
203
+ });
204
+
205
+ test("edits that came from the server are not sent back", async () => {
206
+ const server = new FakeServer();
207
+ const a = new LoroDoc();
208
+ const b = new LoroDoc();
209
+ server.connect(a);
210
+ server.connect(b);
211
+ new CrdtOutbox(b, server.sender());
212
+ // A large edit from b reaches a through the server.
213
+ b.getText("t").insert(0, "x".repeat(5000));
214
+ b.commit();
215
+ await settle();
216
+ const sends: Uint8Array[] = [];
217
+ const send = server.sender();
218
+ new CrdtOutbox(a, async (u, o) => {
219
+ sends.push(u);
220
+ await send(u, o);
221
+ });
222
+ a.getText("t").insert(0, "!");
223
+ a.commit();
224
+ await settle();
225
+ expect(sends.length).toBe(1);
226
+ expect(sends[0].length).toBeLessThan(200);
227
+ expect(text(b).startsWith("!")).toBe(true);
228
+ });
229
+
230
+ test("edits made before the outbox existed are sent", async () => {
231
+ const server = new FakeServer();
232
+ const a = new LoroDoc();
233
+ server.connect(a);
234
+ a.getText("t").insert(0, "early");
235
+ a.commit();
236
+ new CrdtOutbox(a, server.sender());
237
+ await settle();
238
+ expect(text(server.doc)).toBe("early");
239
+ });
240
+
241
+ test("flushOnHide sends with keepalive while a request is in flight", async () => {
242
+ const a = new LoroDoc();
243
+ const seen: boolean[] = [];
244
+ let release: () => void = () => {};
245
+ const outbox = new CrdtOutbox(a, async (_u, { keepalive }) => {
246
+ seen.push(keepalive);
247
+ if (!keepalive) await new Promise<void>((r) => (release = r));
248
+ });
249
+ a.getText("t").insert(0, "x");
250
+ a.commit();
251
+ await tick();
252
+ outbox.flushOnHide();
253
+ await settle();
254
+ expect(seen).toEqual([false, true]);
255
+ expect(outbox.hasUnsent()).toBe(false);
256
+ release();
257
+ await settle();
258
+ });
259
+
260
+ test("dispose stops sending", async () => {
261
+ const a = new LoroDoc();
262
+ let calls = 0;
263
+ const outbox = new CrdtOutbox(a, async () => {
264
+ calls += 1;
265
+ });
266
+ outbox.dispose();
267
+ a.getText("t").insert(0, "x");
268
+ a.commit();
269
+ await settle();
270
+ expect(calls).toBe(0);
271
+ });
272
+
273
+ test("a ProseMirror document edited in two copies converges", async () => {
274
+ const schema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
275
+ const server = new FakeServer();
276
+ // The server creates the field's container on insert.
277
+ server.doc.getMap("row").setContainer("body", new LoroMap());
278
+ server.doc.commit();
279
+ const a = new LoroDoc();
280
+ const b = new LoroDoc();
281
+ server.connect(a);
282
+ server.connect(b);
283
+ new CrdtOutbox(a, server.sender());
284
+ new CrdtOutbox(b, server.sender());
285
+ const para = (s: string) => schema.node("paragraph", null, [schema.text(s)]);
286
+ writeProseMirror(a, "body", schema, schema.node("doc", null, [para("from a")]));
287
+ await settle();
288
+ const current = readProseMirror(b, "body", schema)!;
289
+ writeProseMirror(b, "body", schema, current.copy(current.content.append(schema.node("doc", null, [para("from b")]).content)));
290
+ await settle();
291
+ const expected = ["from a", "from b"];
292
+ for (const d of [a, b, server.doc]) {
293
+ const node = readProseMirror(d, "body", schema)!;
294
+ const paragraphs: string[] = [];
295
+ node.forEach((n) => paragraphs.push(n.textContent));
296
+ expect(paragraphs).toEqual(expected);
297
+ }
298
+ });
299
+ });
300
+
301
+ describe("isRetryable", () => {
302
+ test("network errors, 5xx, 408 and 429 retry; other 4xx do not", () => {
303
+ expect(isRetryable(new Error("x"))).toBe(true);
304
+ expect(isRetryable(httpError(500))).toBe(true);
305
+ expect(isRetryable(httpError(503))).toBe(true);
306
+ expect(isRetryable(httpError(408))).toBe(true);
307
+ expect(isRetryable(httpError(429))).toBe(true);
308
+ expect(isRetryable(httpError(400))).toBe(false);
309
+ expect(isRetryable(httpError(403))).toBe(false);
310
+ expect(isRetryable(httpError(404))).toBe(false);
311
+ });
312
+ });
package/src/outbox.ts ADDED
@@ -0,0 +1,214 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CrdtOutbox — sends a doc's local edits to the server
3
+ //
4
+ // Every edit made on this copy of a row's doc (by an editor binding, a
5
+ // hook, or app code) has to reach `POST /api/crdt/<entity>/<id>`. The
6
+ // outbox listens for local commits and sends the edits the server has not
7
+ // confirmed:
8
+ //
9
+ // - One request at a time, so edits arrive in order. Commits made while
10
+ // a request is in flight go out together in the next one.
11
+ // - What it sends is every op of this peer after the last confirmed one,
12
+ // so a failed request loses nothing: the next send carries it again.
13
+ // Ops of other peers are never sent back; they came from the server.
14
+ // - A network error or a 5xx, 408 or 429 retries with backoff. Any other
15
+ // 4xx (policy denied, row gone, bad update) stops the outbox: sending
16
+ // the same edits again would fail the same way.
17
+ //
18
+ // The server merges idempotently, so an edit sent twice (a response lost
19
+ // after the server applied it) does no harm.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import { VersionVector, type LoroDoc } from "loro-crdt";
23
+
24
+ /** Send one update. Resolve when the server accepted it; reject otherwise.
25
+ * A rejection with a numeric `status` is treated as the HTTP status. */
26
+ export type OutboxSend = (update: Uint8Array, options: { keepalive: boolean }) => Promise<void>;
27
+
28
+ /**
29
+ * - `idle`: the server has every local edit.
30
+ * - `sending`: a request is in flight.
31
+ * - `retrying`: the last request failed and a retry is scheduled.
32
+ * - `rejected`: the server refused the edits; nothing more is sent.
33
+ */
34
+ export type OutboxState = "idle" | "sending" | "retrying" | "rejected";
35
+
36
+ export interface OutboxOptions {
37
+ /** First retry delay. Doubles per failure up to `retryMaxMs`. */
38
+ retryBaseMs?: number;
39
+ retryMaxMs?: number;
40
+ /** Timer functions, replaceable in tests. */
41
+ setTimer?: (fn: () => void, ms: number) => unknown;
42
+ clearTimer?: (handle: unknown) => void;
43
+ }
44
+
45
+ /** True for an error the same request could get past by trying again. */
46
+ export function isRetryable(err: unknown): boolean {
47
+ const status = (err as { status?: unknown } | null)?.status;
48
+ if (typeof status !== "number") return true; // network error, abort, timeout
49
+ return status >= 500 || status === 408 || status === 429;
50
+ }
51
+
52
+ export class CrdtOutbox {
53
+ private readonly doc: LoroDoc;
54
+ private readonly send: OutboxSend;
55
+ private readonly retryBaseMs: number;
56
+ private readonly retryMaxMs: number;
57
+ private readonly setTimer: (fn: () => void, ms: number) => unknown;
58
+ private readonly clearTimer: (handle: unknown) => void;
59
+ private readonly stopListening: () => void;
60
+ private readonly listeners = new Set<() => void>();
61
+
62
+ /** This peer's op counter the server has confirmed (exclusive end). */
63
+ private confirmed = 0;
64
+ private inFlight = false;
65
+ private retryHandle: unknown = null;
66
+ private failures = 0;
67
+ private disposed = false;
68
+ private _state: OutboxState = "idle";
69
+ private _error: unknown = null;
70
+
71
+ constructor(doc: LoroDoc, send: OutboxSend, options: OutboxOptions = {}) {
72
+ this.doc = doc;
73
+ this.send = send;
74
+ this.retryBaseMs = options.retryBaseMs ?? 500;
75
+ this.retryMaxMs = options.retryMaxMs ?? 10_000;
76
+ this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
77
+ this.clearTimer = options.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
78
+ this.stopListening = doc.subscribeLocalUpdates(() => this.schedule());
79
+ // Edits made before the outbox existed go out now.
80
+ this.schedule();
81
+ }
82
+
83
+ get state(): OutboxState {
84
+ return this._state;
85
+ }
86
+
87
+ /** The error that made the outbox `rejected` or `retrying`. */
88
+ get error(): unknown {
89
+ return this._error;
90
+ }
91
+
92
+ /** True while some local edit has not been confirmed by the server. */
93
+ hasUnsent(): boolean {
94
+ return this.ownCounter() > this.confirmed;
95
+ }
96
+
97
+ /** Call `listener` on every state change. Returns the unsubscribe. */
98
+ subscribe(listener: () => void): () => void {
99
+ this.listeners.add(listener);
100
+ return () => this.listeners.delete(listener);
101
+ }
102
+
103
+ /** Send unconfirmed edits now, unless a request is already in flight. */
104
+ schedule(): void {
105
+ if (this.disposed || this._state === "rejected") return;
106
+ if (this.inFlight || this.retryHandle !== null) return; // picked up when that settles
107
+ void this.flush(false);
108
+ }
109
+
110
+ /** Skip the backoff wait (the browser came back online). */
111
+ retryNow(): void {
112
+ if (this.retryHandle === null) return;
113
+ this.clearTimer(this.retryHandle);
114
+ this.retryHandle = null;
115
+ this.schedule();
116
+ }
117
+
118
+ /**
119
+ * Last chance before the page goes away: send what is unconfirmed with
120
+ * `keepalive`, so the browser finishes the request after the page is gone.
121
+ * Runs even while a request is in flight; the server merges duplicates.
122
+ */
123
+ flushOnHide(): void {
124
+ if (this.disposed || this._state === "rejected") return;
125
+ const update = this.unsent();
126
+ if (!update) return;
127
+ const target = this.ownCounter();
128
+ this.send(update, { keepalive: true }).then(
129
+ () => this.confirm(target),
130
+ () => {
131
+ /* the regular path retries if the page stays */
132
+ },
133
+ );
134
+ }
135
+
136
+ /** Stop listening and cancel a pending retry. Edits not sent are dropped. */
137
+ dispose(): void {
138
+ if (this.disposed) return;
139
+ this.disposed = true;
140
+ this.stopListening();
141
+ if (this.retryHandle !== null) {
142
+ this.clearTimer(this.retryHandle);
143
+ this.retryHandle = null;
144
+ }
145
+ this.listeners.clear();
146
+ }
147
+
148
+ private ownCounter(): number {
149
+ return this.doc.oplogVersion().get(this.doc.peerIdStr) ?? 0;
150
+ }
151
+
152
+ /** This peer's ops after `confirmed`, or null when there are none. */
153
+ private unsent(): Uint8Array | null {
154
+ if (!this.hasUnsent()) return null;
155
+ // Start from everything the doc has, then roll this peer back to what
156
+ // the server confirmed: the export is exactly this peer's new ops.
157
+ const from = this.doc.oplogVersion().toJSON();
158
+ if (this.confirmed > 0) from.set(this.doc.peerIdStr, this.confirmed);
159
+ else from.delete(this.doc.peerIdStr);
160
+ return this.doc.export({ mode: "update", from: VersionVector.parseJSON(from) });
161
+ }
162
+
163
+ private confirm(counter: number): void {
164
+ if (counter > this.confirmed) this.confirmed = counter;
165
+ }
166
+
167
+ private async flush(keepalive: boolean): Promise<void> {
168
+ const update = this.unsent();
169
+ if (!update) {
170
+ this.setState("idle", null);
171
+ return;
172
+ }
173
+ const target = this.ownCounter();
174
+ this.inFlight = true;
175
+ this.setState("sending", null);
176
+ try {
177
+ await this.send(update, { keepalive });
178
+ this.confirm(target);
179
+ this.failures = 0;
180
+ } catch (err) {
181
+ this.inFlight = false;
182
+ if (this.disposed) return;
183
+ if (!isRetryable(err)) {
184
+ this.setState("rejected", err);
185
+ return;
186
+ }
187
+ this.failures += 1;
188
+ const delay = Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** (this.failures - 1));
189
+ this.setState("retrying", err);
190
+ this.retryHandle = this.setTimer(() => {
191
+ this.retryHandle = null;
192
+ this.schedule();
193
+ }, delay);
194
+ return;
195
+ }
196
+ this.inFlight = false;
197
+ if (this.disposed) return;
198
+ if (this.hasUnsent()) void this.flush(false);
199
+ else this.setState("idle", null);
200
+ }
201
+
202
+ private setState(state: OutboxState, error: unknown): void {
203
+ if (this._state === state && this._error === error) return;
204
+ this._state = state;
205
+ this._error = error;
206
+ for (const listener of this.listeners) {
207
+ try {
208
+ listener();
209
+ } catch (err) {
210
+ console.warn("[loro] outbox listener threw:", err);
211
+ }
212
+ }
213
+ }
214
+ }
@@ -11,7 +11,7 @@ import {
11
11
  readProseMirror,
12
12
  waitForProseMirrorContainer,
13
13
  writeProseMirror,
14
- } from "./prosemirror";
14
+ } from "./index";
15
15
 
16
16
  // The basic schema plus a mark with an id, like a suggestion.
17
17
  const schema = new Schema({
@@ -0,0 +1,31 @@
1
+ /**
2
+ * React binding for a ProseMirror (Tiptap) field.
3
+ *
4
+ * const { doc, containerId } = useProseMirrorDoc("Doc", id, "body", schema);
5
+ * if (!containerId) return <Loading />;
6
+ * const plugins = [LoroSyncPlugin({ doc, containerId }), LoroUndoPlugin({ doc })];
7
+ *
8
+ * `containerId` is null until the server's snapshot brings the field's
9
+ * container; mount the editor after that (see ./index.ts for why a client
10
+ * never creates it). Edits the editor makes reach the server through the
11
+ * row's outbox, like every edit on a `useLoroDoc` doc.
12
+ */
13
+ import type { ContainerID, LoroDoc } from "loro-crdt";
14
+ import type { Schema } from "prosemirror-model";
15
+ import { useMemo } from "react";
16
+ import { useLoroDoc } from "../index";
17
+ import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
18
+
19
+ export function useProseMirrorDoc(
20
+ entity: string,
21
+ id: string,
22
+ field: string,
23
+ schema: Schema,
24
+ ): { doc: LoroDoc; containerId: ContainerID | null } {
25
+ const doc = useLoroDoc(entity, id);
26
+ // Before any binding writes a mark. Idempotent, so once per doc is enough.
27
+ useMemo(() => configureProseMirrorMarks(doc, schema), [doc, schema]);
28
+ // useLoroDoc re-renders on every applied frame, so this picks up the
29
+ // container as soon as the snapshot lands.
30
+ return { doc, containerId: findProseMirrorContainer(doc, field) };
31
+ }
File without changes