@earendil-works/chord 0.85.0 → 0.86.0

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.
@@ -46,14 +46,13 @@ const cloneJson = (value) => {
46
46
  return value;
47
47
  if (Array.isArray(value))
48
48
  return value.map((item) => cloneJson(item));
49
- const result = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype);
50
- for (const [key, child] of Object.entries(value)) {
51
- Object.defineProperty(result, key, {
52
- value: cloneJson(child),
53
- writable: true,
54
- enumerable: true,
55
- configurable: true,
56
- });
49
+ // Spread preserves compact object layouts instead of reserving extra slots
50
+ // while growing an empty object. Both branches create own writable properties.
51
+ const result = (Object.getPrototypeOf(value) === null ? Object.assign(Object.create(null), value) : { ...value });
52
+ for (const key of Object.keys(result)) {
53
+ const child = result[key];
54
+ if (isObj(child))
55
+ result[key] = cloneJson(child);
57
56
  }
58
57
  return result;
59
58
  };
@@ -61,7 +60,6 @@ const INDEX = /^(?:0|[1-9]\d*)$/;
61
60
  const norm = (target, key) => typeof key === "symbol" ? key : Array.isArray(target) && INDEX.test(key) ? Number(key) : key;
62
61
  const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
63
62
  const MISSING = Symbol("missing");
64
- const dirtyNode = () => ({ children: new Map() });
65
63
  const spliceItems = (target, index, remove, items) => {
66
64
  const removed = Reflect.apply(Array.prototype.splice, target, [index, remove]);
67
65
  const chunkSize = 10_000;
@@ -96,11 +94,6 @@ const jsonEqual = (left, right) => {
96
94
  }
97
95
  return true;
98
96
  };
99
- const ownValue = (value, segment) => {
100
- if (!isObj(value) || !Object.hasOwn(value, segment))
101
- return MISSING;
102
- return value[segment];
103
- };
104
97
  const emitSet = (path, value, out) => {
105
98
  const snapshot = cloneJson(value);
106
99
  if (path.length === 0)
@@ -220,197 +213,461 @@ function diffArray(before, after, path, scan, out) {
220
213
  out.push(["p", [...path], after.length, before.length - after.length, []]);
221
214
  }
222
215
  }
223
- const walkDirty = (before, after, node, path, scan, out) => {
224
- if (node.valueDirty) {
225
- diffValue(before, after, path, scan, out);
226
- return;
227
- }
228
- if (node.array !== undefined) {
229
- if (node.array.kind === "replace") {
230
- if (!jsonEqual(before, after))
231
- emitSet(path, after, out);
216
+ export function track(root, options = {}) {
217
+ const scan = options.maxOverlapScan ?? 65_536;
218
+ let log = [];
219
+ let trie = {};
220
+ let nextOrder = 0;
221
+ let tombstones = 0;
222
+ let liveSlots = 0;
223
+ let nodeCount = 1;
224
+ let lastAddedSlot;
225
+ let hasPending = false;
226
+ let forceBase = true;
227
+ const clearPending = () => {
228
+ log = [];
229
+ trie = {};
230
+ nextOrder = 0;
231
+ tombstones = 0;
232
+ liveSlots = 0;
233
+ nodeCount = 1;
234
+ lastAddedSlot = undefined;
235
+ hasPending = false;
236
+ };
237
+ const logNode = (path) => {
238
+ let at = trie;
239
+ for (const segment of path) {
240
+ if (!at.kids)
241
+ at.kids = new Map();
242
+ let next = at.kids.get(segment);
243
+ if (next === undefined) {
244
+ next = {};
245
+ at.kids.set(segment, next);
246
+ nodeCount++;
247
+ }
248
+ at = next;
249
+ }
250
+ return at;
251
+ };
252
+ const findLogNode = (path) => {
253
+ let at = trie;
254
+ for (const segment of path) {
255
+ const next = at.kids?.get(segment);
256
+ if (next === undefined)
257
+ return undefined;
258
+ at = next;
259
+ }
260
+ return at;
261
+ };
262
+ const compactLog = () => {
263
+ if (tombstones < 1_024 || tombstones * 2 < log.length)
232
264
  return;
265
+ const compacted = [];
266
+ for (const slot of log) {
267
+ if (slot === undefined)
268
+ continue;
269
+ slot.index = compacted.length;
270
+ compacted.push(slot);
233
271
  }
234
- if (!Array.isArray(before) || !Array.isArray(after) || node.array.kind === "diff") {
235
- diffValue(before, after, path, scan, out);
272
+ log = compacted;
273
+ tombstones = 0;
274
+ };
275
+ const killSlot = (slot) => {
276
+ if (slot.dead)
236
277
  return;
278
+ slot.dead = true;
279
+ liveSlots--;
280
+ if (log[slot.index] === slot) {
281
+ log[slot.index] = undefined;
282
+ tombstones++;
283
+ }
284
+ };
285
+ const liveSlot = (at) => {
286
+ const slots = at.slots;
287
+ if (slots === undefined)
288
+ return undefined;
289
+ while (slots.length > 0 && slots[slots.length - 1].dead)
290
+ slots.pop();
291
+ if (slots.length === 0) {
292
+ at.slots = undefined;
293
+ return undefined;
237
294
  }
238
- const start = node.array.start;
239
- if (before.length !== start || after.length < start) {
240
- diffValue(before, after, path, scan, out);
295
+ return slots[slots.length - 1];
296
+ };
297
+ const killHere = (at) => {
298
+ if (at.slots === undefined)
299
+ return;
300
+ for (const slot of at.slots)
301
+ killSlot(slot);
302
+ at.slots = undefined;
303
+ };
304
+ const addSlot = (at, slot) => {
305
+ compactLog();
306
+ slot.order = nextOrder++;
307
+ slot.index = log.length;
308
+ at.lastOrder = slot.order;
309
+ if (at.slots === undefined)
310
+ at.slots = [];
311
+ at.slots.push(slot);
312
+ log.push(slot);
313
+ liveSlots++;
314
+ lastAddedSlot = slot;
315
+ };
316
+ const collapsePending = () => {
317
+ // Long mutation windows can otherwise retain operations and retired path
318
+ // generations indefinitely. Fall back to a complete snapshot once either
319
+ // history exceeds a bounded coalescing window. Payload bytes and transient
320
+ // allocation remain workload-dependent.
321
+ if (forceBase || (liveSlots <= 4_096 && nodeCount <= 4_096))
241
322
  return;
323
+ const value = cloneJson(root);
324
+ clearPending();
325
+ hasPending = true;
326
+ addSlot(trie, { op: ["r", value], dead: false, order: 0, index: 0 });
327
+ };
328
+ const killSubtree = (at) => {
329
+ killHere(at);
330
+ if (at.kids !== undefined) {
331
+ for (const child of at.kids.values())
332
+ killSubtree(child);
333
+ at.kids = undefined;
242
334
  }
243
- for (const [segment, child] of node.children) {
244
- if (typeof segment !== "number" || segment >= start)
245
- continue;
246
- const previous = ownValue(before, segment);
247
- const current = ownValue(after, segment);
248
- if (previous === MISSING || current === MISSING || child.valueDirty) {
249
- diffValue(previous, current, [...path, segment], scan, out);
335
+ if (at.retiredKids !== undefined) {
336
+ for (const generation of at.retiredKids) {
337
+ for (const child of generation.values())
338
+ killSubtree(child);
250
339
  }
251
- else if (isObj(previous) && isObj(current)) {
252
- walkDirty(previous, current, child, [...path, segment], scan, out);
340
+ at.retiredKids = undefined;
341
+ }
342
+ };
343
+ const retireKids = (at) => {
344
+ if (at.kids === undefined)
345
+ return;
346
+ if (at.retiredKids === undefined)
347
+ at.retiredKids = [];
348
+ at.retiredKids.push(at.kids);
349
+ at.kids = undefined;
350
+ };
351
+ // Deepest live ancestor op carrying a payload we can fold a later write into.
352
+ // `s`/`r` carry the whole subtree; `p` carries the items it inserted, so a
353
+ // write to one of those indices belongs inside the payload rather than after it.
354
+ const foldTarget = (path) => {
355
+ let at = trie;
356
+ let found;
357
+ let ancestorMax = -1;
358
+ for (let depth = 0; depth < path.length; depth++) {
359
+ if (found !== undefined && at.lastOrder !== undefined && at.lastOrder > found.slot.order) {
360
+ found = undefined;
253
361
  }
254
- else {
255
- diffValue(previous, current, [...path, segment], scan, out);
362
+ const slot = liveSlot(at);
363
+ // A fold is sound only if nothing has been recorded at this path or above it
364
+ // since: a later op there (a splice on the same array, a replacement of an
365
+ // ancestor) would have to apply after this write, not before it. Writes to
366
+ // other branches are irrelevant, which is why this is not "the most recent op".
367
+ if (slot !== undefined && slot.order >= ancestorMax && at.lastOrder === slot.order) {
368
+ if (slot.op[0] === "s" || slot.op[0] === "r")
369
+ found = { slot, depth };
370
+ else if (slot.op[0] === "p") {
371
+ const index = path[depth];
372
+ const items = slot.op[4];
373
+ if (typeof index === "number" && index >= slot.op[2] && index < slot.op[2] + items.length) {
374
+ found = { slot, depth: depth + 1, item: index - slot.op[2] };
375
+ }
376
+ }
256
377
  }
378
+ if (at.lastOrder !== undefined && at.lastOrder > ancestorMax)
379
+ ancestorMax = at.lastOrder;
380
+ const next = at.kids?.get(path[depth]);
381
+ if (next === undefined)
382
+ break;
383
+ at = next;
257
384
  }
258
- const items = after.slice(start);
259
- if (items.length > 0)
260
- out.push(["p", [...path], start, 0, cloneJson(items)]);
261
- return;
262
- }
263
- for (const [segment, child] of node.children) {
264
- const previous = ownValue(before, segment);
265
- const current = ownValue(after, segment);
266
- if (previous === MISSING || current === MISSING || child.valueDirty) {
267
- diffValue(previous, current, [...path, segment], scan, out);
268
- continue;
269
- }
270
- if (!isObj(previous) || !isObj(current)) {
271
- diffValue(previous, current, [...path, segment], scan, out);
272
- continue;
273
- }
274
- walkDirty(previous, current, child, [...path, segment], scan, out);
275
- }
276
- };
277
- /**
278
- * Bring `baseline` up to `root` along the dirty paths by sharing references.
279
- * Returns false — having changed nothing — if any dirty node is an array
280
- * change other than a pure append, so the caller can replay ops instead.
281
- * Cloning a whole array there is O(n) per flush; replay is O(changes).
282
- *
283
- * Strings are immutable, so root's `after` is shared outright, and sharing it
284
- * also means the next flush compares against a flat string rather than a cons.
285
- * Objects are cloned because root keeps mutating them.
286
- */
287
- const syncBaseline = (baseline, root, node) => {
288
- if (!canSync(node))
289
- return false;
290
- syncInto(baseline, root, node);
291
- return true;
292
- };
293
- const canSync = (node) => {
294
- if (node.array !== undefined && node.array.kind !== "append")
295
- return false;
296
- for (const child of node.children.values())
297
- if (!canSync(child))
385
+ if (found === undefined)
386
+ return undefined;
387
+ return { slot: found.slot, rest: path.slice(found.depth), item: found.item };
388
+ };
389
+ const foldInto = (container, rest, op) => {
390
+ if (rest.length === 0)
298
391
  return false;
299
- return true;
300
- };
301
- const syncInto = (baseline, root, node) => {
302
- const parent = baseline;
303
- if (node.array?.kind === "append" && Array.isArray(baseline) && Array.isArray(root)) {
304
- const start = node.array.start;
305
- for (const [index, child] of node.children) {
306
- if (typeof index === "number" && index < start)
307
- syncChild(parent, root, index, child);
392
+ let target = container;
393
+ for (let index = 0; index < rest.length - 1; index++) {
394
+ if (!isObj(target))
395
+ return false;
396
+ target = target[rest[index]];
308
397
  }
309
- for (let i = start; i < root.length; i++) {
310
- baseline.push(isObj(root[i]) ? cloneJson(root[i]) : root[i]);
398
+ if (!isObj(target))
399
+ return false;
400
+ const key = rest[rest.length - 1];
401
+ const holder = target;
402
+ const write = (value) => {
403
+ Object.defineProperty(holder, key, { value, writable: true, enumerable: true, configurable: true });
404
+ };
405
+ switch (op[0]) {
406
+ case "s":
407
+ if (key === "__proto__")
408
+ return false;
409
+ write(cloneJson(op[2]));
410
+ return true;
411
+ case "d":
412
+ delete holder[key];
413
+ return true;
414
+ case "a": {
415
+ const value = holder[key];
416
+ if (typeof value !== "string")
417
+ return false;
418
+ write(value + op[2]);
419
+ return true;
420
+ }
421
+ case "t": {
422
+ const value = holder[key];
423
+ if (typeof value !== "string")
424
+ return false;
425
+ write(value.slice(op[2]));
426
+ return true;
427
+ }
428
+ case "p": {
429
+ const value = holder[key];
430
+ if (!Array.isArray(value))
431
+ return false;
432
+ spliceItems(value, op[2], op[3], cloneJson(op[4]));
433
+ return true;
434
+ }
435
+ default:
436
+ return false;
311
437
  }
312
- return;
313
- }
314
- for (const [segment, child] of node.children)
315
- syncChild(parent, root, segment, child);
316
- };
317
- const syncChild = (parent, root, segment, child) => {
318
- const current = ownValue(root, segment);
319
- const previous = ownValue(parent, segment);
320
- if (current === MISSING) {
321
- if (Array.isArray(parent))
322
- parent.splice(segment, 1);
323
- else
324
- delete parent[segment];
325
- return;
326
- }
327
- if (child.valueDirty || !isObj(current) || !isObj(previous) || Array.isArray(current) !== Array.isArray(previous)) {
328
- parent[segment] = isObj(current) ? cloneJson(current) : current;
329
- return;
330
- }
331
- syncInto(previous, current, child);
332
- };
333
- const cloneOp = (op) => {
334
- switch (op[0]) {
335
- case "r":
336
- return ["r", cloneJson(op[1])];
337
- case "s":
338
- return ["s", op[1], cloneJson(op[2])];
339
- case "p":
340
- return ["p", op[1], op[2], op[3], cloneJson(op[4])];
341
- default:
342
- return op;
343
- }
344
- };
345
- export function track(root, options = {}) {
346
- const scan = options.maxOverlapScan ?? 65_536;
347
- let pending = dirtyNode();
348
- let hasPending = false;
349
- let baseline;
350
- let forceBase = true;
351
- const clearPending = () => {
352
- pending = dirtyNode();
353
- hasPending = false;
354
438
  };
355
- const ensureNode = (path) => {
439
+ const recordString = (path, previous, value) => {
440
+ if (forceBase)
441
+ return;
356
442
  hasPending = true;
357
- let node = pending;
358
- for (const segment of path) {
359
- if (node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace")
360
- return undefined;
361
- let child = node.children.get(segment);
362
- if (child === undefined)
363
- child = dirtyNode();
364
- else
365
- node.children.delete(segment);
366
- node.children.set(segment, child);
367
- node = child;
443
+ // a string inside a pending payload belongs in that payload, as for any write
444
+ const fold = foldTarget(path);
445
+ if (fold !== undefined) {
446
+ const op = ["s", path, value];
447
+ if (fold.item !== undefined) {
448
+ const items = fold.slot.op[4];
449
+ if (fold.rest.length === 0) {
450
+ items[fold.item] = value;
451
+ return;
452
+ }
453
+ if (foldInto(items[fold.item], fold.rest, op))
454
+ return;
455
+ }
456
+ else {
457
+ const payload = fold.slot.op[0] === "r" ? fold.slot.op[1] : fold.slot.op[2];
458
+ if (foldInto(payload, fold.rest, op))
459
+ return;
460
+ }
368
461
  }
369
- return node;
370
- };
371
- const findNode = (path) => {
372
- let node = pending;
373
- for (const segment of path) {
374
- const child = node.children.get(segment);
375
- if (child === undefined)
376
- return undefined;
377
- node = child;
462
+ const at = logNode(path);
463
+ const live = liveSlot(at);
464
+ if (live?.str !== undefined) {
465
+ live.str.value = value;
466
+ return;
467
+ }
468
+ if (live !== undefined) {
469
+ // a pending set or delete at this path already replaced the value; keep that
470
+ // op and carry the new value in it rather than anchoring to it
471
+ if (live.op[0] === "s" || live.op[0] === "r") {
472
+ live.op = live.op[0] === "r" ? ["r", value] : ["s", live.op[1], value];
473
+ return;
474
+ }
475
+ if (live.op[0] === "d") {
476
+ killHere(at);
477
+ addSlot(at, {
478
+ op: ["s", path, value],
479
+ dead: false,
480
+ order: 0,
481
+ index: 0,
482
+ });
483
+ return;
484
+ }
485
+ // a truncate/append pair from an earlier string diff: both must go
486
+ killHere(at);
378
487
  }
379
- return node;
488
+ killSubtree(at);
489
+ addSlot(at, {
490
+ op: ["s", path, value],
491
+ dead: false,
492
+ order: 0,
493
+ index: 0,
494
+ str: { anchor: previous, value },
495
+ });
380
496
  };
381
- const markValue = (path) => {
382
- const node = ensureNode(path);
383
- if (node === undefined)
497
+ const record = (op) => {
498
+ if (forceBase)
384
499
  return;
385
- node.valueDirty = true;
386
- node.array = undefined;
387
- node.children.clear();
500
+ hasPending = true;
501
+ const path = (op[0] === "r" ? [] : op[1]);
502
+ const existing = findLogNode(path);
503
+ const anchored = existing === undefined ? undefined : liveSlot(existing);
504
+ if (anchored?.str !== undefined) {
505
+ switch (op[0]) {
506
+ case "a":
507
+ anchored.str.value += op[2];
508
+ return;
509
+ case "t":
510
+ anchored.str.value = anchored.str.value.slice(op[2]);
511
+ return;
512
+ case "s":
513
+ if (typeof op[2] === "string") {
514
+ anchored.str.value = op[2];
515
+ return;
516
+ }
517
+ }
518
+ if (existing !== undefined)
519
+ killSubtree(existing);
520
+ }
521
+ else if (existing !== undefined && (op[0] === "s" || op[0] === "d" || op[0] === "r")) {
522
+ // A replacement absorbed into an ancestor payload must still invalidate
523
+ // operations already recorded at and below its destination.
524
+ killSubtree(existing);
525
+ }
526
+ if (path.length > 0) {
527
+ const fold = foldTarget(path);
528
+ if (fold !== undefined) {
529
+ if (fold.item !== undefined) {
530
+ const items = fold.slot.op[4];
531
+ if (fold.rest.length === 0) {
532
+ if (op[0] === "s") {
533
+ items[fold.item] = cloneJson(op[2]);
534
+ return;
535
+ }
536
+ }
537
+ else if (foldInto(items[fold.item], fold.rest, op))
538
+ return;
539
+ }
540
+ else {
541
+ const payload = fold.slot.op[0] === "r" ? fold.slot.op[1] : fold.slot.op[2];
542
+ if (foldInto(payload, fold.rest, op))
543
+ return;
544
+ }
545
+ }
546
+ }
547
+ const at = logNode(path);
548
+ const live = liveSlot(at);
549
+ if (live !== undefined) {
550
+ const previous = live.op;
551
+ if (op[0] === "a" && previous[0] === "a") {
552
+ live.op = ["a", previous[1], previous[2] + op[2]];
553
+ return;
554
+ }
555
+ if (op[0] === "a" && (previous[0] === "s" || previous[0] === "r")) {
556
+ const value = previous[0] === "r" ? previous[1] : previous[2];
557
+ if (typeof value === "string") {
558
+ live.op = previous[0] === "r" ? ["r", value + op[2]] : ["s", previous[1], value + op[2]];
559
+ return;
560
+ }
561
+ }
562
+ if (op[0] === "t" && (previous[0] === "s" || previous[0] === "r")) {
563
+ const value = previous[0] === "r" ? previous[1] : previous[2];
564
+ if (typeof value === "string") {
565
+ const cut = value.slice(op[2]);
566
+ live.op = previous[0] === "r" ? ["r", cut] : ["s", previous[1], cut];
567
+ return;
568
+ }
569
+ }
570
+ if (op[0] === "p" && previous[0] === "p") {
571
+ const previousItems = previous[4];
572
+ // These rewrites are sound only for adjacent recorded operations. A
573
+ // tombstoned operation remains a barrier through lastAddedSlot.
574
+ if (previous[3] === 0 &&
575
+ op[3] === 0 &&
576
+ previous[2] + previousItems.length === op[2] &&
577
+ lastAddedSlot === live) {
578
+ const items = op[4];
579
+ for (let index = 0; index < items.length; index++)
580
+ previousItems.push(items[index]);
581
+ return;
582
+ }
583
+ if (previous[3] === 0 &&
584
+ lastAddedSlot === live &&
585
+ op[2] >= previous[2] &&
586
+ op[2] + op[3] <= previous[2] + previousItems.length) {
587
+ spliceItems(previousItems, op[2] - previous[2], op[3], op[4]);
588
+ if (previousItems.length === 0 && previous[3] === 0)
589
+ killSlot(live);
590
+ return;
591
+ }
592
+ if (op[3] > 0 &&
593
+ op[4].length === 0 &&
594
+ previousItems.length > 0 &&
595
+ lastAddedSlot === live) {
596
+ const from = op[2] - previous[2];
597
+ if (from >= 0 && from + op[3] === previousItems.length) {
598
+ previousItems.length = from;
599
+ if (previousItems.length === 0 && previous[3] === 0)
600
+ killSlot(live);
601
+ return;
602
+ }
603
+ }
604
+ }
605
+ if (op[0] === "s" || op[0] === "d" || op[0] === "r")
606
+ killHere(at);
607
+ }
608
+ if (op[0] === "s" || op[0] === "r" || op[0] === "d") {
609
+ // Replacements dominate all earlier descendants, including generations
610
+ // detached by array splices.
611
+ killSubtree(at);
612
+ }
613
+ else if (op[0] === "p") {
614
+ // Splices preserve earlier writes but form a barrier for later folding.
615
+ retireKids(at);
616
+ }
617
+ addSlot(at, { op, dead: false, order: 0, index: 0 });
388
618
  };
389
- const markArrayAppend = (path, start) => {
390
- const node = ensureNode(path);
391
- if (node === undefined || node.valueDirty || node.array?.kind === "diff" || node.array?.kind === "replace") {
619
+ // Local diff, used when a whole container is assigned: keeps op quality
620
+ // without a baseline by comparing the outgoing value with the incoming one at
621
+ // the moment of the write. String leaves go through recordString so every
622
+ // string path keeps a single anchored slot; otherwise a later write to the
623
+ // same string would have to supersede ops whose starting value it no longer
624
+ // knows.
625
+ const diffInto = (before, after, at) => {
626
+ if (forceBase)
627
+ return;
628
+ hasPending = true;
629
+ if (before === after)
630
+ return;
631
+ if (typeof before === "string" && typeof after === "string") {
632
+ recordString(at.slice(), before, after);
392
633
  return;
393
634
  }
394
- if (node.array === undefined)
395
- node.array = { kind: "append", start };
396
- };
397
- const markArrayDiff = (path) => {
398
- const node = ensureNode(path);
399
- if (node === undefined || node.valueDirty || node.array?.kind === "replace")
635
+ if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
636
+ for (let index = 0; index < after.length; index++) {
637
+ at.push(index);
638
+ diffInto(before[index], after[index], at);
639
+ at.pop();
640
+ }
400
641
  return;
401
- node.array = { kind: "diff" };
402
- node.children.clear();
403
- };
404
- const markArrayReplace = (path) => {
405
- const node = ensureNode(path);
406
- if (node === undefined || node.valueDirty)
642
+ }
643
+ if (isObj(before) && isObj(after) && !Array.isArray(before) && !Array.isArray(after)) {
644
+ const beforeObject = before;
645
+ const afterObject = after;
646
+ const beforeKeys = Object.keys(beforeObject);
647
+ const afterKeys = Object.keys(afterObject);
648
+ if ([...beforeKeys, ...afterKeys].some((key) => RESERVED_SEGMENTS.has(key))) {
649
+ record(["s", at.slice(), cloneJson(after)]);
650
+ return;
651
+ }
652
+ for (const key of afterKeys) {
653
+ at.push(key);
654
+ if (Object.hasOwn(beforeObject, key))
655
+ diffInto(beforeObject[key], afterObject[key], at);
656
+ else
657
+ record(["s", at.slice(), cloneJson(afterObject[key])]);
658
+ at.pop();
659
+ }
660
+ for (const key of beforeKeys) {
661
+ if (!Object.hasOwn(afterObject, key))
662
+ record(["d", [...at, key]]);
663
+ }
407
664
  return;
408
- node.array = { kind: "replace" };
409
- node.children.clear();
410
- };
411
- const appendStart = (path) => {
412
- const array = findNode(path)?.array;
413
- return array?.kind === "append" ? array.start : undefined;
665
+ }
666
+ // arrays of differing length, and everything else: chord's own diff
667
+ const out = [];
668
+ diffValue(before, after, at, scan, out);
669
+ for (const op of out)
670
+ record(op);
414
671
  };
415
672
  const guard = (segment) => {
416
673
  if (typeof segment === "symbol")
@@ -419,7 +676,6 @@ export function track(root, options = {}) {
419
676
  throw new UnsafePathError(segment);
420
677
  return segment;
421
678
  };
422
- const adoptItems = (values) => values;
423
679
  const integer = (value) => {
424
680
  const number = Number(value);
425
681
  if (Number.isNaN(number) || number === 0)
@@ -436,189 +692,619 @@ export function track(root, options = {}) {
436
692
  : Math.max(0, Math.min(integer(args[1]), length - index));
437
693
  return { index, remove };
438
694
  };
439
- const wrap = (object, path, blockedSegment) => {
440
- const childProxies = new Map();
441
- const proxy = new Proxy(object, {
442
- get(target, key, receiver) {
443
- if (Array.isArray(target) && typeof key === "string" && MUTATORS.has(key)) {
444
- return (...args) => {
445
- if (blockedSegment !== undefined)
446
- throw new UnsafePathError(blockedSegment);
447
- const before = target.length;
448
- let result;
449
- switch (key) {
450
- case "push": {
451
- const items = adoptItems(args);
452
- if (items.length > 0)
453
- markArrayAppend(path, before);
454
- spliceItems(target, before, 0, items);
455
- result = target.length;
456
- break;
457
- }
458
- case "unshift": {
459
- const items = adoptItems(args);
460
- if (items.length > 0)
461
- markArrayDiff(path);
462
- spliceItems(target, 0, 0, items);
463
- result = target.length;
464
- break;
465
- }
466
- case "pop":
467
- if (before > 0) {
468
- const start = appendStart(path);
469
- if (start === undefined || before - 1 < start)
470
- markArrayDiff(path);
471
- }
472
- result = Reflect.apply(Array.prototype.pop, target, args);
473
- break;
474
- case "shift":
475
- if (before > 0)
476
- markArrayDiff(path);
477
- result = Reflect.apply(Array.prototype.shift, target, args);
478
- break;
479
- case "splice": {
480
- const items = adoptItems(args.slice(2));
481
- const { index, remove } = spliceRange(before, args);
482
- if (remove > 0 || items.length > 0) {
483
- const start = appendStart(path);
484
- if (index === 0 && remove === before)
485
- markArrayReplace(path);
486
- else if (start !== undefined && index >= start) {
487
- // The final append payload includes all tail edits.
488
- }
489
- else if (index === before && remove === 0)
490
- markArrayAppend(path, before);
491
- else
492
- markArrayDiff(path);
493
- }
494
- result = spliceItems(target, index, remove, items);
495
- break;
496
- }
497
- default:
498
- markArrayDiff(path);
499
- result = Reflect.apply(Array.prototype[key], target, args);
500
- }
501
- if (key === "pop")
502
- childProxies.delete(String(before - 1));
503
- else if (key !== "push")
504
- childProxies.clear();
505
- return key === "sort" || key === "reverse" || key === "fill" || key === "copyWithin" ? proxy : result;
506
- };
695
+ // Paths are walked from placement cells rather than baked into proxies. A held
696
+ // descendant keeps its cells and their owner entries alive, so an ancestor can
697
+ // be re-created after GC without losing array-renumbering metadata. Entries keep
698
+ // only weak references to public proxies; parent caches keep only weak cells.
699
+ let shape = 0;
700
+ // Ordinary tree entries are weak values. Explicit aliases are sparse and stay
701
+ // strong while their raw weak key is alive so alias locations survive a period
702
+ // in which no public proxy exists.
703
+ const entries = new WeakMap();
704
+ const aliases = new WeakMap();
705
+ // A live public proxy retains its entry through this ephemeron. Raw proxy RHS
706
+ // values can therefore be unwrapped before they are written into plain targets.
707
+ const proxyEntries = new WeakMap();
708
+ // WeakRef targets already stay alive through the current JavaScript job. These
709
+ // strong job-local caches avoid repeated deref bookkeeping without extending
710
+ // that lifetime; one microtask drops both maps before the next job.
711
+ let jobCells = new WeakMap();
712
+ let jobProxies = new WeakMap();
713
+ let jobCleanupScheduled = false;
714
+ const scheduleJobCleanup = () => {
715
+ if (jobCleanupScheduled)
716
+ return;
717
+ jobCleanupScheduled = true;
718
+ queueMicrotask(() => {
719
+ jobCells = new WeakMap();
720
+ jobProxies = new WeakMap();
721
+ jobCleanupScheduled = false;
722
+ });
723
+ };
724
+ const keepCellForJob = (ref, cell) => {
725
+ jobCells.set(ref, cell);
726
+ scheduleJobCleanup();
727
+ };
728
+ const derefCell = (ref) => {
729
+ const cached = jobCells.get(ref);
730
+ if (cached !== undefined)
731
+ return cached;
732
+ const cell = ref.deref();
733
+ if (cell !== undefined)
734
+ keepCellForJob(ref, cell);
735
+ return cell;
736
+ };
737
+ const entryFinalizer = new FinalizationRegistry(({ target, token }) => {
738
+ const raw = target.deref();
739
+ if (raw !== undefined && entries.get(raw)?.token === token)
740
+ entries.delete(raw);
741
+ });
742
+ const cellFinalizer = new FinalizationRegistry(({ owner, key, ref }) => {
743
+ const entry = owner.deref();
744
+ if (entry === undefined)
745
+ return;
746
+ if (key !== undefined && entry.childProxies?.get(key) === ref)
747
+ entry.childProxies.delete(key);
748
+ entry.childCells?.delete(ref);
749
+ });
750
+ // Almost no document ever puts one object at two positions. Until one does,
751
+ // every write has exactly one path, so the per-write cell walk is skipped.
752
+ let aliased = false;
753
+ const isDetached = (cell) => {
754
+ for (let c = cell; c !== undefined; c = c.parent)
755
+ if (c.dead)
756
+ return true;
757
+ return false;
758
+ };
759
+ const pathOf = (cell) => {
760
+ if (cell.at === shape && cell.cached !== undefined)
761
+ return cell.cached;
762
+ const out = [];
763
+ for (let c = cell; c !== undefined && c.parent !== undefined; c = c.parent)
764
+ out.push(c.seg);
765
+ out.reverse();
766
+ cell.at = shape;
767
+ cell.cached = out;
768
+ return out;
769
+ };
770
+ const liveCells = (entry) => {
771
+ const out = [];
772
+ for (const cell of entry.cells)
773
+ if (!isDetached(cell))
774
+ out.push(cell);
775
+ return out;
776
+ };
777
+ const primary = (entry) => (aliased ? (liveCells(entry)[0] ?? entry.fallback) : entry.fallback);
778
+ const pathNow = (entry) => pathOf(primary(entry));
779
+ const findEntry = (target) => {
780
+ const alias = aliases.get(target);
781
+ if (alias !== undefined)
782
+ return alias;
783
+ const slot = entries.get(target);
784
+ const entry = slot?.ref.deref();
785
+ if (slot !== undefined && entry === undefined)
786
+ entries.delete(target);
787
+ return entry;
788
+ };
789
+ const indexEntry = (entry) => {
790
+ const token = {};
791
+ entries.set(entry.target, { ref: new WeakRef(entry), token });
792
+ entryFinalizer.register(entry, { target: new WeakRef(entry.target), token });
793
+ };
794
+ const addPlacement = (entry, cell) => {
795
+ if (entry.cells.has(cell))
796
+ return;
797
+ let hasLive = false;
798
+ for (const existing of entry.cells) {
799
+ if (!isDetached(existing)) {
800
+ hasLive = true;
801
+ break;
802
+ }
803
+ }
804
+ cell.entry = entry;
805
+ cell.target = entry.target;
806
+ entry.cells.add(cell);
807
+ if (!hasLive) {
808
+ const previous = entry.fallback;
809
+ entry.fallback = cell;
810
+ if (previous !== cell) {
811
+ shape++;
812
+ // Reattaching a held container moves every known immediate child to
813
+ // its new placement. Array registrations survive cache clears.
814
+ const children = new Set();
815
+ for (const ref of entry.childProxies?.values() ?? []) {
816
+ const child = derefCell(ref);
817
+ if (child !== undefined)
818
+ children.add(child);
819
+ }
820
+ for (const ref of entry.childCells ?? []) {
821
+ const child = derefCell(ref);
822
+ if (child !== undefined)
823
+ children.add(child);
824
+ }
825
+ for (const child of children) {
826
+ if (!child.dead && child.parent === previous)
827
+ child.parent = cell;
828
+ }
829
+ }
830
+ }
831
+ else if (entry.blocked === undefined) {
832
+ aliased = true;
833
+ aliases.set(entry.target, entry);
834
+ }
835
+ };
836
+ const cachePlacement = (owner, key, cell) => {
837
+ let cleanup = cell.cleanup;
838
+ if (cleanup === undefined) {
839
+ cleanup = { owner: new WeakRef(owner), key: undefined, ref: new WeakRef(cell) };
840
+ cell.cleanup = cleanup;
841
+ cellFinalizer.register(cell, cleanup);
842
+ }
843
+ keepCellForJob(cleanup.ref, cell);
844
+ if (cleanup.key !== undefined && cleanup.key !== key && owner.childProxies?.get(cleanup.key) === cleanup.ref) {
845
+ owner.childProxies.delete(cleanup.key);
846
+ }
847
+ if (owner.childProxies === undefined)
848
+ owner.childProxies = new Map();
849
+ owner.childProxies.set(key, cleanup.ref);
850
+ cleanup.key = key;
851
+ if (Array.isArray(owner.target)) {
852
+ if (owner.childCells === undefined)
853
+ owner.childCells = new Set();
854
+ owner.childCells.add(cleanup.ref);
855
+ }
856
+ };
857
+ const findPlacement = (owner, parent, segment, target, blocked) => {
858
+ if (blocked === undefined) {
859
+ const known = findEntry(target);
860
+ if (known !== undefined) {
861
+ for (const cell of known.cells) {
862
+ if (!cell.dead && cell.owner === owner && cell.parent === parent && cell.seg === segment)
863
+ return cell;
507
864
  }
508
- const value = Reflect.get(target, key, receiver);
509
- if (!isObj(value))
510
- return value;
511
- const cached = childProxies.get(key);
512
- if (cached?.target === value)
513
- return cached.proxy;
514
- const rawSegment = norm(target, key);
515
- let segment;
516
- let childBlocked = blockedSegment;
517
- if (blockedSegment !== undefined) {
518
- if (typeof rawSegment === "symbol")
519
- throw new UnsafePathError(String(rawSegment));
520
- segment = rawSegment;
865
+ }
866
+ return undefined;
867
+ }
868
+ // Blocked views are intentionally absent from the ordinary raw-entry index.
869
+ for (const ref of owner.childCells ?? []) {
870
+ const cell = derefCell(ref);
871
+ if (cell !== undefined &&
872
+ !cell.dead &&
873
+ cell.target === target &&
874
+ cell.entry?.blocked === blocked &&
875
+ cell.parent === parent &&
876
+ cell.seg === segment) {
877
+ return cell;
878
+ }
879
+ }
880
+ return undefined;
881
+ };
882
+ const unwrap = (value) => (isObj(value) ? (proxyEntries.get(value)?.target ?? value) : value);
883
+ const adoptItems = (values) => values.map(unwrap);
884
+ // Every operation is emitted once per live location of an explicitly aliased
885
+ // object. Alias cells are independent of public proxy lifetimes.
886
+ const emit = (entry, op) => {
887
+ record(op);
888
+ if (!aliased)
889
+ return;
890
+ const live = liveCells(entry);
891
+ if (live.length <= 1)
892
+ return;
893
+ const base = pathNow(entry);
894
+ const head = primary(entry);
895
+ for (const cell of live) {
896
+ if (cell === head || op[0] === "r")
897
+ continue;
898
+ const rest = op[1].slice(base.length);
899
+ const cloned = [...op];
900
+ cloned[1] = [...pathOf(cell), ...rest];
901
+ record(cloned);
902
+ }
903
+ };
904
+ // Structural mutation updates every still-live placement cell, including cells
905
+ // retained only by held descendants. Dead weak registrations are pruned lazily;
906
+ // finalizers keep idle live parents from accumulating them indefinitely.
907
+ const renumber = (entry, key, before, after, spliceAt, spliceRemove, spliceInsert) => {
908
+ shape++;
909
+ const childCells = entry.childCells;
910
+ if (key === "push" || !Array.isArray(entry.target) || childCells === undefined || childCells.size === 0)
911
+ return;
912
+ let index;
913
+ let remove = 0;
914
+ let insert = 0;
915
+ if (key === "pop") {
916
+ index = before - 1;
917
+ remove = before > 0 ? 1 : 0;
918
+ }
919
+ else if (key === "shift") {
920
+ index = 0;
921
+ remove = before > 0 ? 1 : 0;
922
+ }
923
+ else if (key === "unshift") {
924
+ index = 0;
925
+ insert = after - before;
926
+ }
927
+ else if (key === "splice") {
928
+ index = spliceAt;
929
+ remove = spliceRemove;
930
+ insert = spliceInsert;
931
+ }
932
+ else {
933
+ for (const ref of [...childCells]) {
934
+ const cell = derefCell(ref);
935
+ if (cell === undefined) {
936
+ childCells.delete(ref);
937
+ continue;
521
938
  }
522
- else if (typeof rawSegment === "string" &&
523
- RESERVED_SEGMENTS.has(rawSegment) &&
524
- Object.hasOwn(target, key)) {
525
- segment = rawSegment;
526
- childBlocked = rawSegment;
939
+ const at = entry.target.indexOf(cell.target);
940
+ if (at < 0) {
941
+ cell.dead = true;
942
+ childCells.delete(ref);
527
943
  }
528
944
  else
529
- segment = guard(rawSegment);
530
- const child = wrap(value, [...path, segment], childBlocked);
531
- childProxies.set(key, { target: value, proxy: child });
532
- return child;
533
- },
534
- set(target, key, value) {
535
- if (blockedSegment !== undefined)
536
- throw new UnsafePathError(blockedSegment);
537
- if (Array.isArray(target) && key === "length") {
538
- const before = target.length;
539
- const next = Number(value);
540
- if (!Number.isSafeInteger(next) || next < 0 || next > 4_294_967_295) {
541
- return Reflect.set(target, key, value);
945
+ cell.seg = at;
946
+ }
947
+ entry.childProxies?.clear();
948
+ return;
949
+ }
950
+ const delta = insert - remove;
951
+ for (const ref of [...childCells]) {
952
+ const cell = derefCell(ref);
953
+ if (cell === undefined) {
954
+ childCells.delete(ref);
955
+ continue;
956
+ }
957
+ const at = cell.seg;
958
+ if (typeof at !== "number")
959
+ continue;
960
+ if (at >= index && at < index + remove) {
961
+ cell.dead = true;
962
+ childCells.delete(ref);
963
+ }
964
+ else if (at >= index + remove)
965
+ cell.seg = at + delta;
966
+ }
967
+ entry.childProxies?.clear();
968
+ };
969
+ const wrap = (object, cell, blockedSegment) => {
970
+ const placed = cell.entry;
971
+ if (placed !== undefined && placed.target === object && placed.blocked === blockedSegment) {
972
+ return proxyFor(placed);
973
+ }
974
+ const existing = blockedSegment === undefined ? findEntry(object) : undefined;
975
+ if (existing !== undefined) {
976
+ addPlacement(existing, cell);
977
+ return proxyFor(existing);
978
+ }
979
+ const entry = {
980
+ target: object,
981
+ cells: new Set(),
982
+ fallback: cell,
983
+ blocked: blockedSegment,
984
+ proxy: undefined,
985
+ childProxies: undefined,
986
+ childCells: undefined,
987
+ };
988
+ addPlacement(entry, cell);
989
+ if (blockedSegment === undefined)
990
+ indexEntry(entry);
991
+ return proxyFor(entry);
992
+ };
993
+ const handlerPrototype = {
994
+ get(target, key, receiver) {
995
+ const entry = this.entry;
996
+ if (Array.isArray(target) && typeof key === "string" && MUTATORS.has(key)) {
997
+ return (...args) => {
998
+ if (entry.blocked !== undefined)
999
+ throw new UnsafePathError(entry.blocked);
1000
+ const detached = aliased ? liveCells(entry).length === 0 : isDetached(entry.fallback);
1001
+ if (detached) {
1002
+ return Reflect.apply(Array.prototype[key], target, args.map(unwrap));
542
1003
  }
543
- if (next < before) {
544
- const start = appendStart(path);
545
- if (next === 0)
546
- markArrayReplace(path);
547
- else if (start === undefined || next < start)
548
- markArrayDiff(path);
549
- Reflect.set(target, key, next);
550
- childProxies.clear();
1004
+ const before = target.length;
1005
+ let spliceAt = -1;
1006
+ let spliceRemove = 0;
1007
+ let spliceInsert = 0;
1008
+ let insertAt = -1;
1009
+ let insertCount = 0;
1010
+ let result;
1011
+ switch (key) {
1012
+ case "push": {
1013
+ const items = adoptItems(args);
1014
+ if (items.length > 0)
1015
+ emit(entry, ["p", [...pathNow(entry)], before, 0, cloneJson(items)]);
1016
+ insertAt = before;
1017
+ insertCount = items.length;
1018
+ spliceItems(target, before, 0, items);
1019
+ result = target.length;
1020
+ break;
1021
+ }
1022
+ case "unshift": {
1023
+ const items = adoptItems(args);
1024
+ if (items.length > 0)
1025
+ emit(entry, ["p", [...pathNow(entry)], 0, 0, cloneJson(items)]);
1026
+ insertAt = 0;
1027
+ insertCount = items.length;
1028
+ spliceItems(target, 0, 0, items);
1029
+ result = target.length;
1030
+ break;
1031
+ }
1032
+ case "pop":
1033
+ if (before > 0)
1034
+ emit(entry, ["p", [...pathNow(entry)], before - 1, 1, []]);
1035
+ result = Reflect.apply(Array.prototype.pop, target, args);
1036
+ break;
1037
+ case "shift":
1038
+ if (before > 0)
1039
+ emit(entry, ["p", [...pathNow(entry)], 0, 1, []]);
1040
+ result = Reflect.apply(Array.prototype.shift, target, args);
1041
+ break;
1042
+ case "splice": {
1043
+ const items = adoptItems(args.slice(2));
1044
+ const { index, remove } = spliceRange(before, args);
1045
+ spliceAt = index;
1046
+ spliceRemove = remove;
1047
+ spliceInsert = items.length;
1048
+ insertAt = index;
1049
+ insertCount = items.length;
1050
+ if (remove > 0 || items.length > 0) {
1051
+ if (index === 0 && remove === before) {
1052
+ if (pathNow(entry).length === 0)
1053
+ emit(entry, ["r", cloneJson(items)]);
1054
+ else
1055
+ emit(entry, [
1056
+ "s",
1057
+ [...pathNow(entry)],
1058
+ cloneJson(items),
1059
+ ]);
1060
+ }
1061
+ else
1062
+ emit(entry, ["p", [...pathNow(entry)], index, remove, cloneJson(items)]);
1063
+ }
1064
+ result = spliceItems(target, index, remove, items);
1065
+ break;
1066
+ }
1067
+ default: {
1068
+ result = Reflect.apply(Array.prototype[key], target, args.map(unwrap));
1069
+ if (pathNow(entry).length === 0)
1070
+ emit(entry, ["r", cloneJson(target)]);
1071
+ else
1072
+ emit(entry, [
1073
+ "s",
1074
+ [...pathNow(entry)],
1075
+ cloneJson(target),
1076
+ ]);
1077
+ }
551
1078
  }
552
- else if (next > before) {
553
- markArrayAppend(path, before);
554
- target.length = next;
555
- target.fill(null, before);
1079
+ collapsePending();
1080
+ renumber(entry, key, before, target.length, spliceAt, spliceRemove, spliceInsert);
1081
+ for (let index = insertAt; insertCount > 0 && index < insertAt + insertCount; index++) {
1082
+ const item = target[index];
1083
+ if (!isObj(item))
1084
+ continue;
1085
+ const known = findEntry(item);
1086
+ if (known === undefined || known.target === entry.target)
1087
+ continue;
1088
+ const parent = primary(entry);
1089
+ let seen = false;
1090
+ for (const existingCell of known.cells) {
1091
+ if (!existingCell.dead && existingCell.parent === parent && existingCell.seg === index)
1092
+ seen = true;
1093
+ }
1094
+ if (!seen) {
1095
+ const inserted = {
1096
+ parent,
1097
+ owner: entry,
1098
+ entry: undefined,
1099
+ target: item,
1100
+ seg: index,
1101
+ dead: false,
1102
+ };
1103
+ addPlacement(known, inserted);
1104
+ cachePlacement(entry, String(index), inserted);
1105
+ }
556
1106
  }
557
- return true;
558
- }
559
- const segment = guard(norm(target, key));
560
- if (Array.isArray(target)) {
561
- if (typeof segment !== "number")
562
- throw new UnsafePathError(segment);
563
- if (segment > target.length)
564
- throw new UnsafePathError(segment);
1107
+ return key === "sort" || key === "reverse" || key === "fill" || key === "copyWithin" ? receiver : result;
1108
+ };
1109
+ }
1110
+ const value = Reflect.get(target, key, receiver);
1111
+ if (!isObj(value))
1112
+ return value;
1113
+ const cachedRef = entry.childProxies?.get(key);
1114
+ const cached = cachedRef === undefined ? undefined : derefCell(cachedRef);
1115
+ if (cached !== undefined && cached.target === value && cached.entry !== undefined) {
1116
+ return proxyFor(cached.entry);
1117
+ }
1118
+ if (cachedRef !== undefined && cached === undefined && entry.childProxies?.get(key) === cachedRef) {
1119
+ entry.childProxies?.delete(key);
1120
+ }
1121
+ const rawSegment = norm(target, key);
1122
+ let segment;
1123
+ let childBlocked = entry.blocked;
1124
+ if (entry.blocked !== undefined) {
1125
+ if (typeof rawSegment === "symbol")
1126
+ throw new UnsafePathError(String(rawSegment));
1127
+ segment = rawSegment;
1128
+ }
1129
+ else if (typeof rawSegment === "string" && RESERVED_SEGMENTS.has(rawSegment) && Object.hasOwn(target, key)) {
1130
+ segment = rawSegment;
1131
+ childBlocked = rawSegment;
1132
+ }
1133
+ else
1134
+ segment = guard(rawSegment);
1135
+ const parent = primary(entry);
1136
+ const existingCell = findPlacement(entry, parent, segment, value, childBlocked);
1137
+ if (existingCell?.entry !== undefined) {
1138
+ cachePlacement(entry, key, existingCell);
1139
+ return proxyFor(existingCell.entry);
1140
+ }
1141
+ const childCell = {
1142
+ parent,
1143
+ owner: entry,
1144
+ entry: undefined,
1145
+ target: value,
1146
+ seg: segment,
1147
+ dead: false,
1148
+ };
1149
+ const child = wrap(value, childCell, childBlocked);
1150
+ cachePlacement(entry, key, childCell);
1151
+ return child;
1152
+ },
1153
+ set(target, key, value) {
1154
+ const entry = this.entry;
1155
+ if (entry.blocked !== undefined)
1156
+ throw new UnsafePathError(entry.blocked);
1157
+ const rawValue = unwrap(value);
1158
+ if (aliased ? liveCells(entry).length === 0 : isDetached(entry.fallback)) {
1159
+ return Reflect.set(target, key, rawValue);
1160
+ }
1161
+ if (Array.isArray(target) && key === "length") {
1162
+ const before = target.length;
1163
+ const next = Number(rawValue);
1164
+ if (!Number.isSafeInteger(next) || next < 0 || next > 4_294_967_295) {
1165
+ return Reflect.set(target, key, rawValue);
565
1166
  }
566
- const at = [...path, segment];
567
- if (value === undefined) {
568
- if (Array.isArray(target)) {
569
- throw new TypeError("undefined would create a sparse array; use splice instead");
1167
+ if (next < before) {
1168
+ if (next === 0) {
1169
+ if (pathNow(entry).length === 0)
1170
+ emit(entry, ["r", []]);
1171
+ else
1172
+ emit(entry, ["s", [...pathNow(entry)], []]);
570
1173
  }
571
- markValue(at);
572
- childProxies.delete(key);
573
- return Reflect.deleteProperty(target, key);
1174
+ else
1175
+ emit(entry, ["p", [...pathNow(entry)], next, before - next, []]);
1176
+ Reflect.set(target, key, next);
1177
+ renumber(entry, "splice", before, next, next, before - next, 0);
1178
+ entry.childProxies?.clear();
574
1179
  }
575
- const previous = target[key];
576
- if (previous === value)
577
- return true;
578
- const cached = childProxies.get(key);
579
- if (cached !== undefined && cached.target === previous && cached.proxy === value)
580
- return true;
581
- if (Array.isArray(target)) {
582
- const index = segment;
583
- if (index === target.length)
584
- markArrayAppend(path, target.length);
585
- else {
586
- const start = appendStart(path);
587
- if (start === undefined || index < start)
588
- markValue(at);
589
- }
1180
+ else if (next > before) {
1181
+ target.length = next;
1182
+ target.fill(null, before);
1183
+ const grown = new Array(next - before).fill(null);
1184
+ emit(entry, ["p", [...pathNow(entry)], before, 0, grown]);
590
1185
  }
1186
+ collapsePending();
1187
+ return true;
1188
+ }
1189
+ const segment = guard(norm(target, key));
1190
+ if (Array.isArray(target)) {
1191
+ if (typeof segment !== "number")
1192
+ throw new UnsafePathError(segment);
1193
+ if (segment > target.length)
1194
+ throw new UnsafePathError(segment);
1195
+ }
1196
+ const at = [...pathNow(entry), segment];
1197
+ if (rawValue === undefined) {
1198
+ if (Array.isArray(target))
1199
+ throw new TypeError("undefined would create a sparse array; use splice instead");
1200
+ if (Object.hasOwn(target, key))
1201
+ emit(entry, ["d", at]);
1202
+ entry.childProxies?.delete(key);
1203
+ const deleted = Reflect.deleteProperty(target, key);
1204
+ if (deleted)
1205
+ collapsePending();
1206
+ return deleted;
1207
+ }
1208
+ const previous = target[key];
1209
+ if (previous === rawValue)
1210
+ return true;
1211
+ if (Array.isArray(target) && segment === target.length) {
1212
+ emit(entry, ["p", [...pathNow(entry)], target.length, 0, [cloneJson(rawValue)]]);
1213
+ }
1214
+ else if (isObj(previous) && isObj(rawValue)) {
1215
+ diffInto(previous, rawValue, [...pathNow(entry), segment]);
1216
+ }
1217
+ else if (typeof previous === "string" && typeof rawValue === "string") {
1218
+ if (!aliased)
1219
+ recordString([...pathNow(entry), segment], previous, rawValue);
591
1220
  else
592
- markValue(at);
593
- childProxies.delete(key);
594
- return Reflect.set(target, key, value);
595
- },
596
- deleteProperty(target, key) {
597
- if (blockedSegment !== undefined)
598
- throw new UnsafePathError(blockedSegment);
599
- const segment = guard(norm(target, key));
600
- if (Array.isArray(target)) {
601
- if (typeof segment !== "number")
602
- throw new UnsafePathError(segment);
603
- throw new TypeError("delete would create a sparse array; use splice instead");
1221
+ for (const cell of liveCells(entry))
1222
+ recordString([...pathOf(cell), segment], previous, rawValue);
1223
+ }
1224
+ else {
1225
+ emit(entry, ["s", at, cloneJson(rawValue)]);
1226
+ }
1227
+ entry.childProxies?.delete(key);
1228
+ const updated = Reflect.set(target, key, rawValue);
1229
+ if (updated) {
1230
+ if (isObj(rawValue)) {
1231
+ const known = findEntry(rawValue);
1232
+ if (known !== undefined) {
1233
+ const placement = {
1234
+ parent: primary(entry),
1235
+ owner: entry,
1236
+ entry: undefined,
1237
+ target: rawValue,
1238
+ seg: segment,
1239
+ dead: false,
1240
+ };
1241
+ addPlacement(known, placement);
1242
+ cachePlacement(entry, key, placement);
1243
+ }
604
1244
  }
605
- markValue([...path, segment]);
606
- childProxies.delete(key);
1245
+ collapsePending();
1246
+ }
1247
+ return updated;
1248
+ },
1249
+ deleteProperty(target, key) {
1250
+ const entry = this.entry;
1251
+ if (entry.blocked !== undefined)
1252
+ throw new UnsafePathError(entry.blocked);
1253
+ if (aliased ? liveCells(entry).length === 0 : isDetached(entry.fallback)) {
607
1254
  return Reflect.deleteProperty(target, key);
608
- },
609
- defineProperty() {
610
- throw new TypeError("defineProperty is not supported on tracked state; use assignment");
611
- },
612
- setPrototypeOf() {
613
- throw new TypeError("setPrototypeOf is not supported on tracked state");
614
- },
615
- preventExtensions() {
616
- throw new TypeError("preventExtensions is not supported on tracked state");
617
- },
618
- });
1255
+ }
1256
+ const segment = guard(norm(target, key));
1257
+ if (Array.isArray(target)) {
1258
+ if (typeof segment !== "number")
1259
+ throw new UnsafePathError(segment);
1260
+ throw new TypeError("delete would create a sparse array; use splice instead");
1261
+ }
1262
+ if (Object.hasOwn(target, key))
1263
+ emit(entry, ["d", [...pathNow(entry), segment]]);
1264
+ entry.childProxies?.delete(key);
1265
+ const deleted = Reflect.deleteProperty(target, key);
1266
+ if (deleted)
1267
+ collapsePending();
1268
+ return deleted;
1269
+ },
1270
+ defineProperty() {
1271
+ throw new TypeError("defineProperty is not supported on tracked state; use assignment");
1272
+ },
1273
+ setPrototypeOf() {
1274
+ throw new TypeError("setPrototypeOf is not supported on tracked state");
1275
+ },
1276
+ preventExtensions() {
1277
+ throw new TypeError("preventExtensions is not supported on tracked state");
1278
+ },
1279
+ };
1280
+ function proxyFor(entry) {
1281
+ const cached = jobProxies.get(entry);
1282
+ if (cached !== undefined)
1283
+ return cached;
1284
+ const existing = entry.proxy?.deref();
1285
+ if (existing !== undefined) {
1286
+ jobProxies.set(entry, existing);
1287
+ scheduleJobCleanup();
1288
+ return existing;
1289
+ }
1290
+ const handler = Object.create(handlerPrototype);
1291
+ handler.entry = entry;
1292
+ const proxy = new Proxy(entry.target, handler);
1293
+ entry.proxy = new WeakRef(proxy);
1294
+ jobProxies.set(entry, proxy);
1295
+ scheduleJobCleanup();
1296
+ proxyEntries.set(proxy, entry);
619
1297
  return proxy;
1298
+ }
1299
+ const rootCell = {
1300
+ parent: undefined,
1301
+ owner: undefined,
1302
+ entry: undefined,
1303
+ target: root,
1304
+ seg: "",
1305
+ dead: false,
620
1306
  };
621
- let state = wrap(root, []);
1307
+ let state = wrap(root, rootCell);
622
1308
  return {
623
1309
  get state() {
624
1310
  return state;
@@ -627,15 +1313,22 @@ export function track(root, options = {}) {
627
1313
  return root;
628
1314
  },
629
1315
  set state(next) {
630
- if (next === state) {
1316
+ const rawNext = unwrap(next);
1317
+ if (rawNext === root) {
631
1318
  clearPending();
632
1319
  forceBase = true;
633
1320
  return;
634
1321
  }
635
1322
  clearPending();
636
- root = next;
637
- state = wrap(root, []);
638
- baseline = undefined;
1323
+ root = rawNext;
1324
+ state = wrap(root, {
1325
+ parent: undefined,
1326
+ owner: undefined,
1327
+ entry: undefined,
1328
+ target: root,
1329
+ seg: "",
1330
+ dead: false,
1331
+ });
639
1332
  forceBase = true;
640
1333
  },
641
1334
  rebase() {
@@ -643,33 +1336,31 @@ export function track(root, options = {}) {
643
1336
  forceBase = true;
644
1337
  },
645
1338
  get dirty() {
1339
+ // conservative: true if anything was written since the last flush, even
1340
+ // if the writes cancelled out
646
1341
  return forceBase || hasPending;
647
1342
  },
648
1343
  discard() {
649
- baseline = cloneJson(root);
650
1344
  clearPending();
651
1345
  },
652
1346
  flush() {
653
1347
  if (forceBase) {
654
1348
  const value = cloneJson(root);
655
- baseline = cloneJson(root);
656
1349
  forceBase = false;
657
1350
  clearPending();
658
1351
  return [["r", value]];
659
1352
  }
660
- if (!hasPending || baseline === undefined)
1353
+ if (!hasPending)
661
1354
  return [];
662
1355
  const out = [];
663
- walkDirty(baseline, root, pending, [], scan, out);
664
- // Advance the baseline by SHARING references from root where that is
665
- // cheap and exact — scalars, strings, and array appends. Replaying the
666
- // ops rebuilds every touched string via slice + concat: two window-sized
667
- // allocations per flush and a cons the next flush must flatten. For
668
- // anything the sync cannot express cheaply (a non-append array change),
669
- // it declines and the original replay runs unchanged.
670
- if (!syncBaseline(baseline, root, pending)) {
671
- if (out.length > 0)
672
- baseline = apply(baseline, out.map(cloneOp));
1356
+ for (const slot of log) {
1357
+ if (slot === undefined || slot.dead)
1358
+ continue;
1359
+ if (slot.str !== undefined) {
1360
+ diffValue(slot.str.anchor, slot.str.value, slot.op[1], scan, out);
1361
+ continue;
1362
+ }
1363
+ out.push(slot.op);
673
1364
  }
674
1365
  clearPending();
675
1366
  return out;