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