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
7 changes: 7 additions & 0 deletions .changeset/async-flash-decoder.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
46 changes: 23 additions & 23 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/data/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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
Loading
Loading