Skip to content
Draft
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/async-flash-decoder.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion src/data/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
76 changes: 65 additions & 11 deletions src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlashSubmission | undefined>;

let flashDecoder: FlashDecoder | undefined;

export function provideFlashDecoder(
decoder: (cookieHeader: string | null) => FlashSubmission | undefined
): void {
export function provideFlashDecoder(decoder: FlashDecoder): void {
flashDecoder || (flashDecoder = decoder);
}

Expand Down Expand Up @@ -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<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
// - `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<FlashSubmission | undefined>(
() => {
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<Submission<any, any>[]> | undefined;

// NotReadyError's source must be a reactive async node, not the raw
Expand Down Expand Up @@ -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 [
{
Expand Down
102 changes: 91 additions & 11 deletions test/server/flash-seeding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -38,19 +51,32 @@ function createContext(routing: Awaited<ReturnType<typeof loadRouting>>) {
});
}

// 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<void> }).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);
// the one-shot clear is appended at context creation, before anything
// (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 });
Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -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<void> }).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);
});
});
Loading