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..0ed0f2fb4 --- /dev/null +++ b/.changeset/derived-store-sync-landing-wakes-readers.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +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 ac360657d..0ac59d4a3 100644 --- a/packages/signals/src/store/next/projection.ts +++ b/packages/signals/src/store/next/projection.ts @@ -18,14 +18,17 @@ import { computed, CONFIG_AUTO_DISPOSE, getOwner, + GlobalQueue, handleAsync, isDisposed, STATUS_PENDING, + STATUS_UNINITIALIZED, type Computed, type Refreshable } from "../../core/index.js"; import { + activeTransition, projectionWriteActive, scheduleWithheld, setProjectionWriteActive @@ -239,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, @@ -303,6 +316,9 @@ export function runProjectionComputedNext( }; const sync = handleAsync(owner, result, commit); if (!owner._loading) commit(sync as void | T); + // 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 new file mode 100644 index 000000000..fb967cab2 --- /dev/null +++ b/packages/signals/tests/store/derived-presence-async-3726.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + NotReadyError, + action, + createLoadingBoundary, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + untrack, + type Store +} 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]); + flush(); + expect(sourceState).toBe("resolved"); + expect(presence).toBe("present"); + + resolve([2, 2]); + await tick(); + flush(); + expect(presence).toBe("present"); + }); + + it("readers of presence, length and keys wake when the landing leaves them unchanged", () => { + const [source, setSource] = createSignal(null); + const seen: string[] = []; + createRoot(() => { + const [store] = createStore(() => { + const s = source(); + if (s) return s; + return new Promise(() => {}); + }, []); + createRenderEffect( + () => (0 in store ? "present" : "missing"), + v => { + seen.push(`in:${v}`); + } + ); + createRenderEffect( + () => store.length, + v => { + seen.push(`length:${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", "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(untrack(() => [...store])).toEqual([1]); + }); +});