@pylonsync/loro 0.3.311 → 0.3.312
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 +3 -3
- package/src/contract.test.ts +82 -0
- package/src/index.ts +87 -17
- package/src/registry.test.ts +14 -1
- package/src/registry.ts +32 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/loro",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.312",
|
|
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"
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@pylonsync/react": "0.3.
|
|
21
|
-
"@pylonsync/sync": "0.3.
|
|
20
|
+
"@pylonsync/react": "0.3.312",
|
|
21
|
+
"@pylonsync/sync": "0.3.312",
|
|
22
22
|
"loro-crdt": "^1.10.6"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The client ↔ server doc-shape contract. The server (crates/crdt) puts
|
|
2
|
+
// every entity field inside ONE root LoroMap named "row" — a
|
|
3
|
+
// `crdt: "text"` field is a LoroText CHILD of that map, seeded at insert
|
|
4
|
+
// and projected back to SQL after every merge. The client accessors must
|
|
5
|
+
// resolve through that map: the old top-level `doc.getText(field)` put
|
|
6
|
+
// edits in a container the server never reads, so seeded content rendered
|
|
7
|
+
// empty in the editor and typed content never projected to the row.
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { LoroDoc, LoroText } from "loro-crdt";
|
|
10
|
+
import { getLoroText, getLoroMap } from "./index";
|
|
11
|
+
|
|
12
|
+
/** Build a doc the way the server's apply_patch does: root "row" map
|
|
13
|
+
* with a seeded Text child for the field. */
|
|
14
|
+
function serverSeededDoc(field: string, initial: string): LoroDoc {
|
|
15
|
+
const doc = new LoroDoc();
|
|
16
|
+
const row = doc.getMap("row");
|
|
17
|
+
const text = row.setContainer(field, new LoroText());
|
|
18
|
+
text.insert(0, initial);
|
|
19
|
+
doc.commit();
|
|
20
|
+
return doc;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("row-map contract", () => {
|
|
24
|
+
test("getLoroText resolves the server-seeded container (initial value visible)", () => {
|
|
25
|
+
const doc = serverSeededDoc("content", "# Welcome");
|
|
26
|
+
const text = getLoroText(doc, "content");
|
|
27
|
+
expect(text.toString()).toBe("# Welcome");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("edits land inside the row map where the server projects from", () => {
|
|
31
|
+
const doc = serverSeededDoc("content", "abc");
|
|
32
|
+
const text = getLoroText(doc, "content");
|
|
33
|
+
text.insert(3, "def");
|
|
34
|
+
doc.commit();
|
|
35
|
+
// The server reads doc.getMap("row").get(field) — the edit must be
|
|
36
|
+
// visible THERE, not in a top-level container.
|
|
37
|
+
const json = doc.toJSON() as { row?: { content?: string } };
|
|
38
|
+
expect(json.row?.content).toBe("abcdef");
|
|
39
|
+
// And nothing leaked into the legacy top-level namespace.
|
|
40
|
+
expect(doc.getText("content").toString()).toBe("");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("creates the container under row/ for rows that predate CRDT seeding", () => {
|
|
44
|
+
const doc = new LoroDoc();
|
|
45
|
+
const text = getLoroText(doc, "content");
|
|
46
|
+
text.insert(0, "x");
|
|
47
|
+
doc.commit();
|
|
48
|
+
const json = doc.toJSON() as { row?: { content?: string } };
|
|
49
|
+
expect(json.row?.content).toBe("x");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("edits after a catch-up import land in the winning container", () => {
|
|
53
|
+
// The race: a component creates the field container eagerly BEFORE the
|
|
54
|
+
// server's catch-up snapshot arrives; the merge then makes one of the
|
|
55
|
+
// two containers win the row-map key LWW. Edits must follow the WINNER
|
|
56
|
+
// (what row.content projects to), not a handle captured pre-merge —
|
|
57
|
+
// pre-fix, every keystroke went into the orphaned loser and the server
|
|
58
|
+
// never saw it.
|
|
59
|
+
const server = serverSeededDoc("content", "seeded");
|
|
60
|
+
const snapshot = server.export({ mode: "snapshot" });
|
|
61
|
+
|
|
62
|
+
const client = new LoroDoc();
|
|
63
|
+
getLoroText(client, "content").insert(0, "early");
|
|
64
|
+
client.commit();
|
|
65
|
+
client.import(snapshot);
|
|
66
|
+
|
|
67
|
+
const live = getLoroText(client, "content");
|
|
68
|
+
live.insert(live.length, "!");
|
|
69
|
+
client.commit();
|
|
70
|
+
const json = client.toJSON() as { row?: { content?: string } };
|
|
71
|
+
expect(json.row?.content?.endsWith("!")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("getLoroMap resolves a field-level map child of row/", () => {
|
|
75
|
+
const doc = new LoroDoc();
|
|
76
|
+
const m = getLoroMap(doc, "meta");
|
|
77
|
+
m.set("k", "v");
|
|
78
|
+
doc.commit();
|
|
79
|
+
const json = doc.toJSON() as { row?: { meta?: { k?: string } } };
|
|
80
|
+
expect(json.row?.meta?.k).toBe("v");
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
import { useSyncExternalStore, useEffect, useRef } from "react";
|
|
21
21
|
import type { FormEvent, RefObject } from "react";
|
|
22
22
|
import type { LoroDoc, LoroText, LoroMap } from "loro-crdt";
|
|
23
|
+
import {
|
|
24
|
+
LoroText as LoroTextCtor,
|
|
25
|
+
LoroMap as LoroMapCtor,
|
|
26
|
+
} from "loro-crdt";
|
|
23
27
|
import { db, getBaseUrl, getReactStorage, storageKey } from "@pylonsync/react";
|
|
24
28
|
import { pylonFetch, type SyncEngine } from "@pylonsync/sync";
|
|
25
29
|
import { globalRegistry, LoroRegistry } from "./registry";
|
|
@@ -117,30 +121,84 @@ export function useLoroDoc(entity: string, id: string): LoroDoc {
|
|
|
117
121
|
};
|
|
118
122
|
}, [entity, id]);
|
|
119
123
|
|
|
120
|
-
// useSyncExternalStore drives re-renders. The
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// when the
|
|
124
|
+
// useSyncExternalStore drives re-renders. The SNAPSHOT is the row's
|
|
125
|
+
// monotonic version counter, not the doc: the doc instance is
|
|
126
|
+
// intentionally stable across renders, and React bails out of
|
|
127
|
+
// re-rendering when the snapshot is Object.is-equal — a stable doc
|
|
128
|
+
// snapshot would swallow every notification. The version bumps on
|
|
129
|
+
// each applied frame AND on each local `touch()` after a commit, so
|
|
130
|
+
// consumers re-render optimistically on their own edits instead of
|
|
131
|
+
// waiting for the server's post-merge echo.
|
|
124
132
|
const subscribe = (notify: () => void) =>
|
|
125
133
|
globalRegistry.subscribe(entity, id, notify);
|
|
126
|
-
const
|
|
127
|
-
|
|
134
|
+
const getVersion = () => globalRegistry.version(entity, id);
|
|
135
|
+
useSyncExternalStore(subscribe, getVersion, getVersion);
|
|
136
|
+
return globalRegistry.doc(entity, id);
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
140
|
+
* The server's projection contract (crates/crdt): each entity row's doc
|
|
141
|
+
* holds ONE root `LoroMap` named "row"; its keys are the entity's field
|
|
142
|
+
* names, and a `crdt: "text"` field is a `LoroText` CHILD of that map.
|
|
143
|
+
* The server seeds those containers at insert and projects them back to
|
|
144
|
+
* SQL columns after every merge — a text container anywhere else in the
|
|
145
|
+
* doc is invisible to it. Every field accessor here must resolve through
|
|
146
|
+
* this map or client edits silently diverge from the server's view.
|
|
147
|
+
*/
|
|
148
|
+
const ROOT_MAP = "row";
|
|
149
|
+
|
|
150
|
+
function containerOfKind<C>(value: unknown, kind: string): C | null {
|
|
151
|
+
if (
|
|
152
|
+
value &&
|
|
153
|
+
typeof value === "object" &&
|
|
154
|
+
typeof (value as { kind?: unknown }).kind === "function" &&
|
|
155
|
+
(value as { kind: () => string }).kind() === kind
|
|
156
|
+
) {
|
|
157
|
+
return value as C;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read-only resolve of the field's `LoroText` under the `"row"` root
|
|
164
|
+
* map. Returns null when the container doesn't exist yet — crucially it
|
|
165
|
+
* NEVER creates one: a read-path create races the server's catch-up
|
|
166
|
+
* snapshot, and when the server's seeded container wins the map-key LWW
|
|
167
|
+
* the eagerly-created one is orphaned — every edit made through a
|
|
168
|
+
* captured handle then mutates a container nothing projects or renders.
|
|
169
|
+
*/
|
|
170
|
+
export function resolveLoroText(doc: LoroDoc, key: string): LoroText | null {
|
|
171
|
+
return containerOfKind<LoroText>(doc.getMap(ROOT_MAP).get(key), "Text");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get the `LoroText` for an entity FIELD, resolving through the server's
|
|
176
|
+
* `"row"` root map. The server seeds the container at insert, so the
|
|
177
|
+
* common case finds it (carrying the row's initial value); creation only
|
|
178
|
+
* happens on the WRITE path for rows that predate CRDT mode. Callers
|
|
179
|
+
* must re-resolve per operation rather than capture the handle — after a
|
|
180
|
+
* catch-up snapshot merges, the live container can be a different
|
|
181
|
+
* instance than the one an earlier render saw. Identified by `kind()`
|
|
182
|
+
* rather than instanceof so a duplicated loro-crdt module in the bundle
|
|
183
|
+
* can't break the check.
|
|
134
184
|
*/
|
|
135
185
|
export function getLoroText(doc: LoroDoc, key: string): LoroText {
|
|
136
|
-
|
|
186
|
+
const existing = resolveLoroText(doc, key);
|
|
187
|
+
if (existing) return existing;
|
|
188
|
+
return doc
|
|
189
|
+
.getMap(ROOT_MAP)
|
|
190
|
+
.setContainer(key, new LoroTextCtor()) as LoroText;
|
|
137
191
|
}
|
|
138
192
|
|
|
139
193
|
/**
|
|
140
|
-
*
|
|
194
|
+
* Get the `LoroMap` for an entity FIELD (a map child of the `"row"`
|
|
195
|
+
* root). For the root map itself use `doc.getMap("row")` directly.
|
|
141
196
|
*/
|
|
142
197
|
export function getLoroMap(doc: LoroDoc, key: string): LoroMap {
|
|
143
|
-
|
|
198
|
+
const row = doc.getMap(ROOT_MAP);
|
|
199
|
+
const existing = containerOfKind<LoroMap>(row.get(key), "Map");
|
|
200
|
+
if (existing) return existing;
|
|
201
|
+
return row.setContainer(key, new LoroMapCtor()) as LoroMap;
|
|
144
202
|
}
|
|
145
203
|
|
|
146
204
|
/**
|
|
@@ -172,9 +230,11 @@ export function useCollabText(
|
|
|
172
230
|
field: string,
|
|
173
231
|
): [string, (next: string) => void] {
|
|
174
232
|
const doc = useLoroDoc(entity, id);
|
|
175
|
-
|
|
176
|
-
|
|
233
|
+
// Re-resolved every render/operation, never captured: the live
|
|
234
|
+
// container can change identity when the catch-up snapshot merges.
|
|
235
|
+
const value = resolveLoroText(doc, field)?.toString() ?? "";
|
|
177
236
|
const setValue = (next: string): void => {
|
|
237
|
+
const text = getLoroText(doc, field);
|
|
178
238
|
// Capture the version vector BEFORE the mutation so we can ship
|
|
179
239
|
// exactly the new ops to the server (incremental delta, not the
|
|
180
240
|
// whole snapshot). Loro's `export({mode: "update", from: vv})`
|
|
@@ -188,6 +248,8 @@ export function useCollabText(
|
|
|
188
248
|
text.insert(0, next);
|
|
189
249
|
}
|
|
190
250
|
doc.commit();
|
|
251
|
+
// Optimistic render: notify every consumer of this row NOW.
|
|
252
|
+
globalRegistry.touch(entity, id);
|
|
191
253
|
|
|
192
254
|
const update = doc.export({ mode: "update", from: beforeVv });
|
|
193
255
|
if (update.length === 0) {
|
|
@@ -243,8 +305,10 @@ export function useCollabTextarea<
|
|
|
243
305
|
onInput: (e: FormEvent<T>) => void;
|
|
244
306
|
} {
|
|
245
307
|
const doc = useLoroDoc(entity, id);
|
|
246
|
-
|
|
247
|
-
|
|
308
|
+
// Re-resolved every render, never captured: the live container can
|
|
309
|
+
// change identity when the catch-up snapshot merges (see
|
|
310
|
+
// resolveLoroText), and a stale handle would edit an orphan.
|
|
311
|
+
const value = resolveLoroText(doc, field)?.toString() ?? ""; // re-renders on every applied frame
|
|
248
312
|
const ref = useRef<T | null>(null);
|
|
249
313
|
// The value we last reconciled into the DOM element. Lets us tell our own
|
|
250
314
|
// edits (element already correct — skip) from remote frames (patch the DOM).
|
|
@@ -279,12 +343,18 @@ export function useCollabTextarea<
|
|
|
279
343
|
const prev = domValue.current;
|
|
280
344
|
if (next === prev) return;
|
|
281
345
|
const { index, deleteCount, insert } = spliceDiff(prev, next);
|
|
282
|
-
//
|
|
346
|
+
// Resolve the container per keystroke (see resolveLoroText) and
|
|
347
|
+
// capture the version vector BEFORE mutating so we ship exactly the
|
|
348
|
+
// new ops.
|
|
349
|
+
const text = getLoroText(doc, field);
|
|
283
350
|
const beforeVv = doc.oplogVersion();
|
|
284
351
|
if (deleteCount > 0) text.delete(index, deleteCount);
|
|
285
352
|
if (insert.length > 0) text.insert(index, insert);
|
|
286
353
|
doc.commit();
|
|
287
354
|
domValue.current = next;
|
|
355
|
+
// Optimistic render: the preview (and any other consumer of this
|
|
356
|
+
// row) updates on the local commit, not on the server echo.
|
|
357
|
+
globalRegistry.touch(entity, id);
|
|
288
358
|
const update = doc.export({ mode: "update", from: beforeVv });
|
|
289
359
|
if (update.length > 0) void pushCrdtUpdate(entity, id, update);
|
|
290
360
|
};
|
package/src/registry.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
|
|
6
6
|
import { test, expect } from "bun:test";
|
|
7
|
-
import { LoroDoc } from "loro-crdt";
|
|
7
|
+
import { LoroDoc, LoroText } from "loro-crdt";
|
|
8
8
|
import { LoroRegistry } from "./registry";
|
|
9
9
|
import { CRDT_FRAME_SNAPSHOT, encodeCrdtFrame } from "./wire";
|
|
10
10
|
|
|
@@ -150,3 +150,16 @@ test("two registries hydrated from snapshots converge after exchange", () => {
|
|
|
150
150
|
expect(aText).toBe(bText);
|
|
151
151
|
expect(aText.length).toBeGreaterThan(0);
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
test("touch bumps the version and notifies — optimistic local renders", () => {
|
|
155
|
+
const reg = new LoroRegistry();
|
|
156
|
+
const doc = reg.doc("Doc", "r1");
|
|
157
|
+
let notified = 0;
|
|
158
|
+
reg.subscribe("Doc", "r1", () => notified++);
|
|
159
|
+
const before = reg.version("Doc", "r1");
|
|
160
|
+
doc.getMap("row").setContainer("content", new LoroText()).insert(0, "x");
|
|
161
|
+
doc.commit();
|
|
162
|
+
reg.touch("Doc", "r1");
|
|
163
|
+
expect(reg.version("Doc", "r1")).toBe(before + 1);
|
|
164
|
+
expect(notified).toBe(1);
|
|
165
|
+
});
|
package/src/registry.ts
CHANGED
|
@@ -31,6 +31,12 @@ type Listener = () => void;
|
|
|
31
31
|
interface DocEntry {
|
|
32
32
|
doc: LoroDoc;
|
|
33
33
|
listeners: Set<Listener>;
|
|
34
|
+
/** Bumped on every applied frame, local touch, and eviction. Hooks
|
|
35
|
+
* use this as their useSyncExternalStore snapshot: the doc instance
|
|
36
|
+
* is intentionally stable across renders, and a stable snapshot
|
|
37
|
+
* makes React bail out of re-rendering — so notifications must be
|
|
38
|
+
* accompanied by a value that actually changes. */
|
|
39
|
+
version: number;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
export class LoroRegistry {
|
|
@@ -82,6 +88,7 @@ export class LoroRegistry {
|
|
|
82
88
|
);
|
|
83
89
|
return false;
|
|
84
90
|
}
|
|
91
|
+
entry.version += 1;
|
|
85
92
|
for (const listener of entry.listeners) {
|
|
86
93
|
try {
|
|
87
94
|
listener();
|
|
@@ -92,6 +99,29 @@ export class LoroRegistry {
|
|
|
92
99
|
return true;
|
|
93
100
|
}
|
|
94
101
|
|
|
102
|
+
/** Monotonic per-row change counter — the render snapshot for hooks.
|
|
103
|
+
* Zero for rows the registry hasn't seen. */
|
|
104
|
+
version(entity: string, rowId: string): number {
|
|
105
|
+
return this.docs.get(this.key(entity, rowId))?.version ?? 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Record a LOCAL mutation on a row's doc (the hooks call this right
|
|
109
|
+
* after `doc.commit()`): bumps the version and notifies subscribers
|
|
110
|
+
* so every component reading the row re-renders NOW, optimistically
|
|
111
|
+
* — not when the server's post-merge broadcast echoes back. */
|
|
112
|
+
touch(entity: string, rowId: string): void {
|
|
113
|
+
const entry = this.docs.get(this.key(entity, rowId));
|
|
114
|
+
if (!entry) return;
|
|
115
|
+
entry.version += 1;
|
|
116
|
+
for (const listener of entry.listeners) {
|
|
117
|
+
try {
|
|
118
|
+
listener();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.warn("[loro] listener threw:", err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
95
125
|
/** Drop the cached doc for a row. Tests + the eventual eviction
|
|
96
126
|
* policy. Subscribers receive a final notify before the entry
|
|
97
127
|
* is removed so they can detect the drop and re-create their
|
|
@@ -100,6 +130,7 @@ export class LoroRegistry {
|
|
|
100
130
|
const key = this.key(entity, rowId);
|
|
101
131
|
const entry = this.docs.get(key);
|
|
102
132
|
if (!entry) return;
|
|
133
|
+
entry.version += 1;
|
|
103
134
|
for (const listener of entry.listeners) {
|
|
104
135
|
try {
|
|
105
136
|
listener();
|
|
@@ -119,7 +150,7 @@ export class LoroRegistry {
|
|
|
119
150
|
const key = this.key(entity, rowId);
|
|
120
151
|
let entry = this.docs.get(key);
|
|
121
152
|
if (!entry) {
|
|
122
|
-
entry = { doc: new LoroDoc(), listeners: new Set() };
|
|
153
|
+
entry = { doc: new LoroDoc(), listeners: new Set(), version: 0 };
|
|
123
154
|
this.docs.set(key, entry);
|
|
124
155
|
}
|
|
125
156
|
return entry;
|