-
Notifications
You must be signed in to change notification settings - Fork 292
fix: server action redirects use soft RSC navigation instead of hard reload (#654) #698
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
base: main
Are you sure you want to change the base?
Changes from all commits
de4bf6f
7a94313
ed29480
c714eb3
c16cae3
0fb28ce
7a249da
28750cd
0730add
f14713c
de691d7
6953ee9
3de9712
c81c2f5
a932a5e
b532645
c7c5bca
7cd9bac
6ce2636
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1907,28 +1907,149 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { | |||||||||||||||||||||||
| // We can't use a real HTTP redirect (the fetch would follow it automatically | ||||||||||||||||||||||||
| // and receive a page HTML instead of RSC stream). Instead, we return a 200 | ||||||||||||||||||||||||
| // with x-action-redirect header that the client entry detects and handles. | ||||||||||||||||||||||||
| // | ||||||||||||||||||||||||
| // For same-origin routes, we pre-render the redirect target's RSC payload | ||||||||||||||||||||||||
| // so the client can perform a soft RSC navigation (SPA-style) instead of | ||||||||||||||||||||||||
| // a hard page reload. This matches Next.js behavior. | ||||||||||||||||||||||||
| // | ||||||||||||||||||||||||
| // Note: Middleware is NOT executed for the redirect target pre-render. | ||||||||||||||||||||||||
| // This is a known limitation — the redirect target is rendered directly | ||||||||||||||||||||||||
| // without going through the middleware pipeline. | ||||||||||||||||||||||||
| if (actionRedirect) { | ||||||||||||||||||||||||
| const actionPendingCookies = getAndClearPendingCookies(); | ||||||||||||||||||||||||
| const actionDraftCookie = getDraftModeCookieHeader(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Refresh headers context for the redirect target. We don't clear it | ||||||||||||||||||||||||
| // entirely because the RSC stream is consumed lazily and async | ||||||||||||||||||||||||
| // components need a live context during consumption. | ||||||||||||||||||||||||
| // | ||||||||||||||||||||||||
| // Note: this context is derived from the original POST action request, | ||||||||||||||||||||||||
| // not a synthetic GET to the redirect target. Server components that | ||||||||||||||||||||||||
| // call headers() during the pre-render may see action-request headers | ||||||||||||||||||||||||
| // such as x-rsc-action and multipart/form-data metadata. | ||||||||||||||||||||||||
| setHeadersContext(headersContextFromRequest(request)); | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment documenting this as a known limitation is good. One additional note: This is documented in the comment, but consider also gating the pre-render when the redirect target is known to use |
||||||||||||||||||||||||
| setNavigationContext(null); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Try to pre-render the redirect target for soft RSC navigation. | ||||||||||||||||||||||||
| // This is the Next.js parity fix for issue #654. | ||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| const redirectUrl = new URL(actionRedirect.url, request.url); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Only pre-render same-origin URLs. External URLs fall through to | ||||||||||||||||||||||||
| // the empty-body response, which triggers a hard redirect on the client. | ||||||||||||||||||||||||
| if (redirectUrl.origin === new URL(request.url).origin) { | ||||||||||||||||||||||||
| const redirectMatch = matchRoute(redirectUrl.pathname); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (redirectMatch) { | ||||||||||||||||||||||||
| const { route: redirectRoute, params: redirectParams } = redirectMatch; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Set navigation context for the redirect target | ||||||||||||||||||||||||
| setNavigationContext({ | ||||||||||||||||||||||||
| pathname: redirectUrl.pathname, | ||||||||||||||||||||||||
| searchParams: redirectUrl.searchParams, | ||||||||||||||||||||||||
| params: redirectParams, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
|
Comment on lines
+1946
to
+1951
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: navigation context is not cleaned up on failure. If the This should be wrapped in a try/finally, or the catch block should call
Comment on lines
+1946
to
+1951
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this This is the same pattern as the normal RSC render path (line 1995-1999), so it's not a regression — just worth noting as a pre-existing concern with streaming responses. |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Build and render the redirect target page | ||||||||||||||||||||||||
| // Pre-render the redirect target's RSC payload so the client can | ||||||||||||||||||||||||
| // apply it as a soft navigation (matching Next.js behavior). | ||||||||||||||||||||||||
| // | ||||||||||||||||||||||||
| // Note: Middleware request matching does not run for the redirect | ||||||||||||||||||||||||
| // target — only the original action request goes through middleware. | ||||||||||||||||||||||||
| // However, middleware response headers (Set-Cookie, custom headers) | ||||||||||||||||||||||||
| // from the original request are merged into the redirect response. | ||||||||||||||||||||||||
| // If middleware request matching is needed for the redirect target | ||||||||||||||||||||||||
| // (e.g., auth checks, conditional headers), use a hard redirect. | ||||||||||||||||||||||||
| const redirectElement = buildPageElement( | ||||||||||||||||||||||||
| redirectRoute, | ||||||||||||||||||||||||
| redirectParams, | ||||||||||||||||||||||||
| undefined, | ||||||||||||||||||||||||
| redirectUrl.searchParams, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const redirectOnError = createRscOnErrorHandler( | ||||||||||||||||||||||||
| request, | ||||||||||||||||||||||||
| redirectUrl.pathname, | ||||||||||||||||||||||||
| redirectRoute.pattern, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const rscStream = renderToReadableStream( | ||||||||||||||||||||||||
| { root: redirectElement, returnValue }, | ||||||||||||||||||||||||
| { temporaryReferences, onError: redirectOnError }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Collect cookies after rendering (same as normal action response) | ||||||||||||||||||||||||
| const redirectPendingCookies = getAndClearPendingCookies(); | ||||||||||||||||||||||||
| const redirectDraftCookie = getDraftModeCookieHeader(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const redirectHeaders = { | ||||||||||||||||||||||||
| "Content-Type": "text/x-component; charset=utf-8", | ||||||||||||||||||||||||
| "Vary": "RSC, Accept", | ||||||||||||||||||||||||
| "x-action-redirect": actionRedirect.url, | ||||||||||||||||||||||||
| "x-action-redirect-type": actionRedirect.type, | ||||||||||||||||||||||||
| "x-action-redirect-status": String(actionRedirect.status), | ||||||||||||||||||||||||
| "x-action-rsc-prerender": "1", | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug (from previous review, still present): missing Compare with
Suggested change
|
||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Always include X-Vinext-Params header (even if empty) so the | ||||||||||||||||||||||||
| // client can correctly parse useParams() for the redirect target. | ||||||||||||||||||||||||
| // For routes without dynamic params, this will be "{}". | ||||||||||||||||||||||||
| redirectHeaders["X-Vinext-Params"] = encodeURIComponent(JSON.stringify(redirectParams)); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const redirectResponse = new Response(rscStream, { | ||||||||||||||||||||||||
| status: 200, | ||||||||||||||||||||||||
| headers: redirectHeaders, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Append cookies collected from action and redirect phases | ||||||||||||||||||||||||
| if (actionPendingCookies.length > 0 || actionDraftCookie || redirectPendingCookies.length > 0 || redirectDraftCookie) { | ||||||||||||||||||||||||
| for (const cookie of actionPendingCookies) { | ||||||||||||||||||||||||
| redirectResponse.headers.append("Set-Cookie", cookie); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie); | ||||||||||||||||||||||||
| for (const cookie of redirectPendingCookies) { | ||||||||||||||||||||||||
| redirectResponse.headers.append("Set-Cookie", cookie); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (redirectDraftCookie) redirectResponse.headers.append("Set-Cookie", redirectDraftCookie); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug (from previous review, still present): Both sets need to be included:
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Apply middleware response headers (Set-Cookie, custom headers, etc.) | ||||||||||||||||||||||||
| // to the redirect response. This ensures middleware-set headers are | ||||||||||||||||||||||||
| // preserved even though the redirect target bypasses the middleware | ||||||||||||||||||||||||
| // pipeline. Note: middleware request matching still doesn't run for | ||||||||||||||||||||||||
| // the redirect target — this only merges headers from the original | ||||||||||||||||||||||||
| // action request's middleware execution. | ||||||||||||||||||||||||
| return __applyRouteHandlerMiddlewareContext(redirectResponse, _mwCtx); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } catch (preRenderErr) { | ||||||||||||||||||||||||
| // If pre-rendering fails (e.g., auth guard, missing data, unmatched route), | ||||||||||||||||||||||||
| // clean up contexts and fall through to hard redirect. | ||||||||||||||||||||||||
| setHeadersContext(null); | ||||||||||||||||||||||||
| setNavigationContext(null); | ||||||||||||||||||||||||
| console.error("[vinext] Failed to pre-render redirect target:", preRenderErr); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Fallback: external URL or unmatched route — client will hard-navigate. | ||||||||||||||||||||||||
| // Clean up both contexts before returning. | ||||||||||||||||||||||||
| setHeadersContext(null); | ||||||||||||||||||||||||
| setNavigationContext(null); | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this
Comment on lines
+2033
to
2036
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Suggested change
|
||||||||||||||||||||||||
| const redirectHeaders = new Headers({ | ||||||||||||||||||||||||
| "Content-Type": "text/x-component; charset=utf-8", | ||||||||||||||||||||||||
| "Vary": "RSC, Accept", | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| // Merge middleware headers first so the framework's own redirect control | ||||||||||||||||||||||||
| // headers below are always authoritative and cannot be clobbered by | ||||||||||||||||||||||||
| // middleware that happens to set x-action-redirect* keys. | ||||||||||||||||||||||||
| // Merge middleware headers first so framework redirect-control headers | ||||||||||||||||||||||||
| // below remain authoritative if middleware also sets x-action-redirect*. | ||||||||||||||||||||||||
| __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); | ||||||||||||||||||||||||
| redirectHeaders.set("x-action-redirect", actionRedirect.url); | ||||||||||||||||||||||||
| redirectHeaders.set("x-action-redirect-type", actionRedirect.type); | ||||||||||||||||||||||||
| redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status)); | ||||||||||||||||||||||||
| redirectHeaders.set("X-Vinext-Params", encodeURIComponent(JSON.stringify({}))); | ||||||||||||||||||||||||
| for (const cookie of actionPendingCookies) { | ||||||||||||||||||||||||
| redirectHeaders.append("Set-Cookie", cookie); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); | ||||||||||||||||||||||||
| // Send an empty RSC-like body (client will navigate instead of parsing) | ||||||||||||||||||||||||
| return new Response("", { status: 200, headers: redirectHeaders }); | ||||||||||||||||||||||||
| return new Response(null, { status: 200, headers: redirectHeaders }); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // After the action, re-render the current page so the client | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -897,6 +897,29 @@ type NitroSetupContext = { | |
| }; | ||
| }; | ||
|
|
||
| /** Content-type lookup for static assets. */ | ||
| const CONTENT_TYPES: Record<string, string> = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This Previously, any URL with a dot (except |
||
| ".js": "application/javascript", | ||
| ".mjs": "application/javascript", | ||
| ".css": "text/css", | ||
| ".html": "text/html", | ||
| ".json": "application/json", | ||
| ".png": "image/png", | ||
| ".jpg": "image/jpeg", | ||
| ".jpeg": "image/jpeg", | ||
| ".gif": "image/gif", | ||
| ".svg": "image/svg+xml", | ||
| ".ico": "image/x-icon", | ||
| ".woff": "font/woff", | ||
| ".woff2": "font/woff2", | ||
| ".ttf": "font/ttf", | ||
| ".eot": "application/vnd.ms-fontobject", | ||
| ".webp": "image/webp", | ||
| ".avif": "image/avif", | ||
| ".map": "application/json", | ||
| ".rsc": "text/x-component", | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This This means URLs like This should be in a separate PR with its own tests verifying the new skip behavior. |
||
|
|
||
| export default function vinext(options: VinextOptions = {}): PluginOption[] { | ||
| const viteMajorVersion = getViteMajorVersion(); | ||
| let root: string; | ||
|
|
@@ -2377,6 +2400,31 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
| // (app router is handled by @vitejs/plugin-rsc's built-in middleware) | ||
| if (!hasPagesDir) return next(); | ||
|
|
||
| const applyRequestHeadersToNodeRequest = (nextRequestHeaders: Headers) => { | ||
| for (const key of Object.keys(req.headers)) { | ||
| delete req.headers[key]; | ||
| } | ||
| for (const [key, value] of nextRequestHeaders) { | ||
| req.headers[key] = value; | ||
| } | ||
| }; | ||
|
|
||
| let middlewareRequestHeaders: Headers | null = null; | ||
| let deferredMwResponseHeaders: [string, string][] | null = null; | ||
|
|
||
| const applyDeferredMwHeaders = ( | ||
| response: import("node:http").ServerResponse, | ||
| headers?: [string, string][] | Headers | null, | ||
| ) => { | ||
| if (!headers) return; | ||
| for (const [key, value] of headers) { | ||
| // skip internal x-middleware- headers | ||
| if (key.startsWith("x-middleware-")) continue; | ||
| // append handles multiple Set-Cookie correctly | ||
| response.appendHeader(key, value); | ||
| } | ||
| }; | ||
|
|
||
| // Skip Vite internal requests and static files | ||
| if ( | ||
| url.startsWith("/@") || | ||
|
|
@@ -2462,16 +2510,21 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
| } | ||
|
|
||
| // Skip requests for files with extensions (static assets) | ||
| let pathname = url.split("?")[0]; | ||
| if (pathname.includes(".") && !pathname.endsWith(".html")) { | ||
| const [pathnameWithExt] = url.split("?"); | ||
| const ext = path.extname(pathnameWithExt); | ||
| if (ext && ext !== ".html" && CONTENT_TYPES[ext]) { | ||
| // If middleware was run, apply its headers (Set-Cookie, etc.) | ||
| // before Vite's built-in static-file middleware sends the file. | ||
| // This ensures public/ asset responses have middleware headers. | ||
| applyDeferredMwHeaders(res, deferredMwResponseHeaders); | ||
| return next(); | ||
| } | ||
|
|
||
| // Guard against protocol-relative URL open redirects. | ||
| // Normalize backslashes first: browsers treat /\ as // in URL | ||
| // context. Check the RAW pathname before normalizePath so the | ||
| // guard fires before normalizePath collapses //. | ||
| pathname = pathname.replaceAll("\\", "/"); | ||
| let pathname = pathnameWithExt.replaceAll("\\", "/"); | ||
| if (pathname.startsWith("//")) { | ||
| res.writeHead(404); | ||
| res.end("404 Not Found"); | ||
|
|
@@ -2571,26 +2624,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
| if (redirected) return; | ||
| } | ||
|
|
||
| const applyRequestHeadersToNodeRequest = (nextRequestHeaders: Headers) => { | ||
| for (const key of Object.keys(req.headers)) { | ||
| delete req.headers[key]; | ||
| } | ||
| for (const [key, value] of nextRequestHeaders) { | ||
| req.headers[key] = value; | ||
| } | ||
| }; | ||
|
|
||
| let middlewareRequestHeaders: Headers | null = null; | ||
| let deferredMwResponseHeaders: [string, string][] | null = null; | ||
|
|
||
| const applyDeferredMwHeaders = () => { | ||
| if (deferredMwResponseHeaders) { | ||
| for (const [key, value] of deferredMwResponseHeaders) { | ||
| res.appendHeader(key, value); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| // Run middleware.ts if present | ||
| if (middlewarePath) { | ||
| // Only trust X-Forwarded-Proto when behind a trusted proxy | ||
|
|
@@ -2762,7 +2795,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
|
|
||
| // External rewrite from beforeFiles — proxy to external URL | ||
| if (isExternalUrl(resolvedUrl)) { | ||
| applyDeferredMwHeaders(); | ||
| applyDeferredMwHeaders(res, deferredMwResponseHeaders); | ||
| await proxyExternalRewriteNode(req, res, resolvedUrl); | ||
| return; | ||
| } | ||
|
|
@@ -2777,7 +2810,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
| ); | ||
| const apiMatch = matchRoute(resolvedUrl, apiRoutes); | ||
| if (apiMatch) { | ||
| applyDeferredMwHeaders(); | ||
| applyDeferredMwHeaders(res, deferredMwResponseHeaders); | ||
| if (middlewareRequestHeaders) { | ||
| applyRequestHeadersToNodeRequest(middlewareRequestHeaders); | ||
| } | ||
|
|
@@ -2817,7 +2850,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
|
|
||
| // External rewrite from afterFiles — proxy to external URL | ||
| if (isExternalUrl(resolvedUrl)) { | ||
| applyDeferredMwHeaders(); | ||
| applyDeferredMwHeaders(res, deferredMwResponseHeaders); | ||
| await proxyExternalRewriteNode(req, res, resolvedUrl); | ||
| return; | ||
| } | ||
|
|
@@ -2837,7 +2870,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
| // Try rendering the resolved URL | ||
| const match = matchRoute(resolvedUrl.split("?")[0], routes); | ||
| if (match) { | ||
| applyDeferredMwHeaders(); | ||
| applyDeferredMwHeaders(res, deferredMwResponseHeaders); | ||
| if (middlewareRequestHeaders) { | ||
| applyRequestHeadersToNodeRequest(middlewareRequestHeaders); | ||
| } | ||
|
|
@@ -2855,15 +2888,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
| if (fallbackRewrite) { | ||
| // External fallback rewrite — proxy to external URL | ||
| if (isExternalUrl(fallbackRewrite)) { | ||
| applyDeferredMwHeaders(); | ||
| applyDeferredMwHeaders(res, deferredMwResponseHeaders); | ||
| await proxyExternalRewriteNode(req, res, fallbackRewrite); | ||
| return; | ||
| } | ||
| const fallbackMatch = matchRoute(fallbackRewrite.split("?")[0], routes); | ||
| if (!fallbackMatch && hasAppDir) { | ||
| return next(); | ||
| } | ||
| applyDeferredMwHeaders(); | ||
| applyDeferredMwHeaders(res, deferredMwResponseHeaders); | ||
| if (middlewareRequestHeaders) { | ||
| applyRequestHeadersToNodeRequest(middlewareRequestHeaders); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correctness concern:
headersContextFromRequest(request)creates a context from the original action request (which is a POST withx-rsc-actionheader, potentiallymultipart/form-datacontent type, etc.). If the redirect target page's server components callheaders()orcookies(), they'll see action-specific headers rather than what a normal GET navigation would have.This is hard to fix perfectly without constructing a synthetic GET request for the redirect target, but it should be documented as a known limitation alongside the middleware bypass. Consider adding a comment: