Skip to content

fix: server action redirects use soft RSC navigation instead of hard reload (#654)#698

Open
yunus25jmi1 wants to merge 24 commits intocloudflare:mainfrom
yunus25jmi1:fix/issue-654-action-redirect-soft-nav
Open

fix: server action redirects use soft RSC navigation instead of hard reload (#654)#698
yunus25jmi1 wants to merge 24 commits intocloudflare:mainfrom
yunus25jmi1:fix/issue-654-action-redirect-soft-nav

Conversation

@yunus25jmi1
Copy link
Copy Markdown
Contributor

Fixes #654

What changed

This PR fixes the RSC parity gap where server action redirects caused full page reloads instead of SPA-style soft navigation like Next.js does.

Server-side (app-rsc-entry.ts)

  • When a server action calls redirect(), the server now pre-renders the redirect target's RSC payload
  • For same-origin routes: builds and renders the redirect target page to an RSC stream
  • For external URLs or unmatched routes: falls back to empty-body response (hard redirect)
  • Graceful error handling: if pre-rendering fails, falls back to hard redirect instead of 500

Client-side (app-browser-entry.ts)

  • Detects RSC payload via content-type header
  • When payload present: parses RSC stream, updates React tree with startTransition, updates URL via history.pushState/replaceState
  • Falls back to hard redirect for empty bodies or parse errors

Tests

  • Added E2E test verifying soft RSC navigation (no page reload on redirect)
  • All 11 server actions tests pass

Additional fix

  • Fixed file-matcher.ts to use manual glob implementation for Node.js < 22.14 compatibility

Testing

  • All server actions E2E tests pass (11/11)
  • TypeScript type checks pass
  • Build completes successfully

yunus25jmi1 and others added 3 commits March 27, 2026 23:18
…reload (cloudflare#654)

- Pre-render redirect target's RSC payload in app-rsc-entry.ts
- Client detects RSC payload and performs soft navigation in app-browser-entry.ts
- Falls back to hard redirect for external URLs or pre-render failures
- Add E2E test verifying no hard navigation events on same-origin redirects

This fixes the parity gap where server action redirects caused full page
reloads instead of SPA-style soft navigation like Next.js does.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The glob function from node:fs/promises is only available in Node.js 22.14+.
This replaces it with a manual recursive directory scan that supports
glob patterns like **/page for matching files at any directory depth.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Update client-side to properly detect RSC payload via content-type header
- Fix test to correctly detect soft vs hard navigation using page load events
- All 11 server actions tests now pass

This completes the fix for issue cloudflare#654.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 27, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@698

commit: db1c8ba

- Handle **/* pattern for matching all files with given extensions
- Handle **/page pattern for matching specific files at any depth
- Properly exclude api directories and _ prefixed files
- Update entry-templates snapshots to reflect soft navigation changes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yunus25jmi1
Copy link
Copy Markdown
Contributor Author

yunus25jmi1 commented Mar 28, 2026

@southpolesteve @james-elicx review the PR.

@james-elicx
Copy link
Copy Markdown
Collaborator

You don't need to tag us to ask for a review - we'll take a look when we get a chance :)

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Thanks for working on this — the feature direction is right (pre-rendering the redirect target for soft RSC navigation is how Next.js does it). However, there are several correctness issues that need to be fixed before this can be merged.

Critical bugs

  1. Duplicate Set-Cookie headers — cookies are appended to redirectHeaders at lines 1896-1899, then the same cookies are appended again to the redirectResponse at lines 1946-1951. Since redirectResponse was constructed with redirectHeaders (which already contain the cookies), every cookie will appear twice in the response. This will cause duplicate cookie writes on the client.

  2. Missing setNavigationContext(null) cleanup — the navigation context is set for the redirect target at line 1915 but never cleaned up. If pre-rendering fails (catch block at 1956) or the fallback path is taken, the navigation context is left pointing at the redirect target instead of being reset. The original code calls setNavigationContext(null) at line 1888 and the new code overwrites it at 1915 without restoring it on failure.

  3. Client-side setNavigationContext / setClientParams not called — when navigateRsc() does a normal soft navigation (lines 285-298 in app-browser-entry.ts), it calls setClientParams() to update the client-side navigation shims. The new soft-redirect code path (lines 155-190) skips this entirely. After a server action redirect, usePathname(), useSearchParams(), and useParams() will return stale values from the previous page, not the redirect target.

Significant concerns

  1. Middleware is bypassed for the redirect target — the pre-render calls matchRoute() + buildPageElement() directly, skipping the entire middleware pipeline. If a user's middleware sets auth headers, rewrites the path, or injects cookies for the target route, none of that will happen. Next.js does run middleware for the redirect target. This is a correctness gap that should at minimum be documented as a known limitation.

  2. file-matcher.ts rewrite is unrelated and risky — replacing node:fs/promises glob() with a hand-rolled recursive readdir implementation is a large behavioral change bundled into a feature PR. The original code used Node's built-in glob() (which handles brace expansion, **, and edge cases correctly). The replacement has subtle issues:

    • The exclude callback receives entry.name (just the filename), but the original glob API's function-form exclude receives the full relative path. Callers pass (name) => name === "api" which happens to work for directory names but would break for file-name exclusions at nested paths.
    • Non-glob stems (e.g., a literal path like pages/index) go through a convoluted matching path with relativeBase that may not match correctly when stem contains path separators.
    • The isGlob check only looks for * and **, missing ?, [...], and {...} patterns that buildExtensionGlob generates.
    • There are no new tests for scanWithExtensions despite rewriting its internals completely.

    This should either be a separate PR with dedicated tests, or the Node.js version requirement should be documented instead.

Minor issues

  1. Test is in the wrong describe block — the new soft-navigation test is inside test.describe("useActionState") but it tests basic server action redirect behavior, not useActionState. It belongs in the "Server Actions" describe block.

  2. content-type detection is fragile — the client checks contentType.includes("text/x-component") to decide if there's an RSC payload, but the fallback path also sends Content-Type: text/x-component (set in redirectHeaders) with a null body. The check works because of the && fetchResponse.body guard, but new Response(null) can still have a non-null .body in some environments. A more robust signal (e.g., a dedicated header like x-action-rsc-prerender: 1) would be safer.

Comment on lines +1945 to +1951
// 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);
}

Comment on lines +1914 to +1919
// Set navigation context for the redirect target
setNavigationContext({
pathname: redirectUrl.pathname,
searchParams: redirectUrl.searchParams,
params: redirectParams,
});
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 +163 to +174
if (isServerActionResult(result)) {
// Update the React tree with the redirect target's RSC payload
startTransition(() => {
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
});

* 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.

await expect(page.locator("h1")).toHaveText("useActionState Test");
});

test("server action redirect performs soft RSC navigation (issue #654)", async ({ page }) => {
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 test is inside test.describe("useActionState") but it doesn't test useActionState. It should be in the "Server Actions" describe block above (after line 122).

// 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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 28, 2026

Review posted. Here's a summary of the findings:

3 critical bugs:

  1. Duplicate Set-Cookie headers — cookies are appended to redirectHeaders and then appended again to the redirectResponse (which was constructed from those same headers), doubling every cookie in the response.
  2. Missing setNavigationContext(null) cleanup — if pre-rendering fails, the navigation context is left pointing at the redirect target instead of being reset.
  3. Client-side navigation context not updated — after soft navigation, usePathname(), useSearchParams(), and useParams() will return stale values because setNavigationContext/setClientParams are never called on the client side for this code path.

2 significant concerns:
4. Middleware is completely bypassed for the redirect target (pre-render calls matchRoute + buildPageElement directly).
5. The file-matcher.ts rewrite (replacing Node's glob() with a hand-rolled 70-line recursive walker) is unrelated to the feature, has no tests, and introduces subtle behavioral differences.

2 minor issues:
6. New test is in the wrong describe block (useActionState instead of Server Actions).
7. Content-type sniffing to detect RSC payload is fragile since the fallback also sends text/x-component.

github run

yunus25jmi1 and others added 2 commits March 28, 2026 20:11
- Fix duplicate Set-Cookie headers (collect cookies after rendering, not before)
- Add setNavigationContext(null) cleanup on pre-render failure and fallback
- Update client-side navigation context (setNavigationContext/setClientParams)
  so usePathname(), useSearchParams(), useParams() return correct values
- Add x-action-rsc-prerender header for robust RSC payload detection
- Document middleware bypass as known limitation in code comment
- Move soft navigation test to correct describe block (Server Actions)
- Remove file-matcher.ts changes (will be separate PR)

Fixes review comments from ask-bonk on PR cloudflare#654

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yunus25jmi1
Copy link
Copy Markdown
Contributor Author

Changes done.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Thanks for working on this — the approach is correct (pre-rendering the redirect target for soft RSC navigation), and the update addresses several issues from the previous review (duplicate cookies in the fallback path are fixed, x-action-rsc-prerender header was added for robust detection, setNavigationContext cleanup in the catch block was added). However, there are still a few correctness issues and the file-matcher.ts rewrite remains bundled in.

Issues to fix

1. Action cookies are dropped on successful pre-render (bug)

actionPendingCookies (cookies set during the server action itself, e.g. cookies().set(...)) are collected at line 1889 but never appended to the response when the pre-render succeeds. Only redirectPendingCookies (cookies set during the pre-render of the redirect target) are appended. This means any Set-Cookie calls made in the action before redirect() are silently lost.

The fallback path correctly uses actionPendingCookies, but the success path at lines 1950-1956 only appends redirectPendingCookies.

2. X-Vinext-Params header missing from pre-rendered response

The pre-render response doesn't include the X-Vinext-Params header with the matched route params. On the client side (line 185), fetchResponse.headers.get("X-Vinext-Params") will always be null, so setClientParams({}) is called. This means useParams() will return {} after a soft redirect to any route with dynamic segments (e.g., redirecting to /posts/[slug] would lose the slug param).

Compare with buildAppPageRscResponse in server/app-page-response.ts:169-172 which sets this header.

3. file-matcher.ts rewrite should be a separate PR

The previous review flagged this and it still applies: replacing node:fs/promises glob() with a 70-line hand-rolled recursive walker is a significant behavioral change that is unrelated to the server action redirect feature. It has no tests, and the implementation has known gaps (only checks for */** in isGlob, missing ?/[...]/{...}). Even though the current callers only use **/*, **/page, and **/route patterns (so the gaps don't bite today), this should be a focused, separately-tested change.

4. notifyListeners() not called after pushState/replaceState

This is a minor correctness issue. The normal client-side navigation path in navigation.ts calls notifyListeners() after history.pushState/replaceState to trigger useSyncExternalStore re-renders for usePathname(), useSearchParams(), etc. The new soft-redirect code path skips this. In practice, render(result.root) replaces the entire React tree so components remount with correct values from window.location, but any persistent components (e.g. in a layout that survives the transition) won't be notified of the URL change.

Comment on lines +1950 to +1956
// Append cookies (collected after rendering, not duplicated)
if (redirectPendingCookies.length > 0 || redirectDraftCookie) {
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: actionPendingCookies (cookies set by the server action before calling redirect()) are collected at line 1889 but never appended to this response. Only redirectPendingCookies (from the pre-render) are appended.

This means cookies like cookies().set('session', token) called before redirect('/dashboard') in a server action will be silently dropped.

Both sets of cookies need to be included:

Suggested change
// Append cookies (collected after rendering, not duplicated)
if (redirectPendingCookies.length > 0 || redirectDraftCookie) {
for (const cookie of redirectPendingCookies) {
redirectResponse.headers.append("Set-Cookie", cookie);
}
if (redirectDraftCookie) redirectResponse.headers.append("Set-Cookie", redirectDraftCookie);
}
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);
}

Comment on lines +1937 to +1943
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.

Missing X-Vinext-Params header. The client-side code at app-browser-entry.ts:185 reads this header to call setClientParams(). Without it, useParams() will return {} after a soft redirect to a dynamic route.

Add the params header (same as buildAppPageRscResponse does):

Suggested change
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",
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)) } : {}),
};

Comment on lines +168 to +173
// 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.

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.

* 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 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.

await expect(page.locator('[data-testid="redirect-btn"]')).toBeVisible();
});

test("server action redirect performs soft RSC navigation (issue #654)", async ({ page }) => {
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 test looks good and validates the core behavior well. One note: this is inside the "Server Actions" describe block (which is correct — the previous review's concern about it being in useActionState was addressed by the fact that it's placed before line 150 where useActionState begins).

Consider adding a test case for redirecting to a dynamic route (e.g., /posts/[slug]) to verify that useParams() works correctly after soft redirect. That would catch the missing X-Vinext-Params issue.

Comment on lines +1907 to +1912
// Set navigation context for the redirect target
setNavigationContext({
pathname: redirectUrl.pathname,
searchParams: redirectUrl.searchParams,
params: redirectParams,
});
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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 29, 2026

Review posted on PR #698. Here's a summary of the findings:

2 bugs found:

  1. Action cookies dropped on successful pre-renderactionPendingCookies (cookies set by the server action before redirect()) are collected but never appended to the response in the pre-render success path. Only redirectPendingCookies from the pre-render are included. This silently drops cookies like session tokens set before the redirect.

  2. Missing X-Vinext-Params header — The pre-rendered response doesn't include the X-Vinext-Params header, so useParams() returns {} after a soft redirect to any dynamic route (e.g., /posts/[slug]).

2 other concerns:

  1. file-matcher.ts rewrite still bundled — The previous review flagged this unrelated change (replacing glob() with a 70-line hand-rolled walker, no tests) and it should still be a separate PR.

  2. notifyListeners() not called after pushState — Minor: persistent layout components using usePathname()/useSearchParams() won't re-render after soft redirect, though full-tree replacement mitigates this in most cases.

The PR also addressed several issues from the previous review: duplicate cookies in the fallback path were fixed, x-action-rsc-prerender header was added for robust detection, and setNavigationContext(null) cleanup in the catch block was added.

github run

@james-elicx james-elicx marked this pull request as draft March 29, 2026 10:38
@yunus25jmi1 yunus25jmi1 marked this pull request as ready for review March 29, 2026 13:43
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Thanks for the continued work on this. The approach is correct and the file-matcher.ts change was removed as requested — good. However, there are still two bugs that need fixing (one flagged in the previous review and still present, one new), plus a correctness concern.

Bugs

1. Action cookies dropped on successful pre-render (from previous review, still present)

actionPendingCookies — cookies set by the server action before calling redirect() (e.g. cookies().set('session', token)) — are collected at line 1889 but never appended to the response when pre-rendering succeeds. Only redirectPendingCookies (from rendering the target page) are included. This silently drops session cookies and similar state.

2. headersContext is null during pre-render

setHeadersContext(null) is called at line 1891, before the pre-render attempt starts. The redirect target page's server components may call headers() or cookies() during rendering. Worse, renderToReadableStream returns a lazily-consumed stream — async server components will read headersContext during stream consumption, well after it's been cleared.

Compare with the normal action re-render path (lines 2015-2019), which explicitly says: "Do NOT clear headers/navigation context here — the RSC stream is consumed lazily by the client, and async server components that run during consumption need the context to still be live." The redirect pre-render violates this same principle.

The fix is to either: (a) not clear headersContext before the pre-render, or (b) create a fresh headersContextFromRequest(request) for the redirect target rendering.

3. Missing X-Vinext-Params header (from previous review, still present)

The pre-rendered response doesn't include the X-Vinext-Params header. The client reads it at line 185 to call setClientParams(). Without it, useParams() returns {} after a soft redirect to any dynamic route.

Minor

4. notifyListeners() not called after URL update

The normal navigation path in navigation.ts:608 calls notifyListeners() after pushState/replaceState to trigger useSyncExternalStore re-renders. The soft-redirect path skips this. Persistent layout components using usePathname() or useSearchParams() won't re-render. Since notifyListeners isn't exported, you could dispatch a synthetic popstate event or export the function.

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

if (actionRedirect) {
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
setHeadersContext(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.

Bug: headersContext is cleared here, but the pre-render at lines 1915-1931 builds and renders the redirect target page. If any server component in that page calls headers() or cookies(), it will get null context.

Worse, renderToReadableStream returns a lazily-consumed stream. Async server components that run during stream consumption (after return redirectResponse) will also see null headersContext. Compare with the normal re-render path at lines 2015-2019, which explicitly documents: "Do NOT clear headers/navigation context here — the RSC stream is consumed lazily."

Don't clear headersContext before the pre-render. Either leave it alive (matching the normal action re-render path), or create a fresh context for the redirect target:

Suggested change
setHeadersContext(null);
setHeadersContext(headersContextFromRequest(request));
setNavigationContext(null);

"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)) } : {}),
};

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.

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.)


// Fallback: external URL or unmatched route — client will hard-navigate.
// Clean up navigation context before returning.
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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 29, 2026

Review posted successfully. Here's a summary of what I found:

2 bugs (still present from previous review):

  1. Action cookies dropped on successful pre-renderactionPendingCookies (cookies set by the action before redirect(), like session tokens) are collected but never appended to the pre-rendered response. Only redirectPendingCookies from the target page render are included.

  2. Missing X-Vinext-Params header — The pre-rendered response doesn't include route params, so useParams() returns {} after soft redirect to dynamic routes.

1 new bug:

  1. headersContext is null during pre-rendersetHeadersContext(null) is called at line 1891 before the pre-render starts. If the redirect target page's server components call headers() or cookies(), they'll get null. This is especially problematic because renderToReadableStream returns a lazily-consumed stream — async server components run during stream consumption after the context is cleared. The normal re-render path explicitly documents not clearing context for this reason.

1 minor issue:

  1. notifyListeners() not called — Persistent layout components using usePathname()/useSearchParams() won't re-render after soft redirect.

All issues have inline suggestions with code fixes.

github run

@james-elicx james-elicx marked this pull request as draft March 30, 2026 19:52
- Fix action cookies being dropped on successful pre-render
- Fix headersContext being null during pre-render by refreshing it
- Fix missing X-Vinext-Params header in pre-rendered responses
- Fix client-side hooks not re-rendering after soft redirect (export and call notifyListeners)
- Remove type annotation from app-rsc-entry.ts to avoid parse errors
@yunus25jmi1 yunus25jmi1 marked this pull request as ready for review March 31, 2026 18:08
When rewrites resolve to static files in public/, middleware response
headers (Set-Cookie, security headers, etc.) were being silently dropped.
This fix ensures middleware headers are merged into static file responses
across all three server paths.

Changes:
- prod-server.ts: Pass middlewareHeaders to tryServeStatic() for both
  afterFiles and fallback rewrites
- index.ts: Call applyDeferredMwHeaders() before sending static file
  responses; add CONTENT_TYPES map for MIME types; use try/catch for
  error handling

This maintains parity with the existing tryServeStatic() call which
already included middleware headers.

Fixes cloudflare#654
@yunus25jmi1 yunus25jmi1 force-pushed the fix/issue-654-action-redirect-soft-nav branch from 6df9ef5 to f14713c Compare March 31, 2026 19:06
- Fix navigation context timing: Move context updates inside startTransition
  to ensure they only execute after successful RSC parsing. This prevents
  inconsistent state if fallback to hard redirect occurs.

- Document middleware limitation: Add comment explaining that middleware
  does not execute for pre-rendered redirect targets. This is a known
  limitation tracked for future work.

- Add same-route redirect test: Verify useActionState form state resets
  properly when a form redirects back to itself, matching
Next.js behavior
where redirect causes tree remount.

All checks pass (lint, type, format).
Implements soft RSC navigation for server action redirects, replacing hard
page reloads with SPA-style navigation via startTransition + RSC stream parsing.

Critical fixes:
- Always send X-Vinext-Params header (was missing for routes without params,
  breaking useParams() after redirect)
- Preserve headersContext during pre-render for lazy stream consumption
- Append action cookies unconditionally to redirect response
- Call notifyListeners() inside startTransition for layout re-renders
- Merge middleware headers into redirect response via
  __applyRouteHandlerMiddlewareContext

Client-side improvements:
- Detect pre-rendered payload via x-action-rsc-prerender header
- Update navigation context inside startTransition (prevents inconsistent state
  on fallback)
- Parse X-Vinext-Params for dynamic route params
- Graceful fallback to hard redirect on RSC parse failure

Test coverage:
- Add E2E test verifying soft navigation (no page load event)
- Add useActionState self-redirect test (form state reset after same-route
  redirect)

Documentation:
- Document middleware limitation (request matching doesn't run for redirect
  target, but response headers are preserved)

Fixes cloudflare#654
Fixes review findings
from PR cloudflare#698
@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 1, 2026

Review submitted successfully. Here's a summary of what I found:

Remaining bugs (2):

  1. URL update outside startTransitionhistory.pushState/replaceState runs after startTransition returns, but the transition is async. If render() throws, the URL will point at the redirect target while the page shows stale content. The URL update should be moved inside the startTransition callback (suggestion provided).

  2. headersContext uses original action request — The pre-render creates a headers context from the POST action request, so redirect target pages calling headers() will see action-specific headers (x-rsc-action, multipart/form-data content type). Should be documented as a known limitation.

Unrelated changes that should be separate PRs (2):

  1. CONTENT_TYPES map + applyDeferredMwHeaders refactor in index.ts — changes dev server static asset detection behavior
  2. prod-server.ts static asset serving for beforeFiles/fallback rewrites

Minor (2):

  1. Snapshot bloat (same 120-line block 4x duplicated = ~750 new lines)
  2. action-self-redirect fixture has no corresponding E2E test

Credit: The PR has addressed most issues from previous review rounds — duplicate cookies are fixed, X-Vinext-Params header is included, notifyListeners() is called, setNavigationContext(null) cleanup in catch is present, and the file-matcher.ts rewrite was removed.

github run

@james-elicx james-elicx marked this pull request as draft April 1, 2026 22:25
@yunus25jmi1 yunus25jmi1 marked this pull request as ready for review April 2, 2026 03:31
@yunus25jmi1
Copy link
Copy Markdown
Contributor Author

Review the changes.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Thanks for the continued iteration on this. The core feature (pre-rendering the redirect target for soft RSC navigation) is the right approach, and most bugs from previous reviews have been addressed — action cookies are now included, X-Vinext-Params is set, notifyListeners is handled via commitClientNavigationState, and setNavigationContext(null) cleanup in the catch block is present.

However, there's one remaining bug in the client-side navigation state management and several unrelated changes that should be split out.

Bug

latestClientParams not updated after redirect — The redirect code calls replaceClientParamsWithoutNotify(params) (line 560) but never updates the module-level latestClientParams. Compare with stageClientParams() (line 112-121) which does both. If a subsequent non-redirect server action fires on the redirect target page, it calls createClientNavigationRenderSnapshot(window.location.href, latestClientParams) at line 622 — latestClientParams will still hold the pre-redirect params, causing the tree to render with stale param context.

Unrelated changes to split out

Per the AGENTS.md guidelines ("Do not leave known bugs as 'follow-ups' — fix them in the same PR" applies to bugs in the same area, not unrelated features), these should be separate PRs:

  1. CONTENT_TYPES map + applyDeferredMwHeaders refactor in index.ts — Changes dev server static asset skip logic from pathname.includes('.') to an allowlist. This is a behavioral change (URLs like /api/v1.2/data that were previously skipped now fall through to the router). The applyDeferredMwHeaders signature change is also unrelated.

  2. prod-server.ts static asset serving for rewrites — Adds tryServeStatic calls for beforeFiles and fallback rewrites. Unrelated feature.

Both are potentially good changes but should be reviewed independently for clean bisection.

Minor

  1. The action-self-redirect fixture is added but has no E2E test exercising it. Either add a test or remove it from this PR.

const renderId = ++nextNavigationRenderId;
setter({ renderId, node: result.root, navigationSnapshot });

replaceClientParamsWithoutNotify(params);
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: latestClientParams is not updated here. replaceClientParamsWithoutNotify only stages the params in ClientNavigationState.pendingClientParams — it does not update the module-level latestClientParams variable. Compare with stageClientParams() at line 112-121 which does both:

function stageClientParams(params) {
  latestClientParams = params;  // <-- this is missing
  replaceClientParamsWithoutNotify(params);
}

After a soft redirect, if a subsequent non-redirect server action fires, createClientNavigationRenderSnapshot(window.location.href, latestClientParams) at line 622 will use stale pre-redirect params.

Use stageClientParams instead:

Suggested change
replaceClientParamsWithoutNotify(params);
stageClientParams(params);

// redirect without leaving navigation in an inconsistent state.
const navigationSnapshot = createClientNavigationRenderSnapshot(actionRedirect, params);

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).

// 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.

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.

};

/** 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.

resolvedUrl = rewritten;
resolvedPathname = rewritten.split("?")[0];

if (
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 static asset serving for afterFiles rewrites is unrelated to the server action redirect feature. Same for the fallback rewrite block at line 1617. Please split these into a separate PR.

Also: what happens when tryServeStatic returns false (file not found)? Execution falls through to resolvedUrl = rewritten which was already set, then continues to SSR rendering with a static-asset pathname (e.g., /styles/theme.css). That would produce an HTML 404 error page with a .css URL, which is odd. Consider returning a proper 404 response for missing static assets instead of falling through to SSR.

);
// Ensure transient redirect navigation state is cleared before
// forcing a full-page navigation fallback.
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.

Same issue as line 560 — latestClientParams should be reset when clearing the redirect state. Currently only replaceClientParamsWithoutNotify({}) is called, leaving latestClientParams stale.

Suggested change
setNavigationContext(null);
setNavigationContext(null);
stageClientParams({});
commitClientNavigationState();

{state.error && <p data-testid="error">{state.error}</p>}
</div>
);
}
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 fixture is added but there's no E2E test exercising the self-redirect case. Self-redirect (action redirects back to the same page) is a useful edge case that tests whether form state resets properly, but dead fixtures without tests add maintenance burden. Either add a test for it in this PR or remove it and add it in a follow-up.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 2, 2026

Review posted on PR #698. Here's a summary of the findings:

1 bug:

  • latestClientParams not updated after redirect — The redirect code calls replaceClientParamsWithoutNotify(params) but never updates the module-level latestClientParams. This means subsequent non-redirect server actions on the redirect target page will use stale pre-redirect params when creating navigation snapshots. Fix: use stageClientParams(params) instead, which updates both. Same issue exists in the error cleanup path (line 588-589).

2 unrelated changes that should be split out:

  • CONTENT_TYPES allowlist + applyDeferredMwHeaders refactor in index.ts — changes dev server static file skip behavior
  • prod-server.ts static asset serving for beforeFiles/fallback rewrites — unrelated feature

1 minor:

  • action-self-redirect fixture added with no corresponding E2E test

1 note (non-blocking):

  • The code bypasses renderNavigationPayload/activateNavigationSnapshot, which the existing code (line 611-618) explicitly says is needed for URL-changing server action flows. Works for now since the full tree is replaced, but persistent layout components won't see snapshot-provided destination URLs during the transition.

github run

@james-elicx james-elicx marked this pull request as draft April 2, 2026 10:17
@yunus25jmi1
Copy link
Copy Markdown
Contributor Author

review the changes.

@yunus25jmi1 yunus25jmi1 marked this pull request as ready for review April 3, 2026 15:54
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review Summary

The core feature is the right approach — pre-rendering the redirect target's RSC payload for soft SPA navigation matches Next.js behavior and addresses #654. The code has improved significantly across the review rounds: duplicate cookies are fixed, X-Vinext-Params is included, setNavigationContext(null) cleanup is present in the catch block, headers context is preserved for lazy stream consumption, and the file-matcher.ts rewrite was correctly removed. The self-redirect fixture now has a corresponding E2E test, which is good.

However, there is one remaining bug from the previous review round (flagged by bonk on 2026-04-02) that hasn't been addressed, and the PR still bundles unrelated changes that should be split out.

Bug: latestClientParams not updated after redirect

replaceClientParamsWithoutNotify(params) at line 560 does not update the module-level latestClientParams. Compare with stageClientParams() (lines 112-121) which does both:

function stageClientParams(params) {
  latestClientParams = params;  // <-- this update is missing at line 560
  replaceClientParamsWithoutNotify(params);
}

After a soft redirect, if a subsequent non-redirect server action fires on the redirect target page, createClientNavigationRenderSnapshot(window.location.href, latestClientParams) at line 622 will use stale pre-redirect params. The same issue exists in the error cleanup path at line 589.

Unrelated changes to split out

Per AGENTS.md guidelines, unrelated changes should be separate PRs for clean bisection:

  1. CONTENT_TYPES map + applyDeferredMwHeaders refactor in index.ts — Changes dev server static asset skip logic from pathname.includes('.') to a known-extension allowlist. This is a behavioral change (URLs like /api/v1.2/data that were previously skipped now fall through to the router). The applyDeferredMwHeaders signature change (adding response and headers parameters, filtering x-middleware- headers) is also unrelated.

  2. prod-server.ts static asset serving for afterFiles/fallback rewrites — Adds tryServeStatic calls for rewrite targets. Unrelated feature.

Both may be good changes but should be reviewed independently.

Minor note

The comment at line 617-618 in app-browser-entry.ts ("If server actions ever trigger URL changes via RSC payload...") is now stale — this PR introduces exactly that behavior via the new redirect code path. Consider updating or removing it in a follow-up.

const renderId = ++nextNavigationRenderId;
setter({ renderId, node: result.root, navigationSnapshot });

replaceClientParamsWithoutNotify(params);
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): replaceClientParamsWithoutNotify does not update the module-level latestClientParams. After a soft redirect, if a subsequent non-redirect server action fires on the redirect target page, createClientNavigationRenderSnapshot(window.location.href, latestClientParams) at line 622 will use stale pre-redirect params.

Use stageClientParams instead, which updates both:

Suggested change
replaceClientParamsWithoutNotify(params);
stageClientParams(params);

// Ensure transient redirect navigation state is cleared before
// forcing a full-page navigation fallback.
setNavigationContext(null);
replaceClientParamsWithoutNotify({});
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.

Same issue — latestClientParams should be reset when clearing redirect state in the error path. Use stageClientParams for consistency:

Suggested change
replaceClientParamsWithoutNotify({});
stageClientParams({});

};

/** 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 associated changes to static asset skip logic (line 2515: CONTENT_TYPES[ext] instead of the old pathname.includes('.')) are a behavioral change to the dev server that is unrelated to server action redirect soft navigation.

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

The applyDeferredMwHeaders signature change (adding response and headers parameters, filtering x-middleware- prefixed headers) is also a functional change unrelated to the redirect feature.

resolvedUrl = rewritten;
resolvedPathname = rewritten.split("?")[0];

if (
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 tryServeStatic call for afterFiles rewrites (and the similar one at line 1668 for fallback rewrites) is unrelated to the server action redirect feature. Please split these into a separate PR.

Also: when tryServeStatic returns false (file not found), execution falls through and SSR renders the rewrite target. If the rewrite target is a static asset path like /styles/theme.css, the SSR handler will produce an HTML error page at a .css URL, which is unexpected. Consider returning a proper 404 for missing static assets in rewrite targets rather than falling through to SSR.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 3, 2026

Review submitted successfully on PR #698. Here's a summary of the findings:

1 bug (still present from previous review round):

  • latestClientParams not updated after redirectreplaceClientParamsWithoutNotify(params) at line 560 doesn't update latestClientParams, so subsequent non-redirect server actions on the redirect target page will use stale pre-redirect params. The fix is to use stageClientParams(params) instead. Same issue in the error cleanup path at line 589. Both have inline suggestions.

2 unrelated changes that should be split out:

  • CONTENT_TYPES allowlist + applyDeferredMwHeaders refactor in index.ts — behavioral change to dev server static asset detection
  • prod-server.ts tryServeStatic for afterFiles/fallback rewrites — unrelated feature

1 minor note (non-blocking):

  • Stale comment at line 617-618 about server actions not triggering URL changes via RSC payload — this PR introduces exactly that behavior

github run

@james-elicx james-elicx marked this pull request as draft April 3, 2026 20:59
@yunus25jmi1 yunus25jmi1 marked this pull request as ready for review April 4, 2026 03:37
@yunus25jmi1 yunus25jmi1 marked this pull request as draft April 4, 2026 03:45
@yunus25jmi1 yunus25jmi1 marked this pull request as ready for review April 4, 2026 15:53
@yunus25jmi1
Copy link
Copy Markdown
Contributor Author

Review the changes.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review Summary

The core approach is correct — pre-rendering the redirect target's RSC payload for soft SPA navigation is exactly what Next.js does, and this is a real parity gap that needs fixing. The PR has been through multiple review rounds and has addressed many issues (duplicate cookies fixed, X-Vinext-Params included, notifyListeners handled via commitClientNavigationState, headers context preserved for lazy stream consumption, file-matcher.ts rewrite removed). Good progress.

However, there are still issues that need to be addressed before this can merge.

Remaining bug

  1. getAndClearPendingCookies() called too early for redirect pre-rendergetAndClearPendingCookies() is called synchronously at line 1982 right after renderToReadableStream(). But renderToReadableStream returns a lazily-consumed stream — async server components in the redirect target page that call cookies().set() haven't executed yet at this point. Those cookies will be missed by this getAndClearPendingCookies() call. Compare with the normal re-render path (line ~2081) which has the same structural issue but is documented. This is a pre-existing pattern, so not a blocker for this PR, but worth a comment noting the limitation.

Unrelated changes that should be split out

  1. CONTENT_TYPES map + applyDeferredMwHeaders refactor in index.ts — This changes the dev server's static asset detection from pathname.includes('.') to a known-extension allowlist, which is a behavioral change. URLs like /api/v1.2/data that were previously skipped now fall through to the router. The applyDeferredMwHeaders signature change (adding response and headers params, filtering x-middleware- headers) is also a functional change. Both may be improvements, but they're unrelated to server action redirect soft navigation and should be in a separate PR.

  2. prod-server.ts tryServeStatic for rewrites — Adding static asset serving for afterFiles and fallback rewrites is a separate feature. Also: when tryServeStatic returns false, execution falls through to SSR with a static-asset pathname (e.g., /styles/theme.css), which would produce an HTML error page at a .css URL.

This has been flagged in every review round. Please split these into separate PRs.

Minor

  1. The action-self-redirect fixture has no E2E test. Either add one or remove it from this PR.

  2. Snapshot bloat: the same ~120-line block is duplicated 4x in the snapshot file, adding ~750 lines. This is a consequence of how the entry template generates code, but it's worth noting for future consideration (extracting the redirect pre-render logic into a runtime helper per AGENTS.md guidelines about keeping generated entries thin would reduce this).

);

// Collect cookies after rendering (same as normal action response)
const redirectPendingCookies = getAndClearPendingCookies();
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.

Note: getAndClearPendingCookies() is called synchronously here, but the rscStream above is lazily consumed. Async server components in the redirect target that call cookies().set() during stream consumption will execute after this point, so their cookies will be missed.

This is the same structural limitation as the normal re-render path further down, so it's not a regression. But a comment noting this would help future readers:

Suggested change
const redirectPendingCookies = getAndClearPendingCookies();
// Collect cookies set synchronously during rendering. Note: cookies
// set by async server components during lazy stream consumption
// will not be captured here (same limitation as the normal re-render
// path below).
const redirectPendingCookies = getAndClearPendingCookies();

// 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(
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).

".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 allowlist and the associated change at line 2515 (CONTENT_TYPES[ext] replacing pathname.includes('.')) is a behavioral change to dev server static asset detection that is unrelated to server action redirect soft navigation.

Previously, any URL with a dot (except .html) was skipped. Now, only extensions in this allowlist are skipped. URLs like /api/v1.2/data or /file.unknown will now fall through to the router instead of being skipped. That may be correct, but it's a separate concern.

The applyDeferredMwHeaders signature change (adding explicit response/headers params, filtering x-middleware- prefixed headers) is also an unrelated functional change.

Please split both into a separate PR.

middlewareHeaders,
))
) {
return;
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 tryServeStatic for afterFiles rewrites (and the fallback equivalent at line 1680) is unrelated to the server action redirect feature. Please split into a separate PR.

Also: when tryServeStatic returns false (file not found), execution continues with resolvedUrl = rewritten already set, then proceeds to SSR. If the rewrite target is a static asset path like /styles/theme.css, the SSR handler would produce an HTML error page served at a .css URL. Consider returning a 404 for missing static assets in rewrite targets.

// redirect without leaving navigation in an inconsistent state.
const navigationSnapshot = createClientNavigationRenderSnapshot(actionRedirect, params);

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 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.

{state.error && <p data-testid="error">{state.error}</p>}
</div>
);
}
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 fixture is added but there's no E2E test exercising it. The self-redirect case (action redirects back to the same page) is a useful edge case — it tests whether form state resets properly and whether the RSC tree is re-rendered when the pathname doesn't change.

Either add a test in server-actions.spec.ts or remove this fixture from this PR. Dead fixtures add maintenance burden.

Comment on lines +2033 to 2036
// 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: setHeadersContext(null) and setNavigationContext(null) here are redundant when falling through from the catch block (lines 2028-2029 already do the same cleanup). They're needed when the try block's inner conditions (same-origin, matched route) are not met and execution falls through without entering the catch. A brief 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 contexts (also handles the case where the try block's
// conditions were not met and execution fell through without catching).
setHeadersContext(null);
setNavigationContext(null);

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 5, 2026

Review submitted successfully. Here's a summary of what I found:

1 non-blocking bug (with suggestion):

  • getAndClearPendingCookies() is called synchronously after renderToReadableStream(), but the stream is lazily consumed. Async server components that call cookies().set() during stream consumption will have their cookies missed. This matches the pre-existing pattern in the normal re-render path, so it's not a regression — just needs a documenting comment.

2 unrelated changes that should be split out (flagged in every prior review round):

  • CONTENT_TYPES allowlist + applyDeferredMwHeaders refactor in index.ts — behavioral change to dev server static asset detection
  • prod-server.ts tryServeStatic for afterFiles/fallback rewrites — separate feature with a potential bug (missing static assets fall through to SSR producing HTML at a .css URL)

3 minor items:

  • action-self-redirect fixture has no E2E test — either add one or remove it
  • Generated entry code should be extracted to a runtime helper per AGENTS.md guidelines (would also eliminate ~750 lines of snapshot duplication)
  • Comment at lines 2033-2036 could be clearer about why the context cleanup is needed in both the catch block and the fallback path

github run

Add static file serving for beforeFiles rewrite targets in the Pages
Router production server. This matches Next.js behavior where beforeFiles
rewrites can resolve to static files in public/ or other filesystem paths.

The fix passes middleware headers (including Set-Cookie) to the static
file response, ensuring middleware-set headers appear on static assets.

This resolves the issue from PR cloudflare#776 where beforeFiles rewrites that
target static files were not being served correctly.
…handling

Add comment clarifying that cookies set by async server components
during lazy stream consumption are not captured at the redirect point.
This matches the same limitation in the normal re-render path below.
Update the generated entry snapshots to reflect the clarifying comment
about cookie collection timing in redirect handling.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RSC parity gap: action redirects use hard navigation instead of soft RSC navigation

2 participants