Skip to content

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

Open
yunus25jmi1 wants to merge 5 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 5 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: c714eb3

- 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

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