feat(error-overlay): notify about missing html/body in root layout (#62815)

This commit is contained in:
Balázs Orbán 2024-03-06 11:59:53 +01:00 committed by GitHub
parent 415cd74b9a
commit 8f5107de16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 150 additions and 200 deletions

View file

@ -0,0 +1,31 @@
---
title: Missing Root Layout tags
---
## ``
#### Why This Error Occurred
You forgot to define the `<html>` and/or `<body>` tags in your Root Layout.
#### Possible Ways to Fix It
To fix this error, make sure that both `<html>` and `<body>` are present in your Root Layout.
```diff filename="app/layout.tsx"
export default function Layout({ children }: { children: React.ReactNode }) {
- return children
+ return (
+ <html>
+ <body>
+ {children}
+ </body>
+ </html>
+ )
}
```
### Useful Links
- [Root Layout](https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#root-layout-required)

View file

@ -7,7 +7,6 @@ import React, { use } from 'react'
import { createFromReadableStream } from 'react-server-dom-webpack/client'
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
import { GlobalLayoutRouterContext } from '../shared/lib/app-router-context.shared-runtime'
import onRecoverableError from './on-recoverable-error'
import { callServer } from './app-call-server'
import { isNextRouterError } from './components/is-next-router-error'
@ -15,6 +14,7 @@ import {
ActionQueueContext,
createMutableActionQueue,
} from '../shared/lib/router/action-queue'
import { HMR_ACTIONS_SENT_TO_BROWSER } from '../server/dev/hot-reloader-types'
// Since React doesn't call onerror for errors caught in error boundaries.
const origConsoleError = window.console.error
@ -130,7 +130,7 @@ const StrictModeIfEnabled = process.env.__NEXT_STRICT_MODE_APP
? React.StrictMode
: React.Fragment
function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
function Root({ children }: React.PropsWithChildren<{}>) {
// TODO: remove in the next major version
if (process.env.__NEXT_ANALYTICS_ID) {
// eslint-disable-next-line react-hooks/rules-of-hooks
@ -143,71 +143,19 @@ function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
window.__NEXT_HYDRATED = true
if (window.__NEXT_HYDRATED_CB) {
window.__NEXT_HYDRATED_CB()
}
window.__NEXT_HYDRATED_CB?.()
}, [])
}
return children as React.ReactElement
return children
}
export function hydrate() {
if (process.env.NODE_ENV !== 'production') {
const rootLayoutMissingTagsError = (self as any)
.__next_root_layout_missing_tags_error
const HotReload: typeof import('./components/react-dev-overlay/app/hot-reloader-client').default =
require('./components/react-dev-overlay/app/hot-reloader-client')
.default as typeof import('./components/react-dev-overlay/app/hot-reloader-client').default
// Don't try to hydrate if root layout is missing required tags, render error instead
if (rootLayoutMissingTagsError) {
const reactRootElement = document.createElement('div')
document.body.appendChild(reactRootElement)
const reactRoot = (ReactDOMClient as any).createRoot(reactRootElement, {
onRecoverableError,
})
reactRoot.render(
<GlobalLayoutRouterContext.Provider
value={{
buildId: 'development',
tree: rootLayoutMissingTagsError.tree,
changeByServerResponse: () => {},
focusAndScrollRef: {
apply: false,
onlyHashChange: false,
hashFragment: null,
segmentPaths: [],
},
nextUrl: null,
}}
>
<HotReload
assetPrefix={rootLayoutMissingTagsError.assetPrefix}
// initialState={{
// rootLayoutMissingTagsError: {
// missingTags: rootLayoutMissingTagsError.missingTags,
// },
// }}
/>
</GlobalLayoutRouterContext.Provider>
)
return
}
}
const actionQueue = createMutableActionQueue()
const reactEl = (
<StrictModeIfEnabled>
<HeadManagerContext.Provider
value={{
appDir: true,
}}
>
<HeadManagerContext.Provider value={{ appDir: true }}>
<ActionQueueContext.Provider value={actionQueue}>
<Root>
<ServerRoot />
@ -217,10 +165,11 @@ export function hydrate() {
</StrictModeIfEnabled>
)
const options = {
onRecoverableError,
}
const isError = document.documentElement.id === '__next_error__'
const rootLayoutMissingTags = window.__next_root_layout_missing_tags
const options = { onRecoverableError } satisfies ReactDOMClient.RootOptions
const isError =
document.documentElement.id === '__next_error__' || rootLayoutMissingTags
if (process.env.NODE_ENV !== 'production') {
// Patch console.error to collect information about hydration errors
@ -247,9 +196,17 @@ export function hydrate() {
require('./components/react-dev-overlay/internal/helpers/get-socket-url')
.getSocketUrl as typeof import('./components/react-dev-overlay/internal/helpers/get-socket-url').getSocketUrl
const MissingTags = () => {
throw new Error(
`The following tags are missing in the Root Layout: ${rootLayoutMissingTags?.join(
', '
)}.\nRead more at https://nextjs.org/docs/messages/missing-root-layout-tags`
)
}
let errorTree = (
<ReactDevOverlay state={INITIAL_OVERLAY_STATE} onReactError={() => {}}>
{reactEl}
{rootLayoutMissingTags ? <MissingTags /> : reactEl}
</ReactDevOverlay>
)
const socketUrl = getSocketUrl(process.env.__NEXT_ASSET_PREFIX || '')
@ -266,7 +223,9 @@ export function hydrate() {
return
}
if (obj.action === 'serverComponentChanges') {
if (
obj.action === HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES
) {
window.location.reload()
}
}

View file

@ -9,7 +9,6 @@ import { ShadowPortal } from '../internal/components/ShadowPortal'
import { BuildError } from '../internal/container/BuildError'
import { Errors } from '../internal/container/Errors'
import type { SupportedErrorEvent } from '../internal/container/Errors'
import { RootLayoutError } from '../internal/container/RootLayoutError'
import { parseStack } from '../internal/helpers/parseStack'
import { Base } from '../internal/styles/Base'
import { ComponentStyles } from '../internal/styles/ComponentStyles'
@ -52,12 +51,7 @@ class ReactDevOverlay extends React.PureComponent<
const hasBuildError = state.buildError != null
const hasRuntimeErrors = Boolean(state.errors.length)
const rootLayoutMissingTagsError = state.rootLayoutMissingTagsError
const isMounted =
hasBuildError ||
hasRuntimeErrors ||
reactError ||
rootLayoutMissingTagsError
const isMounted = hasBuildError || hasRuntimeErrors || reactError
return (
<>
@ -75,11 +69,7 @@ class ReactDevOverlay extends React.PureComponent<
<Base />
<ComponentStyles />
{rootLayoutMissingTagsError ? (
<RootLayoutError
missingTags={rootLayoutMissingTagsError.missingTags}
/>
) : hasBuildError ? (
{hasBuildError ? (
<BuildError
message={state.buildError!}
versionInfo={state.versionInfo}

View file

@ -64,9 +64,6 @@ export interface OverlayState {
nextId: number
buildError: string | null
errors: SupportedErrorEvent[]
rootLayoutMissingTagsError?: {
missingTags: string[]
}
refreshState: FastRefreshState
versionInfo: VersionInfo
notFound: boolean

View file

@ -1,72 +0,0 @@
import React from 'react'
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
} from '../components/Dialog'
import { Overlay } from '../components/Overlay'
import { Terminal } from '../components/Terminal'
import { noop as css } from '../helpers/noop-template'
export type RootLayoutErrorProps = { missingTags: string[] }
export const RootLayoutError: React.FC<RootLayoutErrorProps> =
function BuildError({ missingTags }) {
const message =
'Please make sure to include the following tags in your root layout: <html>, <body>.\n\n' +
`Missing required root layout tag${
missingTags.length === 1 ? '' : 's'
}: ` +
missingTags.join(', ')
const noop = React.useCallback(() => {}, [])
return (
<Overlay fixed>
<Dialog
type="error"
aria-labelledby="nextjs__container_root_layout_error_label"
aria-describedby="nextjs__container_root_layout_error_desc"
onClose={noop}
>
<DialogContent>
<DialogHeader className="nextjs-container-root-layout-error-header">
<h4 id="nextjs__container_root_layout_error_label">
Missing required tags
</h4>
</DialogHeader>
<DialogBody className="nextjs-container-root-layout-error-body">
<Terminal content={message} />
<footer>
<p id="nextjs__container_root_layout_error_desc">
<small>
This error and can only be dismissed by providing all
required tags.
</small>
</p>
</footer>
</DialogBody>
</DialogContent>
</Dialog>
</Overlay>
)
}
export const styles = css`
.nextjs-container-root-layout-error-header > h4 {
line-height: 1.5;
margin: 0;
padding: 0;
}
.nextjs-container-root-layout-error-body footer {
margin-top: var(--size-gap);
}
.nextjs-container-root-layout-error-body footer p {
margin: 0;
}
.nextjs-container-root-layout-error-body small {
color: #757575;
}
`

View file

@ -1,5 +1,3 @@
import * as React from 'react'
import { styles as codeFrame } from '../components/CodeFrame/styles'
import { styles as dialog } from '../components/Dialog'
import { styles as leftRightDialogHeader } from '../components/LeftRightDialogHeader/styles'
@ -8,7 +6,6 @@ import { styles as terminal } from '../components/Terminal/styles'
import { styles as toast } from '../components/Toast'
import { styles as versionStaleness } from '../components/VersionStalenessInfo'
import { styles as buildErrorStyles } from '../container/BuildError'
import { styles as rootLayoutErrorStyles } from '../container/RootLayoutError'
import { styles as containerErrorStyles } from '../container/Errors'
import { styles as containerRuntimeErrorStyles } from '../container/RuntimeError'
import { noop as css } from '../helpers/noop-template'
@ -25,7 +22,6 @@ export function ComponentStyles() {
${terminal}
${buildErrorStyles}
${rootLayoutErrorStyles}
${containerErrorStyles}
${containerRuntimeErrorStyles}
${versionStaleness}

View file

@ -820,16 +820,6 @@ async function renderToHTMLOrFlightImpl(
}
const validateRootLayout = dev
? {
assetPrefix: renderOpts.assetPrefix,
getTree: () =>
createFlightRouterStateFromLoaderTree(
loaderTree,
getDynamicParamFromSegment,
query
),
}
: undefined
const { HeadManagerContext } =
require('../../shared/lib/head-manager-context.shared-runtime') as typeof import('../../shared/lib/head-manager-context.shared-runtime')

View file

@ -1,5 +1,3 @@
import type { FlightRouterState } from '../app-render/types'
import { getTracer } from '../lib/trace/tracer'
import { AppRenderSpan } from '../lib/trace/constants'
import { createDecodeTransformStream } from './encode-decode'
@ -431,10 +429,15 @@ function createStripDocumentClosingTagsTransform(): TransformStream<
})
}
export function createRootLayoutValidatorStream(
assetPrefix = '',
getTree: () => FlightRouterState
): TransformStream<Uint8Array, Uint8Array> {
/*
* Checks if the root layout is missing the html or body tags
* and if so, it will inject a script tag to throw an error in the browser, showing the user
* the error message in the error overlay.
*/
export function createRootLayoutValidatorStream(): TransformStream<
Uint8Array,
Uint8Array
> {
let foundHtml = false
let foundBody = false
@ -459,29 +462,23 @@ export function createRootLayoutValidatorStream(
// Flush the decoder.
if (!foundHtml || !foundBody) {
content += decoder.decode()
if (!foundHtml && content.includes('<html')) {
foundHtml = true
}
if (!foundBody && content.includes('<body')) {
foundBody = true
}
if (!foundHtml && content.includes('<html')) foundHtml = true
if (!foundBody && content.includes('<body')) foundBody = true
}
// If html or body tag is missing, we need to inject a script to notify
// the client.
const missingTags: string[] = []
const missingTags: typeof window.__next_root_layout_missing_tags = []
if (!foundHtml) missingTags.push('html')
if (!foundBody) missingTags.push('body')
if (missingTags.length > 0) {
controller.enqueue(
encoder.encode(
`<script>self.__next_root_layout_missing_tags_error=${JSON.stringify(
{ missingTags, assetPrefix: assetPrefix ?? '', tree: getTree() }
)}</script>`
)
if (!missingTags.length) return
controller.enqueue(
encoder.encode(
`<script>self.__next_root_layout_missing_tags=${JSON.stringify(
missingTags
)}</script>`
)
}
)
},
})
}
@ -504,12 +501,7 @@ export type ContinueStreamOptions = {
isStaticGeneration: boolean
getServerInsertedHTML: (() => Promise<string>) | undefined
serverInsertedHTMLToHead: boolean
validateRootLayout:
| {
assetPrefix: string | undefined
getTree: () => FlightRouterState
}
| undefined
validateRootLayout?: boolean
/**
* Suffix to inject after the buffered data, but before the close tags.
*/
@ -555,6 +547,9 @@ export async function continueFizzStream(
// Insert the inlined data (Flight data, form state, etc.) stream into the HTML
inlinedDataStream ? createMergedTransformStream(inlinedDataStream) : null,
// Validate the root layout for missing html or body tags
validateRootLayout ? createRootLayoutValidatorStream() : null,
// Close tags should always be deferred to the end
createMoveSuffixStream(closeTag),
@ -564,13 +559,6 @@ export async function continueFizzStream(
getServerInsertedHTML && serverInsertedHTMLToHead
? createHeadInsertionTransformStream(getServerInsertedHTML)
: null,
validateRootLayout
? createRootLayoutValidatorStream(
validateRootLayout.assetPrefix,
validateRootLayout.getTree
)
: null,
])
}

View file

@ -44,7 +44,10 @@ declare module '*.module.scss' {
interface Window {
MSInputMethodContext?: unknown
/** @internal */
__NEXT_HMR_CB?: null | ((message?: string) => void)
/** @internal */
__next_root_layout_missing_tags?: ('html' | 'body')[]
}
interface NextFetchRequestConfig {

View file

@ -0,0 +1,3 @@
export default function Root({ children }: { children: React.ReactNode }) {
return children
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}

View file

@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}
module.exports = nextConfig

View file

@ -0,0 +1,33 @@
import { nextTestSetup } from 'e2e-utils'
import { getRedboxDescription, hasRedbox } from 'next-test-utils'
describe('show error when missing html or body in root layout', () => {
const { next } = nextTestSetup({ files: __dirname })
it('should show error overlay', async () => {
const browser = await next.browser('/')
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxDescription(browser)).toMatchInlineSnapshot(`
"Error: The following tags are missing in the Root Layout: html, body.
Read more at https://nextjs.org/docs/messages/missing-root-layout-tags"
`)
await next.patchFile('app/layout.tsx', (code) =>
code.replace('return children', 'return <body>{children}</body>')
)
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxDescription(browser)).toMatchInlineSnapshot(`
"Error: The following tags are missing in the Root Layout: html.
Read more at https://nextjs.org/docs/messages/missing-root-layout-tags"
`)
await next.patchFile('app/layout.tsx', (code) =>
code.replace(
'return <body>{children}</body>',
'return <html><body>{children}</body></html>'
)
)
expect(await hasRedbox(browser)).toBe(false)
expect(await browser.elementByCss('p').text()).toBe('hello world')
})
})

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}