diff --git a/.changeset/async-flash-decoder.md b/.changeset/async-flash-decoder.md new file mode 100644 index 00000000..052fbd14 --- /dev/null +++ b/.changeset/async-flash-decoder.md @@ -0,0 +1,7 @@ +--- +"@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. + +Requires `@solidjs/web` 2.0.0-rc.7 (the release that ships the async, encrypted codec and records the unbound function base as the flash `url`, so a `.with()`-bound no-JS post matches its `useSubmission` again); the `solid-js` / `@solidjs/web` peer floor is raised to `^2.0.0-rc.7`. diff --git a/package.json b/package.json index 49f20417..7e459cb7 100644 --- a/package.json +++ b/package.json @@ -48,21 +48,21 @@ "@rollup/plugin-node-resolve": "15.3.0", "@rollup/plugin-terser": "0.4.4", "@solidjs/vite-plugin": "3.0.0-next.35", - "@solidjs/web": "^2.0.0-rc.6", + "@solidjs/web": "^2.0.0-rc.7", "@types/jest": "^29.5.14", "@types/node": "^22.10.0", "babel-preset-solid": "^2.0.0-rc.2", "jsdom": "^25.0.1", "prettier": "^3.4.1", "rollup": "^4.27.4", - "solid-js": "^2.0.0-rc.6", + "solid-js": "^2.0.0-rc.7", "typescript": "^5.7.2", "vite": "^8.2.2", "vitest": "^4.1.11" }, "peerDependencies": { - "@solidjs/web": "^2.0.0-rc.6", - "solid-js": "^2.0.0-rc.6" + "@solidjs/web": "^2.0.0-rc.7", + "solid-js": "^2.0.0-rc.7" }, "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0d8a170..089d4f73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,10 +28,10 @@ importers: version: 0.4.4(rollup@4.27.4) '@solidjs/vite-plugin': specifier: 3.0.0-next.35 - version: 3.0.0-next.35(@solidjs/web@2.0.0-rc.6(solid-js@2.0.0-rc.6))(solid-js@2.0.0-rc.6)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0)) + version: 3.0.0-next.35(@solidjs/web@2.0.0-rc.7(solid-js@2.0.0-rc.7))(solid-js@2.0.0-rc.7)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0)) '@solidjs/web': - specifier: ^2.0.0-rc.6 - version: 2.0.0-rc.6(solid-js@2.0.0-rc.6) + specifier: ^2.0.0-rc.7 + version: 2.0.0-rc.7(solid-js@2.0.0-rc.7) '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -40,7 +40,7 @@ importers: version: 22.10.0 babel-preset-solid: specifier: ^2.0.0-rc.2 - version: 2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.6) + version: 2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.7) jsdom: specifier: ^25.0.1 version: 25.0.1 @@ -51,8 +51,8 @@ importers: specifier: ^4.27.4 version: 4.27.4 solid-js: - specifier: ^2.0.0-rc.6 - version: 2.0.0-rc.6 + specifier: ^2.0.0-rc.7 + version: 2.0.0-rc.7 typescript: specifier: ^5.7.2 version: 5.7.2 @@ -680,8 +680,8 @@ packages: '@solidjs/compiler@2.0.0-rc.4': resolution: {integrity: sha512-lKx6Jp1KbHxqO+v+g7cRbm8I1DHx/10Lj8bKG4vmdGnCfSlFbyhCd8cPTLdLV0YVG7GGSzzF5m8NkC9NlfGN5w==} - '@solidjs/signals@2.0.0-rc.6': - resolution: {integrity: sha512-lPqwZNLPq1Z9CBvgXkMvi1ZFr5OHUiFNz1X40+yehszDWEbJkneZx7BGKIe9eMT/AN1NSL+PMjOiMyZaqVB2xw==} + '@solidjs/signals@2.0.0-rc.7': + resolution: {integrity: sha512-JY0OJ5nGeqxGKOAqCtuoHkFBkaWQYxmf+yBQE8vw/o9C7yUF+Kan9PwcrL0ZHR6+MgyixBUtTfBigwou5hwW5w==} '@solidjs/vite-plugin@3.0.0-next.35': resolution: {integrity: sha512-8Mlftd+WfZkwOoCZRyMxA8innT8b2D/qawTu+28RW/Hj4eSmStSLx4dHjYeH9MxyOwo7DQStAyHAADk5LFQVRw==} @@ -698,10 +698,10 @@ packages: '@testing-library/jest-dom': optional: true - '@solidjs/web@2.0.0-rc.6': - resolution: {integrity: sha512-JgQ2NCjygQpZizZrVjcwPqH3dhIZQZoMRGoGSC6+Tr622cvbuXthDC3pKdNMsjCKCVp19UbT0kkPX15FOdT0pw==} + '@solidjs/web@2.0.0-rc.7': + resolution: {integrity: sha512-qsKKWR4PzzPw8ZGFR0Oc2776G1ONMMBKPJWO3Opf3cWHoczgfhknW6NED0WtdCuR6T8usFTWCZgMM5Cc0h14Dg==} peerDependencies: - solid-js: ^2.0.0-rc.6 + solid-js: ^2.0.0-rc.7 '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1504,8 +1504,8 @@ packages: smob@1.4.1: resolution: {integrity: sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==} - solid-js@2.0.0-rc.6: - resolution: {integrity: sha512-Z/M8s9ypLBf+6Bl3AAb5upgkYCl73HkpM+UxdUJ5uFGctTwpOQVCM9Gpj3Mjbiaki270HHUfA3Z0Lyn4w+fDtg==} + solid-js@2.0.0-rc.7: + resolution: {integrity: sha512-3APJcwGbJ3YzXzPXwl0R3cAiogXLacXdXSFasdE2uw1Gzj5xqDW/0bJu4fs75KK5WzXg+JfpkcsxBjTxkbnLQQ==} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -2486,28 +2486,28 @@ snapshots: '@solidjs/compiler-wasm32-wasi': 2.0.0-rc.4 '@solidjs/compiler-win32-x64-msvc': 2.0.0-rc.4 - '@solidjs/signals@2.0.0-rc.6': {} + '@solidjs/signals@2.0.0-rc.7': {} - '@solidjs/vite-plugin@3.0.0-next.35(@solidjs/web@2.0.0-rc.6(solid-js@2.0.0-rc.6))(solid-js@2.0.0-rc.6)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0))': + '@solidjs/vite-plugin@3.0.0-next.35(@solidjs/web@2.0.0-rc.7(solid-js@2.0.0-rc.7))(solid-js@2.0.0-rc.7)(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0))': dependencies: '@ampproject/remapping': 2.3.0 '@babel/core': 7.26.0 '@solidjs/babel-plugin': 2.0.0-rc.4(@babel/core@7.26.0) '@solidjs/compiler': 2.0.0-rc.4 - '@solidjs/web': 2.0.0-rc.6(solid-js@2.0.0-rc.6) + '@solidjs/web': 2.0.0-rc.7(solid-js@2.0.0-rc.7) '@types/babel__core': 7.20.5 merge-anything: 5.1.7 - solid-js: 2.0.0-rc.6 + solid-js: 2.0.0-rc.7 vite: 8.2.2(@types/node@22.10.0)(terser@5.36.0) vitefu: 1.0.4(vite@8.2.2(@types/node@22.10.0)(terser@5.36.0)) transitivePeerDependencies: - supports-color - '@solidjs/web@2.0.0-rc.6(solid-js@2.0.0-rc.6)': + '@solidjs/web@2.0.0-rc.7(solid-js@2.0.0-rc.7)': dependencies: seroval: 1.5.6 seroval-plugins: 1.5.6(seroval@1.5.6) - solid-js: 2.0.0-rc.6 + solid-js: 2.0.0-rc.7 '@standard-schema/spec@1.1.0': {} @@ -2651,12 +2651,12 @@ snapshots: asynckit@0.4.0: {} - babel-preset-solid@2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.6): + babel-preset-solid@2.0.0-rc.2(@babel/core@7.26.0)(solid-js@2.0.0-rc.7): dependencies: '@babel/core': 7.26.0 '@dom-expressions/babel-plugin-jsx': 0.50.0-next.44(@babel/core@7.26.0) optionalDependencies: - solid-js: 2.0.0-rc.6 + solid-js: 2.0.0-rc.7 better-path-resolve@1.0.0: dependencies: @@ -3267,9 +3267,9 @@ snapshots: smob@1.4.1: {} - solid-js@2.0.0-rc.6: + solid-js@2.0.0-rc.7: dependencies: - '@solidjs/signals': 2.0.0-rc.6 + '@solidjs/signals': 2.0.0-rc.7 csstype: 3.2.3 seroval: 1.5.6 seroval-plugins: 1.5.6(seroval@1.5.6) diff --git a/src/data/action.ts b/src/data/action.ts index 9f30e557..1787a2f8 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -198,6 +198,7 @@ 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). + // The codec is async from @solidjs/web 2.0.0-rc.7 (encrypted cookie). provideFlashDecoder(decodeFlashCookie); } else { setRouterFormHandler(handleFormAction); 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..ccd9ad75 100644 --- a/test/server/flash-seeding.spec.ts +++ b/test/server/flash-seeding.spec.ts @@ -3,17 +3,23 @@ // + 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. +(globalThis as any).__SOLID_SECRET__ = "flash-seeding-spec-secret"; + // 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 +44,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); @@ -50,7 +69,7 @@ describe("SSR flash seeding", () => { expect(event.response.headers.get("Set-Cookie")).toContain("Max-Age=0"); routing.provideFlashDecoder(decodeFlashCookie); - const seeded = router.submissions[0](); + 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 +78,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 +90,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); + // 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 +114,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); + }); });