Skip to content

Commit

Permalink
chore: Create a single error boundary that caches any ui error
Browse files Browse the repository at this point in the history
  • Loading branch information
panteliselef committed Jul 28, 2024
1 parent 78892f0 commit 16828cf
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 106 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import React from 'react'
import { ForbiddenBoundary, NotFoundBoundary } from './ui-errors-boundaries'
import { UIErrorsBoundary } from './ui-errors-boundaries'
import type { UIErrorHelper } from '../../shared/lib/ui-error-types'

export function bailOnUIError(uiError: UIErrorHelper) {
Expand All @@ -24,10 +24,11 @@ export function DevRootUIErrorsBoundary({
children: React.ReactNode
}) {
return (
<ForbiddenBoundary uiComponent={<NotAllowedRootForbiddenError />}>
<NotFoundBoundary uiComponent={<NotAllowedRootNotFoundError />}>
{children}
</NotFoundBoundary>
</ForbiddenBoundary>
<UIErrorsBoundary
not-found={<NotAllowedRootNotFoundError />}
forbidden={<NotAllowedRootForbiddenError />}
>
{children}
</UIErrorsBoundary>
)
}
41 changes: 18 additions & 23 deletions packages/next/src/client/components/layout-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { RedirectBoundary } from './redirect-boundary'
import { getSegmentValue } from './router-reducer/reducers/get-segment-value'
import { createRouterCacheKey } from './router-reducer/create-router-cache-key'
import { hasInterceptionRouteInCurrentTree } from './router-reducer/reducers/has-interception-route-in-current-tree'
import { ForbiddenBoundary, NotFoundBoundary } from './ui-errors-boundaries'
import { UIErrorsBoundary } from './ui-errors-boundaries'

/**
* Add refetch marker to router state at the point of the current layout segment.
Expand Down Expand Up @@ -575,29 +575,24 @@ export default function OuterLayoutRouter({
loadingStyles={loading?.[1]}
loadingScripts={loading?.[2]}
>
<ForbiddenBoundary
uiComponent={forbidden}
uiComponentStyles={forbiddenStyles}
<UIErrorsBoundary
not-found={[notFound, notFoundStyles]}
forbidden={[forbidden, forbiddenStyles]}
>
<NotFoundBoundary
uiComponent={notFound}
uiComponentStyles={notFoundStyles}
>
<RedirectBoundary>
<InnerLayoutRouter
parallelRouterKey={parallelRouterKey}
url={url}
tree={tree}
childNodes={childNodesForParallelRouter!}
segmentPath={segmentPath}
cacheKey={cacheKey}
isActive={
currentChildSegmentValue === preservedSegmentValue
}
/>
</RedirectBoundary>
</NotFoundBoundary>
</ForbiddenBoundary>
<RedirectBoundary>
<InnerLayoutRouter
parallelRouterKey={parallelRouterKey}
url={url}
tree={tree}
childNodes={childNodesForParallelRouter!}
segmentPath={segmentPath}
cacheKey={cacheKey}
isActive={
currentChildSegmentValue === preservedSegmentValue
}
/>
</RedirectBoundary>
</UIErrorsBoundary>
</LoadingBoundary>
</ErrorBoundary>
</ScrollAndFocusHandler>
Expand Down
82 changes: 61 additions & 21 deletions packages/next/src/client/components/ui-error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ import React, { useContext } from 'react'
import { warnOnce } from '../../shared/lib/utils/warn-once'
import { usePathname } from './navigation'
import { MissingSlotContext } from '../../shared/lib/app-router-context.shared-runtime'
import type { UIErrorFileType } from '../../shared/lib/ui-error-types'
import {
uiErrorFileTypes,
type UIErrorFileType,
} from '../../shared/lib/ui-error-types'

interface UIErrorBoundaryProps {
uiComponent?: React.ReactNode
uiComponentStyles?: React.ReactNode
interface UIErrorBoundaryProps
extends Record<
UIErrorFileType,
React.ReactNode | [React.ReactNode, React.ReactNode]
> {
forceTrigger?: boolean
children: React.ReactNode
pathname: string
matcher: (error: unknown) => boolean
matcher: (error: unknown) => UIErrorFileType | undefined
missingSlots?: Set<string>
nextError: UIErrorFileType
}

interface UIErrorBoundaryState {
Expand All @@ -39,11 +43,11 @@ class UIErrorBoundary extends React.Component<
if (
process.env.NODE_ENV === 'development' &&
this.props.missingSlots &&
// A missing children slot is the typical not-found case, so no need to warn
// A missing children slot is the typical ui-error case, so no need to warn
!this.props.missingSlots.has('children')
) {
let warningMessage =
`No default component was found for a parallel route rendered on this page. Falling back to nearest ${this.props.nextError} boundary.
`No default component was found for a parallel route rendered on this page. Falling back to nearest ${this.props.matcher(this.state.error)} boundary.
` +
'Learn more: https://nextjs.org/docs/app/building-your-application/routing/parallel-routes#defaultjs\n\n'

Expand Down Expand Up @@ -87,19 +91,63 @@ class UIErrorBoundary extends React.Component<
}
}

arrayToComponent(componentName: UIErrorFileType): React.ReactNode {
if (Array.isArray(this.props[componentName])) {
return this.props[componentName][0]
}
return this.props[componentName]
}

assertUIComponents() {
uiErrorFileTypes.forEach((type) => {
if (
this.props.matcher(this.state.error) === type &&
!this.arrayToComponent(type)
) {
throw this.state.error
}
})
}

renderUIComponent(componentName: UIErrorFileType | undefined) {
if (
!componentName ||
this.props.matcher(this.state.error) !== componentName
) {
return
}

if (Array.isArray(this.props[componentName])) {
return (
<>
{this.props[componentName][0]}
{this.props[componentName][1]}
</>
)
}

return this.props[componentName]
}

render() {
if (this.state.didCatch) {
if (!this.props.forceTrigger && !this.props.matcher(this.state.error)) {
throw this.state.error
}

this.assertUIComponents()

return (
<>
<meta name="robots" content="noindex" />
{process.env.NODE_ENV === 'development' && (
<meta name="next-error" content={this.props.nextError} />
<meta
name="next-error"
content={this.props.matcher(this.state.error)}
/>
)}
{this.props.uiComponentStyles}
{this.props.uiComponent}

{this.renderUIComponent(this.props.matcher(this.state.error))}
</>
)
}
Expand All @@ -114,22 +162,14 @@ export type UIErrorBoundaryWrapperProps = Omit<
>

export function UIErrorBoundaryWrapper({
uiComponent,
children,
...rest
}: UIErrorBoundaryWrapperProps) {
const pathname = usePathname()
const missingSlots = useContext(MissingSlotContext)
return uiComponent ? (
<UIErrorBoundary
pathname={pathname}
missingSlots={missingSlots}
uiComponent={uiComponent}
{...rest}
>
return (
<UIErrorBoundary pathname={pathname} missingSlots={missingSlots} {...rest}>
{children}
</UIErrorBoundary>
) : (
<>{children}</>
)
}
29 changes: 10 additions & 19 deletions packages/next/src/client/components/ui-errors-boundaries.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
'use client'

import React from 'react'

import {
UIErrorBoundaryWrapper,
type UIErrorBoundaryWrapperProps,
Expand All @@ -11,25 +9,18 @@ import { isNotFoundError } from './not-found'

type BoundaryConsumerProps = Pick<
UIErrorBoundaryWrapperProps,
'uiComponent' | 'uiComponentStyles' | 'children'
'forbidden' | 'not-found' | 'children'
>

export function ForbiddenBoundary(props: BoundaryConsumerProps) {
return (
<UIErrorBoundaryWrapper
nextError={'forbidden'}
matcher={isForbiddenError}
{...props}
/>
)
const matcher = (err: unknown) => {
if (isForbiddenError(err)) {
return 'forbidden'
}
if (isNotFoundError(err)) {
return 'not-found'
}
}

export function NotFoundBoundary(props: BoundaryConsumerProps) {
return (
<UIErrorBoundaryWrapper
nextError={'not-found'}
matcher={isNotFoundError}
{...props}
/>
)
export function UIErrorsBoundary(props: BoundaryConsumerProps) {
return <UIErrorBoundaryWrapper matcher={matcher} {...props} />
}
52 changes: 24 additions & 28 deletions packages/next/src/server/app-render/create-component-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,7 @@ async function createComponentTreeInternal({
renderOpts: { nextConfigOutput, experimental },
staticGenerationStore,
componentMod: {
NotFoundBoundary,
ForbiddenBoundary,
UIErrorsBoundary,
LayoutRouter,
RenderFromTemplateContext,
ClientPageRoot,
Expand Down Expand Up @@ -296,19 +295,34 @@ async function createComponentTreeInternal({
const parallelKeys = Object.keys(parallelRoutes)
const hasSlotKey = parallelKeys.length > 1

// TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`.
// This ensures that a `NotFoundBoundary` is available for when that happens,
// but it's not ideal, as it needlessly invokes the `NotFound` component and renders the `RootLayout` twice.
// TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()` or `forbidden()`.
// This ensures that a `UIErrorsBoundary` is available for when that happens,
// but it's not ideal, as it needlessly invokes the appropriate UIError component and renders the `RootLayout` twice.
// We should instead look into handling the fallback behavior differently in development mode so that it doesn't
// rely on the `NotFound` behavior.
// rely on the `UIErrorsBoundary` behavior.
if (hasSlotKey && rootLayoutAtThisLevel && LayoutOrPage) {
Component = (componentProps: { params: Params }) => {
const NotFoundComponent = NotFound
const ForbiddenComponent = Forbidden
const RootLayoutComponent = LayoutOrPage
return (
<ForbiddenBoundary
uiComponent={
<UIErrorsBoundary
not-found={
NotFoundComponent ? (
<>
{layerAssets}
{/*
* We are intentionally only forwarding params to the root layout, as passing any of the parallel route props
* might trigger `notFound()`, which is not currently supported in the root layout.
*/}
<RootLayoutComponent params={componentProps.params}>
{notFoundStyles}
<NotFoundComponent />
</RootLayoutComponent>
</>
) : undefined
}
forbidden={
ForbiddenComponent ? (
<>
{layerAssets}
Expand All @@ -324,26 +338,8 @@ async function createComponentTreeInternal({
) : undefined
}
>
<NotFoundBoundary
uiComponent={
NotFoundComponent ? (
<>
{layerAssets}
{/*
* We are intentionally only forwarding params to the root layout, as passing any of the parallel route props
* might trigger `notFound()`, which is not currently supported in the root layout.
*/}
<RootLayoutComponent params={componentProps.params}>
{notFoundStyles}
<NotFoundComponent />
</RootLayoutComponent>
</>
) : undefined
}
>
<RootLayoutComponent {...componentProps} />
</NotFoundBoundary>
</ForbiddenBoundary>
<RootLayoutComponent {...componentProps} />
</UIErrorsBoundary>
)
}
}
Expand Down
8 changes: 2 additions & 6 deletions packages/next/src/server/app-render/entry-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,7 @@ import {
} from '../../server/app-render/rsc/preloads'
import { Postpone } from '../../server/app-render/rsc/postpone'
import { taintObjectReference } from '../../server/app-render/rsc/taint'
import {
ForbiddenBoundary,
NotFoundBoundary,
} from '../../client/components/ui-errors-boundaries'
import { UIErrorsBoundary } from '../../client/components/ui-errors-boundaries'

import * as React from 'react'
import {
Expand Down Expand Up @@ -64,8 +61,7 @@ export {
Postpone,
taintObjectReference,
ClientPageRoot,
NotFoundBoundary,
ForbiddenBoundary,
UIErrorsBoundary,
patchFetch,
createCacheScope,
patchCacheScopeSupportIntoReact,
Expand Down
6 changes: 3 additions & 3 deletions test/e2e/app-dir/forbidden-default/forbidden-default.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { nextTestSetup } from 'e2e-utils'
import { check, getRedboxDescription, hasRedbox } from 'next-test-utils'
import { check, getRedboxDescription, assertHasRedbox } from 'next-test-utils'

describe('forbidden-default', () => {
const { next, isNextDev } = nextTestSetup({
Expand All @@ -14,7 +14,7 @@ describe('forbidden-default', () => {

if (isNextDev) {
await check(async () => {
expect(await hasRedbox(browser)).toBe(true)
await assertHasRedbox(browser)
expect(await getRedboxDescription(browser)).toMatch(
/forbidden\(\) is not allowed to use in root layout/
)
Expand All @@ -27,7 +27,7 @@ describe('forbidden-default', () => {
const browser = await next.browser('/?root-forbidden=1')

if (isNextDev) {
expect(await hasRedbox(browser)).toBe(true)
await assertHasRedbox(browser)
expect(await getRedboxDescription(browser)).toBe(
'Error: forbidden() is not allowed to use in root layout'
)
Expand Down

0 comments on commit 16828cf

Please sign in to comment.