bireactive 0.3.3 → 0.3.4
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.
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { DocHandle } from "@automerge/automerge-repo";
|
|
2
2
|
import { type Cell, type Writable } from "../core/cell.js";
|
|
3
3
|
import { type Store } from "../core/store.js";
|
|
4
|
-
import { type By } from "./reconcile.js";
|
|
4
|
+
import { type By, type Replace } from "./reconcile.js";
|
|
5
5
|
/** Bridge options shared by every `connect*` entry. */
|
|
6
6
|
export interface DocOptions {
|
|
7
7
|
/** Identity key for list elements, enabling keyed reconciliation (see `reconcile`). */
|
|
8
8
|
by?: By;
|
|
9
|
+
/** Keys whose values are written wholesale (a `put`) rather than deep-merged —
|
|
10
|
+
* for opaque blobs a downstream bridge can only apply as whole-object puts
|
|
11
|
+
* (see `reconcile`'s `Replace`). */
|
|
12
|
+
replace?: Replace;
|
|
9
13
|
}
|
|
10
14
|
/** Lifecycle shared by every bridge: retarget the doc, detach both directions. */
|
|
11
15
|
export interface DocLifecycle<T> {
|
|
@@ -39,14 +39,14 @@ function deepEqual(a, b) {
|
|
|
39
39
|
return true;
|
|
40
40
|
}
|
|
41
41
|
/** Wire an existing cell to a handle in both directions; returns an unbind. */
|
|
42
|
-
function bind(c, handle, by) {
|
|
42
|
+
function bind(c, handle, by, replace) {
|
|
43
43
|
const onChange = () => {
|
|
44
44
|
c.value = structuredClone(handle.doc());
|
|
45
45
|
};
|
|
46
46
|
handle.on("change", onChange);
|
|
47
47
|
const stop = effect(() => {
|
|
48
48
|
const next = c.value;
|
|
49
|
-
handle.change((d) => reconcile(d, next, by));
|
|
49
|
+
handle.change((d) => reconcile(d, next, by, replace));
|
|
50
50
|
});
|
|
51
51
|
return () => {
|
|
52
52
|
stop();
|
|
@@ -56,8 +56,9 @@ function bind(c, handle, by) {
|
|
|
56
56
|
/** Core: a doc-backed cell plus lifecycle. The cell projections layer on top. */
|
|
57
57
|
function connect(handle, opts) {
|
|
58
58
|
const by = opts?.by;
|
|
59
|
+
const replace = opts?.replace;
|
|
59
60
|
const c = cell(structuredClone(handle.doc()), { equals: deepEqual, name: "doc" });
|
|
60
|
-
let unbind = bind(c, handle, by);
|
|
61
|
+
let unbind = bind(c, handle, by, replace);
|
|
61
62
|
return {
|
|
62
63
|
cell: c,
|
|
63
64
|
retarget: next => {
|
|
@@ -65,7 +66,7 @@ function connect(handle, opts) {
|
|
|
65
66
|
// Seed the cell from the new doc *before* re-binding, so the cell→doc
|
|
66
67
|
// effect doesn't push the old value into the freshly targeted doc.
|
|
67
68
|
c.value = structuredClone(next.doc());
|
|
68
|
-
unbind = bind(c, next, by);
|
|
69
|
+
unbind = bind(c, next, by, replace);
|
|
69
70
|
},
|
|
70
71
|
dispose: () => unbind(),
|
|
71
72
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export type { CellBridge, DocBridge, DocLifecycle, DocOptions, StoreBridge } from "./doc-cell.js";
|
|
2
2
|
export { connectCell, connectDoc, connectStore } from "./doc-cell.js";
|
|
3
|
-
export type { By } from "./reconcile.js";
|
|
3
|
+
export type { By, Replace } from "./reconcile.js";
|
|
4
4
|
export { reconcile } from "./reconcile.js";
|
|
@@ -2,7 +2,16 @@ type Any = any;
|
|
|
2
2
|
/** Stable identity key for a list element; return a primitive. `undefined` (or a
|
|
3
3
|
* collision) on any element makes that list fall back to positional. */
|
|
4
4
|
export type By = (element: unknown) => unknown;
|
|
5
|
+
/** Predicate over an object key (or list index): return `true` to *replace* that
|
|
6
|
+
* field's value wholesale (a scalar assignment → an Automerge `put`) instead of
|
|
7
|
+
* recursively merging into it. Use this for opaque JSON blobs that a downstream
|
|
8
|
+
* bridge can only consume as whole-object puts — e.g. tldraw's `richText`, whose
|
|
9
|
+
* patch applier rejects nested text `splice`s and mis-reads nested `del`s. The
|
|
10
|
+
* value is still only written when it actually differs, so unrelated commits
|
|
11
|
+
* don't churn it. */
|
|
12
|
+
export type Replace = (key: string | number) => boolean;
|
|
5
13
|
/** Minimally mutate the Automerge node `target` (inside `handle.change`) to equal
|
|
6
|
-
* the plain value `next`. Pass `by` for identity-keyed list reconciliation
|
|
7
|
-
|
|
14
|
+
* the plain value `next`. Pass `by` for identity-keyed list reconciliation, and
|
|
15
|
+
* `replace` to assign chosen keys wholesale instead of merging into them. */
|
|
16
|
+
export declare function reconcile(target: Any, next: Any, by?: By, replace?: Replace): void;
|
|
8
17
|
export {};
|
|
@@ -17,27 +17,63 @@
|
|
|
17
17
|
// splices/inserts for the rest, so reorders and mid-inserts merge cleanly.
|
|
18
18
|
import { updateText } from "@automerge/automerge-repo";
|
|
19
19
|
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
20
|
+
/** Structural equality, used to skip a wholesale `replace` write when the field
|
|
21
|
+
* is already deep-equal (so reconciling an unrelated change doesn't rewrite it).
|
|
22
|
+
* Note `a` may be a live Automerge proxy: its *lists* don't expose indices via
|
|
23
|
+
* `Object.keys`, so arrays are walked by length/index, not key enumeration. */
|
|
24
|
+
function deepEq(a, b) {
|
|
25
|
+
if (Object.is(a, b))
|
|
26
|
+
return true;
|
|
27
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object")
|
|
28
|
+
return false;
|
|
29
|
+
const aArr = Array.isArray(a);
|
|
30
|
+
if (aArr !== Array.isArray(b))
|
|
31
|
+
return false;
|
|
32
|
+
if (aArr) {
|
|
33
|
+
const av = a;
|
|
34
|
+
const bv = b;
|
|
35
|
+
if (av.length !== bv.length)
|
|
36
|
+
return false;
|
|
37
|
+
for (let i = 0; i < av.length; i++)
|
|
38
|
+
if (!deepEq(av[i], bv[i]))
|
|
39
|
+
return false;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
const ak = Object.keys(a);
|
|
43
|
+
const bk = Object.keys(b);
|
|
44
|
+
if (ak.length !== bk.length)
|
|
45
|
+
return false;
|
|
46
|
+
for (const k of ak) {
|
|
47
|
+
if (!Object.hasOwn(b, k))
|
|
48
|
+
return false;
|
|
49
|
+
if (!deepEq(a[k], b[k]))
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
20
54
|
/** Minimally mutate the Automerge node `target` (inside `handle.change`) to equal
|
|
21
|
-
* the plain value `next`. Pass `by` for identity-keyed list reconciliation
|
|
22
|
-
|
|
55
|
+
* the plain value `next`. Pass `by` for identity-keyed list reconciliation, and
|
|
56
|
+
* `replace` to assign chosen keys wholesale instead of merging into them. */
|
|
57
|
+
export function reconcile(target, next, by, replace) {
|
|
58
|
+
const ctx = { by, replace };
|
|
23
59
|
if (Array.isArray(next) && Array.isArray(target))
|
|
24
|
-
reconcileList(target, next,
|
|
60
|
+
reconcileList(target, next, ctx);
|
|
25
61
|
else
|
|
26
|
-
reconcileObject(target, next,
|
|
62
|
+
reconcileObject(target, next, ctx);
|
|
27
63
|
}
|
|
28
|
-
function reconcileObject(target, next,
|
|
64
|
+
function reconcileObject(target, next, ctx) {
|
|
29
65
|
for (const k of Object.keys(target))
|
|
30
66
|
if (!(k in next))
|
|
31
67
|
delete target[k];
|
|
32
68
|
for (const k of Object.keys(next))
|
|
33
|
-
setKey(target, k, target[k], next[k], false,
|
|
69
|
+
setKey(target, k, target[k], next[k], false, ctx);
|
|
34
70
|
}
|
|
35
|
-
function reconcileList(target, next,
|
|
36
|
-
if (by !== undefined && reconcileKeyed(target, next,
|
|
71
|
+
function reconcileList(target, next, ctx) {
|
|
72
|
+
if (ctx.by !== undefined && reconcileKeyed(target, next, ctx))
|
|
37
73
|
return;
|
|
38
74
|
const shared = Math.min(target.length, next.length);
|
|
39
75
|
for (let i = 0; i < shared; i++)
|
|
40
|
-
setKey(target, i, target[i], next[i], true,
|
|
76
|
+
setKey(target, i, target[i], next[i], true, ctx);
|
|
41
77
|
if (next.length < target.length)
|
|
42
78
|
target.splice(next.length);
|
|
43
79
|
else
|
|
@@ -46,7 +82,8 @@ function reconcileList(target, next, by) {
|
|
|
46
82
|
}
|
|
47
83
|
/** Keyed list reconcile via LCS. Returns false (→ positional fallback) when keys
|
|
48
84
|
* aren't total + unique on either side. */
|
|
49
|
-
function reconcileKeyed(target, next,
|
|
85
|
+
function reconcileKeyed(target, next, ctx) {
|
|
86
|
+
const by = ctx.by;
|
|
50
87
|
const tKeys = target.map(by);
|
|
51
88
|
const nKeys = next.map(by);
|
|
52
89
|
if (!totalUnique(tKeys) || !totalUnique(nKeys))
|
|
@@ -57,7 +94,7 @@ function reconcileKeyed(target, next, by) {
|
|
|
57
94
|
if (keep.has(nKeys[n])) {
|
|
58
95
|
while (i < target.length && !keep.has(by(target[i])))
|
|
59
96
|
target.splice(i, 1);
|
|
60
|
-
setKey(target, i, target[i], next[n], true,
|
|
97
|
+
setKey(target, i, target[i], next[n], true, ctx); // same identity → merge edits
|
|
61
98
|
i++;
|
|
62
99
|
}
|
|
63
100
|
else {
|
|
@@ -98,7 +135,16 @@ function lcs(a, b) {
|
|
|
98
135
|
}
|
|
99
136
|
return keep;
|
|
100
137
|
}
|
|
101
|
-
function setKey(parent, key, a, b, inList,
|
|
138
|
+
function setKey(parent, key, a, b, inList, ctx) {
|
|
139
|
+
// Wholesale-replace keys bypass the merge entirely: assign the value so
|
|
140
|
+
// Automerge emits a single `put` of the (re)built subtree — no nested text
|
|
141
|
+
// `splice`s, no `del`s — which is all some bridges can apply. Guard on
|
|
142
|
+
// deep-equality so reconciling an unrelated change doesn't rewrite it.
|
|
143
|
+
if (ctx.replace?.(key)) {
|
|
144
|
+
if (!deepEq(a, b))
|
|
145
|
+
parent[key] = b;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
102
148
|
if (typeof b === "string" && typeof a === "string") {
|
|
103
149
|
// Char-level merge for object text fields; list string elements just assign
|
|
104
150
|
// (path-relative updateText targets a keyed field, not an array slot).
|
|
@@ -110,10 +156,10 @@ function setKey(parent, key, a, b, inList, by) {
|
|
|
110
156
|
}
|
|
111
157
|
}
|
|
112
158
|
else if (Array.isArray(b) && Array.isArray(a)) {
|
|
113
|
-
reconcileList(a, b,
|
|
159
|
+
reconcileList(a, b, ctx);
|
|
114
160
|
}
|
|
115
161
|
else if (isPlainObject(b) && isPlainObject(a)) {
|
|
116
|
-
reconcileObject(a, b,
|
|
162
|
+
reconcileObject(a, b, ctx);
|
|
117
163
|
}
|
|
118
164
|
else if (a !== b) {
|
|
119
165
|
parent[key] = b;
|