diff --git a/.changeset/errored-fallback-hole-failure-4316.md b/.changeset/errored-fallback-hole-failure-4316.md new file mode 100644 index 000000000..66a3978a9 --- /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/.changeset/errored-fallback-shell-failure.md b/.changeset/errored-fallback-shell-failure.md new file mode 100644 index 000000000..10241ac55 --- /dev/null +++ b/.changeset/errored-fallback-shell-failure.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +An `` whose fallback fails while rendering (a hole that rejects, or a synchronous throw) now passes that failure to the enclosing boundary on the server, in the shell as well as in a streamed fragment. An outer `` draws its fallback in place of the region, and with none the request fails. Before, the region was sent blank, with the errors recorded and no markup for them. diff --git a/.changeset/parked-fragment-failure-4316.md b/.changeset/parked-fragment-failure-4316.md new file mode 100644 index 000000000..c6f049192 --- /dev/null +++ b/.changeset/parked-fragment-failure-4316.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +Server: a `` that fails after the shell while its fragment waits inside an outer `` that is still pending no longer arrives as a blank region reported as a success. The failure now goes to the nearest enclosing boundary, as it does when the same read fails inside the outer ``'s own render. An `` between them draws its fallback in the outer fragment, the client adopts it, and its error is serialized with it. With no `` between them, the outer fragment fails, and an `` whose whole content is that outer `` draws its fallback there. This also covers an `` fallback that fails in that position. Before, the client hydrated the blank region and either halted or rendered nothing there (#4316). diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index b62c6f259..e2904a30d 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -15,7 +15,12 @@ import { ownerId, onCleanup, inServerComponentScope, - edgeHooks + edgeHooks, + MAX_RESUME_PASSES, + ESCALATE, + fragmentContainer, + setFragmentContainer, + type FragmentContainer } from "./signals.js"; import { OBSERVE } from "@solidjs/signals"; import { sharedConfig, NoHydrateContext, callerRenderContext } from "./shared.js"; @@ -154,6 +159,21 @@ function ssrLoadingBoundary( (o as any).id = contentId; let done: ReturnType | undefined; + // This boundary's fragment as the boundaries rendered into it see it + // (`FragmentContainer`): open from registration until it settles, and + // the descendant `` fallbacks to draw in place of their markup + // when it does (#4316). `discarded` once it settled with a failure: a + // later failure from inside it has nothing left to fail. + const container: FragmentContainer = { + open: false, + region: (from, to, err) => void regions.push({ from, to, err }) + }; + let regions: { + from: string; + to: string | undefined | (() => Promise); + err: any; + }[] = []; + let discarded = false; let handledRenderError: any; let retryPromise: Promise | undefined; @@ -405,7 +425,21 @@ function ssrLoadingBoundary( // errorOutcome): the fragment settles when it lands. function failFragment(err: any, report: boolean, wait?: boolean): boolean | Promise { const settle = (outcome: Outcome) => { + discarded = true; outcomeRendered = !!outcome && outcome.value !== undefined && !outcome.escaped; + // Parked in an enclosing fragment still pending, with nothing drawn at + // this position: the failure is that fragment's content's, as a + // failure in its own pass would be (#4316) — the nearest boundary up + // meets it there (an `` draws its fallback in place of its + // markup; else the enclosing fragment fails). Settled first, so this + // fragment parks before the enclosing one settles; the boundary that + // meets it reports it. + if (!outcome && parentHandler && fragmentContainer(parent, true)) { + done!(undefined, err); + parentHandler(err, ESCALATE); + outcomeRendered = true; + return true; + } if (report && !outcomeRendered) { reportServerError( err, @@ -427,6 +461,15 @@ function ssrLoadingBoundary( o, render, (err: any, handler, outcome) => { + // A failure from inside this boundary's content that surfaced after + // its pass (see `ESCALATE`): still pending, the fragment fails with + // it; already failed, there is nothing left to fail; otherwise its + // content is out or rendered into the enclosing output — up. + if (outcome === ESCALATE) { + if (container.open) return failOpen(err); + if (discarded) return true; + return handler ? handler(err, ESCALATE) : undefined; + } // The outcome question (see errorOutcome) is a ``'s to pass // up: it owns no fallback for an error. if (outcome !== undefined) return handler ? handler(err, outcome) : undefined; @@ -463,6 +506,44 @@ function ssrLoadingBoundary( ); } + // An escalated failure fails the fragment while it is still pending, as a + // failure in its own pass does (the hole path above); the resume loop + // stops at its next wake. + function failOpen(err: any): true { + container.open = false; + discarded = true; + const settling = failFragment(err, true, true); + const settled = (streamed: boolean) => { + if (!outcomeRendered) reportRouted(err, "client"); + record("error", streamed, err); + }; + settling instanceof Promise ? settling.then(settled) : settled(settling); + return true; + } + + // The settled markup with each descendant `` fallback drawn in + // place of its markup (`container.region`), waited for until finished. + // Outermost first: a region inside one already replaced has gone with it. + // A region not found, or a fallback that never finished, fails the + // fragment with the region's error instead (thrown to the caller). + async function drawRegions(value: string): Promise { + for (let i = 0; i < regions.length; i++) { + const r = regions[i]; + if (typeof r.to === "function") r.to = await r.to(); + if (!container.open) return value; + } + const drawn: string[] = []; + for (let i = regions.length - 1; i >= 0; i--) { + const { from, to, err } = regions[i]; + const at = value.indexOf(from); + if (at < 0 && drawn.some(d => d.includes(from))) continue; + if (at < 0 || to === undefined) throw err; + value = value.slice(0, at) + to + value.slice(at + from.length); + drawn.push(from); + } + return value; + } + // Only ever called from the async resume loop below — there is nothing on // the stack to catch a throw from here. The initial (synchronous) discovery // pass propagates unhandled errors directly out of runLoadingPhase to the @@ -560,6 +641,7 @@ function ssrLoadingBoundary( function runDiscovery(): SSRTemplateObject | undefined { disposeOwner(o, false); serializeBuffer = []; + regions = []; retryPromise = undefined; resolveCount = 0; passes++; @@ -671,7 +753,10 @@ function ssrLoadingBoundary( if (ctx.async) { const regOpts = revealGroup ? { revealGroup: revealGroup.id } : undefined; - done = ctx.registerFragment(id, regOpts); + const settle = ctx.registerFragment(id, regOpts); + done = (value, error, escaped) => ((container.open = false), settle(value, error, escaped)); + container.open = true; + setFragmentContainer(o, container); let owners = fragmentOwners.get(ctx.registerFragment); if (!owners) fragmentOwners.set(ctx.registerFragment, (owners = {})); owners[id] = done; @@ -682,7 +767,11 @@ function ssrLoadingBoundary( // registered in the same re-run owns the entry and its reveal slot. let disposed = false; let superseded = false; - onCleanup(() => (disposed = true)); + onCleanup(() => { + disposed = true; + container.open = false; + setFragmentContainer(o, null); + }); const abandon = () => { if (owners![id] !== done) return (superseded = true); delete owners![id]; @@ -717,15 +806,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 ` + @@ -738,6 +827,7 @@ function ssrLoadingBoundary( if (IS_DEV) waits!.start([retryPromise]); await retryPromise.catch(() => {}); if (disposed) return abandon(); + if (discarded) return; if (IS_DEV) waits!.landed(); ret = runDiscovery(); } @@ -749,12 +839,19 @@ function ssrLoadingBoundary( if (IS_DEV) waits!.start(pending.p); await Promise.all(pending.p).catch(() => {}); if (disposed) return abandon(); + if (discarded) return; if (IS_DEV) waits!.landed(); passes++; ret = runLoadingPhase(() => resolveIn(() => ctx.ssr(pending.t, ...pending.h))) as any; } flushSerializeBuffer(); - const streamed = done!(ret && Array.isArray(ret.t) ? ret.t[0] : ((ret && ret.t) as any)); + let value = ret && Array.isArray(ret.t) ? ret.t[0] : ((ret && ret.t) as any); + if (regions.length) { + value = await drawRegions(value); + if (disposed) return abandon(); + if (discarded) return; + } + const streamed = done!(value); record("settled", streamed); } catch (err) { // A failure whose outcome is still resolving settles when it lands. diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 5d493c6b8..b815957f6 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -3707,10 +3707,80 @@ export function repeat( * escapes the component. Outside a server component `outcome` is the * asking ``'s id, and an `` answers only when that * Loading is all of its content (#4287). + * Called with `ESCALATE` it is handed a failure that surfaced after the + * render passed this boundary (no pull of it on the stack), to meet if it + * still can: it answers `true` once the failure is met (see `ESCALATE`). */ -export type BoundaryErrorHandler = (err: any, outcome?: true | string) => BoundaryOutcome; -/** A rendered outcome; a function where it is still resolving (see renderOutcome). */ -export type BoundaryOutcome = string | (() => Promise) | undefined | void; +export type BoundaryErrorHandler = ( + err: any, + outcome?: true | string | typeof ESCALATE +) => BoundaryOutcome; +/** + * A rendered outcome; a function where it is still resolving (see + * renderOutcome); `true` where an escalated failure was met. + */ +export type BoundaryOutcome = + | string + | (() => Promise) + | true + | undefined + | void; + +/** + * The escalation mode of a boundary error handler (#4316): a failure that + * surfaced after the render passed the boundaries above it — a `` + * fragment failing while parked in an enclosing pending fragment, or an + * `` fallback failing while the server waits for it. Each handler + * meets it if its region can still change, else passes it up: a `` + * whose fragment is still pending fails it (as a failure in its own pass + * does); an `` whose markup sits raw in such a fragment draws its + * fallback there (`FragmentContainer.region`), or, still pending itself, + * on its next pull. A boundary whose subtree is already discarded absorbs + * it. Answered `undefined` from the root: nothing could meet it. Not part + * of the public API (the server entry does not export it). + */ +export const ESCALATE: unique symbol = Symbol("escalate") as any; + +/** + * A pending `` fragment, as the `` boundaries rendered + * raw into it see it: still `open` until it settles, and taking a + * descendant ``'s fallback (`to`, possibly still resolving) in + * place of its markup (`from`) when it does. Failing that — the markup is + * not found, or the fallback never finishes — the fragment fails with `err`. + * @internal + */ +export type FragmentContainer = { + open: boolean; + region(from: string, to: string | (() => Promise), err: any): void; +}; +const containers = /* @__PURE__ */ new WeakMap(); +/** @internal A registered ``'s content owner and its fragment. */ +export function setFragmentContainer(owner: Owner, container: FragmentContainer | null): void { + container ? containers.set(owner, container) : containers.delete(owner); +} +/** + * @internal The fragment `owner`'s markup is rendered into: the nearest + * registered `` above it, while still open (`null` once settled — + * the markup is out). With `any`, any open one above it: a fragment + * settling there is parked in it. + */ +export function fragmentContainer(owner: Owner | null, any?: boolean): FragmentContainer | null { + for (let n = owner as unknown as SSROwner | null; n; n = n._parent) { + const c = containers.get(n); + if (c && (c.open || !any)) return c.open ? c : null; + } + return null; +} + +/** + * 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; const ErrorContext: Context = { id: Symbol("ErrorContext"), @@ -3762,7 +3832,7 @@ export function runWithBoundaryErrorContext( onError: ( err: any, parentHandler: BoundaryErrorHandler | null, - outcome?: true | string + outcome?: true | string | typeof ESCALATE ) => BoundaryOutcome, context?: NonNullable, boundaryId?: string @@ -4281,12 +4351,39 @@ 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 fallbackFailed = false; + let disposed: Promise | undefined; + // This boundary's markup as it went into the enclosing output, where it + // is finished markup (no hole of it still resolving). + const markup = (): string | undefined => { + if (!shown || (shown.h && shown.h.length)) return; + const t = Array.isArray(shown.t) ? (shown.t.length === 1 ? shown.t[0] : undefined) : shown.t; + return typeof t === "string" ? t : 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 ( !ctx || + fallbackFailed || (outcome === true ? !inServerComponentScope(owner as unknown as SSROwner) : typeof t !== "string" || @@ -4294,15 +4391,66 @@ export function createErrorBoundary( !t.endsWith(``)) ) return undefined; + return drawFallback(err, outcome, parentHandler); + }; + // A failure handed up after the render passed this boundary (`ESCALATE`, + // #4316), met as the boundary's own pull would meet it: the fallback in + // place of this boundary's markup. Still pending itself, the boundary is + // pulled again and shows it then; finished, rendered raw into a fragment + // still pending, the fallback takes its markup's place in that fragment + // (drawn as an outcome is: waited for, its error serialized once it is + // finished, its own failure the next boundary's). Already showing the + // fallback, it absorbs the failure. Otherwise — the markup is out, or its + // fallback failed — the next boundary up meets it. + const escalated = (err: any, up: BoundaryErrorHandler | null): BoundaryOutcome => { + if (ctx && !fallbackFailed) { + if (fallbackSettled) return true; + if (pending || pulling) return (handleError(err), true); + const from = markup(); + const container = from !== undefined && fragmentContainer(parent); + if (container) { + const to = drawFallback(err, undefined, up); + if (fallbackFailed) return to; + container.region(from!, to as string | (() => Promise), err); + return true; + } + } + return up ? up(err, ESCALATE) : undefined; + }; + // Renders the fallback for `err` as an outcome (`outcome`: the asking + // position) or in place of this boundary's markup (`undefined`). + const drawFallback = ( + err: any, + outcome: true | string | undefined, + parentHandler: BoundaryErrorHandler | null + ): BoundaryOutcome => { + // 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 | typeof ESCALATE) => { + 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 +4460,47 @@ export function createErrorBoundary( const t = resolved.t; return Array.isArray(t) ? t[0] : t; }; - let resolved: any = inCtx(() => ctx.resolve(ctx.escape(handleError(err, true)))); + // The fallback's failure, the next boundary's: asked to draw at the same + // position first (an outcome), then handed it as a failure of its + // content (`ESCALATE`, whose answer is this one's in place of a + // position's: met or not). + const escalate = (): BoundaryOutcome => { + const e = failed!.e; + fallbackFailed = true; + deferredWire = undefined; + if (!parentHandler) return; + if (outcome !== undefined) { + const answer = parentHandler(e, outcome); + if (answer !== undefined) return answer; + } + const met = parentHandler(e, ESCALATE); + if (outcome === undefined) return met; + }; + 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() + : typeof answer === "string" + ? answer + : undefined; + } } return finish(resolved); }; @@ -4330,6 +4512,7 @@ export function createErrorBoundary( () => { let result: any; let handled = false; + let fallbackThrew: { e: any } | undefined; // Already showing the fallback for this request. Re-running would // allocate new fallback ids and serialize the error again (#3920). if (fallbackSettled) return fallbackResult; @@ -4337,18 +4520,35 @@ 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 === ESCALATE) return escalated(err, _parent); + if (outcome !== undefined) return renderOutcome(err, outcome, _parent); handled = true; - result = handleError(err); + // Kept apart: a failure rendering the fallback is not this + // boundary's to handle (see the catch). + try { + result = handleError(err); + } catch (e) { + fallbackThrew = { e }; + throw e; + } throw err; }) : runWithOwner(owner, fn); + pulling = false; } catch (err) { + pulling = false; if (err instanceof NotReadyError) throw err; pending = undefined; + // The fallback failed rendering for the content's error: that + // failure is the enclosing boundary's, met in its own pull (an outer + // `` draws its fallback; none, and the request fails), as + // when the fallback below throws. Never a blank in this boundary's + // place with the failure dropped. + if (fallbackThrew) throw fallbackThrew.e; result = handled ? result : handleError(err); } return result; 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 000000000..e4c14e6f7 --- /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 000000000..189cce2c8 --- /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 000000000..fe86b020e --- /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 000000000..005805be3 --- /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 000000000..48076459c --- /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 000000000..ccf2bfdd0 --- /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 000000000..733d2d1bb --- /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 000000000..efeaa573f --- /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/__artifacts__/errored-shell-fallback-hole-async-failure-A-bare.json b/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-async-failure-A-bare.json new file mode 100644 index 000000000..92cd73e79 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-async-failure-A-bare.json @@ -0,0 +1,5 @@ +{ + "name": "errored-shell-fallback-hole-async-failure-A-bare", + "shell": "

caught: no hint

outertail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-async-failure-A.json b/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-async-failure-A.json new file mode 100644 index 000000000..1156f2efe --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-async-failure-A.json @@ -0,0 +1,5 @@ +{ + "name": "errored-shell-fallback-hole-async-failure-A", + "shell": "

caught: no hint

outertail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-sync-failure-A-bare.json b/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-sync-failure-A-bare.json new file mode 100644 index 000000000..21cc3dbc2 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-sync-failure-A-bare.json @@ -0,0 +1,5 @@ +{ + "name": "errored-shell-fallback-hole-sync-failure-A-bare", + "shell": "

caught: no hint

outertail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-sync-failure-A.json b/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-sync-failure-A.json new file mode 100644 index 000000000..d698e7942 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/errored-shell-fallback-hole-sync-failure-A.json @@ -0,0 +1,5 @@ +{ + "name": "errored-shell-fallback-hole-sync-failure-A", + "shell": "

caught: no hint

outertail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-4316.json new file mode 100644 index 000000000..dec569994 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-4316.json new file mode 100644 index 000000000..954154ccd --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-A-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-B-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-B-4316.json new file mode 100644 index 000000000..b2da8e35b --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-B-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-A-B-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-B-caught-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-B-caught-4316.json new file mode 100644 index 000000000..ed4e878f1 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-B-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-A-B-caught-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-caught-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-caught-4316.json new file mode 100644 index 000000000..71f0b0b0e --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-A-caught-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-late-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-late-4316.json new file mode 100644 index 000000000..3a7f5989f --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-A-late-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-A-late-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-B-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-B-4316.json new file mode 100644 index 000000000..870920e8f --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-B-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-B-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-B-caught-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-B-caught-4316.json new file mode 100644 index 000000000..018c4c3b6 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-B-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-B-caught-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-caught-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-caught-4316.json new file mode 100644 index 000000000..4980381f7 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-caught-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-late-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-late-4316.json new file mode 100644 index 000000000..d5423a1fc --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-inner-late-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-inner-late-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-4316.json new file mode 100644 index 000000000..11427ae14 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-twin-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-4316.json new file mode 100644 index 000000000..51db2171d --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-twin-A-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-B-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-B-4316.json new file mode 100644 index 000000000..aca6f0ff4 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-B-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-twin-A-B-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-B-caught-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-B-caught-4316.json new file mode 100644 index 000000000..7770604d1 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-B-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-twin-A-B-caught-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-caught-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-caught-4316.json new file mode 100644 index 000000000..f9ed93ba9 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-A-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-twin-A-caught-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-B-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-B-4316.json new file mode 100644 index 000000000..66c0f4aea --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-B-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-twin-B-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-B-caught-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-B-caught-4316.json new file mode 100644 index 000000000..47be9347f --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-B-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-twin-B-caught-4316", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-caught-4316.json b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-caught-4316.json new file mode 100644 index 000000000..cd8fdd32b --- /dev/null +++ b/packages/web/test/harness/__artifacts__/parked-fragment-failure-twin-caught-4316.json @@ -0,0 +1,5 @@ +{ + "name": "parked-fragment-failure-twin-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 aaae82e4a..28b39d7b8 100644 --- a/packages/web/test/harness/scenarios.tsx +++ b/packages/web/test/harness/scenarios.tsx @@ -1684,6 +1684,241 @@ 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 }))) +); + +// --------------------------------------------------------------------------- +// #4316 follow-up: a `` fragment that fails while parked in an +// enclosing `` still pending (`{outer()}`, 15ms; the inner +// read rejects at 5ms). Its failure is the enclosing fragment's content's: +// with an `` between (`A`, markup around the position) that +// Errored's fallback is drawn in the enclosing fragment; with none the +// enclosing fragment fails (met, with `caught`, by the outermost Errored +// that is all of its content). `B`: an Errored right around the inner +// Loading whose fallback itself fails (`hint`, 10ms) — its failure is +// A's. The `twin` variant has no inner Loading: the read is a hole of the +// enclosing fragment's own pass, the failure met in that pass — what the +// parked failure must end as. `late`: the enclosing fragment settles first +// (2ms), so the inner one streams on its own (no twin). +export type ParkedFailure = { + variant: "inner" | "twin"; + A: boolean; + B: boolean; + caught: boolean; + late?: boolean; +}; +export const parkedFailureName = (o: ParkedFailure) => + `parked-fragment-failure-${o.variant}${o.A ? "-A" : ""}${o.B ? "-B" : ""}` + + `${o.caught ? "-caught" : ""}${o.late ? "-late" : ""}-4316`; +export function parkedFailure(o: ParkedFailure) { + return function ParkedFragmentFailure() { + const outer = createMemo(async () => { + await sleep(o.late ? 2 : 15); + return "outer"; + }); + const user = createMemo(async (): Promise => { + await sleep(5); + throw new Error("no user 7"); + }); + const hint = createMemo(async (): Promise => { + await sleep(10); + throw new Error("no hint"); + }); + const read = () => user:{user()}; + const failing = () => + o.variant === "inner" ? loading}>{read()} : read(); + const mid = () => + o.B ? ( + ( +

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

+ )} + > + {failing()} +
+ ) : ( + failing() + ); + const region = () => + o.A ? ( +

caught: {e().message}

}> +
{mid()}
+
+ ) : ( +
{mid()}
+ ); + const body = () => ( + outer loading}> + {outer()} + {region()} + + ); + return ( +
+ {o.caught ? ( +

outermost: {e().message}

}>{body()}
+ ) : ( + body() + )} + tail +
+ ); + }; +} +export const parkedFailures: ParkedFailure[] = (["inner", "twin"] as const).flatMap(variant => + [false, true].flatMap(A => + [false, true].flatMap(B => [false, true].map(caught => ({ variant, A, B, caught }))) + ) +); +export const lateParkedFailures: ParkedFailure[] = [false, true].map(A => ({ + variant: "inner" as const, + A, + B: false, + caught: false, + late: true +})); + +// --------------------------------------------------------------------------- +// The #4316 failure at shell level, no `` anywhere: `B`, an +// `` around a read that rejects (5ms), whose fallback's own hole +// fails too — `hole: "async"` rejects at 10ms, `hole: "sync"` is its twin, +// a fallback that throws synchronously. The shell waits for both (and for +// `{outer()}`, 15ms). B's fallback's failure is the next boundary's: +// with `A`, an `` around B (`section`: markup between), A's +// fallback is drawn in the shell; with none, the request fails — both as +// the twin does, never a blank region with B's and A's errors recorded and +// no markup for either. +export type ShellFallbackFailure = { hole: "async" | "sync"; A: boolean; section: boolean }; +export const shellFallbackFailureName = (o: ShellFallbackFailure) => + `errored-shell-fallback-hole-${o.hole}-failure${o.A ? "-A" : ""}${o.section ? "" : "-bare"}`; +export function shellFallbackFailure(o: ShellFallbackFailure) { + return function ShellFallbackFailure() { + 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 B = () => ( + ( +

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

+ )} + > + user:{user()} +
+ ); + const mid = () => (o.section ?
{B()}
: B()); + const region = () => + o.A ? ( +

caught: {e().message}

}>{mid()}
+ ) : ( + mid() + ); + return ( +
+ {region()} + {outer()} + tail +
+ ); + }; +} +export const shellFallbackFailures: ShellFallbackFailure[] = (["async", "sync"] as const).flatMap( + hole => [false, true].flatMap(A => [false, true].map(section => ({ hole, A, section }))) +); + // --------------------------------------------------------------------------- // #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 7c88c0887..32ed1bea2 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,36 @@ * 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, + parkedFailures, + lateParkedFailures, + parkedFailure, + parkedFailureName, + shellFallbackFailures, + shellFallbackFailure, + shellFallbackFailureName, + type FallbackHoleFailure +} from "../harness/scenarios.jsx"; const artifact = (name: string) => resolve(dirname(fileURLToPath(import.meta.url)), `../harness/__artifacts__/${name}.json`); @@ -93,3 +114,222 @@ 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; +}; + +const outcomeOf = (o: FallbackHoleFailure, mode: "loaded" | "streamed") => + replay(fallbackHoleFailureName(o), fallbackHoleFailure(o), mode, "h2"); + +async function replay( + name: string, + App: () => any, + mode: "loaded" | "streamed", + claim: string +): Promise { + const file = artifact(name); + expect(existsSync(file)).toBe(true); + const { shell, rest } = JSON.parse(readFileSync(file, "utf-8")); + 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(claim); + 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(claim) === 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); + } + }); + } + } +}); + +// The #4316 follow-up: a fragment failing while parked in a pending +// enclosing fragment ends as its twin, the same failure met in the +// enclosing fragment's own pass: the client claims the fallback the +// server drew there (an `` between, else the outermost one the +// failed enclosing fragment is all of), or, with none, renders the failed +// fragment fresh. Hydration ids differ (the twin has no inner Loading), so +// the server's page is compared as text. Uncontained, the page halts on +// the client's own rejection: the text it froze at is the client tree's +// (the inner `` shows its fallback, the twin's the outer's), so +// what is compared is that it halted and on what. Warnings are this +// variant's alone: the streamed twin with an `` around a pending +// read misses its fallback key while it hydrates the pending fragment — +// the core's retry of the errored read, once the fragment releases, is +// taken for a client write by readSerializedOrCompute's latched branch, +// which refetches; #4319's served pass is what keeps that read latched. +describe("#4316: a fragment failing while parked in a pending fragment", () => { + const comparable = (r: Outcome) => ({ + ...r, + server: r.server === undefined ? undefined : textOfHtml(r.server), + text: r.halted ? undefined : r.text, + uncaught: [...new Set(r.uncaught)], + warnings: undefined + }); + for (const o of parkedFailures) { + if (o.variant !== "inner") continue; + const claim = o.A ? "h2" : "h3"; + for (const mode of ["loaded", "streamed"] as const) { + test(`${parkedFailureName(o)} [${mode}] ends as its twin`, async () => { + const twinOf = { ...o, variant: "twin" as const }; + const outcome = await replay(parkedFailureName(o), parkedFailure(o), mode, claim); + const twin = await replay(parkedFailureName(twinOf), parkedFailure(twinOf), mode, claim); + expect(comparable(outcome)).toEqual(comparable(twin)); + if (o.A || o.caught) { + const message = o.B ? "no hint" : "no user 7"; + expect(outcome.text).toBe(`${o.A ? "outercaught" : "outermost"}: ${message}tail`); + expect(outcome.halted).toBe(false); + expect(outcome.warnings).toBe(0); + expect(outcome.uncaught).toEqual([]); + if (mode === "loaded") expect(outcome.claimed).toBe(true); + } + }); + } + } + // Settled first, the enclosing fragment's markup was out before the inner + // read failed: its fragment rejects on its own and the client renders it, + // where its own `` meets the failure. + for (const o of lateParkedFailures) { + for (const mode of ["loaded", "streamed"] as const) { + test(`${parkedFailureName(o)} [${mode}] is the client's to render`, async () => { + const outcome = await replay(parkedFailureName(o), parkedFailure(o), mode, "h2"); + if (o.A) { + expect(outcome.text).toBe("outercaught: no user 7tail"); + expect(outcome.halted).toBe(false); + expect(outcome.uncaught).toEqual([]); + } else expect(outcome.uncaught).toContain("no user 7"); + expect(outcome.done).toBe(true); + }); + } + } +}); + +// The same failure at shell level, no ``: the outer `` +// caught B's fallback's failure on the server; the client claims its +// fallback, ending as the sync-throw twin. +describe("#4316: the Errored fallback's own hole fails in the shell", () => { + for (const o of shellFallbackFailures) { + if (o.hole !== "async" || !o.A) continue; + for (const mode of ["loaded", "streamed"] as const) { + test(`${shellFallbackFailureName(o)} [${mode}] ends as its sync-throw twin`, async () => { + const twinOf = { ...o, hole: "sync" as const }; + const outcome = await replay( + shellFallbackFailureName(o), + shellFallbackFailure(o), + mode, + "h2" + ); + const twin = await replay( + shellFallbackFailureName(twinOf), + shellFallbackFailure(twinOf), + mode, + "h2" + ); + expect(outcome).toEqual(twin); + expect(outcome.text).toBe("caught: no hintoutertail"); + expect(outcome.halted).toBe(false); + expect(outcome.warnings).toBe(0); + expect(outcome.uncaught).toEqual([]); + expect(outcome.claimed).toBe(true); + }); + } + } +}); + +function textOfHtml(html: string) { + const el = document.createElement("div"); + el.innerHTML = html; + el.querySelectorAll("template").forEach(t => t.remove()); + return el.textContent; +} 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 000000000..4ed4c92c5 --- /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(/