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 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..78b69e2fa 100644 --- a/packages/vinext/src/server/app-browser-action-result.ts +++ b/packages/vinext/src/server/app-browser-action-result.ts @@ -1,4 +1,9 @@ import { ACTION_REVALIDATED_HEADER } from "./headers.js"; +import { + isRscCompatibilityIdCompatible, + VINEXT_RSC_COMPATIBILITY_ID_HEADER, + VINEXT_RSC_CONTENT_TYPE, +} from "./app-rsc-cache-busting.js"; export type AppBrowserServerActionResult = { root?: TRoot; @@ -70,6 +75,64 @@ 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.toLowerCase().startsWith("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 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 1f1c389d9..1137f7abc 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -59,7 +59,11 @@ import { createDiscardedServerActionRefreshScheduler, createServerActionInitiationSnapshot, isServerActionResult, + normalizeServerActionThrownValue, parseServerActionRevalidationHeader, + readInvalidServerActionResponseError, + resolveServerActionRedirectCompatibilityHardNavigationTarget, + shouldCheckRscCompatibilityForServerActionResponse, shouldClearClientNavigationCachesForServerActionResult, type ServerActionRevalidationKind, type AppBrowserServerActionResult, @@ -110,6 +114,7 @@ import { throwOnServerActionNotFound } from "./server-action-not-found.js"; import { createRscRequestHeaders, createRscRequestUrl, + createServerActionRequestUrl, getVinextRscCompatibilityId, resolveHardNavigationTargetFromRscResponse, resolveRscCompatibilityNavigationDecision, @@ -199,6 +204,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 +590,7 @@ async function renderNavigationPayload( operationLane: OperationLane = "navigation", traversalIntent: HistoryTraversalIntent | null = null, ): Promise { + syncServerActionHttpFallbackHead(null); try { return await browserNavigationController.renderNavigationPayload({ actionType, @@ -595,6 +616,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 +1228,8 @@ function registerServerActionCallback(): void { previousNextUrl: actionInitiation.routerState.previousNextUrl, }); - const fetchResponse = await fetch(await createRscRequestUrl(actionInitiation.path, headers), { + syncServerActionHttpFallbackHead(null); + const fetchResponse = await fetch(createServerActionRequestUrl(actionInitiation.path), { method: "POST", headers, body, @@ -1171,39 +1239,30 @@ 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. - } + const hasActionRedirect = fetchResponse.headers.has(ACTION_REDIRECT_HEADER); + const actionRedirectTarget = resolveActionRedirectTarget(fetchResponse); + if (hasActionRedirect && !actionRedirectTarget) { + return undefined; + } - // 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. + const actionRedirectCompatibilityHardNavigationTarget = + resolveServerActionRedirectCompatibilityHardNavigationTarget({ + actionRedirectHref: actionRedirectTarget?.href ?? null, + clientCompatibilityId: CLIENT_RSC_COMPATIBILITY_ID, + response: fetchResponse, + }); + if (actionRedirectCompatibilityHardNavigationTarget) { clearClientNavigationCaches(); - const redirectType = fetchResponse.headers.get(ACTION_REDIRECT_TYPE_HEADER) ?? "replace"; - if (redirectType === "push") { - browserNavigationController.performHardNavigation(actionRedirect, "assign"); - } else { - browserNavigationController.performHardNavigation(actionRedirect, "replace"); - } + browserNavigationController.performHardNavigation( + actionRedirectCompatibilityHardNavigationTarget, + actionRedirectTarget?.type === "push" ? "assign" : "replace", + ); return undefined; } if ( + !actionRedirectTarget && + shouldCheckRscCompatibilityForServerActionResponse(fetchResponse) && resolveRscCompatibilityNavigationDecision({ clientCompatibilityId: CLIENT_RSC_COMPATIBILITY_ID, currentHref: actionInitiation.href, @@ -1217,14 +1276,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 +1346,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..47b9546a4 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -1,9 +1,18 @@ 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 { parseCookieHeader } from "vinext/shims/internal/parse-cookie-header"; +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 +26,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, @@ -73,9 +84,15 @@ type AppServerActionRedirect = { }; type AppServerActionRoute = { + page?: unknown; pattern: string; + routeHandler?: unknown; + routeSegments?: readonly string[]; }; +type AppServerActionRouteRuntime = "edge" | "experimental-edge" | "nodejs" | null; +type NormalizedAppServerActionRouteRuntime = Exclude; + type ProgressiveServerActionResult = | { formState: ReactFormState | null; @@ -201,6 +218,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 +238,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 +265,105 @@ 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 = cookieHeader ? parseCookieHeader(cookieHeader) : new Map(); + + 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 +486,92 @@ 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 normalizeAppServerActionRouteRuntime( + runtime: AppServerActionRouteRuntime, +): NormalizedAppServerActionRouteRuntime { + return runtime ?? "nodejs"; +} + +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 = normalizeAppServerActionRouteRuntime( + options.resolveRouteRuntime(options.currentRoute), + ); + const targetRuntime = normalizeAppServerActionRouteRuntime( + options.resolveRouteRuntime(options.targetRoute), + ); + return currentRuntime !== targetRuntime; +} + +function canRenderActionRedirectTarget(route: AppServerActionRoute): boolean { + if ("routeHandler" in route && route.routeHandler) return false; + if ("page" in route) return route.page !== null && route.page !== undefined; + return true; +} + function getActionHttpFallbackStatus(error: unknown): number | null { const digest = getNextErrorDigest(error); if (!digest) return null; @@ -430,14 +643,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 +801,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 +856,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 +888,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 +898,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 +910,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 || !canRenderActionRedirectTarget(targetMatch.route)) { + 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 +995,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,19 +1009,26 @@ export async function handleServerActionRscRequest< { temporaryReferences, onError: onRenderError }, ); - options.clearRequestContext(); - const actionHeaders = new Headers({ "Content-Type": VINEXT_RSC_CONTENT_TYPE, Vary: VINEXT_RSC_VARY_HEADER, }); mergeMiddlewareResponseHeaders(actionHeaders, options.middlewareHeaders); applyRscCompatibilityIdHeader(actionHeaders); - - return new Response(rscStream, { - status: options.middlewareStatus ?? actionStatus, - headers: actionHeaders, - }); + for (const cookie of actionPendingCookies) { + actionHeaders.append("Set-Cookie", cookie); + } + if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie); + setActionRevalidatedHeader(actionHeaders, actionRevalidationKind); + + return createServerActionRscResponse( + rscStream, + { + status: options.middlewareStatus ?? actionStatus, + headers: actionHeaders, + }, + options.clearRequestContext, + ); } const match = options.matchRoute(options.cleanPathname); @@ -775,6 +1055,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 +1092,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..3b772dae1 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -4,7 +4,11 @@ import { createOnUncaughtError } from "../packages/vinext/src/server/app-browser import { createDiscardedServerActionRefreshScheduler, createServerActionInitiationSnapshot, + normalizeServerActionThrownValue, parseServerActionRevalidationHeader, + readInvalidServerActionResponseError, + resolveServerActionRedirectCompatibilityHardNavigationTarget, + shouldCheckRscCompatibilityForServerActionResponse, shouldClearClientNavigationCachesForServerActionResult, shouldScheduleRefreshForDiscardedServerAction, } from "../packages/vinext/src/server/app-browser-action-result.js"; @@ -588,6 +592,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 +675,85 @@ 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;charset=utf-8" }, + }), + false, + ); + + 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 + 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 +4435,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 +4445,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..d3ea8cb2a 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -12,19 +12,30 @@ 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; + page?: unknown; params: readonly string[]; pattern: string; + routeHandler?: unknown; + routeSegments?: readonly string[]; + runtime?: "edge" | "experimental-edge" | "nodejs" | null; }; type TestInterceptOptions = { @@ -744,6 +755,201 @@ 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("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({ @@ -956,7 +1162,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 +1182,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 +1208,260 @@ 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(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(JSON.parse(await response!.text())).toEqual({ + returnValue: { ok: true, data: "action-result" }, + }); + expect(renderToReadableStream).toHaveBeenCalledTimes(1); }); - 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("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 () => { + 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(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 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("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 + 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 +1498,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..080826e03 100644 --- a/tests/e2e/app-router/server-actions.spec.ts +++ b/tests/e2e/app-router/server-actions.spec.ts @@ -100,6 +100,223 @@ 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"); + }); + + 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 ({ + 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 ; +}