From b2506614dd94e8851f89529bad9be359a14dcc67 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 18 Mar 2024 13:41:06 -0400 Subject: [PATCH] Updates and tests --- integration/vite-spa-mode-test.ts | 247 ++++++++++++++++++++++++ packages/remix-react/browser.tsx | 27 ++- packages/remix-server-runtime/server.ts | 5 +- 3 files changed, 268 insertions(+), 11 deletions(-) diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index d7d6adc87ac..ef0e2c958bd 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -586,6 +586,253 @@ test.describe("SPA Mode", () => { expect(html.match(/window.__remixContext =/g)?.length).toBe(1); expect(html.match(/💿 Hey developer 👋/g)?.length).toBe(1); }); + + test.describe(" prop", () => { + test("Allows users to provide client-side-only routes via RemixBrowser", 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 ; + } + `, + "src/entry.client.tsx": js` + import { Link, RemixBrowser, Outlet, useLoaderData } from "@remix-run/react"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + + const routes = [{ + index: true, + loader() { + return "Index Loader"; + }, + Component() { + let data = useLoaderData(); + return ( + <> + Go to /parent/child +

{data}

+ + ); + }, + }, { + path: '/parent', + loader() { + return "Parent Loader"; + }, + Component() { + let data = useLoaderData(); + return ( + <> +

{data}

+ + + ); + }, + children: [{ + path: 'child', + loader() { + return "Child Loader"; + }, + Component() { + let data = useLoaderData(); + return

{data}

; + }, + }] + }]; + + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + `, + }, + }); + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("#index-data"); + expect(await app.getHtml("#index-data")).toContain("Index Loader"); + + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + expect(await app.getHtml("#parent-data")).toContain("Parent Loader"); + expect(await app.getHtml("#child-data")).toContain("Child Loader"); + + let app2 = new PlaywrightFixture(appFixture, page); + await app2.goto("/parent/child"); + await page.waitForSelector("#child-data"); + expect(await app2.getHtml("#parent-data")).toContain("Parent Loader"); + expect(await app2.getHtml("#child-data")).toContain("Child Loader"); + }); + + test("Allows users to combine file routes with RemixBrowser routes", 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({ + ssr: false, + })], + }); + `, + "app/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 ; + } + `, + "app/entry.client.tsx": js` + import { Link, RemixBrowser, Outlet, useLoaderData } from "@remix-run/react"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + + const routes = [{ + path: '/parent', + loader() { + return "Parent Loader"; + }, + Component() { + let data = useLoaderData(); + return ( + <> +

{data}

+ + + ); + }, + children: [{ + path: 'child', + loader() { + return "Child Loader"; + }, + Component() { + let data = useLoaderData(); + return

{data}

; + }, + }] + }]; + + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + `, + "app/routes/_index.tsx": js` + import { Link, useLoaderData } from "@remix-run/react"; + + export function clientLoader() { + return "Index Loader"; + } + + export default function Component() { + let data = useLoaderData(); + return ( + <> + Go to /parent/child +

{data}

+ + ); + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("#index-data"); + expect(await app.getHtml("#index-data")).toContain("Index Loader"); + + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + expect(await app.getHtml("#parent-data")).toContain("Parent Loader"); + expect(await app.getHtml("#child-data")).toContain("Child Loader"); + }); + }); }); test.describe("normal apps", () => { diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index e8d784ae782..c236970ae72 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -260,13 +260,19 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { ); if (props.routes) { - let loader: LoaderFunction = () => null; - loader.hydrate = true; + let rootRoute = routes[0]; + if (!rootRoute.children) { + rootRoute.children = []; + } + // If a route doesn't have a loader, add a dummy hydrating loader to stop + // rendering at that level for hydration + let hydratingLoader: LoaderFunction = () => null; + hydratingLoader.hydrate = true; for (let route of props.routes) { if (!route.loader) { - route = { ...route, loader }; + route = { ...route, loader: hydratingLoader }; } - routes[0].children?.push(route); + rootRoute.children.push(route); } } @@ -344,10 +350,11 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { : undefined, }); - let rootRoute = router.routes[0]; - if (rootRoute?.children) { - rootRoute.children.forEach((route) => - addPropRouteToManifest(route as DataRouteObject, rootRoute.id) + // Do this after creating the router so ID's have been added to the routes that we an use as keys in the manifest + if (props.routes) { + let rootDataRoute = router.routes[0]; + rootDataRoute.children?.forEach((route) => + addPropRoutesToRemix(route as DataRouteObject, rootDataRoute.id) ); } @@ -440,7 +447,7 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { ); } -function addPropRouteToManifest(route: DataRouteObject, parentId: string) { +function addPropRoutesToRemix(route: DataRouteObject, parentId: string) { if (!window.__remixManifest.routes[route.id]) { window.__remixManifest.routes[route.id] = { index: route.index, @@ -468,7 +475,7 @@ function addPropRouteToManifest(route: DataRouteObject, parentId: string) { } if (route.children) { for (let child of route.children) { - addPropRouteToManifest(child, route.id); + addPropRoutesToRemix(child, route.id); } } } diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 60e1537e627..7d7968ceae7 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -533,9 +533,12 @@ async function handleDocumentRequest( ) { let context; try { + // When in SPA mode, always request the root, allowing us to respond for + // routes being defined and passed to , which would + // otherwise 404 because no server-side routes exist if (build.isSpaMode) { let url = new URL(request.url); - url.pathname = "/"; + url.pathname = build.basename || "/"; request = new Request(url); } context = await staticHandler.query(request, {