From c3c46b04f9ffa4b4d6e05e32ce32e25a1b594be1 Mon Sep 17 00:00:00 2001
From: Brenley Dueck
Date: Thu, 10 Sep 2026 21:08:43 -0500
Subject: [PATCH] fix: handle URL changes before hydration starts
---
.changeset/early-navigation-hydration.md | 5 +
.../src/e2e/hydration-navigation.test.ts | 99 +++++++++++++++++++
.../tests/src/routes/hydration-navigation.tsx | 25 +++++
packages/start/src/client/mount.ts | 22 ++++-
packages/start/src/server/StartServer.tsx | 2 +
5 files changed, 152 insertions(+), 1 deletion(-)
create mode 100644 .changeset/early-navigation-hydration.md
create mode 100644 apps/tests/src/e2e/hydration-navigation.test.ts
create mode 100644 apps/tests/src/routes/hydration-navigation.tsx
diff --git a/.changeset/early-navigation-hydration.md b/.changeset/early-navigation-hydration.md
new file mode 100644
index 000000000..5a59f5e78
--- /dev/null
+++ b/.changeset/early-navigation-hydration.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/start": patch
+---
+
+Fall back to client rendering when the browser's path or query changes before hydration starts, preventing hydration mismatches and stale server data after early navigation.
diff --git a/apps/tests/src/e2e/hydration-navigation.test.ts b/apps/tests/src/e2e/hydration-navigation.test.ts
new file mode 100644
index 000000000..a0e37039f
--- /dev/null
+++ b/apps/tests/src/e2e/hydration-navigation.test.ts
@@ -0,0 +1,99 @@
+import { expect, test, type ElementHandle } from "@playwright/test";
+
+test("navigates while the initial route module is still loading", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", error => errors.push(error.message));
+ page.on("console", message => {
+ if (message.type() === "error") errors.push(message.text());
+ });
+
+ const requested = Promise.withResolvers();
+ const release = Promise.withResolvers();
+ const isInitialRoute = (url: URL) =>
+ url.pathname === "/src/routes/client-only/index.tsx" ||
+ (url.pathname === "/@vite/lazy" &&
+ !!url.searchParams.get("id")?.includes("/src/routes/client-only/index.tsx?"));
+ await page.route(isInitialRoute, async route => {
+ requested.resolve();
+ await release.promise;
+ await route.continue();
+ });
+
+ try {
+ await page.goto("/client-only", { waitUntil: "commit" });
+ await requested.promise;
+ await page.getByRole("link", { name: "Basic", exact: true }).click();
+ await expect(page.locator("#counter-output")).toHaveText("0");
+ const loaded = page.waitForResponse(response => isInitialRoute(new URL(response.url())));
+ release.resolve();
+ await (await loaded).finished();
+
+ await page.locator("#counter-button").click();
+ await expect(page.locator("#counter-output")).toHaveText("1");
+ await expect(page.locator("#app > ul")).toHaveCount(1);
+ await expect(page.locator("#app > main")).toHaveCount(1);
+ expect(errors).toEqual([]);
+ } finally {
+ release.resolve();
+ }
+});
+
+for (const scenario of [
+ { name: "pathname", from: "/client-only", to: "/", hydrate: false },
+ {
+ name: "streaming pathname",
+ from: "/hydration-navigation?value=server",
+ to: "/",
+ hydrate: false,
+ },
+ {
+ name: "streaming query string",
+ from: "/hydration-navigation?value=server",
+ to: "/hydration-navigation?value=client",
+ hydrate: false,
+ },
+ {
+ name: "hash-only",
+ from: "/hydration-navigation?value=server",
+ to: "/hydration-navigation?value=server#details",
+ hydrate: true,
+ },
+]) {
+ test(`handles a ${scenario.name} change before hydration`, async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", error => errors.push(error.message));
+ page.on("console", message => {
+ if (message.type() === "error") errors.push(message.text());
+ });
+
+ let initialNav: ElementHandle | null = null;
+ await page.route(/\/entry-client(?:-[^/]+)?\.(?:tsx|js)(?:\?|$)/, async route => {
+ // Pin the race before hydration instead of sweeping machine-dependent delays.
+ initialNav = await page.locator("#app > ul").elementHandle();
+ await page.evaluate(to => history.pushState({}, "", to), scenario.to);
+ await route.continue();
+ });
+
+ // Waiting for load also lets the original response finish streaming.
+ await page.goto(scenario.from);
+ await expect(page).toHaveURL(new URL(scenario.to, page.url()).href);
+ if (scenario.to === "/") {
+ await expect(page.locator("#counter-output")).toHaveText("0");
+ await page.locator("#counter-button").click();
+ await expect(page.locator("#counter-output")).toHaveText("1");
+ await expect(page.locator("#hydration-navigation-value")).toHaveCount(0);
+ } else {
+ await expect(page.locator("#hydration-navigation-value")).toHaveText(
+ scenario.hydrate ? "server" : "client",
+ );
+ await page.locator("#hydration-navigation-counter").click();
+ await expect(page.locator("#hydration-navigation-counter")).toHaveText("1");
+ }
+
+ await expect(page.locator("#app > ul")).toHaveCount(1);
+ await expect(page.locator("#app > main")).toHaveCount(1);
+ expect(initialNav).not.toBeNull();
+ expect(await initialNav!.evaluate(node => node.isConnected)).toBe(scenario.hydrate);
+ expect(errors).toEqual([]);
+ });
+}
diff --git a/apps/tests/src/routes/hydration-navigation.tsx b/apps/tests/src/routes/hydration-navigation.tsx
new file mode 100644
index 000000000..b02655e68
--- /dev/null
+++ b/apps/tests/src/routes/hydration-navigation.tsx
@@ -0,0 +1,25 @@
+import { createAsync, query, useSearchParams } from "@solidjs/router";
+import { createSignal, Suspense } from "solid-js";
+
+const getValue = query(async (value: string) => {
+ "use server";
+ await new Promise(resolve => setTimeout(resolve, 500));
+ return value;
+}, "hydration-navigation");
+
+export default function HydrationNavigation() {
+ const [params] = useSearchParams();
+ const value = createAsync(() => getValue(String(params.value || "server")));
+ const [count, setCount] = createSignal(0);
+
+ return (
+
+ Loading navigation data...
}>
+ {value()}
+
+
+
+ );
+}
diff --git a/packages/start/src/client/mount.ts b/packages/start/src/client/mount.ts
index 6836790bf..184ebba03 100644
--- a/packages/start/src/client/mount.ts
+++ b/packages/start/src/client/mount.ts
@@ -1 +1,21 @@
-export { hydrate as mount } from "solid-js/web";
+import { hydrate } from "solid-js/web";
+
+export const mount: typeof hydrate = (code, element, options) => {
+ const initialUrl = document
+ .querySelector("script[data-start-url]")
+ ?.getAttribute("data-start-url");
+ const hydration = (
+ globalThis as typeof globalThis & {
+ _$HY?: { done?: boolean; events: unknown[] | null };
+ }
+ )._$HY;
+
+ if (hydration && initialUrl && initialUrl !== location.pathname + location.search) {
+ // The browser moved on before hydration started. Use hydrate's client-render
+ // fallback and prevent late SSR fragments or queued events from being applied.
+ hydration.done = true;
+ hydration.events = null;
+ }
+
+ return hydrate(code, element, options);
+};
diff --git a/packages/start/src/server/StartServer.tsx b/packages/start/src/server/StartServer.tsx
index 8de8a2a5e..e752ece1b 100644
--- a/packages/start/src/server/StartServer.tsx
+++ b/packages/start/src/server/StartServer.tsx
@@ -17,6 +17,7 @@ const docType = ssr("");
*/
export function StartServer(props: { document: Component }) {
const context = getRequestEvent() as PageEvent;
+ const url = new URL(context.request.url);
// @ts-ignore
const nonce = context.nonce;
@@ -35,6 +36,7 @@ export function StartServer(props: { document: Component
type="module"
nonce={nonce}
async
+ data-start-url={url.pathname + url.search}
src={getSsrManifest("client").path(import.meta.env.START_CLIENT_ENTRY_URL)}
/>
>