@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.
- package/dist/dev.js +2033 -1291
- package/dist/node.cjs +1821 -2401
- package/dist/node.dev.cjs +13724 -0
- package/dist/prod/boundaries.js +3 -1
- package/dist/prod/core/async.js +6 -1
- package/dist/prod/core/attribution-hooks.js +3 -0
- package/dist/prod/core/constants.js +9 -1
- package/dist/prod/core/context.js +10 -16
- package/dist/prod/core/core.js +215 -139
- package/dist/prod/core/dev.js +2 -0
- package/dist/prod/core/effect.js +37 -28
- package/dist/prod/core/external.js +2 -2
- package/dist/prod/core/graph.js +35 -31
- package/dist/prod/core/heap.js +34 -40
- package/dist/prod/core/lanes.js +41 -27
- package/dist/prod/core/optimistic.js +42 -32
- package/dist/prod/core/owner.js +32 -32
- package/dist/prod/core/scheduler.js +140 -154
- package/dist/prod/core/verdict.js +31 -34
- package/dist/prod/index.js +0 -2
- package/dist/prod/map.js +104 -94
- package/dist/prod/signals.js +51 -34
- package/dist/prod/store/next/optimistic.js +153 -172
- package/dist/prod/store/next/reconcile.js +140 -288
- package/dist/prod/store/next/store.js +307 -237
- package/dist/prod/store/store.js +2 -2
- package/dist/types/core/attribution-hooks.d.ts +64 -0
- package/dist/types/core/attribution.d.ts +265 -9
- package/dist/types/core/constants.d.ts +8 -0
- package/dist/types/core/core.d.ts +12 -3
- package/dist/types/core/dev.d.ts +49 -8
- package/dist/types/core/heap.d.ts +5 -3
- package/dist/types/core/invariants.d.ts +1 -1
- package/dist/types/core/lanes.d.ts +10 -0
- package/dist/types/core/scheduler.d.ts +9 -4
- package/dist/types/signals.d.ts +10 -0
- package/dist/types/store/index.d.ts +3 -6
- package/dist/types/store/next/optimistic.d.ts +4 -2
- package/dist/types/store/next/reconcile.d.ts +5 -12
- package/dist/types/store/next/store.d.ts +3 -9
- package/dist/types/store/next/target.d.ts +20 -46
- package/dist/types/store/store.d.ts +14 -9
- package/dist/types-cjs/core/attribution-hooks.d.cts +64 -0
- package/dist/types-cjs/core/attribution.d.cts +265 -9
- package/dist/types-cjs/core/constants.d.cts +8 -0
- package/dist/types-cjs/core/core.d.cts +12 -3
- package/dist/types-cjs/core/dev.d.cts +49 -8
- package/dist/types-cjs/core/heap.d.cts +5 -3
- package/dist/types-cjs/core/invariants.d.cts +1 -1
- package/dist/types-cjs/core/lanes.d.cts +10 -0
- package/dist/types-cjs/core/scheduler.d.cts +9 -4
- package/dist/types-cjs/signals.d.cts +10 -0
- package/dist/types-cjs/store/index.d.cts +3 -6
- package/dist/types-cjs/store/next/optimistic.d.cts +4 -2
- package/dist/types-cjs/store/next/reconcile.d.cts +5 -12
- package/dist/types-cjs/store/next/store.d.cts +3 -9
- package/dist/types-cjs/store/next/target.d.cts +20 -46
- package/dist/types-cjs/store/store.d.cts +14 -9
- package/package.json +3 -2
- package/dist/prod/store/next/patch-hooks.js +0 -13
- package/dist/prod/store/next/patch.js +0 -614
- package/dist/types/store/next/patch-hooks.d.ts +0 -41
- package/dist/types/store/next/patch.d.ts +0 -91
- package/dist/types-cjs/store/next/patch-hooks.d.cts +0 -41
- 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
|
|
336
|
-
`so only the keys
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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)
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
1292
|
+
let causes = null;
|
|
709
1293
|
for (let i = frames.length - 1; i >= 0; i--) {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
parent = flightCauseIn(causes);
|
|
1294
|
+
if (frames[i].causes !== null) {
|
|
1295
|
+
causes = frames[i].causes;
|
|
713
1296
|
break;
|
|
714
1297
|
}
|
|
715
1298
|
}
|
|
716
|
-
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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 ? "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
|
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.
|
|
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
|
|
1607
|
-
scheduled
|
|
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
|
|
1800
|
-
* Call sites are gated by CONFIG_AUTHORITATIVE_OBSERVED, which
|
|
1801
|
-
* carve-out read can set, so `!` invocations are safe once
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
if (
|
|
1935
|
-
|
|
1936
|
-
|
|
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
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
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
|
|
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
|
|
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)
|
|
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:
|
|
2417
|
-
*
|
|
2418
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4187
|
-
// override — and "authoritative arrival
|
|
4188
|
-
// exactly the acknowledgment it waits for.
|
|
4189
|
-
// A17 silence holds for every ordinary
|
|
4190
|
-
//
|
|
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
|
|
4640
|
-
*
|
|
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
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
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
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
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
|
-
//
|
|
5094
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
5223
|
-
|
|
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]:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
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
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
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
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
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
|
-
?
|
|
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
|
|
7765
|
-
*
|
|
7766
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
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
|
-
//
|
|
7895
|
-
//
|
|
7896
|
-
|
|
7897
|
-
//
|
|
7898
|
-
//
|
|
7899
|
-
created.
|
|
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
|
-
/**
|
|
8032
|
-
*
|
|
8033
|
-
*
|
|
8034
|
-
|
|
8035
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9605
|
+
const before = target.v;
|
|
9606
|
+
const clone = cloneRaw(before, target);
|
|
8255
9607
|
ownedRaw.add(clone);
|
|
8256
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
|
|
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
|
-
//
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
|
|
8396
|
-
|
|
8397
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
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.
|
|
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
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
t.u.v[
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
9361
|
-
else if (
|
|
9362
|
-
const wk = (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
10924
|
+
if (initialValue[$TARGET])
|
|
10925
|
+
throw new Error("createStore({ shallow }): value is already a store proxy");
|
|
9493
10926
|
}
|
|
9494
|
-
const proxy = wrapNext(
|
|
10927
|
+
const proxy = wrapNext(initialValue);
|
|
9495
10928
|
if (shallow) {
|
|
9496
10929
|
proxy[$TARGET].s = true;
|
|
9497
|
-
markRawIngest(
|
|
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
|
-
|
|
9554
|
-
|
|
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
|
-
|
|
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
|
|
10126
|
-
*
|
|
10127
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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)
|
|
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
|
-
|
|
12644
|
-
|
|
12645
|
-
|
|
12646
|
-
|
|
12647
|
-
|
|
12648
|
-
|
|
12649
|
-
|
|
12650
|
-
|
|
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,
|