From 68be911dd8ed6c3c684b586bbfda20072109db33 Mon Sep 17 00:00:00 2001 From: Gabriel Valfridsson Date: Wed, 4 Nov 2026 15:28:42 +0000 Subject: [PATCH] fix(signals): guard node-less store staging reads --- .changeset/guard-nodeless-store-staging.md | 5 ++ packages/signals/src/core/core.ts | 2 +- .../store/nodeless-staged-verdict.test.ts | 61 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 .changeset/guard-nodeless-store-staging.md create mode 100644 packages/signals/tests/store/nodeless-staged-verdict.test.ts diff --git a/.changeset/guard-nodeless-store-staging.md b/.changeset/guard-nodeless-store-staging.md new file mode 100644 index 000000000..f77f30610 --- /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. diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 113b33a15..e785dbd09 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -2589,7 +2589,7 @@ export function stagedScreen(c: Computed, el?: Signal | Computed) (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!)) + (observeFlightHook && el && holdStagingHook!(c, el)) ) return false; // A transaction only a `latest()` read opened holds nothing: the staging 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(); + } + }); + } +}