Skip to content

Commit

Permalink
Add docs and collision detection
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 committed Mar 18, 2024
1 parent b250661 commit 8539b90
Show file tree
Hide file tree
Showing 4 changed files with 288 additions and 16 deletions.
49 changes: 49 additions & 0 deletions docs/file-conventions/entry.client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<docs-warn>If any collisions are detected from routes on the file system then a warning will be logged and the routes prop will be ignored.</docs-warn>

```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,
<StrictMode>
<RemixBrowser routes={routes} />
</StrictMode>
);
});
```

[root]: ./root
[server_entry_module]: ./entry.server
[spa-mode]: ../future/spa-mode
[file-based-routing]: ./routes
[routes]: ./vite-config#routes
16 changes: 8 additions & 8 deletions docs/future/spa-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [`<RemixBrowser routes>`][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 `<RemixBrowser routes>` 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
Expand All @@ -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
191 changes: 191 additions & 0 deletions integration/vite-spa-mode-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<html>
<head>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function Root() {
return <Outlet />;
}
export function HydrateFallback() {
return <p>Loading...</p>;
}
`,
"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 <h1>Index from prop</h1>;
},
}];
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser routes={routes} />
</StrictMode>
);
});
`,
"app/routes/_index.tsx": js`
import { Link, useLoaderData } from "@remix-run/react";
export default function Component() {
return <h1>Index from file</h1>
}
`,
},
});
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 (
<html>
<head>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function Root() {
return <Outlet />;
}
export function HydrateFallback() {
return <p>Loading...</p>;
}
`,
"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 <h1>Path from prop</h1>;
},
}];
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser routes={routes} />
</StrictMode>
);
});
`,
"app/routes/_index.tsx": js`
export default function Component() {
return <h1>Index</h1>
}
`,
"app/routes/path.tsx": js`
export default function Component() {
return <h1>Path from file</h1>
}
`,
},
});
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.",
]);
});
});
});

Expand Down
48 changes: 40 additions & 8 deletions packages/remix-react/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 8539b90

Please sign in to comment.