Add new permanentRedirect function in App Router (#54047)

for internal:
https://vercel.slack.com/archives/C03S8ED1DKM/p1691700057242999

### Problem

- The existing [`redirect()`
function](https://nextjs.org/docs/app/api-reference/functions/redirect)
can't control the status code.
- The existing [`redirect()`
function](https://nextjs.org/docs/app/api-reference/functions/redirect)
returns a 307 HTTP redirect response while it returns a 308-equivalent
meta tag `<meta http-equiv="refresh" content="0;url=/foo"/>` in
streaming response (e.g., suspense boundary), making the behavior
inconsistent.

### Solution

Adding a new `permanentRedirect()` function and changing the meta tag
default accordingly.

| func   |      HTTP status      |  meta tag |
|---|:---:|---|
| `redirect()` | 307 | `<meta http-equiv="refresh"
content="1;url=/foo"/>` |
| `permanentRedirect()` | 308 | `<meta http-equiv="refresh"
content="0;url=/foo"/>` |

ref.
https://developers.google.com/search/docs/crawling-indexing/301-redirects

---------

Co-authored-by: JJ Kasper <jj@jjsweb.site>
Co-authored-by: Tim Neutkens <tim@timneutkens.nl>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Shohei Maeda 2023-08-29 06:22:43 +09:00 committed by GitHub
parent bdfbde5db8
commit efd8d22654
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 166 additions and 12 deletions

View file

@ -0,0 +1,58 @@
---
title: permanentRedirect
description: API Reference for the permanentRedirect function.
---
The `permanentRedirect` function allows you to redirect the user to another URL. `permanentRedirect` can be used in Server Components, Client Components, [Route Handlers](/docs/app/building-your-application/routing/route-handlers), and [Server Actions](/docs/app/building-your-application/data-fetching/forms-and-mutations).
When used in a streaming context, this will insert a meta tag to emit the redirect on the client side. Otherwise, it will serve a 308 (Permanent) HTTP redirect response to the caller.
If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api-reference/functions/not-found) instead.
> **Good to know**: If you prefer to return a 307 (Temporary) HTTP redirect instead of 308 (Permanent), you can use the [`redirect` function](/docs/app/api-reference/functions/redirect) instead.
## Parameters
The `permanentRedirect` function accepts two arguments:
```js
permanentRedirect(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. |
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/forms-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.
The `type` parameter has no effect when used in Server Components.
## Returns
`permanentRedirect` does not return any value.
## Example
Invoking the `permanentRedirect()` function throws a `NEXT_REDIRECT` error and terminates rendering of the route segment in which it was thrown.
```jsx filename="app/team/[id]/page.js"
import { permanentRedirect } from 'next/navigation'
async function fetchTeam(id) {
const res = await fetch('https://...')
if (!res.ok) return undefined
return res.json()
}
export default async function Profile({ params }) {
const team = await fetchTeam(params.id)
if (!team) {
permanentRedirect('/login')
}
// ...
}
```
> **Good to know**: `permanentRedirect` does not require you to use `return permanentRedirect()` as it uses the TypeScript [`never`](https://www.typescriptlang.org/docs/handbook/2/functions.html#never) type.

View file

@ -5,8 +5,12 @@ description: API Reference for the redirect function.
The `redirect` function allows you to redirect the user to another URL. `redirect` can be used in Server Components, Client Components, [Route Handlers](/docs/app/building-your-application/routing/route-handlers), and [Server Actions](/docs/app/building-your-application/data-fetching/forms-and-mutations).
When used in a streaming context, this will insert a meta tag to emit the redirect on the client side. Otherwise, it will serve a 307 HTTP redirect response to the caller.
If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api-reference/functions/not-found) instead.
> **Good to know**: If you prefer to return a 308 (Permanent) HTTP redirect instead of 307 (Temporary), you can use the [`permanentRedirect` function](/docs/app/api-reference/functions/permanentRedirect) instead.
## Parameters
The `redirect` function accepts two arguments:

View file

@ -0,0 +1,11 @@
import { type RedirectError, isRedirectError } from './redirect'
export function getRedirectStatusCodeFromError<U extends string>(
error: RedirectError<U>
): number {
if (!isRedirectError(error)) {
throw new Error('Not a redirect error')
}
return error.digest.split(';', 4)[3] === 'true' ? 308 : 307
}

View file

@ -238,5 +238,5 @@ export function useSelectedLayoutSegment(
return selectedLayoutSegments[0]
}
export { redirect } from './redirect'
export { redirect, permanentRedirect } from './redirect'
export { notFound } from './not-found'

View file

@ -8,17 +8,18 @@ export enum RedirectType {
replace = 'replace',
}
type RedirectError<U extends string> = Error & {
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U}`
export type RedirectError<U extends string> = Error & {
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U};${boolean}`
mutableCookies: ResponseCookies
}
export function getRedirectError(
url: string,
type: RedirectType
type: RedirectType,
permanent: boolean = false
): RedirectError<typeof url> {
const error = new Error(REDIRECT_ERROR_CODE) as RedirectError<typeof url>
error.digest = `${REDIRECT_ERROR_CODE};${type};${url}`
error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${permanent}`
const requestStore = requestAsyncStorage.getStore()
if (requestStore) {
error.mutableCookies = requestStore.mutableCookies
@ -27,9 +28,9 @@ export function getRedirectError(
}
/**
* When used in a React server component, this will insert a meta tag to
* When used in a streaming context, this will insert a meta tag to
* redirect the user to the target page. When used in a custom app route, it
* will serve a 302 to the caller.
* will serve a 307 to the caller.
*
* @param url the url to redirect to
*/
@ -37,7 +38,21 @@ export function redirect(
url: string,
type: RedirectType = RedirectType.replace
): never {
throw getRedirectError(url, type)
throw getRedirectError(url, type, false)
}
/**
* When used in a streaming context, this will insert a meta tag to
* redirect the user to the target page. When used in a custom app route, it
* will serve a 308 to the caller.
*
* @param url the url to redirect to
*/
export function permanentRedirect(
url: string,
type: RedirectType = RedirectType.replace
): never {
throw getRedirectError(url, type, true)
}
/**
@ -52,12 +67,15 @@ export function isRedirectError<U extends string>(
): error is RedirectError<U> {
if (typeof error?.digest !== 'string') return false
const [errorCode, type, destination] = (error.digest as string).split(';', 3)
const [errorCode, type, destination, permanent] = (
error.digest as string
).split(';', 4)
return (
errorCode === REDIRECT_ERROR_CODE &&
(type === 'replace' || type === 'push') &&
typeof destination === 'string'
typeof destination === 'string' &&
(permanent === 'true' || permanent === 'false')
)
}

View file

@ -49,6 +49,7 @@ import {
getURLFromRedirectError,
isRedirectError,
} from '../../client/components/redirect'
import { getRedirectStatusCodeFromError } from '../../client/components/get-redirect-status-code-from-error'
import { addImplicitTags, patchFetch } from '../lib/patch-fetch'
import { AppRenderSpan } from '../lib/trace/constants'
import { getTracer } from '../lib/trace/tracer'
@ -1478,11 +1479,13 @@ export async function renderToHTMLOrFlight(
)
} else if (isRedirectError(error)) {
const redirectUrl = getURLFromRedirectError(error)
const isPermanent =
getRedirectStatusCodeFromError(error) === 308 ? true : false
if (redirectUrl) {
errorMetaTags.push(
<meta
httpEquiv="refresh"
content={`0;url=${redirectUrl}`}
content={`${isPermanent ? 0 : 1};url=${redirectUrl}`}
key={error.digest}
/>
)
@ -1562,7 +1565,7 @@ export async function renderToHTMLOrFlight(
let hasRedirectError = false
if (isRedirectError(err)) {
hasRedirectError = true
res.statusCode = 307
res.statusCode = getRedirectStatusCodeFromError(err)
if (err.mutableCookies) {
const headers = new Headers()

View file

@ -0,0 +1,6 @@
import { permanentRedirect } from 'next/navigation'
export default function Page() {
permanentRedirect('/redirect/result')
return <></>
}

View file

@ -0,0 +1,41 @@
import { Suspense } from 'react'
import { permanentRedirect } from 'next/navigation'
function createSuspenseyComponent(Component, { timeout = 0, expire = 10 }) {
let result
let promise
return function Data() {
if (result) return result
if (!promise)
promise = new Promise((resolve) => {
setTimeout(() => {
result = <Component />
setTimeout(() => {
result = undefined
promise = undefined
}, expire)
resolve()
}, timeout)
})
throw promise
}
}
function Redirect() {
permanentRedirect('/redirect/result')
return <></>
}
const SuspenseyRedirect = createSuspenseyComponent(Redirect, {
timeout: 300,
})
export default function () {
return (
<div className="suspense">
<Suspense fallback="fallback">
<SuspenseyRedirect />
</Suspense>
</div>
)
}

View file

@ -421,6 +421,12 @@ createNextDescribe(
})
expect(res.status).toBe(307)
})
it('should respond with 308 status code if permanent flag is set', async () => {
const res = await next.fetch('/redirect/servercomponent-2', {
redirect: 'manual',
})
expect(res.status).toBe(308)
})
})
})
@ -551,6 +557,13 @@ createNextDescribe(
it('should emit refresh meta tag for redirect page when streaming', async () => {
const html = await next.render('/redirect/suspense')
expect(html).toContain(
'<meta http-equiv="refresh" content="1;url=/redirect/result"/>'
)
})
it('should emit refresh meta tag (peramnent) for redirect page when streaming', async () => {
const html = await next.render('/redirect/suspense-2')
expect(html).toContain(
'<meta http-equiv="refresh" content="0;url=/redirect/result"/>'
)