Skip to content

Commit

Permalink
Move root layout validation (vercel#41338)
Browse files Browse the repository at this point in the history
Moves where the validation is made to make sure the error reaches the
client. Tests that an error overlay is shown on the client.

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)

Co-authored-by: JJ Kasper <jj@jjsweb.site>
  • Loading branch information
2 people authored and Kikobeats committed Oct 24, 2022
1 parent bd82a5d commit b1224da
Show file tree
Hide file tree
Showing 12 changed files with 219 additions and 32 deletions.
27 changes: 27 additions & 0 deletions packages/next/client/app-index.tsx
Expand Up @@ -8,6 +8,7 @@ import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-we

import measureWebVitals from './performance-relayer'
import { HeadManagerContext } from '../shared/lib/head-manager-context'
import HotReload from './components/react-dev-overlay/hot-reloader'

/// <reference types="react-dom/experimental" />

Expand Down Expand Up @@ -174,6 +175,32 @@ function RSCComponent(props: any): JSX.Element {
}

export function hydrate() {
if (process.env.NODE_ENV !== 'production') {
const rootLayoutMissingTagsError = (self as any)
.__next_root_layout_missing_tags_error

// 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)

reactRoot.render(
<HotReload
assetPrefix={rootLayoutMissingTagsError.assetPrefix}
initialState={{
rootLayoutMissingTagsError: {
missingTags: rootLayoutMissingTagsError.missingTags,
},
}}
initialTree={rootLayoutMissingTagsError.tree}
/>
)

return
}
}

const reactEl = (
<React.StrictMode>
<HeadManagerContext.Provider
Expand Down
23 changes: 19 additions & 4 deletions packages/next/client/components/react-dev-overlay/hot-reloader.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import type { FlightRouterState } from '../../../server/app-render'
import React, {
useCallback,
useContext,
Expand All @@ -20,7 +21,10 @@ import type { DispatchFn } from './client'
import stripAnsi from 'next/dist/compiled/strip-ansi'
import formatWebpackMessages from '../../dev/error-overlay/format-webpack-messages'
import { useRouter } from '../navigation'
import { errorOverlayReducer } from './internal/error-overlay-reducer'
import {
errorOverlayReducer,
OverlayState,
} from './internal/error-overlay-reducer'

function getSocketProtocol(assetPrefix: string): string {
let protocol = window.location.protocol
Expand All @@ -41,6 +45,7 @@ type PongEvent = any
let mostRecentCompilationHash: any = null
let __nextDevClientId = Math.round(Math.random() * 100 + Date.now())
let hadRuntimeError = false
let hadRootlayoutError = false

// let startLatency = undefined

Expand Down Expand Up @@ -141,7 +146,7 @@ function tryApplyUpdates(
}

function handleApplyUpdates(err: any, updatedModules: any) {
if (err || hadRuntimeError || !updatedModules) {
if (err || hadRuntimeError || hadRootlayoutError || !updatedModules) {
if (err) {
console.warn(
'[Fast Refresh] performing full reload\n\n' +
Expand Down Expand Up @@ -334,7 +339,7 @@ function processMessage(
clientId: __nextDevClientId,
})
)
if (hadRuntimeError) {
if (hadRuntimeError || hadRootlayoutError) {
return window.location.reload()
}
startTransition(() => {
Expand Down Expand Up @@ -420,15 +425,23 @@ function processMessage(
export default function HotReload({
assetPrefix,
children,
initialState,
initialTree,
}: {
assetPrefix: string
children?: ReactNode
initialState?: Partial<OverlayState>
initialTree?: FlightRouterState
}) {
if (initialState?.rootLayoutMissingTagsError) {
hadRootlayoutError = true
}
const stacktraceLimitRef = useRef<undefined | number>()
const [state, dispatch] = React.useReducer(errorOverlayReducer, {
nextId: 1,
buildError: null,
errors: [],
...initialState,
})

const handleOnUnhandledError = useCallback((ev) => {
Expand All @@ -440,7 +453,9 @@ export default function HotReload({
onUnhandledRejection(dispatch, ev)
}, [])

const { tree } = useContext(GlobalLayoutRouterContext)
const { tree } = useContext(GlobalLayoutRouterContext) ?? {
tree: initialTree,
}
const router = useRouter()

const webSocketRef = useRef<WebSocket>()
Expand Down
Expand Up @@ -12,6 +12,7 @@ import { Base } from './styles/Base'
import { ComponentStyles } from './styles/ComponentStyles'
import { CssReset } from './styles/CssReset'
import { parseStack } from './helpers/parseStack'
import { RootLayoutError } from './container/RootLayoutError'

interface ReactDevOverlayState {
reactError: SupportedErrorEvent | null
Expand Down Expand Up @@ -45,7 +46,12 @@ class ReactDevOverlay extends React.PureComponent<

const hasBuildError = state.buildError != null
const hasRuntimeErrors = Boolean(state.errors.length)
const isMounted = hasBuildError || hasRuntimeErrors || reactError
const rootLayoutMissingTagsError = state.rootLayoutMissingTagsError
const isMounted =
hasBuildError ||
hasRuntimeErrors ||
reactError ||
rootLayoutMissingTagsError

return (
<>
Expand All @@ -63,7 +69,11 @@ class ReactDevOverlay extends React.PureComponent<
<Base />
<ComponentStyles />

{hasBuildError ? (
{rootLayoutMissingTagsError ? (
<RootLayoutError
missingTags={rootLayoutMissingTagsError.missingTags}
/>
) : hasBuildError ? (
<BuildError message={state.buildError!} />
) : hasRuntimeErrors ? (
<Errors errors={state.errors} />
Expand Down
@@ -0,0 +1,72 @@
import * as 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>, <head>, <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">
Root layout error
</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 occurred during the build process and can only be
dismissed by fixing the error.
</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;
}
`
Expand Up @@ -32,6 +32,9 @@ export interface OverlayState {
nextId: number
buildError: string | null
errors: SupportedErrorEvent[]
rootLayoutMissingTagsError?: {
missingTags: string[]
}
}

export function errorOverlayReducer(
Expand Down
Expand Up @@ -7,6 +7,7 @@ import { styles as overlay } from '../components/Overlay/styles'
import { styles as terminal } from '../components/Terminal/styles'
import { styles as toast } from '../components/Toast'
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'
Expand All @@ -23,6 +24,7 @@ export function ComponentStyles() {
${terminal}
${buildErrorStyles}
${rootLayoutErrorStyles}
${containerErrorStyles}
${containerRuntimeErrorStyles}
`}
Expand Down
15 changes: 13 additions & 2 deletions packages/next/server/app-render.tsx
Expand Up @@ -1402,6 +1402,15 @@ export async function renderToHTMLOrFlight(
rscChunks: [],
}

const validateRootLayout = dev
? {
validateRootLayout: {
assetPrefix: renderOpts.assetPrefix,
getTree: () => createFlightRouterStateFromLoaderTree(loaderTree),
},
}
: {}

/**
* A new React Component that renders the provided React Component
* using Flight which can then be rendered to HTML.
Expand Down Expand Up @@ -1520,7 +1529,7 @@ export async function renderToHTMLOrFlight(
generateStaticHTML: isStaticGeneration,
getServerInsertedHTML,
serverInsertedHTMLToHead: true,
dev,
...validateRootLayout,
})
} catch (err: any) {
// TODO-APP: show error overlay in development. `element` should probably be wrapped in AppRouter for this case.
Expand Down Expand Up @@ -1551,14 +1560,15 @@ export async function renderToHTMLOrFlight(
generateStaticHTML: isStaticGeneration,
getServerInsertedHTML,
serverInsertedHTMLToHead: true,
dev,
...validateRootLayout,
})
}
}
const renderResult = new RenderResult(await bodyResult())

if (isStaticGeneration) {
const htmlResult = await streamToBufferedResult(renderResult)

// if we encountered any unexpected errors during build
// we fail the prerendering phase and the build
if (capturedErrors.length > 0) {
Expand All @@ -1582,6 +1592,7 @@ export async function renderToHTMLOrFlight(

return new RenderResult(htmlResult)
}

return renderResult
}

Expand Down
31 changes: 22 additions & 9 deletions packages/next/server/node-web-streams-helper.ts
@@ -1,3 +1,4 @@
import type { FlightRouterState } from './app-render'
import { nonNullable } from '../lib/non-nullable'

export type ReactReadableStream = ReadableStream<Uint8Array> & {
Expand Down Expand Up @@ -257,10 +258,10 @@ export function createSuffixStream(
})
}

export function createRootLayoutValidatorStream(): TransformStream<
Uint8Array,
Uint8Array
> {
export function createRootLayoutValidatorStream(
assetPrefix = '',
getTree: () => FlightRouterState
): TransformStream<Uint8Array, Uint8Array> {
let foundHtml = false
let foundHead = false
let foundBody = false
Expand Down Expand Up @@ -289,8 +290,12 @@ export function createRootLayoutValidatorStream(): TransformStream<
].filter(nonNullable)

if (missingTags.length > 0) {
controller.error(
'Missing required root layout tags: ' + missingTags.join(', ')
controller.enqueue(
encodeText(
`<script>self.__next_root_layout_missing_tags_error=${JSON.stringify(
{ missingTags, assetPrefix: assetPrefix ?? '', tree: getTree() }
)}</script>`
)
)
}
},
Expand All @@ -300,19 +305,22 @@ export function createRootLayoutValidatorStream(): TransformStream<
export async function continueFromInitialStream(
renderStream: ReactReadableStream,
{
dev,
suffix,
dataStream,
generateStaticHTML,
getServerInsertedHTML,
serverInsertedHTMLToHead,
validateRootLayout,
}: {
dev?: boolean
suffix?: string
dataStream?: ReadableStream<Uint8Array>
generateStaticHTML: boolean
getServerInsertedHTML?: () => Promise<string>
serverInsertedHTMLToHead: boolean
validateRootLayout?: {
assetPrefix?: string
getTree: () => FlightRouterState
}
}
): Promise<ReadableStream<Uint8Array>> {
const closeTag = '</body></html>'
Expand All @@ -339,7 +347,12 @@ export async function continueFromInitialStream(
: ''
return serverInsertedHTML
}),
dev ? createRootLayoutValidatorStream() : null,
validateRootLayout
? createRootLayoutValidatorStream(
validateRootLayout.assetPrefix,
validateRootLayout.getTree
)
: null,
].filter(nonNullable)

return transforms.reduce(
Expand Down

0 comments on commit b1224da

Please sign in to comment.