Skip to content

Commit

Permalink
support cause in Next.js error signals
Browse files Browse the repository at this point in the history
  • Loading branch information
ztanner committed Dec 8, 2024
1 parent c291ff6 commit ca8c5e7
Show file tree
Hide file tree
Showing 15 changed files with 275 additions and 24 deletions.
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) {
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

0 comments on commit ca8c5e7

Please sign in to comment.