diff --git a/.changeset/shiny-bags-breathe.md b/.changeset/shiny-bags-breathe.md new file mode 100644 index 00000000000..d10ef00a1b0 --- /dev/null +++ b/.changeset/shiny-bags-breathe.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +Fix a bug where hydration wouldn't work right when using child routes and hydrate fallbacks with a basename diff --git a/integration/vite-basename-test.ts b/integration/vite-basename-test.ts index 9c0ceb8690d..1f9583b709c 100644 --- a/integration/vite-basename-test.ts +++ b/integration/vite-basename-test.ts @@ -14,8 +14,8 @@ import { } from "./helpers/vite.js"; import { js } from "./helpers/create-fixture.js"; -const files = { - "app/routes/_index.tsx": String.raw` +const sharedFiles = { + "app/routes/_index.tsx": js` import { useState, useEffect } from "react"; import { Link } from "@remix-run/react" @@ -36,7 +36,7 @@ const files = { ); } `, - "app/routes/other.tsx": String.raw` + "app/routes/other.tsx": js` import { useLoaderData } from "@remix-run/react"; export const loader = () => { @@ -93,7 +93,7 @@ const customServerFile = ({ base = base ?? "/mybase/"; basename = basename ?? base; - return String.raw` + return js` import { createRequestHandler } from "@remix-run/express"; import { installGlobals } from "@remix-run/node"; import express from "express"; @@ -139,15 +139,17 @@ test.describe("Vite base / Remix basename / Vite dev", () => { base, basename, startServer, + files, }: { base: string; basename: string; startServer?: boolean; + files?: Record; }) { port = await getPort(); cwd = await createProject({ "vite.config.js": await viteConfigFile({ port, base, basename }), - ...files, + ...(files || sharedFiles), }); if (startServer !== false) { stop = await viteDev({ cwd, port, basename }); @@ -178,6 +180,78 @@ test.describe("Vite base / Remix basename / Vite dev", () => { "`basename` config must begin with `base` for the default Vite dev server." ); }); + + test("works with child routes using client loaders", async ({ page }) => { + let basename = "/mybase/"; + await setup({ + base: basename, + basename, + files: { + ...sharedFiles, + "app/routes/parent.tsx": js` + import { Outlet } from '@remix-run/react' + export default function Parent() { + return
; + } + `, + "app/routes/parent.child.tsx": js` + import { useState, useEffect } from "react"; + import { useLoaderData } from '@remix-run/react' + export async function clientLoader() { + await new Promise(resolve => setTimeout(resolve, 500)) + return "CHILD" + } + export function HydrateFallback() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return ( + <> +

Loading...

+

Mounted: {mounted ? "yes" : "no"}

+ + ); + } + export default function Child() { + const data = useLoaderData() + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return ( + <> +
{data}
; +

Mounted: {mounted ? "yes" : "no"}

+ + ); + } + `, + }, + }); + + let hydrationErrors: Error[] = []; + page.on("pageerror", (error) => { + if ( + error.message.includes("Hydration failed") || + error.message.includes("error while hydrating") || + error.message.includes("does not match server-rendered HTML") + ) { + hydrationErrors.push(error); + } + }); + + // setup: initial render at basename + await page.goto(`http://localhost:${port}${basename}parent/child`, { + waitUntil: "domcontentloaded", + }); + + await expect(page.locator("#parent")).toBeDefined(); + await expect(page.locator("#loading")).toContainText("Loading..."); + await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); + + expect(hydrationErrors).toEqual([]); + + await page.waitForSelector("#child"); + await expect(page.locator("#child")).toHaveText("CHILD"); + await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); + }); }); test.describe("Vite base / Remix basename / express dev", async () => { @@ -198,7 +272,7 @@ test.describe("Vite base / Remix basename / express dev", async () => { cwd = await createProject({ "vite.config.js": await viteConfigFile({ port, base, basename }), "server.mjs": customServerFile({ port, basename }), - ...files, + ...sharedFiles, }); if (startServer !== false) { stop = await customDev({ cwd, port, basename }); @@ -323,7 +397,7 @@ test.describe("Vite base / Remix basename / vite build", () => { port = await getPort(); cwd = await createProject({ "vite.config.js": await viteConfigFile({ port, base, basename }), - ...files, + ...sharedFiles, }); viteBuild({ cwd }); if (startServer !== false) { @@ -370,7 +444,7 @@ test.describe("Vite base / Remix basename / express build", async () => { cwd = await createProject({ "vite.config.js": await viteConfigFile({ port, base, basename }), "server.mjs": customServerFile({ port, base, basename }), - ...files, + ...sharedFiles, }); viteBuild({ cwd }); if (startServer !== false) { @@ -427,7 +501,7 @@ test.describe("Vite base / Remix basename / express build", async () => { const port = ${port}; app.listen(port, () => console.log('http://localhost:' + port)); `, - ...files, + ...sharedFiles, }); viteBuild({ cwd }); diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index c7a8efee7f2..7d7a393d4d3 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -264,7 +264,11 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { ...window.__remixContext.state, loaderData: { ...window.__remixContext.state.loaderData }, }; - let initialMatches = matchRoutes(routes, window.location); + let initialMatches = matchRoutes( + routes, + window.location, + window.__remixContext.basename + ); if (initialMatches) { for (let match of initialMatches) { let routeId = match.route.id;