Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
24 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
612720f
fix(server-actions): sync latestClientParams during redirect soft-nav
yunus25jmi1 Apr 4, 2026
29dde11
chore(server-actions): refresh stale non-redirect navigation comment
yunus25jmi1 Apr 4, 2026
43b446a
fix(prod-server): serve static files for beforeFiles rewrite targets
yunus25jmi1 Apr 5, 2026
b5e38fa
clarify(app-rsc-entry): clarify cookie collection timing in redirect …
yunus25jmi1 Apr 5, 2026
db1c8ba
test: update snapshots for cookie collection comment change
yunus25jmi1 Apr 5, 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
71 changes: 69 additions & 2 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1877,6 +1877,10 @@ 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.
if (actionRedirect) {
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
Expand All @@ -1893,8 +1897,71 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
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 });

// 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
const redirectElement = buildPageElement(
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.

Consider extracting the redirect pre-render logic (lines 1918-2052) into a runtime helper in server/ rather than keeping it inline in the generated entry template. Per AGENTS.md:

Generated entry modules should stay thin. Move real behavior into normal typed modules under packages/vinext/src/server/* whenever the code involves request/response orchestration, streaming or teeing streams, redirect / not-found / access-fallback handling.

This would also reduce the 4x snapshot duplication (~750 new lines in snapshots are this same block repeated for each test variant).

redirectRoute,
redirectParams,
undefined,
redirectUrl.searchParams,
);

const redirectOnError = createRscOnErrorHandler(
request,
redirectUrl.pathname,
redirectRoute.pattern,
);

const rscStream = renderToReadableStream(
{ root: redirectElement, returnValue },
{ temporaryReferences, onError: redirectOnError },
);

const redirectResponse = new Response(rscStream, {
status: 200,
headers: redirectHeaders,
});

// Append cookies to the response
if (actionPendingCookies.length > 0 || actionDraftCookie) {
for (const cookie of actionPendingCookies) {
redirectResponse.headers.append("Set-Cookie", cookie);
}
if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie);
}
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: duplicate Set-Cookie headers. redirectHeaders already has the cookies appended at lines 1896-1899 above. Since redirectResponse was constructed from redirectHeaders, appending again here doubles every cookie.

This entire block should be removed:

Suggested change
// Append cookies to the response
if (actionPendingCookies.length > 0 || actionDraftCookie) {
for (const cookie of actionPendingCookies) {
redirectResponse.headers.append("Set-Cookie", cookie);
}
if (actionDraftCookie) redirectResponse.headers.append("Set-Cookie", actionDraftCookie);
}

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


return redirectResponse;
}
}
} catch (preRenderErr) {
// If pre-rendering fails (e.g., auth guard, missing data, unmatched route),
// fall through to the empty-body response below. This ensures graceful
// degradation to hard redirect rather than a 500 error.
console.error("[vinext] Failed to pre-render redirect target:", preRenderErr);
}

// Fallback: external URL or unmatched route — client will hard-navigate.
return new Response(null, { status: 200, headers: redirectHeaders });
}

// After the action, re-render the current page so the client
Expand Down
79 changes: 71 additions & 8 deletions packages/vinext/src/routing/file-matcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { glob } from "node:fs/promises";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type { Dirent } from "node:fs";

export const DEFAULT_PAGE_EXTENSIONS = ["tsx", "ts", "jsx", "js"] as const;

Expand Down Expand Up @@ -85,19 +87,80 @@ export function createValidFileMatcher(
}

/**
* Use function-form exclude for Node < 22.14 compatibility.
* Use function-form exclude for Node 22.14+ compatibility.
* Scans for files matching stem with extensions recursively under cwd.
* Supports glob patterns in stem.
*/
export async function* scanWithExtensions(
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 is a complete rewrite of scanWithExtensions from a 6-line glob() wrapper to a 70-line hand-rolled recursive directory walker. This is unrelated to the server action redirect feature and should be in a separate PR with dedicated unit tests.

The original used Node's built-in glob() which correctly handles brace expansion, ** recursion, and other edge cases. The replacement has several issues:

  • exclude receives entry.name (bare filename) instead of the relative path that glob's function-form exclude receives
  • isGlob only checks for */**, missing ?, [...], {...} which buildExtensionGlob generates
  • No tests were added for the new implementation

If the goal is Node < 22.14 compatibility, that's worth doing — but as a focused, well-tested change.

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 is still an unrelated change bundled into a feature PR. The rewrite replaces a 6-line glob() wrapper with a 70-line hand-rolled recursive walker, with no new tests.

Additional issues beyond what the previous review noted:

  • Always recurses into all subdirectories even for non-glob stems (e.g., a literal "page" stem would scan the entire tree instead of checking a single file)
  • The relativeBase parameter passed to recursive calls is the relative path of the current directory, but for non-glob stems the matching logic at line 150 compares relativePath against relativeBase.${ext} — since relativeBase is the directory path and relativePath is the file path, this comparison is unlikely to match correctly for files in subdirectories

This doesn't affect the current callers (all use ** glob patterns), but it means the non-glob branch is dead/broken code.

Please move this to a separate PR with dedicated tests.

stem: string,
cwd: string,
extensions: readonly string[],
exclude?: (name: string) => boolean,
): AsyncGenerator<string> {
const pattern = buildExtensionGlob(stem, extensions);
for await (const file of glob(pattern, {
cwd,
...(exclude ? { exclude } : {}),
})) {
yield file;
const dir = cwd;

// Check if stem contains glob patterns
const isGlob = stem.includes("**") || stem.includes("*");

// Extract the base name from stem (e.g., "**/page" -> "page", "page" -> "page")
// For "**/*", baseName will be "*" which means match all files
const baseName = stem.split("/").pop() || stem;
const matchAllFiles = baseName === "*";

async function* scanDir(currentDir: string, relativeBase: string): AsyncGenerator<string> {
let entries: Dirent[];
try {
entries = (await readdir(currentDir, { withFileTypes: true })) as Dirent[];
} catch {
return;
}

for (const entry of entries) {
if (exclude && exclude(entry.name)) continue;
if (entry.name.startsWith(".")) continue;

const fullPath = join(currentDir, entry.name);
const relativePath = fullPath.startsWith(dir) ? fullPath.slice(dir.length + 1) : fullPath;

if (entry.isDirectory()) {
// Recurse into subdirectories
yield* scanDir(fullPath, relativePath);
} else if (entry.isFile()) {
if (matchAllFiles) {
// For "**/*" pattern, match any file with the given extensions
for (const ext of extensions) {
if (entry.name.endsWith(`.${ext}`)) {
yield relativePath;
break;
}
}
} else {
// Check if file matches baseName.{extension}
for (const ext of extensions) {
const expectedName = `${baseName}.${ext}`;
if (entry.name === expectedName) {
// For glob patterns like **/page, match any path ending with page.tsx
if (isGlob) {
if (relativePath.endsWith(`${baseName}.${ext}`)) {
yield relativePath;
}
} else {
// For non-glob stems, the path should start with the stem
if (
relativePath === `${relativeBase}.${ext}` ||
relativePath.startsWith(`${relativeBase}/`) ||
relativePath === `${baseName}.${ext}`
) {
yield relativePath;
}
}
break;
}
}
}
}
}
}

yield* scanDir(dir, stem);
}
50 changes: 46 additions & 4 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />

import type { ReactNode } from "react";
import { startTransition } from "react";
import type { Root } from "react-dom/client";
import {
createFromFetch,
Expand Down Expand Up @@ -144,11 +145,52 @@ function registerServerActionCallback(): void {
// Fall through to hard redirect below if URL parsing fails.
}

// Use hard redirect for all action redirects because vinext's server
// currently returns an empty body for redirect responses. RSC navigation
// requires a valid RSC payload. This is a known parity gap with Next.js,
// which pre-renders the redirect target's RSC payload.
// Check if the server pre-rendered the redirect target's RSC payload.
// If so, we can perform a soft RSC navigation (SPA-style) instead of
// a hard page reload. This is the fix for issue #654.
const contentType = fetchResponse.headers.get("content-type") ?? "";
const hasRscPayload = contentType.includes("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.

The content-type check is fragile: the fallback Response(null) path also sends Content-Type: text/x-component because it uses the same redirectHeaders. The && fetchResponse.body guard saves you today, but new Response(null).body is non-null in some environments (it's an empty ReadableStream).

Consider adding an explicit signal header on the server side (e.g., x-action-rsc-prerender: 1) to distinguish pre-rendered responses from empty fallbacks, rather than relying on content-type + body presence.

const redirectType = fetchResponse.headers.get("x-action-redirect-type") ?? "replace";

if (hasRscPayload && fetchResponse.body) {
// Server pre-rendered the redirect target — apply it as a soft SPA navigation.
// This matches how Next.js handles action redirects internally.
try {
const result = await createFromFetch(Promise.resolve(fetchResponse), {
temporaryReferences,
});

if (isServerActionResult(result)) {
// Update the React tree with the redirect target's RSC payload
startTransition(() => {
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 existing code at lines 611-618 documents this exact scenario:

If server actions ever trigger URL changes via RSC payload (instead of hard redirects), this would need renderNavigationPayload() + snapshotActivated=true.

This new code triggers a URL change via RSC payload but bypasses renderNavigationPayload and activateNavigationSnapshot. It works in the simple case because commitClientNavigationState handles count=0 gracefully (line 902 checks > 0 before decrementing). But this means the snapshot activation mechanism is bypassed — during the startTransition, hooks like usePathname() in components that survive the transition won't see the snapshot-provided destination URL; they'll read from useSyncExternalStore which still reflects the old URL until commitClientNavigationState fires.

For this PR this is acceptable (the full tree is replaced so most components remount), but consider leaving a // TODO: comment noting this should use renderNavigationPayload for correctness with partial tree updates (e.g., shared layouts).

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 existing code at lines 611-618 documents that server actions don't trigger URL changes via RSC payload. This PR introduces exactly that behavior. The comment has been partially updated (lines 611-614 now mention the redirect path), but line 617-618 still says:

snapshotActivated is intentionally omitted (defaults false) so handleAsyncError skips commitClientNavigationState()

This is accurate for the non-redirect path, but it's worth adding a note that the redirect path above does use commitClientNavigationState() and handles the counter correctly via createClientNavigationRenderSnapshot. The current wording could confuse someone debugging the redirect flow.

getReactRoot().render(result.root);
});

// Update the browser URL without a reload
if (redirectType === "push") {
window.history.pushState(null, "", actionRedirect);
} else {
window.history.replaceState(null, "", actionRedirect);
}
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: client-side navigation context is not updated. After soft-navigating to the redirect target, usePathname(), useSearchParams(), and useParams() will return stale values from the previous page.

Compare with the navigateRsc function (lines 285-294) which calls setClientParams() after navigation. This code path needs equivalent updates:

// After startTransition + history update:
setNavigationContext({
  pathname: new URL(actionRedirect, window.location.origin).pathname,
  searchParams: new URL(actionRedirect, window.location.origin).searchParams,
  params: {}, // or parse from X-Vinext-Params header
});

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.

After pushState/replaceState, the normal navigation path in navigation.ts:608 calls notifyListeners() to trigger useSyncExternalStore re-renders for usePathname(), useSearchParams(), and useParams(). This code path skips that notification.

In practice, render(result.root) replaces the entire tree so most components remount with correct values. But any persistent layout components that use these hooks won't re-render with updated values.

Consider importing and calling notifyListeners (it would need to be exported from navigation.ts), or dispatching a popstate event after the URL update.

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.

Minor: after pushState/replaceState, the normal navigation path in navigation.ts:608 calls notifyListeners() to trigger useSyncExternalStore re-renders for usePathname(), useSearchParams(), etc. This code path skips that.

In practice, render(result.root) replaces the tree so most components remount correctly. But persistent layout components that use these hooks won't re-render with the updated URL.

Since notifyListeners isn't currently exported, one option is to dispatch a synthetic popstate event after the URL update:

window.dispatchEvent(new PopStateEvent("popstate"));

(Though this would also trigger the popstate listener at line 326 which calls navigateRsc — so it needs care. Exporting notifyListeners from navigation.ts is probably cleaner.)


// Handle return value if present
if (result.returnValue) {
if (!result.returnValue.ok) throw result.returnValue.data;
return result.returnValue.data;
}
return undefined;
}
} catch (rscParseErr) {
// RSC parse failed — fall through to hard redirect below.
console.error(
"[vinext] RSC navigation failed, falling back to hard redirect:",
rscParseErr,
);
}
}

// Fallback: empty body (external URL, unmatched route, or parse error).
// Use hard redirect to ensure the navigation still completes.
if (redirectType === "push") {
window.location.assign(actionRedirect);
} else {
Expand Down
Loading
Loading