Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flash-decode-per-request.md
Original file line number Diff line number Diff line change
@@ -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 `<Errored>`, 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.
79 changes: 48 additions & 31 deletions src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Errored>` 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<void> };
};

const flashStates = new WeakMap<object, FlashState>();

let preloadIntent: Intent | undefined;
export function getIntent() {
return preloadIntent || useOptionalContext(RouterContextObj)?.intent?.();
Expand Down Expand Up @@ -826,64 +843,64 @@ 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<void> }
| 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
// 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
flash !== undefined
? createMemo<FlashSubmission | undefined>(
() => {
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 }
)
Expand Down
81 changes: 81 additions & 0 deletions test/server/flash-decode-per-request.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 <Errored>
// 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: () => <main>page</main> }] });
function Status() {
const subs = useSubmissions(save);
return <p class="status">{subs.find(s => s.result?.error)?.result?.error}</p>;
}
function Document(props: { children?: JSX.Element }) {
return (
<html>
<head />
<body>{props.children}</body>
</html>
);
}

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(() => (
<Document>
<Router>
{props => {
routerRenders++;
return (
<Errored fallback={() => <p>failed</p>}>
<Status />
<Loading fallback={<p>loading</p>}>{props.children}</Loading>
</Errored>
);
}}
</Router>
</Document>
)).then(String),
delay(2000).then(() => "TIMEOUT")
])
);

expect(html).toContain('class="status">Failed to save</p>');
// 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
);
});
});
6 changes: 4 additions & 2 deletions vitest.config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Route> trees to the collector.
// in ssr mode so specs can hand JSX <Route> 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"]
},
Expand Down
Loading