From 5a3a3d1972d690741f1aa3eecde8b5537927875a Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 15 Aug 2026 23:46:33 -0400 Subject: [PATCH 1/2] Reorganize client router modules (#97402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure structural refactor, no behavior changes. This continues some of the clean-up work I've been doing in the client router now that more of the implementation has settled, post-Segment Cache and Instant Navigations. I have two primary motivations here. The first is purely maintainability. The current module structure is a mess, and the naming is misleading when browsing the codebase for the first time. The second motivation is to prepare for an upcoming rewrite of the router queue. There will likely be two simultaneous implementations for a while as the new one settles, so it's extra important the interface into the router queue is well-defined. I want the fork between the two worlds to be as clean as possible. This is a similar approach to the one I took when rewriting the prefetching implementation. Some of the key name changes: - ppr-navigations.ts → render-tree.ts. This module's responsibility is to create the tree that gets passed into React for rendering, currently called the CacheNode tree. I considered renaming it to cache-node.ts, but I kept it more generic because I have plans to refactor that type soon. - segment-cache/navigation.ts → app-router-state.ts. This module's responsibility is to compute the next AppRouterState given inputs. It does perform network requests, but it's conceptually a "pure" operation. It doesn't deal with any of the router queueing logic. This is the most reducer-y of the code that still exists, and along with render-tree.ts, it will remain mostly shared between the old and new state machine implementations. - navigator.ts. This is a new module I created to act as the single entry point for router-level operations: push, replace, navigate, traverse, restore, refresh, hmrRefresh. This is the primary place where we'll fork the old and new queue implementations. It's also how we'll ensure the inactive implementation gets dead-code-eliminated properly while the two implementations live side-by-side. --- packages/next/src/client/app-call-server.ts | 6 + packages/next/src/client/app-dir/link.tsx | 24 +- .../client/components/app-router-instance.ts | 217 +---------------- .../navigation.ts => app-router-state.ts} | 54 +++-- .../next/src/client/components/app-router.tsx | 32 +-- .../src/client/components/layout-router.tsx | 2 +- .../next/src/client/components/navigator.ts | 222 ++++++++++++++++++ .../next/src/client/components/prefetch.ts | 111 +++++++++ .../ppr-navigations.ts => render-tree.ts} | 46 ++-- .../create-initial-router-state.ts | 2 +- .../reducers/hmr-refresh-reducer.ts | 2 +- .../reducers/navigate-reducer.ts | 4 +- .../reducers/refresh-reducer.ts | 4 +- .../reducers/restore-reducer.ts | 4 +- .../reducers/server-action-reducer.ts | 4 +- .../reducers/server-patch-reducer.ts | 4 +- .../router-reducer/router-reducer-types.ts | 2 +- .../components/segment-cache/prefetch.ts | 48 ---- .../next/src/server/app-render/app-render.tsx | 2 +- 19 files changed, 437 insertions(+), 353 deletions(-) rename packages/next/src/client/components/{segment-cache/navigation.ts => app-router-state.ts} (94%) create mode 100644 packages/next/src/client/components/navigator.ts create mode 100644 packages/next/src/client/components/prefetch.ts rename packages/next/src/client/components/{router-reducer/ppr-navigations.ts => render-tree.ts} (98%) delete mode 100644 packages/next/src/client/components/segment-cache/prefetch.ts diff --git a/packages/next/src/client/app-call-server.ts b/packages/next/src/client/app-call-server.ts index c447e163828a..85abf4a81b01 100644 --- a/packages/next/src/client/app-call-server.ts +++ b/packages/next/src/client/app-call-server.ts @@ -2,6 +2,12 @@ import { startTransition } from 'react' import { ACTION_SERVER_ACTION } from './components/router-reducer/router-reducer-types' import { dispatchAppRouterAction } from './components/use-action-queue' +/** + * Invoke a Server Action. The returned promise resolves with the action's + * return value once the response has been processed. Navigation and + * revalidation side effects of the action are handled by the router; they are + * not observable through the returned promise. + */ export async function callServer(actionId: string, actionArgs: any[]) { return new Promise((resolve, reject) => { startTransition(() => { diff --git a/packages/next/src/client/app-dir/link.tsx b/packages/next/src/client/app-dir/link.tsx index c798b561f34d..a1fc1c7d53d7 100644 --- a/packages/next/src/client/app-dir/link.tsx +++ b/packages/next/src/client/app-dir/link.tsx @@ -301,21 +301,19 @@ function linkClicked( } } - const { dispatchNavigateAction } = + const { navigate } = // TODO(browser-variant): migrate to a .ts/.browser.ts split so the browser bundle drops the server branch; see scripts/generate-browser-variant-aliases.mjs // ast-grep-ignore: no-typeof-window-require-tsx - require('../components/app-router-instance') as typeof import('../components/app-router-instance') - - React.startTransition(() => { - dispatchNavigateAction( - href, - replace ? 'replace' : 'push', - scroll === false ? ScrollBehavior.NoScroll : ScrollBehavior.Default, - linkInstanceRef.current, - transitionTypes, - prefetchIntent - ) - }) + require('../components/navigator') as typeof import('../components/navigator') + + navigate( + href, + replace ? 'replace' : 'push', + scroll === false ? ScrollBehavior.NoScroll : ScrollBehavior.Default, + linkInstanceRef.current, + transitionTypes, + prefetchIntent + ) } } diff --git a/packages/next/src/client/components/app-router-instance.ts b/packages/next/src/client/components/app-router-instance.ts index 759d6d7c6d6e..ae8c5e3f6e72 100644 --- a/packages/next/src/client/components/app-router-instance.ts +++ b/packages/next/src/client/components/app-router-instance.ts @@ -6,39 +6,24 @@ import { ACTION_SERVER_ACTION, ACTION_NAVIGATE, ACTION_RESTORE, - type NavigateAction, - ACTION_HMR_REFRESH, - PrefetchKind, ScrollBehavior, - type AppHistoryState, } from './router-reducer/router-reducer-types' import { reducer } from './router-reducer/router-reducer' -import { addTransitionType, startTransition } from 'react' +import { startTransition } from 'react' import { isThenable } from '../../shared/lib/is-thenable' -import { - FetchStrategy, - type PrefetchTaskFetchStrategy, -} from './segment-cache/types' -import { prefetch as prefetchWithSegmentCache } from './segment-cache/prefetch' -import { navigate } from './segment-cache/navigation' -import { - dispatchAppRouterAction, - dispatchGestureState, -} from './use-action-queue' -import { resetKnownRoutes } from './segment-cache/optimistic-routes' -import { FreshnessPolicy } from './router-reducer/ppr-navigations' +import { navigate } from './app-router-state' +import { dispatchGestureState } from './use-action-queue' +import { FreshnessPolicy } from './render-tree' import { addBasePath } from '../add-base-path' import { isExternalURL } from './app-router-utils' import type { AppRouterInstance, NavigateOptions, - PrefetchOptions, } from '../../shared/lib/app-router-context.shared-runtime' -import { setLinkForCurrentNavigation, type LinkInstance } from './links' -import type { RouterTransitionPrefetchIntent } from '../router-transition-types' import type { GlobalErrorComponent } from './builtin/global-error' import { isJavaScriptURLString } from '../lib/javascript-url' -import { startRouterTransition } from './router-transition' +import { push, replace, refresh, hmrRefresh } from './navigator' +import { prefetchRoute } from './prefetch' export type DispatchStatePromise = React.Dispatch @@ -269,72 +254,6 @@ export function getCurrentAppRouterState(): AppRouterState | null { return globalActionQueue !== null ? globalActionQueue.state : null } -function getAppRouterActionQueue(): AppRouterActionQueue { - if (globalActionQueue === null) { - throw new Error( - 'Internal Next.js error: Router action dispatched before initialization.' - ) - } - return globalActionQueue -} - -export function dispatchNavigateAction( - href: string, - navigateType: NavigateAction['navigateType'], - scrollBehavior: ScrollBehavior, - linkInstanceRef: LinkInstance | null, - transitionTypes: string[] | undefined, - prefetchIntent: RouterTransitionPrefetchIntent | null -): void { - // TODO: This stuff could just go into the reducer. Leaving as-is for now - // since we're about to rewrite all the router reducer stuff anyway. - - if (transitionTypes) { - for (const type of transitionTypes) { - addTransitionType(type) - } - } - - const url = new URL(addBasePath(href), location.href) - if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) { - window.next.__pendingUrl = url - } - - setLinkForCurrentNavigation(linkInstanceRef) - startRouterTransition( - href, - navigateType, - getAppRouterActionQueue().state.tree, - prefetchIntent - ) - - dispatchAppRouterAction({ - type: ACTION_NAVIGATE, - url, - isExternalUrl: isExternalURL(url), - locationSearch: location.search, - scrollBehavior, - navigateType, - }) -} - -export function dispatchTraverseAction( - href: string, - historyState: AppHistoryState | undefined -) { - startRouterTransition( - href, - 'traverse', - getAppRouterActionQueue().state.tree, - null - ) - dispatchAppRouterAction({ - type: ACTION_RESTORE, - url: new URL(href), - historyState, - }) -} - /** * (Experimental) Perform a gesture navigation. This dispatches through React's * useOptimistic instead of the main action queue, allowing the state to be @@ -391,10 +310,6 @@ function gesturePush(href: string, options?: NavigateOptions): void { } } -// Tracks the newest HMR refresh generation so that a newer refresh can abort -// the request of the one it supersedes. Development only. -let activeHmrRefreshController: AbortController | null = null - /** * The app router that is exposed through `useRouter`. These are public API * methods. Internal Next.js code should call the lower level methods directly @@ -403,121 +318,11 @@ let activeHmrRefreshController: AbortController | null = null export const publicAppRouterInstance: AppRouterInstance = { back: () => window.history.back(), forward: () => window.history.forward(), - prefetch: - // Unlike the old implementation, the Segment Cache doesn't store its - // data in the router reducer state; it writes into a global mutable - // cache. So we don't need to dispatch an action. - (href: string, options?: PrefetchOptions) => { - if (isJavaScriptURLString(href)) { - throw new Error( - 'Next.js has blocked a javascript: URL as a security precaution.' - ) - } - const actionQueue = getAppRouterActionQueue() - const prefetchKind = options?.kind ?? PrefetchKind.AUTO - - // We don't currently offer a way to issue a runtime prefetch via `router.prefetch()`. - // This will be possible when we update its API to not take a PrefetchKind. - let fetchStrategy: PrefetchTaskFetchStrategy - switch (prefetchKind) { - case PrefetchKind.AUTO: { - // We default to PPR. We'll discover whether or not the route supports it with the initial prefetch. - fetchStrategy = FetchStrategy.PPR - break - } - case PrefetchKind.FULL: { - fetchStrategy = FetchStrategy.Full - break - } - default: { - prefetchKind satisfies never - // Despite typescript thinking that this can't happen, - // we might get an unexpected value from user code. - // We don't know what they want, but we know they want a prefetch, - // so use the default. - fetchStrategy = FetchStrategy.PPR - } - } - - prefetchWithSegmentCache( - href, - actionQueue.state.nextUrl, - actionQueue.state.tree, - fetchStrategy, - options?.onInvalidate ?? null - ) - }, - replace: (href: string, options?: NavigateOptions) => { - if (isJavaScriptURLString(href)) { - throw new Error( - 'Next.js has blocked a javascript: URL as a security precaution.' - ) - } - startTransition(() => { - dispatchNavigateAction( - href, - 'replace', - options?.scroll === false - ? ScrollBehavior.NoScroll - : ScrollBehavior.Default, - null, - options?.transitionTypes, - null - ) - }) - }, - push: (href: string, options?: NavigateOptions) => { - if (isJavaScriptURLString(href)) { - throw new Error( - 'Next.js has blocked a javascript: URL as a security precaution.' - ) - } - startTransition(() => { - dispatchNavigateAction( - href, - 'push', - options?.scroll === false - ? ScrollBehavior.NoScroll - : ScrollBehavior.Default, - null, - options?.transitionTypes, - null - ) - }) - }, - refresh: () => { - startTransition(() => { - dispatchAppRouterAction({ - type: ACTION_REFRESH, - }) - }) - }, - hmrRefresh: () => { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'hmrRefresh can only be used in development mode. Please use refresh instead.' - ) - } else { - // Reset the known routes table so that route predictions are cleared - // when routes change during development. - resetKnownRoutes() - let signal: AbortSignal | undefined - if (process.env.__NEXT_SERVER_COMPONENTS_HMR_CANCELLATION) { - // Abort the superseded generation before scheduling the new one, so its - // request is torn down as early as possible. Halting (not rejecting) - // makes the abort safe regardless of order. - activeHmrRefreshController?.abort() - activeHmrRefreshController = new AbortController() - signal = activeHmrRefreshController.signal - } - startTransition(() => { - dispatchAppRouterAction({ - type: ACTION_HMR_REFRESH, - signal, - }) - }) - } - }, + prefetch: prefetchRoute, + replace: replace, + push: push, + refresh: refresh, + hmrRefresh: hmrRefresh, // Default value. Each route segment provides its own value at runtime. Refer // to `useRouter()`. bfcacheId: '0', diff --git a/packages/next/src/client/components/segment-cache/navigation.ts b/packages/next/src/client/components/app-router-state.ts similarity index 94% rename from packages/next/src/client/components/segment-cache/navigation.ts rename to packages/next/src/client/components/app-router-state.ts index 0027d478358c..e7321f0976bd 100644 --- a/packages/next/src/client/components/segment-cache/navigation.ts +++ b/packages/next/src/client/components/app-router-state.ts @@ -1,10 +1,10 @@ import type { FlightRouterState, ScrollRef, -} from '../../../shared/lib/app-router-types' -import type { CacheNode } from '../../../shared/lib/app-router-types' -import { PrefetchHint } from '../../../shared/lib/app-router-types' -import { fetchServerResponse } from '../router-reducer/fetch-server-response' +} from '../../shared/lib/app-router-types' +import type { CacheNode } from '../../shared/lib/app-router-types' +import { PrefetchHint } from '../../shared/lib/app-router-types' +import { fetchServerResponse } from './router-reducer/fetch-server-response' import { startPPRNavigation, spawnDynamicRequests, @@ -12,8 +12,8 @@ import { beginLockedNavigation, type NavigationLock, type NavigationRequestAccumulation, -} from '../router-reducer/ppr-navigations' -import { createHrefFromUrl } from '../router-reducer/create-href-from-url' +} from './render-tree' +import { createHrefFromUrl } from './router-reducer/create-href-from-url' import { EntryStatus, segmentCacheMap, @@ -23,23 +23,29 @@ import { spawnStaticStageCacheWrite, writeRuntimePrefetchStreamIntoCache, type FulfilledRouteCacheEntry, -} from './cache' -import { discoverKnownRoute } from './optimistic-routes' -import { createCacheKey, type NormalizedSearch } from './cache-key' -import type { CacheMap } from './cache-map' -import { schedulePrefetchTask } from './scheduler' -import { PrefetchPriority, FetchStrategy } from './types' -import { getLinkForCurrentNavigation } from '../links' -import type { AppRouterState } from '../router-reducer/router-reducer-types' -import { ScrollBehavior } from '../router-reducer/router-reducer-types' -import { computeChangedPath } from '../router-reducer/compute-changed-path' -import { isJavaScriptURLString } from '../../lib/javascript-url' -import { UnknownDynamicStaleTime, computeDynamicStaleAt } from './bfcache' -import { createLinkPrefetchPartialError } from '../../../shared/lib/instant-messages' +} from './segment-cache/cache' +import { discoverKnownRoute } from './segment-cache/optimistic-routes' +import { + createCacheKey, + type NormalizedSearch, +} from './segment-cache/cache-key' +import type { CacheMap } from './segment-cache/cache-map' +import { schedulePrefetchTask } from './segment-cache/scheduler' +import { PrefetchPriority, FetchStrategy } from './segment-cache/types' +import { getLinkForCurrentNavigation } from './links' +import type { AppRouterState } from './router-reducer/router-reducer-types' +import { ScrollBehavior } from './router-reducer/router-reducer-types' +import { computeChangedPath } from './router-reducer/compute-changed-path' +import { isJavaScriptURLString } from '../lib/javascript-url' +import { + UnknownDynamicStaleTime, + computeDynamicStaleAt, +} from './segment-cache/bfcache' +import { createLinkPrefetchPartialError } from '../../shared/lib/instant-messages' import { createNavigationSeed, type NavigationSeed, -} from './decode-server-response' +} from './segment-cache/decode-server-response' /** * Navigate to a new URL, using the Segment Cache to construct a response. @@ -71,7 +77,7 @@ export function navigate( // requested. if (process.env.__NEXT_EXPOSE_TESTING_API) { const { isNavigationLocked } = - require('./navigation-testing-lock') as typeof import('./navigation-testing-lock') + require('./segment-cache/navigation-testing-lock') as typeof import('./segment-cache/navigation-testing-lock') if (isNavigationLocked()) { // Signal that a new locked navigation is starting. This force-resolves the // previous locked navigation's withheld data (so a reused shared segment @@ -304,7 +310,7 @@ export function navigateToKnownRoute( let restrictToShell = false if (process.env.__NEXT_EXPOSE_TESTING_API) { const { shouldRestrictNavigationToShell } = - require('./navigation-testing-lock') as typeof import('./navigation-testing-lock') + require('./segment-cache/navigation-testing-lock') as typeof import('./segment-cache/navigation-testing-lock') const link = getLinkForCurrentNavigation() restrictToShell = shouldRestrictNavigationToShell( navigationSeed.routeTree.prefetchHints, @@ -867,7 +873,7 @@ async function ensurePrefetchThenNavigate( // cares about has settled — so the navigation below reads present data // rather than a still-in-flight entry. const { beginNavigationLockPrefetch } = - require('./navigation-testing-lock') as typeof import('./navigation-testing-lock') + require('./segment-cache/navigation-testing-lock') as typeof import('./segment-cache/navigation-testing-lock') const navigationLockPrefetch = beginNavigationLockPrefetch() const prefetchTask = schedulePrefetchTask( cacheKey, @@ -906,7 +912,7 @@ async function ensurePrefetchThenNavigate( // document load transition it to captured-MPA. if (!result.pushRef.mpaNavigation) { const { updateCapturedSPAToTree } = - require('./navigation-testing-lock') as typeof import('./navigation-testing-lock') + require('./segment-cache/navigation-testing-lock') as typeof import('./segment-cache/navigation-testing-lock') updateCapturedSPAToTree(currentFlightRouterState, result.tree) } diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index bc79d51ef189..38d0de60071f 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, - startTransition, useInsertionEffect, useDeferredValue, } from 'react' @@ -11,7 +10,6 @@ import { GlobalLayoutRouterContext, } from '../../shared/lib/app-router-context.shared-runtime' import type { CacheNode } from '../../shared/lib/app-router-types' -import { ACTION_RESTORE } from './router-reducer/router-reducer-types' import type { AppHistoryState, AppRouterState, @@ -24,7 +22,7 @@ import { NavigationPromisesContext, type NavigationPromises, } from '../../shared/lib/hooks-client-context.shared-runtime' -import { dispatchAppRouterAction, useActionQueue } from './use-action-queue' +import { useActionQueue } from './use-action-queue' import { setLastCommittedTree } from './router-reducer/reducers/committed-state' import { AppRouterAnnouncer } from './app-router-announcer' import { RedirectBoundary } from './redirect-boundary' @@ -38,11 +36,11 @@ import { } from './router-reducer/compute-changed-path' import { useNavFailureHandler } from './nav-failure-handler' import { - dispatchTraverseAction, publicAppRouterInstance, type AppRouterActionQueue, type GlobalErrorState, } from './app-router-instance' +import { legacyUrgentBFCacheRestore, restore, traverse } from './navigator' import { getRedirectTypeFromError, getURLFromRedirectError } from './redirect' import { isRedirectError } from './redirect-error' import { pingVisibleLinks } from './links' @@ -99,14 +97,7 @@ function handlePopState(state: PopStateEvent['state']): void { return } - // TODO-APP: Ideally the back button should not use startTransition as it should apply the updates synchronously - // Without startTransition works if the cache is there for this path - startTransition(() => { - dispatchTraverseAction( - window.location.href, - state.__PRIVATE_NEXTJS_INTERNALS_TREE - ) - }) + traverse(window.location.href, state.__PRIVATE_NEXTJS_INTERNALS_TREE) } function HistoryUpdater({ @@ -288,11 +279,10 @@ function Router({ // of the last MPA navigation. globalMutable.pendingMpaPath = undefined - dispatchAppRouterAction({ - type: ACTION_RESTORE, - url: new URL(window.location.href), - historyState: window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE, - }) + legacyUrgentBFCacheRestore( + new URL(window.location.href), + window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE + ) } window.addEventListener('pageshow', handlePageShow) @@ -377,13 +367,7 @@ function Router({ const appHistoryState: AppHistoryState | undefined = window.history.state?.__PRIVATE_NEXTJS_INTERNALS_TREE - startTransition(() => { - dispatchAppRouterAction({ - type: ACTION_RESTORE, - url: new URL(url ?? href, href), - historyState: appHistoryState, - }) - }) + restore(new URL(url ?? href, href), appHistoryState) } /** diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index bab021c75860..eb8f269c23ea 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -48,7 +48,7 @@ import { } from '../../shared/lib/hooks-client-context.shared-runtime' import { getParamValueFromCacheKey } from '../route-params' import type { Params } from '../../server/request/params' -import { isDeferredRsc } from './router-reducer/ppr-navigations' +import { isDeferredRsc } from './render-tree' const enum ScrollTargetState { NoClientRects, diff --git a/packages/next/src/client/components/navigator.ts b/packages/next/src/client/components/navigator.ts new file mode 100644 index 000000000000..0d3d26cd433c --- /dev/null +++ b/packages/next/src/client/components/navigator.ts @@ -0,0 +1,222 @@ +// This module is the router's operation interface: one function per +// user-facing operation, called directly by the corresponding entry points +// (Link, the public router methods, the history event handlers). +// +// The navigator owns the startTransition for its operations, along with the +// centralized safety checks; callers must invoke these functions +// synchronously within the originating event. +// +// Server Actions are not a navigator operation: the action queue is +// semantically separate from the router state queue. Its entry point is +// callServer (app-call-server.ts). +// +// The legacy reducer action objects are an implementation detail of the +// current action queue; they are constructed here and never by callers. +// +// This is also the seam where an experimental rewrite of the router state +// machine will fork from the existing implementation, so nothing above this +// interface may depend on how the operations are processed. + +import { addTransitionType, startTransition } from 'react' +import { + ACTION_HMR_REFRESH, + ACTION_NAVIGATE, + ACTION_REFRESH, + ACTION_RESTORE, + type AppHistoryState, + type AppRouterState, + type NavigateAction, + ScrollBehavior, +} from './router-reducer/router-reducer-types' +import type { NavigateOptions } from '../../shared/lib/app-router-context.shared-runtime' +import { dispatchAppRouterAction } from './use-action-queue' +import { getCurrentAppRouterState } from './app-router-instance' +import { setLinkForCurrentNavigation, type LinkInstance } from './links' +import type { RouterTransitionPrefetchIntent } from '../router-transition-types' +import { startRouterTransition } from './router-transition' +import { addBasePath } from '../add-base-path' +import { isExternalURL } from './app-router-utils' +import { isJavaScriptURLString } from '../lib/javascript-url' +import { resetKnownRoutes } from './segment-cache/optimistic-routes' + +function getRequiredAppRouterState(): AppRouterState { + const state = getCurrentAppRouterState() + if (state === null) { + throw new Error( + 'Internal Next.js error: Router action dispatched before initialization.' + ) + } + return state +} + +export function navigate( + href: string, + navigateType: NavigateAction['navigateType'], + scrollBehavior: ScrollBehavior, + linkInstanceRef: LinkInstance | null, + transitionTypes: string[] | undefined, + prefetchIntent: RouterTransitionPrefetchIntent | null +): void { + if (isJavaScriptURLString(href)) { + throw new Error( + 'Next.js has blocked a javascript: URL as a security precaution.' + ) + } + + startTransition(() => { + // TODO: This stuff could just go into the reducer. Leaving as-is for now + // since we're about to rewrite all the router reducer stuff anyway. + + if (transitionTypes) { + for (const type of transitionTypes) { + addTransitionType(type) + } + } + + const url = new URL(addBasePath(href), location.href) + if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) { + window.next.__pendingUrl = url + } + + setLinkForCurrentNavigation(linkInstanceRef) + startRouterTransition( + href, + navigateType, + getRequiredAppRouterState().tree, + prefetchIntent + ) + + dispatchAppRouterAction({ + type: ACTION_NAVIGATE, + url, + isExternalUrl: isExternalURL(url), + locationSearch: location.search, + scrollBehavior, + navigateType, + }) + }) +} + +export function push(href: string, options?: NavigateOptions): void { + navigate( + href, + 'push', + options?.scroll === false + ? ScrollBehavior.NoScroll + : ScrollBehavior.Default, + null, + options?.transitionTypes, + null + ) +} + +export function replace(href: string, options?: NavigateOptions): void { + navigate( + href, + 'replace', + options?.scroll === false + ? ScrollBehavior.NoScroll + : ScrollBehavior.Default, + null, + options?.transitionTypes, + null + ) +} + +export function traverse( + href: string, + historyState: AppHistoryState | undefined +): void { + startTransition(() => { + startRouterTransition( + href, + 'traverse', + getRequiredAppRouterState().tree, + null + ) + restore(new URL(href), historyState) + }) +} + +/** + * Sync the router state to a history entry that was written by something + * other than a router navigation (a userland pushState/replaceState, or a + * bfcache restore). Unlike a traversal, this does not represent a transition + * between routes. + */ +export function restore( + url: URL, + historyState: AppHistoryState | undefined +): void { + startTransition(() => { + dispatchAppRouterAction({ + type: ACTION_RESTORE, + url, + historyState, + }) + }) +} + +/** + * The bfcache `pageshow` restore (see the pageshow handler in app-router.tsx). + * Unlike every other navigator operation, this dispatches as a deliberately + * urgent (non-transition) update: the restored state reset must render before + * anything else can observe the stale mpaNavigation state and re-fire the MPA + * navigation the restore exists to prevent. As a transition it would be + * interruptible, and an intervening urgent render could commit against the + * stale state first. + * + * This is a preserved legacy special case. It will not be carried into the + * concurrent router queue, which handles bfcache restore through its own + * design. + */ +export function legacyUrgentBFCacheRestore( + url: URL, + historyState: AppHistoryState | undefined +): void { + dispatchAppRouterAction({ + type: ACTION_RESTORE, + url, + historyState, + }) +} + +export function refresh(): void { + startTransition(() => { + dispatchAppRouterAction({ + type: ACTION_REFRESH, + }) + }) +} + +// Tracks the newest HMR refresh generation so that a newer refresh can abort +// the request of the one it supersedes. Development only. +let activeHmrRefreshController: AbortController | null = null + +// Development only. +export function hmrRefresh(): void { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'hmrRefresh can only be used in development mode. Please use refresh instead.' + ) + } else { + // Reset the known routes table so that route predictions are cleared + // when routes change during development. + resetKnownRoutes() + let signal: AbortSignal | undefined + if (process.env.__NEXT_SERVER_COMPONENTS_HMR_CANCELLATION) { + // Abort the superseded generation before scheduling the new one, so its + // request is torn down as early as possible. Halting (not rejecting) + // makes the abort safe regardless of order. + activeHmrRefreshController?.abort() + activeHmrRefreshController = new AbortController() + signal = activeHmrRefreshController.signal + } + startTransition(() => { + dispatchAppRouterAction({ + type: ACTION_HMR_REFRESH, + signal, + }) + }) + } +} diff --git a/packages/next/src/client/components/prefetch.ts b/packages/next/src/client/components/prefetch.ts new file mode 100644 index 000000000000..547407546085 --- /dev/null +++ b/packages/next/src/client/components/prefetch.ts @@ -0,0 +1,111 @@ +import type { FlightRouterState } from '../../shared/lib/app-router-types' +import type { PrefetchOptions } from '../../shared/lib/app-router-context.shared-runtime' +import { PrefetchKind } from './router-reducer/router-reducer-types' +import { createPrefetchURL } from './app-router-utils' +import { getCurrentAppRouterState } from './app-router-instance' +import { isJavaScriptURLString } from '../lib/javascript-url' +import { createCacheKey } from './segment-cache/cache-key' +import { schedulePrefetchTask } from './segment-cache/scheduler' +import { + FetchStrategy, + PrefetchPriority, + type PrefetchTaskFetchStrategy, +} from './segment-cache/types' + +/** + * The public prefetch operation, exposed through `router.prefetch`. Converts + * the public options into a fetch strategy, reads the current router state, + * and drives the Segment Cache. + * + * Unlike the old implementation, the Segment Cache doesn't store its data in + * the router reducer state; it writes into a global mutable cache. So we + * don't need to dispatch an action. + */ +export function prefetchRoute(href: string, options?: PrefetchOptions): void { + if (isJavaScriptURLString(href)) { + throw new Error( + 'Next.js has blocked a javascript: URL as a security precaution.' + ) + } + const state = getCurrentAppRouterState() + if (state === null) { + throw new Error( + 'Internal Next.js error: Router action dispatched before initialization.' + ) + } + const prefetchKind = options?.kind ?? PrefetchKind.AUTO + + // We don't currently offer a way to issue a runtime prefetch via `router.prefetch()`. + // This will be possible when we update its API to not take a PrefetchKind. + let fetchStrategy: PrefetchTaskFetchStrategy + switch (prefetchKind) { + case PrefetchKind.AUTO: { + // We default to PPR. We'll discover whether or not the route supports it with the initial prefetch. + fetchStrategy = FetchStrategy.PPR + break + } + case PrefetchKind.FULL: { + fetchStrategy = FetchStrategy.Full + break + } + default: { + prefetchKind satisfies never + // Despite typescript thinking that this can't happen, + // we might get an unexpected value from user code. + // We don't know what they want, but we know they want a prefetch, + // so use the default. + fetchStrategy = FetchStrategy.PPR + } + } + + prefetch( + href, + state.nextUrl, + state.tree, + fetchStrategy, + options?.onInvalidate ?? null + ) +} + +/** + * Entrypoint for prefetching a URL into the Segment Cache. + * @param href - The URL to prefetch. Typically this will come from a , + * or router.prefetch. It must be validated before we attempt to prefetch it. + * @param nextUrl - A special header used by the server for interception routes. + * Roughly corresponds to the current URL. + * @param treeAtTimeOfPrefetch - The FlightRouterState at the time the prefetch + * was requested. This is only used when PPR is disabled. + * @param fetchStrategy - Whether to prefetch dynamic data, in addition to + * static data. This is used by ``. + * @param onInvalidate - A callback that will be called when the prefetch cache + * When called, it signals to the listener that the data associated with the + * prefetch may have been invalidated from the cache. This is not a live + * subscription — it's called at most once per `prefetch` call. The only + * supported use case is to trigger a new prefetch inside the listener, if + * desired. It also may be called even in cases where the associated data is + * still cached. Prefetching is a poll-based (pull) operation, not an event- + * based (push) one. Rather than subscribe to specific cache entries, you + * occasionally poll the prefetch cache to check if anything is missing. + */ +export function prefetch( + href: string, + nextUrl: string | null, + treeAtTimeOfPrefetch: FlightRouterState, + fetchStrategy: PrefetchTaskFetchStrategy, + onInvalidate: null | (() => void) +) { + const url = createPrefetchURL(href) + if (url === null) { + // This href should not be prefetched. + return + } + const cacheKey = createCacheKey(url.href, nextUrl) + schedulePrefetchTask( + cacheKey, + treeAtTimeOfPrefetch, + fetchStrategy, + PrefetchPriority.Default, + onInvalidate, + null // navigationLockPrefetch + ) +} diff --git a/packages/next/src/client/components/router-reducer/ppr-navigations.ts b/packages/next/src/client/components/render-tree.ts similarity index 98% rename from packages/next/src/client/components/router-reducer/ppr-navigations.ts rename to packages/next/src/client/components/render-tree.ts index 1eb4c9bc3b0d..ecbcdc14d194 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/render-tree.ts @@ -1,29 +1,29 @@ import type { FlightRouterState, Segment, -} from '../../../shared/lib/app-router-types' -import type { CacheNode } from '../../../shared/lib/app-router-types' -import type { HeadData, ScrollRef } from '../../../shared/lib/app-router-types' -import { PrefetchHint } from '../../../shared/lib/app-router-types' +} from '../../shared/lib/app-router-types' +import type { CacheNode } from '../../shared/lib/app-router-types' +import type { HeadData, ScrollRef } from '../../shared/lib/app-router-types' +import { PrefetchHint } from '../../shared/lib/app-router-types' import { PAGE_SEGMENT_KEY, DEFAULT_SEGMENT_KEY, NOT_FOUND_SEGMENT_KEY, -} from '../../../shared/lib/segment' -import { matchSegment } from '../match-segments' -import { createHrefFromUrl } from './create-href-from-url' -import { fetchServerResponse } from './fetch-server-response' -import { dispatchAppRouterAction } from '../use-action-queue' +} from '../../shared/lib/segment' +import { matchSegment } from './match-segments' +import { createHrefFromUrl } from './router-reducer/create-href-from-url' +import { fetchServerResponse } from './router-reducer/fetch-server-response' +import { dispatchAppRouterAction } from './use-action-queue' import { ACTION_SERVER_PATCH, type ServerPatchAction, -} from './router-reducer-types' -import { isNavigatingToNewRootLayout } from './is-navigating-to-new-root-layout' -import { getLastCommittedTree } from './reducers/committed-state' +} from './router-reducer/router-reducer-types' +import { isNavigatingToNewRootLayout } from './router-reducer/is-navigating-to-new-root-layout' +import { getLastCommittedTree } from './router-reducer/reducers/committed-state' import { createNavigationSeed, type NavigationSeed, -} from '../segment-cache/decode-server-response' +} from './segment-cache/decode-server-response' import { segmentCacheMap, type SegmentCacheEntry, @@ -39,15 +39,15 @@ import { spawnStaticStageCacheWrite, writeRuntimePrefetchStreamIntoCache, EntryStatus, -} from '../segment-cache/cache' -import { discoverKnownRoute } from '../segment-cache/optimistic-routes' -import { urlSearchParamsToParsedUrlQuery } from '../../route-params' -import type { NormalizedSearch } from '../segment-cache/cache-key' -import type { CacheMap } from '../segment-cache/cache-map' +} from './segment-cache/cache' +import { discoverKnownRoute } from './segment-cache/optimistic-routes' +import { urlSearchParamsToParsedUrlQuery } from '../route-params' +import type { NormalizedSearch } from './segment-cache/cache-key' +import type { CacheMap } from './segment-cache/cache-map' import { getRenderedSearchFromVaryPath, type PageVaryPath, -} from '../segment-cache/vary-path' +} from './segment-cache/vary-path' import { readFromBFCache, readFromBFCacheDuringRegularNavigation, @@ -55,7 +55,7 @@ import { writeHeadToBFCache, updateBFCacheEntryStaleAt, computeDynamicStaleAt, -} from '../segment-cache/bfcache' +} from './segment-cache/bfcache' // 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 @@ -2293,7 +2293,7 @@ function createDeferredRsc< export function getCurrentNavigationLock(): NavigationLock | null { if (process.env.__NEXT_EXPOSE_TESTING_API) { const { getCurrentNavigationGate } = - require('../segment-cache/navigation-testing-lock') as typeof import('../segment-cache/navigation-testing-lock') + require('./segment-cache/navigation-testing-lock') as typeof import('./segment-cache/navigation-testing-lock') return getCurrentNavigationGate() } return null @@ -2311,7 +2311,7 @@ export function getCurrentNavigationLock(): NavigationLock | null { export function beginLockedNavigation(): NavigationLock | null { if (process.env.__NEXT_EXPOSE_TESTING_API) { const { beginLockedNavigation: begin } = - require('../segment-cache/navigation-testing-lock') as typeof import('../segment-cache/navigation-testing-lock') + require('./segment-cache/navigation-testing-lock') as typeof import('./segment-cache/navigation-testing-lock') return begin() } return null @@ -2326,7 +2326,7 @@ export function beginLockedNavigation(): NavigationLock | null { export function resetNavigationLockToPending(): void { if (process.env.__NEXT_EXPOSE_TESTING_API) { const { resetNavigationLockToPending: reset } = - require('../segment-cache/navigation-testing-lock') as typeof import('../segment-cache/navigation-testing-lock') + require('./segment-cache/navigation-testing-lock') as typeof import('./segment-cache/navigation-testing-lock') reset() } } 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 65a884ead452..46565d2955eb 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 @@ -5,7 +5,7 @@ import { extractPathFromFlightRouterState } from './compute-changed-path' import type { AppRouterState } from './router-reducer-types' import { transportNodeToFlightRouterState } from '../../../shared/lib/rsc-transport' -import { createInitialCacheNodeForHydration } from './ppr-navigations' +import { createInitialCacheNodeForHydration } from '../render-tree' import { writeRuntimePrefetchStreamIntoCache, spawnStaticStageCacheWrite, diff --git a/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts index da7a83997bcf..5c9b814723fe 100644 --- a/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts @@ -4,7 +4,7 @@ import type { ReducerState, } from '../router-reducer-types' import { refreshDynamicData } from './refresh-reducer' -import { FreshnessPolicy } from '../ppr-navigations' +import { FreshnessPolicy } from '../../render-tree' export function hmrRefreshReducer( state: ReadonlyReducerState, diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts index b41483d22167..a2666d7c5595 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts @@ -7,9 +7,9 @@ import type { import { completeHardNavigation, navigate as navigateUsingSegmentCache, -} from '../../segment-cache/navigation' +} from '../../app-router-state' import { getStaleTimeMs } from '../../segment-cache/cache' -import { FreshnessPolicy } from '../ppr-navigations' +import { FreshnessPolicy } from '../../render-tree' // These values are set by `define-env-plugin` (based on `nextConfig.experimental.staleTimes`) // and default to 5 minutes (static) / 0 seconds (dynamic) diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts index fb505c312e2d..bfbfd9931576 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts @@ -4,14 +4,14 @@ import type { RefreshAction, } from '../router-reducer-types' import { ScrollBehavior } from '../router-reducer-types' -import { navigateToKnownRoute } from '../../segment-cache/navigation' +import { navigateToKnownRoute } from '../../app-router-state' import { createNavigationSeed } from '../../segment-cache/decode-server-response' import { invalidateSegmentCacheEntries, segmentCacheMap, } from '../../segment-cache/cache' import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree' -import { FreshnessPolicy, getCurrentNavigationLock } from '../ppr-navigations' +import { FreshnessPolicy, getCurrentNavigationLock } from '../../render-tree' import { invalidateBfCache, UnknownDynamicStaleTime, diff --git a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts index ef835e6184b5..f6192e5b43c6 100644 --- a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts @@ -10,12 +10,12 @@ import { spawnDynamicRequests, startPPRNavigation, type NavigationRequestAccumulation, -} from '../ppr-navigations' +} from '../../render-tree' import type { FlightRouterState } from '../../../../shared/lib/app-router-types' import { completeHardNavigation, completeTraverseNavigation, -} from '../../segment-cache/navigation' +} from '../../app-router-state' import { createNavigationSeed } from '../../segment-cache/decode-server-response' import { segmentCacheMap } from '../../segment-cache/cache' import { UnknownDynamicStaleTime } from '../../segment-cache/bfcache' 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 6f63eef8c5dd..902d1feaa0b4 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 @@ -56,7 +56,7 @@ import { completeHardNavigation, navigateToKnownRoute, navigate, -} from '../../segment-cache/navigation' +} from '../../app-router-state' import { createNavigationSeed } from '../../segment-cache/decode-server-response' import { discoverKnownRoute } from '../../segment-cache/optimistic-routes' import type { NormalizedSearch } from '../../segment-cache/cache-key' @@ -67,7 +67,7 @@ import { type ActionRevalidationKind, } from '../../../../shared/lib/action-revalidation-kind' import { isExternalURL } from '../../app-router-utils' -import { FreshnessPolicy, getCurrentNavigationLock } from '../ppr-navigations' +import { FreshnessPolicy, getCurrentNavigationLock } from '../../render-tree' import { processFetch } from '../fetch-server-response' import { invalidateBfCache, diff --git a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts index 8ff69021c82e..a401e3bd4c2f 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts @@ -9,10 +9,10 @@ import { import { completeHardNavigation, navigateToKnownRoute, -} from '../../segment-cache/navigation' +} from '../../app-router-state' import { segmentCacheMap } from '../../segment-cache/cache' import { refreshReducer } from './refresh-reducer' -import { getCurrentNavigationLock } from '../ppr-navigations' +import { getCurrentNavigationLock } from '../../render-tree' export function serverPatchReducer( state: ReadonlyReducerState, 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 6f904d04a008..d4937bba7d66 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 @@ -2,7 +2,7 @@ import type { CacheNode, ScrollRef } from '../../../shared/lib/app-router-types' 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 { FreshnessPolicy } from '../render-tree' export const ACTION_REFRESH = 'refresh' export const ACTION_NAVIGATE = 'navigate' diff --git a/packages/next/src/client/components/segment-cache/prefetch.ts b/packages/next/src/client/components/segment-cache/prefetch.ts deleted file mode 100644 index 96aad7fee7bc..000000000000 --- a/packages/next/src/client/components/segment-cache/prefetch.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { FlightRouterState } from '../../../shared/lib/app-router-types' -import { createPrefetchURL } from '../app-router-utils' -import { createCacheKey } from './cache-key' -import { schedulePrefetchTask } from './scheduler' -import { PrefetchPriority, type PrefetchTaskFetchStrategy } from './types' - -/** - * Entrypoint for prefetching a URL into the Segment Cache. - * @param href - The URL to prefetch. Typically this will come from a , - * or router.prefetch. It must be validated before we attempt to prefetch it. - * @param nextUrl - A special header used by the server for interception routes. - * Roughly corresponds to the current URL. - * @param treeAtTimeOfPrefetch - The FlightRouterState at the time the prefetch - * was requested. This is only used when PPR is disabled. - * @param fetchStrategy - Whether to prefetch dynamic data, in addition to - * static data. This is used by ``. - * @param onInvalidate - A callback that will be called when the prefetch cache - * When called, it signals to the listener that the data associated with the - * prefetch may have been invalidated from the cache. This is not a live - * subscription — it's called at most once per `prefetch` call. The only - * supported use case is to trigger a new prefetch inside the listener, if - * desired. It also may be called even in cases where the associated data is - * still cached. Prefetching is a poll-based (pull) operation, not an event- - * based (push) one. Rather than subscribe to specific cache entries, you - * occasionally poll the prefetch cache to check if anything is missing. - */ -export function prefetch( - href: string, - nextUrl: string | null, - treeAtTimeOfPrefetch: FlightRouterState, - fetchStrategy: PrefetchTaskFetchStrategy, - onInvalidate: null | (() => void) -) { - const url = createPrefetchURL(href) - if (url === null) { - // This href should not be prefetched. - return - } - const cacheKey = createCacheKey(url.href, nextUrl) - schedulePrefetchTask( - cacheKey, - treeAtTimeOfPrefetch, - fetchStrategy, - PrefetchPriority.Default, - onInvalidate, - null // navigationLockPrefetch - ) -} diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index ae194245d60a..7a88209d4c13 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -5477,7 +5477,7 @@ async function streamStagedRenderInDev({ // earlier on a cache miss). When streaming live (a client navigation), it's // surfaced through the Flight payload as `_revealAfter`: the client decodes // it and defers resolving the response's deferred RSCs on it (see - // `ppr-navigations`), so a Suspense boundary's children aren't revealed + // `render-tree`), so a Suspense boundary's children aren't revealed // before their row has been decoded, which would flush a premature fallback. // React serializes the promise as a pending row whose resolution row is // emitted only when we resolve it here, and that row follows the children's From dd76599c3432a0a72d6b4423006eab7eff60856a Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 15 Aug 2026 23:46:34 -0400 Subject: [PATCH 2/2] Scaffolding for concurrentRouterQueue flag (#97413) Sets up the scaffolding for a rewrite of the client router's queue. I've named the new module concurrent-router-queue.ts and the existing module sequential-router-queue.ts, since the main goal of the new one is to improve the router's integration with React's concurrent rendering model. The experimental flag is likewise named `concurrentRouterQueue`. There's no implementation yet; all router operations throw an error when the flag is enabled. The main entry point into the router is navigator.ts. The flag swaps between implementations at the module level to ensure the inactive code is eliminated from the final bundle. --- crates/next-core/src/next_client/context.rs | 2 + crates/next-core/src/next_config.rs | 8 + crates/next-core/src/next_import_map.rs | 37 ++- packages/next/errors.json | 3 +- .../next/src/build/create-compiler-aliases.ts | 20 ++ packages/next/src/build/define-env.ts | 2 + packages/next/src/client/app-call-server.ts | 25 +- .../components/concurrent-router-queue.ts | 96 ++++++++ .../next/src/client/components/navigator.ts | 233 ++---------------- .../components/sequential-router-queue.ts | 217 ++++++++++++++++ .../next/src/client/concurrent-call-server.ts | 30 +++ .../next/src/client/sequential-call-server.ts | 29 +++ packages/next/src/server/config-schema.ts | 1 + packages/next/src/server/config-shared.ts | 7 + .../concurrent-router-queue/app/actions.ts | 5 + .../app/client-components.tsx | 25 ++ .../concurrent-router-queue/app/layout.tsx | 8 + .../concurrent-router-queue/app/page.tsx | 15 ++ .../app/target-page/page.tsx | 3 + .../concurrent-router-queue.test.ts | 72 ++++++ .../concurrent-router-queue/next.config.js | 10 + 21 files changed, 619 insertions(+), 229 deletions(-) create mode 100644 packages/next/src/client/components/concurrent-router-queue.ts create mode 100644 packages/next/src/client/components/sequential-router-queue.ts create mode 100644 packages/next/src/client/concurrent-call-server.ts create mode 100644 packages/next/src/client/sequential-call-server.ts create mode 100644 test/e2e/app-dir/concurrent-router-queue/app/actions.ts create mode 100644 test/e2e/app-dir/concurrent-router-queue/app/client-components.tsx create mode 100644 test/e2e/app-dir/concurrent-router-queue/app/layout.tsx create mode 100644 test/e2e/app-dir/concurrent-router-queue/app/page.tsx create mode 100644 test/e2e/app-dir/concurrent-router-queue/app/target-page/page.tsx create mode 100644 test/e2e/app-dir/concurrent-router-queue/concurrent-router-queue.test.ts create mode 100644 test/e2e/app-dir/concurrent-router-queue/next.config.js diff --git a/crates/next-core/src/next_client/context.rs b/crates/next-core/src/next_client/context.rs index 46c33da40b6b..78f7be7eb64a 100644 --- a/crates/next-core/src/next_client/context.rs +++ b/crates/next-core/src/next_client/context.rs @@ -165,11 +165,13 @@ pub async fn get_client_resolve_options_context( || *next_config .enable_expose_testing_api_in_production_build() .await?; + let concurrent_router_queue = *next_config.enable_concurrent_router_queue().await?; let next_client_resolved_map = get_next_client_resolved_map( project_path.clone(), project_path.clone(), *mode.await?, expose_testing_api, + concurrent_router_queue, ) .await? .to_resolved() diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index a6d35687e791..8bda61f7b412 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -1378,6 +1378,9 @@ pub struct ExperimentalConfig { swc_trace_profiling: Option, transition_indicator: Option, gesture_transition: Option, + /// Forks the client router's entry-point modules to the experimental + /// concurrent router queue implementation via the import map. + concurrent_router_queue: Option, // `rename_all = "camelCase"` would lowercase the acronym to `blockingSsr`; // rename explicitly so it deserializes from the public `blockingSSR` field. #[serde(rename = "blockingSSR")] @@ -2428,6 +2431,11 @@ impl NextConfig { ) } + #[turbo_tasks::function] + pub fn enable_concurrent_router_queue(&self) -> Vc { + Vc::cell(self.experimental.concurrent_router_queue.unwrap_or(false)) + } + #[turbo_tasks::function] pub fn enable_cache_components(&self) -> Vc { Vc::cell(self.cache_components.unwrap_or(false)) diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index d90feb2543e6..546e3a14324f 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -578,6 +578,7 @@ pub async fn get_next_client_resolved_map( root: FileSystemPath, _mode: NextMode, expose_testing_api: bool, + concurrent_router_queue: bool, ) -> Result> { // In the browser bundle, swap every module that has a `.browser` sibling (see // BROWSER_VARIANT_MODULES, generated from the filesystem) for that sibling. The default @@ -613,7 +614,7 @@ pub async fn get_next_client_resolved_map( // alias in `create-compiler-aliases.ts`. if !expose_testing_api { glob_mappings.push(( - fs_root, + fs_root.clone(), Glob::new( rcstr!("**/next/dist/client/components/segment-cache/navigation-testing-lock.js"), GlobOptions::default(), @@ -629,6 +630,40 @@ pub async fn get_next_client_resolved_map( )); } + // When `experimental.concurrentRouterQueue` is enabled, resolve the + // router's forked entry-point modules (the navigator interface and the + // callServer action door) to the concurrent implementations. Neither the + // interface module nor the sequential implementation is bundled at all. + // This mirrors the webpack alias in `create-compiler-aliases.ts`. + if concurrent_router_queue { + glob_mappings.push(( + fs_root.clone(), + Glob::new( + rcstr!("**/next/dist/client/components/navigator.js"), + GlobOptions::default(), + ) + .to_resolved() + .await?, + request_to_import_mapping( + context_path.clone(), + rcstr!("next/dist/client/components/concurrent-router-queue"), + ), + )); + glob_mappings.push(( + fs_root, + Glob::new( + rcstr!("**/next/dist/client/app-call-server.js"), + GlobOptions::default(), + ) + .to_resolved() + .await?, + request_to_import_mapping( + context_path.clone(), + rcstr!("next/dist/client/concurrent-call-server"), + ), + )); + } + Ok(ResolvedMap { by_glob: glob_mappings, } diff --git a/packages/next/errors.json b/packages/next/errors.json index d19cb00827b6..e6358e6e0947 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1473,5 +1473,6 @@ "1472": "Cannot convert a server response with no transport data and no base tree.", "1473": "Invariant: image cache entry \"%s\" is empty", "1474": "Invariant: cannot write an empty buffer to the image cache", - "1475": "Invariant: no direct app page entry found for %s" + "1475": "Invariant: no direct app page entry found for %s", + "1476": "Not implemented: this behavior is not yet supported when `experimental.concurrentRouterQueue` is enabled." } diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index 23733302d31a..06a70cc75a34 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -64,6 +64,8 @@ export function createWebpackAliases({ const isInstantNavigationTestingEnabled = config.cacheComponents === true && (dev || config.experimental.exposeTestingApiInProductionBuild === true) + const isConcurrentRouterQueueEnabled = + config.experimental.concurrentRouterQueue === true // tell webpack where to look for _app and _document // using aliases to allow falling back to the default @@ -199,6 +201,24 @@ export function createWebpackAliases({ 'next/dist/client/components/segment-cache/navigation-testing-lock.disabled', } : {}), + + // When `experimental.concurrentRouterQueue` is enabled, resolve the + // router's forked entry-point modules (the navigator interface and + // the callServer action door) to the concurrent implementations. + // Neither the interface module nor the sequential implementation is + // bundled at all. Same resolved-path matching as the swaps above. + ...(isConcurrentRouterQueueEnabled + ? { + [path.join( + NEXT_PROJECT_ROOT_DIST, + 'client/components/navigator.js' + ) + '$']: 'next/dist/client/components/concurrent-router-queue', + [path.join( + NEXT_PROJECT_ROOT_DIST, + 'client/app-call-server.js' + ) + '$']: 'next/dist/client/concurrent-call-server', + } + : {}), } : {}), diff --git a/packages/next/src/build/define-env.ts b/packages/next/src/build/define-env.ts index bd43436e880b..ebedb611ac56 100644 --- a/packages/next/src/build/define-env.ts +++ b/packages/next/src/build/define-env.ts @@ -384,6 +384,8 @@ export function getDefineEnv({ config.experimental.gestureTransition ?? false, 'process.env.__NEXT_OPTIMISTIC_ROUTING': config.experimental.optimisticRouting ?? false, + 'process.env.__NEXT_CONCURRENT_ROUTER_QUEUE': + config.experimental.concurrentRouterQueue ?? false, 'process.env.__NEXT_INSTRUMENTATION_CLIENT_ROUTER_TRANSITION_EVENTS': config.experimental.instrumentationClientRouterTransitionEvents ?? false, 'process.env.__NEXT_VARY_PARAMS': config.experimental.varyParams ?? false, diff --git a/packages/next/src/client/app-call-server.ts b/packages/next/src/client/app-call-server.ts index 85abf4a81b01..7a92cad353fe 100644 --- a/packages/next/src/client/app-call-server.ts +++ b/packages/next/src/client/app-call-server.ts @@ -1,6 +1,11 @@ -import { startTransition } from 'react' -import { ACTION_SERVER_ACTION } from './components/router-reducer/router-reducer-types' -import { dispatchAppRouterAction } from './components/use-action-queue' +// The entry point for Server Actions: the "action door" into the router. +// Server Actions are deliberately not a navigator operation (navigator.ts) — +// the action queue is semantically separate from the router state queue — +// but this module forks the same way: by default it re-exports the +// sequential implementation, and when `experimental.concurrentRouterQueue` +// is enabled, imports of this module resolve to './concurrent-call-server' +// instead at the bundler level (see create-compiler-aliases.ts and +// next_import_map.rs). Both implementations expose exactly this surface. /** * Invoke a Server Action. The returned promise resolves with the action's @@ -8,16 +13,4 @@ import { dispatchAppRouterAction } from './components/use-action-queue' * revalidation side effects of the action are handled by the router; they are * not observable through the returned promise. */ -export async function callServer(actionId: string, actionArgs: any[]) { - return new Promise((resolve, reject) => { - startTransition(() => { - dispatchAppRouterAction({ - type: ACTION_SERVER_ACTION, - actionId, - actionArgs, - resolve, - reject, - }) - }) - }) -} +export { callServer } from './sequential-call-server' diff --git a/packages/next/src/client/components/concurrent-router-queue.ts b/packages/next/src/client/components/concurrent-router-queue.ts new file mode 100644 index 000000000000..f0d66c8e13d7 --- /dev/null +++ b/packages/next/src/client/components/concurrent-router-queue.ts @@ -0,0 +1,96 @@ +// The concurrent-router-queue implementation of the navigator interface +// (navigator.ts). Callers must never import this module directly; when +// `experimental.concurrentRouterQueue` is enabled, imports of './navigator' +// resolve here at the bundler level (see create-compiler-aliases.ts and +// next_import_map.rs), and neither navigator.ts nor the sequential +// implementation is bundled at all. +// +// This module must remain free of side effects at module scope: in addition +// to the browser bundle, the navigator module graph is also compiled into +// the pre-compiled app-page runtime bundles (via app-render.tsx), where the +// bundler alias cannot reach. Only the browser copy's operations ever run. +// +// TODO: This is currently a stub. Every operation throws so that enabling +// the flag fails loudly instead of silently running the old implementation. + +import type { + AppHistoryState, + NavigateAction, + ScrollBehavior, +} from './router-reducer/router-reducer-types' +import type { NavigateOptions } from '../../shared/lib/app-router-context.shared-runtime' +import type { LinkInstance } from './links' +import type { RouterTransitionPrefetchIntent } from '../router-transition-types' + +// Keep in sync with the identical message in concurrent-call-server.ts, so +// all unimplemented behavior shares a single error (and error code). +function notImplemented(): never { + throw new Error( + 'Not implemented: this behavior is not yet supported when ' + + '`experimental.concurrentRouterQueue` is enabled.' + ) +} + +export function navigate( + _href: string, + _navigateType: NavigateAction['navigateType'], + _scrollBehavior: ScrollBehavior, + _linkInstanceRef: LinkInstance | null, + _transitionTypes: string[] | undefined, + _prefetchIntent: RouterTransitionPrefetchIntent | null +): void { + notImplemented() +} + +export function push(_href: string, _options?: NavigateOptions): void { + notImplemented() +} + +export function replace(_href: string, _options?: NavigateOptions): void { + notImplemented() +} + +export function traverse( + _href: string, + _historyState: AppHistoryState | undefined +): void { + notImplemented() +} + +export function restore( + _url: URL, + _historyState: AppHistoryState | undefined +): void { + notImplemented() +} + +// Never implemented, on purpose. This op exists only because the sequential +// queue expresses an MPA navigation as state (`pushRef.mpaNavigation`) +// consumed by a render-phase side effect, so a bfcache-restored page must +// reset that state with an urgent update before any other render can observe +// it and re-fire the navigation — urgency as a defense. The concurrent +// machine has no such hazard to defend against, and its single +// history/location owner handles the `pageshow` event itself, feeding it in +// as an ordinary restore — so this entry point won't be called at all once +// the shared callers are ported, and it dies with the sequential queue. +export function legacyUrgentBFCacheRestore( + _url: URL, + _historyState: AppHistoryState | undefined +): void { + notImplemented() +} + +export function refresh(): void { + notImplemented() +} + +// Development only. +export function hmrRefresh(): void { + notImplemented() +} + +// Type-only conformance check: this module must expose exactly the surface of +// the navigator interface. Fails to typecheck if a signature drifts. Compiles +// to `const _conformance = null` — no runtime effect. +const _conformance: typeof import('./navigator') = + null as unknown as typeof import('./concurrent-router-queue') diff --git a/packages/next/src/client/components/navigator.ts b/packages/next/src/client/components/navigator.ts index 0d3d26cd433c..b4ee2df62e64 100644 --- a/packages/next/src/client/components/navigator.ts +++ b/packages/next/src/client/components/navigator.ts @@ -8,215 +8,26 @@ // // Server Actions are not a navigator operation: the action queue is // semantically separate from the router state queue. Its entry point is -// callServer (app-call-server.ts). +// callServer (app-call-server.ts), which forks the same way. // -// The legacy reducer action objects are an implementation detail of the -// current action queue; they are constructed here and never by callers. -// -// This is also the seam where an experimental rewrite of the router state -// machine will fork from the existing implementation, so nothing above this -// interface may depend on how the operations are processed. - -import { addTransitionType, startTransition } from 'react' -import { - ACTION_HMR_REFRESH, - ACTION_NAVIGATE, - ACTION_REFRESH, - ACTION_RESTORE, - type AppHistoryState, - type AppRouterState, - type NavigateAction, - ScrollBehavior, -} from './router-reducer/router-reducer-types' -import type { NavigateOptions } from '../../shared/lib/app-router-context.shared-runtime' -import { dispatchAppRouterAction } from './use-action-queue' -import { getCurrentAppRouterState } from './app-router-instance' -import { setLinkForCurrentNavigation, type LinkInstance } from './links' -import type { RouterTransitionPrefetchIntent } from '../router-transition-types' -import { startRouterTransition } from './router-transition' -import { addBasePath } from '../add-base-path' -import { isExternalURL } from './app-router-utils' -import { isJavaScriptURLString } from '../lib/javascript-url' -import { resetKnownRoutes } from './segment-cache/optimistic-routes' - -function getRequiredAppRouterState(): AppRouterState { - const state = getCurrentAppRouterState() - if (state === null) { - throw new Error( - 'Internal Next.js error: Router action dispatched before initialization.' - ) - } - return state -} - -export function navigate( - href: string, - navigateType: NavigateAction['navigateType'], - scrollBehavior: ScrollBehavior, - linkInstanceRef: LinkInstance | null, - transitionTypes: string[] | undefined, - prefetchIntent: RouterTransitionPrefetchIntent | null -): void { - if (isJavaScriptURLString(href)) { - throw new Error( - 'Next.js has blocked a javascript: URL as a security precaution.' - ) - } - - startTransition(() => { - // TODO: This stuff could just go into the reducer. Leaving as-is for now - // since we're about to rewrite all the router reducer stuff anyway. - - if (transitionTypes) { - for (const type of transitionTypes) { - addTransitionType(type) - } - } - - const url = new URL(addBasePath(href), location.href) - if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) { - window.next.__pendingUrl = url - } - - setLinkForCurrentNavigation(linkInstanceRef) - startRouterTransition( - href, - navigateType, - getRequiredAppRouterState().tree, - prefetchIntent - ) - - dispatchAppRouterAction({ - type: ACTION_NAVIGATE, - url, - isExternalUrl: isExternalURL(url), - locationSearch: location.search, - scrollBehavior, - navigateType, - }) - }) -} - -export function push(href: string, options?: NavigateOptions): void { - navigate( - href, - 'push', - options?.scroll === false - ? ScrollBehavior.NoScroll - : ScrollBehavior.Default, - null, - options?.transitionTypes, - null - ) -} - -export function replace(href: string, options?: NavigateOptions): void { - navigate( - href, - 'replace', - options?.scroll === false - ? ScrollBehavior.NoScroll - : ScrollBehavior.Default, - null, - options?.transitionTypes, - null - ) -} - -export function traverse( - href: string, - historyState: AppHistoryState | undefined -): void { - startTransition(() => { - startRouterTransition( - href, - 'traverse', - getRequiredAppRouterState().tree, - null - ) - restore(new URL(href), historyState) - }) -} - -/** - * Sync the router state to a history entry that was written by something - * other than a router navigation (a userland pushState/replaceState, or a - * bfcache restore). Unlike a traversal, this does not represent a transition - * between routes. - */ -export function restore( - url: URL, - historyState: AppHistoryState | undefined -): void { - startTransition(() => { - dispatchAppRouterAction({ - type: ACTION_RESTORE, - url, - historyState, - }) - }) -} - -/** - * The bfcache `pageshow` restore (see the pageshow handler in app-router.tsx). - * Unlike every other navigator operation, this dispatches as a deliberately - * urgent (non-transition) update: the restored state reset must render before - * anything else can observe the stale mpaNavigation state and re-fire the MPA - * navigation the restore exists to prevent. As a transition it would be - * interruptible, and an intervening urgent render could commit against the - * stale state first. - * - * This is a preserved legacy special case. It will not be carried into the - * concurrent router queue, which handles bfcache restore through its own - * design. - */ -export function legacyUrgentBFCacheRestore( - url: URL, - historyState: AppHistoryState | undefined -): void { - dispatchAppRouterAction({ - type: ACTION_RESTORE, - url, - historyState, - }) -} - -export function refresh(): void { - startTransition(() => { - dispatchAppRouterAction({ - type: ACTION_REFRESH, - }) - }) -} - -// Tracks the newest HMR refresh generation so that a newer refresh can abort -// the request of the one it supersedes. Development only. -let activeHmrRefreshController: AbortController | null = null - -// Development only. -export function hmrRefresh(): void { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'hmrRefresh can only be used in development mode. Please use refresh instead.' - ) - } else { - // Reset the known routes table so that route predictions are cleared - // when routes change during development. - resetKnownRoutes() - let signal: AbortSignal | undefined - if (process.env.__NEXT_SERVER_COMPONENTS_HMR_CANCELLATION) { - // Abort the superseded generation before scheduling the new one, so its - // request is torn down as early as possible. Halting (not rejecting) - // makes the abort safe regardless of order. - activeHmrRefreshController?.abort() - activeHmrRefreshController = new AbortController() - signal = activeHmrRefreshController.signal - } - startTransition(() => { - dispatchAppRouterAction({ - type: ACTION_HMR_REFRESH, - signal, - }) - }) - } -} +// This is the seam where the experimental rewrite of the router state +// machine forks from the existing implementation, so nothing above this +// interface may depend on how the operations are processed. The fork happens +// at the bundler level: by default this module re-exports the sequential +// router queue, but when `experimental.concurrentRouterQueue` is enabled, +// imports of this module resolve to './concurrent-router-queue' instead — +// neither this module nor the sequential implementation is bundled at all. +// The aliases live in create-compiler-aliases.ts (webpack/rspack) and +// next_import_map.rs (Turbopack). The export list below is the interface; +// both implementations expose exactly this surface. + +export { + navigate, + push, + replace, + traverse, + restore, + legacyUrgentBFCacheRestore, + refresh, + hmrRefresh, +} from './sequential-router-queue' diff --git a/packages/next/src/client/components/sequential-router-queue.ts b/packages/next/src/client/components/sequential-router-queue.ts new file mode 100644 index 000000000000..f316db660da0 --- /dev/null +++ b/packages/next/src/client/components/sequential-router-queue.ts @@ -0,0 +1,217 @@ +// The sequential-router-queue implementation of the navigator interface +// (navigator.ts). Callers must never import this module directly; they import +// './navigator', which resolves here unless `experimental. +// concurrentRouterQueue` swaps in `./concurrent-router-queue` at the bundler +// level (see create-compiler-aliases.ts and next_import_map.rs). +// +// The legacy reducer action objects are an implementation detail of the +// sequential action queue; they are constructed here and never by callers. +// +// This module must remain free of side effects at module scope: in addition +// to the browser bundle, a statically-resolved copy is compiled into the +// pre-compiled app-page runtime bundles (via app-render.tsx), where the +// bundler alias cannot reach. Only the browser copy's operations ever run. + +import { addTransitionType, startTransition } from 'react' +import { + ACTION_HMR_REFRESH, + ACTION_NAVIGATE, + ACTION_REFRESH, + ACTION_RESTORE, + type AppHistoryState, + type AppRouterState, + type NavigateAction, + ScrollBehavior, +} from './router-reducer/router-reducer-types' +import type { NavigateOptions } from '../../shared/lib/app-router-context.shared-runtime' +import { dispatchAppRouterAction } from './use-action-queue' +import { getCurrentAppRouterState } from './app-router-instance' +import { setLinkForCurrentNavigation, type LinkInstance } from './links' +import type { RouterTransitionPrefetchIntent } from '../router-transition-types' +import { startRouterTransition } from './router-transition' +import { addBasePath } from '../add-base-path' +import { isExternalURL } from './app-router-utils' +import { isJavaScriptURLString } from '../lib/javascript-url' +import { resetKnownRoutes } from './segment-cache/optimistic-routes' + +function getRequiredAppRouterState(): AppRouterState { + const state = getCurrentAppRouterState() + if (state === null) { + throw new Error( + 'Internal Next.js error: Router action dispatched before initialization.' + ) + } + return state +} + +export function navigate( + href: string, + navigateType: NavigateAction['navigateType'], + scrollBehavior: ScrollBehavior, + linkInstanceRef: LinkInstance | null, + transitionTypes: string[] | undefined, + prefetchIntent: RouterTransitionPrefetchIntent | null +): void { + if (isJavaScriptURLString(href)) { + throw new Error( + 'Next.js has blocked a javascript: URL as a security precaution.' + ) + } + + startTransition(() => { + // TODO: This stuff could just go into the reducer. Leaving as-is for now + // since we're about to rewrite all the router reducer stuff anyway. + + if (transitionTypes) { + for (const type of transitionTypes) { + addTransitionType(type) + } + } + + const url = new URL(addBasePath(href), location.href) + if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) { + window.next.__pendingUrl = url + } + + setLinkForCurrentNavigation(linkInstanceRef) + startRouterTransition( + href, + navigateType, + getRequiredAppRouterState().tree, + prefetchIntent + ) + + dispatchAppRouterAction({ + type: ACTION_NAVIGATE, + url, + isExternalUrl: isExternalURL(url), + locationSearch: location.search, + scrollBehavior, + navigateType, + }) + }) +} + +export function push(href: string, options?: NavigateOptions): void { + navigate( + href, + 'push', + options?.scroll === false + ? ScrollBehavior.NoScroll + : ScrollBehavior.Default, + null, + options?.transitionTypes, + null + ) +} + +export function replace(href: string, options?: NavigateOptions): void { + navigate( + href, + 'replace', + options?.scroll === false + ? ScrollBehavior.NoScroll + : ScrollBehavior.Default, + null, + options?.transitionTypes, + null + ) +} + +export function traverse( + href: string, + historyState: AppHistoryState | undefined +): void { + startTransition(() => { + startRouterTransition( + href, + 'traverse', + getRequiredAppRouterState().tree, + null + ) + restore(new URL(href), historyState) + }) +} + +/** + * Sync the router state to a history entry that was written by something + * other than a router navigation (a userland pushState/replaceState, or a + * bfcache restore). Unlike a traversal, this does not represent a transition + * between routes. + */ +export function restore( + url: URL, + historyState: AppHistoryState | undefined +): void { + startTransition(() => { + dispatchAppRouterAction({ + type: ACTION_RESTORE, + url, + historyState, + }) + }) +} + +/** + * The bfcache `pageshow` restore (see the pageshow handler in app-router.tsx). + * Unlike every other navigator operation, this dispatches as a deliberately + * urgent (non-transition) update: the restored state reset must render before + * anything else can observe the stale mpaNavigation state and re-fire the MPA + * navigation the restore exists to prevent. As a transition it would be + * interruptible, and an intervening urgent render could commit against the + * stale state first. + * + * This is a preserved legacy special case. It will not be carried into the + * concurrent router queue, which handles bfcache restore through its own + * design. + */ +export function legacyUrgentBFCacheRestore( + url: URL, + historyState: AppHistoryState | undefined +): void { + dispatchAppRouterAction({ + type: ACTION_RESTORE, + url, + historyState, + }) +} + +export function refresh(): void { + startTransition(() => { + dispatchAppRouterAction({ + type: ACTION_REFRESH, + }) + }) +} + +// Tracks the newest HMR refresh generation so that a newer refresh can abort +// the request of the one it supersedes. Development only. +let activeHmrRefreshController: AbortController | null = null + +// Development only. +export function hmrRefresh(): void { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'hmrRefresh can only be used in development mode. Please use refresh instead.' + ) + } else { + // Reset the known routes table so that route predictions are cleared + // when routes change during development. + resetKnownRoutes() + let signal: AbortSignal | undefined + if (process.env.__NEXT_SERVER_COMPONENTS_HMR_CANCELLATION) { + // Abort the superseded generation before scheduling the new one, so its + // request is torn down as early as possible. Halting (not rejecting) + // makes the abort safe regardless of order. + activeHmrRefreshController?.abort() + activeHmrRefreshController = new AbortController() + signal = activeHmrRefreshController.signal + } + startTransition(() => { + dispatchAppRouterAction({ + type: ACTION_HMR_REFRESH, + signal, + }) + }) + } +} diff --git a/packages/next/src/client/concurrent-call-server.ts b/packages/next/src/client/concurrent-call-server.ts new file mode 100644 index 000000000000..2cfe3a205683 --- /dev/null +++ b/packages/next/src/client/concurrent-call-server.ts @@ -0,0 +1,30 @@ +// The concurrent implementation of callServer (app-call-server.ts). Callers +// must never import this module directly; when +// `experimental.concurrentRouterQueue` is enabled, imports of +// './app-call-server' resolve here at the bundler level (see +// create-compiler-aliases.ts and next_import_map.rs), and neither +// app-call-server.ts nor the sequential implementation is bundled at all. +// +// This module must remain free of side effects at module scope; see the note +// in concurrent-router-queue.ts. +// +// TODO: This is currently a stub. It throws so that enabling the flag fails +// loudly instead of silently running the old implementation. + +export async function callServer( + _actionId: string, + _actionArgs: any[] +): Promise { + // Keep in sync with the identical message in concurrent-router-queue.ts, so + // all unimplemented behavior shares a single error (and error code). + throw new Error( + 'Not implemented: this behavior is not yet supported when ' + + '`experimental.concurrentRouterQueue` is enabled.' + ) +} + +// Type-only conformance check: this module must expose exactly the surface of +// the app-call-server interface. Fails to typecheck if a signature drifts. +// Compiles to `const _conformance = null` — no runtime effect. +const _conformance: typeof import('./app-call-server') = + null as unknown as typeof import('./concurrent-call-server') diff --git a/packages/next/src/client/sequential-call-server.ts b/packages/next/src/client/sequential-call-server.ts new file mode 100644 index 000000000000..ccde3dd9a565 --- /dev/null +++ b/packages/next/src/client/sequential-call-server.ts @@ -0,0 +1,29 @@ +// The sequential implementation of callServer (app-call-server.ts): Server +// Actions are dispatched into the sequential router action queue. Callers +// must never import this module directly; they import './app-call-server', +// which resolves here unless `experimental.concurrentRouterQueue` swaps in +// './concurrent-call-server' at the bundler level (see +// create-compiler-aliases.ts and next_import_map.rs). +// +// This module must remain free of side effects at module scope: in addition +// to the browser bundle, a statically-resolved copy may be compiled into the +// pre-compiled app-page runtime bundles, where the bundler alias cannot +// reach. Only the browser copy ever runs. + +import { startTransition } from 'react' +import { ACTION_SERVER_ACTION } from './components/router-reducer/router-reducer-types' +import { dispatchAppRouterAction } from './components/use-action-queue' + +export async function callServer(actionId: string, actionArgs: any[]) { + return new Promise((resolve, reject) => { + startTransition(() => { + dispatchAppRouterAction({ + type: ACTION_SERVER_ACTION, + actionId, + actionArgs, + resolve, + reject, + }) + }) + }) +} diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 496bb6c4d43e..57d99b717a8a 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -227,6 +227,7 @@ export const experimentalSchema = { dynamicOnHover: z.boolean().optional(), useOffline: z.boolean().optional(), optimisticRouting: z.boolean().optional(), + concurrentRouterQueue: z.boolean().optional(), instrumentationClientRouterTransitionEvents: z.boolean().optional(), varyParams: z.boolean().optional(), prefetchInlining: z diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 3f57b6b16152..1800304f747d 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -521,6 +521,12 @@ export interface ExperimentalConfig { dynamicOnHover?: boolean useOffline?: boolean optimisticRouting?: boolean + /** + * Replaces the client router's sequential action queue with a rewritten + * concurrent implementation. The implementations are swapped at the module + * level by the bundler; the inactive one is not included in the bundle. + */ + concurrentRouterQueue?: boolean instrumentationClientRouterTransitionEvents?: boolean varyParams?: boolean prefetchInlining?: @@ -2234,6 +2240,7 @@ export const defaultConfig = Object.freeze({ useOffline: false, varyParams: true, optimisticRouting: true, + concurrentRouterQueue: false, instrumentationClientRouterTransitionEvents: false, prefetchInlining: true, preloadEntriesOnStart: true, diff --git a/test/e2e/app-dir/concurrent-router-queue/app/actions.ts b/test/e2e/app-dir/concurrent-router-queue/app/actions.ts new file mode 100644 index 000000000000..5c91a7be5815 --- /dev/null +++ b/test/e2e/app-dir/concurrent-router-queue/app/actions.ts @@ -0,0 +1,5 @@ +'use server' + +export async function greet(): Promise { + return 'hello from the server' +} diff --git a/test/e2e/app-dir/concurrent-router-queue/app/client-components.tsx b/test/e2e/app-dir/concurrent-router-queue/app/client-components.tsx new file mode 100644 index 000000000000..b6b3a8eff209 --- /dev/null +++ b/test/e2e/app-dir/concurrent-router-queue/app/client-components.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useState } from 'react' + +// Invokes a Server Action and renders the settled result, the way an app +// would observe its own action's returned promise. +export function ActionButton({ action }: { action: () => Promise }) { + const [result, setResult] = useState('') + return ( + <> + +

{result}

+ + ) +} diff --git a/test/e2e/app-dir/concurrent-router-queue/app/layout.tsx b/test/e2e/app-dir/concurrent-router-queue/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/concurrent-router-queue/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/concurrent-router-queue/app/page.tsx b/test/e2e/app-dir/concurrent-router-queue/app/page.tsx new file mode 100644 index 000000000000..11f839a5fecc --- /dev/null +++ b/test/e2e/app-dir/concurrent-router-queue/app/page.tsx @@ -0,0 +1,15 @@ +import Link from 'next/link' +import { greet } from './actions' +import { ActionButton } from './client-components' + +export default function Page() { + return ( + <> +

home

+ + Go to target page + + + + ) +} diff --git a/test/e2e/app-dir/concurrent-router-queue/app/target-page/page.tsx b/test/e2e/app-dir/concurrent-router-queue/app/target-page/page.tsx new file mode 100644 index 000000000000..65a61600f7a0 --- /dev/null +++ b/test/e2e/app-dir/concurrent-router-queue/app/target-page/page.tsx @@ -0,0 +1,3 @@ +export default function TargetPage() { + return

target page

+} diff --git a/test/e2e/app-dir/concurrent-router-queue/concurrent-router-queue.test.ts b/test/e2e/app-dir/concurrent-router-queue/concurrent-router-queue.test.ts new file mode 100644 index 000000000000..2320b8b17b5e --- /dev/null +++ b/test/e2e/app-dir/concurrent-router-queue/concurrent-router-queue.test.ts @@ -0,0 +1,72 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +const NOT_IMPLEMENTED_ERROR = + 'Not implemented: this behavior is not yet supported when ' + + '`experimental.concurrentRouterQueue` is enabled.' + +// `experimental.concurrentRouterQueue` swaps the router's entry-point modules +// (the navigator and the callServer action door) for the concurrent +// implementations at the bundler level. The concurrent implementations are +// currently stubs that throw a single distinctive error from every operation, +// so this suite verifies the fork wiring: the app boots on the concurrent +// modules without touching them, and every old-world entry point fails +// loudly instead of silently running the sequential implementation. +describe('concurrent-router-queue', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('hydrates cleanly without invoking the forked entry points', async () => { + // `pushErrorAsConsoleLog` records uncaught page errors into the console + // log capture, which works in both dev and start modes. + const browser = await next.browser('/', { pushErrorAsConsoleLog: true }) + expect(await browser.elementByCss('#home').text()).toBe('home') + // Hydration is complete once the client components are interactive. + await browser.waitForElementByCss('#invoke-action') + // Nothing at boot may call into the stubs (or fail in any other way). + const errors = (await browser.log()).filter((log) => log.source === 'error') + expect(errors).toEqual([]) + }) + + it('fails loudly on link navigation', async () => { + const browser = await next.browser('/', { pushErrorAsConsoleLog: true }) + await browser.waitForElementByCss('#invoke-action') + + await browser.elementByCss('#to-target-page').click() + + // The stub throws synchronously inside the click handler, which surfaces + // as an uncaught page error. Wait for it to confirm the click was + // processed. + await retry(async () => { + const errors = (await browser.log()).filter( + (log) => + log.source === 'error' && log.message.includes(NOT_IMPLEMENTED_ERROR) + ) + expect(errors.length).toBeGreaterThan(0) + }) + + // No navigation happened, soft or hard: Link calls preventDefault() + // before dispatching, and the stub throws before any router state or + // pending-URL bookkeeping, so there is no fallback hard navigation. + expect(new URL(await browser.url()).pathname).toBe('/') + // The home page is still rendered; the target page never appears. + expect(await browser.elementByCss('#home').text()).toBe('home') + expect(await browser.hasElementByCssSelector('#target-page')).toBe(false) + }) + + it('fails loudly on server action invocation', async () => { + const browser = await next.browser('/') + await browser.waitForElementByCss('#invoke-action') + + await browser.elementByCss('#invoke-action').click() + + // callServer is async, so the stub surfaces as a rejection of the promise + // returned to the action caller, which the fixture renders. The result + // element is empty (and hidden) until the rejection renders, so + // elementByCss waits for it to appear. + expect(await browser.elementByCss('#action-result').text()).toBe( + `rejected: ${NOT_IMPLEMENTED_ERROR}` + ) + }) +}) diff --git a/test/e2e/app-dir/concurrent-router-queue/next.config.js b/test/e2e/app-dir/concurrent-router-queue/next.config.js new file mode 100644 index 000000000000..1f8fd1dd240d --- /dev/null +++ b/test/e2e/app-dir/concurrent-router-queue/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + concurrentRouterQueue: true, + }, +} + +module.exports = nextConfig