Skip to content
Open
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/early-navigation-hydration.md
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 99 additions & 0 deletions apps/tests/src/e2e/hydration-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>();
const release = Promise.withResolvers<void>();
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<SVGElement | HTMLElement> | 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([]);
});
}
25 changes: 25 additions & 0 deletions apps/tests/src/routes/hydration-navigation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<Suspense fallback={<p>Loading navigation data...</p>}>
<p id="hydration-navigation-value">{value()}</p>
</Suspense>
<button id="hydration-navigation-counter" onClick={() => setCount(count => count + 1)}>
{count()}
</button>
</main>
);
}
22 changes: 21 additions & 1 deletion packages/start/src/client/mount.ts
Original file line number Diff line number Diff line change
@@ -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);
};
2 changes: 2 additions & 0 deletions packages/start/src/server/StartServer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const docType = ssr("<!DOCTYPE html>");
*/
export function StartServer(props: { document: Component<DocumentComponentProps> }) {
const context = getRequestEvent() as PageEvent;
const url = new URL(context.request.url);

// @ts-ignore
const nonce = context.nonce;
Expand All @@ -35,6 +36,7 @@ export function StartServer(props: { document: Component<DocumentComponentProps>
type="module"
nonce={nonce}
async
data-start-url={url.pathname + url.search}
src={getSsrManifest("client").path(import.meta.env.START_CLIENT_ENTRY_URL)}
/>
</>
Expand Down
Loading