Skip to content

Commit e6d943c

Browse files
committed
wip
1 parent 5a0baa4 commit e6d943c

File tree

17 files changed

+248
-7
lines changed

17 files changed

+248
-7
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,5 +698,6 @@
698698
"697": "Next DevTools: Can't render in this environment. This is a bug in Next.js",
699699
"698": "Next DevTools: App Dev Overlay is already mounted. This is a bug in Next.js",
700700
"699": "Next DevTools: Pages Dev Overlay is already mounted. This is a bug in Next.js",
701-
"700": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload."
701+
"700": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload.",
702+
"701": "Next.js RSC redirect protocol error: Missing Location header. This may indicate either a Next.js bug or that a third-party proxy is not correctly implementing the RSC redirect protocol."
702703
}

packages/next/src/client/components/router-reducer/fetch-server-response.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
NEXT_DID_POSTPONE_HEADER,
2929
NEXT_ROUTER_STALE_TIME_HEADER,
3030
} from '../app-router-headers'
31+
import { RSC_REDIRECT_STATUS_CODE } from '../../../shared/lib/constants'
3132
import { callServer } from '../../app-call-server'
3233
import { findSourceMapURL } from '../../app-find-source-map-url'
3334
import { PrefetchKind } from './router-reducer-types'
@@ -328,9 +329,6 @@ export async function createFetch(
328329
// again, but this time we'll append the cache busting search param to prevent
329330
// a mismatch.
330331
//
331-
// TODO: We can optimize Next.js's built-in middleware APIs by returning a
332-
// custom status code, to prevent the browser from automatically following it.
333-
//
334332
// This does not affect Server Action-based redirects; those are encoded
335333
// differently, as part of the Flight body. It only affects redirects that
336334
// occur in a middleware or a third-party proxy.
@@ -340,6 +338,25 @@ export async function createFetch(
340338
// This is to prevent a redirect loop. Same limit used by Chrome.
341339
const MAX_REDIRECTS = 20
342340
for (let n = 0; n < MAX_REDIRECTS; n++) {
341+
// This is a custom status code used to handle RSC redirects.
342+
// We use a custom status code to prevent the browser from automatically
343+
// following the redirect, which would require an additional round trip.
344+
// Instead, we can handle it on the client and immediately send a new RSC
345+
// request to the new location.
346+
if (browserResponse.status === RSC_REDIRECT_STATUS_CODE) {
347+
const location = browserResponse.headers.get('Location')
348+
if (!location) {
349+
throw new Error(
350+
'Next.js RSC redirect protocol error: Missing Location header. This may indicate either a Next.js bug or that a third-party proxy is not correctly implementing the RSC redirect protocol.'
351+
)
352+
}
353+
fetchUrl = new URL(location, fetchUrl)
354+
setCacheBustingSearchParam(fetchUrl, headers)
355+
browserResponse = await fetch(fetchUrl, fetchOptions)
356+
redirected = true
357+
continue
358+
}
359+
343360
if (!browserResponse.redirected) {
344361
// The server did not perform a redirect.
345362
break

packages/next/src/client/components/router-reducer/set-cache-busting-search-param.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,13 @@ export const setCacheBustingSearchParam = (
5757
const rawQuery = existingSearch.startsWith('?')
5858
? existingSearch.slice(1)
5959
: existingSearch
60-
const pairs = rawQuery.split('&').filter(Boolean)
60+
61+
// Always remove any existing cache busting param and add a fresh one to ensure
62+
// we have the correct value based on current request headers
63+
const pairs = rawQuery
64+
.split('&')
65+
.filter(Boolean)
66+
.filter((pair) => !pair.startsWith(`${NEXT_RSC_UNION_QUERY}=`))
6167
pairs.push(`${NEXT_RSC_UNION_QUERY}=${uniqueCacheKey}`)
6268
url.search = pairs.length ? `?${pairs.join('&')}` : ''
6369
}

packages/next/src/server/base-server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
APP_PATHS_MANIFEST,
6969
NEXT_BUILTIN_DOCUMENT,
7070
PAGES_MANIFEST,
71+
RSC_REDIRECT_STATUS_CODE,
7172
STATIC_STATUS_PAGES,
7273
UNDERSCORE_NOT_FOUND_ROUTE,
7374
UNDERSCORE_NOT_FOUND_ROUTE_ENTRY,
@@ -3202,6 +3203,18 @@ export default abstract class Server<
32023203
return null
32033204
}
32043205

3206+
// TODO: add comment + gate by config.
3207+
// deals with `next start` redirect on static RSC pages.
3208+
// TODO: test that it's fine to return 200 here because that's what
3209+
//
3210+
if (
3211+
isRSCRequest &&
3212+
cacheEntry.value?.kind === CachedRouteKind.APP_PAGE &&
3213+
(cacheEntry.value.status === 307 || cacheEntry.value.status === 308)
3214+
) {
3215+
cacheEntry.value.status = RSC_REDIRECT_STATUS_CODE
3216+
}
3217+
32053218
const didPostpone =
32063219
cacheEntry.value?.kind === CachedRouteKind.APP_PAGE &&
32073220
typeof cacheEntry.value.postponed === 'string'

packages/next/src/server/config-shared.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1374,7 +1374,9 @@ export const defaultConfig = {
13741374
viewTransition: false,
13751375
routerBFCache: false,
13761376
removeUncaughtErrorAndRejectionListeners: false,
1377-
validateRSCRequestHeaders: false,
1377+
validateRSCRequestHeaders:
1378+
// TODO: remove once we've confirmed this mode is stable
1379+
!!process.env.__NEXT_TEST_MODE,
13781380
staleTimes: {
13791381
dynamic: 0,
13801382
static: 300,

packages/next/src/server/lib/router-server.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
PHASE_PRODUCTION_SERVER,
3333
PHASE_DEVELOPMENT_SERVER,
3434
UNDERSCORE_NOT_FOUND_ROUTE,
35+
RSC_REDIRECT_STATUS_CODE,
3536
} from '../../shared/lib/constants'
3637
import { RedirectStatusCode } from '../../client/components/redirect-status-code'
3738
import { DevBundlerService } from './dev-bundler-service'
@@ -59,6 +60,7 @@ import {
5960
handleChromeDevtoolsWorkspaceRequest,
6061
isChromeDevtoolsWorkspaceUrl,
6162
} from './chrome-devtools-workspace'
63+
import { RSC_HEADER } from '../../client/components/app-router-headers'
6264

6365
const debug = setupDebug('next:router-server:main')
6466
const isNextFont = (pathname: string | null) =>
@@ -375,6 +377,16 @@ export async function initialize(opts: {
375377
invokedOutputs,
376378
})
377379

380+
// If it was somehow already intercepted, we should stop right away.
381+
// TODO: comment + gate (maybe not needed)
382+
if (statusCode === RSC_REDIRECT_STATUS_CODE) {
383+
res.statusCode = RSC_REDIRECT_STATUS_CODE
384+
const destination = url.format(parsedUrl)
385+
res.setHeader('Location', destination)
386+
res.end()
387+
return
388+
}
389+
378390
if (res.closed || res.finished) {
379391
return
380392
}
@@ -425,9 +437,15 @@ export async function initialize(opts: {
425437
// handle redirect
426438
if (!bodyStream && statusCode && statusCode > 300 && statusCode < 400) {
427439
const destination = url.format(parsedUrl)
428-
res.statusCode = statusCode
429440
res.setHeader('location', destination)
430441

442+
// TODO: comment + gate
443+
if (req.headers[RSC_HEADER.toLowerCase()] === '1') {
444+
res.statusCode = RSC_REDIRECT_STATUS_CODE
445+
return res.end(destination)
446+
}
447+
448+
res.statusCode = statusCode
431449
if (statusCode === RedirectStatusCode.PermanentRedirect) {
432450
res.setHeader('Refresh', `0;url=${destination}`)
433451
}

packages/next/src/server/web/adapter.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { CloseController } from './web-on-close'
3434
import { getEdgePreviewProps } from './get-edge-preview-props'
3535
import { getBuiltinRequestContext } from '../after/builtin-request-context'
3636
import { getImplicitTags } from '../lib/implicit-tags'
37+
import { RSC_REDIRECT_STATUS_CODE } from '../../shared/lib/constants'
3738

3839
export class NextRequestHint extends NextRequest {
3940
sourcePage: string
@@ -430,6 +431,20 @@ export async function adapter(
430431
}
431432
}
432433

434+
// TODO: comment + gate + gate by minimal mode!
435+
if (
436+
isRSCRequest &&
437+
response &&
438+
response.status >= 300 &&
439+
response.status < 400
440+
) {
441+
response = new Response(null, {
442+
...response,
443+
status: RSC_REDIRECT_STATUS_CODE,
444+
headers: response.headers,
445+
})
446+
}
447+
433448
const finalResponse = response ? response : NextResponse.next()
434449

435450
// Flight headers are not overridable / removable so they are applied at the end.

packages/next/src/shared/lib/constants.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,32 @@ export const SYSTEM_ENTRYPOINTS = new Set<string>([
149149
CLIENT_STATIC_FILES_RUNTIME_AMP,
150150
CLIENT_STATIC_FILES_RUNTIME_MAIN_APP,
151151
])
152+
153+
/**
154+
* Custom HTTP status code used to signal a redirect for React Server Component (RSC)
155+
* requests that should be handled by the client-side RSC router, rather than
156+
* being automatically followed by the browser like standard 307/308 redirects.
157+
*
158+
* Purpose:
159+
* - To bypass the browser's default redirect behavior, which typically forwards
160+
* original request headers and might not be ideal for RSC's stateful navigation.
161+
* - To empower the client-side RSC router to intercept this signal and initiate
162+
* a new, clean fetch or navigation to the target URL specified in the 'Location' header.
163+
* This allows for better control over the request lifecycle for the redirected route.
164+
*
165+
* How it's used:
166+
* - Server-side: When an RSC route intends to perform a redirect (originally as a
167+
* 307 or 308), the status code is transformed into this custom value.
168+
* The response must also include a 'Location' header with the redirect target URL.
169+
* - Client-side: The RSC router is programmed to recognize this specific status code.
170+
* Upon detection, it extracts the target URL from the 'Location' header and
171+
* handles the navigation programmatically.
172+
*
173+
* Choice of Value (e.g., 278):
174+
* - The specific value (like 278) is chosen from the 2xx range (indicating success)
175+
* but is not a standard HTTP status code with predefined browser actions for redirection.
176+
* This ensures browsers won't automatically follow it, while still signaling a
177+
* successful server determination that requires client action. It should be an
178+
* officially unassigned status code to minimize conflicts.
179+
*/
180+
export const RSC_REDIRECT_STATUS_CODE = 278
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react'
2+
3+
export default function Page() {
4+
return <div data-testid="about-page">About</div>
5+
}

test/e2e/rsc-redirect/app/layout.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { ReactNode } from 'react'
2+
import React from 'react'
3+
4+
export default function RootLayout({ children }: { children: ReactNode }) {
5+
return (
6+
<html lang="en">
7+
<body>{children}</body>
8+
</html>
9+
)
10+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function Page() {
2+
return (
3+
<div>
4+
Next Config Redirect. This should redirect to /about by specifying a
5+
redirect object in next.config.ts
6+
</div>
7+
)
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function OldAbout() {
2+
return (
3+
<div>
4+
Old About - you should not see this page because middleware should
5+
redirect you to /about
6+
</div>
7+
)
8+
}

test/e2e/rsc-redirect/app/page.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Link from 'next/link'
2+
import React from 'react'
3+
4+
export default function Page() {
5+
return (
6+
<div>
7+
<div>
8+
<Link href="/next-config-redirect" prefetch={false}>
9+
Go to Next Config Redirect Page
10+
</Link>
11+
</div>
12+
<div>
13+
<Link href="/rsc-redirect" prefetch={false}>
14+
Go to RSC redirect page
15+
</Link>
16+
</div>
17+
<div>
18+
<Link href="/about" prefetch={false}>
19+
Go to About Page
20+
</Link>
21+
</div>
22+
<div>
23+
<Link href="/old-about" prefetch={false}>
24+
Go to (Old) About Page
25+
</Link>
26+
</div>
27+
</div>
28+
)
29+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { redirect } from 'next/navigation'
2+
3+
export default function Page() {
4+
redirect('/about')
5+
}

test/e2e/rsc-redirect/index.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { retry } from 'next-test-utils'
3+
4+
describe('RSC Redirect', () => {
5+
const { next, isNextDev } = nextTestSetup({
6+
files: __dirname,
7+
})
8+
9+
it.each([
10+
{ path: '/next-config-redirect', expectedStatus: 278 },
11+
{ path: '/old-about', expectedStatus: 278 },
12+
{ path: '/rsc-redirect', expectedStatus: isNextDev ? 200 : 278 },
13+
])(
14+
'should handle $path redirect with correct status code',
15+
async ({ path, expectedStatus }) => {
16+
const statusCodes: number[] = []
17+
const browser = await next.browser('/', {
18+
beforePageLoad(page) {
19+
page.on('response', (res) => {
20+
statusCodes.push(res.status())
21+
})
22+
},
23+
})
24+
25+
// Click the link
26+
await browser.elementByCss(`a[href="${path}"]`).click()
27+
await browser.waitForIdleNetwork()
28+
// First check the URL to ensure we were redirected
29+
await retry(async () => {
30+
expect(await browser.url()).toBe(`${next.url}/about`)
31+
})
32+
33+
// Then check the content to ensure the page loaded correctly
34+
await browser.waitForElementByCss('[data-testid="about-page"]')
35+
const content = await browser
36+
.elementByCss('[data-testid="about-page"]')
37+
.text()
38+
expect(content).toBe('About')
39+
40+
// Finally check that the expected status code exists in the responses
41+
expect(statusCodes).toContain(expectedStatus)
42+
}
43+
)
44+
})

test/e2e/rsc-redirect/middleware.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { NextResponse } from 'next/server'
2+
import type { NextRequest } from 'next/server'
3+
4+
export function middleware(request: NextRequest) {
5+
if (request.nextUrl.pathname === '/old-about') {
6+
const url = request.nextUrl.clone()
7+
url.pathname = '/about'
8+
return NextResponse.redirect(url)
9+
}
10+
return NextResponse.next()
11+
}

test/e2e/rsc-redirect/next.config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { NextConfig } from 'next'
2+
3+
const config: NextConfig = {
4+
async redirects() {
5+
return [
6+
{
7+
source: '/next-config-redirect',
8+
destination: '/about',
9+
permanent: false,
10+
},
11+
]
12+
},
13+
14+
experimental: {
15+
// TODO: remove it to test flag auto enabled for all tests
16+
validateRSCRequestHeaders: true,
17+
},
18+
}
19+
20+
export default config

0 commit comments

Comments
 (0)