From ab65bda1c6fe3d563716b948e382f4b324712cb8 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:36:39 -0700 Subject: [PATCH 1/4] Route Server Actions directly to owning routes --- packages/next/errors.json | 3 +- packages/next/src/client/app-call-server.ts | 4 ++ .../create-initial-router-state.ts | 8 ++- .../router-reducer/fetch-server-response.ts | 2 + .../router-reducer/ppr-navigations.ts | 7 +++ .../reducers/server-action-reducer.ts | 44 ++++++++++++--- .../router-reducer/router-reducer-types.ts | 7 +++ .../client/components/segment-cache/cache.ts | 7 +++ .../components/segment-cache/navigation.ts | 16 +++++- .../segment-cache/optimistic-routes.ts | 1 + .../next/src/client/flight-data-helpers.ts | 1 + .../next/src/client/server-action-dispatch.ts | 55 +++++++++++++++++++ .../server/app-render/action-handler.test.ts | 11 ++++ .../src/server/app-render/action-handler.ts | 9 ++- .../next/src/server/app-render/app-render.tsx | 3 + .../app-render/collect-segment-data.tsx | 2 + .../server/app-render/manifests-singleton.ts | 37 +++++++++++++ .../next/src/shared/lib/app-router-types.ts | 6 ++ .../shared/lib/server-action-routing-key.ts | 24 ++++++++ test/e2e/app-dir/actions/app-action.test.ts | 44 ++++++++++++++- test/unit/server-action-dispatch.test.ts | 11 ++++ 21 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 packages/next/src/client/server-action-dispatch.ts create mode 100644 packages/next/src/shared/lib/server-action-routing-key.ts create mode 100644 test/unit/server-action-dispatch.test.ts diff --git a/packages/next/errors.json b/packages/next/errors.json index f25fbaa1d489..6fb466844280 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1467,5 +1467,6 @@ "1466": "TypeScript %s does not provide the compiler API required by Next.js. Set %s to true in your Next.js config to use the TypeScript CLI, or install TypeScript 6 instead.", "1467": "TypeScript %s does not provide the compiler API required by Next.js. Set %s back to true in your Next.js config to use the TypeScript CLI, or install TypeScript 6 instead.", "1468": "Missing workUnitStore in createComponentTree", - "1469": "The payload's transport tree is missing a slot that exists in the loader tree: %s" + "1469": "The payload's transport tree is missing a slot that exists in the loader tree: %s", + "1470": "Invariant: Missing Server Action dispatch context. This indicates that the action routing metadata was not registered for this action." } diff --git a/packages/next/src/client/app-call-server.ts b/packages/next/src/client/app-call-server.ts index c447e163828a..edd4104591cb 100644 --- a/packages/next/src/client/app-call-server.ts +++ b/packages/next/src/client/app-call-server.ts @@ -1,14 +1,18 @@ import { startTransition } from 'react' import { ACTION_SERVER_ACTION } from './components/router-reducer/router-reducer-types' import { dispatchAppRouterAction } from './components/use-action-queue' +import { getServerActionDispatchContext } from './server-action-dispatch' export async function callServer(actionId: string, actionArgs: any[]) { + const actionDispatchContext = await getServerActionDispatchContext(actionId) + return new Promise((resolve, reject) => { startTransition(() => { dispatchAppRouterAction({ type: ACTION_SERVER_ACTION, actionId, actionArgs, + actionDispatchContext, resolve, reject, }) diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts index c5ee737b77bd..edd2111beb36 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts @@ -21,6 +21,7 @@ import { import { decodeStageUntilBoundary } from './fetch-server-response' import { discoverKnownRoute } from '../segment-cache/optimistic-routes' import type { NormalizedSearch } from '../segment-cache/cache-key' +import { registerServerActionDispatchContext } from '../../server-action-dispatch' export interface InitialRouterStateParameters { navigatedAt: number @@ -68,6 +69,10 @@ export function createInitialRouterState({ createHrefFromUrl(location) : initialCanonicalUrl + if (location !== null) { + registerServerActionDispatchContext(initialRSCPayload.A, canonicalUrl, null) + } + // Decode the initial transport tree into the RouteTree type, with the // payload's render output embedded on each node. (discoverKnownRoute below // stores this tree in the route cache, which strips the data on write — @@ -106,7 +111,7 @@ export function createInitialRouterState({ // route learning nor segment cache state persists from SSR to client. if (location !== null && metadataVaryPath !== null) { // Learn the route pattern so we can predict it for future navigations. - discoverKnownRoute( + const fulfilledRoute = discoverKnownRoute( Date.now(), location.pathname, location.search as NormalizedSearch, @@ -119,6 +124,7 @@ export function createInitialRouterState({ initialSupportsPerSegmentPrefetching, false // hasDynamicRewrite ) + fulfilledRoute.actionRoutingKeys = initialRSCPayload.A ?? null // TODO: Implement Shell extraction as part of Cached Navigations. // Intentionally holding off on doing this until we decide how the Cached diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 73999c01bc5e..6eb764bee075 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -81,6 +81,7 @@ type SpaFetchServerResponseResult = { renderedSearch: NormalizedSearch couldBeIntercepted: boolean supportsPerSegmentPrefetching: boolean + actionRoutingKeys: readonly string[] | null postponed: boolean dynamicStaleTime: number staticStageData: StaticStageData | null @@ -300,6 +301,7 @@ export async function fetchServerResponse( renderedSearch: flightResponse.q as NormalizedSearch, couldBeIntercepted: interception, supportsPerSegmentPrefetching: flightResponse.S, + actionRoutingKeys: flightResponse.A ?? null, postponed, // The dynamicStaleTime is only present in the response body when // a page exports unstable_dynamicStaleTime and this is a dynamic render. diff --git a/packages/next/src/client/components/router-reducer/ppr-navigations.ts b/packages/next/src/client/components/router-reducer/ppr-navigations.ts index 209d30755a01..6867763b7922 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/router-reducer/ppr-navigations.ts @@ -57,6 +57,7 @@ import { updateBFCacheEntryStaleAt, computeDynamicStaleAt, } from '../segment-cache/bfcache' +import { registerServerActionDispatchContext } from '../../server-action-dispatch' // This is yet another tree type that is used to track pending promises that // need to be fulfilled once the dynamic data is received. The terminal nodes of @@ -1812,6 +1813,12 @@ async function fetchMissingDynamicData( await navigationLock } + registerServerActionDispatchContext( + result.actionRoutingKeys ?? undefined, + result.canonicalUrl, + nextUrl + ) + // TODO: Implement Shell extraction as part of Cached Navigations. // Intentionally holding off on doing this until we decide how the Cached // Navigations behavior should work in combination with App Shells. diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index e7bbd2fd8b94..868556eddbe5 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -70,6 +70,7 @@ import { invalidateBfCache, UnknownDynamicStaleTime, } from '../../segment-cache/bfcache' +import { registerServerActionDispatchContext } from '../../../server-action-dispatch' const createFromFetch = createFromFetchBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromFetch'] @@ -98,6 +99,7 @@ type FetchServerActionResult = { */ actionFlightData: PartialTransportData | string | undefined actionFlightDataRenderedSearch: NormalizedSearch | undefined + actionRoutingKeys: readonly string[] | undefined isPrerender: boolean couldBeIntercepted: boolean } @@ -107,7 +109,17 @@ async function fetchServerAction( nextUrl: ReadonlyReducerState['nextUrl'], action: ServerActionAction ): Promise { - const { actionId, actionArgs } = action + const { actionId, actionArgs, actionDispatchContext } = action + const currentRequestUrl = new URL(state.canonicalUrl, window.location.origin) + const actionRequestUrl = new URL( + actionDispatchContext.url, + window.location.origin + ) + const actionNextUrl = actionDispatchContext.nextUrl + const isCrossRouteDispatch = + actionRequestUrl.pathname !== currentRequestUrl.pathname || + actionRequestUrl.search !== currentRequestUrl.search || + actionNextUrl !== nextUrl const temporaryReferences = createTemporaryReferenceSet() const info = extractInfoFromServerReferenceId(actionId) const usedArgs = omitUnusedArgs(actionArgs, info) @@ -116,9 +128,12 @@ async function fetchServerAction( const headers: Record = { Accept: RSC_CONTENT_TYPE_HEADER, [ACTION_HEADER]: actionId, - [NEXT_ROUTER_STATE_TREE_HEADER]: prepareFlightRouterStateForRequest( + } + + if (!isCrossRouteDispatch) { + headers[NEXT_ROUTER_STATE_TREE_HEADER] = prepareFlightRouterStateForRequest( state.tree - ), + ) } const deploymentId = getDeploymentId() @@ -126,8 +141,8 @@ async function fetchServerAction( headers['x-deployment-id'] = deploymentId } - if (nextUrl) { - headers[NEXT_URL] = nextUrl + if (actionNextUrl) { + headers[NEXT_URL] = actionNextUrl } if (process.env.__NEXT_DEV_SERVER) { @@ -145,7 +160,11 @@ async function fetchServerAction( let res: Response try { - res = await fetch(state.canonicalUrl, { method: 'POST', headers, body }) + res = await fetch(createHrefFromUrl(actionRequestUrl), { + method: 'POST', + headers, + body, + }) // If the fetch succeeds while we're in the offline state, notify the // offline module so it can short-circuit the polling loop. if (process.env.__NEXT_USE_OFFLINE) { @@ -238,6 +257,7 @@ async function fetchServerAction( let actionResult: FetchServerActionResult['actionResult'] let actionFlightData: FetchServerActionResult['actionFlightData'] let actionFlightDataRenderedSearch: FetchServerActionResult['actionFlightDataRenderedSearch'] + let actionRoutingKeys: FetchServerActionResult['actionRoutingKeys'] let couldBeIntercepted: boolean = false if (isRscResponse) { @@ -261,6 +281,7 @@ async function fetchServerAction( // An internal redirect can send an RSC response, but does not have a useful `actionResult`. actionResult = redirectLocation ? undefined : response.a couldBeIntercepted = response.i + actionRoutingKeys = response.A // Check if the response build ID matches the client build ID. // In a multi-zone setup, when a server action triggers a redirect, @@ -293,12 +314,14 @@ async function fetchServerAction( actionResult = undefined actionFlightData = undefined actionFlightDataRenderedSearch = undefined + actionRoutingKeys = undefined } return { actionResult, actionFlightData, actionFlightDataRenderedSearch, + actionRoutingKeys, redirectLocation, redirectType, revalidationKind, @@ -338,6 +361,7 @@ export function serverActionReducer( actionResult, actionFlightData: flightData, actionFlightDataRenderedSearch: flightDataRenderedSearch, + actionRoutingKeys, redirectLocation, redirectType, isPrerender, @@ -472,6 +496,11 @@ export function serverActionReducer( // new fetch, like we would for a normal navigation. const redirectCanonicalUrl = createHrefFromUrl(redirectUrl) const now = Date.now() + registerServerActionDispatchContext( + actionRoutingKeys, + redirectCanonicalUrl, + nextUrl + ) // TODO: Store the dynamic stale time on the top-level state so it's // known during restores and refreshes. const redirectSeed = convertServerPatchToFullTree( @@ -485,7 +514,7 @@ export function serverActionReducer( // Learn the route pattern so we can predict it for future navigations. const metadataVaryPath = redirectSeed.metadataVaryPath if (metadataVaryPath !== null) { - discoverKnownRoute( + const fulfilledRoute = discoverKnownRoute( now, redirectUrl.pathname, redirectUrl.search as NormalizedSearch, @@ -498,6 +527,7 @@ export function serverActionReducer( isPrerender, false // hasDynamicRewrite ) + fulfilledRoute.actionRoutingKeys = actionRoutingKeys ?? null } const navigationLock = getCurrentNavigationLock() diff --git a/packages/next/src/client/components/router-reducer/router-reducer-types.ts b/packages/next/src/client/components/router-reducer/router-reducer-types.ts index 5bce9f1e49f7..8a8df5b4f4f6 100644 --- a/packages/next/src/client/components/router-reducer/router-reducer-types.ts +++ b/packages/next/src/client/components/router-reducer/router-reducer-types.ts @@ -3,6 +3,7 @@ import type { FlightRouterState } from '../../../shared/lib/app-router-types' import type { NavigationSeed } from '../segment-cache/decode-server-response' import type { FetchServerResponseResult } from './fetch-server-response' import type { FreshnessPolicy } from './ppr-navigations' +import type { ServerActionDispatchContext } from '../../server-action-dispatch' export const ACTION_REFRESH = 'refresh' export const ACTION_NAVIGATE = 'navigate' @@ -52,6 +53,12 @@ export interface ServerActionAction { type: typeof ACTION_SERVER_ACTION actionId: string actionArgs: any[] + /** + * The route that owns this action. New clients require this context so gaps + * in the direct dispatch protocol fail loudly. Legacy clients can still rely + * on server forwarding during rollout. + */ + actionDispatchContext: ServerActionDispatchContext resolve: (value: any) => void reject: (reason?: any) => void didRevalidate?: boolean diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index a777012ed880..56495252e2ef 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -218,6 +218,9 @@ type RouteCacheEntryShared = { // received a response from the server. couldBeIntercepted: boolean + // Opaque routing keys for Server Actions handled by this route's worker. + actionRoutingKeys: readonly string[] | null + // When true, this entry should not be used as a template for route // prediction. Set when we discover that the URL was rewritten by middleware // to a different route structure (e.g., /foo was rewritten to /bar). Since @@ -697,6 +700,7 @@ function createDetachedRouteCacheEntry(): PendingRouteCacheEntry { // could be intercepted. It's only set to false once we receive a response // from the server. couldBeIntercepted: true, + actionRoutingKeys: null, // Similarly, we don't yet know if the route supports PPR. supportsPerSegmentPrefetching: false, hasDynamicRewrite: false, @@ -849,6 +853,7 @@ export function deprecated_requestOptimisticRouteCacheEntry( tree: optimisticRouteTree, metadata: optimisticMetadataTree, couldBeIntercepted: routeWithNoSearchParams.couldBeIntercepted, + actionRoutingKeys: routeWithNoSearchParams.actionRoutingKeys, supportsPerSegmentPrefetching: routeWithNoSearchParams.supportsPerSegmentPrefetching, hasDynamicRewrite: routeWithNoSearchParams.hasDynamicRewrite, @@ -2214,6 +2219,7 @@ export async function fetchRouteOnCacheMiss( return null } + entry.actionRoutingKeys = serverData.actionRoutingKeys ?? null discoverKnownRoute( Date.now(), pathname, @@ -3482,6 +3488,7 @@ function writeDynamicTreeResponseIntoCache( return } + entry.actionRoutingKeys = serverData.A ?? null discoverKnownRoute( now, originalPathname, diff --git a/packages/next/src/client/components/segment-cache/navigation.ts b/packages/next/src/client/components/segment-cache/navigation.ts index 62906f0a0257..26518ffa518e 100644 --- a/packages/next/src/client/components/segment-cache/navigation.ts +++ b/packages/next/src/client/components/segment-cache/navigation.ts @@ -40,6 +40,7 @@ import { convertServerPatchToFullTree, type NavigationSeed, } from './decode-server-response' +import { registerServerActionDispatchContext } from '../../server-action-dispatch' /** * Navigate to a new URL, using the Segment Cache to construct a response. @@ -389,6 +390,11 @@ function navigateUsingPrefetchedRouteTree( const routeTree = route.tree const canonicalUrl = route.canonicalUrl + url.hash const renderedSearch = route.renderedSearch + registerServerActionDispatchContext( + route.actionRoutingKeys ?? undefined, + canonicalUrl, + nextUrl + ) const prefetchSeed: NavigationSeed = { renderedSearch, routeTree, @@ -493,6 +499,7 @@ async function navigateToUnknownRoute( renderedSearch, couldBeIntercepted, supportsPerSegmentPrefetching, + actionRoutingKeys, dynamicStaleTime, staticStageData, runtimePrefetchStream, @@ -500,6 +507,12 @@ async function navigateToUnknownRoute( debugInfo, } = result + registerServerActionDispatchContext( + actionRoutingKeys ?? undefined, + canonicalUrl, + nextUrl + ) + // Since the response format of dynamic requests and prefetches is slightly // different, we'll need to massage the data a bit. Create FlightRouterState // tree that simulates what we'd receive as the result of a prefetch. @@ -518,7 +531,7 @@ async function navigateToUnknownRoute( // retrying after a tree mismatch (see dispatchRetryDueToTreeMismatch). const metadataVaryPath = navigationSeed.metadataVaryPath if (metadataVaryPath !== null) { - discoverKnownRoute( + const fulfilledRoute = discoverKnownRoute( now, url.pathname, url.search as NormalizedSearch, @@ -533,6 +546,7 @@ async function navigateToUnknownRoute( supportsPerSegmentPrefetching, false // hasDynamicRewrite - not a retry, rewrite detection happens during traversal ) + fulfilledRoute.actionRoutingKeys = actionRoutingKeys if (staticStageData !== null) { const { response: staticStageResponse, isResponsePartial } = diff --git a/packages/next/src/client/components/segment-cache/optimistic-routes.ts b/packages/next/src/client/components/segment-cache/optimistic-routes.ts index 87b2af934f60..f927e98c9b91 100644 --- a/packages/next/src/client/components/segment-cache/optimistic-routes.ts +++ b/packages/next/src/client/components/segment-cache/optimistic-routes.ts @@ -685,6 +685,7 @@ export function matchKnownRoute( tree: reifiedTree, metadata: reifiedMetadata, couldBeIntercepted: pattern.couldBeIntercepted, + actionRoutingKeys: pattern.actionRoutingKeys, supportsPerSegmentPrefetching: pattern.supportsPerSegmentPrefetching, hasDynamicRewrite: false, renderedSearch: search, diff --git a/packages/next/src/client/flight-data-helpers.ts b/packages/next/src/client/flight-data-helpers.ts index e1c32f2dfb15..93d88f33ee7a 100644 --- a/packages/next/src/client/flight-data-helpers.ts +++ b/packages/next/src/client/flight-data-helpers.ts @@ -65,6 +65,7 @@ export function createInitialRSCPayloadFromFallbackPrerender( m: fallbackInitialRSCPayload.m, G: fallbackInitialRSCPayload.G, S: fallbackInitialRSCPayload.S, + A: fallbackInitialRSCPayload.A, } if (fallbackInitialRSCPayload.b) { payload.b = fallbackInitialRSCPayload.b diff --git a/packages/next/src/client/server-action-dispatch.ts b/packages/next/src/client/server-action-dispatch.ts new file mode 100644 index 000000000000..8de52f49a605 --- /dev/null +++ b/packages/next/src/client/server-action-dispatch.ts @@ -0,0 +1,55 @@ +'use client' + +import { createServerActionRoutingKey } from '../shared/lib/server-action-routing-key' + +export type ServerActionDispatchContext = { + url: string + nextUrl: string | null +} + +const dispatchContextByRoutingKey = new Map< + string, + ServerActionDispatchContext +>() +const routingKeyByActionId = new Map>() + +function normalizeDispatchUrl(url: string | URL): string { + const parsed = new URL(url, window.location.origin) + return parsed.pathname + parsed.search +} + +export function registerServerActionDispatchContext( + actionRoutingKeys: readonly string[] | undefined, + url: string | URL, + nextUrl: string | null +): void { + if (actionRoutingKeys === undefined || actionRoutingKeys.length === 0) { + return + } + + const normalizedUrl = normalizeDispatchUrl(url) + const context = { url: normalizedUrl, nextUrl } + + for (const routingKey of actionRoutingKeys) { + dispatchContextByRoutingKey.set(routingKey, context) + } +} + +export async function getServerActionDispatchContext( + actionId: string +): Promise { + let routingKey = routingKeyByActionId.get(actionId) + if (routingKey === undefined) { + routingKey = createServerActionRoutingKey(actionId) + routingKeyByActionId.set(actionId, routingKey) + } + + const dispatchContext = dispatchContextByRoutingKey.get(await routingKey) + if (dispatchContext === undefined) { + throw new Error( + 'Invariant: Missing Server Action dispatch context. This indicates that the action routing metadata was not registered for this action.' + ) + } + + return dispatchContext +} diff --git a/packages/next/src/server/app-render/action-handler.test.ts b/packages/next/src/server/app-render/action-handler.test.ts index 71b241ab0306..1a329cdee31b 100644 --- a/packages/next/src/server/app-render/action-handler.test.ts +++ b/packages/next/src/server/app-render/action-handler.test.ts @@ -1,9 +1,11 @@ import { parseHostHeader } from './action-handler' import { + getServerActionRoutingKeysForPage, getServerModuleMap, setManifestsSingleton, } from './manifests-singleton' import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' +import { createServerActionRoutingKey } from '../../shared/lib/server-action-routing-key' describe('server module map', () => { const actionId = '00' + 'a'.repeat(40) @@ -39,6 +41,15 @@ describe('server module map', () => { }) }) + it('lists opaque routing keys for the actions owned by a route', async () => { + const routingKeys = await getServerActionRoutingKeysForPage('/test/page') + + expect(routingKeys).toEqual([await createServerActionRoutingKey(actionId)]) + expect( + await getServerActionRoutingKeysForPage('/test/without-actions') + ).toBeUndefined() + }) + it('rejects plausible server reference IDs that are missing', () => { expect(() => getServerModuleMap()[missingActionId]).toThrow( `Failed to find Server Action "${missingActionId}".` diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 1156f88da65e..681df954aa03 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -755,6 +755,13 @@ export async function handleAction({ ) const actionWasForwarded = Boolean(req.headers['x-action-forwarded']) + // A fetch action without a router state tree cannot produce a Flight patch + // for the currently rendered page. This occurs when the client dispatches an + // action directly to a different route, so only execute the action without + // rendering the destination route's page tree. Omitting this untrusted header + // does not affect action lookup, CSRF checks, or user authorization. + const isActionOnlyRequest = + isFetchAction && req.headers[NEXT_ROUTER_STATE_TREE_HEADER] === undefined // A fetch action targeting a fallback route has no concrete params with // which to resume the destination page. const isActionOnlyFallbackRequest = @@ -762,7 +769,7 @@ export async function handleAction({ requestStore.fallbackParams != null && typeof ctx.renderOpts.postponed === 'string' const shouldSkipPageRendering = - actionWasForwarded || isActionOnlyFallbackRequest + actionWasForwarded || isActionOnlyRequest || isActionOnlyFallbackRequest // Only attempt to forward if this request has not already been forwarded. // Otherwise middleware that rewrites the action POST can cause the receiving diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 59f37a8722bb..e62b94b01433 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -154,6 +154,7 @@ import { createFullComponentTree, getRootParams } from './create-component-tree' import { getAssetQueryString } from './get-asset-query-string' import { getClientReferenceManifest, + getServerActionRoutingKeysForPage, getServerModuleMap, } from './manifests-singleton' import { @@ -825,6 +826,7 @@ async function generateDynamicRSCPayload( // static pages do, because their per-segment prefetch responses are // generated during static generation (build or ISR). S: ctx.renderCapabilities.supportsPerSegmentPrefetching, + A: await getServerActionRoutingKeysForPage(ctx.workStore.page), r: getRootParamsVaryParamsAccumulator() ?? undefined, } ) @@ -2252,6 +2254,7 @@ async function getRSCPayload( // static pages do, because their per-segment prefetch responses are // generated during static generation (build or ISR). S: ctx.renderCapabilities.supportsPerSegmentPrefetching, + A: await getServerActionRoutingKeysForPage(ctx.workStore.page), r: getRootParamsVaryParamsAccumulator() ?? undefined, s: staleTimeIterable, a: shellByteLengthPromise, diff --git a/packages/next/src/server/app-render/collect-segment-data.tsx b/packages/next/src/server/app-render/collect-segment-data.tsx index 2616ae2cd11a..948ad972133c 100644 --- a/packages/next/src/server/app-render/collect-segment-data.tsx +++ b/packages/next/src/server/app-render/collect-segment-data.tsx @@ -49,6 +49,7 @@ export type RootTreePrefetch = { buildId?: string tree: TreePrefetch staleTime: number + actionRoutingKeys?: readonly string[] } export type TreePrefetchParam = { @@ -1013,6 +1014,7 @@ async function PrefetchTreeData({ const treePrefetch: RootTreePrefetch = { tree, staleTime, + actionRoutingKeys: initialRSCPayload.A, } if (buildId) { treePrefetch.buildId = buildId diff --git a/packages/next/src/server/app-render/manifests-singleton.ts b/packages/next/src/server/app-render/manifests-singleton.ts index e91ace8c6432..a556fe940aa2 100644 --- a/packages/next/src/server/app-render/manifests-singleton.ts +++ b/packages/next/src/server/app-render/manifests-singleton.ts @@ -7,6 +7,7 @@ import { pathHasPrefix } from '../../shared/lib/router/utils/path-has-prefix' import { removePathPrefix } from '../../shared/lib/router/utils/remove-path-prefix' import { mightBeServerReferenceId } from '../../shared/lib/server-reference-info' import { wellKnownProperties } from '../../shared/lib/utils/reflect-utils' +import { createServerActionRoutingKey } from '../../shared/lib/server-action-routing-key' import { workAsyncStorage } from './work-async-storage.external' export interface ServerModuleMap { @@ -73,6 +74,10 @@ interface ManifestsSingleton { readonly proxiedClientReferenceManifest: DeepReadonly serverActionsManifest: DeepReadonly serverModuleMap: ServerModuleMap + serverActionRoutingKeysPerPage: Map< + string, + Promise + > } type GlobalThisWithManifests = typeof globalThis & { @@ -335,6 +340,36 @@ export function selectWorkerForForwarding( return denormalizeWorkerPageName(Object.keys(workers)[0]) } +export function getServerActionRoutingKeysForPage( + pageName: string +): Promise { + const singleton = getManifestsSingleton() + const runtime = process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node' + const workerPageName = normalizeWorkerPageName(pageName) + const cacheKey = `${runtime}:${workerPageName}` + const cachedRoutingKeys = + singleton.serverActionRoutingKeysPerPage.get(cacheKey) + + if (cachedRoutingKeys !== undefined) { + return cachedRoutingKeys + } + + const actionIds: string[] = [] + const actions = singleton.serverActionsManifest[runtime] + for (const actionId in actions) { + if (actions[actionId].workers[workerPageName] !== undefined) { + actionIds.push(actionId) + } + } + + const routingKeys = + actionIds.length === 0 + ? Promise.resolve(undefined) + : Promise.all(actionIds.map(createServerActionRoutingKey)) + singleton.serverActionRoutingKeysPerPage.set(cacheKey, routingKeys) + return routingKeys +} + export function setManifestsSingleton({ page, clientReferenceManifest, @@ -362,6 +397,7 @@ export function setManifestsSingleton({ }) existingSingleton.serverActionsManifest = serverActionsManifest + existingSingleton.serverActionRoutingKeysPerPage.clear() } else { const clientReferenceManifestsPerRoute = new Map< string, @@ -377,6 +413,7 @@ export function setManifestsSingleton({ proxiedClientReferenceManifest, serverActionsManifest, serverModuleMap: createServerModuleMap(), + serverActionRoutingKeysPerPage: new Map(), } } } diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index e126c44a76b3..7b0a204b6b8b 100644 --- a/packages/next/src/shared/lib/app-router-types.ts +++ b/packages/next/src/shared/lib/app-router-types.ts @@ -408,6 +408,8 @@ export type InitialRSCPayload = { G: [React.ComponentType, React.ReactNode | undefined] /** supportsPerSegmentPrefetching */ S: boolean + /** Opaque routing keys for Server Actions owned by this route. */ + A?: readonly string[] /** * rootVaryParams - the root params accessed anywhere in the response, emitted * once. The client unions these into the head and every segment's vary @@ -486,6 +488,8 @@ export type NavigationFlightResponse = { n?: string /** supportsPerSegmentPrefetching */ S: boolean + /** Opaque routing keys for Server Actions owned by this route. */ + A?: readonly string[] /** renderedSearch */ q: string /** couldBeIntercepted */ @@ -557,6 +561,8 @@ export type ActionFlightResponse = { q: string /** couldBeIntercepted */ i: boolean + /** Opaque routing keys, present when the response contains a redirect target. */ + A?: readonly string[] } export type RSCPayload = diff --git a/packages/next/src/shared/lib/server-action-routing-key.ts b/packages/next/src/shared/lib/server-action-routing-key.ts new file mode 100644 index 000000000000..28aa0686599c --- /dev/null +++ b/packages/next/src/shared/lib/server-action-routing-key.ts @@ -0,0 +1,24 @@ +const textEncoder = new TextEncoder() + +function encodeBase64Url(bytes: Uint8Array): string { + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Creates a public routing key for a Server Action without disclosing its + * unguessable action ID. The key is only used to associate an action reference + * the client already possesses with a route that can execute it. + */ +export async function createServerActionRoutingKey( + actionId: string +): Promise { + const digest = await globalThis.crypto.subtle.digest( + 'SHA-256', + textEncoder.encode(actionId) + ) + return encodeBase64Url(new Uint8Array(digest)) +} diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index 33fd78e13d73..f73d73ccb337 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -94,6 +94,14 @@ describe('app-dir action handling', () => { it('should handle basic actions correctly', async () => { const browser = await next.browser('/server') + let actionRequestHeaders: Record | undefined + + browser.on('request', (request) => { + const headers = request.headers() + if (request.method() === 'POST' && headers['next-action'] !== undefined) { + actionRequestHeaders = headers + } + }) const cnt = await browser.elementById('count').text() expect(cnt).toBe('0') @@ -116,6 +124,9 @@ describe('app-dir action handling', () => { await retry(async () => { expect(await browser.elementById('count').text()).toBe('3') }) + + expect(actionRequestHeaders?.['next-action-only']).toBeUndefined() + expect(actionRequestHeaders?.['next-router-state-tree']).toBeDefined() }) it('should report errors with bad inputs correctly', async () => { @@ -901,10 +912,23 @@ describe('app-dir action handling', () => { } it.each(['node', 'edge'])( - 'should forward action request to a worker that contains the action handler (%s)', + 'should dispatch a delayed action to the route that owns it (%s)', async (runtime) => { const cliOutputIndex = next.cliOutput.length const browser = await next.browser(`/delayed-action/${runtime}`) + const actionRequestPaths: string[] = [] + let actionRequestHeaders: Record | undefined + + browser.on('request', (request) => { + const headers = request.headers() + if ( + request.method() === 'POST' && + headers['next-action'] !== undefined + ) { + actionRequestPaths.push(new URL(request.url()).pathname) + actionRequestHeaders = headers + } + }) // confirm there's no data yet expect(await browser.elementById('delayed-action-result').text()).toBe( @@ -933,6 +957,10 @@ describe('app-dir action handling', () => { // make sure that we still are rendering other-page content expect(await browser.hasElementByCssSelector('#other-page')).toBe(true) + expect(actionRequestPaths).toEqual([`/delayed-action/${runtime}`]) + expect(actionRequestHeaders?.['next-action-only']).toBeUndefined() + expect(actionRequestHeaders?.['next-router-state-tree']).toBeUndefined() + // make sure we didn't get any errors in the console expect(next.cliOutput.slice(cliOutputIndex)).not.toContain( 'Failed to find Server Action' @@ -941,11 +969,21 @@ describe('app-dir action handling', () => { ) it.each(['node', 'edge'])( - 'should not error when a forwarded action triggers a redirect (%s)', + 'should dispatch a delayed redirect action to the route that owns it (%s)', async (runtime) => { let redirectResponseCode + const actionRequestPaths: string[] = [] const browser = await next.browser(`/delayed-action/${runtime}`, { beforePageLoad(page) { + page.on('request', (request) => { + if ( + request.method() === 'POST' && + request.headers()['next-action'] !== undefined + ) { + actionRequestPaths.push(new URL(request.url()).pathname) + } + }) + page.on('response', async (res) => { const headers = await res.allHeaders().catch(() => ({})) if (headers['x-action-redirect']) { @@ -969,6 +1007,8 @@ describe('app-dir action handling', () => { expect(redirectResponseCode).toBe(200) }) + expect(actionRequestPaths).toEqual([`/delayed-action/${runtime}`]) + // confirm that the redirect was handled await browser.waitForElementByCss('#run-action-redirect') } diff --git a/test/unit/server-action-dispatch.test.ts b/test/unit/server-action-dispatch.test.ts new file mode 100644 index 000000000000..daffd5cd2e80 --- /dev/null +++ b/test/unit/server-action-dispatch.test.ts @@ -0,0 +1,11 @@ +import { getServerActionDispatchContext } from '../../packages/next/src/client/server-action-dispatch' + +describe('Server Action dispatch', () => { + it('fails when routing metadata was not registered for an action', async () => { + await expect( + getServerActionDispatchContext('unregistered-action') + ).rejects.toThrow( + 'Invariant: Missing Server Action dispatch context. This indicates that the action routing metadata was not registered for this action.' + ) + }) +}) From 16b7369e05c4000cb90a19f9da58ec88991abc22 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:16:58 -0700 Subject: [PATCH 2/4] Fix Server Action dispatch edge cases --- packages/next/errors.json | 3 +- packages/next/src/client/app-call-server.ts | 2 +- .../reducers/server-action-reducer.ts | 13 +++++--- .../router-reducer/router-reducer-types.ts | 8 ++--- .../next/src/client/server-action-dispatch.ts | 20 ++---------- .../server/app-render/action-handler.test.ts | 8 ++--- .../src/server/app-render/action-handler.ts | 3 +- .../next/src/server/app-render/app-render.tsx | 4 +-- .../server/app-render/manifests-singleton.ts | 13 +++----- .../shared/lib/server-action-routing-key.ts | 27 ++++------------ test/unit/server-action-dispatch.test.ts | 32 ++++++++++++++++--- 11 files changed, 63 insertions(+), 70 deletions(-) diff --git a/packages/next/errors.json b/packages/next/errors.json index 6fb466844280..f25fbaa1d489 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1467,6 +1467,5 @@ "1466": "TypeScript %s does not provide the compiler API required by Next.js. Set %s to true in your Next.js config to use the TypeScript CLI, or install TypeScript 6 instead.", "1467": "TypeScript %s does not provide the compiler API required by Next.js. Set %s back to true in your Next.js config to use the TypeScript CLI, or install TypeScript 6 instead.", "1468": "Missing workUnitStore in createComponentTree", - "1469": "The payload's transport tree is missing a slot that exists in the loader tree: %s", - "1470": "Invariant: Missing Server Action dispatch context. This indicates that the action routing metadata was not registered for this action." + "1469": "The payload's transport tree is missing a slot that exists in the loader tree: %s" } diff --git a/packages/next/src/client/app-call-server.ts b/packages/next/src/client/app-call-server.ts index edd4104591cb..2b0c4abb2f8f 100644 --- a/packages/next/src/client/app-call-server.ts +++ b/packages/next/src/client/app-call-server.ts @@ -4,7 +4,7 @@ import { dispatchAppRouterAction } from './components/use-action-queue' import { getServerActionDispatchContext } from './server-action-dispatch' export async function callServer(actionId: string, actionArgs: any[]) { - const actionDispatchContext = await getServerActionDispatchContext(actionId) + const actionDispatchContext = getServerActionDispatchContext(actionId) return new Promise((resolve, reject) => { startTransition(() => { diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index 868556eddbe5..023fcfac0c0b 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -111,11 +111,14 @@ async function fetchServerAction( ): Promise { const { actionId, actionArgs, actionDispatchContext } = action const currentRequestUrl = new URL(state.canonicalUrl, window.location.origin) - const actionRequestUrl = new URL( - actionDispatchContext.url, - window.location.origin - ) - const actionNextUrl = actionDispatchContext.nextUrl + const actionRequestUrl = + actionDispatchContext === undefined + ? currentRequestUrl + : new URL(actionDispatchContext.url, window.location.origin) + const actionNextUrl = + actionDispatchContext === undefined + ? nextUrl + : actionDispatchContext.nextUrl const isCrossRouteDispatch = actionRequestUrl.pathname !== currentRequestUrl.pathname || actionRequestUrl.search !== currentRequestUrl.search || diff --git a/packages/next/src/client/components/router-reducer/router-reducer-types.ts b/packages/next/src/client/components/router-reducer/router-reducer-types.ts index 8a8df5b4f4f6..c23ccfbadc5c 100644 --- a/packages/next/src/client/components/router-reducer/router-reducer-types.ts +++ b/packages/next/src/client/components/router-reducer/router-reducer-types.ts @@ -54,11 +54,11 @@ export interface ServerActionAction { actionId: string actionArgs: any[] /** - * The route that owns this action. New clients require this context so gaps - * in the direct dispatch protocol fail loudly. Legacy clients can still rely - * on server forwarding during rollout. + * The route that owns this action, if it was advertised in a Flight payload. + * Missing context falls back to the current route so version-skewed action + * IDs retain the existing unrecognized-action behavior. */ - actionDispatchContext: ServerActionDispatchContext + actionDispatchContext: ServerActionDispatchContext | undefined resolve: (value: any) => void reject: (reason?: any) => void didRevalidate?: boolean diff --git a/packages/next/src/client/server-action-dispatch.ts b/packages/next/src/client/server-action-dispatch.ts index 8de52f49a605..8ccc07c95b0b 100644 --- a/packages/next/src/client/server-action-dispatch.ts +++ b/packages/next/src/client/server-action-dispatch.ts @@ -11,7 +11,6 @@ const dispatchContextByRoutingKey = new Map< string, ServerActionDispatchContext >() -const routingKeyByActionId = new Map>() function normalizeDispatchUrl(url: string | URL): string { const parsed = new URL(url, window.location.origin) @@ -35,21 +34,8 @@ export function registerServerActionDispatchContext( } } -export async function getServerActionDispatchContext( +export function getServerActionDispatchContext( actionId: string -): Promise { - let routingKey = routingKeyByActionId.get(actionId) - if (routingKey === undefined) { - routingKey = createServerActionRoutingKey(actionId) - routingKeyByActionId.set(actionId, routingKey) - } - - const dispatchContext = dispatchContextByRoutingKey.get(await routingKey) - if (dispatchContext === undefined) { - throw new Error( - 'Invariant: Missing Server Action dispatch context. This indicates that the action routing metadata was not registered for this action.' - ) - } - - return dispatchContext +): ServerActionDispatchContext | undefined { + return dispatchContextByRoutingKey.get(createServerActionRoutingKey(actionId)) } diff --git a/packages/next/src/server/app-render/action-handler.test.ts b/packages/next/src/server/app-render/action-handler.test.ts index 1a329cdee31b..c69deaca9d0c 100644 --- a/packages/next/src/server/app-render/action-handler.test.ts +++ b/packages/next/src/server/app-render/action-handler.test.ts @@ -41,12 +41,12 @@ describe('server module map', () => { }) }) - it('lists opaque routing keys for the actions owned by a route', async () => { - const routingKeys = await getServerActionRoutingKeysForPage('/test/page') + it('lists opaque routing keys for the actions owned by a route', () => { + const routingKeys = getServerActionRoutingKeysForPage('/test/page') - expect(routingKeys).toEqual([await createServerActionRoutingKey(actionId)]) + expect(routingKeys).toEqual([createServerActionRoutingKey(actionId)]) expect( - await getServerActionRoutingKeysForPage('/test/without-actions') + getServerActionRoutingKeysForPage('/test/without-actions') ).toBeUndefined() }) diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 681df954aa03..95c0cae4f84d 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -758,8 +758,7 @@ export async function handleAction({ // A fetch action without a router state tree cannot produce a Flight patch // for the currently rendered page. This occurs when the client dispatches an // action directly to a different route, so only execute the action without - // rendering the destination route's page tree. Omitting this untrusted header - // does not affect action lookup, CSRF checks, or user authorization. + // rendering the destination route's page tree. const isActionOnlyRequest = isFetchAction && req.headers[NEXT_ROUTER_STATE_TREE_HEADER] === undefined // A fetch action targeting a fallback route has no concrete params with diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index e62b94b01433..69a55a57bc21 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -826,7 +826,7 @@ async function generateDynamicRSCPayload( // static pages do, because their per-segment prefetch responses are // generated during static generation (build or ISR). S: ctx.renderCapabilities.supportsPerSegmentPrefetching, - A: await getServerActionRoutingKeysForPage(ctx.workStore.page), + A: getServerActionRoutingKeysForPage(ctx.workStore.page), r: getRootParamsVaryParamsAccumulator() ?? undefined, } ) @@ -2254,7 +2254,7 @@ async function getRSCPayload( // static pages do, because their per-segment prefetch responses are // generated during static generation (build or ISR). S: ctx.renderCapabilities.supportsPerSegmentPrefetching, - A: await getServerActionRoutingKeysForPage(ctx.workStore.page), + A: getServerActionRoutingKeysForPage(ctx.workStore.page), r: getRootParamsVaryParamsAccumulator() ?? undefined, s: staleTimeIterable, a: shellByteLengthPromise, diff --git a/packages/next/src/server/app-render/manifests-singleton.ts b/packages/next/src/server/app-render/manifests-singleton.ts index a556fe940aa2..e79eaef5e4f1 100644 --- a/packages/next/src/server/app-render/manifests-singleton.ts +++ b/packages/next/src/server/app-render/manifests-singleton.ts @@ -74,10 +74,7 @@ interface ManifestsSingleton { readonly proxiedClientReferenceManifest: DeepReadonly serverActionsManifest: DeepReadonly serverModuleMap: ServerModuleMap - serverActionRoutingKeysPerPage: Map< - string, - Promise - > + serverActionRoutingKeysPerPage: Map } type GlobalThisWithManifests = typeof globalThis & { @@ -342,7 +339,7 @@ export function selectWorkerForForwarding( export function getServerActionRoutingKeysForPage( pageName: string -): Promise { +): readonly string[] | undefined { const singleton = getManifestsSingleton() const runtime = process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node' const workerPageName = normalizeWorkerPageName(pageName) @@ -350,7 +347,7 @@ export function getServerActionRoutingKeysForPage( const cachedRoutingKeys = singleton.serverActionRoutingKeysPerPage.get(cacheKey) - if (cachedRoutingKeys !== undefined) { + if (singleton.serverActionRoutingKeysPerPage.has(cacheKey)) { return cachedRoutingKeys } @@ -364,8 +361,8 @@ export function getServerActionRoutingKeysForPage( const routingKeys = actionIds.length === 0 - ? Promise.resolve(undefined) - : Promise.all(actionIds.map(createServerActionRoutingKey)) + ? undefined + : actionIds.map(createServerActionRoutingKey) singleton.serverActionRoutingKeysPerPage.set(cacheKey, routingKeys) return routingKeys } diff --git a/packages/next/src/shared/lib/server-action-routing-key.ts b/packages/next/src/shared/lib/server-action-routing-key.ts index 28aa0686599c..a9f10657ef3e 100644 --- a/packages/next/src/shared/lib/server-action-routing-key.ts +++ b/packages/next/src/shared/lib/server-action-routing-key.ts @@ -1,24 +1,11 @@ -const textEncoder = new TextEncoder() - -function encodeBase64Url(bytes: Uint8Array): string { - let binary = '' - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]) - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} +import fnv1a from './fnv1a' /** - * Creates a public routing key for a Server Action without disclosing its - * unguessable action ID. The key is only used to associate an action reference - * the client already possesses with a route that can execute it. + * Creates an opaque routing key for a Server Action without serializing its + * full action ID in the route payload. The key only associates an action + * reference the client already possesses with a route that can execute it. It + * is not a security boundary; the server still validates the full action ID. */ -export async function createServerActionRoutingKey( - actionId: string -): Promise { - const digest = await globalThis.crypto.subtle.digest( - 'SHA-256', - textEncoder.encode(actionId) - ) - return encodeBase64Url(new Uint8Array(digest)) +export function createServerActionRoutingKey(actionId: string): string { + return fnv1a(actionId, { size: 128 }).toString(36) } diff --git a/test/unit/server-action-dispatch.test.ts b/test/unit/server-action-dispatch.test.ts index daffd5cd2e80..99f80ae8b7a8 100644 --- a/test/unit/server-action-dispatch.test.ts +++ b/test/unit/server-action-dispatch.test.ts @@ -1,11 +1,33 @@ import { getServerActionDispatchContext } from '../../packages/next/src/client/server-action-dispatch' +import { createServerActionRoutingKey } from '../../packages/next/src/shared/lib/server-action-routing-key' describe('Server Action dispatch', () => { - it('fails when routing metadata was not registered for an action', async () => { - await expect( - getServerActionDispatchContext('unregistered-action') - ).rejects.toThrow( - 'Invariant: Missing Server Action dispatch context. This indicates that the action routing metadata was not registered for this action.' + it('creates routing keys without Web Crypto', () => { + const cryptoDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto' ) + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: undefined, + }) + + try { + expect(createServerActionRoutingKey('00' + 'a'.repeat(40))).toBe( + '2wrzw4qvvu65cf24ef4' + ) + } finally { + if (cryptoDescriptor === undefined) { + delete (globalThis as { crypto?: Crypto }).crypto + } else { + Object.defineProperty(globalThis, 'crypto', cryptoDescriptor) + } + } + }) + + it('falls back when routing metadata was not registered for an action', () => { + expect( + getServerActionDispatchContext('unregistered-action') + ).toBeUndefined() }) }) From 47306ef2442b8b745c0818f3accb416b96bb7567 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:52:15 -0700 Subject: [PATCH 3/4] Preserve Server Action dispatch context --- packages/next/src/client/app-call-server.ts | 24 ++- packages/next/src/client/app-index.tsx | 21 ++- .../create-initial-router-state.ts | 14 +- .../router-reducer/fetch-server-response.ts | 173 +++++++++++++++--- .../router-reducer/ppr-navigations.ts | 7 +- .../reducers/server-action-reducer.ts | 44 +++-- .../client/components/segment-cache/cache.ts | 44 ++++- .../components/segment-cache/navigation.ts | 7 +- .../next/src/client/server-action-dispatch.ts | 51 +++++- test/e2e/app-dir/actions/app-action.test.ts | 70 +++++++ .../app/parallel-action/@slot/default.tsx | 3 + .../app/parallel-action/@slot/one/page.tsx | 6 + .../actions/app/parallel-action/actions.ts | 5 + .../actions/app/parallel-action/button.tsx | 22 +++ .../actions/app/parallel-action/layout.tsx | 14 ++ .../actions/app/parallel-action/one/page.tsx | 5 + .../actions/app/parallel-action/two/page.tsx | 6 + .../e2e/app-dir/actions/app/server/counter.js | 3 + test/e2e/app-dir/actions/app/server/page.js | 29 +-- test/unit/server-action-dispatch.test.ts | 13 ++ 20 files changed, 490 insertions(+), 71 deletions(-) create mode 100644 test/e2e/app-dir/actions/app/parallel-action/@slot/default.tsx create mode 100644 test/e2e/app-dir/actions/app/parallel-action/@slot/one/page.tsx create mode 100644 test/e2e/app-dir/actions/app/parallel-action/actions.ts create mode 100644 test/e2e/app-dir/actions/app/parallel-action/button.tsx create mode 100644 test/e2e/app-dir/actions/app/parallel-action/layout.tsx create mode 100644 test/e2e/app-dir/actions/app/parallel-action/one/page.tsx create mode 100644 test/e2e/app-dir/actions/app/parallel-action/two/page.tsx diff --git a/packages/next/src/client/app-call-server.ts b/packages/next/src/client/app-call-server.ts index 2b0c4abb2f8f..6d6f8e197b02 100644 --- a/packages/next/src/client/app-call-server.ts +++ b/packages/next/src/client/app-call-server.ts @@ -1,21 +1,35 @@ import { startTransition } from 'react' import { ACTION_SERVER_ACTION } from './components/router-reducer/router-reducer-types' import { dispatchAppRouterAction } from './components/use-action-queue' -import { getServerActionDispatchContext } from './server-action-dispatch' - -export async function callServer(actionId: string, actionArgs: any[]) { - const actionDispatchContext = getServerActionDispatchContext(actionId) +import { + getServerActionDispatchContext, + type ServerActionDispatchScope, +} from './server-action-dispatch' +function dispatchServerAction( + actionId: string, + actionArgs: any[], + scope?: ServerActionDispatchScope +) { return new Promise((resolve, reject) => { startTransition(() => { dispatchAppRouterAction({ type: ACTION_SERVER_ACTION, actionId, actionArgs, - actionDispatchContext, + actionDispatchContext: getServerActionDispatchContext(actionId, scope), resolve, reject, }) }) }) } + +export async function callServer(actionId: string, actionArgs: any[]) { + return dispatchServerAction(actionId, actionArgs) +} + +export function createScopedCallServer(scope: ServerActionDispatchScope) { + return (actionId: string, actionArgs: any[]) => + dispatchServerAction(actionId, actionArgs, scope) +} diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index e517169017c5..6326beeb15d9 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -13,7 +13,7 @@ import { onCaughtError, onUncaughtError, } from './react-client-callbacks/error-boundary-callbacks' -import { callServer } from './app-call-server' +import { createScopedCallServer } from './app-call-server' import { findSourceMapURL } from './app-find-source-map-url' import { type AppRouterActionQueue, @@ -29,6 +29,10 @@ import { getDeploymentId } from '../shared/lib/deployment-id' import { setNavigationBuildId } from './navigation-build-id' import type { ClientInstrumentationModules } from './router-transition-types' import { initializeRouterTransitionModules } from './components/router-transition' +import { + createServerActionDispatchScope, + setServerActionDispatchScopeRoutingKeys, +} from './server-action-dispatch' /// @@ -225,13 +229,18 @@ if ( } let initialServerResponse: Promise +const initialActionDispatchScope = createServerActionDispatchScope( + window.location.href, + null +) +const initialCallServer = createScopedCallServer(initialActionDispatchScope) if (instantTestStaticFetch) { // Instant Navigation Testing API: hydrate from the static RSC payload // fetch kicked off by an injected