From 63a33ae371dbf33b38542d47ee34d71aebe12a2f Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 23 May 2026 20:14:43 +1000 Subject: [PATCH 1/4] fix(app-router): preserve forwarded action redirect wrappers 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. --- packages/vinext/src/entries/app-rsc-entry.ts | 8 + .../vinext/src/entries/app-rsc-manifest.ts | 61 ++- .../src/server/app-browser-action-result.ts | 41 ++ .../vinext/src/server/app-browser-entry.ts | 172 +++++++-- .../vinext/src/server/app-browser-state.ts | 2 + .../src/server/app-route-handler-response.ts | 14 +- .../src/server/app-rsc-cache-busting.ts | 7 + .../vinext/src/server/app-segment-config.ts | 10 + .../src/server/app-server-action-execution.ts | 347 +++++++++++++++-- packages/vinext/src/shims/error-boundary.tsx | 10 +- tests/app-browser-entry.test.ts | 93 ++++- tests/app-route-handler-response.test.ts | 31 ++ tests/app-rsc-cache-busting.test.ts | 7 + tests/app-segment-config.test.ts | 20 + tests/app-server-action-execution.test.ts | 365 ++++++++++++++++-- .../nextjs-compat/actions-revalidate.spec.ts | 2 +- tests/e2e/app-router/server-actions.spec.ts | 183 +++++++++ tests/entry-templates.test.ts | 39 ++ .../action-redirect-test/redirect-form.tsx | 17 +- .../action-forwarded-redirect/actions.ts | 11 + .../action-forwarded-redirect/client.tsx | 25 ++ .../cross-runtime-client.tsx | 25 ++ .../action-forwarded-redirect/edge/layout.tsx | 7 + .../edge/other/page.tsx | 7 + .../action-forwarded-redirect/edge/page.tsx | 5 + .../action-forwarded-redirect/node/page.tsx | 7 + .../action-forwarded-redirect/other/page.tsx | 7 + .../action-forwarded-redirect/page.tsx | 5 + .../action-redirect-cookies/page.tsx | 27 ++ .../action-redirect-cookies/target/page.tsx | 19 + .../action-response-semantics/actions.ts | 15 + .../action-response-semantics/client.tsx | 45 +++ .../action-response-semantics/error.tsx | 5 + .../action-response-semantics/not-found.tsx | 3 + .../action-response-semantics/page.tsx | 5 + 35 files changed, 1524 insertions(+), 123 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/actions.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/client.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/cross-runtime-client.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/layout.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/other/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/node/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/other/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/target/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/actions.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/client.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/error.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/not-found.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/page.tsx diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index b2d31cbbc..6d5554aa8 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -337,6 +337,13 @@ function __resolveRouteFetchCacheMode(route) { }); } +function __resolveRouteRuntime(route) { + return __resolveAppPageSegmentConfig({ + layouts: route.layouts, + page: route.page, + }).runtime ?? null; +} + ${imports.join("\n")} ${ @@ -796,6 +803,7 @@ export default __createAppRscHandler({ readFormDataWithLimit: __readFormDataWithLimit, renderToReadableStream, reportRequestError: _reportRequestError, + resolveRouteRuntime: __resolveRouteRuntime, request, sanitizeErrorForClient(error) { return __sanitizeErrorForClient(error); diff --git a/packages/vinext/src/entries/app-rsc-manifest.ts b/packages/vinext/src/entries/app-rsc-manifest.ts index 1e1896e5f..ae814dadd 100644 --- a/packages/vinext/src/entries/app-rsc-manifest.ts +++ b/packages/vinext/src/entries/app-rsc-manifest.ts @@ -31,6 +31,34 @@ type BuildAppRscManifestCodeOptions = { globalNotFoundPath?: string | null; }; +function findRootBoundaryRoute(routes: readonly AppRoute[]): AppRoute | undefined { + return ( + routes.find((route) => route.pattern === "/") ?? + routes.find((route) => route.layouts.length > 0 && route.layoutTreePositions.length > 0) + ); +} + +function rootRouteLayoutPaths(route: AppRoute | undefined): readonly string[] { + if (!route) return []; + if (route.pattern === "/") return route.layouts; + + const rootPosition = route.layoutTreePositions[0]; + return route.layouts.filter((_, index) => route.layoutTreePositions[index] === rootPosition); +} + +function rootRouteBoundaryPath( + route: AppRoute | undefined, + boundaryPaths: readonly (string | null)[] | undefined, + fallbackPath: string | null | undefined, +): string | null { + if (!route) return null; + if (route.pattern === "/") return fallbackPath ?? null; + + const rootPosition = route.layoutTreePositions[0]; + const rootLayoutIndex = route.layoutTreePositions.indexOf(rootPosition); + return boundaryPaths?.[rootLayoutIndex] ?? fallbackPath ?? null; +} + type ImportAllocator = { getImportVar(filePath: string): string; importMap: ReadonlyMap; @@ -305,17 +333,30 @@ export function buildAppRscManifestCode( registerRouteModules(options.routes, imports); const routeEntries = buildRouteEntries(options.routes, imports); - const rootRoute = options.routes.find((r) => r.pattern === "/"); - const rootNotFoundVar = rootRoute?.notFoundPath - ? imports.getImportVar(rootRoute.notFoundPath) - : null; - const rootForbiddenVar = rootRoute?.forbiddenPath - ? imports.getImportVar(rootRoute.forbiddenPath) - : null; - const rootUnauthorizedVar = rootRoute?.unauthorizedPath - ? imports.getImportVar(rootRoute.unauthorizedPath) + const rootRoute = findRootBoundaryRoute(options.routes); + const rootNotFoundPath = rootRouteBoundaryPath( + rootRoute, + rootRoute?.notFoundPaths, + rootRoute?.notFoundPath, + ); + const rootForbiddenPath = rootRouteBoundaryPath( + rootRoute, + rootRoute?.forbiddenPaths, + rootRoute?.forbiddenPath, + ); + const rootUnauthorizedPath = rootRouteBoundaryPath( + rootRoute, + rootRoute?.unauthorizedPaths, + rootRoute?.unauthorizedPath, + ); + const rootNotFoundVar = rootNotFoundPath ? imports.getImportVar(rootNotFoundPath) : null; + const rootForbiddenVar = rootForbiddenPath ? imports.getImportVar(rootForbiddenPath) : null; + const rootUnauthorizedVar = rootUnauthorizedPath + ? imports.getImportVar(rootUnauthorizedPath) : null; - const rootLayoutVars = rootRoute ? rootRoute.layouts.map((l) => imports.getImportVar(l)) : []; + const rootLayoutVars = rootRouteLayoutPaths(rootRoute).map((layoutPath) => + imports.getImportVar(layoutPath), + ); const globalErrorVar = options.globalErrorPath ? imports.getImportVar(options.globalErrorPath) : null; diff --git a/packages/vinext/src/server/app-browser-action-result.ts b/packages/vinext/src/server/app-browser-action-result.ts index 4071a4e79..0e07b9e8d 100644 --- a/packages/vinext/src/server/app-browser-action-result.ts +++ b/packages/vinext/src/server/app-browser-action-result.ts @@ -1,4 +1,5 @@ import { ACTION_REVALIDATED_HEADER } from "./headers.js"; +import { VINEXT_RSC_CONTENT_TYPE } from "./app-rsc-cache-busting.js"; export type AppBrowserServerActionResult = { root?: TRoot; @@ -70,6 +71,46 @@ export function parseServerActionRevalidationHeader( } } +type DigestError = Error & { digest: string }; + +function createServerActionHttpFallbackError(status: number): Error | null { + if (status < 400 || status > 599) return null; + + const error = new Error(status === 404 ? "NEXT_NOT_FOUND" : `NEXT_HTTP_ERROR_FALLBACK;${status}`); + (error as DigestError).digest = + status === 404 ? "NEXT_HTTP_ERROR_FALLBACK;404" : `NEXT_HTTP_ERROR_FALLBACK;${status}`; + return error; +} + +export function normalizeServerActionThrownValue(data: unknown, responseStatus: number): unknown { + return createServerActionHttpFallbackError(responseStatus) ?? data; +} + +export async function readInvalidServerActionResponseError( + response: Pick, + hasRedirectLocation: boolean, +): Promise { + const contentType = response.headers.get("content-type") ?? ""; + const isRscResponse = contentType.startsWith(VINEXT_RSC_CONTENT_TYPE); + if (isRscResponse || hasRedirectLocation) return null; + + // Parity with Next.js' server-action reducer: non-RSC action responses are + // surfaced to the action caller, using a plain text 4xx/5xx body when one is + // available and otherwise falling back to a stable generic message. + const message = + response.status >= 400 && contentType === "text/plain" + ? await response.text() + : "An unexpected response was received from the server."; + + return new Error(message || "An unexpected response was received from the server."); +} + +export function shouldCheckRscCompatibilityForServerActionResponse( + response: Pick, +): boolean { + return (response.headers.get("content-type") ?? "").startsWith(VINEXT_RSC_CONTENT_TYPE); +} + export function shouldScheduleRefreshForDiscardedServerAction( revalidation: ServerActionRevalidationKind, ): boolean { diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1f1c389d9..776809413 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -59,7 +59,10 @@ import { createDiscardedServerActionRefreshScheduler, createServerActionInitiationSnapshot, isServerActionResult, + normalizeServerActionThrownValue, parseServerActionRevalidationHeader, + readInvalidServerActionResponseError, + shouldCheckRscCompatibilityForServerActionResponse, shouldClearClientNavigationCachesForServerActionResult, type ServerActionRevalidationKind, type AppBrowserServerActionResult, @@ -110,6 +113,7 @@ import { throwOnServerActionNotFound } from "./server-action-not-found.js"; import { createRscRequestHeaders, createRscRequestUrl, + createServerActionRequestUrl, getVinextRscCompatibilityId, resolveHardNavigationTargetFromRscResponse, resolveRscCompatibilityNavigationDecision, @@ -199,6 +203,21 @@ const discardedServerActionRefreshScheduler = createDiscardedServerActionRefresh }, }); const NavigationCommitSignal = browserNavigationController.NavigationCommitSignal; +const ACTION_HTTP_FALLBACK_ROBOTS_META_ATTR = "data-vinext-action-http-fallback"; + +function syncServerActionHttpFallbackHead(status: number | null): void { + document.head + .querySelectorAll(`meta[${ACTION_HTTP_FALLBACK_ROBOTS_META_ATTR}="robots"]`) + .forEach((node) => node.remove()); + + if (status !== 404) return; + + const robots = document.createElement("meta"); + robots.name = "robots"; + robots.content = "noindex"; + robots.setAttribute(ACTION_HTTP_FALLBACK_ROBOTS_META_ATTR, "robots"); + document.head.appendChild(robots); +} // Parses a URI-encoded JSON value carried in a response header (e.g. // `X-Vinext-Params`). Returns `null` on missing or malformed input so callers @@ -570,6 +589,7 @@ async function renderNavigationPayload( operationLane: OperationLane = "navigation", traversalIntent: HistoryTraversalIntent | null = null, ): Promise { + syncServerActionHttpFallbackHead(null); try { return await browserNavigationController.renderNavigationPayload({ actionType, @@ -595,6 +615,52 @@ async function renderNavigationPayload( } } +function resolveActionRedirectTarget(response: Response): { href: string; type: string } | null { + const actionRedirect = response.headers.get(ACTION_REDIRECT_HEADER); + if (!actionRedirect) return null; + + if (isDangerousScheme(actionRedirect)) { + console.error(DANGEROUS_URL_BLOCK_MESSAGE); + return null; + } + + try { + const redirectUrl = new URL(actionRedirect, window.location.origin); + if (redirectUrl.origin !== window.location.origin) { + browserNavigationController.performHardNavigation(actionRedirect); + return null; + } + return { + href: redirectUrl.href, + type: response.headers.get(ACTION_REDIRECT_TYPE_HEADER) ?? "replace", + }; + } catch { + browserNavigationController.performHardNavigation(actionRedirect); + return null; + } +} + +class ServerActionRedirectError extends Error { + readonly digest: string; + readonly handled = true; + + constructor(target: { href: string; type: string }) { + super("NEXT_REDIRECT"); + const redirectUrl = new URL(target.href, window.location.origin); + const redirectHref = redirectUrl.pathname + redirectUrl.search + redirectUrl.hash; + const redirectType = target.type === "push" ? "push" : "replace"; + this.digest = `NEXT_REDIRECT;${redirectType};${encodeURIComponent(redirectHref)}`; + } +} + +function createServerActionRedirectError(target: { href: string; type: string }): Error { + return new ServerActionRedirectError(target); +} + +function createPendingServerActionRedirectPromise(): Promise { + return new Promise(() => {}); +} + async function commitSameUrlNavigatePayload( nextElements: Promise, actionInitiation: ActionInitiationSnapshot, @@ -1161,7 +1227,7 @@ function registerServerActionCallback(): void { previousNextUrl: actionInitiation.routerState.previousNextUrl, }); - const fetchResponse = await fetch(await createRscRequestUrl(actionInitiation.path, headers), { + const fetchResponse = await fetch(createServerActionRequestUrl(actionInitiation.path), { method: "POST", headers, body, @@ -1171,39 +1237,15 @@ function registerServerActionCallback(): void { // client/server deployment skew via `unstable_isUnrecognizedActionError`. throwOnServerActionNotFound(fetchResponse, id); - const actionRedirect = fetchResponse.headers.get(ACTION_REDIRECT_HEADER); - if (actionRedirect) { - if (isDangerousScheme(actionRedirect)) { - console.error(DANGEROUS_URL_BLOCK_MESSAGE); - return undefined; - } - - // Check for external URLs that need a hard redirect. - try { - const redirectUrl = new URL(actionRedirect, window.location.origin); - if (redirectUrl.origin !== window.location.origin) { - browserNavigationController.performHardNavigation(actionRedirect); - return undefined; - } - } catch { - // Fall through to hard redirect below if URL parsing fails. - } - - // Use hard redirect for all action redirects because vinext's server - // currently returns an empty body for redirect responses. RSC navigation - // requires a valid RSC payload. This is a known parity gap with Next.js, - // which pre-renders the redirect target's RSC payload. - clearClientNavigationCaches(); - const redirectType = fetchResponse.headers.get(ACTION_REDIRECT_TYPE_HEADER) ?? "replace"; - if (redirectType === "push") { - browserNavigationController.performHardNavigation(actionRedirect, "assign"); - } else { - browserNavigationController.performHardNavigation(actionRedirect, "replace"); - } + const hasActionRedirect = fetchResponse.headers.has(ACTION_REDIRECT_HEADER); + const actionRedirectTarget = resolveActionRedirectTarget(fetchResponse); + if (hasActionRedirect && !actionRedirectTarget) { return undefined; } if ( + !actionRedirectTarget && + shouldCheckRscCompatibilityForServerActionResponse(fetchResponse) && resolveRscCompatibilityNavigationDecision({ clientCompatibilityId: CLIENT_RSC_COMPATIBILITY_ID, currentHref: actionInitiation.href, @@ -1217,14 +1259,68 @@ function registerServerActionCallback(): void { } const revalidation = parseServerActionRevalidationHeader(fetchResponse.headers); + const invalidResponseError = await readInvalidServerActionResponseError( + fetchResponse.clone(), + actionRedirectTarget !== null, + ); + if (invalidResponseError) { + throw invalidResponseError; + } + if ( + actionRedirectTarget && + !shouldCheckRscCompatibilityForServerActionResponse(fetchResponse) + ) { + browserNavigationController.performHardNavigation(actionRedirectTarget.href); + return undefined; + } + const flightResponse = + fetchResponse.status === 303 + ? new Response(fetchResponse.body, { + headers: fetchResponse.headers, + status: 200, + statusText: "OK", + }) + : fetchResponse; const result = await createFromFetch( - Promise.resolve(fetchResponse), + Promise.resolve(flightResponse), { temporaryReferences }, ); + syncServerActionHttpFallbackHead(fetchResponse.status); if (shouldClearClientNavigationCachesForServerActionResult(result, revalidation)) { clearClientNavigationCaches(); } + if (actionRedirectTarget) { + if (isServerActionResult(result) && result.root !== undefined) { + const decoded = AppElementsWire.decode(result.root); + void renderNavigationPayload( + Promise.resolve(decoded), + createClientNavigationRenderSnapshot( + actionRedirectTarget.href, + actionInitiation.routerState.navigationSnapshot.params, + ), + actionRedirectTarget.href, + actionInitiation.navigationId, + actionRedirectTarget.type === "push" ? "push" : "replace", + {}, + null, + null, + FRESH_APP_NAVIGATION_PAYLOAD_ORIGIN, + actionRedirectTarget.type === "push" ? "navigate" : "replace", + "server-action", + ).catch(() => { + browserNavigationController.performHardNavigation(actionRedirectTarget.href); + }); + if (args.length !== 0) { + throw createServerActionRedirectError(actionRedirectTarget); + } + return await createPendingServerActionRedirectPromise(); + } + + browserNavigationController.performHardNavigation(actionRedirectTarget.href); + return undefined; + } + // Server actions stay on the same URL and use commitSameUrlNavigatePayload() // for merge-based dispatch. This path does not call // activateNavigationSnapshot() because there is no URL change to commit, so @@ -1233,17 +1329,27 @@ function registerServerActionCallback(): void { // redirects), this would need renderNavigationPayload(). if (isServerActionResult(result)) { if (result.root !== undefined) { + const returnValue = + result.returnValue && !result.returnValue.ok + ? { + ok: false, + data: normalizeServerActionThrownValue( + result.returnValue.data, + fetchResponse.status, + ), + } + : result.returnValue; return commitSameUrlNavigatePayload( Promise.resolve(AppElementsWire.decode(result.root)), actionInitiation, - result.returnValue, + returnValue, revalidation, ); } if (result.returnValue) { if (!result.returnValue.ok) { - throw result.returnValue.data; + throw normalizeServerActionThrownValue(result.returnValue.data, fetchResponse.status); } return result.returnValue.data; } diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index f9f0fd49e..3c80b0eec 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -11,6 +11,7 @@ import { } from "./app-elements.js"; import { createRscRequestHeaders } from "./app-rsc-cache-busting.js"; import { + NEXT_ACTION_HEADER, RSC_ACTION_HEADER, VINEXT_INTERCEPTION_CONTEXT_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, @@ -226,6 +227,7 @@ export function resolveServerActionRequestState( ): ResolveServerActionRequestStateResult { const headers = createRscRequestHeaders(); headers.set(RSC_ACTION_HEADER, options.actionId); + headers.set(NEXT_ACTION_HEADER, options.actionId); const interceptionContext = resolveInterceptionContextFromPreviousNextUrl( options.previousNextUrl, diff --git a/packages/vinext/src/server/app-route-handler-response.ts b/packages/vinext/src/server/app-route-handler-response.ts index 4977f140d..1ed8fde01 100644 --- a/packages/vinext/src/server/app-route-handler-response.ts +++ b/packages/vinext/src/server/app-route-handler-response.ts @@ -152,6 +152,18 @@ function getSetCookieName(cookie: string): string | null { return cookie.slice(0, equalsIndex); } +function hasSetCookiePath(cookie: string): boolean { + const attributes = cookie.split(";").slice(1); + return attributes.some((attribute) => attribute.trimStart().toLowerCase().startsWith("path=")); +} + +function normalizeReturnedSetCookie(cookie: string): string { + if (!getSetCookieName(cookie) || hasSetCookiePath(cookie)) { + return cookie; + } + return `${cookie}; Path=/`; +} + function applyMutableCookieFallbacks(headers: Headers, pendingCookies: string[]): void { if (pendingCookies.length === 0) { return; @@ -188,7 +200,7 @@ function applyMutableCookieFallbacks(headers: Headers, pendingCookies: string[]) headers.append("Set-Cookie", cookie); } for (const cookie of returnedCookies) { - headers.append("Set-Cookie", cookie); + headers.append("Set-Cookie", normalizeReturnedSetCookie(cookie)); } } diff --git a/packages/vinext/src/server/app-rsc-cache-busting.ts b/packages/vinext/src/server/app-rsc-cache-busting.ts index 35bb6db0e..23c842171 100644 --- a/packages/vinext/src/server/app-rsc-cache-busting.ts +++ b/packages/vinext/src/server/app-rsc-cache-busting.ts @@ -300,6 +300,13 @@ export async function createRscRequestUrl(href: string, headers: Headers): Promi return `${url.pathname}${url.search}`; } +export function createServerActionRequestUrl(href: string): string { + const hashIndex = href.indexOf("#"); + const beforeHash = hashIndex === -1 ? href : href.slice(0, hashIndex); + const url = new URL(beforeHash, "http://vinext.local"); + return `${url.pathname}${url.search}`; +} + export async function createRscRedirectLocation( location: string, request: Request, diff --git a/packages/vinext/src/server/app-segment-config.ts b/packages/vinext/src/server/app-segment-config.ts index dd16cc93c..1b622dd71 100644 --- a/packages/vinext/src/server/app-segment-config.ts +++ b/packages/vinext/src/server/app-segment-config.ts @@ -7,6 +7,7 @@ type AppRouteSegmentConfigModule = { dynamicParams?: unknown; fetchCache?: unknown; revalidate?: unknown; + runtime?: unknown; }; type EffectiveAppPageSegmentConfig = { @@ -14,6 +15,7 @@ type EffectiveAppPageSegmentConfig = { dynamicParamsConfig?: boolean; fetchCache?: FetchCacheMode; revalidateSeconds: number | null; + runtime?: "edge" | "experimental-edge" | "nodejs"; }; type ResolveAppPageSegmentConfigOptions = { @@ -40,6 +42,10 @@ function isRouteSegmentFetchCache(value: unknown): value is FetchCacheMode { return FETCH_CACHE_VALUES.has(value); } +function isRouteSegmentRuntime(value: unknown): value is EffectiveAppPageSegmentConfig["runtime"] { + return value === "edge" || value === "experimental-edge" || value === "nodejs"; +} + function resolveRevalidateSeconds(current: number | null, value: unknown): number | null { // revalidate = false means "cache indefinitely" in Next.js segment config. // Represent it as Infinity so downstream code can distinguish "never @@ -102,6 +108,10 @@ export function resolveAppPageSegmentConfig( config.dynamicConfig = segment.dynamic; } + if (isRouteSegmentRuntime(segment.runtime)) { + config.runtime = segment.runtime; + } + if (segment.dynamicParams === false) { config.dynamicParamsConfig = false; } else if (segment.dynamicParams === true && config.dynamicParamsConfig !== false) { diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index d1945e921..abc16c59e 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -1,9 +1,17 @@ import { getAndClearActionRevalidationKind, type ActionRevalidationKind } from "vinext/shims/cache"; -import type { HeadersAccessPhase } from "vinext/shims/headers"; -import { type FetchCacheMode, setCurrentFetchCacheMode } from "vinext/shims/fetch-cache"; +import { + headersContextFromRequest, + setHeadersContext, + type HeadersAccessPhase, +} from "vinext/shims/headers"; +import { + type FetchCacheMode, + setCurrentFetchCacheMode, + setCurrentFetchSoftTags, +} from "vinext/shims/fetch-cache"; import type { ReactFormState } from "react-dom/client"; import { isExternalUrl } from "../config/config-matchers.js"; -import { addBasePathToPathname, hasBasePath } from "../utils/base-path.js"; +import { addBasePathToPathname, hasBasePath, stripBasePath } from "../utils/base-path.js"; import { ACTION_FORWARDED_HEADER, ACTION_REDIRECT_HEADER, @@ -17,6 +25,8 @@ import { applyRscCompatibilityIdHeader, } from "./app-rsc-cache-busting.js"; import { resolveAppPageActionRerenderTarget } from "./app-page-request.js"; +import { deferUntilStreamConsumed } from "./app-page-stream.js"; +import { buildPageCacheTags } from "./implicit-tags.js"; import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; import { APP_RSC_RENDER_MODE_ACTION_RERENDER_PRESERVE_UI, @@ -74,8 +84,11 @@ type AppServerActionRedirect = { type AppServerActionRoute = { pattern: string; + routeSegments?: readonly string[]; }; +type AppServerActionRouteRuntime = "edge" | "experimental-edge" | "nodejs" | null; + type ProgressiveServerActionResult = | { formState: ReactFormState | null; @@ -201,6 +214,7 @@ export type HandleServerActionRscRequestOptions< ) => BodyInit | null | Promise; reportRequestError: AppServerActionErrorReporter; resolveRouteFetchCacheMode?: (route: TRoute) => FetchCacheMode | null; + resolveRouteRuntime?: (route: TRoute) => AppServerActionRouteRuntime; request: Request; sanitizeErrorForClient: (error: unknown) => unknown; searchParams: URLSearchParams; @@ -220,6 +234,16 @@ export type HandleServerActionRscRequestOptions< const SERVER_ACTION_ARGS_LIMIT = 1000; const ACTION_DID_NOT_REVALIDATE = 0 satisfies ActionRevalidationKind; const ACTION_DID_REVALIDATE_STATIC_AND_DYNAMIC = 1 satisfies ActionRevalidationKind; +const ACTION_REDIRECT_RENDER_STRIPPED_HEADERS = [ + "accept", + "content-length", + "content-type", + "next-action", + "origin", + "rsc", + "x-action-forwarded", + "x-rsc-action", +]; function setActionRevalidatedHeader(headers: Headers, kind: ActionRevalidationKind): void { if (kind === ACTION_DID_NOT_REVALIDATE) return; @@ -237,6 +261,114 @@ function resolveActionRevalidationKind(hasModifiedCookies: boolean): ActionReval return revalidationKind; } +function cloneActionRedirectHeaders(requestHeaders: Headers): Headers { + const headers = new Headers(requestHeaders); + for (const header of ACTION_REDIRECT_RENDER_STRIPPED_HEADERS) { + headers.delete(header); + } + return headers; +} + +function readSetCookieNameValue(setCookie: string): { name: string; value: string } | null { + const equalsIndex = setCookie.indexOf("="); + if (equalsIndex <= 0) return null; + + const name = setCookie.slice(0, equalsIndex).trim(); + const valueEnd = setCookie.indexOf(";", equalsIndex + 1); + const encodedValue = setCookie.slice(equalsIndex + 1, valueEnd === -1 ? undefined : valueEnd); + let value: string; + try { + value = decodeURIComponent(encodedValue); + } catch { + value = encodedValue; + } + + return { name, value }; +} + +function isExpiredSetCookie(setCookie: string): boolean { + return ( + /(?:^|;\s*)max-age=0(?:;|$)/i.test(setCookie) || + /(?:^|;\s*)expires=Thu, 01 Jan 1970/i.test(setCookie) + ); +} + +function applySetCookieMutationsToRequestCookieHeader( + cookieHeader: string | null, + setCookies: readonly string[], +): string | null { + const cookies = new Map(); + if (cookieHeader) { + for (const part of cookieHeader.split(";")) { + const trimmed = part.trim(); + if (!trimmed) continue; + const equalsIndex = trimmed.indexOf("="); + if (equalsIndex <= 0) continue; + cookies.set(trimmed.slice(0, equalsIndex), trimmed.slice(equalsIndex + 1)); + } + } + + for (const setCookie of setCookies) { + const entry = readSetCookieNameValue(setCookie); + if (!entry) continue; + if (isExpiredSetCookie(setCookie)) { + cookies.delete(entry.name); + } else { + cookies.set(entry.name, encodeURIComponent(entry.value)); + } + } + + return cookies.size === 0 + ? null + : [...cookies].map(([name, value]) => `${name}=${value}`).join("; "); +} + +function createActionRedirectRenderRequest(options: { + pendingCookies: readonly string[]; + request: Request; + url: URL; +}): Request { + const headers = cloneActionRedirectHeaders(options.request.headers); + const cookieHeader = applySetCookieMutationsToRequestCookieHeader( + headers.get("cookie"), + options.pendingCookies, + ); + if (cookieHeader === null) { + headers.delete("cookie"); + } else { + headers.set("cookie", cookieHeader); + } + + return new Request(options.url, { + headers, + method: "GET", + }); +} + +function withoutRscBodyHeaders(headers: Headers): Headers { + const nextHeaders = new Headers(headers); + nextHeaders.delete("Content-Type"); + nextHeaders.delete("Vary"); + return nextHeaders; +} + +function isReadableStreamBody(body: BodyInit | null): body is ReadableStream { + return typeof ReadableStream !== "undefined" && body instanceof ReadableStream; +} + +function createServerActionRscResponse( + body: BodyInit | null, + init: ResponseInit, + clearRequestContext: () => void, +): Response { + if (!isReadableStreamBody(body)) { + clearRequestContext(); + return new Response(body, init); + } + + return new Response(deferUntilStreamConsumed(body, clearRequestContext), init); +} + function isRequestBodyTooLarge(error: unknown): boolean { return error instanceof Error && error.message === "Request body too large"; } @@ -359,6 +491,76 @@ export function applyActionRedirectBasePath(url: string, basePath: string): stri return `${addBasePathToPathname(pathname, basePath)}${suffix}`; } +function buildServerActionPageTags(route: AppServerActionRoute, pathname: string): string[] { + return buildPageCacheTags(pathname, [], [...(route.routeSegments ?? [])], "page"); +} + +function resolveInternalActionRedirectTarget( + redirectUrl: string, + requestUrl: string, + basePath: string, +): URL | null { + if (isExternalUrl(redirectUrl)) { + const requestOrigin = new URL(requestUrl).origin; + const parsed = new URL(redirectUrl); + if (parsed.origin !== requestOrigin) return null; + if (basePath && !hasBasePath(parsed.pathname, basePath)) return null; + return parsed; + } + + return new URL(redirectUrl, requestUrl); +} + +function isAncestorRouteRedirect(targetPathname: string, currentPathname: string): boolean { + return targetPathname !== "/" && currentPathname.startsWith(`${targetPathname}/`); +} + +function splitActionRedirectPathname(pathname: string): string[] { + return pathname.split("/").filter(Boolean); +} + +function isStaleChildSiblingRouteRedirect( + targetPathname: string, + currentPathname: string, +): boolean { + const targetSegments = splitActionRedirectPathname(targetPathname); + const currentSegments = splitActionRedirectPathname(currentPathname); + if (targetSegments.length === 0 || currentSegments.length <= targetSegments.length) { + return false; + } + + let commonPrefixLength = 0; + const maxPrefixLength = Math.min(targetSegments.length, currentSegments.length); + while ( + commonPrefixLength < maxPrefixLength && + targetSegments[commonPrefixLength] === currentSegments[commonPrefixLength] + ) { + commonPrefixLength++; + } + + return commonPrefixLength > 0 && commonPrefixLength < targetSegments.length; +} + +function shouldUseForwardedActionRedirectStatus(options: { + actionWasForwarded: boolean; + currentPathname: string; + currentRoute: TRoute | null; + resolveRouteRuntime?: (route: TRoute) => AppServerActionRouteRuntime; + targetPathname: string; + targetRoute: TRoute; +}): boolean { + if (options.actionWasForwarded) return true; + if (isAncestorRouteRedirect(options.targetPathname, options.currentPathname)) return true; + if (isStaleChildSiblingRouteRedirect(options.targetPathname, options.currentPathname)) { + return true; + } + if (!options.currentRoute || !options.resolveRouteRuntime) return false; + + const currentRuntime = options.resolveRouteRuntime(options.currentRoute); + const targetRuntime = options.resolveRouteRuntime(options.targetRoute); + return currentRuntime !== null && currentRuntime !== targetRuntime; +} + function getActionHttpFallbackStatus(error: unknown): number | null { const digest = getNextErrorDigest(error); if (!digest) return null; @@ -430,14 +632,6 @@ export async function handleProgressiveServerActionRequest( return null; } - // Defensive guard: prevent infinite forwarding loops. See handleServerActionRscRequest. - if (options.request.headers.get(ACTION_FORWARDED_HEADER)) { - return createActionNotFoundResponse(null, { - clearRequestContext: options.clearRequestContext, - getAndClearPendingCookies: options.getAndClearPendingCookies, - }); - } - const csrfResponse = validateCsrfOrigin(options.request, options.allowedOrigins); if (csrfResponse) { return csrfResponse; @@ -596,17 +790,6 @@ export async function handleServerActionRscRequest< return null; } - // Defensive guard: if this request has already been forwarded between workers, - // do not attempt to process it again. Prevents infinite forwarding loops when - // middleware rewrites action POSTs. Matches Next.js behavior: - // https://github.com/vercel/next.js/commit/20892dd44e1321c13f755f051e48c3cadd75204b - if (options.request.headers.get(ACTION_FORWARDED_HEADER)) { - return createActionNotFoundResponse(options.actionId, { - clearRequestContext: options.clearRequestContext, - getAndClearPendingCookies: options.getAndClearPendingCookies, - }); - } - const csrfResponse = validateCsrfOrigin(options.request, options.allowedOrigins); if (csrfResponse) return csrfResponse; @@ -662,6 +845,7 @@ export async function handleServerActionRscRequest< let returnValue: AppServerActionReturnValue; let actionRedirect: AppServerActionRedirect | null = null; let actionStatus = 200; + const actionWasForwarded = Boolean(options.request.headers.get(ACTION_FORWARDED_HEADER)); const previousHeadersPhase = options.setHeadersAccessPhase("action"); try { try { @@ -693,7 +877,6 @@ export async function handleServerActionRscRequest< const actionRevalidationKind = resolveActionRevalidationKind( actionPendingCookies.length > 0 || Boolean(actionDraftCookie), ); - options.clearRequestContext(); const redirectHeaders = new Headers({ "Content-Type": VINEXT_RSC_CONTENT_TYPE, Vary: VINEXT_RSC_VARY_HEADER, @@ -704,10 +887,11 @@ export async function handleServerActionRscRequest< // app-browser-entry reads ACTION_REDIRECT_HEADER and calls // window.location.assign/replace verbatim, so the value must already // be a basePath-prefixed URL. - redirectHeaders.set( - ACTION_REDIRECT_HEADER, - applyActionRedirectBasePath(actionRedirect.url, options.basePath ?? ""), + const actionRedirectUrl = applyActionRedirectBasePath( + actionRedirect.url, + options.basePath ?? "", ); + redirectHeaders.set(ACTION_REDIRECT_HEADER, actionRedirectUrl); redirectHeaders.set(ACTION_REDIRECT_TYPE_HEADER, actionRedirect.type); redirectHeaders.set(ACTION_REDIRECT_STATUS_HEADER, String(actionRedirect.status)); for (const cookie of actionPendingCookies) { @@ -715,7 +899,83 @@ export async function handleServerActionRscRequest< } if (actionDraftCookie) redirectHeaders.append("Set-Cookie", actionDraftCookie); setActionRevalidatedHeader(redirectHeaders, actionRevalidationKind); - return new Response("", { status: 200, headers: redirectHeaders }); + + const redirectTarget = resolveInternalActionRedirectTarget( + actionRedirectUrl, + options.request.url, + options.basePath ?? "", + ); + if (!redirectTarget) { + options.clearRequestContext(); + return new Response(null, { + status: 303, + headers: withoutRscBodyHeaders(redirectHeaders), + }); + } + + const targetPathname = stripBasePath(redirectTarget.pathname, options.basePath ?? ""); + const targetMatch = options.matchRoute(targetPathname); + if (!targetMatch) { + options.clearRequestContext(); + return new Response(null, { + status: 303, + headers: withoutRscBodyHeaders(redirectHeaders), + }); + } + const currentMatch = options.matchRoute(options.cleanPathname); + + const redirectRenderRequest = createActionRedirectRenderRequest({ + pendingCookies: [ + ...actionPendingCookies, + ...(actionDraftCookie ? [actionDraftCookie] : []), + ], + request: options.request, + url: redirectTarget, + }); + setHeadersContext(headersContextFromRequest(redirectRenderRequest)); + options.setNavigationContext({ + pathname: targetPathname, + searchParams: redirectTarget.searchParams, + params: targetMatch.params, + }); + setCurrentFetchCacheMode(options.resolveRouteFetchCacheMode?.(targetMatch.route) ?? null); + setCurrentFetchSoftTags(buildServerActionPageTags(targetMatch.route, targetPathname)); + const element = options.buildPageElement({ + cleanPathname: targetPathname, + interceptOpts: undefined, + isRscRequest: true, + mountedSlotsHeader: null, + params: targetMatch.params, + request: redirectRenderRequest, + route: targetMatch.route, + searchParams: redirectTarget.searchParams, + renderMode: APP_RSC_RENDER_MODE_ACTION_RERENDER_PRESERVE_UI, + }); + const onRenderError = options.createRscOnErrorHandler( + redirectRenderRequest, + targetPathname, + targetMatch.route.pattern, + ); + const rscStream = await options.renderToReadableStream( + { root: element, returnValue }, + { temporaryReferences, onError: onRenderError }, + ); + const redirectResponseStatus = shouldUseForwardedActionRedirectStatus({ + actionWasForwarded, + currentPathname: options.cleanPathname, + currentRoute: currentMatch?.route ?? null, + resolveRouteRuntime: options.resolveRouteRuntime, + targetPathname, + targetRoute: targetMatch.route, + }) + ? 200 + : 303; + + return createServerActionRscResponse( + rscStream, + { status: redirectResponseStatus, headers: redirectHeaders }, + options.clearRequestContext, + ); } const actionPendingCookies = options.getAndClearPendingCookies(); @@ -724,7 +984,9 @@ export async function handleServerActionRscRequest< actionPendingCookies.length > 0 || Boolean(actionDraftCookie), ); - const shouldSkipPageRendering = actionRevalidationKind === ACTION_DID_NOT_REVALIDATE; + const shouldSkipPageRendering = + actionWasForwarded || + (actionStatus === 200 && actionRevalidationKind === ACTION_DID_NOT_REVALIDATE); if (shouldSkipPageRendering) { const onRenderError = options.createRscOnErrorHandler( options.request, @@ -736,8 +998,6 @@ export async function handleServerActionRscRequest< { temporaryReferences, onError: onRenderError }, ); - options.clearRequestContext(); - const actionHeaders = new Headers({ "Content-Type": VINEXT_RSC_CONTENT_TYPE, Vary: VINEXT_RSC_VARY_HEADER, @@ -745,10 +1005,14 @@ export async function handleServerActionRscRequest< mergeMiddlewareResponseHeaders(actionHeaders, options.middlewareHeaders); applyRscCompatibilityIdHeader(actionHeaders); - return new Response(rscStream, { - status: options.middlewareStatus ?? actionStatus, - headers: actionHeaders, - }); + return createServerActionRscResponse( + rscStream, + { + status: options.middlewareStatus ?? actionStatus, + headers: actionHeaders, + }, + options.clearRequestContext, + ); } const match = options.matchRoute(options.cleanPathname); @@ -775,6 +1039,9 @@ export async function handleServerActionRscRequest< setCurrentFetchCacheMode( options.resolveRouteFetchCacheMode?.(actionRerenderTarget.route) ?? null, ); + setCurrentFetchSoftTags( + buildServerActionPageTags(actionRerenderTarget.route, options.cleanPathname), + ); element = options.buildPageElement({ cleanPathname: options.cleanPathname, interceptOpts: actionRerenderTarget.interceptOpts, @@ -809,10 +1076,14 @@ export async function handleServerActionRscRequest< mergeMiddlewareResponseHeaders(actionHeaders, options.middlewareHeaders); applyRscCompatibilityIdHeader(actionHeaders); setActionRevalidatedHeader(actionHeaders, actionRevalidationKind); - const actionResponse = new Response(rscStream, { - status: options.middlewareStatus ?? actionStatus, - headers: actionHeaders, - }); + const actionResponse = createServerActionRscResponse( + rscStream, + { + status: options.middlewareStatus ?? actionStatus, + headers: actionHeaders, + }, + options.clearRequestContext, + ); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { actionResponse.headers.append("Set-Cookie", cookie); diff --git a/packages/vinext/src/shims/error-boundary.tsx b/packages/vinext/src/shims/error-boundary.tsx index b70f170f8..3fcb049c8 100644 --- a/packages/vinext/src/shims/error-boundary.tsx +++ b/packages/vinext/src/shims/error-boundary.tsx @@ -204,7 +204,10 @@ export class ErrorBoundaryInner extends React.Component< > { constructor(props: ErrorBoundaryInnerProps) { super(props); - this.state = { error: null, ...readBoundaryResetState(props) }; + this.state = { + error: null, + ...readBoundaryResetState(props), + }; } static getDerivedStateFromProps( @@ -215,7 +218,10 @@ export class ErrorBoundaryInner extends React.Component< if (state.error && shouldResetBoundary(nextResetState, state)) { return { error: null, ...nextResetState }; } - return { error: state.error, ...nextResetState }; + return { + error: state.error, + ...nextResetState, + }; } static getDerivedStateFromError(error: unknown): Partial { diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 8edbf8b2a..9fc7a6693 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -4,7 +4,10 @@ import { createOnUncaughtError } from "../packages/vinext/src/server/app-browser import { createDiscardedServerActionRefreshScheduler, createServerActionInitiationSnapshot, + normalizeServerActionThrownValue, parseServerActionRevalidationHeader, + readInvalidServerActionResponseError, + shouldCheckRscCompatibilityForServerActionResponse, shouldClearClientNavigationCachesForServerActionResult, shouldScheduleRefreshForDiscardedServerAction, } from "../packages/vinext/src/server/app-browser-action-result.js"; @@ -588,6 +591,35 @@ describe("app browser entry navigation scheduling", () => { ).toEqual({ kind: "compatible" }); }); + it("does not classify non-Flight action HTTP errors as RSC compatibility failures", () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + expect( + shouldCheckRscCompatibilityForServerActionResponse( + new Response("Custom error!", { + headers: { "content-type": "text/plain" }, + status: 500, + }), + ), + ).toBe(false); + expect( + shouldCheckRscCompatibilityForServerActionResponse( + new Response(JSON.stringify({ error: "Custom error!" }), { + headers: { "content-type": "application/json" }, + status: 500, + }), + ), + ).toBe(false); + expect( + shouldCheckRscCompatibilityForServerActionResponse( + new Response("flight", { + headers: { "content-type": "text/x-component" }, + status: 200, + }), + ), + ).toBe(true); + }); + it("creates replayable cached RSC snapshots with compatibility IDs", async () => { stubWindow("https://example.com/current"); @@ -642,6 +674,55 @@ describe("app browser entry navigation scheduling", () => { ).toBe("none"); }); + it("restores action HTTP fallback errors from response status", () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + const fallback = normalizeServerActionThrownValue(new Error("sanitized"), 404); + + expect(fallback).toBeInstanceOf(Error); + expect((fallback as Error & { digest?: string }).digest).toBe("NEXT_HTTP_ERROR_FALLBACK;404"); + }); + + it("uses text/plain action response bodies as boundary errors", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + const error = await readInvalidServerActionResponseError( + new Response("Custom error!", { + status: 500, + headers: { "content-type": "text/plain" }, + }), + false, + ); + + expect(error?.message).toBe("Custom error!"); + }); + + it("uses a stable generic error for non-RSC action responses", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + const error = await readInvalidServerActionResponseError( + new Response(JSON.stringify({ error: "Custom error!" }), { + status: 500, + headers: { "content-type": "application/json" }, + }), + false, + ); + + expect(error?.message).toBe("An unexpected response was received from the server."); + }); + + it("allows non-RSC server action redirect responses", async () => { + const error = await readInvalidServerActionResponseError( + new Response("", { + status: 303, + headers: { "content-type": "text/plain" }, + }), + true, + ); + + expect(error).toBeNull(); + }); + it("captures server action initiation URL state without a hash in the request path", () => { const routerState = createState({ navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/a?tab=1", { @@ -4323,7 +4404,7 @@ describe("mounted slot helpers", () => { }); describe("resolveServerActionRequestState", () => { - it("includes only the RSC markers and x-rsc-action when previousNextUrl is null and no slots are mounted", () => { + it("includes the public Next.js action header when previousNextUrl is null and no slots are mounted", () => { const elements = createResolvedElements("route:/settings", "/"); const { headers } = resolveServerActionRequestState({ @@ -4333,8 +4414,16 @@ describe("resolveServerActionRequestState", () => { previousNextUrl: null, }); - expect(Array.from(headers.keys()).sort()).toEqual(["accept", "rsc", "x-rsc-action"]); + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + expect(Array.from(headers.keys()).sort()).toEqual([ + "accept", + "next-action", + "rsc", + "x-rsc-action", + ]); expect(headers.get("accept")).toBe("text/x-component"); + expect(headers.get("next-action")).toBe("action-abc"); expect(headers.get("rsc")).toBe("1"); expect(headers.get("x-rsc-action")).toBe("action-abc"); }); diff --git a/tests/app-route-handler-response.test.ts b/tests/app-route-handler-response.test.ts index 112ad49d8..634f444bb 100644 --- a/tests/app-route-handler-response.test.ts +++ b/tests/app-route-handler-response.test.ts @@ -276,6 +276,37 @@ describe("app route handler response helpers", () => { await expect(result.text()).resolves.toBe("body"); }); + it("normalizes returned response cookies when they override mutable cookie fallbacks", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + const response = new Response("body", { + headers: [ + ["Set-Cookie", "bar=bar2"], + ["Set-Cookie", "baz=baz2"], + ], + }); + + const result = finalizeRouteHandlerResponse(response, { + pendingCookies: [ + "foo=foo1; Path=/", + "bar=bar1; Path=/", + "test1=value1; Path=/; Secure", + "test2=value2; Path=/handler; HttpOnly", + ], + draftCookie: null, + isHead: false, + }); + + expect(result.headers.getSetCookie()).toEqual([ + "foo=foo1; Path=/", + "test1=value1; Path=/; Secure", + "test2=value2; Path=/handler; HttpOnly", + "bar=bar2; Path=/", + "baz=baz2; Path=/", + ]); + await expect(result.text()).resolves.toBe("body"); + }); + it("strips internal middleware headers from finalized route handler responses", async () => { const response = new Response("body", { headers: [ diff --git a/tests/app-rsc-cache-busting.test.ts b/tests/app-rsc-cache-busting.test.ts index 4fe9d5f82..c0ddb1a7f 100644 --- a/tests/app-rsc-cache-busting.test.ts +++ b/tests/app-rsc-cache-busting.test.ts @@ -4,6 +4,7 @@ import { computeRscCacheBustingSearchParam, createRscRequestHeaders, createRscRequestUrl, + createServerActionRequestUrl, isRscCompatibilityIdCompatible, resolveInvalidRscCacheBustingRequest, setRscCacheBustingSearchParam, @@ -59,6 +60,12 @@ describe("App Router RSC cache-busting", () => { ); }); + it("keeps server action POSTs on the visible route URL", () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + expect(createServerActionRequestUrl("/server?name=alice#section")).toBe("/server?name=alice"); + }); + it("changes the hash when a varying header changes", async () => { const feedHash = await computeRscCacheBustingSearchParam( createRscRequestHeaders({ interceptionContext: "/feed" }), diff --git a/tests/app-segment-config.test.ts b/tests/app-segment-config.test.ts index e970d29d2..1a1d716b5 100644 --- a/tests/app-segment-config.test.ts +++ b/tests/app-segment-config.test.ts @@ -160,6 +160,26 @@ describe("resolveAppPageSegmentConfig", () => { }); }); + it("uses the child route runtime when segment runtimes differ", () => { + expect( + resolveAppPageSegmentConfig({ + layouts: [{ runtime: "edge" }], + page: { runtime: "nodejs" }, + }).runtime, + ).toBe("nodejs"); + }); + + it("ignores unknown runtime values", () => { + expect( + resolveAppPageSegmentConfig({ + layouts: [{ runtime: "bun" }], + page: {}, + }), + ).toEqual({ + revalidateSeconds: null, + }); + }); + it("keeps explicit dynamicParams false sticky across child segments", () => { expect( resolveAppPageSegmentConfig({ diff --git a/tests/app-server-action-execution.test.ts b/tests/app-server-action-execution.test.ts index 844cd9886..00ae943a2 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -12,19 +12,28 @@ import { createServerActionNotFoundResponse, throwOnServerActionNotFound, } from "../packages/vinext/src/server/server-action-not-found.js"; -import { unstable_isUnrecognizedActionError } from "../packages/vinext/src/shims/navigation.js"; +import { + redirect, + unstable_isUnrecognizedActionError, +} from "../packages/vinext/src/shims/navigation.js"; import { VINEXT_RSC_COMPATIBILITY_ID_HEADER, VINEXT_RSC_VARY_HEADER, } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; import { refresh, revalidatePath, revalidateTag } from "../packages/vinext/src/shims/cache.js"; -import { setHeadersAccessPhase } from "../packages/vinext/src/shims/headers.js"; +import { + cookies, + setHeadersAccessPhase, + setHeadersContext, +} from "../packages/vinext/src/shims/headers.js"; import { withEnvVar } from "./env-test-helpers.js"; type TestRoute = { id: string; params: readonly string[]; pattern: string; + routeSegments?: readonly string[]; + runtime?: "edge" | "experimental-edge" | "nodejs" | null; }; type TestInterceptOptions = { @@ -744,6 +753,163 @@ describe("app server action execution helpers", () => { expect(response?.headers.get("x-action-revalidated")).toBe("1"); }); + it("renders same-origin action redirects as a single-pass Flight response", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + const response = await handleServerActionRscRequest( + createRscOptions({ + loadServerAction() { + return Promise.resolve(() => redirect("/redirect-target")); + }, + matchRoute(pathname) { + if (pathname === "/redirect-target") { + return { + params: {}, + route: { id: "redirect-target", params: [], pattern: "/redirect-target" }, + }; + } + return { + params: {}, + route: { id: "dashboard", params: [], pattern: "/dashboard" }, + }; + }, + }), + ); + + expect(response?.status).toBe(303); + expect(response?.headers.get("x-action-redirect")).toBe("/redirect-target"); + expect(JSON.parse(await response!.text())).toEqual({ + root: "redirect-target:{}:none", + returnValue: { ok: true }, + }); + }); + + it("renders internal action redirects with a clean GET request and action cookies", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action-node-middleware.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action-node-middleware.test.ts + const renderRequests: Request[] = []; + const response = await handleServerActionRscRequest( + createRscOptions({ + buildPageElement({ request }) { + renderRequests.push(request); + return "redirect-target:{}:none"; + }, + getAndClearPendingCookies() { + return ["theme=dark; Path=/", "deleted=; Path=/; Max-Age=0"]; + }, + loadServerAction() { + return Promise.resolve(() => redirect("/redirect-target?from=action")); + }, + matchRoute(pathname) { + if (pathname === "/redirect-target") { + return { + params: {}, + route: { id: "redirect-target", params: [], pattern: "/redirect-target" }, + }; + } + return { + params: {}, + route: { id: "dashboard", params: [], pattern: "/dashboard" }, + }; + }, + request: createFetchActionRequest({ + accept: "text/x-component", + cookie: "session=1; deleted=stale", + "next-action": "action-id", + rsc: "1", + }), + }), + ); + + expect(response?.status).toBe(303); + expect(response?.headers.get("x-action-redirect")).toBe("/redirect-target?from=action"); + const renderRequest = renderRequests[0]; + if (!renderRequest) throw new Error("Expected redirect render request"); + + expect(renderRequest.method).toBe("GET"); + expect(renderRequest.url).toBe("https://example.com/redirect-target?from=action"); + expect(renderRequest.headers.get("next-action")).toBeNull(); + expect(renderRequest.headers.get("x-rsc-action")).toBeNull(); + expect(renderRequest.headers.get("rsc")).toBeNull(); + expect(renderRequest.headers.get("content-type")).toBeNull(); + expect(renderRequest.headers.get("origin")).toBeNull(); + expect(renderRequest.headers.get("cookie")).toBe("session=1; theme=dark"); + }); + + it("keeps redirected action render context alive until the Flight body is consumed", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action-node-middleware.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action-node-middleware.test.ts + const clearRequestContext = vi.fn(() => setHeadersContext(null)); + + try { + const response = await handleServerActionRscRequest( + createRscOptions({ + clearRequestContext, + getAndClearPendingCookies() { + return ["theme=dark; Path=/"]; + }, + loadServerAction() { + return Promise.resolve(() => redirect("/redirect-target")); + }, + matchRoute(pathname) { + if (pathname === "/redirect-target") { + return { + params: {}, + route: { id: "redirect-target", params: [], pattern: "/redirect-target" }, + }; + } + return { + params: {}, + route: { id: "dashboard", params: [], pattern: "/dashboard" }, + }; + }, + renderToReadableStream() { + return new ReadableStream({ + async pull(controller) { + const cookieStore = await cookies(); + controller.enqueue( + new TextEncoder().encode(cookieStore.get("theme")?.value ?? "missing"), + ); + controller.close(); + }, + }); + }, + }), + ); + + expect(clearRequestContext).not.toHaveBeenCalled(); + expect(await response?.text()).toBe("dark"); + expect(clearRequestContext).toHaveBeenCalledTimes(1); + } finally { + setHeadersContext(null); + } + }); + + it("falls back to header-only redirects when the target is not an App route", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action-node-middleware.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action-node-middleware.test.ts + const response = await handleServerActionRscRequest( + createRscOptions({ + loadServerAction() { + return Promise.resolve(() => redirect("/pages-target")); + }, + matchRoute(pathname) { + if (pathname === "/pages-target") return null; + return { + params: {}, + route: { id: "dashboard", params: [], pattern: "/dashboard" }, + }; + }, + }), + ); + + expect(response?.status).toBe(303); + expect(response?.headers.get("x-action-redirect")).toBe("/pages-target"); + expect(response?.headers.get("content-type")).toBeNull(); + expect(response?.headers.get("vary")).toBeNull(); + expect(await response?.text()).toBe(""); + }); + it("does not emit x-action-revalidated when a fetch action revalidates a tag with a profile", async () => { const response = await handleServerActionRscRequest( createRscOptions({ @@ -956,7 +1122,9 @@ describe("app server action execution helpers", () => { it("encodes fetch-action redirects as RSC control headers", async () => { const clearContext = vi.fn(); - const renderToReadableStream = vi.fn(); + const renderToReadableStream = vi.fn( + (model: TestActionModel) => new Response(JSON.stringify(model)).body, + ); const response = await handleServerActionRscRequest( createRscOptions({ @@ -974,15 +1142,18 @@ describe("app server action execution helpers", () => { }), ); - expect(response?.status).toBe(200); + expect(response?.status).toBe(303); expect(response?.headers.get("x-action-redirect")).toBe("/target?ok=1"); expect(response?.headers.get("x-action-redirect-type")).toBe("push"); expect(response?.headers.get("x-action-redirect-status")).toBe("308"); expect(response?.headers.get("x-middleware")).toBe("present"); expect(response?.headers.getSetCookie()).toEqual(["action=1; Path=/"]); - expect(await response?.text()).toBe(""); + expect(JSON.parse(await response!.text())).toEqual({ + root: "dashboard:{}:none", + returnValue: { ok: true }, + }); expect(clearContext).toHaveBeenCalledTimes(1); - expect(renderToReadableStream).not.toHaveBeenCalled(); + expect(renderToReadableStream).toHaveBeenCalledTimes(1); }); it("emits x-action-revalidated when a redirecting fetch action revalidates a tag", async () => { @@ -997,47 +1168,184 @@ describe("app server action execution helpers", () => { }), ); - expect(response?.status).toBe(200); + expect(response?.status).toBe(303); expect(response?.headers.get("x-action-revalidated")).toBe("1"); expect(response?.headers.get("x-action-redirect")).toBe("/target"); }); - // Defensive guard for forwarded action POSTs — prevents infinite forwarding - // loops when middleware rewrites actions to pages that don't bundle them. - // Ported from Next.js: vercel/next.js@20892dd - // https://github.com/vercel/next.js/commit/20892dd44e1321c13f755f051e48c3cadd75204b - it("returns action-not-found when x-action-forwarded header is present on fetch action", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + it("processes forwarded action POSTs but suppresses same-page rerenders", async () => { + const renderToReadableStream = vi.fn( + (model: TestActionModel) => new Response(JSON.stringify(model)).body, + ); + + const response = await handleServerActionRscRequest( + createRscOptions({ + request: createFetchActionRequest({ "x-action-forwarded": "1" }), + renderToReadableStream, + }), + ); + + expect(response?.status).toBe(200); + expect(JSON.parse(await response!.text())).toEqual({ + returnValue: { ok: true, data: "action-result" }, + }); + expect(renderToReadableStream).toHaveBeenCalledTimes(1); + }); + + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + it("returns forwarded action redirects with a 200 wrapper response", async () => { const response = await handleServerActionRscRequest( createRscOptions({ request: createFetchActionRequest({ "x-action-forwarded": "1" }), + loadServerAction() { + return Promise.resolve(() => { + throw { digest: "NEXT_REDIRECT;;%2Ftarget;307" }; + }); + }, }), ); - expect(response?.status).toBe(404); - expect(response?.headers.get("x-nextjs-action-not-found")).toBe("1"); - expect(await response?.text()).toBe("Server action not found."); + + expect(response?.status).toBe(200); + expect(response?.headers.get("x-action-redirect")).toBe("/target"); + expect(JSON.parse(await response!.text())).toEqual({ + root: "dashboard:{}:none", + returnValue: { ok: true }, + }); }); - it("returns action-not-found when x-action-forwarded is present on progressive action", async () => { - const response = requireProgressiveActionResponse( - await handleProgressiveServerActionRequest( - createOptions({ - request: createMultipartRequest({ "x-action-forwarded": "1" }), + it("returns stale child-route action redirects with a 200 wrapper response", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + const response = await handleServerActionRscRequest( + createRscOptions({ + cleanPathname: "/delayed-action/node/other", + loadServerAction() { + return Promise.resolve(() => redirect("/delayed-action/node")); + }, + matchRoute(pathname) { + if (pathname === "/delayed-action/node") { + return { + params: {}, + route: { + id: "delayed-action-node", + params: [], + pattern: "/delayed-action/node", + }, + }; + } + return null; + }, + request: createFetchActionRequest({ + "next-action": "action-id", + rsc: "1", }), - ), + }), ); - expect(response.status).toBe(404); - expect(response.headers.get("x-nextjs-action-not-found")).toBe("1"); - expect(await response.text()).toBe("Server action not found."); + + expect(response?.status).toBe(200); + expect(response?.headers.get("x-action-redirect")).toBe("/delayed-action/node"); + expect(JSON.parse(await response!.text())).toEqual({ + root: "delayed-action-node:{}:none", + returnValue: { ok: true }, + }); }); - it("returns action-not-found for any truthy x-action-forwarded value", async () => { + it("returns cross-runtime action redirects with a 200 wrapper response", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts const response = await handleServerActionRscRequest( createRscOptions({ - request: createFetchActionRequest({ "x-action-forwarded": "true" }), + cleanPathname: "/delayed-action/edge/other", + loadServerAction() { + return Promise.resolve(() => redirect("/delayed-action/node")); + }, + matchRoute(pathname) { + if (pathname === "/delayed-action/edge/other") { + return { + params: {}, + route: { + id: "delayed-action-edge-other", + params: [], + pattern: "/delayed-action/edge/other", + runtime: "edge", + }, + }; + } + if (pathname === "/delayed-action/node") { + return { + params: {}, + route: { + id: "delayed-action-node", + params: [], + pattern: "/delayed-action/node", + runtime: null, + }, + }; + } + return null; + }, + resolveRouteRuntime(route) { + return route.runtime ?? null; + }, + request: createFetchActionRequest({ + "next-action": "action-id", + rsc: "1", + }), }), ); - expect(response?.status).toBe(404); - expect(response?.headers.get("x-nextjs-action-not-found")).toBe("1"); + + expect(response?.status).toBe(200); + expect(response?.headers.get("x-action-redirect")).toBe("/delayed-action/node"); + expect(JSON.parse(await response!.text())).toEqual({ + root: "delayed-action-node:{}:none", + returnValue: { ok: true }, + }); + }); + + it("returns stale child sibling action redirects with a 200 wrapper response", async () => { + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + const response = await handleServerActionRscRequest( + createRscOptions({ + cleanPathname: "/delayed-action/edge/other", + loadServerAction() { + return Promise.resolve(() => redirect("/delayed-action/node")); + }, + matchRoute(pathname) { + if (pathname === "/delayed-action/edge/other") { + return { + params: {}, + route: { + id: "delayed-action-edge-other", + params: [], + pattern: "/delayed-action/edge/other", + }, + }; + } + if (pathname === "/delayed-action/node") { + return { + params: {}, + route: { + id: "delayed-action-node", + params: [], + pattern: "/delayed-action/node", + }, + }; + } + return null; + }, + request: createFetchActionRequest({ + "next-action": "action-id", + rsc: "1", + }), + }), + ); + + expect(response?.status).toBe(200); + expect(response?.headers.get("x-action-redirect")).toBe("/delayed-action/node"); }); it("does not block actions when x-action-forwarded is absent", async () => { @@ -1074,6 +1382,7 @@ describe("app server action execution helpers", () => { expect(response?.status).toBe(statusCode); expect(await response?.text()).toBe("fallback-flight"); expect(renderedModel).toEqual({ + root: "dashboard:{}:none", returnValue: { ok: false, data: fallbackError }, }); } diff --git a/tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts b/tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts index 3c334ddf7..841033f0b 100644 --- a/tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts @@ -19,7 +19,7 @@ test.describe("Next.js compat: actions-revalidate (browser)", () => { return page.waitForResponse( (response) => response.request().method() === "POST" && - response.url().includes("/nextjs-compat/action-discarding.rsc"), + response.url().includes("/nextjs-compat/action-discarding"), ); } diff --git a/tests/e2e/app-router/server-actions.spec.ts b/tests/e2e/app-router/server-actions.spec.ts index ba5107a50..402af518d 100644 --- a/tests/e2e/app-router/server-actions.spec.ts +++ b/tests/e2e/app-router/server-actions.spec.ts @@ -100,6 +100,189 @@ test.describe("Server Actions", () => { await expect(page.locator("h1")).toHaveText("About"); }); + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + test("server action posts to the visible route and exposes text/plain failures", async ({ + page, + }) => { + await page.route("**/nextjs-compat/action-response-semantics", async (route) => { + const headers = await route.request().allHeaders(); + if (headers["next-action"]) { + await route.fulfill({ + status: 500, + contentType: "text/plain", + body: "Custom error!", + }); + return; + } + await route.continue(); + }); + + await page.goto(`${BASE}/nextjs-compat/action-response-semantics`); + await waitForAppRouterHydration(page); + await page.click("#complete-action"); + + await expect(page.locator("#action-error")).toHaveText("Custom error!"); + }); + + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + test("server action exposes a stable error for invalid action response content-type", async ({ + page, + }) => { + await page.route("**/nextjs-compat/action-response-semantics", async (route) => { + const headers = await route.request().allHeaders(); + if (headers["next-action"]) { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "Custom error!" }), + }); + return; + } + await route.continue(); + }); + + await page.goto(`${BASE}/nextjs-compat/action-response-semantics`); + await waitForAppRouterHydration(page); + await page.click("#complete-action"); + + await expect(page.locator("#action-error")).toHaveText( + "An unexpected response was received from the server.", + ); + }); + + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + test("server action notFound renders the route not-found boundary", async ({ page }) => { + await page.goto(`${BASE}/nextjs-compat/action-response-semantics`); + await waitForAppRouterHydration(page); + await page.click("#missing-action"); + + await expect(page.locator("#action-not-found")).toHaveText("Action not found boundary"); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex"); + }); + + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + test("same-origin server action redirects complete in a single POST response", async ({ + page, + }) => { + const actionResponses: number[] = []; + page.on("response", (response) => { + const request = response.request(); + if ( + request.method() === "POST" && + new URL(response.url()).pathname === "/nextjs-compat/action-response-semantics" + ) { + actionResponses.push(response.status()); + } + }); + + await page.goto(`${BASE}/nextjs-compat/action-response-semantics`); + await waitForAppRouterHydration(page); + await page.click("#redirect-action"); + + await expect(page).toHaveURL(`${BASE}/about`); + await expect(page.locator("h1")).toHaveText("About"); + expect(actionResponses).toEqual([303]); + }); + + // Ported from Next.js: test/e2e/app-dir/actions/app-action-node-middleware.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action-node-middleware.test.ts + test("server action redirects merge action cookies and strip next-action from the target render", async ({ + page, + }) => { + const actionResponses: number[] = []; + page.on("response", (response) => { + const request = response.request(); + if ( + request.method() === "POST" && + new URL(response.url()).pathname === "/nextjs-compat/action-redirect-cookies" + ) { + actionResponses.push(response.status()); + } + }); + + await page.context().addCookies([ + { + name: "stale", + value: "1", + url: BASE, + }, + ]); + + await page.goto(`${BASE}/nextjs-compat/action-redirect-cookies`); + await waitForAppRouterHydration(page); + await page.click("#redirect-with-cookie-mutation"); + + await expect(page).toHaveURL(`${BASE}/nextjs-compat/action-redirect-cookies/target?baz=1`); + await expect(page.locator("#target-theme")).toHaveText("dark"); + await expect(page.locator("#target-stale")).toHaveText("missing"); + await expect(page.locator("#target-baz")).toHaveText("1"); + expect(actionResponses).toEqual([303]); + }); + + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + test("stale child-route server action redirects return a 200 wrapper response", async ({ + page, + }) => { + let redirectResponseStatus: number | undefined; + page.on("response", (response) => { + const request = response.request(); + if ( + request.method() === "POST" && + response.headers()["x-action-redirect"] && + new URL(response.url()).pathname === "/nextjs-compat/action-forwarded-redirect/other" + ) { + redirectResponseStatus = response.status(); + } + }); + + 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"); + await expect(page.locator("#forwarded-redirect-other")).toHaveText("Forwarded Redirect Other"); + + await expect(async () => { + expect(redirectResponseStatus).toBe(200); + }).toPass({ timeout: 10_000 }); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/action-forwarded-redirect`); + await expect(page.locator("#run-forwarded-redirect")).toBeVisible(); + }); + + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + test("cross-runtime server action redirects return a 200 wrapper response", async ({ page }) => { + let redirectResponseStatus: number | undefined; + page.on("response", (response) => { + const request = response.request(); + if ( + request.method() === "POST" && + response.headers()["x-action-redirect"] && + new URL(response.url()).pathname === "/nextjs-compat/action-forwarded-redirect/edge/other" + ) { + redirectResponseStatus = response.status(); + } + }); + + await page.goto(`${BASE}/nextjs-compat/action-forwarded-redirect/edge`); + await waitForAppRouterHydration(page); + await page.click("#run-cross-runtime-redirect"); + await page.click("#go-cross-runtime-other"); + await expect(page.locator("#cross-runtime-other")).toHaveText("Cross Runtime Other"); + + await expect(async () => { + expect(redirectResponseStatus).toBe(200); + }).toPass({ timeout: 10_000 }); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/action-forwarded-redirect/node`); + await expect(page.locator("#cross-runtime-node-target")).toHaveText( + "Cross Runtime Node Target", + ); + }); + test("server action cookie writes do not make the rerender path mutable", async ({ page }) => { test.slow(); await page.goto(`${BASE}/nextjs-compat/action-cookie-phase`); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index a685bece2..2ae96dcdc 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -365,6 +365,45 @@ describe("App Router generated manifest construction", () => { ]); }); + it("derives route-miss root boundaries when the app has no root page", () => { + const routes = [ + { + pattern: "/server", + patternParts: ["server"], + pagePath: "/tmp/test/app/server/page.tsx", + routePath: null, + layouts: ["/tmp/test/app/layout.tsx"], + templates: [], + parallelSlots: [], + loadingPath: null, + errorPath: null, + layoutErrorPaths: [null], + notFoundPath: "/tmp/test/app/not-found.tsx", + notFoundPaths: ["/tmp/test/app/not-found.tsx"], + forbiddenPath: null, + forbiddenPaths: ["/tmp/test/app/forbidden.tsx"], + unauthorizedPath: null, + unauthorizedPaths: ["/tmp/test/app/unauthorized.tsx"], + routeSegments: ["server"], + templateTreePositions: [], + layoutTreePositions: [0], + isDynamic: false, + params: [], + }, + ] satisfies AppRoute[]; + + const manifest = buildAppRscManifestCode({ + routes, + metadataRoutes: [], + globalErrorPath: null, + }); + + expect(manifest.rootLayoutVars).toEqual(["mod_1"]); + expect(manifest.rootNotFoundVar).toBe("mod_2"); + expect(manifest.rootForbiddenVar).toBe("mod_3"); + expect(manifest.rootUnauthorizedVar).toBe("mod_4"); + }); + it("exposes layout-level generateStaticParams to App Router prerender", () => { // Ported from Next.js: test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts diff --git a/tests/fixtures/app-basic/app/action-redirect-test/redirect-form.tsx b/tests/fixtures/app-basic/app/action-redirect-test/redirect-form.tsx index e377fedf2..5a67a9914 100644 --- a/tests/fixtures/app-basic/app/action-redirect-test/redirect-form.tsx +++ b/tests/fixtures/app-basic/app/action-redirect-test/redirect-form.tsx @@ -8,17 +8,18 @@ export default function RedirectForm() { return (
-
{ - startTransition(async () => { - await redirectAction(); + -
+ {isPending ? "Redirecting..." : "Redirect to About"} +
); } diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/actions.ts b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/actions.ts new file mode 100644 index 000000000..ba13504cb --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/actions.ts @@ -0,0 +1,11 @@ +"use server"; + +import { redirect } from "next/navigation"; + +export async function delayedRedirectAction(): Promise { + redirect("/nextjs-compat/action-forwarded-redirect"); +} + +export async function delayedCrossRuntimeRedirectAction(): Promise { + redirect("/nextjs-compat/action-forwarded-redirect/node"); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/client.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/client.tsx new file mode 100644 index 000000000..cc882041b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/client.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Link from "next/link"; +import { delayedRedirectAction } from "./actions"; + +export default function ActionForwardedRedirectClient() { + return ( +
+

Action Forwarded Redirect

+ + + Other + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/cross-runtime-client.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/cross-runtime-client.tsx new file mode 100644 index 000000000..625e32b6d --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/cross-runtime-client.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Link from "next/link"; +import { delayedCrossRuntimeRedirectAction } from "./actions"; + +export default function ActionForwardedCrossRuntimeClient() { + return ( +
+

Action Forwarded Cross Runtime

+ + + Other + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/layout.tsx new file mode 100644 index 000000000..cdca9bec6 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +export const runtime = "edge"; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/other/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/other/page.tsx new file mode 100644 index 000000000..d01ce6179 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/other/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

Cross Runtime Other

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/page.tsx new file mode 100644 index 000000000..4514b7eb9 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/edge/page.tsx @@ -0,0 +1,5 @@ +import ActionForwardedCrossRuntimeClient from "../cross-runtime-client"; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/node/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/node/page.tsx new file mode 100644 index 000000000..3419c55ee --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/node/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

Cross Runtime Node Target

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/other/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/other/page.tsx new file mode 100644 index 000000000..c8ef5d6d2 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/other/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

Forwarded Redirect Other

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/page.tsx new file mode 100644 index 000000000..a8acfef59 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-forwarded-redirect/page.tsx @@ -0,0 +1,5 @@ +import ActionForwardedRedirectClient from "./client"; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/page.tsx new file mode 100644 index 000000000..0f95872c3 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/page.tsx @@ -0,0 +1,27 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +async function updateCookiesAndRedirect() { + "use server"; + + const cookieStore = await cookies(); + cookieStore.delete("stale"); + cookieStore.set("theme", "dark"); + redirect("/nextjs-compat/action-redirect-cookies/target?baz=1"); +} + +export default async function Page() { + const cookieStore = await cookies(); + + return ( +
+

Action Redirect Cookies

+

{cookieStore.get("theme")?.value ?? "missing"}

+
+ +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/target/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/target/page.tsx new file mode 100644 index 000000000..ee59bac6d --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-redirect-cookies/target/page.tsx @@ -0,0 +1,19 @@ +import { cookies, headers } from "next/headers"; + +export default async function Page({ searchParams }: { searchParams: Promise<{ baz?: string }> }) { + const cookieStore = await cookies(); + const headerStore = await headers(); + + if (headerStore.get("next-action")) { + throw new Error("Action header should not be present"); + } + + return ( +
+

Action Redirect Cookie Target

+

{cookieStore.get("theme")?.value ?? "missing"}

+

{cookieStore.get("stale")?.value ?? "missing"}

+

{(await searchParams).baz ?? "missing"}

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/actions.ts b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/actions.ts new file mode 100644 index 000000000..e51084085 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/actions.ts @@ -0,0 +1,15 @@ +"use server"; + +import { notFound, redirect } from "next/navigation"; + +export async function completeAction(): Promise { + return "complete"; +} + +export async function redirectToAbout(): Promise { + redirect("/about"); +} + +export async function missingAction(): Promise { + notFound(); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/client.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/client.tsx new file mode 100644 index 000000000..f43f07dff --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/client.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useTransition } from "react"; +import { completeAction, missingAction, redirectToAbout } from "./actions"; + +export default function ActionResponseSemanticsClient() { + const [, startTransition] = useTransition(); + + return ( +
+

Action Response Semantics

+ + + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/error.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/error.tsx new file mode 100644 index 000000000..d9b00affe --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/error.tsx @@ -0,0 +1,5 @@ +"use client"; + +export default function Error({ error }: { error: Error }) { + return

{error.message}

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/not-found.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/not-found.tsx new file mode 100644 index 000000000..02438c0d8 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/not-found.tsx @@ -0,0 +1,3 @@ +export default function NotFound() { + return

Action not found boundary

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/page.tsx new file mode 100644 index 000000000..83c46b999 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-response-semantics/page.tsx @@ -0,0 +1,5 @@ +import ActionResponseSemanticsClient from "./client"; + +export default function Page() { + return ; +} From 90057bc40641bdd027ddea4192a77aa0ca37c19a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 23 May 2026 21:58:26 +1000 Subject: [PATCH 2/4] fix(app-router): harden action redirect edge cases 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. --- .../src/server/app-browser-action-result.ts | 26 ++++++- .../vinext/src/server/app-browser-entry.ts | 16 +++++ .../src/server/app-server-action-execution.ts | 15 ++++- tests/app-browser-entry.test.ts | 33 ++++++++- tests/app-server-action-execution.test.ts | 67 +++++++++++++++++++ 5 files changed, 153 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/app-browser-action-result.ts b/packages/vinext/src/server/app-browser-action-result.ts index 0e07b9e8d..78b69e2fa 100644 --- a/packages/vinext/src/server/app-browser-action-result.ts +++ b/packages/vinext/src/server/app-browser-action-result.ts @@ -1,5 +1,9 @@ import { ACTION_REVALIDATED_HEADER } from "./headers.js"; -import { VINEXT_RSC_CONTENT_TYPE } from "./app-rsc-cache-busting.js"; +import { + isRscCompatibilityIdCompatible, + VINEXT_RSC_COMPATIBILITY_ID_HEADER, + VINEXT_RSC_CONTENT_TYPE, +} from "./app-rsc-cache-busting.js"; export type AppBrowserServerActionResult = { root?: TRoot; @@ -98,7 +102,7 @@ export async function readInvalidServerActionResponseError( // surfaced to the action caller, using a plain text 4xx/5xx body when one is // available and otherwise falling back to a stable generic message. const message = - response.status >= 400 && contentType === "text/plain" + response.status >= 400 && contentType.toLowerCase().startsWith("text/plain") ? await response.text() : "An unexpected response was received from the server."; @@ -111,6 +115,24 @@ export function shouldCheckRscCompatibilityForServerActionResponse( return (response.headers.get("content-type") ?? "").startsWith(VINEXT_RSC_CONTENT_TYPE); } +export function resolveServerActionRedirectCompatibilityHardNavigationTarget(options: { + actionRedirectHref: string | null; + clientCompatibilityId: string | null | undefined; + response: Pick; +}): string | null { + if (!options.actionRedirectHref) return null; + if (!shouldCheckRscCompatibilityForServerActionResponse(options.response)) return null; + if ( + isRscCompatibilityIdCompatible( + options.response.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER), + options.clientCompatibilityId, + ) + ) { + return null; + } + return options.actionRedirectHref; +} + export function shouldScheduleRefreshForDiscardedServerAction( revalidation: ServerActionRevalidationKind, ): boolean { diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 776809413..458e72edf 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -62,6 +62,7 @@ import { normalizeServerActionThrownValue, parseServerActionRevalidationHeader, readInvalidServerActionResponseError, + resolveServerActionRedirectCompatibilityHardNavigationTarget, shouldCheckRscCompatibilityForServerActionResponse, shouldClearClientNavigationCachesForServerActionResult, type ServerActionRevalidationKind, @@ -1243,6 +1244,21 @@ function registerServerActionCallback(): void { return undefined; } + const actionRedirectCompatibilityHardNavigationTarget = + resolveServerActionRedirectCompatibilityHardNavigationTarget({ + actionRedirectHref: actionRedirectTarget?.href ?? null, + clientCompatibilityId: CLIENT_RSC_COMPATIBILITY_ID, + response: fetchResponse, + }); + if (actionRedirectCompatibilityHardNavigationTarget) { + clearClientNavigationCaches(); + browserNavigationController.performHardNavigation( + actionRedirectCompatibilityHardNavigationTarget, + actionRedirectTarget?.type === "push" ? "assign" : "replace", + ); + return undefined; + } + if ( !actionRedirectTarget && shouldCheckRscCompatibilityForServerActionResponse(fetchResponse) && diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index abc16c59e..fdbe31f65 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -83,7 +83,9 @@ type AppServerActionRedirect = { }; type AppServerActionRoute = { + page?: unknown; pattern: string; + routeHandler?: unknown; routeSegments?: readonly string[]; }; @@ -561,6 +563,12 @@ function shouldUseForwardedActionRedirectStatus { const error = await readInvalidServerActionResponseError( new Response("Custom error!", { status: 500, - headers: { "content-type": "text/plain" }, + headers: { "content-type": "text/plain;charset=utf-8" }, }), false, ); @@ -697,6 +698,36 @@ describe("app browser entry navigation scheduling", () => { expect(error?.message).toBe("Custom error!"); }); + it("hard-navigates incompatible RSC action redirect responses to the redirect target", () => { + const target = resolveServerActionRedirectCompatibilityHardNavigationTarget({ + actionRedirectHref: "https://example.com/target", + clientCompatibilityId: "client-build", + response: new Response("flight", { + headers: { + "content-type": "text/x-component", + [VINEXT_RSC_COMPATIBILITY_ID_HEADER]: "server-build", + }, + }), + }); + + expect(target).toBe("https://example.com/target"); + }); + + it("does not hard-navigate compatible RSC action redirect responses", () => { + const target = resolveServerActionRedirectCompatibilityHardNavigationTarget({ + actionRedirectHref: "https://example.com/target", + clientCompatibilityId: "same-build", + response: new Response("flight", { + headers: { + "content-type": "text/x-component", + [VINEXT_RSC_COMPATIBILITY_ID_HEADER]: "same-build", + }, + }), + }); + + expect(target).toBeNull(); + }); + it("uses a stable generic error for non-RSC action responses", async () => { // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts diff --git a/tests/app-server-action-execution.test.ts b/tests/app-server-action-execution.test.ts index 00ae943a2..943c15b12 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -30,8 +30,10 @@ import { withEnvVar } from "./env-test-helpers.js"; type TestRoute = { id: string; + page?: unknown; params: readonly string[]; pattern: string; + routeHandler?: unknown; routeSegments?: readonly string[]; runtime?: "edge" | "experimental-edge" | "nodejs" | null; }; @@ -910,6 +912,44 @@ describe("app server action execution helpers", () => { expect(await response?.text()).toBe(""); }); + it("falls back to header-only redirects when the target is an App route handler", async () => { + const buildPageElement = vi.fn(() => "should-not-render"); + + const response = await handleServerActionRscRequest( + createRscOptions({ + buildPageElement, + loadServerAction() { + return Promise.resolve(() => redirect("/api/logout")); + }, + matchRoute(pathname) { + if (pathname === "/api/logout") { + return { + params: {}, + route: { + id: "api-logout", + page: null, + params: [], + pattern: "/api/logout", + routeHandler: {}, + }, + }; + } + return { + params: {}, + route: { id: "dashboard", page: {}, params: [], pattern: "/dashboard" }, + }; + }, + }), + ); + + expect(response?.status).toBe(303); + expect(response?.headers.get("x-action-redirect")).toBe("/api/logout"); + expect(response?.headers.get("content-type")).toBeNull(); + expect(response?.headers.get("vary")).toBeNull(); + expect(await response?.text()).toBe(""); + expect(buildPageElement).not.toHaveBeenCalled(); + }); + it("does not emit x-action-revalidated when a fetch action revalidates a tag with a profile", async () => { const response = await handleServerActionRscRequest( createRscOptions({ @@ -1194,6 +1234,33 @@ describe("app server action execution helpers", () => { expect(renderToReadableStream).toHaveBeenCalledTimes(1); }); + it("preserves forwarded action cookie and revalidation side effects without a rerender", async () => { + const response = await handleServerActionRscRequest( + createRscOptions({ + getAndClearPendingCookies() { + return ["forwarded=1; Path=/"]; + }, + getDraftModeCookieHeader() { + return "draft=1; Path=/"; + }, + loadServerAction() { + return Promise.resolve(async () => { + await revalidatePath("/dashboard"); + return "forwarded-result"; + }); + }, + request: createFetchActionRequest({ "x-action-forwarded": "1" }), + }), + ); + + expect(response?.status).toBe(200); + expect(response?.headers.get("x-action-revalidated")).toBe("1"); + expect(response?.headers.getSetCookie()).toEqual(["forwarded=1; Path=/", "draft=1; Path=/"]); + expect(JSON.parse(await response!.text())).toEqual({ + returnValue: { ok: true, data: "forwarded-result" }, + }); + }); + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts it("returns forwarded action redirects with a 200 wrapper response", async () => { From 12f041d28a047eef61fc3b6bc540de0ee299d878 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 23 May 2026 22:31:08 +1000 Subject: [PATCH 3/4] ci: allow zizmor to read action metadata 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. --- .github/workflows/zizmor.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index da09e9a31..a1d7f4532 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -13,6 +13,7 @@ jobs: name: Run zizmor runs-on: ubuntu-latest permissions: + contents: read security-events: write steps: - name: Checkout repository From b0d2df8a3c158f250ecf75f70bfa6e8ac3ca3aa0 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 24 May 2026 01:46:03 +1000 Subject: [PATCH 4/4] fix(app-router): harden action redirect review cases Forwarded action redirects treated a missing segment runtime as incomparable instead of the implicit Node runtime. That let implicit Node to explicit Edge redirects use 303 semantics even though they cross runtime boundaries and need the wrapper response path. Server action dispatch also left action-owned HTTP fallback head metadata in place until a response reached the status sync path. Invalid non-RSC responses could throw first and leave stale noindex metadata behind. Normalize null route runtimes to nodejs at the comparison boundary, clear action fallback head metadata before each action fetch, and cover both regressions. The redirect render cookie replay now reuses the existing request cookie parser instead of carrying a local parser clone. --- .../vinext/src/server/app-browser-entry.ts | 1 + .../src/server/app-server-action-execution.ts | 29 ++++++----- tests/app-server-action-execution.test.ts | 49 +++++++++++++++++++ tests/e2e/app-router/server-actions.spec.ts | 34 +++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 458e72edf..1137f7abc 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1228,6 +1228,7 @@ function registerServerActionCallback(): void { previousNextUrl: actionInitiation.routerState.previousNextUrl, }); + syncServerActionHttpFallbackHead(null); const fetchResponse = await fetch(createServerActionRequestUrl(actionInitiation.path), { method: "POST", headers, diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index fdbe31f65..47b9546a4 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -4,6 +4,7 @@ import { setHeadersContext, type HeadersAccessPhase, } from "vinext/shims/headers"; +import { parseCookieHeader } from "vinext/shims/internal/parse-cookie-header"; import { type FetchCacheMode, setCurrentFetchCacheMode, @@ -90,6 +91,7 @@ type AppServerActionRoute = { }; type AppServerActionRouteRuntime = "edge" | "experimental-edge" | "nodejs" | null; +type NormalizedAppServerActionRouteRuntime = Exclude; type ProgressiveServerActionResult = | { @@ -299,16 +301,7 @@ function applySetCookieMutationsToRequestCookieHeader( cookieHeader: string | null, setCookies: readonly string[], ): string | null { - const cookies = new Map(); - if (cookieHeader) { - for (const part of cookieHeader.split(";")) { - const trimmed = part.trim(); - if (!trimmed) continue; - const equalsIndex = trimmed.indexOf("="); - if (equalsIndex <= 0) continue; - cookies.set(trimmed.slice(0, equalsIndex), trimmed.slice(equalsIndex + 1)); - } - } + const cookies = cookieHeader ? parseCookieHeader(cookieHeader) : new Map(); for (const setCookie of setCookies) { const entry = readSetCookieNameValue(setCookie); @@ -543,6 +536,12 @@ function isStaleChildSiblingRouteRedirect( return commonPrefixLength > 0 && commonPrefixLength < targetSegments.length; } +function normalizeAppServerActionRouteRuntime( + runtime: AppServerActionRouteRuntime, +): NormalizedAppServerActionRouteRuntime { + return runtime ?? "nodejs"; +} + function shouldUseForwardedActionRedirectStatus(options: { actionWasForwarded: boolean; currentPathname: string; @@ -558,9 +557,13 @@ function shouldUseForwardedActionRedirectStatus { }); }); + it("treats implicit node to explicit edge action redirects as cross-runtime", async () => { + const response = await handleServerActionRscRequest( + createRscOptions({ + loadServerAction() { + return Promise.resolve(() => redirect("/delayed-action/edge")); + }, + matchRoute(pathname) { + if (pathname === "/dashboard") { + return { + params: {}, + route: { + id: "dashboard", + params: [], + pattern: "/dashboard", + runtime: null, + }, + }; + } + if (pathname === "/delayed-action/edge") { + return { + params: {}, + route: { + id: "delayed-action-edge", + params: [], + pattern: "/delayed-action/edge", + runtime: "edge", + }, + }; + } + return null; + }, + resolveRouteRuntime(route) { + return route.runtime ?? null; + }, + request: createFetchActionRequest({ + "next-action": "action-id", + rsc: "1", + }), + }), + ); + + expect(response?.status).toBe(200); + expect(response?.headers.get("x-action-redirect")).toBe("/delayed-action/edge"); + expect(JSON.parse(await response!.text())).toEqual({ + root: "delayed-action-edge:{}:none", + returnValue: { ok: true }, + }); + }); + it("returns stale child sibling action redirects with a 200 wrapper response", async () => { // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts diff --git a/tests/e2e/app-router/server-actions.spec.ts b/tests/e2e/app-router/server-actions.spec.ts index 402af518d..080826e03 100644 --- a/tests/e2e/app-router/server-actions.spec.ts +++ b/tests/e2e/app-router/server-actions.spec.ts @@ -163,6 +163,40 @@ test.describe("Server Actions", () => { await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex"); }); + test("server action invalid responses clear stale not-found head metadata", async ({ page }) => { + await page.goto(`${BASE}/nextjs-compat/action-response-semantics`); + await waitForAppRouterHydration(page); + + await page.evaluate(() => { + const robots = document.createElement("meta"); + robots.name = "robots"; + robots.content = "noindex"; + robots.setAttribute("data-vinext-action-http-fallback", "robots"); + document.head.appendChild(robots); + }); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex"); + + await page.route("**/nextjs-compat/action-response-semantics", async (route) => { + const headers = await route.request().allHeaders(); + if (headers["next-action"]) { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "Custom error!" }), + }); + return; + } + await route.continue(); + }); + + await page.click("#complete-action"); + + await expect(page.locator("#action-error")).toHaveText( + "An unexpected response was received from the server.", + ); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + }); + // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts test("same-origin server action redirects complete in a single POST response", async ({