@solidjs/signals 2.0.0-rc.1 → 2.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/dev.js +1779 -383
  2. package/dist/node.cjs +2058 -1359
  3. package/dist/prod/affects.js +16 -16
  4. package/dist/prod/boundaries.js +90 -90
  5. package/dist/prod/core/action.js +3 -3
  6. package/dist/prod/core/async.js +74 -72
  7. package/dist/prod/core/constants.js +39 -1
  8. package/dist/prod/core/core.js +332 -224
  9. package/dist/prod/core/effect.js +56 -56
  10. package/dist/prod/core/external.js +4 -4
  11. package/dist/prod/core/graph.js +65 -54
  12. package/dist/prod/core/heap.js +52 -44
  13. package/dist/prod/core/invariants.js +3 -2
  14. package/dist/prod/core/lanes.js +41 -34
  15. package/dist/prod/core/optimistic.js +93 -68
  16. package/dist/prod/core/owner.js +97 -96
  17. package/dist/prod/core/scheduler.js +243 -194
  18. package/dist/prod/core/verdict.js +191 -77
  19. package/dist/prod/map.js +104 -104
  20. package/dist/prod/signals.js +1 -1
  21. package/dist/prod/store/next/optimistic.js +31 -23
  22. package/dist/prod/store/next/projection.js +109 -47
  23. package/dist/prod/store/next/reconcile.js +78 -74
  24. package/dist/prod/store/next/store.js +357 -90
  25. package/dist/prod/store/store.js +7 -7
  26. package/dist/types/core/attribution-hooks.d.ts +52 -0
  27. package/dist/types/core/attribution.d.ts +186 -0
  28. package/dist/types/core/constants.d.ts +26 -0
  29. package/dist/types/core/core.d.ts +8 -1
  30. package/dist/types/core/dev.d.ts +20 -3
  31. package/dist/types/core/graph.d.ts +1 -0
  32. package/dist/types/core/invariants.d.ts +1 -1
  33. package/dist/types/core/lanes.d.ts +4 -16
  34. package/dist/types/core/scheduler.d.ts +8 -0
  35. package/dist/types/core/types.d.ts +85 -41
  36. package/dist/types/store/next/projection.d.ts +0 -16
  37. package/dist/types/store/next/store.d.ts +6 -0
  38. package/dist/types/store/next/target.d.ts +18 -0
  39. package/dist/types-cjs/core/attribution-hooks.d.cts +52 -0
  40. package/dist/types-cjs/core/attribution.d.cts +186 -0
  41. package/dist/types-cjs/core/constants.d.cts +26 -0
  42. package/dist/types-cjs/core/core.d.cts +8 -1
  43. package/dist/types-cjs/core/dev.d.cts +20 -3
  44. package/dist/types-cjs/core/graph.d.cts +1 -0
  45. package/dist/types-cjs/core/invariants.d.cts +1 -1
  46. package/dist/types-cjs/core/lanes.d.cts +4 -16
  47. package/dist/types-cjs/core/scheduler.d.cts +8 -0
  48. package/dist/types-cjs/core/types.d.cts +85 -41
  49. package/dist/types-cjs/store/next/projection.d.cts +0 -16
  50. package/dist/types-cjs/store/next/store.d.cts +6 -0
  51. package/dist/types-cjs/store/next/target.d.cts +18 -0
  52. package/package.json +15 -15
  53. package/dist/types/store/optimistic.d.ts +0 -45
  54. package/dist/types/store/projection.d.ts +0 -70
  55. package/dist/types/store/reconcile.d.ts +0 -46
  56. package/dist/types-cjs/store/optimistic.d.cts +0 -45
  57. package/dist/types-cjs/store/projection.d.cts +0 -70
  58. package/dist/types-cjs/store/reconcile.d.cts +0 -46
package/dist/dev.js CHANGED
@@ -75,6 +75,15 @@ const REACTIVE_MANUAL_WRITE = 1 << 10;
75
75
  * pending window does not read as pending (question-scoped pending model).
76
76
  */
77
77
  const REACTIVE_REASK = 1 << 11;
78
+ /**
79
+ * A dependency write landed while this subscriber was mid-recompute — a
80
+ * nested pull committed beneath one of its reads (#3037). The heap refuses
81
+ * RECOMPUTING nodes, so recompute's tail consumes this latch and reschedules:
82
+ * values the pass read before the nested commit are stale. Only set for
83
+ * links validated this pass (gen-current): a write to an untouched link is
84
+ * either re-read later in the pass (fresh) or trimmed with it (not a dep).
85
+ */
86
+ const REACTIVE_MISSED_WAKE = 1 << 12;
78
87
  // Static configuration bits packed into Owner/Computed/Signal _config.
79
88
  const CONFIG_OWNED_WRITE = 1 << 0;
80
89
  const CONFIG_NO_SNAPSHOT = 1 << 1;
@@ -83,6 +92,31 @@ const CONFIG_IN_SNAPSHOT_SCOPE = 1 << 3;
83
92
  const CONFIG_CHILDREN_FORBIDDEN = 1 << 4;
84
93
  const CONFIG_AUTO_DISPOSE = 1 << 5;
85
94
  const CONFIG_SYNC = 1 << 6;
95
+ // Presence bits (stage-3 hot-path monomorphism, DESIGN-PATCH-CHANNEL §11b):
96
+ // optional per-node slots (_overrideValue, _pendingSignal/_latestValueComputed,
97
+ // _snapshotValue, _optimisticLane) are NOT part of every node's hidden class —
98
+ // reading a missing property defeats V8's inline caches on the hottest write/
99
+ // notify loops. These bits live on the always-present `_config` so hot paths
100
+ // pay one monomorphic masked read and only touch the optional field when its
101
+ // installer flagged it. Bits are STICKY ("may be set") — the guarded field
102
+ // read remains authoritative.
103
+ const CONFIG_OPTIMISTIC = 1 << 7;
104
+ const CONFIG_HAS_COMPANIONS = 1 << 8;
105
+ const CONFIG_HAS_SNAPSHOT = 1 << 9;
106
+ const CONFIG_HAS_LANE = 1 << 10;
107
+ /** Set on a FIREWALL computed when any of its child signals creates an
108
+ * isPending()/latest() companion. Gates the post-recompute child-companion
109
+ * walk (#3038): a store computed's `_child` chain holds one node per
110
+ * materialized leaf, so walking it unconditionally makes every update cost
111
+ * O(all leaves ever read). Sticky — set at companion creation, never
112
+ * cleared; sync-only apps never set it and never pay the walk. */
113
+ const CONFIG_CHILD_COMPANIONS = 1 << 11;
114
+ /** Set on a computed when its first firewall child signal is installed
115
+ * (projection machinery). Gates markNode's firewall-children walk with one
116
+ * masked read of the always-present _config — the walk's old `_child` read
117
+ * moved into the cold extension (§12), and an unconditional `_x` deref per
118
+ * marked node measurably taxed the propagation hot path (diamond -22%). */
119
+ const CONFIG_FW_CHILDREN = 1 << 12;
86
120
  const STATUS_PENDING = 1 << 0;
87
121
  const STATUS_ERROR = 1 << 1;
88
122
  const STATUS_UNINITIALIZED = 1 << 2;
@@ -117,6 +151,559 @@ const defaultContext = {};
117
151
  */
118
152
  const $REFRESH = Symbol("refresh");
119
153
 
154
+ let attrHooks = null;
155
+ function setAttributionHooks(hooks) {
156
+ attrHooks = hooks;
157
+ }
158
+
159
+ let changeSeq = 0;
160
+ let runSeq = 0;
161
+ const defaultOptions = {
162
+ log: true,
163
+ stacks: false,
164
+ historyLimit: 200,
165
+ hotRuns: { count: 120, windowMs: 1000 },
166
+ wideDeps: 30,
167
+ hotTime: { budgetMs: 8, windowMs: 1000 },
168
+ unstableMemos: 4,
169
+ wideWrites: 250
170
+ };
171
+ let options = { ...defaultOptions };
172
+ const listeners = new Set();
173
+ let history = [];
174
+ const now = typeof performance !== "undefined" ? () => performance.now() : () => Date.now();
175
+ const frames = [];
176
+ const scopeCosts = new Map();
177
+ const writeCosts = new Map();
178
+ function rootsOf(causes, out) {
179
+ for (const c of causes) {
180
+ if (c.kind === "derived" && c.causes && c.causes.length > 0) rootsOf(c.causes, out);
181
+ else out.add(c.name);
182
+ }
183
+ }
184
+ function recordCosts(event) {
185
+ let scope = scopeCosts.get(event.node);
186
+ if (scope === undefined) {
187
+ scope = {
188
+ name: event.nodeName,
189
+ kind: event.nodeKind,
190
+ runs: 0,
191
+ selfMs: 0,
192
+ wastedMs: 0,
193
+ overlayMs: 0
194
+ };
195
+ scopeCosts.set(event.node, scope);
196
+ }
197
+ scope.runs++;
198
+ scope.selfMs += event.selfMs;
199
+ if (event.phase !== "plain") scope.overlayMs += event.selfMs;
200
+ else if (!event.changed && !event.held) scope.wastedMs += event.selfMs;
201
+ const roots = new Set();
202
+ rootsOf(event.causes, roots);
203
+ for (const name of roots) {
204
+ let write = writeCosts.get(name);
205
+ if (write === undefined) writeCosts.set(name, (write = { name, runs: 0, downstreamMs: 0 }));
206
+ write.runs++;
207
+ write.downstreamMs += event.selfMs;
208
+ }
209
+ }
210
+ function nodeName(node) {
211
+ return node._name ?? "anonymous";
212
+ }
213
+ function preview(v) {
214
+ if (v === null) return "null";
215
+ switch (typeof v) {
216
+ case "undefined":
217
+ return "undefined";
218
+ case "string":
219
+ return JSON.stringify(v.length > 40 ? v.slice(0, 40) + "…" : v);
220
+ case "number":
221
+ case "boolean":
222
+ case "bigint":
223
+ return String(v);
224
+ case "function":
225
+ return "[function]";
226
+ case "symbol":
227
+ return v.toString();
228
+ default:
229
+ return Array.isArray(v) ? `Array(${v.length})` : `[${v.constructor?.name ?? "object"}]`;
230
+ }
231
+ }
232
+ function captureStack() {
233
+ if (!options.stacks) return undefined;
234
+ const raw = new Error().stack?.split("\n") ?? [];
235
+ // Drop the message line and every frame inside the reactive core; the first
236
+ // remaining frames are the user code that performed the write.
237
+ return raw
238
+ .slice(1)
239
+ .filter(line => !/(?:^|[/\\])(?:packages[/\\])?signals[/\\](src|dist)[/\\]/.test(line))
240
+ .slice(0, 3)
241
+ .map(line => line.trim());
242
+ }
243
+ /** Sentinel for "no value transition to record" (refresh() stamps). */
244
+ const NO_VALUES = Symbol("no-values");
245
+ /** Record a root change (setSignal / refresh / async landing) on the node. */
246
+ /**
247
+ * Written-fan-out warning — the write-time complement of the always-on
248
+ * HUGE_FAN_OUT link-time warning (see dev.ts). Static fan-out that never
249
+ * writes is harmless; a committed root invalidation reaching hundreds of
250
+ * subscribers re-runs all of them this flush. Uses the dev-maintained
251
+ * `_subCount` from the graph-size diagnostics — no core sites touched.
252
+ * Once per node; re-warns only when the subscriber count has doubled since
253
+ * the last warning, so it cannot spam alongside HUGE_FAN_OUT's own
254
+ * 2000-and-up milestones.
255
+ */
256
+ function checkWideWrite(node, kind) {
257
+ const limit = options.wideWrites;
258
+ if (typeof limit !== "number") return;
259
+ const subs = node._subCount ?? 0;
260
+ const attributed = node;
261
+ if (subs < limit || subs < (attributed._devWideWriteWarnedAt ?? 0) * 2) return;
262
+ attributed._devWideWriteWarnedAt = subs;
263
+ const verb =
264
+ kind === "refresh" ? "refresh of" : kind === "async" ? "async landing on" : "write to";
265
+ const message =
266
+ `[WIDE_WRITE] ${verb} "${nodeName(node)}" reached ${subs} subscribers — every one ` +
267
+ `re-runs this flush. If consumers ask keyed questions of this value (for example every ` +
268
+ `row comparing against one selected id), invert with createSelector or createProjection ` +
269
+ `so only the keys whose answer flipped update.`;
270
+ emitDiagnostic({
271
+ code: "WIDE_WRITE",
272
+ kind: "perf",
273
+ severity: "warn",
274
+ message,
275
+ nodeName: nodeName(node),
276
+ data: { subscribers: subs, write: kind }
277
+ });
278
+ console.warn(message);
279
+ }
280
+ function stampWrite(node, kind, prev = NO_VALUES, value = NO_VALUES) {
281
+ const record = { seq: ++changeSeq, kind, name: nodeName(node) };
282
+ if (value !== NO_VALUES) {
283
+ record.prev = prev === NO_VALUES ? undefined : preview(prev);
284
+ record.value = preview(value);
285
+ }
286
+ record.stack = captureStack();
287
+ node._devChange = record;
288
+ // stampWrite is the single funnel for committed root invalidations (sync
289
+ // writes, refresh(), async landings), which makes it the one place the
290
+ // written-fan-out check needs to live.
291
+ checkWideWrite(node, kind);
292
+ }
293
+ /** Record a derived change (memo produced a new value) with its causes. */
294
+ function stampDerived(node, causes) {
295
+ node._devChange = {
296
+ seq: ++changeSeq,
297
+ kind: "derived",
298
+ name: nodeName(node),
299
+ causes
300
+ };
301
+ }
302
+ /**
303
+ * Collect the deps whose committed change is newer than this node's previous
304
+ * run. Called at recompute entry, while `_deps` still holds the previous
305
+ * run's links. A refresh() stamp on the node itself also counts — that is a
306
+ * self-invalidation, not a dep change.
307
+ */
308
+ function collectCauses(el) {
309
+ const seen = el._devSeenSeq ?? 0;
310
+ const causes = [];
311
+ const self = el._devChange;
312
+ if (self !== undefined && self.seq > seen && self.kind === "refresh") causes.push(self);
313
+ for (let l = el._deps; l !== null; l = l._nextDep) {
314
+ const change = l._dep._devChange;
315
+ if (change !== undefined && change.seq > seen) causes.push(change);
316
+ }
317
+ return causes;
318
+ }
319
+ /** Advance the node's seen-cursor to the present. Call after every run. */
320
+ function markSeen(el) {
321
+ el._devSeenSeq = changeSeq;
322
+ }
323
+ /** Snapshot the node's current dep identities (call before a run replaces them). */
324
+ function captureDeps(el) {
325
+ const deps = [];
326
+ for (let l = el._deps; l !== null; l = l._nextDep) deps.push(l._dep);
327
+ return deps;
328
+ }
329
+ /**
330
+ * Wide-scope warning — the coarse-read / helper-leak signature: one scope
331
+ * subscribed to dozens of sources re-runs when ANY of them change. Fired from
332
+ * recordRerun for re-runs and directly from recompute for creation runs (a
333
+ * memo can be born too wide). Re-warns only on 50% further growth.
334
+ */
335
+ function checkDepWidth(el) {
336
+ const limit = options.wideDeps;
337
+ if (limit === false) return;
338
+ let count = 0;
339
+ const names = [];
340
+ for (let l = el._deps; l !== null; l = l._nextDep) {
341
+ count++;
342
+ if (names.length < 12) names.push(nodeName(l._dep));
343
+ }
344
+ const node = el;
345
+ if (count < limit || count < (node._devWideWarnedAt ?? 0) * 1.5) return;
346
+ node._devWideWarnedAt = count;
347
+ const kind = el._type ? "effect" : "memo";
348
+ const message =
349
+ `[WIDE_SCOPE_DEPS] ${kind} "${nodeName(el)}" is subscribed to ${count} sources — ` +
350
+ `it re-runs when any of them change. Narrow its reads or split it into smaller memos. ` +
351
+ `Sources: ${names.join(", ")}${count > names.length ? ", …" : ""}`;
352
+ emitDiagnostic({
353
+ code: "WIDE_SCOPE_DEPS",
354
+ kind: "perf",
355
+ severity: "warn",
356
+ message,
357
+ nodeName: nodeName(el),
358
+ data: { depCount: count, deps: names }
359
+ });
360
+ console.warn(message);
361
+ }
362
+ /**
363
+ * Hot-scope warning — flags a scope that re-ran more than `count` times
364
+ * inside one `windowMs` window. Warned once per window, with the most recent
365
+ * cause chain named so the leaking signal is identified in the message.
366
+ */
367
+ function checkHotRuns(el, event) {
368
+ const cfg = options.hotRuns;
369
+ if (cfg === false) return;
370
+ const node = el;
371
+ const now = Date.now();
372
+ if (node._devWinStart === undefined || now - node._devWinStart > cfg.windowMs) {
373
+ node._devWinStart = now;
374
+ node._devWinCount = 0;
375
+ node._devHotWarned = false;
376
+ }
377
+ node._devWinCount = (node._devWinCount ?? 0) + 1;
378
+ if (node._devHotWarned || node._devWinCount < cfg.count) return;
379
+ node._devHotWarned = true;
380
+ const rootCause = event.causes.map(c => `"${c.name}" (${c.kind})`).join(", ");
381
+ const message =
382
+ `[HOT_SCOPE_RERUNS] ${event.nodeKind} "${event.nodeName}" re-ran ${node._devWinCount} times ` +
383
+ `in ${Math.max(1, now - node._devWinStart)}ms — a hot signal is likely leaking into this ` +
384
+ `scope. Latest cause: ${rootCause || "(untracked pull)"}`;
385
+ emitDiagnostic({
386
+ code: "HOT_SCOPE_RERUNS",
387
+ kind: "perf",
388
+ severity: "warn",
389
+ message,
390
+ nodeName: event.nodeName,
391
+ data: {
392
+ runs: node._devWinCount,
393
+ windowMs: cfg.windowMs,
394
+ causes: event.causes.map(c => c.name)
395
+ }
396
+ });
397
+ console.warn(message);
398
+ }
399
+ /**
400
+ * Time-budget warning — the counterpart of checkHotRuns for the
401
+ * few-but-expensive scope: warns when one scope's summed self-time within a
402
+ * window exceeds the budget. Warned once per window.
403
+ */
404
+ function checkHotTime(el, event) {
405
+ const cfg = options.hotTime;
406
+ if (cfg === false) return;
407
+ const node = el;
408
+ const at = now();
409
+ if (node._devTimeWinStart === undefined || at - node._devTimeWinStart > cfg.windowMs) {
410
+ node._devTimeWinStart = at;
411
+ node._devTimeWinMs = 0;
412
+ node._devTimeWarned = false;
413
+ }
414
+ node._devTimeWinMs = (node._devTimeWinMs ?? 0) + event.selfMs;
415
+ if (node._devTimeWarned || node._devTimeWinMs < cfg.budgetMs) return;
416
+ node._devTimeWarned = true;
417
+ const rootCause = event.causes.map(c => `"${c.name}" (${c.kind})`).join(", ");
418
+ const message =
419
+ `[HOT_SCOPE_TIME] ${event.nodeKind} "${event.nodeName}" spent ` +
420
+ `${node._devTimeWinMs.toFixed(1)}ms of compute inside one ${cfg.windowMs}ms window ` +
421
+ `(budget ${cfg.budgetMs}ms). Latest cause: ${rootCause || "(untracked pull)"}`;
422
+ emitDiagnostic({
423
+ code: "HOT_SCOPE_TIME",
424
+ kind: "perf",
425
+ severity: "warn",
426
+ message,
427
+ nodeName: event.nodeName,
428
+ data: {
429
+ spentMs: node._devTimeWinMs,
430
+ budgetMs: cfg.budgetMs,
431
+ windowMs: cfg.windowMs,
432
+ causes: event.causes.map(c => c.name)
433
+ }
434
+ });
435
+ console.warn(message);
436
+ }
437
+ function recordRerun(el, causes, prevDeps, timing, changed, phase, held) {
438
+ const node = el;
439
+ // Subscription diff: `prevDeps` was captured at run entry; `_deps` now
440
+ // holds the fresh set. A changed set is the "helper edit changed distant
441
+ // call sites" signal — surfaced per-event and in the console format.
442
+ const newDeps = captureDeps(el);
443
+ const prevSet = new Set(prevDeps);
444
+ const newSet = new Set(newDeps);
445
+ const depsAdded = [];
446
+ const depsRemoved = [];
447
+ for (const d of newDeps) if (!prevSet.has(d)) depsAdded.push(nodeName(d));
448
+ for (const d of prevDeps) if (!newSet.has(d)) depsRemoved.push(nodeName(d));
449
+ const event = {
450
+ run: ++runSeq,
451
+ nodeRuns: (node._devRunCount = (node._devRunCount ?? 0) + 1),
452
+ nodeKind: el._type ? "effect" : "memo",
453
+ nodeName: nodeName(el),
454
+ node: el,
455
+ causes,
456
+ depCount: newDeps.length,
457
+ depsAdded,
458
+ depsRemoved,
459
+ selfMs: timing.selfMs,
460
+ totalMs: timing.totalMs,
461
+ changed,
462
+ phase,
463
+ held
464
+ };
465
+ history.push(event);
466
+ if (history.length > options.historyLimit) history.shift();
467
+ recordCosts(event);
468
+ checkHotRuns(el, event);
469
+ checkHotTime(el, event);
470
+ checkDepWidth(el);
471
+ for (const listener of listeners) listener(event);
472
+ if (options.log) console.log(formatRerun(event));
473
+ }
474
+ function formatCause(cause, depth, out) {
475
+ const pad = " ".repeat(depth + 1);
476
+ let line = `${pad}← ${cause.kind === "derived" ? "memo" : "signal"} "${cause.name}" ${cause.kind === "derived" ? "changed" : cause.kind} (#${cause.seq})`;
477
+ if (cause.prev !== undefined) line += ` ${cause.prev} → ${cause.value}`;
478
+ out.push(line);
479
+ if (cause.stack) for (const frame of cause.stack) out.push(`${pad} ${frame}`);
480
+ if (cause.causes && depth < 10) {
481
+ for (const upstream of cause.causes) formatCause(upstream, depth + 1, out);
482
+ }
483
+ }
484
+ function formatRerun(event) {
485
+ const out = [
486
+ `[why-run] ${event.nodeKind} "${event.nodeName}" ran (run ${event.nodeRuns}, ` +
487
+ `${event.selfMs.toFixed(2)}ms${event.changed ? "" : ", unchanged"}` +
488
+ `${event.phase === "plain" ? "" : `, ${event.phase}`}${event.held ? ", held" : ""})` +
489
+ (event.causes.length === 0 ? " — no tracked cause (pull or retry)" : "")
490
+ ];
491
+ for (const cause of event.causes) formatCause(cause, 0, out);
492
+ if (event.depsAdded.length > 0 || event.depsRemoved.length > 0) {
493
+ const delta = [
494
+ ...event.depsAdded.map(n => `+"${n}"`),
495
+ ...event.depsRemoved.map(n => `-"${n}"`)
496
+ ].join(" ");
497
+ out.push(` deps changed: ${delta} (${event.depCount} total)`);
498
+ }
499
+ return out.join("\n");
500
+ }
501
+ /**
502
+ * Values eligible for the unstable-output check: plain objects and arrays
503
+ * only. Promises, iterators, Dates, Maps, class instances etc. all have no
504
+ * (or unrepresentative) own enumerable keys, so a shallow compare would
505
+ * false-positive on them — a fresh Promise is a genuinely new value.
506
+ */
507
+ function isPlainShape(v) {
508
+ if (v === null || typeof v !== "object") return false;
509
+ if (Array.isArray(v)) return true;
510
+ const proto = Object.getPrototypeOf(v);
511
+ return proto === Object.prototype || proto === null;
512
+ }
513
+ /** Shallow structural equivalence, capped so hot paths stay cheap. */
514
+ const UNSTABLE_KEY_CAP = 64;
515
+ function shallowEquivalent(a, b) {
516
+ const aArr = Array.isArray(a);
517
+ if (aArr !== Array.isArray(b)) return false;
518
+ if (aArr) {
519
+ const arrA = a;
520
+ const arrB = b;
521
+ if (arrA.length !== arrB.length || arrA.length > UNSTABLE_KEY_CAP) return false;
522
+ for (let i = 0; i < arrA.length; i++) if (arrA[i] !== arrB[i]) return false;
523
+ return true;
524
+ }
525
+ const keys = Object.keys(a);
526
+ if (keys.length > UNSTABLE_KEY_CAP || keys.length !== Object.keys(b).length) return false;
527
+ for (const key of keys) {
528
+ if (!(key in b) || a[key] !== b[key]) return false;
529
+ }
530
+ return true;
531
+ }
532
+ /**
533
+ * Unstable-output warning — the fan-out amplifier signature: a memo whose
534
+ * committed value is referentially new but structurally identical run after
535
+ * run has an equality gate that never closes, so ALL its subscribers re-run
536
+ * on EVERY upstream change. Checked only on plain (non-overlay) changed runs;
537
+ * a genuinely different value (or a non-plain shape) resets the streak.
538
+ */
539
+ function checkUnstableOutput(el, prevValue, newValue) {
540
+ const limit = options.unstableMemos;
541
+ // typeof guard: an explicit `unstableMemos: undefined` in enable() options
542
+ // clobbers the default through the spread — treat any non-number as off.
543
+ if (typeof limit !== "number") return;
544
+ const node = el;
545
+ if (
546
+ prevValue === newValue || // paranoia: changed runs should never hit this
547
+ !isPlainShape(prevValue) ||
548
+ !isPlainShape(newValue) ||
549
+ !shallowEquivalent(prevValue, newValue)
550
+ ) {
551
+ node._devUnstableRuns = 0;
552
+ node._devUnstableWarned = false;
553
+ return;
554
+ }
555
+ node._devUnstableRuns = (node._devUnstableRuns ?? 0) + 1;
556
+ if (node._devUnstableWarned || node._devUnstableRuns < limit) return;
557
+ node._devUnstableWarned = true;
558
+ const shape = Array.isArray(newValue) ? "array" : "object";
559
+ const message =
560
+ `[UNSTABLE_MEMO_OUTPUT] memo "${nodeName(el)}" produced a new-but-equivalent ${shape} on ` +
561
+ `${node._devUnstableRuns} consecutive runs — its equality gate never closes, so every ` +
562
+ `subscriber re-runs on every upstream change. Return stable references or pass an ` +
563
+ `\`equals\` option.`;
564
+ emitDiagnostic({
565
+ code: "UNSTABLE_MEMO_OUTPUT",
566
+ kind: "perf",
567
+ severity: "warn",
568
+ message,
569
+ nodeName: nodeName(el),
570
+ data: { runs: node._devUnstableRuns, shape }
571
+ });
572
+ console.warn(message);
573
+ }
574
+ // The engine's implementation of the core's dev hook points. Installed by
575
+ // enable(), uninstalled by disable() — while uninstalled the core pays one
576
+ // null check per site and nothing else.
577
+ let asyncStartSeq = 0;
578
+ let asyncStartTime = 0;
579
+ let asyncStartValue;
580
+ const engineHooks = {
581
+ recomputeStart(el, create) {
582
+ frames.push({
583
+ start: now(),
584
+ childMs: 0,
585
+ causes: create ? null : collectCauses(el),
586
+ prevDeps: create ? null : captureDeps(el),
587
+ // Mirror recompute's own prev-value resolution: an earlier run in the
588
+ // same flush may still be holding in _pendingValue.
589
+ prevValue: el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value
590
+ });
591
+ },
592
+ derivedChanged(el) {
593
+ const frame = frames[frames.length - 1];
594
+ stampDerived(el, frame !== undefined && frame.causes !== null ? frame.causes : []);
595
+ },
596
+ recomputeEnd(el, _create, changed, optimistic, transition, held) {
597
+ const frame = frames.pop();
598
+ // enable() can land mid-recompute: no opening frame, nothing to report.
599
+ if (frame === undefined) return;
600
+ const totalMs = now() - frame.start;
601
+ if (frames.length > 0) frames[frames.length - 1].childMs += totalMs;
602
+ const selfMs = Math.max(0, totalMs - frame.childMs);
603
+ // Unstable-output check: memos only, non-create, plain runs with a
604
+ // committed change. The fresh value sits in `_pendingValue` for held
605
+ // plain-flush memo commits and in `_value` for direct ones. Overlay runs
606
+ // are excluded — an optimistic re-derive legitimately produces fresh
607
+ // equivalents while the lane settles.
608
+ if (frame.causes !== null && changed && !optimistic && !transition && !el._type)
609
+ checkUnstableOutput(
610
+ el,
611
+ frame.prevValue,
612
+ el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value
613
+ );
614
+ if (frame.causes !== null)
615
+ recordRerun(
616
+ el,
617
+ frame.causes,
618
+ frame.prevDeps,
619
+ { selfMs, totalMs },
620
+ changed,
621
+ optimistic ? "optimistic" : transition ? "transition" : "plain",
622
+ held
623
+ );
624
+ // Creation runs still get the wide-scope check: a memo can be born with
625
+ // its coarse-read problem already in place.
626
+ else checkDepWidth(el);
627
+ markSeen(el);
628
+ },
629
+ write(el, prev, value) {
630
+ stampWrite(el, "write", prev, value);
631
+ },
632
+ refreshed(el) {
633
+ stampWrite(el, "refresh");
634
+ },
635
+ asyncStart(el) {
636
+ asyncStartSeq = el._devChange?.seq ?? 0;
637
+ asyncStartTime = el._time;
638
+ asyncStartValue = el._value;
639
+ },
640
+ asyncEnd(el, prev, value, direct) {
641
+ if (direct) {
642
+ // Core calls this unconditionally (hook calls cannot live inside its
643
+ // try blocks — see attribution-hooks.ts), so committed-ness is detected
644
+ // here against the asyncStart snapshot: a direct commit moves `_value`
645
+ // (or `_time`, for a same-reference commit under `equals: false`), and
646
+ // a transition hold parks the value in `_pendingValue`. A landing the
647
+ // equality gate swallowed moves none of them and must leave no stamp.
648
+ const committed =
649
+ el._value !== asyncStartValue || el._time !== asyncStartTime || el._pendingValue === value;
650
+ if (committed) stampWrite(el, "async", prev === undefined ? NO_VALUES : prev, value);
651
+ return;
652
+ }
653
+ // Landed through setSignal: reclassify its "write" stamp as an async
654
+ // landing — but only if it actually stamped (the value changed) since
655
+ // asyncStart; a no-change landing must leave no fresh stamp behind.
656
+ const change = el._devChange;
657
+ if (change !== undefined && change.seq > asyncStartSeq && change.kind === "write")
658
+ stampWrite(el, "async", NO_VALUES, value);
659
+ }
660
+ };
661
+ const attribution = {
662
+ enable(opts) {
663
+ options = { ...defaultOptions, ...opts };
664
+ frames.length = 0;
665
+ scopeCosts.clear();
666
+ writeCosts.clear();
667
+ setAttributionHooks(engineHooks);
668
+ },
669
+ disable() {
670
+ listeners.clear();
671
+ history = [];
672
+ frames.length = 0;
673
+ scopeCosts.clear();
674
+ writeCosts.clear();
675
+ setAttributionHooks(null);
676
+ },
677
+ subscribe(listener) {
678
+ listeners.add(listener);
679
+ return () => listeners.delete(listener);
680
+ },
681
+ history() {
682
+ return history;
683
+ },
684
+ why(target) {
685
+ const node = target?.[$REFRESH] ?? target;
686
+ return history.filter(event => event.node === node);
687
+ },
688
+ subscriptions(target) {
689
+ const node = target?.[$REFRESH] ?? target;
690
+ const names = [];
691
+ for (let l = node?._deps ?? null; l !== null; l = l._nextDep) names.push(nodeName(l._dep));
692
+ return names;
693
+ },
694
+ costs() {
695
+ return {
696
+ scopes: [...scopeCosts.values()].sort((a, b) => b.selfMs - a.selfMs),
697
+ writes: [...writeCosts.values()].sort((a, b) => b.downstreamMs - a.downstreamMs)
698
+ };
699
+ },
700
+ format: formatRerun
701
+ };
702
+
703
+ /** First warning when a node's live edge count reaches this size. */
704
+ const GRAPH_SIZE_WARN_AT = 2000;
705
+ /** Repeat the warning at this interval after the first. */
706
+ const GRAPH_SIZE_WARN_EVERY = 500;
120
707
  const hooks = {};
121
708
  const diagnosticListeners = new Set();
122
709
  const diagnosticCaptures = new Set();
@@ -146,6 +733,12 @@ const diagnostics = {
146
733
  const DEV$1 = {
147
734
  hooks,
148
735
  diagnostics,
736
+ // Getter: attribution.ts imports emitDiagnostic back from this module,
737
+ // so when attribution.ts evaluates first the `attribution` binding is
738
+ // still uninitialized here — defer the read to access time.
739
+ get attribution() {
740
+ return attribution;
741
+ },
149
742
  getChildren,
150
743
  getSignals,
151
744
  getParent,
@@ -239,6 +832,64 @@ function getObservers(node) {
239
832
  }
240
833
  return observers;
241
834
  }
835
+ function shouldWarnGraphSize(count) {
836
+ return count >= GRAPH_SIZE_WARN_AT && (count - GRAPH_SIZE_WARN_AT) % GRAPH_SIZE_WARN_EVERY === 0;
837
+ }
838
+ /**
839
+ * DEV-only: bump live edge counts after a new graph link and warn when a
840
+ * node grows an unusually large fan-out (many subscribers on one source) or
841
+ * fan-in (many sources on one computation). Repeat-reads that `link()`
842
+ * dedupes never reach here. Always-on in dev — unlike the opt-in attribution
843
+ * engine, a graph-size pathology should surface without asking.
844
+ */
845
+ function noteGraphLink(dep, sub) {
846
+ const fanOut = (dep._subCount = (dep._subCount || 0) + 1);
847
+ const fanIn = (sub._depCount = (sub._depCount || 0) + 1);
848
+ if (shouldWarnGraphSize(fanOut)) {
849
+ const name = dep._name;
850
+ const message =
851
+ `[HUGE_FAN_OUT] ${name ? `Signal "${name}"` : "A signal"} has ${fanOut} subscribers. ` +
852
+ `Each will re-run when it changes. If many independent computations read the same value ` +
853
+ `(for example every row of a list comparing against one selected id), prefer a per-key ` +
854
+ `store or projection so only the items whose result flipped update.`;
855
+ emitDiagnostic({
856
+ code: "HUGE_FAN_OUT",
857
+ kind: "graph",
858
+ severity: "warn",
859
+ message,
860
+ nodeName: name,
861
+ ownerId: dep.id,
862
+ ownerName: name,
863
+ data: { count: fanOut }
864
+ });
865
+ console.warn(message);
866
+ }
867
+ if (shouldWarnGraphSize(fanIn)) {
868
+ const name = sub._name;
869
+ const message =
870
+ `[HUGE_FAN_IN] ${name ? `Computation "${name}"` : "A computation"} has ${fanIn} sources. ` +
871
+ `It will re-run when any of them change. Narrow the read or split the derivation so each ` +
872
+ `computation tracks only what it needs.`;
873
+ emitDiagnostic({
874
+ code: "HUGE_FAN_IN",
875
+ kind: "graph",
876
+ severity: "warn",
877
+ message,
878
+ nodeName: name,
879
+ ownerId: sub.id,
880
+ ownerName: name,
881
+ data: { count: fanIn }
882
+ });
883
+ console.warn(message);
884
+ }
885
+ }
886
+ /** DEV-only: drop live edge counts when a link is removed. */
887
+ function unnoteGraphLink(link) {
888
+ const dep = link._dep;
889
+ const sub = link._sub;
890
+ if (dep._subCount) dep._subCount--;
891
+ if (sub._depCount) sub._depCount--;
892
+ }
242
893
 
243
894
  function createAsyncReporters() {
244
895
  return new Map();
@@ -298,8 +949,9 @@ function getOrCreateLane(signal) {
298
949
  }
299
950
  // Detect parent lane: _parentSource chains from pendingSignal → pendingValueComputed → original.
300
951
  // The child lane should not merge with the parent lane.
301
- const parentSource = signal._parentSource;
302
- const parentLane = parentSource?._optimisticLane ? findLane(parentSource._optimisticLane) : null;
952
+ const parentSource = signal._x?._parentSource;
953
+ const parentOptLane = parentSource?._x?._optimisticLane;
954
+ const parentLane = parentOptLane ? findLane(parentOptLane) : null;
303
955
  lane = {
304
956
  _source: signal,
305
957
  _pendingAsync: new Set(),
@@ -316,8 +968,8 @@ function getOrCreateLane(signal) {
316
968
  // parent-child is a property of the nodes, not of write order — otherwise
317
969
  // the owner's write merges the companion's subscribers into this lane and
318
970
  // their effects wait on its async instead of flushing immediately.
319
- adoptCompanionLane(signal._pendingSignal, lane);
320
- adoptCompanionLane(signal._latestValueComputed, lane);
971
+ adoptCompanionLane(signal._x?._pendingSignal, lane);
972
+ adoptCompanionLane(signal._x?._latestValueComputed, lane);
321
973
  return lane;
322
974
  }
323
975
  function adoptCompanionLane(companion, parent) {
@@ -359,11 +1011,11 @@ function mergeLanes(lane1, lane2) {
359
1011
  * Resolve a node's lane: follow union-find chain, verify active, clear if stale.
360
1012
  */
361
1013
  function resolveLane(el) {
362
- const lane = el._optimisticLane;
1014
+ const lane = el._x?._optimisticLane;
363
1015
  if (!lane) return undefined;
364
1016
  const root = findLane(lane);
365
1017
  if (activeLanes.has(root)) return root;
366
- el._optimisticLane = undefined;
1018
+ if (el._x !== null) el._x._optimisticLane = undefined;
367
1019
  return undefined;
368
1020
  }
369
1021
  function resolveTransition(el) {
@@ -372,10 +1024,10 @@ function resolveTransition(el) {
372
1024
  // transactions (#2912) — the merged root's _transition would hand this
373
1025
  // node's override to whichever action wrote last through the shared
374
1026
  // reader. Chase merge chains; a dead owner settled through another path.
375
- if (hasActiveOverride$1(el) && el._overrideOwner) {
376
- const owner = (el._overrideOwner = currentTransition(el._overrideOwner));
1027
+ if (hasActiveOverride$1(el) && el._x?._overrideOwner) {
1028
+ const owner = (ext(el)._overrideOwner = currentTransition(el._x?._overrideOwner));
377
1029
  if (owner._done !== true) return owner;
378
- el._overrideOwner = null;
1030
+ if (el._x !== null) el._x._overrideOwner = null;
379
1031
  }
380
1032
  return resolveLane(el)?._transition ?? el._transition;
381
1033
  }
@@ -383,7 +1035,8 @@ function resolveTransition(el) {
383
1035
  * Check if a node has an active optimistic override.
384
1036
  */
385
1037
  function hasActiveOverride$1(el) {
386
- return !!(el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING);
1038
+ const x = el._x;
1039
+ return x !== null && x._overrideValue !== undefined && x._overrideValue !== NOT_PENDING;
387
1040
  }
388
1041
  /**
389
1042
  * Assign or merge a lane onto a node. At convergence points (node already has
@@ -391,13 +1044,14 @@ function hasActiveOverride$1(el) {
391
1044
  */
392
1045
  function assignOrMergeLane(el, sourceLane) {
393
1046
  const sourceRoot = findLane(sourceLane);
394
- const existing = el._optimisticLane;
1047
+ const existing = el._x?._optimisticLane;
395
1048
  if (existing) {
396
1049
  // If the subscriber's lane was merged into another lane, it's stale —
397
1050
  // replace it with the new source lane instead of following the merge chain
398
1051
  // (which would incorrectly merge the new lane into the old group)
399
1052
  if (existing._mergedInto) {
400
- el._optimisticLane = sourceLane;
1053
+ ext(el)._optimisticLane = sourceLane;
1054
+ el._config |= CONFIG_HAS_LANE;
401
1055
  return;
402
1056
  }
403
1057
  const existingRoot = findLane(existing);
@@ -406,14 +1060,16 @@ function assignOrMergeLane(el, sourceLane) {
406
1060
  // Parent-child lanes stay independent so isPending resolves without
407
1061
  // waiting for the parent's async. The child keeps ownership.
408
1062
  if (sourceRoot._parentLane && findLane(sourceRoot._parentLane) === existingRoot) {
409
- el._optimisticLane = sourceLane;
1063
+ ext(el)._optimisticLane = sourceLane;
1064
+ el._config |= CONFIG_HAS_LANE;
410
1065
  } else if (existingRoot._parentLane && findLane(existingRoot._parentLane) === sourceRoot);
411
1066
  else mergeLanes(sourceRoot, existingRoot);
412
1067
  }
413
1068
  return;
414
1069
  }
415
1070
  }
416
- el._optimisticLane = sourceLane;
1071
+ ext(el)._optimisticLane = sourceLane;
1072
+ el._config |= CONFIG_HAS_LANE;
417
1073
  }
418
1074
 
419
1075
  const transitions = new Set();
@@ -474,13 +1130,13 @@ function sweepTransientStoreNodes() {
474
1130
  continue;
475
1131
  }
476
1132
  if (node._pendingValue !== NOT_PENDING) continue;
477
- if (node._overrideValue !== undefined && node._overrideValue !== NOT_PENDING) continue;
1133
+ if (node._x?._overrideValue !== undefined && node._x?._overrideValue !== NOT_PENDING) continue;
478
1134
  // A live affects() mark keeps the node addressable: sweeping it would
479
1135
  // detach the refcount from the slot (a fresh probe would upsert a new,
480
1136
  // unmarked node for the same property).
481
- if (node._affectsCount) continue;
1137
+ if (node._x?._affectsCount) continue;
482
1138
  transientStoreNodes.delete(node);
483
- node._unobserved?.();
1139
+ node._x?._unobserved?.();
484
1140
  }
485
1141
  }
486
1142
  function resetUnhandledAsync() {
@@ -732,6 +1388,9 @@ class GlobalQueue extends Queue {
732
1388
  static _applyReask = null;
733
1389
  static _repollVerdicts = null;
734
1390
  static _witnessAffects = null;
1391
+ // Re-asks probes whose verdict was provisionally suppressed by a fresh read
1392
+ // of a held value, once the transaction gains an async blocker (#3028).
1393
+ static _wakeSuppressedProbes = null;
735
1394
  // Optimistic-engine hooks (wired by core/optimistic.ts via
736
1395
  // installOptimisticEngine(), called from verdict.ts / createOptimistic /
737
1396
  // createOptimisticStore — every module that can create optimistic state).
@@ -864,7 +1523,7 @@ class GlobalQueue extends Queue {
864
1523
  // Only track async if the boundary is propagating STATUS_PENDING (not caught by boundary)
865
1524
  if (mask & STATUS_PENDING) {
866
1525
  if (flags & STATUS_PENDING) {
867
- const actualError = error !== undefined ? error : node._error;
1526
+ const actualError = error !== undefined ? error : node._x?._error;
868
1527
  // A visibility-only mark notification (the affects() boundary
869
1528
  // channel) updates display state on its way up but must be invisible
870
1529
  // to completion accounting BY CONSTRUCTION: it never registers a
@@ -876,7 +1535,10 @@ class GlobalQueue extends Queue {
876
1535
  if (!reporters) activeTransition._asyncReporters.set(source, (reporters = new Set()));
877
1536
  const prevSize = reporters.size;
878
1537
  reporters.add(node);
879
- if (reporters.size !== prevSize) schedule();
1538
+ if (reporters.size !== prevSize) {
1539
+ schedule();
1540
+ GlobalQueue._wakeSuppressedProbes?.(activeTransition);
1541
+ }
880
1542
  }
881
1543
  if (_enforceLoadingBoundary) _hitUnhandledAsync = true;
882
1544
  }
@@ -939,32 +1601,60 @@ function queuePendingNode(node) {
939
1601
  // REACTIVE_REASK) so the hot notification loop skips the per-subscriber flag
940
1602
  // clear entirely in apps that never refresh.
941
1603
  let reaskArmed = false;
1604
+ /** §12d: bumped by every recompute and every new subscriber edge. A node's
1605
+ * staged-rewrite skip is sound only while NOTHING recomputed or linked since
1606
+ * its last notify — a mid-batch pull can clean a marked subscriber, and a
1607
+ * skipped re-write would leave it stale. */
1608
+ let notifyEpoch = 0;
1609
+ function bumpNotifyEpoch() {
1610
+ notifyEpoch++;
1611
+ }
942
1612
  function armReaskClear() {
943
1613
  reaskArmed = true;
944
1614
  }
945
1615
  function insertSubs(node, optimistic = false) {
1616
+ // §12d: stamp before walking — setSignal's staged-rewrite fast path skips
1617
+ // the next walk for this node while the epoch holds (marking is idempotent).
1618
+ node._notifiedAt = notifyEpoch;
946
1619
  // Get source lane: prefer node's own lane over current context
947
1620
  // This is important for isPending signals which need their own lane to flush immediately
948
- const sourceLane = node._optimisticLane || currentOptimisticLane;
949
- const hasSnapshot = node._snapshotValue !== undefined;
1621
+ // Presence bits gate the optional-slot probes (see constants.ts): one
1622
+ // masked read of the always-present _config instead of missing-property
1623
+ // lookups in the hottest notify loop. Bits are sticky — the field read
1624
+ // stays authoritative when a bit is set.
1625
+ const cfg = node._config;
1626
+ const sourceLane =
1627
+ (cfg & CONFIG_HAS_LANE ? node._x?._optimisticLane : undefined) || currentOptimisticLane;
1628
+ const hasSnapshot = (cfg & CONFIG_HAS_SNAPSHOT) !== 0 && node._x?._snapshotValue !== undefined;
950
1629
  const clearReask = reaskArmed;
951
1630
  for (let s = node._subs; s !== null; s = s._nextSub) {
1631
+ const sub = s._sub;
952
1632
  // A value-change notification is a new question for the subscriber: any
953
1633
  // pending re-ask mark (refresh) it carried is superseded.
954
- if (clearReask) s._sub._flags &= ~REACTIVE_REASK;
955
- if (hasSnapshot && s._sub._config & CONFIG_IN_SNAPSHOT_SCOPE) {
956
- s._sub._flags |= REACTIVE_SNAPSHOT_STALE;
1634
+ if (clearReask) sub._flags &= ~REACTIVE_REASK;
1635
+ // Missed-wake latch (#3037): this write is landing while the subscriber
1636
+ // is mid-recompute (a nested pull committing beneath its reads), and the
1637
+ // heap refuses RECOMPUTING nodes. A gen-current link means the pass
1638
+ // already validated this dep — the value it read is now stale — so latch
1639
+ // for recompute's tail to reschedule. Untouched links need no latch (the
1640
+ // pass either re-reads them fresh or trims them), and neither does the
1641
+ // tail link: it is the read IN FLIGHT — read() links before it pulls, so
1642
+ // this very commit is what that read returns.
1643
+ if (sub._flags & REACTIVE_RECOMPUTING_DEPS && s._gen === sub._depGen && s !== sub._depsTail)
1644
+ sub._flags |= REACTIVE_MISSED_WAKE;
1645
+ if (hasSnapshot && sub._config & CONFIG_IN_SNAPSHOT_SCOPE) {
1646
+ sub._flags |= REACTIVE_SNAPSHOT_STALE;
957
1647
  continue;
958
1648
  }
959
1649
  if (optimistic && sourceLane) {
960
- s._sub._flags |= REACTIVE_OPTIMISTIC_DIRTY;
961
- assignOrMergeLane(s._sub, sourceLane);
1650
+ sub._flags |= REACTIVE_OPTIMISTIC_DIRTY;
1651
+ assignOrMergeLane(sub, sourceLane);
962
1652
  } else if (optimistic) {
963
- s._sub._flags |= REACTIVE_OPTIMISTIC_DIRTY;
1653
+ sub._flags |= REACTIVE_OPTIMISTIC_DIRTY;
964
1654
  // No source lane means reversion - clear subscriber's lane so effects go to regular queue
965
- s._sub._optimisticLane = undefined;
1655
+ if (sub._x) sub._x._optimisticLane = undefined;
966
1656
  }
967
- enqueueSub(s._sub);
1657
+ enqueueSub(sub);
968
1658
  }
969
1659
  }
970
1660
  function commitPendingNode(n) {
@@ -974,7 +1664,7 @@ function commitPendingNode(n) {
974
1664
  n._value = n._pendingValue;
975
1665
  n._pendingValue = NOT_PENDING;
976
1666
  }
977
- if (n._pendingSignal || n._latestValueComputed) GlobalQueue._snapCompanions(n);
1667
+ if (n._config & CONFIG_HAS_COMPANIONS) GlobalQueue._snapCompanions(n);
978
1668
  return;
979
1669
  }
980
1670
  if (n._pendingValue !== NOT_PENDING) {
@@ -989,9 +1679,9 @@ function commitPendingNode(n) {
989
1679
  c._loading = false;
990
1680
  c._flags &= ~REACTIVE_MANUAL_WRITE;
991
1681
  if (!(c._statusFlags & STATUS_PENDING)) c._statusFlags &= ~STATUS_UNINITIALIZED;
992
- if (c._pendingFirstChild !== null || c._pendingDisposal !== null)
1682
+ if (c._x != null && (c._x._pendingFirstChild !== null || c._x._pendingDisposal !== null))
993
1683
  GlobalQueue._dispose(c, false, true);
994
- if (n._pendingSignal || n._latestValueComputed) GlobalQueue._snapCompanions(n);
1684
+ if (n._config & CONFIG_HAS_COMPANIONS) GlobalQueue._snapCompanions(n);
995
1685
  }
996
1686
  // Store commit hook (INTERNALS-STORE-STATE.md §3): installed by the store
997
1687
  // module at init (same treeshakeable pattern as _resolveOptimistic /
@@ -1145,18 +1835,18 @@ function runQueue$1(queue, type) {
1145
1835
  }
1146
1836
  function reporterBlocksSource(reporter, source) {
1147
1837
  if (reporter._flags & (REACTIVE_ZOMBIE | REACTIVE_DISPOSED)) return false;
1148
- if (reporter._pendingSources?.has(source)) return true;
1838
+ if (reporter._x?._pendingSources?.has(source)) return true;
1149
1839
  for (let dep = reporter._deps; dep; dep = dep._nextDep) {
1150
1840
  let current = dep._dep;
1151
1841
  while (current) {
1152
1842
  if (current === source || current._firewall === source) return true;
1153
- current = current._parentSource;
1843
+ current = current._x?._parentSource;
1154
1844
  }
1155
1845
  }
1156
1846
  return !!(
1157
1847
  reporter._statusFlags & STATUS_PENDING &&
1158
- reporter._error instanceof NotReadyError &&
1159
- reporter._error.source === source
1848
+ reporter._x?._error instanceof NotReadyError &&
1849
+ reporter._x?._error.source === source
1160
1850
  );
1161
1851
  }
1162
1852
  function transitionComplete(transition) {
@@ -1173,7 +1863,7 @@ function transitionComplete(transition) {
1173
1863
  reporters.delete(reporter);
1174
1864
  }
1175
1865
  if (!hasLive) transition._asyncReporters.delete(source);
1176
- else if (source._statusFlags & STATUS_PENDING && source._error?.source === source) {
1866
+ else if (source._statusFlags & STATUS_PENDING && source._x?._error?.source === source) {
1177
1867
  done = false;
1178
1868
  break;
1179
1869
  }
@@ -1238,6 +1928,9 @@ function actualInsertIntoHeap(n, heap) {
1238
1928
  }
1239
1929
  function insertIntoHeap(n, heap) {
1240
1930
  let flags = n._flags;
1931
+ // RECOMPUTING refusals are not always losses: a genuinely missed wake (a
1932
+ // write to a link this pass already validated) is latched link-side in
1933
+ // insertSubs as REACTIVE_MISSED_WAKE for recompute's tail (#3037).
1241
1934
  if (flags & (REACTIVE_IN_HEAP | REACTIVE_RECOMPUTING_DEPS | REACTIVE_MANUAL_WRITE)) return;
1242
1935
  if (flags & REACTIVE_CHECK) {
1243
1936
  n._flags = (flags & -4) | REACTIVE_DIRTY | REACTIVE_IN_HEAP;
@@ -1295,8 +1988,11 @@ function markNode(el, newState = REACTIVE_DIRTY) {
1295
1988
  for (let link = el._subs; link !== null; link = link._nextSub) {
1296
1989
  markNode(link._sub, REACTIVE_CHECK);
1297
1990
  }
1298
- if (el._child !== null) {
1299
- for (let child = el._child; child !== null; child = child._nextChild) {
1991
+ // Firewall children (projection machinery only): gate the cold-extension
1992
+ // deref on the config bit markNode runs per sub edge per write, and an
1993
+ // unconditional _x chase here taxed every propagation (diamond -22%).
1994
+ if (el._config & CONFIG_FW_CHILDREN) {
1995
+ for (let child = el._x._child; child !== null; child = child._nextChild) {
1300
1996
  for (let link = child._subs; link !== null; link = link._nextSub) {
1301
1997
  markNode(link._sub, REACTIVE_CHECK);
1302
1998
  }
@@ -1357,17 +2053,14 @@ function markDisposal(el) {
1357
2053
  }
1358
2054
  }
1359
2055
  function dispose(node) {
1360
- // Leave every scheduler heap on disposal (mirrors `unobserved`): a node
1361
- // still queued here would be recomputed by the next flush, and recompute()
1362
- // rewriting `_flags` would clear REACTIVE_DISPOSEDresurrecting it (#2983).
1363
- deleteFromHeap(node, queueFor(node));
1364
- let toRemove = node._deps;
1365
- while (toRemove !== null) {
1366
- toRemove = unlinkSubs(toRemove);
1367
- }
1368
- node._deps = null;
1369
- node._depsTail = null;
1370
- disposeChildren(node, true);
2056
+ // Direct disposal is death, not dormancy: strip the observation lifecycle
2057
+ // so a later read freezes at the last committed value instead of
2058
+ // reawakening the node (#3024). The teardown itself (heap removal a node
2059
+ // left queued would be recomputed and resurrected by the next flush (#2983)
2060
+ // dep unlinking, child disposal) is exactly unobserved()'s body; only
2061
+ // this flag distinguishes death from dormancy.
2062
+ node._config &= ~CONFIG_AUTO_DISPOSE;
2063
+ unobserved(node);
1371
2064
  }
1372
2065
  function disposeChildren(node, self = false, zombie) {
1373
2066
  const flags = node._flags;
@@ -1380,33 +2073,36 @@ function disposeChildren(node, self = false, zombie) {
1380
2073
  // edge). Snap runs after the DISPOSED flag is set so the oracle reads
1381
2074
  // false, and notifies subscribers still watching the companion.
1382
2075
  const n = node;
1383
- if (n._pendingSignal || n._latestValueComputed) GlobalQueue._snapCompanions(n);
2076
+ if (n._x?._pendingSignal || n._x?._latestValueComputed) GlobalQueue._snapCompanions(n);
1384
2077
  }
1385
2078
  if (self && true) clearSignals(node);
1386
- if (self && node._fn) node._inFlight = null;
1387
- let child = zombie ? node._pendingFirstChild : node._firstChild;
2079
+ if (self && node._fn && node._x !== null) node._x._inFlight = null;
2080
+ let child = zombie ? (node._x?._pendingFirstChild ?? null) : node._firstChild;
1388
2081
  while (child) {
1389
2082
  const nextChild = child._nextSibling;
1390
2083
  const n = child;
2084
+ // Owner teardown is death regardless of the child's own lifecycle
2085
+ // (#3024): strip AUTO_DISPOSE so a post-disposal read freezes at the
2086
+ // last committed value instead of reawakening in a torn-down tree.
2087
+ // Runs before the recursion so already-dormant children (whose
2088
+ // disposeChildren call early-returns on REACTIVE_DISPOSED) die too.
2089
+ // Only unobserved()'s own node keeps its dormancy — it is never in
2090
+ // this loop; its children are rebuilt fresh on reawaken.
2091
+ n._config &= ~CONFIG_AUTO_DISPOSE;
1391
2092
  // Heap removal must not be gated on `_deps`: a dependency-free
1392
2093
  // computation queued by refresh() has a null dep list but still sits in
1393
2094
  // the dirty heap, and left there the post-disposal flush recomputes it —
1394
2095
  // recompute() rewriting `_flags` clears REACTIVE_DISPOSED and the node
1395
2096
  // comes back to life (post-unmount runs, leaked cleanups, #2983).
1396
- if (n._flags & (REACTIVE_IN_HEAP | REACTIVE_IN_HEAP_HEIGHT)) deleteFromHeap(n, queueFor(n));
1397
- if (n._deps) {
1398
- let toRemove = n._deps;
1399
- do {
1400
- toRemove = unlinkSubs(toRemove);
1401
- } while (toRemove !== null);
1402
- n._deps = null;
1403
- n._depsTail = null;
1404
- }
2097
+ // deleteFromHeap self-guards on the in-heap flags (and tolerates plain
2098
+ // Owners, whose _flags is undefined), so no gate here.
2099
+ deleteFromHeap(n, queueFor(n));
2100
+ clearDeps(n);
1405
2101
  disposeChildren(child, true);
1406
2102
  child = nextChild;
1407
2103
  }
1408
2104
  if (zombie) {
1409
- node._pendingFirstChild = null;
2105
+ if (node._x !== null) node._x._pendingFirstChild = null;
1410
2106
  } else {
1411
2107
  node._firstChild = null;
1412
2108
  node._childCount = 0;
@@ -1439,7 +2135,7 @@ function disposeChildren(node, self = false, zombie) {
1439
2135
  }
1440
2136
  }
1441
2137
  function runDisposal(node, zombie) {
1442
- let disposal = zombie ? node._pendingDisposal : node._disposal;
2138
+ let disposal = zombie ? node._x?._pendingDisposal : node._disposal;
1443
2139
  if (!disposal) return;
1444
2140
  if (Array.isArray(disposal)) {
1445
2141
  for (let i = 0; i < disposal.length; i++) {
@@ -1449,7 +2145,9 @@ function runDisposal(node, zombie) {
1449
2145
  } else {
1450
2146
  disposal.call(disposal);
1451
2147
  }
1452
- zombie ? (node._pendingDisposal = null) : (node._disposal = null);
2148
+ if (zombie) {
2149
+ if (node._x !== null) node._x._pendingDisposal = null;
2150
+ } else node._disposal = null;
1453
2151
  }
1454
2152
  function childId(owner, consume) {
1455
2153
  let counter = owner;
@@ -1589,8 +2287,7 @@ function createOwner(options) {
1589
2287
  _queue: parent?._queue ?? globalQueue,
1590
2288
  _context: parent?._context || defaultContext,
1591
2289
  _childCount: 0,
1592
- _pendingDisposal: null,
1593
- _pendingFirstChild: null,
2290
+ _x: null,
1594
2291
  _parent: parent,
1595
2292
  dispose: disposeRootSelf
1596
2293
  };
@@ -1649,6 +2346,7 @@ function createRoot(init, options) {
1649
2346
 
1650
2347
  // https://github.com/stackblitz/alien-signals/blob/v2.0.3/src/system.ts#L100
1651
2348
  function unlinkSubs(link) {
2349
+ unnoteGraphLink(link);
1652
2350
  const dep = link._dep;
1653
2351
  const nextDep = link._nextDep;
1654
2352
  const nextSub = link._nextSub;
@@ -1659,7 +2357,7 @@ function unlinkSubs(link) {
1659
2357
  else {
1660
2358
  dep._subs = nextSub;
1661
2359
  if (nextSub === null) {
1662
- dep._unobserved?.();
2360
+ dep._x?._unobserved?.();
1663
2361
  // No more subscribers; only tear down if CONFIG_AUTO_DISPOSE is set.
1664
2362
  // A pending node is exempt: its in-flight async work (or the
1665
2363
  // transition holding it) is an observer — tearing down would orphan
@@ -1687,14 +2385,22 @@ function trimStaleDeps(el) {
1687
2385
  else el._deps = null;
1688
2386
  }
1689
2387
  }
1690
- function unobserved(el) {
1691
- deleteFromHeap(el, queueFor(el));
2388
+ // Shared by unobserved() and the disposeChildren child loop. The truthy guard
2389
+ // (not `!== null`) matters: plain Owners in a child chain have no _deps field,
2390
+ // and skipping early also avoids adding one (hidden-class churn) via the
2391
+ // null-out below.
2392
+ function clearDeps(el) {
1692
2393
  let dep = el._deps;
1693
- while (dep !== null) {
2394
+ if (!dep) return;
2395
+ do {
1694
2396
  dep = unlinkSubs(dep);
1695
- }
2397
+ } while (dep !== null);
1696
2398
  el._deps = null;
1697
2399
  el._depsTail = null;
2400
+ }
2401
+ function unobserved(el) {
2402
+ deleteFromHeap(el, queueFor(el));
2403
+ clearDeps(el);
1698
2404
  disposeChildren(el, true);
1699
2405
  }
1700
2406
  // https://github.com/stackblitz/alien-signals/blob/v2.0.3/src/system.ts#L52
@@ -1754,6 +2460,9 @@ function link(dep, sub, pendingObserver = false) {
1754
2460
  else sub._deps = newLink;
1755
2461
  if (prevSub !== null) prevSub._nextSub = newLink;
1756
2462
  else dep._subs = newLink;
2463
+ // New subscriber edge: staged-rewrite skips (§12d) must not miss it.
2464
+ bumpNotifyEpoch();
2465
+ noteGraphLink(dep, sub);
1757
2466
  }
1758
2467
 
1759
2468
  // The lazily-created Set is the ONE container for pending sources. Its
@@ -1762,18 +2471,18 @@ function link(dep, sub, pendingObserver = false) {
1762
2471
  // overlapping source landed beside the Set and removePendingSource refused
1763
2472
  // to clear it, stranding the Set members' pending forever (#2893).
1764
2473
  function addPendingSource(el, source) {
1765
- if (el._pendingSources?.has(source)) return false;
1766
- (el._pendingSources ??= new Set()).add(source);
2474
+ if (el._x?._pendingSources?.has(source)) return false;
2475
+ (ext(el)._pendingSources ??= new Set()).add(source);
1767
2476
  return true;
1768
2477
  }
1769
2478
  function removePendingSource(el, source) {
1770
- if (!el._pendingSources?.delete(source)) return false;
1771
- if (el._pendingSources.size === 0) el._pendingSources = undefined;
2479
+ if (!el._x?._pendingSources?.delete(source)) return false;
2480
+ if (el._x?._pendingSources.size === 0) if (el._x !== null) el._x._pendingSources = undefined;
1772
2481
  return true;
1773
2482
  }
1774
2483
  function clearPendingSources(el) {
1775
- el._pendingSources?.clear();
1776
- el._pendingSources = undefined;
2484
+ el._x?._pendingSources?.clear();
2485
+ if (el._x !== null) el._x._pendingSources = undefined;
1777
2486
  }
1778
2487
  // A rejection-pending only resolves through the settle sweep over the
1779
2488
  // SOURCE's subscribers, so it is retryable iff a tracked read created that
@@ -1783,7 +2492,7 @@ function clearPendingSources(el) {
1783
2492
  function retryReaches(el, source) {
1784
2493
  for (let d = el._deps; d; d = d._nextDep) {
1785
2494
  const dep = d._dep._firewall || d._dep;
1786
- if (dep === source || dep._pendingSources?.has(source)) return true;
2495
+ if (dep === source || dep._x?._pendingSources?.has(source)) return true;
1787
2496
  }
1788
2497
  return false;
1789
2498
  }
@@ -1795,7 +2504,7 @@ function retryReaches(el, source) {
1795
2504
  * transition, no lane registration. Commit #0 keeps serving.
1796
2505
  */
1797
2506
  function parkLoadingWindow(el, e) {
1798
- el._blocked = true;
2507
+ ext(el)._blocked = true;
1799
2508
  if (e.source) addPendingSource(el, e.source);
1800
2509
  // A settled error is the node's answer ("the error stays the answer until
1801
2510
  // this retry can actually run") — the park must not replace it: reads
@@ -1805,22 +2514,22 @@ function parkLoadingWindow(el, e) {
1805
2514
  }
1806
2515
  function setPendingError(el, source, error) {
1807
2516
  if (!source) {
1808
- el._error = null;
2517
+ if (el._x !== null) el._x._error = null;
1809
2518
  return;
1810
2519
  }
1811
2520
  if (error instanceof NotReadyError && error.source === source) {
1812
- el._error = error;
2521
+ ext(el)._error = error;
1813
2522
  return;
1814
2523
  }
1815
- const current = el._error;
2524
+ const current = el._x?._error;
1816
2525
  if (!(current instanceof NotReadyError) || current.source !== source) {
1817
- el._error = new NotReadyError(source);
2526
+ ext(el)._error = new NotReadyError(source);
1818
2527
  }
1819
2528
  }
1820
2529
  function forEachDependent(el, fn) {
1821
2530
  for (let s = el._subs; s !== null; s = s._nextSub) fn(s._sub, s);
1822
2531
  // `?? null`: affects() marks route plain signals (no `_child` slot) through here.
1823
- for (let child = el._child ?? null; child !== null; child = child._nextChild) {
2532
+ for (let child = el._x?._child ?? null; child !== null; child = child._nextChild) {
1824
2533
  for (let s = child._subs; s !== null; s = s._nextSub) fn(s._sub, s);
1825
2534
  }
1826
2535
  }
@@ -1879,7 +2588,7 @@ function settleErroredDependents(el, error) {
1879
2588
  const visit = node => {
1880
2589
  if (visited.has(node)) return;
1881
2590
  visited.add(node);
1882
- if (node._error === error) {
2591
+ if (node._x?._error === error) {
1883
2592
  enqueueSub(node);
1884
2593
  scheduled = true;
1885
2594
  }
@@ -1898,7 +2607,7 @@ function settlePendingSource(el) {
1898
2607
  if (visited.has(node) || !removePendingSource(node, el)) return;
1899
2608
  visited.add(node);
1900
2609
  node._time = clock;
1901
- const remaining = node._pendingSources?.values().next().value;
2610
+ const remaining = node._x?._pendingSources?.values().next().value;
1902
2611
  // STATUS_ERROR + pending sources only coexist via an errored loading
1903
2612
  // window's park (notifyStatus(STATUS_ERROR) clears pending sources
1904
2613
  // otherwise): the settled error stays the answer through the settle —
@@ -1912,11 +2621,11 @@ function settlePendingSource(el) {
1912
2621
  node._statusFlags &= ~STATUS_PENDING;
1913
2622
  if (!errored) setPendingError(node);
1914
2623
  updateCompanions !== null && updateCompanions(node);
1915
- if (node._blocked) {
2624
+ if (node._x?._blocked) {
1916
2625
  enqueueSub(node);
1917
2626
  scheduled = true;
1918
2627
  }
1919
- node._blocked = false;
2628
+ if (node._x !== null) node._x._blocked = false;
1920
2629
  // Fully settled with nobody watching: release candidate (#2934). Checked
1921
2630
  // again at release time — deferred so unobserved() can't unlink subs
1922
2631
  // lists this walk is still iterating.
@@ -1944,7 +2653,7 @@ function handleAsync(el, result, setter) {
1944
2653
  });
1945
2654
  }
1946
2655
  if (!thenable && !iterator) {
1947
- el._inFlight = null;
2656
+ if (el._x !== null) el._x._inFlight = null;
1948
2657
  // A sync landing is the first real answer for a loadingValue node.
1949
2658
  el._loading = false;
1950
2659
  return result;
@@ -1971,7 +2680,7 @@ function handleAsync(el, result, setter) {
1971
2680
  });
1972
2681
  throw new Error(message);
1973
2682
  }
1974
- el._inFlight = result;
2683
+ ext(el)._inFlight = result;
1975
2684
  let syncValue;
1976
2685
  // Settle-time transition re-entry. The loading rail is invisible to
1977
2686
  // transactions (#2933): a boundary-caught first load never registers as an
@@ -1998,7 +2707,7 @@ function handleAsync(el, result, setter) {
1998
2707
  globalQueue.initTransition(transition);
1999
2708
  };
2000
2709
  const handleError = error => {
2001
- if (el._inFlight !== result) return;
2710
+ if (el._x?._inFlight !== result) return;
2002
2711
  // NotReadyError from rejected promises should be treated as pending, not error
2003
2712
  let stillPending = error instanceof NotReadyError;
2004
2713
  // Dev-only authorship diagnostic (#2987): no edge means a post-`await`
@@ -2023,7 +2732,7 @@ function handleAsync(el, result, setter) {
2023
2732
  // serving commit #0 — same parking as recompute's catch for sync
2024
2733
  // dependency throws. The dead flight is released so the clock-gated
2025
2734
  // error-retry pull (updateIfNecessary) can also re-ask.
2026
- el._inFlight = null;
2735
+ if (el._x !== null) el._x._inFlight = null;
2027
2736
  parkLoadingWindow(el, error);
2028
2737
  el._time = clock;
2029
2738
  return;
@@ -2037,7 +2746,7 @@ function handleAsync(el, result, setter) {
2037
2746
  if (!stillPending) releaseSettledDependents(el);
2038
2747
  };
2039
2748
  const asyncWrite = (value, then) => {
2040
- if (el._inFlight !== result) return;
2749
+ if (el._x?._inFlight !== result) return;
2041
2750
  // If the node was dirtied by a newer write (optimistic override or regular),
2042
2751
  // skip this stale async result — the upcoming flush will recompute the node
2043
2752
  // with the new value, creating a fresh Promise that supersedes this one.
@@ -2048,10 +2757,14 @@ function handleAsync(el, result, setter) {
2048
2757
  clearStatus(el);
2049
2758
  const lane = resolveLane(el);
2050
2759
  if (lane) lane._pendingAsync.delete(el);
2760
+ // Attribution hook: lets the engine snapshot state before the landing
2761
+ // branches, so it can tell whether the plain path's setSignal committed a
2762
+ // change (and only then classify it as an async landing).
2763
+ if (attrHooks !== null) attrHooks.asyncStart(el);
2051
2764
  if (setter) {
2052
2765
  setter(value);
2053
2766
  if (wasUninitialized) clearStatus(el, true);
2054
- } else if (el._overrideValue !== undefined) {
2767
+ } else if (el._x?._overrideValue !== undefined) {
2055
2768
  // Optimistic node — resting OR covered by an active override — holds
2056
2769
  // through the shared pending-node path, exactly like a plain async memo,
2057
2770
  // so the commit clears STATUS_UNINITIALIZED (#2806) and elevation to
@@ -2071,7 +2784,10 @@ function handleAsync(el, result, setter) {
2071
2784
  // override every reader sees the override (A17), so waking subs would
2072
2785
  // re-show an unchanged view — the revert is the notification point.
2073
2786
  GlobalQueue._syncCompanions !== null && GlobalQueue._syncCompanions(el, value);
2074
- if (!hasActiveOverride$1(el)) insertSubs(el);
2787
+ if (!hasActiveOverride$1(el)) {
2788
+ if (attrHooks !== null) attrHooks.asyncEnd(el, undefined, value, true);
2789
+ insertSubs(el);
2790
+ }
2075
2791
  el._time = clock;
2076
2792
  } else if (lane) {
2077
2793
  // Route through lane's effect queue for independent flushing
@@ -2095,6 +2811,13 @@ function handleAsync(el, result, setter) {
2095
2811
  // rejection (#2837).
2096
2812
  notifyStatus(el, STATUS_ERROR, e);
2097
2813
  }
2814
+ // Attribution hook — unconditional, and OUTSIDE the try: rollup's
2815
+ // tryCatchDeoptimization retains anything referenced inside a try even
2816
+ // behind a folded true guard, so even a dev-only flag smuggled out of
2817
+ // the commit branch leaves prod residue (#2883). The engine instead
2818
+ // detects whether this landing committed by comparing the node against
2819
+ // its asyncStart snapshot (see attribution.ts).
2820
+ if (attrHooks !== null) attrHooks.asyncEnd(el, prevValue, value, true);
2098
2821
  } else {
2099
2822
  try {
2100
2823
  setSignal(el, () => value);
@@ -2103,6 +2826,11 @@ function handleAsync(el, result, setter) {
2103
2826
  // pre-commit failure here, and there is no user callsite to throw to.
2104
2827
  notifyStatus(el, STATUS_ERROR, e);
2105
2828
  }
2829
+ // Attribution hook: this path landed through setSignal, whose write
2830
+ // hook already saw any committed change — direct=false lets the engine
2831
+ // reclassify that write as an async landing iff it actually committed.
2832
+ // Outside the try (#2883 — see attribution-hooks.ts).
2833
+ if (attrHooks !== null) attrHooks.asyncEnd(el, undefined, value, false);
2106
2834
  }
2107
2835
  // First real answer landing: the window closes when the answer becomes
2108
2836
  // OBSERVABLE. A direct commit is observable now; a transition-held write
@@ -2187,7 +2915,7 @@ function handleAsync(el, result, setter) {
2187
2915
  syncResult = r;
2188
2916
  resolved = true;
2189
2917
  if (r.done) completed = true;
2190
- } else if (el._inFlight !== result) {
2918
+ } else if (el._x?._inFlight !== result) {
2191
2919
  return;
2192
2920
  } else if (!r.done) {
2193
2921
  hadValue = true;
@@ -2208,7 +2936,7 @@ function handleAsync(el, result, setter) {
2208
2936
  if (isSync && initialRead) {
2209
2937
  syncError = e;
2210
2938
  rejected = true;
2211
- } else if (el._inFlight === result) {
2939
+ } else if (el._x?._inFlight === result) {
2212
2940
  completed = true;
2213
2941
  handleError(e);
2214
2942
  settleAutodispose();
@@ -2282,7 +3010,7 @@ function handleAsync(el, result, setter) {
2282
3010
  syncValue = v;
2283
3011
  resolved = true;
2284
3012
  } else if (
2285
- el._inFlight === result &&
3013
+ el._x?._inFlight === result &&
2286
3014
  !(el._flags & REACTIVE_DISPOSED) &&
2287
3015
  flattenIfIterable(v, registerDeferredClose)
2288
3016
  );
@@ -2339,20 +3067,24 @@ function handleAsync(el, result, setter) {
2339
3067
  return syncValue;
2340
3068
  }
2341
3069
  function clearStatus(el, clearUninitialized = false) {
2342
- if (el._pendingSources) clearPendingSources(el);
2343
- if (el._blocked) el._blocked = false;
3070
+ if (el._x?._pendingSources) clearPendingSources(el);
3071
+ if (el._x?._blocked) if (el._x !== null) el._x._blocked = false;
2344
3072
  // The pending window is over; its quiet classification dies with it.
2345
3073
  // (Unconditional: _reask is baked into the node literals, so this is a
2346
3074
  // plain store to an existing slot — no shape change.)
2347
- el._reask = false;
3075
+ if (el._x !== null) el._x._reask = false;
2348
3076
  el._statusFlags = clearUninitialized ? 0 : el._statusFlags & STATUS_UNINITIALIZED;
2349
- if (el._error) setPendingError(el);
3077
+ if (el._x?._error) setPendingError(el);
2350
3078
  // Update pending signal for isPending() reactivity (companions only exist
2351
3079
  // once the verdict layer created them, which installs the hooks).
2352
- if (el._pendingSignal || el._latestValueComputed) GlobalQueue._updatePendingSignal(el);
2353
- if (el._child && GlobalQueue._updateChildCompanions !== null)
3080
+ if (el._x?._pendingSignal || el._x?._latestValueComputed) GlobalQueue._updatePendingSignal(el);
3081
+ if (
3082
+ el._x?._child &&
3083
+ el._config & CONFIG_CHILD_COMPANIONS &&
3084
+ GlobalQueue._updateChildCompanions !== null
3085
+ )
2354
3086
  GlobalQueue._updateChildCompanions(el);
2355
- if (el._notifyStatus) el._notifyStatus();
3087
+ if (el._x?._notifyStatus) el._x._notifyStatus.call(el);
2356
3088
  }
2357
3089
  function notifyStatus(el, status, error, blockStatus, lane) {
2358
3090
  // Wrap regular errors to track source node
@@ -2366,7 +3098,7 @@ function notifyStatus(el, status, error, blockStatus, lane) {
2366
3098
  status === STATUS_PENDING && error instanceof NotReadyError ? error.source : undefined;
2367
3099
  const isSource = pendingSource === el;
2368
3100
  const isOptimisticBoundary =
2369
- status === STATUS_PENDING && el._overrideValue !== undefined && !isSource;
3101
+ status === STATUS_PENDING && el._x?._overrideValue !== undefined && !isSource;
2370
3102
  const startsBlocking = isOptimisticBoundary && hasActiveOverride$1(el);
2371
3103
  if (!blockStatus) {
2372
3104
  if (status === STATUS_PENDING && pendingSource) {
@@ -2379,10 +3111,14 @@ function notifyStatus(el, status, error, blockStatus, lane) {
2379
3111
  clearPendingSources(el);
2380
3112
  el._statusFlags =
2381
3113
  status | (status !== STATUS_ERROR ? el._statusFlags & STATUS_UNINITIALIZED : 0);
2382
- el._error = error;
3114
+ ext(el)._error = error;
2383
3115
  }
2384
3116
  GlobalQueue._updatePendingSignal !== null && GlobalQueue._updatePendingSignal(el);
2385
- if (el._child && GlobalQueue._updateChildCompanions !== null)
3117
+ if (
3118
+ el._x?._child &&
3119
+ el._config & CONFIG_CHILD_COMPANIONS &&
3120
+ GlobalQueue._updateChildCompanions !== null
3121
+ )
2386
3122
  GlobalQueue._updateChildCompanions(el);
2387
3123
  }
2388
3124
  if (lane && !blockStatus) {
@@ -2390,22 +3126,24 @@ function notifyStatus(el, status, error, blockStatus, lane) {
2390
3126
  }
2391
3127
  const downstreamBlockStatus = blockStatus || startsBlocking;
2392
3128
  const downstreamLane = blockStatus || isOptimisticBoundary ? undefined : lane;
2393
- if (el._notifyStatus) {
3129
+ if (el._x?._notifyStatus) {
2394
3130
  if (blockStatus && status === STATUS_PENDING) {
2395
3131
  return;
2396
3132
  }
2397
3133
  if (downstreamBlockStatus) {
2398
- el._notifyStatus(status, error);
3134
+ el._x._notifyStatus.call(el, status, error);
2399
3135
  } else {
2400
- el._notifyStatus();
3136
+ el._x._notifyStatus.call(el);
2401
3137
  }
2402
3138
  return;
2403
3139
  }
2404
3140
  forEachDependent(el, (sub, link) => {
2405
3141
  sub._time = clock;
2406
3142
  if (
2407
- (status === STATUS_PENDING && pendingSource && !sub._pendingSources?.has(pendingSource)) ||
2408
- (status !== STATUS_PENDING && (sub._error !== error || sub._pendingSources))
3143
+ (status === STATUS_PENDING &&
3144
+ pendingSource &&
3145
+ !sub._x?._pendingSources?.has(pendingSource)) ||
3146
+ (status !== STATUS_PENDING && (sub._x?._error !== error || sub._x?._pendingSources))
2409
3147
  ) {
2410
3148
  // A pending-observer link is the subscription an `isPending` read created.
2411
3149
  // It exists so the observer re-runs when the source settles, but it must
@@ -2496,7 +3234,7 @@ function releaseSubtree(owner) {
2496
3234
  function clearSnapshots() {
2497
3235
  if (snapshotSources) {
2498
3236
  for (const source of snapshotSources) {
2499
- delete source._snapshotValue;
3237
+ delete source._x?._snapshotValue;
2500
3238
  // StoreNode targets share one pre-initialized hidden class (see
2501
3239
  // createStoreProxy) — assign undefined instead of deleting, and only
2502
3240
  // when present so signal-node sources don't grow the field.
@@ -2507,18 +3245,26 @@ function clearSnapshots() {
2507
3245
  snapshotCaptureActive = false;
2508
3246
  }
2509
3247
  function recompute(el, create = false) {
3248
+ // §12d: any recompute can clean a marked subscriber — invalidate skips.
3249
+ bumpNotifyEpoch();
2510
3250
  const isEffect = el._type;
3251
+ // Attribution hook: fired before this run touches the dep list — `_deps`
3252
+ // still holds the previous run's links (the subscriptions that could have
3253
+ // triggered this run, and the baseline for the engine's subscription diff).
3254
+ let devChanged = false;
3255
+ if (attrHooks !== null) attrHooks.recomputeStart(el, create);
2511
3256
  if (!create) {
2512
3257
  if (el._transition && (!isEffect || activeTransition) && activeTransition !== el._transition)
2513
3258
  globalQueue.initTransition(el._transition);
2514
3259
  deleteFromHeap(el, queueFor(el));
2515
- el._inFlight = null;
3260
+ if (el._x !== null) el._x._inFlight = null;
2516
3261
  // Tracked effects run after finalizePureQueue, so dispose immediately instead of deferring
2517
3262
  if (el._transition || isEffect === EFFECT_TRACKED) disposeChildren(el);
2518
3263
  else if (el._firstChild !== null || el._disposal !== null) {
2519
3264
  markDisposal(el);
2520
- el._pendingDisposal = el._disposal;
2521
- el._pendingFirstChild = el._firstChild;
3265
+ const x = ext(el);
3266
+ x._pendingDisposal = el._disposal;
3267
+ x._pendingFirstChild = el._firstChild;
2522
3268
  el._disposal = null;
2523
3269
  el._firstChild = null;
2524
3270
  el._childCount = 0;
@@ -2526,12 +3272,15 @@ function recompute(el, create = false) {
2526
3272
  } else clearSignals(el);
2527
3273
  }
2528
3274
  let isOptimisticDirty = !!(el._flags & REACTIVE_OPTIMISTIC_DIRTY);
2529
- const hasOverride = el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING;
3275
+ const hasOverride =
3276
+ (el._config & CONFIG_OPTIMISTIC) !== 0 &&
3277
+ el._x?._overrideValue !== NOT_PENDING &&
3278
+ el._x?._overrideValue !== undefined;
2530
3279
  const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED);
2531
3280
  // Outgoing error, captured before the compute clears status: if this run
2532
3281
  // recovers to an unchanged value, dependents still holding this object must
2533
3282
  // be swept (settleErroredDependents, #2949).
2534
- const outgoingError = el._statusFlags & STATUS_ERROR ? el._error : undefined;
3283
+ const outgoingError = el._statusFlags & STATUS_ERROR ? el._x?._error : undefined;
2535
3284
  // Re-ask classification lives in the verdict module; capture the flag before
2536
3285
  // the recompute wipes _flags below.
2537
3286
  const hadReask = (el._flags & REACTIVE_REASK) !== 0;
@@ -2548,6 +3297,7 @@ function recompute(el, create = false) {
2548
3297
  el._time = clock;
2549
3298
  let value = el._pendingValue === NOT_PENDING ? el._value : el._pendingValue;
2550
3299
  let oldHeight = el._height;
3300
+ let missedWake = false;
2551
3301
  let prevTracking = tracking;
2552
3302
  let prevLane = currentOptimisticLane;
2553
3303
  let prevStrictRead = false;
@@ -2595,13 +3345,13 @@ function recompute(el, create = false) {
2595
3345
  // with a setter callback). In that case, the outer `handleAsync` call below would
2596
3346
  // clobber the fresh subscription, so we skip it and let the internally-registered
2597
3347
  // iteration drive updates.
2598
- const prevInFlight = el._inFlight;
3348
+ const prevInFlight = el._x?._inFlight;
2599
3349
  const fnResult = el._fn(value);
2600
3350
  const isAsyncResult = typeof fnResult === "object" && fnResult !== null;
2601
- const inFlightChanged = el._inFlight !== prevInFlight;
3351
+ const inFlightChanged = el._x?._inFlight !== prevInFlight;
2602
3352
  value = inFlightChanged || !isAsyncResult ? fnResult : handleAsync(el, fnResult);
2603
3353
  if (!inFlightChanged && !isAsyncResult) {
2604
- el._inFlight = null;
3354
+ if (el._x !== null) el._x._inFlight = null;
2605
3355
  // A sync (non-object) return is the first real answer; async-shaped
2606
3356
  // results clear inside handleAsync at their own landing points, and a
2607
3357
  // self-registered flight (inFlightChanged — projections) clears when
@@ -2609,23 +3359,14 @@ function recompute(el, create = false) {
2609
3359
  el._loading = false;
2610
3360
  }
2611
3361
  }
2612
- // On a status-free node clearStatus is a guaranteed no-op: every branch
2613
- // in its body is gated on one of these fields, and with _statusFlags === 0
2614
- // the flags write (create or not) stores the 0 already there.
2615
- if (
2616
- el._statusFlags !== 0 ||
2617
- el._notifyStatus !== undefined ||
2618
- el._error ||
2619
- el._reask ||
2620
- el._blocked ||
2621
- el._pendingSources !== undefined ||
2622
- el._pendingSignal !== undefined ||
2623
- el._latestValueComputed !== undefined ||
2624
- el._child !== null
2625
- )
2626
- clearStatus(el, create);
2627
- // _optimisticLane is only ever assigned by engine paths.
2628
- if (el._optimisticLane) GlobalQueue._laneAsyncSettled(el);
3362
+ // On a status-free node clearStatus is a guaranteed no-op: every field
3363
+ // its body gates on is either _statusFlags or lives in the cold
3364
+ // extension no extension, no status to clear. (_x from an unrelated
3365
+ // installer just makes clearStatus a cheap re-verified no-op.)
3366
+ if (el._statusFlags !== 0 || el._x !== null) clearStatus(el, create);
3367
+ // _optimisticLane is only ever assigned by engine paths (CONFIG_HAS_LANE
3368
+ // is their sticky presence mark).
3369
+ if (el._config & CONFIG_HAS_LANE && el._x?._optimisticLane) GlobalQueue._laneAsyncSettled(el);
2629
3370
  } catch (e) {
2630
3371
  const notReady = e instanceof NotReadyError;
2631
3372
  if (notReady && el._loading) {
@@ -2643,7 +3384,7 @@ function recompute(el, create = false) {
2643
3384
  if (notReady && currentOptimisticLane) GlobalQueue._laneAsyncPending(el);
2644
3385
  let reaskChanged = false;
2645
3386
  if (notReady) {
2646
- el._blocked = true;
3387
+ ext(el)._blocked = true;
2647
3388
  if (GlobalQueue._applyReask !== null) reaskChanged = GlobalQueue._applyReask(el, hadReask);
2648
3389
  }
2649
3390
  notifyStatus(
@@ -2651,7 +3392,7 @@ function recompute(el, create = false) {
2651
3392
  notReady ? STATUS_PENDING : STATUS_ERROR,
2652
3393
  e,
2653
3394
  undefined,
2654
- notReady ? el._optimisticLane : undefined
3395
+ notReady ? el._x?._optimisticLane : undefined
2655
3396
  );
2656
3397
  if (reaskChanged) GlobalQueue._repollVerdicts(el);
2657
3398
  }
@@ -2660,13 +3401,19 @@ function recompute(el, create = false) {
2660
3401
  latestReadActive = prevLatestRead;
2661
3402
  strictRead = prevStrictRead;
2662
3403
  if (isStaleEffect) stale = prevStale;
3404
+ // Consume the missed-wake latch (#3037, set by insertSubs): a dep write
3405
+ // landed beneath this pass on a link it had already validated. The wipe
3406
+ // below must not key off DIRTY/CHECK — the read-time pull protocol
3407
+ // (markNode(c) in read()) marks the running node as part of ordinary
3408
+ // bookkeeping, and those marks are correctly discarded here.
3409
+ missedWake = (el._flags & REACTIVE_MISSED_WAKE) !== 0;
2663
3410
  el._flags = REACTIVE_NONE | (create ? el._flags & REACTIVE_SNAPSHOT_STALE : 0);
2664
3411
  context = oldcontext;
2665
3412
  }
2666
- if (!el._error) {
3413
+ if (!el._x?._error) {
2667
3414
  trimStaleDeps(el);
2668
3415
  const compareValue = hasOverride
2669
- ? unwrapOverride(el._overrideValue)
3416
+ ? unwrapOverride(el._x?._overrideValue)
2670
3417
  : el._pendingValue === NOT_PENDING
2671
3418
  ? el._value
2672
3419
  : el._pendingValue;
@@ -2681,21 +3428,27 @@ function recompute(el, create = false) {
2681
3428
  // flush, bypassing every boundary and wedging the queue (#2837).
2682
3429
  notifyStatus(el, STATUS_ERROR, e);
2683
3430
  }
3431
+ // A committed derived change becomes a cause for this node's subscribers,
3432
+ // chaining their attribution through this node to the root write.
3433
+ if (attrHooks !== null) {
3434
+ devChanged = valueChanged && !el._x?._error;
3435
+ if (devChanged && !isEffect && !create) attrHooks.derivedChanged(el);
3436
+ }
2684
3437
  // Effects use `_equals: false` (no per-effect closure). The side effects that
2685
3438
  // the equals closure used to perform — flagging the effect dirty and enqueueing
2686
3439
  // its runner — happen here instead. `!create` matches the previous `initialized`
2687
3440
  // gate: the explicit recompute(node, true) inside effect() does not enqueue, so
2688
3441
  // effect() can call its runner synchronously for the first run.
2689
3442
  if (isEffect && valueChanged) {
2690
- el._modified = !el._error;
3443
+ el._modified = !el._x?._error;
2691
3444
  // Reuse one bound runner per effect — runEffect no-ops on a stale
2692
3445
  // `_modified`, so re-enqueueing the same function is harmless.
2693
3446
  if (!create)
2694
3447
  el._queue.enqueue(isEffect, (el._boundRunEffect ??= GlobalQueue._runEffect.bind(null, el)));
2695
3448
  }
2696
- if (el._error);
3449
+ if (el._x?._error);
2697
3450
  else if (valueChanged) {
2698
- const prevVisible = hasOverride ? el._overrideValue : undefined;
3451
+ const prevVisible = hasOverride ? el._x?._overrideValue : undefined;
2699
3452
  if (
2700
3453
  create ||
2701
3454
  // Plain sync flush (no transition on either side) commits effect
@@ -2704,6 +3457,13 @@ function recompute(el, create = false) {
2704
3457
  // paying it per effect on the plain path is pure overhead.
2705
3458
  (isEffect && (activeTransition !== el._transition || activeTransition === null)) ||
2706
3459
  isOptimisticDirty
3460
+ // NOTE (stage-3, 2026-08-21): a quiet-world MEMO direct-commit was
3461
+ // attempted here and REVERTED — memo staging is load-bearing beyond
3462
+ // transitions: mid-batch pulls (latest()/isPending()/read-triggered
3463
+ // recomputes before sources commit) must see the fresh value while
3464
+ // PLAIN reads stay committed until flush (#3009 purity). The pending
3465
+ // round-trip is that separation; it cannot be skipped on any path a
3466
+ // pull can reach.
2707
3467
  ) {
2708
3468
  el._value = value;
2709
3469
  // Lane-propagated correction: upstream data is fresh, correct the
@@ -2711,7 +3471,7 @@ function recompute(el, create = false) {
2711
3471
  // own reveal schedule; drop any superseded older hold so its queued
2712
3472
  // commit can't clobber the fresh value.
2713
3473
  if (hasOverride && isOptimisticDirty) {
2714
- el._overrideValue = value === undefined ? OVERRIDE_UNDEFINED : value;
3474
+ ext(el)._overrideValue = value === undefined ? OVERRIDE_UNDEFINED : value;
2715
3475
  el._pendingValue = NOT_PENDING;
2716
3476
  }
2717
3477
  } else {
@@ -2731,7 +3491,7 @@ function recompute(el, create = false) {
2731
3491
  // subscriber-less node has nothing to notify.
2732
3492
  if (
2733
3493
  el._subs !== null &&
2734
- (!hasOverride || isOptimisticDirty || el._overrideValue !== prevVisible)
3494
+ (!hasOverride || isOptimisticDirty || el._x?._overrideValue !== prevVisible)
2735
3495
  )
2736
3496
  insertSubs(el, isOptimisticDirty || hasOverride);
2737
3497
  } else if (hasOverride) {
@@ -2750,15 +3510,28 @@ function recompute(el, create = false) {
2750
3510
  // dependents still holding the propagated error consumed their dirty flag
2751
3511
  // in an errored run and may sit on stale commits (#2949). Changed-value
2752
3512
  // recoveries ride insertSubs above; a comparator throw re-errored the node
2753
- // (el._error re-set), so this only runs on a genuinely clean recovery.
2754
- if (outgoingError !== undefined && !valueChanged && !el._error)
3513
+ // (el._x?._error re-set), so this only runs on a genuinely clean recovery.
3514
+ if (outgoingError !== undefined && !valueChanged && !el._x?._error)
2755
3515
  settleErroredDependents(el, outgoingError);
2756
3516
  }
3517
+ // Attribution hook: fired before the lane restore so `currentOptimisticLane`
3518
+ // still reflects THIS run's posture. The facts distinguish an overlay
3519
+ // recompute (optimistic lane, transition replay, transition-held commit)
3520
+ // from a plain committed one — the engine must not blame overlay runs as
3521
+ // waste or double-count them against plain aggregates.
3522
+ if (attrHooks !== null)
3523
+ attrHooks.recomputeEnd(
3524
+ el,
3525
+ create,
3526
+ devChanged,
3527
+ isOptimisticDirty || currentOptimisticLane !== null,
3528
+ activeTransition !== null || el._transition !== null,
3529
+ el._pendingValue !== NOT_PENDING
3530
+ );
2757
3531
  currentOptimisticLane = prevLane;
2758
3532
  const needsPendingCommit =
2759
3533
  el._pendingValue !== NOT_PENDING ||
2760
- el._pendingFirstChild !== null ||
2761
- el._pendingDisposal !== null ||
3534
+ (el._x !== null && (el._x._pendingFirstChild !== null || el._x._pendingDisposal !== null)) ||
2762
3535
  (el._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)) !== 0;
2763
3536
  // Override-covered holds (hasOverride) always queue: their commit belongs
2764
3537
  // to their own transition's schedule (A18 re-rule) and is unobservable
@@ -2773,8 +3546,22 @@ function recompute(el, create = false) {
2773
3546
  isEffect &&
2774
3547
  activeTransition !== el._transition &&
2775
3548
  runInTransition(el._transition, () => recompute(el));
3549
+ // Missed-wake reschedule (see the finally above): values this pass read
3550
+ // before the nested commit are stale, so run again now that the heap will
3551
+ // accept the node. Equality gates stop same-value landings from cascading,
3552
+ // and a re-run only latches again if another nested commit changes a dep
3553
+ // beneath it — convergent unless deps genuinely keep changing.
3554
+ if (missedWake) {
3555
+ enqueueSub(el);
3556
+ schedule();
3557
+ }
2776
3558
  }
2777
3559
  function updateIfNecessary(el) {
3560
+ // Never re-enter a node that is currently computing: its dep bookkeeping
3561
+ // (_depsTail/_depGen) is live, and a nested recompute would corrupt it.
3562
+ // A mid-pass mark stays latched for recompute's own tail to reschedule
3563
+ // (#3037); readers meanwhile serve the values the pass has so far.
3564
+ if (el._flags & REACTIVE_RECOMPUTING_DEPS) return;
2778
3565
  if (el._flags & REACTIVE_CHECK) {
2779
3566
  for (let d = el._deps; d; d = d._nextDep) {
2780
3567
  const dep1 = d._dep;
@@ -2789,7 +3576,7 @@ function updateIfNecessary(el) {
2789
3576
  }
2790
3577
  if (
2791
3578
  el._flags & (REACTIVE_DIRTY | REACTIVE_OPTIMISTIC_DIRTY) ||
2792
- (el._error && el._time < clock && !el._inFlight)
3579
+ (el._x?._error && el._time < clock && !el._x?._inFlight)
2793
3580
  ) {
2794
3581
  recompute(el);
2795
3582
  }
@@ -2811,7 +3598,6 @@ function computed(fn, options) {
2811
3598
  (options?._noSnapshot ? CONFIG_NO_SNAPSHOT : 0) |
2812
3599
  (snapshotCaptureActive && ownerInSnapshotScope(context) ? CONFIG_IN_SNAPSHOT_SCOPE : 0),
2813
3600
  _equals: options?.equals != null ? options.equals : isEqual,
2814
- _unobserved: options?.unobserved,
2815
3601
  _disposal: null,
2816
3602
  _queue: context?._queue ?? globalQueue,
2817
3603
  _context: context?._context ?? defaultContext,
@@ -2819,7 +3605,6 @@ function computed(fn, options) {
2819
3605
  _fn: fn,
2820
3606
  _value: loading ? options.loadingValue : undefined,
2821
3607
  _height: 0,
2822
- _child: null,
2823
3608
  _nextHeap: undefined,
2824
3609
  _prevHeap: null,
2825
3610
  _deps: null,
@@ -2836,17 +3621,47 @@ function computed(fn, options) {
2836
3621
  _statusFlags: loading ? 0 : STATUS_UNINITIALIZED,
2837
3622
  _time: clock,
2838
3623
  _pendingValue: NOT_PENDING,
2839
- _pendingDisposal: null,
2840
- _pendingFirstChild: null,
2841
- _inFlight: null,
2842
3624
  _transition: null,
2843
- _reask: false,
2844
- _loading: loading
3625
+ _notifiedAt: -1,
3626
+ _loading: loading,
3627
+ // Cold machinery (async/transition/optimistic/verdict slots) lives one
3628
+ // hop away in the lazily-allocated extension — the core literal MUST
3629
+ // stay under V8's in-object boundary (§12: past ~39 fields every
3630
+ // allocation spills to a backing store and creation cost ~4x's).
3631
+ _x: null
2845
3632
  };
2846
3633
  self._name = options?.name ?? "computed";
3634
+ if (options?.unobserved) ext(self)._unobserved = options.unobserved;
2847
3635
  setupComputedNode(self, options);
2848
3636
  return self;
2849
3637
  }
3638
+ /** Lazily allocate a node's cold extension (ONE shape for signals and
3639
+ * computeds — `_x` access stays monomorphic). Installers write through
3640
+ * this; hot paths read `el._x?._field` gated by the _config presence bits.
3641
+ * Never call ext() just to store a field's default. */
3642
+ function ext(el) {
3643
+ return (el._x ??= {
3644
+ _overrideValue: undefined,
3645
+ _overrideOwner: undefined,
3646
+ _optimisticLane: undefined,
3647
+ _pendingSignal: undefined,
3648
+ _latestValueComputed: undefined,
3649
+ _parentSource: undefined,
3650
+ _affectsCount: 0,
3651
+ _inFlight: null,
3652
+ _error: undefined,
3653
+ _blocked: undefined,
3654
+ _pendingSources: undefined,
3655
+ _notifyStatus: undefined,
3656
+ _reask: false,
3657
+ _child: null,
3658
+ _unobserved: undefined,
3659
+ _snapshotValue: undefined,
3660
+ _pendingDisposal: null,
3661
+ _pendingFirstChild: null,
3662
+ _companionChildren: undefined
3663
+ });
3664
+ }
2850
3665
  /**
2851
3666
  * Build an Effect node with all effect-specific fields baked into a single object literal,
2852
3667
  * so V8 sees the full hidden class shape at construction time. Effects always run in lazy
@@ -2863,7 +3678,6 @@ function createEffectNode(fn, effectFn, errorFn, type, notifyStatus, options) {
2863
3678
  (options?.sync ? CONFIG_SYNC : 0) |
2864
3679
  (snapshotCaptureActive && ownerInSnapshotScope(context) ? CONFIG_IN_SNAPSHOT_SCOPE : 0),
2865
3680
  _equals: false,
2866
- _unobserved: options?.unobserved,
2867
3681
  _disposal: null,
2868
3682
  _queue: context?._queue ?? globalQueue,
2869
3683
  _context: context?._context ?? defaultContext,
@@ -2871,7 +3685,6 @@ function createEffectNode(fn, effectFn, errorFn, type, notifyStatus, options) {
2871
3685
  _fn: fn,
2872
3686
  _value: undefined,
2873
3687
  _height: 0,
2874
- _child: null,
2875
3688
  _nextHeap: undefined,
2876
3689
  _prevHeap: null,
2877
3690
  _deps: null,
@@ -2887,11 +3700,8 @@ function createEffectNode(fn, effectFn, errorFn, type, notifyStatus, options) {
2887
3700
  _statusFlags: STATUS_UNINITIALIZED,
2888
3701
  _time: clock,
2889
3702
  _pendingValue: NOT_PENDING,
2890
- _pendingDisposal: null,
2891
- _pendingFirstChild: null,
2892
- _inFlight: null,
2893
3703
  _transition: null,
2894
- _reask: false,
3704
+ _notifiedAt: -1,
2895
3705
  _loading: false,
2896
3706
  _modified: false,
2897
3707
  _prevValue: undefined,
@@ -2899,9 +3709,12 @@ function createEffectNode(fn, effectFn, errorFn, type, notifyStatus, options) {
2899
3709
  _errorFn: errorFn,
2900
3710
  _cleanup: undefined,
2901
3711
  _type: type,
2902
- _notifyStatus: notifyStatus
3712
+ _x: null
2903
3713
  };
2904
3714
  self._name = options?.name ?? "effect";
3715
+ // Boundary effects carry a status channel; most effects never touch _x.
3716
+ if (notifyStatus !== undefined) ext(self)._notifyStatus = notifyStatus;
3717
+ if (options?.unobserved) ext(self)._unobserved = options.unobserved;
2905
3718
  setupComputedNode(self, lazyOptions);
2906
3719
  return self;
2907
3720
  }
@@ -2936,7 +3749,8 @@ function setupComputedNode(self, options) {
2936
3749
  !options?.lazy && recompute(self, true);
2937
3750
  if (snapshotCaptureActive && !options?.lazy) {
2938
3751
  if (!(self._statusFlags & STATUS_PENDING) && !(self._config & CONFIG_NO_SNAPSHOT)) {
2939
- self._snapshotValue = self._value === undefined ? NO_SNAPSHOT : self._value;
3752
+ ext(self)._snapshotValue = self._value === undefined ? NO_SNAPSHOT : self._value;
3753
+ self._config |= CONFIG_HAS_SNAPSHOT;
2940
3754
  snapshotSources.add(self);
2941
3755
  }
2942
3756
  }
@@ -2947,38 +3761,52 @@ function signal(v, options, firewall = null) {
2947
3761
  _config:
2948
3762
  (options?.ownedWrite ? CONFIG_OWNED_WRITE : 0) |
2949
3763
  (options?._noSnapshot ? CONFIG_NO_SNAPSHOT : 0),
2950
- _unobserved: options?.unobserved,
2951
3764
  _value: v,
2952
3765
  _subs: null,
2953
3766
  _subsTail: null,
2954
3767
  _time: clock,
2955
3768
  _firewall: firewall,
2956
- _nextChild: firewall?._child || null,
2957
- _pendingValue: NOT_PENDING
3769
+ _nextChild: firewall?._x?._child || null,
3770
+ _pendingValue: NOT_PENDING,
3771
+ // Signal-literal diet (§12e): NO _time/_fn/_statusFlags slots. Stores
3772
+ // materialize one signal per touched leaf, so signal bytes are store
3773
+ // bytes. _time is write-only on signals (every read site is computed-
3774
+ // typed error-retry gating); _fn/_statusFlags read falsy-identically as
3775
+ // missing properties on the shared paths (undefined masks to 0).
3776
+ _transition: null,
3777
+ _notifiedAt: -1,
3778
+ _x: null
2958
3779
  };
2959
3780
  {
2960
3781
  s._name = options?.name ?? "signal";
2961
3782
  s._internal = !!firewall;
2962
3783
  }
2963
- firewall && (firewall._child = s);
3784
+ if (options?.unobserved) ext(s)._unobserved = options.unobserved;
3785
+ if (firewall) {
3786
+ ext(firewall)._child = s;
3787
+ firewall._config |= CONFIG_FW_CHILDREN;
3788
+ }
2964
3789
  if (
2965
3790
  snapshotCaptureActive &&
2966
3791
  !(s._config & CONFIG_NO_SNAPSHOT) &&
2967
3792
  !((firewall?._statusFlags ?? 0) & STATUS_PENDING)
2968
3793
  ) {
2969
- s._snapshotValue = v === undefined ? NO_SNAPSHOT : v;
3794
+ ext(s)._snapshotValue = v === undefined ? NO_SNAPSHOT : v;
3795
+ s._config |= CONFIG_HAS_SNAPSHOT;
2970
3796
  snapshotSources.add(s);
2971
3797
  }
2972
3798
  return s;
2973
3799
  }
2974
3800
  function optimisticSignal(v, options) {
2975
3801
  const s = signal(v, options);
2976
- s._overrideValue = NOT_PENDING;
3802
+ ext(s)._overrideValue = NOT_PENDING;
3803
+ s._config |= CONFIG_OPTIMISTIC;
2977
3804
  return s;
2978
3805
  }
2979
3806
  function optimisticComputed(fn, options) {
2980
3807
  const c = computed(fn, options);
2981
- c._overrideValue = NOT_PENDING;
3808
+ ext(c)._overrideValue = NOT_PENDING;
3809
+ c._config |= CONFIG_OPTIMISTIC;
2982
3810
  return c;
2983
3811
  }
2984
3812
  function isEqual(a, b) {
@@ -3041,7 +3869,13 @@ function prepareComputed(comp, refresh) {
3041
3869
  comp._flags &= ~REACTIVE_LAZY;
3042
3870
  recompute(comp, true);
3043
3871
  } else if (comp._flags & REACTIVE_DISPOSED) {
3044
- recompute(comp, true);
3872
+ // Two disposal lifecycles share the flag (#3024). Observation-lifecycle
3873
+ // nodes (CONFIG_AUTO_DISPOSE) are dormant — torn down by unobserved()
3874
+ // when the last subscriber left — and reads reawaken them; that is the
3875
+ // pay-for-use contract. Owner-lifecycle nodes are dead: recomputing would
3876
+ // re-run user code in a torn-down tree (and discard manual writes on
3877
+ // derived-writable signals), so reads return the last committed value.
3878
+ if (comp._config & CONFIG_AUTO_DISPOSE) recompute(comp, true);
3045
3879
  } else if (refresh) {
3046
3880
  updateIfNecessary(comp);
3047
3881
  }
@@ -3066,8 +3900,8 @@ function readNodeFast(el) {
3066
3900
  pendingCheckActive ||
3067
3901
  el._fn ||
3068
3902
  el._firewall ||
3069
- el._overrideValue !== undefined ||
3070
- el._snapshotValue !== undefined ||
3903
+ el._x?._overrideValue !== undefined ||
3904
+ el._x?._snapshotValue !== undefined ||
3071
3905
  activeTransition !== null ||
3072
3906
  currentOptimisticLane !== null ||
3073
3907
  snapshotCaptureActive ||
@@ -3107,8 +3941,8 @@ function read(el) {
3107
3941
  if (
3108
3942
  !computed._fn &&
3109
3943
  owner === el &&
3110
- el._overrideValue === undefined &&
3111
- el._snapshotValue === undefined &&
3944
+ el._x?._overrideValue === undefined &&
3945
+ el._x?._snapshotValue === undefined &&
3112
3946
  activeTransition === null &&
3113
3947
  currentOptimisticLane === null &&
3114
3948
  !snapshotCaptureActive &&
@@ -3168,13 +4002,23 @@ function read(el) {
3168
4002
  // active override throws.
3169
4003
  if (currentOptimisticLane === null || GlobalQueue._laneSuspends(owner)) {
3170
4004
  if (!tracking && el !== c) link(el, c);
3171
- throw owner._error;
4005
+ throw owner._x?._error;
3172
4006
  }
3173
- } else if (c && owner !== el && owner._statusFlags & STATUS_UNINITIALIZED) {
4007
+ } else if (c && owner._statusFlags & STATUS_UNINITIALIZED) {
4008
+ // A stale (render) reader of a node held pending in ANOTHER transition
4009
+ // normally keeps showing the node's committed value instead of
4010
+ // entangling the two transactions — but an uninitialized node has no
4011
+ // committed value to show. Suspend on it (firewall-backed store reads
4012
+ // always took this branch; plain memos now do too): the reader
4013
+ // registers as a reporter of that source, and its pending-node stamp
4014
+ // ties it to the active transaction, so the two transactions merge
4015
+ // when the source settles. Falling through served `undefined` as if
4016
+ // settled and stranded the reader outside both transactions, so it
4017
+ // never re-ran when either landed (#3043 port).
3174
4018
  if (!tracking && el !== c) link(el, c);
3175
- throw owner._error;
4019
+ throw owner._x?._error;
3176
4020
  } else if (!c && owner._statusFlags & STATUS_UNINITIALIZED) {
3177
- throw owner._error;
4021
+ throw owner._x?._error;
3178
4022
  }
3179
4023
  }
3180
4024
  // `owner` is the computed itself, or the firewall behind a store node —
@@ -3189,10 +4033,10 @@ function read(el) {
3189
4033
  if (tracking && !pendingCheckActive && owner._time < clock) {
3190
4034
  recompute(owner);
3191
4035
  return read(el);
3192
- } else throw owner._error;
4036
+ } else throw owner._x?._error;
3193
4037
  }
3194
4038
  if (snapshotCaptureActive && c && c._config & CONFIG_IN_SNAPSHOT_SCOPE) {
3195
- const sv = el._snapshotValue;
4039
+ const sv = el._x?._snapshotValue;
3196
4040
  if (sv !== undefined) {
3197
4041
  const snapshot = sv === NO_SNAPSHOT ? undefined : sv;
3198
4042
  const current = el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value;
@@ -3206,9 +4050,9 @@ function read(el) {
3206
4050
  ownerName: c?._name,
3207
4051
  nodeName: owner?._name
3208
4052
  });
3209
- if (el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING) {
4053
+ if (el._x?._overrideValue !== undefined && el._x?._overrideValue !== NOT_PENDING) {
3210
4054
  // A17: the override IS the value for every reader.
3211
- return unwrapOverride(el._overrideValue);
4055
+ return unwrapOverride(el._x?._overrideValue);
3212
4056
  }
3213
4057
  // Entanglement gate: a reader recomputing under an optimistic lane that reads
3214
4058
  // a pending mid-transition write sees the committed value. Projection-store
@@ -3300,9 +4144,10 @@ function setSignal(el, v) {
3300
4144
  globalQueue.initTransition(el._transition);
3301
4145
  // The optimistic write path lives with the engine: only optimisticSignal /
3302
4146
  // optimisticComputed callers and optimistic store nodes carry an
3303
- // _overrideValue slot, and every module that creates one installs the
3304
- // engine first.
3305
- if (el._overrideValue !== undefined && !projectionWriteActive)
4147
+ // _overrideValue slot (flagged by CONFIG_OPTIMISTIC a masked read of the
4148
+ // always-present config instead of a missing-property probe), and every
4149
+ // module that installs one installs the engine first.
4150
+ if (el._config & CONFIG_OPTIMISTIC && !projectionWriteActive)
3306
4151
  return GlobalQueue._optimisticWrite(el, v);
3307
4152
  const currentValue = el._pendingValue === NOT_PENDING ? el._value : el._pendingValue;
3308
4153
  if (typeof v === "function") v = v(currentValue);
@@ -3311,15 +4156,29 @@ function setSignal(el, v) {
3311
4156
  const valueChanged =
3312
4157
  !!(el._statusFlags & STATUS_UNINITIALIZED) || !el._equals || !el._equals(currentValue, v);
3313
4158
  if (!valueChanged) return v;
3314
- if (el._pendingValue === NOT_PENDING) queuePendingNode(el);
4159
+ // Attribution hook: this committed write is where a re-run chain begins.
4160
+ if (attrHooks !== null) attrHooks.write(el, currentValue, v);
4161
+ const wasStaged = el._pendingValue !== NOT_PENDING;
4162
+ if (!wasStaged) queuePendingNode(el);
3315
4163
  el._pendingValue = v;
3316
4164
  // syncCompanions only pokes _pendingSignal/_latestValueComputed — with
3317
4165
  // neither companion present the call is a guaranteed no-op (companions are
3318
- // only ever created, never removed, and creating one installs the hook).
3319
- (el._pendingSignal !== undefined || el._latestValueComputed !== undefined) &&
4166
+ // only ever created, never removed, and creating one installs the hook and
4167
+ // sets CONFIG_HAS_COMPANIONS one masked read replaces two optional-field
4168
+ // probes on every write).
4169
+ el._config & CONFIG_HAS_COMPANIONS &&
3320
4170
  GlobalQueue._syncCompanions !== null &&
3321
4171
  GlobalQueue._syncCompanions(el, v);
3322
- el._time = clock;
4172
+ // _time is a computed-only slot (§12e): writing it on a signal would fork
4173
+ // the lean shape. Every read site is computed-typed.
4174
+ if (el._fn !== undefined) el._time = clock;
4175
+ // Staged-rewrite fast path (§12d): a re-write to a node whose subscribers
4176
+ // were already walked — and where nothing has recomputed or linked since
4177
+ // (epoch) — re-stages the value and stops. The walk is idempotent (subs
4178
+ // marked, heap entries flag-guarded, effects queued once); lane and reask
4179
+ // contexts change what a walk MEANS, so they always walk.
4180
+ if (wasStaged && el._notifiedAt === notifyEpoch && currentOptimisticLane === null && !reaskArmed)
4181
+ return v;
3323
4182
  insertSubs(el);
3324
4183
  schedule();
3325
4184
  return v;
@@ -3338,6 +4197,7 @@ function suppressComputedRecompute(el) {
3338
4197
  schedule();
3339
4198
  }
3340
4199
  el._flags = (el._flags & -4) | REACTIVE_MANUAL_WRITE;
4200
+ el._manualWriteTime = clock;
3341
4201
  }
3342
4202
  /**
3343
4203
  * User-facing setter for the memo form of `createSignal(fn)`. Behaves like
@@ -3456,10 +4316,19 @@ function refresh(target) {
3456
4316
  });
3457
4317
  throw new Error(REACTIVE_WRITE_IN_OWNED_SCOPE_REFRESH_MESSAGE);
3458
4318
  }
3459
- if (
3460
- typeof node._fn === "function" &&
3461
- !(node._flags & (REACTIVE_DISPOSED | REACTIVE_MANUAL_WRITE))
3462
- ) {
4319
+ if (typeof node._fn === "function" && !(node._flags & REACTIVE_DISPOSED)) {
4320
+ if (node._flags & REACTIVE_MANUAL_WRITE) {
4321
+ // A manual write in the CURRENT tick wins over the refresh (#2692).
4322
+ // A mask stamped in an earlier tick only survives because a
4323
+ // transaction (action) is holding the pending drain open; there the
4324
+ // refresh is a later, explicit re-ask and lifts the mask — otherwise
4325
+ // any setStore early in an action silently swallows every refresh()
4326
+ // for the rest of the transaction (#3026).
4327
+ if (node._manualWriteTime === clock) return;
4328
+ node._flags &= ~REACTIVE_MANUAL_WRITE;
4329
+ // No REASK below: the batch carries a manual value change, so the
4330
+ // recompute is not a quiet re-ask of an unchanged question.
4331
+ }
3463
4332
  // A refresh with no value-change dirt already queued is a re-ask of the
3464
4333
  // same question: mark it so the recompute classifies any resulting
3465
4334
  // pending window as quiet (not pending). If the node is already dirty
@@ -3467,11 +4336,14 @@ function refresh(target) {
3467
4336
  // REACTIVE_IN_HEAP counts as dirt: insertSubs schedules subscribers by
3468
4337
  // heap insertion alone (no DIRTY/CHECK flag), so a same-batch value
3469
4338
  // change followed by refresh() must not be laundered into a quiet re-ask.
3470
- if (!(node._flags & (REACTIVE_DIRTY | REACTIVE_CHECK | REACTIVE_IN_HEAP))) {
4339
+ else if (!(node._flags & (REACTIVE_DIRTY | REACTIVE_CHECK | REACTIVE_IN_HEAP))) {
3471
4340
  node._flags |= REACTIVE_REASK;
3472
4341
  armReaskClear();
3473
4342
  }
3474
4343
  node._flags = (node._flags & ~REACTIVE_CHECK) | REACTIVE_DIRTY;
4344
+ // A refresh() self-invalidation is a root cause too — the target's next
4345
+ // run has no changed dep to point at, so it points here instead.
4346
+ if (attrHooks !== null) attrHooks.refreshed(node);
3475
4347
  insertIntoHeap(node, queueFor(node));
3476
4348
  schedule();
3477
4349
  }
@@ -3631,11 +4503,20 @@ function isUndefined(value) {
3631
4503
  */
3632
4504
  /** The optimistic half of setSignal, fired when `_overrideValue !== undefined`. */
3633
4505
  function optimisticWrite(el, v) {
3634
- const hasOverride = el._overrideValue !== NOT_PENDING;
3635
- const currentValue = hasOverride ? unwrapOverride(el._overrideValue) : el._value;
4506
+ const hasOverride = el._x?._overrideValue !== NOT_PENDING;
4507
+ const currentValue = hasOverride ? unwrapOverride(el._x?._overrideValue) : el._value;
3636
4508
  if (typeof v === "function") v = v(currentValue);
3637
4509
  const valueChanged =
3638
- !!(el._statusFlags & STATUS_UNINITIALIZED) || !el._equals || !el._equals(currentValue, v);
4510
+ !!(el._statusFlags & STATUS_UNINITIALIZED) ||
4511
+ // A dirty node's _value is stale (its queued recompute hasn't run — e.g.
4512
+ // a latest() shadow marked by the previous landing's companion snap), so
4513
+ // equality against it must not swallow the write. Without this, a sync
4514
+ // push returning the shadow to that stale value was dropped, the snap
4515
+ // recompute then committed the parent's old value, and the banner showed
4516
+ // the previous transition's target (#3041 follow-up).
4517
+ !!((el._flags ?? 0) & (REACTIVE_DIRTY | REACTIVE_CHECK)) ||
4518
+ !el._equals ||
4519
+ !el._equals(currentValue, v);
3639
4520
  if (!valueChanged) {
3640
4521
  // Same-value write with an active override still entangles the current
3641
4522
  // action's transition — the hold must outlast all overlapping actions.
@@ -3653,19 +4534,20 @@ function optimisticWrite(el, v) {
3653
4534
  // Stamp ownership on the node (post-merge, so entangled writers share the
3654
4535
  // joint root). resolveTransition prefers this over the lane's _transition,
3655
4536
  // which a shared subscriber can merge across transactions (#2912).
3656
- el._overrideOwner = activeTransition;
4537
+ ext(el)._overrideOwner = activeTransition;
3657
4538
  const lane = getOrCreateLane(el);
3658
- el._optimisticLane = lane;
4539
+ ext(el)._optimisticLane = lane;
4540
+ el._config |= CONFIG_HAS_LANE;
3659
4541
  // Literal undefined must not land raw: the slot doubles as the optimistic
3660
4542
  // brand, and erasing it makes the write invisible and routes follow-up
3661
4543
  // writes off the optimistic path into permanent commits (#2898).
3662
- el._overrideValue = v === undefined ? OVERRIDE_UNDEFINED : v;
4544
+ ext(el)._overrideValue = v === undefined ? OVERRIDE_UNDEFINED : v;
3663
4545
  // syncCompanions only pokes _pendingSignal/_latestValueComputed — with
3664
4546
  // neither companion present the call is a guaranteed no-op.
3665
- (el._pendingSignal !== undefined || el._latestValueComputed !== undefined) &&
4547
+ (el._x?._pendingSignal !== undefined || el._x?._latestValueComputed !== undefined) &&
3666
4548
  GlobalQueue._syncCompanions !== null &&
3667
4549
  GlobalQueue._syncCompanions(el, v);
3668
- el._time = clock;
4550
+ if (el._fn !== undefined) el._time = clock; // §12e: computed-only slot
3669
4551
  insertSubs(el, true);
3670
4552
  schedule();
3671
4553
  return v;
@@ -3682,7 +4564,7 @@ function transitionBlocked(transition) {
3682
4564
  hasActiveOverride$1(node) &&
3683
4565
  "_statusFlags" in node &&
3684
4566
  node._statusFlags & STATUS_PENDING &&
3685
- node._error instanceof NotReadyError
4567
+ node._x?._error instanceof NotReadyError
3686
4568
  ) {
3687
4569
  return true;
3688
4570
  }
@@ -3696,26 +4578,26 @@ function resolveOptimisticNodes(nodes) {
3696
4578
  const len = nodes.length;
3697
4579
  for (let i = 0; i < len; i++) {
3698
4580
  const node = nodes[i];
3699
- node._optimisticLane = undefined;
4581
+ if (node._x !== null) node._x._optimisticLane = undefined;
3700
4582
  // Revert is a pure drop: there is no revert target to commit —
3701
4583
  // override-covered authoritative values hold in _pendingValue and
3702
4584
  // elevate on their OWN transition's schedule (A18 as re-ruled 2026-07-07).
3703
4585
  if (!(node._statusFlags & STATUS_PENDING)) node._statusFlags &= ~STATUS_UNINITIALIZED;
3704
- const prevOverride = node._overrideValue;
3705
- node._overrideValue = NOT_PENDING;
4586
+ const prevOverride = node._x?._overrideValue;
4587
+ ext(node)._overrideValue = NOT_PENDING;
3706
4588
  if (prevOverride !== NOT_PENDING && node._value !== unwrapOverride(prevOverride))
3707
4589
  insertSubs(node, true);
3708
4590
  node._transition = null;
3709
- node._overrideOwner = null;
4591
+ if (node._x !== null) node._x._overrideOwner = null;
3710
4592
  }
3711
4593
  // Settlement checkpoint (#2838): companions caught in this batch (or owned
3712
4594
  // by a node in it) re-derive from committed state, so verdicts survive the
3713
4595
  // transition that produced them (A19 — pending is a property of the data).
3714
4596
  for (let i = 0; i < len; i++) {
3715
4597
  const node = nodes[i];
3716
- if (node._pendingSignal || node._latestValueComputed) GlobalQueue._snapCompanions(node);
3717
- const owner = node._parentSource;
3718
- if (owner && (owner._pendingSignal === node || owner._latestValueComputed === node))
4598
+ if (node._x?._pendingSignal || node._x?._latestValueComputed) GlobalQueue._snapCompanions(node);
4599
+ const owner = node._x?._parentSource;
4600
+ if (owner && (owner._x?._pendingSignal === node || owner._x?._latestValueComputed === node))
3719
4601
  GlobalQueue._snapCompanions(owner);
3720
4602
  }
3721
4603
  nodes.splice(0, len);
@@ -3746,7 +4628,8 @@ function cleanupCompletedLanes(completingTransition) {
3746
4628
  if (lane._effectQueues[0].length) runQueue(lane._effectQueues[0], EFFECT_RENDER);
3747
4629
  if (lane._effectQueues[1].length) runQueue(lane._effectQueues[1], EFFECT_USER);
3748
4630
  }
3749
- if (lane._source._optimisticLane === lane) lane._source._optimisticLane = undefined;
4631
+ if (lane._source._x?._optimisticLane === lane)
4632
+ if (lane._source._x !== null) lane._source._x._optimisticLane = undefined;
3750
4633
  lane._pendingAsync.clear();
3751
4634
  lane._effectQueues[0].length = 0;
3752
4635
  lane._effectQueues[1].length = 0;
@@ -3759,7 +4642,7 @@ function laneSuspends(owner) {
3759
4642
  // Per-lane suspension: only throw if in same lane as pending async
3760
4643
  // AND the node doesn't have an active override (overrides are the visible value,
3761
4644
  // downstream in the lane should read the override, not throw)
3762
- const pendingLane = owner._optimisticLane;
4645
+ const pendingLane = owner._x?._optimisticLane;
3763
4646
  if (!pendingLane) return false;
3764
4647
  return findLane(pendingLane) === findLane(currentOptimisticLane) && !hasActiveOverride$1(owner);
3765
4648
  }
@@ -3786,21 +4669,36 @@ function gatedRead(el, owner, c) {
3786
4669
  */
3787
4670
  function laneReadsCommitted(el, owner, c) {
3788
4671
  if (
3789
- el._overrideValue !== undefined ||
3790
- !!el._optimisticLane ||
4672
+ el._x?._overrideValue !== undefined ||
4673
+ !!el._x?._optimisticLane ||
3791
4674
  !!(owner._statusFlags & STATUS_PENDING)
3792
- )
4675
+ ) {
4676
+ // The committed view hides a staged in-flight value that will promote
4677
+ // silently (commitPendingNode never re-notifies). gatedRead records plain
4678
+ // signals for replay at commit; async memos are excluded from it by the
4679
+ // `_fn` check and reach here instead — a lane-assigned source whose async
4680
+ // already settled (laneAsyncSettled keeps _optimisticLane) served its
4681
+ // committed value to a reader that never re-ran after the landing, so a
4682
+ // pending-gated branch stayed one value behind permanently (#3041
4683
+ // follow-up). Record the reader under the same replay contract.
4684
+ if (el._pendingValue !== NOT_PENDING)
4685
+ (activeTransition ?? globalQueue._batch)._gatedSubs.add(c);
3793
4686
  return true;
3794
- if (owner === el && stale && c._parentSource !== el) {
3795
- // The committed view can hide a same-tick ambient write (a lane member —
3796
- // even just an isPending companion flip puts the reader "under a lane"
3797
- // against unrelated plain writes). With no transaction the write commits
3798
- // at THIS flush's end with no re-delivery, so record the reader for
3799
- // replay at commit — the same contract gatedRead provides when a
3800
- // transaction is active (#2963). If a transaction forms mid-flush,
3801
- // initTransition carries the recording over to it.
3802
- if (el._pendingValue !== NOT_PENDING && activeTransition === null)
3803
- globalQueue._batch._gatedSubs.add(c);
4687
+ }
4688
+ if (owner === el && stale && c._x?._parentSource !== el) {
4689
+ // The committed view can hide a staged write (a lane member even just
4690
+ // an isPending companion flip puts the reader "under a lane"). The
4691
+ // staged value commits with no re-delivery (commitPendingNode never
4692
+ // re-notifies), so record the reader for replay at commit — the same
4693
+ // contract gatedRead provides (#2963). gatedRead itself only covers
4694
+ // signal reads where the reading computed differs from the source; the
4695
+ // owner === el memo/self read lands here instead. With a transaction
4696
+ // active the staged value promotes silently at ITS landing, so record
4697
+ // into the transaction (#3041 follow-up: a pending-gated branch that
4698
+ // first read its async source during the landing flush stayed one value
4699
+ // behind permanently); with none, into the ambient batch.
4700
+ if (el._pendingValue !== NOT_PENDING)
4701
+ (activeTransition ?? globalQueue._batch)._gatedSubs.add(c);
3804
4702
  return true;
3805
4703
  }
3806
4704
  return false;
@@ -3827,10 +4725,10 @@ function recomputeLane(el, own) {
3827
4725
  !globalQueue._running &&
3828
4726
  !activeTransition &&
3829
4727
  !lane._transition &&
3830
- lane._source._parentSource !== undefined &&
3831
- el._overrideValue === undefined
4728
+ lane._source._x?._parentSource !== undefined &&
4729
+ el._x?._overrideValue === undefined
3832
4730
  ) {
3833
- el._optimisticLane = undefined;
4731
+ if (el._x !== null) el._x._optimisticLane = undefined;
3834
4732
  return false;
3835
4733
  }
3836
4734
  return lane;
@@ -3853,7 +4751,8 @@ function laneAsyncPending(el) {
3853
4751
  const lane = findLane(currentOptimisticLane);
3854
4752
  if (lane._source !== el) {
3855
4753
  lane._pendingAsync.add(el);
3856
- el._optimisticLane = lane;
4754
+ ext(el)._optimisticLane = lane;
4755
+ el._config |= CONFIG_HAS_LANE;
3857
4756
  GlobalQueue._updatePendingSignal !== null && GlobalQueue._updatePendingSignal(lane._source);
3858
4757
  }
3859
4758
  }
@@ -3902,18 +4801,43 @@ function installOptimisticEngine() {
3902
4801
  // same lanes, so the verdict layer brings the engine with it.
3903
4802
  installOptimisticEngine();
3904
4803
  let pendingProbe = null;
4804
+ /**
4805
+ * Probes whose verdict was suppressed by the fresh-read pairing rule while
4806
+ * the held write's fate was still undecided (see recordFreshRead /
4807
+ * wakeSuppressedProbes): held node → the wrapper computeds that probed it.
4808
+ * Entries die with the hold — the commit/revert snap clears them.
4809
+ */
4810
+ const suppressedProbes = new Map();
3905
4811
  /**
3906
4812
  * Get or create the pending signal for a node (lazy).
3907
4813
  * Used by isPending() to track pending state reactively.
3908
4814
  */
4815
+ /** #3038: register a companion-carrying firewall child on its firewall's
4816
+ * companion set and arm the post-recompute snap (CONFIG_CHILD_COMPANIONS is
4817
+ * the one-load gate at the call sites). The snap then iterates exactly the
4818
+ * children someone asked verdicts of — O(companions) — never the full
4819
+ * `_child` chain, which carries one node per materialized leaf (the
4820
+ * O(all-leaves-ever-read)-per-update pathology). Entries are permanent like
4821
+ * the companions themselves; a store with no leaf-level isPending()/latest()
4822
+ * reads never allocates the set or pays the walk. */
4823
+ function markFirewallChildCompanions(el) {
4824
+ const fw = el._firewall;
4825
+ if (!fw) return;
4826
+ fw._config |= CONFIG_CHILD_COMPANIONS;
4827
+ (ext(fw)._companionChildren ??= new Set()).add(el);
4828
+ }
3909
4829
  function getPendingSignal(el) {
3910
- if (!el._pendingSignal) {
4830
+ let ps = el._x?._pendingSignal;
4831
+ if (!ps) {
3911
4832
  // Start false, write true if pending - ensures reversion returns to false
3912
- el._pendingSignal = optimisticSignal(false, { ownedWrite: true });
3913
- el._pendingSignal._parentSource = el;
3914
- if (computePendingState(el)) setSignal(el._pendingSignal, true);
4833
+ ps = optimisticSignal(false, { ownedWrite: true });
4834
+ ext(el)._pendingSignal = ps;
4835
+ el._config |= CONFIG_HAS_COMPANIONS;
4836
+ markFirewallChildCompanions(el);
4837
+ ext(ps)._parentSource = el;
4838
+ if (computePendingState(el)) setSignal(ps, true);
3915
4839
  }
3916
- return el._pendingSignal;
4840
+ return ps;
3917
4841
  }
3918
4842
  function collectPendingSources(el) {
3919
4843
  if (!pendingProbe) return;
@@ -3942,7 +4866,7 @@ function witnessAffects(node) {
3942
4866
  * inherits the coverage it reports on.
3943
4867
  */
3944
4868
  function markWalk(el, seen) {
3945
- if (el._affectsCount) return true;
4869
+ if (el._x?._affectsCount) return true;
3946
4870
  // A real error outranks an inherited mark (A16/A24c): an errored node
3947
4871
  // answers probes with its error, not a coverage verdict, and coverage does
3948
4872
  // not flow through it — matching the rails' behavior, where propagation
@@ -3972,11 +4896,11 @@ function markCovered(el) {
3972
4896
  return activeAffectsMarks !== 0 && markWalk(el, new Set());
3973
4897
  }
3974
4898
  function quietPending(el) {
3975
- if (el._pendingSources) {
3976
- for (const source of el._pendingSources) if (!source._reask) return false;
4899
+ if (el._x?._pendingSources) {
4900
+ for (const source of el._x._pendingSources) if (!source._x?._reask) return false;
3977
4901
  return true;
3978
4902
  }
3979
- return el._reask;
4903
+ return el._x?._reask ?? false;
3980
4904
  }
3981
4905
  // NOTE: a loadingValue node's open loading window (_loading) is verdict-quiet
3982
4906
  // on purpose: commit #0 answers the question by declaration, so the window
@@ -4000,15 +4924,15 @@ function computePendingState(el) {
4000
4924
  // so the one walk covers direct marks, derivation, and companion chains.
4001
4925
  if (markCovered(el)) return true;
4002
4926
  const firewall = el._firewall;
4003
- if (el._parentSource) {
4004
- const parentNode = el._parentSource;
4927
+ if (el._x?._parentSource) {
4928
+ const parentNode = el._x?._parentSource;
4005
4929
  const parent = parentNode._firewall || parentNode;
4006
4930
  return newQuestionInFlight(parent);
4007
4931
  }
4008
4932
  if (firewall && el._pendingValue !== NOT_PENDING && !hasActiveOverride$1(el)) {
4009
4933
  return (
4010
4934
  !!(firewall._flags & REACTIVE_MANUAL_WRITE) ||
4011
- (!firewall._inFlight && !(firewall._statusFlags & STATUS_PENDING)) ||
4935
+ (!firewall._x?._inFlight && !(firewall._statusFlags & STATUS_PENDING)) ||
4012
4936
  (!!(firewall._statusFlags & STATUS_PENDING) && quietPending(firewall))
4013
4937
  );
4014
4938
  }
@@ -4022,25 +4946,25 @@ function computePendingState(el) {
4022
4946
  !comp._loading
4023
4947
  ) {
4024
4948
  if (hasActiveOverride$1(el))
4025
- return !el._equals || !el._equals(el._pendingValue, unwrapOverride(el._overrideValue));
4949
+ return !el._equals || !el._equals(el._pendingValue, unwrapOverride(el._x?._overrideValue));
4026
4950
  return true;
4027
4951
  }
4028
4952
  return newQuestionInFlight(comp);
4029
4953
  }
4030
4954
  function syncCompanions(el, value) {
4031
- if (el._pendingSignal) updatePendingSignal(el);
4032
- if (el._latestValueComputed) setSignal(el._latestValueComputed, value);
4955
+ if (el._x?._pendingSignal) updatePendingSignal(el);
4956
+ if (el._x?._latestValueComputed) setSignal(el._x?._latestValueComputed, value);
4033
4957
  }
4034
4958
  function updatePendingSignal(el) {
4035
- if (el._pendingSignal) {
4036
- setSignal(el._pendingSignal, computePendingState(el));
4959
+ if (el._x?._pendingSignal) {
4960
+ setSignal(el._x?._pendingSignal, computePendingState(el));
4037
4961
  }
4038
- if (el._latestValueComputed) updatePendingSignal(el._latestValueComputed);
4962
+ if (el._x?._latestValueComputed) updatePendingSignal(el._x?._latestValueComputed);
4039
4963
  }
4040
4964
  function updateChildCompanions(el) {
4041
- for (let child = el._child; child !== null; child = child._nextChild) {
4042
- if (child._pendingSignal || child._latestValueComputed) updatePendingSignal(child);
4043
- }
4965
+ const companions = el._x?._companionChildren;
4966
+ if (companions === undefined) return;
4967
+ for (const child of companions) updatePendingSignal(child);
4044
4968
  }
4045
4969
  /**
4046
4970
  * Re-derive every verdict companion downstream of `el` (subs + firewall
@@ -4057,30 +4981,65 @@ function repollDownstreamVerdicts(el, snap = false) {
4057
4981
  const visit = node => {
4058
4982
  if (visited.has(node)) return;
4059
4983
  visited.add(node);
4060
- if (node._pendingSignal || node._latestValueComputed) update(node);
4984
+ if (node._x?._pendingSignal || node._x?._latestValueComputed) update(node);
4061
4985
  for (let s = node._subs; s !== null; s = s._nextSub) visit(s._sub);
4062
- for (let child = node._child ?? null; child !== null; child = child._nextChild) {
4986
+ for (let child = node._x?._child ?? null; child !== null; child = child._nextChild) {
4063
4987
  visit(child);
4064
4988
  }
4065
4989
  };
4066
4990
  visit(el);
4067
4991
  }
4992
+ /**
4993
+ * The correction half of the provisional fresh-read suppression (see
4994
+ * collectPending): fired from the sanctioned async-registration site
4995
+ * (GlobalQueue.notify) when a transaction gains an in-flight async blocker.
4996
+ * Every probe that returned "not pending" purely because it read a held
4997
+ * value belonging to that transaction re-runs — its re-probe now sees the
4998
+ * live blocker through heldAwaitingAsync and lands the true verdict. The
4999
+ * wake mirrors a companion write's own notification (optimistic-dirty on the
5000
+ * companion's lane) so the corrected verdict commits and flushes immediately
5001
+ * instead of being held with the transaction it reports on.
5002
+ */
5003
+ function wakeSuppressedProbes(transition) {
5004
+ if (suppressedProbes.size === 0) return;
5005
+ let woke = false;
5006
+ for (const [node, probes] of suppressedProbes) {
5007
+ const nt = node._transition;
5008
+ const t = nt ? currentTransition(nt) : null;
5009
+ if (!t) {
5010
+ suppressedProbes.delete(node);
5011
+ continue;
5012
+ }
5013
+ if (t !== transition) continue;
5014
+ suppressedProbes.delete(node);
5015
+ const lane = node._x?._pendingSignal?._x?._optimisticLane;
5016
+ for (const p of probes) {
5017
+ if (p._flags & REACTIVE_DISPOSED) continue;
5018
+ p._flags |= REACTIVE_OPTIMISTIC_DIRTY;
5019
+ if (lane) assignOrMergeLane(p, lane);
5020
+ else if (p._x !== null) p._x._optimisticLane = undefined;
5021
+ enqueueSub(p);
5022
+ woke = true;
5023
+ }
5024
+ }
5025
+ if (woke) schedule();
5026
+ }
4068
5027
  function snapCompanionsToState(owner) {
4069
- const sig = owner._pendingSignal;
4070
- if (sig && (sig._overrideValue === undefined || sig._overrideValue === NOT_PENDING)) {
5028
+ suppressedProbes.size !== 0 && suppressedProbes.delete(owner);
5029
+ const sig = owner._x?._pendingSignal;
5030
+ if (sig && (sig._x?._overrideValue === undefined || sig._x?._overrideValue === NOT_PENDING)) {
4071
5031
  const pending = computePendingState(owner);
4072
5032
  if (sig._value !== pending || sig._pendingValue !== NOT_PENDING) {
4073
5033
  sig._value = pending;
4074
5034
  sig._pendingValue = NOT_PENDING;
4075
- sig._time = clock;
4076
5035
  insertSubs(sig);
4077
5036
  schedule();
4078
5037
  }
4079
5038
  }
4080
- const shadow = owner._latestValueComputed;
5039
+ const shadow = owner._x?._latestValueComputed;
4081
5040
  if (shadow && !(shadow._flags & REACTIVE_DISPOSED)) {
4082
5041
  if (
4083
- (shadow._overrideValue === undefined || shadow._overrideValue === NOT_PENDING) &&
5042
+ (shadow._x?._overrideValue === undefined || shadow._x?._overrideValue === NOT_PENDING) &&
4084
5043
  shadow._pendingValue === NOT_PENDING &&
4085
5044
  !Object.is(shadow._value, owner._value) &&
4086
5045
  !(shadow._flags & (REACTIVE_DIRTY | REACTIVE_CHECK))
@@ -4094,20 +5053,37 @@ function snapCompanionsToState(owner) {
4094
5053
  }
4095
5054
  }
4096
5055
  function getLatestValueComputed(el) {
4097
- if (!el._latestValueComputed) {
5056
+ let lvc = el._x?._latestValueComputed;
5057
+ // A shadow disposed while unobserved (its gated reader unmounted at a
5058
+ // landing) is a corpse: sync writes into it equality-swallow against its
5059
+ // frozen _value, and a later read revives it via recompute — clearing
5060
+ // DISPOSED and re-deriving from the committed view, so the banner showed
5061
+ // the previous transition's target (#3041 follow-up). Treat it as absent;
5062
+ // recreation backfills from the in-flight write below.
5063
+ if (lvc && lvc._flags & REACTIVE_DISPOSED) lvc = undefined;
5064
+ if (!lvc) {
4098
5065
  const prevPending = latestReadActive;
4099
5066
  setLatestReadActive(false);
4100
5067
  const prevCheck = pendingCheckActive;
4101
5068
  setPendingCheckActive(false);
4102
5069
  const prevContext = context;
4103
5070
  setContextInternal(null); // Detach from owner so it isn't disposed with effects
4104
- el._latestValueComputed = optimisticComputed(() => read(el));
4105
- el._latestValueComputed._parentSource = el; // Parent-child lane relationship
5071
+ lvc = optimisticComputed(() => read(el));
5072
+ ext(el)._latestValueComputed = lvc;
5073
+ el._config |= CONFIG_HAS_COMPANIONS;
5074
+ markFirewallChildCompanions(el);
5075
+ ext(lvc)._parentSource = el; // Parent-child lane relationship
5076
+ // Backfill an in-flight write (mirrors getPendingSignal): the companion is
5077
+ // created lazily, possibly after the write was processed — syncCompanions
5078
+ // only pushes into companions that already exist, so the first latest()
5079
+ // read inside a held transition showed the committed value (#3041).
5080
+ if (el._pendingValue !== NOT_PENDING && !hasActiveOverride$1(el))
5081
+ setSignal(lvc, el._pendingValue);
4106
5082
  setContextInternal(prevContext);
4107
5083
  setPendingCheckActive(prevCheck);
4108
5084
  setLatestReadActive(prevPending);
4109
5085
  }
4110
- return el._latestValueComputed;
5086
+ return lvc;
4111
5087
  }
4112
5088
  /** The latest()-mode read path, installed as GlobalQueue._latestRead. */
4113
5089
  function latestRead(el) {
@@ -4115,8 +5091,8 @@ function latestRead(el) {
4115
5091
  const prevPending = latestReadActive;
4116
5092
  setLatestReadActive(false);
4117
5093
  const visibleValue =
4118
- el._overrideValue !== undefined && el._overrideValue !== NOT_PENDING
4119
- ? unwrapOverride(el._overrideValue)
5094
+ el._x?._overrideValue !== undefined && el._x?._overrideValue !== NOT_PENDING
5095
+ ? unwrapOverride(el._x?._overrideValue)
4120
5096
  : el._value;
4121
5097
  let value;
4122
5098
  try {
@@ -4142,8 +5118,8 @@ function latestRead(el) {
4142
5118
  setLatestReadActive(prevPending);
4143
5119
  }
4144
5120
  if (pendingComputed._statusFlags & STATUS_PENDING) return visibleValue;
4145
- if (stale && currentOptimisticLane && pendingComputed._optimisticLane) {
4146
- const pcLane = findLane(pendingComputed._optimisticLane);
5121
+ if (stale && currentOptimisticLane && pendingComputed._x?._optimisticLane) {
5122
+ const pcLane = findLane(pendingComputed._x?._optimisticLane);
4147
5123
  const curLane = findLane(currentOptimisticLane);
4148
5124
  if (pcLane !== curLane && pcLane._pendingAsync.size > 0) {
4149
5125
  return visibleValue;
@@ -4169,21 +5145,47 @@ function pendingCheckRead(el, c, owner, firewall) {
4169
5145
  if (c && ownerStatus & STATUS_PENDING && ownerStatus & STATUS_UNINITIALIZED) {
4170
5146
  if (tracking && el !== c) link(el, c);
4171
5147
  setPendingCheckActive(true);
4172
- throw owner._error;
5148
+ throw owner._x?._error;
4173
5149
  }
4174
5150
  collectPendingSources(el);
4175
5151
  if (firewall) collectPendingSources(firewall);
4176
5152
  setPendingCheckActive(true);
4177
5153
  }
5154
+ /**
5155
+ * A held node whose transaction still has an async question in flight. The
5156
+ * probe's fresh-read pairing rule (#2831 — "a reader that sees the fresh
5157
+ * value must not also be told it is pending") only applies to LANDED answers
5158
+ * awaiting reveal; while the answer is still computing, the fresh value the
5159
+ * reader saw is an input, and pending remains the truth for every reader
5160
+ * (#3028).
5161
+ */
5162
+ function heldAwaitingAsync(el) {
5163
+ const et = el._transition;
5164
+ const t = et ? currentTransition(et) : null;
5165
+ if (!t || t._done) return false;
5166
+ for (const [source, reporters] of t._asyncReporters) {
5167
+ if (
5168
+ reporters.size &&
5169
+ source._statusFlags & STATUS_PENDING &&
5170
+ source._x?._error?.source === source
5171
+ )
5172
+ return true;
5173
+ }
5174
+ return false;
5175
+ }
4178
5176
  function recordFreshRead(el, value) {
4179
- if (pendingProbe !== null && el._pendingValue !== NOT_PENDING && value === el._pendingValue)
5177
+ if (pendingProbe !== null && el._pendingValue !== NOT_PENDING && value === el._pendingValue) {
5178
+ if (heldAwaitingAsync(el)) return;
4180
5179
  pendingProbe.freshReads.add(el);
5180
+ }
4181
5181
  }
4182
5182
  function applyReask(el, hadReask) {
4183
5183
  const wasPending = !!(el._statusFlags & STATUS_PENDING);
4184
- const isReask = hadReask && !(wasPending && !el._reask);
4185
- const changed = wasPending && el._reask !== isReask;
4186
- el._reask = isReask;
5184
+ const isReask = hadReask && !(wasPending && !el._x?._reask);
5185
+ const changed = wasPending && (el._x?._reask ?? false) !== isReask;
5186
+ // Allocation-free for the quiet case: false is the extension default.
5187
+ if (isReask) ext(el)._reask = true;
5188
+ else if (el._x !== null) el._x._reask = false;
4187
5189
  return changed;
4188
5190
  }
4189
5191
  function latest(fn) {
@@ -4202,7 +5204,8 @@ function isPending(fn) {
4202
5204
  const probe = (pendingProbe = {
4203
5205
  found: false,
4204
5206
  sources: new Set(),
4205
- freshReads: new Set()
5207
+ freshReads: new Set(),
5208
+ suppressed: []
4206
5209
  });
4207
5210
  const collectPending = () => {
4208
5211
  setPendingCheckActive(false);
@@ -4210,12 +5213,27 @@ function isPending(fn) {
4210
5213
  setStrictRead(false);
4211
5214
  try {
4212
5215
  probe.sources.forEach(source => {
4213
- if (read(getPendingSignal(source)) && !probe.freshReads.has(source)) probe.found = true;
5216
+ if (read(getPendingSignal(source))) {
5217
+ if (!probe.freshReads.has(source)) probe.found = true;
5218
+ else probe.suppressed.push(source);
5219
+ }
4214
5220
  });
4215
5221
  } finally {
4216
5222
  setStrictRead(prevStrictRead);
4217
5223
  setPendingCheckActive(true);
4218
5224
  }
5225
+ // A "not pending" verdict that exists only because this reader saw the
5226
+ // fresh held value is provisional: if the write turns out NOT to commit
5227
+ // this flush (a downstream async pends and holds it), the suppression was
5228
+ // wrong and the wrapper must re-ask (#3028). Remember who to wake — the
5229
+ // async registration (GlobalQueue.notify) triggers wakeSuppressedProbes.
5230
+ if (!probe.found && probe.suppressed.length && context && typeof context._fn === "function") {
5231
+ for (const source of probe.suppressed) {
5232
+ let probes = suppressedProbes.get(source);
5233
+ if (!probes) suppressedProbes.set(source, (probes = new Set()));
5234
+ probes.add(context);
5235
+ }
5236
+ }
4219
5237
  };
4220
5238
  try {
4221
5239
  fn();
@@ -4247,6 +5265,7 @@ GlobalQueue._recordFresh = recordFreshRead;
4247
5265
  GlobalQueue._applyReask = applyReask;
4248
5266
  GlobalQueue._repollVerdicts = repollDownstreamVerdicts;
4249
5267
  GlobalQueue._witnessAffects = witnessAffects;
5268
+ GlobalQueue._wakeSuppressedProbes = wakeSuppressedProbes;
4250
5269
 
4251
5270
  /**
4252
5271
  * Effects are the leaf nodes of our reactive graph. When their sources change, they are
@@ -4286,7 +5305,7 @@ function effect(compute, effect, error, options) {
4286
5305
  function notifyEffectStatus(status, error) {
4287
5306
  // Use passed values if provided, otherwise read from node
4288
5307
  const actualStatus = status !== undefined ? status : this._statusFlags;
4289
- const actualError = error !== undefined ? error : this._error;
5308
+ const actualError = error !== undefined ? error : this._x?._error;
4290
5309
  if (actualStatus & STATUS_ERROR) {
4291
5310
  this._queue.notify(this, STATUS_PENDING, 0);
4292
5311
  if (this._type === EFFECT_USER) {
@@ -4345,7 +5364,7 @@ function runEffect(node) {
4345
5364
  // notifyEffectStatus, and a runner queued by an earlier valueChanged in the
4346
5365
  // same flush must not be hijacked by a later-arriving error status.
4347
5366
  if (node._statusFlags & STATUS_ERROR && node._type === EFFECT_USER) {
4348
- const err = unwrapStatusError(node._error);
5367
+ const err = unwrapStatusError(node._x?._error);
4349
5368
  node._prevValue = node._value;
4350
5369
  node._modified = false;
4351
5370
  try {
@@ -4382,7 +5401,7 @@ function runEffect(node) {
4382
5401
  // The final cleanup is invoked by disposeChildren at true disposal.
4383
5402
  node._cleanup = nextCleanup;
4384
5403
  } catch (error) {
4385
- node._error = new StatusError(node, error);
5404
+ ext(node)._error = new StatusError(node, error);
4386
5405
  node._statusFlags |= STATUS_ERROR;
4387
5406
  if (!node._queue.notify(node, STATUS_ERROR, STATUS_ERROR)) {
4388
5407
  haltReactivity(error);
@@ -4433,11 +5452,11 @@ function trackedEffect(fn, options) {
4433
5452
  node._config = (node._config & ~CONFIG_AUTO_DISPOSE) | CONFIG_CHILDREN_FORBIDDEN;
4434
5453
  node._modified = true;
4435
5454
  node._type = EFFECT_TRACKED;
4436
- node._notifyStatus = (status, error) => {
5455
+ ext(node)._notifyStatus = (status, error) => {
4437
5456
  const actualStatus = status !== undefined ? status : node._statusFlags;
4438
5457
  if (actualStatus & STATUS_ERROR) {
4439
5458
  node._queue.notify(node, STATUS_PENDING, 0);
4440
- const err = error !== undefined ? error : node._error;
5459
+ const err = error !== undefined ? error : node._x?._error;
4441
5460
  if (!node._queue.notify(node, STATUS_ERROR, STATUS_ERROR)) {
4442
5461
  haltReactivity(unwrapStatusError(err));
4443
5462
  throw err;
@@ -5207,7 +6226,7 @@ function inheritAffectsMarks(node, raw, property) {
5207
6226
  // A live scope exists, so affects.ts already installed the mark engine.
5208
6227
  for (const [carrier, entry] of affectsScopes) {
5209
6228
  if (
5210
- carrier._affectsCount &&
6229
+ carrier._x?._affectsCount &&
5211
6230
  entry.scope.has(raw) &&
5212
6231
  (entry.key === undefined || entry.key === property)
5213
6232
  ) {
@@ -5327,7 +6346,7 @@ function witnessAffectsMark(target, property) {
5327
6346
  // Callers guard on `pendingCheckActive`, which only flips inside
5328
6347
  // isPending() — the verdict layer is loaded and its hook installed.
5329
6348
  const own = target[STORE_NODE]?.[$AFFECTS];
5330
- if (own?._affectsCount) GlobalQueue._witnessAffects(own);
6349
+ if (own?._x?._affectsCount) GlobalQueue._witnessAffects(own);
5331
6350
  if (affectsScopes.size) {
5332
6351
  // Chained backings (§7b): a wrapper's STORE_VALUE can be another store's
5333
6352
  // proxy — marks cover by identity of the BASE raw, so resolve the chain
@@ -5336,7 +6355,7 @@ function witnessAffectsMark(target, property) {
5336
6355
  for (const [carrier, entry] of affectsScopes) {
5337
6356
  if (
5338
6357
  carrier !== own &&
5339
- carrier._affectsCount &&
6358
+ carrier._x?._affectsCount &&
5340
6359
  (entry.key === undefined || entry.key === property)
5341
6360
  ) {
5342
6361
  let r = raw;
@@ -5404,7 +6423,7 @@ function getStoreAffectsNodes(target, key) {
5404
6423
  * mid-window recomputes.
5405
6424
  */
5406
6425
  function markAffects(node) {
5407
- node._affectsCount = (node._affectsCount || 0) + 1;
6426
+ ext(node)._affectsCount = (node._x?._affectsCount || 0) + 1;
5408
6427
  shiftAffectsMarks(1);
5409
6428
  }
5410
6429
  /**
@@ -5420,7 +6439,7 @@ function markAffects(node) {
5420
6439
  * no visual change.
5421
6440
  */
5422
6441
  function notifyMarkBoundaries(node) {
5423
- if (!node._subs && !node._child) return;
6442
+ if (!node._subs && !node._x?._child) return;
5424
6443
  const error = new NotReadyError(node);
5425
6444
  error._markVisual = true;
5426
6445
  const visited = new Set();
@@ -5429,8 +6448,8 @@ function notifyMarkBoundaries(node) {
5429
6448
  visited.add(sub);
5430
6449
  // Display consumers (render effects, boundary computeds) act on the
5431
6450
  // notification; descent stops there, exactly like the status rails.
5432
- if (sub._notifyStatus) {
5433
- sub._notifyStatus(STATUS_PENDING, error);
6451
+ if (sub._x?._notifyStatus) {
6452
+ sub._x._notifyStatus.call(sub, STATUS_PENDING, error);
5434
6453
  return;
5435
6454
  }
5436
6455
  forEachDependent(sub, visit);
@@ -5463,8 +6482,8 @@ function registerAffectsMark(node) {
5463
6482
  */
5464
6483
  function releaseAffectsMark(node) {
5465
6484
  shiftAffectsMarks(-1);
5466
- node._affectsCount--;
5467
- if (!node._affectsCount) {
6485
+ node._x._affectsCount--;
6486
+ if (!node._x._affectsCount) {
5468
6487
  GlobalQueue._repollVerdicts !== null && GlobalQueue._repollVerdicts(node, true);
5469
6488
  GlobalQueue._releaseAffectsScope?.(node);
5470
6489
  }
@@ -5558,13 +6577,46 @@ function affects(target, key) {
5558
6577
  */
5559
6578
  // ---------------------------------------------------------------------------
5560
6579
  // wrap / dedupe
6580
+ /** Pre-shaped constructor for OBJECT proxy targets: V8 tips a bare `{}` into
6581
+ * dictionary mode once ~19 named properties are assigned onto it (the #3044
6582
+ * `ovl`/`del` fields crossed that line — every trap's field read became a
6583
+ * hash lookup, a 15% deep-dbmon tick regression). Declaring every field in a
6584
+ * constructor pre-allocates in-object slots so the map stays fast, with
6585
+ * headroom for future fields. The prototype is reset to `Object.prototype`
6586
+ * so proxy-forwarded semantics (getPrototypeOf, constructor) are exactly a
6587
+ * plain object's. Array targets keep the bare-`[]` path — they must carry
6588
+ * the array exotic class for `Array.isArray(proxy)`, and arrays store named
6589
+ * fields off-object where this cliff does not apply. */
6590
+ function TargetShape() {
6591
+ this.v = undefined;
6592
+ this.ch = undefined;
6593
+ this.pb = undefined;
6594
+ this.n = undefined;
6595
+ this.h = undefined;
6596
+ this.k = undefined;
6597
+ this.dk = undefined;
6598
+ this.u = undefined;
6599
+ this.pk = undefined;
6600
+ this.px = undefined;
6601
+ this.d = undefined;
6602
+ this.a = undefined;
6603
+ this.sc = undefined;
6604
+ this.nc = undefined;
6605
+ this.adopted = undefined;
6606
+ this.fam = undefined;
6607
+ this.s = undefined;
6608
+ this.ovl = undefined;
6609
+ this.del = undefined;
6610
+ this.wk = undefined;
6611
+ }
6612
+ TargetShape.prototype = Object.prototype;
5561
6613
  function createTarget(value, parent, parentKey, fam = parent?.fam ?? null) {
5562
6614
  // The proxy target carries the array exotic class when the value is an
5563
6615
  // array, so Array.isArray(proxy) is true; the fields live on it directly.
5564
6616
  // Direct field assignment in one fixed order (no Object.assign literal
5565
6617
  // copy): every target shares a hidden-class transition chain — createTarget
5566
6618
  // was the #2 store cost in the uibench creation profile.
5567
- const t = Array.isArray(value) ? [] : {};
6619
+ const t = Array.isArray(value) ? [] : new TargetShape();
5568
6620
  t.v = value;
5569
6621
  // Chained-backing flag (backing IS another store's proxy, §7b) — cached so
5570
6622
  // the hot read path never does a per-read symbol lookup on the backing.
@@ -5584,6 +6636,9 @@ function createTarget(value, parent, parentKey, fam = parent?.fam ?? null) {
5584
6636
  t.adopted = false;
5585
6637
  t.fam = fam;
5586
6638
  t.s = false;
6639
+ t.ovl = false;
6640
+ t.del = null;
6641
+ t.wk = null;
5587
6642
  t.px = new Proxy(t, traps);
5588
6643
  // Legacy interop: shared machinery (affects walks, wrap dedupe) reads the
5589
6644
  // proxy off looked-up targets as a field.
@@ -5610,7 +6665,13 @@ function wrapNext(value, parent = null, parentKey = null, fam = parent?.fam ?? n
5610
6665
  function unwrapValue(v) {
5611
6666
  if (v == null || typeof v !== "object") return v;
5612
6667
  const t = v[$TARGET];
5613
- if (t !== undefined && t.px === v && t.v !== undefined) return t.pb ?? t.v;
6668
+ if (t !== undefined && t.px === v && t.v !== undefined) {
6669
+ // A draft escaping into other storage must be a REAL container that
6670
+ // becomes this target's committed backing at fold (the shared-raw
6671
+ // contract) — a prototype overlay is neither.
6672
+ if (t.ovl) materializePB(t);
6673
+ return t.pb ?? t.v;
6674
+ }
5614
6675
  return v;
5615
6676
  }
5616
6677
  // ---------------------------------------------------------------------------
@@ -5622,13 +6683,19 @@ function getNode(target, key, current) {
5622
6683
  const created = (node = signal(
5623
6684
  current,
5624
6685
  {
6686
+ // Attribution-only: name store property nodes by path segment so
6687
+ // attribution chains and wide-scope warnings read "store.todos", not
6688
+ // "signal". Gated on the engine being installed — node creation is
6689
+ // the hottest store path, and the disabled cost must stay one null
6690
+ // check (nodes created before enable() stay generically named).
6691
+ name: attrHooks !== null ? "store." + String(key) : undefined,
5625
6692
  // Logical-slot equality: values resolving to the same child target
5626
6693
  // are the same slot (privatization/adoption swap raw identity without
5627
6694
  // changing the logical value — only changed leaves notify, R9).
5628
6695
  equals: (a, b) => isEqual(a, b) || sameLogicalSlot(target, a, b),
5629
6696
  unobserved() {
5630
6697
  // A live affects() mark keeps the node addressable (sweep parity).
5631
- if (created._affectsCount) return;
6698
+ if (created._x?._affectsCount) return;
5632
6699
  if (target.n && target.n[key] === created) {
5633
6700
  delete target.n[key];
5634
6701
  target.nc--;
@@ -5655,7 +6722,10 @@ function getNode(target, key, current) {
5655
6722
  created.pxv = undefined;
5656
6723
  // Optimistic families: arm the override slot — setSignal routes armed
5657
6724
  // nodes through the core engine (lanes, ownership, reverts all native).
5658
- if (target.fam?.opt) created._overrideValue = NOT_PENDING;
6725
+ if (target.fam?.opt) {
6726
+ ext(created)._overrideValue = NOT_PENDING;
6727
+ created._config |= CONFIG_OPTIMISTIC;
6728
+ }
5659
6729
  // A node born inside a live mark's identity scope inherits the mark
5660
6730
  // (the declaration walk could only cover nodes existing then).
5661
6731
  if (key !== $AFFECTS && affectsScopesLive()) inheritAffectsMarks(created, target.v, key);
@@ -5680,14 +6750,17 @@ function getHasNode(target, key, present) {
5680
6750
  {
5681
6751
  equals: isEqual,
5682
6752
  unobserved() {
5683
- if (created._affectsCount) return;
6753
+ if (created._x?._affectsCount) return;
5684
6754
  if (target.h && target.h[key] === created) delete target.h[key];
5685
6755
  }
5686
6756
  },
5687
6757
  target.fam?.node ?? undefined
5688
6758
  ));
5689
6759
  created._config |= CONFIG_OWNED_WRITE;
5690
- if (target.fam?.opt) created._overrideValue = NOT_PENDING;
6760
+ if (target.fam?.opt) {
6761
+ ext(created)._overrideValue = NOT_PENDING;
6762
+ created._config |= CONFIG_OPTIMISTIC;
6763
+ }
5691
6764
  if (affectsScopesLive()) inheritAffectsMarks(created, target.v, key);
5692
6765
  nodes[key] = node;
5693
6766
  markDescendants(target);
@@ -5708,7 +6781,10 @@ function getKeySetNode(target) {
5708
6781
  target.fam?.node ?? undefined
5709
6782
  ));
5710
6783
  created._config |= CONFIG_OWNED_WRITE;
5711
- if (target.fam?.opt) created._overrideValue = NOT_PENDING;
6784
+ if (target.fam?.opt) {
6785
+ ext(created)._overrideValue = NOT_PENDING;
6786
+ created._config |= CONFIG_OPTIMISTIC;
6787
+ }
5712
6788
  target.k = k;
5713
6789
  markDescendants(target);
5714
6790
  }
@@ -5728,7 +6804,10 @@ function getDeepNode(target) {
5728
6804
  target.fam?.node ?? undefined
5729
6805
  ));
5730
6806
  created._config |= CONFIG_OWNED_WRITE;
5731
- if (target.fam?.opt) created._overrideValue = NOT_PENDING;
6807
+ if (target.fam?.opt) {
6808
+ ext(created)._overrideValue = NOT_PENDING;
6809
+ created._config |= CONFIG_OPTIMISTIC;
6810
+ }
5732
6811
  if (affectsScopesLive()) inheritAffectsMarks(created, target.v, $TRACK);
5733
6812
  target.dk = dk;
5734
6813
  markDescendants(target);
@@ -5771,10 +6850,62 @@ function cloneRaw(source, t) {
5771
6850
  ? Object.defineProperties([], descs)
5772
6851
  : Object.create(Object.getPrototypeOf(source), descs);
5773
6852
  }
6853
+ /** One-time own-accessor scan (Annex-B probes, no descriptor allocation);
6854
+ * returns true when the container is plain data (overlay-safe). */
6855
+ function scanAccessorsOnce(target) {
6856
+ const src = target.v;
6857
+ for (const key of Reflect.ownKeys(src)) {
6858
+ // Own keys shadow prototype accessors, so the lookups are exact here.
6859
+ if (lookupGetter.call(src, key) !== undefined || lookupSetter.call(src, key) !== undefined) {
6860
+ target.a = true;
6861
+ break;
6862
+ }
6863
+ }
6864
+ target.sc = true;
6865
+ return !target.a;
6866
+ }
6867
+ /** Downgrade a prototype-overlay pending backing to the clone path: builds
6868
+ * the real container (committed + overlay writes − deletes) that fold will
6869
+ * SWAP in as the committed backing, exactly as if the draft had started on
6870
+ * the clone path. Consumers that need a complete container (reconcile's
6871
+ * diff walks, drafts escaping into other storage) call this. */
6872
+ function materializePB(target) {
6873
+ if (!target.ovl) return;
6874
+ const proto = target.pb;
6875
+ const clone = cloneRaw(target.v, target);
6876
+ for (const key of Reflect.ownKeys(proto)) {
6877
+ const d = Object.getOwnPropertyDescriptor(proto, key);
6878
+ if (d.get || d.set || !d.enumerable || !d.writable || !d.configurable)
6879
+ Object.defineProperty(clone, key, d);
6880
+ else clone[key] = d.value;
6881
+ }
6882
+ if (target.del !== null) {
6883
+ for (const key of target.del) delete clone[key];
6884
+ target.del = null;
6885
+ }
6886
+ const map = target.fam?.map ?? storeNextLookup;
6887
+ map.delete(proto);
6888
+ ownedRaw.add(clone);
6889
+ map.set(clone, target);
6890
+ target.pb = clone;
6891
+ target.ovl = false;
6892
+ }
5774
6893
  function ensurePB(target) {
5775
6894
  let pb = target.pb;
5776
6895
  if (pb === null) {
5777
- pb = target.pb = cloneRaw(target.v, target);
6896
+ // Prototype-chain overlay (#3044): plain-data non-array containers
6897
+ // outside projection/optimistic families open drafts in O(1) — own keys
6898
+ // are the writes, reads fall through to committed. Everything else
6899
+ // (arrays: splice/length semantics; families: seeding/revert machinery;
6900
+ // accessor containers: live getters) keeps the descriptor clone.
6901
+ if (
6902
+ target.fam === null &&
6903
+ !Array.isArray(target.v) &&
6904
+ (target.sc ? !target.a : scanAccessorsOnce(target))
6905
+ ) {
6906
+ pb = target.pb = Object.create(target.v);
6907
+ target.ovl = true;
6908
+ } else pb = target.pb = cloneRaw(target.v, target);
5778
6909
  // Optimistic families: seed USER drafts from the OPTIMISTIC VIEW
5779
6910
  // (committed + active node overrides), so follow-up writes compose on
5780
6911
  // optimism instead of clobbering from base (#2951's compose half).
@@ -5786,14 +6917,14 @@ function ensurePB(target) {
5786
6917
  if (nodes !== null) {
5787
6918
  for (const key of Reflect.ownKeys(nodes)) {
5788
6919
  const node = nodes[key];
5789
- if (hasActiveOverride(node)) pb[key] = unwrapOverride(node._overrideValue);
6920
+ if (hasActiveOverride(node)) pb[key] = unwrapOverride(node._x?._overrideValue);
5790
6921
  }
5791
6922
  }
5792
6923
  const has = target.h;
5793
6924
  if (has !== null) {
5794
6925
  for (const key of Reflect.ownKeys(has)) {
5795
6926
  const node = has[key];
5796
- if (hasActiveOverride(node) && !unwrapOverride(node._overrideValue)) delete pb[key];
6927
+ if (hasActiveOverride(node) && !unwrapOverride(node._x?._overrideValue)) delete pb[key];
5797
6928
  }
5798
6929
  }
5799
6930
  }
@@ -5820,6 +6951,18 @@ function adoptPB(target, incoming, eager = false) {
5820
6951
  target.adopted = true;
5821
6952
  }
5822
6953
  target.pb = null;
6954
+ // Overlay and accessor-scan state describe the OUTGOING backing — a
6955
+ // swapped container must not inherit them: a stale `ovl` beside a nulled
6956
+ // pb crashes materializePB (unwrapValue consults ovl before the
6957
+ // null-coalesce), a stale `del` would read the adoptee's keys as deleted
6958
+ // in the next draft, and a stale plain-data verdict (`sc`/`a`) could
6959
+ // admit an accessor-bearing adoptee to the overlay path. Reset; the next
6960
+ // draft rescans once (#3044 audit follow-up).
6961
+ target.ovl = false;
6962
+ target.del = null;
6963
+ target.wk = null; // adoption supersedes any staged trap writes
6964
+ target.sc = false;
6965
+ target.a = false;
5823
6966
  target.v = incoming;
5824
6967
  target.ch = incoming[$TARGET] !== undefined;
5825
6968
  (target.fam?.map ?? storeNextLookup).set(incoming, target);
@@ -5863,9 +7006,17 @@ function drainFolds() {
5863
7006
  const pb = t.pb;
5864
7007
  const nodes = t.n;
5865
7008
  if (nodes !== null) {
5866
- for (const key of Reflect.ownKeys(nodes)) {
7009
+ // Only written keys can hold (their nodes took the setSignal); the
7010
+ // wk bound keeps this O(written) — see notifyWrites. Same fallback
7011
+ // rules as the notify (WK_ALL / accessors / non-plain prototypes).
7012
+ const wkh = t.wk;
7013
+ const keys =
7014
+ wkh === null || wkh === WK_ALL || t.a === true || !plainProto(t.ovl ? t.v : pb)
7015
+ ? Reflect.ownKeys(nodes)
7016
+ : wkh;
7017
+ for (const key of keys) {
5867
7018
  const node = nodes[key];
5868
- if (node._pendingValue !== NOT_PENDING) {
7019
+ if (node !== undefined && node._pendingValue !== NOT_PENDING) {
5869
7020
  held = true;
5870
7021
  break;
5871
7022
  }
@@ -5875,9 +7026,36 @@ function drainFolds() {
5875
7026
  foldOlds.set(t, old); // re-queue: commit happens when the hold settles
5876
7027
  continue;
5877
7028
  }
5878
- t.v = pb;
5879
- t.ch = false; // pb is always a plain clone
5880
- t.pb = null;
7029
+ if (t.ovl) {
7030
+ // Overlay flatten (#3044): apply this batch's writes onto an OWNED
7031
+ // committed backing in place — O(written), not O(container). The
7032
+ // backing keeps its identity, so the `t.v === old` gate below skips
7033
+ // path copying (the parent slot already points here) and the
7034
+ // adopted-notify (setter notifications happened at write time).
7035
+ // Unowned backings privatize first (clone once, parents re-slotted)
7036
+ // — the never-mutate-user-data contract holds.
7037
+ privatizeCommitted(t);
7038
+ const v = t.v;
7039
+ for (const key of Reflect.ownKeys(pb)) {
7040
+ const d = Object.getOwnPropertyDescriptor(pb, key);
7041
+ if (d.get || d.set || !d.enumerable || !d.writable || !d.configurable)
7042
+ Object.defineProperty(v, key, d);
7043
+ else v[key] = d.value;
7044
+ }
7045
+ if (t.del !== null) {
7046
+ for (const key of t.del) delete v[key];
7047
+ t.del = null;
7048
+ }
7049
+ (t.fam?.map ?? storeNextLookup).delete(pb);
7050
+ t.pb = null;
7051
+ t.ovl = false;
7052
+ t.wk = null; // written-keys window closes with the fold commit
7053
+ } else {
7054
+ t.v = pb;
7055
+ t.ch = false; // pb is always a plain clone
7056
+ t.pb = null;
7057
+ t.wk = null; // written-keys window closes with the fold commit
7058
+ }
5881
7059
  }
5882
7060
  if (t.v === old) continue; // adopted then re-adopted back, or no-op
5883
7061
  // Path copying (CAS: see the eager-fold twin above).
@@ -5900,8 +7078,19 @@ function drainFolds() {
5900
7078
  * "pending home = the node when a node exists"). Unobserved keys stay in the
5901
7079
  * pending backing and fold directly at commit.
5902
7080
  */
7081
+ /** Sentinel for `t.wk`: the written-keys bound is unusable this batch (an
7082
+ * array length write implicitly deleted indices) — consumers full-scan. */
7083
+ const WK_ALL = new Set();
7084
+ /** Plain-prototype check for the written-keys bound: prototype getters on
7085
+ * class instances can derive from ANY field, so only plain-data containers
7086
+ * may bound the notify to written keys. Overlay pbs chain to the COMMITTED
7087
+ * object (#3044), so overlay plainness is judged on the committed proto. */
7088
+ const plainProto = o => {
7089
+ const p = Object.getPrototypeOf(o);
7090
+ return p === Object.prototype || p === Array.prototype || p === null;
7091
+ };
5903
7092
  function notifyWrites(t) {
5904
- const pb = t.pb;
7093
+ let pb = t.pb;
5905
7094
  if (pb === null) return;
5906
7095
  // Optimistic channel: user writes on an optimistic family become node-level
5907
7096
  // engine writes (armed nodes route setSignal through optimisticWrite) — the
@@ -5931,8 +7120,11 @@ function notifyWrites(t) {
5931
7120
  }
5932
7121
  const old = t.v;
5933
7122
  // Devtools mutation hook: full-key diff (dev-only cost) so unobserved
5934
- // writes report too, matching the legacy set-trap hook.
7123
+ // writes report too, matching the legacy set-trap hook. Overlay backings
7124
+ // materialize first so the diff walks a real container.
5935
7125
  if (DEV$1.hooks.onStoreNodeUpdate) {
7126
+ if (t.ovl) materializePB(t);
7127
+ pb = t.pb;
5936
7128
  for (const key of Reflect.ownKeys(pb)) {
5937
7129
  if (Array.isArray(pb) && key === "length") continue;
5938
7130
  const ov = old[key];
@@ -5945,9 +7137,20 @@ function notifyWrites(t) {
5945
7137
  }
5946
7138
  }
5947
7139
  const nodes = t.n;
7140
+ // Written-keys bound: trap writes record their keys, so the notify visits
7141
+ // O(written) nodes instead of every subscription on the record (a selection
7142
+ // map with thousands of per-key subscribers pays two visits per select,
7143
+ // not a full scan). Falls back to the full node scan when the bound can't
7144
+ // hold: no trap granularity (wk null), an array length write (WK_ALL —
7145
+ // implicit index deletes), accessors on the record (t.a — a getter node's
7146
+ // value can change when ANY key is written), or a non-plain prototype.
7147
+ const wk0 = t.wk;
7148
+ const writtenKeys = wk0 === WK_ALL || t.a === true || !plainProto(t.ovl ? t.v : pb) ? null : wk0;
5948
7149
  if (nodes !== null) {
5949
- for (const key of Reflect.ownKeys(nodes)) {
7150
+ const keys = writtenKeys ?? Reflect.ownKeys(nodes);
7151
+ for (const key of keys) {
5950
7152
  const node = nodes[key];
7153
+ if (node === undefined) continue;
5951
7154
  // Per-key accessor handling: the node's cached flag plus ONE getter
5952
7155
  // probe on the incoming side (getters arriving via merge/adoption).
5953
7156
  // Setter-only props read as data (value undefined) so lookupSetter is
@@ -5968,31 +7171,48 @@ function notifyWrites(t) {
5968
7171
  // projection recompute can run before the prior fold commits) — the
5969
7172
  // node's OWN current value is the true old side, and setSignal's
5970
7173
  // internal equality already checks exactly that.
5971
- const nv = pb[key];
7174
+ const nv = t.del !== null && t.del.has(key) ? undefined : pb[key];
5972
7175
  setSignal(node, () => nv);
5973
7176
  }
5974
7177
  }
5975
7178
  const has = t.h;
5976
7179
  if (has !== null) {
5977
- for (const key of Reflect.ownKeys(has)) setSignal(has[key], key in pb);
7180
+ for (const key of Reflect.ownKeys(has))
7181
+ setSignal(has[key], key in pb && !(t.del !== null && t.del.has(key)));
5978
7182
  }
5979
7183
  // Deep-witness (dk): setter writes must notify a deep() subscriber even on
5980
7184
  // keys with no node. O(pb keys) equality only when a witness exists.
5981
7185
  if (t.dk !== null) {
5982
- for (const key of Reflect.ownKeys(pb)) {
5983
- const nv = pb[key];
5984
- const ov = old[key];
5985
- if (nv !== null && typeof nv === "object" ? !targetsEqual(ov, nv) : !isEqual(ov, nv)) {
5986
- bumpDeep(t);
5987
- break;
7186
+ if (t.del !== null && t.del.size !== 0) bumpDeep(t);
7187
+ else
7188
+ for (const key of Reflect.ownKeys(pb)) {
7189
+ const nv = pb[key];
7190
+ const ov = old[key];
7191
+ if (nv !== null && typeof nv === "object" ? !targetsEqual(ov, nv) : !isEqual(ov, nv)) {
7192
+ bumpDeep(t);
7193
+ break;
7194
+ }
5988
7195
  }
5989
- }
5990
7196
  }
5991
7197
  if (t.k !== null) {
5992
- const changed =
5993
- Array.isArray(pb) && Array.isArray(old)
5994
- ? arrayStructureChanged(old, pb)
5995
- : membershipChanged(old, pb);
7198
+ let changed;
7199
+ if (t.ovl) {
7200
+ // Overlay membership: only NEW own keys or deletes can change it.
7201
+ changed = t.del !== null && t.del.size !== 0;
7202
+ if (!changed) {
7203
+ for (const key of Reflect.ownKeys(pb)) {
7204
+ if (!hasOwn.call(old, key)) {
7205
+ changed = true;
7206
+ break;
7207
+ }
7208
+ }
7209
+ }
7210
+ } else {
7211
+ changed =
7212
+ Array.isArray(pb) && Array.isArray(old)
7213
+ ? arrayStructureChanged(old, pb)
7214
+ : membershipChanged(old, pb);
7215
+ }
5996
7216
  if (changed) setSignal(t.k, v => v + 1);
5997
7217
  }
5998
7218
  // Projection backing folds split by channel (two pinned contracts):
@@ -6008,6 +7228,7 @@ function notifyWrites(t) {
6008
7228
  if (t.fam !== null && t.pb !== null && getWriteOverride()) {
6009
7229
  const oldBacking = t.v;
6010
7230
  t.pb = null;
7231
+ t.wk = null; // written-keys window closes with the eager fold
6011
7232
  t.v = pb;
6012
7233
  t.ch = false;
6013
7234
  if (t.u && t.u.v[t.pk] === oldBacking) {
@@ -6278,7 +7499,7 @@ function runAuthoritative(fn) {
6278
7499
  /** Active optimistic override on an armed node (armed slot idles at
6279
7500
  * NOT_PENDING; undefined = unarmed plain node). */
6280
7501
  function hasActiveOverride(node) {
6281
- return node._overrideValue !== undefined && node._overrideValue !== NOT_PENDING;
7502
+ return node._x?._overrideValue !== undefined && node._x?._overrideValue !== NOT_PENDING;
6282
7503
  }
6283
7504
  /** Context-aware node view for reads outside tracking: active override >
6284
7505
  * held pending (owner context) > the BACKING value. Committed truth lives in
@@ -6289,7 +7510,7 @@ function hasActiveOverride(node) {
6289
7510
  * keys, which are served by the trap, not the node). */
6290
7511
  function nodeValue(node, backing) {
6291
7512
  const v = hasActiveOverride(node)
6292
- ? unwrapOverride(node._overrideValue)
7513
+ ? unwrapOverride(node._x?._overrideValue)
6293
7514
  : node._pendingValue !== NOT_PENDING && inOwnerContext()
6294
7515
  ? node._pendingValue
6295
7516
  : backing;
@@ -6325,7 +7546,8 @@ function serveDataKey(target, key, backingValue, src, node) {
6325
7546
  // #2951). Once ensurePB runs, the seeded clone carries the view.
6326
7547
  if (target.fam?.opt && target.pb === null) {
6327
7548
  const node = target.n?.[key];
6328
- if (node !== undefined && hasActiveOverride(node)) v = unwrapOverride(node._overrideValue);
7549
+ if (node !== undefined && hasActiveOverride(node))
7550
+ v = unwrapOverride(node._x?._overrideValue);
6329
7551
  }
6330
7552
  } else {
6331
7553
  if (node !== undefined) {
@@ -6368,6 +7590,15 @@ function serveDataKey(target, key, backingValue, src, node) {
6368
7590
  * link is what wakes async-memo readers when the landing writes values;
6369
7591
  * the firewall link rides the same read). */
6370
7592
  function firewallGate(target) {
7593
+ // Own-draft ops are exempt: an async derive's continuation (generator body
7594
+ // after an `await`/`yield`) runs OUTSIDE the sync write scope (inDraft is
7595
+ // already false), but its draft-proxy traps mark every op with the write
7596
+ // override. Those reads are the derive working its own draft (state.push
7597
+ // reading .length) — gating them throws NotReadyError back into the derive
7598
+ // itself, which the post-await read diagnostic (#2987) then escalates to a
7599
+ // reactivity halt. The gate exists for EXTERNAL readers (seed invisibility,
7600
+ // proj R23); the derive is the author.
7601
+ if (projectionWriteActive || getWriteOverride()) return;
6371
7602
  const fw = target.fam?.node;
6372
7603
  if (fw != null && fw._statusFlags & (STATUS_UNINITIALIZED | STATUS_ERROR)) read(fw);
6373
7604
  }
@@ -6399,6 +7630,12 @@ const traps = {
6399
7630
  if (pendingCheckActive) witnessAffectsMark(target, key);
6400
7631
  if (target.fam !== null && getObserver() === null && !inDraft(target)) firewallGate(target);
6401
7632
  const src = readSource(target);
7633
+ // Overlay delete (#3044): a prototype overlay cannot shadow a delete, so
7634
+ // deleted keys are tracked aside and read as absent in the pending view.
7635
+ if (target.del !== null && src === target.pb && target.del.has(key)) {
7636
+ if (!inDraft(target) && getObserver() !== null) read(getNode(target, key, undefined));
7637
+ return undefined;
7638
+ }
6402
7639
  // Hot inline case: existing PLAIN node (non-accessor), unchained backing,
6403
7640
  // tracked read of a present data key — the dbmon/uibench effect re-read
6404
7641
  // shape. Skips serveDataKey's frame, the FORCE compare (only accessor
@@ -6439,14 +7676,21 @@ const traps = {
6439
7676
  // the node's cached flag; the first TRACKED read (which creates the
6440
7677
  // node) probes once — untracked node-less reads take the plain path,
6441
7678
  // where a raw-receiver getter still returns correct committed values.
7679
+ // Tracking suppression is PER-TARGET (inDraft), never global: `writing`
7680
+ // counts every open setter anywhere, and a projection derive runs its
7681
+ // whole body inside one — a global gate silently swallowed EXTERNAL
7682
+ // absent-key/accessor subscriptions for every store read during any
7683
+ // derive, leaving nested projections permanently dependency-less when
7684
+ // their sources hadn't materialized yet (#3037).
6442
7685
  const node0 = target.n?.[key];
6443
7686
  {
6444
7687
  const acc =
6445
7688
  node0 !== undefined
6446
7689
  ? node0.acc === true
6447
- : !writing && getObserver() !== null && isOwnAccessor(src, key);
7690
+ : !inDraft(target) && getObserver() !== null && isOwnAccessor(src, key);
6448
7691
  if (acc) {
6449
- if (!writing && getObserver() !== null) read(node0 ?? getNode(target, key, undefined));
7692
+ if (!inDraft(target) && getObserver() !== null)
7693
+ read(node0 ?? getNode(target, key, undefined));
6450
7694
  const v = Reflect.get(src, key, receiver);
6451
7695
  if (target.s) return serveShallow(target, key, v);
6452
7696
  return isWrappable(v) ? draftServe(target, wrapNext(v, target, key)) : v;
@@ -6455,19 +7699,27 @@ const traps = {
6455
7699
  // Plain-data fast path: no descriptor allocation per read.
6456
7700
  // Inherited pollution keys are never served (core R30) — checked before
6457
7701
  // the proto-function branch can leak `constructor`. Interned-string
6458
- // compares beat a Set hash on this per-read path.
7702
+ // compares beat a Set hash on this per-read path. Overlay pending
7703
+ // backings chain to the committed backing, so "own in the view" means
7704
+ // own on either layer (ownInView) — a genuine prototype method is one
7705
+ // that is own on NEITHER.
7706
+ const viewOvl = target.ovl && src === target.pb;
6459
7707
  if (
6460
7708
  (key === "constructor" || key === "__proto__" || key === "prototype") &&
6461
- !hasOwn.call(src, key)
7709
+ !hasOwn.call(src, key) &&
7710
+ !(viewOvl && hasOwn.call(target.v, key))
6462
7711
  )
6463
7712
  return undefined;
6464
7713
  let v = src[key];
6465
- if (v === undefined ? !hasOwn.call(src, key) : false) {
7714
+ if (
7715
+ v === undefined ? !hasOwn.call(src, key) && !(viewOvl && hasOwn.call(target.v, key)) : false
7716
+ ) {
6466
7717
  // Inherited: prototype getters/methods run with the proxy receiver.
6467
7718
  v = Reflect.get(src, key, receiver);
6468
7719
  if (typeof v === "function") return v; // proto methods untracked
6469
- // Reading a currently-absent own key subscribes to it (R12).
6470
- if (v === undefined && !writing) {
7720
+ // Reading a currently-absent own key subscribes to it (R12) — for any
7721
+ // target OUTSIDE its own draft scope, even mid-setter (#3037, above).
7722
+ if (v === undefined && !inDraft(target)) {
6471
7723
  if (getObserver() !== null) read(getNode(target, key, undefined));
6472
7724
  const node = target.n?.[key];
6473
7725
  if (node) {
@@ -6477,12 +7729,18 @@ const traps = {
6477
7729
  }
6478
7730
  } else if (v === undefined && inDraft(target) && target.fam?.opt && target.pb === null) {
6479
7731
  const node = target.n?.[key];
6480
- if (node !== undefined && hasActiveOverride(node)) v = unwrapOverride(node._overrideValue);
7732
+ if (node !== undefined && hasActiveOverride(node))
7733
+ v = unwrapOverride(node._x?._overrideValue);
6481
7734
  }
6482
7735
  if (target.s) return serveShallow(target, key, v);
6483
7736
  return isWrappable(v) ? draftServe(target, wrapNext(v, target, key)) : v;
6484
7737
  }
6485
- if (typeof v === "function" && !hasOwn.call(src, key)) return v; // proto method
7738
+ if (
7739
+ typeof v === "function" &&
7740
+ !hasOwn.call(src, key) &&
7741
+ !(viewOvl && hasOwn.call(target.v, key))
7742
+ )
7743
+ return v; // proto method
6486
7744
  return serveDataKey(target, key, v, src, node0);
6487
7745
  },
6488
7746
  has(target, key) {
@@ -6491,6 +7749,8 @@ const traps = {
6491
7749
  if (target.fam !== null && getObserver() === null && !inDraft(target)) firewallGate(target);
6492
7750
  const src = readSource(target);
6493
7751
  let present = key in src;
7752
+ // Overlay deletes read as absent in the pending view (#3044).
7753
+ if (present && target.del !== null && src === target.pb && target.del.has(key)) present = false;
6494
7754
  if (!inDraft(target)) {
6495
7755
  if (getObserver() !== null) {
6496
7756
  const node = getHasNode(target, key, present);
@@ -6499,12 +7759,12 @@ const traps = {
6499
7759
  } else {
6500
7760
  const node = target.h?.[key];
6501
7761
  if (node !== undefined && hasActiveOverride(node))
6502
- present = !!unwrapOverride(node._overrideValue);
7762
+ present = !!unwrapOverride(node._x?._overrideValue);
6503
7763
  }
6504
7764
  } else if (target.fam?.opt && target.pb === null) {
6505
7765
  const node = target.h?.[key];
6506
7766
  if (node !== undefined && hasActiveOverride(node))
6507
- present = !!unwrapOverride(node._overrideValue);
7767
+ present = !!unwrapOverride(node._x?._overrideValue);
6508
7768
  }
6509
7769
  return present;
6510
7770
  },
@@ -6512,7 +7772,18 @@ const traps = {
6512
7772
  if (pendingCheckActive) witnessAffectsMark(target);
6513
7773
  if (target.fam !== null && getObserver() === null && !inDraft(target)) firewallGate(target);
6514
7774
  if (!inDraft(target) && getObserver() !== null) read(getKeySetNode(target));
6515
- const keys = Reflect.ownKeys(readSource(target));
7775
+ const src = readSource(target);
7776
+ let keys;
7777
+ if (target.ovl && src === target.pb) {
7778
+ // Overlay merge (#3044): committed keys in their order, then this
7779
+ // batch's NEW keys, minus deletes.
7780
+ keys = Reflect.ownKeys(target.v);
7781
+ const del = target.del;
7782
+ if (del !== null && del.size !== 0) keys = keys.filter(key => !del.has(key));
7783
+ for (const key of Reflect.ownKeys(src)) {
7784
+ if (!hasOwn.call(target.v, key)) keys.push(key);
7785
+ }
7786
+ } else keys = Reflect.ownKeys(src);
6516
7787
  // Optimistic membership overlay: presence-node overrides add/remove keys
6517
7788
  // (per-transaction lifecycle rides the nodes — §6, FINDING-2's fix).
6518
7789
  // Draft reads before the first write overlay too (pb, once created, is
@@ -6523,7 +7794,7 @@ const traps = {
6523
7794
  const node = target.h[key];
6524
7795
  if (!hasActiveOverride(node)) continue;
6525
7796
  set ??= new Set(keys);
6526
- if (unwrapOverride(node._overrideValue)) set.add(key);
7797
+ if (unwrapOverride(node._x?._overrideValue)) set.add(key);
6527
7798
  else set.delete(key);
6528
7799
  }
6529
7800
  if (set !== null) return [...set];
@@ -6531,11 +7802,18 @@ const traps = {
6531
7802
  return keys;
6532
7803
  },
6533
7804
  getOwnPropertyDescriptor(target, key) {
6534
- const desc = Object.getOwnPropertyDescriptor(readSource(target), key);
7805
+ const srcD = readSource(target);
7806
+ let desc = Object.getOwnPropertyDescriptor(srcD, key);
7807
+ // Overlay (#3044): unwritten keys live on the committed backing;
7808
+ // deleted keys are absent from the pending view.
7809
+ if (target.ovl && srcD === target.pb) {
7810
+ if (target.del !== null && target.del.has(key)) return undefined;
7811
+ if (desc === undefined) desc = Object.getOwnPropertyDescriptor(target.v, key);
7812
+ }
6535
7813
  if (target.fam?.opt && !inDraft(target)) {
6536
7814
  const node = target.h?.[key];
6537
7815
  if (node !== undefined && hasActiveOverride(node)) {
6538
- if (!unwrapOverride(node._overrideValue)) return undefined; // opt delete
7816
+ if (!unwrapOverride(node._x?._overrideValue)) return undefined; // opt delete
6539
7817
  if (desc === undefined) {
6540
7818
  const vn = target.n?.[key];
6541
7819
  return {
@@ -6562,24 +7840,50 @@ const traps = {
6562
7840
  const override = !draft && getWriteOverride();
6563
7841
  if (!draft && !override) return true;
6564
7842
  if (key === "__proto__") return true; // pollution guard (core R30)
7843
+ // Unwrap BEFORE ensurePB: unwrapValue materializes a self-referencing
7844
+ // draft's overlay (replacing target.pb), so a pb local captured earlier
7845
+ // would be the abandoned overlay and the write would vanish.
7846
+ // Shallow slots store what was written VERBATIM — another store's proxy
7847
+ // passes through by reference (#2932; markRawOne skips proxies), while
7848
+ // deep stores unwrap to raw backings.
7849
+ const uv = target.s ? value : unwrapValue(value);
6565
7850
  const pb = ensurePB(target);
6566
7851
  pendingNotify.add(target);
7852
+ // Array length writes implicitly delete indices — the written-keys bound
7853
+ // can't see them, so poison to the full scan for this batch. Index
7854
+ // writes implicitly GROW length, so arrays always record it alongside.
7855
+ if (Array.isArray(pb)) {
7856
+ if (key === "length") target.wk = WK_ALL;
7857
+ else if (target.wk !== WK_ALL) {
7858
+ const wk = (target.wk ??= new Set());
7859
+ wk.add(key);
7860
+ wk.add("length");
7861
+ }
7862
+ } else if (target.wk !== WK_ALL) (target.wk ??= new Set()).add(key);
6567
7863
  // Own data keys literally named "prototype"/"constructor" land as data —
6568
7864
  // defineProperty sidesteps a proto-chain setter named the same.
6569
7865
  if (UNSAFE_KEYS.has(key)) {
6570
7866
  Object.defineProperty(pb, key, {
6571
- value: unwrapValue(value),
7867
+ value: uv,
6572
7868
  writable: true,
6573
7869
  enumerable: true,
6574
7870
  configurable: true
6575
7871
  });
7872
+ if (target.del !== null) target.del.delete(key);
6576
7873
  return true;
6577
7874
  }
6578
- // Shallow slots store what was written VERBATIM another store's proxy
6579
- // passes through by reference (#2932; markRawOne skips proxies), while
6580
- // deep stores unwrap to raw backings.
6581
- const uv = target.s ? value : unwrapValue(value);
6582
- pb[key] = uv;
7875
+ // Overlay first-write DEFINES the own key: assignment through the proto
7876
+ // chain would reject on a non-writable committed property (the clone
7877
+ // path normalized descriptors for exactly this — R51 parity).
7878
+ if (target.ovl && !hasOwn.call(pb, key)) {
7879
+ Object.defineProperty(pb, key, {
7880
+ value: uv,
7881
+ writable: true,
7882
+ enumerable: true,
7883
+ configurable: true
7884
+ });
7885
+ } else pb[key] = uv;
7886
+ if (target.del !== null) target.del.delete(key);
6583
7887
  // Shallow ingest: written records are sticky raw-marked (one entity is
6584
7888
  // never both deep-wrapped and raw — R41/#2932, shared invariant).
6585
7889
  if (target.s && uv !== null && typeof uv === "object") markRawOne(uv);
@@ -6594,10 +7898,13 @@ const traps = {
6594
7898
  if (!draft && !override) return true;
6595
7899
  if (key === "__proto__") return true;
6596
7900
  if (desc.get || desc.set) target.a = true;
7901
+ // Unwrap before ensurePB (see the set trap: self-reference materializes).
7902
+ if ("value" in desc) desc = { ...desc, value: unwrapValue(desc.value) };
6597
7903
  const pb = ensurePB(target);
6598
7904
  pendingNotify.add(target);
6599
- if ("value" in desc) desc = { ...desc, value: unwrapValue(desc.value) };
7905
+ if (target.wk !== WK_ALL) (target.wk ??= new Set()).add(key);
6600
7906
  Object.defineProperty(pb, key, desc);
7907
+ if (target.del !== null) target.del.delete(key);
6601
7908
  if (override) notifyWrites(target);
6602
7909
  return true;
6603
7910
  },
@@ -6607,7 +7914,11 @@ const traps = {
6607
7914
  if (!draft && !override) return true;
6608
7915
  const pb = ensurePB(target);
6609
7916
  pendingNotify.add(target);
7917
+ if (target.wk !== WK_ALL) (target.wk ??= new Set()).add(key);
6610
7918
  delete pb[key];
7919
+ // A prototype overlay cannot shadow a delete of a committed key —
7920
+ // record it aside (#3044); reads/has/ownKeys/commit consult the set.
7921
+ if (target.ovl && hasOwn.call(target.v, key)) (target.del ??= new Set()).add(key);
6611
7922
  if (override) notifyWrites(target);
6612
7923
  return true;
6613
7924
  }
@@ -6749,6 +8060,9 @@ function snapshotWalk(value, seen, fam) {
6749
8060
  if (t === undefined) break;
6750
8061
  if (t.fam !== null) fam = t.fam;
6751
8062
  if (t.fam?.opt === true) (optOwners ??= []).push(t);
8063
+ // Snapshot runs mid-flush (tracked memos execute before commit), so a
8064
+ // pending prototype overlay must present as a REAL merged container.
8065
+ if (t.ovl) materializePB(t);
6752
8066
  const backing = t.pb ?? t.v;
6753
8067
  if (backing === src) break;
6754
8068
  src = backing;
@@ -6848,6 +8162,10 @@ function reconcileNextState(value, state, key, replace = false) {
6848
8162
  if (state == null) throw new Error("Cannot reconcile null or undefined state");
6849
8163
  const t = state?.[$TARGET];
6850
8164
  if (t === undefined || t.px !== state) throw new Error("reconcile target is not a store proxy");
8165
+ // Reconcile's diff walks need a REAL pending container — a prototype
8166
+ // overlay (#3044) materializes to the clone path first (edge: reconcile
8167
+ // inside a setter that already wrote this target).
8168
+ if (t.ovl) materializePB(t);
6851
8169
  let keyFn = key === null ? null : typeof key === "string" ? item => item?.[key] : key;
6852
8170
  // §7b chained backing: a projection derive returning a LIVE store proxy
6853
8171
  // adopts the proxy itself as the backing — reads flow through the inner
@@ -7176,10 +8494,29 @@ function descend(pv, nv, keyFn, fam, proj = false) {
7176
8494
  * primitives; the generic draft write-traps are reused from the legacy
7177
8495
  * module unchanged.
7178
8496
  */
7179
- function createWriteTraps(isActive, onDraftWrite) {
7180
- // Save/restore, never hard-reset: the draft can be driven from inside an
7181
- // enclosing authoritative-write scope (next-store optimistic derives), and
7182
- // a hard `false` would clobber it mid-derive.
8497
+ /**
8498
+ * Wrap a store proxy as a projection DRAFT: every operation carries the write
8499
+ * override (the derive is the author — its ops must not hit the §6c firewall
8500
+ * gate, even in a continuation after an `await`/`yield` where the sync write
8501
+ * scope has closed).
8502
+ *
8503
+ * FAKE TARGET, not the store proxy itself (#3060): after a proxy trap
8504
+ * returns, the engine runs spec invariant validation against the proxy's
8505
+ * TARGET — [[OwnPropertyKeys]] after ownKeys, [[GetOwnProperty]] after
8506
+ * set/getOwnPropertyDescriptor/defineProperty. With the store proxy as
8507
+ * target those checks re-enter the store's traps OUTSIDE the override
8508
+ * bracket (the trap's finally has already run), so `Object.keys(state)` in
8509
+ * a derive continuation fired the firewall gate and re-threw the
8510
+ * projection's own pending NotReadyError into the derive. A dummy of
8511
+ * matching kind (array/object, same trick as the store's own TargetShape)
8512
+ * keeps invariant validation away from the store entirely; the traps
8513
+ * forward to the closed-over inner proxy inside the bracket.
8514
+ *
8515
+ * Save/restore projectionWriteActive, never hard-reset: the draft can be
8516
+ * driven from inside an enclosing authoritative-write scope (next-store
8517
+ * optimistic derives), and a hard `false` would clobber it mid-derive.
8518
+ */
8519
+ function wrapDraft(inner, isActive, onDraftWrite) {
7183
8520
  const traps = {
7184
8521
  get(_, prop) {
7185
8522
  let value;
@@ -7187,13 +8524,15 @@ function createWriteTraps(isActive, onDraftWrite) {
7187
8524
  setWriteOverride(true);
7188
8525
  setProjectionWriteActive(true);
7189
8526
  try {
7190
- value = _[prop];
8527
+ value = inner[prop];
7191
8528
  } finally {
7192
8529
  setWriteOverride(false);
7193
8530
  setProjectionWriteActive(was);
7194
8531
  }
7195
8532
  if (prop === $TARGET) return value;
7196
- return typeof value === "object" && value !== null ? new Proxy(value, traps) : value;
8533
+ return typeof value === "object" && value !== null
8534
+ ? wrapDraft(value, isActive, onDraftWrite)
8535
+ : value;
7197
8536
  },
7198
8537
  has(_, prop) {
7199
8538
  let value;
@@ -7201,7 +8540,7 @@ function createWriteTraps(isActive, onDraftWrite) {
7201
8540
  setWriteOverride(true);
7202
8541
  setProjectionWriteActive(true);
7203
8542
  try {
7204
- value = prop in _;
8543
+ value = prop in inner;
7205
8544
  } finally {
7206
8545
  setWriteOverride(false);
7207
8546
  setProjectionWriteActive(was);
@@ -7214,7 +8553,7 @@ function createWriteTraps(isActive, onDraftWrite) {
7214
8553
  setWriteOverride(true);
7215
8554
  setProjectionWriteActive(true);
7216
8555
  try {
7217
- _[prop] = value;
8556
+ inner[prop] = value;
7218
8557
  onDraftWrite?.();
7219
8558
  } finally {
7220
8559
  setWriteOverride(false);
@@ -7228,7 +8567,49 @@ function createWriteTraps(isActive, onDraftWrite) {
7228
8567
  setWriteOverride(true);
7229
8568
  setProjectionWriteActive(true);
7230
8569
  try {
7231
- delete _[prop];
8570
+ delete inner[prop];
8571
+ onDraftWrite?.();
8572
+ } finally {
8573
+ setWriteOverride(false);
8574
+ setProjectionWriteActive(was);
8575
+ }
8576
+ return true;
8577
+ },
8578
+ ownKeys() {
8579
+ const was = projectionWriteActive;
8580
+ setWriteOverride(true);
8581
+ setProjectionWriteActive(true);
8582
+ try {
8583
+ return Reflect.ownKeys(inner);
8584
+ } finally {
8585
+ setWriteOverride(false);
8586
+ setProjectionWriteActive(was);
8587
+ }
8588
+ },
8589
+ getOwnPropertyDescriptor(_, prop) {
8590
+ let d;
8591
+ const was = projectionWriteActive;
8592
+ setWriteOverride(true);
8593
+ setProjectionWriteActive(true);
8594
+ try {
8595
+ d = Reflect.getOwnPropertyDescriptor(inner, prop);
8596
+ } finally {
8597
+ setWriteOverride(false);
8598
+ setProjectionWriteActive(was);
8599
+ }
8600
+ // The dummy target doesn't hold the key, so a non-configurable report
8601
+ // would violate the proxy invariant. Store descriptors are already
8602
+ // normalized configurable; enforce it for raw leaves too.
8603
+ if (d) d.configurable = true;
8604
+ return d;
8605
+ },
8606
+ defineProperty(_, prop, desc) {
8607
+ if (isActive && !isActive()) return true;
8608
+ const was = projectionWriteActive;
8609
+ setWriteOverride(true);
8610
+ setProjectionWriteActive(true);
8611
+ try {
8612
+ Reflect.defineProperty(inner, prop, desc);
7232
8613
  onDraftWrite?.();
7233
8614
  } finally {
7234
8615
  setWriteOverride(false);
@@ -7237,7 +8618,8 @@ function createWriteTraps(isActive, onDraftWrite) {
7237
8618
  return true;
7238
8619
  }
7239
8620
  };
7240
- return traps;
8621
+ // Matching-kind dummy so Array.isArray(draft) answers like the store.
8622
+ return new Proxy(Array.isArray(inner) ? [] : {}, traps);
7241
8623
  }
7242
8624
  function createProjectionNextInternal(fn, seed, options) {
7243
8625
  const fam = {
@@ -7291,9 +8673,10 @@ function runProjectionComputedNext(wrappedStore, fn, key, wrapCommit, onDraftWri
7291
8673
  const shadow = owner._loading
7292
8674
  ? JSON.parse(JSON.stringify(wrappedStore[$TARGET][STORE_VALUE]))
7293
8675
  : null;
7294
- const draft = new Proxy(
8676
+ const draft = wrapDraft(
7295
8677
  wrappedStore,
7296
- createWriteTraps(() => !settled || owner._inFlight === result, onDraftWrite)
8678
+ () => !settled || owner._x?._inFlight === result,
8679
+ onDraftWrite
7297
8680
  );
7298
8681
  storeSetterNext(
7299
8682
  draft,
@@ -7374,10 +8757,15 @@ function familyHasLiveOverrides(fam) {
7374
8757
  if (bucket === null) continue;
7375
8758
  for (const key of Reflect.ownKeys(bucket)) {
7376
8759
  const node = bucket[key];
7377
- if (node._overrideValue !== undefined && node._overrideValue !== NOT_PENDING) return true;
8760
+ if (node._x?._overrideValue !== undefined && node._x?._overrideValue !== NOT_PENDING)
8761
+ return true;
7378
8762
  }
7379
8763
  }
7380
- if (t.k !== null && t.k._overrideValue !== undefined && t.k._overrideValue !== NOT_PENDING)
8764
+ if (
8765
+ t.k !== null &&
8766
+ t.k._x?._overrideValue !== undefined &&
8767
+ t.k._x?._overrideValue !== NOT_PENDING
8768
+ )
7381
8769
  return true;
7382
8770
  }
7383
8771
  overlaid.clear(); // nothing live — drop the bookkeeping
@@ -7451,13 +8839,13 @@ function notifyOptimisticWrites(t, pb) {
7451
8839
  const visible = (key, fallback) => {
7452
8840
  const node = t.n?.[key];
7453
8841
  return node !== undefined && hasActiveOverride(node)
7454
- ? unwrapOverride(node._overrideValue)
8842
+ ? unwrapOverride(node._x?._overrideValue)
7455
8843
  : fallback;
7456
8844
  };
7457
8845
  const visiblePresent = key => {
7458
8846
  const node = t.h?.[key];
7459
8847
  return node !== undefined && hasActiveOverride(node)
7460
- ? !!unwrapOverride(node._overrideValue)
8848
+ ? !!unwrapOverride(node._x?._overrideValue)
7461
8849
  : key in old;
7462
8850
  };
7463
8851
  let structural = false;
@@ -7518,14 +8906,18 @@ function consumeOverridesNext(fam) {
7518
8906
  for (const t of overlaid) {
7519
8907
  const drop = (node, committed) => {
7520
8908
  if (!hasActiveOverride(node)) return;
7521
- const prev = unwrapOverride(node._overrideValue);
8909
+ const prev = unwrapOverride(node._x?._overrideValue);
7522
8910
  // Full legacy reset (clearOptimisticOverride parity): the landing is
7523
8911
  // authoritative NOW — fold committed into the node directly instead
7524
8912
  // of riding a transaction's commit (whose queues may be stashed with
7525
8913
  // the transaction parked; the wake would strand until it settles).
7526
- node._overrideValue = NOT_PENDING;
7527
- node._overrideOwner = null;
7528
- node._optimisticLane = undefined;
8914
+ ext(node)._overrideValue = NOT_PENDING;
8915
+ node._config |= CONFIG_OPTIMISTIC;
8916
+ const nx = node._x;
8917
+ if (nx) {
8918
+ nx._overrideOwner = null;
8919
+ nx._optimisticLane = undefined;
8920
+ }
7529
8921
  node._pendingValue = NOT_PENDING;
7530
8922
  node._value = committed;
7531
8923
  if (!node._equals || !node._equals(prev, committed)) {
@@ -7561,9 +8953,13 @@ function consumeOverridesNext(fam) {
7561
8953
  for (const key of Reflect.ownKeys(has)) drop(has[key], key in t.v);
7562
8954
  }
7563
8955
  if (t.k !== null && hasActiveOverride(t.k)) {
7564
- t.k._overrideValue = NOT_PENDING;
7565
- t.k._overrideOwner = null;
7566
- t.k._optimisticLane = undefined;
8956
+ ext(t.k)._overrideValue = NOT_PENDING;
8957
+ t.k._config |= CONFIG_OPTIMISTIC;
8958
+ const kx = t.k._x;
8959
+ if (kx) {
8960
+ kx._overrideOwner = null;
8961
+ kx._optimisticLane = undefined;
8962
+ }
7567
8963
  insertSubs(t.k, true);
7568
8964
  schedule();
7569
8965
  }
@@ -7583,7 +8979,7 @@ function optimisticView(t, src) {
7583
8979
  for (const key of Reflect.ownKeys(nodes)) {
7584
8980
  const node = nodes[key];
7585
8981
  if (!hasActiveOverride(node)) continue;
7586
- const ov = unwrapOverride(node._overrideValue);
8982
+ const ov = unwrapOverride(node._x?._overrideValue);
7587
8983
  if (key === "length" && Array.isArray(src)) {
7588
8984
  if (src.length !== ov) ensure().length = ov;
7589
8985
  } else if (!isEqual(src[key], ov)) ensure()[key] = ov;
@@ -7594,7 +8990,7 @@ function optimisticView(t, src) {
7594
8990
  for (const key of Reflect.ownKeys(has)) {
7595
8991
  const node = has[key];
7596
8992
  if (!hasActiveOverride(node)) continue;
7597
- const present = !!unwrapOverride(node._overrideValue);
8993
+ const present = !!unwrapOverride(node._x?._overrideValue);
7598
8994
  if (!present && key in (out ?? src)) delete ensure()[key];
7599
8995
  }
7600
8996
  }
@@ -8322,10 +9718,10 @@ function compare(key, a, b) {
8322
9718
 
8323
9719
  function boundaryComputed(fn, propagationMask) {
8324
9720
  const node = computed(fn, { lazy: true });
8325
- node._notifyStatus = (status, error) => {
9721
+ ext(node)._notifyStatus = (status, error) => {
8326
9722
  // Use passed values if provided, otherwise read from node
8327
9723
  const flags = status !== undefined ? status : node._statusFlags;
8328
- const actualError = error !== undefined ? error : node._error;
9724
+ const actualError = error !== undefined ? error : node._x?._error;
8329
9725
  // Notify both status dimensions like a render effect does; the queue chain
8330
9726
  // consumes this boundary's own type and forwards the remainder upward until
8331
9727
  // a boundary that handles it is found.
@@ -8338,8 +9734,8 @@ function boundaryComputed(fn, propagationMask) {
8338
9734
  const foreign = flags & ~node._propagationMask & (STATUS_PENDING | STATUS_ERROR);
8339
9735
  if (foreign) {
8340
9736
  node._statusFlags &= ~foreign;
8341
- if (node._error === actualError && !(node._statusFlags & (STATUS_PENDING | STATUS_ERROR)))
8342
- node._error = undefined;
9737
+ if (node._x?._error === actualError && !(node._statusFlags & (STATUS_PENDING | STATUS_ERROR)))
9738
+ if (node._x !== null) node._x._error = undefined;
8343
9739
  }
8344
9740
  // An ERROR the chain could not deliver to any boundary is uncaught. The
8345
9741
  // scrub above already removed it from reader-visible state, so without
@@ -8579,13 +9975,13 @@ class CollectionQueue extends Queue {
8579
9975
  return super.notify(node, type, flags, error);
8580
9976
  if (flags & this._collectionType) {
8581
9977
  this._pending = true;
8582
- const source = error?.source || node._error?.source;
9978
+ const source = error?.source || node._x?._error?.source;
8583
9979
  if (source) {
8584
9980
  const wasEmpty = this._sources.size === 0;
8585
9981
  this._sources.add(source);
8586
9982
  if (wasEmpty) setSignal(this._disabled, true);
8587
9983
  if (this._collectionType & STATUS_ERROR) {
8588
- setSignal(this._error, unwrapStatusError(source._error));
9984
+ setSignal(this._error, unwrapStatusError(source._x?._error));
8589
9985
  }
8590
9986
  }
8591
9987
  }
@@ -8600,7 +9996,7 @@ class CollectionQueue extends Queue {
8600
9996
  // sweep (finalizePureQueue after mark release) re-runs this check.
8601
9997
  if (
8602
9998
  source._flags & REACTIVE_DISPOSED ||
8603
- (!source._affectsCount &&
9999
+ (!source._x?._affectsCount &&
8604
10000
  !(source._statusFlags & this._collectionType) &&
8605
10001
  !(this._collectionType & STATUS_ERROR && source._statusFlags & STATUS_PENDING))
8606
10002
  )
@@ -8661,7 +10057,7 @@ function createCollectionBoundary(type, fn, fallback, onFn) {
8661
10057
  else throw e;
8662
10058
  }
8663
10059
  queue._pending =
8664
- pending || !!(tree._statusFlags & type) || tree._error instanceof NotReadyError;
10060
+ pending || !!(tree._statusFlags & type) || tree._x?._error instanceof NotReadyError;
8665
10061
  });
8666
10062
  const controller =
8667
10063
  _revealUsed && type === STATUS_PENDING ? getContext(RevealControllerContext) : null;