Skip to content

Commit

Permalink
Support an optional Layout export from the root route (#8709)
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 authored Feb 8, 2024
1 parent fff83ad commit c9615ca
Show file tree
Hide file tree
Showing 21 changed files with 350 additions and 85 deletions.
7 changes: 7 additions & 0 deletions .changeset/fresh-knives-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@remix-run/dev": minor
"@remix-run/react": minor
"@remix-run/server-runtime": minor
---

Allow an optional `Layout` export from the root route
14 changes: 13 additions & 1 deletion docs/components/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ toc: false

# `<Scripts />`

This component renders the client runtime of your app. You should render it inside the [`<body>`][body-element] of your HTML, usually in `app/root.tsx`.
This component renders the client runtime of your app. You should render it inside the [`<body>`][body-element] of your HTML, usually in [`app/root.tsx`][root].

```tsx filename=app/root.tsx lines=[8]
import { Scripts } from "@remix-run/react";
Expand All @@ -24,4 +24,16 @@ export default function Root() {

If you don't render the `<Scripts/>` component, your app will still work like a traditional web app without JavaScript, relying solely on HTML and browser behaviors.

## Props

The `<Scripts>` component can pass through certain attributes to the underlying `<script>` tags such as:

- `<Scripts crossOrigin>` for hosting your static assets on a different server than your app.
- `<Scripts nonce>` to support a [content security policy for scripts][csp] with [nonce-sources][csp-nonce] for your `<script>` tags.

You cannot pass through attributes such as `async`/`defer`/`src`/`type`/`noModule` because they are managed by Remix internally.

[body-element]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/body
[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
[csp-nonce]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources
[root]: ../file-conventions/root
121 changes: 109 additions & 12 deletions docs/file-conventions/root.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,26 @@ toc: false

# Root Route

FIXME: This is mostly the wrong doc, right code.
The "root" route (`app/root.tsx`) is the only _required_ route in your Remix application because it is the parent to all routes in your `routes/` directory and is in charge of rendering the root `<html>` document.

These components are to be used once inside your root route (`root.tsx`). They include everything Remix figured out or built in order for your page to render properly.
Beyond that, it's mostly just like any other route and supports all of the standard route exports:

```tsx
- [`headers`][headers]
- [`meta`][meta]
- [`links`][links]
- [`loader`][loader]
- [`clientLoader`][clientloader]
- [`action`][action]
- [`clientAction`][clientaction]
- [`default`][component]
- [`ErrorBoundary`][errorboundary]
- [`HydrateFallback`][hydratefallback]
- [`handle`][handle]
- [`shouldRevalidate`][shouldrevalidate]

Because the root route manages your document, it is the proper place to render a handful of "document-level" components Remix provides. These components are to be used once inside your root route and they include everything Remix figured out or built in order for your page to render properly.

```tsx filename=app/root.tsx
import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
import {
Links,
Expand All @@ -36,14 +51,14 @@ export default function App() {
content="width=device-width, initial-scale=1"
/>

{/* All meta exports on all routes will go here */}
{/* All `meta` exports on all routes will render here */}
<Meta />

{/* All link exports on all routes will go here */}
{/* All `link` exports on all routes will render here */}
<Links />
</head>
<body>
{/* Child routes go here */}
{/* Child routes render here */}
<Outlet />

{/* Manages scroll position for client-side transitions */}
Expand All @@ -64,16 +79,98 @@ export default function App() {
}
```

You can pass extra props to `<Scripts />` like `<Scripts crossOrigin />` for hosting your static assets on a different server than your app.
## Layout Export

Because the root route manages the document for all routes, it also supports an additional optional `Layout` export. You can read the details in this [RFC][layout-rfc] but the layout route serves 2 purposes:

- Avoid duplicating your document/"app shell" across your root component, `HydrateFallback`, and `ErrorBoundary`
- Avoids React from re-mounting your app shell elements when switching between the root component/`HydrateFallback`/`ErrorBoundary` which can cause a FOUC if React removes and re-adds `<link rel="stylesheet">` tags from your `<Links>` component.

```tsx filename=app/root.tsx lines=[10-31]
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";

export function Layout({ children }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<Meta />
<Links />
</head>
<body>
{/* children will be the root Component, ErrorBoundary, or HydrateFallback */}
{children}
<Scripts />
<ScrollRestoration />
<LiveReload />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}

export default function ErrorBoundary() {
const error = useRouteError();

The example above renders several `<script />` tags into the resulting HTML. While this usually just works, you might have configured a [content security policy for scripts][csp] that prevents these `<script />` tags from being executed. In particular, to support [content security policies with nonce-sources for scripts][csp-nonce], the `<Scripts />`, `<LiveReload />` and `<ScrollRestoration />` components support a `nonce` property, e.g.`<Script nonce={nonce}/>`. The provided nonce is subsequently passed to the `<script />` tag rendered into the HTML by these components, allowing the scripts to be executed in accordance with your CSP policy.
if (isRouteErrorResponse(error)) {
return (
<>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</>
);
}

return (
<>
<h1>Error!</h1>
<p>{error?.message ?? "Unknown error"}</p>
</>
);
}
```

See also:

- [`meta`][meta]
- [`links`][links]
- [`<Meta>`][meta-component]
- [`<Links>`][links-component]
- [`<Outlet>`][outlet-component]
- [`<ScrollRestoration>`][scrollrestoration-component]
- [`<Scripts>`][scripts-component]
- [`<LiveReload>`][livereload-component]

[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
[csp-nonce]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources
[headers]: ../route/headers
[meta]: ../route/meta
[links]: ../route/links
[loader]: ../route/loader
[clientloader]: ../route/client-loader
[action]: ../route/action
[clientaction]: ../route/client-action
[component]: ../route/component
[errorboundary]: ../route/error-boundary
[hydratefallback]: ../route/hydrate-fallback
[handle]: ../route/handle
[shouldrevalidate]: ../route/should-revalidate
[layout-rfc]: https://github.com/remix-run/remix/discussions/8702
[scripts-component]: ../components/scripts
[links-component]: ../components/links
[meta-component]: ../components/meta
[livereload-component]: ../components/live-reload
[scrollrestoration-component]: ../components/scroll-restoration
[outlet-component]: ../components/outlet
2 changes: 1 addition & 1 deletion integration/client-data-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";

import { ServerMode } from "../packages/remix-server-runtime/mode.js";
import { ServerMode } from "../build/node_modules/@remix-run/server-runtime/dist/mode.js";
import {
createAppFixture,
createFixture,
Expand Down
84 changes: 77 additions & 7 deletions integration/root-route-test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";

import { ServerMode } from "../build/node_modules/@remix-run/server-runtime/dist/mode.js";
import {
createAppFixture,
createFixture,
Expand All @@ -12,7 +13,11 @@ test.describe("root route", () => {
let fixture: Fixture;
let appFixture: AppFixture;

test.beforeAll(async () => {
test.afterAll(() => {
appFixture.close();
});

test("matches the sole root route on /", async ({ page }) => {
fixture = await createFixture({
files: {
"app/root.tsx": js`
Expand All @@ -28,18 +33,83 @@ test.describe("root route", () => {
`,
},
});

appFixture = await createAppFixture(fixture);
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/");
await page.waitForSelector("h1");
expect(await app.getHtml("h1")).toMatch("Hello Root!");
});

test.afterAll(() => {
appFixture.close();
});

test("matches the sole root route on /", async ({ page }) => {
test("renders the Layout around the component", async ({ page }) => {
fixture = await createFixture({
files: {
"app/root.tsx": js`
export function Layout({ children }) {
return (
<html>
<head>
<title>Layout Title</title>
</head>
<body>
{children}
</body>
</html>
);
}
export default function Root() {
return <h1>Hello Root!</h1>;
}
`,
},
});
appFixture = await createAppFixture(fixture);
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/");
await page.waitForSelector("h1");
expect(await app.getHtml("title")).toMatch("Layout Title");
expect(await app.getHtml("h1")).toMatch("Hello Root!");
});

test("renders the Layout around the ErrorBoundary", async ({ page }) => {
let oldConsoleError;
oldConsoleError = console.error;
console.error = () => {};

fixture = await createFixture(
{
files: {
"app/root.tsx": js`
import { useRouteError } from '@remix-run/react';
export function Layout({ children }) {
return (
<html>
<head>
<title>Layout Title</title>
</head>
<body>
{children}
</body>
</html>
);
}
export default function Root() {
throw new Error('broken render')
}
export function ErrorBoundary() {
return <p>{useRouteError().message}</p>;
}
`,
},
},
ServerMode.Development
);
appFixture = await createAppFixture(fixture, ServerMode.Development);
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/");
await page.waitForSelector("p");
expect(await app.getHtml("title")).toMatch("Layout Title");
expect(await app.getHtml("p")).toMatch("broken render");

console.error = oldConsoleError;
});
});
1 change: 1 addition & 0 deletions packages/remix-dev/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const CLIENT_ROUTE_EXPORTS = [
"ErrorBoundary",
"handle",
"HydrateFallback",
"Layout",
"links",
"meta",
"shouldRevalidate",
Expand Down
8 changes: 8 additions & 0 deletions packages/remix-react/routeModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface RouteModule {
clientLoader?: ClientLoaderFunction;
ErrorBoundary?: ErrorBoundaryComponent;
HydrateFallback?: HydrateFallbackComponent;
Layout?: LayoutComponent;
default: RouteComponent;
handle?: RouteHandle;
links?: LinksFunction;
Expand Down Expand Up @@ -72,6 +73,13 @@ export type ErrorBoundaryComponent = ComponentType;
*/
export type HydrateFallbackComponent = ComponentType;

/**
* Optional, root-only `<Route Layout>` component to wrap the root content in.
* Useful for defining the <html>/<head>/<body> document shell shared by the
* Component, HydrateFallback, and ErrorBoundary
*/
export type LayoutComponent = ComponentType;

/**
* A function that defines `<link>` tags to be inserted into the `<head>` of
* the document on route transitions.
Expand Down
Loading

0 comments on commit c9615ca

Please sign in to comment.