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

support cause in Next.js error signals #73665

Draft
wants to merge 1 commit into
base: canary
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 39 additions & 0 deletions docs/01-app/03-api-reference/04-functions/forbidden.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,45 @@ export default async function AdminPage() {
}
```

You can also pass an error cause to `forbidden` to handle different types of errors before escalating to the Next.js' error page. For example, you can call `forbidden` with an error cause associated with a session expiration error and redirect the user to the login page.

```tsx filename="app/lib/dal.ts"
import { forbidden } from 'next/navigation'
import { cookies } from 'next/headers'

export async function verifySession() {
const cookieStore = await cookies()
const token = cookieStore.get('token')

if (!token) {
forbidden(new AuthError('No token found'))
}

if (isExpired(token.value)) {
forbidden(new SessionExpiredError('Session expired'))
}
}
```

```tsx filename="app/auth/page.tsx"
import { InvalidPermissionError, isExpired } from '@/app/lib/dal'

export default async function AdminPage() {
try {
await verifySession()
} catch (err) {
if (err instanceof Error && err.cause instanceof SessionExpiredError) {
redirect('/login')
}

throw err
}

// Render the admin page for authorized users
return <></>
}
```

## Good to know

- The `forbidden` function cannot be called in the [root layout](/docs/app/building-your-application/routing/layouts-and-templates#root-layout-required).
Expand Down
37 changes: 36 additions & 1 deletion docs/01-app/03-api-reference/04-functions/not-found.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ async function fetchUser(id) {
}

export default async function Profile({ params }) {
const user = await fetchUser(params.id)
const id = await params.id
const user = await fetchUser(id)

if (!user) {
notFound()
Expand All @@ -29,6 +30,40 @@ export default async function Profile({ params }) {
}
```

You can also pass an error cause to `notFound` to handle different types of errors before escalating to the Next.js' error page. For example, you can call `notFound` with a path where the resource was relocated to, which can be selectively handled when using this function, while still falling back to a 404 page if unhandled.

```tsx filename="app/auth/page.js"
import { ResourceRelocatedError } from '@/app/lib/dal'
import { notFound } from 'next/navigation'

async function fetchArticle(slug) {
const res = await fetch('https://...')

if (res.status === 301) {
notFound(new ResourceRelocatedError('/new-url'))
}

return res.json()
}

export default function Page({ params }) {
let article

try {
const slug = await params.slug
article = await fetchArticle(slug)
} catch (err) {
if (err.cause instanceof ResourceRelocatedError) {
redirect(err.cause.redirectTo)
}

throw err
}

// ...
}
```

> **Good to know**: `notFound()` does not require you to use `return notFound()` due to using the TypeScript [`never`](https://www.typescriptlang.org/docs/handbook/2/functions.html#never) type.

## Version History
Expand Down
13 changes: 7 additions & 6 deletions docs/01-app/03-api-reference/04-functions/permanentRedirect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,17 @@ If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api

## Parameters

The `permanentRedirect` function accepts two arguments:
The `permanentRedirect` function accepts three arguments:

```js
permanentRedirect(path, type)
permanentRedirect(path, type, cause)
```

| Parameter | Type | Description |
| --------- | ------------------------------------------------------------- | ----------------------------------------------------------- |
| `path` | `string` | The URL to redirect to. Can be a relative or absolute path. |
| `type` | `'replace'` (default) or `'push'` (default in Server Actions) | The type of redirect to perform. |
| Parameter | Type | Description |
| --------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `path` | `string` | The URL to redirect to. Can be a relative or absolute path. |
| `type` | `'replace'` (default) or `'push'` (default in Server Actions) | The type of redirect to perform. |
| `cause` | `Error` | An optional error that will be attached to the `cause` property of the error thrown by Next.js. |

By default, `permanentRedirect` will use `push` (adding a new entry to the browser history stack) in [Server Actions](/docs/app/building-your-application/data-fetching/server-actions-and-mutations) and `replace` (replacing the current URL in the browser history stack) everywhere else. You can override this behavior by specifying the `type` parameter.

Expand Down
11 changes: 6 additions & 5 deletions docs/01-app/03-api-reference/04-functions/redirect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api

## Parameters

The `redirect` function accepts two arguments:
The `redirect` function accepts three arguments:

```js
redirect(path, type)
```

| Parameter | Type | Description |
| --------- | ------------------------------------------------------------- | ----------------------------------------------------------- |
| `path` | `string` | The URL to redirect to. Can be a relative or absolute path. |
| `type` | `'replace'` (default) or `'push'` (default in Server Actions) | The type of redirect to perform. |
| Parameter | Type | Description |
| --------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `path` | `string` | The URL to redirect to. Can be a relative or absolute path. |
| `type` | `'replace'` (default) or `'push'` (default in Server Actions) | The type of redirect to perform. |
| `cause` | `Error` | An optional error that will be attached to the `cause` property of the error thrown by Next.js. |

By default, `redirect` will use `push` (adding a new entry to the browser history stack) in [Server Actions](/docs/app/building-your-application/data-fetching/server-actions-and-mutations) and `replace` (replacing the current URL in the browser history stack) everywhere else. You can override this behavior by specifying the `type` parameter.

Expand Down
43 changes: 43 additions & 0 deletions docs/01-app/03-api-reference/04-functions/unauthorized.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,49 @@ export default async function DashboardPage() {
}
```

You can also pass an error cause to `forbidden` to handle different types of errors before escalating to the Next.js' error page. For example, you can call `unauthorized` with an error cause associated with a session expiration error and redirect the user to the login page.

```tsx filename="app/lib/dal.ts"
import { unauthorized } from 'next/navigation'
import { cookies } from 'next/headers'

export async function verifySession() {
const cookieStore = await cookies()
const token = cookieStore.get('token')

if (!token) {
unauthorized(new AuthError('No token found'))
}

if (isExpired(token.value)) {
unauthorized(new SessionExpiredError('Session expired'))
}
}
```

```tsx filename="app/auth/page.tsx"
import { SessionExpiredError, verifySession } from '@/app/lib/dal'

export default async function AdminPage() {
try {
await verifySession()
} catch (err) {
if (err instanceof Error && err.cause instanceof SessionExpiredError) {
redirect('/login')
}

throw err
}

// Render the dashboard for authenticated users
return (
<main>
<h1>Welcome to the Dashboard</h1>
</main>
)
}
```

## Good to know

- The `unauthorized` function cannot be called in the [root layout](/docs/app/building-your-application/routing/layouts-and-templates#root-layout-required).
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/client/components/forbidden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ import {

const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};403`

export function forbidden(): never {
export function forbidden(cause?: Error): never {
if (!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS) {
throw new Error(
`\`forbidden()\` is experimental and only allowed to be enabled when \`experimental.authInterrupts\` is enabled.`
)
}

// eslint-disable-next-line no-throw-literal
const error = new Error(DIGEST) as HTTPAccessFallbackError
const error = new Error(DIGEST, { cause }) as HTTPAccessFallbackError
;(error as HTTPAccessFallbackError).digest = DIGEST
throw error
}
4 changes: 2 additions & 2 deletions packages/next/src/client/components/not-found.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import {

const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};404`

export function notFound(): never {
export function notFound(cause?: Error): never {
// eslint-disable-next-line no-throw-literal
const error = new Error(DIGEST) as HTTPAccessFallbackError
const error = new Error(DIGEST, { cause }) as HTTPAccessFallbackError
;(error as HTTPAccessFallbackError).digest = DIGEST

throw error
Expand Down
16 changes: 10 additions & 6 deletions packages/next/src/client/components/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
export function getRedirectError(
url: string,
type: RedirectType,
statusCode: RedirectStatusCode = RedirectStatusCode.TemporaryRedirect
statusCode: RedirectStatusCode = RedirectStatusCode.TemporaryRedirect,
cause?: Error
): RedirectError {
const error = new Error(REDIRECT_ERROR_CODE) as RedirectError
const error = new Error(REDIRECT_ERROR_CODE, { cause }) as RedirectError
error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${statusCode};`
return error
}
Expand All @@ -32,15 +33,17 @@ export function getRedirectError(
export function redirect(
/** The URL to redirect to */
url: string,
type?: RedirectType
type?: RedirectType,
cause?: Error
): never {
const actionStore = actionAsyncStorage.getStore()
const redirectType =
type || (actionStore?.isAction ? RedirectType.push : RedirectType.replace)
throw getRedirectError(
url,
redirectType,
RedirectStatusCode.TemporaryRedirect
RedirectStatusCode.TemporaryRedirect,
cause
)
}

Expand All @@ -58,9 +61,10 @@ export function redirect(
export function permanentRedirect(
/** The URL to redirect to */
url: string,
type: RedirectType = RedirectType.replace
type: RedirectType = RedirectType.replace,
cause?: Error
): never {
throw getRedirectError(url, type, RedirectStatusCode.PermanentRedirect)
throw getRedirectError(url, type, RedirectStatusCode.PermanentRedirect, cause)
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/client/components/unauthorized.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ import {

const DIGEST = `${HTTP_ERROR_FALLBACK_ERROR_CODE};401`

export function unauthorized(): never {
export function unauthorized(cause?: Error): never {
if (!process.env.__NEXT_EXPERIMENTAL_AUTH_INTERRUPTS) {
throw new Error(
`\`unauthorized()\` is experimental and only allowed to be used when \`experimental.authInterrupts\` is enabled.`
)
}

// eslint-disable-next-line no-throw-literal
const error = new Error(DIGEST) as HTTPAccessFallbackError
const error = new Error(DIGEST, { cause }) as HTTPAccessFallbackError
;(error as HTTPAccessFallbackError).digest = DIGEST
throw error
}
16 changes: 16 additions & 0 deletions test/e2e/app-dir/forbidden/basic/app/cause/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { redirect } from 'next/navigation'
import { SessionExpiredError, verifyAuth } from './utils'

export default async function Page() {
try {
await verifyAuth()
} catch (err) {
if (err.cause instanceof SessionExpiredError) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work when there's multiple nested cause correct?

redirect('/')
}

throw err
}

return <p id="page">Cause Page</p>
}
29 changes: 29 additions & 0 deletions test/e2e/app-dir/forbidden/basic/app/cause/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { cookies } from 'next/headers'
import { forbidden } from 'next/navigation'

export class AuthError extends Error {
constructor(message) {
super(message)
this.name = 'AuthError'
}
}

export class SessionExpiredError extends Error {
constructor(message) {
super(message)
this.name = 'AuthError'
}
}

export async function verifyAuth() {
const cookieStore = await cookies()
const token = cookieStore.get('token')

if (!token) {
forbidden(new AuthError('No token found'))
}

if (token.value === 'expired') {
forbidden(new SessionExpiredError('Session expired'))
}
}
19 changes: 19 additions & 0 deletions test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'

describe('app dir - forbidden with customized boundary', () => {
const { next } = nextTestSetup({
Expand Down Expand Up @@ -38,4 +39,22 @@ describe('app dir - forbidden with customized boundary', () => {
'Root Forbidden'
)
})

it('should support a custom error cause', async () => {
const browser = await next.browser('/cause')

// we don't have explicit handling for a missing token, but we still triggered `forbidden`
expect(await browser.elementByCss('h1').text()).toBe('Root Forbidden')

// Set a token
await browser.eval("document.cookie='token=expired'")
await browser.refresh()

// expired token, and the user decided that SessionExpiredError should be handled by redirecting to root
retry(async () => {
expect(await browser.url()).toBe(next.url)
})

expect(await browser.elementByCss('h1').text()).toBe('My page')
})
})
16 changes: 16 additions & 0 deletions test/e2e/app-dir/unauthorized/basic/app/cause/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { redirect } from 'next/navigation'
import { SessionExpiredError, verifyAuth } from './utils'

export default async function Page() {
try {
await verifyAuth()
} catch (err) {
if (err.cause instanceof SessionExpiredError) {
redirect('/')
}

throw err
}

return <p id="page">Cause Page</p>
}
Loading
Loading