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)} />