diff --git a/.changeset/guard-nodeless-store-staging.md b/.changeset/guard-nodeless-store-staging.md new file mode 100644 index 000000000..2d2ddd202 --- /dev/null +++ b/.changeset/guard-nodeless-store-staging.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Prevent an untracked staged-store read beside `latest()` from throwing and halting reactivity when the store has no reactive node. A pass whose verdict posture is only inherited is now served the hold's own write from a node-less store key, as it is from a signal or a key with a node, so the landing frame no longer tears. diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index a37ac59e1..07704490f 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -206,7 +206,7 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1 What is rendered from a display-ahead read reveals together with it: an async memo over `latest(x)` holds the `latest` view's reveal until it lands; a combined `isPending(() => [fast(), copy()])` over two async memos, one wrapped in a sync memo, stays `true` until both land, because a memo's value is one shared slot — it computes under its own hold posture, never under the posture of whichever reader pulled it. A node whose fetch a hold is waiting on is that hold's work, not a verdict reader: a pass that re-runs it while its fetch is in flight reads plainly — following the change it is fetching for — even if an earlier pass of it called `isPending` or `latest` (#3884). A memo's posture is what it reads: one that reads a verdict reader's value is a derivation of a display-ahead read, and a verdict reader itself — `createMemo(() => [held(), V()])` with `V = createMemo(() => isPending(x) && latest(e))` shows `V`'s verdict now, beside the screen's `held()`, as the inline probe and a render effect reading both do (#4050: a Provider's children memo over a router shell). **The probe decides the pass's posture in either read order (clarified 2026-10-28):** a pass that probes is a verdict reader whether the probe came before or after a plain read of the same held node — `[x(), isPending(x)]` and `[isPending(x), x()]` both show `[committed, true]` now and re-derive at the landing (A10) — unless the pass is the hold's own work: born in its world (D-27, #4341) or re-run by the write it holds (#3884 above). Those two read plainly; a probe does not rescue them. A pass that read two holds' display-ahead values is one reveal unit of both (A15 lanes corollary, #3335): it waits for either lane's flight, never showing one hold's `latest()` beside a half-computed derivation of the other's. -**A verdict posture outlives the pass that asked (ruled 2026-10-28):** a pass that asked (`isPending`, `latest`) is the hold's verdict reader on every later run of its node until one completes having asked nothing — that run still reads as a verdict reader; the next is an ordinary derivation. On each such run its plain read of a node the hold holds is the screen's (a frame reader, A10; re-derived at the landing as A15's stale reader) and it joins nothing, whoever dirtied it — a sync write, a boundary redraw, a landing. The hold's own work is not in this: a node whose fetch the hold waits on, or a pass born in its world, reads plainly whatever it asked before (the #3884 clause and D-27 #4341 above, unchanged). A boundary's display-ahead swap is this posture handed on: an `on` pass that read `latest`/`isPending` is a verdict reader, and the fallback swap it asks for runs as that reader's work — the output's pass reads nothing of the held frame (a pending tree is linked, not read) and its answer, the fallback, is shown now beside the held frame and stays the screen until the landing, instead of being staged with the write's frame as a plain `on`'s swap is (D-15): `[A] → [A + spinner] → [B + spinner] → [B + comments]`, never `[A] → [B + spinner]`. Only the `on` pass's asking counts: `latest()` inside the fallback is the fallback's own display-ahead, not the swap's — a plain `on` over an action-held input lands the swap with the frame whatever the fallback reads (#4337). Today two memories carry this — `CONFIG_VERDICT` left on the node by the pass that probed, and the node's listing in the hold's verdict lane (`fallback`'s `b._lane`, `recompute`'s head) — and the rule is the posture, not the membership. Pins: `tests/loading-on-rearm-3540.test.ts`, `tests/loading-on-frame-following-3540.test.ts` §4–5, `tests/loading-on-latest-fallback-4337.test.ts`. **An inherited posture is not a probe (2026-11-02, #4544):** a pass whose verdict posture is only inherited — it read a verdict memo, and neither it nor its node's last pass opened a window of its own — is the hold's work where it reads the hold's own write (a sync write the hold holds, not a flight's answer): it is served that staging and joins the hold, as a plain derivation does. One flush or several make no difference (F-10): before the flush parks, the pass reads the flush's own write as that staging, leaving the verdict lane an earlier read of the verdict memo seated it in, as the other read order keeps it out. Not a staging a window asked of under the hold that holds it (or in this flush, before one): `[latest(x), x()]` through a memo stays atomic (A10, D-26: `tests/latest-memo-inline-parity.test.ts`). That mark is the hold's — it expires when the hold lands or dissolves, and a later hold reads the node as never asked. A pass that probes in its own body stays the hold's verdict reader and reads the screen (F-6 over a staging it holds). When a frame born into the hold (a render effect never committed) reads such a reader's settled value and goes pending on what that display-ahead value asks for, the pending does not hold the hold (F-6: display-ahead readers never hold the change they read ahead of): the hold lands, the reader re-derives at the landing (A15) and the frame with it. A lane's reveal still waits on it (S-29). Pins: `tests/verdict-memo-held-branch-4544.test.ts`. +**A verdict posture outlives the pass that asked (ruled 2026-10-28):** a pass that asked (`isPending`, `latest`) is the hold's verdict reader on every later run of its node until one completes having asked nothing — that run still reads as a verdict reader; the next is an ordinary derivation. On each such run its plain read of a node the hold holds is the screen's (a frame reader, A10; re-derived at the landing as A15's stale reader) and it joins nothing, whoever dirtied it — a sync write, a boundary redraw, a landing. The hold's own work is not in this: a node whose fetch the hold waits on, or a pass born in its world, reads plainly whatever it asked before (the #3884 clause and D-27 #4341 above, unchanged). A boundary's display-ahead swap is this posture handed on: an `on` pass that read `latest`/`isPending` is a verdict reader, and the fallback swap it asks for runs as that reader's work — the output's pass reads nothing of the held frame (a pending tree is linked, not read) and its answer, the fallback, is shown now beside the held frame and stays the screen until the landing, instead of being staged with the write's frame as a plain `on`'s swap is (D-15): `[A] → [A + spinner] → [B + spinner] → [B + comments]`, never `[A] → [B + spinner]`. Only the `on` pass's asking counts: `latest()` inside the fallback is the fallback's own display-ahead, not the swap's — a plain `on` over an action-held input lands the swap with the frame whatever the fallback reads (#4337). Today two memories carry this — `CONFIG_VERDICT` left on the node by the pass that probed, and the node's listing in the hold's verdict lane (`fallback`'s `b._lane`, `recompute`'s head) — and the rule is the posture, not the membership. Pins: `tests/loading-on-rearm-3540.test.ts`, `tests/loading-on-frame-following-3540.test.ts` §4–5, `tests/loading-on-latest-fallback-4337.test.ts`. **An inherited posture is not a probe (2026-11-02, #4544):** a pass whose verdict posture is only inherited — it read a verdict memo, and neither it nor its node's last pass opened a window of its own — is the hold's work where it reads the hold's own write (a sync write the hold holds, not a flight's answer): it is served that staging and joins the hold, as a plain derivation does. One flush or several make no difference (F-10): before the flush parks, the pass reads the flush's own write as that staging, leaving the verdict lane an earlier read of the verdict memo seated it in, as the other read order keeps it out. Not a staging a window asked of under the hold that holds it (or in this flush, before one): `[latest(x), x()]` through a memo stays atomic (A10, D-26: `tests/latest-memo-inline-parity.test.ts`). That mark is the hold's — it expires when the hold lands or dissolves, and a later hold reads the node as never asked. A pass that probes in its own body stays the hold's verdict reader and reads the screen (F-6 over a staging it holds). When a frame born into the hold (a render effect never committed) reads such a reader's settled value and goes pending on what that display-ahead value asks for, the pending does not hold the hold (F-6: display-ahead readers never hold the change they read ahead of): the hold lands, the reader re-derives at the landing (A15) and the frame with it. A lane's reveal still waits on it (S-29). A store key is served the same with or without a node (R2, signal parity): a node-less staging is this flush's own write with no node a window could have asked through, so the posture alone decides (2026-11-04, #4681). Pins: `tests/verdict-memo-held-branch-4544.test.ts`, `tests/posture-store-parity.test.ts` S9. **Collapses:** [A31](./SPEC-ASYNC-SEMANTICS.md#a31-a-memo-computes-under-its-own-lane-posture-never-its-pullers) (#3442), §20.1 question 3 ("Yeah definitely it is held. Consistent with any optimistic update"), OL-R24/R25/R34, recovery 6.2. **Status:** standing. A memo over a verdict memo: `tests/verdict-memo-under-held-memo-4050.test.ts`, `packages/web/test/held-shell-pending-feedback-4050.spec.tsx` (U-10). **Fixed 2026-11-01:** the verdict shortcut in `propagateStatus` (a probed source settling re-derives its verdict reader) now clears the settled source from the reader's pending sources — left in place, an errored probe target stranded a reader re-parked on a second source at that source's landing; `tests/verdict-shortcut-pending-source-liveness.test.ts`. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 324fdb079..21476611b 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -29,7 +29,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | INV | 11 | 4 | 7 | 4 | | RUL | 13 | 3 | 6 | 6 | | R (CS) | 59 | 15 | 20 | 30 | -| R (OL) | 37 | 4 | 1 | 32 | +| R (OL) | 37 | 5 | 1 | 31 | | R (OS) | 46 | 0 | 0 | 46 | | R (PJ) | 37 | 2 | 5 | 32 | | R (RS) | 38 | 0 | 2 | 36 | @@ -142,7 +142,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| | CS-R1 | live | `docs/rules-mining/core-store.md:9` | store.ts×2 | continuation-sibling-reader-4146.test.ts×2 flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 visibility-oracle-store.states.ts×1 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).** | -| CS-R2 | live | `docs/rules-mining/core-store.md:13` | — | syncThenable.test.ts×12 visibility-oracle.states.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).** | +| CS-R2 | live | `docs/rules-mining/core-store.md:13` | — | posture-store-parity.test.ts×1 syncThenable.test.ts×12 visibility-oracle.states.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).** | | CS-R2a | live | `docs/INTERNALS-STORE-STATE.md:107` | — | — | Corollary R2a (Ryan, 2026-08-17): **take no responsibility for mutation | | CS-R3 | live | `docs/rules-mining/core-store.md:18` | — | — | A store proxy ingested into another store (deep or shallow) is re-wrapped in the ingesting store's own proxy family — never identity-passed, never raw-marked.** | | CS-R4 | live | `docs/rules-mining/core-store.md:22` | — | — | Write isolation across a store chain: writing through the last store in a derived chain is visible only there; upstream stores and base objects untouched (shallow or deep middle).** | @@ -205,7 +205,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| | OL-R1 | live | `docs/rules-mining/optimistic-lanes.md:11` | — | — | `createOptimistic(value \| fn)` returns `[accessor, setter]`; the accessor returns the initial or computed value; the setter accepts a value or an updater function. | -| OL-R2 | superseded | `docs/rules-mining/optimistic-lanes.md:15` | — | — | ~~An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context.~~ **Superseded 2026-09-10 by A28(5)** (SPEC-ASYN… | +| OL-R2 | superseded | `docs/rules-mining/optimistic-lanes.md:15` | verdict.ts×1 | — | ~~An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context.~~ **Superseded 2026-09-10 by A28(5)** (SPEC-ASYN… | | OL-R3 | live | `docs/rules-mining/optimistic-lanes.md:19` | — | — | The setter's updater receives the current _visible_ (optimistic-if-overridden) value, never the committed value; a plain setter on the underlying source during a transition composes on the transition'… | | OL-R4 | live | `docs/rules-mining/optimistic-lanes.md:23` | seat.ts×3 | — | Multiple optimistic writes before settle compose sequentially (each updater sees the prior override; last write wins). | | OL-R5 | live | `docs/rules-mining/optimistic-lanes.md:27` | lanes.ts×2 scheduler.ts×2 seat.ts×2 types.ts×1 verdict.ts×1 | — | An optimistic write **outside any action** reverts at the next flush; subscribers observe the optimistic value and then the reverted value within that single flush (effect log `[1, 2, 1]` after one `f… | diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 113b33a15..82dc36ac1 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -2588,8 +2588,9 @@ export function stagedScreen(c: Computed, el?: Signal | Computed) bornInto(c, flushTransaction) || (el?._config! & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD || // #4544: this flush's own write, read by a pass whose posture is only - // inherited, is the frame's derivation, not a probe of it. - (observeFlightHook && holdStagingHook!(c, el!)) + // inherited, is the frame's derivation, not a probe of it — a store's + // node-less staging (no `el`) too. + (observeFlightHook && holdStagingHook!(c, el)) ) return false; // A transaction only a `latest()` read opened holds nothing: the staging diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index da526f35a..99351a9e7 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -192,7 +192,7 @@ export let keepReadersHook: ((el: Computed) => void) | null = null; export let attachHook: ((node: Computed) => boolean) | null = null; /** #4544 (verdict.ts). Null without the verdict engine. */ export let holdStagingHook: - | ((c: Computed, el: Signal | Computed) => unknown) + | ((c: Computed, el?: Signal | Computed) => unknown) | null = null; export function installVerdictHooks( observeFlight: NonNullable, diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 6aad2c205..f65716faf 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -869,22 +869,27 @@ function attachWaits(node: Computed): boolean { * that staging — held (`frameRead`), or this flush's before it parks * (`stagedScreen`), leaving the verdict lane an earlier read of the verdict * memo seated it in (D-25: either read order). A frame (a render effect, a - * boundary's structure) reads the screen as before. */ -function holdStaging(c: Computed, el: Signal | Computed): unknown { - const a = el._x?._askedIn; + * boundary's structure) reads the screen as before. No `el` is a store + * key's node-less staging (store.ts `readSource`): by construction this + * flush's own sync write to a plain store, with no node for a window to + * have asked through — the posture alone decides, as it does for the + * signal (R2, signal parity). */ +function holdStaging(c: Computed, el?: Signal | Computed): unknown { + const a = el?._x?._askedIn; return ( !( c._config & (CONFIG_OWN_PROBE | CONFIG_FRAME) || c._flags & (REACTIVE_WINDOW | REACTIVE_PROBE_UNANSWERED) ) && - el._pendingValue !== NOT_PENDING && - !(el._config & (CONFIG_ASYNC | CONFIG_OVERRIDE)) && - a !== clock && - !( - typeof a == "object" && - resolveTx(a) === - resolveTx(el._config & CONFIG_HELD ? el._x!._transaction! : flushTransaction!) - ) && + (!el || + (el._pendingValue !== NOT_PENDING && + !(el._config & (CONFIG_ASYNC | CONFIG_OVERRIDE)) && + a !== clock && + !( + typeof a == "object" && + resolveTx(a) === + resolveTx(el._config & CONFIG_HELD ? el._x!._transaction! : flushTransaction!) + ))) && (!passLane || (passLane._parent!._verdict === passLane && (setPassLane(null), true))) ); } diff --git a/packages/signals/tests/posture-store-parity.test.ts b/packages/signals/tests/posture-store-parity.test.ts index af86f0bdd..eae7d5e60 100644 --- a/packages/signals/tests/posture-store-parity.test.ts +++ b/packages/signals/tests/posture-store-parity.test.ts @@ -41,6 +41,11 @@ * S8 — (fixed, 3b step 6c) a derivation's untracked read of a SUPERSEDED * store node derives from the landed truth (A18), not the override — * nodeValue delegates to core's one slow selection, `serve`. + * S9 — (fixed) a pass whose verdict posture is only inherited is served the + * hold's own write (D-25, #4544) from a store key with no node, as from + * the signal and from a key with a node: `stagedScreen`'s node-less call + * (store.ts `readSource`) used to skip `holdStaging` — first a throw + * (#4681), then the screen, which tore the landing frame. * Discovery for S4–S7: the matrix's `memoUntracked` / `effectUntracked` * reader kinds (an untracked read inside a derivation), added with S5. * @@ -604,3 +609,130 @@ describe("S8 — a derivation's UNTRACKED read of a superseded store node derive expect(log).toEqual([2]); }); }); + +const S9_SOURCES = [ + "signal", + "signal, untracked", + "store, tracked", + "store, untracked, container node", + "store, untracked, leaf node only", + "store, untracked, no node" +] as const; +type S9Source = (typeof S9_SOURCES)[number]; +type S9Posture = + | "inherited isPending" + | "inherited latest" + | "own latest" + | "latest of the same key, through a memo"; + +/** One flush writes a hold (`data` goes async) and `count`, and re-runs + * `pass` through an unrelated trigger, so an untracked read of `count` + * happens in the write's flush. `pass` reads a verdict memo (inherited + * posture), probes itself (own posture), or reads a memo over `latest` of + * the very source it reads (D-26). */ +async function s9Trace(source: S9Source, posture: S9Posture) { + const trace: string[] = []; + let resolve!: (v: number) => void; + let target!: { k: unknown; n?: Record }; + const app = createRoot(dispose => { + const [step, setStep] = createSignal(0); + const [trigger, setTrigger] = createSignal(0); + const [count, setCount] = createSignal(0); + const [state, setState] = createStore({ count: 0 }); + target = (state as any)[$TARGET]; + if (source === "store, untracked, container node") + createRenderEffect( + () => Object.keys(state), + () => {} + ); + if (source === "store, untracked, leaf node only") + createRenderEffect( + () => state.count, + () => {} + ); + const data = createMemo(() => { + const s = step(); + return s === 0 ? s : new Promise(r => (resolve = r)); + }); + const read = () => + source === "signal" + ? count() + : source === "signal, untracked" + ? untrack(count) + : source === "store, tracked" + ? state.count + : untrack(() => state.count); + const verdict = createMemo(() => + posture === "inherited isPending" + ? isPending(data) + ? 1 + : 0 + : posture === "latest of the same key, through a memo" + ? latest(source.startsWith("signal") ? count : () => state.count) + : latest(data) + ); + const pass = createMemo(() => { + trigger(); + const v = posture === "own latest" ? latest(data) : verdict(); + const c = read(); + trace.push(`pass ${v}/${c}`); + return `${v}/${c}`; + }); + createRenderEffect( + () => `${data()} ${pass()}`, + v => { + trace.push(`screen ${v}`); + } + ); + return { dispose, setStep, setTrigger, setCount, setState }; + }); + flush(); + const nodes = { container: target.k !== null, leaf: !!target.n?.count }; + app.setStep(1); + app.setCount(1); + app.setState(d => { + d.count = 1; + }); + app.setTrigger(1); + flush(); + trace.push("|"); + resolve(1); + await settle(); + app.dispose(); + return { trace: trace.join(" ; "), nodes }; +} + +describe("S9 — an inherited verdict posture is served the hold's own write, with or without a store node (D-25 #4544, R2) — signal vs store", () => { + // `|` is the landing. Mid-hold the inherited pass derives from the hold's + // write (`/1`), so the landing frame shows the landed `data` with it. + const expected: Record, string> = { + "inherited isPending": + "pass 0/0 ; screen 0 0/0 ; pass 1/1 ; | ; screen 1 1/1 ; pass 0/1 ; screen 1 0/1", + "inherited latest": "pass 0/0 ; screen 0 0/0 ; pass 0/1 ; | ; pass 1/1 ; screen 1 1/1", + // F-6: a pass that probes reads the screen and re-derives at the landing. + "own latest": "pass 0/0 ; screen 0 0/0 ; pass 0/0 ; | ; pass 1/0 ; pass 1/1 ; screen 1 1/1" + }; + for (const [posture, trace] of Object.entries(expected) as [keyof typeof expected, string][]) + for (const source of S9_SOURCES) + it(`${posture}: ${source}`, async () => { + const r = await s9Trace(source, posture); + expect(r.nodes).toEqual({ + container: source === "store, untracked, container node", + leaf: source === "store, tracked" || source === "store, untracked, leaf node only" + }); + expect(r.trace).toBe(trace); + }); + + // D-26: `[latest(x), x()]` through a memo stays atomic for every source. + // The window's tracked read gives the key a leaf, so the store's read is + // never node-less here and `_askedIn` decides on the leaf. Pinned as + // parity with the signal; the landing frame itself is not ruled here. + for (const source of S9_SOURCES.slice(1)) + it(`latest of the same key, through a memo: ${source} matches the signal`, async () => { + const posture = "latest of the same key, through a memo"; + const r = await s9Trace(source, posture); + expect(r.nodes.leaf).toBe(!source.startsWith("signal")); + expect(r.trace).toBe((await s9Trace("signal", posture)).trace); + expect(r.trace).toContain("pass 1/0 ; |"); + }); +}); diff --git a/packages/signals/tests/store/nodeless-staged-verdict.test.ts b/packages/signals/tests/store/nodeless-staged-verdict.test.ts new file mode 100644 index 000000000..8aa9617b8 --- /dev/null +++ b/packages/signals/tests/store/nodeless-staged-verdict.test.ts @@ -0,0 +1,61 @@ +import { afterEach, expect, it } from "vitest"; +import { + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + latest, + resetErrorHalt, + untrack +} from "../../src/index.js"; + +afterEach(() => { + resetErrorHalt(); + flush(); +}); + +for (const reader of ["render", "memo"] as const) { + for (const tracked of [false, true]) { + it(`${reader}: latest beside a ${tracked ? "tracked" : "node-less untracked"} store read settles and keeps updating`, async () => { + const requests = [Promise.withResolvers(), Promise.withResolvers()]; + let screen: number[] = []; + const app = createRoot(dispose => { + const [step, setStep] = createSignal(0); + const [state, setState] = createStore({ count: 0 }); + const data = createMemo(() => { + const current = step(); + return current === 0 ? current : requests[current - 1].promise; + }); + // An untracked first read leaves the store without a container node. + const read = () => [latest(data), tracked ? state.count : untrack(() => state.count)]; + const value = reader === "memo" ? createMemo(read) : read; + createRenderEffect(value, result => { + screen = result; + }); + return { dispose, setStep, setState }; + }); + + try { + flush(); + expect(screen).toEqual([0, 0]); + for (const current of [1, 2]) { + app.setStep(current); + app.setState(draft => { + draft.count = current; + }); + // Before the fix, stagedScreen forwards an absent signal to + // holdStaging and this flush throws while dereferencing its _x. + expect(() => flush()).not.toThrow(); + requests[current - 1].resolve(current); + await requests[current - 1].promise; + flush(); + expect(screen).toEqual([current, current]); + } + } finally { + app.dispose(); + } + }); + } +} diff --git a/packages/signals/tests/visibility-oracle-posture.test.ts b/packages/signals/tests/visibility-oracle-posture.test.ts index e7e5ef258..e1de979d8 100644 --- a/packages/signals/tests/visibility-oracle-posture.test.ts +++ b/packages/signals/tests/visibility-oracle-posture.test.ts @@ -30,7 +30,10 @@ * * Readers under a posture: untracked, memo (its pass value and what a render * effect over it publishes), effect (a render effect reading `x` DIRECTLY — - * the reporter shape the fuzzer's P1 reduction used), latest, isPending. + * the reporter shape the fuzzer's P1 reduction used), latest, isPending, and + * memoInheritedUntracked (a memo whose verdict posture is only inherited — + * it reads a verdict memo over an unrelated signal — reading `x` untracked: + * D-25 #4544's reader, posture-store-parity S9). * * This is the discovery pass: no cell is pinned. Every cell is recorded to * VISIBILITY_POSTURE_REPORT (a markdown table per state) so red cells can be @@ -109,6 +112,7 @@ const READERS = [ "untracked", "memo", "memoUntracked", + "memoInheritedUntracked", "effect", "effectUntracked", "latest", @@ -238,10 +242,14 @@ async function cell(state: State, posture: Posture, reader: Reader): Promise isPending(x)); break; case "memo": - case "memoUntracked": { + case "memoUntracked": + case "memoInheritedUntracked": { const d = createRoot(d => { + const [quiet] = createSignal(0); + const verdict = reader === "memoInheritedUntracked" && createMemo(() => latest(quiet)); const m = createMemo(() => { if (gated && !show()) return "gated"; + if (verdict) verdict(); let v: Cell; try { v = rd();