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

Introduce Forbidden and the UI Error abstraction #65993

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4e63e4c
feat: Introduce `forbidden()`
panteliselef Mar 13, 2024
36131d2
chore: cleanup
panteliselef Mar 13, 2024
868a047
feat: Support `forbidden` in server actions
panteliselef Mar 13, 2024
c08d7d0
feat: Default forbidden page
panteliselef Mar 13, 2024
0df7820
test: Add `forbidden-default` e2e test
panteliselef Mar 13, 2024
e44fb3a
chore: Add more descriptive comments
panteliselef Mar 14, 2024
154499a
chore: UI error boundary
panteliselef May 8, 2024
9ac429d
chore: Eliminate code duplication
panteliselef May 8, 2024
584f658
chore: Build correct paths for ui errors in segment paths
panteliselef May 9, 2024
e4432b8
tests: Update test cases
panteliselef May 9, 2024
43b488e
chore: Remove code duplication for actions
panteliselef May 9, 2024
1632366
chore: Remove code duplication for api routes
panteliselef May 9, 2024
56d903c
type: Remove code duplication for api routes
panteliselef May 9, 2024
db3544e
chore: Remove code duplication for app-render
panteliselef May 9, 2024
150c312
chore: Infer types as much as possible across modules
panteliselef May 17, 2024
4977d35
chore: Avoid formatting in base-server.ts
panteliselef May 20, 2024
37376b0
chore: Renaming files and variables
panteliselef May 20, 2024
c3baf8b
chore: Create helper function for ui errors
panteliselef May 20, 2024
90b36b5
Merge branch 'refs/heads/canary' into elef/introduce-error-builder
panteliselef Jun 9, 2024
d480602
fix: Incorrect import path after resolving conflicts
panteliselef Jun 9, 2024
63c397c
Merge branch 'canary' into elef/introduce-error-builder
ijjk Jun 10, 2024
b7854dd
fix: Leftover attribute after merge commit
panteliselef Jun 10, 2024
af0155f
Merge branch 'canary' into elef/introduce-error-builder
ijjk Jun 10, 2024
ebadaa5
fix: Leftover unused component
panteliselef Jun 10, 2024
858644b
fix: Resolve conflicts
panteliselef Jun 19, 2024
32c44c7
feat: Update next-swc to support forbidden
panteliselef Jun 19, 2024
6fce298
Merge branch 'canary' into elef/introduce-error-builder
panteliselef Jun 19, 2024
78892f0
Merge branch 'refs/heads/canary' into elef/introduce-error-builder
panteliselef Jul 26, 2024
0b8a76f
chore: Create a single error boundary that catches any ui error
panteliselef Jul 27, 2024
f8ff45c
Merge branch 'refs/heads/canary' into elef/introduce-error-builder
panteliselef Jul 28, 2024
54275a3
test: Add `forbidden` e2e test
panteliselef Mar 13, 2024
55b1e60
Create more tests
panteliselef Jul 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/napi/src/app_structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ struct ComponentsForJs {
#[serde(skip_serializing_if = "Option::is_none", rename = "not-found")]
not_found: Option<RcStr>,
#[serde(skip_serializing_if = "Option::is_none")]
forbidden: Option<RcStr>,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<RcStr>,
#[serde(skip_serializing_if = "Option::is_none")]
route: Option<RcStr>,
Expand Down Expand Up @@ -157,6 +159,7 @@ async fn prepare_components_for_js(
loading,
template,
not_found,
forbidden,
default,
route,
metadata,
Expand All @@ -178,6 +181,7 @@ async fn prepare_components_for_js(
add(&mut result.loading, project_path, loading).await?;
add(&mut result.template, project_path, template).await?;
add(&mut result.not_found, project_path, not_found).await?;
add(&mut result.forbidden, project_path, forbidden).await?;
add(&mut result.default, project_path, default).await?;
add(&mut result.route, project_path, route).await?;

Expand Down
10 changes: 10 additions & 0 deletions crates/next-core/src/app_structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ pub struct Components {
#[serde(skip_serializing_if = "Option::is_none")]
pub not_found: Option<Vc<FileSystemPath>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub forbidden: Option<Vc<FileSystemPath>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Vc<FileSystemPath>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub route: Option<Vc<FileSystemPath>>,
Expand All @@ -69,6 +71,7 @@ impl Components {
loading: self.loading,
template: self.template,
not_found: self.not_found,
forbidden: self.forbidden,
default: None,
route: None,
metadata: self.metadata.clone(),
Expand Down Expand Up @@ -342,6 +345,7 @@ async fn get_directory_tree_internal(
"loading" => components.loading = Some(file),
"template" => components.template = Some(file),
"not-found" => components.not_found = Some(file),
"forbidden" => components.forbidden = Some(file),
"default" => components.default = Some(file),
"route" => components.route = Some(file),
_ => {}
Expand Down Expand Up @@ -854,6 +858,12 @@ async fn directory_tree_to_loader_tree(
);
}

if (is_root_directory || is_root_layout) && components.forbidden.is_none() {
components.forbidden = Some(
get_next_package(app_dir).join("dist/client/components/forbidden-error.js".into()),
);
}

let mut tree = LoaderTree {
page: app_page.clone(),
segment: directory_name.clone(),
Expand Down
5 changes: 5 additions & 0 deletions crates/next-core/src/loader_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ enum ComponentType {
Loading,
Template,
NotFound,
Forbidden,
}

impl ComponentType {
Expand All @@ -65,6 +66,7 @@ impl ComponentType {
ComponentType::Loading => "loading",
ComponentType::Template => "template",
ComponentType::NotFound => "not-found",
ComponentType::Forbidden => "forbidden",
}
}
}
Expand Down Expand Up @@ -380,6 +382,7 @@ impl LoaderTreeBuilder {
loading,
template,
not_found,
forbidden,
metadata,
route: _,
} = &*components.await?;
Expand All @@ -394,6 +397,8 @@ impl LoaderTreeBuilder {
.await?;
self.write_component(ComponentType::NotFound, *not_found)
.await?;
self.write_component(ComponentType::Forbidden, *forbidden)
.await?;
let components_code = replace(&mut self.loader_tree_code, temp_loader_tree_code);

// add parallel_routes
Expand Down
68 changes: 56 additions & 12 deletions packages/next/src/build/webpack/loaders/next-app-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,32 @@ export type AppLoaderOptions = {
}
type AppLoader = webpack.LoaderDefinitionFunction<AppLoaderOptions>

const UI_FILE_TYPES = {
'not-found': 'not-found',
forbidden: 'forbidden',
} as const

type UIFileType = keyof typeof UI_FILE_TYPES

const UI_FILE_TYPES_AS_VALUES = Object.keys(UI_FILE_TYPES) as UIFileType[]

const FILE_TYPES = {
layout: 'layout',
template: 'template',
error: 'error',
loading: 'loading',
'not-found': 'not-found',
...UI_FILE_TYPES,
} as const

const GLOBAL_ERROR_FILE_TYPE = 'global-error'
const PAGE_SEGMENT = 'page$'
const PARALLEL_CHILDREN_SEGMENT = 'children$'

const defaultNotFoundPath = 'next/dist/client/components/not-found-error'
const defaultUIErrorPaths: Record<UIFileType, string> = {
'not-found': 'next/dist/client/components/not-found-error',
forbidden: 'next/dist/client/components/forbidden-error',
}

const defaultGlobalErrorPath = 'next/dist/client/components/error-boundary'
const defaultLayoutPath = 'next/dist/client/components/default-layout'

Expand Down Expand Up @@ -204,9 +217,13 @@ async function createTreeCodeFromPath(

const isDefaultNotFound = isAppBuiltinNotFoundPage(pagePath)
const appDirPrefix = isDefaultNotFound ? APP_DIR_ALIAS : splittedPath[0]
const hasRootNotFound = await resolver(
`${appDirPrefix}/${FILE_TYPES['not-found']}`

const uiErrorPaths = await Promise.all(
UI_FILE_TYPES_AS_VALUES.map((fileType) =>
resolver(`${appDirPrefix}/${UI_FILE_TYPES[fileType]}`)
)
)

const pages: string[] = []

let rootLayout: string | undefined
Expand Down Expand Up @@ -364,18 +381,45 @@ async function createTreeCodeFromPath(
return false
}) as [ValueOf<typeof FILE_TYPES>, string][]

// Add default not found error as root not found if not present
const hasNotFoundFile = definedFilePaths.some(
([type]) => type === 'not-found'
// Mark used ui error files by the route segment
function createFileTypeCounters(
paths: typeof filePaths,
types: UIFileType[]
) {
const dictionary = new Map<string, number>()
for (const [type] of paths) {
const item = dictionary.get(type)
if (item) {
dictionary.set(type, item + 1)
} else {
dictionary.set(type, 1)
}
}

return types.map((t) => (dictionary.get(t) || 0) >= 1)
}

// Check if ui error files exist for this segment path
const fileTypeCounters = createFileTypeCounters(
definedFilePaths,
UI_FILE_TYPES_AS_VALUES
)

// If the first layer is a group route, we treat it as root layer
const isFirstLayerGroupRoute =
segments.length === 1 &&
subSegmentPath.filter((seg) => isGroupSegment(seg)).length === 1
if ((isRootLayer || isFirstLayerGroupRoute) && !hasNotFoundFile) {
// If you already have a root not found, don't insert default not-found to group routes root
if (!(hasRootNotFound && isFirstLayerGroupRoute)) {
definedFilePaths.push(['not-found', defaultNotFoundPath])

for (let i = 0; i < UI_FILE_TYPES_AS_VALUES.length; i++) {
const fileType = UI_FILE_TYPES_AS_VALUES[i]
const hasFileType = fileTypeCounters[i]
const hasRootFileType = uiErrorPaths[i]

if ((isRootLayer || isFirstLayerGroupRoute) && !hasFileType) {
// If you already have a root file, don't insert default file to group routes root
if (!(hasRootFileType && isFirstLayerGroupRoute)) {
definedFilePaths.push([fileType, defaultUIErrorPaths[fileType]])
}
}
}

Expand Down Expand Up @@ -420,7 +464,7 @@ async function createTreeCodeFromPath(
if (isNotFoundRoute && normalizedParallelKey === 'children') {
const notFoundPath =
definedFilePaths.find(([type]) => type === 'not-found')?.[1] ??
defaultNotFoundPath
defaultUIErrorPaths['not-found']

const varName = `notFound${nestedCollectedDeclarations.length}`
nestedCollectedDeclarations.push([varName, notFoundPath])
Expand Down
6 changes: 3 additions & 3 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -561,9 +561,9 @@ function Router({

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 DevRootUIErrorsBoundary: typeof import('./dev-root-ui-error-boundary').DevRootUIErrorsBoundary =
require('./dev-root-ui-error-boundary').DevRootUIErrorsBoundary
content = <DevRootUIErrorsBoundary>{content}</DevRootUIErrorsBoundary>
}
const HotReloader: typeof import('./react-dev-overlay/app/hot-reloader-client').default =
require('./react-dev-overlay/app/hot-reloader-client').default
Expand Down

This file was deleted.

34 changes: 34 additions & 0 deletions packages/next/src/client/components/dev-root-ui-error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client'

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

export function bailOnUIError(uiError: UIErrorHelper) {
throw new Error(`${uiError}() is not allowed to use in root layout`)
}

function NotAllowedRootNotFoundError() {
bailOnUIError('notFound')
return null
}

function NotAllowedRootForbiddenError() {
bailOnUIError('forbidden')
return null
}

export function DevRootUIErrorsBoundary({
children,
}: {
children: React.ReactNode
}) {
return (
<UIErrorsBoundary
not-found={<NotAllowedRootNotFoundError />}
forbidden={<NotAllowedRootForbiddenError />}
>
{children}
</UIErrorsBoundary>
)
}
11 changes: 11 additions & 0 deletions packages/next/src/client/components/forbidden-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { UIErrorTemplate } from './ui-error-template'

export default function Forbidden() {
return (
<UIErrorTemplate
pageTitle="403: This page is forbidden."
title="403"
subtitle="This page is forbidden."
/>
)
}
33 changes: 33 additions & 0 deletions packages/next/src/client/components/forbidden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createUIError } from './ui-error-builder'

const { thrower, matcher } = createUIError('NEXT_FORBIDDEN')

// TODO(@panteliselef): Update docs
/**
* This function allows you to render the [forbidden.js file]
* within a route segment as well as inject a tag.
*
* `forbidden()` can be used in
* [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components),
* [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and
* [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations).
*
* - In a Server Component, this will insert a `<meta name="robots" content="noindex" />` meta tag and set the status code to 403.
* - In a Route Handler or Server Action, it will serve a 403 to the caller.
*
* // TODO(@panteliselef): Update docs
* Read more: [Next.js Docs: `forbidden`](https://nextjs.org/docs/app/api-reference/functions/not-found)
*/
const forbidden = thrower

// TODO(@panteliselef): Update docs
/**
* Checks an error to determine if it's an error generated by the `forbidden()`
* helper.
*
* @param error the error that may reference a forbidden error
* @returns true if the error is a forbidden error
*/
const isForbiddenError = matcher

export { forbidden, isForbiddenError }
6 changes: 4 additions & 2 deletions packages/next/src/client/components/is-next-router-error.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { isNotFoundError } from './not-found'
import { isRedirectError } from './redirect'
import { matchUIError } from '../../shared/lib/ui-error-types'

/**
* Returns true if the error is a navigation signal error. These errors are
* thrown by user code to perform navigation operations and interrupt the React
* render.
*/
export function isNextRouterError(error: any): boolean {
return isRedirectError(error) || isNotFoundError(error)
return (
error && error.digest && (isRedirectError(error) || !!matchUIError(error))
)
}
Loading