From c79f50329eda493ad3734f525be4439caf3b3bc2 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Wed, 30 Sep 2026 15:27:36 +0000 Subject: [PATCH 1/2] fix(signals): derived store sync landing wakes parked readers (#3726) A derived store whose first run returned a pending promise and whose rerun landed synchronously kept STATUS_UNINITIALIZED on the projection computed until the flush commit, because recompute only clears the flag on a creation pass. The recompute-side settle walk (#3181) requires the node to be initialized, so it skipped the projection, and readers subscribed to a store node the landing left unchanged (`"length" in store` on an array seed, `Object.keys`, an unchanged `length`) had no value notification to fall back on. They stayed pending, blank inside a loading boundary, while a sibling read of the source updated. The projection's sync commit through the setter now retires the flag the way asyncWrite does after its setter landing, so the walk releases those readers in the same flush. The retirement is gated on the previous run having been pending, which leaves a born-held creation pass unchanged. --- ...erived-store-sync-landing-wakes-readers.md | 5 ++ packages/signals/src/store/next/projection.ts | 4 + .../store/derived-presence-async-3726.test.ts | 85 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 .changeset/derived-store-sync-landing-wakes-readers.md create mode 100644 packages/signals/tests/store/derived-presence-async-3726.test.ts diff --git a/.changeset/derived-store-sync-landing-wakes-readers.md b/.changeset/derived-store-sync-landing-wakes-readers.md new file mode 100644 index 000000000..69a7cb8f5 --- /dev/null +++ b/.changeset/derived-store-sync-landing-wakes-readers.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A derived store whose first flight is superseded by a synchronous landing now wakes every reader parked on that flight (#3726). When the derive returned a pending promise and a later source write made it return a value synchronously, the projection computed kept `STATUS_UNINITIALIZED` until the flush commit, so the recompute-side settle walk (#3181) that requires initialization skipped it. Readers whose store node the landing left unchanged (`"length" in store` on an array seed, `Object.keys`, an unchanged `length`) had no value notification to fall back on and stayed pending, blank inside a `` boundary while a sibling read of the source updated. The sync commit through the setter now retires the flag the way the async landing does, so the walk releases those readers in the same flush. diff --git a/packages/signals/src/store/next/projection.ts b/packages/signals/src/store/next/projection.ts index ac360657d..370c6b75a 100644 --- a/packages/signals/src/store/next/projection.ts +++ b/packages/signals/src/store/next/projection.ts @@ -21,6 +21,7 @@ import { handleAsync, isDisposed, STATUS_PENDING, + STATUS_UNINITIALIZED, type Computed, type Refreshable } from "../../core/index.js"; @@ -303,6 +304,9 @@ export function runProjectionComputedNext( }; const sync = handleAsync(owner, result, commit); if (!owner._loading) commit(sync as void | T); + // A sync landing over a pending flight initializes as asyncWrite's does (#3181 walk). + if (owner._x?._inFlight == null && owner._statusFlags & STATUS_PENDING) + owner._statusFlags &= ~STATUS_UNINITIALIZED; }, false ); diff --git a/packages/signals/tests/store/derived-presence-async-3726.test.ts b/packages/signals/tests/store/derived-presence-async-3726.test.ts new file mode 100644 index 000000000..277449ac8 --- /dev/null +++ b/packages/signals/tests/store/derived-presence-async-3726.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + createLoadingBoundary, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush +} from "../../src/index.js"; + +const tick = () => new Promise(r => setTimeout(r, 0)); + +describe("sync landing after a pending first flight wakes unchanged-node readers (#3726)", () => { + it("presence read inside a loading boundary shows the landed answer", async () => { + const [source, setSource] = createSignal(null); + let resolve!: (v: number[]) => void; + let presence = ""; + let sourceState = ""; + createRoot(() => { + const [store] = createStore(() => { + const s = source(); + if (s) return s; + return new Promise(r => (resolve = r)); + }, []); + createLoadingBoundary( + () => { + createRenderEffect( + () => ("length" in store ? "present" : "missing"), + v => { + presence = v; + } + ); + createRenderEffect( + () => (source() ? "resolved" : "pending"), + v => { + sourceState = v; + } + ); + }, + () => "fallback" + ); + }); + flush(); + expect(presence).toBe(""); + expect(sourceState).toBe("pending"); + + setSource([1]); + resolve([1]); + flush(); + await tick(); + flush(); + expect(sourceState).toBe("resolved"); + expect(presence).toBe("present"); + }); + + it("readers of presence, length and keys wake when the landing leaves them unchanged", () => { + const [source, setSource] = createSignal<{ a?: number } | null>(null); + const seen: string[] = []; + createRoot(() => { + const [store] = createStore<{ a?: number }>(() => { + const s = source(); + if (s) return s; + return new Promise<{ a?: number }>(() => {}); + }, {}); + createRenderEffect( + () => ("a" in store ? "present" : "missing"), + v => { + seen.push(`in:${v}`); + } + ); + createRenderEffect( + () => Object.keys(store).length, + v => { + seen.push(`keys:${v}`); + } + ); + }); + flush(); + expect(seen).toEqual([]); + + setSource({}); + flush(); + expect(seen.sort()).toEqual(["in:missing", "keys:0"]); + }); +}); From 37ac48033a86ada25e88ade4abfbf25882fbc1e6 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Wed, 30 Sep 2026 15:47:19 +0000 Subject: [PATCH 2/2] fix: address review findings The projection computed retires STATUS_UNINITIALIZED after a synchronous landing only when the pass is mainline or its transaction is parked by nothing but the node's own flight, so a landing staged by a live action or beside another pending source keeps the seed invisible until that transaction commits. The gate is keyed on the node's own pending-source entry instead of any STATUS_PENDING, so a first run parked on an upstream async source no longer trips it. The tests assert in the flush that lands the sync value, register a length reader, and pin the transaction-held case. --- ...erived-store-sync-landing-wakes-readers.md | 2 +- packages/signals/src/store/next/projection.ts | 16 +++++- .../store/derived-presence-async-3726.test.ts | 55 +++++++++++++++---- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/.changeset/derived-store-sync-landing-wakes-readers.md b/.changeset/derived-store-sync-landing-wakes-readers.md index 69a7cb8f5..0ed0f2fb4 100644 --- a/.changeset/derived-store-sync-landing-wakes-readers.md +++ b/.changeset/derived-store-sync-landing-wakes-readers.md @@ -2,4 +2,4 @@ "@solidjs/signals": patch --- -A derived store whose first flight is superseded by a synchronous landing now wakes every reader parked on that flight (#3726). When the derive returned a pending promise and a later source write made it return a value synchronously, the projection computed kept `STATUS_UNINITIALIZED` until the flush commit, so the recompute-side settle walk (#3181) that requires initialization skipped it. Readers whose store node the landing left unchanged (`"length" in store` on an array seed, `Object.keys`, an unchanged `length`) had no value notification to fall back on and stayed pending, blank inside a `` boundary while a sibling read of the source updated. The sync commit through the setter now retires the flag the way the async landing does, so the walk releases those readers in the same flush. +A derived store whose pending first flight is superseded by a synchronous landing now wakes the readers parked on that flight in the same flush, including readers whose store node the landing left unchanged (`"length" in store`, `Object.keys`). A landing staged by a transaction keeps the seed invisible until that transaction commits. diff --git a/packages/signals/src/store/next/projection.ts b/packages/signals/src/store/next/projection.ts index 370c6b75a..0ac59d4a3 100644 --- a/packages/signals/src/store/next/projection.ts +++ b/packages/signals/src/store/next/projection.ts @@ -18,6 +18,7 @@ import { computed, CONFIG_AUTO_DISPOSE, getOwner, + GlobalQueue, handleAsync, isDisposed, STATUS_PENDING, @@ -27,6 +28,7 @@ import { } from "../../core/index.js"; import { + activeTransition, projectionWriteActive, scheduleWithheld, setProjectionWriteActive @@ -240,6 +242,16 @@ function cloneState(v: T, shallow: boolean): T { return shallow ? (Array.isArray(v) ? (v.slice() as T) : { ...v }) : JSON.parse(JSON.stringify(v)); } +// Held past this flush: the transaction is parked by more than the node's own flight. +function landingHeld(owner: Computed): boolean { + const t = activeTransition; + if (t === null) return false; + if (t._actions.length || GlobalQueue._transitionBlocked?.(t)) return true; + for (const source of t._asyncReporters.keys()) + if (source !== owner && source._x?._pendingSources?.size) return true; + return false; +} + export function runProjectionComputedNext( wrappedStore: Store, fn: (draft: T) => void | T | Promise | AsyncIterable, @@ -304,8 +316,8 @@ export function runProjectionComputedNext( }; const sync = handleAsync(owner, result, commit); if (!owner._loading) commit(sync as void | T); - // A sync landing over a pending flight initializes as asyncWrite's does (#3181 walk). - if (owner._x?._inFlight == null && owner._statusFlags & STATUS_PENDING) + // Recompute's settle walk for a superseded flight skips an uninitialized node. + if (owner._x?._pendingSources?.has(owner) && !landingHeld(owner)) owner._statusFlags &= ~STATUS_UNINITIALIZED; }, false diff --git a/packages/signals/tests/store/derived-presence-async-3726.test.ts b/packages/signals/tests/store/derived-presence-async-3726.test.ts index 277449ac8..fb967cab2 100644 --- a/packages/signals/tests/store/derived-presence-async-3726.test.ts +++ b/packages/signals/tests/store/derived-presence-async-3726.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "vitest"; import { + NotReadyError, + action, createLoadingBoundary, createRenderEffect, createRoot, createSignal, createStore, - flush + flush, + untrack, + type Store } from "../../src/index.js"; const tick = () => new Promise(r => setTimeout(r, 0)); @@ -45,29 +49,37 @@ describe("sync landing after a pending first flight wakes unchanged-node readers expect(sourceState).toBe("pending"); setSource([1]); - resolve([1]); flush(); + expect(sourceState).toBe("resolved"); + expect(presence).toBe("present"); + + resolve([2, 2]); await tick(); flush(); - expect(sourceState).toBe("resolved"); expect(presence).toBe("present"); }); it("readers of presence, length and keys wake when the landing leaves them unchanged", () => { - const [source, setSource] = createSignal<{ a?: number } | null>(null); + const [source, setSource] = createSignal(null); const seen: string[] = []; createRoot(() => { - const [store] = createStore<{ a?: number }>(() => { + const [store] = createStore(() => { const s = source(); if (s) return s; - return new Promise<{ a?: number }>(() => {}); - }, {}); + return new Promise(() => {}); + }, []); createRenderEffect( - () => ("a" in store ? "present" : "missing"), + () => (0 in store ? "present" : "missing"), v => { seen.push(`in:${v}`); } ); + createRenderEffect( + () => store.length, + v => { + seen.push(`length:${v}`); + } + ); createRenderEffect( () => Object.keys(store).length, v => { @@ -78,8 +90,31 @@ describe("sync landing after a pending first flight wakes unchanged-node readers flush(); expect(seen).toEqual([]); - setSource({}); + setSource([]); + flush(); + expect(seen.sort()).toEqual(["in:missing", "keys:0", "length:0"]); + }); + + it("a landing held by a transaction keeps the seed invisible until the commit", async () => { + const [source, setSource] = createSignal(null); + let release!: () => void; + let store!: Store; + createRoot(() => { + [store] = createStore(() => source() ?? new Promise(() => {}), [9, 9, 9]); + }); + flush(); + + const done = action(function* () { + setSource([1]); + yield new Promise(r => (release = r)); + })(); + flush(); + expect(() => untrack(() => [...store])).toThrow(NotReadyError); + + release(); + await done; + await tick(); flush(); - expect(seen.sort()).toEqual(["in:missing", "keys:0"]); + expect(untrack(() => [...store])).toEqual([1]); }); });