From f7547f6d770a2b2cdf4b39297bbef5f35e27c747 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 26 Oct 2026 18:15:20 +0000
Subject: [PATCH 01/16] fix(solid,web): draw an Errored fallback for a
Loading's async failure, and adopt it (#4287)
Outside a server component, a whose async read failed during a
streamed render settled its fragment blank, and the hydrating client
rendered the failed boundary from scratch: it asked again and showed the
loading fallback.
Server: the failing now asks the enclosing for its
outcome outside server components too, passing its own id. The Errored
answers only when that Loading is all of its content (its resolved markup
is the Loading's placeholder): its fallback is drawn into the fragment, or
inlined pre-flush, and the error is serialized at the Errored id. Deeper
in the content it answers nothing, as before. A rejected fragment's
template is marked `e`, and `$dfr` records the nodes it swaps in for it.
Client: inside a hydrated Errored's no-claim content run, a
runs its content with its data adopted instead of waiting on its
fragment, so the Errored holds the client's own error and tracks the
content's inputs (#4273). A boundary that claimed its fallback and then
renders a rejected fragment fresh clears the nodes the swap put in first,
so nothing of the server's outcome is left beside the client's render.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
---
.changeset/errored-async-outcome-4287.md | 6 +
documentation/solid-2.0/12-ssr-http.md | 2 +-
packages/solid/src/client/hydration.ts | 52 ++++---
packages/solid/src/server/hydration.ts | 16 +-
packages/solid/src/server/signals.ts | 32 +++-
packages/solid/test/server/ssr-async.spec.ts | 35 ++---
packages/web/src/server.ts | 14 +-
.../__artifacts__/async-cond-before-for.json | 2 +-
...async-memo-element-4242-data-streamed.json | 2 +-
...o-element-4242-issue-errored-streamed.json | 2 +-
...element-4242-issue-streamed-reference.json | 2 +-
...sync-memo-element-4242-issue-streamed.json | 2 +-
...emo-element-4242-jsx-errored-streamed.json | 2 +-
...o-element-4242-jsx-streamed-reference.json | 2 +-
.../async-memo-element-4242-jsx-streamed.json | 2 +-
.../__artifacts__/async-settled-element.json | 2 +-
.../async-settled-fragment-root.json | 2 +-
.../__artifacts__/bare-client-late-final.json | 2 +-
.../__artifacts__/bounded-streamed-text.json | 2 +-
...lient-only-before-suspending-fragment.json | 2 +-
.../client-only-before-suspending-route.json | 2 +-
.../__artifacts__/cond-component-prop.json | 2 +-
.../deferred-hole-before-siblings.json | 2 +-
.../deferred-settled-element.json | 2 +-
.../__artifacts__/document-shell-styled.json | 2 +-
.../dynamic-async-loading-3666-streamed.json | 2 +-
.../dynamic-component-parity-streamed.json | 2 +-
.../dynamic-fallback-stream.json | 2 +-
...errored-loading-parked-rejection-4287.json | 5 +
...ored-loading-postflush-rejection-4287.json | 5 +
.../errored-loading-preflush-rejection.json | 2 +-
...tion-loading-postflush-rejection-4287.json | 5 +
.../frame-live-document-loaded.json | 2 +-
.../frame-live-document-streamed.json | 2 +-
.../frame-live-document-switched.json | 2 +-
...nonlive-document-3666-streamed-loaded.json | 2 +-
...nlive-document-3666-streamed-streamed.json | 2 +-
.../__artifacts__/generic-hydration-ab.json | 2 +-
.../__artifacts__/generic-hydration-ba.json | 2 +-
.../http-primitives-streamed-loading.json | 2 +-
.../hybrid-memo-handoff-memo-generator.json | 2 +-
.../hybrid-memo-handoff-memo-promise.json | 2 +-
.../hybrid-memo-handoff-signal-generator.json | 2 +-
.../hybrid-memo-handoff-signal-promise.json | 2 +-
.../hybrid-store-handoff-3574-generator.json | 2 +-
.../hybrid-store-handoff-3574-promise.json | 2 +-
.../late-boundary-after-done.json | 2 +-
.../__artifacts__/live-shell-source-3764.json | 6 +-
.../loading-fallback-reactive-text.json | 2 +-
.../loading-fallback-ternary.json | 2 +-
.../__artifacts__/loading-on-boundary.json | 2 +-
.../__artifacts__/memo-live-resume.json | 2 +-
.../__artifacts__/nav-before-resume.json | 2 +-
.../onsettled-store-write-3950-reconcile.json | 2 +-
.../onsettled-store-write-3950-splice.json | 2 +-
...d-write-shell-beside-pending-boundary.json | 2 +-
.../onsettled-write-show-streamed.json | 2 +-
.../__artifacts__/portal-before-siblings.json | 2 +-
.../__artifacts__/projection-live-resume.json | 2 +-
.../projection-repeat-stream.json | 2 +-
...use-head-stylesheet-streamed-boundary.json | 2 +-
.../__artifacts__/welcome-status-loaded.json | 2 +-
.../welcome-status-streamed.json | 2 +-
.../__artifacts__/write-before-resume.json | 2 +-
packages/web/test/harness/scenarios.tsx | 137 ++++++++++++++++--
.../errored-async-outcome-4287.spec.tsx | 88 +++++++++++
.../frame-fragment-error-outcome.spec.tsx | 32 +++-
.../server/ssr-async-rejection-3569.spec.tsx | 19 +--
...ssr-fragment-rejection-stub-batch.spec.tsx | 11 +-
packages/web/test/server/ssr-stream.spec.tsx | 29 ++--
70 files changed, 447 insertions(+), 153 deletions(-)
create mode 100644 .changeset/errored-async-outcome-4287.md
create mode 100644 packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-4287.json
create mode 100644 packages/web/test/harness/__artifacts__/errored-loading-postflush-rejection-4287.json
create mode 100644 packages/web/test/harness/__artifacts__/errored-section-loading-postflush-rejection-4287.json
create mode 100644 packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
diff --git a/.changeset/errored-async-outcome-4287.md b/.changeset/errored-async-outcome-4287.md
new file mode 100644
index 000000000..068a76de4
--- /dev/null
+++ b/.changeset/errored-async-outcome-4287.md
@@ -0,0 +1,6 @@
+---
+"solid-js": patch
+"@solidjs/web": patch
+---
+
+An `` around a `` whose async read fails during a streamed render now shows its fallback in the server HTML outside server components too, and the client adopts it. Before, the boundary's HTML was empty until hydration (neither the loading nor the error fallback), and the hydrating client rendered the failed boundary from scratch: it asked again and showed the loading fallback. Now, when the `` is all of the ``'s content, the server draws the error fallback in its place (in the shell, or in the streamed fragment after the shell) and serializes the error at the ``, so a client that hydrates after the failure arrived claims that markup and adopts its data without asking again, and recovers when the content's inputs change, as for any hydrated failure (#4273). A client that hydrated before the failure arrived renders it once, with nothing of the server's swap left beside it. With markup between the two boundaries the server still leaves the position blank and the client renders the failure (#4287).
diff --git a/documentation/solid-2.0/12-ssr-http.md b/documentation/solid-2.0/12-ssr-http.md
index f610017c2..17e3daefc 100644
--- a/documentation/solid-2.0/12-ssr-http.md
+++ b/documentation/solid-2.0/12-ssr-http.md
@@ -193,7 +193,7 @@ renderToStream(code, { onError });
handleServerFunctionRequest(request, { onError });
```
-- **Called once per error object per `kind` per request, at first sight** (per render when no request is in scope — never once per process: an error object reused across requests is heard, and mapped, by each), with where the failure was met. A direct server-function call that throws during SSR inside an `` is heard twice, as `server-function` and then as `render`, so `kind: "render"` alone tells you a render failed; the wire value is the first one the hook returned. `kind: "render"` — `fallback` (an `` rendered its fallback; `boundary` is its hydration id and `boundaryPath` its component labels — where the error was **met** — while `ownerPath` is where it was **thrown**, the labels up the owner chain it escaped, when the compiler emitted them), `client` (a `` fragment rejected, the client re-renders the subtree), `failed` (nothing contained it; the request fails — including a synchronous throw out of `renderToString` or `renderToStream`'s first pass, reported before it reaches the caller), `serialize` (a hydration value would not serialize and the render went on without it — reported for a render that passed `onError`, as seroval's own `onError` always was). `kind: "server-function"` — `thrown` (the body threw; `functionId`, and `direct: true` for an in-process call during SSR) or `channel` (a rejection or throw escaping through the result graph, the head already committed). `kind: "request"` is always `failed`: a failure that fails the request outside any render or server function the runtime reports (a middleware throw, a setup step), reported by the framework's request handler through `reportRequestFailure(error, event)`. A render failure, synchronous throws included, is reported as `render`, so a handler that catches one and passes it to `reportRequestFailure` adds nothing. It has no per-request tier, so only the ambient hook hears it. `event` is the request, when the failure happened inside one, and always for `kind: "request"`.
+- **Called once per error object per `kind` per request, at first sight** (per render when no request is in scope — never once per process: an error object reused across requests is heard, and mapped, by each), with where the failure was met. A direct server-function call that throws during SSR inside an `` is heard twice, as `server-function` and then as `render`, so `kind: "render"` alone tells you a render failed; the wire value is the first one the hook returned. `kind: "render"` — `fallback` (an `` rendered its fallback; `boundary` is its hydration id and `boundaryPath` its component labels — where the error was **met** — while `ownerPath` is where it was **thrown**, the labels up the owner chain it escaped, when the compiler emitted them), `client` (a `` fragment rejected, the client re-renders the subtree — unless the `` is all of an ``'s content: that `` draws its fallback into the fragment, reports `fallback`, and the hydrating client adopts it, [#4287](https://github.com/solidjs/solid/issues/4287)), `failed` (nothing contained it; the request fails — including a synchronous throw out of `renderToString` or `renderToStream`'s first pass, reported before it reaches the caller), `serialize` (a hydration value would not serialize and the render went on without it — reported for a render that passed `onError`, as seroval's own `onError` always was). `kind: "server-function"` — `thrown` (the body threw; `functionId`, and `direct: true` for an in-process call during SSR) or `channel` (a rejection or throw escaping through the result graph, the head already committed). `kind: "request"` is always `failed`: a failure that fails the request outside any render or server function the runtime reports (a middleware throw, a setup step), reported by the framework's request handler through `reportRequestFailure(error, event)`. A render failure, synchronous throws included, is reported as `render`, so a handler that catches one and passes it to `reportRequestFailure` adds nothing. It has no per-request tier, so only the ambient hook hears it. `event` is the request, when the failure happened inside one, and always for `kind: "request"`.
- **The return is the wire value**: rendered into the fallback, serialized for hydration, sent as the RPC error. `undefined` leaves the default policy in place (generic outside dev, fidelity in dev). A returned value is the author's intent — like a `wrapInvocation` mapping — and is not sanitized again; `markSafeError` is not needed on it. Ignored for `failed` and `serialize`, which have no wire. One road a mapping does not reach: a rejected async source's serialized rejection is encoded the moment the source rejects, ahead of the boundary, and carries the default policy's value; the hydrating client renders from the boundary's record, which carries the mapping.
- **Once means once across roads.** A direct server-function call that throws during SSR is met first by the invocation (`kind: "server-function"`, `direct: true`) and then by the `` that contains it; the boundary reuses the verdict — the same replacement in the fallback and the record — and does not report again. A `` re-pull recurring the same throw is the same object, the same verdict. The observe tier keeps the multi-event picture (an `"invocation"` record _and_ an `SSR_RENDER_ERROR_CONTAINED` finding) for consumers that want it.
- **Two tiers, as `wrapInvocation` has.** Ambient: `configureServerErrors({ onError })`, once per process, registered on `globalThis` under a registered symbol so a bundled server build and an instrumented `--import`ed module share it — and the only tier that sees direct in-process calls. Per request, overriding it: `onError` on `renderToStream`/`renderToString`, and on `handleServerFunctionRequest` for the call it dispatches. A throwing hook is reported on the console and treated as silent. With no hook anywhere, a failure that fails the request goes to `console.error` — never silent. A synchronous render throw is the exception: it is rethrown to the caller, whose own handling (or `reportRequestFailure`) decides.
diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts
index 73895fbe9..3521fc779 100644
--- a/packages/solid/src/client/hydration.ts
+++ b/packages/solid/src/client/hydration.ts
@@ -306,6 +306,9 @@ let _snapshotRootOwner: Owner | null = null;
// The boundary owner whose resume window is open (null during a root pass,
// which claims everything). Reached as `sharedConfig.isClaiming`.
let _claimOwner: Owner | null = null;
+// The claim window over no owner: a hydrated ``'s content run,
+// where nothing claims (hydratedCreateErrorBoundary).
+const NO_CLAIM = {} as Owner;
function isClaiming(): boolean {
if (!_claimOwner) return true;
let owner: Owner | null = getOwner();
@@ -1293,9 +1296,7 @@ function hydratedCreateErrorBoundary(
// A later run claims nothing either way: claims are read only while
// hydrating.
() =>
- _hydratingValue
- ? hydrateWindow("", fn, undefined, ((o = getOwner()), {} as Owner))
- : fn(),
+ _hydratingValue ? hydrateWindow("", fn, undefined, ((o = getOwner()), NO_CLAIM)) : fn(),
(e, reset) => ((o = 0), fallback(e, reset))
);
// No fallback yet: the content ran clean. Hold the server's fallback
@@ -2853,7 +2854,18 @@ function hydratedCreateLoadingBoundary(
// A parked fragment has settled but not swapped: it waits like a pending one.
const s = fr && typeof fr === "object" ? (fr.s === 1 && fragmentParked(id) ? 0 : fr.s) : 0;
- if (s === 1 && !assetPromise && !fragmentSuperseded(id)) {
+ // Inside a hydrated ``'s content run, which claims no DOM (the
+ // markup is the server's fallback for the failure, #4287), the
+ // fragment is never claimed: the content runs now with its data
+ // adopted, so the Errored holds the client's own error and tracks its
+ // inputs (#4273).
+ const unclaimed = _claimOwner === NO_CLAIM;
+ // Nothing else consumes a rejected `_fr` promise then — swallow it so
+ // a rejected fragment (error finalize or a client-hole handoff) doesn't
+ // surface as an unhandled rejection.
+ if (s === 2 || unclaimed) (fr as Promise).catch?.(noop);
+
+ if (unclaimed || (s === 1 && !assetPromise && !fragmentSuperseded(id))) {
// Fragment already settled and swapped in ($df ran before hydration):
// the content is in the DOM, so hydrate straight through. The fallback
// only hydrates when it is actually showing — rendering it here would
@@ -2895,13 +2907,9 @@ function hydratedCreateLoadingBoundary(
if (s === 1 || s === 2) {
if (s === 2) {
- // Rejected stream fragments swap to an empty template; any outer error fallback
- // has to be created as fresh client DOM, not claimed from server markup.
- // Nothing else consumes the settled-rejected `_fr` promise once this
- // branch takes over — swallow it so a rejected fragment (error
- // finalize or a client-hole handoff) doesn't surface as an
- // unhandled rejection.
- (fr as Promise).catch?.(noop);
+ // Rejected stream fragments swap to an empty template (or, under a
+ // hydrated ``, the fallback it claims — above); any outer
+ // error fallback here is created as fresh client DOM.
// Observe: the fresh render is a "recovery" record; prod folds the
// branch and `recover` with it.
const resumeRejected = IS_OBSERVE
@@ -2930,6 +2938,12 @@ function hydratedCreateLoadingBoundary(
// for this resume to claim instead of holding it; if the swap already
// arrived and was held awaiting a claimant, replay it now.
claimFragment(id);
+ const hy = (globalThis as any)._$HY;
+ // Revealed ahead of its `_fr` settlement (two scripts on the wire; an
+ // async entry can run between them, #4074): the fallback is gone and
+ // the content is in the DOM, so render nothing until the resume
+ // adopts it.
+ const revealed = hy.v?.[id];
waitAndResume(
fr,
shouldHydrate =>
@@ -2944,18 +2958,18 @@ function hydratedCreateLoadingBoundary(
const assets = boundaryAssets(id);
assets ? waitAndResume(true, resume, assets) : resume(true);
})
- : IS_OBSERVE
- ? recover(id, registeredAt, resume)
- : resume(false),
+ : // Rendered fresh. A rejected fragment swapped in over the
+ // claimed fallback left its nodes — the server's outcome for
+ // the failure, which `$dfr` lists in the reveal record
+ // (#4287) — beside the range the fresh render takes: clear
+ // them first.
+ (revealed || hy.v?.[id]?.forEach?.((n: ChildNode) => n.remove()),
+ IS_OBSERVE ? recover(id, registeredAt, resume) : resume(false)),
assetPromise,
false,
fragmentAbort(id)
);
- // Revealed ahead of its `_fr` settlement (two scripts on the wire; an
- // async entry can run between them, #4074): the fallback is gone and
- // the content is in the DOM, so render nothing until the resume
- // adopts it.
- return (globalThis as any)._$HY.v?.[id] ? undefined : fallback();
+ return revealed ? undefined : fallback();
}
if (assetPromise && !sharedConfig.has!(id)) {
diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts
index f605daa11..94f8719f3 100644
--- a/packages/solid/src/server/hydration.ts
+++ b/packages/solid/src/server/hydration.ts
@@ -361,8 +361,12 @@ function ssrLoadingBoundary(
// What the fragment carries for a failure once it is registered (its
// channel owns the routing — see runLoadingPhase). Outside a server
- // component: nothing — the blank the client twin renders fresh over, as
- // always (`hydratedCreateLoadingBoundary`'s rejected arm). Inside one
+ // component (asked with this boundary's id, #4287): the fallback of an
+ // `` this boundary is all the content of — drawn where the client
+ // twin draws it, its error serialized at the Errored id so the hydrating
+ // client adopts that markup; otherwise nothing — the blank the client
+ // twin renders fresh over (`hydratedCreateLoadingBoundary`'s rejected
+ // arm). Inside one
// (frames-rulings 3.3, A0 corollary 4 inward — the position shows what the
// SERVER rendered for the outcome, never a blank, never a client-invented
// state; there is no twin): the nearest server ``'s fallback for
@@ -375,10 +379,10 @@ function ssrLoadingBoundary(
// hydration-free scope's; its head and asset registrations drop with the
// error, as the error path drops them today.
function errorOutcome(err: any): { value?: string; escaped?: { frame?: string } } | undefined {
- if (!inServerComponentScope(o as any)) return undefined;
- const rendered = parentHandler ? parentHandler(err, true) : undefined;
+ const scoped = inServerComponentScope(o as any);
+ const rendered = parentHandler ? parentHandler(err, scoped || id) : undefined;
if (typeof rendered === "string") return { value: rendered };
- return { value: plainFallback(), escaped: { frame: ctx.frameId } };
+ if (scoped) return { value: plainFallback(), escaped: { frame: ctx.frameId } };
}
// Settle the fragment with the failure. The server error hook hears of it
// first (the `_fr` rejection and a transport sink's error chunk read the
@@ -412,7 +416,7 @@ function ssrLoadingBoundary(
(err: any, handler, outcome) => {
// The outcome question (see errorOutcome) is a ``'s to pass
// up: it owns no fallback for an error.
- if (outcome) return handler ? handler(err, true) : undefined;
+ if (outcome !== undefined) return handler ? handler(err, outcome) : undefined;
handledRenderError = err;
if (done) {
// Once the fragment is registered, its channel owns error routing:
diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts
index 51dce3640..96c4f39b8 100644
--- a/packages/solid/src/server/signals.ts
+++ b/packages/solid/src/server/signals.ts
@@ -3704,9 +3704,11 @@ export function repeat(
* rendered, never a blank): the nearest server `` answers with
* its fallback as markup; a `` passes the question up; a handler
* that belongs to no server `` answers `undefined` — the error
- * escapes the component.
+ * 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).
*/
-export type BoundaryErrorHandler = (err: any, outcome?: true) => string | undefined | void;
+export type BoundaryErrorHandler = (err: any, outcome?: true | string) => string | undefined | void;
const ErrorContext: Context = {
id: Symbol("ErrorContext"),
@@ -3758,7 +3760,7 @@ export function runWithBoundaryErrorContext(
onError: (
err: any,
parentHandler: BoundaryErrorHandler | null,
- outcome?: true
+ outcome?: true | string
) => string | undefined | void,
context?: NonNullable,
boundaryId?: string
@@ -4108,6 +4110,8 @@ export function createErrorBoundary(
// children — re-running would recreate the async work from scratch, which
// is pending again on every pass and can never settle (#2809 SSR loop).
let pending: { t: string[]; h: Function[]; p: Promise[] } | undefined;
+ // The content's last resolved markup (see renderOutcome).
+ let shown: any;
// The client boundary is two computeds under `owner`: one runs `fn`, the
// next flattens its result. A zero-arg function `fn` hands back — a nested
// boundary's accessor, a function child — is unwrapped inside that second
@@ -4152,6 +4156,7 @@ export function createErrorBoundary(
resolved = resolveIn(() => ctx!.resolve(ctx!.escape(value)));
}
pending = resolved?.p?.length ? resolved : undefined;
+ shown = resolved;
if (pending) {
// Propagate the FINAL classification through the aggregate: with a
// client hole in the set, the combined promise can never settle, and
@@ -4260,8 +4265,23 @@ export function createErrorBoundary(
// the error escapes the component as the frame's own error (the outward
// face). A fallback still resolving (an async hole in it) has no finished
// markup to answer with and escapes the same way.
- const renderOutcome = (err: any): string | undefined => {
- if (!ctx || !inServerComponentScope(owner as unknown as SSROwner)) return undefined;
+ // Outside a server component (`outcome` is the asking ``'s id,
+ // #4287) this boundary answers only when that Loading's placeholder is
+ // all of its content: the fallback then lands where the client twin draws
+ // it, and the error serialized here lets it adopt that markup. Deeper in
+ // the content it answers nothing — the client renders the failure fresh.
+ const renderOutcome = (err: any, outcome: true | string): string | undefined => {
+ let t = shown && shown.t;
+ if (Array.isArray(t)) t = t[0];
+ if (
+ !ctx ||
+ (outcome === true
+ ? !inServerComponentScope(owner as unknown as SSROwner)
+ : typeof t !== "string" ||
+ !t.startsWith(``))
+ )
+ return undefined;
// Rendered from a resume loop, where the render context the compiled
// template reads has long moved past this boundary's: restore it.
const prevCtx = sharedConfig.context;
@@ -4294,7 +4314,7 @@ export function createErrorBoundary(
result = ctx
? runWithBoundaryErrorContext(owner, resolve, (err, _parent, outcome) => {
if (err instanceof NotReadyError) throw err;
- if (outcome) return renderOutcome(err);
+ if (outcome !== undefined) return renderOutcome(err, outcome);
handled = true;
result = handleError(err);
throw err;
diff --git a/packages/solid/test/server/ssr-async.spec.ts b/packages/solid/test/server/ssr-async.spec.ts
index 71b4faad5..21f2b8541 100644
--- a/packages/solid/test/server/ssr-async.spec.ts
+++ b/packages/solid/test/server/ssr-async.spec.ts
@@ -1050,7 +1050,7 @@ describe("Loading SSR Async", () => {
).toBe(true);
});
- test("pre-flush Loading rejection rides the fragment channel, not the outer Errored (#2997)", async () => {
+ test("pre-flush Loading rejection rides the fragment channel with the outer Errored's fallback (#2997, #4287)", async () => {
const { context, fragmentResults, fragmentErrors, fragmentSettles, serialized } =
createMockSSRContext({
fragmentFlushed: false
@@ -1093,30 +1093,27 @@ describe("Loading SSR Async", () => {
await tick();
// The fragment channel owns the error once the boundary registered:
- // done(undefined, err) settles the fragment (pre-flush the renderer
- // inlines the placeholder away and rejects `_fr`; the client
- // re-renders the subtree fresh and its Errored catches). The outer
- // Errored's handler must NOT run at async time — its rendered fallback
- // would have no consumer (the accessor pull is long gone), and the
- // serialized error record it leaves at the boundary id sends the
- // hydrating client claiming fallback DOM that was never emitted — the
- // #2997 blank page.
- expect(fallbackCalls).toBe(0);
- expect(fragmentSettles).toEqual([{ key: "t000", value: undefined, error: fetchError }]);
+ // done(value, err) settles the fragment (pre-flush the renderer
+ // inlines the placeholder and rejects `_fr`). The Loading is all
+ // of the outer Errored's content, so it is asked for its outcome: its
+ // fallback is the fragment's markup, and the error it serializes at
+ // its id is what the hydrating client adopts that markup with (#4287).
+ expect(fallbackCalls).toBe(1);
+ expect(fragmentSettles).toEqual([
+ { key: "t000", value: "OuterError: Async fetch failed", error: fetchError }
+ ]);
expect(fragmentResults.size).toBe(0);
expect(fragmentErrors.size).toBe(0);
expect(
[...serialized.values()].some(v => v instanceof Error && v.message === "Async fetch failed")
- ).toBe(false);
+ ).toBe(true);
- // A later re-creation of the Errored (re-pull) still claims the settled
- // rejection synchronously — the pull is on the stack, so the fallback
- // render has a consumer.
+ // A later re-pull of the Errored hands back that same fallback.
expect(read(result)).toBe("OuterError: Async fetch failed");
expect(fallbackCalls).toBe(1);
});
- test("outer Errored stays out of post-flush Loading rejection ownership", async () => {
+ test("outer Errored draws its fallback for a post-flush Loading rejection it is filled by (#4287)", async () => {
const { context, fragmentResults, fragmentErrors, serialized } = createMockSSRContext({
fragmentFlushed: true
});
@@ -1157,14 +1154,14 @@ describe("Loading SSR Async", () => {
d.reject(fetchError);
await tick();
- expect(fallbackCalls).toBe(0);
+ expect(fallbackCalls).toBe(1);
expect(fragmentResults.size).toBe(1);
- expect([...fragmentResults.values()][0]).toBeUndefined();
+ expect([...fragmentResults.values()][0]).toBe("OuterError: Async fetch failed");
expect(fragmentErrors.size).toBe(1);
expect([...fragmentErrors.values()][0]).toBe(fetchError);
expect(
[...serialized.values()].some(v => v instanceof Error && v.message === "Async fetch failed")
- ).toBe(false);
+ ).toBe(true);
});
test("Loading with nested Errored resolves mixed success and error content", async () => {
diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts
index f64ec7499..0b77cb18f 100644
--- a/packages/web/src/server.ts
+++ b/packages/web/src/server.ts
@@ -1669,7 +1669,9 @@ function isAttrName(prop: string): boolean {
// (`_$HY.v[id] = 1` — with the `id_fr` declaration the serializer already
// writes, this makes "which declared fragments are still outstanding" a
// pure record read for the runtime: no promise introspection, no DOM
-// scan, valid across the pre-boot window), then announce it as
+// scan, valid across the pre-boot window; for a rejected fragment's
+// template, marked `e`, the record is the list of nodes it inserts, which
+// a client boundary rendering the failure fresh clears, #4287), then announce it as
// `_$HY.fe(id, parent)` — the parent scopes consumers that need to look
// at what just landed (server-component boundaries adopt there) to the
// revealed fragment instead of the document. A marker that isn't in the
@@ -1702,7 +1704,7 @@ function isAttrName(prop: string): boolean {
// this script installed a listener in the same document.
// - $dfg(id): group-style gate check; reveals a waiting group once all style counts hit zero.
// - $dfj(ids): reveal a group in registration order, waiting if any member still has pending styles.
-const REPLACE_SCRIPT = `function $df(e){return _$HY.f?_$HY.f(e):$dfr(e)}function $dfr(e,n,o,t){if(!(n=document.getElementById(e)))return 0;if(!(o=document.getElementById("pl-"+e)))return(_$HY.dq=_$HY.dq||{})[e]=1,0;for(;o&&(8!==o.nodeType||o.nodeValue!=="pl-"+e);)t=o.nextSibling,o.remove(),o=t;t=o.parentNode,o.replaceWith(n.content),n.remove(),(_$HY.v=_$HY.v||{})[e]=1,_$HY.fe(e,t),_$HY.hp&&_$HY.hp[e]&&($dh(_$HY.hp[e]),delete _$HY.hp[e]),$dfd();return 1}function $dfl(e,o,n){if(!(o=document.getElementById("pl-"+e)))return(_$HY.dlq=_$HY.dlq||{})[e]=1,0;if(o._$fl)return 1;for(n=o.nextSibling;n;){if(8===n.nodeType&&n.nodeValue==="pl-"+e){o.parentNode&&o.parentNode.insertBefore(o.content.cloneNode(!0),n),o._$fl=1,$dfd();return 1}n=n.nextSibling}return 0}function $dflj(e,i){for(i=0;i0)return;for(i=0;i0){for(n=0;n0)return;for(i=0;i0){for(n=0;n${value}`;
for (let i = 0; i < styles.inline.length; i++) {
buffer.write(renderInlineStyle(styles.inline[i], nonce));
}
@@ -2401,7 +2407,7 @@ export function renderToStream(code, options = {}) {
// before the parser reaches anything written after the link, and $dfr
// reads a missing template as a swap that already ran: the reveal
// would be dropped and the boundary would stay on its fallback.
- buffer.write(`${value}`);
+ buffer.write(tpl);
for (const entry of styles.links) {
buffer.write(
typeof entry === "string"
@@ -2410,7 +2416,7 @@ export function renderToStream(code, options = {}) {
);
}
} else {
- buffer.write(`${value}`);
+ buffer.write(tpl);
if (!deferActivation) {
emitTask(`$df("${key}")`);
}
diff --git a/packages/web/test/harness/__artifacts__/async-cond-before-for.json b/packages/web/test/harness/__artifacts__/async-cond-before-for.json
index 54e9d03ee..ca1c0eacc 100644
--- a/packages/web/test/harness/__artifacts__/async-cond-before-for.json
+++ b/packages/web/test/harness/__artifacts__/async-cond-before-for.json
@@ -1,5 +1,5 @@
{
"name": "async-cond-before-for",
"shell": "
loading
",
- "rest": "
shown
a
b
"
+ "rest": "
shown
a
b
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/async-memo-element-4242-data-streamed.json b/packages/web/test/harness/__artifacts__/async-memo-element-4242-data-streamed.json
index 350679124..3e9fe4f2c 100644
--- a/packages/web/test/harness/__artifacts__/async-memo-element-4242-data-streamed.json
+++ b/packages/web/test/harness/__artifacts__/async-memo-element-4242-data-streamed.json
@@ -1,5 +1,5 @@
{
"name": "async-memo-element-4242-data-streamed",
"shell": "loading",
- "rest": "
Ada:x:<b>
plain text
3:<i>
<p>a string, not markup</p>
2026-10-24T00:00:00.000Z
"
+ "rest": "
Ada:x:<b>
plain text
3:<i>
<p>a string, not markup</p>
2026-10-24T00:00:00.000Z
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-errored-streamed.json b/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-errored-streamed.json
index 068fc19b2..256bf0331 100644
--- a/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-errored-streamed.json
+++ b/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-errored-streamed.json
@@ -1,5 +1,5 @@
{
"name": "async-memo-element-4242-issue-errored-streamed",
"shell": "outer",
- "rest": "caught:ASYNC_MEMO_ELEMENTnested-41"
+ "rest": "caught:ASYNC_MEMO_ELEMENTnested-41"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-streamed-reference.json b/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-streamed-reference.json
index b10575225..bd8804792 100644
--- a/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-streamed-reference.json
+++ b/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-streamed-reference.json
@@ -1,5 +1,5 @@
{
"name": "async-memo-element-4242-issue-streamed-reference",
"shell": "outer",
- "rest": " "
+ "rest": " "
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-streamed.json b/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-streamed.json
index 30d373347..34b89d568 100644
--- a/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-streamed.json
+++ b/packages/web/test/harness/__artifacts__/async-memo-element-4242-issue-streamed.json
@@ -1,5 +1,5 @@
{
"name": "async-memo-element-4242-issue-streamed",
"shell": "outer",
- "rest": " nested-41"
+ "rest": " nested-41"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-errored-streamed.json b/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-errored-streamed.json
index 823d59d28..31cf10f93 100644
--- a/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-errored-streamed.json
+++ b/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-errored-streamed.json
@@ -1,5 +1,5 @@
{
"name": "async-memo-element-4242-jsx-errored-streamed",
"shell": "outer",
- "rest": "ASYNC_MEMO_ELEMENT"
+ "rest": "ASYNC_MEMO_ELEMENT"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-streamed-reference.json b/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-streamed-reference.json
index 494086b3d..806bac710 100644
--- a/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-streamed-reference.json
+++ b/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-streamed-reference.json
@@ -1,5 +1,5 @@
{
"name": "async-memo-element-4242-jsx-streamed-reference",
"shell": "outer",
- "rest": " "
+ "rest": " "
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-streamed.json b/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-streamed.json
index a18ad4560..5705c20c0 100644
--- a/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-streamed.json
+++ b/packages/web/test/harness/__artifacts__/async-memo-element-4242-jsx-streamed.json
@@ -1,5 +1,5 @@
{
"name": "async-memo-element-4242-jsx-streamed",
"shell": "outer",
- "rest": " "
+ "rest": " "
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/async-settled-element.json b/packages/web/test/harness/__artifacts__/async-settled-element.json
index 4edef7416..59d6ca3d6 100644
--- a/packages/web/test/harness/__artifacts__/async-settled-element.json
+++ b/packages/web/test/harness/__artifacts__/async-settled-element.json
@@ -1,5 +1,5 @@
{
"name": "async-settled-element",
"shell": "
loading
",
- "rest": "
Value: 42
"
+ "rest": "
Value: 42
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/async-settled-fragment-root.json b/packages/web/test/harness/__artifacts__/async-settled-fragment-root.json
index b3604caa3..24454622c 100644
--- a/packages/web/test/harness/__artifacts__/async-settled-fragment-root.json
+++ b/packages/web/test/harness/__artifacts__/async-settled-fragment-root.json
@@ -1,5 +1,5 @@
{
"name": "async-settled-fragment-root",
"shell": "
loading
",
- "rest": "Count: 42 after"
+ "rest": "Count: 42 after"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/bare-client-late-final.json b/packages/web/test/harness/__artifacts__/bare-client-late-final.json
index 18a7e3691..6dc20e8dc 100644
--- a/packages/web/test/harness/__artifacts__/bare-client-late-final.json
+++ b/packages/web/test/harness/__artifacts__/bare-client-late-final.json
@@ -1,5 +1,5 @@
{
"name": "bare-client-late-final",
"shell": "
wait
",
- "rest": " "
+ "rest": " "
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/bounded-streamed-text.json b/packages/web/test/harness/__artifacts__/bounded-streamed-text.json
index 9a94984fb..b44464549 100644
--- a/packages/web/test/harness/__artifacts__/bounded-streamed-text.json
+++ b/packages/web/test/harness/__artifacts__/bounded-streamed-text.json
@@ -1,5 +1,5 @@
{
"name": "bounded-streamed-text",
"shell": "
before
loading
tail
",
- "rest": "Count: 42 after"
+ "rest": "Count: 42 after"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/client-only-before-suspending-fragment.json b/packages/web/test/harness/__artifacts__/client-only-before-suspending-fragment.json
index 548552b5a..a8951f8b5 100644
--- a/packages/web/test/harness/__artifacts__/client-only-before-suspending-fragment.json
+++ b/packages/web/test/harness/__artifacts__/client-only-before-suspending-fragment.json
@@ -1,5 +1,5 @@
{
"name": "client-only-before-suspending-fragment",
"shell": "wait",
- "rest": "
loaded
"
+ "rest": "
loaded
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/client-only-before-suspending-route.json b/packages/web/test/harness/__artifacts__/client-only-before-suspending-route.json
index 36b974c40..f36bb55f7 100644
--- a/packages/web/test/harness/__artifacts__/client-only-before-suspending-route.json
+++ b/packages/web/test/harness/__artifacts__/client-only-before-suspending-route.json
@@ -1,5 +1,5 @@
{
"name": "client-only-before-suspending-route",
"shell": "wait",
- "rest": "
loaded
"
+ "rest": "
loaded
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/cond-component-prop.json b/packages/web/test/harness/__artifacts__/cond-component-prop.json
index b9d173ef7..ed0ae4c20 100644
--- a/packages/web/test/harness/__artifacts__/cond-component-prop.json
+++ b/packages/web/test/harness/__artifacts__/cond-component-prop.json
@@ -1,5 +1,5 @@
{
"name": "cond-component-prop",
"shell": "
wait
",
- "rest": "badge-a"
+ "rest": "badge-a"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/deferred-hole-before-siblings.json b/packages/web/test/harness/__artifacts__/deferred-hole-before-siblings.json
index 1348ebfb2..b84ca9b6c 100644
--- a/packages/web/test/harness/__artifacts__/deferred-hole-before-siblings.json
+++ b/packages/web/test/harness/__artifacts__/deferred-hole-before-siblings.json
@@ -1,5 +1,5 @@
{
"name": "deferred-hole-before-siblings",
"shell": "
loading
",
- "rest": "
2
Title
tail
"
+ "rest": "
2
Title
tail
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/deferred-settled-element.json b/packages/web/test/harness/__artifacts__/deferred-settled-element.json
index 33c04d273..afa4603a7 100644
--- a/packages/web/test/harness/__artifacts__/deferred-settled-element.json
+++ b/packages/web/test/harness/__artifacts__/deferred-settled-element.json
@@ -1,5 +1,5 @@
{
"name": "deferred-settled-element",
"shell": "
loading
",
- "rest": "
Value: 42
"
+ "rest": "
Value: 42
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/document-shell-styled.json b/packages/web/test/harness/__artifacts__/document-shell-styled.json
index dec281bef..d0686690f 100644
--- a/packages/web/test/harness/__artifacts__/document-shell-styled.json
+++ b/packages/web/test/harness/__artifacts__/document-shell-styled.json
@@ -1,6 +1,6 @@
{
"chunks": [
"
waiting
",
- ""
+ ""
]
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/dynamic-async-loading-3666-streamed.json b/packages/web/test/harness/__artifacts__/dynamic-async-loading-3666-streamed.json
index 018f09c60..82b35be4d 100644
--- a/packages/web/test/harness/__artifacts__/dynamic-async-loading-3666-streamed.json
+++ b/packages/web/test/harness/__artifacts__/dynamic-async-loading-3666-streamed.json
@@ -1,5 +1,5 @@
{
"name": "dynamic-async-loading-3666-streamed",
"shell": "Loading…",
- "rest": ""
+ "rest": ""
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json b/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json
index 1bd8e7092..6f8f8b142 100644
--- a/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json
+++ b/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json
@@ -1,5 +1,5 @@
{
"name": "dynamic-component-parity-streamed",
"shell": "
client
shell-fallback
",
- "rest": "
note v1
"
+ "rest": "
note v1
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/dynamic-fallback-stream.json b/packages/web/test/harness/__artifacts__/dynamic-fallback-stream.json
index be1e88dde..792396a4a 100644
--- a/packages/web/test/harness/__artifacts__/dynamic-fallback-stream.json
+++ b/packages/web/test/harness/__artifacts__/dynamic-fallback-stream.json
@@ -1,5 +1,5 @@
{
"name": "dynamic-fallback-stream",
"shell": "
status preparing 42% done
",
- "rest": "ready"
+ "rest": "ready"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-4287.json b/packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-4287.json
new file mode 100644
index 000000000..a76b96a13
--- /dev/null
+++ b/packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-4287.json
@@ -0,0 +1,5 @@
+{
+ "name": "errored-loading-parked-rejection-4287",
+ "shell": "
outer loadingtail
",
+ "rest": "outer
failed: no user 7
"
+}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/errored-loading-postflush-rejection-4287.json b/packages/web/test/harness/__artifacts__/errored-loading-postflush-rejection-4287.json
new file mode 100644
index 000000000..3b03ab1fc
--- /dev/null
+++ b/packages/web/test/harness/__artifacts__/errored-loading-postflush-rejection-4287.json
@@ -0,0 +1,5 @@
+{
+ "name": "errored-loading-postflush-rejection-4287",
+ "shell": "
loadingtail
",
+ "rest": "
failed: no user 7
"
+}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/errored-loading-preflush-rejection.json b/packages/web/test/harness/__artifacts__/errored-loading-preflush-rejection.json
index 78a10d880..18f5834aa 100644
--- a/packages/web/test/harness/__artifacts__/errored-loading-preflush-rejection.json
+++ b/packages/web/test/harness/__artifacts__/errored-loading-preflush-rejection.json
@@ -1,5 +1,5 @@
{
"name": "errored-loading-preflush-rejection",
- "shell": "
tail
",
+ "shell": "
caught
tail
",
"rest": ""
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/errored-section-loading-postflush-rejection-4287.json b/packages/web/test/harness/__artifacts__/errored-section-loading-postflush-rejection-4287.json
new file mode 100644
index 000000000..e5db53513
--- /dev/null
+++ b/packages/web/test/harness/__artifacts__/errored-section-loading-postflush-rejection-4287.json
@@ -0,0 +1,5 @@
+{
+ "name": "errored-section-loading-postflush-rejection-4287",
+ "shell": "
title
loadingtail
",
+ "rest": " "
+}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json b/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json
index d3ede6c6b..16b4f6ab1 100644
--- a/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json
+++ b/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json
@@ -1,5 +1,5 @@
{
"name": "frame-live-document-loaded",
"shell": "
shell-fallback
",
- "rest": "
room v1
"
+ "rest": "
room v1
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json b/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json
index e6415111d..f23488a91 100644
--- a/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json
+++ b/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json
@@ -1,5 +1,5 @@
{
"name": "frame-live-document-streamed",
"shell": "
shell-fallback
",
- "rest": "
room v1
"
+ "rest": "
room v1
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-switched.json b/packages/web/test/harness/__artifacts__/frame-live-document-switched.json
index d57330f72..a8ac8608f 100644
--- a/packages/web/test/harness/__artifacts__/frame-live-document-switched.json
+++ b/packages/web/test/harness/__artifacts__/frame-live-document-switched.json
@@ -1,5 +1,5 @@
{
"name": "frame-live-document-switched",
"shell": "
shell-fallback
",
- "rest": "
room v1
"
+ "rest": "
room v1
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json
index c142f6607..8ed43883d 100644
--- a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json
+++ b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json
@@ -1,5 +1,5 @@
{
"name": "frame-nonlive-document-3666-streamed-loaded",
"shell": "
shell-fallback
",
- "rest": "
note v1
"
+ "rest": "
note v1
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json
index 042a08bfc..076a48a41 100644
--- a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json
+++ b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json
@@ -1,5 +1,5 @@
{
"name": "frame-nonlive-document-3666-streamed-streamed",
"shell": "
shell-fallback
",
- "rest": "
note v1
"
+ "rest": "
note v1
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/generic-hydration-ab.json b/packages/web/test/harness/__artifacts__/generic-hydration-ab.json
index d314c994d..54e9caf93 100644
--- a/packages/web/test/harness/__artifacts__/generic-hydration-ab.json
+++ b/packages/web/test/harness/__artifacts__/generic-hydration-ab.json
@@ -2,7 +2,7 @@
"shell": "
label:/a
/a
a-loading
b-loading
",
"chunks": [
"",
- "data:a/alabel:/ashared:/a
i0
i1
",
+ "data:a/alabel:/ashared:/a
i0
i1
",
"data:b/alabel:/ashared:/a
i0
i1
"
]
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/generic-hydration-ba.json b/packages/web/test/harness/__artifacts__/generic-hydration-ba.json
index 0544b2389..82a473c5b 100644
--- a/packages/web/test/harness/__artifacts__/generic-hydration-ba.json
+++ b/packages/web/test/harness/__artifacts__/generic-hydration-ba.json
@@ -2,7 +2,7 @@
"shell": "
label:/a
/a
a-loading
b-loading
",
"chunks": [
"",
- "data:b/alabel:/ashared:/a
i0
i1
",
+ "data:b/alabel:/ashared:/a
i0
i1
",
"data:a/alabel:/ashared:/a
i0
i1
"
]
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/http-primitives-streamed-loading.json b/packages/web/test/harness/__artifacts__/http-primitives-streamed-loading.json
index fd2c289e7..0af0f6cd4 100644
--- a/packages/web/test/harness/__artifacts__/http-primitives-streamed-loading.json
+++ b/packages/web/test/harness/__artifacts__/http-primitives-streamed-loading.json
@@ -1,5 +1,5 @@
{
"name": "http-primitives-streamed-loading",
"shell": "
loading
",
- "rest": "
head Value: 42 tail
"
+ "rest": "
head Value: 42 tail
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-memo-generator.json b/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-memo-generator.json
index a8e314f28..f649b9dc6 100644
--- a/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-memo-generator.json
+++ b/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-memo-generator.json
@@ -1,5 +1,5 @@
{
"name": "hybrid-memo-handoff-memo-generator",
"shell": "loading",
- "rest": "true:0"
+ "rest": "true:0"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-memo-promise.json b/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-memo-promise.json
index 769c4fc9a..e8372e21c 100644
--- a/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-memo-promise.json
+++ b/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-memo-promise.json
@@ -1,5 +1,5 @@
{
"name": "hybrid-memo-handoff-memo-promise",
"shell": "loading",
- "rest": "true:0"
+ "rest": "true:0"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-signal-generator.json b/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-signal-generator.json
index d362b4273..9eaf541c1 100644
--- a/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-signal-generator.json
+++ b/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-signal-generator.json
@@ -1,5 +1,5 @@
{
"name": "hybrid-memo-handoff-signal-generator",
"shell": "loading",
- "rest": "true:0"
+ "rest": "true:0"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-signal-promise.json b/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-signal-promise.json
index 75b5aaf48..ead6c3533 100644
--- a/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-signal-promise.json
+++ b/packages/web/test/harness/__artifacts__/hybrid-memo-handoff-signal-promise.json
@@ -1,5 +1,5 @@
{
"name": "hybrid-memo-handoff-signal-promise",
"shell": "loading",
- "rest": "true:0"
+ "rest": "true:0"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/hybrid-store-handoff-3574-generator.json b/packages/web/test/harness/__artifacts__/hybrid-store-handoff-3574-generator.json
index a0b4279cb..ea30f1d35 100644
--- a/packages/web/test/harness/__artifacts__/hybrid-store-handoff-3574-generator.json
+++ b/packages/web/test/harness/__artifacts__/hybrid-store-handoff-3574-generator.json
@@ -1,5 +1,5 @@
{
"name": "hybrid-store-handoff-3574-generator",
"shell": "loading",
- "rest": "true:0"
+ "rest": "true:0"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/hybrid-store-handoff-3574-promise.json b/packages/web/test/harness/__artifacts__/hybrid-store-handoff-3574-promise.json
index 214e03825..54087369c 100644
--- a/packages/web/test/harness/__artifacts__/hybrid-store-handoff-3574-promise.json
+++ b/packages/web/test/harness/__artifacts__/hybrid-store-handoff-3574-promise.json
@@ -1,5 +1,5 @@
{
"name": "hybrid-store-handoff-3574-promise",
"shell": "loading",
- "rest": "true:0"
+ "rest": "true:0"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/late-boundary-after-done.json b/packages/web/test/harness/__artifacts__/late-boundary-after-done.json
index afe358194..2adb6e7e9 100644
--- a/packages/web/test/harness/__artifacts__/late-boundary-after-done.json
+++ b/packages/web/test/harness/__artifacts__/late-boundary-after-done.json
@@ -1,5 +1,5 @@
{
"name": "late-boundary-after-done",
"shell": "
lead
waiting
tail
",
- "rest": "late content"
+ "rest": "late content"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/live-shell-source-3764.json b/packages/web/test/harness/__artifacts__/live-shell-source-3764.json
index ebd146f00..3e15b7812 100644
--- a/packages/web/test/harness/__artifacts__/live-shell-source-3764.json
+++ b/packages/web/test/harness/__artifacts__/live-shell-source-3764.json
@@ -2,19 +2,19 @@
"a": {
"shell": "",
"chunks": [
- "
rows: 2
"
+ "
rows: 2
"
]
},
"b": {
"shell": "",
"chunks": [
- "
1
2
"
+ "
1
2
"
]
},
"c": {
"shell": "
shell
",
"chunks": [
- "
slow
rows: 2
"
+ "
slow
rows: 2
"
]
}
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/loading-fallback-reactive-text.json b/packages/web/test/harness/__artifacts__/loading-fallback-reactive-text.json
index 84956f13e..802893719 100644
--- a/packages/web/test/harness/__artifacts__/loading-fallback-reactive-text.json
+++ b/packages/web/test/harness/__artifacts__/loading-fallback-reactive-text.json
@@ -1,5 +1,5 @@
{
"name": "loading-fallback-reactive-text",
"shell": "
head
0
",
- "rest": "done"
+ "rest": "done"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/loading-fallback-ternary.json b/packages/web/test/harness/__artifacts__/loading-fallback-ternary.json
index df4acb39c..f0a455645 100644
--- a/packages/web/test/harness/__artifacts__/loading-fallback-ternary.json
+++ b/packages/web/test/harness/__artifacts__/loading-fallback-ternary.json
@@ -1,5 +1,5 @@
{
"name": "loading-fallback-ternary",
"shell": "
wait-a
tail
",
- "rest": "
L: ldata
"
+ "rest": "
L: ldata
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/loading-on-boundary.json b/packages/web/test/harness/__artifacts__/loading-on-boundary.json
index 9f67027bf..0a732188e 100644
--- a/packages/web/test/harness/__artifacts__/loading-on-boundary.json
+++ b/packages/web/test/harness/__artifacts__/loading-on-boundary.json
@@ -1,5 +1,5 @@
{
"name": "loading-on-boundary",
"shell": "
shell
wait
",
- "rest": "late#lobby"
+ "rest": "late#lobby"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/memo-live-resume.json b/packages/web/test/harness/__artifacts__/memo-live-resume.json
index b48db3a29..b8d8e1324 100644
--- a/packages/web/test/harness/__artifacts__/memo-live-resume.json
+++ b/packages/web/test/harness/__artifacts__/memo-live-resume.json
@@ -1,5 +1,5 @@
{
"name": "memo-live-resume",
"shell": "
loading
",
- "rest": "
idletail
"
+ "rest": "
idletail
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/nav-before-resume.json b/packages/web/test/harness/__artifacts__/nav-before-resume.json
index 671cbc360..b36ccb3ba 100644
--- a/packages/web/test/harness/__artifacts__/nav-before-resume.json
+++ b/packages/web/test/harness/__artifacts__/nav-before-resume.json
@@ -1,7 +1,7 @@
{
"shell": "
title:/a
label:/a
side-loading
a-loading",
"chunks": [
- "a-data",
+ "a-data",
""
]
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/onsettled-store-write-3950-reconcile.json b/packages/web/test/harness/__artifacts__/onsettled-store-write-3950-reconcile.json
index 402fdfbf8..5b62044e2 100644
--- a/packages/web/test/harness/__artifacts__/onsettled-store-write-3950-reconcile.json
+++ b/packages/web/test/harness/__artifacts__/onsettled-store-write-3950-reconcile.json
@@ -1,6 +1,6 @@
{
"shell": "
pending
",
"chunks": [
- "
remove
keep
"
+ "
remove
keep
"
]
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/onsettled-store-write-3950-splice.json b/packages/web/test/harness/__artifacts__/onsettled-store-write-3950-splice.json
index 402fdfbf8..5b62044e2 100644
--- a/packages/web/test/harness/__artifacts__/onsettled-store-write-3950-splice.json
+++ b/packages/web/test/harness/__artifacts__/onsettled-store-write-3950-splice.json
@@ -1,6 +1,6 @@
{
"shell": "
pending
",
"chunks": [
- "
remove
keep
"
+ "
remove
keep
"
]
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/onsettled-write-shell-beside-pending-boundary.json b/packages/web/test/harness/__artifacts__/onsettled-write-shell-beside-pending-boundary.json
index e12ec3cb2..468624e08 100644
--- a/packages/web/test/harness/__artifacts__/onsettled-write-shell-beside-pending-boundary.json
+++ b/packages/web/test/harness/__artifacts__/onsettled-write-shell-beside-pending-boundary.json
@@ -1,5 +1,5 @@
{
"name": "onsettled-write-shell-beside-pending-boundary",
"shell": "
nobody
wait
",
- "rest": "late"
+ "rest": "late"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/onsettled-write-show-streamed.json b/packages/web/test/harness/__artifacts__/onsettled-write-show-streamed.json
index e39acb4ab..05534fb52 100644
--- a/packages/web/test/harness/__artifacts__/onsettled-write-show-streamed.json
+++ b/packages/web/test/harness/__artifacts__/onsettled-write-show-streamed.json
@@ -1,5 +1,5 @@
{
"name": "onsettled-write-show-streamed",
"shell": "Loading…",
- "rest": "Hello"
+ "rest": "Hello"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/portal-before-siblings.json b/packages/web/test/harness/__artifacts__/portal-before-siblings.json
index 826e6d21a..d4bc225bc 100644
--- a/packages/web/test/harness/__artifacts__/portal-before-siblings.json
+++ b/packages/web/test/harness/__artifacts__/portal-before-siblings.json
@@ -1,5 +1,5 @@
{
"name": "portal-before-siblings",
"shell": "
loading
",
- "rest": "
lead
head
srv
"
+ "rest": "
lead
head
srv
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/projection-live-resume.json b/packages/web/test/harness/__artifacts__/projection-live-resume.json
index 8aa37db41..393a3649e 100644
--- a/packages/web/test/harness/__artifacts__/projection-live-resume.json
+++ b/packages/web/test/harness/__artifacts__/projection-live-resume.json
@@ -1,5 +1,5 @@
{
"name": "projection-live-resume",
"shell": "
loading
",
- "rest": "
idletail
"
+ "rest": "
idletail
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/projection-repeat-stream.json b/packages/web/test/harness/__artifacts__/projection-repeat-stream.json
index 14eb9a35a..67e4ea735 100644
--- a/packages/web/test/harness/__artifacts__/projection-repeat-stream.json
+++ b/packages/web/test/harness/__artifacts__/projection-repeat-stream.json
@@ -1,5 +1,5 @@
{
"name": "projection-repeat-stream",
"shell": "
loading
",
- "rest": "
1:one
"
+ "rest": "
1:one
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/use-head-stylesheet-streamed-boundary.json b/packages/web/test/harness/__artifacts__/use-head-stylesheet-streamed-boundary.json
index c0a7480d6..863bd1cf5 100644
--- a/packages/web/test/harness/__artifacts__/use-head-stylesheet-streamed-boundary.json
+++ b/packages/web/test/harness/__artifacts__/use-head-stylesheet-streamed-boundary.json
@@ -1,5 +1,5 @@
{
"name": "use-head-stylesheet-streamed-boundary",
"shell": "
lead
waiting
tail 0
",
- "rest": "late styled"
+ "rest": "late styled"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json
index 933816757..633044148 100644
--- a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json
+++ b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json
@@ -1,5 +1,5 @@
{
"name": "welcome-status-loaded",
"shell": "
¶ 1…
",
- "rest": "42 tokens · 7 tok/s"
+ "rest": "42 tokens · 7 tok/s"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json
index ca4333790..0d55150b2 100644
--- a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json
+++ b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json
@@ -1,5 +1,5 @@
{
"name": "welcome-status-streamed",
"shell": "
¶ 1…
",
- "rest": "42 tokens · 7 tok/s"
+ "rest": "42 tokens · 7 tok/s"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/write-before-resume.json b/packages/web/test/harness/__artifacts__/write-before-resume.json
index 4a1544f0d..3b839d915 100644
--- a/packages/web/test/harness/__artifacts__/write-before-resume.json
+++ b/packages/web/test/harness/__artifacts__/write-before-resume.json
@@ -1,6 +1,6 @@
{
"shell": "
title:/a
label:/a
side-loading
sync-loading
",
"chunks": [
- ""
+ ""
]
}
\ No newline at end of file
diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx
index a02d62298..73878cd1c 100644
--- a/packages/web/test/harness/scenarios.tsx
+++ b/packages/web/test/harness/scenarios.tsx
@@ -62,10 +62,8 @@ export type Scenario = {
expectedText: string;
/**
* Override for the "streamed" replay mode. Needed where the two modes
- * legitimately settle to different text — e.g. a live $df swap of a
- * REJECTED fragment splices in the rejection template's single-space
- * text node (@solidjs/web's rejection channel), which full-page-loaded
- * hydration never renders.
+ * legitimately settle to different text. (A live $df swap of a REJECTED
+ * fragment no longer leaves its markup beside the fresh render, #4287.)
*/
expectedTextStreamed?: string;
/**
@@ -1505,6 +1503,98 @@ function PreflushRejection() {
);
}
+// ---------------------------------------------------------------------------
+// #4287: the same order with the read failing AFTER the shell flushed, the
+// async memo outside both boundaries. The server draws the Errored fallback
+// into the fragment and serializes the error at the Errored id; the client
+// adopts that fallback (no client ask, no loading flash) and, as for any
+// hydrated failure (#4273), recovers once the memo's input changes.
+let setPostflushKey!: (v: string) => void;
+let postflushClientAsks = 0;
+// The hydration dry run computes under a mock Promise: count real asks only.
+const RealPromise = Promise;
+export const postflushClientAskCount = () => postflushClientAsks;
+function PostflushRejection() {
+ const [key, setKey] = createSignal("u7");
+ setPostflushKey = setKey;
+ const user = createMemo(async (): Promise => {
+ const k = key();
+ if (!isServer && Promise === RealPromise) postflushClientAsks++;
+ await sleep(5);
+ if (k === "u7") throw new Error("no user 7");
+ return k;
+ });
+ return (
+
+
failed: {e().message}
}>
+ loading}>
+ user:{user()}
+
+
+ tail
+
+ );
+}
+
+// The same failure with markup between the Errored and the Loading: the
+// Loading's position is not the Errored's, so the server draws nothing there
+// (the client twin's fallback replaces the whole ) and the client
+// renders the failure fresh, then recovers.
+let setNestedKey!: (v: string) => void;
+function PostflushRejectionNested() {
+ const [key, setKey] = createSignal("u7");
+ setNestedKey = setKey;
+ const user = createMemo(async (): Promise => {
+ const k = key();
+ await sleep(5);
+ if (k === "u7") throw new Error("no user 7");
+ return k;
+ });
+ return (
+
+
failed: {e().message}
}>
+
+
title
+ loading}>
+ user:{user()}
+
+
+
+ tail
+
+ );
+}
+
+// The failure inside an outer still pending when it lands: the
+// inner fragment parks in the outer one, which settles later carrying the
+// Errored fallback the server drew.
+function PostflushRejectionParked() {
+ const [key] = createSignal("u7");
+ const outer = createMemo(async () => {
+ await sleep(15);
+ return "outer";
+ });
+ const user = createMemo(async (): Promise => {
+ const k = key();
+ await sleep(5);
+ if (k === "u7") throw new Error("no user 7");
+ return k;
+ });
+ return (
+
+ outer loading}>
+ {outer()}
+
failed: {e().message}
}>
+ loading}>
+ user:{user()}
+
+
+
+ tail
+
+ );
+}
+
// ---------------------------------------------------------------------------
// #3414: a sync throw the SERVER catches, with a zero-arg fallback thunk
// (`fallback={() => }`), inside an enclosing boundary. Errored
@@ -2817,9 +2907,6 @@ export const scenarios: Scenario[] = [
App: BareClientLateFinal,
async: true,
expectedText: "widgettail",
- // The live rejected swap splices the rejection template's single-space
- // text node into the range (see expectedTextStreamed).
- expectedTextStreamed: " widgettail",
serverText: "wait"
},
{
@@ -2827,9 +2914,39 @@ export const scenarios: Scenario[] = [
App: PreflushRejection,
async: true,
expectedText: "caughttail",
- // Pre-flush the rejected boundary inlines to an empty region: neither
- // fallback reaches the server HTML — only the static sibling.
- serverText: "tail"
+ // Pre-flush the rejected boundary inlines the Errored fallback the
+ // server drew for it (#4287).
+ serverText: "caught tail"
+ },
+ {
+ name: "errored-section-loading-postflush-rejection-4287",
+ App: PostflushRejectionNested,
+ async: true,
+ expectedText: "failed: no user 7tail",
+ serverText: "title loading tail",
+ update: () => setNestedKey("u8"),
+ expectedTextAfterUpdate: "titleuser:u8tail"
+ },
+ {
+ name: "errored-loading-parked-rejection-4287",
+ App: PostflushRejectionParked,
+ async: true,
+ expectedText: "outerfailed: no user 7tail",
+ serverText: "outer loading tail failed: no user 7"
+ // No update pass: hydrated mid-stream, the memo re-asks on the outer
+ // boundary's resume (as it does on `next`), so the Errored holds the
+ // server's fallback until reset() rather than tracking the memo.
+ },
+ {
+ name: "errored-loading-postflush-rejection-4287",
+ App: PostflushRejection,
+ async: true,
+ expectedText: "failed: no user 7tail",
+ // Shell: the loading fallback; the fragment: the Errored fallback.
+ serverText: "loading tail failed: no user 7",
+ update: () => setPostflushKey("u8"),
+ expectedTextAfterUpdate: "user:u8tail",
+ stableSelector: "div"
},
{
name: "errored-thunk-fallback-under-errored",
diff --git a/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx b/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
new file mode 100644
index 000000000..f92c89876
--- /dev/null
+++ b/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
@@ -0,0 +1,88 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ *
+ * #4287: `` around a `` whose content reads an async memo
+ * that rejects after the shell flushed. The server draws the Errored
+ * fallback into the fragment and serializes the error at the Errored id.
+ *
+ * - Hydrating after the failure arrived, the client adopts the server's
+ * fallback node: no loading fallback, no new ask, and (#4273) it recovers
+ * when the memo's input changes.
+ * - Hydrating before it arrived, the client renders the failure itself once
+ * the fragment rejects, with nothing of the server's swap left beside it.
+ *
+ * Replays the parity harness artifact (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 { hydrate } from "@solidjs/web";
+import { scenarios, postflushClientAskCount } from "../harness/scenarios.jsx";
+
+const name = "errored-loading-postflush-rejection-4287";
+const file = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ `../harness/__artifacts__/${name}.json`
+);
+const scenario = scenarios.find(s => s.name === name)!;
+const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
+
+function applyChunk(container: HTMLDivElement, chunk: string, first: boolean) {
+ const scriptRe = /",
+ "rest": " "
+}
\ No newline at end of file
diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx
index 73878cd1c..d3d6d8d00 100644
--- a/packages/web/test/harness/scenarios.tsx
+++ b/packages/web/test/harness/scenarios.tsx
@@ -1536,6 +1536,43 @@ function PostflushRejection() {
);
}
+// The same failure with an async hole in the Errored fallback, still
+// pending when the Loading fails: the server has no finished fallback markup
+// to draw, so it serializes no error either (a record with no markup behind
+// it would make the client claim a fallback that is not there). The client
+// renders the failure itself, as it does on `next`.
+let setPostflushHoleKey!: (v: string) => void;
+function PostflushRejectionAsyncFallback() {
+ const [key, setKey] = createSignal("u7");
+ setPostflushHoleKey = setKey;
+ const user = createMemo(async (): Promise => {
+ const k = key();
+ await sleep(5);
+ if (k === "u7") throw new Error("no user 7");
+ return k;
+ });
+ const hint = createMemo(async () => {
+ await sleep(20);
+ return "HINT";
+ });
+ return (
+
+ (
+
+ failed: {e().message} {hint()}
+
+ )}
+ >
+ loading}>
+ user:{user()}
+
+
+ tail
+
+ );
+}
+
// The same failure with markup between the Errored and the Loading: the
// Loading's position is not the Errored's, so the server draws nothing there
// (the client twin's fallback replaces the whole ) and the client
@@ -2937,6 +2974,15 @@ export const scenarios: Scenario[] = [
// boundary's resume (as it does on `next`), so the Errored holds the
// server's fallback until reset() rather than tracking the memo.
},
+ {
+ name: "errored-loading-postflush-rejection-async-fallback-4287",
+ App: PostflushRejectionAsyncFallback,
+ async: true,
+ expectedText: "failed: no user 7 HINTtail",
+ serverText: "loading tail",
+ update: () => setPostflushHoleKey("u8"),
+ expectedTextAfterUpdate: "user:u8tail"
+ },
{
name: "errored-loading-postflush-rejection-4287",
App: PostflushRejection,
diff --git a/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx b/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
index f92c89876..c2de8cbef 100644
--- a/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
+++ b/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
@@ -11,6 +11,9 @@
* when the memo's input changes.
* - Hydrating before it arrived, the client renders the failure itself once
* the fragment rejects, with nothing of the server's swap left beside it.
+ * - With an async hole in the fallback still pending when the Loading fails,
+ * the server has no fallback markup to draw and serializes no error: the
+ * client renders the failure itself instead of claiming a missing node.
*
* Replays the parity harness artifact (test/server/hydration-harness.spec.tsx).
*/
@@ -22,11 +25,10 @@ import { flush } from "solid-js";
import { hydrate } from "@solidjs/web";
import { scenarios, postflushClientAskCount } from "../harness/scenarios.jsx";
+const artifact = (name: string) =>
+ resolve(dirname(fileURLToPath(import.meta.url)), `../harness/__artifacts__/${name}.json`);
const name = "errored-loading-postflush-rejection-4287";
-const file = resolve(
- dirname(fileURLToPath(import.meta.url)),
- `../harness/__artifacts__/${name}.json`
-);
+const file = artifact(name);
const scenario = scenarios.find(s => s.name === name)!;
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
@@ -82,7 +84,51 @@ async function run(mode: "loaded" | "streamed") {
}
}
+async function runAsyncFallback(mode: "loaded" | "streamed") {
+ const name = "errored-loading-postflush-rejection-async-fallback-4287";
+ const file = artifact(name);
+ const scenario = scenarios.find(s => s.name === 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(() => {});
+ let dispose: (() => void) | undefined;
+ try {
+ applyChunk(container, shell, true);
+ if (mode === "loaded") applyChunk(container, rest, false);
+ dispose = hydrate(() => , container);
+ flush();
+ await sleep(10);
+ flush();
+ if (mode === "streamed") applyChunk(container, rest, false);
+ await sleep(40);
+ flush();
+ expect(container.textContent).toBe("failed: no user 7 HINTtail");
+ expect(container.querySelectorAll("p").length).toBe(1);
+
+ scenario.update!();
+ flush();
+ await sleep(30);
+ flush();
+ expect(container.textContent).toBe("user:u8tail");
+ expect(warn).not.toHaveBeenCalled();
+ // No fallback markup was sent, so no error record either.
+ expect(rest).not.toContain('_$HY.r["3"]');
+ } finally {
+ warn.mockRestore();
+ dispose?.();
+ await sleep(0);
+ container.remove();
+ }
+}
+
describe("#4287: Errored over a Loading whose read fails after the shell", () => {
test("hydrated after the failure arrived: adopts the server's fallback", () => run("loaded"));
test("hydrated before the failure arrived: shows it once, no ask", () => run("streamed"));
+ test("fallback with a pending async hole, hydrated after the failure: renders it", () =>
+ runAsyncFallback("loaded"));
+ test("fallback with a pending async hole, hydrated before the failure: renders it", () =>
+ runAsyncFallback("streamed"));
});
From e1366109d1f25a27a320603ebf17ebb3b2b6521a Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 26 Oct 2026 21:46:58 +0000
Subject: [PATCH 03/16] fix(solid,web): stream a parked Errored fallback once
its async hole resolves (#4306)
An whose fallback reads async data, around a that
fails after the shell inside an outer still pending, lost its
region: the outcome render met the fallback's pending hole, answered no
markup, and the parked fragment spliced a blank into the outer one while
its `_fr` resolved. The client claimed against the blank and showed
nothing there, with hydration warnings.
renderOutcome now answers a still-resolving fallback with a function
that waits out its holes and returns the finished markup, serializing
the error only then. The calls it only while its fragment is
parked in an enclosing pending one (the registry's new `parked()` query
on the settle function), where nothing of the failure shows yet: the
outer fragment carries the fallback, or, if the outer settles first,
the inner fragment streams it when it lands. A failure in a region
already shown settles at once, as before. Server-only; no client bytes.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
---
.../errored-parked-async-outcome-4306.md | 6 ++
packages/solid/src/server/hydration.ts | 62 ++++++++----
packages/solid/src/server/shared.ts | 7 +-
packages/solid/src/server/signals.ts | 46 ++++++---
packages/web/src/server.ts | 9 +-
...-parked-rejection-async-fallback-4306.json | 5 +
...ed-rejection-late-async-fallback-4306.json | 5 +
packages/web/test/harness/scenarios.tsx | 61 ++++++++++++
...errored-parked-async-outcome-4306.spec.tsx | 95 +++++++++++++++++++
9 files changed, 260 insertions(+), 36 deletions(-)
create mode 100644 .changeset/errored-parked-async-outcome-4306.md
create mode 100644 packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-async-fallback-4306.json
create mode 100644 packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-late-async-fallback-4306.json
create mode 100644 packages/web/test/hydration/errored-parked-async-outcome-4306.spec.tsx
diff --git a/.changeset/errored-parked-async-outcome-4306.md b/.changeset/errored-parked-async-outcome-4306.md
new file mode 100644
index 000000000..f8c9e8b8c
--- /dev/null
+++ b/.changeset/errored-parked-async-outcome-4306.md
@@ -0,0 +1,6 @@
+---
+"solid-js": patch
+"@solidjs/web": patch
+---
+
+An `` whose fallback reads async data, around a `` that fails after the shell inside an outer `` still pending, now streams its fallback. Before, the failure landed while the fallback's async read was still pending, so the server had no markup for it and the outer fragment arrived with the Errored region blank; the client hydrated against the blank with warnings and showed nothing there. Now a failure whose fragment is parked in a pending outer one (nothing of it shows yet) waits for the fallback to finish: the outer fragment carries it, or, when the outer settles first, the inner fragment streams it once it resolves. The error is serialized with it and the client claims the markup. A failure in a region already shown still settles at once, as before (#4306).
diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts
index 94f8719f3..01847b088 100644
--- a/packages/solid/src/server/hydration.ts
+++ b/packages/solid/src/server/hydration.ts
@@ -179,7 +179,7 @@ function ssrLoadingBoundary(
const timed = IS_DEV || observed;
const discoveredAt = timed ? performance.now() : 0;
let recorded = false;
- let streamedOnError = false;
+ let streamedOnError: boolean | Promise = false;
let pendingRecord: (() => void) | undefined;
const record = (outcome: BoundaryEvent["outcome"], streamed: boolean, error?: unknown) => {
if (recorded) return;
@@ -378,10 +378,18 @@ function ssrLoadingBoundary(
// Hydration ids inside the rendered fallback are the component's own
// hydration-free scope's; its head and asset registrations drop with the
// error, as the error path drops them today.
- function errorOutcome(err: any): { value?: string; escaped?: { frame?: string } } | undefined {
+ // An `` fallback still resolving answers with a function (see
+ // `renderOutcome`); waited for only while this fragment is parked in an
+ // enclosing pending one, where nothing of the failure shows yet and the
+ // enclosing fragment can carry the finished fallback (#4306). Elsewhere
+ // it is no markup: the fragment settles as a plain rejection now.
+ type Outcome = { value?: string; escaped?: { frame?: string } } | undefined;
+ function errorOutcome(err: any, wait?: boolean): Outcome | Promise {
const scoped = inServerComponentScope(o as any);
const rendered = parentHandler ? parentHandler(err, scoped || id) : undefined;
if (typeof rendered === "string") return { value: rendered };
+ if (typeof rendered === "function" && wait && done!.parked?.())
+ return rendered().then(value => (value === undefined ? undefined : { value }));
if (scoped) return { value: plainFallback(), escaped: { frame: ctx.frameId } };
}
// Settle the fragment with the failure. The server error hook hears of it
@@ -393,19 +401,24 @@ function ssrLoadingBoundary(
// Whether the last failure's outcome was rendered by a server
// (its own finding names it; the "client re-renders" one would be wrong).
let outcomeRendered = false;
- function failFragment(err: any, report: boolean): boolean {
- const outcome = errorOutcome(err);
- outcomeRendered = !!outcome && outcome.value !== undefined && !outcome.escaped;
- if (report && !outcomeRendered) {
- reportServerError(
- err,
- { kind: "render", handling: "client", boundary: id },
- o,
- ctx.errorPolicy,
- ctx._errorScope
- );
- }
- return done!(outcome && outcome.value, err, outcome && outcome.escaped);
+ // With `wait`, answers a promise where the outcome is still resolving (see
+ // errorOutcome): the fragment settles when it lands.
+ function failFragment(err: any, report: boolean, wait?: boolean): boolean | Promise {
+ const settle = (outcome: Outcome) => {
+ outcomeRendered = !!outcome && outcome.value !== undefined && !outcome.escaped;
+ if (report && !outcomeRendered) {
+ reportServerError(
+ err,
+ { kind: "render", handling: "client", boundary: id },
+ o,
+ ctx.errorPolicy,
+ ctx._errorScope
+ );
+ }
+ return done!(outcome && outcome.value, err, outcome && outcome.escaped);
+ };
+ const outcome = errorOutcome(err, wait);
+ return outcome instanceof Promise ? outcome.then(settle) : settle(outcome);
}
function runLoadingPhase(render: () => T): T {
@@ -431,8 +444,9 @@ function ssrLoadingBoundary(
// hydration before the fragment channel can engage. (Inside a
// server component the nearest server `` IS asked — for
// its rendered outcome, not to route; see errorOutcome.)
- streamedOnError = failFragment(err, true);
- if (!outcomeRendered) reportRouted(err, "client");
+ streamedOnError = failFragment(err, true, true);
+ if (!(streamedOnError instanceof Promise) && !outcomeRendered)
+ reportRouted(err, "client");
throw err;
}
// Synchronous discovery (no fragment yet): the enclosing Errored's
@@ -460,10 +474,16 @@ function ssrLoadingBoundary(
// flushed — for that pre-shell case, and for a parent handler that itself
// throws, route through the renderer's containment channel instead: the
// request fails (onError + wind-down), the process survives.
- function finalizeError(err: any) {
+ function finalizeError(err: any): void | Promise {
if (handledRenderError === err) {
handledRenderError = undefined;
- record("error", streamedOnError, err);
+ const settling = streamedOnError;
+ if (settling instanceof Promise)
+ return settling.then(streamed => {
+ if (!outcomeRendered) reportRouted(err, "client");
+ record("error", streamed, err);
+ });
+ record("error", settling, err);
return;
}
if (done) {
@@ -737,7 +757,9 @@ function ssrLoadingBoundary(
const streamed = done!(ret && Array.isArray(ret.t) ? ret.t[0] : ((ret && ret.t) as any));
record("settled", streamed);
} catch (err) {
- finalizeError(err);
+ // A failure whose outcome is still resolving settles when it lands.
+ const settling = finalizeError(err);
+ if (settling) await settling;
} finally {
// The slot settles either way: on error, `done(undefined, err)` wrote
// the fragment template and rejects `key_fr`, so the client error path
diff --git a/packages/solid/src/server/shared.ts b/packages/solid/src/server/shared.ts
index 91d38ab33..74de8f05a 100644
--- a/packages/solid/src/server/shared.ts
+++ b/packages/solid/src/server/shared.ts
@@ -42,12 +42,15 @@ export type HydrationContext = {
* server component (no server `` rendered an outcome for it) —
* the renderer surfaces it as the frame's own error, the outward face;
* `escaped.frame` names the component's frame where the channel needs it
- * (the document face's `sc:live` op).
+ * (the document face's `sc:live` op). `parked`: whether settling now
+ * would park its markup in an enclosing pending fragment.
*/
registerFragment: (
v: string,
options?: { revealGroup?: string }
- ) => (v?: string, err?: any, escaped?: { frame?: string }) => boolean;
+ ) => ((v?: string, err?: any, escaped?: { frame?: string }) => boolean) & {
+ parked?: () => boolean;
+ };
/**
* @internal The frame id of the server component this context renders
* inside, on the document face (set by the frames server runtime on the
diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts
index 353bf2385..89dc79ee0 100644
--- a/packages/solid/src/server/signals.ts
+++ b/packages/solid/src/server/signals.ts
@@ -3708,7 +3708,9 @@ export function repeat(
* asking ``'s id, and an `` answers only when that
* Loading is all of its content (#4287).
*/
-export type BoundaryErrorHandler = (err: any, outcome?: true | string) => string | undefined | void;
+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;
const ErrorContext: Context = {
id: Symbol("ErrorContext"),
@@ -3761,7 +3763,7 @@ export function runWithBoundaryErrorContext(
err: any,
parentHandler: BoundaryErrorHandler | null,
outcome?: true | string
- ) => string | undefined | void,
+ ) => BoundaryOutcome,
context?: NonNullable,
boundaryId?: string
): T {
@@ -4275,7 +4277,12 @@ export function createErrorBoundary(
// all of its content: the fallback then lands where the client twin draws
// it, and the error serialized here lets it adopt that markup. Deeper in
// the content it answers nothing — the client renders the failure fresh.
- const renderOutcome = (err: any, outcome: true | string): string | undefined => {
+ // Asked by a `` whose failure nothing shows yet (its fragment is
+ // parked in an enclosing pending one, #4306), a fallback still 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.
+ const renderOutcome = (err: any, outcome: true | string): BoundaryOutcome => {
let t = shown && shown.t;
if (Array.isArray(t)) t = t[0];
if (
@@ -4289,19 +4296,32 @@ export function createErrorBoundary(
return undefined;
// Rendered from a resume loop, where the render context the compiled
// template reads has long moved past this boundary's: restore it.
- const prevCtx = sharedConfig.context;
- sharedConfig.context = ctx;
- try {
- const resolved: any = ctx.resolve(ctx.escape(handleError(err, true)));
- if (!resolved || (resolved.h && resolved.h.length)) return undefined;
+ const inCtx = (run: () => any) => {
+ const prevCtx = sharedConfig.context;
+ sharedConfig.context = ctx;
+ try {
+ return run();
+ } catch {
+ } finally {
+ sharedConfig.context = prevCtx;
+ }
+ };
+ const finish = (resolved: any) => {
+ if (!resolved || (resolved.h && resolved.h.length)) return;
if (deferredWire) (serializeError(deferredWire), (deferredWire = undefined));
const t = resolved.t;
return Array.isArray(t) ? t[0] : t;
- } catch {
- return undefined;
- } finally {
- sharedConfig.context = prevCtx;
- }
+ };
+ let resolved: any = inCtx(() => ctx.resolve(ctx.escape(handleError(err, true))));
+ if (outcome === true || !resolved || !resolved.h || !resolved.h.length) return finish(resolved);
+ 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));
+ }
+ return finish(resolved);
+ };
};
// `$lhSkip`: boundary machinery owns this position (see ssrLoadingBoundary)
// — a live binding over the boundary's output would re-run resolve(),
diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts
index 0b77cb18f..fa30284e7 100644
--- a/packages/web/src/server.ts
+++ b/packages/web/src/server.ts
@@ -2962,7 +2962,7 @@ export function renderToStream(code, options = {}) {
(stubBatch ||= new Map()).set(key + "_fr", p);
else write(key + "_fr", p);
}
- return (value, error, escaped) => {
+ const settle = (value, error, escaped) => {
if (registry.has(key)) {
const item = registry.get(key);
registry.delete(key);
@@ -3051,6 +3051,13 @@ export function renderToStream(code, options = {}) {
}
return firstFlushed;
};
+ // Whether settling now would park this fragment's markup in an
+ // enclosing pending one (above) — nothing of it would show yet (#4306).
+ settle.parked = () => {
+ for (const k of registry.keys()) if (k !== key && key.startsWith(k)) return true;
+ return false;
+ };
+ return settle;
},
revealFragments(groupOrKeys) {
// Group reveal follows fragment registration order so visibility order
diff --git a/packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-async-fallback-4306.json b/packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-async-fallback-4306.json
new file mode 100644
index 000000000..fa7b48a2e
--- /dev/null
+++ b/packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-async-fallback-4306.json
@@ -0,0 +1,5 @@
+{
+ "name": "errored-loading-parked-rejection-async-fallback-4306",
+ "shell": "
outer loadingtail
",
+ "rest": "outer
failed: no user 7 HINT
"
+}
\ No newline at end of file
diff --git a/packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-late-async-fallback-4306.json b/packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-late-async-fallback-4306.json
new file mode 100644
index 000000000..5d2b27b4f
--- /dev/null
+++ b/packages/web/test/harness/__artifacts__/errored-loading-parked-rejection-late-async-fallback-4306.json
@@ -0,0 +1,5 @@
+{
+ "name": "errored-loading-parked-rejection-late-async-fallback-4306",
+ "shell": "
outer loadingtail
",
+ "rest": "outerloading
failed: no user 7 HINT
"
+}
\ No newline at end of file
diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx
index d3d6d8d00..119db677d 100644
--- a/packages/web/test/harness/scenarios.tsx
+++ b/packages/web/test/harness/scenarios.tsx
@@ -1632,6 +1632,53 @@ function PostflushRejectionParked() {
);
}
+// #4306: the parked failure with an async hole in the Errored fallback,
+// still pending when the inner Loading fails. The failure waits out the
+// hole while parked (nothing of it shows yet): settled before the outer
+// Loading, the outer fragment carries the finished fallback; settled after
+// it (`late`), the outer fragment carries the inner placeholder and the
+// inner fragment streams the fallback once the hole lands. Either way the
+// client claims it. (No update pass, as for the plain-fallback parked
+// scenario above.)
+let parkedHoleAsks = 0;
+export const parkedHoleAskCount = () => parkedHoleAsks;
+function parkedRejectionAsyncFallback(late: boolean) {
+ return function PostflushRejectionParkedAsyncFallback() {
+ const outer = createMemo(async () => {
+ await sleep(15);
+ return "outer";
+ });
+ const user = createMemo(async (): Promise => {
+ if (!isServer && Promise === RealPromise) parkedHoleAsks++;
+ await sleep(5);
+ throw new Error("no user 7");
+ });
+ const hint = createMemo(async () => {
+ await sleep(late ? 25 : 10);
+ return "HINT";
+ });
+ return (
+
+ );
+ };
+}
+
// ---------------------------------------------------------------------------
// #3414: a sync throw the SERVER catches, with a zero-arg fallback thunk
// (`fallback={() => }`), inside an enclosing boundary. Errored
@@ -2974,6 +3021,20 @@ export const scenarios: Scenario[] = [
// boundary's resume (as it does on `next`), so the Errored holds the
// server's fallback until reset() rather than tracking the memo.
},
+ {
+ name: "errored-loading-parked-rejection-async-fallback-4306",
+ App: parkedRejectionAsyncFallback(false),
+ async: true,
+ expectedText: "outerfailed: no user 7 HINTtail",
+ serverText: "outer loading tail failed: no user 7 HINT"
+ },
+ {
+ name: "errored-loading-parked-rejection-late-async-fallback-4306",
+ App: parkedRejectionAsyncFallback(true),
+ async: true,
+ expectedText: "outerfailed: no user 7 HINTtail",
+ serverText: "outer loading tail loading failed: no user 7 HINT"
+ },
{
name: "errored-loading-postflush-rejection-async-fallback-4287",
App: PostflushRejectionAsyncFallback,
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
new file mode 100644
index 000000000..7316f81c8
--- /dev/null
+++ b/packages/web/test/hydration/errored-parked-async-outcome-4306.spec.tsx
@@ -0,0 +1,95 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ *
+ * #4306: `` with an async hole in its fallback, around a
+ * `` whose read fails after the shell, inside an outer ``
+ * still pending when it fails. The failure waits out the fallback's hole
+ * while parked in the outer fragment, so the server sends the finished
+ * fallback (and the error record at the Errored id):
+ *
+ * - hole settled before the outer Loading: inside the outer fragment;
+ * - hole settled after it: the outer fragment carries the inner
+ * placeholder, and the inner fragment streams the fallback.
+ *
+ * The client claims that markup in both replay orders, without warnings;
+ * hydrated after the stream it asks nothing again.
+ *
+ * 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 { hydrate } from "@solidjs/web";
+import { scenarios, parkedHoleAskCount } from "../harness/scenarios.jsx";
+
+const artifact = (name: string) =>
+ resolve(dirname(fileURLToPath(import.meta.url)), `../harness/__artifacts__/${name}.json`);
+const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
+
+function applyChunk(container: HTMLDivElement, chunk: string, first: boolean) {
+ const scriptRe = /",
- "rest": " "
+ "rest": "
failed: no user 7 HINT
"
}
\ No newline at end of file
diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx
index 119db677d..aaae82e4a 100644
--- a/packages/web/test/harness/scenarios.tsx
+++ b/packages/web/test/harness/scenarios.tsx
@@ -1537,21 +1537,26 @@ function PostflushRejection() {
}
// The same failure with an async hole in the Errored fallback, still
-// pending when the Loading fails: the server has no finished fallback markup
-// to draw, so it serializes no error either (a record with no markup behind
-// it would make the client claim a fallback that is not there). The client
-// renders the failure itself, as it does on `next`.
+// pending when the Loading fails: the failure waits for the fallback to be
+// ready (RULES-DERIVED D-18, #4306) — the loading placeholder stays until
+// the fragment swaps in the finished fallback, its error serialized with it
+// (serializing it before would make the client claim a fallback that is not
+// there). The client claims it, asking for neither read again.
let setPostflushHoleKey!: (v: string) => void;
+let postflushHoleAsks = 0;
+export const postflushHoleAskCount = () => postflushHoleAsks;
function PostflushRejectionAsyncFallback() {
const [key, setKey] = createSignal("u7");
setPostflushHoleKey = setKey;
const user = createMemo(async (): Promise => {
const k = key();
+ if (!isServer && Promise === RealPromise) postflushHoleAsks++;
await sleep(5);
if (k === "u7") throw new Error("no user 7");
return k;
});
const hint = createMemo(async () => {
+ if (!isServer && Promise === RealPromise) postflushHoleAsks++;
await sleep(20);
return "HINT";
});
@@ -1634,7 +1639,7 @@ function PostflushRejectionParked() {
// #4306: the parked failure with an async hole in the Errored fallback,
// still pending when the inner Loading fails. The failure waits out the
-// hole while parked (nothing of it shows yet): settled before the outer
+// hole (as unparked, above): settled before the outer
// Loading, the outer fragment carries the finished fallback; settled after
// it (`late`), the outer fragment carries the inner placeholder and the
// inner fragment streams the fallback once the hole lands. Either way the
@@ -3040,7 +3045,8 @@ export const scenarios: Scenario[] = [
App: PostflushRejectionAsyncFallback,
async: true,
expectedText: "failed: no user 7 HINTtail",
- serverText: "loading tail",
+ // Shell: the loading fallback; the fragment: the finished fallback.
+ serverText: "loading tail failed: no user 7 HINT",
update: () => setPostflushHoleKey("u8"),
expectedTextAfterUpdate: "user:u8tail"
},
diff --git a/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx b/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
index c2de8cbef..5b62c5ee2 100644
--- a/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
+++ b/packages/web/test/hydration/errored-async-outcome-4287.spec.tsx
@@ -12,8 +12,9 @@
* - Hydrating before it arrived, the client renders the failure itself once
* the fragment rejects, with nothing of the server's swap left beside it.
* - With an async hole in the fallback still pending when the Loading fails,
- * the server has no fallback markup to draw and serializes no error: the
- * client renders the failure itself instead of claiming a missing node.
+ * the failure waits for the fallback (RULES-DERIVED D-18, #4306): the
+ * loading placeholder stays until the fragment swaps in the finished
+ * fallback with the error record, and the client claims it.
*
* Replays the parity harness artifact (test/server/hydration-harness.spec.tsx).
*/
@@ -23,7 +24,11 @@ import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { flush } from "solid-js";
import { hydrate } from "@solidjs/web";
-import { scenarios, postflushClientAskCount } from "../harness/scenarios.jsx";
+import {
+ scenarios,
+ postflushClientAskCount,
+ postflushHoleAskCount
+} from "../harness/scenarios.jsx";
const artifact = (name: string) =>
resolve(dirname(fileURLToPath(import.meta.url)), `../harness/__artifacts__/${name}.json`);
@@ -84,29 +89,60 @@ async function run(mode: "loaded" | "streamed") {
}
}
+// The stream after the shell, one script at a time (a template rides with
+// the script after it), as a browser parses it.
+const pieces = (rest: string) => rest.match(/[\s\S]*?<\/script>/g) ?? [rest];
+
async function runAsyncFallback(mode: "loaded" | "streamed") {
const name = "errored-loading-postflush-rejection-async-fallback-4287";
const file = artifact(name);
const scenario = scenarios.find(s => s.name === name)!;
expect(existsSync(file)).toBe(true);
const { shell, rest } = JSON.parse(readFileSync(file, "utf-8"));
+ // The failure waited for its fallback: the fragment carries it finished,
+ // and the error record at the Errored id with it (#4306).
+ expect(rest).toContain('_$HY.r["3"]');
+ expect(rest).toContain("HINT
");
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 asksBefore = postflushHoleAskCount();
let dispose: (() => void) | undefined;
try {
applyChunk(container, shell, true);
if (mode === "loaded") applyChunk(container, rest, false);
+ const serverP = container.querySelector("p");
dispose = hydrate(() => , container);
flush();
await sleep(10);
flush();
- if (mode === "streamed") applyChunk(container, rest, false);
+ if (mode === "streamed") {
+ // Between the failure and the hint landing the loading placeholder
+ // stays: nothing shows until the swap brings the finished fallback.
+ const seen: string[] = [container.textContent!];
+ for (const piece of pieces(rest)) {
+ applyChunk(container, piece, false);
+ flush();
+ await sleep(5);
+ flush();
+ seen.push(container.textContent!);
+ }
+ const swapped = seen.indexOf("failed: no user 7 HINTtail");
+ expect(swapped).toBeGreaterThan(1);
+ expect(new Set(seen.slice(0, swapped))).toEqual(new Set(["loadingtail"]));
+ }
await sleep(40);
flush();
expect(container.textContent).toBe("failed: no user 7 HINTtail");
expect(container.querySelectorAll("p").length).toBe(1);
+ if (mode === "loaded") expect(container.querySelector("p")).toBe(serverP);
+ // Hydrated after the failure, the server's answers are used: neither
+ // read is asked again. Hydrated before it, the client renders the
+ // failure itself once `_fr` rejects (as for the plain fallback), and
+ // that fresh read of `user` — a cycle after its error landed — retries
+ // it: one ask, during which the inner Loading shows its fallback again.
+ expect(postflushHoleAskCount()).toBe(asksBefore + (mode === "streamed" ? 1 : 0));
scenario.update!();
flush();
@@ -114,8 +150,6 @@ async function runAsyncFallback(mode: "loaded" | "streamed") {
flush();
expect(container.textContent).toBe("user:u8tail");
expect(warn).not.toHaveBeenCalled();
- // No fallback markup was sent, so no error record either.
- expect(rest).not.toContain('_$HY.r["3"]');
} finally {
warn.mockRestore();
dispose?.();
@@ -127,8 +161,8 @@ async function runAsyncFallback(mode: "loaded" | "streamed") {
describe("#4287: Errored over a Loading whose read fails after the shell", () => {
test("hydrated after the failure arrived: adopts the server's fallback", () => run("loaded"));
test("hydrated before the failure arrived: shows it once, no ask", () => run("streamed"));
- test("fallback with a pending async hole, hydrated after the failure: renders it", () =>
+ test("fallback with a pending async hole, hydrated after the failure: adopts it", () =>
runAsyncFallback("loaded"));
- test("fallback with a pending async hole, hydrated before the failure: renders it", () =>
+ test("fallback with a pending async hole, hydrated before the failure: placeholder until the swap", () =>
runAsyncFallback("streamed"));
});
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 7316f81c8..7c88c0887 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
@@ -5,8 +5,8 @@
* #4306: `` with an async hole in its fallback, around a
* `` whose read fails after the shell, inside an outer ``
* still pending when it fails. The failure waits out the fallback's hole
- * while parked in the outer fragment, so the server sends the finished
- * fallback (and the error record at the Errored id):
+ * (RULES-DERIVED D-18), so the server sends the finished fallback (and the
+ * error record at the Errored id):
*
* - hole settled before the outer Loading: inside the outer fragment;
* - hole settled after it: the outer fragment carries the inner
From d9a7a496ce026f7d28d190378122286894fe2f09 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 27 Oct 2026 02:29:06 +0000
Subject: [PATCH 05/16] fix(solid,web): a hydrated failure re-serves the
server's error instead of asking again (#4287, #4306)
A client hydrating mid-stream asked again for an async memo whose server
read failed, and flashed back to the inner fallback after the
failure showed: Errored > Loading > read, with the read failing after the
shell, unparked (the fragment rejects and the client renders the failure
fresh over the swapped-in Errored fallback) or parked in a pending outer
Loading (the outer resume runs the hydrated Errored's content, NO_CLAIM).
Both content runs read the shell memo a cycle after its error landed, and
a later tracked read retries an errored async node (PJ-R35). The retry
re-enters the hydration latch, which since f3da41e23 has taken every
re-entry for a dependency change ("re-entry is always invalidation-
driven"), and since b68a907de computes such a rerun at once for a shell
node outside the claim in progress: one real ask, the Loading fallback
for its duration. Before f3da41e23 the latch re-served the serialized
answer on every re-entry while hydrating, so the retry asked nothing.
Inside a hydrated failure's content run (NO_CLAIM) a latched rerun now
re-serves the server's answer, its error included, and arms nothing. A
rejected fragment's fresh render runs as such a content run when the swap
brought the outcome the server drew for the failure (the reveal record
lists its nodes); to make that list the outcome alone, a rejected
fragment with no outcome streams an empty template instead of a lone
space. Uncontained failures (no outcome) keep rendering fresh as before.
Tests: the #4287/#4306 hydration specs stream piece by piece with a clock
tick between pieces (the plain-fallback case only passed because no flush
ran between the error landing and the fresh read), assert 0 asks in both
modes and no flash back to a loading fallback, and cover the parked
plain-fallback scenario, which now has an ask counter and an update pass
(the Errored tracks the memo, #4273).
Size: 0 B on every CSR scenario (compiled floor 9,966 B); +65-71 B
minified on the hydrating scenarios.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
---
.changeset/hydrated-failure-no-reask.md | 6 +
packages/solid/src/client/hydration.ts | 31 +++-
packages/web/src/server.ts | 22 ++-
...element-4242-issue-streamed-reference.json | 2 +-
...sync-memo-element-4242-issue-streamed.json | 2 +-
...o-element-4242-jsx-streamed-reference.json | 2 +-
.../async-memo-element-4242-jsx-streamed.json | 2 +-
.../__artifacts__/bare-client-late-final.json | 2 +-
...tion-loading-postflush-rejection-4287.json | 2 +-
packages/web/test/harness/scenarios.tsx | 22 ++-
.../errored-async-outcome-4287.spec.tsx | 142 ++++++++++++++----
...errored-parked-async-outcome-4306.spec.tsx | 55 +++++--
.../frame-fragment-error-outcome.spec.tsx | 3 +-
13 files changed, 220 insertions(+), 73 deletions(-)
create mode 100644 .changeset/hydrated-failure-no-reask.md
diff --git a/.changeset/hydrated-failure-no-reask.md b/.changeset/hydrated-failure-no-reask.md
new file mode 100644
index 000000000..97bbc75d5
--- /dev/null
+++ b/.changeset/hydrated-failure-no-reask.md
@@ -0,0 +1,6 @@
+---
+"solid-js": patch
+"@solidjs/web": patch
+---
+
+A client hydrating mid-stream no longer asks again for an async read whose server answer was an error, nor flashes back to the `` fallback after the failure showed (#4287, #4306). The content of a hydrated failure (a hydrated ``'s content run, or a rejected fragment's fresh render over the `` fallback the server drew) reads a shell memo a cycle after its error landed, and a later tracked read retries an errored async node; the serialized-value latch took that rerun for a client write and computed. Inside such a content run the rerun now re-serves the server's answer and arms nothing. A rejected fragment with no outcome streams an empty template (it was a lone space), so the nodes its swap lists are the server's outcome alone.
diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts
index d09a7e718..36ad4e7c5 100644
--- a/packages/solid/src/client/hydration.ts
+++ b/packages/solid/src/client/hydration.ts
@@ -306,8 +306,11 @@ let _snapshotRootOwner: Owner | null = null;
// The boundary owner whose resume window is open (null during a root pass,
// which claims everything). Reached as `sharedConfig.isClaiming`.
let _claimOwner: Owner | null = null;
-// The claim window over no owner: a hydrated ``'s content run,
-// where nothing claims (hydratedCreateErrorBoundary).
+// The claim window over no owner: a hydrated failure's content run — a
+// hydrated ``'s (hydratedCreateErrorBoundary), or a rejected
+// fragment's fresh render over the outcome the server drew for it
+// (resumeBoundaryHydration) — where nothing claims and what the content
+// reads is the server's failure (readSerializedOrCompute).
const NO_CLAIM = {} as Owner;
function isClaiming(): boolean {
if (!_claimOwner) return true;
@@ -625,7 +628,9 @@ export function readHydratedValue(initP: any, refresh: () => void, options?: any
* not deferred. Arming the takeover gate on that re-entry re-runs exactly
* the diverged nodes against their live sources when hydration ends.
* (Async settles commit through the adopted thenable without re-entering
- * the wrapper, so re-entry here is always invalidation-driven.)
+ * the wrapper, so re-entry here is invalidation-driven — save one: a later
+ * tracked read retries an errored async node (PJ-R35) with nothing changed.
+ * A hydrated failure's content run re-serves that one, below.)
*/
const latchedOnce = new WeakSet