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