From 4a63b5af019598fbabdd46df65c15d7d615be392 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 01:57:28 -0700 Subject: [PATCH] fix(signals): root the awaited refresh() waiter in every tier (#3888) An unowned computed is auto-disposing, and the settle walk releases an unobserved one instead of re-running it, so the prod/observe waiter (built without a root to save bytes) never saw the refetch land: an awaited refresh() and an action's yield refresh(x) hung, and the refetched value never rendered. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/fix-awaited-refresh-production.md | 5 ++ packages/signals/src/signals.ts | 13 +++-- packages/signals/tests/dist-artifacts.test.ts | 57 +++++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-awaited-refresh-production.md diff --git a/.changeset/fix-awaited-refresh-production.md b/.changeset/fix-awaited-refresh-production.md new file mode 100644 index 000000000..8b69e12e0 --- /dev/null +++ b/.changeset/fix-awaited-refresh-production.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Fix an awaited `refresh()` never settling in production builds: the waiter is always created under a root, so it re-runs when the refetch lands instead of being released as an unobserved node. `yield refresh(x)` in an action now finishes, and the refetched value reaches the UI. diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index 00207ea00..08b8656ac 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -907,11 +907,12 @@ export function refresh( const own = flushTransaction; const promise = new Promise((res, rej) => { queueMicrotask(() => { - // No createRoot: the microtask has no ambient owner, so the watcher is - // naturally detached, and settle disposes the node directly (dev keeps - // a root husk purely to stay quiet about the missing owner). The - // capture runs inside the watcher's own pass, where the ambient owner - // IS the watcher — exactly what dispose() takes. + // The root is load-bearing: an ownerless computed is auto-disposing, + // and the settle walk releases an unobserved one instead of re-running + // it, so the waiter would never see the refetch land (#3888). Settle + // disposes the waiter directly; the capture runs inside the watcher's + // own pass, where the ambient owner IS the watcher — exactly what + // dispose() takes. let waiter: Computed | null = null; const make = () => watch( @@ -931,7 +932,7 @@ export function refresh( undefined, own ); - __DEV__ ? createRoot(make) : make(); + createRoot(make); }); }); // Fire-and-forget refresh must not turn a failed refetch into an unhandled diff --git a/packages/signals/tests/dist-artifacts.test.ts b/packages/signals/tests/dist-artifacts.test.ts index 70e8886d3..8e6cb88e0 100644 --- a/packages/signals/tests/dist-artifacts.test.ts +++ b/packages/signals/tests/dist-artifacts.test.ts @@ -232,6 +232,63 @@ describe("@solidjs/signals cleanup order per tier", () => { } }); +describe("@solidjs/signals awaited refresh per tier", () => { + // #3888: the waiter `refresh()` returns is the same node in every tier, but + // prod once built it without a root, and an unowned waiter was released by + // the settle walk instead of re-running — the promise never settled. Only + // the artifacts show the tier split. + const cores: Record Promise> = { + prod: () => import("../dist/prod/index.js"), + observe: () => import("../dist/observe/index.js"), + dev: () => import("../dist/dev.js") + }; + const settle = async (flush: () => void) => { + for (let i = 0; i < 5; i++) { + flush(); + await new Promise(r => setTimeout(r)); + } + }; + async function mount(core: any) { + let calls = 0; + const seen: number[] = []; + let value!: () => number; + const dispose = core.createRoot((dispose: () => void) => { + value = core.createMemo(() => Promise.resolve(++calls)); + core.createRenderEffect(value, (v: number) => void seen.push(v)); + return dispose; + }); + await settle(core.flush); + expect(seen).toEqual([1]); + return { value, seen, dispose }; + } + for (const tier of Object.keys(TIERS) as (keyof typeof TIERS)[]) { + test(`${tier}: await refresh(x) delivers the refetch`, async () => { + const core = await cores[tier](); + const { value, seen, dispose } = await mount(core); + let out: unknown = "pending"; + core.refresh(value).then((v: unknown) => (out = v)); + await settle(core.flush); + expect(out).toBe(2); + expect(seen).toEqual([1, 2]); + dispose(); + }); + + test(`${tier}: an action's yield refresh(x) finishes and the refetch renders`, async () => { + const core = await cores[tier](); + const { value, seen, dispose } = await mount(core); + let out: unknown = "pending"; + const save = core.action(function* () { + return yield core.refresh(value); + }); + save().then((v: unknown) => (out = v)); + await settle(core.flush); + expect(out).toBe(2); + expect(seen).toEqual([1, 2]); + dispose(); + }); + } +}); + describe("@solidjs/signals node literals per tier", () => { // Each node factory has two object literals — prod, and observe = prod plus // its diagnostic slots (`_name`; `_owner` on signals) — selected at build