diff --git a/.changeset/flash-decode-per-request.md b/.changeset/flash-decode-per-request.md new file mode 100644 index 00000000..c24fed50 --- /dev/null +++ b/.changeset/flash-decode-per-request.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +The flash cookie is now detected, cleared and decoded once per request rather than once per router instance. A server render can re-create the router while it retries a suspension — a `useSubmissions` read under ``, inside a document shell, retries from above the router — and each new router restarted the decode, so the render never settled and the stream retried until the process ran out of memory. Each re-created router also appended another `Set-Cookie` clear. diff --git a/src/routing.ts b/src/routing.ts index 6919f90a..2ab8e11b 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -779,6 +779,23 @@ export function provideFlashDecoder(decoder: FlashDecoder): void { flashDecoder || (flashDecoder = decoder); } +/** + * A request's flash cookie and its decode, shared by every router created + * for that request. A server render can re-create the router — a suspension + * under `` retries from the nearest hole above it, which may sit + * above the router — so state kept on one router instance would restart the + * decode with every re-creation and the seeding read would never settle. + * Keyed by the request event, so it lives exactly as long as the request. + */ +type FlashState = { + cookieHeader: string | null; + decode?: + | { done: true; value: FlashSubmission | undefined } + | { done: false; promise: Promise }; +}; + +const flashStates = new WeakMap(); + let preloadIntent: Intent | undefined; export function getIntent() { return preloadIntent || useOptionalContext(RouterContextObj)?.intent?.(); @@ -826,38 +843,37 @@ export function createRouterContext( // unread outcome must not haunt a later request's render. Only detection // and clearing happen here (the runtime's isomorphic half); the raw header // is stashed and decoding waits for the action-provided codec, read when - // the lazily allocated submissions signal below first initializes. - let flashCookieHeader: string | null | undefined; + // the lazily allocated submissions signal below first initializes. Both + // happen once per request: a router re-created for the same request finds + // the request's flash state (see `flashStates`) and neither clears again + // nor decodes again. + let flash: FlashState | undefined; if (isServer) { const e = getRequestEvent(); if (e && !(e.router && e.router.submission)) { - const cookieHeader = e.request.headers.get("cookie"); - if (hasFlashCookie(cookieHeader)) { - flashCookieHeader = cookieHeader; - // one-shot: clear it even when unreadable so it can't haunt later renders - if (e.response && e.response.headers) - e.response.headers.append("Set-Cookie", clearFlashCookie()); + flash = flashStates.get(e); + if (!flash) { + const cookieHeader = e.request.headers.get("cookie"); + if (hasFlashCookie(cookieHeader)) { + flashStates.set(e, (flash = { cookieHeader })); + // one-shot: clear it even when unreadable so it can't haunt later renders + if (e.response && e.response.headers) + e.response.headers.append("Set-Cookie", clearFlashCookie()); + } } } } - // The decode, at most once per request: the decoder may answer with a - // Promise (the cookie is encrypted; the runtime's decodeFlashCookie is - // async), and this cache is what keeps the parked read's rerun from - // restarting it — resumption finds the settled outcome and just reads it. - // A decoder that rejects reads as "no flash", matching the runtime's own - // malformed-cookie semantics. - let flashDecode: - | { done: true; value: FlashSubmission | undefined } - | { done: false; promise: Promise } - | undefined; - // The seeding read, as a memo: NotReadyError must surface from a reactive // node the graph can park and retry — never from router setup, which no - // boundary guards — and the memo bounds the recompute to this function; - // a parked reader resumes into the settled cache above, never a second - // decode. Created only when a flash cookie actually arrived (server-only - // by construction: flashCookieHeader is only ever set there), and + // boundary guards — and the memo bounds the recompute to this function. + // The decode runs at most once per request: the decoder may answer with a + // Promise (the cookie is encrypted; the runtime's decodeFlashCookie is + // async), and the request's cached state is what keeps a parked read's + // rerun — or a re-created router's first read — from restarting it. A + // decoder that rejects reads as "no flash", matching the runtime's own + // malformed-cookie semantics. Created only when a flash cookie actually + // arrived (server-only by construction: `flash` is only ever set there), and // - `lazy`: server memos compute eagerly by default — deferred to first // read, a request whose submissions are never read never decodes; // - `transparent`: the memo exists on the server only, so its owner @@ -865,25 +881,26 @@ export function createRouterContext( // submissions as [] without ever creating this memo, would miss it // and every sibling id would shift. const flashSubmission = - flashCookieHeader !== undefined + flash !== undefined ? createMemo( () => { + const state = flash!; if (!flashDecoder) return undefined; - if (!flashDecode) { - const promise = flashDecoder(flashCookieHeader!).then( + if (!state.decode) { + const promise = flashDecoder(state.cookieHeader).then( value => { - flashDecode = { done: true, value }; + state.decode = { done: true, value }; }, () => { - flashDecode = { done: true, value: undefined }; + state.decode = { done: true, value: undefined }; } ); - flashDecode = { done: false, promise }; + state.decode = { done: false, promise }; } // SSR carries the Promise through NotReadyError so the parked // reader can resume, exactly like the lazy matches above. - if (!flashDecode.done) throw new NotReadyError(flashDecode.promise); - return flashDecode.value; + if (!state.decode.done) throw new NotReadyError(state.decode.promise); + return state.decode.value; }, { lazy: true, transparent: true } ) diff --git a/test/server/flash-decode-per-request.spec.tsx b/test/server/flash-decode-per-request.spec.tsx new file mode 100644 index 00000000..184fb93f --- /dev/null +++ b/test/server/flash-decode-per-request.spec.tsx @@ -0,0 +1,81 @@ +// The flash decode belongs to the request, not to a router instance. A +// server render may re-create the router: a suspension under +// escalates to the nearest retryable hole, and when that hole sits above the +// router (a document shell's `{props.children}`) the retry renders a new one. +// A decode cached on the instance restarted with every new router, so the +// seeding read never settled and the stream retried forever. +import { Errored, Loading } from "solid-js"; +import { createRequestEvent, renderToStream, type JSX } from "@solidjs/web"; +import { provideRequestEvent } from "@solidjs/web/storage"; +import { decodeFlashCookie, encodeFlashCookie } from "@solidjs/web/server-functions/server"; +import { action, useSubmissions } from "../../src/data/action.js"; +import { provideFlashDecoder } from "../../src/routing.js"; +import { createRouter } from "../../src/index.js"; + +(globalThis as any).__SOLID_SECRET__ = "flash-decode-per-request-spec-secret"; + +// first provide wins: installed before any action installs the default codec +let decodes = 0; +provideFlashDecoder(cookieHeader => { + decodes++; + return decodeFlashCookie(cookieHeader); +}); + +const save = action( + Object.assign(async () => ({ error: "Failed to save" }), { url: "/_server/save" }) +); + +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); + +describe("flash decode per request", () => { + test("a document render that re-creates the router settles with the flash outcome", async () => { + let routerRenders = 0; + const Router = createRouter({ routes: [{ path: "/", component: () =>
page
}] }); + function Status() { + const subs = useSubmissions(save); + return

{subs.find(s => s.result?.error)?.result?.error}

; + } + function Document(props: { children?: JSX.Element }) { + return ( + + + {props.children} + + ); + } + + const cookie = (await encodeFlashCookie("/_server/save", { error: "Failed to save" }, []))!; + const event = createRequestEvent( + new Request("http://localhost/", { headers: { cookie: cookie.split(";")[0] } }) + ); + const html = await provideRequestEvent(event, () => + Promise.race([ + renderToStream(() => ( + + + {props => { + routerRenders++; + return ( +

failed

}> + + loading

}>{props.children}
+
+ ); + }} +
+
+ )).then(String), + delay(2000).then(() => "TIMEOUT") + ]) + ); + + expect(html).toContain('class="status">Failed to save

'); + // the retry did render a new router; the request still decoded once and + // cleared the cookie once + expect(routerRenders).toBeGreaterThan(1); + expect(decodes).toBe(1); + expect(event.response.headers.getSetCookie().filter(c => c.startsWith("flash="))).toHaveLength( + 1 + ); + }); +}); diff --git a/vitest.config.server.ts b/vitest.config.server.ts index cde6c4e8..979dde1a 100644 --- a/vitest.config.server.ts +++ b/vitest.config.server.ts @@ -2,12 +2,14 @@ // solid-js resolve to their server builds (isServer true, real request-event // scoping via @solidjs/web/storage). Exercises the server integration in // src/server.ts, which the jsdom suite cannot reach. The solid plugin runs -// in ssr mode so specs can hand JSX trees to the collector. +// in ssr mode so specs can hand JSX trees to the collector, and +// compiles hydratable, as apps do: the hydratable server output is what +// shapes where an async render retries from. import { defineConfig, Plugin } from "vitest/config"; import solidPlugin from "@solidjs/vite-plugin"; export default defineConfig({ - plugins: [solidPlugin({ ssr: true, solid: { hydratable: false } }) as Plugin], + plugins: [solidPlugin({ ssr: true, solid: { hydratable: true } }) as Plugin], resolve: { conditions: ["node", "module", "development|production"] },