feat(error-overlay): notify about missing html
/body
in root layout (#62815)
This commit is contained in:
parent
415cd74b9a
commit
8f5107de16
14 changed files with 150 additions and 200 deletions
31
errors/missing-root-layout-tags.mdx
Normal file
31
errors/missing-root-layout-tags.mdx
Normal 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)
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -64,9 +64,6 @@ export interface OverlayState {
|
|||
nextId: number
|
||||
buildError: string | null
|
||||
errors: SupportedErrorEvent[]
|
||||
rootLayoutMissingTagsError?: {
|
||||
missingTags: string[]
|
||||
}
|
||||
refreshState: FastRefreshState
|
||||
versionInfo: VersionInfo
|
||||
notFound: boolean
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`
|
|
@ -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}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
|
|
3
packages/next/types/global.d.ts
vendored
3
packages/next/types/global.d.ts
vendored
|
@ -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 {
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p>hello world</p>
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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"]
|
||||
}
|
Loading…
Reference in a new issue