@solidjs/signals 2.0.0-rc.6 → 2.0.0-rc.7

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 (65) hide show
  1. package/dist/dev.js +2033 -1291
  2. package/dist/node.cjs +1821 -2401
  3. package/dist/node.dev.cjs +13724 -0
  4. package/dist/prod/boundaries.js +3 -1
  5. package/dist/prod/core/async.js +6 -1
  6. package/dist/prod/core/attribution-hooks.js +3 -0
  7. package/dist/prod/core/constants.js +9 -1
  8. package/dist/prod/core/context.js +10 -16
  9. package/dist/prod/core/core.js +215 -139
  10. package/dist/prod/core/dev.js +2 -0
  11. package/dist/prod/core/effect.js +37 -28
  12. package/dist/prod/core/external.js +2 -2
  13. package/dist/prod/core/graph.js +35 -31
  14. package/dist/prod/core/heap.js +34 -40
  15. package/dist/prod/core/lanes.js +41 -27
  16. package/dist/prod/core/optimistic.js +42 -32
  17. package/dist/prod/core/owner.js +32 -32
  18. package/dist/prod/core/scheduler.js +140 -154
  19. package/dist/prod/core/verdict.js +31 -34
  20. package/dist/prod/index.js +0 -2
  21. package/dist/prod/map.js +104 -94
  22. package/dist/prod/signals.js +51 -34
  23. package/dist/prod/store/next/optimistic.js +153 -172
  24. package/dist/prod/store/next/reconcile.js +140 -288
  25. package/dist/prod/store/next/store.js +307 -237
  26. package/dist/prod/store/store.js +2 -2
  27. package/dist/types/core/attribution-hooks.d.ts +64 -0
  28. package/dist/types/core/attribution.d.ts +265 -9
  29. package/dist/types/core/constants.d.ts +8 -0
  30. package/dist/types/core/core.d.ts +12 -3
  31. package/dist/types/core/dev.d.ts +49 -8
  32. package/dist/types/core/heap.d.ts +5 -3
  33. package/dist/types/core/invariants.d.ts +1 -1
  34. package/dist/types/core/lanes.d.ts +10 -0
  35. package/dist/types/core/scheduler.d.ts +9 -4
  36. package/dist/types/signals.d.ts +10 -0
  37. package/dist/types/store/index.d.ts +3 -6
  38. package/dist/types/store/next/optimistic.d.ts +4 -2
  39. package/dist/types/store/next/reconcile.d.ts +5 -12
  40. package/dist/types/store/next/store.d.ts +3 -9
  41. package/dist/types/store/next/target.d.ts +20 -46
  42. package/dist/types/store/store.d.ts +14 -9
  43. package/dist/types-cjs/core/attribution-hooks.d.cts +64 -0
  44. package/dist/types-cjs/core/attribution.d.cts +265 -9
  45. package/dist/types-cjs/core/constants.d.cts +8 -0
  46. package/dist/types-cjs/core/core.d.cts +12 -3
  47. package/dist/types-cjs/core/dev.d.cts +49 -8
  48. package/dist/types-cjs/core/heap.d.cts +5 -3
  49. package/dist/types-cjs/core/invariants.d.cts +1 -1
  50. package/dist/types-cjs/core/lanes.d.cts +10 -0
  51. package/dist/types-cjs/core/scheduler.d.cts +9 -4
  52. package/dist/types-cjs/signals.d.cts +10 -0
  53. package/dist/types-cjs/store/index.d.cts +3 -6
  54. package/dist/types-cjs/store/next/optimistic.d.cts +4 -2
  55. package/dist/types-cjs/store/next/reconcile.d.cts +5 -12
  56. package/dist/types-cjs/store/next/store.d.cts +3 -9
  57. package/dist/types-cjs/store/next/target.d.cts +20 -46
  58. package/dist/types-cjs/store/store.d.cts +14 -9
  59. package/package.json +3 -2
  60. package/dist/prod/store/next/patch-hooks.js +0 -13
  61. package/dist/prod/store/next/patch.js +0 -614
  62. package/dist/types/store/next/patch-hooks.d.ts +0 -41
  63. package/dist/types/store/next/patch.d.ts +0 -91
  64. package/dist/types-cjs/store/next/patch-hooks.d.cts +0 -41
  65. package/dist/types-cjs/store/next/patch.d.cts +0 -91
package/dist/dev.js CHANGED
@@ -183,6 +183,14 @@ const CONFIG_FRESH_READ = 1 << 16;
183
183
  * (A17). Cleared at commit (the commit IS the reveal); subscribers masked
184
184
  * during the hold are woken by finalizePureQueue's post-revert pass. */
185
185
  const CONFIG_HELD_TRUTH = 1 << 17;
186
+ /** SLOT node (store leaf): created through `slotSignal` with `_host`/`_key`
187
+ * backrefs baked into the literal. The unobserved sweep dispatches these to
188
+ * the ONE shared hook (`setSlotUnobserved`) instead of a per-node closure
189
+ * held in a per-node extension — store mounts materialize one signal per
190
+ * touched leaf, so per-node allocations (options object, equals closure,
191
+ * unobserved closure, NodeExtension) were the measured create-floor bytes
192
+ * (warm dbmon profile: store node machinery ~36% + GC ~29%). */
193
+ const CONFIG_SLOT_NODE = 1 << 18;
186
194
  const STATUS_PENDING = 1 << 0;
187
195
  const STATUS_ERROR = 1 << 1;
188
196
  const STATUS_UNINITIALIZED = 1 << 2;
@@ -233,7 +241,9 @@ const defaultOptions = {
233
241
  hotTime: { budgetMs: 8, windowMs: 1000 },
234
242
  unstableMemos: 4,
235
243
  wideWrites: 250,
236
- waterfalls: { minFlightMs: 50 }
244
+ waterfalls: { minFlightMs: 50 },
245
+ holds: { infoMs: 100, warnMs: 200 },
246
+ longHolds: { infoMs: 500, warnMs: 1000 }
237
247
  };
238
248
  let options = { ...defaultOptions };
239
249
  const listeners = new Set();
@@ -309,6 +319,99 @@ function captureStack() {
309
319
  }
310
320
  /** Sentinel for "no value transition to record" (refresh() stamps). */
311
321
  const NO_VALUES = Symbol("no-values");
322
+ // --- Provenance -------------------------------------------------------------
323
+ //
324
+ // Who performed a write is not a graph fact — the graph only sees the write.
325
+ // The engine keeps an ambient answer: a stack of imperative frames the core
326
+ // announces (effect callbacks, action steps) and the interaction the web
327
+ // runtime declares around event dispatch. A write stamps the innermost frame;
328
+ // frames nested under an interaction carry it. Effects run in a later flush
329
+ // than the click that caused them, so their frame inherits the interaction
330
+ // from the run's cause chain instead (recorded at recomputeEnd).
331
+ const EXTERNAL_ORIGIN = { kind: "external" };
332
+ const originFrames = [];
333
+ /** Per action invocation (keyed by its iterator): the interaction its first step ran under. */
334
+ const actionInteractions = new WeakMap();
335
+ /**
336
+ * Set by `withInteraction` for the duration of a handler. Lives outside the
337
+ * enable/disable lifecycle on purpose: the web runtime marks dispatch whether
338
+ * or not an engine is listening, and enable() mid-handler must see the mark.
339
+ */
340
+ let currentInteraction = null;
341
+ /** The interaction an origin runs under (itself, when it is one). */
342
+ function interactionOf(origin) {
343
+ if (origin === undefined) return undefined;
344
+ return origin.kind === "interaction" ? origin : origin.interaction;
345
+ }
346
+ /** The interaction a cause list traces back to — root writes only, derived links walked. */
347
+ function interactionIn(causes) {
348
+ for (const c of causes) {
349
+ const found =
350
+ c.kind === "derived"
351
+ ? c.causes !== undefined
352
+ ? interactionIn(c.causes)
353
+ : undefined
354
+ : interactionOf(c.origin);
355
+ if (found !== undefined) return found;
356
+ }
357
+ return undefined;
358
+ }
359
+ function currentOrigin() {
360
+ const frame = originFrames[originFrames.length - 1];
361
+ if (frame !== undefined) return frame;
362
+ return currentInteraction ?? EXTERNAL_ORIGIN;
363
+ }
364
+ const effectFrames = new WeakMap();
365
+ function pushFrame(kind, name, interaction, effect) {
366
+ const frame = { kind };
367
+ if (name) frame.name = name;
368
+ const under = interaction ?? currentInteraction ?? undefined;
369
+ if (under !== undefined) frame.interaction = under;
370
+ if (effect !== undefined) {
371
+ const node = effect;
372
+ if (node._devRunSeq !== undefined) frame.run = node._devRunSeq;
373
+ effectFrames.set(frame, { node: effect, causes: node._devRunCauses });
374
+ }
375
+ originFrames.push(frame);
376
+ }
377
+ function popFrame(kind) {
378
+ // Frames are strictly nested; a mismatch means enable() landed mid-frame
379
+ // (the opener never pushed) — leave the stack alone rather than pop a stranger.
380
+ const top = originFrames[originFrames.length - 1];
381
+ if (top !== undefined && top.kind === kind) originFrames.pop();
382
+ }
383
+ /**
384
+ * Run `fn` as the handler of a user interaction: every root write it performs
385
+ * (and every action step or effect the write causes) carries the interaction
386
+ * as provenance. The web runtime wraps event dispatch in this; it is dev-only
387
+ * and safe to call with no engine enabled.
388
+ */
389
+ function withInteraction(ref, fn) {
390
+ const prev = currentInteraction;
391
+ const origin = { kind: "interaction", name: ref.type, at: ref.at ?? now() };
392
+ if (ref.target) origin.target = ref.target;
393
+ currentInteraction = origin;
394
+ try {
395
+ return fn();
396
+ } finally {
397
+ currentInteraction = prev;
398
+ }
399
+ }
400
+ /** `click on button#next "Next →"`, `effect "syncTitle"`, `action "save"`, … */
401
+ function formatOrigin(origin) {
402
+ switch (origin.kind) {
403
+ case "interaction":
404
+ return `${origin.name} on ${origin.target ?? "an element"}`;
405
+ case "effect":
406
+ return `effect${origin.name ? ` "${origin.name}"` : ""}`;
407
+ case "action":
408
+ return `action${origin.name ? ` "${origin.name}"` : ""}`;
409
+ case "async":
410
+ return `async landing${origin.name ? ` on "${origin.name}"` : ""}`;
411
+ default:
412
+ return "outside the reactive system";
413
+ }
414
+ }
312
415
  /** Record a root change (setSignal / refresh / async landing) on the node. */
313
416
  /**
314
417
  * Written-fan-out warning — the write-time complement of the always-on
@@ -332,17 +435,21 @@ function checkWideWrite(node, kind) {
332
435
  const message =
333
436
  `[WIDE_WRITE] ${verb} "${nodeName(node)}" reached ${subs} subscribers — every one ` +
334
437
  `re-runs this flush. If consumers ask keyed questions of this value (for example every ` +
335
- `row comparing against one selected id), invert with createSelector or createProjection ` +
336
- `so only the keys whose answer flipped update.`;
337
- emitDiagnostic({
338
- code: "WIDE_WRITE",
339
- kind: "perf",
340
- severity: "warn",
341
- message,
342
- nodeName: nodeName(node),
343
- data: { subscribers: subs, write: kind }
344
- });
345
- console.warn(message);
438
+ `row comparing against one selected id), invert it: keep the answer in a store used as a ` +
439
+ `map keyed by id, so each consumer reads its own key and only the keys that flipped update.`;
440
+ reportDiagnostic(
441
+ emitDiagnostic(
442
+ {
443
+ code: "WIDE_WRITE",
444
+ kind: "perf",
445
+ severity: "warn",
446
+ message,
447
+ nodeName: nodeName(node),
448
+ data: { subscribers: subs, write: kind }
449
+ },
450
+ node
451
+ )
452
+ );
346
453
  }
347
454
  function stampWrite(node, kind, prev = NO_VALUES, value = NO_VALUES) {
348
455
  const record = { seq: ++changeSeq, kind, name: nodeName(node) };
@@ -350,8 +457,11 @@ function stampWrite(node, kind, prev = NO_VALUES, value = NO_VALUES) {
350
457
  record.prev = prev === NO_VALUES ? undefined : preview(prev);
351
458
  record.value = preview(value);
352
459
  }
460
+ record.origin = kind === "async" ? asyncOrigin(node) : currentOrigin();
461
+ record.at = now();
353
462
  record.stack = captureStack();
354
463
  node._devChange = record;
464
+ if (kind === "write") trackEffectWrite(node, record, value);
355
465
  // stampWrite is the single funnel for committed root invalidations (sync
356
466
  // writes, refresh(), async landings), which makes it the one place the
357
467
  // written-fan-out check needs to live.
@@ -416,15 +526,19 @@ function checkDepWidth(el) {
416
526
  `[WIDE_SCOPE_DEPS] ${kind} "${nodeName(el)}" is subscribed to ${count} sources — ` +
417
527
  `it re-runs when any of them change. Narrow its reads or split it into smaller memos. ` +
418
528
  `Sources: ${names.join(", ")}${count > names.length ? ", …" : ""}`;
419
- emitDiagnostic({
420
- code: "WIDE_SCOPE_DEPS",
421
- kind: "perf",
422
- severity: "warn",
423
- message,
424
- nodeName: nodeName(el),
425
- data: { depCount: count, deps: names }
426
- });
427
- console.warn(message);
529
+ reportDiagnostic(
530
+ emitDiagnostic(
531
+ {
532
+ code: "WIDE_SCOPE_DEPS",
533
+ kind: "perf",
534
+ severity: "warn",
535
+ message,
536
+ nodeName: nodeName(el),
537
+ data: { depCount: count, deps: names }
538
+ },
539
+ el
540
+ )
541
+ );
428
542
  }
429
543
  const hotCauses = new Map();
430
544
  const HOT_FANOUT_FIRST_MILESTONE = 5;
@@ -465,19 +579,23 @@ function checkHotRuns(el, event) {
465
579
  `[HOT_SCOPE_RERUNS] ${event.nodeKind} "${event.nodeName}" re-ran ${node._devWinCount} times ` +
466
580
  `in ${Math.max(1, now - node._devWinStart)}ms — a hot signal is likely leaking into this ` +
467
581
  `scope. Latest cause: ${rootCause || "(untracked pull)"}`;
468
- emitDiagnostic({
469
- code: "HOT_SCOPE_RERUNS",
470
- kind: "perf",
471
- severity: "warn",
472
- message,
473
- nodeName: event.nodeName,
474
- data: {
475
- runs: node._devWinCount,
476
- windowMs: cfg.windowMs,
477
- causes: event.causes.map(c => c.name)
478
- }
479
- });
480
- console.warn(message);
582
+ reportDiagnostic(
583
+ emitDiagnostic(
584
+ {
585
+ code: "HOT_SCOPE_RERUNS",
586
+ kind: "perf",
587
+ severity: "warn",
588
+ message,
589
+ nodeName: event.nodeName,
590
+ data: {
591
+ runs: node._devWinCount,
592
+ windowMs: cfg.windowMs,
593
+ causes: event.causes.map(c => c.name)
594
+ }
595
+ },
596
+ el
597
+ )
598
+ );
481
599
  return;
482
600
  }
483
601
  // Additional scopes hot from the same cause: silent until a milestone —
@@ -488,16 +606,22 @@ function checkHotRuns(el, event) {
488
606
  `[HOT_SCOPE_FANOUT] ${window.scopes} scopes have gone hot (${window.runs} re-runs) within ` +
489
607
  `${cfg.windowMs}ms, all driven by ${causeKey} — one hot cause is re-running a large part ` +
490
608
  `of the graph. Per-scope warnings are suppressed; fix the cause. If consumers ask keyed ` +
491
- `questions of it, invert with createSelector or createProjection.`;
492
- emitDiagnostic({
493
- code: "HOT_SCOPE_FANOUT",
494
- kind: "perf",
495
- severity: "warn",
496
- message,
497
- nodeName: causeKey,
498
- data: { cause: causeKey, scopes: window.scopes, runs: window.runs, windowMs: cfg.windowMs }
499
- });
500
- console.warn(message);
609
+ `questions of it, invert it: a store used as a map keyed by id, one key per consumer.`;
610
+ // The subject is the shared CAUSE, not this victim scope — no single owner
611
+ // path locates it, so the event carries none.
612
+ reportDiagnostic(
613
+ emitDiagnostic(
614
+ {
615
+ code: "HOT_SCOPE_FANOUT",
616
+ kind: "perf",
617
+ severity: "warn",
618
+ message,
619
+ nodeName: causeKey,
620
+ data: { cause: causeKey, scopes: window.scopes, runs: window.runs, windowMs: cfg.windowMs }
621
+ },
622
+ null
623
+ )
624
+ );
501
625
  }
502
626
  /**
503
627
  * Time-budget warning — the counterpart of checkHotRuns for the
@@ -522,23 +646,28 @@ function checkHotTime(el, event) {
522
646
  `[HOT_SCOPE_TIME] ${event.nodeKind} "${event.nodeName}" spent ` +
523
647
  `${node._devTimeWinMs.toFixed(1)}ms of compute inside one ${cfg.windowMs}ms window ` +
524
648
  `(budget ${cfg.budgetMs}ms). Latest cause: ${rootCause || "(untracked pull)"}`;
525
- emitDiagnostic({
526
- code: "HOT_SCOPE_TIME",
527
- kind: "perf",
528
- severity: "warn",
529
- message,
530
- nodeName: event.nodeName,
531
- data: {
532
- spentMs: node._devTimeWinMs,
533
- budgetMs: cfg.budgetMs,
534
- windowMs: cfg.windowMs,
535
- causes: event.causes.map(c => c.name)
536
- }
537
- });
538
- console.warn(message);
649
+ reportDiagnostic(
650
+ emitDiagnostic(
651
+ {
652
+ code: "HOT_SCOPE_TIME",
653
+ kind: "perf",
654
+ severity: "warn",
655
+ message,
656
+ nodeName: event.nodeName,
657
+ data: {
658
+ spentMs: node._devTimeWinMs,
659
+ budgetMs: cfg.budgetMs,
660
+ windowMs: cfg.windowMs,
661
+ causes: event.causes.map(c => c.name)
662
+ }
663
+ },
664
+ el
665
+ )
666
+ );
539
667
  }
540
668
  function recordRerun(el, causes, prevDeps, timing, changed, phase, held) {
541
669
  const node = el;
670
+ const prevCauses = node._devRunCauses;
542
671
  // Subscription diff: `prevDeps` was captured at run entry; `_deps` now
543
672
  // holds the fresh set. A changed set is the "helper edit changed distant
544
673
  // call sites" signal — surfaced per-event and in the console format.
@@ -565,19 +694,35 @@ function recordRerun(el, causes, prevDeps, timing, changed, phase, held) {
565
694
  phase,
566
695
  held
567
696
  };
697
+ const interaction = interactionIn(causes);
698
+ if (interaction !== undefined) event.interaction = interaction;
699
+ // The effect phase runs later in the flush with no cause list of its own:
700
+ // it inherits this run's interaction and is joined to this run's causes
701
+ // (see effectRunStart / pushFrame).
702
+ node._devRunInteraction = interaction;
703
+ node._devRunSeq = event.run;
704
+ node._devRunCauses = causes;
568
705
  history.push(event);
569
706
  if (history.length > options.historyLimit) history.shift();
570
707
  recordCosts(event);
708
+ recordFeedbackRun(event);
709
+ if (event.nodeKind === "effect") checkEffectCycle(el, causes);
710
+ checkRelayTear(el, causes, prevCauses);
571
711
  checkHotRuns(el, event);
572
712
  checkHotTime(el, event);
573
713
  checkDepWidth(el);
574
714
  for (const listener of listeners) listener(event);
575
- if (options.log) console.log(formatRerun(event));
715
+ if (options.log) logRerun(event);
576
716
  }
577
717
  function formatCause(cause, depth, out) {
578
718
  const pad = " ".repeat(depth + 1);
579
719
  let line = `${pad}← ${cause.kind === "derived" ? "memo" : "signal"} "${cause.name}" ${cause.kind === "derived" ? "changed" : cause.kind} (#${cause.seq})`;
580
720
  if (cause.prev !== undefined) line += ` ${cause.prev} → ${cause.value}`;
721
+ if (cause.origin !== undefined && cause.origin.kind !== "external") {
722
+ line += ` — ${formatOrigin(cause.origin)}`;
723
+ const under = cause.origin.interaction;
724
+ if (under !== undefined) line += ` (under ${formatOrigin(under)})`;
725
+ }
581
726
  out.push(line);
582
727
  if (cause.stack) for (const frame of cause.stack) out.push(`${pad} ${frame}`);
583
728
  if (cause.causes && depth < 10) {
@@ -601,6 +746,22 @@ function formatRerun(event) {
601
746
  }
602
747
  return out.join("\n");
603
748
  }
749
+ /**
750
+ * Console face of a re-run: the headline as a collapsed group with the
751
+ * why-chain and dep delta inside, so a busy console stays scannable (one line
752
+ * per run, evidence a click away). Consoles without grouping get the text.
753
+ */
754
+ function logRerun(event) {
755
+ const text = formatRerun(event);
756
+ const nl = text.indexOf("\n");
757
+ if (nl === -1 || typeof console.groupCollapsed !== "function") {
758
+ console.log(text);
759
+ return;
760
+ }
761
+ console.groupCollapsed(text.slice(0, nl));
762
+ console.log(text.slice(nl + 1));
763
+ console.groupEnd();
764
+ }
604
765
  /**
605
766
  * Values eligible for the unstable-output check: plain objects and arrays
606
767
  * only. Promises, iterators, Dates, Maps, class instances etc. all have no
@@ -664,15 +825,434 @@ function checkUnstableOutput(el, prevValue, newValue) {
664
825
  `${node._devUnstableRuns} consecutive runs — its equality gate never closes, so every ` +
665
826
  `subscriber re-runs on every upstream change. Return stable references or pass an ` +
666
827
  `\`equals\` option.`;
667
- emitDiagnostic({
668
- code: "UNSTABLE_MEMO_OUTPUT",
669
- kind: "perf",
670
- severity: "warn",
671
- message,
672
- nodeName: nodeName(el),
673
- data: { runs: node._devUnstableRuns, shape }
674
- });
675
- console.warn(message);
828
+ reportDiagnostic(
829
+ emitDiagnostic(
830
+ {
831
+ code: "UNSTABLE_MEMO_OUTPUT",
832
+ kind: "perf",
833
+ severity: "warn",
834
+ message,
835
+ nodeName: nodeName(el),
836
+ data: { runs: node._devUnstableRuns, shape }
837
+ },
838
+ el
839
+ )
840
+ );
841
+ }
842
+ const EFFECT_CYCLE_MAX_HOPS = 6;
843
+ const reportedCycles = new Set();
844
+ let nextDevId = 0;
845
+ const devIds = new WeakMap();
846
+ function devId(node) {
847
+ let id = devIds.get(node);
848
+ if (id === undefined) devIds.set(node, (id = ++nextDevId));
849
+ return id;
850
+ }
851
+ function rootWrites(causes, out) {
852
+ for (const c of causes) {
853
+ if (c.kind === "derived") {
854
+ if (c.causes !== undefined) rootWrites(c.causes, out);
855
+ } else out.push(c);
856
+ }
857
+ }
858
+ /**
859
+ * The effect writes leading from an earlier run of `target` to the run whose
860
+ * `causes` these are, in causal order (target's own write first), or null.
861
+ */
862
+ function findEffectCycle(target, causes, visited, hops) {
863
+ const roots = [];
864
+ rootWrites(causes, roots);
865
+ for (const write of roots) {
866
+ const origin = write.origin;
867
+ if (origin === undefined || origin.kind !== "effect") continue;
868
+ const info = effectFrames.get(origin);
869
+ if (info === undefined) continue;
870
+ if (info.node === target) return [{ effect: info.node, write }];
871
+ if (hops >= EFFECT_CYCLE_MAX_HOPS || visited.has(info.node) || info.causes === undefined)
872
+ continue;
873
+ visited.add(info.node);
874
+ const rest = findEffectCycle(target, info.causes, visited, hops + 1);
875
+ if (rest !== null) {
876
+ rest.push({ effect: info.node, write });
877
+ return rest;
878
+ }
879
+ }
880
+ return null;
881
+ }
882
+ /** Names of the memos between a direct cause of a run and `write`, root-first. */
883
+ function derivedPath(causes, write, path) {
884
+ for (const c of causes) {
885
+ if (c === write) return true;
886
+ if (c.kind === "derived" && c.causes !== undefined) {
887
+ path.unshift(c.name);
888
+ if (derivedPath(c.causes, write, path)) return true;
889
+ path.shift();
890
+ }
891
+ }
892
+ return false;
893
+ }
894
+ function describeWrite(write) {
895
+ if (write.kind === "refresh") return `refreshed "${write.name}"`;
896
+ const values = write.prev !== undefined ? ` (${write.prev} → ${write.value})` : "";
897
+ return `wrote "${write.name}"${values}`;
898
+ }
899
+ function checkEffectCycle(el, causes) {
900
+ const links = findEffectCycle(el, causes, new Set([el]), 0);
901
+ if (links === null) return;
902
+ const key = links
903
+ .map(link => devId(link.effect))
904
+ .sort((a, b) => a - b)
905
+ .join(",");
906
+ if (reportedCycles.has(key)) return;
907
+ reportedCycles.add(key);
908
+ const flushes = links.length + 1;
909
+ let message;
910
+ if (links.length === 1) {
911
+ const [{ write }] = links;
912
+ const path = [];
913
+ derivedPath(causes, write, path);
914
+ const via = path.length > 0 ? ` through ${path.map(n => `memo "${n}"`).join(" → ")}` : "";
915
+ message =
916
+ `[EFFECT_WRITES_OWN_SOURCE] effect "${nodeName(el)}" re-ran because of its own write: it ` +
917
+ `${describeWrite(write)}, which fed back into its inputs${via}. Two flushes to settle, ` +
918
+ `and the screen rendered the pre-write value in between. The written value is a function ` +
919
+ `of what the effect reads — compute it in a memo (or normalize where the source is ` +
920
+ `written) instead of correcting it after the fact.`;
921
+ } else {
922
+ const names = links.map(link => `"${nodeName(link.effect)}"`);
923
+ const steps = links
924
+ .map(
925
+ (link, i) => `effect ${names[i]}${i > 0 ? " re-ran and" : ""} ${describeWrite(link.write)}`
926
+ )
927
+ .join("; ");
928
+ message =
929
+ `[EFFECT_WRITES_OWN_SOURCE] effects ${[...names, names[0]].join(" → ")} relay writes in a ` +
930
+ `cycle: ${steps}; which fed back into effect ${names[0]}'s inputs — ${flushes} flushes to ` +
931
+ `settle after each change, each rendering an intermediate state. Every relayed value is a ` +
932
+ `function of the original inputs: derive them in memos and drop the writes.`;
933
+ }
934
+ const severity = links.length === 1 ? "warn" : "info";
935
+ const entry = emitDiagnostic(
936
+ {
937
+ code: "EFFECT_WRITES_OWN_SOURCE",
938
+ kind: "perf",
939
+ severity,
940
+ message,
941
+ nodeName: nodeName(el),
942
+ data: {
943
+ effects: links.map(link => nodeName(link.effect)),
944
+ writes: links.map(link => ({
945
+ effect: nodeName(link.effect),
946
+ kind: link.write.kind,
947
+ name: link.write.name,
948
+ prev: link.write.prev,
949
+ value: link.write.value
950
+ })),
951
+ flushes
952
+ }
953
+ },
954
+ el
955
+ );
956
+ if (severity === "warn") reportDiagnostic(entry);
957
+ }
958
+ const RELAY_WARN_AT = 3;
959
+ const relays = new Map();
960
+ const copyWrites = new WeakSet();
961
+ const copyReported = new WeakSet();
962
+ /** The node each root write record was stamped on (records are serializable and cannot hold it). */
963
+ const recordNodes = new WeakMap();
964
+ /**
965
+ * Write-side bookkeeping for the relay heuristics: who has written this
966
+ * signal, and whether an effect just copied its compute output into it.
967
+ */
968
+ function trackEffectWrite(node, record, value) {
969
+ const n = node;
970
+ const origin = record.origin;
971
+ const info =
972
+ origin !== undefined && origin.kind === "effect" ? effectFrames.get(origin) : undefined;
973
+ const writer = info === undefined ? 0 : devId(info.node);
974
+ recordNodes.set(record, node);
975
+ n._devSoleWriter = n._devSoleWriter === undefined || n._devSoleWriter === writer ? writer : null;
976
+ if (info !== undefined && value !== undefined && value === info.node._value) {
977
+ copyWrites.add(record);
978
+ n._devCopyRuns = n._devCopyFrom === writer ? (n._devCopyRuns ?? 0) + 1 : 1;
979
+ n._devCopyFrom = writer;
980
+ if (n._devCopyRuns >= 2 && n._devSoleWriter === writer) checkCopyEffect(info.node, node);
981
+ } else n._devCopyRuns = 0;
982
+ }
983
+ /** The effect's source whose current value the compute output is, if any (the prop-to-state port). */
984
+ function passthroughSource(effect) {
985
+ for (let l = effect._deps; l !== null; l = l._nextDep)
986
+ if (l._dep._value === effect._value) return nodeName(l._dep);
987
+ return undefined;
988
+ }
989
+ /** The repair for a write that is the effect's compute output. */
990
+ function copyRepair(effect, target) {
991
+ const source = passthroughSource(effect);
992
+ return source !== undefined
993
+ ? `The written value is "${source}" itself: read "${source}" where "${target}" is read ` +
994
+ `(or createMemo it if a stable derivation is needed) and delete the effect.`
995
+ : `The written value is the effect's compute output — by contract a pure function of ` +
996
+ `what it tracks: make "${target}" a memo of that computation and delete the effect.`;
997
+ }
998
+ function checkCopyEffect(effect, target) {
999
+ if (copyReported.has(target)) return;
1000
+ copyReported.add(target);
1001
+ const name = nodeName(target);
1002
+ const message =
1003
+ `[EFFECT_RELAY_TEAR] effect "${nodeName(effect)}" writes its compute output into ` +
1004
+ `"${name}" on every run, and nothing else writes "${name}" — it is derived state kept ` +
1005
+ `one flush late: everything reading it paints a frame behind everything reading the ` +
1006
+ `source. ${copyRepair(effect, name)}`;
1007
+ reportDiagnostic(
1008
+ emitDiagnostic(
1009
+ {
1010
+ code: "EFFECT_RELAY_TEAR",
1011
+ kind: "perf",
1012
+ severity: "warn",
1013
+ message,
1014
+ nodeName: nodeName(effect),
1015
+ data: {
1016
+ relay: nodeName(effect),
1017
+ wrote: name,
1018
+ copy: true,
1019
+ passthrough: passthroughSource(effect) ?? null,
1020
+ soleWriter: true
1021
+ }
1022
+ },
1023
+ effect
1024
+ )
1025
+ );
1026
+ }
1027
+ /**
1028
+ * `victim` re-ran with `causes`; its previous run had `prevCauses`. A tear is
1029
+ * a re-run whose root writes ALL came from effects (no independent outside
1030
+ * cause) and at least one of which was made by a run that shares a root
1031
+ * write with the victim's previous run.
1032
+ */
1033
+ function checkRelayTear(victim, causes, prevCauses) {
1034
+ if (prevCauses === undefined || causes.length === 0) return;
1035
+ const roots = [];
1036
+ rootWrites(causes, roots);
1037
+ if (roots.length === 0) return;
1038
+ let relay;
1039
+ let write;
1040
+ let shared;
1041
+ for (const root of roots) {
1042
+ const origin = root.origin;
1043
+ if (origin === undefined || origin.kind !== "effect") return;
1044
+ const info = effectFrames.get(origin);
1045
+ // Own-source cycles are EFFECT_WRITES_OWN_SOURCE's; a create-run relay
1046
+ // is initial sync, not a tear for one change.
1047
+ if (info === undefined || info.node === victim || info.causes === undefined) return;
1048
+ if (shared === undefined) {
1049
+ const relayRoots = [];
1050
+ rootWrites(info.causes, relayRoots);
1051
+ const prevRoots = [];
1052
+ rootWrites(prevCauses, prevRoots);
1053
+ const hit = relayRoots.find(r => prevRoots.includes(r));
1054
+ if (hit !== undefined) {
1055
+ shared = hit;
1056
+ relay = info;
1057
+ write = root;
1058
+ }
1059
+ }
1060
+ }
1061
+ if (shared === undefined || relay === undefined || write === undefined) return;
1062
+ const key = `${devId(relay.node)}:${write.name}`;
1063
+ let state = relays.get(key);
1064
+ if (state === undefined) relays.set(key, (state = { count: 0, warned: false }));
1065
+ state.count++;
1066
+ const copy = copyWrites.has(write);
1067
+ const target = recordNodes.get(write);
1068
+ const soleWriter = target !== undefined && target._devSoleWriter === devId(relay.node);
1069
+ // Derivable outright: the value is the compute output and nothing else
1070
+ // writes the signal. A copy INTO a signal that has other writers is the
1071
+ // "reset editable state from a source" shape — the tear is real, but a memo
1072
+ // is not the answer, so it stays advisory like any other non-derivable tear.
1073
+ const derivable = copy && soleWriter;
1074
+ const severity = derivable || state.count >= RELAY_WARN_AT ? "warn" : "info";
1075
+ // First sighting always reports (advisory); afterwards only the escalation.
1076
+ if (state.count > 1 && (severity !== "warn" || state.warned)) return;
1077
+ // One verdict per derivable signal: the copy report (checkCopyEffect) and
1078
+ // the tear report carry the same repair.
1079
+ if (derivable && target !== undefined) {
1080
+ if (copyReported.has(target)) return;
1081
+ copyReported.add(target);
1082
+ }
1083
+ if (severity === "warn") state.warned = true;
1084
+ const victimKind = victim._type ? "effect" : "memo";
1085
+ const relayName = nodeName(relay.node);
1086
+ const repair = derivable
1087
+ ? copyRepair(relay.node, write.name)
1088
+ : copy
1089
+ ? `The written value is the effect's compute output, but "${write.name}" has other ` +
1090
+ `writers — editable state reset from a source. If the reset is the intent, the tear ` +
1091
+ `is its cost; if "${write.name}" only ever mirrors the source, drop the local copy ` +
1092
+ `and read the source.`
1093
+ : soleWriter
1094
+ ? `Nothing else writes "${write.name}" — it is derived state: make it a memo over what ` +
1095
+ `the effect reads and every reader gets it in the same flush.`
1096
+ : `If "${write.name}" is computed from what the effect reads, make it a memo so readers ` +
1097
+ `get it in the same flush; if the write reads something outside the graph (layout, ` +
1098
+ `time), the tear is the cost of measuring.`;
1099
+ const message =
1100
+ `[EFFECT_RELAY_TEAR] ${victimKind} "${nodeName(victim)}" ran twice for one write of ` +
1101
+ `"${shared.name}": once in the flush where "${shared.name}" changed, and again after ` +
1102
+ `effect "${relayName}" relayed it by writing "${write.name}" — the first frame showed the ` +
1103
+ `new "${shared.name}" with the stale "${write.name}"` +
1104
+ (state.count > 1 ? ` (${state.count} times so far)` : "") +
1105
+ `. ${repair}`;
1106
+ const entry = emitDiagnostic(
1107
+ {
1108
+ code: "EFFECT_RELAY_TEAR",
1109
+ kind: "perf",
1110
+ severity,
1111
+ message,
1112
+ nodeName: nodeName(victim),
1113
+ data: {
1114
+ victim: nodeName(victim),
1115
+ root: shared.name,
1116
+ relay: relayName,
1117
+ wrote: write.name,
1118
+ copy,
1119
+ passthrough: copy ? (passthroughSource(relay.node) ?? null) : null,
1120
+ soleWriter,
1121
+ occurrences: state.count
1122
+ }
1123
+ },
1124
+ victim
1125
+ );
1126
+ if (severity === "warn") reportDiagnostic(entry);
1127
+ }
1128
+ // --- Immutable updates in stores ---------------------------------------------
1129
+ //
1130
+ // `draft.user = { ...draft.user, name }` / `draft.items = [...draft.items,
1131
+ // x]` / `draft.items = draft.items.filter(...)` — the React habit of
1132
+ // producing a fresh container to change one leaf. The store tracks leaves, so
1133
+ // a fresh container is pure cost: every reader of `user` (any path below it)
1134
+ // re-runs for the one leaf that moved, where a draft mutation would re-run
1135
+ // only the readers of `name`. The store's notify sees both containers at the
1136
+ // write and reports a leaf census (identity on unwrapped values, capped); the
1137
+ // verdict is the whole detector: a replacement whose leaves are mostly the
1138
+ // SAME values is a spread-copy, and one whose leaves are mostly different is
1139
+ // new data (reconcile's job — and UNSTABLE_LIST_IDENTITY's, downstream). Once
1140
+ // per store path.
1141
+ const immutableReported = new Set();
1142
+ function checkImmutableUpdate(path, isArray, total, same, prevTotal) {
1143
+ if (immutableReported.has(path) || total < 2) return;
1144
+ // Push/filter/splice copies change the length by a little; a wholesale
1145
+ // resize is a different operation even if some items survive.
1146
+ if (isArray && Math.abs(prevTotal - total) > Math.max(1, total >> 2)) return;
1147
+ // At least half the leaves carried over unchanged, and at least one did.
1148
+ if (same === 0 || same * 2 < total) return;
1149
+ immutableReported.add(path);
1150
+ const changed = total - same;
1151
+ const shape = isArray ? "array" : "object";
1152
+ const repair = isArray
1153
+ ? `mutate the draft in place (push/splice/index assignment) so only the touched ` +
1154
+ `indices notify`
1155
+ : `assign the leaf on the draft (\`${path}.<key> = …\`) so only readers of that key re-run`;
1156
+ const message =
1157
+ `[IMMUTABLE_UPDATE_IN_STORE] "${path}" was replaced with a fresh ${shape} whose ` +
1158
+ `${isArray ? "items" : "leaves"} are mostly the same values (${same} of ${total} unchanged` +
1159
+ `${changed > 0 ? `, ${changed} changed` : ""}) — a spread-copy update. The store already ` +
1160
+ `tracks ${isArray ? "items" : "leaves"}; a new container makes every reader of "${path}" ` +
1161
+ `re-run for the ${changed === 1 ? "one that" : "few that"} moved. Instead, ${repair}. For ` +
1162
+ `data arriving from outside (a fetch result), merge it with reconcile(data, key)(${path}).`;
1163
+ reportDiagnostic(
1164
+ emitDiagnostic({
1165
+ code: "IMMUTABLE_UPDATE_IN_STORE",
1166
+ kind: "perf",
1167
+ severity: "warn",
1168
+ message,
1169
+ nodeName: path,
1170
+ data: { path, shape, total, unchanged: same, changed }
1171
+ })
1172
+ );
1173
+ }
1174
+ // --- Unstable list identity -----------------------------------------------------
1175
+ //
1176
+ // `<For>` keyed by identity (the default) treats every new object as a new
1177
+ // row. When a re-fetch hands back fresh objects for the same records, or a
1178
+ // spread-copy rebuilds the array, most rows are disposed and recreated —
1179
+ // DOM, state, focus, and all — for data that did not change. mapArray knows
1180
+ // exactly which items exited and entered; pairing them (by `id` when the
1181
+ // items carry one, else by position) and sampling shallow equivalence turns
1182
+ // that into a verdict: churn that replaced equivalent records is unstable
1183
+ // identity, not a new list. A key function that still churns has the same
1184
+ // disease one level up (its keys are not stable). Once per list.
1185
+ const LIST_CHURN_SAMPLE = 8;
1186
+ const listIdentityWarned = new WeakSet();
1187
+ function recordId(item) {
1188
+ if (item === null || typeof item !== "object") return undefined;
1189
+ const o = item;
1190
+ return o.id ?? o.key ?? o._id ?? undefined;
1191
+ }
1192
+ function checkListIdentity(el, removed, created, newLen, keyed) {
1193
+ if (listIdentityWarned.has(el)) return;
1194
+ // Most of the list turned over, and the turnover was a swap (rows out ≈ rows in).
1195
+ if (created.length < 2 || created.length * 2 < newLen) return;
1196
+ if (Math.abs(removed.length - created.length) > Math.max(1, created.length >> 2)) return;
1197
+ // Pair exited with entered: by record id when present, else by position.
1198
+ const byId = new Map();
1199
+ for (const item of removed) {
1200
+ const id = recordId(item);
1201
+ if (id !== undefined) byId.set(id, item);
1202
+ }
1203
+ let sampled = 0;
1204
+ let equivalent = 0;
1205
+ const step = Math.max(1, Math.floor(created.length / LIST_CHURN_SAMPLE));
1206
+ for (let i = 0; i < created.length && sampled < LIST_CHURN_SAMPLE; i += step) {
1207
+ const item = created[i];
1208
+ const id = recordId(item);
1209
+ const prev = id !== undefined ? byId.get(id) : removed[i];
1210
+ if (prev === undefined || !isPlainShape(prev) || !isPlainShape(item)) continue;
1211
+ sampled++;
1212
+ if (shallowEquivalent(prev, item)) equivalent++;
1213
+ }
1214
+ if (sampled === 0 || equivalent * 2 < sampled) return;
1215
+ listIdentityWarned.add(el);
1216
+ const name = nodeName(el);
1217
+ const repair = keyed
1218
+ ? `The key function returned different keys for equivalent records — return a stable ` +
1219
+ `field (\`keyed: item => item.id\`), not the object or a computed value that changes ` +
1220
+ `with the fetch.`
1221
+ : `Key the list by a stable field (\`keyed: item => item.id\`), or merge the data into ` +
1222
+ `a store with reconcile(data, "id") so the same records keep the same identity.`;
1223
+ const message =
1224
+ `[UNSTABLE_LIST_IDENTITY] list "${name}" recreated ${created.length} of ${newLen} rows on ` +
1225
+ `an update where the entering items are equivalent to the ones they replaced ` +
1226
+ `(${equivalent} of ${sampled} sampled pairs identical field-for-field) — fresh objects ` +
1227
+ `for the same records, so identity keying threw away every row's DOM and state and ` +
1228
+ `rebuilt it. ${repair}`;
1229
+ reportDiagnostic(
1230
+ emitDiagnostic(
1231
+ {
1232
+ code: "UNSTABLE_LIST_IDENTITY",
1233
+ kind: "perf",
1234
+ severity: "warn",
1235
+ message,
1236
+ nodeName: name,
1237
+ data: {
1238
+ removed: removed.length,
1239
+ created: created.length,
1240
+ length: newLen,
1241
+ sampled,
1242
+ equivalent,
1243
+ keyed
1244
+ }
1245
+ },
1246
+ el
1247
+ )
1248
+ );
1249
+ }
1250
+ /** Provenance of an async landing: the flight, under the interaction that started it. */
1251
+ function asyncOrigin(el) {
1252
+ const origin = { kind: "async", name: nodeName(el) };
1253
+ const interaction = liveFlights.get(el)?.interaction;
1254
+ if (interaction !== undefined) origin.interaction = interaction;
1255
+ return origin;
676
1256
  }
677
1257
  // WeakMaps: an errored/abandoned flight must not leak its node or block GC.
678
1258
  const liveFlights = new WeakMap();
@@ -697,30 +1277,39 @@ function flightCauseIn(causes) {
697
1277
  return best;
698
1278
  }
699
1279
  function trackFlightStart(el, flight) {
700
- if (options.waterfalls === false) return;
701
1280
  const at = now();
702
1281
  const origin = flightOrigins.get(flight) ?? at;
703
1282
  if (origin === at) flightOrigins.set(flight, at);
1283
+ // Census: a flight still in the air when the node starts another was
1284
+ // superseded — its answer will be discarded.
1285
+ const stats = flightBucket(el);
1286
+ stats.flights++;
1287
+ if (liveFlights.has(el)) stats.abandoned++;
704
1288
  // Nearest enclosing frame with causes: create runs carry null (a node born
705
1289
  // inside a parent's recompute inherits the parent's causality — the
706
1290
  // boundary-reveal case, and the lazy sibling whose first pull is gated
707
1291
  // behind an earlier not-ready read), so walk down to the first re-run frame.
708
- let parent = null;
1292
+ let causes = null;
709
1293
  for (let i = frames.length - 1; i >= 0; i--) {
710
- const causes = frames[i].causes;
711
- if (causes !== null) {
712
- parent = flightCauseIn(causes);
1294
+ if (frames[i].causes !== null) {
1295
+ causes = frames[i].causes;
713
1296
  break;
714
1297
  }
715
1298
  }
716
- // The sequentiality test. A marked/previously-seen flight whose origin
717
- // predates the upstream landing was in the air alongside it: parallel.
718
- if (parent !== null && origin < parent.landedAt) parent = null;
719
- liveFlights.set(el, {
720
- origin,
721
- startSeq: changeSeq,
722
- chain: parent === null ? [] : [...parent.chain, { name: parent.name, ms: parent.ms }]
723
- });
1299
+ const live = { origin, startSeq: changeSeq, chain: [] };
1300
+ // Provenance: the flight belongs to whatever interaction caused the
1301
+ // recompute that started it (a create run under a click's handler a
1302
+ // freshly mounted async node — inherits the ambient interaction instead).
1303
+ const interaction = causes !== null ? interactionIn(causes) : (currentInteraction ?? undefined);
1304
+ if (interaction !== undefined) live.interaction = interaction;
1305
+ if (options.waterfalls !== false && causes !== null) {
1306
+ let parent = flightCauseIn(causes);
1307
+ // The sequentiality test. A marked/previously-seen flight whose origin
1308
+ // predates the upstream landing was in the air alongside it: parallel.
1309
+ if (parent !== null && origin < parent.landedAt) parent = null;
1310
+ if (parent !== null) live.chain = [...parent.chain, { name: parent.name, ms: parent.ms }];
1311
+ }
1312
+ liveFlights.set(el, live);
724
1313
  }
725
1314
  /**
726
1315
  * Flight landed (whether or not the value committed — the wall time was
@@ -733,6 +1322,10 @@ function finalizeFlight(el) {
733
1322
  liveFlights.delete(el);
734
1323
  const landedAt = now();
735
1324
  const ms = landedAt - flight.origin;
1325
+ const stats = flightBucket(el);
1326
+ stats.landed++;
1327
+ stats.landedMs += ms;
1328
+ if (ms > stats.worstMs) stats.worstMs = ms;
736
1329
  const record = el._devChange;
737
1330
  // Only a stamp this landing produced may carry the measurement — a stale
738
1331
  // async record from a previous landing must not be re-labeled.
@@ -778,15 +1371,429 @@ function checkWaterfall(el, chain, ms) {
778
1371
  // preload. A 3+ chain that survived the origin test is near-certainly
779
1372
  // structural — that one earns the console.
780
1373
  const severity = seq > 2 ? "warn" : "info";
781
- emitDiagnostic({
782
- code: "ASYNC_WATERFALL",
783
- kind: "perf",
784
- severity,
785
- message,
786
- nodeName: nodeName(el),
787
- data: { chain: links.map(l => ({ name: l.name, ms: l.ms })), sequentialMs: totalMs }
788
- });
789
- if (severity === "warn") console.warn(message);
1374
+ const entry = emitDiagnostic(
1375
+ {
1376
+ code: "ASYNC_WATERFALL",
1377
+ kind: "perf",
1378
+ severity,
1379
+ message,
1380
+ nodeName: nodeName(el),
1381
+ data: { chain: links.map(l => ({ name: l.name, ms: l.ms })), sequentialMs: totalMs }
1382
+ },
1383
+ el
1384
+ );
1385
+ if (severity === "warn") reportDiagnostic(entry);
1386
+ }
1387
+ const holdStates = new WeakMap();
1388
+ let activeHold = null;
1389
+ let holdLog = [];
1390
+ /** Companions are optimistic nodes too; `_parentSource` marks them. */
1391
+ function isCompanion(node) {
1392
+ return !!node._x && node._x._parentSource !== undefined;
1393
+ }
1394
+ function censusRegistrations(t, state) {
1395
+ for (const node of t._optimisticNodes)
1396
+ if (!isCompanion(node)) state.acknowledgedBy.add(`optimistic:${nodeName(node)}`);
1397
+ for (const store of t._optimisticStores)
1398
+ state.acknowledgedBy.add(`optimistic:${store?._name ?? "store"}`);
1399
+ for (const node of t._affectsNodes) state.acknowledgedBy.add(`affects:${nodeName(node)}`);
1400
+ }
1401
+ const HOLD_CENSUS_CAP = 10_000;
1402
+ /** Companions with live readers, anywhere downstream of the hold's nodes. */
1403
+ function censusCompanions(roots, out) {
1404
+ const visited = new Set();
1405
+ const stack = [...roots];
1406
+ while (stack.length > 0 && visited.size < HOLD_CENSUS_CAP) {
1407
+ const node = stack.pop();
1408
+ if (visited.has(node)) continue;
1409
+ visited.add(node);
1410
+ const x = node._x;
1411
+ if (x) {
1412
+ if (x._pendingSignal !== undefined && x._pendingSignal._subs !== null)
1413
+ out.add(`isPending:${nodeName(node)}`);
1414
+ if (x._latestValueComputed !== undefined && x._latestValueComputed._subs !== null)
1415
+ out.add(`latest:${nodeName(node)}`);
1416
+ for (let child = x._child ?? null; child !== null; child = child._nextChild ?? null)
1417
+ stack.push(child);
1418
+ }
1419
+ for (let s = node._subs; s !== null; s = s._nextSub) stack.push(s._sub);
1420
+ }
1421
+ }
1422
+ function holdState(t) {
1423
+ let state = holdStates.get(t);
1424
+ if (state === undefined) {
1425
+ state = {
1426
+ start: now(),
1427
+ flushes: 0,
1428
+ blockers: new Set(),
1429
+ acknowledgedBy: new Set(),
1430
+ painted: 0,
1431
+ action: false
1432
+ };
1433
+ holdStates.set(t, state);
1434
+ }
1435
+ return state;
1436
+ }
1437
+ function trackHoldStart(t) {
1438
+ if (options.holds === false) return;
1439
+ const state = holdState(t);
1440
+ state.flushes++;
1441
+ if (t._actions.length > 0) state.action = true;
1442
+ for (const [source, reporters] of t._asyncReporters)
1443
+ if (reporters.size > 0) state.blockers.add(source);
1444
+ censusRegistrations(t, state);
1445
+ activeHold = state;
1446
+ }
1447
+ function trackHoldMerge(target, outgoing) {
1448
+ const from = holdStates.get(outgoing);
1449
+ if (from === undefined) return;
1450
+ holdStates.delete(outgoing);
1451
+ const into = holdState(target);
1452
+ if (from.start < into.start) into.start = from.start;
1453
+ into.flushes += from.flushes;
1454
+ into.painted += from.painted;
1455
+ into.action ||= from.action;
1456
+ for (const b of from.blockers) into.blockers.add(b);
1457
+ for (const a of from.acknowledgedBy) into.acknowledgedBy.add(a);
1458
+ }
1459
+ function trackHoldSettled(t) {
1460
+ const state = holdStates.get(t);
1461
+ if (state === undefined) return;
1462
+ holdStates.delete(t);
1463
+ // Root writes only: a memo in _pendingNodes is a derived hold, and the
1464
+ // question is whether the USER's input went unanswered.
1465
+ const heldWrites = [];
1466
+ let subject = null;
1467
+ let interaction;
1468
+ let lastJoinAt = -Infinity;
1469
+ for (const node of t._pendingNodes) {
1470
+ if (typeof node._fn === "function" || isCompanion(node)) continue;
1471
+ const change = node._devChange;
1472
+ if (change === undefined || change.kind !== "write") continue;
1473
+ if (subject === null) subject = node;
1474
+ const held = { name: nodeName(node), prev: change.prev, value: change.value };
1475
+ if (change.origin !== undefined) held.origin = change.origin;
1476
+ heldWrites.push(held);
1477
+ // Earliest interaction among the held writes: the user has been waiting
1478
+ // since the first thing they did that this transaction is holding.
1479
+ const under = interactionOf(change.origin);
1480
+ if (under !== undefined && (interaction === undefined || under.at < interaction.at))
1481
+ interaction = under;
1482
+ // Latest write: a signal written twice while held carries the later
1483
+ // stamp, so this is the user's final input, not their first.
1484
+ if (change.at !== undefined && change.at > lastJoinAt) lastJoinAt = change.at;
1485
+ }
1486
+ if (heldWrites.length === 0) return;
1487
+ censusRegistrations(t, state);
1488
+ censusCompanions([...t._pendingNodes, ...state.blockers], state.acknowledgedBy);
1489
+ const end = now();
1490
+ // The hold began no later than its first parked flush; an interaction stamp
1491
+ // reaches further back (dispatch). A node rewritten mid-hold keeps only its
1492
+ // latest record, so the surviving interaction may be a later one — the
1493
+ // flush clock keeps the first wait from being forgotten.
1494
+ const holdMs = end - Math.min(state.start, interaction !== undefined ? interaction.at : Infinity);
1495
+ const event = {
1496
+ holdMs,
1497
+ tailMs: lastJoinAt === -Infinity ? holdMs : Math.min(holdMs, end - lastJoinAt),
1498
+ flushes: state.flushes,
1499
+ heldWrites,
1500
+ blockers: [...state.blockers].map(nodeName),
1501
+ acknowledgedBy: [...state.acknowledgedBy],
1502
+ paintedDuringHold: state.painted,
1503
+ action: state.action
1504
+ };
1505
+ if (interaction !== undefined) event.interaction = interaction;
1506
+ holdLog.push(event);
1507
+ if (holdLog.length > options.historyLimit) holdLog.shift();
1508
+ recordFeedbackHold(event);
1509
+ if (isSilentHold(event)) checkSilentHold(event, subject);
1510
+ else checkLongHold(event, subject);
1511
+ }
1512
+ /** `isLongHold` — the tail outlasted `longHolds.infoMs`. */
1513
+ function isLongHold(event) {
1514
+ const cfg = options.longHolds;
1515
+ return cfg !== false && cfg !== undefined && event.tailMs >= cfg.infoMs;
1516
+ }
1517
+ function describeHeldWrites(event) {
1518
+ return event.heldWrites
1519
+ .map(w => (w.prev !== undefined ? `"${w.name}" (${w.prev} → ${w.value})` : `"${w.name}"`))
1520
+ .join(", ");
1521
+ }
1522
+ function describeBlockers(event, lead) {
1523
+ return event.blockers.length > 0
1524
+ ? ` ${lead} ${event.blockers.map(b => `"${b}"`).join(", ")}`
1525
+ : "";
1526
+ }
1527
+ /**
1528
+ * The boundary repair, shared by LONG_HOLD and a long SILENT_HOLD: a wait
1529
+ * this long should show a fallback, not a stale screen. A `Loading` boundary
1530
+ * lifts the write out of the hold only when it has not revealed yet or its
1531
+ * `on` prop changed — a revealed boundary with no `on` IS the stale screen.
1532
+ */
1533
+ function boundaryRepair(event) {
1534
+ const key = event.heldWrites[0]?.name ?? "key";
1535
+ return (
1536
+ `A wait this long is past what a stale screen should carry: show a fallback instead. Put ` +
1537
+ `the reader behind a Loading boundary keyed on what changed — <Loading on={${key}()} ` +
1538
+ `fallback={…}> — so the write commits at once and the fallback shows where the data lands; ` +
1539
+ `a boundary that has already revealed keeps the old content unless \`on\` changes. If the ` +
1540
+ `data itself is the problem, preload it or cache it so the wait never gets this long.`
1541
+ );
1542
+ }
1543
+ function holdData(event) {
1544
+ const data = {
1545
+ holdMs: event.holdMs,
1546
+ tailMs: event.tailMs,
1547
+ flushes: event.flushes,
1548
+ heldWrites: event.heldWrites.map(w => w.name),
1549
+ blockers: event.blockers,
1550
+ action: event.action
1551
+ };
1552
+ if (event.interaction !== undefined)
1553
+ data.interaction = { type: event.interaction.name, target: event.interaction.target };
1554
+ return data;
1555
+ }
1556
+ function checkSilentHold(event, subject) {
1557
+ const cfg = options.holds;
1558
+ if (cfg === false) return;
1559
+ if (event.holdMs < cfg.infoMs) return;
1560
+ const ms = event.holdMs.toFixed(0);
1561
+ const writes = describeHeldWrites(event);
1562
+ const waitedOn = describeBlockers(event, "waiting on");
1563
+ // With the interaction stamped the sentence starts from what the user did;
1564
+ // without it, from the writes.
1565
+ const who = event.interaction !== undefined ? `${formatOrigin(event.interaction)} ` : "";
1566
+ let message = event.action
1567
+ ? `[SILENT_HOLD] ${who}${who ? "started an action that" : "an action"} held ${writes} for ` +
1568
+ `${ms}ms${waitedOn} and the screen showed nothing for the whole round-trip: no optimistic ` +
1569
+ `value, no isPending() reader, no affects() mark, and no effect ran while it was held. ` +
1570
+ `Pair the action with a createOptimistic/createOptimisticStore write for the expected ` +
1571
+ `outcome (it reverts on failure), or co-write a createOptimistic(false) "saving" flag ` +
1572
+ `the UI reads.`
1573
+ : `[SILENT_HOLD] ${who}${who ? "wrote" : "writes to"} ${writes}${who ? "; the write was" : " were"} ` +
1574
+ `held ${ms}ms${waitedOn} and the screen showed nothing for the wait: no ` +
1575
+ `isPending()/latest() reader downstream, no optimistic value, no affects() mark, and no ` +
1576
+ `effect ran while it was held — the interaction was dead for ${ms}ms. Show the wait: ` +
1577
+ `read isPending(() => ${event.blockers[0] ?? "source"}()) to render a busy state, or ` +
1578
+ `latest(${event.heldWrites[0].name}) to reveal the new input immediately while the data ` +
1579
+ `catches up. The hold itself is correct — do not "fix" this by moving the write off the ` +
1580
+ `async path.`;
1581
+ const long = isLongHold(event);
1582
+ if (long) message += ` ${boundaryRepair(event)}`;
1583
+ const severity = event.holdMs >= cfg.warnMs ? "warn" : "info";
1584
+ const data = holdData(event);
1585
+ data.long = long;
1586
+ const entry = emitDiagnostic(
1587
+ {
1588
+ code: "SILENT_HOLD",
1589
+ kind: "responsiveness",
1590
+ severity,
1591
+ message,
1592
+ nodeName: nodeName(subject),
1593
+ data
1594
+ },
1595
+ subject
1596
+ );
1597
+ if (severity === "warn") reportDiagnostic(entry);
1598
+ }
1599
+ function checkLongHold(event, subject) {
1600
+ const cfg = options.longHolds;
1601
+ if (cfg === false || cfg === undefined) return;
1602
+ if (event.tailMs < cfg.infoMs) return;
1603
+ const tail = event.tailMs.toFixed(0);
1604
+ const writes = describeHeldWrites(event);
1605
+ const waitedOn = describeBlockers(event, "waiting on");
1606
+ const who = event.interaction !== undefined ? `${formatOrigin(event.interaction)} ` : "";
1607
+ const answered =
1608
+ event.acknowledgedBy.length > 0
1609
+ ? `${event.acknowledgedBy.map(a => `"${a}"`).join(", ")} said it was pending`
1610
+ : `an effect painted meanwhile`;
1611
+ const sinceLast =
1612
+ event.tailMs < event.holdMs - 1
1613
+ ? ` after the last input (${event.holdMs.toFixed(0)}ms in all)`
1614
+ : "";
1615
+ const message =
1616
+ `[LONG_HOLD] ${who}${who ? "wrote" : "writes to"} ${writes}; the screen kept the old ` +
1617
+ `content for ${tail}ms${sinceLast}${waitedOn} — ${answered}, but the hold ran on well past ` +
1618
+ `the point where "loading" over stale content reads as broken. ${boundaryRepair(event)}`;
1619
+ const severity = event.tailMs >= cfg.warnMs ? "warn" : "info";
1620
+ const data = holdData(event);
1621
+ data.acknowledgedBy = event.acknowledgedBy;
1622
+ const entry = emitDiagnostic(
1623
+ {
1624
+ code: "LONG_HOLD",
1625
+ kind: "responsiveness",
1626
+ severity,
1627
+ message,
1628
+ nodeName: nodeName(subject),
1629
+ data
1630
+ },
1631
+ subject
1632
+ );
1633
+ if (severity === "warn") reportDiagnostic(entry);
1634
+ }
1635
+ const feedbackSources = new Map();
1636
+ const feedbackInteractions = new Map();
1637
+ const flightStats = new Map();
1638
+ const fallbackStats = new Map();
1639
+ const FALLBACK_FLASH_MS = 150;
1640
+ function flightBucket(el) {
1641
+ let row = flightStats.get(el);
1642
+ if (row === undefined) {
1643
+ row = { source: nodeName(el), flights: 0, landed: 0, abandoned: 0, landedMs: 0, worstMs: 0 };
1644
+ flightStats.set(el, row);
1645
+ }
1646
+ return row;
1647
+ }
1648
+ function trackFallback(boundary, tree, shown) {
1649
+ let bucket = fallbackStats.get(boundary);
1650
+ if (bucket === undefined) {
1651
+ bucket = {
1652
+ row: { boundary: "boundary", shows: 0, shownMs: 0, worstMs: 0, flashes: 0 },
1653
+ shownAt: null
1654
+ };
1655
+ fallbackStats.set(boundary, bucket);
1656
+ }
1657
+ // The first show can fire before the subtree exists; name on first sight.
1658
+ if (bucket.row.boundary === "boundary" && tree !== undefined) {
1659
+ const path = ownerPath(tree);
1660
+ if (path !== undefined) bucket.row.boundary = path.join(" › ");
1661
+ }
1662
+ if (shown) {
1663
+ if (bucket.shownAt === null) {
1664
+ bucket.shownAt = now();
1665
+ bucket.row.shows++;
1666
+ }
1667
+ return;
1668
+ }
1669
+ if (bucket.shownAt === null) return;
1670
+ const ms = now() - bucket.shownAt;
1671
+ bucket.shownAt = null;
1672
+ bucket.row.shownMs += ms;
1673
+ if (ms > bucket.row.worstMs) bucket.row.worstMs = ms;
1674
+ if (ms < FALLBACK_FLASH_MS) bucket.row.flashes++;
1675
+ }
1676
+ /** No affordance answered and nothing painted while held. */
1677
+ function isSilentHold(event) {
1678
+ return event.paintedDuringHold === 0 && event.acknowledgedBy.length === 0;
1679
+ }
1680
+ function interactionBucket(interaction) {
1681
+ const key = formatOrigin(interaction);
1682
+ let bucket = feedbackInteractions.get(key);
1683
+ if (bucket === undefined) {
1684
+ bucket = {
1685
+ row: {
1686
+ interaction: key,
1687
+ dispatches: 0,
1688
+ runs: 0,
1689
+ selfMs: 0,
1690
+ worstDispatchMs: 0,
1691
+ holds: 0,
1692
+ heldMs: 0,
1693
+ silentMs: 0,
1694
+ worstHoldMs: 0
1695
+ },
1696
+ dispatches: new Map()
1697
+ };
1698
+ feedbackInteractions.set(key, bucket);
1699
+ }
1700
+ const at = interaction.at ?? 0;
1701
+ if (!bucket.dispatches.has(at)) {
1702
+ bucket.dispatches.set(at, 0);
1703
+ bucket.row.dispatches++;
1704
+ }
1705
+ return bucket;
1706
+ }
1707
+ function recordFeedbackRun(event) {
1708
+ if (event.interaction === undefined) return;
1709
+ const bucket = interactionBucket(event.interaction);
1710
+ bucket.row.runs++;
1711
+ bucket.row.selfMs += event.selfMs;
1712
+ const at = event.interaction.at ?? 0;
1713
+ const dispatchMs = bucket.dispatches.get(at) + event.selfMs;
1714
+ bucket.dispatches.set(at, dispatchMs);
1715
+ if (dispatchMs > bucket.row.worstDispatchMs) bucket.row.worstDispatchMs = dispatchMs;
1716
+ }
1717
+ function recordFeedbackHold(event) {
1718
+ const sources = [...event.blockers].sort();
1719
+ const key = sources.join("\u0000");
1720
+ let bucket = feedbackSources.get(key);
1721
+ if (bucket === undefined) {
1722
+ bucket = {
1723
+ row: {
1724
+ sources,
1725
+ holds: 0,
1726
+ heldMs: 0,
1727
+ worstMs: 0,
1728
+ silent: 0,
1729
+ silentMs: 0,
1730
+ latestOnly: 0,
1731
+ long: 0,
1732
+ longMs: 0,
1733
+ acknowledgedBy: [],
1734
+ interactions: [],
1735
+ writes: [],
1736
+ actions: 0
1737
+ },
1738
+ acks: new Map(),
1739
+ interactions: new Map(),
1740
+ writes: new Set()
1741
+ };
1742
+ feedbackSources.set(key, bucket);
1743
+ }
1744
+ const row = bucket.row;
1745
+ const silent = isSilentHold(event);
1746
+ row.holds++;
1747
+ row.heldMs += event.holdMs;
1748
+ if (event.holdMs > row.worstMs) row.worstMs = event.holdMs;
1749
+ if (silent) {
1750
+ row.silent++;
1751
+ row.silentMs += event.holdMs;
1752
+ } else if (
1753
+ event.acknowledgedBy.length > 0 &&
1754
+ event.acknowledgedBy.every(by => by.startsWith("latest:"))
1755
+ )
1756
+ row.latestOnly++;
1757
+ if (isLongHold(event)) {
1758
+ row.long++;
1759
+ row.longMs += event.tailMs;
1760
+ }
1761
+ if (event.action) row.actions++;
1762
+ for (const by of event.acknowledgedBy) bucket.acks.set(by, (bucket.acks.get(by) ?? 0) + 1);
1763
+ for (const w of event.heldWrites) bucket.writes.add(w.name);
1764
+ if (event.interaction !== undefined) {
1765
+ const key = formatOrigin(event.interaction);
1766
+ bucket.interactions.set(key, (bucket.interactions.get(key) ?? 0) + 1);
1767
+ const ib = interactionBucket(event.interaction);
1768
+ ib.row.holds++;
1769
+ ib.row.heldMs += event.holdMs;
1770
+ if (silent) ib.row.silentMs += event.holdMs;
1771
+ if (event.holdMs > ib.row.worstHoldMs) ib.row.worstHoldMs = event.holdMs;
1772
+ }
1773
+ }
1774
+ function rankedCounts(counts, key) {
1775
+ return [...counts].sort((a, b) => b[1] - a[1]).map(([name, holds]) => ({ [key]: name, holds }));
1776
+ }
1777
+ function feedbackTables() {
1778
+ const flights = [...flightStats.values()]
1779
+ .map(row => ({ ...row }))
1780
+ .sort((a, b) => b.abandoned - a.abandoned || b.flights - a.flights);
1781
+ const fallbacks = [...fallbackStats.values()]
1782
+ .map(bucket => ({ ...bucket.row }))
1783
+ .sort((a, b) => b.flashes - a.flashes || b.shownMs - a.shownMs);
1784
+ const sources = [...feedbackSources.values()]
1785
+ .map(bucket => ({
1786
+ ...bucket.row,
1787
+ acknowledgedBy: rankedCounts(bucket.acks, "by"),
1788
+ interactions: rankedCounts(bucket.interactions, "interaction"),
1789
+ writes: [...bucket.writes]
1790
+ }))
1791
+ .sort((a, b) => b.silentMs - a.silentMs || b.heldMs - a.heldMs);
1792
+ // Ranked by the total time the user spent on it: held plus synchronous work.
1793
+ const interactions = [...feedbackInteractions.values()]
1794
+ .map(bucket => ({ ...bucket.row }))
1795
+ .sort((a, b) => b.heldMs + b.selfMs - (a.heldMs + a.selfMs));
1796
+ return { sources, interactions, flights, fallbacks };
790
1797
  }
791
1798
  // The engine's implementation of the core's dev hook points. Installed by
792
1799
  // enable(), uninstalled by disable() — while uninstalled the core pays one
@@ -847,7 +1854,7 @@ const engineHooks = {
847
1854
  frame.prevDeps,
848
1855
  { selfMs, totalMs },
849
1856
  changed,
850
- optimistic ? "optimistic" : transition ? "transition" : "plain",
1857
+ optimistic ? "optimistic" : transition ? "held" : "plain",
851
1858
  held
852
1859
  );
853
1860
  // Creation runs still get the wide-scope check: a memo can be born with
@@ -892,6 +1899,48 @@ const engineHooks = {
892
1899
  if (change !== undefined && change.seq > asyncStartSeq && change.kind === "write")
893
1900
  stampWrite(el, "async", NO_VALUES, value);
894
1901
  finalizeFlight(el);
1902
+ },
1903
+ effectRunStart(el) {
1904
+ pushFrame("effect", nodeName(el), el._devRunInteraction, el);
1905
+ },
1906
+ effectRunEnd() {
1907
+ popFrame("effect");
1908
+ if (activeHold !== null) activeHold.painted++;
1909
+ },
1910
+ actionStepStart(it, name) {
1911
+ // Steps after a yield resume from a promise callback with no ambient
1912
+ // interaction; the one that started the action (its first step) is the
1913
+ // action's interaction for every step.
1914
+ let interaction = actionInteractions.get(it);
1915
+ if (interaction === undefined && !actionInteractions.has(it)) {
1916
+ interaction = currentInteraction ?? undefined;
1917
+ actionInteractions.set(it, interaction);
1918
+ }
1919
+ pushFrame("action", name, interaction);
1920
+ },
1921
+ actionStepEnd() {
1922
+ popFrame("action");
1923
+ },
1924
+ holdStart(t) {
1925
+ trackHoldStart(t);
1926
+ },
1927
+ holdEnd() {
1928
+ activeHold = null;
1929
+ },
1930
+ transitionSettled(t) {
1931
+ trackHoldSettled(t);
1932
+ },
1933
+ transitionMerged(target, outgoing) {
1934
+ trackHoldMerge(target, outgoing);
1935
+ },
1936
+ storeReplaced(path, isArray, total, unchanged, prevTotal) {
1937
+ checkImmutableUpdate(path, isArray, total, unchanged, prevTotal);
1938
+ },
1939
+ listChurn(el, removed, created, newLen, keyed) {
1940
+ checkListIdentity(el, removed, created, newLen, keyed);
1941
+ },
1942
+ boundaryFallback(boundary, tree, shown) {
1943
+ trackFallback(boundary, tree, shown);
895
1944
  }
896
1945
  };
897
1946
  const attribution = {
@@ -901,6 +1950,16 @@ const attribution = {
901
1950
  scopeCosts.clear();
902
1951
  writeCosts.clear();
903
1952
  waterfallLog = [];
1953
+ holdLog = [];
1954
+ activeHold = null;
1955
+ feedbackSources.clear();
1956
+ feedbackInteractions.clear();
1957
+ reportedCycles.clear();
1958
+ relays.clear();
1959
+ immutableReported.clear();
1960
+ flightStats.clear();
1961
+ fallbackStats.clear();
1962
+ originFrames.length = 0;
904
1963
  hotCauses.clear();
905
1964
  setAttributionHooks(engineHooks);
906
1965
  },
@@ -911,6 +1970,16 @@ const attribution = {
911
1970
  scopeCosts.clear();
912
1971
  writeCosts.clear();
913
1972
  waterfallLog = [];
1973
+ holdLog = [];
1974
+ activeHold = null;
1975
+ feedbackSources.clear();
1976
+ feedbackInteractions.clear();
1977
+ reportedCycles.clear();
1978
+ relays.clear();
1979
+ immutableReported.clear();
1980
+ flightStats.clear();
1981
+ fallbackStats.clear();
1982
+ originFrames.length = 0;
914
1983
  hotCauses.clear();
915
1984
  setAttributionHooks(null);
916
1985
  },
@@ -940,13 +2009,21 @@ const attribution = {
940
2009
  waterfalls() {
941
2010
  return waterfallLog;
942
2011
  },
2012
+ holds() {
2013
+ return holdLog;
2014
+ },
2015
+ feedback() {
2016
+ return feedbackTables();
2017
+ },
943
2018
  markFlight(flight, startedAt = now()) {
944
2019
  // Earliest wins: re-marking (a cache re-serving the same promise) must
945
2020
  // not move the origin later.
946
2021
  const existing = flightOrigins.get(flight);
947
2022
  if (existing === undefined || startedAt < existing) flightOrigins.set(flight, startedAt);
948
2023
  },
949
- format: formatRerun
2024
+ withInteraction,
2025
+ format: formatRerun,
2026
+ formatOrigin
950
2027
  };
951
2028
 
952
2029
  /** First warning when a node's live edge count reaches this size. */
@@ -1000,22 +2077,88 @@ const DEV$1 = {
1000
2077
  getSources,
1001
2078
  getObservers
1002
2079
  };
1003
- function emitDiagnostic(event) {
2080
+ /**
2081
+ * Root-first names of the owners enclosing `subject` (inclusive when the
2082
+ * subject is itself a named owner). Signals hop to their registering owner
2083
+ * (`_owner`, set by registerGraph). Unnamed owners are skipped so the path
2084
+ * reads as the component tree plus the scope: `<App> › <TodoRow> › effect`.
2085
+ */
2086
+ function ownerPath(subject) {
2087
+ if (!subject) return undefined;
2088
+ let owner = "_parent" in subject ? subject : (subject._owner ?? null);
2089
+ const path = [];
2090
+ for (; owner !== null; owner = owner._parent) {
2091
+ const name = owner._name;
2092
+ if (typeof name === "string" && name.length) path.push(name);
2093
+ }
2094
+ return path.length ? path.reverse() : undefined;
2095
+ }
2096
+ /**
2097
+ * Records a diagnostic on the structured channel (listeners, captures) and
2098
+ * returns the entry. `subject` locates it: the current reactive `context` by
2099
+ * default (right for the synchronous rule checks — they fire inside the
2100
+ * scope that misbehaved); pass the node for scheduler-time findings whose
2101
+ * ambient context is the flush, or `null` for events that have no location
2102
+ * by nature. Console output is a separate step — see `reportDiagnostic`.
2103
+ */
2104
+ function emitDiagnostic(event, subject = context) {
1004
2105
  const entry = {
1005
2106
  sequence: ++diagnosticSequence,
1006
2107
  ...event
1007
2108
  };
2109
+ const path = ownerPath(subject);
2110
+ if (path) entry.ownerPath = path;
2111
+ if (subject) eventSubjects.set(entry, subject);
1008
2112
  for (const listener of diagnosticListeners) listener(entry);
1009
2113
  for (const capture of diagnosticCaptures) capture.push(entry);
1010
- if (consoleFooter && !footeredCodes.has(entry.code)) {
1011
- footeredCodes.add(entry.code);
1012
- const footer = consoleFooter(entry);
1013
- // Call sites console.warn/error their message after emitDiagnostic
1014
- // returns; a microtask lands the footer right below that report.
1015
- if (footer) queueMicrotask(() => console.warn(footer));
2114
+ // Footer for events that never reach reportDiagnostic because the call site
2115
+ // throws the message instead (every such site is severity "error"): a
2116
+ // microtask lands it below the thrown error. Sites that DO report consume
2117
+ // the once-per-code slot synchronously first, so this finds it taken and
2118
+ // stays silent one console entry per finding. Advisory (`info`) events
2119
+ // are structured-channel only and get no footer: nothing on the console
2120
+ // for it to follow.
2121
+ if (entry.severity === "error" && consoleFooter && !footeredCodes.has(entry.code)) {
2122
+ queueMicrotask(() => {
2123
+ const footer = takeFooter(entry);
2124
+ if (footer) console.warn(footer);
2125
+ });
1016
2126
  }
1017
2127
  return entry;
1018
2128
  }
2129
+ /** The once-per-code footer text, consuming the slot. Undefined if taken or unregistered. */
2130
+ function takeFooter(entry) {
2131
+ if (!consoleFooter || footeredCodes.has(entry.code)) return undefined;
2132
+ footeredCodes.add(entry.code);
2133
+ return consoleFooter(entry);
2134
+ }
2135
+ /**
2136
+ * The subject each emitted event was about, for the console step: events are
2137
+ * serializable records and cannot carry the node, but the console can show
2138
+ * what the node knows — a rendering runtime may stamp a binding effect with
2139
+ * the DOM element it writes (`_devElement`), and a live element reference
2140
+ * beside the message is the most addressable pointer a console can print.
2141
+ */
2142
+ const eventSubjects = new WeakMap();
2143
+ /**
2144
+ * The console face of a diagnostic — ONE entry per finding: the message, the
2145
+ * owner path (`in <App> › <TodoRow> › effect`) so a human can locate it, the
2146
+ * once-per-code footer as trailing lines, and — when the subject is a
2147
+ * binding effect the rendering runtime tagged — the element it writes, as a
2148
+ * second console argument (hover highlights it, click jumps to Elements).
2149
+ * Severity picks the console method. Call sites report the entry
2150
+ * `emitDiagnostic` returned so the structured and console channels never
2151
+ * disagree.
2152
+ */
2153
+ function reportDiagnostic(entry) {
2154
+ let text = entry.message;
2155
+ if (entry.ownerPath) text += `\n in ${entry.ownerPath.join(" › ")}`;
2156
+ const footer = takeFooter(entry);
2157
+ if (footer) text += `\n${footer}`;
2158
+ const element = eventSubjects.get(entry)?._devElement;
2159
+ const args = element !== undefined ? [text, element] : [text];
2160
+ entry.severity === "error" ? console.error(...args) : console.warn(...args);
2161
+ }
1019
2162
  /**
1020
2163
  * Shared strict-read diagnostics for core read() and the store proxy traps.
1021
2164
  * Single source for the message text — the #2897 safeguard parity between
@@ -1039,15 +2182,16 @@ function warnStrictReadUntracked(strictReadLabel, fields) {
1039
2182
  const message =
1040
2183
  `[STRICT_READ_UNTRACKED] Reactive value read directly in ${strictReadLabel} will not update. ` +
1041
2184
  `Move it into a tracking scope (JSX, a memo, or an effect's compute function).`;
1042
- emitDiagnostic({
1043
- code: "STRICT_READ_UNTRACKED",
1044
- kind: "strict-read",
1045
- severity: "warn",
1046
- message,
1047
- data: { strictRead: strictReadLabel },
1048
- ...fields
1049
- });
1050
- console.warn(message);
2185
+ reportDiagnostic(
2186
+ emitDiagnostic({
2187
+ code: "STRICT_READ_UNTRACKED",
2188
+ kind: "strict-read",
2189
+ severity: "warn",
2190
+ message,
2191
+ data: { strictRead: strictReadLabel },
2192
+ ...fields
2193
+ })
2194
+ );
1051
2195
  }
1052
2196
  function registerGraph(value, owner) {
1053
2197
  value._owner = owner;
@@ -1114,17 +2258,21 @@ function noteGraphLink(dep, sub) {
1114
2258
  `Each will re-run when it changes. If many independent computations read the same value ` +
1115
2259
  `(for example every row of a list comparing against one selected id), prefer a per-key ` +
1116
2260
  `store or projection so only the items whose result flipped update.`;
1117
- emitDiagnostic({
1118
- code: "HUGE_FAN_OUT",
1119
- kind: "graph",
1120
- severity: "warn",
1121
- message,
1122
- nodeName: name,
1123
- ownerId: dep.id,
1124
- ownerName: name,
1125
- data: { count: fanOut }
1126
- });
1127
- console.warn(message);
2261
+ reportDiagnostic(
2262
+ emitDiagnostic(
2263
+ {
2264
+ code: "HUGE_FAN_OUT",
2265
+ kind: "graph",
2266
+ severity: "warn",
2267
+ message,
2268
+ nodeName: name,
2269
+ ownerId: dep.id,
2270
+ ownerName: name,
2271
+ data: { count: fanOut }
2272
+ },
2273
+ dep
2274
+ )
2275
+ );
1128
2276
  }
1129
2277
  if (shouldWarnGraphSize(fanIn)) {
1130
2278
  const name = sub._name;
@@ -1132,17 +2280,21 @@ function noteGraphLink(dep, sub) {
1132
2280
  `[HUGE_FAN_IN] ${name ? `Computation "${name}"` : "A computation"} has ${fanIn} sources. ` +
1133
2281
  `It will re-run when any of them change. Narrow the read or split the derivation so each ` +
1134
2282
  `computation tracks only what it needs.`;
1135
- emitDiagnostic({
1136
- code: "HUGE_FAN_IN",
1137
- kind: "graph",
1138
- severity: "warn",
1139
- message,
1140
- nodeName: name,
1141
- ownerId: sub.id,
1142
- ownerName: name,
1143
- data: { count: fanIn }
1144
- });
1145
- console.warn(message);
2283
+ reportDiagnostic(
2284
+ emitDiagnostic(
2285
+ {
2286
+ code: "HUGE_FAN_IN",
2287
+ kind: "graph",
2288
+ severity: "warn",
2289
+ message,
2290
+ nodeName: name,
2291
+ ownerId: sub.id,
2292
+ ownerName: name,
2293
+ data: { count: fanIn }
2294
+ },
2295
+ sub
2296
+ )
2297
+ );
1146
2298
  }
1147
2299
  }
1148
2300
  /** DEV-only: drop live edge counts when a link is removed. */
@@ -1250,6 +2402,22 @@ function findLane(lane) {
1250
2402
  while (lane._mergedInto) lane = lane._mergedInto;
1251
2403
  return lane;
1252
2404
  }
2405
+ /**
2406
+ * Is the lane held? `_pendingAsync` records the async the lane OWNS (derived
2407
+ * under it); the transaction's reporter map records the async a render effect
2408
+ * OBSERVED pending with no boundary taking it (INV-3, the one registration
2409
+ * site). A hold needs both — the same rule the transaction itself uses, so a
2410
+ * memo nobody renders, or one a fallback-showing boundary caught, cannot tear
2411
+ * a frame and holds nothing (#3289). An orphan lane has no observation record
2412
+ * and never holds.
2413
+ */
2414
+ function laneHeld(lane) {
2415
+ const t = lane._transition;
2416
+ if (t)
2417
+ for (const node of lane._pendingAsync)
2418
+ if (currentTransition(t)._asyncReporters.has(node)) return true;
2419
+ return false;
2420
+ }
1253
2421
  /**
1254
2422
  * Merge two lanes when their dependency graphs overlap.
1255
2423
  */
@@ -1359,7 +2527,7 @@ function cancelZombieRecompute(el) {
1359
2527
  }
1360
2528
  let clock = 0;
1361
2529
  let activeTransition = null;
1362
- let scheduled$1 = false;
2530
+ let scheduled = false;
1363
2531
  let halted = false;
1364
2532
  let haltNotified = false;
1365
2533
  let syncDepth = 0;
@@ -1367,6 +2535,10 @@ let projectionWriteActive = false;
1367
2535
  let inTrackedQueueCallback = false;
1368
2536
  let _enforceLoadingBoundary = false;
1369
2537
  let _hitUnhandledAsync = false;
2538
+ // Once per enforcement window: the ASYNC_OUTSIDE_LOADING_BOUNDARY finding is a
2539
+ // fact about the MOUNT ("the root mount will be deferred"), not about each
2540
+ // pending render effect — N async siblings at mount used to produce N copies.
2541
+ let _reportedUnhandledAsync = false;
1370
2542
  // Store property nodes that were created solely to carry a pending write (no
1371
2543
  // subscribers at write time). Swept after each flush that commits pending
1372
2544
  // values — any still without subs get disposed via their `_unobserved` hook,
@@ -1398,11 +2570,19 @@ function sweepTransientStoreNodes() {
1398
2570
  // unmarked node for the same property).
1399
2571
  if (node._x?._affectsCount) continue;
1400
2572
  transientStoreNodes.delete(node);
1401
- node._x?._unobserved?.();
2573
+ if (node._config & CONFIG_SLOT_NODE) slotUnobservedHook(node);
2574
+ else node._x?._unobserved?.();
1402
2575
  }
1403
2576
  }
2577
+ /**
2578
+ * Consume the unhandled-async hit. Returns whether this is the first report
2579
+ * of the current enforcement window — the caller warns only then.
2580
+ */
1404
2581
  function resetUnhandledAsync() {
1405
2582
  _hitUnhandledAsync = false;
2583
+ if (_reportedUnhandledAsync) return false;
2584
+ _reportedUnhandledAsync = true;
2585
+ return true;
1406
2586
  }
1407
2587
  /**
1408
2588
  * Toggles the dev-mode "must be inside a `<Loading>` boundary" enforcement
@@ -1414,6 +2594,7 @@ function resetUnhandledAsync() {
1414
2594
  */
1415
2595
  function enforceLoadingBoundary(enabled) {
1416
2596
  _enforceLoadingBoundary = enabled;
2597
+ if (enabled) _reportedUnhandledAsync = false;
1417
2598
  }
1418
2599
  function setProjectionWriteActive(value) {
1419
2600
  projectionWriteActive = value;
@@ -1451,6 +2632,7 @@ function createBatch() {
1451
2632
  };
1452
2633
  }
1453
2634
  function mergeTransitionState(target, outgoing) {
2635
+ if (attrHooks !== null) attrHooks.transitionMerged(target, outgoing);
1454
2636
  outgoing._done = target;
1455
2637
  target._actions.push(...outgoing._actions);
1456
2638
  for (const lane of activeLanes) if (lane._transition === outgoing) lane._transition = target;
@@ -1469,27 +2651,6 @@ function mergeTransitionState(target, outgoing) {
1469
2651
  outgoing._affectsNodes.length = 0;
1470
2652
  }
1471
2653
  for (const store of outgoing._optimisticStores) target._optimisticStores.add(store);
1472
- // Patch-channel stash (store/next/patch.ts): entries held for the outgoing
1473
- // transition must ride the merge like every other per-transition
1474
- // collection — releaseBatch only reads the COMMITTING transition's stash,
1475
- // so a stranded sidecar would silently drop its patches. Move (don't
1476
- // copy), same aliasing rule as the collections above. The field is an
1477
- // expando so this module stays free of patch imports (pay-for-use).
1478
- const heldPatches = outgoing._heldPatches;
1479
- if (heldPatches !== undefined) {
1480
- outgoing._heldPatches = undefined;
1481
- let dest = target._heldPatches;
1482
- if (dest !== undefined) dest.push(...heldPatches);
1483
- else dest = target._heldPatches = heldPatches;
1484
- // Retarget the entries' coalescing stamps to the surviving stash
1485
- // (opaque backref contract with store/next/patch.ts): without this a
1486
- // post-merge emission misses the stamp and pushes a SECOND entry —
1487
- // the record's patch applies twice at commit (re-audit 5, P1-2).
1488
- for (let i = 0; i < heldPatches.length; i++) {
1489
- const pc = heldPatches[i].pc;
1490
- if (pc !== undefined && pc.qe === heldPatches[i]) pc.qa = dest;
1491
- }
1492
- }
1493
2654
  for (const [source, reporters] of outgoing._asyncReporters) {
1494
2655
  let targetReporters = target._asyncReporters.get(source);
1495
2656
  if (!targetReporters) target._asyncReporters.set(source, (targetReporters = new Set()));
@@ -1603,8 +2764,8 @@ function schedule() {
1603
2764
  notifyHalted();
1604
2765
  return;
1605
2766
  }
1606
- if (scheduled$1) return;
1607
- scheduled$1 = true;
2767
+ if (scheduled) return;
2768
+ scheduled = true;
1608
2769
  if (!syncDepth && !globalQueue._running && !projectionWriteActive) queueMicrotask(flush);
1609
2770
  }
1610
2771
  /**
@@ -1709,6 +2870,9 @@ class Queue {
1709
2870
  schedule();
1710
2871
  }
1711
2872
  stashQueues(stub) {
2873
+ // Attribution hook: the parking transition's lane effects have run; its
2874
+ // queues are being stashed. Root call only (children recurse below).
2875
+ if (attrHooks !== null && this === globalQueue) attrHooks.holdEnd();
1712
2876
  stub._queues[0].push(...this._queues[0]);
1713
2877
  stub._queues[1].push(...this._queues[1]);
1714
2878
  this._queues = [[], []];
@@ -1796,9 +2960,10 @@ class GlobalQueue extends Queue {
1796
2960
  static _laneReadsCommitted = null;
1797
2961
  static _recomputeLane = null;
1798
2962
  static _laneAsyncPending = null;
1799
- /** Authoritative-view reader wakeup (until()): installed at first until() call.
1800
- * Call sites are gated by CONFIG_AUTHORITATIVE_OBSERVED, which only until()'s
1801
- * carve-out read can set, so `!` invocations are safe once the gate holds. */
2963
+ /** Authoritative-view reader wakeup: installed by until() and refresh() before
2964
+ * their first read. Call sites are gated by CONFIG_AUTHORITATIVE_OBSERVED, which
2965
+ * only such a reader's carve-out read can set, so `!` invocations are safe once
2966
+ * the gate holds (#3303). */
1802
2967
  static _notifyAuthoritativeObservers = null;
1803
2968
  static _laneAsyncSettled = null;
1804
2969
  static _trackOptimisticStore = null;
@@ -1845,7 +3010,7 @@ class GlobalQueue extends Queue {
1845
3010
  // A kept ambient batch may hold pending nodes (#2916): stay
1846
3011
  // scheduled so the outer drain loop commits them via the plain
1847
3012
  // flush path instead of leaving them until the next natural flush.
1848
- scheduled$1 = dirtyQueue._max >= dirtyQueue._min || this._batch._pendingNodes.length > 0;
3013
+ scheduled = dirtyQueue._max >= dirtyQueue._min || this._batch._pendingNodes.length > 0;
1849
3014
  reassignPendingTransition(stashedTransition._pendingNodes);
1850
3015
  activeTransition = null;
1851
3016
  finalizePureQueue(null, true);
@@ -1885,7 +3050,7 @@ class GlobalQueue extends Queue {
1885
3050
  }
1886
3051
  clock++;
1887
3052
  // Check if finalization added items to the heap (from optimistic reversion)
1888
- scheduled$1 = dirtyQueue._max >= dirtyQueue._min;
3053
+ scheduled = dirtyQueue._max >= dirtyQueue._min;
1889
3054
  // Run lane effects first (for ready lanes), then regular effects
1890
3055
  activeLanes.size && GlobalQueue._runLaneEffects(EFFECT_RENDER);
1891
3056
  this.run(EFFECT_RENDER);
@@ -1902,7 +3067,7 @@ class GlobalQueue extends Queue {
1902
3067
  }
1903
3068
  if (
1904
3069
  true &&
1905
- !scheduled$1 &&
3070
+ !scheduled &&
1906
3071
  !activeTransition &&
1907
3072
  transitions.size === 0 &&
1908
3073
  activeLanes.size === 0
@@ -1919,21 +3084,32 @@ class GlobalQueue extends Queue {
1919
3084
  // Only track async if the boundary is propagating STATUS_PENDING (not caught by boundary)
1920
3085
  if (mask & STATUS_PENDING) {
1921
3086
  if (flags & STATUS_PENDING) {
1922
- const actualError = error !== undefined ? error : node._x?._error;
3087
+ // Callers pass either nothing or this node's own `_x._error`, so `??`
3088
+ // is exact (a null error falls back to the same null).
3089
+ const actualError = error ?? node._x?._error;
1923
3090
  // A visibility-only mark notification (the affects() boundary
1924
3091
  // channel) updates display state on its way up but must be invisible
1925
3092
  // to completion accounting BY CONSTRUCTION: it never registers a
1926
3093
  // reporter and never counts toward the loading-boundary diagnostic.
1927
3094
  if (actualError?._markVisual) return true;
1928
- if (activeTransition && actualError) {
1929
- const source = actualError.source;
1930
- let reporters = activeTransition._asyncReporters.get(source);
1931
- if (!reporters) activeTransition._asyncReporters.set(source, (reporters = new Set()));
1932
- const prevSize = reporters.size;
1933
- reporters.add(node);
1934
- if (reporters.size !== prevSize) {
1935
- schedule();
1936
- GlobalQueue._wakeSuppressedProbes?.(activeTransition);
3095
+ if (actualError) {
3096
+ // A reveal can discover a flight started in an earlier flush. Hold
3097
+ // the staged writes with that reader (A15), even if the reader is
3098
+ // new. Fresh/reset loading boundaries consume pending before it
3099
+ // reaches here. A reader already parked in a transition must not
3100
+ // open a second one.
3101
+ if (!activeTransition && !node._transition && currentBatch._pendingNodes.length)
3102
+ this.initTransition();
3103
+ if (activeTransition) {
3104
+ const source = actualError.source;
3105
+ let reporters = activeTransition._asyncReporters.get(source);
3106
+ if (!reporters) activeTransition._asyncReporters.set(source, (reporters = new Set()));
3107
+ const prevSize = reporters.size;
3108
+ reporters.add(node);
3109
+ if (reporters.size !== prevSize) {
3110
+ schedule();
3111
+ GlobalQueue._wakeSuppressedProbes?.(activeTransition);
3112
+ }
1937
3113
  }
1938
3114
  }
1939
3115
  if (_enforceLoadingBoundary) _hitUnhandledAsync = true;
@@ -1960,6 +3136,11 @@ class GlobalQueue extends Queue {
1960
3136
  } else if (transition) {
1961
3137
  const outgoing = activeTransition;
1962
3138
  mergeTransitionState(transition, outgoing);
3139
+ // Effects the outgoing transaction parked belong to the surviving one
3140
+ // now: back onto the live queue, where this flush parks them under
3141
+ // `transition` or runs them at its completion. The outgoing stash is
3142
+ // never read again — the transaction is dead (#3310).
3143
+ this.restoreQueues(outgoing._queueStash);
1963
3144
  transitions.delete(outgoing);
1964
3145
  activeTransition = transition;
1965
3146
  }
@@ -2117,16 +3298,6 @@ let storeCommitHook = null;
2117
3298
  function setStoreCommitHook(fn) {
2118
3299
  storeCommitHook = fn;
2119
3300
  }
2120
- /** Patch-channel release hook (next/patch.ts): transition-stamped patch
2121
- * emissions are released when THEIR batch commits. Transitions never
2122
- * abort: failed actions still commit (only optimistic overrides revert),
2123
- * and merged-away transitions hand their stash to the survivor
2124
- * (mergeTransitionState) — every stash drains exactly once. Injected like
2125
- * storeCommitHook to stay tree-shakeable. */
2126
- let patchCommitHook = null;
2127
- function setPatchCommitHook(fn) {
2128
- patchCommitHook = fn;
2129
- }
2130
3301
  /** Held truth committed this finalize, awaiting its post-revert wake (see
2131
3302
  * finalizePureQueue): the commit IS the reveal, but subscribers must not
2132
3303
  * re-derive until the settling transaction's optimistic overrides have
@@ -2158,7 +3329,6 @@ function commitPendingNodes() {
2158
3329
  }
2159
3330
  pendingNodes.length = 0;
2160
3331
  storeCommitHook?.();
2161
- patchCommitHook?.(currentBatch);
2162
3332
  }
2163
3333
  function finalizePureQueue(completingTransition = null, incomplete = false) {
2164
3334
  // For incomplete transitions, skip pending resolution and optimistic reversion
@@ -2287,13 +3457,14 @@ function flush(fn) {
2287
3457
  const message =
2288
3458
  "[FLUSH_IN_EFFECT_CALLBACK] flush() called from inside an effect callback is a no-op: the flush that runs effects is already in progress. " +
2289
3459
  "Writes made here are processed in the same flush's continuation; to force a drain afterwards, defer it: queueMicrotask(() => flush()).";
2290
- emitDiagnostic({
2291
- code: "FLUSH_IN_EFFECT_CALLBACK",
2292
- kind: "lifecycle",
2293
- severity: "warn",
2294
- message
2295
- });
2296
- console.warn(message);
3460
+ reportDiagnostic(
3461
+ emitDiagnostic({
3462
+ code: "FLUSH_IN_EFFECT_CALLBACK",
3463
+ kind: "lifecycle",
3464
+ severity: "warn",
3465
+ message
3466
+ })
3467
+ );
2297
3468
  }
2298
3469
  return;
2299
3470
  }
@@ -2301,7 +3472,7 @@ function flush(fn) {
2301
3472
  let count = 0;
2302
3473
  // `flush()` is an explicit drain point, so it must also process an active
2303
3474
  // transition even if no microtask was scheduled for it yet.
2304
- while (scheduled$1 || activeTransition) {
3475
+ while (scheduled || activeTransition) {
2305
3476
  if (++count === 1e5) {
2306
3477
  // Attribution beats a bare guard (#3140): say what kept the loop alive.
2307
3478
  // A completed transition being re-activated reads `done=true` here —
@@ -2309,7 +3480,7 @@ function flush(fn) {
2309
3480
  // (#2843) usually show staged work naming the culprit node.
2310
3481
  const t = activeTransition;
2311
3482
  throw new Error(
2312
- `Potential Infinite Loop Detected. Kept alive by ${scheduled$1 ? "scheduled work" : "an active transition"}${
3483
+ `Potential Infinite Loop Detected. Kept alive by ${scheduled ? "scheduled work" : "an active transition"}${
2313
3484
  t
2314
3485
  ? `; transition: done=${t._done === true}, pending=${t._pendingNodes.length}, optimistic=${t._optimisticNodes.length}, asyncReporters=${t._asyncReporters.size}`
2315
3486
  : ""
@@ -2340,7 +3511,11 @@ function reporterBlocksSource(reporter, source) {
2340
3511
  }
2341
3512
  function transitionComplete(transition) {
2342
3513
  if (transition._done) return true;
2343
- if (transition._actions.length) return false;
3514
+ if (transition._actions.length) {
3515
+ // A live action parks the transaction regardless of async state.
3516
+ if (attrHooks !== null) attrHooks.holdStart(transition);
3517
+ return false;
3518
+ }
2344
3519
  let done = true;
2345
3520
  for (const [source, reporters] of transition._asyncReporters) {
2346
3521
  let hasLive = false;
@@ -2361,6 +3536,13 @@ function transitionComplete(transition) {
2361
3536
  // blockage"); the hook's loops over _optimisticNodes/_optimisticStores are
2362
3537
  // no-ops when the transition holds neither, so no pre-check is needed.
2363
3538
  if (done && GlobalQueue._transitionBlocked?.(transition)) done = false;
3539
+ // Attribution hook: this verdict is the fork between settling (held writes
3540
+ // commit next — `_pendingNodes` still lists them) and parking (the flush
3541
+ // runs the lane effects, then stashes; `holdEnd` fires from stashQueues).
3542
+ // Fired here rather than at flush()'s call site because that site is inside
3543
+ // a `try` (see the rule in attribution-hooks.ts).
3544
+ if (attrHooks !== null)
3545
+ done ? attrHooks.transitionSettled(transition) : attrHooks.holdStart(transition);
2364
3546
  done && (transition._done = true);
2365
3547
  return done;
2366
3548
  }
@@ -2413,19 +3595,13 @@ function queueFor(n) {
2413
3595
  return n._flags & REACTIVE_ZOMBIE ? zombieQueue : dirtyQueue;
2414
3596
  }
2415
3597
  /**
2416
- * Schedule one subscriber to re-run on the next flush: tracked effects bypass
2417
- * the heap and go directly to their effect queue; everything else is inserted
2418
- * into its own (zombie-flag-routed) heap with the `_min` cursor pulled down.
3598
+ * Schedule one subscriber to re-run on the next flush: inserted into its own
3599
+ * (zombie-flag-routed) heap with the `_min` cursor pulled down. Tracked
3600
+ * effects ride the heap too the heap visit is their (empty) compute phase,
3601
+ * which hands the callback to the user queue once the pass has committed
3602
+ * (see GlobalQueue._update, #3291).
2419
3603
  */
2420
3604
  function enqueueSub(node) {
2421
- if (node._type === EFFECT_TRACKED) {
2422
- const tracked = node;
2423
- if (!tracked._modified) {
2424
- tracked._modified = true;
2425
- tracked._queue.enqueue(EFFECT_USER, tracked._run);
2426
- }
2427
- return;
2428
- }
2429
3605
  const queue = queueFor(node);
2430
3606
  if (queue._min > node._height) queue._min = node._height;
2431
3607
  insertIntoHeap(node, queue);
@@ -2876,7 +4052,10 @@ function unlinkSubs(link) {
2876
4052
  else {
2877
4053
  dep._subs = nextSub;
2878
4054
  if (nextSub === null) {
2879
- dep._x?._unobserved?.();
4055
+ // Slot nodes (store leaves) dispatch to the ONE shared hook — no
4056
+ // per-node unobserved closure, no NodeExtension to hold it.
4057
+ if (dep._config & CONFIG_SLOT_NODE) slotUnobservedHook(dep);
4058
+ else dep._x?._unobserved?.();
2880
4059
  // No more subscribers; only tear down if CONFIG_AUTO_DISPOSE is set.
2881
4060
  // A pending node is exempt: its in-flight async work (or the
2882
4061
  // transition holding it) is an observer — tearing down would orphan
@@ -3397,7 +4576,12 @@ function handleAsync(el, result, setter) {
3397
4576
  // change (and only then classify it as an async landing).
3398
4577
  if (attrHooks !== null) attrHooks.asyncStart(el);
3399
4578
  if (setter) {
3400
- setter(value);
4579
+ try {
4580
+ setter(value);
4581
+ } catch (error) {
4582
+ handleError(error);
4583
+ return;
4584
+ }
3401
4585
  if (wasUninitialized) clearStatus(el, true);
3402
4586
  } else if (el._x?._overrideValue !== undefined) {
3403
4587
  // Optimistic node — resting OR covered by an active override — holds
@@ -3818,7 +5002,21 @@ function notifyStatus(el, status, error, blockStatus, lane) {
3818
5002
  });
3819
5003
  }
3820
5004
 
3821
- GlobalQueue._update = recompute;
5005
+ // The heap's per-node step. A tracked effect's heap visit is its compute
5006
+ // phase — empty, like a user effect whose compute reads nothing — and hands
5007
+ // the callback to the user queue. Routing the wake through the heap, rather
5008
+ // than straight into the queue at notify time, is what orders the run after
5009
+ // the commit regardless of which phase the write came from: a write in a
5010
+ // render-effect callback stages its value for the next pass, but a wake pushed
5011
+ // directly into the user queue ran in the SAME pass, read the old value, and
5012
+ // nothing re-notified it when the value landed (#3291).
5013
+ GlobalQueue._update = el => {
5014
+ if (el._type === EFFECT_TRACKED) {
5015
+ deleteFromHeap(el, queueFor(el));
5016
+ el._modified = true;
5017
+ el._queue.enqueue(EFFECT_USER, el._run);
5018
+ } else recompute(el);
5019
+ };
3822
5020
  GlobalQueue._dispose = disposeChildren;
3823
5021
  const PRIMITIVE_IN_FORBIDDEN_SCOPE_MESSAGE =
3824
5022
  "[PRIMITIVE_IN_FORBIDDEN_SCOPE] Cannot create reactive primitives inside createTrackedEffect or owner-backed onSettled";
@@ -4183,11 +5381,11 @@ function recompute(el, create = false) {
4183
5381
  if (el._pendingValue === NOT_PENDING) queuePendingNode(el);
4184
5382
  el._pendingValue = value;
4185
5383
  if (wasLoading) el._loading = true; // see the held branch above (#2990)
4186
- // A authoritative-view reader (until()) observed this node past its
4187
- // override — and "authoritative arrival equal to the override" is
4188
- // exactly the acknowledgment it waits for. Wake those readers only;
4189
- // A17 silence holds for every ordinary subscriber. (Hook installed by
4190
- // until(), the only setter of the gating bit.)
5384
+ // An authoritative-view reader (until()'s predicate, refresh()'s waiter)
5385
+ // observed this node past its override — and "authoritative arrival
5386
+ // equal to the override" is exactly the acknowledgment it waits for.
5387
+ // Wake those readers only; A17 silence holds for every ordinary
5388
+ // subscriber. (Hook installed by both setters of the gating bit, #3303.)
4191
5389
  if (el._config & CONFIG_AUTHORITATIVE_OBSERVED) GlobalQueue._notifyAuthoritativeObservers(el);
4192
5390
  } else if (el._height != oldHeight) {
4193
5391
  for (let s = el._subs; s !== null; s = s._nextSub) {
@@ -4517,6 +5715,61 @@ function signal(v, options, firewall = null) {
4517
5715
  }
4518
5716
  return s;
4519
5717
  }
5718
+ // ---------------------------------------------------------------------------
5719
+ // SLOT SIGNALS (store leaves) — the create-floor diet. Store mounts
5720
+ // materialize one signal per touched leaf (~13 × rows on dbmon), so per-node
5721
+ // allocations are mount bytes: the generic path costs an options object, an
5722
+ // equals closure, an unobserved closure, a NodeExtension to hold it, and
5723
+ // three post-construction expandos (acc/px/pxv → hidden-class transitions).
5724
+ // slotSignal bakes everything into ONE literal: `_host`/`_key` backrefs
5725
+ // replace the closures (equals is a method call — `this` is the node; the
5726
+ // unobserved sweep dispatches CONFIG_SLOT_NODE to one shared hook), and the
5727
+ // store's wrap-cache fields are pre-shaped.
5728
+ /** The shared slot-node unobserved handler — a live binding read directly by
5729
+ * the sweep sites (no wrapper frame, no null check: a CONFIG_SLOT_NODE node
5730
+ * existing implies the store module loaded and registered the hook). */
5731
+ let slotUnobservedHook;
5732
+ /** Install the shared slot-node unobserved handler (store module, once). */
5733
+ function setSlotUnobserved(fn) {
5734
+ slotUnobservedHook = fn;
5735
+ }
5736
+ function slotSignal(v, equals, host, key, acc, firewall = null) {
5737
+ const s = {
5738
+ _equals: equals,
5739
+ _config: CONFIG_OWNED_WRITE | CONFIG_SLOT_NODE,
5740
+ _value: v,
5741
+ _subs: null,
5742
+ _subsTail: null,
5743
+ _time: clock,
5744
+ _firewall: firewall,
5745
+ _nextChild: firewall?._x?._child || null,
5746
+ _pendingValue: NOT_PENDING,
5747
+ _transition: null,
5748
+ _notifiedAt: -1,
5749
+ _x: null,
5750
+ // Slot backrefs: what the equals/unobserved closures used to capture.
5751
+ _host: host,
5752
+ _key: key,
5753
+ // Store read-path caches, pre-shaped (were post-construction expandos).
5754
+ acc,
5755
+ px: undefined,
5756
+ pxv: undefined
5757
+ };
5758
+ {
5759
+ s._name = "signal";
5760
+ s._internal = !!firewall;
5761
+ }
5762
+ if (firewall) {
5763
+ ext(firewall)._child = s;
5764
+ firewall._config |= CONFIG_FW_CHILDREN;
5765
+ }
5766
+ if (snapshotCaptureActive && !((firewall?._statusFlags ?? 0) & STATUS_PENDING)) {
5767
+ ext(s)._snapshotValue = v === undefined ? NO_SNAPSHOT : v;
5768
+ s._config |= CONFIG_HAS_SNAPSHOT;
5769
+ snapshotSources.add(s);
5770
+ }
5771
+ return s;
5772
+ }
4520
5773
  function optimisticSignal(v, options) {
4521
5774
  const s = signal(v, options);
4522
5775
  ext(s)._overrideValue = NOT_PENDING;
@@ -4636,9 +5889,11 @@ function notifyAuthoritativeObservers(el) {
4636
5889
  }
4637
5890
  schedule();
4638
5891
  }
4639
- /** Installs the until() machinery hook. Idempotent; called by until() before
4640
- * any authoritative-view read happens (same late-binding contract as the
4641
- * optimistic engine). */
5892
+ /** Installs the authoritative-reader wakeup hook. Idempotent; called by every
5893
+ * creator of a CONFIG_AUTHORITATIVE_READ computation — until() and refresh()
5894
+ * before its first read (same late-binding contract as the optimistic engine;
5895
+ * the gating bit is only ever set by such a read, so the `!` call sites are
5896
+ * safe once every setter installs, #3303). */
4642
5897
  function installAuthoritativeRead() {
4643
5898
  if (GlobalQueue._notifyAuthoritativeObservers === null)
4644
5899
  GlobalQueue._notifyAuthoritativeObservers = notifyAuthoritativeObservers;
@@ -4742,20 +5997,26 @@ function read(el) {
4742
5997
  const message =
4743
5998
  "[PENDING_ASYNC_FORBIDDEN_SCOPE] Reading a pending async value inside createTrackedEffect or onSettled will throw. " +
4744
5999
  "Use createEffect instead which supports async-aware reactivity.";
4745
- emitDiagnostic({
4746
- code: "PENDING_ASYNC_FORBIDDEN_SCOPE",
4747
- kind: "async",
4748
- severity: "warn",
4749
- message,
4750
- ownerId: c.id,
4751
- ownerName: c._name,
4752
- nodeName: owner?._name
4753
- });
4754
- console.warn(message);
6000
+ reportDiagnostic(
6001
+ emitDiagnostic(
6002
+ {
6003
+ code: "PENDING_ASYNC_FORBIDDEN_SCOPE",
6004
+ kind: "async",
6005
+ severity: "warn",
6006
+ message,
6007
+ ownerId: c.id,
6008
+ ownerName: c._name,
6009
+ nodeName: owner?._name
6010
+ },
6011
+ c
6012
+ )
6013
+ );
4755
6014
  }
4756
6015
  // Per-lane suspension lives with the engine (a non-null lane implies it
4757
6016
  // is installed): under a lane, only same-lane pending async without an
4758
- // active override throws.
6017
+ // active override throws — plus uninitialized sources regardless of
6018
+ // lane (#3276); that check rides laneSuspends so floor bundles don't
6019
+ // pay for it.
4759
6020
  if (currentOptimisticLane === null || GlobalQueue._laneSuspends(owner)) {
4760
6021
  if (!tracking && el !== c) link(el, c);
4761
6022
  throw owner._x?._error;
@@ -5025,15 +6286,19 @@ function runWithOwner(owner, fn) {
5025
6286
  if (owner && owner._flags & REACTIVE_DISPOSED) {
5026
6287
  const message =
5027
6288
  "[RUN_WITH_DISPOSED_OWNER] runWithOwner called with a disposed owner. Children created inside will never be disposed.";
5028
- emitDiagnostic({
5029
- code: "RUN_WITH_DISPOSED_OWNER",
5030
- kind: "owner",
5031
- severity: "warn",
5032
- message,
5033
- ownerId: owner.id,
5034
- ownerName: owner._name
5035
- });
5036
- console.warn(message);
6289
+ reportDiagnostic(
6290
+ emitDiagnostic(
6291
+ {
6292
+ code: "RUN_WITH_DISPOSED_OWNER",
6293
+ kind: "owner",
6294
+ severity: "warn",
6295
+ message,
6296
+ ownerId: owner.id,
6297
+ ownerName: owner._name
6298
+ },
6299
+ owner
6300
+ )
6301
+ );
5037
6302
  }
5038
6303
  const oldContext = context;
5039
6304
  const prevTracking = tracking;
@@ -5090,8 +6355,14 @@ function markRefresh(node) {
5090
6355
  // for the rest of the transaction (#3026).
5091
6356
  if (node._manualWriteTime === clock) return;
5092
6357
  node._flags &= ~REACTIVE_MANUAL_WRITE;
5093
- // No REASK below: the batch carries a manual value change, so the
5094
- // recompute is not a quiet re-ask of an unchanged question.
6358
+ // The lift falls through to the re-ask classification below. The held
6359
+ // write's value change already rides the transaction; the refetch it
6360
+ // asks for is the same question with unchanged inputs. Skipping the
6361
+ // mark here classified that refetch as a NEW question, which pends
6362
+ // every leaf (3.1) — an action doing setStore + yield + refresh(store)
6363
+ // lit up every sibling row, and affects() could not narrow it (a mark
6364
+ // only turns pending on). Same-question motion stays silent (3.4);
6365
+ // the written slot and any declared mark carry the pending instead.
5095
6366
  }
5096
6367
  // A refresh with no value-change dirt already queued is a re-ask of the
5097
6368
  // same question: mark it so the recompute classifies any resulting
@@ -5100,7 +6371,7 @@ function markRefresh(node) {
5100
6371
  // REACTIVE_IN_HEAP counts as dirt: insertSubs schedules subscribers by
5101
6372
  // heap insertion alone (no DIRTY/CHECK flag), so a same-batch value
5102
6373
  // change followed by refresh() must not be laundered into a quiet re-ask.
5103
- else if (!(node._flags & (REACTIVE_DIRTY | REACTIVE_CHECK | REACTIVE_IN_HEAP))) {
6374
+ if (!(node._flags & (REACTIVE_DIRTY | REACTIVE_CHECK | REACTIVE_IN_HEAP))) {
5104
6375
  node._flags |= REACTIVE_REASK;
5105
6376
  armReaskClear();
5106
6377
  }
@@ -5219,8 +6490,10 @@ function getContext(context, owner = getOwner()) {
5219
6490
  if (!owner) {
5220
6491
  throw new NoOwnerError();
5221
6492
  }
5222
- const value = hasContext(context, owner) ? owner._context[context.id] : context.defaultValue;
5223
- if (isUndefined(value)) {
6493
+ // `undefined` alone means unset a provided `null` is a value (no `??`).
6494
+ let value = owner._context[context.id];
6495
+ if (value === undefined) value = context.defaultValue;
6496
+ if (value === undefined) {
5224
6497
  throw new ContextNotFoundError();
5225
6498
  }
5226
6499
  return value;
@@ -5242,15 +6515,9 @@ function setContext(context, value, owner = getOwner()) {
5242
6515
  // we don't do this, everything will be a singleton and all hell will break lose.
5243
6516
  owner._context = {
5244
6517
  ...owner._context,
5245
- [context.id]: isUndefined(value) ? context.defaultValue : value
6518
+ [context.id]: value === undefined ? context.defaultValue : value
5246
6519
  };
5247
6520
  }
5248
- function hasContext(context, owner) {
5249
- return !isUndefined(owner?._context[context.id]);
5250
- }
5251
- function isUndefined(value) {
5252
- return typeof value === "undefined";
5253
- }
5254
6521
 
5255
6522
  /**
5256
6523
  * The optimistic write engine, moved out of core.ts/scheduler.ts. Everything
@@ -5370,11 +6637,12 @@ function runQueue(queue, type) {
5370
6637
  for (let i = 0; i < queue.length; i++) queue[i](type);
5371
6638
  }
5372
6639
  /**
5373
- * Run effects from all lanes that are ready (no pending async).
6640
+ * Run effects from all lanes that are ready (no OBSERVED pending async — see
6641
+ * laneHeld).
5374
6642
  */
5375
6643
  function runLaneEffects(type) {
5376
6644
  for (const lane of activeLanes) {
5377
- if (lane._mergedInto || lane._pendingAsync.size > 0) continue;
6645
+ if (lane._mergedInto || laneHeld(lane)) continue;
5378
6646
  const effects = lane._effectQueues[type - 1];
5379
6647
  if (effects.length) {
5380
6648
  lane._effectQueues[type - 1] = [];
@@ -5406,6 +6674,14 @@ function cleanupCompletedLanes(completingTransition) {
5406
6674
  }
5407
6675
  /** read()'s per-lane suspension test (pending-throw path, lane context). */
5408
6676
  function laneSuspends(owner) {
6677
+ // An UNINITIALIZED async source suspends regardless of lane (#3276): a
6678
+ // lane mismatch preserves an already-committed stale value, but a source
6679
+ // with no committed truth has nothing to serve — the cross-lane read
6680
+ // surfaced a fabricated `undefined` where latest() itself suspends
6681
+ // (latestRead rethrows NotReady for tracked uninitialized reads). Lives
6682
+ // here rather than read()'s throw path so the floor bundles don't pay:
6683
+ // this is only reachable under a lane, which implies the engine.
6684
+ if (owner._statusFlags & STATUS_UNINITIALIZED) return true;
5409
6685
  // Per-lane suspension: only throw if in same lane as pending async
5410
6686
  // AND the node doesn't have an active override (overrides are the visible value,
5411
6687
  // downstream in the lane should read the override, not throw)
@@ -5513,14 +6789,16 @@ function recomputeLane(el, own) {
5513
6789
  }
5514
6790
  return null;
5515
6791
  }
5516
- /** recompute()'s catch path: track pending async in the current lane. */
6792
+ /** recompute()'s catch path: record the pending async as the current lane's
6793
+ * (ownership — laneHeld decides the hold). The lane source's isPending
6794
+ * companion is NOT refreshed here: its verdict never read _pendingAsync, and
6795
+ * the source's own write/commit/settlement paths keep it current. */
5517
6796
  function laneAsyncPending(el) {
5518
6797
  const lane = findLane(currentOptimisticLane);
5519
6798
  if (lane._source !== el) {
5520
6799
  lane._pendingAsync.add(el);
5521
6800
  ext(el)._optimisticLane = lane;
5522
6801
  el._config |= CONFIG_HAS_LANE;
5523
- GlobalQueue._updatePendingSignal !== null && GlobalQueue._updatePendingSignal(lane._source);
5524
6802
  }
5525
6803
  }
5526
6804
  /** recompute()'s success path: the node's async settled, clear it from its lane. */
@@ -5528,8 +6806,6 @@ function laneAsyncSettled(el) {
5528
6806
  const resolvedLane = resolveLane(el);
5529
6807
  if (resolvedLane) {
5530
6808
  resolvedLane._pendingAsync.delete(el);
5531
- GlobalQueue._updatePendingSignal !== null &&
5532
- GlobalQueue._updatePendingSignal(resolvedLane._source);
5533
6809
  }
5534
6810
  }
5535
6811
  function trackOptimisticStore(store) {
@@ -5658,10 +6934,6 @@ function markWalk(el, seen) {
5658
6934
  }
5659
6935
  return false;
5660
6936
  }
5661
- /** Gated entry: apps with no live mark pay one integer compare. */
5662
- function markCovered(el) {
5663
- return activeAffectsMarks !== 0 && markWalk(el, new Set());
5664
- }
5665
6937
  function quietPending(el) {
5666
6938
  if (el._x?._pendingSources) {
5667
6939
  for (const source of el._x._pendingSources) if (!source._x?._reask) return false;
@@ -5689,7 +6961,8 @@ function computePendingState(el) {
5689
6961
  // Mark coverage is transitive by dep-graph reachability: a latest() shadow
5690
6962
  // reaches its owner (and a store leaf its firewall) through its own deps,
5691
6963
  // so the one walk covers direct marks, derivation, and companion chains.
5692
- if (markCovered(el)) return true;
6964
+ // Gated: apps with no live mark pay one integer compare.
6965
+ if (activeAffectsMarks !== 0 && markWalk(el, new Set())) return true;
5693
6966
  const firewall = el._firewall;
5694
6967
  if (el._x?._parentSource) {
5695
6968
  const parentNode = el._x?._parentSource;
@@ -5907,7 +7180,7 @@ function latestRead(el) {
5907
7180
  if (stale && currentOptimisticLane && pendingComputed._x?._optimisticLane) {
5908
7181
  const pcLane = findLane(pendingComputed._x?._optimisticLane);
5909
7182
  const curLane = findLane(currentOptimisticLane);
5910
- if (pcLane !== curLane && pcLane._pendingAsync.size > 0) {
7183
+ if (pcLane !== curLane && laneHeld(pcLane)) {
5911
7184
  return visibleValue;
5912
7185
  }
5913
7186
  }
@@ -6131,16 +7404,20 @@ function effect(compute, effect, error, options) {
6131
7404
  if (!node._parent) {
6132
7405
  const message =
6133
7406
  "[NO_OWNER_EFFECT] Effects created outside a reactive context will never be disposed";
6134
- emitDiagnostic({
6135
- code: "NO_OWNER_EFFECT",
6136
- kind: "lifecycle",
6137
- severity: "warn",
6138
- message,
6139
- ownerId: node.id,
6140
- ownerName: node._name,
6141
- data: { effectType: "effect" }
6142
- });
6143
- console.warn(message);
7407
+ reportDiagnostic(
7408
+ emitDiagnostic(
7409
+ {
7410
+ code: "NO_OWNER_EFFECT",
7411
+ kind: "lifecycle",
7412
+ severity: "warn",
7413
+ message,
7414
+ ownerId: node.id,
7415
+ ownerName: node._name,
7416
+ data: { effectType: "effect" }
7417
+ },
7418
+ node
7419
+ )
7420
+ );
6144
7421
  }
6145
7422
  }
6146
7423
  function notifyEffectStatus(status, error) {
@@ -6171,24 +7448,29 @@ function notifyEffectStatus(status, error) {
6171
7448
  }
6172
7449
  } else if (this._type === EFFECT_RENDER) {
6173
7450
  this._queue.notify(this, STATUS_PENDING | STATUS_ERROR, actualStatus, actualError);
6174
- if (_hitUnhandledAsync) {
7451
+ if (_hitUnhandledAsync && resetUnhandledAsync()) {
6175
7452
  // Async without a `Loading` ancestor is legal (the mount defers), so this
6176
7453
  // is a consistent FYI — an `Errored` above must not swallow it. The old
6177
7454
  // STATUS_ERROR re-notify here dated from when enforcement routed the
6178
7455
  // pending to the error boundary; that both suppressed the warning and
6179
- // showed the error fallback in dev only (#2822).
6180
- resetUnhandledAsync();
7456
+ // showed the error fallback in dev only (#2822). Reported once per
7457
+ // mount (resetUnhandledAsync gates), located at the first pending
7458
+ // effect's owner path.
6181
7459
  const message =
6182
7460
  "[ASYNC_OUTSIDE_LOADING_BOUNDARY] An async value was read outside a Loading boundary. The root mount will be deferred until all pending async settles.";
6183
- emitDiagnostic({
6184
- code: "ASYNC_OUTSIDE_LOADING_BOUNDARY",
6185
- kind: "async",
6186
- severity: "warn",
6187
- message,
6188
- ownerId: this.id,
6189
- ownerName: this._name
6190
- });
6191
- console.warn(message);
7461
+ reportDiagnostic(
7462
+ emitDiagnostic(
7463
+ {
7464
+ code: "ASYNC_OUTSIDE_LOADING_BOUNDARY",
7465
+ kind: "async",
7466
+ severity: "warn",
7467
+ message,
7468
+ ownerId: this.id,
7469
+ ownerName: this._name
7470
+ },
7471
+ this
7472
+ )
7473
+ );
6192
7474
  }
6193
7475
  }
6194
7476
  }
@@ -6228,6 +7510,7 @@ function runEffect(node) {
6228
7510
  {
6229
7511
  prevStrictRead = setStrictRead("an effect callback");
6230
7512
  setEffectCallback(true);
7513
+ if (attrHooks !== null) attrHooks.effectRunStart(node);
6231
7514
  }
6232
7515
  const prevCleanup = node._cleanup;
6233
7516
  node._cleanup = undefined;
@@ -6256,6 +7539,9 @@ function runEffect(node) {
6256
7539
  node._prevValue = node._value;
6257
7540
  node._modified = false;
6258
7541
  }
7542
+ // Outside the try (see the rule in attribution-hooks.ts). Reached whether or
7543
+ // not the callback threw — a throw that escapes the catch above halts.
7544
+ if (attrHooks !== null) attrHooks.effectRunEnd(node);
6259
7545
  }
6260
7546
  GlobalQueue._runEffect = runEffect;
6261
7547
  /**
@@ -6265,6 +7551,9 @@ GlobalQueue._runEffect = runEffect;
6265
7551
  */
6266
7552
  function trackedEffect(fn, options) {
6267
7553
  const run = () => {
7554
+ // `_modified` is NOT redundant with the heap: the heap dedups within a
7555
+ // pass, but a held transition's passes each enqueue `_run` into the same
7556
+ // user queue, and this gate is what collapses them into one run at commit.
6268
7557
  if (!node._modified || node._flags & REACTIVE_DISPOSED) return;
6269
7558
  setTrackedQueueCallback(true);
6270
7559
  try {
@@ -6297,20 +7586,28 @@ function trackedEffect(fn, options) {
6297
7586
  // _type): its error arm is behavior-identical to the closure that used to
6298
7587
  // live here, without the per-node NodeExtension allocation.
6299
7588
  node._run = run;
6300
- node._queue.enqueue(EFFECT_USER, run);
7589
+ // The first run rides the heap like every wake (GlobalQueue._update), so a
7590
+ // tracked effect created inside a render-effect callback runs after that
7591
+ // pass's staged writes commit, not before.
7592
+ enqueueSub(node);
7593
+ schedule();
6301
7594
  if (!node._parent) {
6302
7595
  const message =
6303
7596
  "[NO_OWNER_EFFECT] Effects created outside a reactive context will never be disposed";
6304
- emitDiagnostic({
6305
- code: "NO_OWNER_EFFECT",
6306
- kind: "lifecycle",
6307
- severity: "warn",
6308
- message,
6309
- ownerId: node.id,
6310
- ownerName: node._name,
6311
- data: { effectType: "trackedEffect" }
6312
- });
6313
- console.warn(message);
7597
+ reportDiagnostic(
7598
+ emitDiagnostic(
7599
+ {
7600
+ code: "NO_OWNER_EFFECT",
7601
+ kind: "lifecycle",
7602
+ severity: "warn",
7603
+ message,
7604
+ ownerId: node.id,
7605
+ ownerName: node._name,
7606
+ data: { effectType: "trackedEffect" }
7607
+ },
7608
+ node
7609
+ )
7610
+ );
6314
7611
  }
6315
7612
  }
6316
7613
  // Install the shared effect status notifier (statusNotifierOf serves it to
@@ -6434,11 +7731,17 @@ function action(genFn) {
6434
7731
  };
6435
7732
  const step = (v, err) => {
6436
7733
  let r;
7734
+ // Attribution hooks bracket the synchronous slice of generator body
7735
+ // this step runs (up to the next yield): writes inside are the
7736
+ // action's. Both sites sit outside the try (attribution-hooks.ts).
7737
+ if (attrHooks !== null) attrHooks.actionStepStart(it, genFn.name || undefined);
6437
7738
  try {
6438
7739
  r = err ? it.throw(v) : it.next(v);
6439
7740
  } catch (e) {
7741
+ if (attrHooks !== null) attrHooks.actionStepEnd(it);
6440
7742
  return done(undefined, e, true);
6441
7743
  }
7744
+ if (attrHooks !== null) attrHooks.actionStepEnd(it);
6442
7745
  // A rejected iterator result (async generators) means the error already
6443
7746
  // escaped the generator body — it is completed, and throwing back in
6444
7747
  // would just reject again forever. Settle instead.
@@ -6526,13 +7829,14 @@ function onCleanup(fn) {
6526
7829
  if (!owner) {
6527
7830
  const message =
6528
7831
  "[NO_OWNER_CLEANUP] onCleanup called outside a reactive context will never be run";
6529
- emitDiagnostic({
6530
- code: "NO_OWNER_CLEANUP",
6531
- kind: "lifecycle",
6532
- severity: "warn",
6533
- message
6534
- });
6535
- console.warn(message);
7832
+ reportDiagnostic(
7833
+ emitDiagnostic({
7834
+ code: "NO_OWNER_CLEANUP",
7835
+ kind: "lifecycle",
7836
+ severity: "warn",
7837
+ message
7838
+ })
7839
+ );
6536
7840
  } else if (owner._config & CONFIG_CHILDREN_FORBIDDEN) {
6537
7841
  const message =
6538
7842
  "[CLEANUP_IN_FORBIDDEN_SCOPE] Cannot use onCleanup inside createTrackedEffect or onSettled; return a cleanup function instead";
@@ -6691,6 +7995,16 @@ function createRenderEffect(compute, effectFn, options) {
6691
7995
  * Creates a tracked reactive effect where dependency tracking and side effects happen
6692
7996
  * in the same scope.
6693
7997
  *
7998
+ * @deprecated Do not use in new code. For a side effect that follows reactive
7999
+ * state, use `createEffect(compute, effect)` — it separates tracking from the
8000
+ * side effect, knows its dependencies before it runs, and participates in
8001
+ * async and transitions. For one-time DOM work after render (measuring,
8002
+ * attaching third-party widgets to a ref), use `onSettled`. Tracking from
8003
+ * inside the effect phase — the only thing this primitive adds — is retained
8004
+ * solely to ease 1.x migration: it runs beside user-effect callbacks after
8005
+ * values commit, never holds a transition, and cannot observe a write staged
8006
+ * earlier in the same flush by a signal it has not read yet.
8007
+ *
6694
8008
  * WARNING: Because tracking and effects happen in the same scope, this primitive
6695
8009
  * may run multiple times for a single change or show tearing (reading inconsistent
6696
8010
  * state). Use only when dynamic subscription patterns require same-scope tracking.
@@ -6936,6 +8250,13 @@ function refresh(target) {
6936
8250
  // own eager compute is untouched: created after a refresh it still settles
6937
8251
  // stale-while-revalidate (#2930) — its contract is "first settled value",
6938
8252
  // not "next quiescent state".
8253
+ //
8254
+ // An authoritative reader is woken through a late-bound hook when the truth
8255
+ // lands EQUAL to a standing override (the A17-silent path). Every setter of
8256
+ // that reader bit must install it — until() does, and this waiter is the
8257
+ // other one (#3303: refresh of an optimistic in an app that never called
8258
+ // until() dereferenced the null hook).
8259
+ installAuthoritativeRead();
6939
8260
  markRefresh(node);
6940
8261
  const promise = new Promise((res, rej) => {
6941
8262
  queueMicrotask(() => {
@@ -7217,7 +8538,7 @@ function createOptimistic(first, second) {
7217
8538
  function onSettled(callback) {
7218
8539
  const owner = getOwner();
7219
8540
  owner && !(owner._config & CONFIG_CHILDREN_FORBIDDEN)
7220
- ? createTrackedEffect(() => untrack(callback), { name: "onSettled" })
8541
+ ? trackedEffect(() => untrack(callback), { name: "onSettled" })
7221
8542
  : globalQueue.enqueue(EFFECT_USER, () => {
7222
8543
  // Unowned, out-of-band fire (no owner, or a children-forbidden one this
7223
8544
  // one-shot must not bind to): a returned cleanup has no lifecycle to
@@ -7720,15 +9041,6 @@ function affects(target, key) {
7720
9041
  }
7721
9042
  }
7722
9043
 
7723
- let patchHooks = null;
7724
- let rowHooks = null;
7725
- function installPatchHooks(hooks) {
7726
- patchHooks = hooks;
7727
- }
7728
- function installRowHooks(hooks) {
7729
- rowHooks = hooks;
7730
- }
7731
-
7732
9044
  /**
7733
9045
  * Store rewrite — increment 2: plain deep stores with pending-backing writes.
7734
9046
  * Contract: INTERNALS-STORE-STATE.md.
@@ -7761,9 +9073,9 @@ function installRowHooks(hooks) {
7761
9073
  *
7762
9074
  * ARRAY SHAPE RULE: arrays normalize their named properties to dictionary
7763
9075
  * mode as the count grows (V8 13.x: counts ≡ 0 mod 3 from 18 up), so the
7764
- * target's named field count is capped at 20 — write-side patch-channel
7765
- * state lives inside the single `pc` extension (see target.ts), never as
7766
- * new named fields here. */
9076
+ * target's named field count is capped at 20 — any future write-side state
9077
+ * beyond `wk` must ride an extension object (see target.ts), never new
9078
+ * named fields here. */
7767
9079
  function TargetShape() {
7768
9080
  this.v = undefined;
7769
9081
  this.ch = undefined;
@@ -7779,20 +9091,16 @@ function TargetShape() {
7779
9091
  this.a = undefined;
7780
9092
  this.sc = undefined;
7781
9093
  this.nc = undefined;
7782
- this.adopted = undefined;
9094
+ this.ab = undefined;
7783
9095
  this.fam = undefined;
7784
9096
  this.s = undefined;
7785
9097
  this.ovl = undefined;
7786
9098
  this.del = undefined;
7787
- this.pc = undefined;
9099
+ this.wk = undefined;
7788
9100
  this.hv = undefined;
7789
9101
  this.ht = undefined;
7790
9102
  }
7791
9103
  TargetShape.prototype = Object.prototype;
7792
- /** Lazily allocate the patch-channel extension (one literal shape). */
7793
- function pcOf(t) {
7794
- return t.pc ?? (t.pc = { sp: null, p: null, ro: null, wk: null, qa: null, qe: null });
7795
- }
7796
9104
  function createTarget(value, parent, parentKey, fam = parent?.fam ?? null) {
7797
9105
  // The proxy target carries the array exotic class when the value is an
7798
9106
  // array, so Array.isArray(proxy) is true; the fields live on it directly.
@@ -7809,7 +9117,7 @@ function createTarget(value, parent, parentKey, fam = parent?.fam ?? null) {
7809
9117
  t.h = null;
7810
9118
  t.k = null;
7811
9119
  t.dk = null;
7812
- t.pc = null;
9120
+ t.wk = null;
7813
9121
  t.u = parent;
7814
9122
  t.pk = parentKey;
7815
9123
  t.px = null;
@@ -7817,7 +9125,7 @@ function createTarget(value, parent, parentKey, fam = parent?.fam ?? null) {
7817
9125
  t.a = false;
7818
9126
  t.sc = false;
7819
9127
  t.nc = 0;
7820
- t.adopted = false;
9128
+ t.ab = null;
7821
9129
  t.fam = fam;
7822
9130
  t.s = false;
7823
9131
  t.ovl = false;
@@ -7861,50 +9169,68 @@ function unwrapValue(v) {
7861
9169
  }
7862
9170
  // ---------------------------------------------------------------------------
7863
9171
  // nodes: pure subscription points (values used only for equality gating)
7864
- function getNode(target, key, current) {
9172
+ // Shared slot-node equality (create-floor diet): ONE function for every
9173
+ // store node — `this` is the node (method-call convention at every _equals
9174
+ // site), `_host` is the baked-in target backref. Logical-slot equality:
9175
+ // values resolving to the same child target are the same slot
9176
+ // (privatization/adoption swap raw identity without changing the logical
9177
+ // value — only changed leaves notify, R9).
9178
+ const slotNodeEquals = function (a, b) {
9179
+ return isEqual(a, b) || sameLogicalSlot(this._host, a, b);
9180
+ };
9181
+ // Shared slot-node unobserved handler (create-floor diet): registered once;
9182
+ // the core sweep dispatches CONFIG_SLOT_NODE nodes here instead of holding a
9183
+ // per-node closure in a per-node NodeExtension.
9184
+ setSlotUnobserved(node => {
9185
+ // A live affects() mark keeps the node addressable (sweep parity).
9186
+ if (node._x?._affectsCount) return;
9187
+ const t = node._host;
9188
+ const key = node._key;
9189
+ if (t.n && t.n[key] === node) {
9190
+ delete t.n[key];
9191
+ t.nc--;
9192
+ }
9193
+ });
9194
+ function getNode(
9195
+ target,
9196
+ key,
9197
+ current,
9198
+ // First-read dedupe (create-floor slice 2): the get trap probes
9199
+ // accessor-ness right before creating the node — pass the verdict through
9200
+ // so creation skips the second descriptor scan. -1 = unknown (other
9201
+ // callers), 0/1 = probed.
9202
+ accKnown = -1
9203
+ ) {
7865
9204
  const nodes = (target.n ??= Object.create(null));
7866
9205
  let node = nodes[key];
7867
9206
  if (node === undefined) {
7868
- const created = (node = signal(
9207
+ // Create-floor diet: slotSignal bakes the whole node into one literal —
9208
+ // no options object, no equals/unobserved closures, no NodeExtension,
9209
+ // no post-construction expandos (acc + the wrap cache px/pxv are
9210
+ // pre-shaped fields: the proxy last served for this key and the raw it
9211
+ // wrapped — one pointer compare replaces the per-read WeakMap lookup in
9212
+ // wrapNext). ownedWrite rides the literal's config: the setter carries
9213
+ // the owned-scope write guard; node-level setSignals are internal
9214
+ // notification machinery. Projection nodes carry the projection
9215
+ // computed as their firewall: reads through them link the derive's
9216
+ // status/lifecycle (§7b).
9217
+ const created = (node = slotSignal(
7869
9218
  current,
7870
- {
7871
- // Attribution-only: name store property nodes by path segment so
7872
- // attribution chains and wide-scope warnings read "store.todos", not
7873
- // "signal". Gated on the engine being installed — node creation is
7874
- // the hottest store path, and the disabled cost must stay one null
7875
- // check (nodes created before enable() stay generically named).
7876
- name: attrHooks !== null ? "store." + String(key) : undefined,
7877
- // Logical-slot equality: values resolving to the same child target
7878
- // are the same slot (privatization/adoption swap raw identity without
7879
- // changing the logical value — only changed leaves notify, R9).
7880
- equals: (a, b) => isEqual(a, b) || sameLogicalSlot(target, a, b),
7881
- unobserved() {
7882
- // A live affects() mark keeps the node addressable (sweep parity).
7883
- if (created._x?._affectsCount) return;
7884
- if (target.n && target.n[key] === created) {
7885
- delete target.n[key];
7886
- target.nc--;
7887
- }
7888
- }
7889
- },
7890
- // Projection nodes carry the projection computed as their firewall:
7891
- // reads through them link the derive's status/lifecycle (§7b).
9219
+ slotNodeEquals,
9220
+ target,
9221
+ key,
9222
+ // Accessor-ness resolved ONCE per node (no per-object descriptor
9223
+ // scan on reads): accessor keys serve through Reflect.get with the
9224
+ // proxy receiver.
9225
+ accKnown === -1 ? isOwnAccessor(target.pb ?? target.v, key) : accKnown === 1,
7892
9226
  target.fam?.node ?? undefined
7893
9227
  ));
7894
- // Store nodes are ownedWrite: the setter carries the owned-scope write
7895
- // guard; node-level setSignals are internal notification machinery.
7896
- created._config |= CONFIG_OWNED_WRITE;
7897
- // Accessor-ness resolved ONCE per node (no per-object descriptor scan):
7898
- // accessor keys serve through Reflect.get with the proxy receiver.
7899
- created.acc = isOwnAccessor(target.pb ?? target.v, key);
7900
- // Wrap cache: the proxy last served for this key and the raw it wrapped.
7901
- // Raw-as-truth stores raw in nodes, so every object read needs a wrapper;
7902
- // one pointer compare (pxv === value) replaces the per-read WeakMap
7903
- // lookup in wrapNext — the dominant read-path cost vs legacy, whose
7904
- // nodes stored pre-wrapped values. A replaced child fails the compare
7905
- // and re-wraps; at most one stale proxy is pinned until the next read.
7906
- created.px = undefined;
7907
- created.pxv = undefined;
9228
+ // Attribution-only: name store property nodes by path segment so
9229
+ // attribution chains and wide-scope warnings read "store.todos", not
9230
+ // "signal". Gated on the engine being installed — node creation is
9231
+ // the hottest store path, and the disabled cost must stay one null
9232
+ // check (nodes created before enable() stay generically named).
9233
+ if (attrHooks !== null) created._name = "store." + String(key);
7908
9234
  // Optimistic families: arm the override slot — setSignal routes armed
7909
9235
  // nodes through the core engine (lanes, ownership, reverts all native).
7910
9236
  if (target.fam?.opt) {
@@ -8028,11 +9354,15 @@ function cloneRaw(source, t) {
8028
9354
  ? Object.defineProperties([], descs)
8029
9355
  : Object.create(Object.getPrototypeOf(source), descs);
8030
9356
  }
8031
- /** Scanned plainness for patch admission (patchableRaw): runs the one-time
8032
- * accessor scan if it hasn't happened yet — the sticky `a` flag alone is not
8033
- * trustworthy before a scan (it starts false and is discovered lazily). */
8034
- function targetIsPlain(target) {
8035
- return target.sc ? !target.a : scanAccessorsOnce(target);
9357
+ /** Copy own `key` from `from` onto `to`. A plain data slot (enumerable,
9358
+ * writable, configurable, no accessor) is a bare assignment — the common case
9359
+ * and the cheap one; anything else goes through defineProperty so accessors
9360
+ * and attribute flags survive the copy. */
9361
+ function copyOwn(to, from, key) {
9362
+ const d = Object.getOwnPropertyDescriptor(from, key);
9363
+ if (d.get || d.set || !d.enumerable || !d.writable || !d.configurable)
9364
+ Object.defineProperty(to, key, d);
9365
+ else to[key] = d.value;
8036
9366
  }
8037
9367
  /** One-time own-accessor scan (Annex-B probes, no descriptor allocation);
8038
9368
  * returns true when the container is plain data (overlay-safe). */
@@ -8057,12 +9387,7 @@ function materializePB(target) {
8057
9387
  if (!target.ovl) return;
8058
9388
  const proto = target.pb;
8059
9389
  const clone = cloneRaw(target.v, target);
8060
- for (const key of Reflect.ownKeys(proto)) {
8061
- const d = Object.getOwnPropertyDescriptor(proto, key);
8062
- if (d.get || d.set || !d.enumerable || !d.writable || !d.configurable)
8063
- Object.defineProperty(clone, key, d);
8064
- else clone[key] = d.value;
8065
- }
9390
+ for (const key of Reflect.ownKeys(proto)) copyOwn(clone, proto, key);
8066
9391
  if (target.del !== null) {
8067
9392
  for (const key of target.del) delete clone[key];
8068
9393
  target.del = null;
@@ -8174,7 +9499,18 @@ function adoptPB(target, incoming, eager = false) {
8174
9499
  // fold diff; ~half of dbmon tick time was this duplication).
8175
9500
  if (!eager) {
8176
9501
  queueFold(target); // records the pre-batch old before we swap
8177
- target.adopted = true;
9502
+ // Diff base = the view the nodes were last told (#3296). A draft's
9503
+ // setter-exit notifications already moved them to its pending backing,
9504
+ // so a later adoption diffs against THAT — against committed, a key the
9505
+ // draft changed and the adoption restores would never re-notify. An
9506
+ // adoption with no draft leaves nodes where they were: keep an existing
9507
+ // base, else the pre-batch committed (foldOlds' entry itself stays the
9508
+ // committed identity for the path-copy CAS). Eager callers read t.pb
9509
+ // directly; this hand-off exists because pb is gone by drain time.
9510
+ if (target.pb !== null) {
9511
+ if (target.ovl) materializePB(target);
9512
+ target.ab = target.pb;
9513
+ } else target.ab ??= foldOlds.get(target);
8178
9514
  // #3074/#3075: a projection recompute deriving from uncommitted inputs
8179
9515
  // swaps the backing SPECULATIVELY — committed-visibility readers must
8180
9516
  // keep the pre-hold view until the hold resolves (a source held by a
@@ -8202,7 +9538,7 @@ function adoptPB(target, incoming, eager = false) {
8202
9538
  target.del = null;
8203
9539
  target.sc = false;
8204
9540
  target.a = false;
8205
- if (target.pc !== null) target.pc.wk = null; // adoption supersedes staged trap writes
9541
+ target.wk = null; // adoption supersedes staged trap writes
8206
9542
  target.v = incoming;
8207
9543
  target.ch = incoming[$TARGET] !== undefined;
8208
9544
  (target.fam?.map ?? storeNextLookup).set(incoming, target);
@@ -8248,19 +9584,55 @@ const stagedTruthPB = new WeakMap();
8248
9584
  * transition is ambient). Entries die with their draft — tentative backings
8249
9585
  * are consumed at setter exit. */
8250
9586
  const tentativePBs = new WeakSet();
9587
+ /** A draft read composes the live optimistic view until the draft has opened
9588
+ * its OWN view-seeded backing (ensurePB seeds that clone from the view and
9589
+ * registers it in tentativePBs; from then on reads must see the draft's
9590
+ * writes, not the overrides they superseded). A pending backing that exists
9591
+ * for any other reason is not that clone — a truth landing staged into a
9592
+ * retaining transaction (#3164 fold) is authoritative truth WITHOUT the
9593
+ * live overrides. ensurePB parks such a backing on the draft's first WRITE
9594
+ * and reseeds from the view, but the reads that precede that write went to
9595
+ * the staged truth: `votes++` read base, wrote base+1, and the override it
9596
+ * emitted landed on the value already displayed — a second in-flight
9597
+ * increment made after a sibling's landing was invisible (#2951's compose
9598
+ * half, one landing later). */
9599
+ function draftSeesOverrides(target) {
9600
+ return target.pb === null || !tentativePBs.has(target.pb);
9601
+ }
8251
9602
  /** Committed-time privatization for parent-chain slot updates (path copying). */
8252
9603
  function privatizeCommitted(target) {
8253
9604
  if (ownedRaw.has(target.v)) return;
8254
- const clone = cloneRaw(target.v, target);
9605
+ const before = target.v;
9606
+ const clone = cloneRaw(before, target);
8255
9607
  ownedRaw.add(clone);
8256
- storeNextLookup.set(clone, target);
9608
+ // Register in the target's OWN registration map (#3284): family targets
9609
+ // (derived stores, projections, optimistic) resolve children through
9610
+ // fam.map — a clone parked only in the global lookup makes the next parent
9611
+ // read miss, wrap a fresh target, and orphan every node (subscribers) on
9612
+ // this one.
9613
+ (target.fam?.map ?? storeNextLookup).set(clone, target);
8257
9614
  target.v = clone;
8258
9615
  target.ch = false;
8259
9616
  if (target.u) {
8260
9617
  privatizeCommitted(target.u);
8261
9618
  devAssertNeverUserMutation(target.u.v);
8262
- target.u.v[target.pk] = target.v;
8263
- }
9619
+ target.u.v[parentSlotKey(target, before)] = target.v;
9620
+ }
9621
+ }
9622
+ /** Resolve the slot this child currently occupies in its parent's committed
9623
+ * backing (#3282). `pk` is stamped at wrap time and arrays MOVE: a reverse/
9624
+ * unshift/splice relocates the raw, and a fold that re-points the wrap-time
9625
+ * slot writes the clone over whichever row lives there now. Objects never
9626
+ * move keys, so the stamp is authoritative; for arrays, verify and re-locate
9627
+ * by identity when stale (fold-time only — never on a read path). */
9628
+ function parentSlotKey(target, expected) {
9629
+ const pk = target.pk;
9630
+ const pv = target.u.v;
9631
+ if (pv[pk] === expected || !Array.isArray(pv)) return pk;
9632
+ const at = pv.indexOf(expected);
9633
+ if (at === -1) return pk;
9634
+ target.pk = at;
9635
+ return at;
8264
9636
  }
8265
9637
  function drainFolds() {
8266
9638
  if (foldOlds.size === 0) return;
@@ -8271,11 +9643,6 @@ function drainFolds() {
8271
9643
  // is committing the batch the pull ran ahead of. Transition holds stay —
8272
9644
  // they clear when their transition is done (heldMaskView).
8273
9645
  if (t.ht === PLAIN_HOLD) t.ht = t.hv = null;
8274
- // Eager (write-override) family folds swap pb -> v at notifyWrites'
8275
- // tail: by the time this drain runs they carry no pb, and their
8276
- // structural ops must emit at the fold-commit site below (the clone
8277
- // branch never sees them). Re-audit blocker 4.
8278
- const foldedEager = t.pb === null;
8279
9646
  if (t.pb !== null) {
8280
9647
  // #3089: a fold written under a still-running transition defers to
8281
9648
  // that transition's settle (the write-time stamp covers unobserved
@@ -8299,7 +9666,7 @@ function drainFolds() {
8299
9666
  // Only written keys can hold (their nodes took the setSignal); the
8300
9667
  // wk bound keeps this O(written) — see notifyWrites. Same fallback
8301
9668
  // rules as the notify (WK_ALL / accessors / non-plain prototypes).
8302
- const wkh = t.pc !== null ? t.pc.wk : null;
9669
+ const wkh = t.wk;
8303
9670
  const keys =
8304
9671
  wkh === null ||
8305
9672
  wkh === WK_ALL ||
@@ -8331,12 +9698,7 @@ function drainFolds() {
8331
9698
  // — the never-mutate-user-data contract holds.
8332
9699
  privatizeCommitted(t);
8333
9700
  const v = t.v;
8334
- for (const key of Reflect.ownKeys(pb)) {
8335
- const d = Object.getOwnPropertyDescriptor(pb, key);
8336
- if (d.get || d.set || !d.enumerable || !d.writable || !d.configurable)
8337
- Object.defineProperty(v, key, d);
8338
- else v[key] = d.value;
8339
- }
9701
+ for (const key of Reflect.ownKeys(pb)) copyOwn(v, pb, key);
8340
9702
  if (t.del !== null) {
8341
9703
  for (const key of t.del) delete v[key];
8342
9704
  t.del = null;
@@ -8344,82 +9706,141 @@ function drainFolds() {
8344
9706
  (t.fam?.map ?? storeNextLookup).delete(pb);
8345
9707
  t.pb = null;
8346
9708
  t.ovl = false;
8347
- if (t.pc !== null) t.pc.wk = null; // written-keys window closes with the fold commit
9709
+ t.wk = null; // written-keys window closes with the fold commit
9710
+ } else if (t.v !== old) {
9711
+ // Privatized mid-batch (#3271): an earlier fold in this drain
9712
+ // path-copied THROUGH this target — privatizeCommitted cloned the
9713
+ // committed backing, re-pointed the parent slot at the clone, and
9714
+ // stitched the descendant's fold into it. The draft's pb predates
9715
+ // that: swapping it in would clobber the descendant's fold, and the
9716
+ // parent CAS below (still comparing against `old`) would fail and
9717
+ // orphan this fold entirely — a projection draft writing descendant-
9718
+ // then-ancestor silently lost the ancestor write. Merge the batch's
9719
+ // writes onto the current container instead (the parent slot already
9720
+ // points at it). privatize first: an adopt-then-write batch can land
9721
+ // here with an unowned adoptee as t.v.
9722
+ privatizeCommitted(t);
9723
+ const v = t.v;
9724
+ const wk = t.wk;
9725
+ if (wk !== null && wk !== WK_ALL) {
9726
+ // The trap records every write/delete key — apply exactly those.
9727
+ for (const key of wk) {
9728
+ if (hasOwn.call(pb, key)) copyOwn(v, pb, key);
9729
+ else delete v[key];
9730
+ }
9731
+ } else {
9732
+ // Array length write poisoned the bound (WK_ALL) — value-diff
9733
+ // against the pre-batch old. Slots the draft never touched hold
9734
+ // the same raw reference in both, so descendant folds stay put.
9735
+ // Not copyOwn: the plain-value write is GATED on "the draft changed
9736
+ // this slot" — an untouched slot in pb holds the pre-batch reference,
9737
+ // and writing it back would clobber a descendant fold stitched into v.
9738
+ for (const key of Reflect.ownKeys(pb)) {
9739
+ const d = Object.getOwnPropertyDescriptor(pb, key);
9740
+ if (d.get || d.set || !d.enumerable || !d.writable || !d.configurable)
9741
+ Object.defineProperty(v, key, d);
9742
+ else if (d.value !== old[key] || !hasOwn.call(old, key)) v[key] = d.value;
9743
+ }
9744
+ for (const key of Reflect.ownKeys(old)) {
9745
+ if (!hasOwn.call(pb, key)) delete v[key];
9746
+ }
9747
+ }
9748
+ (t.fam?.map ?? storeNextLookup).delete(pb);
9749
+ t.pb = null;
9750
+ t.wk = null; // written-keys window closes with the fold commit
8348
9751
  } else {
8349
- // Setter-channel structural ops: a fold that changes an array's shape
8350
- // (push/splice/permutation through the setter — the reconcile walk
8351
- // never queues here) is a structural visibility transition for any
8352
- // registered list driver. Identity-keyed; aligned folds emit nothing.
8353
- // Family targets defer to their own adoption emission (fam reconcile).
8354
- // Arrays always fold on this clone branch (overlay is non-array only).
8355
- // Family setter drafts (writable projection push/splice through the
8356
- // masked setter) fold on this branch too and the fold IS their
8357
- // visibility moment — emit unless the structure already rode another
8358
- // channel: adoption folds (reconcile walk emitted ops) and
8359
- // optimistic families (lane-timed override channel). Re-audit
8360
- // blocker 4.
8361
- if (
8362
- t.pc !== null &&
8363
- t.pc.ro !== null &&
8364
- !t.adopted &&
8365
- t.fam?.opt !== true &&
8366
- Array.isArray(pb) &&
8367
- Array.isArray(t.v)
8368
- )
8369
- rowHooks.emitSetterRowOps(t, t.v, pb);
8370
9752
  t.v = pb;
8371
9753
  t.ch = false; // pb is always a plain clone
8372
9754
  t.pb = null;
8373
- if (t.pc !== null) t.pc.wk = null; // written-keys window closes with the fold commit
9755
+ t.wk = null; // written-keys window closes with the fold commit
9756
+ }
9757
+ }
9758
+ const base = t.ab;
9759
+ t.ab = null;
9760
+ if (t.v !== old) {
9761
+ // Path copying (CAS: see the eager-fold twin above). Slot resolved by
9762
+ // identity (#3282): an array move relocated the raw, so the wrap-time
9763
+ // pk may point at a sibling — a raw-slot CAS there both failed to
9764
+ // re-point AND (via privatizeCommitted's unguarded write) clobbered
9765
+ // the sibling.
9766
+ if (t.u) {
9767
+ const slot = parentSlotKey(t, old);
9768
+ if (t.u.v[slot] === old) {
9769
+ privatizeCommitted(t.u);
9770
+ devAssertNeverUserMutation(t.u.v);
9771
+ t.u.v[slot] = t.v;
9772
+ }
8374
9773
  }
8375
9774
  }
8376
- if (t.v === old) {
8377
- // A no-op adoption (A -> B -> A before flush) still consumed its walk:
8378
- // clear the flag or every later setter row-op gate (!t.adopted) stays
8379
- // failed and a driven family list freezes (re-audit 5, P1-1).
8380
- t.adopted = false;
9775
+ // Adoption notify against the base the nodes were last told (#3296). A
9776
+ // no-op adoption (A -> B -> A before flush, no draft) has base === v and
9777
+ // nothing to say; a draft superseded by an adoption back to the SAME raw
9778
+ // still has (base = pending backing) !== v and must notify.
9779
+ if (base !== null && base !== t.v) notifyFold(t, base, t.v);
9780
+ }
9781
+ }
9782
+ /** Dev: dotted path of a target from its store root (`store.user.address`). */
9783
+ function storePath$1(t) {
9784
+ let path = "";
9785
+ for (let cur = t; cur !== null; cur = cur.u)
9786
+ path = cur.pk === null ? "store" + path : "." + String(cur.pk) + path;
9787
+ return path;
9788
+ }
9789
+ /**
9790
+ * Dev (attribution engine installed): announce written keys whose old and new
9791
+ * values are both containers but different logical slots — the raw material
9792
+ * for the spread-copy diagnostic. The engine owns the verdict.
9793
+ */
9794
+ function reportReplacedContainers(t, old, pb, writtenKeys) {
9795
+ const keys = writtenKeys ?? Reflect.ownKeys(pb);
9796
+ const isArray = Array.isArray(pb);
9797
+ for (const key of keys) {
9798
+ if (isArray && key === "length") continue;
9799
+ if (t.del !== null && t.del.has(key)) continue;
9800
+ const ov = unwrapValue(old[key]);
9801
+ const nv = unwrapValue(pb[key]);
9802
+ if (
9803
+ ov === null ||
9804
+ nv === null ||
9805
+ typeof ov !== "object" ||
9806
+ typeof nv !== "object" ||
9807
+ ov === nv ||
9808
+ targetsEqual(ov, nv)
9809
+ )
8381
9810
  continue;
8382
- }
8383
- // Patch channel (fold-commit site): family targets emit HERE the fold
8384
- // IS their visibility moment (held folds re-queued above emit when they
8385
- // actually commit) — and so do PLAIN fold-adopted targets (setter-
8386
- // returned root replacements, chained-store swaps: adoptions WITHOUT a
8387
- // reconcile walk, so no walk-site emission ever happened — re-audit 2,
8388
- // P1-2). Plain eager targets emitted at their walk/setter sites already.
8389
- if (t.pc !== null && (t.fam !== null || t.adopted)) {
8390
- // Structural ops for folds whose structure rode no other channel:
8391
- // eager-folded family SETTER drafts (write-override swaps pb -> v at
8392
- // notifyWrites' tail the clone branch never sees them; adoption
8393
- // folds re-emitting would double the walk's ops) and PLAIN fold
8394
- // adoptions (no walk at all). Optimistic families ride the override
8395
- // channel (lane-timed ops + revert RESYNC) — never re-emit here.
8396
- if (
8397
- t.pc.ro !== null &&
8398
- t.fam?.opt !== true &&
8399
- (t.fam !== null ? foldedEager && !t.adopted : t.adopted) &&
8400
- Array.isArray(t.v) &&
8401
- Array.isArray(old)
8402
- )
8403
- rowHooks.emitSetterRowOps(t, old, t.v);
8404
- if (t.pc.p !== null) {
8405
- // Accessor demotion at the fold-commit seam is DEV-ONLY (see the
8406
- // reconcile seam note: prod never pays per-adoption scans).
8407
- if (!targetIsPlain(t)) patchHooks.demoteToEffects(t);
8408
- else patchHooks.emitPatchLocal(t, t.v, old);
8409
- }
8410
- }
8411
- // Path copying (CAS: see the eager-fold twin above).
8412
- if (t.u && t.u.v[t.pk] === old) {
8413
- privatizeCommitted(t.u);
8414
- devAssertNeverUserMutation(t.u.v);
8415
- t.u.v[t.pk] = t.v;
8416
- }
8417
- if (t.adopted) {
8418
- t.adopted = false;
8419
- notifyFold(t, old, t.v);
8420
- }
9811
+ // Leaf census on the store side: leaves read through a draft are proxies
9812
+ // of the committed raws, so identity must be judged on unwrapped values.
9813
+ const isArr = Array.isArray(nv);
9814
+ if (isArr !== Array.isArray(ov)) continue;
9815
+ let total;
9816
+ let unchanged = 0;
9817
+ if (isArr) {
9818
+ total = nv.length;
9819
+ if (total > REPLACED_CENSUS_MAX) continue;
9820
+ const oldItems = new Set();
9821
+ for (const item of ov) oldItems.add(unwrapValue(item));
9822
+ for (const item of nv) if (oldItems.has(unwrapValue(item))) unchanged++;
9823
+ } else {
9824
+ const nkeys = Object.keys(nv);
9825
+ total = nkeys.length;
9826
+ if (total > REPLACED_CENSUS_MAX) continue;
9827
+ for (const k of nkeys) if (sameLeaf(ov[k], nv[k])) unchanged++;
9828
+ }
9829
+ attrHooks.storeReplaced(
9830
+ storePath$1(t) + "." + String(key),
9831
+ isArr,
9832
+ total,
9833
+ unchanged,
9834
+ isArr ? ov.length : Object.keys(ov).length
9835
+ );
8421
9836
  }
8422
9837
  }
9838
+ const REPLACED_CENSUS_MAX = 64;
9839
+ function sameLeaf(a, b) {
9840
+ if (a === b) return true;
9841
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
9842
+ return unwrapValue(a) === unwrapValue(b) || targetsEqual(a, b);
9843
+ }
8423
9844
  /**
8424
9845
  * Setter-exit notification (write channel): diff the draft's pending backing
8425
9846
  * against committed and setSignal every changed OBSERVED key — write-time
@@ -8484,7 +9905,7 @@ function notifyWrites(t) {
8484
9905
  // implicit index deletes), accessors on the record (t.a — a getter node's
8485
9906
  // value can change when ANY key is written), or a non-plain prototype
8486
9907
  // (class instances: prototype getters derive from arbitrary fields).
8487
- const wk0 = t.pc !== null ? t.pc.wk : null;
9908
+ const wk0 = t.wk;
8488
9909
  // Overlay pbs chain to the COMMITTED object (#3044): a prototype-overlay
8489
9910
  // draft is plain data on its own layer, but its getPrototypeOf is the
8490
9911
  // committed container — judge plainness by the COMMITTED prototype or the
@@ -8492,6 +9913,7 @@ function notifyWrites(t) {
8492
9913
  // would full-scan: the exact selection-map workload wk exists for; jf
8493
9914
  // `select` regressed 2x on this).
8494
9915
  const writtenKeys = wk0 === WK_ALL || t.a === true || !plainProto(t.ovl ? t.v : pb) ? null : wk0;
9916
+ if (attrHooks !== null) reportReplacedContainers(t, old, pb, writtenKeys);
8495
9917
  if (nodes !== null) {
8496
9918
  const keys = writtenKeys ?? Reflect.ownKeys(nodes);
8497
9919
  for (const key of keys) {
@@ -8564,13 +9986,6 @@ function notifyWrites(t) {
8564
9986
  }
8565
9987
  if (changed) setSignal(t.k, v => v + 1);
8566
9988
  }
8567
- // Patch channel (setter site): a committed write transitions this record —
8568
- // queue its patches and bubble to ancestors (targeted nested writes must
8569
- // reach the row patch, §4b). One number compare when no patches exist.
8570
- // Family targets skip this site: their visibility moment is the FOLD
8571
- // commit (drainFolds emits), not the recompute/draft write.
8572
- if (t.fam === null && patchHooks !== null && patchHooks.hasPatches())
8573
- patchHooks.emitPatch(t, pb, old);
8574
9989
  // Projection backing folds split by channel (two pinned contracts):
8575
9990
  // - sync-derive drafts (recompute body): NEVER eager — a downstream async
8576
9991
  // hold can form LATER in the same flush and the leaf must stay at stale
@@ -8595,10 +10010,14 @@ function notifyWrites(t) {
8595
10010
  t.pb = null;
8596
10011
  t.v = pb;
8597
10012
  t.ch = false;
8598
- if (t.u && t.u.v[t.pk] === oldBacking) {
8599
- privatizeCommitted(t.u);
8600
- devAssertNeverUserMutation(t.u.v);
8601
- t.u.v[t.pk] = pb;
10013
+ if (t.u) {
10014
+ // Identity-resolved slot (#3282) — see drainFolds' path-copy twin.
10015
+ const slot = parentSlotKey(t, oldBacking);
10016
+ if (t.u.v[slot] === oldBacking) {
10017
+ privatizeCommitted(t.u);
10018
+ devAssertNeverUserMutation(t.u.v);
10019
+ t.u.v[slot] = pb;
10020
+ }
8602
10021
  }
8603
10022
  }
8604
10023
  }
@@ -8977,7 +10396,7 @@ function nodeValue(node, backing) {
8977
10396
  * backing IS another store's proxy) serve the read-through value — the outer
8978
10397
  * node is linked only for adoption-swap notification, its value never
8979
10398
  * shadows the live chain. */
8980
- function serveDataKey(target, key, backingValue, src, node) {
10399
+ function serveDataKey(target, key, backingValue, src, node, accKnown = -1) {
8981
10400
  const chained = target.ch && src === target.v;
8982
10401
  let v = backingValue;
8983
10402
  // §6: on optimistic arrays LENGTH IS A VIEW, not a node value — one home
@@ -9005,7 +10424,7 @@ function serveDataKey(target, key, backingValue, src, node) {
9005
10424
  // #2951). Once ensurePB runs, the seeded clone carries the view.
9006
10425
  // AUTHORITATIVE drafts (projection derive) never overlay — ensurePB's
9007
10426
  // seeding rule, applied to the read side (#3108).
9008
- if (target.fam?.opt && target.pb === null && !authoritativeServe()) {
10427
+ if (target.fam?.opt && draftSeesOverrides(target) && !authoritativeServe()) {
9009
10428
  const node = target.n?.[key];
9010
10429
  if (node !== undefined && hasActiveOverride(node))
9011
10430
  v = unwrapOverride(node._x?._overrideValue);
@@ -9025,7 +10444,9 @@ function serveDataKey(target, key, backingValue, src, node) {
9025
10444
  v = nodeValue(node, backingValue);
9026
10445
  }
9027
10446
  } else if (getObserver() !== null) {
9028
- read(getNode(target, key, backingValue));
10447
+ // First tracked read: create + link, and let the wrap-cache branch
10448
+ // below populate px/pxv so read #2 skips wrapNext (slice 2).
10449
+ read((node = getNode(target, key, backingValue, accKnown)));
9029
10450
  }
9030
10451
  }
9031
10452
  // Shallow stores serve data raw; store-proxy slots get boundary wrappers.
@@ -9127,8 +10548,11 @@ const traps = {
9127
10548
  // tracked read of a present data key — the dbmon/uibench effect re-read
9128
10549
  // shape. Skips serveDataKey's frame, the FORCE compare (only accessor
9129
10550
  // keys ever hold the sentinel), and isWrappable for primitives.
10551
+ // ONE node-map lookup serves this block and the accessor probe below
10552
+ // (nothing between them creates nodes).
10553
+ const node0 = target.n?.[key];
9130
10554
  if (target.ch === false && writeScopes === null) {
9131
- const nodeH = target.n?.[key];
10555
+ const nodeH = node0;
9132
10556
  if (nodeH !== undefined && nodeH.acc !== true && getObserver() !== null) {
9133
10557
  let nv = readNodeFast(nodeH);
9134
10558
  if (nv === READ_SLOW) nv = read(nodeH);
@@ -9146,7 +10570,19 @@ const traps = {
9146
10570
  }
9147
10571
  // Dev strictRead: untracked store reads in labeled scopes (component
9148
10572
  // bodies, effect callbacks) warn — the value can never update the reader.
9149
- if (strictRead && !inDraft(target) && typeof key === "string" && getObserver() === null) {
10573
+ // `then` is exempt: resolving a promise with a store proxy (refresh()'s
10574
+ // waiter delivers the store, `Promise.resolve(store)`, `return store`
10575
+ // from an async function) makes the engine probe `.then` for
10576
+ // thenable-ness synchronously in the caller's scope. That is not a read
10577
+ // the user wrote, and it must neither warn nor escalate to the pending
10578
+ // throw — a throw out of promise resolution rejects the promise.
10579
+ if (
10580
+ strictRead &&
10581
+ !inDraft(target) &&
10582
+ typeof key === "string" &&
10583
+ key !== "then" &&
10584
+ getObserver() === null
10585
+ ) {
9150
10586
  // Safeguard parity with core read() (#2897): a component-body read of
9151
10587
  // a REFETCHING derived store escalates — the untracked reader can never
9152
10588
  // observe the in-flight update (strict-read matrix, opt R30–R34).
@@ -9169,15 +10605,21 @@ const traps = {
9169
10605
  // absent-key/accessor subscriptions for every store read during any
9170
10606
  // derive, leaving nested projections permanently dependency-less when
9171
10607
  // their sources hadn't materialized yet (#3037).
9172
- const node0 = target.n?.[key];
10608
+ // First-read dedupe (create-floor slice 2): remember the probe verdict —
10609
+ // node creation downstream reuses it instead of re-scanning the
10610
+ // descriptor, but only when the probed object IS the one getNode would
10611
+ // scan (pb ?? v).
10612
+ let accProbe = -1;
9173
10613
  {
9174
- const acc =
9175
- node0 !== undefined
9176
- ? node0.acc === true
9177
- : !inDraft(target) && getObserver() !== null && isOwnAccessor(src, key);
10614
+ let acc;
10615
+ if (node0 !== undefined) acc = node0.acc === true;
10616
+ else if (!inDraft(target) && getObserver() !== null) {
10617
+ acc = isOwnAccessor(src, key);
10618
+ if (src === (target.pb ?? target.v)) accProbe = acc ? 1 : 0;
10619
+ } else acc = false;
9178
10620
  if (acc) {
9179
10621
  if (!inDraft(target) && getObserver() !== null)
9180
- read(node0 ?? getNode(target, key, undefined));
10622
+ read(node0 ?? getNode(target, key, undefined, accProbe));
9181
10623
  const v = Reflect.get(src, key, receiver);
9182
10624
  if (target.s) return serveShallow(target, key, v);
9183
10625
  return isWrappable(v) ? draftServe(target, wrapNext(v, target, key)) : v;
@@ -9207,7 +10649,7 @@ const traps = {
9207
10649
  // Reading a currently-absent own key subscribes to it (R12) — for any
9208
10650
  // target OUTSIDE its own draft scope, even mid-setter (#3037, above).
9209
10651
  if (v === undefined && !inDraft(target)) {
9210
- if (getObserver() !== null) read(getNode(target, key, undefined));
10652
+ if (getObserver() !== null) read(getNode(target, key, undefined, accProbe));
9211
10653
  const node = target.n?.[key];
9212
10654
  if (node) {
9213
10655
  const nv = nodeValue(node, undefined);
@@ -9218,7 +10660,7 @@ const traps = {
9218
10660
  v === undefined &&
9219
10661
  inDraft(target) &&
9220
10662
  target.fam?.opt &&
9221
- target.pb === null &&
10663
+ draftSeesOverrides(target) &&
9222
10664
  // AUTHORITATIVE drafts (landing folds) never seed from overrides —
9223
10665
  // the caller's optimism is not truth (has-trap twin below).
9224
10666
  !authoritativeServe()
@@ -9236,7 +10678,7 @@ const traps = {
9236
10678
  !(viewOvl && hasOwn.call(target.v, key))
9237
10679
  )
9238
10680
  return v; // proto method
9239
- return serveDataKey(target, key, v, src, node0);
10681
+ return serveDataKey(target, key, v, src, node0, accProbe);
9240
10682
  },
9241
10683
  has(target, key) {
9242
10684
  if (key === $TARGET || key === $PROXY || key === $TRACK) return true;
@@ -9258,7 +10700,7 @@ const traps = {
9258
10700
  if (node !== undefined && hasActiveOverride(node))
9259
10701
  present = !!unwrapOverride(node._x?._overrideValue);
9260
10702
  }
9261
- } else if (target.fam?.opt && target.pb === null && !authoritativeServe()) {
10703
+ } else if (target.fam?.opt && draftSeesOverrides(target) && !authoritativeServe()) {
9262
10704
  const node = target.h?.[key];
9263
10705
  if (node !== undefined && hasActiveOverride(node))
9264
10706
  present = !!unwrapOverride(node._x?._overrideValue);
@@ -9290,7 +10732,7 @@ const traps = {
9290
10732
  !authoritativeServe() &&
9291
10733
  target.fam?.opt &&
9292
10734
  target.h !== null &&
9293
- (!inDraft(target) || target.pb === null)
10735
+ (!inDraft(target) || draftSeesOverrides(target))
9294
10736
  ) {
9295
10737
  let set = null;
9296
10738
  for (const key of Reflect.ownKeys(target.h)) {
@@ -9355,15 +10797,14 @@ const traps = {
9355
10797
  // Array length writes implicitly delete indices — the written-keys bound
9356
10798
  // can't see them, so poison to the full scan for this batch. Index
9357
10799
  // writes implicitly GROW length, so arrays always record it alongside.
9358
- const pcs = pcOf(target);
9359
10800
  if (Array.isArray(pb)) {
9360
- if (key === "length") pcs.wk = WK_ALL;
9361
- else if (pcs.wk !== WK_ALL) {
9362
- const wk = (pcs.wk ??= new Set());
10801
+ if (key === "length") target.wk = WK_ALL;
10802
+ else if (target.wk !== WK_ALL) {
10803
+ const wk = (target.wk ??= new Set());
9363
10804
  wk.add(key);
9364
10805
  wk.add("length");
9365
10806
  }
9366
- } else if (pcs.wk !== WK_ALL) (pcs.wk ??= new Set()).add(key);
10807
+ } else if (target.wk !== WK_ALL) (target.wk ??= new Set()).add(key);
9367
10808
  // Own data keys literally named "prototype"/"constructor" land as data —
9368
10809
  // defineProperty sidesteps a proto-chain setter named the same.
9369
10810
  if (UNSAFE_KEYS.has(key)) {
@@ -9401,20 +10842,12 @@ const traps = {
9401
10842
  const override = !draft && getWriteOverride();
9402
10843
  if (!draft && !override) return true;
9403
10844
  if (key === "__proto__") return true;
9404
- if (desc.get || desc.set) {
9405
- target.a = true;
9406
- // Accessor demotion (re-audit blocker 3): a record that acquires an
9407
- // accessor after patch registration stops being patchable — pull its
9408
- // patches and re-drive them as tracked effect fallbacks. Hooks are
9409
- // installed whenever pc.p exists (registration installs them).
9410
- if (target.pc !== null && target.pc.p !== null) patchHooks.demoteToEffects(target);
9411
- }
10845
+ if (desc.get || desc.set) target.a = true;
9412
10846
  // Unwrap before ensurePB (see the set trap: self-reference materializes).
9413
10847
  if ("value" in desc) desc = { ...desc, value: unwrapValue(desc.value) };
9414
10848
  const pb = ensurePB(target);
9415
10849
  pendingNotify.add(target);
9416
- const pcd = pcOf(target);
9417
- if (pcd.wk !== WK_ALL) (pcd.wk ??= new Set()).add(key);
10850
+ if (target.wk !== WK_ALL) (target.wk ??= new Set()).add(key);
9418
10851
  Object.defineProperty(pb, key, desc);
9419
10852
  if (target.del !== null) target.del.delete(key);
9420
10853
  if (override) notifyWrites(target);
@@ -9426,8 +10859,7 @@ const traps = {
9426
10859
  if (!draft && !override) return true;
9427
10860
  const pb = ensurePB(target);
9428
10861
  pendingNotify.add(target);
9429
- const pcx = pcOf(target);
9430
- if (pcx.wk !== WK_ALL) (pcx.wk ??= new Set()).add(key);
10862
+ if (target.wk !== WK_ALL) (target.wk ??= new Set()).add(key);
9431
10863
  delete pb[key];
9432
10864
  // A prototype overlay cannot shadow a delete of a committed key —
9433
10865
  // record it aside (#3044); reads/has/ownKeys/commit consult the set.
@@ -9482,24 +10914,29 @@ function storeSetterNext(proxy, fn, guard = true) {
9482
10914
  setNextAffectsNodeResolver((t, key) =>
9483
10915
  key === $AFFECTS ? getNode(t, $AFFECTS, undefined) : getNode(t, key, (t.pb ?? t.v)[key])
9484
10916
  );
9485
- function createStoreNext(init, shallow = false) {
10917
+ function createStoreNext(initialValue, shallow = false) {
9486
10918
  if (shallow && true) {
9487
10919
  // Never both deep-wrapped and raw (R41/R44): a value already tracked as
9488
10920
  // a DEEP store cannot be ingested shallow.
9489
- const existing = storeNextLookup.get(init);
10921
+ const existing = storeNextLookup.get(initialValue);
9490
10922
  if (existing !== undefined && !existing.s)
9491
10923
  throw new Error("createStore({ shallow }): value is already tracked as a deep store");
9492
- if (init[$TARGET]) throw new Error("createStore({ shallow }): value is already a store proxy");
10924
+ if (initialValue[$TARGET])
10925
+ throw new Error("createStore({ shallow }): value is already a store proxy");
9493
10926
  }
9494
- const proxy = wrapNext(init);
10927
+ const proxy = wrapNext(initialValue);
9495
10928
  if (shallow) {
9496
10929
  proxy[$TARGET].s = true;
9497
- markRawIngest(init);
10930
+ markRawIngest(initialValue);
9498
10931
  }
9499
10932
  registerGraph(proxy, getOwner());
9500
10933
  const setter = fn => storeSetterNext(proxy, fn);
9501
10934
  return [proxy, setter];
9502
10935
  }
10936
+ // ---------------------------------------------------------------------------
10937
+ // snapshot (next targets): the backing IS the plain raw graph — zero copy.
10938
+ // Sees pending (R27) by reading pb. Chained/owned-copy caching lands with the
10939
+ // utilities increment; this covers the createStore-suite contract.
9503
10940
  /** True when `proxy` is a SHALLOW store (children served verbatim, slots
9504
10941
  * replaced by reference — #2932). The list driver uses this to choose the
9505
10942
  * slot-patch channel (collected row bodies) over per-record registration. */
@@ -9550,8 +10987,26 @@ function deepNext(value) {
9550
10987
  read(getKeySetNode(t));
9551
10988
  read(getDeepNode(t));
9552
10989
  const map = t.fam?.map ?? storeNextLookup;
9553
- for (const key of Reflect.ownKeys(src)) {
9554
- const desc = Object.getOwnPropertyDescriptor(src, key);
10990
+ // Overlay pending backings chain to the committed object (#3044): their
10991
+ // OWN keys are only this batch's writes. A bare ownKeys walk mid-flush
10992
+ // (effects recompute before the fold commits) missed every untouched
10993
+ // child, so the re-subscribing effect dropped those records from its
10994
+ // dependency set — later child edits never notified it (#3283). Merge
10995
+ // committed keys, minus deletes, exactly as the ownKeys trap does.
10996
+ let keys = Reflect.ownKeys(src);
10997
+ if (t.ovl && src === t.pb) {
10998
+ const merged = Reflect.ownKeys(t.v);
10999
+ const del = t.del;
11000
+ const filtered =
11001
+ del !== null && del.size !== 0 ? merged.filter(key => !del.has(key)) : merged;
11002
+ for (const key of keys) {
11003
+ if (!hasOwn.call(t.v, key)) filtered.push(key);
11004
+ }
11005
+ keys = filtered;
11006
+ }
11007
+ for (const key of keys) {
11008
+ const desc =
11009
+ Object.getOwnPropertyDescriptor(src, key) ?? Object.getOwnPropertyDescriptor(t.v, key);
9555
11010
  if (desc === undefined) continue;
9556
11011
  if (desc.get || desc.set) {
9557
11012
  t.a = true;
@@ -9776,35 +11231,13 @@ function applyAdopt(t, incoming, keyFn, proj = false) {
9776
11231
  // folds (downstream holds can form later in the flush).
9777
11232
  const eager = fam === null;
9778
11233
  const shallow = t.s === true;
9779
- const old = t.v;
11234
+ // Node-notify base is the view the nodes were last told (#3296): a draft
11235
+ // preceding this reconcile already moved them to its pending backing at
11236
+ // setter exit (prev, materialized above), so diffing incoming against the
11237
+ // committed backing would skip a key the draft changed and the reconcile
11238
+ // restores — the node would commit the superseded draft value.
11239
+ const old = prev;
9780
11240
  adoptPB(t, incoming, eager);
9781
- // Patch channel (adoption site): this record transitioned — queue its
9782
- // patches with the pre-adopt prev. No bubbling walk: the adoption walk
9783
- // visits parents before children, so ancestors emitted already. EAGER
9784
- // only — family targets' visibility moment is their fold commit
9785
- // (drainFolds emits there; emitting here too would double-fire).
9786
- if (patchHooks !== null && eager && t.pc !== null && t.pc.p !== null) {
9787
- // Accessor demotion at the ADOPTION seam is DEV-ONLY (prod principle:
9788
- // explicitly-odd input must not cost correct-input prod — the
9789
- // per-adoption scan was ~12% of dbmon's tick since adoptPB resets the
9790
- // verdict every adoption). Dev demotes AND warns; prod emits directly,
9791
- // so a getter adoptee's OUTSIDE deps (signals) won't re-apply in prod —
9792
- // caught loudly during development instead. Registration-time admission
9793
- // (patchableRaw) keeps its full one-time scan in both modes.
9794
- if (!targetIsPlain(t)) {
9795
- console.warn(
9796
- "A reconcile adopted an object with own getters into a record that " +
9797
- "carries compiled patches. Patches read raw values and will not " +
9798
- "track the getters' reactive dependencies — this record's patches " +
9799
- "are demoted to effects in development, but production will NOT " +
9800
- "demote. Avoid getters on patched records, or key them out of " +
9801
- "patch-eligible templates."
9802
- );
9803
- patchHooks.demoteToEffects(t);
9804
- } else {
9805
- patchHooks.emitPatchLocal(t, incoming, old);
9806
- }
9807
- }
9808
11241
  // Shallow adoption: records are slot values — sticky raw-mark the incoming
9809
11242
  // set (R41) and never descend; slot notification is the positional diff.
9810
11243
  if (shallow) markRawIngest(incoming);
@@ -9928,62 +11361,12 @@ function applyAdopt(t, incoming, keyFn, proj = false) {
9928
11361
  }
9929
11362
  }
9930
11363
  }
9931
- // Row ops (PR-B): emit structural ops ONLY when structure changed —
9932
- // aligned value ticks pay nothing. Built after the walk so retained
9933
- // rows' value patches queue first (adds bind at op-apply).
9934
- if (
9935
- rowHooks !== null &&
9936
- t.pc !== null &&
9937
- t.pc.ro !== null &&
9938
- (structStart < nlen || plen !== nlen)
9939
- )
9940
- buildAndEmitRowOps(t, prevRows, nextRows, structStart, keyFn);
9941
11364
  } else {
9942
11365
  const dlen = Math.min(prevRows.length, nextRows.length);
9943
11366
  const nlen = nextRows.length;
9944
11367
  let dkBumpedP = false;
9945
- const sp = rowHooks !== null && t.pc !== null ? t.pc.sp : null;
9946
- // Row ops for shallow/positional lists: track the key-aligned prefix
9947
- // (keyed) so aligned value ticks emit nothing; keyless lists emit only
9948
- // on length change (append/truncate). Slot-patch consumers need the
9949
- // alignment tracking too (aligned = value tick, misaligned = ops).
9950
- const ro = rowHooks !== null && t.pc !== null ? t.pc.ro : null;
9951
- let keyAligned = keyFn !== null && (ro !== null || sp !== null);
9952
- let keyPrefix = 0;
9953
11368
  for (let i = 0; i < nlen; i++) {
9954
11369
  const nvP = nextRows[i];
9955
- if (keyAligned && i < dlen) {
9956
- const pvK = prevRows[i];
9957
- if (
9958
- pvK !== null &&
9959
- typeof pvK === "object" &&
9960
- nvP !== null &&
9961
- typeof nvP === "object" &&
9962
- // SameValueZero (self-sweep): strict === here broke slot
9963
- // alignment on NaN keys while buildRowOps retained the row —
9964
- // retained DOM with suppressed value ticks (the round-1 NaN
9965
- // staleness, in the shallow branch).
9966
- sameKey(keyFn(pvK), keyFn(nvP))
9967
- )
9968
- keyPrefix++;
9969
- else keyAligned = false;
9970
- }
9971
- // Slot-patch dispatch (shallow): a KEY-ALIGNED slot whose value was
9972
- // replaced by reference is a value tick — emit through the queue.
9973
- // Misaligned/appended slots are STRUCTURE (row ops rebuild or move
9974
- // them; new rows initial-apply at bind), so they emit nothing here.
9975
- // Keyless positional lists treat same-index replacement as the value
9976
- // tick for indices below the common length.
9977
- // `i < dlen` is load-bearing for BOTH modes: an appended position
9978
- // past a fully-aligned prefix (vacuously aligned when prev is empty)
9979
- // has no previous slot — emitting a slot tick for it races the row
9980
- // ops that CREATE the row (the slot queue applies first, indexing a
9981
- // row that does not exist yet). Equivalence-matrix finding:
9982
- // clear-then-refill and pure appends crashed the driver.
9983
- if (sp !== null && i < dlen && (keyFn === null || keyAligned)) {
9984
- const pvS = prevRows[i];
9985
- if (pvS !== nvP) rowHooks.emitSlotPatch(t, i, nvP, pvS);
9986
- }
9987
11370
  if (!shallow && i < dlen && nvP !== null && typeof nvP === "object")
9988
11371
  descend(unwrapValue(prevRows[i]), nvP, keyFn, fam, proj);
9989
11372
  if (
@@ -10004,15 +11387,6 @@ function applyAdopt(t, incoming, keyFn, proj = false) {
10004
11387
  }
10005
11388
  }
10006
11389
  }
10007
- if (ro !== null) {
10008
- const plen = prevRows.length;
10009
- if (keyFn !== null) {
10010
- if (keyPrefix < nlen || plen !== nlen)
10011
- buildAndEmitRowOps(t, prevRows, nextRows, keyPrefix, keyFn);
10012
- } else if (plen !== nlen) {
10013
- buildAndEmitRowOps(t, prevRows, nextRows, dlen, null);
10014
- }
10015
- }
10016
11390
  }
10017
11391
  if (eager) {
10018
11392
  if (nodes !== null && nodesHit < t.nc) {
@@ -10033,22 +11407,6 @@ function applyAdopt(t, incoming, keyFn, proj = false) {
10033
11407
  // slots must not notify, R9). This replaces the notifyFold re-walk that
10034
11408
  // doubled dbmon's diff cost. for-in covers own enumerable string keys
10035
11409
  // with no key-array allocation; symbols get a pass only when present.
10036
- // PROTOTYPE compiled-patch fast path: a pure-patch record (no nodes,
10037
- // no presence/key-set/deep subscribers, no family) adopts and hands the
10038
- // (next, prev) pair to its compiled patch — no per-key walk at all.
10039
- if (
10040
- t.pc !== null &&
10041
- t.pc.p !== null &&
10042
- eager &&
10043
- t.n === null &&
10044
- t.h === null &&
10045
- t.k === null &&
10046
- t.dk === null &&
10047
- fam === null
10048
- ) {
10049
- // Adoption already ran at applyAdopt entry; emission was queued there.
10050
- return;
10051
- }
10052
11410
  const nodes = eager ? t.n : null;
10053
11411
  let nodesHit = 0;
10054
11412
  let dkBumped = false;
@@ -10116,91 +11474,13 @@ function applyAdopt(t, incoming, keyFn, proj = false) {
10116
11474
  const hasOwnP = Object.prototype.hasOwnProperty;
10117
11475
  /** Setter-channel row ops (the fold site calls this for array targets with
10118
11476
  * ops consumers): structural mutation through the setter — push/splice/index
10119
- * assignment/permutation — is a visibility transition for the list container
10120
- * just like a reconcile walk, and drivers consuming registerRowOps must see
10121
- * it. Setter mutations move the SAME row objects around, so RAW IDENTITY is
10122
- * the key. Aligned arrays (value-only folds) emit nothing. */
10123
- const identityKey = r => unwrapValue(r);
10124
11477
  /** Key equality for EVERY key comparison in this module (re-audit 2, P1-5):
10125
- * SameValueZero, matching the Map-based matchers (buildRowOps, the adoption
10126
- * window) — NaN keys are equal to themselves, so aligned NaN rows stay
10127
- * aligned in the prefix walk instead of forever misaligning. Adoption and
10128
- * row ops MUST agree on key equality or retained DOM rows go stale. */
11478
+ * SameValueZero, matching the adoption window's Map-based matcher NaN keys
11479
+ * are equal to themselves, so aligned NaN rows stay aligned in the prefix
11480
+ * walk instead of forever misaligning. */
10129
11481
  function sameKey(a, b) {
10130
11482
  return a === b || (a !== a && b !== b);
10131
11483
  }
10132
- function emitSetterRowOps(t, prevRows, nextRows) {
10133
- const ops = buildIdentityRowOps(prevRows, nextRows);
10134
- if (ops !== null) rowHooks.emitRowOps(t, nextRows, ops);
10135
- }
10136
- /** Identity-keyed structural diff, returned rather than emitted: shared by
10137
- * the setter channel (regular queue) and the OPTIMISTIC write channel (lane
10138
- * queue) — same retention semantics, different dispatch timing. Returns
10139
- * null when the lists are identity-aligned (no structure changed). */
10140
- function buildIdentityRowOps(prevRows, nextRows) {
10141
- let p = 0;
10142
- const min = prevRows.length < nextRows.length ? prevRows.length : nextRows.length;
10143
- while (p < min && unwrapValue(prevRows[p]) === unwrapValue(nextRows[p])) p++;
10144
- if (p === prevRows.length && p === nextRows.length) return null;
10145
- return buildRowOps(prevRows, nextRows, p, identityKey);
10146
- }
10147
- /** Shared row-ops builder (keyed deep branch + shallow/positional branch):
10148
- * key-matches the misaligned window into { prefix, sources, removed }.
10149
- * `keyFn === null` degrades to positional ops (append/truncate only). */
10150
- function buildAndEmitRowOps(t, prevRows, nextRows, structStart, keyFn) {
10151
- rowHooks.emitRowOps(t, nextRows, buildRowOps(prevRows, nextRows, structStart, keyFn));
10152
- }
10153
- function buildRowOps(prevRows, nextRows, structStart, keyFn) {
10154
- const plen = prevRows.length;
10155
- const nlen = nextRows.length;
10156
- const sources = new Array(nlen - structStart);
10157
- // Occurrence-aware matching (re-audit): duplicate keys queue their old
10158
- // indices and each is consumed ONCE — first-wins reuse would hand the same
10159
- // source (and its one DOM row) to multiple next positions. The no-dup fast
10160
- // shape stays a bare number; collisions upgrade to a queue.
10161
- let oldIndexByKey = null;
10162
- if (keyFn !== null && structStart < plen) {
10163
- oldIndexByKey = new Map();
10164
- for (let j = structStart; j < plen; j++) {
10165
- const p = unwrapValue(prevRows[j]);
10166
- if (p !== null && typeof p === "object") {
10167
- const pk = keyFn(p);
10168
- if (pk === undefined) continue;
10169
- const existing = oldIndexByKey.get(pk);
10170
- if (existing === undefined) oldIndexByKey.set(pk, j);
10171
- else if (Array.isArray(existing)) existing.push(j);
10172
- else oldIndexByKey.set(pk, [existing, j]);
10173
- }
10174
- }
10175
- }
10176
- const consumed = oldIndexByKey !== null ? new Set() : null;
10177
- for (let k = structStart; k < nlen; k++) {
10178
- const nv = nextRows[k];
10179
- let oldIdx = -1;
10180
- if (nv !== null && typeof nv === "object" && oldIndexByKey !== null) {
10181
- const nk = keyFn(nv);
10182
- if (nk !== undefined) {
10183
- const m = oldIndexByKey.get(nk);
10184
- if (m !== undefined) {
10185
- if (Array.isArray(m)) {
10186
- oldIdx = m.shift();
10187
- if (m.length === 1) oldIndexByKey.set(nk, m[0]);
10188
- } else {
10189
- oldIdx = m;
10190
- oldIndexByKey.delete(nk);
10191
- }
10192
- consumed.add(oldIdx);
10193
- }
10194
- }
10195
- }
10196
- sources[k - structStart] = oldIdx;
10197
- }
10198
- const removed = [];
10199
- for (let j = structStart; j < plen; j++) {
10200
- if (consumed === null || !consumed.has(j)) removed.push(unwrapValue(prevRows[j]));
10201
- }
10202
- return { prefix: structStart, sources, removed };
10203
- }
10204
11484
  function descend(pv, nv, keyFn, fam, proj = false) {
10205
11485
  if (pv === null || typeof pv !== "object" || nv === null || typeof nv !== "object") return;
10206
11486
  // Lookup FIRST: a hit implies pv was wrappable and never raw-marked (only
@@ -10470,558 +11750,6 @@ function runProjectionComputedNext(wrappedStore, fn, key, wrapCommit, aroundDraf
10470
11750
  return owner;
10471
11751
  }
10472
11752
 
10473
- /**
10474
- * PR-A: the patch channel (DESIGN-PATCH-CHANNEL.md).
10475
- *
10476
- * Compiled patch functions — per-record compare-and-write consumers —
10477
- * dispatched by the store's visibility transitions instead of render
10478
- * effects. This module owns registration, the per-flush apply queue
10479
- * (effect-phase timing, §2b), the owned-prev rule (§2c), and dispatch
10480
- * bubbling (§4b). Emission calls live at the four visibility-transition
10481
- * sites (adoption walk, setter notify, fold commit, override lifecycle)
10482
- * and are gated on registration, so unpatched stores pay a null check.
10483
- *
10484
- * Bubbling contract: a targeted nested write reaches ancestor patches as a
10485
- * FORCED re-apply — the third `force` argument makes every compiled compare
10486
- * pass, so the ancestor rewrites its bound fields from its current backing
10487
- * (idempotent, and prev-free: an ancestor's pre-state is not reconstructible
10488
- * after in-place folds). Compiled bodies therefore have the signature
10489
- * `(next, prev, force?)`.
10490
- *
10491
- * Tree-shaking: core never imports this module; stores without patches
10492
- * never schedule the queue.
10493
- */
10494
- let queue = null;
10495
- let scheduled = false;
10496
- function drainApplyQueue() {
10497
- // Settle-time fallback for optimistic emissions (a reverting flush may
10498
- // have no active lanes left to run the lane-slot drain).
10499
- drainOptimistic();
10500
- const q = queue;
10501
- queue = null;
10502
- scheduled = false;
10503
- if (q === null) return;
10504
- // Per-entry isolation: one throwing patch must not abort its siblings
10505
- // (effect parity — each effect isolates its failure). A throwing patch
10506
- // routes through its REGISTERING OWNER's queue chain exactly like a
10507
- // render-effect error (§2b): an Errored boundary above the row collects
10508
- // it (source = the owner, error read via owner._x?._error). Unhandled errors
10509
- // rethrow after the drain so they still surface.
10510
- let firstError = UNSET;
10511
- for (let i = 0; i < q.length; i++) {
10512
- clearStamp(q[i]);
10513
- const { list, prev, force, t } = q[i];
10514
- const next = t !== null ? (t.pb ?? t.v) : q[i].next;
10515
- firstError = applyEntries(list, next, prev, force, firstError);
10516
- }
10517
- if (firstError !== UNSET) {
10518
- // Unhandled patch errors HALT like unhandled effect errors (re-audit 2,
10519
- // P1-4): app state is undefined past an unboundaried throw.
10520
- haltReactivity(firstError);
10521
- throw firstError;
10522
- }
10523
- }
10524
- const UNSET = Symbol();
10525
- /** ONE callback/error primitive for every drain (normal, transition-held,
10526
- * optimistic): per-entry isolation — a throwing patch must not abort its
10527
- * siblings (effect parity) — and failures route through the REGISTERING
10528
- * OWNER's queue chain exactly like a render-effect error (§2b): an Errored
10529
- * boundary above the row collects it. Unhandled errors are aggregated by the
10530
- * caller (first one rethrows after its drain completes). */
10531
- function applyEntries(list, next, prev, force, firstError) {
10532
- // SNAPSHOT multi-consumer lists (re-audit 5, P1-3): a callback can dispose
10533
- // a sibling's owner, whose unbind SPLICES this same array mid-iteration —
10534
- // index-walking the live array skips the shifted consumer. The dominant
10535
- // single-consumer case pays nothing; unbound entries are marked so a
10536
- // snapshot never applies a consumer severed by an earlier callback.
10537
- const snap = list.length > 1 ? list.slice() : list;
10538
- for (let j = 0; j < snap.length; j++) {
10539
- const entry = snap[j];
10540
- if (entry.u === true) continue;
10541
- // Disposed owners drop their patches (the row unmounted mid-flush).
10542
- if (entry.owner !== null && isDisposed(entry.owner)) continue;
10543
- try {
10544
- entry.fn(next, prev, force);
10545
- } catch (err) {
10546
- let handled = false;
10547
- const owner = entry.owner;
10548
- if (owner !== null) {
10549
- // Route through the nearest COMPUTED ancestor (re-audit 2, P1-4):
10550
- // <Errored>.reset() recomputes its sources, and a plain owner (the
10551
- // list driver's listOwner) is not recomputable — the component/memo
10552
- // scope above it is, and recomputing it rebuilds the rows, exactly
10553
- // what reset means for a throwing render effect.
10554
- let source = owner;
10555
- while (source !== null && source._fn === undefined) source = source._parent;
10556
- source ??= owner;
10557
- const statusErr = new StatusError(source, err);
10558
- ext(source)._error = statusErr;
10559
- source._statusFlags = (source._statusFlags ?? 0) | STATUS_ERROR;
10560
- handled = owner._queue.notify(source, STATUS_ERROR, STATUS_ERROR, statusErr);
10561
- }
10562
- if (!handled && firstError === UNSET) firstError = err;
10563
- }
10564
- }
10565
- return firstError;
10566
- }
10567
- // Transition-stamped emissions (§2b, "the walk is not the visibility moment
10568
- // inside a transition"): entries stash DIRECTLY on their transition
10569
- // (`_heldPatches`) and release into the live queue when THAT batch commits
10570
- // (patchCommitHook). Reverted transitions never commit — their stash drops
10571
- // with the transition object, no revert bookkeeping. The field (rather than
10572
- // a WeakMap) keeps the every-flush commit-hook check to one property read;
10573
- // the ambient batch never stashes.
10574
- let commitHookInstalled = false;
10575
- function releaseBatch(batch) {
10576
- const held = batch._heldPatches;
10577
- if (held === undefined) return;
10578
- batch._heldPatches = undefined;
10579
- for (let i = 0; i < held.length; i++) pushLive(held[i]);
10580
- }
10581
- function pushLive(item) {
10582
- if (queue === null) queue = [];
10583
- queue.push(item);
10584
- if (!scheduled) {
10585
- scheduled = true;
10586
- globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue);
10587
- }
10588
- }
10589
- function push(item) {
10590
- const tx = activeTransition;
10591
- if (tx !== null) {
10592
- let held = tx._heldPatches;
10593
- if (held === undefined) tx._heldPatches = held = [];
10594
- held.push(item);
10595
- return;
10596
- }
10597
- pushLive(item);
10598
- }
10599
- /** Self-entry push with SAME-BATCH COALESCING (re-audit 2/3): a record's
10600
- * later non-forced emission into the same container UPDATES the queued
10601
- * entry in place — `next` takes the newest capture (adoption swaps the
10602
- * backing object per emission; dropping the later one applied STALE state),
10603
- * `prev` keeps the batch's earliest (effect semantics: one application per
10604
- * batch spanning the whole window). The entry's consumer list is the live
10605
- * pc.p array, so mid-batch registrants ride the single application. Forced
10606
- * entries and row/slot ops never coalesce; the drain clears the stamps so a
10607
- * quiet record retains nothing from its last batch. */
10608
- function pushSelf(pc, item) {
10609
- const tx = activeTransition;
10610
- let arr;
10611
- if (tx !== null) {
10612
- let held = tx._heldPatches;
10613
- if (held === undefined) tx._heldPatches = held = [];
10614
- arr = held;
10615
- } else {
10616
- if (queue === null) queue = [];
10617
- arr = queue;
10618
- }
10619
- if (pc.qa === arr && pc.qe !== null) {
10620
- const qe = pc.qe;
10621
- qe.next = item.next;
10622
- qe.list = item.list; // pc.p can be re-created if emptied mid-batch
10623
- return;
10624
- }
10625
- pc.qa = arr;
10626
- pc.qe = item;
10627
- item.pc = pc;
10628
- arr.push(item);
10629
- if (arr === queue && !scheduled) {
10630
- scheduled = true;
10631
- globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue);
10632
- }
10633
- }
10634
- /** Drain-side stamp clear (re-audit 3, P2-6): without it a quiet long-lived
10635
- * record's channel retains its last batch's container array, entry, and both
10636
- * captured backings for the record's lifetime. */
10637
- function clearStamp(item) {
10638
- const pc = item.pc;
10639
- if (pc !== undefined && pc.qe === item) {
10640
- pc.qa = null;
10641
- pc.qe = null;
10642
- }
10643
- }
10644
- /** Shallow clone for the owned-prev rule (§2c): owned backings fold values
10645
- * INTO the same raw at commit, so a queued prev must be snapshotted. */
10646
- function clonePrev(prev) {
10647
- return Array.isArray(prev) ? prev.slice() : { ...prev };
10648
- }
10649
- /**
10650
- * Emit a record's visibility transition. Callers gate on `hasPatches()` and
10651
- * `t.d` cheaply; this function re-checks and walks ancestors (§4b).
10652
- */
10653
- function emitPatch(t, next, prev) {
10654
- const p = t.pc !== null ? t.pc.p : null;
10655
- if (p !== null)
10656
- pushSelf(t.pc, {
10657
- list: p,
10658
- next,
10659
- prev: ownedRaw.has(prev) ? clonePrev(prev) : prev,
10660
- force: false,
10661
- t: null
10662
- });
10663
- // Bubbling: ancestors force-re-apply from their LIVE backing, resolved at
10664
- // drain (privatization may clone it between now and then).
10665
- let u = t.u;
10666
- while (u !== null) {
10667
- const up = u.pc !== null ? u.pc.p : null;
10668
- if (up !== null) push({ list: up, next: null, prev: null, force: true, t: u });
10669
- u = u.u;
10670
- }
10671
- }
10672
- /** Emission for sites that already stand at the record with both sides in
10673
- * hand and have already handled ancestors (the adoption walk descends —
10674
- * parents were visited first), so no bubbling walk. */
10675
- function emitPatchLocal(t, next, prev) {
10676
- const p = t.pc !== null ? t.pc.p : null;
10677
- if (p !== null)
10678
- pushSelf(t.pc, {
10679
- list: p,
10680
- next,
10681
- prev: ownedRaw.has(prev) ? clonePrev(prev) : prev,
10682
- force: false,
10683
- t: null
10684
- });
10685
- }
10686
- /** Optimistic-channel emission: overrides are visible THIS flush while the
10687
- * transaction is in flight — that is what optimism means. These ride a
10688
- * dedicated queue drained at LANE-EFFECT timing (the regular effect queues
10689
- * are stashed by an in-flight action), with the regular drain as the
10690
- * settle-time fallback. `next === null` = forced re-apply from the live
10691
- * target (the revert shape: committed truth back onto the DOM). */
10692
- let optQueue = null;
10693
- function drainOptimistic() {
10694
- const q = optQueue;
10695
- optQueue = null;
10696
- if (q === null) return;
10697
- // Same isolation/routing primitive as the normal drain (re-audit blocker
10698
- // 5): one throwing optimistic patch must not abort its siblings, and it
10699
- // must reach the registering owner's Errored boundary.
10700
- let firstError = UNSET;
10701
- for (let i = 0; i < q.length; i++) {
10702
- clearStamp(q[i]);
10703
- const { list, prev, force, t } = q[i];
10704
- const next = t !== null ? (t.pb ?? t.v) : q[i].next;
10705
- firstError = applyEntries(list, next, prev, force, firstError);
10706
- }
10707
- if (firstError !== UNSET) {
10708
- haltReactivity(firstError);
10709
- throw firstError;
10710
- }
10711
- }
10712
- function emitPatchOptimistic(t, next, prev) {
10713
- const p = t.pc !== null ? t.pc.p : null;
10714
- if (p === null) return;
10715
- if (optQueue === null) optQueue = [];
10716
- if (next === null) optQueue.push({ list: p, next: null, prev: null, force: true, t });
10717
- else {
10718
- // Same-batch coalescing, optimistic container (re-audit 3): later
10719
- // non-forced emission updates the queued entry's next in place.
10720
- const pc = t.pc;
10721
- if (pc.qa === optQueue && pc.qe !== null) {
10722
- const qe = pc.qe;
10723
- qe.next = next;
10724
- qe.list = p;
10725
- } else {
10726
- const item = { list: p, next, prev, force: false, t: null };
10727
- pc.qa = optQueue;
10728
- pc.qe = item;
10729
- item.pc = pc;
10730
- optQueue.push(item);
10731
- }
10732
- }
10733
- // Backup scheduling: the lane-slot drain covers in-flight application; a
10734
- // stashed regular drain guarantees settle-time application when no lane
10735
- // survives to the final flush (pure reverts).
10736
- if (!scheduled) {
10737
- scheduled = true;
10738
- globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue);
10739
- }
10740
- }
10741
- /** Row-ops emission at OPTIMISTIC (lane) timing: user drafts on an
10742
- * optimistic family must show structure IN FLIGHT — bypassing the
10743
- * transition stash exactly like emitPatchOptimistic. Two forms:
10744
- * - `ops` given (write site): `nextRows` is the draft's intended visible
10745
- * list, ops the identity diff against the pre-write optimistic view.
10746
- * - `ops === null` (revert site): RESYNC — the consumer rebuilds retention
10747
- * by row identity against the live post-revert view, resolved from the
10748
- * target at drain time (overrides are gone by then, so `pb ?? v` IS the
10749
- * committed truth). */
10750
- function emitRowOpsOptimistic(t, nextRows, ops) {
10751
- const list = t.pc !== null ? t.pc.ro : null;
10752
- if (list === null) return;
10753
- if (optQueue === null) optQueue = [];
10754
- optQueue.push({
10755
- list: list.map(e => ({
10756
- owner: e.owner,
10757
- fn: (n, _p) => e.fn(n, ops)
10758
- })),
10759
- next: nextRows,
10760
- prev: null,
10761
- force: false,
10762
- t: nextRows === null ? t : null
10763
- });
10764
- if (!scheduled) {
10765
- scheduled = true;
10766
- globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue);
10767
- }
10768
- }
10769
- /**
10770
- * Register a compiled patch on a store record. Multi-consumer (two lists
10771
- * can render one record); owner-scoped for disposal. Returns unbind.
10772
- */
10773
- // Global registration count: the cheap gate emission sites check before any
10774
- // per-record work (unpatched apps pay one number compare per transition).
10775
- let patchCount = 0;
10776
- function hasPatches() {
10777
- return patchCount > 0;
10778
- }
10779
- function registerPatch(record, fn) {
10780
- let t = record?.[$TARGET];
10781
- if (t === undefined) throw new Error("registerPatch: not a store record");
10782
- // Chained backings (§7b): register on the ULTIMATE owner — that is where
10783
- // value transitions fold and dispatch; the wrapper's identity is stable
10784
- // and would never fire (see ultimateTarget).
10785
- t = ultimateTarget(t) ?? t;
10786
- if (!commitHookInstalled) {
10787
- commitHookInstalled = true;
10788
- armPatchHooks();
10789
- setPatchCommitHook(releaseBatch);
10790
- GlobalQueue._drainPatchOptimistic = drainOptimistic;
10791
- }
10792
- const entry = { fn, owner: getOwner() };
10793
- const pc = pcOf(t);
10794
- const list = (pc.p ??= []);
10795
- list.push(entry);
10796
- patchCount++;
10797
- // Bindings are subscriptions for reachability (§6d pruning must descend
10798
- // into bound records).
10799
- markDescendants(t);
10800
- let unbound = false;
10801
- return () => {
10802
- if (unbound) return;
10803
- unbound = true;
10804
- entry.u = true; // dispatch snapshots skip severed consumers
10805
- // Decrement ONLY on actual removal: a demotion (demoteToEffects) may
10806
- // have already pulled this entry and repaired the count — the splice
10807
- // miss is how this closure learns that.
10808
- const idx = list.indexOf(entry);
10809
- if (idx >= 0) {
10810
- list.splice(idx, 1);
10811
- patchCount--;
10812
- }
10813
- if (list.length === 0 && pc.p === list) pc.p = null;
10814
- };
10815
- }
10816
- /** Resolve a target through CHAINED backings (§7b) to the ultimate owner.
10817
- * A projection family wrapper's backing IS another store's proxy: value
10818
- * transitions fold on the ULTIMATE target (the wrapper's identity never
10819
- * changes), so patch registration and raw resolution must land there or
10820
- * registered patches never fire (equivalence-matrix finding: projection
10821
- * value ticks froze driver rows while classic effects tracked through). */
10822
- function ultimateTarget(t) {
10823
- while (t.ch) {
10824
- const u = (t.pb ?? t.v)?.[$TARGET];
10825
- if (u === undefined) return undefined;
10826
- t = u;
10827
- }
10828
- return t;
10829
- }
10830
- /** Dual-driver bind probe (compiler runtime contract): when `record` is a
10831
- * patchable store record, returns its CURRENT raw backing (the driver's
10832
- * initial force-apply reads it directly — no proxy traffic, no tracking);
10833
- * returns undefined otherwise (driver falls back to the effect path).
10834
- * Not patchable: non-records, non-proxies, accessor-bearing records
10835
- * (patches read raw — getters need tracked evaluation), broken chains. */
10836
- function patchableRaw(record) {
10837
- let t = record?.[$TARGET];
10838
- if (t === undefined || t.px !== record || t.a === true) return undefined;
10839
- t = ultimateTarget(t);
10840
- // SCAN before trusting (re-audit blocker 3): `a` starts false and is only
10841
- // discovered lazily (first draft, deep walks) — admission must run the
10842
- // one-time own-accessor scan itself, or a getter-bearing record takes the
10843
- // patch path and its getter's OUTSIDE dependencies (signals, other
10844
- // records) never re-apply. Sticky `sc` makes this one probe pass per
10845
- // record lifetime.
10846
- if (t === undefined || !targetIsPlain(t)) return undefined;
10847
- return t.pb ?? t.v;
10848
- }
10849
- /** Accessor demotion (design §5): a record that acquires an accessor after
10850
- * registration stops being patchable — reads must go through tracked
10851
- * evaluation. Clears patches and repairs the global count; callers re-drive
10852
- * the pulled bodies (demoteToEffects). */
10853
- function demotePatches(t) {
10854
- if (t.pc === null) return null;
10855
- const p = t.pc.p;
10856
- t.pc.p = null;
10857
- if (p === null) return null;
10858
- patchCount -= p.length;
10859
- // Drain IN PLACE: unbind closures captured this array — a late unbind must
10860
- // miss its indexOf and not double-decrement the repaired count.
10861
- return p.splice(0, p.length);
10862
- }
10863
- /** The demotion re-drive (re-audit blocker 3): each pulled body becomes the
10864
- * SAME dual-driver effect fallback the web runtime would have chosen had the
10865
- * record carried the accessor at bind — a tracked compute pass (next === prev
10866
- * short-circuits every compare into a pure read THROUGH THE PROXY, so getter
10867
- * dependencies track) plus an untracked force-apply at effect timing.
10868
- *
10869
- * Creation is DEFERRED to the effect phase: the trap that discovers the
10870
- * accessor runs mid-draft, and an effect's initial pass must not read
10871
- * through the proxy inside the write window. The record's own transition
10872
- * for that draft is covered by the new effect's initial force-apply.
10873
- *
10874
- * Known edge (documented): a demoted LIST-ROW body re-drives under its
10875
- * registering owner (the list owner), so per-row severing on removal is
10876
- * lost for demoted rows — the effect lives until the LIST disposes. Rows
10877
- * only demote when user code defines an accessor on a row record at
10878
- * runtime. */
10879
- function demoteToEffects(t) {
10880
- const entries = demotePatches(t);
10881
- if (entries === null || entries.length === 0) return;
10882
- const proxy = t.px;
10883
- globalQueue.enqueue(EFFECT_RENDER, () => {
10884
- for (let i = 0; i < entries.length; i++) {
10885
- const entry = entries[i];
10886
- if (entry.owner !== null && isDisposed(entry.owner)) continue;
10887
- const fn = entry.fn;
10888
- runWithOwner(entry.owner, () =>
10889
- createRenderEffect(
10890
- () => {
10891
- fn(proxy, proxy, false);
10892
- },
10893
- () => {
10894
- // Block body: a compiled patch body's return value must not be
10895
- // mistaken for an effect cleanup.
10896
- untrack(() => fn(proxy, undefined, true));
10897
- }
10898
- )
10899
- );
10900
- }
10901
- });
10902
- }
10903
- /** Register a structural-ops consumer on a keyed store array (the list
10904
- * container's channel — what `For` consumes through the seam). */
10905
- function registerRowOps(array, fn) {
10906
- let t = array?.[$TARGET];
10907
- if (t === undefined) throw new Error("registerRowOps: not a store array");
10908
- // Chained backings resolve to the ULTIMATE owner, same as registerPatch
10909
- // (§7b) — the walk/fold emits there (re-audit blocker 4).
10910
- t = ultimateTarget(t) ?? t;
10911
- armRowHooks();
10912
- if (!commitHookInstalled) {
10913
- commitHookInstalled = true;
10914
- armPatchHooks();
10915
- setPatchCommitHook(releaseBatch);
10916
- GlobalQueue._drainPatchOptimistic = drainOptimistic;
10917
- }
10918
- const entry = { fn, owner: getOwner() };
10919
- const pc = pcOf(t);
10920
- const list = (pc.ro ??= []);
10921
- list.push(entry);
10922
- patchCount++;
10923
- markDescendants(t);
10924
- let unbound = false;
10925
- return () => {
10926
- if (unbound) return;
10927
- unbound = true;
10928
- patchCount--;
10929
- const idx = list.indexOf(entry);
10930
- if (idx >= 0) list.splice(idx, 1);
10931
- if (list.length === 0 && pc.ro === list) pc.ro = null;
10932
- };
10933
- }
10934
- /** Slot patches (shallow arrays) ride the same apply queue: the walk emits
10935
- * per aligned value-replaced slot; application happens at effect phase under
10936
- * the registration owner's lifetime. */
10937
- function emitSlotPatch(t, index, next, prev) {
10938
- const sp = t.pc !== null ? t.pc.sp : null;
10939
- if (sp === null) return;
10940
- push({
10941
- list: sp.map(e => ({ owner: e.owner, fn: () => e.fn(index, next, prev) })),
10942
- next,
10943
- prev,
10944
- force: false,
10945
- t: null
10946
- });
10947
- }
10948
- /** Slot patch for shallow arrays: the reconcile walk emits (index, next,
10949
- * prev) for KEY-ALIGNED value-replaced slots (structure rides row ops), and
10950
- * the emission queues through the patch apply queue — effect-phase timing,
10951
- * transition stamping, disposed-owner drop — like every other channel. */
10952
- function registerSlotPatchNext(arr, fn) {
10953
- let t = arr?.[$TARGET];
10954
- if (t === undefined) throw new Error("registerSlotPatchNext: not a store array");
10955
- // Chained backings resolve to the ULTIMATE owner, same as registerPatch
10956
- // (§7b) — the walk emits slot ticks there (re-audit blocker 4).
10957
- t = ultimateTarget(t) ?? t;
10958
- armRowHooks();
10959
- if (!commitHookInstalled) {
10960
- commitHookInstalled = true;
10961
- armPatchHooks();
10962
- setPatchCommitHook(releaseBatch);
10963
- GlobalQueue._drainPatchOptimistic = drainOptimistic;
10964
- }
10965
- // Multi-consumer (external audit): one shallow array can drive several
10966
- // lists — registrations are a list, unbinds splice their own entry.
10967
- const pc = pcOf(t);
10968
- const entry = { fn, owner: getOwner() };
10969
- (pc.sp ??= []).push(entry);
10970
- markDescendants(t);
10971
- let unbound = false;
10972
- return () => {
10973
- if (unbound || pc.sp === null) return;
10974
- unbound = true;
10975
- const idx = pc.sp.indexOf(entry);
10976
- if (idx >= 0) pc.sp.splice(idx, 1);
10977
- if (pc.sp.length === 0) pc.sp = null;
10978
- };
10979
- }
10980
- /** Row-ops ride the SAME apply queue/timing as record patches: transition-
10981
- * stamped, applied at effect phase, in emission order (structure before the
10982
- * new rows' own patches can exist; retained rows' value patches commute). */
10983
- function emitRowOps(t, next, ops) {
10984
- const list = t.pc !== null ? t.pc.ro : null;
10985
- if (list === null) return;
10986
- push({
10987
- list: list.map(e => ({
10988
- owner: e.owner,
10989
- fn: (n, _p) => e.fn(n, ops)
10990
- })),
10991
- next,
10992
- prev: null,
10993
- force: false,
10994
- t: null
10995
- });
10996
- }
10997
- // Pay-for-use seams: the write paths (store/reconcile/optimistic) emit
10998
- // through installed hooks instead of importing this module. Installation is
10999
- // LAZY (first registration) rather than a module-scope call — the dist is a
11000
- // flat bundle, and a top-level side effect would retain the whole channel in
11001
- // every consumer. TWO TIERS so a value-only registration (registerPatch —
11002
- // present in ~every bundle under patch-mode default) does not retain the
11003
- // list machinery (row-ops emitters + reconcile's diff builders): row hooks
11004
- // arm only from the list driver's registrations. Sound because every
11005
- // emission site is guarded by the matching pc channel, which only the
11006
- // corresponding registration creates. See patch-hooks.ts.
11007
- function armPatchHooks() {
11008
- installPatchHooks({
11009
- emitPatch,
11010
- emitPatchLocal,
11011
- emitPatchOptimistic,
11012
- hasPatches,
11013
- demoteToEffects
11014
- });
11015
- }
11016
- function armRowHooks() {
11017
- installRowHooks({
11018
- emitRowOps,
11019
- emitSlotPatch,
11020
- emitSetterRowOps,
11021
- emitRowOpsOptimistic
11022
- });
11023
- }
11024
-
11025
11753
  /**
11026
11754
  * Store rewrite — optimistic stores (§3/§7, RUL-3): no store-side layer, no
11027
11755
  * backup snapshots. Nodes in an optimistic family are ARMED core signals
@@ -11075,20 +11803,11 @@ function installNextBlockedHalf() {
11075
11803
  // so the hook only empties the batch set.
11076
11804
  if (!GlobalQueue._clearOptimisticStores) {
11077
11805
  GlobalQueue._clearOptimisticStores = stores => {
11078
- // Patch channel (revert site): engine-native reverts flip node values
11079
- // back to committed; patched records need a forced DOM re-apply from
11080
- // the post-revert view. Emission only — next keeps no layer to clear.
11081
11806
  for (const px of stores) {
11082
11807
  const t = px?.[$TARGET];
11083
11808
  const overlaid = t?.fam?.overlaid;
11084
11809
  if (overlaid !== undefined) {
11085
11810
  for (const ot of overlaid) {
11086
- if (ot.pc !== null && ot.pc.p !== null) patchHooks.emitPatchOptimistic(ot, null, null);
11087
- // Row-ops resync (family increment 2): reverts flip node values
11088
- // back engine-natively; a driven list must rebuild retention by
11089
- // row identity against the post-revert view (resolved from the
11090
- // target at drain — overrides are gone by then).
11091
- if (ot.pc !== null && ot.pc.ro !== null) rowHooks.emitRowOpsOptimistic(ot, null, null);
11092
11811
  // Keyset resync (classic channel twin): the keyset node's own
11093
11812
  // revert can compare EQUAL (a landing's bump matched the
11094
11813
  // tentative bump) while the arrangement underneath changed —
@@ -11147,13 +11866,13 @@ function familyHasLiveOverrides(fam) {
11147
11866
  overlaid.clear(); // nothing live — drop the bookkeeping
11148
11867
  return false;
11149
11868
  }
11150
- function createOptimisticStoreNext(first, second, options) {
11869
+ function createOptimisticStoreNext(first, second, third) {
11151
11870
  // Engine first (armed nodes need optimisticWrite installed before any
11152
11871
  // node exists), then the next-shape hooks.
11153
11872
  installOptimisticEngine();
11154
11873
  installNextBlockedHalf();
11155
11874
  const derived = typeof first === "function";
11156
- if (!derived && options === undefined) options = second;
11875
+ const options = derived ? third : second;
11157
11876
  const initialValue = derived ? second : first;
11158
11877
  const fam = {
11159
11878
  map: new WeakMap(),
@@ -11242,7 +11961,16 @@ function createOptimisticStoreNext(first, second, options) {
11242
11961
  if (!self._loading) fam.ft = null;
11243
11962
  return;
11244
11963
  }
11245
- if (self._loading) return;
11964
+ // First flight (#3146 carve-out): nothing has ever committed, so there
11965
+ // is no truth to keep on screen and no optimistic state to protect. An
11966
+ // uninitialized ask suspends its readers into their Loading boundary
11967
+ // exactly like a plain derived store's first flight — declaring a
11968
+ // transaction here instead held the ROOT MOUNT (render()'s scheduled
11969
+ // insert rides transitions) until the fetch landed, so the boundary's
11970
+ // fallback never showed and the whole page stayed blank. The loading
11971
+ // window (#2933) already declares nothing for the same reason; once
11972
+ // the first truth lands, every refetch flight declares as before.
11973
+ if (self._loading || self._statusFlags & STATUS_UNINITIALIZED) return;
11246
11974
  let txn = activeTransition;
11247
11975
  if (txn === null) globalQueue.initTransition((txn = createTransition()));
11248
11976
  fam.ft = txn;
@@ -11482,22 +12210,6 @@ function notifyOptimisticWrites(t, pb) {
11482
12210
  if (ft !== null) globalQueue.initTransition(ft);
11483
12211
  }
11484
12212
  const old = t.v;
11485
- // Patch channel (override-application site): the draft IS the intended
11486
- // visible state; prev is the view before these overrides apply. Bypasses
11487
- // the transition stash — optimism is visible in flight.
11488
- if (t.pc !== null && t.pc.p !== null)
11489
- patchHooks.emitPatchOptimistic(t, pb, optimisticView(t, old));
11490
- // Row-ops channel (family increment 2): optimistic STRUCTURE on an array
11491
- // rides node overrides — it never enters the reconcile walk — so a driven
11492
- // list must get its structural ops here, lane-timed. Identity diff of the
11493
- // pre-write optimistic view against the draft; aligned writes emit nothing.
11494
- if (t.pc !== null && t.pc.ro !== null && Array.isArray(pb)) {
11495
- const prevView = optimisticView(t, old);
11496
- if (Array.isArray(prevView)) {
11497
- const ops = buildIdentityRowOps(prevView, pb);
11498
- if (ops !== null) rowHooks.emitRowOpsOptimistic(t, pb, ops);
11499
- }
11500
- }
11501
12213
  const visible = (key, fallback) => {
11502
12214
  const node = t.n?.[key];
11503
12215
  return node !== undefined && hasActiveOverride(node)
@@ -11595,7 +12307,12 @@ function applyTentative(t, incoming, keyFn) {
11595
12307
  const isArr = Array.isArray(incoming);
11596
12308
  if (Array.isArray(view) !== isArr) return; // kind change at root: flat overrides below
11597
12309
  const pairs = [];
11598
- const pbLike = isArr ? [...incoming] : shallowWithSymbols(incoming);
12310
+ let pbLike;
12311
+ if (isArr) pbLike = [...incoming];
12312
+ else {
12313
+ pbLike = {};
12314
+ for (const k of Reflect.ownKeys(incoming)) pbLike[k] = incoming[k];
12315
+ }
11599
12316
  const match = (pv, nv) => {
11600
12317
  if (!isWrappable(pv) || !isWrappable(nv)) return null;
11601
12318
  if (rawValuesUsed && (isRawValue(pv) || isRawValue(nv))) return null;
@@ -11674,11 +12391,6 @@ function applyTentative(t, incoming, keyFn) {
11674
12391
  for (let i = 0; i < pairs.length; i++)
11675
12392
  applyTentative(pairs[i][0], unwrapValue(pairs[i][1]), keyFn);
11676
12393
  }
11677
- function shallowWithSymbols(src) {
11678
- const out = {};
11679
- for (const k of Reflect.ownKeys(src)) out[k] = src[k];
11680
- return out;
11681
- }
11682
12394
 
11683
12395
  const DELETE = Symbol("STORE_PATH_DELETE");
11684
12396
  function isPrototypePollutionKey(part) {
@@ -12018,7 +12730,10 @@ function mapArray(list, map, options) {
12018
12730
  _byIndex: options?.keyed === false,
12019
12731
  _fallback: options?.fallback
12020
12732
  };
12021
- const node = computed(updateKeyedMap.bind(data));
12733
+ const node = computed(
12734
+ updateKeyedMap.bind(data),
12735
+ options?.name ? { name: options.name } : undefined
12736
+ );
12022
12737
  // Untracked reads inside the internal owner resolve via _parentComputed; routing
12023
12738
  // them through node lets store-proxy lookups see pending writes (not stale _value).
12024
12739
  data._owner._parentComputed = node;
@@ -12106,7 +12821,19 @@ function updateKeyedMap() {
12106
12821
  this._items = newItems.slice(0);
12107
12822
  this._len = newLen;
12108
12823
  } else {
12109
- let start, end, newEnd, item, key, newIndices, newIndicesNext, removed, created;
12824
+ let start,
12825
+ end,
12826
+ newEnd,
12827
+ item,
12828
+ key,
12829
+ newIndices,
12830
+ newIndicesNext,
12831
+ removed,
12832
+ created,
12833
+ // Dev (attribution engine installed): the items behind the exited and
12834
+ // entered rows, for the list-identity census.
12835
+ removedItems,
12836
+ createdItems;
12110
12837
  // skip common prefix
12111
12838
  for (
12112
12839
  start = 0, end = Math.min(this._len, newLen);
@@ -12164,13 +12891,17 @@ function updateKeyedMap() {
12164
12891
  indexes && (indexes[j] = this._indexes[i]);
12165
12892
  j = newIndicesNext[j];
12166
12893
  newIndices.set(key, j);
12167
- } else (removed ??= []).push(this._nodes[i]);
12894
+ } else {
12895
+ (removed ??= []).push(this._nodes[i]);
12896
+ if (true && attrHooks !== null) (removedItems ??= []).push(item);
12897
+ }
12168
12898
  }
12169
12899
  // 2) create new rows into the temp arrays; an abort disposes only these
12170
12900
  try {
12171
12901
  for (j = start; j <= newEnd; j++) {
12172
12902
  if (tempNodes[j] !== undefined) continue;
12173
12903
  (created ??= []).push((tempNodes[j] = createOwner()));
12904
+ if (true && attrHooks !== null) (createdItems ??= []).push(newItems[j]);
12174
12905
  temp[j] = runWithOwner(tempNodes[j], mapper);
12175
12906
  }
12176
12907
  } catch (err) {
@@ -12210,6 +12941,14 @@ function updateKeyedMap() {
12210
12941
  // save a copy of the mapped items for the next update
12211
12942
  this._items = newItems.slice(0);
12212
12943
  if (removed) for (i = 0; i < removed.length; i++) removed[i].dispose();
12944
+ if (true && attrHooks !== null && removedItems !== undefined && createdItems !== undefined)
12945
+ attrHooks.listChurn(
12946
+ this._owner._parentComputed,
12947
+ removedItems,
12948
+ createdItems,
12949
+ newLen,
12950
+ this._key !== undefined
12951
+ );
12213
12952
  }
12214
12953
  });
12215
12954
  return this._mappings;
@@ -12588,7 +13327,11 @@ class CollectionQueue extends Queue {
12588
13327
  if (source) {
12589
13328
  const wasEmpty = this._sources.size === 0;
12590
13329
  this._sources.add(source);
12591
- if (wasEmpty) setSignal(this._disabled, true);
13330
+ if (wasEmpty) {
13331
+ setSignal(this._disabled, true);
13332
+ if (attrHooks !== null && this._collectionType & STATUS_PENDING)
13333
+ attrHooks.boundaryFallback(this, this._tree, true);
13334
+ }
12592
13335
  if (this._collectionType & STATUS_ERROR) {
12593
13336
  setSignal(this._error, unwrapStatusError(source._x?._error));
12594
13337
  }
@@ -12624,6 +13367,8 @@ class CollectionQueue extends Queue {
12624
13367
  }
12625
13368
  if (!this._pending) {
12626
13369
  setSignal(this._disabled, false);
13370
+ if (attrHooks !== null && this._collectionType & STATUS_PENDING)
13371
+ attrHooks.boundaryFallback(this, this._tree, false);
12627
13372
  if (this._onFn) {
12628
13373
  try {
12629
13374
  this._prevOn = untrack(() => this._onFn());
@@ -12640,14 +13385,15 @@ function createCollectionBoundary(type, fn, fallback, onFn) {
12640
13385
  if (!getOwner()) {
12641
13386
  const message =
12642
13387
  "[NO_OWNER_BOUNDARY] Boundaries created outside a reactive context will never be disposed.";
12643
- emitDiagnostic({
12644
- code: "NO_OWNER_BOUNDARY",
12645
- kind: "lifecycle",
12646
- severity: "warn",
12647
- message,
12648
- data: { boundaryType: type === STATUS_PENDING ? "loading" : "error" }
12649
- });
12650
- console.warn(message);
13388
+ reportDiagnostic(
13389
+ emitDiagnostic({
13390
+ code: "NO_OWNER_BOUNDARY",
13391
+ kind: "lifecycle",
13392
+ severity: "warn",
13393
+ message,
13394
+ data: { boundaryType: type === STATUS_PENDING ? "loading" : "error" }
13395
+ })
13396
+ );
12651
13397
  }
12652
13398
  const owner = createOwner();
12653
13399
  if (_revealUsed) setContext(RevealControllerContext, null, owner);
@@ -12958,13 +13704,9 @@ export {
12958
13704
  omit,
12959
13705
  onCleanup,
12960
13706
  onSettled,
12961
- patchableRaw,
12962
13707
  peekNextChildId,
12963
13708
  reconcile,
12964
13709
  refresh,
12965
- registerPatch,
12966
- registerRowOps,
12967
- registerSlotPatchNext as registerSlotPatch,
12968
13710
  releaseSnapshotScope,
12969
13711
  repeat,
12970
13712
  resetErrorHalt,