fix: server action redirects use soft RSC navigation instead of hard reload (#654)#698
Conversation
…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>
commit: |
- 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>
|
@southpolesteve @james-elicx review the PR. |
|
You don't need to tag us to ask for a review - we'll take a look when we get a chance :) |
|
/bigbonk review |
There was a problem hiding this comment.
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
-
Duplicate Set-Cookie headers — cookies are appended to
redirectHeadersat lines 1896-1899, then the same cookies are appended again to theredirectResponseat lines 1946-1951. SinceredirectResponsewas constructed withredirectHeaders(which already contain the cookies), every cookie will appear twice in the response. This will cause duplicate cookie writes on the client. -
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 callssetNavigationContext(null)at line 1888 and the new code overwrites it at 1915 without restoring it on failure. -
Client-side
setNavigationContext/setClientParamsnot called — whennavigateRsc()does a normal soft navigation (lines 285-298 inapp-browser-entry.ts), it callssetClientParams()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(), anduseParams()will return stale values from the previous page, not the redirect target.
Significant concerns
-
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. -
file-matcher.tsrewrite is unrelated and risky — replacingnode:fs/promisesglob()with a hand-rolled recursivereaddirimplementation is a large behavioral change bundled into a feature PR. The original code used Node's built-inglob()(which handles brace expansion,**, and edge cases correctly). The replacement has subtle issues:- The
excludecallback receivesentry.name(just the filename), but the originalglobAPI'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 withrelativeBasethat may not match correctly whenstemcontains path separators. - The
isGlobcheck only looks for*and**, missing?,[...], and{...}patterns thatbuildExtensionGlobgenerates. - There are no new tests for
scanWithExtensionsdespite rewriting its internals completely.
This should either be a separate PR with dedicated tests, or the Node.js version requirement should be documented instead.
- The
Minor issues
-
Test is in the wrong
describeblock — the new soft-navigation test is insidetest.describe("useActionState")but it tests basic server action redirect behavior, notuseActionState. It belongs in the"Server Actions"describe block. -
content-typedetection is fragile — the client checkscontentType.includes("text/x-component")to decide if there's an RSC payload, but the fallback path also sendsContent-Type: text/x-component(set inredirectHeaders) with anullbody. The check works because of the&& fetchResponse.bodyguard, butnew Response(null)can still have a non-null.bodyin some environments. A more robust signal (e.g., a dedicated header likex-action-rsc-prerender: 1) would be safer.
| // 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); | ||
| } |
There was a problem hiding this comment.
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:
| // 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); | |
| } |
| // Set navigation context for the redirect target | ||
| setNavigationContext({ | ||
| pathname: redirectUrl.pathname, | ||
| searchParams: redirectUrl.searchParams, | ||
| params: redirectParams, | ||
| }); |
There was a problem hiding this comment.
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).
| 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); | ||
| } |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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:
excludereceivesentry.name(bare filename) instead of the relative path thatglob's function-form exclude receivesisGlobonly checks for*/**, missing?,[...],{...}whichbuildExtensionGlobgenerates- 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 }) => { |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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.
|
Review posted. Here's a summary of the findings: 3 critical bugs:
2 significant concerns: 2 minor issues: |
- 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>
|
Changes done. |
|
/bigbonk review |
There was a problem hiding this comment.
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.
| // 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); | ||
| } |
There was a problem hiding this comment.
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:
| // 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); | |
| } |
| 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", |
There was a problem hiding this comment.
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):
| 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)) } : {}), | |
| }; |
| // Update the browser URL without a reload | ||
| if (redirectType === "push") { | ||
| window.history.pushState(null, "", actionRedirect); | ||
| } else { | ||
| window.history.replaceState(null, "", actionRedirect); | ||
| } |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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
relativeBaseparameter passed to recursive calls is the relative path of the current directory, but for non-glob stems the matching logic at line 150 comparesrelativePathagainstrelativeBase.${ext}— sincerelativeBaseis the directory path andrelativePathis 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 }) => { |
There was a problem hiding this comment.
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.
| // Set navigation context for the redirect target | ||
| setNavigationContext({ | ||
| pathname: redirectUrl.pathname, | ||
| searchParams: redirectUrl.searchParams, | ||
| params: redirectParams, | ||
| }); |
There was a problem hiding this comment.
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.
|
Review posted on PR #698. Here's a summary of the findings: 2 bugs found:
2 other concerns:
The PR also addressed several issues from the previous review: duplicate cookies in the fallback path were fixed, |
|
/bigbonk review |
There was a problem hiding this comment.
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); | ||
| } |
There was a problem hiding this comment.
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:
| } | |
| 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); |
There was a problem hiding this comment.
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:
| 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", |
There was a problem hiding this comment.
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.
| "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); | ||
| } |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
|
Review posted successfully. Here's a summary of what I found: 2 bugs (still present from previous review):
1 new bug:
1 minor issue:
All issues have inline suggestions with code fixes. |
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)
Client-side (app-browser-entry.ts)
Tests
Additional fix
Testing