Refine the not-found rendering process for app router (#52790)

### What

This PR changes the flow of not-found rendering process. 

### Why

`not-found.js` was rendered in two ways before:
* 1 is SSR rendering the not-found as 404
* 2 is triggering the error on RSC rendering then the error will be
preserved in inline flight data, on the client it will recover the error
and trigger the proper error boundary.

The solution has been through a jounery:
No top-level not found boundary -> introduce metadata API -> then we
create a top level root not found boundary -> then we delete it due to
duplicated rendering of root layout -> now this

So the solution before this PR is still having a root not found boundary
wrapped in the `AppRouter`, it's being used in a lot of places including
HMR. As we discovered it's doing duplicated rendering of root layout,
then we removed it and it started failing with rendering `not-found` but
missing root layout. In this PR we redesign the process.

### How

Now the rendering architecture looks like:

* For normal root not-found and certain level of not-found boundary
they're still covered by `LayoutRouter`
* For other error renderings including not-found
* Fully remove the top level not-found boundary, when it renders with
404 error it goes to render the fallback page
* During rendering the fallback page it will check if it should just
renders a 404 error page or render nothing and let the error from inline
flight data to trigger the error boundary

pseudo code
```
try {
  render AppRouter > PageComponent
} catch (err) {
  create ErrorComponent by determine err
  render AppRouter > ErrorComponent
}
```

In this way if the error is thrown from top-level like the page itself
or even from metadata, we can still catch them and render the proper
error page based on the error type.

The problematic is the HMR: introduces a new development mode meta tag
`<meta name="next-error">` to indicate it's 404 so that we don't do
refresh. This reverts the change brougt in #51637 as it will also has
the duplicated rendering problem for root layout if it's included in the
top level not found boundary.

Also fixes the root layout missing issue:

Fixes #52718
Fixes #52739

---------

Co-authored-by: Shu Ding <g@shud.in>
This commit is contained in:
Jiachi Liu 2023-07-20 23:12:06 +02:00 committed by GitHub
parent 7a0297c2d4
commit cb24c555a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 423 additions and 248 deletions

View file

@ -54,7 +54,6 @@ import { isBot } from '../../shared/lib/router/utils/is-bot'
import { addBasePath } from '../add-base-path'
import { AppRouterAnnouncer } from './app-router-announcer'
import { RedirectBoundary } from './redirect-boundary'
import { NotFoundBoundary } from './not-found-boundary'
import { findHeadInCache } from './router-reducer/reducers/find-head-in-cache'
import { createInfinitePromise } from './infinite-promise'
import { NEXT_RSC_UNION_QUERY } from './app-router-headers'
@ -89,14 +88,6 @@ export function urlToUrlWithoutFlightMarker(url: string): URL {
return urlWithoutFlightParameters
}
const HotReloader:
| typeof import('./react-dev-overlay/hot-reloader-client').default
| null =
process.env.NODE_ENV === 'production'
? null
: (require('./react-dev-overlay/hot-reloader-client')
.default as typeof import('./react-dev-overlay/hot-reloader-client').default)
type AppRouterProps = Omit<
Omit<InitialRouterStateParameters, 'isServer' | 'location'>,
'initialParallelRoutes'
@ -104,9 +95,6 @@ type AppRouterProps = Omit<
buildId: string
initialHead: ReactNode
assetPrefix: string
// Top level boundaries props
notFound: React.ReactNode | undefined
asNotFound?: boolean
}
function isExternalURL(url: URL) {
@ -224,8 +212,6 @@ function Router({
initialCanonicalUrl,
children,
assetPrefix,
notFound,
asNotFound,
}: AppRouterProps) {
const initialState = useMemo(
() =>
@ -445,9 +431,7 @@ function Router({
return findHeadInCache(cache, tree[1])
}, [cache, tree])
const notFoundProps = { notFound, asNotFound }
const content = (
let content = (
<RedirectBoundary>
{head}
{cache.subTreeData}
@ -455,6 +439,18 @@ function Router({
</RedirectBoundary>
)
if (process.env.NODE_ENV !== 'production') {
if (typeof window !== 'undefined') {
const DevRootNotFoundBoundary: typeof import('./dev-root-not-found-boundary').DevRootNotFoundBoundary =
require('./dev-root-not-found-boundary').DevRootNotFoundBoundary
content = <DevRootNotFoundBoundary>{content}</DevRootNotFoundBoundary>
}
const HotReloader: typeof import('./react-dev-overlay/hot-reloader-client').default =
require('./react-dev-overlay/hot-reloader-client').default
content = <HotReloader assetPrefix={assetPrefix}>{content}</HotReloader>
}
return (
<>
<HistoryUpdater
@ -484,16 +480,7 @@ function Router({
url: canonicalUrl,
}}
>
{HotReloader ? (
// HotReloader implements a separate NotFoundBoundary to maintain the HMR ping interval
<HotReloader assetPrefix={assetPrefix} {...notFoundProps}>
{content}
</HotReloader>
) : (
<NotFoundBoundary {...notFoundProps}>
{content}
</NotFoundBoundary>
)}
{content}
</LayoutRouterContext.Provider>
</AppRouterContext.Provider>
</GlobalLayoutRouterContext.Provider>

View file

@ -0,0 +1,25 @@
'use client'
import React from 'react'
import { NotFoundBoundary } from './not-found-boundary'
export function bailOnNotFound() {
throw new Error('notFound() is not allowed to use in root layout')
}
function NotAllowedRootNotFoundError() {
bailOnNotFound()
return null
}
export function DevRootNotFoundBoundary({
children,
}: {
children: React.ReactNode
}) {
return (
<NotFoundBoundary notFound={<NotAllowedRootNotFoundError />}>
{children}
</NotFoundBoundary>
)
}

View file

@ -491,7 +491,6 @@ export default function OuterLayoutRouter({
template,
notFound,
notFoundStyles,
asNotFound,
styles,
}: {
parallelRouterKey: string
@ -506,7 +505,6 @@ export default function OuterLayoutRouter({
hasLoading: boolean
notFound: React.ReactNode | undefined
notFoundStyles: React.ReactNode | undefined
asNotFound?: boolean
styles?: React.ReactNode
}) {
const context = useContext(LayoutRouterContext)
@ -574,7 +572,6 @@ export default function OuterLayoutRouter({
<NotFoundBoundary
notFound={notFound}
notFoundStyles={notFoundStyles}
asNotFound={asNotFound}
>
<RedirectBoundary>
<InnerLayoutRouter

View file

@ -64,6 +64,9 @@ class NotFoundErrorBoundary extends React.Component<
return (
<>
<meta name="robots" content="noindex" />
{process.env.NODE_ENV === 'development' && (
<meta name="next-error" content="not-found" />
)}
{this.props.notFoundStyles}
{this.props.notFound}
</>

View file

@ -10,7 +10,6 @@ import stripAnsi from 'next/dist/compiled/strip-ansi'
import formatWebpackMessages from '../../dev/error-overlay/format-webpack-messages'
import { useRouter } from '../navigation'
import {
ACTION_NOT_FOUND,
ACTION_VERSION_INFO,
INITIAL_OVERLAY_STATE,
errorOverlayReducer,
@ -36,8 +35,6 @@ import {
} from './internal/helpers/use-websocket'
import { parseComponentStack } from './internal/helpers/parse-component-stack'
import type { VersionInfo } from '../../../server/dev/parse-version-info'
import { isNotFoundError } from '../not-found'
import { NotFoundBoundary } from '../not-found-boundary'
interface Dispatcher {
onBuildOk(): void
@ -45,7 +42,6 @@ interface Dispatcher {
onVersionInfo(versionInfo: VersionInfo): void
onBeforeRefresh(): void
onRefresh(): void
onNotFound(): void
}
// TODO-APP: add actual type
@ -54,8 +50,6 @@ type PongEvent = any
let mostRecentCompilationHash: any = null
let __nextDevClientId = Math.round(Math.random() * 100 + Date.now())
// let startLatency = undefined
function onBeforeFastRefresh(dispatcher: Dispatcher, hasUpdates: boolean) {
if (hasUpdates) {
dispatcher.onBeforeRefresh()
@ -422,18 +416,30 @@ function processMessage(
fetch(window.location.href, {
credentials: 'same-origin',
}).then((pageRes) => {
if (pageRes.status === 200) {
// Page exists now, reload
startTransition(() => {
// @ts-ignore it exists, it's just hidden
router.fastRefresh()
dispatcher.onRefresh()
})
} else if (pageRes.status === 404) {
let shouldRefresh = pageRes.ok
// TODO-APP: investigate why edge runtime needs to reload
const isEdgeRuntime = pageRes.headers.get('x-edge-runtime') === '1'
if (pageRes.status === 404) {
// Check if head present as document.head could be null
// We are still on the page,
// dispatch an error so it's caught by the NotFound handler
dispatcher.onNotFound()
const devErrorMetaTag = document.head?.querySelector(
'meta[name="next-error"]'
)
shouldRefresh = !devErrorMetaTag
}
// Page exists now, reload
startTransition(() => {
if (shouldRefresh) {
if (isEdgeRuntime) {
window.location.reload()
} else {
// @ts-ignore it exists, it's just hidden
router.fastRefresh()
dispatcher.onRefresh()
}
}
})
})
}
return
@ -450,15 +456,9 @@ function processMessage(
export default function HotReload({
assetPrefix,
children,
notFound,
notFoundStyles,
asNotFound,
}: {
assetPrefix: string
children?: ReactNode
notFound?: React.ReactNode
notFoundStyles?: React.ReactNode
asNotFound?: boolean
}) {
const [state, dispatch] = useReducer(
errorOverlayReducer,
@ -481,9 +481,6 @@ export default function HotReload({
onVersionInfo(versionInfo) {
dispatch({ type: ACTION_VERSION_INFO, versionInfo })
},
onNotFound() {
dispatch({ type: ACTION_NOT_FOUND })
},
}
}, [dispatch])
@ -505,9 +502,7 @@ export default function HotReload({
frames: parseStack(reason.stack!),
})
}, [])
const handleOnReactError = useCallback((error: Error) => {
// not found errors are handled by the parent boundary, not the dev overlay
if (isNotFoundError(error)) throw error
const handleOnReactError = useCallback(() => {
RuntimeErrorHandler.hadRuntimeError = true
}, [])
useErrorHandler(handleOnUnhandledError, handleOnUnhandledRejection)
@ -538,15 +533,8 @@ export default function HotReload({
}, [sendMessage, router, webSocketRef, dispatcher])
return (
<NotFoundBoundary
key={`${state.notFound}`}
notFound={notFound}
notFoundStyles={notFoundStyles}
asNotFound={asNotFound}
>
<ReactDevOverlay onReactError={handleOnReactError} state={state}>
{children}
</ReactDevOverlay>
</NotFoundBoundary>
<ReactDevOverlay onReactError={handleOnReactError} state={state}>
{children}
</ReactDevOverlay>
)
}

View file

@ -13,7 +13,6 @@ import { parseStack } from './helpers/parseStack'
import { Base } from './styles/Base'
import { ComponentStyles } from './styles/ComponentStyles'
import { CssReset } from './styles/CssReset'
import { notFound } from '../../not-found'
interface ReactDevOverlayState {
reactError: SupportedErrorEvent | null
@ -59,10 +58,6 @@ class ReactDevOverlay extends React.PureComponent<
reactError ||
rootLayoutMissingTagsError
if (state.notFound) {
notFound()
}
return (
<>
{reactError ? (

View file

@ -10,7 +10,6 @@ export const ACTION_REFRESH = 'fast-refresh'
export const ACTION_UNHANDLED_ERROR = 'unhandled-error'
export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection'
export const ACTION_VERSION_INFO = 'version-info'
export const ACTION_NOT_FOUND = 'not-found'
export const INITIAL_OVERLAY_STATE: OverlayState = {
nextId: 1,
buildError: null,
@ -34,10 +33,6 @@ interface FastRefreshAction {
type: typeof ACTION_REFRESH
}
interface NotFoundAction {
type: typeof ACTION_NOT_FOUND
}
export interface UnhandledErrorAction {
type: typeof ACTION_UNHANDLED_ERROR
reason: Error
@ -96,7 +91,6 @@ export const errorOverlayReducer: React.Reducer<
| BuildErrorAction
| BeforeFastRefreshAction
| FastRefreshAction
| NotFoundAction
| UnhandledErrorAction
| UnhandledRejectionAction
| VersionInfoAction
@ -104,7 +98,7 @@ export const errorOverlayReducer: React.Reducer<
> = (state, action) => {
switch (action.type) {
case ACTION_BUILD_OK: {
return { ...state, buildError: null, notFound: false }
return { ...state, buildError: null }
}
case ACTION_BUILD_ERROR: {
return { ...state, buildError: action.message }
@ -112,14 +106,10 @@ export const errorOverlayReducer: React.Reducer<
case ACTION_BEFORE_REFRESH: {
return { ...state, refreshState: { type: 'pending', errors: [] } }
}
case ACTION_NOT_FOUND: {
return { ...state, notFound: true }
}
case ACTION_REFRESH: {
return {
...state,
buildError: null,
notFound: false,
errors:
// Errors can come in during updates. In this case, UNHANDLED_ERROR
// and UNHANDLED_REJECTION events might be dispatched between the

View file

@ -17,10 +17,7 @@ import type { RequestAsyncStorage } from '../../client/components/request-async-
import React from 'react'
import { NotFound as DefaultNotFound } from '../../client/components/error'
import {
createServerComponentRenderer,
ErrorHtml,
} from './create-server-components-renderer'
import { createServerComponentRenderer } from './create-server-components-renderer'
import { ParsedUrlQuery } from 'querystring'
import { NextParsedUrlQuery } from '../request-meta'
@ -30,6 +27,7 @@ import {
createBufferedTransformStream,
continueFromInitialStream,
streamToBufferedResult,
cloneTransformStream,
} from '../stream-utils/node-web-streams-helper'
import {
canSegmentBeOverridden,
@ -81,8 +79,6 @@ import { appendMutableCookies } from '../web/spec-extension/adapters/request-coo
import { ComponentsType } from '../../build/webpack/loaders/next-app-loader'
import { ModuleReference } from '../../build/webpack/loaders/metadata/types'
export const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
segment: string
@ -93,6 +89,19 @@ export type GetDynamicParamFromSegment = (
type: DynamicParamTypesShort
} | null
function ErrorHtml({
children,
}: {
head?: React.ReactNode
children?: React.ReactNode
}) {
return (
<html id="__next_error__">
<body>{children}</body>
</html>
)
}
// Find the closest matched component in the loader tree for a given component type
function findMatchedComponent(
loaderTree: LoaderTree,
@ -593,7 +602,7 @@ export async function renderToHTMLOrFlight(
firstItem?: boolean
injectedCSS: Set<string>
injectedFontPreloadTags: Set<string>
asNotFound?: boolean
asNotFound?: boolean | 'force'
}): Promise<{
Component: React.ComponentType
styles: React.ReactNode
@ -918,12 +927,26 @@ export async function renderToHTMLOrFlight(
// If it's a not found route, and we don't have any matched parallel
// routes, we try to render the not found component if it exists.
let isLeaf =
process.env.NODE_ENV === 'production'
? !segment && !rootLayoutIncluded
: !parallelRouteMap.length && segment === '__DEFAULT__' // hit parallel-route-default
let notFoundComponent = {}
if (asNotFound && !parallelRouteMap.length && NotFound) {
if (
NotFound &&
// For action not-found we force render the NotFound and stop checking the parallel routes.
(asNotFound === 'force' ||
// For normal case where we should look up for not-found, keep checking the parallel routes.
(asNotFound && isLeaf))
) {
notFoundComponent = {
children: (
<>
<meta name="robots" content="noindex" />
{process.env.NODE_ENV === 'development' && (
<meta name="next-error" content="not-found" />
)}
{notFoundStyles}
<NotFound />
</>
@ -1265,11 +1288,6 @@ export async function renderToHTMLOrFlight(
Uint8Array
> = new TransformStream()
const serverErrorComponentsInlinedTransformStream: TransformStream<
Uint8Array,
Uint8Array
> = new TransformStream()
// Get the nonce from the incoming request if it has one.
const csp = req.headers['content-security-policy']
let nonce: string | undefined
@ -1284,13 +1302,6 @@ export async function renderToHTMLOrFlight(
rscChunks: [],
}
const serverErrorComponentsRenderOpts = {
transformStream: serverErrorComponentsInlinedTransformStream,
clientReferenceManifest,
serverContexts,
rscChunks: [],
}
const validateRootLayout = dev
? {
validateRootLayout: {
@ -1310,32 +1321,47 @@ export async function renderToHTMLOrFlight(
injectedCSS: Set<string>,
requestPathname: string
) {
const { layout } = tree[2]
// `depth` represents how many layers we need to search into the tree.
// For instance:
// pathname '/abc' will be 0 depth, means stop at the root level
// pathname '/abc/def' will be 1 depth, means stop at the first level
const depth = requestPathname.split('/').length - 2
const notFound = findMatchedComponent(tree, 'not-found', depth)
const rootLayoutAtThisLevel = typeof layout !== 'undefined'
const [NotFound, notFoundStyles] = notFound
? await createComponentAndStyles({
filePath: notFound[1],
getComponent: notFound[0],
injectedCSS,
})
: rootLayoutAtThisLevel
? [DefaultNotFound]
: []
return [NotFound, notFoundStyles]
}
async function getRootLayout(
tree: LoaderTree,
injectedCSS: Set<string>,
injectedFontPreloadTags: Set<string>
) {
const { layout } = tree[2]
const layoutPath = layout?.[1]
const styles = getLayerAssets({
layoutOrPagePath: layoutPath,
injectedCSS: new Set(injectedCSS),
injectedFontPreloadTags: new Set(injectedFontPreloadTags),
})
const rootLayoutModule = layout?.[0]
const RootLayout = rootLayoutModule
? interopDefault(await rootLayoutModule())
: null
return [RootLayout, styles]
}
/**
* A new React Component that renders the provided React Component
* using Flight which can then be rendered to HTML.
*/
const ServerComponentsRenderer = createServerComponentRenderer<{
asNotFound: boolean
asNotFound: boolean | 'force'
}>(
async (props) => {
// Create full component tree from root to leaf.
@ -1353,12 +1379,6 @@ export async function renderToHTMLOrFlight(
asNotFound: props.asNotFound,
})
const initialTree = createFlightRouterStateFromLoaderTree(
loaderTree,
getDynamicParamFromSegment,
query
)
const createMetadata = (tree: LoaderTree, errorType?: 'not-found') => (
// Adding key={requestId} to make metadata remount for each render
// @ts-expect-error allow to use async server component
@ -1373,10 +1393,10 @@ export async function renderToHTMLOrFlight(
/>
)
const [NotFound, notFoundStyles] = await getNotFound(
const initialTree = createFlightRouterStateFromLoaderTree(
loaderTree,
injectedCSS,
pathname
getDynamicParamFromSegment,
query
)
return (
@ -1387,18 +1407,11 @@ export async function renderToHTMLOrFlight(
assetPrefix={assetPrefix}
initialCanonicalUrl={pathname}
initialTree={initialTree}
initialHead={<>{createMetadata(loaderTree, undefined)}</>}
initialHead={createMetadata(
loaderTree,
props.asNotFound ? 'not-found' : undefined
)}
globalErrorComponent={GlobalError}
notFound={
NotFound ? (
<ErrorHtml>
{createMetadata(loaderTree, 'not-found')}
{notFoundStyles}
<NotFound />
</ErrorHtml>
) : undefined
}
asNotFound={props.asNotFound}
>
<ComponentTree />
</AppRouter>
@ -1453,8 +1466,10 @@ export async function renderToHTMLOrFlight(
* This option is used to indicate that the page should be rendered as
* if it was not found. When it's enabled, instead of rendering the
* page component, it renders the not-found segment.
*
* If it's 'force', we don't traverse the tree and directly render the NotFound.
*/
asNotFound?: boolean
asNotFound: boolean | 'force'
}) => {
const polyfills = buildManifest.polyfillFiles
.filter(
@ -1470,7 +1485,7 @@ export async function renderToHTMLOrFlight(
const content = (
<InsertedHTML>
<ServerComponentsRenderer asNotFound={!!asNotFound} />
<ServerComponentsRenderer asNotFound={asNotFound} />
</InsertedHTML>
)
@ -1486,9 +1501,17 @@ export async function renderToHTMLOrFlight(
flushedErrorMetaTagsUntilIndex++
) {
const error = serverCapturedErrors[flushedErrorMetaTagsUntilIndex]
if (isNotFoundError(error)) {
errorMetaTags.push(
<meta name="robots" content="noindex" key={error.digest} />
<meta name="robots" content="noindex" key={error.digest} />,
process.env.NODE_ENV === 'development' ? (
<meta
name="next-error"
content="not-found"
key="next-error"
/>
) : null
)
} else if (isRedirectError(error)) {
const redirectUrl = getURLFromRedirectError(error)
@ -1564,7 +1587,7 @@ export async function renderToHTMLOrFlight(
})
const result = await continueFromInitialStream(renderStream, {
dataStream: serverComponentsInlinedTransformStream.readable,
dataStream: serverComponentsRenderOpts.transformStream.readable,
generateStaticHTML:
staticGenerationStore.isStaticGeneration || generateStaticHTML,
getServerInsertedHTML: () =>
@ -1590,6 +1613,7 @@ export async function renderToHTMLOrFlight(
pagePath
)
}
if (isNotFoundError(err)) {
res.statusCode = 404
}
@ -1609,104 +1633,154 @@ export async function renderToHTMLOrFlight(
res.setHeader('Location', getURLFromRedirectError(err))
}
const use404Error = res.statusCode === 404
const useDefaultError = res.statusCode < 400 || hasRedirectError
const is404 = res.statusCode === 404
const { layout } = loaderTree[2]
const injectedCSS = new Set<string>()
const injectedFontPreloadTags = new Set<string>()
const [RootLayout, rootStyles] = await getRootLayout(
loaderTree,
injectedCSS,
injectedFontPreloadTags
)
const [NotFound, notFoundStyles] = await getNotFound(
loaderTree,
injectedCSS,
pathname
)
const rootLayoutModule = layout?.[0]
const RootLayout = rootLayoutModule
? interopDefault(await rootLayoutModule())
: null
const metadata = (
// @ts-expect-error allow to use async server component
<MetadataTree
key={requestId}
tree={loaderTree}
pathname={pathname}
errorType={
use404Error
? 'not-found'
: hasRedirectError
? 'redirect'
: undefined
}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
appUsingSizeAdjust={appUsingSizeAdjust}
/>
)
const serverErrorElement = (
<ErrorHtml
// For default error we render metadata directly into the head
head={useDefaultError ? metadata : null}
>
{useDefaultError
? null
: React.createElement(
createServerComponentRenderer(
async () => {
return (
<>
{/* For server components error metadata needs to be inside inline flight data, so they can be hydrated */}
{metadata}
{use404Error ? (
<RootLayout params={{}}>
{notFoundStyles}
<meta name="robots" content="noindex" />
<NotFound />
</RootLayout>
) : undefined}
</>
)
},
ComponentMod,
serverErrorComponentsRenderOpts,
serverComponentsErrorHandler,
nonce
)
)}
</ErrorHtml>
)
const renderStream = await renderToInitialStream({
ReactDOMServer: require('react-dom/server.edge'),
element: serverErrorElement,
streamOptions: {
nonce,
// Include hydration scripts in the HTML
bootstrapScripts: subresourceIntegrityManifest
? buildManifest.rootMainFiles.map((src) => ({
src:
`${assetPrefix}/_next/` +
src +
getAssetQueryString(false),
integrity: subresourceIntegrityManifest[src],
}))
: buildManifest.rootMainFiles.map(
(src) =>
`${assetPrefix}/_next/` + src + getAssetQueryString(false)
// Preserve the existing RSC inline chunks from the page rendering.
// For 404 errors: the metadata from layout can be skipped with the error page.
// For other errors (such as redirection): it can still be re-thrown on client.
const serverErrorComponentsRenderOpts: typeof serverComponentsRenderOpts =
{
...serverComponentsRenderOpts,
rscChunks: [],
transformStream: is404
? new TransformStream()
: cloneTransformStream(
serverComponentsRenderOpts.transformStream
),
},
})
}
return await continueFromInitialStream(renderStream, {
dataStream: (useDefaultError
? serverComponentsInlinedTransformStream
: serverErrorComponentsInlinedTransformStream
).readable,
generateStaticHTML: staticGenerationStore.isStaticGeneration,
getServerInsertedHTML: () => getServerInsertedHTML([]),
serverInsertedHTMLToHead: true,
...validateRootLayout,
})
const errorType = is404
? 'not-found'
: hasRedirectError
? 'redirect'
: undefined
const errorMeta = (
<>
{res.statusCode >= 400 && (
<meta name="robots" content="noindex" />
)}
{process.env.NODE_ENV === 'development' && (
<meta name="next-error" content="not-found" />
)}
</>
)
const ErrorPage = createServerComponentRenderer(
async () => {
const head = (
<>
{/* @ts-expect-error allow to use async server component */}
<MetadataTree
key={requestId}
tree={loaderTree}
pathname={pathname}
errorType={errorType}
searchParams={providedSearchParams}
getDynamicParamFromSegment={getDynamicParamFromSegment}
appUsingSizeAdjust={appUsingSizeAdjust}
/>
{errorMeta}
</>
)
const notFoundLoaderTree: LoaderTree = is404
? ['__DEFAULT__', {}, loaderTree[2]]
: loaderTree
const initialTree = createFlightRouterStateFromLoaderTree(
notFoundLoaderTree,
getDynamicParamFromSegment,
query
)
const GlobalNotFound = NotFound || DefaultNotFound
const ErrorLayout = RootLayout || ErrorHtml
const notFoundElement = (
<ErrorLayout params={{}}>
{rootStyles}
{notFoundStyles}
<GlobalNotFound />
</ErrorLayout>
)
// For metadata notFound error there's no global not found boundary on top
// so we create a not found page with AppRouter
return (
<AppRouter
buildId={renderOpts.buildId}
assetPrefix={assetPrefix}
initialCanonicalUrl={pathname}
initialTree={initialTree}
initialHead={head}
globalErrorComponent={GlobalError}
>
{is404 ? notFoundElement : <ErrorHtml head={head} />}
</AppRouter>
)
},
ComponentMod,
serverErrorComponentsRenderOpts,
serverComponentsErrorHandler,
nonce
)
try {
const renderStream = await renderToInitialStream({
ReactDOMServer: require('react-dom/server.edge'),
element: <ErrorPage />,
streamOptions: {
nonce,
// Include hydration scripts in the HTML
bootstrapScripts: subresourceIntegrityManifest
? buildManifest.rootMainFiles.map((src) => ({
src:
`${assetPrefix}/_next/` +
src +
getAssetQueryString(false),
integrity: subresourceIntegrityManifest[src],
}))
: buildManifest.rootMainFiles.map(
(src) =>
`${assetPrefix}/_next/` +
src +
getAssetQueryString(false)
),
},
})
return await continueFromInitialStream(renderStream, {
dataStream:
serverErrorComponentsRenderOpts.transformStream.readable,
generateStaticHTML: staticGenerationStore.isStaticGeneration,
getServerInsertedHTML: () => getServerInsertedHTML([]),
serverInsertedHTMLToHead: true,
...validateRootLayout,
})
} catch (finalErr: any) {
if (
process.env.NODE_ENV !== 'production' &&
isNotFoundError(finalErr)
) {
const bailOnNotFound: typeof import('../../client/components/dev-root-not-found-boundary').bailOnNotFound =
require('../../client/components/dev-root-not-found-boundary').bailOnNotFound
bailOnNotFound()
}
throw finalErr
}
}
}
)
@ -1725,7 +1799,7 @@ export async function renderToHTMLOrFlight(
})
if (actionRequestResult === 'not-found') {
return new RenderResult(await bodyResult({ asNotFound: true }))
return new RenderResult(await bodyResult({ asNotFound: 'force' }))
} else if (actionRequestResult) {
return actionRequestResult
}

View file

@ -75,18 +75,3 @@ export function createServerComponentRenderer<Props>(
return use(response)
}
}
export function ErrorHtml({
head,
children,
}: {
head?: React.ReactNode
children?: React.ReactNode
}) {
return (
<html id="__next_error__">
<head>{head}</head>
<body>{children}</body>
</html>
)
}

View file

@ -36,7 +36,7 @@ export async function getLayoutOrPageModule(loaderTree: LoaderTree) {
// First check not-found, if it doesn't exist then pick layout
export async function getErrorOrLayoutModule(
loaderTree: LoaderTree,
errorType: 'error' | 'not-found'
errorType: 'not-found'
) {
const { [errorType]: error, layout } = loaderTree[2]
if (typeof error !== 'undefined') {

View file

@ -457,7 +457,6 @@ export async function renderToHTMLImpl(
let Document = extra.Document
// Component will be wrapped by ServerComponentWrapper for RSC
let Component: React.ComponentType<{}> | ((props: any) => JSX.Element) =
renderOpts.Component
const OriginComponent = Component

View file

@ -30,6 +30,25 @@ export const streamToBufferedResult = async (
return renderChunks.join('')
}
export function cloneTransformStream(source: TransformStream) {
const sourceReader = source.readable.getReader()
const clone = new TransformStream({
async start(controller) {
while (true) {
const { done, value } = await sourceReader.read()
if (done) {
break
}
controller.enqueue(value)
}
},
// skip the its own written chunks
transform() {},
})
return clone
}
export function readableStreamTee<T = any>(
readable: ReadableStream<T>
): [ReadableStream<T>, ReadableStream<T>] {

View file

@ -5,7 +5,7 @@ import { redirectAction } from './actions'
export default function Form() {
return (
<form>
<input type="text" name="hidden-info" value="hi" hidden />
<input type="text" name="hidden-info" defaultValue="hi" hidden />
<input type="text" name="name" id="client-name" required />
<button formAction={redirectAction} type="submit" id="there">
Go there

View file

@ -45,7 +45,7 @@ export default function Form() {
<>
<hr />
<form action={action}>
<input type="text" name="hidden-info" value="hi" hidden />
<input type="text" name="hidden-info" defaultValue="hi" hidden />
<input type="text" name="name" id="name" required />
<button type="submit" id="submit">
Submit

View file

@ -1,4 +1,4 @@
export default function notFound() {
export default function NotFound() {
return <h2>root not found page</h2>
}

View file

@ -535,11 +535,18 @@ createNextDescribe(
const noIndexTag = '<meta name="robots" content="noindex"/>'
const defaultViewportTag =
'<meta name="viewport" content="width=device-width, initial-scale=1"/>'
const devErrorMetadataTag =
'<meta name="next-error" content="not-found"/>'
const html = await next.render('/not-found/suspense')
expect(html).toContain(noIndexTag)
// only contain once
expect(html.split(noIndexTag).length).toBe(2)
expect(html.split(defaultViewportTag).length).toBe(2)
if (isNextDev) {
// only contain dev error tag once
expect(html.split(devErrorMetadataTag).length).toBe(2)
}
})
it('should emit refresh meta tag for redirect page when streaming', async () => {

View file

@ -1,10 +1,16 @@
export default function Layout({ children }) {
return (
<html>
<head>
<title>Hello World</title>
</head>
<body>{children}</body>
<head />
<body>
<header>
<nav id="layout-nav">Navbar</nav>
</header>
{children}
<footer>
<p id="layout-footer">Footer</p>
</footer>
</body>
</html>
)
}

View file

@ -1,4 +1,4 @@
export default function Page() {
export default function NotFound() {
return (
<>
<h1>This Is The Not Found Page</h1>
@ -7,3 +7,5 @@ export default function Page() {
</>
)
}
NotFound.displayName = 'NotFound'

View file

@ -10,8 +10,12 @@ createNextDescribe(
({ next, isNextDev }) => {
const runTests = ({ isEdge }: { isEdge: boolean }) => {
it('should use the not-found page for non-matching routes', async () => {
const html = await next.render('/random-content')
expect(html).toContain('This Is The Not Found Page')
const browser = await next.browser('/random-content')
expect(await browser.elementByCss('h1').text()).toContain(
'This Is The Not Found Page'
)
// should contain root layout content
expect(await browser.elementByCss('#layout-nav').text()).toBe('Navbar')
})
it('should allow to have a valid /not-found route', async () => {

View file

@ -0,0 +1,27 @@
'use client'
import React, { useState } from 'react'
import { notFound } from 'next/navigation'
import NotFoundTrigger from './not-found-trigger'
export default function Root({ children }) {
// notFound()
const [clicked, setClicked] = useState(false)
if (clicked) {
notFound()
}
return (
<html>
<body>
<NotFoundTrigger />
<button id="trigger-not-found" onClick={() => setClicked(true)}>
Click to not found
</button>
{children}
</body>
</html>
)
}
export const dynamic = 'force-dynamic'

View file

@ -0,0 +1,12 @@
'use client'
import { useSearchParams, notFound } from 'next/navigation'
export default function NotFoundTrigger() {
const searchParams = useSearchParams()
if (searchParams.get('root-not-found')) {
notFound()
}
return null
}

View file

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

View file

@ -0,0 +1,52 @@
import { createNextDescribe } from 'e2e-utils'
import { check, getRedboxDescription, hasRedbox } from 'next-test-utils'
createNextDescribe(
'app dir - root layout not found',
{
files: __dirname,
skipDeployment: true,
},
({ next, isNextDev }) => {
it('should error on client notFound from root layout in browser', async () => {
const browser = await next.browser('/')
await browser.elementByCss('#trigger-not-found').click()
if (isNextDev) {
await check(async () => {
expect(await hasRedbox(browser, true)).toBe(true)
expect(await getRedboxDescription(browser)).toMatch(
/notFound\(\) is not allowed to use in root layout/
)
return 'success'
}, /success/)
} else {
expect(await browser.elementByCss('h2').text()).toBe(
'Application error: a server-side exception has occurred (see the server logs for more information).'
)
expect(await browser.elementByCss('p').text()).toBe(
'Digest: NEXT_NOT_FOUND'
)
}
})
it('should error on server notFound from root layout on server-side', async () => {
const browser = await next.browser('/?root-not-found=1')
if (isNextDev) {
expect(await hasRedbox(browser, true)).toBe(true)
expect(await getRedboxDescription(browser)).toBe(
'Error: notFound() is not allowed to use in root layout'
)
} else {
expect(await browser.elementByCss('h2').text()).toBe(
'Application error: a server-side exception has occurred (see the server logs for more information).'
)
expect(await browser.elementByCss('p').text()).toBe(
'Digest: NEXT_NOT_FOUND'
)
}
})
}
)