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 c447e163828a..7a92cad353fe 100644 --- a/packages/next/src/client/app-call-server.ts +++ b/packages/next/src/client/app-call-server.ts @@ -1,17 +1,16 @@ -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. -export async function callServer(actionId: string, actionArgs: any[]) { - return new Promise((resolve, reject) => { - startTransition(() => { - dispatchAppRouterAction({ - type: ACTION_SERVER_ACTION, - actionId, - actionArgs, - resolve, - reject, - }) - }) - }) -} +/** + * 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 { callServer } from './sequential-call-server' 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/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/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..b4ee2df62e64 --- /dev/null +++ b/packages/next/src/client/components/navigator.ts @@ -0,0 +1,33 @@ +// 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), which forks the same way. +// +// 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/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/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/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 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