From ec3d441d86c7cf43fff61a945d24d36e17d528b3 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 15 Mar 2024 15:50:29 -0400 Subject: [PATCH 1/6] Add RemixBrowser routes prop --- .changeset/remix-browser-routes.md | 7 +++ packages/remix-react/browser.tsx | 66 +++++++++++++++++++++++-- packages/remix-react/components.tsx | 2 +- packages/remix-react/links.ts | 11 +++-- packages/remix-react/routes.tsx | 2 +- packages/remix-server-runtime/server.ts | 5 ++ 6 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 .changeset/remix-browser-routes.md diff --git a/.changeset/remix-browser-routes.md b/.changeset/remix-browser-routes.md new file mode 100644 index 00000000000..eee24ebb061 --- /dev/null +++ b/.changeset/remix-browser-routes.md @@ -0,0 +1,7 @@ +--- +"@remix-run/dev": minor +"@remix-run/react": minor +"@remix-run/server-runtime": minor +--- + +Add new `` prop to permit client-side route definition in SPA mode diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index c7a8efee7f2..e8d784ae782 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -1,7 +1,8 @@ -import type { HydrationState, Router } from "@remix-run/router"; +import type { HydrationState, LoaderFunction, Router } from "@remix-run/router"; import { createBrowserHistory, createRouter } from "@remix-run/router"; import type { ReactElement } from "react"; import * as React from "react"; +import type { DataRouteObject } from "react-router"; import { UNSAFE_mapRouteProperties as mapRouteProperties } from "react-router"; import { matchRoutes, RouterProvider } from "react-router-dom"; @@ -9,7 +10,11 @@ import { RemixContext } from "./components"; import type { AssetsManifest, FutureConfig } from "./entry"; import { RemixErrorBoundary } from "./errorBoundaries"; import { deserializeErrors } from "./errors"; -import type { RouteModules } from "./routeModules"; +import type { + ClientActionFunction, + ClientLoaderFunction, + RouteModules, +} from "./routeModules"; import { createClientRoutes, createClientRoutesWithHMRRevalidationOptOut, @@ -50,7 +55,9 @@ declare global { } /* eslint-enable prefer-let/prefer-let */ -export interface RemixBrowserProps {} +export interface RemixBrowserProps { + routes?: DataRouteObject[]; +} let stateDecodingPromise: | (Promise & { @@ -191,7 +198,7 @@ if (import.meta && import.meta.hot) { * `app/entry.client.js`). This component is used by React to hydrate the HTML * that was received from the server. */ -export function RemixBrowser(_props: RemixBrowserProps): ReactElement { +export function RemixBrowser(props: RemixBrowserProps): ReactElement { if (!router) { // Hard reload if the path we tried to load is not the current path. // This is usually the result of 2 rapid back/forward clicks from an @@ -252,6 +259,17 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { window.__remixContext.isSpaMode ); + if (props.routes) { + let loader: LoaderFunction = () => null; + loader.hydrate = true; + for (let route of props.routes) { + if (!route.loader) { + route = { ...route, loader }; + } + routes[0].children?.push(route); + } + } + let hydrationData = undefined; if (!window.__remixContext.isSpaMode) { // Create a shallow clone of `loaderData` we can mutate for partial hydration. @@ -326,6 +344,13 @@ 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) + ); + } + // We can call initialize() immediately if the router doesn't have any // loaders to run on hydration if (router.state.initialized) { @@ -414,3 +439,36 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { ); } + +function addPropRouteToManifest(route: DataRouteObject, parentId: string) { + if (!window.__remixManifest.routes[route.id]) { + window.__remixManifest.routes[route.id] = { + index: route.index, + caseSensitive: route.caseSensitive, + id: route.id, + path: route.path, + parentId, + hasAction: false, + hasLoader: false, + hasClientAction: route.action != null, + hasClientLoader: route.loader != null, + hasErrorBoundary: route.hasErrorBoundary === true, + imports: [], + css: [], + module: undefined, + }; + window.__remixRouteModules[route.id] = { + ...route, + clientAction: route.action as ClientActionFunction, + clientLoader: route.loader as ClientLoaderFunction, + default: route.Component || (() => null), + HydrateFallback: route.HydrateFallback || undefined, + ErrorBoundary: route.ErrorBoundary || undefined, + }; + } + if (route.children) { + for (let child of route.children) { + addPropRouteToManifest(child, route.id); + } + } +} diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 646ea39d435..0e07b0fe694 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -926,7 +926,7 @@ import(${JSON.stringify(manifest.entry.module)});`; .concat(nextMatches) .map((match) => { let route = manifest.routes[match.route.id]; - return (route.imports || []).concat([route.module]); + return route.module ? (route.imports || []).concat([route.module]) : []; }) .flat(1); diff --git a/packages/remix-react/links.ts b/packages/remix-react/links.ts index d93f420bbfe..b473040259b 100644 --- a/packages/remix-react/links.ts +++ b/packages/remix-react/links.ts @@ -443,7 +443,10 @@ export function getModuleLinkHrefs( matches .map((match) => { let route = manifestPatch.routes[match.route.id]; - let hrefs = [route.module]; + let hrefs = []; + if (route.module) { + hrefs.push(route.module); + } if (route.imports) { hrefs = hrefs.concat(route.imports); } @@ -464,8 +467,10 @@ function getCurrentPageModulePreloadHrefs( matches .map((match) => { let route = manifest.routes[match.route.id]; - let hrefs = [route.module]; - + let hrefs = []; + if (route.module) { + hrefs.push(route.module); + } if (route.imports) { hrefs = hrefs.concat(route.imports); } diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index 3b792993075..608e4d6274b 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -48,7 +48,7 @@ export interface EntryRoute extends Route { hasErrorBoundary: boolean; imports?: string[]; css?: string[]; - module: string; + module?: string; parentId?: string; } diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index e8199f1ffd5..60e1537e627 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -533,6 +533,11 @@ async function handleDocumentRequest( ) { let context; try { + if (build.isSpaMode) { + let url = new URL(request.url); + url.pathname = "/"; + request = new Request(url); + } context = await staticHandler.query(request, { requestContext: loadContext, }); From 93c5e69483a14ae04942e53713baf3051bfde2ee Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 18 Mar 2024 11:50:24 -0400 Subject: [PATCH 2/6] Fix TS error --- packages/remix-react/routeModules.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/remix-react/routeModules.ts b/packages/remix-react/routeModules.ts index 7c37854e32d..d164e543798 100644 --- a/packages/remix-react/routeModules.ts +++ b/packages/remix-react/routeModules.ts @@ -12,6 +12,7 @@ import type { import type { LoaderFunction, SerializeFrom } from "@remix-run/server-runtime"; import type { AppData } from "./data"; +import invariant from "./invariant"; import type { LinkDescriptor } from "./links"; import type { EntryRoute } from "./routes"; @@ -186,6 +187,7 @@ export async function loadRouteModule( } try { + invariant(route.module, "Can't load route without a defined module"); let routeModule = await import(/* webpackIgnore: true */ route.module); routeModulesCache[route.id] = routeModule; return routeModule; From b2506614dd94e8851f89529bad9be359a14dcc67 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 18 Mar 2024 13:41:06 -0400 Subject: [PATCH 3/6] 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, { From 8539b9071b20eebaca7f215e84a5e6c60d6e5686 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 18 Mar 2024 18:39:55 -0400 Subject: [PATCH 4/6] Add docs and collision detection --- docs/file-conventions/entry.client.md | 49 +++++++ docs/future/spa-mode.md | 16 +-- integration/vite-spa-mode-test.ts | 191 ++++++++++++++++++++++++++ packages/remix-react/browser.tsx | 48 +++++-- 4 files changed, 288 insertions(+), 16 deletions(-) diff --git a/docs/file-conventions/entry.client.md b/docs/file-conventions/entry.client.md index f464aef3054..f5615eced7c 100644 --- a/docs/file-conventions/entry.client.md +++ b/docs/file-conventions/entry.client.md @@ -28,4 +28,53 @@ startTransition(() => { This is the first piece of code that runs in the browser. You can initialize client side libraries, add client only providers, etc. +## `RemixBrowser` + +The `RemixBrowser` component is the top-level component of your Remix application - and will render from the [root component][root] down for the matched routes. + +### `routes` prop + +`RemixBrowser` accepts a single optional `routes` prop that can be used with [Remix SPA Mode][spa-mode] if you have not yet moved your routes to use the [file-based routing convention][file-based-routing] or the [`routes`][routes] config. The routes passed via this prop will be appended as additional children of your root route. + +If any collisions are detected from routes on the file system then a warning will be logged and the routes prop will be ignored. + +```tsx filename=entry.client.stsx +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +const routes = [ + { + index: true, + loader: indexLoader, + Component: Index, + }, + { + path: "/parent", + loader: parentLoader, + Component: Parent, + children: [ + { + path: "child", + loader: childLoader, + Component: Child, + }, + ], + }, +]; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); +``` + +[root]: ./root [server_entry_module]: ./entry.server +[spa-mode]: ../future/spa-mode +[file-based-routing]: ./routes +[routes]: ./vite-config#routes diff --git a/docs/future/spa-mode.md b/docs/future/spa-mode.md index d3dec43c7bd..6daa5dd10eb 100644 --- a/docs/future/spa-mode.md +++ b/docs/future/spa-mode.md @@ -270,17 +270,16 @@ Once you're using vite, you should be able to drop your `BrowserRouter` app into **If you are currently using `RouterProvider`** -If you are currently using `RouterProvider`, then the best approach is to move your routes to individual files and load them via `route.lazy`: +Replace your React Router `index.html` file with an `app/root.tsx` route that exports a `default` component (which renders an `Outlet`) and `HydrateFallback` for your loading state. -- Name these files according to the Remix file conventions to make the move to Remix (SPA) easier -- Export your route components as a named `Component` export (for RR) and also a `default` export (for eventual use by Remix) +You can migrate your routes by passing your route config to the [``][remix-browser-routes] prop, which should get your current `RouterProvider` app running in Remix SPA Mode. -Once you've got all your routes living in their own files, you can: +Then, you can start moving sub-trees to individual files iteratively: -- Move those files over into the Remix `app/` directory -- Enable SPA Mode -- Rename all `loader`/`action` function to `clientLoader`/`clientAction` -- Replace your React Router `index.html` file with an `app/root.tsx` route that exports a `default` component and `HydrateFallback` +- Export your route `Component` as the `default` export +- Rename any `loader`/`action` functions to `clientLoader`/`clientAction` + +You must move entire sub-trees starting with top-level routes, since Remix doesn't know how to intelligently combine your file-based routes with prop-based routes. I.e., you can't have `routes/parent.tsx` and then provide a `path: "child"` route via the `` prop that is intended to be a child of the parent route. You must move the parent route and all of it's children to files in one pass. [rfc]: https://github.com/remix-run/remix/discussions/7638 [client-data]: ../guides/client-data @@ -302,3 +301,4 @@ Once you've got all your routes living in their own files, you can: [sirv-cli]: https://www.npmjs.com/package/sirv-cli [vite-ssr-noexternal]: https://vitejs.dev/config/ssr-options#ssr-noexternal [vite-ssr-external]: https://vitejs.dev/config/ssr-options#ssr-external +[remix-browser-routes]: ../file-conventions/entry.client#routes-prop diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index ef0e2c958bd..c1296518d1f 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -832,6 +832,197 @@ test.describe("SPA Mode", () => { expect(await app.getHtml("#parent-data")).toContain("Parent Loader"); expect(await app.getHtml("#child-data")).toContain("Child Loader"); }); + + test("Throws an error if users provide duplicate index 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 ; + } + + export function HydrateFallback() { + return

Loading...

; + } + `, + "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 = [{ + index: true, + Component() { + return

Index from prop

; + }, + }]; + + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + `, + "app/routes/_index.tsx": js` + import { Link, useLoaderData } from "@remix-run/react"; + + export default function Component() { + return

Index from file

+ } + `, + }, + }); + let logs: string[] = []; + page.on("console", (msg) => logs.push(msg.text())); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await new Promise((r) => setTimeout(r, 100)); + expect(logs).toEqual([ + "Cannot add a duplicate child index route to the root route via the `RemixBrowser` `routes` prop. The `routes` prop will be ignored.", + ]); + }); + + test("Throws an error if users provide duplicate path 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 ; + } + + export function HydrateFallback() { + return

Loading...

; + } + `, + "app/entry.client.tsx": js` + import { RemixBrowser } from "@remix-run/react"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + + const routes = [{ + path: '/path', + Component() { + return

Path from prop

; + }, + }]; + + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + `, + "app/routes/_index.tsx": js` + export default function Component() { + return

Index

+ } + `, + "app/routes/path.tsx": js` + export default function Component() { + return

Path from file

+ } + `, + }, + }); + let logs: string[] = []; + page.on("console", (msg) => logs.push(msg.text())); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await new Promise((r) => setTimeout(r, 100)); + expect(logs).toEqual([ + "Cannot add a duplicate child route with path `/path` to the root route via the `RemixBrowser` `routes` prop. The `routes` prop will be ignored.", + ]); + }); }); }); diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index c236970ae72..9271a9246c8 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -259,20 +259,52 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { window.__remixContext.isSpaMode ); + let foundRoutesPropCollision = false; if (props.routes) { 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; + let existingRootChildren = new Set(); + for (let child of rootRoute.children) { + if (child.index) { + existingRootChildren.add("_index"); + } else if (child.path) { + existingRootChildren.add(child.path); + } + } for (let route of props.routes) { - if (!route.loader) { - route = { ...route, loader: hydratingLoader }; + if (route.index && existingRootChildren.has("_index")) { + foundRoutesPropCollision = true; + console.warn( + `Cannot add a duplicate child index route to the root route via ` + + `the \`RemixBrowser\` \`routes\` prop. The \`routes\` prop ` + + `will be ignored.` + ); + } else if ( + route.path && + existingRootChildren.has(route.path.replace(/^\//, "")) + ) { + foundRoutesPropCollision = true; + console.warn( + `Cannot add a duplicate child route with path \`${route.path}\` to ` + + `the root route via the \`RemixBrowser\` \`routes\` prop. The ` + + `\`routes\` prop will be ignored.` + ); + } + } + + if (!foundRoutesPropCollision) { + // 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: hydratingLoader }; + } + rootRoute.children.push(route); } - rootRoute.children.push(route); } } @@ -351,7 +383,7 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { }); // 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) { + if (props.routes && !foundRoutesPropCollision) { let rootDataRoute = router.routes[0]; rootDataRoute.children?.forEach((route) => addPropRoutesToRemix(route as DataRouteObject, rootDataRoute.id) From a7d836a3c1b3bdbf42edbd32df85f5d96d98e483 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 19 Mar 2024 12:20:33 -0400 Subject: [PATCH 5/6] Minor updates --- .changeset/remix-browser-routes.md | 2 +- docs/file-conventions/entry.client.md | 6 ++- packages/remix-react/browser.tsx | 75 ++++++++++++++++----------- 3 files changed, 51 insertions(+), 32 deletions(-) diff --git a/.changeset/remix-browser-routes.md b/.changeset/remix-browser-routes.md index eee24ebb061..5fb7ff92963 100644 --- a/.changeset/remix-browser-routes.md +++ b/.changeset/remix-browser-routes.md @@ -4,4 +4,4 @@ "@remix-run/server-runtime": minor --- -Add new `` prop to permit client-side route definition in SPA mode +Add new `` prop to permit client-side route definitions in SPA mode diff --git a/docs/file-conventions/entry.client.md b/docs/file-conventions/entry.client.md index f5615eced7c..682a449b505 100644 --- a/docs/file-conventions/entry.client.md +++ b/docs/file-conventions/entry.client.md @@ -36,7 +36,11 @@ The `RemixBrowser` component is the top-level component of your Remix applicatio `RemixBrowser` accepts a single optional `routes` prop that can be used with [Remix SPA Mode][spa-mode] if you have not yet moved your routes to use the [file-based routing convention][file-based-routing] or the [`routes`][routes] config. The routes passed via this prop will be appended as additional children of your root route. -If any collisions are detected from routes on the file system then a warning will be logged and the routes prop will be ignored. +Routes defined this way are strictly client-side, so your `loader`/`action` will be internally converted to Remix `clientLoader`/`clientAction`'s. + +This is not intended to be used as a primary method of definin routes, and is intended to be used as a migration path from a React Router `RouterProvider` application. + +If any collisions are detected from routes on the file system then a warning will be logged and the `routes` prop will be ignored. ```tsx filename=entry.client.stsx import { RemixBrowser } from "@remix-run/react"; diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 9271a9246c8..eb28a547689 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -260,39 +260,16 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { ); let foundRoutesPropCollision = false; - if (props.routes) { + if (props.routes && props.routes.length > 0) { let rootRoute = routes[0]; if (!rootRoute.children) { rootRoute.children = []; } - let existingRootChildren = new Set(); - for (let child of rootRoute.children) { - if (child.index) { - existingRootChildren.add("_index"); - } else if (child.path) { - existingRootChildren.add(child.path); - } - } - for (let route of props.routes) { - if (route.index && existingRootChildren.has("_index")) { - foundRoutesPropCollision = true; - console.warn( - `Cannot add a duplicate child index route to the root route via ` + - `the \`RemixBrowser\` \`routes\` prop. The \`routes\` prop ` + - `will be ignored.` - ); - } else if ( - route.path && - existingRootChildren.has(route.path.replace(/^\//, "")) - ) { - foundRoutesPropCollision = true; - console.warn( - `Cannot add a duplicate child route with path \`${route.path}\` to ` + - `the root route via the \`RemixBrowser\` \`routes\` prop. The ` + - `\`routes\` prop will be ignored.` - ); - } - } + + foundRoutesPropCollision = checkForRoutesCollision( + props.routes, + rootRoute.children + ); if (!foundRoutesPropCollision) { // If a route doesn't have a loader, add a dummy hydrating loader to stop @@ -382,7 +359,9 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { : undefined, }); - // Do this after creating the router so ID's have been added to the routes that we an use as keys in the manifest + // Do this after creating the router so ID's have been added to the routes + // that we can use as keys in the manifest. `router.routes` will contain the + // route IDs, `props.routes` may not. if (props.routes && !foundRoutesPropCollision) { let rootDataRoute = router.routes[0]; rootDataRoute.children?.forEach((route) => @@ -511,3 +490,39 @@ function addPropRoutesToRemix(route: DataRouteObject, parentId: string) { } } } + +function checkForRoutesCollision( + propRoutes: DataRouteObject[], + rootChildren: DataRouteObject[] +) { + let foundRoutesPropCollision = false; + let existingRootChildren = new Set(); + for (let child of rootChildren) { + if (child.index) { + existingRootChildren.add("_index"); + } else if (child.path) { + existingRootChildren.add(child.path); + } + } + for (let route of propRoutes) { + if (route.index && existingRootChildren.has("_index")) { + foundRoutesPropCollision = true; + console.warn( + `Cannot add a duplicate child index route to the root route via ` + + `the \`RemixBrowser\` \`routes\` prop. The \`routes\` prop ` + + `will be ignored.` + ); + } else if ( + route.path && + existingRootChildren.has(route.path.replace(/^\//, "")) + ) { + foundRoutesPropCollision = true; + console.warn( + `Cannot add a duplicate child route with path \`${route.path}\` to ` + + `the root route via the \`RemixBrowser\` \`routes\` prop. The ` + + `\`routes\` prop will be ignored.` + ); + } + } + return foundRoutesPropCollision; +} From f738c2a92b12d9fc3e122bf438639d02fdda6442 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 19 Mar 2024 17:16:14 -0400 Subject: [PATCH 6/6] Add error for non-spa mode --- integration/vite-spa-mode-test.ts | 10 +++--- packages/remix-react/browser.tsx | 56 ++++++++++++++++++------------- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/integration/vite-spa-mode-test.ts b/integration/vite-spa-mode-test.ts index c1296518d1f..d93942e8b0d 100644 --- a/integration/vite-spa-mode-test.ts +++ b/integration/vite-spa-mode-test.ts @@ -921,9 +921,10 @@ test.describe("SPA Mode", () => { appFixture = await createAppFixture(fixture); let app = new PlaywrightFixture(appFixture, page); await app.goto("/", true); - await new Promise((r) => setTimeout(r, 100)); expect(logs).toEqual([ - "Cannot add a duplicate child index route to the root route via the `RemixBrowser` `routes` prop. The `routes` prop will be ignored.", + expect.stringContaining( + "Error: Cannot add a duplicate child index route to the root route via the `RemixBrowser` `routes` prop. The `routes` prop will be ignored." + ), ]); }); @@ -1018,9 +1019,10 @@ test.describe("SPA Mode", () => { appFixture = await createAppFixture(fixture); let app = new PlaywrightFixture(appFixture, page); await app.goto("/", true); - await new Promise((r) => setTimeout(r, 100)); expect(logs).toEqual([ - "Cannot add a duplicate child route with path `/path` to the root route via the `RemixBrowser` `routes` prop. The `routes` prop will be ignored.", + expect.stringContaining( + "Error: Cannot add a duplicate child route with path `/path` to the root route via the `RemixBrowser` `routes` prop. The `routes` prop will be ignored." + ), ]); }); }); diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index eb28a547689..3f62af8331d 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -259,19 +259,20 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { window.__remixContext.isSpaMode ); - let foundRoutesPropCollision = false; + let propRoutesError: unknown; if (props.routes && props.routes.length > 0) { let rootRoute = routes[0]; if (!rootRoute.children) { rootRoute.children = []; } - foundRoutesPropCollision = checkForRoutesCollision( - props.routes, - rootRoute.children - ); + try { + validatePropRoutes( + props.routes, + rootRoute.children, + window.__remixContext.isSpaMode + ); - if (!foundRoutesPropCollision) { // 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; @@ -282,6 +283,8 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { } rootRoute.children.push(route); } + } catch (e) { + propRoutesError = e; } } @@ -362,11 +365,15 @@ export function RemixBrowser(props: RemixBrowserProps): ReactElement { // Do this after creating the router so ID's have been added to the routes // that we can use as keys in the manifest. `router.routes` will contain the // route IDs, `props.routes` may not. - if (props.routes && !foundRoutesPropCollision) { - let rootDataRoute = router.routes[0]; - rootDataRoute.children?.forEach((route) => - addPropRoutesToRemix(route as DataRouteObject, rootDataRoute.id) - ); + if (props.routes) { + if (propRoutesError) { + console.warn(propRoutesError); + } else { + let rootDataRoute = router.routes[0]; + rootDataRoute.children?.forEach((route) => + addPropRoutesToRemix(route as DataRouteObject, rootDataRoute.id) + ); + } } // We can call initialize() immediately if the router doesn't have any @@ -491,11 +498,17 @@ function addPropRoutesToRemix(route: DataRouteObject, parentId: string) { } } -function checkForRoutesCollision( +function validatePropRoutes( propRoutes: DataRouteObject[], - rootChildren: DataRouteObject[] + rootChildren: DataRouteObject[], + isSpaMode: boolean ) { - let foundRoutesPropCollision = false; + if (!isSpaMode) { + throw new Error( + `The prop is only usable in SPA Mode.` + ); + } + let existingRootChildren = new Set(); for (let child of rootChildren) { if (child.index) { @@ -504,25 +517,22 @@ function checkForRoutesCollision( existingRootChildren.add(child.path); } } + for (let route of propRoutes) { if (route.index && existingRootChildren.has("_index")) { - foundRoutesPropCollision = true; - console.warn( + throw new Error( `Cannot add a duplicate child index route to the root route via ` + `the \`RemixBrowser\` \`routes\` prop. The \`routes\` prop ` + `will be ignored.` ); - } else if ( - route.path && - existingRootChildren.has(route.path.replace(/^\//, "")) - ) { - foundRoutesPropCollision = true; - console.warn( + } + + if (route.path && existingRootChildren.has(route.path.replace(/^\//, ""))) { + throw new Error( `Cannot add a duplicate child route with path \`${route.path}\` to ` + `the root route via the \`RemixBrowser\` \`routes\` prop. The ` + `\`routes\` prop will be ignored.` ); } } - return foundRoutesPropCollision; }