diff --git a/.changeset/proud-paws-hope.md b/.changeset/proud-paws-hope.md new file mode 100644 index 00000000000..ed1fd80180d --- /dev/null +++ b/.changeset/proud-paws-hope.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +Fix a bug with SPA mode when the root route had no children diff --git a/docs/file-conventions/root.md b/docs/file-conventions/root.md index 7121ed42e8e..65eb4ea8543 100644 --- a/docs/file-conventions/root.md +++ b/docs/file-conventions/root.md @@ -123,7 +123,7 @@ export default function App() { return ; } -export default function ErrorBoundary() { +export function ErrorBoundary() { const error = useRouteError(); if (isRouteErrorResponse(error)) { diff --git a/integration/spa-mode-test.ts b/integration/spa-mode-test.ts index f71a84d32cd..afcd7bc3205 100644 --- a/integration/spa-mode-test.ts +++ b/integration/spa-mode-test.ts @@ -431,6 +431,90 @@ test.describe("SPA Mode", () => { "Index Loader Data" ); }); + + test("works for migration apps with only a root route and no loader", async ({ + page, + }) => { + fixture = await createFixture({ + compiler: "vite", + spaMode: true, + files: { + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ + // We don't want to pick up the app/routes/_index.tsx file from + // the template and instead want to use only the src/root.tsx + // file below + appDirectory: "src", + ssr: false, + })], + }); + `, + "src/root.tsx": js` + import { + Meta, + Links, + Outlet, + Routes, + Route, + Scripts, + ScrollRestoration, + } from "@remix-run/react"; + + export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + + ); + } + + export default function Root() { + return ( + <> +

Root

+ + Index} /> + + + ); + } + + export function HydrateFallback() { + return

Loading SPA...

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let res = await fixture.requestDocument("/"); + let html = await res.text(); + expect(html).toMatch('

Loading SPA...

'); + + let logs: string[] = []; + page.on("console", (msg) => logs.push(msg.text())); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("[data-root]"); + expect(await page.locator("[data-root]").textContent()).toBe("Root"); + expect(await page.locator("[data-index]").textContent()).toBe("Index"); + + // Hydrates without issues + expect(logs).toEqual([]); + }); }); test.describe("normal apps", () => { diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index 22671a29f01..8975c093a27 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -148,11 +148,11 @@ export function createServerRoutes( index: route.index, path: route.path, handle: routeModule.handle, - // For SPA Mode, all routes are lazy except root. We don't need a full - // implementation here though - just need a `lazy` prop to tell the RR - // rendering where to stop - lazy: - isSpaMode && route.id !== "root" ? () => spaModeLazyPromise : undefined, + // For SPA Mode, all routes are lazy except root. However we tell the + // router root is also lazy here too since we don't need a full + // implementation - we just need a `lazy` prop to tell the RR rendering + // where to stop which is always at the root route in SPA mode + lazy: isSpaMode ? () => spaModeLazyPromise : undefined, // For partial hydration rendering, we need to indicate when the route // has a loader/clientLoader, but it won't ever be called during the static // render, so just give it a no-op function so we can render down to the