Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move root layout validation #41338

Merged
merged 18 commits into from Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
@@ -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