@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/loro",
3
- "version": "0.3.311",
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.311",
21
- "@pylonsync/sync": "0.3.311",
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 snapshot is the doc
121
- // itself (referentially stable across calls — same instance from
122
- // the registry), so React's bail-out keeps re-renders bounded to
123
- // when the registry's listener actually fires.
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 getSnapshot = () => globalRegistry.doc(entity, id);
127
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
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
- * Convenience: get a `LoroText` container at the given top-level key,
132
- * creating it if absent. Wraps `doc.getText(key)` so callers don't
133
- * have to import loro-crdt directly for the common case.
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
- return doc.getText(key);
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
- * Convenience: get a `LoroMap` container at the given top-level key.
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
- return doc.getMap(key);
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
- const text = doc.getText(field);
176
- const value = text.toString();
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
- const text = doc.getText(field);
247
- const value = text.toString(); // re-renders on every applied frame
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
- // Capture the version vector BEFORE mutating so we ship exactly the new ops.
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
  };
@@ -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;