Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/errored-fallback-hole-failure-4316.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"solid-js": patch
---

Server: when an `<Errored>` renders its fallback for a `<Loading>` 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 `<Loading>`, that outer fragment fails; under an outer `<Errored>` 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 `<Errored>` without sending its fallback.

The server's wait for that fallback now stops after the same pass budget as the `<Loading>` 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).
5 changes: 5 additions & 0 deletions .changeset/errored-fallback-shell-failure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"solid-js": patch
---

An `<Errored>` 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 `<Errored>` 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.
5 changes: 5 additions & 0 deletions .changeset/parked-fragment-failure-4316.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"solid-js": patch
---

Server: a `<Loading>` that fails after the shell while its fragment waits inside an outer `<Loading>` 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 `<Loading>`'s own render. An `<Errored>` between them draws its fallback in the outer fragment, the client adopts it, and its error is serialized with it. With no `<Errored>` between them, the outer fragment fails, and an `<Errored>` whose whole content is that outer `<Loading>` draws its fallback there. This also covers an `<Errored>` fallback that fails in that position. Before, the client hydrated the blank region and either halted or rendered nothing there (#4316).
113 changes: 105 additions & 8 deletions packages/solid/src/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -154,6 +159,21 @@ function ssrLoadingBoundary(
(o as any).id = contentId;

let done: ReturnType<HydrationContext["registerFragment"]> | undefined;
// This boundary's fragment as the boundaries rendered into it see it
// (`FragmentContainer`): open from registration until it settles, and
// the descendant `<Errored>` 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<string | undefined>);
err: any;
}[] = [];
let discarded = false;
let handledRenderError: any;
let retryPromise: Promise<any> | undefined;

Expand Down Expand Up @@ -405,7 +425,21 @@ function ssrLoadingBoundary(
// errorOutcome): the fragment settles when it lands.
function failFragment(err: any, report: boolean, wait?: boolean): boolean | Promise<boolean> {
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 `<Errored>` 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,
Expand All @@ -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 `<Loading>`'s to pass
// up: it owns no fallback for an error.
if (outcome !== undefined) return handler ? handler(err, outcome) : undefined;
Expand Down Expand Up @@ -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 `<Errored>` 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<string> {
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
Expand Down Expand Up @@ -560,6 +641,7 @@ function ssrLoadingBoundary(
function runDiscovery(): SSRTemplateObject | undefined {
disposeOwner(o, false);
serializeBuffer = [];
regions = [];
retryPromise = undefined;
resolveCount = 0;
passes++;
Expand Down Expand Up @@ -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;
Expand All @@ -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];
Expand Down Expand Up @@ -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(
`<Loading> boundary discovery did not converge after ${passes} passes — ` +
`an async source produces a new pending answer on every retry. Ensure repeated ` +
Expand All @@ -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();
}
Expand All @@ -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.
Expand Down
Loading
Loading