Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
de4bf6f
fix: server action redirects use soft RSC navigation instead of hard …
yunus25jmi1 Mar 27, 2026
7a94313
fix: use manual glob implementation for Node compatibility
yunus25jmi1 Mar 27, 2026
ed29480
fix: complete soft RSC navigation for server action redirects
yunus25jmi1 Mar 27, 2026
c714eb3
fix: improve file-matcher glob handling and update snapshots
yunus25jmi1 Mar 27, 2026
c16cae3
fix: address review feedback for soft RSC navigation
yunus25jmi1 Mar 28, 2026
0fb28ce
test: update entry-templates snapshots after review fixes
yunus25jmi1 Mar 28, 2026
7a249da
fix: refactor scanWithExtensions to use glob for file matching
yunus25jmi1 Mar 29, 2026
28750cd
fix(server-actions): address round-3 review feedback for soft redirects
yunus25jmi1 Mar 31, 2026
0730add
test: update entry-templates snapshots after round-3 review fixes
yunus25jmi1 Mar 31, 2026
f14713c
fix(rewrites): include middleware headers in static file responses
yunus25jmi1 Mar 31, 2026
de691d7
fix(server-actions): address code review feedback for soft redirects
yunus25jmi1 Apr 1, 2026
6953ee9
fix(server-actions): complete soft RSC navigation for action redirects
yunus25jmi1 Apr 1, 2026
3de9712
fix(server-actions): address final review feedback
yunus25jmi1 Apr 1, 2026
c81c2f5
fix(server-actions): cleanup fallback headers context and apply middl…
yunus25jmi1 Apr 1, 2026
a932a5e
fix(server-actions): harden redirect fallback context and headers
yunus25jmi1 Apr 1, 2026
b532645
fix(server-actions): make redirect navigation atomic
yunus25jmi1 Apr 2, 2026
c7c5bca
Merge upstream/main and resolve app browser/navigation conflicts
yunus25jmi1 Apr 2, 2026
7cd9bac
test: update entry-template snapshots after merge conflict resolution
yunus25jmi1 Apr 2, 2026
6ce2636
Merge upstream/main and resolve app-rsc-entry snapshot conflicts
yunus25jmi1 Apr 3, 2026
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
131 changes: 126 additions & 5 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Copy link
Copy Markdown
Contributor

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 with x-rsc-action header, potentially multipart/form-data content type, etc.). If the redirect target page's server components call headers() or cookies(), 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:

Suggested change
setHeadersContext(headersContextFromRequest(request));
// 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 action request (POST),
// not a synthetic GET to the redirect target. Server components in the
// redirect target that call headers() will see the action request's
// headers (including x-rsc-action, Content-Type, etc.).
setHeadersContext(headersContextFromRequest(request));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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: headersContextFromRequest(request) clones the action request's headers, which includes Content-Type: multipart/form-data (for form submissions) and x-rsc-action. If the redirect target page has server components that branch on headers().get('content-type'), they'll see form data content type instead of the expected text/html accept header a normal GET navigation would have.

This is documented in the comment, but consider also gating the pre-render when the redirect target is known to use headers() in the future. For now the documentation is sufficient.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: navigation context is not cleaned up on failure. If the try block throws after this point, setNavigationContext is left pointing at the redirect target. The catch block at line 1956 doesn't reset it.

This should be wrapped in a try/finally, or the catch block should call setNavigationContext(null).

Comment on lines +1946 to +1951
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this setNavigationContext call is correct for the pre-render, but if buildPageElement or renderToReadableStream throws, the catch block at line 1961 resets it to null. However, if the renderToReadableStream call succeeds but the stream itself errors during lazy consumption (after the return redirectResponse), the navigation context will be left pointing at the redirect target for any subsequent requests in the same isolate.

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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug (from previous review, still present): missing X-Vinext-Params header. The client at app-browser-entry.ts:185 reads this to call setClientParams(). Without it, useParams() returns {} after soft redirect to a dynamic route (e.g., /posts/[slug]).

Compare with buildAppPageRscResponse at server/app-page-response.ts:169-172.

Suggested change
"x-action-rsc-prerender": "1",
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",
...(Object.keys(redirectParams).length > 0 ? { "X-Vinext-Params": encodeURIComponent(JSON.stringify(redirectParams)) } : {}),
};

};

// 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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug (from previous review, still present): actionPendingCookies are collected at line 1889 but never appended here. Only redirectPendingCookies are included. This drops cookies set by the action before redirect() was called (e.g., cookies().set('session', token)).

Both sets need to be included:

Suggested change
}
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);
}


// 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this setNavigationContext(null) is redundant when falling through from the catch block, since line 1964 already resets it. Not harmful, but a bit confusing to read — it looks like it should be unreachable from that path.

Comment on lines +2033 to 2036
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: setHeadersContext(null) + setNavigationContext(null) here are redundant when falling through from the catch block at line 1990-1995, which already does the same cleanup. The code is correct (it also handles the case where the try block's inner if conditions fail and execution skips the catch entirely), but a comment would help readability:

Suggested change
// Fallback: external URL or unmatched route — client will hard-navigate.
// Clean up both contexts before returning.
setHeadersContext(null);
setNavigationContext(null);
// Fallback: external URL or unmatched route — client will hard-navigate.
// Clean up both contexts before returning. This also handles the case
// where the try block's conditions (same-origin, matched route) were
// not met and execution fell through without entering the catch.
setHeadersContext(null);
setNavigationContext(null);

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
Expand Down
91 changes: 62 additions & 29 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,29 @@ type NitroSetupContext = {
};
};

/** Content-type lookup for static assets. */
const CONTENT_TYPES: Record<string, string> = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This CONTENT_TYPES allowlist and the associated change at line 2473 (CONTENT_TYPES[ext] instead of pathname.includes('.')) is a behavioral change to the dev server's static file skip logic that's unrelated to server action redirects.

Previously, any URL with a dot (except .html) was skipped. Now, only known extensions are skipped. URLs like /api/v1.2/data or /file.unknown will now fall through to the Pages Router handler. That may be correct, but it changes existing behavior and should be a separate PR with its own testing.

".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",
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This CONTENT_TYPES map and the associated changes to static asset detection (line 3095: switching from pathname.includes('.') to CONTENT_TYPES[ext]) are a behavioral change to the dev server's static file skip logic. Previously, any URL with a dot (except .html) was skipped. Now, only known extensions in this allowlist are skipped.

This means URLs like /api/v1.2/data or /file.unknown that were previously skipped will now fall through to the Pages Router handler. That may be correct (or even an improvement), but it's a subtle behavioral change that's unrelated to server action redirect soft navigation.

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;
Expand Down Expand Up @@ -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("/@") ||
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down
Loading
Loading