From 7ce3c98d537457d4455415ff35ce32a13bd2b27d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:26:59 +0000 Subject: [PATCH 1/3] fix(solid): an Errored fallback's own failure goes to the next boundary up; its wait is budgeted (#4316, #4317) #4316: when a fails after the shell and its renders the fallback as the outcome (renderOutcome), a fallback hole that rejected while the server waited for it was swallowed. The retry ran with no owner, so the hole's error never met a handler and the outcome came back empty. Parked in a pending outer , the inner fragment then settled as a blank child, and the client hydrated the blank and halted. A fallback that threw synchronously reached the parent handler instead, but in its no-consumer mode: an outer serialized its error record and rendered a fallback nobody sent. Now a failure of the fallback, sync or async, goes to the next boundary up. The fallback owner's handler holds it while the outcome renders, and the retry runs under that owner. The parent is first asked for the outcome at the same position, so an outer whose whole content is that position draws its fallback there and serializes its error with it. Otherwise the failure is routed as a content failure, so a pending outer fails its fragment. An outer reached that way while it is not being pulled lets it pass and does not render. #4317: the wait now shares the resume loop's convergence budget (MAX_RESUME_PASSES, internal; hydration.ts uses it in place of the 10000 literal). Past the budget it answers what is available, which is no finished fallback. It also races each wait against the fallback owner's teardown, so an aborted render stops waiting at once. Tests: sync-throw twins for the parked and unparked shapes, with and without an outer . The server harness holds the async variant's markup to the twin's. The hydrate spec holds its client outcome, loaded and streamed, to the twin's, and a caught variant must claim the outer fallback. Server specs cover the budget (a fresh pending source every pass) and the abort. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .../errored-fallback-hole-failure-4316.md | 7 + packages/solid/src/server/hydration.ts | 11 +- packages/solid/src/server/signals.ts | 102 ++++++++++++-- ...ored-fallback-hole-async-failure-4316.json | 5 + ...llback-hole-async-failure-caught-4316.json | 5 + ...llback-hole-async-failure-parked-4316.json | 5 + ...hole-async-failure-parked-caught-4316.json | 5 + ...rored-fallback-hole-sync-failure-4316.json | 5 + ...allback-hole-sync-failure-caught-4316.json | 5 + ...allback-hole-sync-failure-parked-4316.json | 5 + ...-hole-sync-failure-parked-caught-4316.json | 5 + packages/web/test/harness/scenarios.tsx | 75 ++++++++++ ...errored-parked-async-outcome-4306.spec.tsx | 131 +++++++++++++++++- .../errored-fallback-wait-4317.spec.tsx | 113 +++++++++++++++ .../test/server/hydration-harness.spec.tsx | 44 +++++- 15 files changed, 507 insertions(+), 16 deletions(-) create mode 100644 .changeset/errored-fallback-hole-failure-4316.md create mode 100644 packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-4316.json create mode 100644 packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-caught-4316.json create mode 100644 packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-parked-4316.json create mode 100644 packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-parked-caught-4316.json create mode 100644 packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-4316.json create mode 100644 packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-caught-4316.json create mode 100644 packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-parked-4316.json create mode 100644 packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-parked-caught-4316.json create mode 100644 packages/web/test/server/errored-fallback-wait-4317.spec.tsx diff --git a/.changeset/errored-fallback-hole-failure-4316.md b/.changeset/errored-fallback-hole-failure-4316.md new file mode 100644 index 0000000000..66a3978a9e --- /dev/null +++ b/.changeset/errored-fallback-hole-failure-4316.md @@ -0,0 +1,7 @@ +--- +"solid-js": patch +--- + +Server: when an `` renders its fallback for a `` that failed after the shell, and a hole in that fallback rejects while the server waits for it, the rejection now goes to the next boundary up, as it does when a fallback throws synchronously. Inside a pending outer ``, that outer fragment fails; under an outer `` whose whole content is that position, the outer fallback is drawn there and the client adopts it. Before, the region streamed blank and the hydrating client halted (#4316). A synchronous throw in that fallback no longer writes an error record for an outer `` without sending its fallback. + +The server's wait for that fallback now stops after the same pass budget as the `` resume loop and then streams what it has. It also stops as soon as the render is torn down, for example when the request is aborted (#4317). diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index b62c6f2590..25fa95c24a 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -15,7 +15,8 @@ import { ownerId, onCleanup, inServerComponentScope, - edgeHooks + edgeHooks, + MAX_RESUME_PASSES } from "./signals.js"; import { OBSERVE } from "@solidjs/signals"; import { sharedConfig, NoHydrateContext, callerRenderContext } from "./shared.js"; @@ -717,15 +718,15 @@ function ssrLoadingBoundary( }; (async () => { try { - // Convergence budget: each pass should retire at least one async - // slot, so passes are bounded by the boundary's async-slot count in - // any converging render — real trees sit far below this. A shape + // Convergence budget (`MAX_RESUME_PASSES`): each pass should retire + // at least one async slot, so passes are bounded by the boundary's + // async-slot count in any converging render. A shape // that plants a fresh pending source every pass (an async read whose // answer is never adoptable at the re-created slot) would otherwise // loop at microtask speed, serializing a new deferred per pass until // the process OOMs (#3003). Fail the boundary loudly instead. const checkBudget = () => { - if (passes <= 10000) return; + if (passes <= MAX_RESUME_PASSES) return; throw new Error( ` boundary discovery did not converge after ${passes} passes — ` + `an async source produces a new pending answer on every retry. Ensure repeated ` + diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 5d493c6b8b..7fc2e1226f 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -3712,6 +3712,22 @@ export type BoundaryErrorHandler = (err: any, outcome?: true | string) => Bounda /** A rendered outcome; a function where it is still resolving (see renderOutcome). */ export type BoundaryOutcome = string | (() => Promise) | undefined | void; +/** + * Convergence budget of a boundary's resume loop: each pass should retire at + * least one async slot, so passes are bounded by the slot count in any + * converging render — real trees sit far below this. Shared by the + * `` resume loop (hydration.ts) and an `` outcome's wait + * for its fallback (renderOutcome). + * @internal + */ +export const MAX_RESUME_PASSES = 10000; + +// Set while an `` hands a failure of its own fallback up the +// handler chain as a content failure (see renderOutcome's `escalate`): an +// enclosing `` reached that way while it is not being pulled has +// no consumer for a fallback, so it lets the failure pass. +let escalating = 0; + const ErrorContext: Context = { id: Symbol("ErrorContext"), defaultValue: null @@ -4281,8 +4297,26 @@ export function createErrorBoundary( // resolving answers with a function instead: called, it waits out the // fallback's holes and answers with the finished markup (or `undefined`, // a client hole or a failure in it), serializing the error only then - // (#4306). - const renderOutcome = (err: any, outcome: true | string): BoundaryOutcome => { + // (#4306). The wait shares the resume loop's convergence budget: past it + // the outcome is what is available, the unfinished fallback none. It stops + // as soon as the render is torn down (#4317). + // A failure of the fallback itself — a hole that throws while it renders, + // or rejects while it is waited out — is the next boundary up's, as a + // fallback's failure is when it renders in the boundary's own pull + // (#4316): an enclosing `` that can draw at the asking position + // answers for it; otherwise it is a failure of the enclosing content (a + // pending `` around this one fails its fragment), and this + // boundary answers nothing. + let answering = false; + let interceptor = false; + let pulling = false; + let torn = false; + let disposed: Promise | undefined; + const renderOutcome = ( + err: any, + outcome: true | string, + parentHandler: BoundaryErrorHandler | null + ): BoundaryOutcome => { let t = shown && shown.t; if (Array.isArray(t)) t = t[0]; if ( @@ -4294,15 +4328,33 @@ export function createErrorBoundary( !t.endsWith(``)) ) return undefined; + // A hole of the fallback that throws while it renders reaches the + // fallback owner's handler first: hold it for `escalate` instead of + // letting the parent handler route it from here. + if (parentHandler && !interceptor) { + interceptor = true; + setContext( + ErrorContext, + (e: any, o?: true | string) => { + if (o === undefined && answering) throw e; + return parentHandler(e, o); + }, + outputOwner + ); + } + let failed: { e: any } | undefined; // Rendered from a resume loop, where the render context the compiled // template reads has long moved past this boundary's: restore it. const inCtx = (run: () => any) => { const prevCtx = sharedConfig.context; sharedConfig.context = ctx; + answering = true; try { return run(); - } catch { + } catch (e) { + if (!(e instanceof NotReadyError)) failed = { e }; } finally { + answering = false; sharedConfig.context = prevCtx; } }; @@ -4312,13 +4364,41 @@ export function createErrorBoundary( const t = resolved.t; return Array.isArray(t) ? t[0] : t; }; + const escalate = (): BoundaryOutcome => { + const e = failed!.e; + deferredWire = undefined; + if (!parentHandler) return; + const answer = parentHandler(e, outcome); + if (answer !== undefined) return answer; + escalating++; + try { + parentHandler(e); + } catch { + } finally { + escalating--; + } + }; let resolved: any = inCtx(() => ctx.resolve(ctx.escape(handleError(err, true)))); + if (failed) return escalate(); if (outcome === true || !resolved || !resolved.h || !resolved.h.length) return finish(resolved); + disposed ||= new Promise(r => + runWithOwner(outputOwner!, () => onCleanup(() => ((torn = true), r()))) + ); return async () => { - while (resolved && resolved.p && resolved.p.length) { - if (resolved.p.some((p: any) => p.$clientHole)) return; - await Promise.all(resolved.p).catch(() => {}); - resolved = inCtx(() => ctx.ssr(resolved.t, ...resolved.h)); + for (let passes = 0; resolved && resolved.p && resolved.p.length; passes++) { + if (torn || passes >= MAX_RESUME_PASSES || resolved.p.some((p: any) => p.$clientHole)) + return; + await Promise.race([Promise.all(resolved.p).catch(() => {}), disposed]); + if (torn) return; + // Under the fallback's owner, as it rendered: a hole failing now + // meets the handler a sync throw did. + resolved = inCtx(() => + runWithOwner(outputOwner!, () => ctx.ssr(resolved.t, ...resolved.h)) + ); + if (failed) { + const answer = escalate(); + return typeof answer === "function" ? answer() : answer; + } } return finish(resolved); }; @@ -4337,16 +4417,22 @@ export function createErrorBoundary( // stashed holes read from (marking them disposed drops their settlement). if (ctx && !pending) disposeOwner(owner, false); try { + pulling = true; result = ctx ? runWithBoundaryErrorContext(owner, resolve, (err, _parent, outcome) => { if (err instanceof NotReadyError) throw err; - if (outcome !== undefined) return renderOutcome(err, outcome); + if (outcome !== undefined) return renderOutcome(err, outcome, _parent); + // Escalated to while not pulled: no consumer for a fallback + // (see `escalating`). + if (escalating && !pulling) throw err; handled = true; result = handleError(err); throw err; }) : runWithOwner(owner, fn); + pulling = false; } catch (err) { + pulling = false; if (err instanceof NotReadyError) throw err; pending = undefined; result = handled ? result : handleError(err); diff --git a/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-4316.json b/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-4316.json new file mode 100644 index 0000000000..e4c14e6f71 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-4316.json @@ -0,0 +1,5 @@ +{ + "name": "errored-fallback-hole-async-failure-4316", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-caught-4316.json b/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-caught-4316.json new file mode 100644 index 0000000000..189cce2c86 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "errored-fallback-hole-async-failure-caught-4316", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-parked-4316.json b/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-parked-4316.json new file mode 100644 index 0000000000..fe86b020e2 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-parked-4316.json @@ -0,0 +1,5 @@ +{ + "name": "errored-fallback-hole-async-failure-parked-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-parked-caught-4316.json b/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-parked-caught-4316.json new file mode 100644 index 0000000000..005805be3e --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-fallback-hole-async-failure-parked-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "errored-fallback-hole-async-failure-parked-caught-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-4316.json b/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-4316.json new file mode 100644 index 0000000000..48076459cd --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-4316.json @@ -0,0 +1,5 @@ +{ + "name": "errored-fallback-hole-sync-failure-4316", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-caught-4316.json b/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-caught-4316.json new file mode 100644 index 0000000000..ccf2bfdd06 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "errored-fallback-hole-sync-failure-caught-4316", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-parked-4316.json b/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-parked-4316.json new file mode 100644 index 0000000000..733d2d1bb2 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-parked-4316.json @@ -0,0 +1,5 @@ +{ + "name": "errored-fallback-hole-sync-failure-parked-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-parked-caught-4316.json b/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-parked-caught-4316.json new file mode 100644 index 0000000000..efeaa573f6 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-fallback-hole-sync-failure-parked-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "errored-fallback-hole-sync-failure-parked-caught-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx index aaae82e4ae..e8d7226c50 100644 --- a/packages/web/test/harness/scenarios.tsx +++ b/packages/web/test/harness/scenarios.tsx @@ -1684,6 +1684,81 @@ function parkedRejectionAsyncFallback(late: boolean) { }; } +// --------------------------------------------------------------------------- +// #4316: the #4287/#4306 failure with the Errored fallback's own hole +// failing. `hole: "async"` reads an async memo that rejects at 10ms (after +// the inner Loading failed at 5ms, before the outer one settles at 15ms); +// `hole: "sync"` is its twin, a fallback that throws synchronously. The +// fallback's failure belongs to the next boundary up, so the async variant +// must end exactly as its twin does — server markup and client outcome. +// `parked`: inside an outer still pending when it fails; +// `caught`: an outer around everything catches it. +export type FallbackHoleFailure = { hole: "async" | "sync"; parked: boolean; caught: boolean }; +export const fallbackHoleFailureName = (o: FallbackHoleFailure) => + `errored-fallback-hole-${o.hole}-failure${o.parked ? "-parked" : ""}${o.caught ? "-caught" : ""}-4316`; +export function fallbackHoleFailure(o: FallbackHoleFailure) { + return function ErroredFallbackHoleFailure() { + const outer = createMemo(async () => { + await sleep(15); + return "outer"; + }); + const user = createMemo(async (): Promise => { + await sleep(5); + throw new Error("no user 7"); + }); + // The twin takes the async memo's owner slot too, so both variants' + // hydration keys line up. + const slot = createMemo(async (): Promise => { + if (o.hole === "sync") return ""; + await sleep(10); + throw new Error("no hint"); + }); + const hint = + o.hole === "async" + ? slot + : (): string => { + throw new Error("no hint"); + }; + const Inner = () => ( + ( +

+ failed: {e().message} {hint()} +

+ )} + > + loading}> + user:{user()} + +
+ ); + const Body = o.parked + ? () => ( + outer loading}> + {outer()} + + + ) + : Inner; + return o.caught ? ( +
+

caught: {e().message}

}> + +
+ tail +
+ ) : ( +
+ + tail +
+ ); + }; +} +export const fallbackHoleFailures: FallbackHoleFailure[] = (["async", "sync"] as const).flatMap( + hole => [false, true].flatMap(parked => [false, true].map(caught => ({ hole, parked, caught }))) +); + // --------------------------------------------------------------------------- // #3414: a sync throw the SERVER catches, with a zero-arg fallback thunk // (`fallback={() => }`), inside an enclosing boundary. Errored diff --git a/packages/web/test/hydration/errored-parked-async-outcome-4306.spec.tsx b/packages/web/test/hydration/errored-parked-async-outcome-4306.spec.tsx index 7c88c0887d..bc9e5b5bcd 100644 --- a/packages/web/test/hydration/errored-parked-async-outcome-4306.spec.tsx +++ b/packages/web/test/hydration/errored-parked-async-outcome-4306.spec.tsx @@ -15,15 +15,29 @@ * The client claims that markup in both replay orders, without warnings; * hydrated after the stream it asks nothing again. * + * #4316: the same shapes (parked or not, an outer `` or none) with + * the fallback's own hole failing — rejecting while the failure waits for + * it. The failure is the next boundary up's, as when the fallback throws + * synchronously: the async variant ends exactly as its sync-throw twin, + * the server's markup and the client's outcome alike; caught, the client + * claims the outer ``'s fallback the server drew. + * * Replays the parity harness artifacts (test/server/hydration-harness.spec.tsx). */ import { describe, expect, test, vi } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { flush } from "solid-js"; +import { flush, resetErrorHalt } from "solid-js"; import { hydrate } from "@solidjs/web"; -import { scenarios, parkedHoleAskCount } from "../harness/scenarios.jsx"; +import { + scenarios, + parkedHoleAskCount, + fallbackHoleFailures, + fallbackHoleFailure, + fallbackHoleFailureName, + type FallbackHoleFailure +} from "../harness/scenarios.jsx"; const artifact = (name: string) => resolve(dirname(fileURLToPath(import.meta.url)), `../harness/__artifacts__/${name}.json`); @@ -93,3 +107,116 @@ describe("#4306: parked failure, Errored fallback with an async hole", () => { run(late, "loaded")); test("hole settles after the outer Loading, hydrated mid-stream", () => run(late, "streamed")); }); + +type Outcome = { + /** The page the server's stream builds, before anything hydrates (loaded). */ + server: string | undefined; + text: string; + halted: boolean; + done: boolean; + warnings: number; + /** What reached the page's uncaught-error channel (`reportError`). */ + uncaught: string[]; + /** In loaded mode: the client kept the server's outer-fallback node. */ + claimed: boolean; +}; + +async function outcomeOf(o: FallbackHoleFailure, mode: "loaded" | "streamed"): Promise { + const name = fallbackHoleFailureName(o); + const file = artifact(name); + expect(existsSync(file)).toBe(true); + const { shell, rest } = JSON.parse(readFileSync(file, "utf-8")); + const App = fallbackHoleFailure(o); + const container = document.createElement("div"); + document.body.appendChild(container); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errors: string[] = []; + const error = vi + .spyOn(console, "error") + .mockImplementation((...a: unknown[]) => void errors.push(a.map(String).join(" "))); + // An uncontained failure halts the page: own its uncaught channels (the + // platform's `reportError`, and the process's for the rethrow out of a + // scheduled flush) so what escapes is part of the outcome. + const uncaught: string[] = []; + const report = (e: unknown) => void uncaught.push(e instanceof Error ? e.message : String(e)); + vi.stubGlobal("reportError", report); + const events = ["uncaughtException", "unhandledRejection"] as const; + const previous = events.map(e => process.listeners(e)); + events.forEach(e => (process.removeAllListeners(e), process.on(e, report))); + resetErrorHalt(); + const textOf = () => { + const clone = container.cloneNode(true) as HTMLElement; + clone.querySelectorAll("template").forEach(t => t.remove()); + return clone.textContent!; + }; + let dispose: (() => void) | undefined; + try { + applyChunk(container, shell, true); + if (mode === "loaded") applyChunk(container, rest, false); + const server = mode === "loaded" ? container.innerHTML : undefined; + const serverH2 = container.querySelector("h2"); + try { + dispose = hydrate(() => , container); + } catch {} + flush(); + await sleep(10); + flush(); + if (mode === "streamed") applyChunk(container, rest, false); + await sleep(50); + flush(); + await sleep(10); + return { + server, + text: textOf(), + halted: errors.some(m => m.includes("[REACTIVITY_HALTED]")), + done: !!(globalThis as any)._$HY.done, + warnings: warn.mock.calls.length, + uncaught, + claimed: !!serverH2 && container.querySelector("h2") === serverH2 + }; + } finally { + try { + dispose?.(); + } catch {} + await sleep(0); + container.remove(); + events.forEach((e, i) => { + process.off(e, report); + for (const listener of previous[i]) process.on(e, listener as any); + }); + vi.unstubAllGlobals(); + warn.mockRestore(); + error.mockRestore(); + resetErrorHalt(); + } +} + +describe("#4316: the Errored fallback's own hole fails", () => { + for (const o of fallbackHoleFailures) { + if (o.hole !== "async") continue; + for (const mode of ["loaded", "streamed"] as const) { + test(`${fallbackHoleFailureName(o)} [${mode}] ends as its sync-throw twin`, async () => { + const outcome = await outcomeOf(o, mode); + const twin = await outcomeOf({ ...o, hole: "sync" }, mode); + // A halted page's later notices (how often the halt is reported, + // what it refuses after) are the halt's, not the outcome's: the + // comparison takes which errors escaped, and warnings while live. + const comparable = (r: Outcome) => ({ + ...r, + uncaught: [...new Set(r.uncaught)], + warnings: r.halted ? undefined : r.warnings + }); + expect(comparable(outcome)).toEqual(comparable(twin)); + if (o.caught) { + // The outer Errored caught it on the server: the client claims + // its fallback and the page stays live. + expect(outcome.text).toBe("caught: no hinttail"); + expect(outcome.halted).toBe(false); + expect(outcome.warnings).toBe(0); + if (mode === "loaded") expect(outcome.claimed).toBe(true); + } + }); + } + } +}); diff --git a/packages/web/test/server/errored-fallback-wait-4317.spec.tsx b/packages/web/test/server/errored-fallback-wait-4317.spec.tsx new file mode 100644 index 0000000000..4ed4c92c5e --- /dev/null +++ b/packages/web/test/server/errored-fallback-wait-4317.spec.tsx @@ -0,0 +1,113 @@ +/** + * @jsxImportSource @solidjs/web + * + * #4317: a post-flush `` failure waits for the enclosing + * ``'s fallback to finish before it streams the outcome (#4306). + * That wait is a resume loop, and keeps the `` resume loop's two + * guards: + * + * - its convergence budget: a fallback hole that answers a fresh pending + * source on every pass stops being waited for, and the failure streams + * the outcome that is available — no finished fallback, so none: the + * fragment rejects as for any unfinished fallback, and the response ends; + * - the render's teardown: an aborted request stops waiting at once, and + * the fallback is not rendered again when its pending source lands. + */ +import { describe, expect, test } from "vitest"; +import { Errored, Loading, NotReadyError, createMemo } from "solid-js"; +import { renderToStream } from "@solidjs/web"; + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); +const MAX_RESUME_PASSES = 10000; + +function App(props: { hint: () => string; onRender?: () => void }) { + const user = createMemo(async (): Promise => { + await sleep(5); + throw new Error("no user 7"); + }); + return ( +
+ ( +

+ failed: {e().message} {props.hint()} +

+ )} + > + loading}> + user:{user()} + +
+ tail +
+ ); +} + +describe("#4317: the wait for an Errored fallback's async holes", () => { + test("stops at the resume loop's pass budget and streams what is available", async () => { + let asks = 0; + // Pathological: a fresh pending source on every pass, settling a turn + // later, so the wait never converges (and a deadline can still fire). + const hint = (): string => { + asks++; + throw new NotReadyError(new Promise(r => setImmediate(r)) as any); + }; + const chunks: string[] = []; + const errors: unknown[] = []; + const ended = new Promise(resolve => + renderToStream(() => , { onError: e => void errors.push(e) }).pipe({ + write: (c: string) => void chunks.push(c), + end: resolve + }) + ); + const outcome = await Promise.race([ + ended.then(() => "ended"), + sleep(15000).then(() => "still waiting") + ]); + expect(outcome).toBe("ended"); + // The discovery render plus one per budgeted pass, never more. + expect(asks).toBe(MAX_RESUME_PASSES + 1); + const html = chunks.join(""); + // No finished fallback: the fragment rejects with nothing drawn, and no + // error record claims a fallback that was never sent. + expect(html).toMatch(/