diff --git a/.changeset/async-flash-decoder.md b/.changeset/async-flash-decoder.md new file mode 100644 index 00000000..9656c2eb --- /dev/null +++ b/.changeset/async-flash-decoder.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Require the async flash decoder (solidjs/solid#3239): the flash cookie is now encrypted, so the runtime's `decodeFlashCookie` returns a Promise and the `provideFlashDecoder` slot takes only that shape. The submissions seed carries the in-flight decode through the not-ready protocol from a lazy, hydration-transparent memo — a request that never reads submissions never decodes, the decode runs at most once, and the server-only memo consumes no hydration-id slot. diff --git a/src/data/action.ts b/src/data/action.ts index 9f30e557..d7bbde7c 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -198,7 +198,9 @@ function installRouterIntegrations() { if (isServer) { // Server-only: initSubmissions only decodes during SSR, so client builds // tree-shake the codec (which now lives behind the runtime's server entry). - provideFlashDecoder(decodeFlashCookie); + // TODO(@solidjs/web >= 2.0.0-rc.7): pass decodeFlashCookie directly — the + // async wrapper only exists because rc.6 still types it synchronous. + provideFlashDecoder(async cookieHeader => decodeFlashCookie(cookieHeader)); } else { setRouterFormHandler(handleFormAction); provideFlightConsumer(setupFlightDataConsumer); diff --git a/src/routing.ts b/src/routing.ts index 1353ead9..180e1a37 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -732,12 +732,16 @@ export function provideFlightConsumer(factory: (router: RouterContext) => () => * decoder is always installed before useSubmission can read — and a * router-only app, where it never installs, has no actions that could have * produced a flash cookie in the first place. + * + * The decoder is async: the flash cookie is encrypted (solidjs/solid#3239), + * so the runtime's `decodeFlashCookie` decrypts through WebCrypto and the + * seeding read parks on the not-ready protocol until the decode settles. */ -let flashDecoder: ((cookieHeader: string | null) => FlashSubmission | undefined) | undefined; +type FlashDecoder = (cookieHeader: string | null) => Promise; + +let flashDecoder: FlashDecoder | undefined; -export function provideFlashDecoder( - decoder: (cookieHeader: string | null) => FlashSubmission | undefined -): void { +export function provideFlashDecoder(decoder: FlashDecoder): void { flashDecoder || (flashDecoder = decoder); } @@ -802,6 +806,55 @@ export function createRouterContext( } } } + + // 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 + // - `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 + // must not consume a hydration-id slot — the client, which seeds + // submissions as [] without ever creating this memo, would miss it + // and every sibling id would shift. + const flashSubmission = + flashCookieHeader !== undefined + ? createMemo( + () => { + if (!flashDecoder) return undefined; + if (!flashDecode) { + const promise = flashDecoder(flashCookieHeader!).then( + value => { + flashDecode = { done: true, value }; + }, + () => { + flashDecode = { done: true, value: undefined }; + } + ); + flashDecode = { 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; + }, + { lazy: true, transparent: true } + ) + : undefined; + let submissions: Signal[]> | undefined; // NotReadyError's source must be a reactive async node, not the raw @@ -1073,16 +1126,17 @@ export function createRouterContext( // Seeds the initial submission from a no-JS form post: the server // function runtime redirected back with the outcome in a one-shot flash // cookie (its default no-JS convention), consumed eagerly above and - // decoded here — so the post-redirect SSR renders useSubmission() state - // exactly as a scripted submission would. An explicitly pre-seeded - // `event.router.submission` (framework integrations) takes precedence. + // decoded through the flashSubmission memo — so the post-redirect SSR + // renders useSubmission() state exactly as a scripted submission would. + // The memo read may throw NotReadyError while the (encrypted) cookie + // decodes; the assignment in the submissions getter never completed, so + // the resumed rerun retries it against the settled decode. An explicitly + // pre-seeded `event.router.submission` (framework integrations) takes + // precedence. function initSubmissions() { const e = getRequestEvent(); const submission = - (e && e.router && e.router.submission) || - (flashDecoder && flashCookieHeader !== undefined - ? flashDecoder(flashCookieHeader) - : undefined); + (e && e.router && e.router.submission) || (flashSubmission && flashSubmission()); if (!submission) return []; return [ { diff --git a/test/server/flash-seeding.spec.ts b/test/server/flash-seeding.spec.ts index 412237c2..f967a4f3 100644 --- a/test/server/flash-seeding.spec.ts +++ b/test/server/flash-seeding.spec.ts @@ -3,17 +3,30 @@ // + one-shot clear via the runtime's isomorphic half, so the Set-Cookie // precedes any streaming flush) and defers decoding to the codec the action // side provides (provideFlashDecoder), read when the lazily allocated -// submissions signal first initializes. Fresh module instances per test — -// the decoder slot is module-global and first-provide-wins. -import { createRoot, createSignal } from "solid-js"; +// submissions signal first initializes. The decoder is async — the cookie is +// encrypted (solidjs/solid#3239) — so the seeding read parks on the +// not-ready protocol until the decode settles. Fresh module instances per +// test — the decoder slot is module-global and first-provide-wins. +import { createRoot, createSignal, NotReadyError } from "solid-js"; import { vi } from "vitest"; import { provideRequestEvent } from "@solidjs/web/storage"; import { decodeFlashCookie, encodeFlashCookie } from "@solidjs/web/server-functions/server"; +// The encrypted codec resolves its key from the deployment secret; the +// bundler-injected global is the zero-config vehicle. (Inert under a +// pre-encryption @solidjs/web, required from 2.0.0-rc.7.) +(globalThis as any).__SOLID_SECRET__ = "flash-seeding-spec-secret"; + +// The runtime decoder, in the async shape the slot requires. (The wrapper +// also absorbs a pre-encryption sync decodeFlashCookie, so this spec runs +// against either runtime while the rc.7 dep bump is in flight.) +const asyncDecodeFlashCookie = async (cookieHeader: string | null) => + decodeFlashCookie(cookieHeader); + // encodeFlashCookie produces a Set-Cookie value; requests carry just the // name=value pair in their Cookie header -const flashCookieHeader = (result: any, input: any[] = []) => - encodeFlashCookie("/_server?id=createNote", result, input).split(";")[0]; +const flashCookieHeader = async (result: any, input: any[] = []) => + (await encodeFlashCookie("/_server?id=createNote", result, input))!.split(";")[0]; function createEvent(cookie?: string, routerInit?: any) { return { @@ -38,10 +51,23 @@ function createContext(routing: Awaited>) { }); } +// The seeding read under the async decoder: the first read parks on the +// in-flight decode (NotReadyError carrying its promise); the resumed read +// finds the settled outcome. +async function readSeeded(router: { submissions: [() => any, any] }) { + try { + return router.submissions[0](); + } catch (error) { + if (!(error instanceof NotReadyError)) throw error; + await (error as unknown as { source: Promise }).source; + return router.submissions[0](); + } +} + describe("SSR flash seeding", () => { test("clears the cookie eagerly and seeds submissions through the provided decoder", async () => { const routing = await loadRouting(); - const event = createEvent(flashCookieHeader({ id: 1 })); + const event = createEvent(await flashCookieHeader({ id: 1 })); await provideRequestEvent(event, async () => { const router = createContext(routing); @@ -49,8 +75,8 @@ describe("SSR flash seeding", () => { // (or nothing) ever reads submissions expect(event.response.headers.get("Set-Cookie")).toContain("Max-Age=0"); - routing.provideFlashDecoder(decodeFlashCookie); - const seeded = router.submissions[0](); + routing.provideFlashDecoder(asyncDecodeFlashCookie); + const seeded = await readSeeded(router); expect(seeded).toHaveLength(1); expect(seeded[0].url).toBe("/_server?id=createNote"); expect(seeded[0].result).toEqual({ id: 1 }); @@ -59,7 +85,7 @@ describe("SSR flash seeding", () => { test("clears the cookie even when no decoder was ever provided", async () => { const routing = await loadRouting(); - const event = createEvent(flashCookieHeader("saved")); + const event = createEvent(await flashCookieHeader("saved")); await provideRequestEvent(event, async () => { const router = createContext(routing); @@ -71,13 +97,14 @@ describe("SSR flash seeding", () => { test("a pre-seeded event.router.submission takes precedence and leaves the cookie alone", async () => { const routing = await loadRouting(); const submission = { url: "/x", input: [], result: "pre-seeded" }; - const event = createEvent(flashCookieHeader("ignored"), { submission }); + const event = createEvent(await flashCookieHeader("ignored"), { submission }); await provideRequestEvent(event, async () => { const router = createContext(routing); expect(event.response.headers.get("Set-Cookie")).toBeNull(); - routing.provideFlashDecoder(decodeFlashCookie); + routing.provideFlashDecoder(asyncDecodeFlashCookie); + // the pre-seed never decodes, so the read is synchronous const seeded = router.submissions[0](); expect(seeded).toHaveLength(1); expect(seeded[0].result).toBe("pre-seeded"); @@ -94,4 +121,57 @@ describe("SSR flash seeding", () => { expect(router.submissions[0]()).toEqual([]); }); }); + + test("the decode parks the seeding read and runs exactly once", async () => { + const routing = await loadRouting(); + const event = createEvent(await flashCookieHeader({ id: 7 })); + + let decodes = 0; + const countingDecoder = async (cookieHeader: string | null) => { + decodes++; + return decodeFlashCookie(cookieHeader); + }; + + await provideRequestEvent(event, async () => { + const router = createContext(routing); + routing.provideFlashDecoder(countingDecoder); + + // first read: the decode is in flight, the reader parks on its promise + let parked: unknown; + try { + router.submissions[0](); + } catch (error) { + parked = error; + } + expect(parked).toBeInstanceOf(NotReadyError); + await (parked as { source: Promise }).source; + + // resumed read: seeded from the settled decode, which ran exactly once + const seeded = router.submissions[0](); + expect(seeded).toHaveLength(1); + expect(seeded[0].url).toBe("/_server?id=createNote"); + expect(seeded[0].result).toEqual({ id: 7 }); + expect(decodes).toBe(1); + }); + }); + + test("a request whose submissions go unread never decodes", async () => { + // lazy: the memo defers the decode to the first submissions read — a + // request that renders without touching useSubmission never decodes. + const routing = await loadRouting(); + const event = createEvent(await flashCookieHeader("unread")); + + let decodes = 0; + await provideRequestEvent(event, async () => { + routing.provideFlashDecoder(async () => { + decodes++; + return undefined; + }); + const router = createContext(routing); + // the eager half still ran: detection + one-shot clear + expect(event.response.headers.get("Set-Cookie")).toContain("Max-Age=0"); + void router; + }); + expect(decodes).toBe(0); + }); });