fix(app-router): preserve forwarded action redirect wrappers#1582
fix(app-router): preserve forwarded action redirect wrappers#1582NathanDrake2406 wants to merge 13 commits into
Conversation
commit: |
There was a problem hiding this comment.
Pull request overview
Aligns Vinext App Router server-action behavior with Next.js for redirecting actions, especially when the visible route is stale or the action is effectively “forwarded”, by ensuring the client receives a consumable Flight wrapper (and correct wrapper-vs-redirect status semantics) and by improving client-side error/redirect handling.
Changes:
- Server: render same-origin redirect targets into the action POST Flight response when possible, preserve cookies/revalidation headers, and select wrapper status semantics for forwarded/stale/cross-runtime redirects.
- Browser: keep action POSTs on the visible route URL, send the public
next-actionheader, improve handling of non-RSC action error responses, and hard-navigate when redirect Flight is incompatible. - Routing/metadata/tests: thread segment
runtimeinto route config, refine root boundary derivation in the generated manifest, and add/extend unit + e2e fixtures/tests for the new Next.js-compat scenarios.
Reviewed changes
Copilot reviewed 36 out of 36 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/page.tsx | Adds compat fixture page for action response semantics. |
| tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/not-found.tsx | Adds compat not-found boundary for action notFound assertions. |
| tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/error.tsx | Adds compat error boundary for surfaced action errors. |
| tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/client.tsx | Client UI to trigger complete/redirect/notFound actions. |
| tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/actions.ts | Server actions to drive semantics tests (return/redirect/notFound). |
| tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/target/page.tsx | Target page asserts cookies merged and next-action stripped. |
| tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/page.tsx | Source page action mutates cookies then redirects. |
| tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/page.tsx | Fixture page for forwarded redirect scenario. |
| tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/other/page.tsx | Fixture “other” page for stale child-route redirect test. |
| tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/node/page.tsx | Fixture node target page for cross-runtime redirect test. |
| tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/page.tsx | Fixture edge page using cross-runtime client. |
| tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/other/page.tsx | Fixture edge “other” page for cross-runtime stale route test. |
| tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/layout.tsx | Sets runtime = "edge" for cross-runtime route metadata. |
| tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/cross-runtime-client.tsx | Client triggers delayed cross-runtime redirect action. |
| tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/client.tsx | Client triggers delayed forwarded redirect action. |
| tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/actions.ts | Redirecting server actions for forwarded/cross-runtime cases. |
| tests/fixtures/app-basic/app/action-redirect-test/redirect-form.tsx | Updates redirect trigger from form submit to button click transition. |
| tests/entry-templates.test.ts | Adds regression test for root boundary derivation without root page. |
| tests/e2e/app-router/server-actions.spec.ts | Adds e2e coverage for action visible-route POSTs, redirects, stale/cross-runtime wrapper semantics, and invalid responses. |
| tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts | Adjusts POST URL matching for visible-route action requests. |
| tests/app-server-action-execution.test.ts | Extends unit coverage for redirect rendering, cookie replay, forwarded wrapper semantics, and redirected Flight behavior. |
| tests/app-segment-config.test.ts | Adds tests for segment runtime resolution/unknown runtime ignoring. |
| tests/app-rsc-cache-busting.test.ts | Adds test ensuring server action POST URLs stay on visible route (strip hash). |
| tests/app-route-handler-response.test.ts | Adds test for returned Set-Cookie normalization when overriding fallbacks. |
| tests/app-browser-entry.test.ts | Adds unit tests for new action response parsing/compatibility/error behavior and next-action header inclusion. |
| packages/vinext/src/shims/error-boundary.tsx | Formatting-only state object changes. |
| packages/vinext/src/server/app-server-action-execution.ts | Core server-side redirect rendering, wrapper status selection, cookie replay into redirected GET render, soft tag threading, forwarded rerender suppression changes. |
| packages/vinext/src/server/app-segment-config.ts | Parses and threads supported runtime values into effective segment config. |
| packages/vinext/src/server/app-rsc-cache-busting.ts | Adds createServerActionRequestUrl() helper to keep action POSTs on visible route URL. |
| packages/vinext/src/server/app-route-handler-response.ts | Normalizes returned Set-Cookie values by ensuring Path=/ when missing. |
| packages/vinext/src/server/app-browser-state.ts | Adds public next-action header to action request state. |
| packages/vinext/src/server/app-browser-entry.ts | Updates browser action dispatch to visible-route URLs, improves redirect Flight handling, non-RSC error surfacing, compatibility hard-nav logic, and 404 head metadata syncing. |
| packages/vinext/src/server/app-browser-action-result.ts | Adds helpers for non-RSC action error extraction, compatibility checks, and HTTP fallback normalization. |
| packages/vinext/src/entries/app-rsc-manifest.ts | Improves root boundary (notFound/forbidden/unauthorized/layout) derivation when no root page exists. |
| packages/vinext/src/entries/app-rsc-entry.ts | Threads route runtime resolution into server action execution options. |
| .github/workflows/zizmor.yml | Adds contents: read permission for zizmor workflow. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 12f041d28a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
/bigbonk review |
There was a problem hiding this comment.
Review
This is a substantial and well-structured PR that closes a meaningful parity gap with Next.js server-action redirect behavior. The core design — rendering the redirect target inline during the action POST and using wrapper status semantics for forwarded/stale/cross-runtime cases — is sound and well-motivated by the upstream Next.js references.
Test coverage is strong: the unit tests for app-server-action-execution are thorough (redirect rendering, cookie replay, context lifecycle, forwarded/stale/cross-runtime wrapping), and the Playwright E2E tests cover the key user-visible scenarios. Good provenance discipline with // Ported from Next.js: annotations.
The main areas where I have feedback are: (1) a potential cookie value round-trip corruption in the redirect render path, (2) an unexplained branching condition in the browser action dispatch, (3) the ACTION_FORWARDED_HEADER removal from the progressive action path, and (4) a few minor clarity items. See inline comments.
|
Review submitted. Here's a summary of the feedback I posted: Overall assessment: Strong PR with good design, thorough test coverage, and clear upstream references. The core approach of rendering redirect targets inline during the action POST is sound. Key feedback points:
|
b0d2df8 to
ce25702
Compare
|
Addressed the review feedback from @ask-bonk:
All 262 targeted tests pass cleanly. Rebased on current upstream/main. |
|
/bigbonk review |
There was a problem hiding this comment.
Review
This is a well-designed PR that closes a real parity gap with Next.js server-action redirect behavior. The core approach — rendering the redirect target inline during the action POST and using wrapper status semantics for forwarded/stale/cross-runtime cases — is sound, well-referenced to upstream Next.js code, and backed by good test coverage at both unit and E2E levels.
The previous review from @ask-bonk was thorough and most feedback was addressed. I have one material issue (the null runtime normalization that was claimed fixed but appears missing), a couple of minor observations, and some positive callouts.
Summary
- 1 issue — implicit Node → explicit Edge runtime comparison still broken (see inline)
- 1 nit — duplicate
runtimeassignment in segment config loop - 1 observation —
rootRouteBoundaryPathfindIndexalways returns 0 - Good overall design, test discipline, and upstream provenance annotations
|
Review submitted. Here's a summary of the feedback I posted: Overall assessment: Well-designed PR that closes a meaningful Next.js parity gap. The core approach of rendering redirect targets inline during the action POST and using wrapper status semantics is sound, well-referenced to upstream Next.js, and has strong test coverage. Key feedback:
|
|
/bigbonk review |
There was a problem hiding this comment.
Review
This is a well-executed PR that closes a meaningful Next.js parity gap for server-action redirects. The core design — rendering the redirect target inline during the action POST, using wrapper status semantics for forwarded/stale/cross-runtime cases, and deferring context cleanup until stream consumption — is sound and well-motivated by the upstream references.
The previous review cycles have been productive; the runtime normalization fix, duplicate segment config cleanup, and cookie encoding fixes are all confirmed in the final code. Test coverage is strong at both unit and E2E levels.
I have a few remaining items below — one stale/misleading comment, one potential correctness issue, and a few observations.
Summary
- 1 nit — misleading comment on cookie value encoding (no behavioral impact)
- 1 question —
readInvalidServerActionResponseErrorrejects 200 OK non-RSC responses as errors - 1 observation —
resolveServerActionRedirectLocationis now dead code in production - 1 minor — relative URL resolution includes search params in base, which is spec-correct but could surprise
|
Review submitted. Here's a summary of the feedback I posted: Overall assessment: Well-executed PR that closes a meaningful Next.js parity gap. The core approach of rendering redirect targets inline during the action POST, wrapper status semantics for forwarded/stale/cross-runtime cases, and deferred context cleanup via Key feedback:
|
baecc5e to
59e3053
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Review
This is a well-executed PR that closes a meaningful Next.js parity gap for server-action redirect behavior. The core design is sound: rendering the redirect target inline during the action POST, using wrapper status semantics (200 vs 303) for forwarded/stale/cross-runtime cases, and deferring clearRequestContext until the RSC stream is fully consumed via createServerActionRscResponse.
All feedback from the three previous review cycles has been addressed: runtime normalization, duplicate segment config removal, cookie encoding fix, args.length branching removal, stale comments, trailing semicolon handling, and default redirect type. The code is in good shape.
Test coverage is strong — 455+ new lines in the server action unit tests, 183 new lines in E2E, and good provenance discipline with // Ported from Next.js: annotations. The test fixtures are well-structured with appropriate separation of server/client concerns.
Minor items
I have two minor observations and one comment-level nit. None are blocking.
1. readInvalidServerActionResponseError is a strict non-RSC gate including 200 OK.
This function (in app-browser-action-result.ts:121-138) returns an error for any response that isn't text/x-component and doesn't have a redirect header — including a 200 OK with application/json. This matches Next.js behavior (their server-action reducer rejects non-Flight responses regardless of status), so it's correct for parity. Just flagging for awareness: if a middleware or CDN ever rewrites an action response's content-type, this will throw rather than attempt to process the body. The existing readInvalidServerActionResponseError name makes this behavior reasonably discoverable.
2. isStaleChildSiblingRouteRedirect — naming vs semantics.
The function detects redirects where the current path is deeper than the target and they share a partial prefix but diverge before the target ends. The name says "child sibling" but it more precisely detects "divergent cousin" redirects (target diverges from an ancestor of current). The behavior is correct — these are exactly the cases where the visible-route worker wouldn't own the target page in Next.js's multi-worker model. The name is fine for now but could be clarified if the function's scope ever changes.
3. Comment precision on shouldSkipPageRendering.
The comment at app-server-action-execution.ts:1016-1019 says "Only skip when the action status is 200 and no revalidation side-effects occurred" but doesn't mention the actionWasForwarded case (line 1021) which also skips unconditionally. The comment was added per a previous review — it just needs a small amendment to cover the forwarded case for completeness.
|
Review submitted. Here's a summary of what I found: Overall assessment: Approved. This is a well-executed PR that closes a meaningful Next.js parity gap. All feedback from three previous review cycles has been addressed and verified in the current code. What the PR does:
What I verified:
One minor suggestion posted: Expanding the |
d0449c6 to
878dfab
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Review
This is a well-executed PR that closes a meaningful Next.js parity gap for server-action redirect behavior. The core architectural shift — rendering the redirect target inline during the action POST instead of returning an empty redirect and forcing a second browser navigation — is sound and well-motivated by the upstream Next.js references.
What I verified
Server-side redirect rendering (app-server-action-execution.ts):
- The
resolveInternalActionRedirectTarget→matchRoute→canRenderActionRedirectTarget→createActionRedirectRenderRequest→ render pipeline is clean and has correct fallbacks (external URLs → 303 header-only, non-page routes → 303 header-only). createServerActionRscResponsecorrectly defersclearRequestContextviadeferUntilStreamConsumedfor stream bodies while clearing immediately for non-stream bodies. This prevents the AsyncLocalStorage race that existed before.- Cookie replay via
applySetCookieMutationsToRequestCookieHeadercorrectly merges Set-Cookie mutations into the redirect render request's Cookie header, with proper handling of expired cookies (max-age=0and epochexpires). ThereadSetCookieNameValueextraction is now verbatim (no encode/decode asymmetry). - The
shouldUseForwardedActionRedirectStatuslogic correctly handles all four cases (forwarded, ancestor, stale-sibling, cross-runtime) and thenormalizeRuntimehelper resolvesnull→"nodejs"so implicit-Node → explicit-Edge is detected. - Set-Cookie and
x-action-revalidatedheaders are now properly emitted on the skip-rendering path, fixing the forwarded action side-effect loss bug.
Browser-side action dispatch (app-browser-entry.ts):
- The switch from
createRscRequestUrltocreateServerActionRequestUrlfor action POSTs correctly targets the visible route URL, matching Next.js behavior. resolveActionRedirectTargetproperly handles absolute, relative, and slash-prefixed redirects with correct cross-origin detection and hard-navigation fallback.- The RSC compatibility check gating (
shouldCheckRscCompatibilityForServerActionResponse) correctly prevents non-RSC error responses from being misclassified as deployment skew. - The 303→200 status normalization before
createFromFetchis necessary because React's Flight client rejects non-2xx responses. - The
ServerActionRedirectErrorwithhandled = trueand properNEXT_REDIRECTdigest matches the expected React error boundary integration.
Segment config and manifest (app-segment-config.ts, app-rsc-manifest.ts):
- The runtime type narrowing to the union type and
isRouteSegmentRuntimeguard are clean. The duplicate assignment was correctly removed. - The
findRootBoundaryRoutefallback for apps without a root page is a good fix — it prevents boundary loss whenapp/not-found.tsxexists butapp/page.tsxdoesn't.
Test coverage:
- 455+ new unit test lines covering redirect rendering, cookie replay, deferred context cleanup, forwarded/stale/cross-runtime wrapper semantics, and HTTP fallback status.
- 183 new E2E lines covering text/plain error propagation, notFound boundary rendering, single-POST redirect completion, cookie merging, stale-route wrapper status, and cross-runtime wrapper status.
- Good provenance discipline with
// Ported from Next.js:annotations throughout.
Issues found
I found two minor items (both inline below). Neither is blocking.
Good work overall — the design is clean, the fallback paths are correct, and the test coverage is thorough.
|
Review submitted. Here's a summary of the feedback I posted: Overall assessment: Approved. This is a well-executed PR that closes a meaningful Next.js parity gap for server-action redirect behavior. The core architectural shift — rendering the redirect target inline during the action POST instead of returning an empty redirect — is sound, well-referenced to upstream Next.js, and has thorough test coverage. What I verified:
Two minor inline comments posted (neither blocking):
|
Server action redirects could fall back to hard HTTP redirects when the action POST represented stale visible-route state or a cross-runtime worker hop. That diverges from Next.js, where forwarded action redirects return an RSC wrapper response that the client can apply without treating the action as a failed network redirect. The action pipeline now renders same-origin redirect targets into the POST response, preserves action cookies for the target render, and uses the wrapper status for forwarded, stale child-route, and cross-runtime redirect shapes. Browser action handling now posts to the visible route with the public next-action header and only runs RSC compatibility checks for real Flight responses. Regression coverage ports the relevant Next.js action cases across helper tests and app-router E2E fixtures.
Forwarded non-redirect actions could skip page rendering without preserving Set-Cookie or x-action-revalidated headers, and same-origin redirect rendering treated any App route match as a page render target. Redirect Flight responses also bypassed the RSC compatibility guard. Gate inline redirect rendering to actual page routes, propagate forwarded action side-effect headers on the no-rerender path, and hard-navigate redirect responses when their Flight compatibility ID does not match the client. Text/plain action failures now accept charset-qualified content types. Regression coverage exercises route-handler redirect fallback, forwarded cookie and revalidation propagation, redirect Flight compatibility fallback, and text/plain;charset=utf-8 action errors.
The zizmor online impostor-commit audit failed before auditing because the job token had no contents read scope. The audit needs to query GitHub tag and commit metadata for referenced actions such as voidzero-dev/setup-vp. Grant contents: read to the zizmor job while keeping checkout credentials unpersisted and security-events scoped to the existing upload path.
- Remove encodeURIComponent round-trip in applySetCookieMutationsToRequestCookieHeader; Cookie header values are raw, not URL-encoded - Guard segment runtime fallback with isRouteSegmentRuntime to ignore unknown values - Replace indexOf with findIndex in rootRouteBoundaryPath for clarity - Relax expired-cookie date regex to accept 'Thu, 1 Jan 1970' without leading zero - Document args.length branching for programmatic vs form action redirect semantics - Document actionStatus === 200 guard on skip-page-render condition - Document why progressive action path omits forwarded-action header check - Comment createPendingServerActionRedirectPromise per React suspension semantics - Fix edge-runtime redirect test to expect 303 (same-runtime, non-forwarded) - Remove unused navigateClientSide import after redirect path refactor
The root layout position is always layoutTreePositions[0], so indexOf / findIndex was a no-op dance. The boundary at position 0 is just boundaryPaths[0].
…ts, and config duplicate
878dfab to
f9d475d
Compare
…y, broaden expired cookie regex, fix redirect dedup test status
|
/bigbonk review |
There was a problem hiding this comment.
Review
This is a well-designed PR that closes a meaningful Next.js parity gap. The core shift — rendering redirect targets inline during the action POST and returning a Flight payload with redirect control headers — is the right architecture. The deferred cleanup pattern (createServerActionRscResponse + deferUntilStreamConsumed), cookie replay into the synthetic redirect render request, and wrapper status selection logic are all clean and well-tested.
This has been through five review cycles and all prior feedback appears addressed. I found one minor correctness concern, one test coverage gap, and a couple of observations. Nothing blocking.
Summary
- 1 minor issue —
readInvalidServerActionResponseErrorguards ordering relative torevalidationparsing - 1 test gap — no coverage for relative redirect URLs in the redirect rendering path
- 2 observations — duplicated URL resolution logic, E2E timing dependency
What I verified
- Redirect rendering pipeline:
resolveInternalActionRedirectTarget→matchRoute→canRenderActionRedirectTarget→createActionRedirectRenderRequest→ render →createServerActionRscResponse. Fallbacks to 303 header-only responses for external, unmatched, and route-handler targets are correct. - Deferred cleanup:
deferUntilStreamConsumedcorrectly hooksflush,cancel, and reader error, with aonce()guard. The non-stream path callsclearRequestContextsynchronously. - Cookie replay:
applySetCookieMutationsToRequestCookieHeadercorrectly merges Set-Cookie mutations, handlesmax-age=0/ epochexpiresdeletions, and stores values verbatim (previous decode/encode asymmetry fixed). - Runtime normalization:
normalizeRuntimecollapsesnull→"nodejs"and"experimental-edge"→"edge". All fourshouldUseForwardedActionRedirectStatuscases (forwarded, ancestor, stale-sibling, cross-runtime) are tested. - Browser-side dispatch: visible-route POST URLs,
next-actionheader, 303→200 rewrite forcreateFromFetch, response cloning before error detection, andServerActionRedirectErrorwithhandled: trueare all sound. - Manifest changes:
findRootBoundaryRoutefallback for apps without a root page,rootRouteLayoutPathsfiltering to root-position layouts, androotRouteBoundaryPathboundary selection are correct. - Test coverage: 455+ new unit test lines, 183 new E2E lines, good provenance annotations. The E2E tests cover the key user-visible scenarios including cookie merging, stale-route wrapper status, and cross-runtime wrapper status.
|
Review submitted. Here's a summary of what I posted: Overall assessment: Well-designed PR that closes a meaningful Next.js parity gap. The core architecture — rendering redirect targets inline during the action POST and using wrapper status semantics — is sound. All prior review feedback (5 cycles) has been addressed. No blocking issues found. What I verified:
Inline comments posted (6 total, none blocking):
|
…edirect depth guard
…oid race in slow CI
|
All review feedback from @ask-bonk has been addressed. Summary of fixes across review cycles: Code changes in this PR:
Observations acknowledged (no code change needed):
All 57 unit tests in app-server-action-execution.test.ts pass cleanly. |
|
/bigbonk re-review for issues and parity |
There was a problem hiding this comment.
Re-review: Issues and Parity
Thorough re-review with focus on correctness, Next.js parity, and edge cases. This is a well-designed PR that closes a real gap. The core architecture — rendering redirect targets inline during the action POST, wrapper status semantics, and deferred context cleanup — is sound. Five prior review cycles have addressed the major issues.
I found a few remaining items. One is a minor behavioral concern, the others are observations and nits.
Summary
- 1 minor issue —
resolveServerActionRedirectLocationis now dead production code - 1 parity question —
readInvalidServerActionResponseErrortreats 200 OK with non-RSC content-type as an error (matches Next.js, but worth documenting) - 2 nits — test variable shadowing, E2E timing fragility
- Code quality — clean, well-referenced to upstream, strong test coverage (456+ unit test lines, 183 E2E lines)
Overall the code is in good shape. The prior review feedback has been thoroughly addressed.
| @@ -100,6 +105,62 @@ export function resolveServerActionRedirectLocation(options: { | |||
| }; | |||
| } | |||
There was a problem hiding this comment.
Nit: resolveServerActionRedirectLocation is no longer called from any production code path — the browser entry now uses resolveActionRedirectTarget (in app-browser-entry.ts:630). It's still exported and tested in app-browser-entry.test.ts, but it's dead code in production.
Not blocking — it could be useful as a utility for future callers, and keeping the tests ensures behavioral documentation. But if you want to trim dead exports, this is a candidate.
| Promise.resolve(flightResponse), | ||
| { temporaryReferences }, | ||
| ); | ||
| syncServerActionHttpFallbackHead(fetchResponse.status); |
There was a problem hiding this comment.
Observation: syncServerActionHttpFallbackHead(fetchResponse.status) runs after createFromFetch parses the Flight payload. If createFromFetch throws (e.g., malformed Flight data), the head sync never runs, meaning a stale noindex from a prior action could persist. However, this is mitigated by the syncServerActionHttpFallbackHead(null) call at the top of the callback (line 1246), which clears any stale meta before the fetch. So the only scenario where stale meta could persist is if a previous action set 404 meta and the current action's createFromFetch throws — but the 404 meta was already cleared at line 1246. Safe.
Good defensive pattern.
| headers: withoutRscBodyHeaders(redirectHeaders), | ||
| }); | ||
| } | ||
| const currentMatch = options.matchRoute(options.cleanPathname); |
There was a problem hiding this comment.
Subtle: options.matchRoute(options.cleanPathname) is called to get currentMatch for the wrapper status decision, but this is after the navigation context was already switched to the redirect target (lines 1047-1054). At this point the cleanPathname still correctly refers to the original action's pathname since it's a parameter, not a mutable state. But the headers context and navigation context are now pointing at the redirect target.
This is fine because shouldUseForwardedActionRedirectStatus only reads the currentRoute and targetRoute objects (which are immutable route descriptors), not any ambient context. Just noting the ordering for future readers: line 1037 must stay before any code that would mutate options.cleanPathname (currently impossible since it's a readonly option, but worth being aware of if the type ever changes).
| return currentRuntime !== targetRuntime; | ||
| } | ||
|
|
||
| function canRenderActionRedirectTarget(route: AppServerActionRoute): boolean { |
There was a problem hiding this comment.
Correctness question: canRenderActionRedirectTarget checks "page" in route and requires page to be truthy. But the AppServerActionRoute type definition (line 88) has page?: unknown — it's optional. When page is absent from the object entirely, "page" in route returns false, and the function falls through to return true (line 643).
This means if a route doesn't have a page property at all (not null, not undefined, just absent), it's treated as renderable. Is this intentional? The generated route objects should always have page when they're page routes, but this feels like it could surprise if a manually constructed route object (e.g., in tests) omits page.
The existing tests pass explicit page values, so this isn't a current issue. Just a defensive coding observation.
| await page.goto(`${BASE}/nextjs-compat/action-forwarded-redirect`); | ||
| await waitForAppRouterHydration(page); | ||
| await page.click("#run-forwarded-redirect"); | ||
| await page.click("#go-forwarded-redirect-other"); |
There was a problem hiding this comment.
The stale child-route test relies on a 3-second setTimeout in the fixture's onClick handler to allow the navigation to /other to complete before the action redirect fires. The toPass({ timeout: 10_000 }) polling on the assertion (line 249) helps, but the fundamental timing assumption is that clicking #go-forwarded-redirect-other immediately after #run-forwarded-redirect always completes the SPA navigation within 3 seconds.
In slow CI environments with cold compilation, the initial click might trigger module compilation that consumes part of that 3-second window. The 3-second delay (increased from 1s per review feedback) gives reasonable margin, but if this test starts flaking in CI, this is the root cause. Consider either:
- Using a
page.waitForURLbetween the two clicks to ensure navigation completed - Having the fixture signal readiness via a DOM element rather than relying on a fixed delay
| // Only deeper-to-shallower redirects can be stale in the Next.js worker | ||
| // model (same-depth siblings share the same page worker). The depth guard | ||
| // ensures we don't misclassify same-level redirects. | ||
| if (targetSegments.length === 0 || currentSegments.length <= targetSegments.length) { |
There was a problem hiding this comment.
The depth guard comment is clear. One edge case to be aware of: isStaleChildSiblingRouteRedirect("/a/b", "/a/b/c/d") where target is shallower than current — this correctly returns true (commonPrefixLength = 2, targetSegments.length = 2, so commonPrefixLength < targetSegments.length is false... wait, actually this returns false because commonPrefixLength === targetSegments.length).
Let me trace through: target = /a/b (segments: ["a", "b"]), current = /a/b/c/d (segments: ["a", "b", "c", "d"]). targetSegments.length = 2, currentSegments.length = 4, so the depth guard passes. commonPrefixLength loops to 2 (both segments match). Then commonPrefixLength > 0 && commonPrefixLength < targetSegments.length → 2 > 0 && 2 < 2 → false.
This means a redirect from a deeper path to a parent-sibling path (e.g., /a/b/c/d → /a/b) is NOT detected as stale. But that's correct — /a/b is an ancestor of /a/b/c/d, and that case is handled by isAncestorRouteRedirect at line 629 instead. The two functions partition the space cleanly. Good.
| return null; | ||
| } | ||
| const statusHeader = response.headers.get(ACTION_REDIRECT_STATUS_HEADER); | ||
| const status = statusHeader ? parseInt(statusHeader, 10) : 307; |
There was a problem hiding this comment.
Nit: the default status is 307 when the header is missing, while ACTION_REDIRECT_TYPE_HEADER defaults to "push" (line 662). In ServerActionRedirectError (line 680), the digest format includes the status: NEXT_REDIRECT;push;/path;307;. If a future consumer of this digest expects specific status semantics (e.g., 307 = preserve method, 303 = change to GET), the default of 307 may be incorrect for action redirects which should generally use 303 (change POST to GET). However, since this status is only used in the digest for React error boundary matching and not for actual HTTP semantics, it's fine in practice.
| fallbackPath: string | null | undefined, | ||
| ): string | null { | ||
| if (!route) return null; | ||
| if (route.pattern === "/") return fallbackPath ?? null; |
There was a problem hiding this comment.
The boundaryPaths?.[0] fallback for non-root routes assumes that boundaryPaths (which is route.notFoundPaths, route.forbiddenPaths, etc.) is ordered with the root-level boundary first. This is true if boundary paths are ordered from outermost layout to innermost, which matches the layout tree scanning order in the router. If the ordering convention ever changes, this indexing would silently pick the wrong boundary.
Not blocking — the current scanner produces correctly ordered arrays.
|
Review submitted. Here's a summary of the feedback I posted: Overall assessment: Well-designed PR that closes a meaningful Next.js parity gap. The core architecture -- rendering redirect targets inline during the action POST, wrapper status semantics for forwarded/stale/cross-runtime cases, and deferred context cleanup via Key feedback points:
|
Fixes #1481
Overview
app-server-action-execution.ts,app-browser-entry.ts,app-segment-config.ts,server-actions.spec.tsWhy
For App Router actions, the POST URL is the visible route, while
next-actionidentifies the server reference. If the visible route is stale or belongs to a different action worker, Next.js forwards the action and still returns a client-consumable Flight wrapper for redirecting actions. Vinext has one deployed worker, but the client observable contract is the same: the action response must carry redirect headers plus a valid Flight body when the action redirect can be rendered internally.next-actionheader and keeps action POST URLs on the visible route.What Changed
route.tstarget could be treated as a page render target.Set-Cookieandx-action-revalidated.Maintainer review path
packages/vinext/src/server/app-server-action-execution.tsfor action redirect target rendering, cookie replay into the redirected GET render, and wrapper status selection.packages/vinext/src/server/app-browser-entry.tsandapp-browser-action-result.tsfor visible-route action POSTs, non-RSC error handling, and redirect Flight consumption.packages/vinext/src/server/app-segment-config.tsplusentries/app-rsc-entry.tsfor runtime metadata threading.tests/app-server-action-execution.test.tsandtests/e2e/app-router/server-actions.spec.tsfor the low-boundary and browser-level regression coverage.Validation
vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/app-dir/actions/app-action-node-middleware.test.tsvp test run tests/app-server-action-execution.test.ts tests/app-browser-entry.test.ts tests/app-segment-config.test.ts tests/app-route-handler-response.test.ts tests/app-rsc-cache-busting.test.ts tests/entry-templates.test.tsCI=1 PLAYWRIGHT_PROJECT=app-router npx playwright test tests/e2e/app-router/server-actions.spec.ts -g "server action redirects|stale child-route|cross-runtime|server action notFound|text/plain|invalid action response" --retries=0vp checkknip --no-progress; the first commit hook also rerantests/entry-templates.test.ts.Risk and compatibility
runtimeis parsed only from supported Next.js values.Non-goals
References