From 362fc2fed3b8df806369a6be12eb50251c2e87f3 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Mon, 29 Jun 2026 20:11:28 -0500 Subject: [PATCH] fix(signals): clear uninitialized optimistic async completion --- .../fix-optimistic-first-refresh-pending.md | 5 +++ packages/solid-signals/src/core/async.ts | 7 +++ .../tests/createOptimistic.test.ts | 45 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 .changeset/fix-optimistic-first-refresh-pending.md diff --git a/.changeset/fix-optimistic-first-refresh-pending.md b/.changeset/fix-optimistic-first-refresh-pending.md new file mode 100644 index 000000000..55267e22d --- /dev/null +++ b/.changeset/fix-optimistic-first-refresh-pending.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Fix `isPending` staying false on the first `refresh()` of an async `createOptimistic` accessor when `isPending` is the only reactive consumer. diff --git a/packages/solid-signals/src/core/async.ts b/packages/solid-signals/src/core/async.ts index 0463b34aa..87e24a6dd 100644 --- a/packages/solid-signals/src/core/async.ts +++ b/packages/solid-signals/src/core/async.ts @@ -211,6 +211,13 @@ export function handleAsync( el._pendingValue = value; else { el._value = value; + // First successful completion of a *resting* optimistic node (no active + // override) must clear STATUS_UNINITIALIZED, same as the `setter` branch + // above. Otherwise a node whose only consumer is `isPending(() => x())` + // — which never drives the observed commit path — stays UNINITIALIZED + // forever, and every later refresh() is misread as an initial load, so + // `isPending` never reports `true` (#2799 follow-up). + if (wasUninitialized) clearStatus(el, true); insertSubs(el); } el._time = clock; diff --git a/packages/solid-signals/tests/createOptimistic.test.ts b/packages/solid-signals/tests/createOptimistic.test.ts index 58c574d3b..42373cb01 100644 --- a/packages/solid-signals/tests/createOptimistic.test.ts +++ b/packages/solid-signals/tests/createOptimistic.test.ts @@ -939,6 +939,51 @@ describe("createOptimistic", () => { expect(isPending(() => $data())).toBe(false); }); + it("isPending effect fires on the FIRST refresh when isPending is the only consumer (#2799 follow-up)", async () => { + // The #2799 test above keeps the node alive with + // `createRenderEffect(data, () => {})` — an eager value-observer that + // happens to clear STATUS_UNINITIALIZED on the initial load. When the only + // consumer is a reactive `isPending(() => data())` (the real JSX case), + // nothing cleared that flag on first completion, so the *first* refresh() + // was misread as an initial load: the effect never observed `true` until a + // second refresh. + let resolveFetch: ((v: number[]) => void) | null = null; + const makeFetch = () => new Promise(r => (resolveFetch = r)); + + const seen: boolean[] = []; + let $data!: SourceAccessor; + createRoot(() => { + const [data] = createOptimistic(() => makeFetch()); + $data = data; + // isPending is the sole consumer — no eager read of data(). + createEffect( + () => isPending(() => data()), + p => { + seen.push(p); + } + ); + }); + + // Initial load settles — no stale data yet, so isPending stays false. + flush(); + resolveFetch!([1, 2, 3]); + await Promise.resolve(); + flush(); + expect(seen.at(-1)).toBe(false); + + // The very FIRST refresh must drive the effect to `true`. + refresh($data); + flush(); + expect(seen.at(-1)).toBe(true); + + // Settling the refetch swaps in the new value and clears pending. + resolveFetch!([4, 5, 6]); + await Promise.resolve(); + flush(); + expect($data()).toEqual([4, 5, 6]); + expect(seen.at(-1)).toBe(false); + }); + it("plain optimistic stays true through refresh-of-unrelated-async (issue #2685)", async () => { // github.com/solidjs/solid/issues/2685 — the optimistic signal itself // is plain (no async source); the async work comes from refresh()ing