Skip to content

Commit

Permalink
render error fallback
Browse files Browse the repository at this point in the history
  • Loading branch information
huozhi committed Nov 20, 2024
1 parent 179def5 commit 4ed3837
Show file tree
Hide file tree
Showing 26 changed files with 185 additions and 410 deletions.
2 changes: 1 addition & 1 deletion crates/next-core/src/app_structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,7 +865,7 @@ fn directory_tree_to_loader_tree_internal(
// the path).
let is_root_layout = app_path.is_root() && modules.layout.is_some();

if (is_root_directory || is_root_layout) {
if is_root_directory || is_root_layout {
if modules.not_found.is_none() {
modules.not_found = Some(
get_next_package(app_dir).join("dist/client/components/not-found-error.js".into()),
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/client/components/forbidden-error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HTTPAccessErrorFallback } from './http-access-fallback/error-fallback'

export default function NotFound() {
export default function Forbidden() {
return (
<HTTPAccessErrorFallback
status={403}
Expand Down
5 changes: 3 additions & 2 deletions packages/next/src/client/components/forbidden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
type HTTPAccessFallbackError,
} from './http-access-fallback/http-access-fallback'


// TODO: Add `forbidden` docs
/**
* @experimental
Expand All @@ -26,7 +25,9 @@ export function forbidden(): never {
!process.env.__NEXT_TEST_MODE &&
!process.env.NEXT_PRIVATE_SKIP_CANARY_CHECK
) {
throw new Error(`\`forbidden()\` is experimental and not allowed allowed to used in canary builds.`)
throw new Error(
`\`forbidden()\` is experimental and only allowed to be used in canary builds.`
)
}

// eslint-disable-next-line no-throw-literal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@

import React, { useContext } from 'react'
import { useUntrackedPathname } from '../navigation-untracked'
import {
HTTPAccessErrorStatus,
getAccessFallbackHTTPStatus,
isHTTPAccessFallbackError
import {
HTTPAccessErrorStatus,
getAccessFallbackHTTPStatus,
isHTTPAccessFallbackError,
} from './http-access-fallback'
import { warnOnce } from '../../../shared/lib/utils/warn-once'
import { MissingSlotContext } from '../../../shared/lib/app-router-context.shared-runtime'
Expand Down Expand Up @@ -79,7 +79,6 @@ class HTTPAccessFallbackErrorBoundary extends React.Component<
static getDerivedStateFromError(error: any) {
if (isHTTPAccessFallbackError(error)) {
const httpStatus = getAccessFallbackHTTPStatus(error)
console.log('get httpStatus', httpStatus)
return {
triggeredStatus: httpStatus,
}
Expand Down Expand Up @@ -113,17 +112,23 @@ class HTTPAccessFallbackErrorBoundary extends React.Component<
render() {
const { notFound, forbidden, unauthorized } = this.props
const { triggeredStatus } = this.state
console.log('render triggeredStatus', triggeredStatus, this.props)
if (triggeredStatus) {
return (
<>
<meta name="robots" content="noindex" />
{process.env.NODE_ENV === 'development' && (
<meta name="next-error" content="not-found" />
)}
{(triggeredStatus === HTTPAccessErrorStatus.NOT_FOUND && notFound) ? notFound : null}
{(triggeredStatus === HTTPAccessErrorStatus.FORBIDDEN && forbidden) ? forbidden : null}
{(triggeredStatus === HTTPAccessErrorStatus.UNAUTHORIZED && unauthorized) ? unauthorized : null}
{triggeredStatus === HTTPAccessErrorStatus.NOT_FOUND && notFound
? notFound
: null}
{triggeredStatus === HTTPAccessErrorStatus.FORBIDDEN && forbidden
? forbidden
: null}
{triggeredStatus === HTTPAccessErrorStatus.UNAUTHORIZED &&
unauthorized
? unauthorized
: null}
</>
)
}
Expand Down
8 changes: 7 additions & 1 deletion packages/next/src/client/components/layout-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ export default function OuterLayoutRouter({
template,
notFound,
forbidden,
unauthorized,
}: {
parallelRouterKey: string
segmentPath: FlightSegmentPath
Expand All @@ -525,6 +526,7 @@ export default function OuterLayoutRouter({
template: React.ReactNode
notFound: React.ReactNode | undefined
forbidden: React.ReactNode | undefined
unauthorized: React.ReactNode | undefined
}) {
const context = useContext(LayoutRouterContext)
if (!context) {
Expand Down Expand Up @@ -581,7 +583,11 @@ export default function OuterLayoutRouter({
errorScripts={errorScripts}
>
<LoadingBoundary loading={loading}>
<HTTPAccessFallbackBoundary notFound={notFound} forbidden={forbidden}>
<HTTPAccessFallbackBoundary
notFound={notFound}
forbidden={forbidden}
unauthorized={unauthorized}
>
<RedirectBoundary>
<InnerLayoutRouter
parallelRouterKey={parallelRouterKey}
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/client/components/unauthorized-error.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { HTTPAccessErrorFallback } from './http-access-fallback/error-fallback'

export default function NotFound() {
export default function Unauthorized() {
return (
<HTTPAccessErrorFallback
status={401}
message="This page is not authorized"
message="You're not authorized to access this page"
/>
)
}
4 changes: 3 additions & 1 deletion packages/next/src/client/components/unauthorized.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export function unauthorized(): never {
!process.env.__NEXT_TEST_MODE &&
!process.env.NEXT_PRIVATE_SKIP_CANARY_CHECK
) {
throw new Error(`\`unauthorized()\` is experimental and not allowed allowed to used in canary builds.`)
throw new Error(
`\`unauthorized()\` is experimental and only allowed to be used in canary builds.`
)
}

// eslint-disable-next-line no-throw-literal
Expand Down
51 changes: 33 additions & 18 deletions packages/next/src/server/app-render/create-component-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,15 @@ async function createComponentTreeInternal({
const { page, layoutOrPagePath, segment, modules, parallelRoutes } =
parseLoaderTree(tree)

const { layout, template, error, loading, 'not-found': notFound, forbidden } = modules
const {
layout,
template,
error,
loading,
'not-found': notFound,
forbidden,
unauthorized,
} = modules

const injectedCSSWithCurrentLayout = new Set(injectedCSS)
const injectedJSWithCurrentLayout = new Set(injectedJS)
Expand Down Expand Up @@ -194,7 +202,7 @@ async function createComponentTreeInternal({
injectedJS: injectedJSWithCurrentLayout,
})
: []

const [Forbidden, forbiddenStyles] = forbidden
? await createComponentStylesAndScripts({
ctx,
Expand All @@ -205,6 +213,16 @@ async function createComponentTreeInternal({
})
: []

const [Unauthorized, unauthorizedStyles] = unauthorized
? await createComponentStylesAndScripts({
ctx,
filePath: unauthorized[1],
getComponent: unauthorized[0],
injectedCSS: injectedCSSWithCurrentLayout,
injectedJS: injectedJSWithCurrentLayout,
})
: []

let dynamic = layoutOrPageMod?.dynamic

if (nextConfigOutput === 'export') {
Expand Down Expand Up @@ -363,15 +381,23 @@ async function createComponentTreeInternal({
<NotFound />
</>
) : undefined
const forbiddenComponent =

const forbiddenComponent =
Forbidden && isChildrenRouteKey ? (
<>
{forbiddenStyles}
<Forbidden />
</>
) : undefined

const unauthorizedComponent =
Unauthorized && isChildrenRouteKey ? (
<>
{unauthorizedStyles}
<Unauthorized />
</>
) : undefined

// if we're prefetching and that there's a Loading component, we bail out
// otherwise we keep rendering for the prefetch.
// We also want to bail out if there's no Loading component in the tree.
Expand Down Expand Up @@ -468,6 +494,7 @@ async function createComponentTreeInternal({
templateScripts={templateScripts}
notFound={notFoundComponent}
forbidden={forbiddenComponent}
unauthorized={unauthorizedComponent}
/>,
childCacheNodeSeedData,
]
Expand Down Expand Up @@ -640,6 +667,7 @@ async function createComponentTreeInternal({
)
}

// TODO: support forbidden and unauthorized in parallel routes
if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) {
// TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`.
// This ensures that a `HTTPAccessFallbackBoundary` is available for when that happens,
Expand All @@ -662,20 +690,6 @@ async function createComponentTreeInternal({
params={currentParams}
/>
)
const forbiddenClientSegment = (
<ClientSegmentRoot
Component={SegmentComponent}
slots={{
children: (
<>
{forbiddenStyles}
<Forbidden />
</>
),
}}
params={currentParams}
/>
)

segmentNode = (
<HTTPAccessFallbackBoundary
Expand Down Expand Up @@ -717,6 +731,7 @@ async function createComponentTreeInternal({
<SegmentComponent {...parallelRouteProps} params={params} />
)

// TODO: support forbidden and unauthorized in parallel routes
if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) {
// TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`.
// This ensures that a `HTTPAccessFallbackBoundary` is available for when that happens,
Expand Down
39 changes: 32 additions & 7 deletions test/development/acceptance-app/hydration-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,18 +434,43 @@ describe('Error overlay for hydration errors in App router', () => {
expect(await getRedboxTotalErrorCount(browser)).toBe(1)
})

expect(await session.getRedboxDescription()).toMatchInlineSnapshot(`
"In HTML, whitespace text nodes cannot be a child of <table>. Make sure you don't have any extra whitespace between tags on each line of your source code.
This will cause a hydration error.
if (process.env.TURBOPACK) {
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(`
"In HTML, whitespace text nodes cannot be a child of <table>. Make sure you don't have any extra whitespace between tags on each line of your source code.
This will cause a hydration error.
...
<RenderFromTemplateContext>
<ScrollAndFocusHandler segmentPath={[...]}>
<InnerScrollAndFocusHandler segmentPath={[...]} focusAndScrollRef={{apply:false, ...}}>
<ErrorBoundary errorComponent={undefined} errorStyles={undefined} errorScripts={undefined}>
<LoadingBoundary hasLoading={false} loading={undefined} loadingStyles={undefined} loadingScripts={undefined}>
<HTTPAccessFallbackBoundary notFound={[...]} forbidden={[...]} unauthorized={[...]}>
<HTTPAccessFallbackErrorBoundary pathname="/" notFound={[...]} forbidden={[...]} unauthorized={[...]} ...>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<InnerLayoutRouter parallelRouterKey="children" url="/" tree={[...]} childNodes={Map} ...>
<ClientPageRoot Component={function Page} searchParams={{}} params={{}}>
<Page params={Promise} searchParams={Promise}>
> <table>
> {" "}
...
...
"
`)
} else {
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(`
"In HTML, whitespace text nodes cannot be a child of <table>. Make sure you don't have any extra whitespace between tags on each line of your source code.
This will cause a hydration error.
...
<RenderFromTemplateContext>
<ScrollAndFocusHandler segmentPath={[...]}>
<InnerScrollAndFocusHandler segmentPath={[...]} focusAndScrollRef={{apply:false, ...}}>
<ErrorBoundary errorComponent={undefined} errorStyles={undefined} errorScripts={undefined}>
<LoadingBoundary loading={null}>
<HTTPAccessFallbackBoundary notFound={[...]}>
<HTTPAccessFallbackErrorBoundary pathname="/" notFound={[...]} missingSlots={Set}>
<HTTPAccessFallbackBoundary notFound={[...]} forbidden={undefined} unauthorized={undefined}>
<HTTPAccessFallbackErrorBoundary pathname="/" notFound={[...]} forbidden={undefined} ...>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<InnerLayoutRouter parallelRouterKey="children" url="/" tree={[...]} childNodes={Map} ...>
Expand Down Expand Up @@ -905,7 +930,7 @@ describe('Error overlay for hydration errors in App router', () => {
if (isTurbopack) {
expect(fullPseudoHtml).toMatchInlineSnapshot(`
"...
<HTTPAccessFallbackErrorBoundary pathname="/" notFound={[...]} missingSlots={Set}>
<HTTPAccessFallbackErrorBoundary pathname="/" notFound={[...]} forbidden={[...]} unauthorized={[...]} ...>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<InnerLayoutRouter parallelRouterKey="children" url="/" tree={[...]} childNodes={Map} segmentPath={[...]} ...>
Expand All @@ -925,7 +950,7 @@ describe('Error overlay for hydration errors in App router', () => {
} else {
expect(fullPseudoHtml).toMatchInlineSnapshot(`
"...
<HTTPAccessFallbackErrorBoundary pathname="/" notFound={[...]} missingSlots={Set}>
<HTTPAccessFallbackErrorBoundary pathname="/" notFound={[...]} forbidden={undefined} unauthorized={undefined} ...>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<InnerLayoutRouter parallelRouterKey="children" url="/" tree={[...]} childNodes={Map} segmentPath={[...]} ...>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export default function NotFound() {
export default function Forbidden() {
return <div id="forbidden">{`dynamic/[id] forbidden`}</div>
}
3 changes: 0 additions & 3 deletions test/e2e/app-dir/forbidden/basic/app/dynamic/[id]/page.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { forbidden } from 'next/navigation'

// avoid static generation to fill the dynamic params
export const dynamic = 'force-dynamic'

export default async function Page(props) {
const params = await props.params

Expand Down
6 changes: 2 additions & 4 deletions test/e2e/app-dir/forbidden/basic/app/forbidden.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
export default function NotFound() {
export default function Forbidden() {
return (
<>
<h1>Root Not Found</h1>
<h1>Root Forbidden</h1>

<div id="timestamp">{Date.now()}</div>
</>
)
}

NotFound.displayName = 'NotFound'
Loading

0 comments on commit 4ed3837

Please sign in to comment.