diff --git a/.changeset/abandoned-records-compute-on-fresh-render.md b/.changeset/abandoned-records-compute-on-fresh-render.md new file mode 100644 index 000000000..2f41fa793 --- /dev/null +++ b/.changeset/abandoned-records-compute-on-fresh-render.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +A streamed boundary that fails on the server can leave nested reads still pending. The server abandons those reads, and their records used to settle as `undefined`, which the client's fresh render of the failed fragment would then adopt as the server's answer. The server now unregisters abandoned records, so those reads compute on the client. The fresh render also spends an id on each element's hydration key, as the server did, so its reads line up with the server's records by id. diff --git a/.changeset/fresh-render-dynamic-portal.md b/.changeset/fresh-render-dynamic-portal.md new file mode 100644 index 000000000..5010a5aa2 --- /dev/null +++ b/.changeset/fresh-render-dynamic-portal.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +A boundary whose server render failed is rendered fresh on the client while adopting the server's records. Inside that render, a `` tag now creates its element instead of throwing a hydration mismatch, and a `` mounts. Hydration also completes when the adopted error has no client boundary to catch it. diff --git a/.changeset/uncontained-failure-adopts-server-error.md b/.changeset/uncontained-failure-adopts-server-error.md new file mode 100644 index 000000000..9958dda8d --- /dev/null +++ b/.changeset/uncontained-failure-adopts-server-error.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +Hydration never asks again for what the server sent, even when a streamed boundary's fragment rejects with no outcome (no `` on the server could draw the failure). The client still renders that fragment fresh, so its graph is created, but every read in it now claims its serialized record: reads the server resolved take their values, and a read whose record is an error throws that error to the nearest client boundary, or is an uncaught error when there is none. Only reads with no record, such as those cut off by a truncated stream, compute. Recovery comes from a real input change or a boundary's `reset()`, as for any hydrated failure. diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index eceb2abab..4dc5ddb00 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -54,8 +54,6 @@ import { IS_DEV, IS_OBSERVE } from "./core.js"; // enableHydration(). import { hydrateStoreLike } from "./store-hydration.js"; -declare const _$HY: any; - type HydrationSsrFields = { /** * Defer the SSR stream flush until this primitive's first value is @@ -590,8 +588,9 @@ export function syncThenable(value: any) { /** * A served pass: `write(x)` lands a served answer — the server's answer for a * node arriving (the async runtime writes it and flushes, synchronously), - * or a rejected fragment's fresh render over the outcome the server drew - * (the boundary's trigger) — and the flush round that carries it is + * or a boundary's fresh render (its trigger; `adopt`: hydrating, so what it + * creates adopts its server answer by id, claiming nothing) — and the + * flush round that carries it is * hydration's own pass over served answers (`_claimOwner`, that round). A * node that adopted its own server answer re-runs there because the * server's answer for something it reads arrived, not because a client @@ -601,12 +600,27 @@ export function syncThenable(value: any) { * the flush, where a node it reaches computes. Runs from a settled promise, * outside any claim window. */ -const servedPass = (write: (x?: any) => void) => (x?: any) => { +const servedPass = (write: (x?: any) => void, adopt?: boolean) => (x?: any) => { flush(); + // Adopting, hydration is on but nothing claims: the render is a client + // render, so it opens no snapshot scope either (the `hydrating` setter, + // which would release one, is bypassed here). A scope left open would hold + // every write made in it — a client-only node's gate (a ) never + // opening. + _hydratingValue = _snapshotRootOwner = adopt as any; _claimOwner = ~clock; - write(x); - flush(); - _claimOwner = null; + // An uncaught error (no client boundary for a served error) leaves the + // flush by throwing: the pass still ends, and hydration still completes + // — a boundary's fresh render is its resume, done all the same. (Outside + // a resume the check is a no-op: hydration is done, or boundaries are + // pending.) + try { + write(x); + flush(); + } finally { + _claimOwner = _hydratingValue = _snapshotRootOwner = null as any; + checkHydrationComplete(); + } }; /** @@ -2306,7 +2320,7 @@ function resumeBoundaryHydration( id: string, set: () => void, release: () => boolean, - shouldHydrate = true + shouldHydrate: boolean | 0 = true ) { // Read before release(): releasing removes the boundaryScopes entry. const scope = sharedConfig.boundaryScopes?.get(id); @@ -2334,12 +2348,15 @@ function resumeBoundaryHydration( scope, o ) - : // The client renders the boundary fresh (recover): no claim. Over the - // outcome the server drew for the failure (an `` fallback the - // swap brought, listed in the reveal record, #4287) it is that - // failure's content run, as a hydrated ``'s is: a served pass. - // A recover only runs while hydrating, so the page's `_$HY` exists. - (_$HY.v?.[id]?.[0] ? servedPass(set) : set)(); + : // The client renders the boundary fresh (recover): no claim, a served + // pass. After a rejection (0: the server's, or the stream cut short) + // the render adopts what the server sent by id, as a hydrated + // ``'s content run does: the failing read's error included, + // thrown to the nearest client boundary — the outcome the server drew + // for it, if any (#4287), or an uncaught error — instead of asking + // again. What the server never sent (a truncated ref is dropped from + // the registry) computes. + servedPass(set, shouldHydrate === 0)(); flush(); checkHydrationComplete(); } @@ -2355,7 +2372,7 @@ function resumeBoundaryHydration( function recover( id: string, registeredAt: number, - resume: (shouldHydrate?: boolean) => void + resume: (shouldHydrate?: boolean | 0) => void ): void { if (registeredAt < 0 || !OBSERVE!.records.observed("recovery")) { resume(false); @@ -2373,7 +2390,7 @@ function recover( function initBoundaryResume( o: Owner, id: string -): [trigger: () => void, resume: (shouldHydrate?: boolean) => void, release: () => boolean] { +): [trigger: () => void, resume: (shouldHydrate?: boolean | 0) => void, release: () => boolean] { _pendingBoundaries++; (o as any)._hp = 1; // Capture the current root's registry/gather pair for this boundary's @@ -2733,9 +2750,9 @@ function fragmentAbort(id: string): Promise { function waitAndResume( p: any, - resume: (shouldHydrate?: boolean) => void, + resume: (shouldHydrate?: boolean | 0) => void, assetPromise?: Promise, - hydrateRejected = true, + hydrateRejected: boolean | 0 = true, abort?: Promise ) { // Settle data and assets independently: an asset error must not be written @@ -2743,7 +2760,7 @@ function waitAndResume( // its own hydrate semantics. (`p` may be an exotic thenable — coerce first.) // An abort promise (fragment truncation) races the data: its rejection // flows through the same rejected-state write as a server-sent rejection. - const data: Promise = ( + const data: Promise = ( abort ? Promise.race([Promise.resolve(p), abort]) : Promise.resolve(p) ).then( () => { @@ -2786,7 +2803,7 @@ function reportAssetFailure(err: any) { function scheduleResumeAfterAssets( id: string, - resume: (shouldHydrate?: boolean) => void, + resume: (shouldHydrate?: boolean | 0) => void, assetPromise?: Promise ): boolean { sharedConfig.gather?.(id); @@ -2973,7 +2990,7 @@ function hydratedCreateLoadingBoundary( // branch and `recover` with it. waitAndResume( 1, - IS_OBSERVE ? () => recover(id, registeredAt, resume) : () => resume(false), + IS_OBSERVE ? () => recover(id, registeredAt, () => resume(0)) : () => resume(0), assetPromise ); return undefined; @@ -3016,9 +3033,13 @@ function hydratedCreateLoadingBoundary( // (#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)), + IS_OBSERVE + ? recover(id, registeredAt, () => resume(shouldHydrate)) + : resume(shouldHydrate)), assetPromise, - false, + // A rejection (the server's, or the stream cut short) resumes with 0: + // the fresh render adopts what the server sent (resumeBoundaryHydration). + 0, fragmentAbort(id) ); return revealed ? undefined : fallback(); @@ -3057,7 +3078,10 @@ export function NoHydration(props: { children: SolidElement }): SolidElement { const o = createOwner(); return runWithOwner(o, () => { setContext(NoHydrateContext, true); - if (sharedConfig.hydrating) return undefined as unknown as SolidElement; + // Claiming, not the bare flag: a rejected fragment's fresh render + // (adopting by id, claiming nothing) renders the children like any + // client render. + if (isHydrating()) return undefined as unknown as SolidElement; return props.children; }) as unknown as SolidElement; } diff --git a/packages/solid/test/client-hydration.spec.ts b/packages/solid/test/client-hydration.spec.ts index 0ac7b06e9..b3c14725d 100644 --- a/packages/solid/test/client-hydration.spec.ts +++ b/packages/solid/test/client-hydration.spec.ts @@ -4697,7 +4697,7 @@ describe("Loading boundary: fragment registration channel (_fr)", () => { expect(memo()).toBe(99); }); - test("rejected _fr resumes without hydrating serialized children", async () => { + test("rejected _fr resumes fresh, adopting serialized children", async () => { let rejectFr!: (e: any) => void; const frPromise: any = new Promise((_, rej) => (rejectFr = rej)); frPromise.s = 0; @@ -4739,7 +4739,8 @@ describe("Loading boundary: fragment registration channel (_fr)", () => { await new Promise(r => setTimeout(r, 50)); flush(); - expect(memo()).toBe(0); + // Fresh DOM, but the server's answer is used: hydration never refetches. + expect(memo()).toBe(7); }); test("_fr with assets waits for both fragment and assets", async () => { diff --git a/packages/solid/test/client-recovery-record.spec.ts b/packages/solid/test/client-recovery-record.spec.ts index 3fd8e5ac6..677c02516 100644 --- a/packages/solid/test/client-recovery-record.spec.ts +++ b/packages/solid/test/client-recovery-record.spec.ts @@ -119,8 +119,8 @@ describe.skipIf(OBSERVE === undefined)("recovery record", () => { rejectFr(new Error("stream error")); await new Promise(r => setTimeout(r, 20)); flush(); - // Fresh client DOM: the serialized 7 was never adopted. - expect(b.memo()).toBe(0); + // Fresh client DOM, the serialized 7 adopted: hydration never refetches. + expect(b.memo()).toBe(7); expect(records).toEqual([{ id: "t0", at: T0, waitedMs: 30, renderMs: RENDER_MS }]); }); @@ -133,7 +133,7 @@ describe.skipIf(OBSERVE === undefined)("recovery record", () => { const b = boundary(); await new Promise(r => setTimeout(r, 10)); flush(); - expect(b.memo()).toBe(0); + expect(b.memo()).toBe(7); // The rejection had already arrived when the boundary registered: the // clock did not move between registration and the recovery, so the // person saw no fallback — `waitedMs` is exactly 0. @@ -163,7 +163,7 @@ describe.skipIf(OBSERVE === undefined)("recovery record", () => { const b = boundary(); await new Promise(r => setTimeout(r, 10)); flush(); - expect(b.memo()).toBe(0); + expect(b.memo()).toBe(7); // The runtime never read the clock: `registeredAt` is -1 with no listener. expect(performance.now).not.toHaveBeenCalled(); }); diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index c17db49c9..1e967ff4c 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -2234,10 +2234,12 @@ function gateHeadResource(props) { // errored) sheet must acquire synchronously — cached sheets add zero // wait, adopted server-emitted sheets never stall — and even a // settled promise costs a microtask through the async machinery. - // Hydrating content is already visible; the server gated its reveal. + // Claimed content is already visible; the server gated its reveal. A + // fresh render under hydration (a rejected fragment's) gates like any + // client render. if ( gateable && - !sharedConfig.hydrating && + !isHydrating() && entry.loadState === "pending" && typeof waitAsset === "function" ) @@ -2580,20 +2582,22 @@ export function hydrate(code, element, options = {}) { export function getNextElement(template?: () => Element): Element; export function getNextElement(template) { - let node, - key, - hydrating = isHydrating(); - if (!hydrating || !(node = sharedConfig.registry.get((key = getHydrationKey())))) { + // The key is consumed whenever hydration is on, claiming or not: a + // rejected fragment's fresh render adopts its server answers by id, and + // the server spent one on this element, so ids after it line up. + let key = sharedConfig.hydrating && getHydrationKey(), + node = isHydrating() && sharedConfig.registry.get(key); + if (!node) { if (!template) { throw new Error(`Hydration Mismatch. Unable to find DOM nodes for hydration key: ${key}`); } // A key miss during the hydration walk is a real mismatch: fresh renders - // (portals, rejected fragments, post-hydration content) all run with the - // hydrating flag off, so there is no legitimate way to get here. The + // (portals, rejected fragments, post-hydration content) all run outside + // the claim, so there is no legitimate way to get here. The // detached element returned below keeps the render alive but never lands // in the document — without a report that reads as a silently frozen // page (solidjs/solid#3000). - if ("_SOLID_DEV_" && hydrating) { + if ("_SOLID_DEV_" && isHydrating()) { hydrationKeyMisses++; console.warn( `Hydration key miss for "${key}": no server-rendered element carries this key` + diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 06c0272a9..06c4f93eb 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -325,14 +325,16 @@ function staticElement(tag: string, props: any): JSX.Element { // CREATED (customized built-in / namespace), so they are read once here, // untracked — the DOM can't change either after creation — and then flow // through spread() like any attribute. Hydration claims the - // parser-namespaced node, so neither applies. - const el = hydrating - ? getNextElement() - : createElement( - tag, - untrack(() => props.is), - untrack(() => props.xmlns) - ); + // parser-namespaced node, so neither applies — when it claims: a fresh + // render under hydration (a rejected fragment's, adopting by id) spends + // the key and creates, like a compiled template. + const create = () => + createElement( + tag, + untrack(() => props.is), + untrack(() => props.xmlns) + ); + const el = hydrating ? getNextElement(create) : create(); spread(el, props); // Compiled JSX emits runHydrationEvents() after an element that carries // event handlers. Handlers bound through spread() here need the same call, diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 758190a55..e764bb8d7 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -2662,9 +2662,10 @@ export function renderToStream(code, options = {}) { // resume loops may be parked on promises that never settle), and pending // serialized values under the errored key would gate seroval's onDone the // same way. Settle both. Descendant `_fr` stubs resolve clean (not - // rejected) and abandoned data ids resolve undefined: the client re-renders - // the errored region fresh off the OUTER fragment's rejection, so nothing - // consumes these — a rejection would only raise unhandled-rejection noise. + // rejected) and abandoned data ids resolve undefined, and are unregistered + // on the client: it re-renders the errored region fresh off the OUTER + // fragment's rejection, so nothing consumes these — a rejection would only + // raise unhandled-rejection noise. // // A finding (`SSR_SUBTREE_ABANDONED`) when the discard took work with it — // nested fragments still rendering, serialized values still pending: the @@ -2686,13 +2687,24 @@ export function renderToStream(code, options = {}) { fragments++; } } + const unregister = []; for (const [id, settle] of pendingSerialized) { if (id.startsWith(key)) { pendingSerialized.delete(id); settle(); serialized++; + // Not yet on the wire: never registered. Otherwise the client drops + // it (below). + if (!stubBatch?.delete(id)) unregister.push(`delete _$HY.r[${JSON.stringify(id)}]`); } } + // The settle above only releases seroval: `undefined` is not the + // server's answer. The client renders this subtree fresh, adopting what + // the server sent by id, so an abandoned record must not be there to + // adopt: unregister it (a document task, ahead of the `_fr` rejection + // that triggers the fresh render) and the read computes. A frame sink + // has no reader for these records. + if (unregister.length && !options.sink) pushTask(unregister.join(";")); if ("_SOLID_OBSERVE_" && (fragments || serialized)) emitFinding( { diff --git a/packages/web/test/harness/__artifacts__/abandoned-sibling-computes.json b/packages/web/test/harness/__artifacts__/abandoned-sibling-computes.json new file mode 100644 index 000000000..d2900804b --- /dev/null +++ b/packages/web/test/harness/__artifacts__/abandoned-sibling-computes.json @@ -0,0 +1,5 @@ +{ + "name": "abandoned-sibling-computes", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/nested-rejection-no-outcome.json b/packages/web/test/harness/__artifacts__/nested-rejection-no-outcome.json new file mode 100644 index 000000000..319a99a24 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/nested-rejection-no-outcome.json @@ -0,0 +1,5 @@ +{ + "name": "nested-rejection-no-outcome", + "shell": "
outer loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/rejected-fragment-client-only.json b/packages/web/test/harness/__artifacts__/rejected-fragment-client-only.json new file mode 100644 index 000000000..d32207ef6 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/rejected-fragment-client-only.json @@ -0,0 +1,5 @@ +{ + "name": "rejected-fragment-client-only", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/rejected-fragment-dynamic.json b/packages/web/test/harness/__artifacts__/rejected-fragment-dynamic.json new file mode 100644 index 000000000..bb7885ce0 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/rejected-fragment-dynamic.json @@ -0,0 +1,5 @@ +{ + "name": "rejected-fragment-dynamic", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/rejected-fragment-for.json b/packages/web/test/harness/__artifacts__/rejected-fragment-for.json new file mode 100644 index 000000000..e5f6eed60 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/rejected-fragment-for.json @@ -0,0 +1,5 @@ +{ + "name": "rejected-fragment-for", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/rejected-fragment-nested-loading.json b/packages/web/test/harness/__artifacts__/rejected-fragment-nested-loading.json new file mode 100644 index 000000000..4eab1b54a --- /dev/null +++ b/packages/web/test/harness/__artifacts__/rejected-fragment-nested-loading.json @@ -0,0 +1,5 @@ +{ + "name": "rejected-fragment-nested-loading", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/rejected-fragment-nohydration.json b/packages/web/test/harness/__artifacts__/rejected-fragment-nohydration.json new file mode 100644 index 000000000..89b5235ec --- /dev/null +++ b/packages/web/test/harness/__artifacts__/rejected-fragment-nohydration.json @@ -0,0 +1,5 @@ +{ + "name": "rejected-fragment-nohydration", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/rejected-fragment-portal.json b/packages/web/test/harness/__artifacts__/rejected-fragment-portal.json new file mode 100644 index 000000000..bf0b62730 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/rejected-fragment-portal.json @@ -0,0 +1,5 @@ +{ + "name": "rejected-fragment-portal", + "shell": "
loading
tail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/rejected-fragment-stylesheet.json b/packages/web/test/harness/__artifacts__/rejected-fragment-stylesheet.json new file mode 100644 index 000000000..77a9f5f35 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/rejected-fragment-stylesheet.json @@ -0,0 +1,5 @@ +{ + "name": "rejected-fragment-stylesheet", + "shell": "
loadingtail
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/served-error-reread.json b/packages/web/test/harness/__artifacts__/served-error-reread.json new file mode 100644 index 000000000..eb035229a --- /dev/null +++ b/packages/web/test/harness/__artifacts__/served-error-reread.json @@ -0,0 +1,5 @@ +{ + "name": "served-error-reread", + "shell": "
loadingslow loading
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx index cb6d46a78..718fd4f80 100644 --- a/packages/web/test/harness/scenarios.tsx +++ b/packages/web/test/harness/scenarios.tsx @@ -37,7 +37,8 @@ import { For, Loading, Errored, - Repeat + Repeat, + NoHydration } from "solid-js"; import { Portal, @@ -1968,6 +1969,301 @@ export const shellFallbackFailures: ShellFallbackFailure[] = (["async", "sync"] hole => [false, true].flatMap(A => [false, true].map(section => ({ hole, A, section }))) ); +// --------------------------------------------------------------------------- +// Never refetch: a served error is the server's answer while hydration runs. +// A shell memo `user` fails on the server after the shell flushed; an +// over a reads it beside an unrelated signal `n`, and a +// slow second keeps hydration open after the failure shows. On +// the client `user` resolves, so only a real ask recovers: the Errored's +// reset() is one, mid-hydration too. A write to `n` re-runs the reader. +let servedErrorAsks = 0; +export const servedErrorAskCount = () => servedErrorAsks; +export const servedErrorControl: { setN?: (v: number) => void; reset?: () => void } = {}; +function ServedErrorReread() { + const [n, setN] = createSignal(0); + servedErrorControl.setN = setN; + const user = createMemo(async (): Promise => { + if (!isServer && Promise === RealPromise) servedErrorAsks++; + await sleep(5); + if (isServer) throw new Error("no user 7"); + return "u7"; + }); + const slow = createMemo(async () => { + await sleep(isServer ? 60 : 1); + return "slow"; + }); + return ( +
+ void) => ( + (servedErrorControl.reset = reset), + (

failed: {e().message}

) + )} + > + loading}> + {n() + ":" + user()} + +
+ slow loading}> + {slow()} + +
+ ); +} + +// An uncontained failure the server cannot draw: an in the shell +// over a that streamed its content (a resolved read `a`, a +// counter), with a nested whose read `user` fails after that, +// beside a read `b` the server resolved. The Errored and the outer fragment +// are already on the wire, so the inner fragment rejects with no outcome. +// The client renders that fragment fresh, so its graph is created, but +// every read claims what the server sent: `b` its value, `user` its error, +// thrown to the nearest client boundary, the shell's Errored. No ask. +// reset() asks, and the recreated graph is live. +let nestedRejectionAsks = 0; +let nestedRejectionSiblingAsks = 0; +export const nestedRejectionAskCount = () => nestedRejectionAsks; +export const nestedRejectionSiblingAskCount = () => nestedRejectionSiblingAsks; +export const nestedRejectionControl: { reset?: () => void } = {}; +function NestedRejectionUser() { + const b = createMemo(async (): Promise => { + if (!isServer && Promise === RealPromise) nestedRejectionSiblingAsks++; + await sleep(10); + return "b"; + }); + const user = createMemo(async (): Promise => { + if (!isServer && Promise === RealPromise) nestedRejectionAsks++; + await sleep(20); + if (isServer) throw new Error("no user 7"); + return "u7"; + }); + return ( + + {b()} + {user()} + + ); +} +function NestedRejection() { + const a = createMemo(async () => { + await sleep(5); + return "a"; + }); + const [count, setCount] = createSignal(0); + return ( +
+ void) => ( + (nestedRejectionControl.reset = reset), + (

failed: {e().message}

) + )} + > + outer loading}> + {a()} + + inner loading}> + + + +
+ tail +
+ ); +} + +// A sibling the server never finished: a streams `a` (resolved at +// 5ms) and `c` (50ms on the server), and its content throws on the server +// once `a` lands, a render error no record carries. The fragment rejects +// with `c` still pending, so the server abandons it. The client renders the +// fragment fresh: `a` and `d` take the server's values (`d` sits after an +// element, whose hydration key the server spent an id on: the fresh +// render's ids must line up), and `c`, which has no answer from the +// server, computes (an abandoned record is not the value `undefined`). +// Nothing throws on the client, so the graph is live. +const abandonedAsks = { a: 0, c: 0, d: 0 }; +export const abandonedSiblingAsks = () => ({ ...abandonedAsks }); +function AbandonedSiblingC() { + const c = createMemo(async () => { + if (!isServer && Promise === RealPromise) abandonedAsks.c++; + await sleep(isServer ? 50 : 1); + return "c"; + }); + return {c()}; +} +function AbandonedSiblingD() { + const d = createMemo(async () => { + if (!isServer && Promise === RealPromise) abandonedAsks.d++; + await sleep(2); + return "d"; + }); + return {d()}; +} +function AbandonedSiblingContent() { + const a = createMemo(async () => { + if (!isServer && Promise === RealPromise) abandonedAsks.a++; + await sleep(5); + return "a"; + }); + const [count, setCount] = createSignal(0); + return ( + <> + {a()} + + c loading}> + + + + {(() => { + const v = a(); + if (isServer) throw new Error("server render"); + return "!" + v; + })()} + + + + ); +} +function AbandonedSibling() { + return ( +
+ loading}> + + + tail +
+ ); +} + +// A fragment whose server render threw (no record carries it) is rendered +// fresh on the client with hydration on, adopting by id and claiming +// nothing. Element runtimes that pick claim-or-create on that flag alone +// must create here: `` (staticElement) and a +// `` (its client-only gate) inside such a fragment. +function ServerRenderThrows(props: { children: JSX.Element }) { + const a = createMemo(async () => { + await sleep(5); + return "a"; + }); + return ( + <> + {a()} + {props.children} + + {(() => { + const v = a(); + if (isServer) throw new Error("server render"); + return "!" + v; + })()} + + + ); +} +function RejectedDynamic() { + return ( +
+ loading}> + + dyn + + + tail +
+ ); +} +function RejectedPortal() { + const [host, setHost] = createSignal(); + return ( +
+ loading}> + + + portal + + + +
+ tail +
+ ); +} +function RejectedNoHydration() { + return ( +
+ loading}> + + + nh + + + + tail +
+ ); +} +const RejectedClientOnlyWidget = clientOnly(() => Promise.resolve({ default: () => co })); +function RejectedClientOnly() { + return ( +
+ loading}> + + cf} /> + + + tail +
+ ); +} +function RejectedFor() { + return ( +
+ loading}> + + {v => {v}} + + + tail +
+ ); +} +function RejectedSheetRoute() { + useHead({ tag: "link", props: { rel: "stylesheet", href: "/rejected-sheet.css" } }); + return css; +} +function RejectedStylesheet() { + return ( +
+ loading}> + + + + + tail +
+ ); +} +function RejectedNestedLoading() { + const n = createMemo(async () => { + await sleep(10); + return "n"; + }); + return ( +
+ loading}> + + inner}> + {n()} + + + + tail +
+ ); +} + // --------------------------------------------------------------------------- // #3414: a sync throw the SERVER catches, with a zero-arg fallback thunk // (`fallback={() => }`), inside an enclosing boundary. Errored @@ -3344,6 +3640,78 @@ export const scenarios: Scenario[] = [ expectedTextAfterUpdate: "user:u8tail", stableSelector: "div" }, + { + name: "served-error-reread", + App: ServedErrorReread, + async: true, + expectedText: "failed: no user 7slow", + serverText: "loading slow loading failed: no user 7 slow" + }, + { + name: "rejected-fragment-dynamic", + App: RejectedDynamic, + async: true, + expectedText: "adyn!atail", + serverText: "loading tail" + }, + { + name: "rejected-fragment-portal", + App: RejectedPortal, + async: true, + expectedText: "a!aportaltail", + serverText: "loading tail" + }, + { + name: "rejected-fragment-nohydration", + App: RejectedNoHydration, + async: true, + expectedText: "anh!atail", + serverText: "loading tail" + }, + { + name: "rejected-fragment-client-only", + App: RejectedClientOnly, + async: true, + expectedText: "aco!atail", + serverText: "loading tail" + }, + { + name: "rejected-fragment-for", + App: RejectedFor, + async: true, + expectedText: "axy!atail", + serverText: "loading tail" + }, + { + name: "rejected-fragment-stylesheet", + App: RejectedStylesheet, + async: true, + // The fresh content waits for its sheet, which jsdom never loads here; + // served-error-no-reask.spec.tsx dispatches the load. + expectedText: "loadingtail", + serverText: "loading tail" + }, + { + name: "rejected-fragment-nested-loading", + App: RejectedNestedLoading, + async: true, + expectedText: "an!atail", + serverText: "loading tail" + }, + { + name: "abandoned-sibling-computes", + App: AbandonedSibling, + async: true, + expectedText: "adc!an0tail", + serverText: "loading tail" + }, + { + name: "nested-rejection-no-outcome", + App: NestedRejection, + async: true, + expectedText: "failed: no user 7tail", + serverText: "outer loading tail a c0 inner loading" + }, { name: "served-source-landing", App: ServedSourceLanding, diff --git a/packages/web/test/hydration/async-memo-element-4242.spec.tsx b/packages/web/test/hydration/async-memo-element-4242.spec.tsx index 19c587019..4e53cc3d7 100644 --- a/packages/web/test/hydration/async-memo-element-4242.spec.tsx +++ b/packages/web/test/hydration/async-memo-element-4242.spec.tsx @@ -13,7 +13,10 @@ * - inside an ``, the client hydrates the fallback the server * rendered for the refusal and hydration completes; * - uncontained, the client does exactly what it does for any other - * server render error at that position (the `-reference` twin); + * server render error at that position (the `-reference` twin): the + * rejected fragment's fresh render adopts the server's error instead of + * asking again, and with no client boundary to catch it, it is an + * uncaught error; * - ordinary data lands, is adopted without the client recomputing it, and * the page is interactive. */ @@ -207,15 +210,27 @@ describe("async memo resolving to rendered output (#4242) — hydration", () => rejections: o.rejections.length }); expect(comparable(outcome)).toEqual(comparable(reference)); - if (mode === "loaded") { - // The fragment rejected and the client rendered the subtree - // itself: the element on screen, live. - expect(outcome.halted).toBe(false); - expect(outcome.done).toBe(true); - if (variant.shape === "jsx") { - expect(outcome.text).toBe("jsx-41Count: 1"); - expect(outcome.afterClick).toBe("Count: 1"); - } else expect(outcome.text).toBe("nested-41"); + // The fragment rejected with no outcome: the client renders it + // fresh, adopting what the server sent — the memo's error, never + // asked for again (hydration never refetches). Nothing on the client + // catches it (a does not), so it is an uncaught error, as + // on any client render: the reactive system halts, the element + // never renders (no counter to click). The boundary has resumed all + // the same, so hydration completes. Recovery is a real input change + // or a reset() of a client boundary, as for any hydrated failure + // (#4273). + for (const o of [outcome, reference]) { + // `outer` adopted the server's answer. The issue shape's `inner` + // run is the fixture's own: the hydration dry run of `outer` only + // subscribes, but its continuation waits on a real timer (the + // fixture's wait escapes the dry run's mock Promise) and builds + // the inner slot, whose memo has no owner id to hydrate by. + expect(o.runs).toEqual(variant.shape === "jsx" ? [] : ["inner"]); + expect(o.halted).toBe(true); + expect(o.done).toBe(true); + expect(o.text).toBe(""); + expect(o.afterClick).toBe(undefined); + expect(o.shown).toEqual(mode === "streamed" ? ["outer", ""] : [""]); } }); } 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 3239d3edf..ae83729a3 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 @@ -270,19 +270,17 @@ describe("#4316: the Errored fallback's own hole fails", () => { // the server's page is compared as text. Uncontained, the page halts on // the client's own rejection: the text it froze at is the client tree's // (the inner `` shows its fallback, the twin's the outer's), so -// what is compared is that it halted and on what. Warnings are this -// variant's alone: the streamed twin with an `` around a pending -// read misses its fallback key while it hydrates the pending fragment — -// the core's retry of the errored read, once the fragment releases, is -// taken for a client write by readSerializedOrCompute's latched branch, -// which refetches; #4319's served pass is what keeps that read latched. +// what is compared is that it halted and on what. Warnings are compared +// too: the streamed twin with an `` around a pending read used to +// miss its fallback key while it hydrated the pending fragment (the core's +// retry of the errored read, once the fragment released, was taken for a +// client write and refetched); the served pass keeps that read latched. describe("#4316: a fragment failing while parked in a pending fragment", () => { const comparable = (r: Outcome) => ({ ...r, server: r.server === undefined ? undefined : textOfHtml(r.server), text: r.halted ? undefined : r.text, - uncaught: [...new Set(r.uncaught)], - warnings: undefined + uncaught: [...new Set(r.uncaught)] }); for (const o of parkedFailures) { if (o.variant !== "inner") continue; @@ -315,8 +313,16 @@ describe("#4316: a fragment failing while parked in a pending fragment", () => { expect(outcome.text).toBe("outercaught: no user 7tail"); expect(outcome.halted).toBe(false); expect(outcome.uncaught).toEqual([]); - } else expect(outcome.uncaught).toContain("no user 7"); - expect(outcome.done).toBe(true); + expect(outcome.done).toBe(true); + } else { + // Nothing on the client catches it: the fresh render adopts the + // server's error (no ask) and it is uncaught, as on the server, so + // reactivity halts; the boundary has resumed all the same, so + // hydration completes (as #4242's uncontained variants). + expect(outcome.uncaught).toContain("no user 7"); + expect(outcome.halted).toBe(true); + expect(outcome.done).toBe(true); + } }); } } diff --git a/packages/web/test/hydration/served-error-no-reask.spec.tsx b/packages/web/test/hydration/served-error-no-reask.spec.tsx new file mode 100644 index 000000000..51dad0def --- /dev/null +++ b/packages/web/test/hydration/served-error-no-reask.spec.tsx @@ -0,0 +1,299 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Never refetch on hydration: an error the server sent is the server's + * answer, and the hydrating client never asks again for it. + * + * - `nested-rejection-no-outcome`: a failure the server could not draw (the + * inner fragment rejects with no outcome: the shell's `` and the + * outer fragment were already on the wire). The client renders the + * fragment fresh, so its graph is created, but every read in it claims + * what the server sent: the sibling read its value, the failed read its + * error, thrown to the nearest client boundary, the shell's Errored. No + * ask. reset() asks, and the recreated graph is live. + * - `served-error-reread`: mid-hydration (a slow boundary still pending) + * the served error stands with no ask; the Errored's reset() is a real + * retry, and asks and recovers right away, hydration still running. + * + * - `abandoned-sibling-computes`: a fragment fails on the server (a render + * error, no record) while a nested read is still pending; the server + * abandons that read. The fresh render takes the served values for the + * reads the server finished (one after an element: the render's ids line + * up with the server's) and computes only the abandoned one, which the + * server never answered: one ask, its own value, a live graph. + * - `rejected-fragment-dynamic` / `rejected-fragment-portal` / + * `rejected-fragment-nohydration`: the fresh render runs with hydration on + * but claims nothing, so a `` tag, a `` and a + * `` in it render their DOM (a `` used to throw a + * hydration mismatch there, a `` never mounted, `` + * rendered nothing). `clientOnly`, `` and a nested `` pin + * the same. `rejected-fragment-stylesheet`: its stylesheet gates the + * reveal, as in any client render. + * + * 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, + abandonedSiblingAsks, + nestedRejectionAskCount, + nestedRejectionControl, + nestedRejectionSiblingAskCount, + servedErrorAskCount, + servedErrorControl +} from "../harness/scenarios.jsx"; + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); +const load = (name: string) => { + const file = resolve( + dirname(fileURLToPath(import.meta.url)), + `../harness/__artifacts__/${name}.json` + ); + expect(existsSync(file)).toBe(true); + return { + ...(JSON.parse(readFileSync(file, "utf-8")) as { shell: string; rest: string }), + scenario: scenarios.find(s => s.name === name)! + }; +}; + +function applyChunk(container: HTMLDivElement, chunk: string, first: boolean) { + const scriptRe = /]*)>([\s\S]*?)<\/script>/g; + const scripts = [...chunk.matchAll(scriptRe)].map(m => m[1]); + const stripped = chunk.replace(scriptRe, ""); + if (first) container.innerHTML = stripped; + else container.insertAdjacentHTML("beforeend", stripped); + for (const s of scripts) (0, eval)(s); +} +// 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 settle() { + for (let i = 0; i < 4; i++) { + await sleep(5); + flush(); + } +} + +async function withPage( + name: string, + body: (container: HTMLDivElement, rest: string) => Promise +) { + const { shell, rest, scenario } = load(name); + 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(() => {}); + // A rejected record the stream settles before anything on the client + // reads it is reported unhandled; it is the server's error, nothing else. + const rejections: string[] = []; + const onRejection = (r: unknown) => void rejections.push(String(r)); + process.on("unhandledRejection", onRejection); + let dispose: (() => void) | undefined; + try { + applyChunk(container, shell, true); + await body(container, rest); + // `body` hydrates; the harness disposes it. + dispose = (container as any).__dispose; + expect(warn).not.toHaveBeenCalled(); + await sleep(5); + for (const r of rejections) expect(r).toBe("Error: no user 7"); + } finally { + process.off("unhandledRejection", onRejection); + warn.mockRestore(); + dispose?.(); + await sleep(0); + container.remove(); + } + void scenario; +} +function start(container: HTMLDivElement, App: () => any) { + (container as any).__dispose = hydrate(() => , container); + flush(); +} + +describe("a failure the server could not draw: adopted, thrown to the nearest client boundary", () => { + const name = "nested-rejection-no-outcome"; + for (const mode of ["loaded", "streamed"] as const) + test(`[${mode}] the shell's Errored shows the server's error, no ask; reset() asks`, () => + withPage(name, async (container, rest) => { + const { scenario } = load(name); + const asks = nestedRejectionAskCount(); + const siblingAsks = nestedRejectionSiblingAskCount(); + if (mode === "loaded") applyChunk(container, rest, false); + start(container, scenario.App); + await settle(); + if (mode === "streamed") + for (const piece of pieces(rest)) { + applyChunk(container, piece, false); + await settle(); + } + await settle(); + expect(container.textContent).toBe("failed: no user 7tail"); + // The fresh render claimed what the server sent: its error for + // `user`, its value for `b`. Nothing is asked again. + expect(nestedRejectionAskCount()).toBe(asks); + expect(nestedRejectionSiblingAskCount()).toBe(siblingAsks); + + nestedRejectionControl.reset!(); + await settle(); + await sleep(30); + await settle(); + expect(nestedRejectionAskCount()).toBe(asks + 1); + expect(container.textContent).toBe("ac0bu7tail"); + // The recreated graph is live. + container.querySelector("#nested-rejection-count")!.click(); + flush(); + expect(container.textContent).toBe("ac1bu7tail"); + })); +}); + +describe("a served error mid-hydration", () => { + const name = "served-error-reread"; + test("mid-hydration: the server's error, no ask; reset() asks and recovers", () => + withPage(name, async (container, rest) => { + const { scenario } = load(name); + const asks = servedErrorAskCount(); + start(container, scenario.App); + await settle(); + const all = pieces(rest); + // Up to the failure; the slow boundary is still pending. + let i = 0; + while (!container.textContent!.includes("failed")) { + applyChunk(container, all[i++], false); + await settle(); + } + applyChunk(container, all[i++], false); + await settle(); + expect(container.textContent).toBe("failed: no user 7slow loading"); + expect((globalThis as any)._$HY.done).toBeFalsy(); + expect(servedErrorAskCount()).toBe(asks); + + // reset() is a real retry, mid-hydration too. + servedErrorControl.reset!(); + await settle(); + expect(servedErrorAskCount()).toBe(asks + 1); + expect(container.textContent).toBe("0:u7slow loading"); + + for (; i < all.length; i++) { + applyChunk(container, all[i], false); + await settle(); + } + await settle(); + expect(container.textContent).toBe("0:u7slow"); + expect(servedErrorAskCount()).toBe(asks + 1); + })); + + test("loaded: the server's error, no ask", () => + withPage(name, async (container, rest) => { + const { scenario } = load(name); + const asks = servedErrorAskCount(); + applyChunk(container, rest, false); + start(container, scenario.App); + await settle(); + await settle(); + expect(container.textContent).toBe("failed: no user 7slow"); + expect(servedErrorAskCount()).toBe(asks); + })); +}); + +describe("a read the server abandoned computes; served reads are adopted", () => { + const name = "abandoned-sibling-computes"; + for (const mode of ["loaded", "streamed"] as const) + test(`[${mode}] one ask, for the abandoned read only`, () => + withPage(name, async (container, rest) => { + const { scenario } = load(name); + const asks = abandonedSiblingAsks(); + if (mode === "loaded") applyChunk(container, rest, false); + start(container, scenario.App); + await settle(); + if (mode === "streamed") + for (const piece of pieces(rest)) { + applyChunk(container, piece, false); + await settle(); + } + await settle(); + expect(container.textContent).toBe("adc!an0tail"); + const now = abandonedSiblingAsks(); + // `a` and `d` are the server's answers (`d`'s id, after an element, + // lines up with the server's); `c` never got one, so it computes. + expect(now.a - asks.a).toBe(0); + expect(now.d - asks.d).toBe(0); + expect(now.c - asks.c).toBe(1); + expect((globalThis as any)._$HY.done).toBe(true); + container.querySelector("#abandoned-sibling-count")!.click(); + flush(); + expect(container.textContent).toBe("adc!an1tail"); + })); +}); + +describe("element runtimes in a fresh render create, they claim nothing", () => { + for (const [name, text] of [ + ["rejected-fragment-dynamic", "adyn!atail"], + ["rejected-fragment-portal", "a!aportaltail"], + ["rejected-fragment-nohydration", "anh!atail"], + ["rejected-fragment-client-only", "aco!atail"], + ["rejected-fragment-for", "axy!atail"], + ["rejected-fragment-nested-loading", "an!atail"] + ] as const) + for (const mode of ["loaded", "streamed"] as const) + test(`${name} [${mode}]`, () => + withPage(name, async (container, rest) => { + const { scenario } = load(name); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + if (mode === "loaded") applyChunk(container, rest, false); + start(container, scenario.App); + await settle(); + if (mode === "streamed") + for (const piece of pieces(rest)) { + applyChunk(container, piece, false); + await settle(); + } + await settle(); + expect(container.textContent).toBe(text); + expect((globalThis as any)._$HY.done).toBe(true); + expect(errors).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + } + })); +}); + +describe("a fresh render's stylesheet gates its reveal, like any client render", () => { + const name = "rejected-fragment-stylesheet"; + const href = "/rejected-sheet.css"; + for (const mode of ["loaded", "streamed"] as const) + test(`[${mode}] the content waits for its sheet`, () => + withPage(name, async (container, rest) => { + const { scenario } = load(name); + if (mode === "loaded") applyChunk(container, rest, false); + start(container, scenario.App); + await settle(); + if (mode === "streamed") + for (const piece of pieces(rest)) { + applyChunk(container, piece, false); + await settle(); + } + await settle(); + // The server never drew this content, so nothing gated it there: the + // fresh render holds the reveal until the sheet loads (it revealed + // unstyled content when the gate read the bare hydration flag). + const link = document.head.querySelector(`link[href="${href}"]`) as HTMLLinkElement; + try { + expect(link.getAttribute("rel")).toBe("preload"); + expect(container.textContent).toBe("loadingtail"); + link.dispatchEvent(new Event("load")); + await settle(); + expect(link.getAttribute("rel")).toBe("stylesheet"); + expect(container.textContent).toBe("acss!atail"); + } finally { + link.remove(); + } + })); +}); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index ad288de0f..9da54e398 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2648,8 +2648,9 @@ module.exports = [ // Throwing cleanups no longer abort teardown (#4213, 2026-10-21, over next @ a2a5b6f8): 30.41 KB -> 30.42 KB, recorded minified 95,109 -> 95,268 B (+159 B), measured in CI at 30,403 B brotli. Every onCleanup and effect cleanup runs when one throws; the first error is rethrown by a root's dispose() or at the end of the flush. Size-Exception accepted by the maintainer (2026-10-21). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. // Keyed optimistic guess folds a correction (D-34, #4210, 2026-10-21, accepted by the maintainer 2026-10-22, re-measured over next @ db8241ce): 30.42 -> 30.66 KB, recorded minified 95,268 -> 95,837 B (+569 B, golfed from +694 B at the maintainer's request), measured locally (linux) at 30,642 B brotli: this scenario carries createOptimisticStore, so it pays for the fold itself (store/optimistic.ts `fold`, `foldedRead`, core `joinPassAt`); a keyed arrangement guess is recognized by its view's removed-keys set. Correctness: two overlapping writes on one keyed store no longer tear the store or remount the confirmed row. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Off-screen fallback disposed in the pass that picks content (#4282 / #4268, 2026-10-26, over next @ fbd375d1): 30.66 KB -> 30.86 KB, recorded minified 95,837 -> 95,113 B (-724 B; +24 B over next), measured in CI at 30,847 B brotli. A re-armed Loading disposes a fallback its committed output does not show in that pass, so the fallback's pending render effects stop holding the frame. Size-Exception accepted by the maintainer (2026-10-26). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured. - limit: "30.86 KB", - capMinified: 95113, + // A rejected fragment's fresh render claims what the server sent (#4319, 2026-10-27, over next @ 0090fe34): 30.86 KB -> 30.96 KB, recorded minified 95,113 -> 95,147 B (+34 B; +18 B over next, the rest #4314's), measured locally (linux) at 30,944 B brotli. The adopting fresh render resets its claim state in a try/finally and owns the snapshot root, so a served error no longer leaves stale claim state for the next hydrate and a Portal under a rejected boundary mounts. Correctness; Size-Exception accepted by the maintainer (2026-10-27). Cap at measured + 10 B rounded up to 0.01 KB, never lowered; recorded minified = measured; CI re-bases. + limit: "30.96 KB", + capMinified: 95147, alias }, {