lib0 1.0.0-rc.21 → 1.0.0-rc.23

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.
Files changed (62) hide show
  1. package/dist/array.d.ts +4 -4
  2. package/dist/broadcastchannel.d.ts +3 -3
  3. package/dist/decoding.d.ts +3 -3
  4. package/dist/delta/delta.d.ts +0 -8
  5. package/dist/delta/rdt/dom.d.ts +19 -25
  6. package/dist/delta/rdt.d.ts +24 -0
  7. package/dist/diff.d.ts +1 -1
  8. package/dist/encoding.d.ts +4 -4
  9. package/dist/environment.common.d.ts +21 -0
  10. package/dist/environment.d.ts +2 -8
  11. package/dist/error.d.ts +21 -3
  12. package/dist/eventloop.d.ts +1 -1
  13. package/dist/indexeddb.d.ts +3 -3
  14. package/dist/indexeddbV2.d.ts +3 -3
  15. package/dist/iterator.d.ts +3 -3
  16. package/dist/list.d.ts +6 -6
  17. package/dist/logging.common.d.ts +1 -1
  18. package/dist/logging.d.ts +1 -1
  19. package/dist/logging.node.d.ts +1 -1
  20. package/dist/map.d.ts +4 -4
  21. package/dist/object.d.ts +2 -2
  22. package/dist/observable.d.ts +2 -2
  23. package/dist/pair.d.ts +2 -2
  24. package/dist/pledge.d.ts +10 -10
  25. package/dist/promise.d.ts +2 -2
  26. package/dist/schema.d.ts +19 -1
  27. package/dist/sort.d.ts +3 -3
  28. package/dist/storage.d.ts +2 -2
  29. package/dist/testing.d.ts +18 -11
  30. package/package.json +3 -3
  31. package/src/array.js +5 -5
  32. package/src/broadcastchannel.js +4 -4
  33. package/src/cache.js +1 -1
  34. package/src/decoding.js +2 -2
  35. package/src/delta/delta.js +13 -26
  36. package/src/delta/rdt/dom.js +176 -63
  37. package/src/delta/rdt.js +39 -10
  38. package/src/diff.js +1 -1
  39. package/src/encoding.js +2 -2
  40. package/src/environment.common.js +41 -0
  41. package/src/environment.js +2 -21
  42. package/src/error.js +10 -4
  43. package/src/eventloop.js +3 -3
  44. package/src/function.js +1 -1
  45. package/src/indexeddb.js +4 -4
  46. package/src/indexeddbV2.js +4 -4
  47. package/src/iterator.js +4 -4
  48. package/src/list.js +4 -4
  49. package/src/logging.common.js +3 -3
  50. package/src/logging.js +3 -3
  51. package/src/logging.node.js +3 -3
  52. package/src/map.js +4 -4
  53. package/src/mutex.js +2 -2
  54. package/src/object.js +2 -2
  55. package/src/observable.js +1 -1
  56. package/src/pair.js +2 -2
  57. package/src/pledge.js +6 -6
  58. package/src/promise.js +3 -3
  59. package/src/schema.js +316 -3
  60. package/src/sort.js +4 -4
  61. package/src/storage.js +8 -4
  62. package/src/testing.js +15 -15
package/dist/testing.d.ts CHANGED
@@ -36,26 +36,33 @@ export class TestCase {
36
36
  get prng(): prng.PRNG;
37
37
  }
38
38
  export const repetitionTime: number;
39
- export function run(moduleName: string, name: string, f: (arg0: TestCase) => void | Promise<any>, i: number, numberOfTests: number): Promise<boolean>;
39
+ export function run(moduleName: string, name: string, f: (tc: TestCase) => void | Promise<any>, i: number, numberOfTests: number): Promise<boolean>;
40
40
  export function describe(description: string, info?: string): void;
41
41
  export function info(info: string): void;
42
42
  export const printDom: (_createNode: () => Node) => void;
43
43
  export const printCanvas: (canvas: HTMLCanvasElement, height: number) => void;
44
- export function group(description: string, f: (...args: any[]) => void): void;
45
- export function groupAsync(description: string, f: (...args: any[]) => Promise<any>): Promise<void>;
46
- export function measureTime(message: string, f: (...args: any[]) => void): number;
47
- export function measureTimeAsync(message: string, f: (...args: any[]) => Promise<any>): Promise<number>;
44
+ export function group(description: string, f: (...args: Array<any>) => void): void;
45
+ export function groupAsync(description: string, f: (...args: Array<any>) => Promise<any>): Promise<void>;
46
+ export function measureTime(message: string, f: (...args: Array<any>) => void): number;
47
+ export function measureTimeAsync(message: string, f: (...args: Array<any>) => Promise<any>): Promise<number>;
48
48
  export function compareArrays<T>(as: Array<T>, bs: Array<T>, m?: string): boolean;
49
49
  export function compareStrings(a: string, b: string, m?: string): void;
50
50
  export function compareObjects<K, V>(a: any, b: any, m?: string): void;
51
- export function compare<T>(a: T, b: T, message?: string | null, customCompare?: (arg0: any, arg1: T, arg2: T, arg3: string, arg4: any) => boolean): boolean;
52
- export function assert<T>(property: T, message?: string | null): asserts property is NonNullable<T>;
53
- export function promiseRejected(f: (...args: any[]) => Promise<any>): Promise<void>;
54
- export function fails(f: (...args: any[]) => void): void;
55
- export function failsAsync(f: (...args: any[]) => Promise<any>): Promise<undefined>;
51
+ export function compare<T>(a: T, b: T, message?: string | null, customCompare?: (constructor: any, a: T, b: T, path: string, compareValues: any) => boolean): boolean;
52
+ /**
53
+ * `@type` (not `@param`/`@return`) is required — an assertion signature only narrows if the
54
+ * callee is declared with an explicit type annotation.
55
+ *
56
+ * @throws {TestError}
57
+ * @type {<T>(property: T, message?: string|null) => asserts property is NonNullable<T>}
58
+ */
59
+ export const assert: <T>(property: T, message?: string | null) => asserts property is NonNullable<T>;
60
+ export function promiseRejected(f: (...args: Array<any>) => Promise<any>): Promise<void>;
61
+ export function fails(f: (...args: Array<any>) => void): void;
62
+ export function failsAsync(f: (...args: Array<any>) => Promise<any>): Promise<undefined>;
56
63
  export function runTests(tests: {
57
64
  [x: string]: {
58
- [x: string]: (arg0: TestCase) => any | Promise<any>;
65
+ [x: string]: (tc: TestCase) => any | Promise<any>;
59
66
  };
60
67
  }): Promise<boolean>;
61
68
  export function fail(reason: string): never;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lib0",
3
- "version": "1.0.0-rc.21",
3
+ "version": "1.0.0-rc.23",
4
4
  "description": "",
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -332,9 +332,9 @@
332
332
  },
333
333
  "devDependencies": {
334
334
  "@types/node": "^26.0.0",
335
- "c8": "^11.0.0",
335
+ "c8": "^12.0.0",
336
336
  "dpdm": "^4.0.1",
337
- "jsdom": "^29.1.1",
337
+ "jsdom": "^30.0.1",
338
338
  "standard": "^17.1.0",
339
339
  "typescript": "^6.0.3"
340
340
  },
package/src/array.js CHANGED
@@ -108,7 +108,7 @@ export const flatten = arr => fold(arr, /** @type {Array<ELEM>} */ ([]), (acc, v
108
108
  /**
109
109
  * @template T
110
110
  * @param {number} len
111
- * @param {function(number, Array<T>):T} f
111
+ * @param {(index: number, arr: Array<T>) => T} f
112
112
  * @return {Array<T>}
113
113
  */
114
114
  export const unfold = (len, f) => {
@@ -124,7 +124,7 @@ export const unfold = (len, f) => {
124
124
  * @template RESULT
125
125
  * @param {Array<T>} arr
126
126
  * @param {RESULT} seed
127
- * @param {function(RESULT, T, number):RESULT} folder
127
+ * @param {(acc: RESULT, val: T, index: number) => RESULT} folder
128
128
  */
129
129
  export const fold = (arr, seed, folder) => arr.reduce(folder, seed)
130
130
 
@@ -145,7 +145,7 @@ export const unique = arr => from(set.from(arr))
145
145
  * @template T
146
146
  * @template M
147
147
  * @param {ArrayLike<T>} arr
148
- * @param {function(T):M} mapper
148
+ * @param {(val: T) => M} mapper
149
149
  * @return {Array<T>}
150
150
  */
151
151
  export const uniqueBy = (arr, mapper) => {
@@ -170,10 +170,10 @@ export const uniqueBy = (arr, mapper) => {
170
170
 
171
171
  /**
172
172
  * @template {ArrayLike<any>} ARR
173
- * @template {function(ARR extends ArrayLike<infer T> ? T : never, number, ARR):any} MAPPER
173
+ * @template {(val: ARR extends ArrayLike<infer T> ? T : never, index: number, arr: ARR) => any} MAPPER
174
174
  * @param {ARR} arr
175
175
  * @param {MAPPER} mapper
176
- * @return {Array<MAPPER extends function(...any): infer M ? M : never>}
176
+ * @return {Array<MAPPER extends ((...args: Array<any>) => infer M) ? M : never>}
177
177
  */
178
178
  export const map = (arr, mapper) => {
179
179
  /**
@@ -24,7 +24,7 @@ import * as storage from './storage.js'
24
24
 
25
25
  /**
26
26
  * @typedef {Object} Channel
27
- * @property {Set<function(any, any):any>} Channel.subs
27
+ * @property {Set<(data: any, origin: any) => any>} Channel.subs
28
28
  * @property {any} Channel.bc
29
29
  */
30
30
 
@@ -41,7 +41,7 @@ class LocalStoragePolyfill {
41
41
  constructor (room) {
42
42
  this.room = room
43
43
  /**
44
- * @type {null|function({data:Uint8Array}):void}
44
+ * @type {null|((e: {data:Uint8Array}) => void)}
45
45
  */
46
46
  this.onmessage = null
47
47
  /**
@@ -96,7 +96,7 @@ const getChannel = room =>
96
96
  *
97
97
  * @function
98
98
  * @param {string} room
99
- * @param {function(any, any):any} f
99
+ * @param {(data: any, origin: any) => any} f
100
100
  */
101
101
  export const subscribe = (room, f) => {
102
102
  getChannel(room).subs.add(f)
@@ -108,7 +108,7 @@ export const subscribe = (room, f) => {
108
108
  *
109
109
  * @function
110
110
  * @param {string} room
111
- * @param {function(any, any):any} f
111
+ * @param {(data: any, origin: any) => any} f
112
112
  */
113
113
  export const unsubscribe = (room, f) => {
114
114
  const channel = getChannel(room)
package/src/cache.js CHANGED
@@ -173,7 +173,7 @@ export const remove = (cache, key) => {
173
173
  *
174
174
  * @param {Cache<K, V>} cache
175
175
  * @param {K} key
176
- * @param {function():Promise<V>} init
176
+ * @param {() => Promise<V>} init
177
177
  * @param {boolean} removeNull Optional argument that automatically removes values that resolve to null/undefined from the cache.
178
178
  * @return {Promise<V> | V}
179
179
  */
package/src/decoding.js CHANGED
@@ -460,7 +460,7 @@ export const readBigInt64 = decoder => /** @type {any} */ (readFromDataView(deco
460
460
  export const readBigUint64 = decoder => /** @type {any} */ (readFromDataView(decoder, 8)).getBigUint64(0, false)
461
461
 
462
462
  /**
463
- * @type {Array<function(Decoder):any>}
463
+ * @type {Array<(decoder: Decoder) => any>}
464
464
  */
465
465
  const readAnyLookupTable = [
466
466
  decoder => undefined, // CASE 127: undefined
@@ -508,7 +508,7 @@ export const readAny = decoder => readAnyLookupTable[127 - readUint8(decoder)](d
508
508
  export class RleDecoder extends Decoder {
509
509
  /**
510
510
  * @param {Uint8Array} uint8Array
511
- * @param {function(Decoder):T} reader
511
+ * @param {(decoder: Decoder) => T} reader
512
512
  */
513
513
  constructor (uint8Array, reader) {
514
514
  super(uint8Array)
@@ -355,15 +355,6 @@ export class TextOp extends list.ListNode {
355
355
  this._fingerprint = null
356
356
  }
357
357
 
358
- /**
359
- * @param {string} newVal
360
- */
361
- _updateInsert (newVal) {
362
- // @ts-ignore
363
- this.insert = newVal
364
- this._fingerprint = null
365
- }
366
-
367
358
  /**
368
359
  * @return {'insert'}
369
360
  */
@@ -462,18 +453,6 @@ export class InsertOp extends list.ListNode {
462
453
  this._fingerprint = null
463
454
  }
464
455
 
465
- /* c8 ignore start */
466
- /**
467
- * @param {ArrayContent} _newVal
468
- */
469
- _updateInsert (_newVal) {
470
- // Mirror of TextOp._updateInsert; not currently called on InsertOp because
471
- // adjacent inserts are merged in-place via `end.insert.push(...)`. Kept for
472
- // parity with TextOp's API.
473
- error.unexpectedCase() // throw if called
474
- }
475
- /* c8 ignore stop */
476
-
477
456
  /**
478
457
  * @return {'insert'}
479
458
  */
@@ -1437,7 +1416,7 @@ export class Delta extends DeltaData {
1437
1416
  })
1438
1417
  encoding.writeVarUint(encoder, keys.length)
1439
1418
  for (const key of keys) {
1440
- encoding.writeVarString(encoder, /** @type {any} */ (this.attrs[/** @type {keyof Attrs} */ (key)]).fingerprint)
1419
+ encoding.writeVarString(encoder, /** @type {any} */ (this.attrs[/** @type {keyof typeof this.attrs} */ (key)]).fingerprint)
1441
1420
  }
1442
1421
  encoding.writeVarUint(encoder, this.children.len)
1443
1422
  for (const child of this.children) {
@@ -1810,13 +1789,15 @@ export const _mergeChildWithPrev = (d, op) => {
1810
1789
  } else if ($deleteOp.check(op)) {
1811
1790
  /** @type {DeleteOp<any>} */ (prevOp).delete += op.delete
1812
1791
  } else if ($textOp.check(op)) {
1813
- /** @type {TextOp} */ (prevOp)._updateInsert(/** @type {TextOp} */ (prevOp).insert + op.insert)
1792
+ // @ts-ignore
1793
+ /** @type {TextOp} */ (prevOp).insert += op.insert
1814
1794
  /* c8 ignore start */
1815
1795
  } else {
1816
1796
  // unreachable: the constructor check at the top of the function already
1817
1797
  // limits `op` to one of the four kinds tested above
1818
1798
  error.unexpectedCase()
1819
1799
  }
1800
+ prevOp._fingerprint = null
1820
1801
  /* c8 ignore stop */
1821
1802
  list.remove(d.children, op)
1822
1803
  return true
@@ -2100,7 +2081,9 @@ export class DeltaBuilder extends Delta {
2100
2081
  const end = this.children.end
2101
2082
  if (s.$string.check(insert)) {
2102
2083
  if ($textOp.check(end) && checkMergedEquals(end)) {
2103
- end._updateInsert(end.insert + insert)
2084
+ // @ts-ignore
2085
+ end.insert += insert
2086
+ end._fingerprint = null
2104
2087
  } else if (insert.length > 0) {
2105
2088
  list.pushEnd(this.children, new TextOp(insert, mergedFormats, mergedAttribution))
2106
2089
  }
@@ -3659,7 +3642,7 @@ export const random = (gen, $d, conf = {}) => {
3659
3642
  }
3660
3643
  for (let i = prng.uint32(gen, minChildOps, maxChildOps); i > 0; i--) {
3661
3644
  /**
3662
- * @type {Array<function():void>}
3645
+ * @type {Array<() => void>}
3663
3646
  */
3664
3647
  const possibleOps = []
3665
3648
  if (hasText) {
@@ -3818,7 +3801,11 @@ export const create = (nodeNameOrSchema, attrsOrSchema, children) => {
3818
3801
  * }>}
3819
3802
  */
3820
3803
  /**
3821
- * @param {Array<string|null|{[K:string|number]:any}|Array<any>>} args
3804
+ * `...X` (not `Array<X>`) is the rest-parameter form — `@param {Array<X>} args` declares a single
3805
+ * array-typed parameter, which makes the overloads above incompatible with this signature. TS only
3806
+ * cross-checks overloads against the implementation for function declarations, so it stayed silent.
3807
+ *
3808
+ * @param {...(string|null|{[K:string|number]:any}|Array<any>)} args
3822
3809
  * @return {DeltaBuilder<{}>}
3823
3810
  */
3824
3811
  export const from = (...args) => {
@@ -3,11 +3,12 @@
3
3
  /**
4
4
  * # DOM delta RDT
5
5
  *
6
- * {@link domRDT} creates an RDT (see `../rdt.js`) backed by a live DOM subtree. DOM mutations are
7
- * observed with a `MutationObserver`, turned into deltas (by diffing the new DOM state against the
8
- * last-known one) and emitted as `'delta'` events (with the RDT itself as their
9
- * {@link import('../rdt.js').RDT origin}); incoming deltas are applied back onto the DOM. This lets a
10
- * DOM subtree be bound to any other RDT.
6
+ * {@link domRDT} creates an RDT (see `../rdt.js`) backed by a live DOM subtree. A `MutationObserver`
7
+ * reports which nodes changed; the mirror ({@link DomRDT#_state}) is reconciled *incrementally* — only
8
+ * the changed paths are re-read from the DOM, every unchanged subtree is reused by reference (via the
9
+ * {@link DomRDT#_nodes} `WeakMap`), and the rebuilt tree is diffed against the previous mirror to emit a
10
+ * minimal `'delta'` (with the RDT itself as its {@link import('../rdt.js').RDT origin}). Incoming deltas
11
+ * are applied back onto the DOM. This lets a DOM subtree be bound to any other RDT.
11
12
  *
12
13
  * @module delta/rdt/dom
13
14
  */
@@ -30,30 +31,15 @@ import * as s from '../../schema.js'
30
31
  */
31
32
 
32
33
  /**
33
- * Recursively convert a DOM node into a {@link DomDelta} (an "insert everything" delta describing the
34
- * node, its attributes, and its subtree).
34
+ * A shared, never-mutated empty `onPath` set for the initial full read (see {@link reconcile}), where an
35
+ * empty `_nodes` map means nothing is reused anyway.
35
36
  *
36
- * @param {Node} domNode
37
- * @return {DomDelta}
37
+ * @type {Set<Node>}
38
38
  */
39
- const domToDelta = domNode => {
40
- if (dom.$element.check(domNode)) {
41
- const d = delta.create(domNode.nodeName.toLowerCase())
42
- for (let i = 0; i < domNode.attributes.length; i++) {
43
- const attr = /** @type {Attr} */ (domNode.attributes.item(i))
44
- d.setAttr(attr.nodeName, attr.value)
45
- }
46
- domNode.childNodes.forEach(child => {
47
- d.insert(dom.$text.check(child) ? (child.textContent ?? '') : [domToDelta(child)])
48
- })
49
- return /** @type {DomDelta} */ (d)
50
- }
51
- /* c8 ignore next */ // defensive: only element nodes are ever rendered/observed
52
- error.unexpectedCase()
53
- }
39
+ const EMPTY_ON_PATH = new Set()
54
40
 
55
41
  /**
56
- * Render a {@link DomDelta} into a fresh DOM element (the inverse of {@link domToDelta}).
42
+ * Render a {@link DomDelta} into a fresh DOM element (the inverse of {@link reconcile}).
57
43
  *
58
44
  * @param {DomDelta} d
59
45
  * @return {Element}
@@ -187,9 +173,143 @@ export const $domDelta = /* @__PURE__ */ delta.$delta({ name: s.$string, attrs:
187
173
  * @typedef {delta.Delta<DomConf>} DomDelta
188
174
  */
189
175
 
176
+ // The mirror is maintained by these module-private functions (kept as functions, not methods, per the
177
+ // lib0 style guide — methods are reserved for the duck-typed RDT interface: `applyDelta`/`delta`/
178
+ // `destroy`). Each takes the {@link DomRDT} whose `_state`/`_nodes`/`observedNode` it reads or advances.
179
+
180
+ /**
181
+ * Reconcile `node` against the DOM, reusing subtrees by reference. When `node` is not on the dirty
182
+ * `onPath` and is already mapped in `rdt._nodes`, its existing mirror subtree is returned unread;
183
+ * otherwise the node's shell (name + attributes + child list) is rebuilt — text children become an
184
+ * `insert` of their content (adjacent text coalesces, normalising `splitText`), element children recurse
185
+ * — and re-registered in `rdt._nodes`. Purely constructive: it never mutates an existing mirror node,
186
+ * only allocates fresh shells and shares unchanged subtrees.
187
+ *
188
+ * @param {DomRDT} rdt
189
+ * @param {Node} node
190
+ * @param {Set<Node>} onPath dirty elements ∪ their ancestors up to the observed root
191
+ * @return {DomDelta}
192
+ */
193
+ const reconcile = (rdt, node, onPath) => {
194
+ if (!onPath.has(node) && rdt._nodes.has(node)) {
195
+ return /** @type {DomDelta} */ (rdt._nodes.get(node))
196
+ }
197
+ if (dom.$element.check(node)) {
198
+ const d = delta.create(node.nodeName.toLowerCase())
199
+ for (let i = 0; i < node.attributes.length; i++) {
200
+ const attr = /** @type {Attr} */ (node.attributes.item(i))
201
+ d.setAttr(attr.nodeName, attr.value)
202
+ }
203
+ node.childNodes.forEach(child => {
204
+ d.insert(dom.$text.check(child) ? (child.textContent ?? '') : [reconcile(rdt, child, onPath)])
205
+ })
206
+ rdt._nodes.set(node, /** @type {DomDelta} */ (d))
207
+ return /** @type {DomDelta} */ (d)
208
+ }
209
+ /* c8 ignore next */ // defensive: only element nodes are ever rendered/observed
210
+ error.unexpectedCase()
211
+ }
212
+
213
+ /**
214
+ * The set of elements whose OWN content (attributes, character data, or child list) a mutation batch
215
+ * changed: a `characterData` record dirties the text node's parent, every other record its target.
216
+ * Targets no longer under the observed root (removed in this same batch) are dropped — their removal is
217
+ * captured by a surviving ancestor's `childList` record.
218
+ *
219
+ * @param {DomRDT} rdt
220
+ * @param {MutationRecord[]} records
221
+ * @return {Set<Element>}
222
+ */
223
+ const dirtyElements = (rdt, records) => {
224
+ /** @type {Set<Element>} */
225
+ const dirty = new Set()
226
+ for (const r of records) {
227
+ const el = r.type === 'characterData' ? r.target.parentNode : r.target
228
+ // `contains` is reflexive, so it admits the observed root itself and drops disconnected targets
229
+ if (el != null && dom.$element.check(el) && rdt.observedNode.contains(el)) {
230
+ dirty.add(el)
231
+ }
232
+ }
233
+ return dirty
234
+ }
235
+
236
+ /**
237
+ * The dirty elements together with all their ancestors up to (and including) the observed root — the
238
+ * nodes whose shells {@link reconcile} must rebuild (everything off this path is reused).
239
+ *
240
+ * @param {DomRDT} rdt
241
+ * @param {Set<Element>} dirty
242
+ * @return {Set<Node>}
243
+ */
244
+ const computeOnPath = (rdt, dirty) => {
245
+ /** @type {Set<Node>} */
246
+ const onPath = new Set()
247
+ for (const el of dirty) {
248
+ /** @type {Node?} */
249
+ let p = el
250
+ while (p != null && !onPath.has(p)) {
251
+ onPath.add(p)
252
+ if (p === rdt.observedNode) break
253
+ p = p.parentNode
254
+ }
255
+ }
256
+ return onPath
257
+ }
258
+
190
259
  /**
191
- * An RDT backed by a live DOM subtree. DOM mutations observed via `MutationObserver` are diffed
192
- * against the last-known state and emitted as deltas; incoming deltas are applied back onto the DOM.
260
+ * Reconcile the mirror against the DOM described by `records`, returning the freshly-rebuilt root — or
261
+ * `null` when nothing relevant changed (in which case no `rdt._nodes` entries were touched, so the
262
+ * caller may safely leave `rdt._state` as-is).
263
+ *
264
+ * @param {DomRDT} rdt
265
+ * @param {MutationRecord[]} records
266
+ * @return {DomDelta?}
267
+ */
268
+ const reconcileFromRecords = (rdt, records) => {
269
+ const dirty = dirtyElements(rdt, records)
270
+ if (dirty.size === 0) return null
271
+ return reconcile(rdt, rdt.observedNode, computeOnPath(rdt, dirty))
272
+ }
273
+
274
+ /**
275
+ * Reconcile against the DOM described by `records` and return the change to emit (the diff of the
276
+ * previous mirror against the rebuilt one), advancing `rdt._state`. Returns an empty delta when nothing
277
+ * relevant changed.
278
+ *
279
+ * @param {DomRDT} rdt
280
+ * @param {MutationRecord[]} records
281
+ * @return {DomDelta}
282
+ */
283
+ const pull = (rdt, records) => {
284
+ const fresh = reconcileFromRecords(rdt, records)
285
+ if (fresh == null) return /** @type {DomDelta} */ (delta.create())
286
+ // `clone: true` is REQUIRED here (not merely an optimisation as on a throwaway diff): `fresh` becomes
287
+ // the persistent `_state`, so the emitted change must share none of its subtrees — a consumer that
288
+ // freezes/mutates a shared child would otherwise corrupt the live mirror.
289
+ const change = /** @type {DomDelta} */ (delta.diff(rdt._state, fresh, { clone: true }))
290
+ // MUST commit: `reconcile` already re-pointed `rdt._nodes` at the rebuilt shells, so keeping the old
291
+ // `_state` would desync it. (Skipping is safe only when nothing was reconciled — the `fresh == null`
292
+ // short-circuit above.)
293
+ rdt._state = fresh
294
+ return change
295
+ }
296
+
297
+ /**
298
+ * The `MutationObserver` callback: reconcile the batch and, if it produced a change, emit it with the
299
+ * RDT itself as origin (a locally-observed DOM edit is produced by this RDT — see {@link RDT}).
300
+ *
301
+ * @param {DomRDT} rdt
302
+ * @param {MutationRecord[]} mutations
303
+ */
304
+ const mutationHandler = (rdt, mutations) => {
305
+ const change = pull(rdt, mutations)
306
+ if (!change.isEmpty()) rdt.emit('delta', [change, rdt])
307
+ }
308
+
309
+ /**
310
+ * An RDT backed by a live DOM subtree. DOM mutations observed via `MutationObserver` reconcile the
311
+ * mirror incrementally (re-reading only changed paths, reusing unchanged subtrees by reference) and the
312
+ * resulting minimal diff is emitted as a delta; incoming deltas are applied back onto the DOM.
193
313
  *
194
314
  * @implements {RDT<DomConf>}
195
315
  * @extends {ObservableV2<{ delta: (delta: delta.Delta<DomConf>, origin: any) => void, destroy: (rdt: DomRDT) => void }>}
@@ -206,44 +326,30 @@ class DomRDT extends ObservableV2 {
206
326
  this.$delta = /** @type {any} */ ($domDelta)
207
327
  this.observedNode = observedNode
208
328
  /**
209
- * Last-known DOM state. The observe path diffs the live DOM against this to compute the change to
210
- * emit, then advances it; `applyDelta` re-syncs it after mutating the DOM.
329
+ * Maps each observed DOM node to its mirror delta node in {@link DomRDT#_state}. A {@link reconcile}
330
+ * reuses an unchanged subtree by reference (skipping its DOM read) via this map; entries for removed
331
+ * nodes are reclaimed automatically.
332
+ *
333
+ * @type {WeakMap<Node, DomDelta>}
334
+ */
335
+ this._nodes = new WeakMap()
336
+ /**
337
+ * The live delta mirror of the observed subtree, maintained incrementally. A reconcile rebuilds only
338
+ * the changed paths and swaps this whole root (see {@link pull}); it is never mutated in place, so a
339
+ * mirror handed out by {@link DomRDT#delta} stays a valid immutable snapshot.
211
340
  *
212
341
  * @type {DomDelta}
213
342
  */
214
- this._state = domToDelta(observedNode)
215
- this.observer = new MutationObserver(this._mutationHandler)
343
+ this._state = reconcile(this, observedNode, EMPTY_ON_PATH)
344
+ this.observer = new MutationObserver(mutations => mutationHandler(this, mutations))
216
345
  this.observer.observe(observedNode, {
217
346
  subtree: true,
218
347
  childList: true,
219
348
  attributes: true,
220
- characterDataOldValue: true
349
+ characterData: true
221
350
  })
222
351
  }
223
352
 
224
- /**
225
- * Pull the local DOM changes accumulated since the last sync as a delta (by diffing the live DOM
226
- * against `_state`), advancing `_state` to the current DOM.
227
- *
228
- * @return {DomDelta}
229
- */
230
- _pull () {
231
- const next = domToDelta(this.observedNode)
232
- const change = delta.diff(this._state, next)
233
- this._state = next
234
- return change
235
- }
236
-
237
- /**
238
- * @param {MutationRecord[]} mutations
239
- */
240
- _mutationHandler = mutations => {
241
- if (mutations.length === 0) return
242
- const change = this._pull()
243
- // a locally-observed DOM edit: this RDT is the producer, so it is the origin (see {@link RDT})
244
- if (!change.isEmpty()) this.emit('delta', [change, this])
245
- }
246
-
247
353
  /**
248
354
  * Apply a foreign delta onto the DOM.
249
355
  *
@@ -257,10 +363,12 @@ class DomRDT extends ObservableV2 {
257
363
  * listeners can recognise (and skip) their own changes — see {@link RDT} “Origins”. Defaults to `null`
258
364
  * (an anonymous/local change).
259
365
  * @return {delta.DeltaBuilder<DomConf> | null} the rebased local change (`b`), or `null` when there
260
- * were no concurrent edits
366
+ * were no concurrent edits. A binding maps a returned fix back onto the other side with the
367
+ * `correction` origin (see `../rdt.js`)
261
368
  */
262
369
  applyDelta (d, origin = null) {
263
- const b = this._pull()
370
+ // Drain + diff any pending local edits (their async MutationObserver callback may not have fired).
371
+ const b = pull(this, this.observer.takeRecords())
264
372
  /** @type {DomDelta} */
265
373
  let toApply = d
266
374
  /** @type {DomDelta?} */
@@ -272,10 +380,13 @@ class DomRDT extends ObservableV2 {
272
380
  fix = bOnD.isEmpty() ? null : bOnD
273
381
  }
274
382
  applyDeltaToDom(this.observedNode, toApply)
275
- this._state = domToDelta(this.observedNode)
276
- // MutationObserver callbacks are async, so the Binding's (synchronous) mutex cannot suppress the
277
- // echo of our own write — draining the self-caused records here is what prevents it.
278
- this.observer.takeRecords()
383
+ // Re-sync `_state` from the DOM our own write produced, reconciling from its self-caused records
384
+ // (taken here — MutationObserver callbacks are async, so the Binding's synchronous mutex cannot
385
+ // suppress this echo; draining the records is what prevents it). We reconcile rather than
386
+ // `_state.apply(toApply)` because apply's copy-on-write would re-clone nodes out of `_nodes`, and
387
+ // its inserted nodes carry no mapping — the DOM (incl. its text coalescing) is the authority.
388
+ const fresh = reconcileFromRecords(this, this.observer.takeRecords())
389
+ if (fresh != null) this._state = fresh
279
390
  // Forward the effective change so a chained binding on the other side picks it up; the binding
280
391
  // that fed us `d` swallows this re-emit via its own mutex (see ../rdt.js). `fix` is a freshly
281
392
  // rebased builder at runtime, returned as the owned change the binding maps on.
@@ -284,12 +395,14 @@ class DomRDT extends ObservableV2 {
284
395
  }
285
396
 
286
397
  /**
287
- * The current state as a delta: an "insert everything" delta describing the observed subtree.
398
+ * The current state as a delta: the live mirror ({@link DomRDT#_state}), an "insert everything" delta
399
+ * describing the observed subtree. A shared read snapshot — consumers clone before mutating (see
400
+ * {@link RDT}); the mirror is never mutated in place, so a returned snapshot stays valid.
288
401
  *
289
402
  * @return {delta.Delta<DomConf>}
290
403
  */
291
404
  get delta () {
292
- return domToDelta(this.observedNode)
405
+ return this._state
293
406
  }
294
407
 
295
408
  destroy () {