From 40014d8a2131fb10466a71a86f91ae6579054673 Mon Sep 17 00:00:00 2001
From: Ryan Carniato
Date: Wed, 30 Sep 2026 22:41:46 -0700
Subject: [PATCH 1/2] fix(routing): decode the flash cookie once per request,
not per router
A server render can re-create the router while it retries a suspension.
A `useSubmissions` read in a component body under `` suspends on
the async flash decode; the server boundary escalates that to the nearest
retryable hole, and in a document shell that hole is `{props.children}`
above the router, so each retry renders a new router. The decode was cached
on the router instance, so every new router started it again: the seeding
read never settled, each pass added a fresh blocking promise, and the
stream retried until the process ran out of memory. Each new router also
appended another one-shot `Set-Cookie` clear.
The cookie header and its decode now live in a WeakMap keyed by the request
event, shared by every router created for that request.
The server test config now compiles hydratable, as apps do. The hang only
reproduces under the hydratable server output, which is what places the
retry above the router; the other server specs pass unchanged under it.
Co-authored-by: Claude via Cursor
Co-authored-by: Cursor
---
.changeset/flash-decode-per-request.md | 5 ++
src/routing.ts | 79 +++++++++++-------
test/server/flash-decode-per-request.spec.tsx | 82 +++++++++++++++++++
vitest.config.server.ts | 6 +-
4 files changed, 139 insertions(+), 33 deletions(-)
create mode 100644 .changeset/flash-decode-per-request.md
create mode 100644 test/server/flash-decode-per-request.spec.tsx
diff --git a/.changeset/flash-decode-per-request.md b/.changeset/flash-decode-per-request.md
new file mode 100644
index 00000000..c24fed50
--- /dev/null
+++ b/.changeset/flash-decode-per-request.md
@@ -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 ``, 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.
diff --git a/src/routing.ts b/src/routing.ts
index 6919f90a..2ab8e11b 100644
--- a/src/routing.ts
+++ b/src/routing.ts
@@ -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 `` 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 };
+};
+
+const flashStates = new WeakMap
}>{props.children}
+
+ );
+ }}
+
+
+ )).then(String),
+ delay(2000).then(() => "TIMEOUT")
+ ])
+ );
+
+ expect(html).toContain('class="status">Failed to save');
+ // 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
+ );
+ });
+});
diff --git a/vitest.config.server.ts b/vitest.config.server.ts
index cde6c4e8..979dde1a 100644
--- a/vitest.config.server.ts
+++ b/vitest.config.server.ts
@@ -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 trees to the collector.
+// in ssr mode so specs can hand JSX 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"]
},
From bd4d58ca8019a7a4a9754fe1916d05eeb6cb63e2 Mon Sep 17 00:00:00 2001
From: Ryan Carniato
Date: Thu, 1 Oct 2026 00:47:55 -0700
Subject: [PATCH 2/2] test(routing): import JSX from @solidjs/web in the flash
decode spec
Co-authored-by: Claude via Cursor
Co-authored-by: Cursor
---
test/server/flash-decode-per-request.spec.tsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/test/server/flash-decode-per-request.spec.tsx b/test/server/flash-decode-per-request.spec.tsx
index 97ab6696..184fb93f 100644
--- a/test/server/flash-decode-per-request.spec.tsx
+++ b/test/server/flash-decode-per-request.spec.tsx
@@ -5,8 +5,7 @@
// 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 type { JSX } from "solid-js";
-import { createRequestEvent, renderToStream } from "@solidjs/web";
+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";