From f22b89a3302fe303ee63ccafcbc3ef7acb027d50 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:18:50 +0000 Subject: [PATCH 01/74] feat(react-router): publish router state as concurrent render frames React's never fires across a router navigation. The navigation is already inside React.startTransition, but router state reaches components through useSyncExternalStore, which React schedules at a hardcoded SyncLane from the store's own subscription callback. The transition lane is lost before the update reaches the tree. Introduces an opt-in render-frame protocol behind the new router option experimental_concurrentRenderFrames (default off, so nothing changes unless it is set): router-core - every aggregate router state carries a monotonic frameId - StartTransitionFn callbacks now return the assembled RouterState, so partial publication is a type error - matchRoute accepts a presented _state, so it does not fall back to the pending imperative location during render react-router - RouterStateProvider owns the committed frame in React state, stages a successor inside startTransition, and commits it on acknowledgement - Matches acknowledges the exact rendered frameId, so a superseded frame cannot settle a newer navigation - every reactive read (useRouterState, useLocation, useMatch, useMatches, useMatchRoute, Match, Outlet, links, not-found, head tags, scripts, useCanGoBack) selects from the frame when enabled, and keeps its existing atom subscription when disabled - after hydration the route Suspense boundaries consolidate at Matches so publication and acknowledgement are atomic; SSR and the first hydration render keep the existing per-route boundaries so the shell can stream and the client hydrates the same tree Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .changeset/concurrent-router-render-frames.md | 10 ++ packages/react-router/src/Match.tsx | 62 +++++++- packages/react-router/src/Matches.tsx | 130 ++++++++++++++-- packages/react-router/src/RouterProvider.tsx | 10 +- packages/react-router/src/Scripts.tsx | 21 ++- packages/react-router/src/Transitioner.tsx | 42 ++++-- .../react-router/src/headContentUtils.tsx | 24 +-- packages/react-router/src/link.tsx | 16 +- packages/react-router/src/not-found.tsx | 53 +++---- packages/react-router/src/router.ts | 8 + .../react-router/src/routerStateContext.tsx | 140 ++++++++++++++++++ packages/react-router/src/useCanGoBack.ts | 9 ++ packages/react-router/src/useLocation.tsx | 10 ++ packages/react-router/src/useMatch.tsx | 59 +++++--- packages/react-router/src/useRouterState.tsx | 10 ++ packages/router-core/src/load-client.ts | 8 +- packages/router-core/src/router.ts | 23 ++- packages/router-core/src/stores.ts | 2 + 18 files changed, 522 insertions(+), 115 deletions(-) create mode 100644 .changeset/concurrent-router-render-frames.md create mode 100644 packages/react-router/src/routerStateContext.tsx diff --git a/.changeset/concurrent-router-render-frames.md b/.changeset/concurrent-router-render-frames.md new file mode 100644 index 00000000000..9d3a7c0a0b2 --- /dev/null +++ b/.changeset/concurrent-router-render-frames.md @@ -0,0 +1,10 @@ +--- +'@tanstack/react-router': minor +'@tanstack/router-core': minor +--- + +Add an experimental `experimental_concurrentRenderFrames` router option that publishes router state to React as one immutable render frame per navigation. + +React's `` and other transition-only behaviour never engage across a navigation today, because router state reaches components through `useSyncExternalStore`, which React schedules at a synchronous lane from the store's own subscription callback — after the `startTransition` scope has exited. When enabled, the React adapter owns the committed frame in state, stages a successor inside the transition, and acknowledges the exact rendered frame, so an interrupted or superseded navigation cannot settle a newer one. + +Default off; with the option unset, the existing granular store subscriptions and selector behaviour are unchanged. diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 97b8f795892..5e65b7abeb9 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -11,7 +11,8 @@ import { matchContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' -import { ClientOnly } from './ClientOnly' +import { ClientOnly, useHydrated } from './ClientOnly' +import { useRouterStateSelector } from './routerStateContext' import { nonRouteComponentContext, wrapInNonRouteComponentContext, @@ -43,11 +44,22 @@ type OutletMatchSelection = [ parentNotFoundError: unknown, ] +type ConcurrentOutletMatchSelection = [ + parentGlobalNotFound: boolean, + parentNotFoundError: unknown, + childRouteId: string | undefined, +] + const outletMatchSelectionEqual = ( a: OutletMatchSelection, b: OutletMatchSelection, ) => a[0] === b[0] && a[1] === b[1] +const concurrentOutletMatchSelectionEqual = ( + a: ConcurrentOutletMatchSelection, + b: ConcurrentOutletMatchSelection, +) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2] + const canWrapInSuspense = ( router: ReturnType, route: AnyRoute, @@ -66,6 +78,15 @@ export const Match = React.memo(function MatchImpl({ routeId: string }) { const router = useRouter() + if (router.options.experimental_concurrentRenderFrames) { + // The option is fixed for the mounted router, so this branch cannot change + // hook order during the component's lifetime. + // eslint-disable-next-line react-hooks/rules-of-hooks + const match = useRouterStateSelector(router, (state) => + state.matches.find((candidate) => candidate.routeId === routeId), + ) + return + } if (isServer ?? router.isServer) { const match = router.stores.byRoute.get(routeId)!.get()! @@ -101,9 +122,19 @@ function MatchView({ : route.options.notFoundComponent const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only' + const _isServer = isServer ?? router.isServer + const isHydrating = Boolean(router.ssr) && !useHydrated() + // Once hydrated, a concurrent frame must suspend and acknowledge as one + // unit. During SSR and hydration, retain the route boundaries so the server + // can stream its shell and the client hydrates the same boundary tree. + const useFrameRootBoundary = + router.options.experimental_concurrentRenderFrames && + !_isServer && + !isHydrating // A root component may render the document itself. Only place its Suspense // boundary in pure CSR, inside an explicit shell, or when explicitly opted in. const ResolvedSuspenseBoundary = + !useFrameRootBoundary && canWrapInSuspense(router, route, match.ssr) && (route.options.wrapInSuspense ?? pendingElement ?? @@ -277,7 +308,28 @@ export const Outlet = React.memo(function OutletImpl() { let parentNotFoundError: unknown let childRouteId: string | undefined - if (isServer ?? router.isServer) { + if (router.options.experimental_concurrentRenderFrames) { + // The option is fixed for the mounted router, so this branch cannot change + // hook order during the component's lifetime. + // eslint-disable-next-line react-hooks/rules-of-hooks + ;[parentGlobalNotFound, parentNotFoundError, childRouteId] = + useRouterStateSelector( + router, + (state): ConcurrentOutletMatchSelection => { + const matches = state.matches + const parentIndex = matches.findIndex( + (match) => match.routeId === routeId, + ) + const parentMatch = matches[parentIndex]! + return [ + !!parentMatch._notFound, + parentMatch.error, + matches[parentIndex + 1]?.routeId, + ] + }, + concurrentOutletMatchSelectionEqual, + ) + } else if (isServer ?? router.isServer) { const matches = router.stores.matches.get() const parentIndex = matches.findIndex((match) => match.routeId === routeId) const parentMatch = matches[parentIndex]! @@ -314,7 +366,11 @@ export const Outlet = React.memo(function OutletImpl() { const nextMatch = - if (routeId === rootRouteId) { + // Matches owns the experiment's single acknowledgement boundary. + if ( + routeId === rootRouteId && + !router.options.experimental_concurrentRenderFrames + ) { return ( {nextMatch} diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 576c40a16fe..6f34300e788 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -12,12 +12,20 @@ import { Transitioner, settleOwner } from './Transitioner' import { matchContext } from './matchContext' import { Match, renderPending } from './Match' import { SafeFragment } from './SafeFragment' +import { useHydrated } from './ClientOnly' +import { + RouterStateFrame, + useRouterStateOwner, + useRouterStateSelector, +} from './routerStateContext' +import type { RouterRenderFrame } from './routerStateContext' import type { StructuralSharingOption, ValidateSelected, } from './structuralSharing' import type { AnyRoute, + AnyRouteMatch, AnyRouter, DeepPartial, Expand, @@ -47,13 +55,26 @@ declare module '@tanstack/router-core' { */ export function Matches() { const router = useRouter() + const routerStateOwner = useRouterStateOwner() + const [renderFrame, setRenderFrame] = React.useState() + const activeFrame = renderFrame ?? routerStateOwner?.frame const rootRoute: AnyRoute = router.routesById[rootRouteId] const pendingElement = renderPending(router, rootRoute) - // Do not render a root Suspense during SSR or hydrating from SSR + const _isServer = isServer ?? router.isServer + const isHydrating = Boolean(router.ssr) && !useHydrated() + // SSR and hydration keep upstream's route-level boundaries for streaming + // and an identical hydration tree. Afterwards, the frame path consolidates + // suspension at this root so one complete frame is acknowledged atomically. + const useFrameRootBoundary = + router.options.experimental_concurrentRenderFrames && + !_isServer && + !isHydrating const ResolvedSuspense = - (isServer ?? router.isServer) || router.ssr ? SafeFragment : React.Suspense + _isServer || (router.ssr && !useFrameRootBoundary) + ? SafeFragment + : React.Suspense const inner = ( <> @@ -65,10 +86,21 @@ export function Matches() { // router object, so React skips the update. // eslint-disable-next-line react-hooks/rules-of-hooks -- server only, condition is static t={React.useState()[1]} + setRenderFrame={setRenderFrame} /> )} - + {activeFrame ? ( + + + + ) : ( + + )} ) @@ -80,25 +112,57 @@ export function Matches() { ) } -function MatchesInner() { +function MatchesInner({ + activeFrame, + renderFrame, + setRenderFrame, +}: { + activeFrame?: RouterRenderFrame + renderFrame?: RouterRenderFrame + setRenderFrame: React.Dispatch< + React.SetStateAction + > +}) { const router = useRouter() + const routerStateOwner = useRouterStateOwner() const acknowledgement = router._rendered! - const matches = - (isServer ?? router.isServer) - ? router.stores.matches.get() - : // eslint-disable-next-line react-hooks/rules-of-hooks - useStore( - router.stores.matches, - (value) => acknowledgement[0 /* offered */] ?? value, - ) + let matches: Array + if (router.options.experimental_concurrentRenderFrames) { + // The option is fixed for the mounted router, so this branch cannot change + // hook order during the component's lifetime. + // eslint-disable-next-line react-hooks/rules-of-hooks + matches = useRouterStateSelector(router, (state) => state.matches) + } else if (isServer ?? router.isServer) { + matches = router.stores.matches.get() + } else { + // eslint-disable-next-line react-hooks/rules-of-hooks + matches = useStore(router.stores.matches, (value) => + Array.isArray(acknowledgement[0 /* offered */]) + ? acknowledgement[0 /* offered */] + : value, + ) + } const match = matches[0] const routeId = match?.routeId useLayoutEffect(() => { - if (acknowledgement[0 /* offered */] === matches) { + const acknowledged = router.options.experimental_concurrentRenderFrames + ? acknowledgement[0 /* offered */] === activeFrame?.frameId + : acknowledgement[0 /* offered */] === matches + if (acknowledged) { + if (renderFrame && routerStateOwner?.commit(renderFrame)) { + setRenderFrame(undefined) + } settleOwner(acknowledgement, true) } - }, [acknowledgement, matches]) + }, [ + acknowledgement, + activeFrame, + matches, + renderFrame, + routerStateOwner, + setRenderFrame, + ]) const matchComponent = routeId ? : null @@ -177,6 +241,33 @@ export function useMatchRoute(): < } } + if (router.options.experimental_concurrentRenderFrames) { + // The option is fixed for the mounted router, so this branch cannot change + // hook order during the component's lifetime. + // eslint-disable-next-line react-hooks/rules-of-hooks + const state = useRouterStateSelector(router, (frameState) => frameState) + // eslint-disable-next-line react-hooks/rules-of-hooks + return React.useCallback( + (opts) => { + const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts + + // Match against the presented frame so a pending imperative location + // cannot leak into the committed render. + return router.matchRoute( + rest as any, + { + pending, + caseSensitive, + fuzzy, + includeSearch, + _state: state, + } as any, + ) + }, + [router, state], + ) + } + // eslint-disable-next-line react-hooks/rules-of-hooks return React.useCallback( (opts) => { @@ -267,6 +358,17 @@ export function useMatches< ): UseMatchesResult { const router = useRouter() + if (router.options.experimental_concurrentRenderFrames) { + // The option is fixed for the mounted router, so this branch cannot change + // hook order during the component's lifetime. + // eslint-disable-next-line react-hooks/rules-of-hooks + const selectMatches = useStructuralSharing(opts, router) + // eslint-disable-next-line react-hooks/rules-of-hooks + return useRouterStateSelector(router, (state) => + selectMatches(state.matches), + ) as UseMatchesResult + } + if (isServer ?? router.isServer) { const matches = router.stores.matches.get() as Array< MakeRouteMatchUnion diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index 81c3fb4ece2..b767035e922 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -4,6 +4,7 @@ import * as React from 'react' import { hasKeys } from '@tanstack/router-core' import { Matches } from './Matches' import { routerContext } from './routerContext' +import { RouterStateProvider } from './routerStateContext' import type { AnyRouter, RegisteredRouter, @@ -36,9 +37,16 @@ export function RouterContextProvider< }) } + const childrenWithState = router.options + .experimental_concurrentRenderFrames ? ( + {children} + ) : ( + children + ) + const provider = ( - {children} + {childrenWithState} ) diff --git a/packages/react-router/src/Scripts.tsx b/packages/react-router/src/Scripts.tsx index a1b189d4c6a..d422cbfd804 100644 --- a/packages/react-router/src/Scripts.tsx +++ b/packages/react-router/src/Scripts.tsx @@ -3,6 +3,7 @@ import { _getAssetMatches, deepEqual } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { Asset } from './Asset' import { useRouter } from './useRouter' +import { useRouterStateSelector } from './routerStateContext' import type { RouterManagedTag } from '@tanstack/router-core' type ScriptRenderAsset = RouterManagedTag & { @@ -62,15 +63,21 @@ export const Scripts = () => { return scripts } - if (isServer ?? router.isServer) { - const activeMatches = router.stores.matches.get() - const scripts = getScripts(activeMatches) - return renderScripts(router, scripts) + let scripts: ReturnType + if (router.options.experimental_concurrentRenderFrames) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + scripts = useRouterStateSelector( + router, + (state) => getScripts(state.matches), + deepEqual, + ) + } else if (isServer ?? router.isServer) { + scripts = getScripts(router.stores.matches.get()) + } else { + // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static + scripts = useStore(router.stores.matches, getScripts, deepEqual) } - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const scripts = useStore(router.stores.matches, getScripts, deepEqual) - return renderScripts(router, scripts) } diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index ddf8afea2e1..ed03f04b0e8 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -4,7 +4,9 @@ import * as React from 'react' import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' import { useLayoutEffect } from './utils' import { useRouter } from './useRouter' +import { useRouterStateOwner } from './routerStateContext' import type { AnyRouter } from '@tanstack/router-core' +import type { RouterRenderFrame } from './routerStateContext' export function settleOwner( owner: NonNullable, @@ -17,10 +19,15 @@ export function settleOwner( export function Transitioner({ t, + setRenderFrame, }: { t: React.Dispatch> + setRenderFrame: React.Dispatch< + React.SetStateAction + > }) { const router = useRouter() + const routerStateOwner = useRouterStateOwner() const acknowledgement = (router._rendered ??= []) const mounted = process.env.NODE_ENV !== 'production' @@ -33,7 +40,21 @@ export function Transitioner({ settleOwner(acknowledgement, false) acknowledgement.push(expected, resolve) t(router) - React.startTransition(fn) + React.startTransition(() => { + routerStateOwner?.begin() + try { + const headFrame = fn() + const frame = routerStateOwner?.stage(headFrame) + if (frame) { + acknowledgement[0 /* offered */] = frame.frameId + setRenderFrame(frame) + } + } catch (cause) { + routerStateOwner?.cancel() + setRenderFrame(undefined) + throw cause + } + }) }) // Subscribe before canonicalizing so the initial URL has exactly one load. @@ -78,14 +99,17 @@ export function Transitioner({ resolvedLocation?.href === location.href && resolvedLocation.state.__TSR_key === location.state.__TSR_key ) { - acknowledgement.push(router.stores.matches.get(), (rendered) => { - if (rendered) { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo(resolvedLocation, resolvedLocation), - }) - } - }) + acknowledgement.push( + routerStateOwner?.frame.frameId ?? router.stores.matches.get(), + (rendered) => { + if (rendered) { + router.emit({ + type: 'onRendered', + ...getLocationChangeInfo(resolvedLocation, resolvedLocation), + }) + } + }, + ) } else if (!router._tx) { router.load({ sync: true }).catch(console.error) } diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index d61ca829dbe..1e4da11b70f 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -11,6 +11,7 @@ import { } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' +import { useRouterStateSelector } from './routerStateContext' import type { AnyRouteMatch, AssetCrossOriginConfig, @@ -196,21 +197,24 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const router = useRouter() const nonce = router.options.ssr?.nonce - if (isServer ?? router.isServer) { - return buildTagsFromMatches( - router, - nonce, - router.stores.matches.get(), - assetCrossOrigin, - ) - } - - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static const selectTags = React.useCallback( (matches: Array) => buildTagsFromMatches(router, nonce, matches, assetCrossOrigin), [assetCrossOrigin, nonce, router], ) + if (router.options.experimental_concurrentRenderFrames) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + return useRouterStateSelector( + router, + (state) => selectTags(state.matches), + deepEqual, + ) + } + + if (isServer ?? router.isServer) { + return selectTags(router.stores.matches.get()) + } + // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static return useStore(router.stores.matches, selectTags, deepEqual) } diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 6f0998c6355..7af1fede0fc 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -14,6 +14,7 @@ import { } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' +import { useRouterStateSelector } from './routerStateContext' import { useForwardedRef, useIntersectionObserver } from './utils' @@ -458,12 +459,15 @@ export function useLinkProps< [stableActiveOptions, disabled, isHydrated, _options, router, to], ) - // eslint-disable-next-line react-hooks/rules-of-hooks - const [href, isActive] = useStore( - router.stores.location, - selectLinkState, - compareLinkState, - ) + const [href, isActive] = router.options.experimental_concurrentRenderFrames + ? // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + useRouterStateSelector( + router, + (state) => selectLinkState(state.location), + compareLinkState, + ) + : // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + useStore(router.stores.location, selectLinkState, compareLinkState) const externalLink = isActive === undefined ? href : undefined const linkDisabled = disabled || href === undefined diff --git a/packages/react-router/src/not-found.tsx b/packages/react-router/src/not-found.tsx index 458a30520b0..87f3ca6fe80 100644 --- a/packages/react-router/src/not-found.tsx +++ b/packages/react-router/src/not-found.tsx @@ -4,6 +4,7 @@ import { isServer } from '@tanstack/router-core/isServer' import { useStore } from '@tanstack/react-store' import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' +import { useRouterStateSelector } from './routerStateContext' import type { ErrorInfo } from 'react' import type { NotFoundError } from '@tanstack/router-core' @@ -13,43 +14,25 @@ export function CatchNotFound(props: { children: React.ReactNode }) { const router = useRouter() - - if (isServer ?? router.isServer) { - const pathname = router.stores.location.get().pathname - const status = router.stores.status.get() - const resetKey = `not-found-${pathname}-${status}` - - return ( - resetKey} - onCatch={(error, errorInfo) => { - if (isNotFound(error)) { - props.onCatch?.(error, errorInfo) - } else { - throw error - } - }} - errorComponent={({ error }) => { - if (isNotFound(error)) { - return props.fallback?.(error) - } else { - throw error - } - }} - > - {props.children} - + let pathname: string + let status: 'pending' | 'idle' + if (router.options.experimental_concurrentRenderFrames) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + ;[pathname, status] = useRouterStateSelector( + router, + (state) => [state.location.pathname, state.status] as const, + (a, b) => a[0] === b[0] && a[1] === b[1], ) + } else if (isServer ?? router.isServer) { + pathname = router.stores.location.get().pathname + status = router.stores.status.get() + } else { + // TODO: Some way for the user to programmatically reset the not-found boundary? + // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static + pathname = useStore(router.stores.location, (location) => location.pathname) + // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static + status = useStore(router.stores.status, (status) => status) } - - // TODO: Some way for the user to programmatically reset the not-found boundary? - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const pathname = useStore( - router.stores.location, - (location) => location.pathname, - ) - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const status = useStore(router.stores.status, (status) => status) const resetKey = `not-found-${pathname}-${status}` return ( diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index de5e89a9fbc..9e4aa43e01c 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -16,6 +16,14 @@ import type { declare module '@tanstack/router-core' { export interface RouterOptionsExtensions { + /** + * Publish one immutable router snapshot through React state per navigation. + * This keeps urgent renders on the committed route while a newer route is + * pending, at the cost of broad context invalidation. + * + * @experimental + */ + experimental_concurrentRenderFrames?: boolean /** * The default `component` a route should use if no component is provided. * diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx new file mode 100644 index 00000000000..3c0ca999c36 --- /dev/null +++ b/packages/react-router/src/routerStateContext.tsx @@ -0,0 +1,140 @@ +'use client' + +import * as React from 'react' +import { useStore } from '@tanstack/react-store' +import { isServer } from '@tanstack/router-core/isServer' +import { useLayoutEffect } from './utils' +import type { AnyRouter, RouterState } from '@tanstack/router-core' + +export type RouterRenderFrame = RouterState + +type RouterStateContextValue = { + router: AnyRouter + frame: RouterRenderFrame + begin: () => void + stage: (frame: RouterRenderFrame) => RouterRenderFrame + cancel: () => void + commit: (frame: RouterRenderFrame) => boolean +} + +const defaultCompare = (a: unknown, b: unknown) => a === b + +const routerStateContext = React.createContext< + RouterStateContextValue | undefined +>(undefined) + +export function RouterStateProvider({ + router, + children, +}: { + router: AnyRouter + children: React.ReactNode +}) { + const staging = React.useRef(false) + const pendingFrame = React.useRef(undefined) + const [frame, setFrame] = React.useState(() => + router.stores.__store.get(), + ) + + const publish = React.useCallback(() => { + if (staging.current || pendingFrame.current) { + return + } + const nextFrame = router.stores.__store.get() + if (nextFrame.status === 'pending') { + return + } + setFrame((previous) => + previous.frameId === nextFrame.frameId ? previous : nextFrame, + ) + }, [router]) + + const begin = React.useCallback(() => { + staging.current = true + }, []) + + const stage = React.useCallback((nextFrame: RouterRenderFrame) => { + staging.current = false + pendingFrame.current = nextFrame + return nextFrame + }, []) + + const cancel = React.useCallback(() => { + staging.current = false + pendingFrame.current = undefined + publish() + }, [publish]) + + const commit = React.useCallback((nextFrame: RouterRenderFrame) => { + if (pendingFrame.current?.frameId !== nextFrame.frameId) { + return false + } + pendingFrame.current = undefined + setFrame(nextFrame) + return true + }, []) + + useLayoutEffect(() => { + const subscription = router.stores.__store.subscribe(() => publish()) + publish() + return () => subscription.unsubscribe() + }, [publish, router]) + + const value = React.useMemo( + () => ({ router, frame, begin, stage, cancel, commit }), + [router, frame, begin, stage, cancel, commit], + ) + + return ( + + {children} + + ) +} + +export function RouterStateFrame({ + frame, + children, +}: { + frame: RouterRenderFrame + children: React.ReactNode +}) { + const owner = React.useContext(routerStateContext)! + const value = React.useMemo(() => ({ ...owner, frame }), [owner, frame]) + return ( + + {children} + + ) +} + +export function useRouterStateOwner() { + return React.useContext(routerStateContext) +} + +export function useRouterStateSelector( + router: AnyRouter, + selector: (state: RouterState) => TSelected, + compare: (a: TSelected, b: TSelected) => boolean = defaultCompare, +): TSelected { + const context = React.useContext(routerStateContext) + const contextState = context?.router === router ? context.frame : null + const selection = React.useRef<{ value: TSelected } | undefined>(undefined) + + if (contextState) { + const next = selector(contextState) + if (!selection.current || !compare(selection.current.value, next)) { + selection.current = { value: next } + } + return selection.current.value + } + + if (isServer ?? router.isServer) { + return selector(router.stores.__store.get()) + } + + // The frame option is fixed when the router is created, so this branch + // cannot change hook order during the lifetime of a mounted router. + // eslint-disable-next-line react-hooks/rules-of-hooks + return useStore(router.stores.__store, selector, compare) +} diff --git a/packages/react-router/src/useCanGoBack.ts b/packages/react-router/src/useCanGoBack.ts index a20f947f438..6482fdc5ecc 100644 --- a/packages/react-router/src/useCanGoBack.ts +++ b/packages/react-router/src/useCanGoBack.ts @@ -1,10 +1,19 @@ import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' +import { useRouterStateSelector } from './routerStateContext' export function useCanGoBack() { const router = useRouter() + if (router.options.experimental_concurrentRenderFrames) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + return useRouterStateSelector( + router, + (state) => state.location.state.__TSR_index !== 0, + ) + } + if (isServer ?? router.isServer) { return router.stores.location.get().state.__TSR_index !== 0 } diff --git a/packages/react-router/src/useLocation.tsx b/packages/react-router/src/useLocation.tsx index 5c8c7c8d7e6..5ee3072ed98 100644 --- a/packages/react-router/src/useLocation.tsx +++ b/packages/react-router/src/useLocation.tsx @@ -4,6 +4,7 @@ import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' import { useStructuralSharing } from './useMatch' +import { useRouterStateSelector } from './routerStateContext' import type { StructuralSharingOption, ValidateSelected, @@ -52,6 +53,15 @@ export function useLocation< ): UseLocationResult { const router = useRouter() + if (router.options.experimental_concurrentRenderFrames) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + const selectLocation = useStructuralSharing(opts, router) + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + return useRouterStateSelector(router, (state) => + selectLocation(state.location), + ) as UseLocationResult + } + if (isServer ?? router.isServer) { const location = router.stores.location.get() return ( diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index d66a69f6405..c69bdb08b52 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -6,6 +6,7 @@ import { invariant, replaceEqualDeep } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { dummyMatchContext, matchContext } from './matchContext' import { useRouter } from './useRouter' +import { useRouterStateSelector } from './routerStateContext' import type { StructuralSharingOption, ValidateSelected, @@ -150,36 +151,50 @@ export function useMatch< const routeId = opts.from ?? nearestRouteId const matchStore = router.stores.getMatchStore(routeId!) - if (isServer ?? router.isServer) { - const match = matchStore.get() - if (!match) { - if (opts.shouldThrow ?? true) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - `Invariant failed: Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, - ) + if (!router.options.experimental_concurrentRenderFrames) { + if (isServer ?? router.isServer) { + const match = matchStore.get() + if (!match) { + if (opts.shouldThrow ?? true) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + `Invariant failed: Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, + ) + } + + invariant() } - invariant() + return undefined as any } - return undefined as any + return (opts.select ? opts.select(match as any) : match) as any } - return (opts.select ? opts.select(match as any) : match) as any - } - - const selector = - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - useStructuralSharing(opts, router) + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + const selector = useStructuralSharing(opts, router) + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + const matchSelection = useStore(matchStore, (match) => + match ? selector(match as any) : dummyMatch, + ) - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const matchSelection = useStore(matchStore, (match) => - match ? selector(match as any) : dummyMatch, - ) + if (matchSelection !== dummyMatch) { + return matchSelection as any + } + } else { + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + const selector = useStructuralSharing(opts, router) + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + const matchSelection = useRouterStateSelector(router, (state) => { + const match = state.matches.find( + (candidate) => candidate.routeId === routeId, + ) + return match ? selector(match as any) : dummyMatch + }) - if (matchSelection !== dummyMatch) { - return matchSelection as any + if (matchSelection !== dummyMatch) { + return matchSelection as any + } } if (opts.shouldThrow ?? true) { diff --git a/packages/react-router/src/useRouterState.tsx b/packages/react-router/src/useRouterState.tsx index f3fbb2ad6e9..621b350b426 100644 --- a/packages/react-router/src/useRouterState.tsx +++ b/packages/react-router/src/useRouterState.tsx @@ -4,6 +4,7 @@ import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' import { useStructuralSharing } from './useMatch' +import { useRouterStateSelector } from './routerStateContext' import type { AnyRouter, RegisteredRouter, @@ -54,6 +55,15 @@ export function useRouterState< }) const router = opts?.router || contextRouter + if (router.options.experimental_concurrentRenderFrames) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + return useRouterStateSelector( + router, + // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + useStructuralSharing(opts, router), + ) as UseRouterStateResult + } + // During SSR we render exactly once and do not need reactivity. // Avoid subscribing to the store (and any structural sharing work) on the server. // The expression must stay inlined in the `if` so bundlers fold the diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 1994eb2c371..45669c78342 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1528,7 +1528,10 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { })) offered[index]!.status = 'pending' const ack = (session[4 /* ack */] = router - .startTransition(() => router.stores.setMatches(offered), offered) + .startTransition(() => { + router.stores.setMatches(offered) + return router.stores.__store.get() + }, offered) .then((rendered) => { if ( rendered && @@ -1872,12 +1875,13 @@ async function runClientTransaction( finishPending(router, tx) commitMatches(router, tx, matches, resolvedPrefix) if (router._tx !== tx) { - return + return router.stores.__store.get() } router.emit({ type: 'onLoad', ...changeInfo }) if (router._tx === tx) { router.emit({ type: 'onBeforeRouteMount', ...changeInfo }) } + return router.stores.__store.get() } const rendered = await router.startTransition(commit, matches) if (process.env.NODE_ENV !== 'production' && tx[6 /* refresh */]) { diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 4ae7554d2cd..0e758243785 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -574,6 +574,8 @@ export interface RouterState< in out TRouteTree extends AnyRoute = AnyRoute, in out TRouteMatch = MakeRouteMatchUnion, > { + /** Monotonic identity for one atomically assembled render snapshot. */ + frameId: number status: 'pending' | 'idle' isLoading: boolean matches: Array @@ -792,7 +794,7 @@ export type CommitLocationFn = ({ }: ParsedLocation & CommitLocationOptions) => Promise export type StartTransitionFn = ( - fn: () => void, + fn: () => RouterState, expected: Array, ) => Promise @@ -1079,7 +1081,7 @@ export interface RouterCore< _serverResult?: ServerLoadResult /** Framework publication waiting for an exact render acknowledgement. */ _rendered?: [ - offered?: Array, + offered?: Array | number, settle?: (rendered: boolean) => void, ] /** Development-only HMR reload for a route and its descendants. */ @@ -2628,16 +2630,24 @@ export class RouterCore< } const next = this.buildLocation(matchLocation as any) - const isPending = this.stores.status.get() === 'pending' + const presentedState = ( + opts as MatchRouteOptions & { _state?: RouterState } + )?._state + const isPending = + (presentedState?.status ?? this.stores.status.get()) === 'pending' if (opts?.pending && !isPending) { return false } const pending = opts?.pending ?? !isPending - const baseLocation = pending - ? this.latestLocation - : this.stores.resolvedLocation.get() || this.stores.location.get() + const baseLocation = presentedState + ? pending + ? presentedState.location + : presentedState.resolvedLocation || presentedState.location + : pending + ? this.latestLocation + : this.stores.resolvedLocation.get() || this.stores.location.get() const match = findSingleMatch( next.pathname, @@ -2767,6 +2777,7 @@ export function getInitialRouterState( location: ParsedLocation, ): RouterState { return { + frameId: 0, isLoading: false, status: 'idle', resolvedLocation: undefined, diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts index 8c2beeb2e72..e1fe0e69dd0 100644 --- a/packages/router-core/src/stores.ts +++ b/packages/router-core/src/stores.ts @@ -92,6 +92,7 @@ export function createRouterStores( config: StoreConfig, ): RouterStores { const { createMutableStore, createReadonlyStore, batch } = config + let nextFrameId = 0 // non reactive utilities const byRoute = new Map() @@ -110,6 +111,7 @@ export function createRouterStores( // compatibility "big" state store const __store = createReadonlyStore(() => ({ + frameId: nextFrameId++, status: status.get(), isLoading: status.get() === 'pending', matches: matches.get(), From 63793a3e43724fe34192383537bc35c79e05a9a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:15:17 +0000 Subject: [PATCH 02/74] test(e2e): assert view transitions actually run The view-transitions e2e app shipped a single placeholder test, and the viewTransition tests in the basic app only assert that the destination heading renders. Both pass whether or not a view transition occurs, so neither guards the feature. Replaces the placeholder with three tests that wrap document.startViewTransition before app code runs and sample the live animations once the browser reports the transition ready: - a viewTransition navigation starts exactly one real view transition - the transition pairs the shared element, animating the ::view-transition-group/old/new(main-content) pseudo-elements - the configured types are applied, so the document matches :active-view-transition-type(slide-left) and then (slide-right) Verified as a real guard: removing viewTransition from the link under test fails all three, and restoring it passes them again. They also pass with experimental_concurrentRenderFrames enabled, covering the render frame path added in this branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../view-transitions/tests/app.spec.ts | 106 +++++++++++++++++- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/e2e/react-router/view-transitions/tests/app.spec.ts b/e2e/react-router/view-transitions/tests/app.spec.ts index 472bd6ed0bb..379efc18f29 100644 --- a/e2e/react-router/view-transitions/tests/app.spec.ts +++ b/e2e/react-router/view-transitions/tests/app.spec.ts @@ -1,10 +1,108 @@ -import { test } from '@playwright/test' +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +/** One observed call to `document.startViewTransition`. */ +type ViewTransitionRecord = { + /** Transition types active on the document while the transition ran. */ + types: Array + /** Pseudo-elements the browser animated for the transition. */ + pseudos: Array +} + +declare global { + interface Window { + __viewTransitions: Array + } +} + +const KNOWN_TYPES = ['slide-left', 'slide-right', 'warp'] + +/** + * Wrap `document.startViewTransition` before any app code runs, and sample the + * live animations once the browser reports the transition as ready. Asserting + * on the real API is the only way to tell a view transition apart from a plain + * navigation that happens to end on the right page. + */ +async function recordViewTransitions(page: Page) { + await page.addInitScript((knownTypes: Array) => { + window.__viewTransitions = [] + const original = document.startViewTransition?.bind(document) + if (!original) { + return + } + document.startViewTransition = ((...args: Array) => { + const record: ViewTransitionRecord = { types: [], pseudos: [] } + window.__viewTransitions.push(record) + const transition = original(...(args as [any])) + transition.ready + .then(() => { + record.pseudos = document + .getAnimations() + .map((animation) => (animation.effect as any)?.pseudoElement) + .filter((pseudo): pseudo is string => Boolean(pseudo)) + record.types = knownTypes.filter((type) => + document.documentElement.matches( + `:active-view-transition-type(${type})`, + ), + ) + }) + .catch(() => {}) + return transition + }) as typeof document.startViewTransition + }, KNOWN_TYPES) +} + +const getRecords = (page: Page) => page.evaluate(() => window.__viewTransitions) test.beforeEach(async ({ page }) => { + await recordViewTransitions(page) await page.goto('/') }) -test('placeholder test', async ({ page }) => { - // This is a placeholder test - await page.waitForLoadState('networkidle') +test('a viewTransition navigation starts a real view transition', async ({ + page, +}) => { + await page.getByRole('link', { name: 'Next Page' }).click() + await expect(page.getByRole('heading')).toContainText( + 'This example demonstrates a variety of custom page transitions', + ) + + await expect.poll(async () => (await getRecords(page)).length).toBe(1) +}) + +test('the transition pairs the shared element across the navigation', async ({ + page, +}) => { + await page.getByRole('link', { name: 'Next Page' }).click() + + await expect + .poll(async () => (await getRecords(page))[0]?.pseudos ?? []) + .toEqual( + expect.arrayContaining([ + '::view-transition-group(main-content)', + '::view-transition-old(main-content)', + '::view-transition-new(main-content)', + ]), + ) +}) + +test('the configured viewTransition types are applied to the document', async ({ + page, +}) => { + const supportsTypes = await page.evaluate(() => + Boolean( + window.CSS?.supports?.('selector(:active-view-transition-type(a))'), + ), + ) + test.skip(!supportsTypes, 'browser does not support view transition types') + + await page.getByRole('link', { name: 'Next Page' }).click() + await expect + .poll(async () => (await getRecords(page))[0]?.types ?? []) + .toEqual(['slide-left']) + + await page.getByRole('link', { name: 'Previous Page' }).click() + await expect + .poll(async () => (await getRecords(page))[1]?.types ?? []) + .toEqual(['slide-right']) }) From efb0245bcd5d652c189aa3cd8c4078bba1e4efac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:48:36 +0000 Subject: [PATCH 03/74] fix(react-router): preserve selector-level subscriptions on the frame path The first implementation published the frame as a changing Context value, so every consumer re-rendered on every navigation regardless of its selector. That traded the existing fine-grained selector contract for correctness, which is not an acceptable trade even for a first pass. Splits the single changing Context into two: - a stable owner context, whose identity never changes, carrying the committed frame plus a subscriber set; - a frame context read only by route presentation, which re-renders per navigation regardless. Selector hooks now read the stable owner and subscribe. The owner notifies subscribers from inside the Router's startTransition, so their updates keep the transition lane, and each subscriber re-renders only when its own selection changes. Adds tests/concurrent-render-frames.test.tsx, which asserts a consumer whose selection is unchanged does not re-render during a navigation while one whose selection changed does. It runs against both the store path and the frame path. Verified as a real guard: against the previous Context implementation the frame case fails with 6 renders where 2 are expected, and the store case passes. With this change both pass. Selector-call counts across the existing store-updates-during-navigation cases are now lower on the frame path than on the store path (7->3, 5->3, 3->2, 3->1), never higher. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 5 +- .../react-router/src/routerStateContext.tsx | 214 ++++++++++++------ .../tests/concurrent-render-frames.test.tsx | 108 +++++++++ 3 files changed, 253 insertions(+), 74 deletions(-) create mode 100644 packages/react-router/tests/concurrent-render-frames.test.tsx diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 6f34300e788..24ec41c3670 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -15,6 +15,7 @@ import { SafeFragment } from './SafeFragment' import { useHydrated } from './ClientOnly' import { RouterStateFrame, + useRouterFrame, useRouterStateOwner, useRouterStateSelector, } from './routerStateContext' @@ -55,9 +56,9 @@ declare module '@tanstack/router-core' { */ export function Matches() { const router = useRouter() - const routerStateOwner = useRouterStateOwner() + const committedFrame = useRouterFrame() const [renderFrame, setRenderFrame] = React.useState() - const activeFrame = renderFrame ?? routerStateOwner?.frame + const activeFrame = renderFrame ?? committedFrame const rootRoute: AnyRoute = router.routesById[rootRouteId] const pendingElement = renderPending(router, rootRoute) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 3c0ca999c36..a4d2b12bf8c 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -8,21 +8,45 @@ import type { AnyRouter, RouterState } from '@tanstack/router-core' export type RouterRenderFrame = RouterState -type RouterStateContextValue = { +type FrameSubscriber = (frame: RouterRenderFrame) => void + +type RouterStateOwner = { router: AnyRouter + /** The frame React has committed and painted. */ frame: RouterRenderFrame + /** The frame the tree should render now: a staged successor, else committed. */ + getRenderFrame: () => RouterRenderFrame + /** + * Register for frame publications. Subscribers are notified from inside the + * Router's `startTransition`, so their updates keep the transition lane. + */ + subscribe: (subscriber: FrameSubscriber) => () => void begin: () => void stage: (frame: RouterRenderFrame) => RouterRenderFrame cancel: () => void commit: (frame: RouterRenderFrame) => boolean + /** Adopt head state as the committed frame when no navigation is staged. */ + publish: () => void } const defaultCompare = (a: unknown, b: unknown) => a === b -const routerStateContext = React.createContext< - RouterStateContextValue | undefined +/** + * Stable for the lifetime of the Router. Selector hooks read this and + * subscribe, so publishing a frame does not invalidate every consumer. + */ +const routerStateOwnerContext = React.createContext< + RouterStateOwner | undefined >(undefined) +/** + * Carries the exact frame a subtree is rendering. Only route presentation + * reads it, because those components re-render per navigation regardless. + */ +const routerFrameContext = React.createContext( + undefined, +) + export function RouterStateProvider({ router, children, @@ -30,68 +54,96 @@ export function RouterStateProvider({ router: AnyRouter children: React.ReactNode }) { - const staging = React.useRef(false) - const pendingFrame = React.useRef(undefined) - const [frame, setFrame] = React.useState(() => + const [committed, setCommitted] = React.useState(() => router.stores.__store.get(), ) - const publish = React.useCallback(() => { - if (staging.current || pendingFrame.current) { - return - } - const nextFrame = router.stores.__store.get() - if (nextFrame.status === 'pending') { - return + const ownerRef = React.useRef(undefined) + if (!ownerRef.current) { + const subscribers = new Set() + let staging = false + let pending: RouterRenderFrame | undefined + + const notify = (frame: RouterRenderFrame) => { + // Copy first: a subscriber may unsubscribe while we iterate. + for (const subscriber of Array.from(subscribers)) { + subscriber(frame) + } } - setFrame((previous) => - previous.frameId === nextFrame.frameId ? previous : nextFrame, - ) - }, [router]) - - const begin = React.useCallback(() => { - staging.current = true - }, []) - - const stage = React.useCallback((nextFrame: RouterRenderFrame) => { - staging.current = false - pendingFrame.current = nextFrame - return nextFrame - }, []) - - const cancel = React.useCallback(() => { - staging.current = false - pendingFrame.current = undefined - publish() - }, [publish]) - - const commit = React.useCallback((nextFrame: RouterRenderFrame) => { - if (pendingFrame.current?.frameId !== nextFrame.frameId) { - return false + + const owner: RouterStateOwner = { + router, + frame: committed, + getRenderFrame: () => pending ?? owner.frame, + subscribe: (subscriber) => { + subscribers.add(subscriber) + return () => { + subscribers.delete(subscriber) + } + }, + begin: () => { + staging = true + }, + stage: (nextFrame) => { + staging = false + pending = nextFrame + notify(nextFrame) + return nextFrame + }, + cancel: () => { + staging = false + pending = undefined + owner.publish() + }, + commit: (nextFrame) => { + if (pending?.frameId !== nextFrame.frameId) { + return false + } + pending = undefined + owner.frame = nextFrame + setCommitted(nextFrame) + return true + }, + // Publication outside a Router transition: adopt the head state as the + // committed frame, unless a navigation is being staged or is pending. + publish: () => { + if (staging || pending) { + return + } + const nextFrame = router.stores.__store.get() + if (nextFrame.status === 'pending') { + return + } + if (nextFrame.frameId === owner.frame.frameId) { + return + } + owner.frame = nextFrame + setCommitted(nextFrame) + notify(nextFrame) + }, } - pendingFrame.current = undefined - setFrame(nextFrame) - return true - }, []) + + ownerRef.current = owner + } + + const owner = ownerRef.current useLayoutEffect(() => { - const subscription = router.stores.__store.subscribe(() => publish()) - publish() + const subscription = router.stores.__store.subscribe(() => owner.publish()) + owner.publish() return () => subscription.unsubscribe() - }, [publish, router]) - - const value = React.useMemo( - () => ({ router, frame, begin, stage, cancel, commit }), - [router, frame, begin, stage, cancel, commit], - ) + }, [owner, router]) return ( - - {children} - + + + {children} + + ) } +/** Override the frame for a subtree that is rendering a staged successor. */ export function RouterStateFrame({ frame, children, @@ -99,17 +151,20 @@ export function RouterStateFrame({ frame: RouterRenderFrame children: React.ReactNode }) { - const owner = React.useContext(routerStateContext)! - const value = React.useMemo(() => ({ ...owner, frame }), [owner, frame]) return ( - + {children} - + ) } export function useRouterStateOwner() { - return React.useContext(routerStateContext) + return React.useContext(routerStateOwnerContext) +} + +/** The committed frame, for route presentation that must track every frame. */ +export function useRouterFrame() { + return React.useContext(routerFrameContext) } export function useRouterStateSelector( @@ -117,24 +172,39 @@ export function useRouterStateSelector( selector: (state: RouterState) => TSelected, compare: (a: TSelected, b: TSelected) => boolean = defaultCompare, ): TSelected { - const context = React.useContext(routerStateContext) - const contextState = context?.router === router ? context.frame : null - const selection = React.useRef<{ value: TSelected } | undefined>(undefined) - - if (contextState) { - const next = selector(contextState) - if (!selection.current || !compare(selection.current.value, next)) { - selection.current = { value: next } + const owner = React.useContext(routerStateOwnerContext) + + if (!owner || owner.router !== router) { + if (isServer ?? router.isServer) { + return selector(router.stores.__store.get()) } - return selection.current.value + // The frame option is fixed when the router is created, so this branch + // cannot change hook order during the lifetime of a mounted router. + // eslint-disable-next-line react-hooks/rules-of-hooks + return useStore(router.stores.__store, selector, compare) } - if (isServer ?? router.isServer) { - return selector(router.stores.__store.get()) - } + // eslint-disable-next-line react-hooks/rules-of-hooks + const [, forceRender] = React.useReducer((count: number) => count + 1, 0) + // eslint-disable-next-line react-hooks/rules-of-hooks + const selection = React.useRef(undefined as TSelected) + // eslint-disable-next-line react-hooks/rules-of-hooks + const latest = React.useRef({ selector, compare }) + latest.current = { selector, compare } + + selection.current = selector(owner.getRenderFrame()) - // The frame option is fixed when the router is created, so this branch - // cannot change hook order during the lifetime of a mounted router. // eslint-disable-next-line react-hooks/rules-of-hooks - return useStore(router.stores.__store, selector, compare) + React.useEffect(() => { + // Re-render only when this subscriber's own selection changed, which is + // what keeps selector-level render counts identical to the store path. + return owner.subscribe((frame) => { + const next = latest.current.selector(frame) + if (!latest.current.compare(selection.current, next)) { + forceRender() + } + }) + }, [owner]) + + return selection.current } diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx new file mode 100644 index 00000000000..bd79112b162 --- /dev/null +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + useRouterState, +} from '../src' + +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + +/** + * Two consumers of `useRouterState` with different selections: one that changes + * on every navigation, and one that does not. A fine-grained selector contract + * means only the first re-renders. + */ +function setup(experimental_concurrentRenderFrames: boolean) { + const renders = { changing: 0, stable: 0 } + + function ChangingConsumer() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + renders.changing++ + return
{pathname}
+ } + + function StableConsumer() { + // True from the first commit onwards, so it never changes value across + // these navigations even though the underlying router state does. + const hasMatches = useRouterState({ select: (s) => s.matches.length > 0 }) + renders.stable++ + return
{String(hasMatches)}
+ } + + const rootRoute = createRootRoute({ + component: function RootComponent() { + return ( + <> + Back + Posts + + + + + ) + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + experimental_concurrentRenderFrames, + }) + + render() + + return { renders, router } +} + +async function navigateToPosts() { + const link = await waitFor(() => screen.getByRole('link', { name: 'Posts' })) + fireEvent.click(link) + await waitFor(() => screen.getByRole('heading', { name: 'Posts Title' })) +} + +describe.each([ + ['store subscriptions', false], + ['concurrent render frames', true], +])('%s', (_name, frames) => { + test('a consumer re-renders only when its own selection changes', async () => { + const { renders } = setup(frames) + + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + expect(screen.getByTestId('pathname')).toHaveTextContent('/') + + const before = { ...renders } + await navigateToPosts() + + // The selection changed, so this consumer must have re-rendered. + expect(renders.changing).toBeGreaterThan(before.changing) + expect(screen.getByTestId('pathname')).toHaveTextContent('/posts') + + // The selection did not change, so this consumer must not have. + expect(renders.stable).toBe(before.stable) + }) +}) From e26610b5be141d82c1239a174bf0c50f3536a48b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:34:39 +0000 Subject: [PATCH 04/74] test: cover the render-frame invariants, and fix a hooks violation Lint fixes - `Boolean(router.ssr) && !useHydrated()` called a hook behind a short-circuit whose condition is not static, so hook order could change between renders. Extracts useFrameRootBoundary, which calls useHydrated unconditionally inside a branch that depends only on the option. The default path no longer subscribes to it at all, which an earlier attempt at this fix changed and which produced unhandled concurrent rendering errors in the hydration suite. - Adds the missing useLayoutEffect dependency in Matches. - Moves an eslint-disable onto the line of the call it covers. test:eslint now reports 0 errors for both packages, matching main exactly (26 and 99 warnings). Tests router-core, tests/render-frames.test.ts: - the initial state carries a frame identity - every assembled state gets a new, increasing identity - a frame is a complete, self-consistent snapshot - matchRoute resolves against a presented frame, not the head location react-router, tests/concurrent-render-frames.test.tsx, each run against both the store path and the frame path: - a consumer re-renders only when its own selection changes - a consumer mounted during a pending navigation reads the committed route. The store path reads the route being prepared while the previous one is still on screen; the frame path reads what is visible. The test asserts both, pinning the difference this option removes. - a superseded navigation does not commit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Match.tsx | 17 +- packages/react-router/src/Matches.tsx | 11 +- .../react-router/src/routerStateContext.tsx | 22 ++ .../tests/concurrent-render-frames.test.tsx | 264 +++++++++++++----- .../router-core/tests/render-frames.test.ts | 85 ++++++ 5 files changed, 319 insertions(+), 80 deletions(-) create mode 100644 packages/router-core/tests/render-frames.test.ts diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 5e65b7abeb9..a30146ddd60 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -11,8 +11,11 @@ import { matchContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' -import { ClientOnly, useHydrated } from './ClientOnly' -import { useRouterStateSelector } from './routerStateContext' +import { ClientOnly } from './ClientOnly' +import { + useFrameRootBoundary, + useRouterStateSelector, +} from './routerStateContext' import { nonRouteComponentContext, wrapInNonRouteComponentContext, @@ -123,18 +126,14 @@ function MatchView({ const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only' const _isServer = isServer ?? router.isServer - const isHydrating = Boolean(router.ssr) && !useHydrated() // Once hydrated, a concurrent frame must suspend and acknowledge as one // unit. During SSR and hydration, retain the route boundaries so the server // can stream its shell and the client hydrates the same boundary tree. - const useFrameRootBoundary = - router.options.experimental_concurrentRenderFrames && - !_isServer && - !isHydrating + const frameRootBoundary = useFrameRootBoundary(router, _isServer) // A root component may render the document itself. Only place its Suspense // boundary in pure CSR, inside an explicit shell, or when explicitly opted in. const ResolvedSuspenseBoundary = - !useFrameRootBoundary && + !frameRootBoundary && canWrapInSuspense(router, route, match.ssr) && (route.options.wrapInSuspense ?? pendingElement ?? @@ -311,8 +310,8 @@ export const Outlet = React.memo(function OutletImpl() { if (router.options.experimental_concurrentRenderFrames) { // The option is fixed for the mounted router, so this branch cannot change // hook order during the component's lifetime. - // eslint-disable-next-line react-hooks/rules-of-hooks ;[parentGlobalNotFound, parentNotFoundError, childRouteId] = + // eslint-disable-next-line react-hooks/rules-of-hooks useRouterStateSelector( router, (state): ConcurrentOutletMatchSelection => { diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 24ec41c3670..2d7279d2357 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -12,9 +12,9 @@ import { Transitioner, settleOwner } from './Transitioner' import { matchContext } from './matchContext' import { Match, renderPending } from './Match' import { SafeFragment } from './SafeFragment' -import { useHydrated } from './ClientOnly' import { RouterStateFrame, + useFrameRootBoundary, useRouterFrame, useRouterStateOwner, useRouterStateSelector, @@ -64,16 +64,12 @@ export function Matches() { const pendingElement = renderPending(router, rootRoute) const _isServer = isServer ?? router.isServer - const isHydrating = Boolean(router.ssr) && !useHydrated() // SSR and hydration keep upstream's route-level boundaries for streaming // and an identical hydration tree. Afterwards, the frame path consolidates // suspension at this root so one complete frame is acknowledged atomically. - const useFrameRootBoundary = - router.options.experimental_concurrentRenderFrames && - !_isServer && - !isHydrating + const frameRootBoundary = useFrameRootBoundary(router, _isServer) const ResolvedSuspense = - _isServer || (router.ssr && !useFrameRootBoundary) + _isServer || (router.ssr && !frameRootBoundary) ? SafeFragment : React.Suspense @@ -161,6 +157,7 @@ function MatchesInner({ activeFrame, matches, renderFrame, + router.options.experimental_concurrentRenderFrames, routerStateOwner, setRenderFrame, ]) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index a4d2b12bf8c..dad25f9c585 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -4,6 +4,7 @@ import * as React from 'react' import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useLayoutEffect } from './utils' +import { useHydrated } from './ClientOnly' import type { AnyRouter, RouterState } from '@tanstack/router-core' export type RouterRenderFrame = RouterState @@ -208,3 +209,24 @@ export function useRouterStateSelector( return selection.current } + +/** + * Whether this render should consolidate route suspension at the frame root. + * + * Only the frame path asks, so `useHydrated` is never subscribed to on the + * default path. Within the frame branch the hook is unconditional, and the + * branch itself depends only on the option, which is fixed when the router is + * created. + */ +export function useFrameRootBoundary( + router: AnyRouter, + isServerRender: boolean, +): boolean { + if (!router.options.experimental_concurrentRenderFrames) { + return false + } + // eslint-disable-next-line react-hooks/rules-of-hooks + const hydrated = useHydrated() + const isHydrating = Boolean(router.ssr) && !hydrated + return !isServerRender && !isHydrating +} diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index bd79112b162..5ceae8d0637 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -1,11 +1,13 @@ import { afterEach, describe, expect, test } from 'vitest' import { + act, cleanup, fireEvent, render, screen, waitFor, } from '@testing-library/react' +import * as React from 'react' import { Link, Outlet, @@ -13,6 +15,7 @@ import { createRootRoute, createRoute, createRouter, + useLocation, useRouterState, } from '../src' @@ -21,31 +24,44 @@ afterEach(() => { cleanup() }) -/** - * Two consumers of `useRouterState` with different selections: one that changes - * on every navigation, and one that does not. A fine-grained selector contract - * means only the first re-renders. - */ -function setup(experimental_concurrentRenderFrames: boolean) { - const renders = { changing: 0, stable: 0 } - - function ChangingConsumer() { - const pathname = useRouterState({ select: (s) => s.location.pathname }) - renders.changing++ - return
{pathname}
- } - - function StableConsumer() { - // True from the first commit onwards, so it never changes value across - // these navigations even though the underlying router state does. - const hasMatches = useRouterState({ select: (s) => s.matches.length > 0 }) - renders.stable++ - return
{String(hasMatches)}
- } - - const rootRoute = createRootRoute({ - component: function RootComponent() { - return ( +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +const MODES: Array<[string, boolean]> = [ + ['store subscriptions', false], + ['concurrent render frames', true], +] + +describe.each(MODES)('%s', (_name, experimental_concurrentRenderFrames) => { + /** + * Two consumers with different selections: one that changes on every + * navigation and one that does not. A fine-grained selector contract means + * only the first re-renders. + */ + test('a consumer re-renders only when its own selection changes', async () => { + const renders = { changing: 0, stable: 0 } + + function ChangingConsumer() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + renders.changing++ + return
{pathname}
+ } + + function StableConsumer() { + // True from the first commit onwards, so it never changes value across + // these navigations even though the underlying router state does. + const hasMatches = useRouterState({ select: (s) => s.matches.length > 0 }) + renders.stable++ + return
{String(hasMatches)}
+ } + + const rootRoute = createRootRoute({ + component: () => ( <> Back Posts @@ -53,50 +69,34 @@ function setup(experimental_concurrentRenderFrames: boolean) { - ) - }, - }) - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>

Index Title

, - }) - - const postsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/posts', - component: () =>

Posts Title

, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postsRoute]), - experimental_concurrentRenderFrames, - }) - - render() - - return { renders, router } -} - -async function navigateToPosts() { - const link = await waitFor(() => screen.getByRole('link', { name: 'Posts' })) - fireEvent.click(link) - await waitFor(() => screen.getByRole('heading', { name: 'Posts Title' })) -} - -describe.each([ - ['store subscriptions', false], - ['concurrent render frames', true], -])('%s', (_name, frames) => { - test('a consumer re-renders only when its own selection changes', async () => { - const { renders } = setup(frames) + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts Title

, + }) + + render( + , + ) await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) expect(screen.getByTestId('pathname')).toHaveTextContent('/') const before = { ...renders } - await navigateToPosts() + fireEvent.click(screen.getByRole('link', { name: 'Posts' })) + await waitFor(() => screen.getByRole('heading', { name: 'Posts Title' })) // The selection changed, so this consumer must have re-rendered. expect(renders.changing).toBeGreaterThan(before.changing) @@ -105,4 +105,140 @@ describe.each([ // The selection did not change, so this consumer must not have. expect(renders.stable).toBe(before.stable) }) + + /** + * The reason the consistency boundary lives in the Router adapter: a reader + * mounted by an urgent update while a navigation is in flight must observe + * the route that is actually on screen, not the one being prepared. + */ + test('a consumer mounted during a pending navigation reads the committed route', async () => { + const gate = deferred() + let showLateConsumer!: (show: boolean) => void + + function LateConsumer() { + const pathname = useLocation({ select: (l) => l.pathname }) + return
{pathname}
+ } + + const rootRoute = createRootRoute({ + component: function RootComponent() { + const [show, setShow] = React.useState(false) + showLateConsumer = setShow + return ( + <> + Slow + {show ? : null} + + + ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + component: () =>

Slow Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + // Publish a pending frame immediately, so the reader below really is + // mounted while a staged successor exists. + defaultPendingMs: 0, + experimental_concurrentRenderFrames, + }) + render() + + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + // Begin a navigation that cannot finish yet. + fireEvent.click(screen.getByRole('link', { name: 'Slow' })) + // The head state has moved on while the previous route is still on screen: + // this is the window in which a new reader could observe the wrong route. + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + expect(router.stores.location.get().pathname).toBe('/slow') + expect( + screen.getByRole('heading', { name: 'Index Title' }), + ).toBeInTheDocument() + + // Mount a new reader urgently, while that navigation is still pending. + act(() => showLateConsumer(true)) + + // The frame path agrees with what is visible. The store path does not: + // `useLocation` reads the mutable head atom, so a reader mounted here sees + // the route being prepared while the previous one is still on screen. + // Asserting both pins the difference this option is meant to remove. + expect(screen.getByTestId('late').textContent).toBe( + experimental_concurrentRenderFrames ? '/' : '/slow', + ) + + gate.resolve() + await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) + await waitFor(() => + expect(screen.getByTestId('late')).toHaveTextContent('/slow'), + ) + }) + + /** A navigation replaced before it resolves must never become visible. */ + test('a superseded navigation does not commit', async () => { + const first = deferred() + const second = deferred() + + const rootRoute = createRootRoute({ + component: () => ( + <> + First + Second + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + loader: () => first.promise, + component: () =>

First Title

, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: () => second.promise, + component: () =>

Second Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + experimental_concurrentRenderFrames, + }) + render() + + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + fireEvent.click(screen.getByRole('link', { name: 'First' })) + fireEvent.click(screen.getByRole('link', { name: 'Second' })) + + // Resolve the superseded navigation last, so it would win on ordering + // alone if the newer frame were not gating the commit. + second.resolve() + await waitFor(() => screen.getByRole('heading', { name: 'Second Title' })) + first.resolve() + + await waitFor(() => + expect( + screen.getByRole('heading', { name: 'Second Title' }), + ).toBeInTheDocument(), + ) + expect(screen.queryByRole('heading', { name: 'First Title' })).toBeNull() + expect(router.state.location.pathname).toBe('/second') + }) }) diff --git a/packages/router-core/tests/render-frames.test.ts b/packages/router-core/tests/render-frames.test.ts new file mode 100644 index 00000000000..1d2a18829a9 --- /dev/null +++ b/packages/router-core/tests/render-frames.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +function createRouter() { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + const postRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + }) + + return createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute, postRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) +} + +describe('render frames', () => { + test('the initial router state carries a frame identity', () => { + const router = createRouter() + expect(typeof router.state.frameId).toBe('number') + }) + + test('every assembled state gets a new, increasing frame identity', async () => { + const router = createRouter() + + const first = router.stores.__store.get().frameId + await router.navigate({ to: '/about' }) + const second = router.stores.__store.get().frameId + await router.navigate({ to: '/posts/123' }) + const third = router.stores.__store.get().frameId + + expect(second).toBeGreaterThan(first) + expect(third).toBeGreaterThan(second) + }) + + test('a frame is a complete, self-consistent snapshot', async () => { + const router = createRouter() + await router.navigate({ to: '/posts/123' }) + + const frame = router.stores.__store.get() + + // Everything a consumer can read comes from the one snapshot, so a frame + // can never mix slices from different navigations. + expect(frame.location.pathname).toBe('/posts/123') + expect(frame.matches.map((match) => match.routeId)).toEqual([ + '__root__', + '/posts/$postId', + ]) + expect(frame.status).toBe('idle') + expect(frame.isLoading).toBe(false) + }) + + test('matchRoute matches against a presented frame, not the head location', async () => { + const router = createRouter() + await router.navigate({ to: '/about' }) + const presented = router.stores.__store.get() + + await router.navigate({ to: '/posts/123' }) + + // The head has moved on, but a render presenting the older frame must + // still resolve links and active state against what it is showing. + expect(router.matchRoute({ to: '/posts/$postId' } as any)).toBeTruthy() + expect( + router.matchRoute({ to: '/about' } as any, { _state: presented } as any), + ).toBeTruthy() + expect( + router.matchRoute( + { to: '/posts/$postId' } as any, + { + _state: presented, + } as any, + ), + ).toBe(false) + }) +}) From 4dfe0a7641e2f7710136a5d233f525aa3686c5b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:59:44 +0000 Subject: [PATCH 05/74] fix: stop the frame protocol from changing shared StartTransitionFn Requiring transition callbacks to return the assembled RouterState was a breaking change for every framework, not just React. router.startTransition is public API on RouterCore, so any caller passing a side-effecting callback stopped type-checking. solid-router's public-presentation-lane-contract test is exactly such a caller, and failed with "Type 'number' is not assignable to type 'RouterState'". Widening the return to `RouterState | void` does not help: TypeScript only allows an arbitrary return type when the target return type is exactly `void`, not a union containing it. Reverts the signature and the load-client publication sites to upstream. The React adapter now reads router.stores.__store.get() itself, directly after fn() has run its batched writes, which yields the same frame. router-core's diff is now additive only: frameId on RouterState and in createRouterStores, a widened _rendered acknowledgement, and matchRoute's presented _state. Verified across all four packages -- router-core, react-router, solid-router, vue-router -- for test:eslint, test:unit, test:types, test:build and build: 0 lint errors, all suites passing, no type errors. React behaviour unchanged: the POC still measures one view transition per navigation with a real shared-element morph, and the view-transitions e2e suite passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Transitioner.tsx | 6 ++++-- packages/router-core/src/load-client.ts | 8 ++------ packages/router-core/src/router.ts | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index ed03f04b0e8..1d23929bb5c 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -43,8 +43,10 @@ export function Transitioner({ React.startTransition(() => { routerStateOwner?.begin() try { - const headFrame = fn() - const frame = routerStateOwner?.stage(headFrame) + fn() + // Read the aggregate state after the batched writes, so the staged + // frame is exactly what this publication assembled. + const frame = routerStateOwner?.stage(router.stores.__store.get()) if (frame) { acknowledgement[0 /* offered */] = frame.frameId setRenderFrame(frame) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 45669c78342..1994eb2c371 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1528,10 +1528,7 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { })) offered[index]!.status = 'pending' const ack = (session[4 /* ack */] = router - .startTransition(() => { - router.stores.setMatches(offered) - return router.stores.__store.get() - }, offered) + .startTransition(() => router.stores.setMatches(offered), offered) .then((rendered) => { if ( rendered && @@ -1875,13 +1872,12 @@ async function runClientTransaction( finishPending(router, tx) commitMatches(router, tx, matches, resolvedPrefix) if (router._tx !== tx) { - return router.stores.__store.get() + return } router.emit({ type: 'onLoad', ...changeInfo }) if (router._tx === tx) { router.emit({ type: 'onBeforeRouteMount', ...changeInfo }) } - return router.stores.__store.get() } const rendered = await router.startTransition(commit, matches) if (process.env.NODE_ENV !== 'production' && tx[6 /* refresh */]) { diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 0e758243785..1d3c2439621 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -794,7 +794,7 @@ export type CommitLocationFn = ({ }: ParsedLocation & CommitLocationOptions) => Promise export type StartTransitionFn = ( - fn: () => RouterState, + fn: () => void, expected: Array, ) => Promise From 11d26a733c98815d505ac7a665f85448d0648f05 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:49:05 +0000 Subject: [PATCH 06/74] fix(react-router): scope frame reads so selectors and correctness both hold The subscription binding restored selector-level render counts but reintroduced tearing: a single global read of `pending ?? committed` ignores where a consumer sits, so a reader mounted by an unrelated urgent update during a suspended navigation saw the route being prepared rather than the one on screen. Reading the frame from context instead fixes that but invalidates every consumer, which is the trade the previous revision was made to avoid. Measured: the guard passes, and the untouched consumer goes from 3 renders to 6. The two only conflicted because one global answer was serving two different questions. The answer is positional, so each position now has its own scope: - a root scope, for readers outside the route tree, which advances only when a navigation commits; - a presentation scope, provided by Matches for the route subtree, which advances when a frame is staged. Scope identity is stable for the router's lifetime, so putting a scope in Context invalidates nobody; consumers read `scope.frame` and subscribe to that scope for updates. Position decides which frame they see and when they update. Adds the reader-outside-the-route-tree guard, ported from the app that found this. It fails against the previous revision and passes here. Verified: react-router 1044, router-core 1617, solid-router 887, vue-router 138+3, all with 0 lint errors and no type errors; both e2e suites; the POC still measures one view transition per navigation with a real shared-element morph; and selector-call counts on the frame path stay at or below the store path (7->3, 5->3, 3->2, 3->1). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 7 +- .../react-router/src/routerStateContext.tsx | 159 ++++++++++-------- .../tests/concurrent-render-frames.test.tsx | 93 ++++++++++ 3 files changed, 183 insertions(+), 76 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 2d7279d2357..8187e50ebba 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -15,7 +15,6 @@ import { SafeFragment } from './SafeFragment' import { RouterStateFrame, useFrameRootBoundary, - useRouterFrame, useRouterStateOwner, useRouterStateSelector, } from './routerStateContext' @@ -56,9 +55,9 @@ declare module '@tanstack/router-core' { */ export function Matches() { const router = useRouter() - const committedFrame = useRouterFrame() + const routerStateOwner = useRouterStateOwner() const [renderFrame, setRenderFrame] = React.useState() - const activeFrame = renderFrame ?? committedFrame + const activeFrame = renderFrame ?? routerStateOwner?.frame const rootRoute: AnyRoute = router.routesById[rootRouteId] const pendingElement = renderPending(router, rootRoute) @@ -88,7 +87,7 @@ export function Matches() { )} {activeFrame ? ( - + type FrameSubscriber = (frame: RouterRenderFrame) => void -type RouterStateOwner = { +/** + * A position in the tree, with the frame that position should render. + * + * Identity is stable for the router's lifetime, so putting a scope in Context + * never invalidates its consumers; they subscribe for updates instead. Which + * scope a consumer reads is decided by where it sits: + * + * - outside the route tree it reads the committed frame, and only advances + * when a navigation commits; + * - inside the route tree it reads the frame that subtree is rendering, which + * is a staged successor while a navigation is in flight. + * + * That is what keeps a reader mounted by an unrelated urgent update on the + * route the user can actually see. + */ +type RouterStateScope = { router: AnyRouter - /** The frame React has committed and painted. */ frame: RouterRenderFrame - /** The frame the tree should render now: a staged successor, else committed. */ - getRenderFrame: () => RouterRenderFrame - /** - * Register for frame publications. Subscribers are notified from inside the - * Router's `startTransition`, so their updates keep the transition lane. - */ subscribe: (subscriber: FrameSubscriber) => () => void + notify: () => void +} + +type RouterStateOwner = { + router: AnyRouter + /** The committed scope, for readers outside the route tree. */ + root: RouterStateScope + /** The presentation scope, for the route subtree. */ + route: RouterStateScope + /** The committed frame. */ + frame: RouterRenderFrame begin: () => void stage: (frame: RouterRenderFrame) => RouterRenderFrame cancel: () => void commit: (frame: RouterRenderFrame) => boolean - /** Adopt head state as the committed frame when no navigation is staged. */ publish: () => void } const defaultCompare = (a: unknown, b: unknown) => a === b -/** - * Stable for the lifetime of the Router. Selector hooks read this and - * subscribe, so publishing a frame does not invalidate every consumer. - */ +function createScope( + router: AnyRouter, + frame: RouterRenderFrame, +): RouterStateScope { + const subscribers = new Set() + const scope: RouterStateScope = { + router, + frame, + subscribe: (subscriber) => { + subscribers.add(subscriber) + return () => { + subscribers.delete(subscriber) + } + }, + notify: () => { + // Copy first: a subscriber may unsubscribe while we iterate. + for (const subscriber of Array.from(subscribers)) { + subscriber(scope.frame) + } + }, + } + return scope +} + +const routerStateScopeContext = React.createContext< + RouterStateScope | undefined +>(undefined) + const routerStateOwnerContext = React.createContext< RouterStateOwner | undefined >(undefined) -/** - * Carries the exact frame a subtree is rendering. Only route presentation - * reads it, because those components re-render per navigation regardless. - */ -const routerFrameContext = React.createContext( - undefined, -) - export function RouterStateProvider({ router, children, @@ -55,32 +89,20 @@ export function RouterStateProvider({ router: AnyRouter children: React.ReactNode }) { - const [committed, setCommitted] = React.useState(() => - router.stores.__store.get(), - ) - const ownerRef = React.useRef(undefined) if (!ownerRef.current) { - const subscribers = new Set() + const initial = router.stores.__store.get() + const root = createScope(router, initial) + const route = createScope(router, initial) let staging = false let pending: RouterRenderFrame | undefined - const notify = (frame: RouterRenderFrame) => { - // Copy first: a subscriber may unsubscribe while we iterate. - for (const subscriber of Array.from(subscribers)) { - subscriber(frame) - } - } - const owner: RouterStateOwner = { router, - frame: committed, - getRenderFrame: () => pending ?? owner.frame, - subscribe: (subscriber) => { - subscribers.add(subscriber) - return () => { - subscribers.delete(subscriber) - } + root, + route, + get frame() { + return root.frame }, begin: () => { staging = true @@ -88,12 +110,17 @@ export function RouterStateProvider({ stage: (nextFrame) => { staging = false pending = nextFrame - notify(nextFrame) + // Only the route subtree presents a staged frame. Readers outside it + // stay on the committed one until this navigation commits. + route.frame = nextFrame + route.notify() return nextFrame }, cancel: () => { staging = false pending = undefined + route.frame = root.frame + route.notify() owner.publish() }, commit: (nextFrame) => { @@ -101,12 +128,10 @@ export function RouterStateProvider({ return false } pending = undefined - owner.frame = nextFrame - setCommitted(nextFrame) + root.frame = nextFrame + root.notify() return true }, - // Publication outside a Router transition: adopt the head state as the - // committed frame, unless a navigation is being staged or is pending. publish: () => { if (staging || pending) { return @@ -115,15 +140,15 @@ export function RouterStateProvider({ if (nextFrame.status === 'pending') { return } - if (nextFrame.frameId === owner.frame.frameId) { + if (nextFrame.frameId === root.frame.frameId) { return } - owner.frame = nextFrame - setCommitted(nextFrame) - notify(nextFrame) + root.frame = nextFrame + route.frame = nextFrame + root.notify() + route.notify() }, } - ownerRef.current = owner } @@ -137,25 +162,20 @@ export function RouterStateProvider({ return ( - + {children} - + ) } -/** Override the frame for a subtree that is rendering a staged successor. */ -export function RouterStateFrame({ - frame, - children, -}: { - frame: RouterRenderFrame - children: React.ReactNode -}) { +/** Present the route subtree from the presentation scope. */ +export function RouterStateFrame({ children }: { children: React.ReactNode }) { + const owner = React.useContext(routerStateOwnerContext) return ( - + {children} - + ) } @@ -163,19 +183,14 @@ export function useRouterStateOwner() { return React.useContext(routerStateOwnerContext) } -/** The committed frame, for route presentation that must track every frame. */ -export function useRouterFrame() { - return React.useContext(routerFrameContext) -} - export function useRouterStateSelector( router: AnyRouter, selector: (state: RouterState) => TSelected, compare: (a: TSelected, b: TSelected) => boolean = defaultCompare, ): TSelected { - const owner = React.useContext(routerStateOwnerContext) + const scope = React.useContext(routerStateScopeContext) - if (!owner || owner.router !== router) { + if (!scope || scope.router !== router) { if (isServer ?? router.isServer) { return selector(router.stores.__store.get()) } @@ -193,19 +208,19 @@ export function useRouterStateSelector( const latest = React.useRef({ selector, compare }) latest.current = { selector, compare } - selection.current = selector(owner.getRenderFrame()) + selection.current = selector(scope.frame) // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { // Re-render only when this subscriber's own selection changed, which is // what keeps selector-level render counts identical to the store path. - return owner.subscribe((frame) => { + return scope.subscribe((frame) => { const next = latest.current.selector(frame) if (!latest.current.compare(selection.current, next)) { forceRender() } }) - }, [owner]) + }, [scope]) return selection.current } diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 5ceae8d0637..6a056fa4993 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -10,7 +10,9 @@ import { import * as React from 'react' import { Link, + Matches, Outlet, + RouterContextProvider, RouterProvider, createRootRoute, createRoute, @@ -242,3 +244,94 @@ describe.each(MODES)('%s', (_name, experimental_concurrentRenderFrames) => { expect(router.state.location.pathname).toBe('/second') }) }) + +describe('concurrent render frames', () => { + /** + * A reader mounted outside the route tree by an unrelated urgent update must + * agree with what is on screen. This is the failure that motivates the whole + * design: a menu, toast, or modal opened while a route is suspending would + * otherwise render against a route the user cannot see. + */ + test('a reader outside the route tree does not read ahead of the visible route', async () => { + let releaseNext: () => void = () => {} + let nextReady = false + const nextGate = new Promise((resolve) => { + releaseNext = () => { + nextReady = true + resolve() + } + }) + + function NextPage() { + if (!nextReady) { + throw nextGate + } + return

Next Title

+ } + + function PresentedPath() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + return
{pathname}
+ } + + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: NextPage, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + experimental_concurrentRenderFrames: true, + }) + + function TestApp() { + const [show, setShow] = React.useState(false) + return ( + + + {show ? : null} + + + ) + } + + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + // The imperative head advances while the route it names suspends. + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/next' }) + }) + await waitFor(() => + expect(router.stores.location.get().pathname).toBe('/next'), + ) + expect(screen.getByRole('heading', { name: 'Index Title' })).toBeVisible() + + // An urgent update, unrelated to routing, mounts a reader. + fireEvent.click(screen.getByRole('button', { name: 'Show presented path' })) + + expect(screen.getByTestId('presented').textContent).toBe('/') + + await act(async () => { + releaseNext() + await nextGate + }) + await navigation + await waitFor(() => screen.getByRole('heading', { name: 'Next Title' })) + await waitFor(() => + expect(screen.getByTestId('presented').textContent).toBe('/next'), + ) + }) +}) From a1bc7d4432d4d4144b83e41c56d8858e8460644b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 20:28:20 +0000 Subject: [PATCH 07/74] fix(react-router): compare notifications against the committed selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useRouterStateSelector wrote its selection to a ref during render and used that same ref as the comparison basis for store notifications. A render can be discarded — suspended, interrupted, or superseded — so the ref could hold a value that never reached the screen. If a later frame then selected that same value, the notification compared equal and skipped the re-render, leaving a consumer that does not otherwise re-render (a memoized one, for instance) stuck showing the older committed value. Keeps the in-progress selection separate from the committed one, records the committed value in a layout effect, and compares notifications against that. The committed value is boxed so a committed `undefined` is distinguishable from having committed nothing yet. This is the same class of bug the option exists to remove: a render-phase write being treated as if it were presented state. Reported by Codex review on mixcloud/router-transitions-poc#2. I could not build a failing regression test for it. Two attempts — a consumer outside the route tree, and a memoized consumer inside it — passed against the unfixed code, because act() flushing in jsdom commits the staged render rather than discarding it. The fix is applied on the strength of the mechanism rather than a reproduction, and the existing suites cover it for regressions. Verified: router-core 1617, react-router 1044, solid-router 887, vue-router 138+3, all with 0 lint errors and no type errors; the POC still measures one view transition per navigation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 242156004e8..04b92e30f73 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -202,27 +202,45 @@ export function useRouterStateSelector( // eslint-disable-next-line react-hooks/rules-of-hooks const [, forceRender] = React.useReducer((count: number) => count + 1, 0) + // The selection for the render currently executing. A render can be + // discarded — suspended, interrupted, or superseded — so this is + // work in progress, not necessarily what anyone can see. // eslint-disable-next-line react-hooks/rules-of-hooks - const selection = React.useRef(undefined as TSelected) + const rendered = React.useRef(undefined as TSelected) + // The selection that actually reached the screen. Boxed so that a committed + // `undefined` is distinguishable from having committed nothing yet. + // eslint-disable-next-line react-hooks/rules-of-hooks + const committed = React.useRef<{ value: TSelected } | undefined>(undefined) // eslint-disable-next-line react-hooks/rules-of-hooks const latest = React.useRef({ selector, compare }) latest.current = { selector, compare } - selection.current = selector(scope.frame) + rendered.current = selector(scope.frame) + + // eslint-disable-next-line react-hooks/rules-of-hooks + useLayoutEffect(() => { + committed.current = { value: rendered.current } + }) // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { // Re-render only when this subscriber's own selection changed, which is // what keeps selector-level render counts identical to the store path. + // + // Compare against the committed selection, never the in-progress one: a + // discarded render leaves a value here that was never presented, and + // comparing against it would skip the re-render that should have shown + // the frame, leaving this consumer stuck on what is on screen. return scope.subscribe((frame) => { const next = latest.current.selector(frame) - if (!latest.current.compare(selection.current, next)) { + const onScreen = committed.current + if (!onScreen || !latest.current.compare(onScreen.value, next)) { forceRender() } }) }, [scope]) - return selection.current + return rendered.current } /** From 3f4f8f687c01caa984c3fccd95a2a9625005dcb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 20:45:51 +0000 Subject: [PATCH 08/74] fix(react-router): commit selector config, and keep navigation progress live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Codex review on mixcloud/router-transitions-poc#2. The selector and comparator were still written to a ref during render, so the previous committed-selection fix was incomplete. A discarded render left behind a selector that never presented anything, and a later notification could evaluate the new frame with that selector while comparing against a value produced by the committed one. Where those compared equal, the re-render was skipped and the consumer went stale. The committed value, selector and comparator are now recorded together in the layout effect and used together by notifications, because comparing a value from one selector against a value from another is meaningless. Navigation progress was not reaching consumers outside the route tree. That scope deliberately stays on the committed route, and it was therefore dropping status entirely, so a global loading indicator never saw a navigation start. Confirmed as a regression against the store path: the new test passes with the option off and failed with it on. The committed scope's status and isLoading now track the head while its location and matches stay committed — progress is not route content, so this cannot surface a route the user cannot see, and the reader-outside-the-route-tree guard still passes. This narrows invariant 2 of the RFC, which said a render cannot combine location, status and matches from different publications. Status is deliberately live; location and matches are not. Verified: router-core 1617, react-router 1045, solid-router 887, vue-router 138+3, 0 lint errors, no type errors; view-transitions e2e 3/3; the POC still measures one view transition per navigation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 62 +++++++++++++----- .../tests/concurrent-render-frames.test.tsx | 63 +++++++++++++++++++ 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 04b92e30f73..2da1fde3f84 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -97,6 +97,26 @@ export function RouterStateProvider({ let staging = false let pending: RouterRenderFrame | undefined + // Navigation progress is not route content. The committed scope stays on + // the route that is visible, but its status tracks the head, so progress + // UI outside the route tree — a global loading bar, say — still sees a + // navigation start and finish. Location and matches are untouched, so this + // cannot surface a route the user cannot see. + const syncProgress = (head: RouterRenderFrame) => { + if ( + root.frame.status === head.status && + root.frame.isLoading === head.isLoading + ) { + return + } + root.frame = { + ...root.frame, + status: head.status, + isLoading: head.isLoading, + } + root.notify() + } + const owner: RouterStateOwner = { router, root, @@ -133,11 +153,14 @@ export function RouterStateProvider({ return true }, publish: () => { + const head = router.stores.__store.get() if (staging || pending) { + syncProgress(head) return } - const nextFrame = router.stores.__store.get() + const nextFrame = head if (nextFrame.status === 'pending') { + syncProgress(head) return } if (nextFrame.frameId === root.frame.frameId) { @@ -207,19 +230,25 @@ export function useRouterStateSelector( // work in progress, not necessarily what anyone can see. // eslint-disable-next-line react-hooks/rules-of-hooks const rendered = React.useRef(undefined as TSelected) - // The selection that actually reached the screen. Boxed so that a committed - // `undefined` is distinguishable from having committed nothing yet. + // What actually reached the screen: the selection, and the selector and + // comparator that produced it. Kept together, because comparing a value from + // one selector against a value from another is meaningless. Boxed so that a + // committed `undefined` is distinguishable from having committed nothing yet. // eslint-disable-next-line react-hooks/rules-of-hooks - const committed = React.useRef<{ value: TSelected } | undefined>(undefined) - // eslint-disable-next-line react-hooks/rules-of-hooks - const latest = React.useRef({ selector, compare }) - latest.current = { selector, compare } + const committed = React.useRef< + | { + value: TSelected + selector: (state: RouterState) => TSelected + compare: (a: TSelected, b: TSelected) => boolean + } + | undefined + >(undefined) rendered.current = selector(scope.frame) // eslint-disable-next-line react-hooks/rules-of-hooks useLayoutEffect(() => { - committed.current = { value: rendered.current } + committed.current = { value: rendered.current, selector, compare } }) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -227,14 +256,19 @@ export function useRouterStateSelector( // Re-render only when this subscriber's own selection changed, which is // what keeps selector-level render counts identical to the store path. // - // Compare against the committed selection, never the in-progress one: a - // discarded render leaves a value here that was never presented, and - // comparing against it would skip the re-render that should have shown - // the frame, leaving this consumer stuck on what is on screen. + // Everything here comes from the committed render, never the one in + // progress: a discarded render leaves behind a selection, and a selector, + // that were never presented. Comparing against either would skip the + // re-render that should have shown the frame, leaving this consumer stuck + // on what is on screen. return scope.subscribe((frame) => { - const next = latest.current.selector(frame) const onScreen = committed.current - if (!onScreen || !latest.current.compare(onScreen.value, next)) { + if (!onScreen) { + forceRender() + return + } + const next = onScreen.selector(frame) + if (!onScreen.compare(onScreen.value, next)) { forceRender() } }) diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 6a056fa4993..01c5a2998e4 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -334,4 +334,67 @@ describe('concurrent render frames', () => { expect(screen.getByTestId('presented').textContent).toBe('/next'), ) }) + + /** + * Navigation progress is not route content: a global indicator sitting + * outside the route tree must still see a navigation start and finish, even + * though that scope deliberately stays on the committed route. + */ + test('navigation progress reaches a consumer outside the route tree', async () => { + const gate = deferred() + + function Progress() { + const isLoading = useRouterState({ select: (s) => s.isLoading }) + return
{String(isLoading)}
+ } + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + component: () =>

Slow Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames: true, + }) + + render( + + + + , + ) + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false'), + ) + + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/slow' }) + }) + + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('true'), + ) + + await act(async () => { + gate.resolve() + await gate.promise + }) + await navigation + await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false'), + ) + }) }) From 47c56463ffce286e7006d6c1d4678b37139b943f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 21:23:49 +0000 Subject: [PATCH 09/74] fix(react-router): keep a staged frame inside the render presenting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scope held the frame it should present in a single mutable field. During a staged navigation that field was the staged frame, so any render reading the scope saw it — including an urgent re-render of a component inside the route still on screen. A keystroke, a timer, or a local toggle in the visible route would read the route being prepared. Split the scope into two slots, committed and staged, and move the choice between them into React state on each consumer: a consumer records which publication its own render is presenting, and React versions that state per tree. A work-in-progress render can accept the staged publication without the committed tree following it there. Consumers still accept a publication only when their own selection changed, so selector-level render counts are unchanged. Navigation progress now reaches the route subtree as well. It was overlaid only onto the committed scope, so a spinner rendered by the route being left never saw the navigation it was waiting on. Location and matches are still untouched, so this cannot surface a route the user cannot see. Both are covered by new regression tests, which fail without this change. --- .../react-router/src/routerStateContext.tsx | 161 +++++++++++++----- .../tests/concurrent-render-frames.test.tsx | 139 +++++++++++++++ 2 files changed, 255 insertions(+), 45 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 2da1fde3f84..eaa737b41a2 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -12,23 +12,29 @@ export type RouterRenderFrame = RouterState type FrameSubscriber = (frame: RouterRenderFrame) => void /** - * A position in the tree, with the frame that position should render. + * A position in the tree, and the publications that position can present. * * Identity is stable for the router's lifetime, so putting a scope in Context * never invalidates its consumers; they subscribe for updates instead. Which * scope a consumer reads is decided by where it sits: * - * - outside the route tree it reads the committed frame, and only advances - * when a navigation commits; - * - inside the route tree it reads the frame that subtree is rendering, which - * is a staged successor while a navigation is in flight. + * - outside the route tree it reads the committed publication, and only + * advances when a navigation commits; + * - inside the route tree it can also present a staged successor, while a + * navigation is in flight. * - * That is what keeps a reader mounted by an unrelated urgent update on the - * route the user can actually see. + * A scope holds both publications in separate slots rather than one mutable + * field. A consumer records, in React state, *which* publication its own + * render is presenting, and React versions that state per tree. So a + * work-in-progress render that has been offered the staged publication cannot + * drag it into the tree the user is still looking at. */ type RouterStateScope = { router: AnyRouter - frame: RouterRenderFrame + /** The publication this position has committed. */ + committed: RouterRenderFrame + /** A publication offered to the render presenting it, not yet committed. */ + staged: RouterRenderFrame | undefined subscribe: (subscriber: FrameSubscriber) => () => void notify: () => void } @@ -50,6 +56,38 @@ type RouterStateOwner = { const defaultCompare = (a: unknown, b: unknown) => a === b +/** The publication a fresh reader at this position should start from. */ +function offeredFrame(scope: RouterStateScope): RouterRenderFrame { + return scope.staged ?? scope.committed +} + +/** + * The publication a render presenting `frameId` should read. + * + * A render that was offered the staged publication keeps reading it until it + * commits or is discarded. Every other render — including one the staged + * publication was never offered to, because its own selection did not change — + * reads the committed publication. + */ +function resolveFrame( + scope: RouterStateScope, + frameId: number, +): RouterRenderFrame { + const staged = scope.staged + return staged && staged.frameId === frameId ? staged : scope.committed +} + +/** Overlay navigation progress onto a publication without changing its content. */ +function withProgress( + frame: RouterRenderFrame, + head: RouterRenderFrame, +): RouterRenderFrame { + if (frame.status === head.status && frame.isLoading === head.isLoading) { + return frame + } + return { ...frame, status: head.status, isLoading: head.isLoading } +} + function createScope( router: AnyRouter, frame: RouterRenderFrame, @@ -57,7 +95,8 @@ function createScope( const subscribers = new Set() const scope: RouterStateScope = { router, - frame, + committed: frame, + staged: undefined, subscribe: (subscriber) => { subscribers.add(subscriber) return () => { @@ -65,9 +104,10 @@ function createScope( } }, notify: () => { + const frameToPresent = offeredFrame(scope) // Copy first: a subscriber may unsubscribe while we iterate. for (const subscriber of Array.from(subscribers)) { - subscriber(scope.frame) + subscriber(frameToPresent) } }, } @@ -97,24 +137,32 @@ export function RouterStateProvider({ let staging = false let pending: RouterRenderFrame | undefined - // Navigation progress is not route content. The committed scope stays on - // the route that is visible, but its status tracks the head, so progress - // UI outside the route tree — a global loading bar, say — still sees a - // navigation start and finish. Location and matches are untouched, so this - // cannot surface a route the user cannot see. + // Navigation progress is not route content. Both scopes stay on the route + // they are presenting, but their status tracks the head, so progress UI — + // a global loading bar outside the route tree, or a spinner rendered by the + // route the user is leaving — sees a navigation start and finish. Location + // and matches are untouched, so this cannot surface a route the user + // cannot see. const syncProgress = (head: RouterRenderFrame) => { - if ( - root.frame.status === head.status && - root.frame.isLoading === head.isLoading - ) { - return + let changed = false + for (const scope of [root, route]) { + const nextCommitted = withProgress(scope.committed, head) + if (nextCommitted !== scope.committed) { + scope.committed = nextCommitted + changed = true + } + if (scope.staged) { + const nextStaged = withProgress(scope.staged, head) + if (nextStaged !== scope.staged) { + scope.staged = nextStaged + changed = true + } + } } - root.frame = { - ...root.frame, - status: head.status, - isLoading: head.isLoading, + if (changed) { + root.notify() + route.notify() } - root.notify() } const owner: RouterStateOwner = { @@ -122,7 +170,7 @@ export function RouterStateProvider({ root, route, get frame() { - return root.frame + return root.committed }, begin: () => { staging = true @@ -130,16 +178,18 @@ export function RouterStateProvider({ stage: (nextFrame) => { staging = false pending = nextFrame - // Only the route subtree presents a staged frame. Readers outside it - // stay on the committed one until this navigation commits. - route.frame = nextFrame + // Only the route subtree is offered a staged publication, and only the + // render that accepts it presents it. Readers outside that subtree, and + // readers whose own selection did not change, stay on the committed + // publication until this navigation commits. + route.staged = nextFrame route.notify() return nextFrame }, cancel: () => { staging = false pending = undefined - route.frame = root.frame + route.staged = undefined route.notify() owner.publish() }, @@ -148,8 +198,13 @@ export function RouterStateProvider({ return false } pending = undefined - root.frame = nextFrame + // The staged publication is now what everyone has committed, so the + // staged slot empties and both scopes resolve to it. + root.committed = nextFrame + route.committed = nextFrame + route.staged = undefined root.notify() + route.notify() return true }, publish: () => { @@ -163,11 +218,12 @@ export function RouterStateProvider({ syncProgress(head) return } - if (nextFrame.frameId === root.frame.frameId) { + if (nextFrame.frameId === root.committed.frameId) { return } - root.frame = nextFrame - route.frame = nextFrame + root.committed = nextFrame + route.committed = nextFrame + route.staged = undefined root.notify() route.notify() }, @@ -223,8 +279,16 @@ export function useRouterStateSelector( return useStore(router.stores.__store, selector, compare) } + // Which publication this consumer is presenting. It lives in React state, so + // React versions it per tree: a work-in-progress render can accept the staged + // publication without the still-visible tree following it there. `revision` + // makes every accepted update a distinct state value, so a progress-only + // change — same frame, new status — still re-renders. // eslint-disable-next-line react-hooks/rules-of-hooks - const [, forceRender] = React.useReducer((count: number) => count + 1, 0) + const [presenting, setPresenting] = React.useState(() => ({ + frameId: offeredFrame(scope).frameId, + revision: 0, + })) // The selection for the render currently executing. A render can be // discarded — suspended, interrupted, or superseded — so this is // work in progress, not necessarily what anyone can see. @@ -244,7 +308,7 @@ export function useRouterStateSelector( | undefined >(undefined) - rendered.current = selector(scope.frame) + rendered.current = selector(resolveFrame(scope, presenting.frameId)) // eslint-disable-next-line react-hooks/rules-of-hooks useLayoutEffect(() => { @@ -253,23 +317,30 @@ export function useRouterStateSelector( // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { - // Re-render only when this subscriber's own selection changed, which is - // what keeps selector-level render counts identical to the store path. + // Accept a publication only when this subscriber's own selection changed, + // which is what keeps selector-level render counts identical to the store + // path. A consumer that declines stays on the committed publication, where + // its selection is by definition the same. // - // Everything here comes from the committed render, never the one in - // progress: a discarded render leaves behind a selection, and a selector, - // that were never presented. Comparing against either would skip the - // re-render that should have shown the frame, leaving this consumer stuck - // on what is on screen. + // Everything compared here comes from the committed render, never the one + // in progress: a discarded render leaves behind a selection, and a + // selector, that were never presented. Comparing against either would skip + // the re-render that should have shown the frame, leaving this consumer + // stuck on what is on screen. return scope.subscribe((frame) => { + const accept = () => + setPresenting((previous) => ({ + frameId: frame.frameId, + revision: previous.revision + 1, + })) const onScreen = committed.current if (!onScreen) { - forceRender() + accept() return } const next = onScreen.selector(frame) if (!onScreen.compare(onScreen.value, next)) { - forceRender() + accept() } }) }, [scope]) diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 01c5a2998e4..399f6c009d0 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -397,4 +397,143 @@ describe('concurrent render frames', () => { expect(screen.getByTestId('loading').textContent).toBe('false'), ) }) + + /** + * The same isolation, one level in. A reader that sits *inside* the visible + * route and re-renders for an unrelated urgent reason — a keystroke, a + * timer, a local toggle — must keep observing the route on screen. The + * staged publication belongs to the render that is presenting it, and that + * render has not committed yet. + */ + test('a reader inside the visible route does not read ahead when re-rendered urgently', async () => { + let releaseNext: () => void = () => {} + let nextReady = false + const nextGate = new Promise((resolve) => { + releaseNext = () => { + nextReady = true + resolve() + } + }) + + function NextPage() { + if (!nextReady) { + throw nextGate + } + return

Next Title

+ } + + function IndexPage() { + const [bumps, setBumps] = React.useState(0) + const pathname = useRouterState({ select: (s) => s.location.pathname }) + return ( + <> +

Index Title

+ +
{`${pathname}|${bumps}`}
+ + ) + } + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexPage, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: NextPage, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + expect(screen.getByTestId('inside').textContent).toBe('/|0') + + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/next' }) + }) + await waitFor(() => + expect(router.stores.location.get().pathname).toBe('/next'), + ) + expect(screen.getByRole('heading', { name: 'Index Title' })).toBeVisible() + + // An urgent update inside the still-visible route. It must not drag the + // staged route into a tree that has not committed it. + fireEvent.click(screen.getByRole('button', { name: 'Bump' })) + expect(screen.getByTestId('inside').textContent).toBe('/|1') + + await act(async () => { + releaseNext() + await nextGate + }) + await navigation + await waitFor(() => screen.getByRole('heading', { name: 'Next Title' })) + }) + + /** + * Progress is not route content, so it has to cross the presentation + * boundary: a spinner rendered by the visible route must still see the + * navigation it is waiting on. + */ + test('navigation progress reaches a consumer inside the route tree', async () => { + const gate = deferred() + + function Progress() { + const isLoading = useRouterState({ select: (s) => s.isLoading }) + return
{String(isLoading)}
+ } + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

Index Title

+ + + ), + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + component: () =>

Slow Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false'), + ) + + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/slow' }) + }) + + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('true'), + ) + + await act(async () => { + gate.resolve() + await gate.promise + }) + await navigation + await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) + }) }) From 37df8a415fbcba864770b648437a69a30b0dc462 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 21:39:36 +0000 Subject: [PATCH 10/74] fix(react-router): re-read on subscribing so a commit-phase publication is not missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A frame can be published during the commit phase — MatchesInner commits an acknowledged frame from a layout effect — which lands after a consumer has rendered but before its passive effects run. A consumer that only started listening in a passive effect never heard it, and stayed on what it had already rendered until the next publication. Subscribe in a layout effect and re-read immediately afterwards, the way useSyncExternalStore does. The re-read resolves this consumer's own frameId rather than taking whatever is newest, so a committed tree still resolves to the committed slot and staged-frame isolation is unaffected. I could not build a failing test for it: act() flushes passive effects at its boundaries, so the window never opens in this harness. Applied on the mechanism. --- .../react-router/src/routerStateContext.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index eaa737b41a2..8a637d65ee6 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -316,7 +316,7 @@ export function useRouterStateSelector( }) // eslint-disable-next-line react-hooks/rules-of-hooks - React.useEffect(() => { + useLayoutEffect(() => { // Accept a publication only when this subscriber's own selection changed, // which is what keeps selector-level render counts identical to the store // path. A consumer that declines stays on the committed publication, where @@ -327,7 +327,7 @@ export function useRouterStateSelector( // selector, that were never presented. Comparing against either would skip // the re-render that should have shown the frame, leaving this consumer // stuck on what is on screen. - return scope.subscribe((frame) => { + const unsubscribe = scope.subscribe((frame) => { const accept = () => setPresenting((previous) => ({ frameId: frame.frameId, @@ -343,6 +343,27 @@ export function useRouterStateSelector( accept() } }) + + // A publication can land between this consumer's render and this effect — + // `MatchesInner` commits a frame from a layout effect of its own — and a + // notification sent then reaches nobody who is not yet listening. So + // re-read on subscribing, the way `useSyncExternalStore` does. + // + // This re-reads *this consumer's own* publication rather than whatever is + // newest: it keeps `frameId` and only forces the render, so a committed + // tree still resolves to the committed slot and a staged frame stays with + // the render presenting it. + const onScreen = committed.current + if (onScreen) { + setPresenting((previous) => { + const next = onScreen.selector(resolveFrame(scope, previous.frameId)) + return onScreen.compare(onScreen.value, next) + ? previous + : { ...previous, revision: previous.revision + 1 } + }) + } + + return unsubscribe }, [scope]) return rendered.current From 020545359c5734f2782e90e9cdbcd8ef9e515e8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 21:52:31 +0000 Subject: [PATCH 11/74] fix(router-core): answer explicit pending matches from the head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `matchRoute({ pending: true })` asks about the navigation in flight — is this the link we are going to? — which is a question about the head, not about what the calling render is showing. Resolving it against the presented frame meant a destination-aware navigation indicator could never light up: it only ever renders before the commit, so the frame it presents is always the route being left. Explicit pending queries now resolve status and location from the head, exactly as they did before this branch. Ordinary matching still follows the presented frame, so active-link state keeps tracking what is on screen. --- packages/router-core/src/router.ts | 27 ++++++--- .../router-core/tests/render-frames.test.ts | 57 ++++++++++++++++++- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 1d3c2439621..41d250b4679 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2633,21 +2633,30 @@ export class RouterCore< const presentedState = ( opts as MatchRouteOptions & { _state?: RouterState } )?._state - const isPending = - (presentedState?.status ?? this.stores.status.get()) === 'pending' + // An explicit `pending: true` query asks about the navigation in flight — + // "is this the link we are going to?" — which is a question about the head, + // not about what this render is showing. It is answered from the head + // whether or not a frame is presented, exactly as it always has been. + // Everything else resolves against the frame being presented. + const isPending = ( + opts?.pending + ? this.stores.status.get() + : (presentedState?.status ?? this.stores.status.get()) + ) === 'pending' if (opts?.pending && !isPending) { return false } const pending = opts?.pending ?? !isPending - const baseLocation = presentedState - ? pending - ? presentedState.location - : presentedState.resolvedLocation || presentedState.location - : pending - ? this.latestLocation - : this.stores.resolvedLocation.get() || this.stores.location.get() + const baseLocation = + presentedState && !opts?.pending + ? pending + ? presentedState.location + : presentedState.resolvedLocation || presentedState.location + : pending + ? this.latestLocation + : this.stores.resolvedLocation.get() || this.stores.location.get() const match = findSingleMatch( next.pathname, diff --git a/packages/router-core/tests/render-frames.test.ts b/packages/router-core/tests/render-frames.test.ts index 1d2a18829a9..a9a732fc68b 100644 --- a/packages/router-core/tests/render-frames.test.ts +++ b/packages/router-core/tests/render-frames.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + function createRouter() { const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ @@ -82,4 +90,51 @@ describe('render frames', () => { ), ).toBe(false) }) + + test('an explicit pending query resolves against the head, not the frame', async () => { + const gate = deferred() + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + + // The frame the still-visible route is presenting: `/`, not `/slow`. + const presented = router.stores.__store.get() + + const navigation = router.navigate({ to: '/slow' }) + await vi.waitFor(() => + expect(router.stores.status.get()).toBe('pending'), + ) + + // `pending: true` asks about the navigation in flight. A destination-aware + // indicator rendered by the route still on screen presents the older frame, + // but must still recognise where the router is going — it only ever renders + // before the commit, so resolving this against the presented frame would + // mean it could never light up at all. + expect( + router.matchRoute({ to: '/slow' } as any, { + _state: presented, + pending: true, + } as any), + ).toBeTruthy() + + // Ordinary matching still follows what is on screen. + expect( + router.matchRoute({ to: '/slow' } as any, { _state: presented } as any), + ).toBe(false) + + gate.resolve() + await navigation + }) }) From dbdf44100c25b6a35f228678aacca04f6a0a6c61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 22:05:51 +0000 Subject: [PATCH 12/74] fix(react-router): only offer a staged frame from inside the transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scope notification carried whatever the position was presenting, staged included. Stage sends its notification from inside the Router's startTransition, but syncProgress sends one from the store's subscription, on an urgent lane. Offering the staged frame there would let a progress change move the still-visible tree onto a route that has not committed — the same leak the previous commit closed, arriving by a different route. A notification now either offers a specific publication, which only stage() ever does, or offers nothing and means re-read what you are already presenting. Progress, commit, cancel and publish all use the second form, so no notification sent outside a transition can move a consumer off the route it is showing. This also folds the subscribe-time re-read into the same path. The window is currently unreachable: while a frame is staged the head stays pending, so the progress overlay never changes and the notification never fires. The added test therefore passes against the previous commit too, and guards the property rather than reproducing a failure. Verified by probe, not assumed. --- .../react-router/src/routerStateContext.tsx | 76 ++++++++----- .../tests/concurrent-render-frames.test.tsx | 101 ++++++++++++++++++ 2 files changed, 149 insertions(+), 28 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 8a637d65ee6..61ec728cef8 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -9,7 +9,14 @@ import type { AnyRouter, RouterState } from '@tanstack/router-core' export type RouterRenderFrame = RouterState -type FrameSubscriber = (frame: RouterRenderFrame) => void +/** + * Notified either with a staged publication being *offered* — which only ever + * happens from inside the Router's `startTransition` — or with nothing, meaning + * "re-read whatever you are already presenting". A subscriber may move onto a + * staged publication only in the first case, so no notification sent outside a + * transition can move a consumer off the route it is showing. + */ +type FrameSubscriber = (offered: RouterRenderFrame | undefined) => void /** * A position in the tree, and the publications that position can present. @@ -36,7 +43,7 @@ type RouterStateScope = { /** A publication offered to the render presenting it, not yet committed. */ staged: RouterRenderFrame | undefined subscribe: (subscriber: FrameSubscriber) => () => void - notify: () => void + notify: (offered?: RouterRenderFrame) => void } type RouterStateOwner = { @@ -103,11 +110,10 @@ function createScope( subscribers.delete(subscriber) } }, - notify: () => { - const frameToPresent = offeredFrame(scope) + notify: (offered) => { // Copy first: a subscriber may unsubscribe while we iterate. for (const subscriber of Array.from(subscribers)) { - subscriber(frameToPresent) + subscriber(offered) } }, } @@ -160,6 +166,11 @@ export function RouterStateProvider({ } } if (changed) { + // A refresh, never an offer. This runs from the store's subscription, + // outside any transition: offering the staged publication here would + // let an urgent update move the visible tree onto a route that has not + // committed. Consumers re-read the slot they are already presenting, + // which is where the overlaid progress now is. root.notify() route.notify() } @@ -183,7 +194,8 @@ export function RouterStateProvider({ // readers whose own selection did not change, stay on the committed // publication until this navigation commits. route.staged = nextFrame - route.notify() + // The one and only offer, and it is inside `startTransition`. + route.notify(nextFrame) return nextFrame }, cancel: () => { @@ -327,20 +339,41 @@ export function useRouterStateSelector( // selector, that were never presented. Comparing against either would skip // the re-render that should have shown the frame, leaving this consumer // stuck on what is on screen. - const unsubscribe = scope.subscribe((frame) => { - const accept = () => + // Re-read the publication this consumer is already presenting, without + // moving it onto another one. Used for every notification that is not an + // offer, and when the subscription is installed. + const refresh = () => { + const onScreen = committed.current + if (!onScreen) { + return + } + setPresenting((previous) => { + const next = onScreen.selector(resolveFrame(scope, previous.frameId)) + return onScreen.compare(onScreen.value, next) + ? previous + : { ...previous, revision: previous.revision + 1 } + }) + } + + const unsubscribe = scope.subscribe((offered) => { + if (!offered) { + refresh() + return + } + const onScreen = committed.current + if (!onScreen) { setPresenting((previous) => ({ - frameId: frame.frameId, + frameId: offered.frameId, revision: previous.revision + 1, })) - const onScreen = committed.current - if (!onScreen) { - accept() return } - const next = onScreen.selector(frame) + const next = onScreen.selector(offered) if (!onScreen.compare(onScreen.value, next)) { - accept() + setPresenting((previous) => ({ + frameId: offered.frameId, + revision: previous.revision + 1, + })) } }) @@ -348,20 +381,7 @@ export function useRouterStateSelector( // `MatchesInner` commits a frame from a layout effect of its own — and a // notification sent then reaches nobody who is not yet listening. So // re-read on subscribing, the way `useSyncExternalStore` does. - // - // This re-reads *this consumer's own* publication rather than whatever is - // newest: it keeps `frameId` and only forces the render, so a committed - // tree still resolves to the committed slot and a staged frame stays with - // the render presenting it. - const onScreen = committed.current - if (onScreen) { - setPresenting((previous) => { - const next = onScreen.selector(resolveFrame(scope, previous.frameId)) - return onScreen.compare(onScreen.value, next) - ? previous - : { ...previous, revision: previous.revision + 1 } - }) - } + refresh() return unsubscribe }, [scope]) diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 399f6c009d0..5bb6b79642a 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -536,4 +536,105 @@ describe('concurrent render frames', () => { await navigation await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) }) + + /** + * The isolation depends on the staged publication being *offered* only from + * inside the Router's `startTransition`. Progress notifications do not come + * from there — they come from the store's subscription, on an urgent lane — + * so an offer sent from one would pull the visible tree onto a route that has + * not committed. + * + * This guards the property rather than reproducing a failure: today the head + * stays `pending` for exactly as long as a frame is staged, so a progress + * change cannot occur inside that window and the notification never fires. + * The protocol should not depend on that coincidence, and this test fails if + * a future change makes progress movable while a frame is staged without + * keeping offers transition-scoped. + */ + test('a progress notification during a staged navigation cannot move the visible route', async () => { + const gate = deferred() + let releaseNext: () => void = () => {} + let nextReady = false + const nextGate = new Promise((resolve) => { + releaseNext = () => { + nextReady = true + resolve() + } + }) + + function NextPage() { + if (!nextReady) { + throw nextGate + } + return

Next Title

+ } + + function Inside() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + return
{pathname}
+ } + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> +

Index Title

+ + + ), + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: NextPage, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + component: () =>

Slow Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute, slowRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + expect(screen.getByTestId('inside').textContent).toBe('/') + + // Stage a navigation whose route suspends, so `/next` sits in the staged + // slot with the previous route still on screen. + let first!: Promise + act(() => { + first = router.navigate({ to: '/next' }) + }) + await waitFor(() => + expect(router.stores.location.get().pathname).toBe('/next'), + ) + expect(screen.getByTestId('inside').textContent).toBe('/') + + // Now move progress while that navigation is still suspended. The + // notification this produces is urgent, and must not carry the staged + // route with it. + let second!: Promise + act(() => { + second = router.navigate({ to: '/slow' }) + }) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + + expect(screen.getByTestId('inside').textContent).toBe('/') + + releaseNext() + gate.resolve() + await act(async () => { + await nextGate + await gate.promise + }) + await first.catch(() => {}) + await second.catch(() => {}) + }) }) From 78481320826a32324a420a6616d1091b15dd24ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 22:16:23 +0000 Subject: [PATCH 13/74] fix(react-router): key the frame owner by router identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner was built once per mount and closes over the router it was built for. A provider handed a different router — a test rerender, HMR, switching tenant — kept publishing through the previous router's scopes, so navigations on the replacement either read the store synchronously or staged stale frames. It is now rebuilt when the router identity changes, and the construction moves out of the component, since it belongs to the router rather than to a mount. Worth knowing: swapping the router prop of a mounted RouterProvider does not work upstream either — with the option off, the same swap renders an empty tree — so there is no end-to-end behaviour to compare against and this is defensive. The test therefore pins the part that is this change's to get right: the owner follows router identity, in both directions. --- .../react-router/src/routerStateContext.tsx | 224 ++++++++++-------- .../tests/concurrent-render-frames.test.tsx | 66 ++++++ 2 files changed, 185 insertions(+), 105 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 61ec728cef8..549fc720fde 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -128,6 +128,120 @@ const routerStateOwnerContext = React.createContext< RouterStateOwner | undefined >(undefined) +/** + * Everything a router's publications need, closed over that one router. + * + * Built outside the component because it belongs to the router, not to a + * mount: a provider handed a different router has to build a new one rather + * than keep publishing through the old router's scopes. + */ +function createOwner(router: AnyRouter): RouterStateOwner { + const initial = router.stores.__store.get() + const root = createScope(router, initial) + const route = createScope(router, initial) + let staging = false + let pending: RouterRenderFrame | undefined + + // Navigation progress is not route content. Both scopes stay on the route + // they are presenting, but their status tracks the head, so progress UI — + // a global loading bar outside the route tree, or a spinner rendered by the + // route the user is leaving — sees a navigation start and finish. Location + // and matches are untouched, so this cannot surface a route the user + // cannot see. + const syncProgress = (head: RouterRenderFrame) => { + let changed = false + for (const scope of [root, route]) { + const nextCommitted = withProgress(scope.committed, head) + if (nextCommitted !== scope.committed) { + scope.committed = nextCommitted + changed = true + } + if (scope.staged) { + const nextStaged = withProgress(scope.staged, head) + if (nextStaged !== scope.staged) { + scope.staged = nextStaged + changed = true + } + } + } + if (changed) { + // A refresh, never an offer. This runs from the store's subscription, + // outside any transition: offering the staged publication here would + // let an urgent update move the visible tree onto a route that has not + // committed. Consumers re-read the slot they are already presenting, + // which is where the overlaid progress now is. + root.notify() + route.notify() + } + } + + const owner: RouterStateOwner = { + router, + root, + route, + get frame() { + return root.committed + }, + begin: () => { + staging = true + }, + stage: (nextFrame) => { + staging = false + pending = nextFrame + // Only the route subtree is offered a staged publication, and only the + // render that accepts it presents it. Readers outside that subtree, and + // readers whose own selection did not change, stay on the committed + // publication until this navigation commits. + route.staged = nextFrame + // The one and only offer, and it is inside `startTransition`. + route.notify(nextFrame) + return nextFrame + }, + cancel: () => { + staging = false + pending = undefined + route.staged = undefined + route.notify() + owner.publish() + }, + commit: (nextFrame) => { + if (pending?.frameId !== nextFrame.frameId) { + return false + } + pending = undefined + // The staged publication is now what everyone has committed, so the + // staged slot empties and both scopes resolve to it. + root.committed = nextFrame + route.committed = nextFrame + route.staged = undefined + root.notify() + route.notify() + return true + }, + publish: () => { + const head = router.stores.__store.get() + if (staging || pending) { + syncProgress(head) + return + } + const nextFrame = head + if (nextFrame.status === 'pending') { + syncProgress(head) + return + } + if (nextFrame.frameId === root.committed.frameId) { + return + } + root.committed = nextFrame + route.committed = nextFrame + route.staged = undefined + root.notify() + route.notify() + }, + } + return owner +} + export function RouterStateProvider({ router, children, @@ -135,112 +249,12 @@ export function RouterStateProvider({ router: AnyRouter children: React.ReactNode }) { + // Keyed by router identity. A mounted provider can be handed a different + // router — a test rerender, HMR, switching tenant — and an owner built for + // the previous one would keep reading and staging that router's state. const ownerRef = React.useRef(undefined) - if (!ownerRef.current) { - const initial = router.stores.__store.get() - const root = createScope(router, initial) - const route = createScope(router, initial) - let staging = false - let pending: RouterRenderFrame | undefined - - // Navigation progress is not route content. Both scopes stay on the route - // they are presenting, but their status tracks the head, so progress UI — - // a global loading bar outside the route tree, or a spinner rendered by the - // route the user is leaving — sees a navigation start and finish. Location - // and matches are untouched, so this cannot surface a route the user - // cannot see. - const syncProgress = (head: RouterRenderFrame) => { - let changed = false - for (const scope of [root, route]) { - const nextCommitted = withProgress(scope.committed, head) - if (nextCommitted !== scope.committed) { - scope.committed = nextCommitted - changed = true - } - if (scope.staged) { - const nextStaged = withProgress(scope.staged, head) - if (nextStaged !== scope.staged) { - scope.staged = nextStaged - changed = true - } - } - } - if (changed) { - // A refresh, never an offer. This runs from the store's subscription, - // outside any transition: offering the staged publication here would - // let an urgent update move the visible tree onto a route that has not - // committed. Consumers re-read the slot they are already presenting, - // which is where the overlaid progress now is. - root.notify() - route.notify() - } - } - - const owner: RouterStateOwner = { - router, - root, - route, - get frame() { - return root.committed - }, - begin: () => { - staging = true - }, - stage: (nextFrame) => { - staging = false - pending = nextFrame - // Only the route subtree is offered a staged publication, and only the - // render that accepts it presents it. Readers outside that subtree, and - // readers whose own selection did not change, stay on the committed - // publication until this navigation commits. - route.staged = nextFrame - // The one and only offer, and it is inside `startTransition`. - route.notify(nextFrame) - return nextFrame - }, - cancel: () => { - staging = false - pending = undefined - route.staged = undefined - route.notify() - owner.publish() - }, - commit: (nextFrame) => { - if (pending?.frameId !== nextFrame.frameId) { - return false - } - pending = undefined - // The staged publication is now what everyone has committed, so the - // staged slot empties and both scopes resolve to it. - root.committed = nextFrame - route.committed = nextFrame - route.staged = undefined - root.notify() - route.notify() - return true - }, - publish: () => { - const head = router.stores.__store.get() - if (staging || pending) { - syncProgress(head) - return - } - const nextFrame = head - if (nextFrame.status === 'pending') { - syncProgress(head) - return - } - if (nextFrame.frameId === root.committed.frameId) { - return - } - root.committed = nextFrame - route.committed = nextFrame - route.staged = undefined - root.notify() - route.notify() - }, - } - ownerRef.current = owner + if (!ownerRef.current || ownerRef.current.router !== router) { + ownerRef.current = createOwner(router) } const owner = ownerRef.current diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 5bb6b79642a..d43fd57a89b 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -8,6 +8,10 @@ import { waitFor, } from '@testing-library/react' import * as React from 'react' +import { + RouterStateProvider, + useRouterStateOwner, +} from '../src/routerStateContext' import { Link, Matches, @@ -20,6 +24,7 @@ import { useLocation, useRouterState, } from '../src' +import type { AnyRouter } from '@tanstack/router-core' afterEach(() => { window.history.replaceState(null, 'root', '/') @@ -637,4 +642,65 @@ describe('concurrent render frames', () => { await first.catch(() => {}) await second.catch(() => {}) }) + + /** + * A mounted provider can be handed a different router — a test rerender, + * HMR, switching tenant. The frame owner closes over the router it was built + * for, so it has to be rebuilt, or every publication after the swap goes + * through the previous router's scopes. + * + * Asserted on the owner rather than through a rendered navigation: swapping + * the `router` prop of a mounted `RouterProvider` does not work upstream + * either — with `experimental_concurrentRenderFrames` off, the same swap + * renders an empty tree — so there is no end-to-end behaviour to compare + * against. This pins the part that is this change's to get right. + */ + test('a provider handed a different router builds an owner for it', async () => { + const owners: Array = [] + + function OwnerProbe() { + const owner = useRouterStateOwner() + owners.push(owner?.router) + return null + } + + const makeRouter = () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + return createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + experimental_concurrentRenderFrames: true, + }) + } + + const first = makeRouter() + const second = makeRouter() + + const { rerender } = render( + + + , + ) + expect(owners.at(-1)).toBe(first) + + rerender( + + + , + ) + expect(owners.at(-1)).toBe(second) + + // And back, to pin that identity is what decides it rather than a one-shot + // "has the router ever changed" flag. + rerender( + + + , + ) + expect(owners.at(-1)).toBe(first) + }) }) From a3d580752579cce9b60c577f697b0ee7daf28151 Mon Sep 17 00:00:00 2001 From: Mat Clayton Date: Fri, 4 Sep 2026 00:05:41 +0100 Subject: [PATCH 14/74] fix(react-router): tolerate a frame that drops the Outlet's own route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A frame is offered to every subscribed consumer, so an `Outlet` belonging to a route the next frame drops still runs its selector against that frame. It read its own match unconditionally, so `matches[parentIndex]` was `undefined` and the selector threw. Because a scope notifies subscribers in a plain loop, the throw stopped every later consumer being offered the frame, `Matches` never acknowledged it, and the navigation stayed `pending` for good — the URL changed while the old route stayed on screen. This only reached navigations that change the shape of the match tree, which is why same-route parameter changes looked fine. Co-Authored-By: Claude Opus 5 --- packages/react-router/src/Match.tsx | 18 ++++- .../tests/concurrent-render-frames.test.tsx | 76 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index a30146ddd60..9ffa4a8eacc 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -63,6 +63,21 @@ const concurrentOutletMatchSelectionEqual = ( b: ConcurrentOutletMatchSelection, ) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +/** + * What an `Outlet` selects from a frame that no longer matches its route. + * + * A frame is offered to every subscribed consumer, including ones React is + * about to unmount because the new frame's match tree has a different shape. + * Those consumers still run their selector against the new frame, so it has to + * describe a route that has left the tree rather than assume its own match is + * still there. Rendering no child is correct for a subtree that is going away. + */ +const absentOutletMatchSelection: ConcurrentOutletMatchSelection = [ + false, + undefined, + undefined, +] + const canWrapInSuspense = ( router: ReturnType, route: AnyRoute, @@ -319,7 +334,8 @@ export const Outlet = React.memo(function OutletImpl() { const parentIndex = matches.findIndex( (match) => match.routeId === routeId, ) - const parentMatch = matches[parentIndex]! + const parentMatch = matches[parentIndex] + if (!parentMatch) return absentOutletMatchSelection return [ !!parentMatch._notFound, parentMatch.error, diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index d43fd57a89b..8a6c6772258 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -655,6 +655,82 @@ describe('concurrent render frames', () => { * renders an empty tree — so there is no end-to-end behaviour to compare * against. This pins the part that is this change's to get right. */ + /** + * A frame is offered to every subscribed consumer, so an `Outlet` belonging + * to a route the next frame drops still runs its selector against that + * frame. Reading its own match unconditionally threw there, and because a + * scope notifies its subscribers in a plain loop, the throw stopped every + * later consumer being offered the frame — so `Matches` never acknowledged + * it and the navigation stayed pending for good. + */ + test('a route leaving the match tree does not wedge the navigation', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + // A route with its own Outlet: navigating away from its child drops both + // this route and the nested one from the match tree. + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + component: () => ( +
+

Nested Title

+ +
+ ), + }) + const nestedChildRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/child', + component: () =>

Nested Child Title

, + }) + const siblingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/sibling', + component: () =>

Sibling Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + nestedRoute.addChildren([nestedChildRoute]), + siblingRoute, + ]), + experimental_concurrentRenderFrames: true, + }) + + render( + + + , + ) + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + let toChild!: Promise + act(() => { + toChild = router.navigate({ to: '/nested/child' }) + }) + await waitFor(() => + screen.getByRole('heading', { name: 'Nested Child Title' }), + ) + await toChild + + // The sibling's match tree has neither /nested nor /nested/child in it. + let toSibling!: Promise + act(() => { + toSibling = router.navigate({ to: '/sibling' }) + }) + + await waitFor(() => screen.getByRole('heading', { name: 'Sibling Title' })) + await toSibling + expect(router.stores.status.get()).toBe('idle') + }) + test('a provider handed a different router builds an owner for it', async () => { const owners: Array = [] From f3692042946d172983726ead8007aa152d76752d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 21:46:00 +0000 Subject: [PATCH 15/74] fix: answer presented matches and absent routes from the right publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings from the upstream PR. `matchRoute` built its target location before applying the presented frame, so a caller inheriting the current search — `search: true` — inherited it from the navigation head while the comparison used the presented frame. A link to the route actually on screen then reported itself inactive. The target is now built from the same publication it is compared against. (A destination that simply omits `search` builds an empty search and compares partially, so only inheriting callers could see this.) `Match` asserted that the presented frame contains its own route, the same assumption the Outlet selector made. A frame that drops the route can still reach a consumer React has not unmounted, so it renders no match instead. The view-transition e2e tests polled for records that cannot exist where `document.startViewTransition` is absent, timing out instead of skipping. The init script now records support on the page and the tests skip on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../view-transitions/tests/app.spec.ts | 28 ++++++++ packages/react-router/src/Match.tsx | 9 ++- packages/router-core/src/router.ts | 34 ++++++---- .../router-core/tests/render-frames.test.ts | 64 +++++++++++++++++-- 4 files changed, 113 insertions(+), 22 deletions(-) diff --git a/e2e/react-router/view-transitions/tests/app.spec.ts b/e2e/react-router/view-transitions/tests/app.spec.ts index 379efc18f29..40c19cbd885 100644 --- a/e2e/react-router/view-transitions/tests/app.spec.ts +++ b/e2e/react-router/view-transitions/tests/app.spec.ts @@ -12,6 +12,8 @@ type ViewTransitionRecord = { declare global { interface Window { __viewTransitions: Array + /** Whether this browser has the View Transitions API at all. */ + __viewTransitionsSupported: boolean } } @@ -27,6 +29,9 @@ async function recordViewTransitions(page: Page) { await page.addInitScript((knownTypes: Array) => { window.__viewTransitions = [] const original = document.startViewTransition?.bind(document) + // Recorded on the page rather than returned from `addInitScript`, which + // resolves to a handle for removing the script, not to the script's value. + window.__viewTransitionsSupported = Boolean(original) if (!original) { return } @@ -54,6 +59,14 @@ async function recordViewTransitions(page: Page) { const getRecords = (page: Page) => page.evaluate(() => window.__viewTransitions) +/** + * Without the API there is nothing to record, so the polls below would wait for + * records that can never arrive and fail on timeout. Skip instead: absent + * support is not a failing assertion about this change. + */ +const supportsViewTransitions = (page: Page) => + page.evaluate(() => window.__viewTransitionsSupported) + test.beforeEach(async ({ page }) => { await recordViewTransitions(page) await page.goto('/') @@ -62,6 +75,11 @@ test.beforeEach(async ({ page }) => { test('a viewTransition navigation starts a real view transition', async ({ page, }) => { + test.skip( + !(await supportsViewTransitions(page)), + 'browser does not support document.startViewTransition', + ) + await page.getByRole('link', { name: 'Next Page' }).click() await expect(page.getByRole('heading')).toContainText( 'This example demonstrates a variety of custom page transitions', @@ -73,6 +91,11 @@ test('a viewTransition navigation starts a real view transition', async ({ test('the transition pairs the shared element across the navigation', async ({ page, }) => { + test.skip( + !(await supportsViewTransitions(page)), + 'browser does not support document.startViewTransition', + ) + await page.getByRole('link', { name: 'Next Page' }).click() await expect @@ -89,6 +112,11 @@ test('the transition pairs the shared element across the navigation', async ({ test('the configured viewTransition types are applied to the document', async ({ page, }) => { + test.skip( + !(await supportsViewTransitions(page)), + 'browser does not support document.startViewTransition', + ) + const supportsTypes = await page.evaluate(() => Boolean( window.CSS?.supports?.('selector(:active-view-transition-type(a))'), diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 9ffa4a8eacc..9a77fcdaaaa 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -103,7 +103,14 @@ export const Match = React.memo(function MatchImpl({ const match = useRouterStateSelector(router, (state) => state.matches.find((candidate) => candidate.routeId === routeId), ) - return + // Same reasoning as `absentOutletMatchSelection`: a frame that drops this + // route can reach a consumer React has not unmounted yet. Rendering no + // match is correct for a subtree that is going away, and is safer than + // asserting a match the frame does not describe. + if (!match) { + return null + } + return } if (isServer ?? router.isServer) { diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 41d250b4679..8e108f6ccd2 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2620,16 +2620,6 @@ export class RouterCore< TDefaultStructuralSharingOption, TRouterHistory > = (location, opts) => { - const matchLocation = { - ...location, - to: location.to - ? this.resolvePathWithBase(location.from || '', location.to as string) - : undefined, - params: location.params || {}, - leaveParams: true, - } - const next = this.buildLocation(matchLocation as any) - const presentedState = ( opts as MatchRouteOptions & { _state?: RouterState } )?._state @@ -2638,11 +2628,10 @@ export class RouterCore< // not about what this render is showing. It is answered from the head // whether or not a frame is presented, exactly as it always has been. // Everything else resolves against the frame being presented. - const isPending = ( - opts?.pending + const isPending = + (opts?.pending ? this.stores.status.get() - : (presentedState?.status ?? this.stores.status.get()) - ) === 'pending' + : (presentedState?.status ?? this.stores.status.get())) === 'pending' if (opts?.pending && !isPending) { return false } @@ -2658,6 +2647,23 @@ export class RouterCore< ? this.latestLocation : this.stores.resolvedLocation.get() || this.stores.location.get() + const matchLocation = { + ...location, + to: location.to + ? this.resolvePathWithBase(location.from || '', location.to as string) + : undefined, + params: location.params || {}, + leaveParams: true, + // Build the target from the same publication it is about to be compared + // against. Otherwise a destination that omits `search` inherits it from + // the head while the comparison uses the presented frame, and a link to + // the route actually on screen reports itself inactive. + ...(presentedState && !opts?.pending + ? { _fromLocation: baseLocation } + : {}), + } + const next = this.buildLocation(matchLocation as any) + const match = findSingleMatch( next.pathname, opts?.caseSensitive ?? false, diff --git a/packages/router-core/tests/render-frames.test.ts b/packages/router-core/tests/render-frames.test.ts index a9a732fc68b..cdaccc99aab 100644 --- a/packages/router-core/tests/render-frames.test.ts +++ b/packages/router-core/tests/render-frames.test.ts @@ -113,9 +113,7 @@ describe('render frames', () => { const presented = router.stores.__store.get() const navigation = router.navigate({ to: '/slow' }) - await vi.waitFor(() => - expect(router.stores.status.get()).toBe('pending'), - ) + await vi.waitFor(() => expect(router.stores.status.get()).toBe('pending')) // `pending: true` asks about the navigation in flight. A destination-aware // indicator rendered by the route still on screen presents the older frame, @@ -123,10 +121,13 @@ describe('render frames', () => { // before the commit, so resolving this against the presented frame would // mean it could never light up at all. expect( - router.matchRoute({ to: '/slow' } as any, { - _state: presented, - pending: true, - } as any), + router.matchRoute( + { to: '/slow' } as any, + { + _state: presented, + pending: true, + } as any, + ), ).toBeTruthy() // Ordinary matching still follows what is on screen. @@ -137,4 +138,53 @@ describe('render frames', () => { gate.resolve() await navigation }) + + test('a presented match target inherits search from the presented frame', async () => { + const gate = deferred() + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateSearch: (search: Record) => ({ + tab: (search.tab as string | undefined) ?? 'a', + }), + // Only the second tab is slow, so the initial load settles and the + // navigation away can be held open. + loaderDeps: ({ search }: any) => ({ tab: search.tab }), + loader: ({ deps }: any) => + deps.tab === 'b' ? gate.promise : Promise.resolve(), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + history: createMemoryHistory({ initialEntries: ['/posts?tab=a'] }), + }) + await router.load() + + // The frame the visible route is presenting: `/posts?tab=a`. + const presented = router.stores.__store.get() + expect(presented.location.search).toMatchObject({ tab: 'a' }) + + // Move the head to the same route with a different search, and hold it. + const navigation = router.navigate({ to: '/posts', search: { tab: 'b' } }) + await vi.waitFor(() => + expect(router.latestLocation.search).toMatchObject({ tab: 'b' }), + ) + + // A link to the route actually on screen that inherits the current search. + // The target has to inherit `tab` from the frame it will be compared + // against, not from the head — otherwise the visible route reports itself + // inactive. (A destination that simply omits `search` builds an empty + // search and compares partially, so only inheriting callers can see this.) + expect( + router.matchRoute( + { to: '/posts', search: true } as any, + { + _state: presented, + } as any, + ), + ).toBeTruthy() + + gate.resolve() + await navigation + }) }) From 31358775f23080514630d9b76f9428d75aee885d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 22:01:53 +0000 Subject: [PATCH 16/74] test(react-router): run the view-transition fixture on the frame path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture never set `experimental_concurrentRenderFrames`, so its tests ran entirely on the default store path. Enabling it makes them a regression guard that the option does not break `viewTransition: true`. It does not make them cover the option's own behaviour, and the comment says so: they assert on `document.startViewTransition`, which `viewTransition: true` calls directly, and they pass with the option either way — verified by running the suite both ways. React's ``, which is what the option unblocks, is canary-only and cannot run on the React version this repo pins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- e2e/react-router/view-transitions/src/main.tsx | 11 +++++++++++ .../view-transitions/src/routeTree.gen.ts | 7 +------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/e2e/react-router/view-transitions/src/main.tsx b/e2e/react-router/view-transitions/src/main.tsx index 065d69a2cf8..7c4954c04c4 100644 --- a/e2e/react-router/view-transitions/src/main.tsx +++ b/e2e/react-router/view-transitions/src/main.tsx @@ -38,6 +38,17 @@ const router = createRouter({ // return [`slide-${direction}`] // }, // }, + + // Run this fixture on the render-frame path, so its tests act as a + // regression guard that the option does not break `viewTransition: true`. + // + // They do not, and cannot, prove the option's own behaviour: they assert on + // `document.startViewTransition`, which `viewTransition: true` calls + // directly, and they pass with the option either way (verified). React's + // `` — what the option actually unblocks — is canary-only, + // so it cannot be exercised on the React version this repo pins. The unit + // tests cover the protocol; see the PR for the measured evidence. + experimental_concurrentRenderFrames: true, }) // Register things for typesafety diff --git a/e2e/react-router/view-transitions/src/routeTree.gen.ts b/e2e/react-router/view-transitions/src/routeTree.gen.ts index 8982fca68f8..e37b6df3c06 100644 --- a/e2e/react-router/view-transitions/src/routeTree.gen.ts +++ b/e2e/react-router/view-transitions/src/routeTree.gen.ts @@ -74,12 +74,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | '/' - | '/posts' - | '/explore' - | '/how-it-works' - | '/posts/$postId' - | '/posts/' + '/' | '/posts' | '/explore' | '/how-it-works' | '/posts/$postId' | '/posts/' fileRoutesByTo: FileRoutesByTo to: '/' | '/explore' | '/how-it-works' | '/posts/$postId' | '/posts' id: From 0608e9bd2340766c7f3fad17fe53aa6c612fe42d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 22:11:15 +0000 Subject: [PATCH 17/74] fix(react-router): take progress from the head when a frame commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `commit` wrote the frame's own `status` and `isLoading` into both scopes. If a newer navigation was already loading by the time an older frame committed — navigation A staged and suspended, B enters its loader — that assignment replaced B's pending overlay with A's stale idle snapshot. B has already emitted its pending store update and may emit nothing further until it finishes, so loading selectors read false for the rest of its wait. Progress now comes from the head at commit time, the same source `syncProgress` uses. Content still comes from the frame, so this cannot surface a route the user cannot see. Reported on the downstream patch PR. Like the other two interleavings on this branch it is applied on the mechanism: `act()` commits a staged render rather than leaving it suspended while a second navigation starts, so the window does not open in the unit harness. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/routerStateContext.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 549fc720fde..2f74b015aec 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -211,8 +211,18 @@ function createOwner(router: AnyRouter): RouterStateOwner { pending = undefined // The staged publication is now what everyone has committed, so the // staged slot empties and both scopes resolve to it. - root.committed = nextFrame - route.committed = nextFrame + // + // Progress comes from the head rather than from the frame, because a + // newer navigation may already be loading by the time this one commits. + // Committing the frame's own `status`/`isLoading` would replace that + // navigation's overlay with a stale idle snapshot, and it may emit + // nothing further until it finishes — leaving progress false throughout. + const committedFrame = withProgress( + nextFrame, + router.stores.__store.get(), + ) + root.committed = committedFrame + route.committed = committedFrame route.staged = undefined root.notify() route.notify() From 9af6f37081a70cb620fa429ee5f07dc34a63349b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 22:22:24 +0000 Subject: [PATCH 18/74] fix: match a presented frame against its own location, and meet AGENTS.md `matchRoute` consulted a presented frame's `resolvedLocation` for ordinary matching. While a successor is staged but unacknowledged that still names the route being left, so a destination-aware `useMatchRoute` rendering in the successor tree reported the very route it was presenting as inactive, and only corrected itself after acknowledgement published again. A presented frame is now matched against its own `location`, which is what the calling render is showing and the reason for passing a frame at all. Also from review of this branch against the repo's own rules: - the absent-parent guard in `Outlet` was a one-line `if`, which AGENTS.md prohibits; it now has braces - the new router option had a changeset but no `docs/` entry, which AGENTS.md requires for features. `RouterOptionsType.md` now documents it, including the two behaviour changes an adopter needs before enabling it: route-level pending components are not used after hydration, and `location`/`matches` lag the head by design while `status`/`isLoading` and explicit `pending` matching do not Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterOptionsType.md | 21 ++++++++++ packages/react-router/src/Match.tsx | 4 +- packages/router-core/src/router.ts | 9 +++-- .../router-core/tests/render-frames.test.ts | 40 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 3556f4ab240..aa52739951b 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -418,3 +418,24 @@ If you want to configure to remount all route components upon `params` change, u ```tsx remountDeps: ({ params }) => params ``` + +### `experimental_concurrentRenderFrames` property + +- Type: `boolean` +- Optional +- Defaults to `false` +- **Experimental.** When `true`, the React adapter publishes router state to React as one immutable _render frame_ per navigation, instead of through the individual store subscriptions that `useSyncExternalStore` backs. +- Enable it if you need React's `` — or any other transition-only behaviour — to engage across a navigation. Router state otherwise reaches components through `useSyncExternalStore`, which React schedules at a synchronous lane from the store's own subscription callback, after the `startTransition` scope has exited. That update is therefore never a transition, and `` only runs for transitions. +- Selector behaviour is unchanged: a consumer re-renders only when its own selection changes. + +Two behaviour changes to know about before enabling it: + +- **Route-level pending components are not used after hydration.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. A child route that suspends bubbles to that boundary, whose fallback comes from the root route, so a child- or parent-specific `pendingComponent` is skipped. Provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`. +- **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working. + +```tsx +const router = createRouter({ + routeTree, + experimental_concurrentRenderFrames: true, +}) +``` diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 9a77fcdaaaa..4211c8ac5ba 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -342,7 +342,9 @@ export const Outlet = React.memo(function OutletImpl() { (match) => match.routeId === routeId, ) const parentMatch = matches[parentIndex] - if (!parentMatch) return absentOutletMatchSelection + if (!parentMatch) { + return absentOutletMatchSelection + } return [ !!parentMatch._notFound, parentMatch.error, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 8e108f6ccd2..a0bd6362077 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2638,11 +2638,14 @@ export class RouterCore< const pending = opts?.pending ?? !isPending + // A presented frame is matched against its own `location`: that is the + // route the calling render is showing, which is the whole point of passing + // a frame. Its `resolvedLocation` still names the previous route while a + // successor is staged but unacknowledged, so consulting it would report the + // destination inactive for exactly the render that is presenting it. const baseLocation = presentedState && !opts?.pending - ? pending - ? presentedState.location - : presentedState.resolvedLocation || presentedState.location + ? presentedState.location : pending ? this.latestLocation : this.stores.resolvedLocation.get() || this.stores.location.get() diff --git a/packages/router-core/tests/render-frames.test.ts b/packages/router-core/tests/render-frames.test.ts index cdaccc99aab..cbae92669f9 100644 --- a/packages/router-core/tests/render-frames.test.ts +++ b/packages/router-core/tests/render-frames.test.ts @@ -187,4 +187,44 @@ describe('render frames', () => { gate.resolve() await navigation }) + + + test('a staged frame matches its own destination before acknowledgement', async () => { + const gate = deferred() + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const nextRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => gate.promise, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + + const navigation = router.navigate({ to: '/next' }) + await vi.waitFor(() => + expect(router.latestLocation.pathname).toBe('/next'), + ) + + // The successor as a render would present it: its own location is the + // destination, while `resolvedLocation` still names the route being left. + const staged = router.stores.__store.get() + expect(staged.location.pathname).toBe('/next') + + // A destination-aware `useMatchRoute` in the successor tree renders before + // acknowledgement. Matching against the frame's stale `resolvedLocation` + // would report the very route it is presenting as inactive. + expect( + router.matchRoute({ to: '/next' } as any, { _state: staged } as any), + ).toBeTruthy() + + gate.resolve() + await navigation + }) }) From dd7cddaaaf695fcbd62582888e0e44ac05294f20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 22:23:10 +0000 Subject: [PATCH 19/74] chore: drop generated route-tree churn from the diff Local codegen reformatted a union in the view-transitions fixture's `routeTree.gen.ts`. It is not part of this change and only adds noise for a reviewer, so it is reverted to `main`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- e2e/react-router/view-transitions/src/routeTree.gen.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e/react-router/view-transitions/src/routeTree.gen.ts b/e2e/react-router/view-transitions/src/routeTree.gen.ts index e37b6df3c06..8982fca68f8 100644 --- a/e2e/react-router/view-transitions/src/routeTree.gen.ts +++ b/e2e/react-router/view-transitions/src/routeTree.gen.ts @@ -74,7 +74,12 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - '/' | '/posts' | '/explore' | '/how-it-works' | '/posts/$postId' | '/posts/' + | '/' + | '/posts' + | '/explore' + | '/how-it-works' + | '/posts/$postId' + | '/posts/' fileRoutesByTo: FileRoutesByTo to: '/' | '/explore' | '/how-it-works' | '/posts/$postId' | '/posts' id: From 479e67aa4d0d86fe0ae86524d2d28c4dc1794311 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 22:36:46 +0000 Subject: [PATCH 20/74] fix: keep hook order fixed, and keep a throwing selector out of the navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hazards in the frame path's selector hook, neither with an analogue on the store path. `useRouterStateSelector` branched on whether the router it was handed matched the scope above it, taking `useStore` when it did not and the frame hooks when it did. That condition is not static: `useRouterState` accepts a `router` option, and a consumer that changes it between two routers — one with an owner above it, one without — changed hook shape with it, so React failed on the hook order ("Should have a queue") rather than merely reading the other router. A reader with no owner for the router it names now resolves to a detached scope that presents that router's store head, so the same hooks run in the same order on every render whatever the argument. The server branch moves above the hooks and resolves the frame the client's first render would. A selector is user code, and the frame path runs it outside React's render — from the Router's `startTransition`, by way of `notify` — to decide whether a consumer's selection changed. A throw there reached no error boundary and unwound into the navigation that sent the notification, wedging it; the earlier `Outlet` fix only stopped the built-in selector from throwing. A throwing selector is now read as "the selection changed", so the consumer re-renders, throws during render, and the nearest error boundary handles it as it would on the store path. Both tests fail without the change with the symptoms above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterOptionsType.md | 1 + .../react-router/src/routerStateContext.tsx | 109 +++++++++---- .../tests/concurrent-render-frames.test.tsx | 154 ++++++++++++++++++ 3 files changed, 236 insertions(+), 28 deletions(-) diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index aa52739951b..609dca316d2 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -432,6 +432,7 @@ Two behaviour changes to know about before enabling it: - **Route-level pending components are not used after hydration.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. A child route that suspends bubbles to that boundary, whose fallback comes from the root route, so a child- or parent-specific `pendingComponent` is skipped. Provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`. - **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working. +- **Every reader goes through the frame path, including one that names a router explicitly.** `useRouterState({ router })` pointing at a router with no provider above it reads that router's store head — the same content as before — but through React state rather than `useSyncExternalStore`, so its updates are no longer flushed synchronously. ```tsx const router = createRouter({ diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 2f74b015aec..6ea2c028d1d 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -1,7 +1,6 @@ 'use client' import * as React from 'react' -import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useLayoutEffect } from './utils' import { useHydrated } from './ClientOnly' @@ -298,21 +297,61 @@ export function useRouterStateOwner() { return React.useContext(routerStateOwnerContext) } +/** + * The scope for a reader that has no owner above it for the router it names — + * `useRouterState({ router })` pointing at another instance, or a consumer + * rendered outside `RouterProvider`. + * + * There is no presentation to isolate here, so this scope presents the store + * head and treats every notification as a plain refresh: the same content the + * default `useStore` path gives. Going through the *same* hooks as a scoped + * reader is the point — the argument can change between renders, and a reader + * that changed hook shape with it would crash on the hook order rather than + * merely read a different router. Cached per router so the identity the + * subscription effect depends on stays stable. + */ +const detachedScopes = new WeakMap() + +function detachedScope(router: AnyRouter): RouterStateScope { + const existing = detachedScopes.get(router) + if (existing) { + return existing + } + const scope: RouterStateScope = { + router, + get committed() { + return router.stores.__store.get() + }, + staged: undefined, + subscribe: (subscriber) => { + const subscription = router.stores.__store.subscribe(() => + subscriber(undefined), + ) + return () => subscription.unsubscribe() + }, + notify: () => {}, + } + detachedScopes.set(router, scope) + return scope +} + export function useRouterStateSelector( router: AnyRouter, selector: (state: RouterState) => TSelected, compare: (a: TSelected, b: TSelected) => boolean = defaultCompare, ): TSelected { - const scope = React.useContext(routerStateScopeContext) - - if (!scope || scope.router !== router) { - if (isServer ?? router.isServer) { - return selector(router.stores.__store.get()) - } - // The frame option is fixed when the router is created, so this branch - // cannot change hook order during the lifetime of a mounted router. - // eslint-disable-next-line react-hooks/rules-of-hooks - return useStore(router.stores.__store, selector, compare) + const ownerScope = React.useContext(routerStateScopeContext) + // Not conditional on anything that can change: whichever scope this reader + // resolves to, the hooks below run, in this order, on every render. + const scope = + ownerScope && ownerScope.router === router + ? ownerScope + : detachedScope(router) + + if (isServer ?? router.isServer) { + // One render, no reactivity, so nothing to subscribe to. `offeredFrame` is + // what the client path would resolve on its first render. + return selector(offeredFrame(scope)) } // Which publication this consumer is presenting. It lives in React state, so @@ -366,17 +405,38 @@ export function useRouterStateSelector( // Re-read the publication this consumer is already presenting, without // moving it onto another one. Used for every notification that is not an // offer, and when the subscription is installed. + // Whether the selection this consumer has on screen still holds for + // `frame`. + // + // A selector and a comparator are user code, and this runs them outside + // React's render — from the Router's `startTransition`, by way of + // `notify`. A throw here would reach neither an error boundary (there is + // no component on the stack) nor the consumer that owns the selector; it + // would unwind into whichever navigation sent the notification and wedge + // it. So a throwing selector is read as "the selection changed": this + // consumer re-renders, the selector throws during render instead, and the + // nearest error boundary handles it the way it would on the store path. + const stillHolds = ( + onScreen: NonNullable, + frame: RouterRenderFrame, + ) => { + try { + return onScreen.compare(onScreen.value, onScreen.selector(frame)) + } catch { + return false + } + } + const refresh = () => { const onScreen = committed.current if (!onScreen) { return } - setPresenting((previous) => { - const next = onScreen.selector(resolveFrame(scope, previous.frameId)) - return onScreen.compare(onScreen.value, next) + setPresenting((previous) => + stillHolds(onScreen, resolveFrame(scope, previous.frameId)) ? previous - : { ...previous, revision: previous.revision + 1 } - }) + : { ...previous, revision: previous.revision + 1 }, + ) } const unsubscribe = scope.subscribe((offered) => { @@ -385,20 +445,13 @@ export function useRouterStateSelector( return } const onScreen = committed.current - if (!onScreen) { - setPresenting((previous) => ({ - frameId: offered.frameId, - revision: previous.revision + 1, - })) + if (onScreen && stillHolds(onScreen, offered)) { return } - const next = onScreen.selector(offered) - if (!onScreen.compare(onScreen.value, next)) { - setPresenting((previous) => ({ - frameId: offered.frameId, - revision: previous.revision + 1, - })) - } + setPresenting((previous) => ({ + frameId: offered.frameId, + revision: previous.revision + 1, + })) }) // A publication can land between this consumer's render and this effect — diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 8a6c6772258..757874bbc0f 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -780,3 +780,157 @@ describe('concurrent render frames', () => { expect(owners.at(-1)).toBe(first) }) }) + +/** + * Frame-path-only hazards: both are about the frame path's extra machinery, + * and neither has an analogue on the store path, so they are pinned once + * rather than through the mode matrix. + */ +describe('concurrent render frames', () => { + const makeRouter = () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts Title

, + }) + return createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + experimental_concurrentRenderFrames: true, + }) + } + + /** + * `useRouterState({ router })` names a router explicitly, and that argument + * can change between renders. A reader whose hook shape depended on whether + * the named router matched the owner above it would not merely read the + * other router — it would crash on the hook order. + */ + test('a consumer whose router argument changes keeps its hook order', async () => { + const first = makeRouter() + const second = makeRouter() + + function Probe({ router }: { router: AnyRouter }) { + const pathname = useRouterState({ + router, + select: (state) => state.location.pathname, + }) + return
{pathname}
+ } + + // `second` has no owner above it here, so it resolves to a different scope + // than `first` does. + const { rerender } = render( + + + + + , + ) + expect(screen.getByTestId('pathname')).toHaveTextContent('/') + + const swap = (router: AnyRouter) => + rerender( + + + + + , + ) + + // Onto the scoped router, and back off it: either direction changes hook + // order if the branch is taken per render. + swap(first) + expect(screen.getByTestId('pathname')).toHaveTextContent('/') + swap(second) + expect(screen.getByTestId('pathname')).toHaveTextContent('/') + }) + + /** + * A selector is user code, and the frame path runs it outside React's + * render — from the Router's `startTransition`, to decide whether a + * consumer's selection changed. A throw there reaches no error boundary and + * unwinds into the navigation that sent the notification. + */ + test('a throwing selector surfaces in render rather than wedging the navigation', async () => { + const errors: Array = [] + + class Boundary extends React.Component< + { children: React.ReactNode }, + { failed: boolean } + > { + state = { failed: false } + static getDerivedStateFromError() { + return { failed: true } + } + componentDidCatch(error: Error) { + errors.push(error.message) + } + render() { + return this.state.failed ? ( +
caught
+ ) : ( + this.props.children + ) + } + } + + function Boom() { + const pathname = useRouterState({ + select: (state) => { + if (state.location.pathname === '/posts') { + throw new Error('selector boom') + } + return state.location.pathname + }, + }) + return
{pathname}
+ } + + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts Title

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + experimental_concurrentRenderFrames: true, + }) + + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/posts' }) + }) + + // The navigation completes, and the throw lands where a throwing selector + // lands on the store path: in the nearest error boundary. + await waitFor(() => screen.getByRole('heading', { name: 'Posts Title' })) + await navigation + expect(router.stores.status.get()).toBe('idle') + await waitFor(() => screen.getByTestId('caught')) + expect(errors).toContain('selector boom') + }) +}) From 4eba1b453fe8f5c1eb296a6678786d716d73f86d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 22:47:12 +0000 Subject: [PATCH 21/74] fix: navigate from the location a link's href was built from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the frame path a link's `href` is built from the location the render is presenting, which during a pending navigation is the route on screen rather than the router's head. The click and the preload still passed the options through untouched, so `buildLocation` resolved them against `latestLocation`: with `/posts?page=1` visible and `/posts?page=5` pending, a link with a functional `search` updater displayed `?page=2` and navigated to `?page=6`. The selector now returns the location it built against, and the click and preload resolve from it — an explicit `_fromLocation` in the link's own options still wins. It costs no re-render: `compareLinkState` already compares only the href and the active flag. The test fails without the change with `Posts 6` on screen. Also stops the selector hook holding its in-progress selection in a ref. The ref is shared by the current and work-in-progress trees, so a later render could overwrite it before an earlier one committed, and the earlier tree's layout effect would record a selection that was never on its screen — enough to skip a re-render it needed. It is a plain local now, and each render's effect closes over its own. Like the other commit-ordering fixes here, `act()` cannot open that window, so this rests on the mechanism rather than on a failing test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/link.tsx | 38 +++++++-- .../react-router/src/routerStateContext.tsx | 19 +++-- .../tests/concurrent-render-frames.test.tsx | 79 +++++++++++++++++++ 3 files changed, 120 insertions(+), 16 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 7af1fede0fc..a6f547b6ed1 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -36,7 +36,20 @@ import type { // Undefined active state marks an external or blocked link. // Keep that classification with the href instead of parsing it again on render. -type LinkState = [href: string | undefined, isActive?: boolean] +/** + * `from` is the location the `href` was built against. Navigation and + * preloading have to resolve against that same location, not whichever one the + * router has reached since: with concurrent render frames a link rendered + * against the visible route would otherwise navigate relative to the route + * being prepared, so a functional `search` updater would build one location + * for the href the user sees and another for the click that follows it. + * `compareLinkState` ignores it, so it cannot cost a re-render. + */ +type LinkState = [ + href: string | undefined, + isActive?: boolean, + from?: ParsedLocation, +] // Keep a referentially stable value while the contents are equal. Links // routinely pass inline `params` / `search` object literals, which would @@ -454,12 +467,14 @@ export function useLinkProps< router.basepath, isHydrated, ), + location, ] }, [stableActiveOptions, disabled, isHydrated, _options, router, to], ) - const [href, isActive] = router.options.experimental_concurrentRenderFrames + const [href, isActive, hrefFrom] = router.options + .experimental_concurrentRenderFrames ? // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static useRouterStateSelector( router, @@ -484,12 +499,16 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks const doPreload = React.useCallback(() => { // `preloadRoute` builds the location itself; it is no longer held in render - // state. It only reads the options, so `_options` can go through as-is. - router.preloadRoute(_options as any).catch((err) => { - console.warn(err) - console.warn(preloadWarning) - }) - }, [router, _options]) + // state. It resolves against `hrefFrom` so it preloads the destination this + // link is displaying, and an explicit `_fromLocation` in the options still + // wins. + router + .preloadRoute({ _fromLocation: hrefFrom, ..._options } as any) + .catch((err) => { + console.warn(err) + console.warn(preloadWarning) + }) + }, [router, _options, hrefFrom]) // eslint-disable-next-line react-hooks/rules-of-hooks const enqueuePreload = React.useCallback( @@ -599,6 +618,9 @@ export function useLinkProps< // All is well? Navigate! // N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing router.navigate({ + // Resolve against the location this link's href was built from, so the + // click goes where the href says it does. + _fromLocation: hrefFrom, ..._options, replace, resetScroll, diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 6ea2c028d1d..f85f91419b1 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -364,11 +364,6 @@ export function useRouterStateSelector( frameId: offeredFrame(scope).frameId, revision: 0, })) - // The selection for the render currently executing. A render can be - // discarded — suspended, interrupted, or superseded — so this is - // work in progress, not necessarily what anyone can see. - // eslint-disable-next-line react-hooks/rules-of-hooks - const rendered = React.useRef(undefined as TSelected) // What actually reached the screen: the selection, and the selector and // comparator that produced it. Kept together, because comparing a value from // one selector against a value from another is meaningless. Boxed so that a @@ -383,11 +378,19 @@ export function useRouterStateSelector( | undefined >(undefined) - rendered.current = selector(resolveFrame(scope, presenting.frameId)) + // The selection for the render currently executing. A render can be + // discarded — suspended, interrupted, or superseded — so this is work in + // progress, not necessarily what anyone can see; it is a plain local, and + // the effect below closes over it, so each render carries its own. Holding + // it in a ref instead would let a later render overwrite it before an + // earlier one commits, and the earlier tree's effect would then record a + // selection that was never on its screen — enough to skip a re-render it + // needed. + const rendered = selector(resolveFrame(scope, presenting.frameId)) // eslint-disable-next-line react-hooks/rules-of-hooks useLayoutEffect(() => { - committed.current = { value: rendered.current, selector, compare } + committed.current = { value: rendered, selector, compare } }) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -463,7 +466,7 @@ export function useRouterStateSelector( return unsubscribe }, [scope]) - return rendered.current + return rendered } /** diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 757874bbc0f..30cc249bb50 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -851,6 +851,85 @@ describe('concurrent render frames', () => { expect(screen.getByTestId('pathname')).toHaveTextContent('/') }) + + /** + * A link's `href` is built from the location it is rendered against, which + * on the frame path is the one on screen rather than the router's head. The + * click has to resolve against that same location, or a functional `search` + * updater sends the user somewhere other than where the href they saw + * pointed. + */ + test('a click resolves against the location the href was built from', async () => { + const gate = deferred() + let loads = 0 + + const rootRoute = createRootRoute({ + component: () => ( + <> + ({ page: (prev.page ?? 1) + 1 })} + > + Next page + + + + ), + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateSearch: (search: Record) => ({ + page: Number(search.page ?? 1), + }), + // The search is part of the loader key, so each page really loads. + loaderDeps: ({ search }: { search: { page: number } }) => ({ + page: search.page, + }), + // Every load after the first one hangs until the test lets it through, + // which is the window in which the visible route and the head disagree. + loader: async () => { + loads++ + if (loads > 1) { + await gate.promise + } + }, + component: () => { + const page = postsRoute.useSearch({ select: (s) => s.page }) + return

{`Posts ${page}`}

+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + experimental_concurrentRenderFrames: true, + }) + + window.history.replaceState(null, '', '/posts?page=1') + render() + await waitFor(() => screen.getByRole('heading', { name: 'Posts 1' })) + + const link = () => screen.getByRole('link', { name: 'Next page' }) + expect(link()).toHaveAttribute('href', '/posts?page=2') + + // Head moves to page 5 and stays pending, so the head and the visible + // route disagree about what "the next page" is. + act(() => { + void router.navigate({ to: '/posts', search: { page: 5 } }) + }) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + screen.getByRole('heading', { name: 'Posts 1' }) + expect(link()).toHaveAttribute('href', '/posts?page=2') + + act(() => { + fireEvent.click(link()) + }) + gate.resolve() + + await waitFor(() => screen.getByRole('heading', { name: 'Posts 2' })) + expect(router.stores.location.get().search).toEqual({ page: 2 }) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From e22b3fc2b017cd8d54adad38b992780a35955dc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 23:08:54 +0000 Subject: [PATCH 22/74] fix: freeze the frame-path decision per component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The option gates which hooks a reader calls, and every call site branched on it live. The router a component reads is not fixed, though: `useRouterState({ router })` takes one as an option, and a provider can be re-rendered with another. Handing a component a router configured the other way therefore changed its hook sequence and React failed on the hook order — a crash, not a wrong read. The detached scope in the previous commit only covered switches between two routers that both have the option on. Every branch on the option now goes through `useFrameMode`, which decides once at first render and keeps that answer. A reader frozen on the frame path but later handed a router with no owner resolves to that router's store head; one frozen on the store path reads the head directly. Either way it reads the right router's state — it keeps the isolation behaviour it mounted with rather than changing shape. The test swaps across the option in both directions and fails without this. Also documents the limitation Codex raised alongside it: a consumer that mounts mid-navigation, or whose `select` changes while one is in flight, has no earlier selection to compare against and no way to tell which tree is rendering it, so that first render can read the staged route. That is the same open problem as mount-time isolation, and it now says so in the options doc rather than only in the PR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterOptionsType.md | 1 + packages/react-router/src/Match.tsx | 15 ++++-------- packages/react-router/src/Matches.tsx | 19 +++++++-------- packages/react-router/src/RouterProvider.tsx | 10 +++++--- packages/react-router/src/Scripts.tsx | 9 ++++--- .../react-router/src/headContentUtils.tsx | 9 ++++--- packages/react-router/src/link.tsx | 14 +++++++---- packages/react-router/src/not-found.tsx | 9 ++++--- .../react-router/src/routerStateContext.tsx | 24 ++++++++++++++++++- packages/react-router/src/useCanGoBack.ts | 9 ++++--- packages/react-router/src/useLocation.tsx | 11 +++++---- packages/react-router/src/useMatch.tsx | 15 +++++++----- packages/react-router/src/useRouterState.tsx | 11 +++++---- .../tests/concurrent-render-frames.test.tsx | 13 ++++++++-- 14 files changed, 111 insertions(+), 58 deletions(-) diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 609dca316d2..dcfc3850847 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -432,6 +432,7 @@ Two behaviour changes to know about before enabling it: - **Route-level pending components are not used after hydration.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. A child route that suspends bubbles to that boundary, whose fallback comes from the root route, so a child- or parent-specific `pendingComponent` is skipped. Provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`. - **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working. +- **A reader with no previous answer can see the route being prepared.** A consumer that mounts during a navigation, or whose `select` function changes while one is in flight, has no earlier selection to compare against and no way to tell which tree is rendering it, so that first render can read the staged route rather than the visible one. Consumers already mounted with a stable selector are isolated. - **Every reader goes through the frame path, including one that names a router explicitly.** `useRouterState({ router })` pointing at a router with no provider above it reads that router's store head — the same content as before — but through React state rather than `useSyncExternalStore`, so its updates are no longer flushed synchronously. ```tsx diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 4211c8ac5ba..c5a943a3b42 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -13,6 +13,7 @@ import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import { ClientOnly } from './ClientOnly' import { + useFrameMode, useFrameRootBoundary, useRouterStateSelector, } from './routerStateContext' @@ -96,9 +97,7 @@ export const Match = React.memo(function MatchImpl({ routeId: string }) { const router = useRouter() - if (router.options.experimental_concurrentRenderFrames) { - // The option is fixed for the mounted router, so this branch cannot change - // hook order during the component's lifetime. + if (useFrameMode(router)) { // eslint-disable-next-line react-hooks/rules-of-hooks const match = useRouterStateSelector(router, (state) => state.matches.find((candidate) => candidate.routeId === routeId), @@ -329,9 +328,8 @@ export const Outlet = React.memo(function OutletImpl() { let parentNotFoundError: unknown let childRouteId: string | undefined - if (router.options.experimental_concurrentRenderFrames) { - // The option is fixed for the mounted router, so this branch cannot change - // hook order during the component's lifetime. + const frameMode = useFrameMode(router) + if (frameMode) { ;[parentGlobalNotFound, parentNotFoundError, childRouteId] = // eslint-disable-next-line react-hooks/rules-of-hooks useRouterStateSelector( @@ -391,10 +389,7 @@ export const Outlet = React.memo(function OutletImpl() { const nextMatch = // Matches owns the experiment's single acknowledgement boundary. - if ( - routeId === rootRouteId && - !router.options.experimental_concurrentRenderFrames - ) { + if (routeId === rootRouteId && !frameMode) { return ( {nextMatch} diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 8187e50ebba..e0e478bda3e 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -14,6 +14,7 @@ import { Match, renderPending } from './Match' import { SafeFragment } from './SafeFragment' import { RouterStateFrame, + useFrameMode, useFrameRootBoundary, useRouterStateOwner, useRouterStateSelector, @@ -122,10 +123,9 @@ function MatchesInner({ const router = useRouter() const routerStateOwner = useRouterStateOwner() const acknowledgement = router._rendered! + const frameMode = useFrameMode(router) let matches: Array - if (router.options.experimental_concurrentRenderFrames) { - // The option is fixed for the mounted router, so this branch cannot change - // hook order during the component's lifetime. + if (frameMode) { // eslint-disable-next-line react-hooks/rules-of-hooks matches = useRouterStateSelector(router, (state) => state.matches) } else if (isServer ?? router.isServer) { @@ -142,7 +142,7 @@ function MatchesInner({ const routeId = match?.routeId useLayoutEffect(() => { - const acknowledged = router.options.experimental_concurrentRenderFrames + const acknowledged = frameMode ? acknowledgement[0 /* offered */] === activeFrame?.frameId : acknowledgement[0 /* offered */] === matches if (acknowledged) { @@ -154,9 +154,9 @@ function MatchesInner({ }, [ acknowledgement, activeFrame, + frameMode, matches, renderFrame, - router.options.experimental_concurrentRenderFrames, routerStateOwner, setRenderFrame, ]) @@ -238,9 +238,8 @@ export function useMatchRoute(): < } } - if (router.options.experimental_concurrentRenderFrames) { - // The option is fixed for the mounted router, so this branch cannot change - // hook order during the component's lifetime. + // eslint-disable-next-line react-hooks/rules-of-hooks -- server return above, condition is static + if (useFrameMode(router)) { // eslint-disable-next-line react-hooks/rules-of-hooks const state = useRouterStateSelector(router, (frameState) => frameState) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -355,9 +354,7 @@ export function useMatches< ): UseMatchesResult { const router = useRouter() - if (router.options.experimental_concurrentRenderFrames) { - // The option is fixed for the mounted router, so this branch cannot change - // hook order during the component's lifetime. + if (useFrameMode(router)) { // eslint-disable-next-line react-hooks/rules-of-hooks const selectMatches = useStructuralSharing(opts, router) // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index b767035e922..0950f5cc51c 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -4,7 +4,10 @@ import * as React from 'react' import { hasKeys } from '@tanstack/router-core' import { Matches } from './Matches' import { routerContext } from './routerContext' -import { RouterStateProvider } from './routerStateContext' +import { + RouterStateProvider, + useFrameMode, +} from './routerStateContext' import type { AnyRouter, RegisteredRouter, @@ -37,8 +40,9 @@ export function RouterContextProvider< }) } - const childrenWithState = router.options - .experimental_concurrentRenderFrames ? ( + // Frozen, like every other branch on the option: swapping the router for + // one configured differently must not unmount the whole tree. + const childrenWithState = useFrameMode(router as AnyRouter) ? ( {children} ) : ( children diff --git a/packages/react-router/src/Scripts.tsx b/packages/react-router/src/Scripts.tsx index d422cbfd804..4b471f5d181 100644 --- a/packages/react-router/src/Scripts.tsx +++ b/packages/react-router/src/Scripts.tsx @@ -3,7 +3,10 @@ import { _getAssetMatches, deepEqual } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { Asset } from './Asset' import { useRouter } from './useRouter' -import { useRouterStateSelector } from './routerStateContext' +import { + useFrameMode, + useRouterStateSelector, +} from './routerStateContext' import type { RouterManagedTag } from '@tanstack/router-core' type ScriptRenderAsset = RouterManagedTag & { @@ -64,8 +67,8 @@ export const Scripts = () => { } let scripts: ReturnType - if (router.options.experimental_concurrentRenderFrames) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + if (useFrameMode(router)) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount scripts = useRouterStateSelector( router, (state) => getScripts(state.matches), diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index 1e4da11b70f..ab8203d549e 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -11,7 +11,10 @@ import { } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' -import { useRouterStateSelector } from './routerStateContext' +import { + useFrameMode, + useRouterStateSelector, +} from './routerStateContext' import type { AnyRouteMatch, AssetCrossOriginConfig, @@ -202,8 +205,8 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { buildTagsFromMatches(router, nonce, matches, assetCrossOrigin), [assetCrossOrigin, nonce, router], ) - if (router.options.experimental_concurrentRenderFrames) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + if (useFrameMode(router)) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount return useRouterStateSelector( router, (state) => selectTags(state.matches), diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index a6f547b6ed1..99893209226 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -14,7 +14,10 @@ import { } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' -import { useRouterStateSelector } from './routerStateContext' +import { + useFrameMode, + useRouterStateSelector, +} from './routerStateContext' import { useForwardedRef, useIntersectionObserver } from './utils' @@ -473,15 +476,16 @@ export function useLinkProps< [stableActiveOptions, disabled, isHydrated, _options, router, to], ) - const [href, isActive, hrefFrom] = router.options - .experimental_concurrentRenderFrames - ? // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + // eslint-disable-next-line react-hooks/rules-of-hooks -- server return above, condition is static + const frameMode = useFrameMode(router) + const [href, isActive, hrefFrom] = frameMode + ? // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount useRouterStateSelector( router, (state) => selectLinkState(state.location), compareLinkState, ) - : // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + : // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount useStore(router.stores.location, selectLinkState, compareLinkState) const externalLink = isActive === undefined ? href : undefined const linkDisabled = disabled || href === undefined diff --git a/packages/react-router/src/not-found.tsx b/packages/react-router/src/not-found.tsx index 87f3ca6fe80..40575320bb8 100644 --- a/packages/react-router/src/not-found.tsx +++ b/packages/react-router/src/not-found.tsx @@ -4,7 +4,10 @@ import { isServer } from '@tanstack/router-core/isServer' import { useStore } from '@tanstack/react-store' import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' -import { useRouterStateSelector } from './routerStateContext' +import { + useFrameMode, + useRouterStateSelector, +} from './routerStateContext' import type { ErrorInfo } from 'react' import type { NotFoundError } from '@tanstack/router-core' @@ -16,8 +19,8 @@ export function CatchNotFound(props: { const router = useRouter() let pathname: string let status: 'pending' | 'idle' - if (router.options.experimental_concurrentRenderFrames) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + if (useFrameMode(router)) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount ;[pathname, status] = useRouterStateSelector( router, (state) => [state.location.pathname, state.status] as const, diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index f85f91419b1..cadd24a710f 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -335,6 +335,28 @@ function detachedScope(router: AnyRouter): RouterStateScope { return scope } +/** + * Whether this component reads through the frame path, decided once. + * + * The option gates which hooks a reader calls, and the router a component + * reads is not fixed: `useRouterState({ router })` takes one as an option, + * and a provider can be re-rendered with another. If the answer changed under + * a mounted component, its hook sequence would change with it and React would + * fail on the hook order rather than merely read the other router. So it is + * frozen at first render, and every branch on the option goes through this. + * + * A reader frozen on the frame path but later handed a router with no owner + * resolves to that router's store head; one frozen on the store path reads + * the head directly. Either way it reads the right router's state — it just + * keeps the isolation behaviour it mounted with. + */ +export function useFrameMode(router: AnyRouter): boolean { + const [mode] = React.useState(() => + Boolean(router.options.experimental_concurrentRenderFrames), + ) + return mode +} + export function useRouterStateSelector( router: AnyRouter, selector: (state: RouterState) => TSelected, @@ -481,7 +503,7 @@ export function useFrameRootBoundary( router: AnyRouter, isServerRender: boolean, ): boolean { - if (!router.options.experimental_concurrentRenderFrames) { + if (!useFrameMode(router)) { return false } // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/react-router/src/useCanGoBack.ts b/packages/react-router/src/useCanGoBack.ts index 6482fdc5ecc..a0afae298f1 100644 --- a/packages/react-router/src/useCanGoBack.ts +++ b/packages/react-router/src/useCanGoBack.ts @@ -1,13 +1,16 @@ import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' -import { useRouterStateSelector } from './routerStateContext' +import { + useFrameMode, + useRouterStateSelector, +} from './routerStateContext' export function useCanGoBack() { const router = useRouter() - if (router.options.experimental_concurrentRenderFrames) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + if (useFrameMode(router)) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount return useRouterStateSelector( router, (state) => state.location.state.__TSR_index !== 0, diff --git a/packages/react-router/src/useLocation.tsx b/packages/react-router/src/useLocation.tsx index 5ee3072ed98..c6b74bd4a9a 100644 --- a/packages/react-router/src/useLocation.tsx +++ b/packages/react-router/src/useLocation.tsx @@ -4,7 +4,10 @@ import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' import { useStructuralSharing } from './useMatch' -import { useRouterStateSelector } from './routerStateContext' +import { + useFrameMode, + useRouterStateSelector, +} from './routerStateContext' import type { StructuralSharingOption, ValidateSelected, @@ -53,10 +56,10 @@ export function useLocation< ): UseLocationResult { const router = useRouter() - if (router.options.experimental_concurrentRenderFrames) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + if (useFrameMode(router)) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount const selectLocation = useStructuralSharing(opts, router) - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount return useRouterStateSelector(router, (state) => selectLocation(state.location), ) as UseLocationResult diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index c69bdb08b52..b077a8a7956 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -6,7 +6,10 @@ import { invariant, replaceEqualDeep } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { dummyMatchContext, matchContext } from './matchContext' import { useRouter } from './useRouter' -import { useRouterStateSelector } from './routerStateContext' +import { + useFrameMode, + useRouterStateSelector, +} from './routerStateContext' import type { StructuralSharingOption, ValidateSelected, @@ -151,7 +154,7 @@ export function useMatch< const routeId = opts.from ?? nearestRouteId const matchStore = router.stores.getMatchStore(routeId!) - if (!router.options.experimental_concurrentRenderFrames) { + if (!useFrameMode(router)) { if (isServer ?? router.isServer) { const match = matchStore.get() if (!match) { @@ -171,9 +174,9 @@ export function useMatch< return (opts.select ? opts.select(match as any) : match) as any } - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount const selector = useStructuralSharing(opts, router) - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount const matchSelection = useStore(matchStore, (match) => match ? selector(match as any) : dummyMatch, ) @@ -182,9 +185,9 @@ export function useMatch< return matchSelection as any } } else { - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount const selector = useStructuralSharing(opts, router) - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount const matchSelection = useRouterStateSelector(router, (state) => { const match = state.matches.find( (candidate) => candidate.routeId === routeId, diff --git a/packages/react-router/src/useRouterState.tsx b/packages/react-router/src/useRouterState.tsx index 621b350b426..62f1e1b4cf3 100644 --- a/packages/react-router/src/useRouterState.tsx +++ b/packages/react-router/src/useRouterState.tsx @@ -4,7 +4,10 @@ import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' import { useStructuralSharing } from './useMatch' -import { useRouterStateSelector } from './routerStateContext' +import { + useFrameMode, + useRouterStateSelector, +} from './routerStateContext' import type { AnyRouter, RegisteredRouter, @@ -55,11 +58,11 @@ export function useRouterState< }) const router = opts?.router || contextRouter - if (router.options.experimental_concurrentRenderFrames) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + if (useFrameMode(router)) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount return useRouterStateSelector( router, - // eslint-disable-next-line react-hooks/rules-of-hooks -- option is static + // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount useStructuralSharing(opts, router), ) as UseRouterStateResult } diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 30cc249bb50..a09835a0bfa 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -787,7 +787,7 @@ describe('concurrent render frames', () => { * rather than through the mode matrix. */ describe('concurrent render frames', () => { - const makeRouter = () => { + const makeRouter = (frames = true) => { const rootRoute = createRootRoute({ component: () => }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, @@ -801,7 +801,7 @@ describe('concurrent render frames', () => { }) return createRouter({ routeTree: rootRoute.addChildren([indexRoute, postsRoute]), - experimental_concurrentRenderFrames: true, + experimental_concurrentRenderFrames: frames, }) } @@ -814,6 +814,9 @@ describe('concurrent render frames', () => { test('a consumer whose router argument changes keeps its hook order', async () => { const first = makeRouter() const second = makeRouter() + // Configured the other way, so the swap crosses the option itself and not + // just scope identity. + const plain = makeRouter(false) function Probe({ router }: { router: AnyRouter }) { const pathname = useRouterState({ @@ -849,6 +852,12 @@ describe('concurrent render frames', () => { expect(screen.getByTestId('pathname')).toHaveTextContent('/') swap(second) expect(screen.getByTestId('pathname')).toHaveTextContent('/') + // And onto a router that is not on the frame path at all, which decides + // the branch above `useRouterStateSelector` rather than inside it. + swap(plain) + expect(screen.getByTestId('pathname')).toHaveTextContent('/') + swap(first) + expect(screen.getByTestId('pathname')).toHaveTextContent('/') }) From cc08459b380da421667e92db38f235f148c751ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 23:21:07 +0000 Subject: [PATCH 23/74] fix: read a link's source location at click time, not in render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit carried the location a link's href was built from in the selector's tuple, where `compareLinkState` ignores it so it costs no re-render. That is also why it went stale: a link whose href and active flag do not change never re-renders, so the value it captured was from whichever navigation last moved its selection. An absolute link with a functional `state` updater — whose href is fixed — therefore resolved a click against a location several navigations old. `useRouterStateSelector` now optionally hands back a getter for the publication a consumer is presenting, read at call time and updated at commit. A link resolves its click and its preload through that: the staged publication while it is presenting one, and the committed publication otherwise, which for a link that sat out a navigation is the route now on screen. Nothing is captured, so nothing can go stale. Test: a link with a static search, so its href never changes, whose `state` updater records the history index it resolved against. Without the change it reports 0 after a navigation to index 1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/link.tsx | 47 +++++++------- .../react-router/src/routerStateContext.tsx | 35 ++++++++++ .../tests/concurrent-render-frames.test.tsx | 65 +++++++++++++++++++ 3 files changed, 125 insertions(+), 22 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 99893209226..c6cde7c76d4 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -22,6 +22,7 @@ import { import { useForwardedRef, useIntersectionObserver } from './utils' import { useHydrated } from './ClientOnly' +import type { RouterRenderFrame } from './routerStateContext' import type { ActiveOptions, AnyRouter, @@ -39,20 +40,7 @@ import type { // Undefined active state marks an external or blocked link. // Keep that classification with the href instead of parsing it again on render. -/** - * `from` is the location the `href` was built against. Navigation and - * preloading have to resolve against that same location, not whichever one the - * router has reached since: with concurrent render frames a link rendered - * against the visible route would otherwise navigate relative to the route - * being prepared, so a functional `search` updater would build one location - * for the href the user sees and another for the click that follows it. - * `compareLinkState` ignores it, so it cannot cost a re-render. - */ -type LinkState = [ - href: string | undefined, - isActive?: boolean, - from?: ParsedLocation, -] +type LinkState = [href: string | undefined, isActive?: boolean] // Keep a referentially stable value while the contents are equal. Links // routinely pass inline `params` / `search` object literals, which would @@ -470,7 +458,6 @@ export function useLinkProps< router.basepath, isHydrated, ), - location, ] }, [stableActiveOptions, disabled, isHydrated, _options, router, to], @@ -478,12 +465,25 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks -- server return above, condition is static const frameMode = useFrameMode(router) - const [href, isActive, hrefFrom] = frameMode + // The publication this link is presenting, read at click time rather than + // captured in render. Navigation and preloading have to resolve against the + // location the href was built from — with concurrent render frames a link + // rendered against the visible route would otherwise navigate relative to + // the route being prepared — but a link whose href did not change does not + // re-render, so a value captured here would be from whichever navigation + // last moved it. `undefined` outside the frame path, where the router's own + // head is the right source and already fresh. + // eslint-disable-next-line react-hooks/rules-of-hooks + const presentedFrame = React.useRef<(() => RouterRenderFrame) | undefined>( + undefined, + ) + const [href, isActive] = frameMode ? // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount useRouterStateSelector( router, (state) => selectLinkState(state.location), compareLinkState, + presentedFrame, ) : // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount useStore(router.stores.location, selectLinkState, compareLinkState) @@ -503,16 +503,19 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks const doPreload = React.useCallback(() => { // `preloadRoute` builds the location itself; it is no longer held in render - // state. It resolves against `hrefFrom` so it preloads the destination this - // link is displaying, and an explicit `_fromLocation` in the options still - // wins. + // state. It resolves against the publication this link is presenting, so + // it preloads the destination the link is displaying, and an explicit + // `_fromLocation` in the options still wins. router - .preloadRoute({ _fromLocation: hrefFrom, ..._options } as any) + .preloadRoute({ + _fromLocation: presentedFrame.current?.().location, + ..._options, + } as any) .catch((err) => { console.warn(err) console.warn(preloadWarning) }) - }, [router, _options, hrefFrom]) + }, [router, _options]) // eslint-disable-next-line react-hooks/rules-of-hooks const enqueuePreload = React.useCallback( @@ -624,7 +627,7 @@ export function useLinkProps< router.navigate({ // Resolve against the location this link's href was built from, so the // click goes where the href says it does. - _fromLocation: hrefFrom, + _fromLocation: presentedFrame.current?.().location, ..._options, replace, resetScroll, diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index cadd24a710f..d6df711df6b 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -361,6 +361,19 @@ export function useRouterStateSelector( router: AnyRouter, selector: (state: RouterState) => TSelected, compare: (a: TSelected, b: TSelected) => boolean = defaultCompare, + /** + * Filled with a getter for the publication this consumer is presenting, for + * a caller that needs it outside render — an event handler resolving against + * the route the user is looking at, say. + * + * It has to be a getter rather than a value: a consumer whose selection did + * not change does not re-render, so anything captured in render would be + * from whichever navigation last moved its selection. Reading at call time + * gives the staged publication while this consumer is presenting one, and + * the committed publication otherwise — which for a consumer that sat out a + * navigation is the route now on screen. + */ + presentedFrame?: React.MutableRefObject<(() => RouterRenderFrame) | undefined>, ): TSelected { const ownerScope = React.useContext(routerStateScopeContext) // Not conditional on anything that can change: whichever scope this reader @@ -386,6 +399,11 @@ export function useRouterStateSelector( frameId: offeredFrame(scope).frameId, revision: 0, })) + // The publication this consumer is presenting, for `presentedFrame` to read + // after the fact. Updated at commit, so it describes the tree on screen + // rather than a render that may yet be discarded. + // eslint-disable-next-line react-hooks/rules-of-hooks + const presentingRef = React.useRef(presenting) // What actually reached the screen: the selection, and the selector and // comparator that produced it. Kept together, because comparing a value from // one selector against a value from another is meaningless. Boxed so that a @@ -413,8 +431,25 @@ export function useRouterStateSelector( // eslint-disable-next-line react-hooks/rules-of-hooks useLayoutEffect(() => { committed.current = { value: rendered, selector, compare } + presentingRef.current = presenting }) + // eslint-disable-next-line react-hooks/rules-of-hooks + const getPresentedFrame = React.useCallback( + () => resolveFrame(scope, presentingRef.current.frameId), + [scope], + ) + // eslint-disable-next-line react-hooks/rules-of-hooks + useLayoutEffect(() => { + if (!presentedFrame) { + return + } + presentedFrame.current = getPresentedFrame + return () => { + presentedFrame.current = undefined + } + }, [getPresentedFrame, presentedFrame]) + // eslint-disable-next-line react-hooks/rules-of-hooks useLayoutEffect(() => { // Accept a publication only when this subscriber's own selection changed, diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index a09835a0bfa..c61afb064b6 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -939,6 +939,71 @@ describe('concurrent render frames', () => { expect(router.stores.location.get().search).toEqual({ page: 2 }) }) + + /** + * The other half of that: a link whose href does not change does not + * re-render, so anything its render captured is from whichever navigation + * last moved it. The location has to be read when the click happens. + */ + test('a click resolves against the current location when the href never changed', async () => { + const rootRoute = createRootRoute({ + component: () => ( + <> + ({ from: prev.__TSR_index })} + > + Fixed target + + + + ), + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateSearch: (search: Record) => ({ + page: Number(search.page ?? 1), + }), + component: () => { + const page = postsRoute.useSearch({ select: (s) => s.page }) + return

{`Posts ${page}`}

+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + experimental_concurrentRenderFrames: true, + }) + + window.history.replaceState(null, '', '/posts?page=1') + render() + await waitFor(() => screen.getByRole('heading', { name: 'Posts 1' })) + + const link = () => screen.getByRole('link', { name: 'Fixed target' }) + // Static search, so this href is the same before and after the navigation + // below and the link has no reason to re-render. + expect(link()).toHaveAttribute('href', '/posts?page=9') + + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/posts', search: { page: 2 } }) + }) + await waitFor(() => screen.getByRole('heading', { name: 'Posts 2' })) + await navigation + expect(link()).toHaveAttribute('href', '/posts?page=9') + + const indexOnScreen = (router.stores.location.get().state as any).__TSR_index + + act(() => { + fireEvent.click(link()) + }) + await waitFor(() => screen.getByRole('heading', { name: 'Posts 9' })) + // The updater ran against the location that was on screen when it was + // clicked, not the one the link last rendered against. + expect((router.stores.location.get().state as any).from).toBe(indexOnScreen) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From 35331a6d28509d9fb69e34e04f86db0d2484c024 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 23:32:01 +0000 Subject: [PATCH 24/74] fix: leave a structural-sharing cache untouched when probing an offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural sharing keeps a consumer's selection referentially stable by caching the last result and returning it again whenever the next one is deep-equal. Deciding whether an offered publication changed a consumer's selection runs that selector outside render, against a publication that may never commit — and the cache write from there described a render nobody saw. If the staged route then suspended and the visible tree re-rendered urgently, selecting its unchanged committed publication started from the staged value, so it returned a *fresh* object: the stability the option promises, broken, and every memoized child below it re-rendered for nothing. A structural-sharing selector now carries handles to save and put back its cache, forwarded onto the closures that select from a frame through one, and the probe restores it afterwards. A consumer that accepts the offer re-renders and writes the cache for real. Test: an object selection re-rendered urgently while the staged route is suspended. Without the change it comes back deep-equal but not identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 11 ++- .../react-router/src/routerStateContext.tsx | 16 +++- packages/react-router/src/useLocation.tsx | 10 ++- packages/react-router/src/useMatch.tsx | 68 +++++++++++--- .../tests/concurrent-render-frames.test.tsx | 89 +++++++++++++++++++ 5 files changed, 176 insertions(+), 18 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index e0e478bda3e..ff371e891d6 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -6,7 +6,7 @@ import { rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' -import { useStructuralSharing } from './useMatch' +import { useStructuralSharing, withSelectorCache } from './useMatch' import { useLayoutEffect } from './utils' import { Transitioner, settleOwner } from './Transitioner' import { matchContext } from './matchContext' @@ -37,6 +37,7 @@ import type { MatchRouteOptions, RegisteredRouter, ResolveRoute, + RouterState, ToSubOptionsProps, } from '@tanstack/router-core' @@ -358,8 +359,12 @@ export function useMatches< // eslint-disable-next-line react-hooks/rules-of-hooks const selectMatches = useStructuralSharing(opts, router) // eslint-disable-next-line react-hooks/rules-of-hooks - return useRouterStateSelector(router, (state) => - selectMatches(state.matches), + return useRouterStateSelector( + router, + withSelectorCache( + (state: RouterState) => selectMatches(state.matches), + selectMatches, + ), ) as UseMatchesResult } diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index d6df711df6b..fcd920ec109 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -5,6 +5,7 @@ import { isServer } from '@tanstack/router-core/isServer' import { useLayoutEffect } from './utils' import { useHydrated } from './ClientOnly' import type { AnyRouter, RouterState } from '@tanstack/router-core' +import type { CacheableSelector } from './useMatch' export type RouterRenderFrame = RouterState @@ -359,7 +360,7 @@ export function useFrameMode(router: AnyRouter): boolean { export function useRouterStateSelector( router: AnyRouter, - selector: (state: RouterState) => TSelected, + selector: CacheableSelector, TSelected>, compare: (a: TSelected, b: TSelected) => boolean = defaultCompare, /** * Filled with a getter for the publication this consumer is presenting, for @@ -412,7 +413,7 @@ export function useRouterStateSelector( const committed = React.useRef< | { value: TSelected - selector: (state: RouterState) => TSelected + selector: CacheableSelector, TSelected> compare: (a: TSelected, b: TSelected) => boolean } | undefined @@ -480,10 +481,21 @@ export function useRouterStateSelector( onScreen: NonNullable, frame: RouterRenderFrame, ) => { + // A structural-sharing selector caches its last result to keep the + // selection referentially stable, and this runs it against a + // publication that may never commit. Writing that cache here would + // leave the still-visible tree comparing against a result it never + // rendered, and it would return a fresh object next time — breaking + // the stability the option promises. So the cache is put back + // afterwards; a consumer that accepts the offer re-renders and writes + // it for real. + const cached = onScreen.selector.snapshotCache?.() try { return onScreen.compare(onScreen.value, onScreen.selector(frame)) } catch { return false + } finally { + onScreen.selector.restoreCache?.(cached) } } diff --git a/packages/react-router/src/useLocation.tsx b/packages/react-router/src/useLocation.tsx index c6b74bd4a9a..21bd07ce4f3 100644 --- a/packages/react-router/src/useLocation.tsx +++ b/packages/react-router/src/useLocation.tsx @@ -3,7 +3,7 @@ import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' -import { useStructuralSharing } from './useMatch' +import { useStructuralSharing, withSelectorCache } from './useMatch' import { useFrameMode, useRouterStateSelector, @@ -60,8 +60,12 @@ export function useLocation< // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount const selectLocation = useStructuralSharing(opts, router) // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount - return useRouterStateSelector(router, (state) => - selectLocation(state.location), + return useRouterStateSelector( + router, + withSelectorCache( + (state: RouterState) => selectLocation(state.location), + selectLocation, + ), ) as UseLocationResult } diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index b077a8a7956..e97dadbe221 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -19,6 +19,7 @@ import type { MakeRouteMatch, MakeRouteMatchUnion, RegisteredRouter, + RouterState, StrictOrFrom, ThrowConstraint, ThrowOrOptional, @@ -26,6 +27,40 @@ import type { const dummyMatch = {} +/** + * A selector whose cached previous result can be saved and put back. + * + * Structural sharing keeps a consumer's selection referentially stable by + * caching the last result and returning it again whenever the next one is + * deep-equal. The render-frame path also runs selectors *outside* render, to + * decide whether a consumer's selection changed under a publication it has + * been offered — and a cache write from there describes a render that may + * never commit. So that path saves the cache, runs the selector, and puts the + * cache back; a consumer that accepts the offer re-renders and writes it for + * real. See `useRouterStateSelector`. + */ +export type CacheableSelector = (( + slice: TSlice, +) => TSelected) & { + snapshotCache?: () => unknown + restoreCache?: (cached: unknown) => void +} + +/** + * Carry a selector's cache handles onto a closure wrapping it, so a caller + * that selects from a frame through a structural-sharing selector stays + * probe-safe. + */ +export function withSelectorCache( + wrapper: (slice: TOuter) => TSelected, + inner: CacheableSelector, +): CacheableSelector { + const cacheable: CacheableSelector = wrapper + cacheable.snapshotCache = inner.snapshotCache + cacheable.restoreCache = inner.restoreCache + return cacheable +} + export function useStructuralSharing< TRouter extends AnyRouter, TSelected, @@ -42,14 +77,18 @@ export function useStructuralSharing< } | undefined, router: TRouter, -): ( - slice: TStoreSlice, -) => ValidateSelected { +): CacheableSelector< + TStoreSlice, + ValidateSelected +> { const previousResult = // @ts-expect-error -- init to undefined, but without writing `undefined` to shave bytes React.useRef>() - return (slice) => { + const select: CacheableSelector< + TStoreSlice, + ValidateSelected + > = (slice) => { const selected = opts?.select ? opts.select(slice as unknown as TSelectSlice) : (slice as ValidateSelected) @@ -63,6 +102,12 @@ export function useStructuralSharing< return selected } + select.snapshotCache = () => previousResult.current + select.restoreCache = (cached: unknown) => { + previousResult.current = + cached as ValidateSelected + } + return select } export interface UseMatchBaseOptions< @@ -188,12 +233,15 @@ export function useMatch< // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount const selector = useStructuralSharing(opts, router) // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount - const matchSelection = useRouterStateSelector(router, (state) => { - const match = state.matches.find( - (candidate) => candidate.routeId === routeId, - ) - return match ? selector(match as any) : dummyMatch - }) + const matchSelection = useRouterStateSelector( + router, + withSelectorCache((state: RouterState) => { + const match = state.matches.find( + (candidate) => candidate.routeId === routeId, + ) + return match ? selector(match as any) : dummyMatch + }, selector), + ) if (matchSelection !== dummyMatch) { return matchSelection as any diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index c61afb064b6..d37ec58a906 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -1004,6 +1004,95 @@ describe('concurrent render frames', () => { expect((router.stores.location.get().state as any).from).toBe(indexOnScreen) }) + + /** + * Structural sharing promises a referentially stable selection. Deciding + * whether an offer changed a consumer's selection runs its selector outside + * render, against a publication that may never commit, so that decision must + * not leave its cache describing a render nobody saw. + */ + test('an offer does not disturb a structural-sharing selection', async () => { + let releaseNext: () => void = () => {} + let nextReady = false + const nextGate = new Promise((resolve) => { + releaseNext = () => { + nextReady = true + resolve() + } + }) + + function NextPage() { + if (!nextReady) { + throw nextGate + } + return

Next Title

+ } + + const selections: Array<{ pathname: string }> = [] + + function IndexPage() { + const [bumps, setBumps] = React.useState(0) + // An object selection, so structural sharing is what keeps its identity + // stable across renders that do not change it. + const selected = useRouterState({ + structuralSharing: true, + select: (state) => ({ pathname: state.location.pathname }), + }) + selections.push(selected) + return ( + <> +

Index Title

+ +
{`${selected.pathname}|${bumps}`}
+ + ) + } + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexPage, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: NextPage, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + const onScreen = selections.at(-1)! + + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/next' }) + }) + await waitFor(() => + expect(router.stores.location.get().pathname).toBe('/next'), + ) + + // The staged route is suspended, so the visible tree is still the one on + // screen. Re-render it urgently: its selection is unchanged, so it must be + // the same object it rendered before. + fireEvent.click(screen.getByRole('button', { name: 'Bump' })) + expect(screen.getByTestId('inside').textContent).toBe('/|1') + expect(selections.at(-1)).toBe(onScreen) + + await act(async () => { + releaseNext() + await nextGate + }) + await navigation + await waitFor(() => screen.getByRole('heading', { name: 'Next Title' })) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From 125bdefdc38716e98cffc57ea65f756f2df2c467 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 23:40:41 +0000 Subject: [PATCH 25/74] fix: keep a pending matcher following the head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the frame path `useMatchRoute` subscribed only to the publication it presents, but an explicit `matchRoute({ pending: true })` deliberately resolves against the head — that is what makes destination-aware indicators work while a navigation is in flight. A second navigation starting while the first is still pending moves only the head location: `status` stays `pending`, no frame is staged, and the presented one is identical, so nothing re-rendered the matcher. A destination indicator therefore kept reporting the navigation that had already been superseded until the next frame landed. The frame path now also subscribes to the head `location.href`, which is what the default path already does. It is the one hook that spans both sides, so it is the one that subscribes to both; non-pending queries still resolve against the presented frame, so nothing reads ahead. Test: two gated navigations, the second superseding the first while `status` never leaves `pending`. Without the change the indicator reads `true|false` where it should read `false|true`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 15 +++- .../tests/concurrent-render-frames.test.tsx | 76 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index ff371e891d6..8666db7103a 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -261,7 +261,20 @@ export function useMatchRoute(): < } as any, ) }, - [router, state], + [ + router, + state, + // An explicit `matchRoute({ pending: true })` resolves against the + // head, so this hook has to re-render when the head moves — and a + // second navigation starting while the first is still pending changes + // only the location, which stages no frame and leaves the presented + // one identical. Without this a destination indicator would keep + // reporting the navigation that has already been superseded. It is the + // one hook that spans both, so it is the one that subscribes to both; + // non-pending queries still resolve against the presented frame. + // eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps + useStore(router.stores.location, (location) => location.href), + ], ) } diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index d37ec58a906..337673a38ac 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -22,6 +22,7 @@ import { createRoute, createRouter, useLocation, + useMatchRoute, useRouterState, } from '../src' import type { AnyRouter } from '@tanstack/router-core' @@ -1093,6 +1094,81 @@ describe('concurrent render frames', () => { await waitFor(() => screen.getByRole('heading', { name: 'Next Title' })) }) + + /** + * A destination indicator asks about the navigation in flight, so it + * resolves against the head. A second navigation starting while the first is + * still pending moves only the head location — no frame is staged, and the + * presented one is identical — so nothing would re-render it. + */ + test('a pending matcher follows the head when a navigation is superseded', async () => { + const first = deferred() + const second = deferred() + + function Indicator() { + const matchRoute = useMatchRoute() + const toFirst = !!matchRoute({ to: '/first', pending: true }) + const toSecond = !!matchRoute({ to: '/second', pending: true }) + return
{`${toFirst}|${toSecond}`}
+ } + + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + loader: () => first.promise, + component: () =>

First Title

, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: () => second.promise, + component: () =>

Second Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + expect(screen.getByTestId('target').textContent).toBe('false|false') + + act(() => { + void router.navigate({ to: '/first' }) + }) + await waitFor(() => + expect(screen.getByTestId('target').textContent).toBe('true|false'), + ) + + // Supersede it. `status` stays 'pending' throughout, so the only thing + // that moves is the head location. + act(() => { + void router.navigate({ to: '/second' }) + }) + await waitFor(() => + expect(screen.getByTestId('target').textContent).toBe('false|true'), + ) + + await act(async () => { + first.resolve() + second.resolve() + }) + await waitFor(() => screen.getByRole('heading', { name: 'Second Title' })) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From 70d36fd7ec6bd7bc88aa72c5cb51559b9d947b0c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 23:45:01 +0000 Subject: [PATCH 26/74] fix: tag a queued render frame with the router that produced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Matches` held the queued frame in plain state, so it belonged to whichever router last set it. A router swapped under a mounted provider keeps its own navigation in flight, and with it the `startTransition` override holding that dispatch, so its staged frame could still arrive after the swap: it would mask the current router's own frame, whose acknowledgement then never settles — and because `frameId` counts per router, a collision could commit the wrong router's snapshot outright. The frame now carries the router that produced it and a foreign one is ignored. No test, and I would rather say why than imply one exists: swapping the router under a mounted `RouterProvider` does not render the replacement at all, on this path or the default one. A two-router probe asserting only that the second router's route appears fails identically with the option on and off, so the masking scenario cannot be reached end to end from the harness. The fix is a defensive identity check on a dispatch that can outlive its router. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 21 +++++++++++++++---- packages/react-router/src/Transitioner.tsx | 4 +--- .../tests/concurrent-render-frames.test.tsx | 1 + 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 8666db7103a..15713ddf26d 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -58,7 +58,22 @@ declare module '@tanstack/router-core' { export function Matches() { const router = useRouter() const routerStateOwner = useRouterStateOwner() - const [renderFrame, setRenderFrame] = React.useState() + // Tagged with the router that produced it. A router swapped under a mounted + // provider keeps its own navigation in flight, along with the + // `startTransition` override holding this dispatch, so its staged frame can + // still arrive here afterwards. Untagged it would mask the current router's + // own frame — and because `frameId` counts per router, a collision could + // commit the wrong router's snapshot outright. + const [queuedFrame, setQueuedFrame] = React.useState< + { router: AnyRouter; frame: RouterRenderFrame } | undefined + >() + const renderFrame = + queuedFrame?.router === router ? queuedFrame.frame : undefined + const setRenderFrame = React.useCallback( + (frame: RouterRenderFrame | undefined) => + setQueuedFrame(frame ? { router, frame } : undefined), + [router], + ) const activeFrame = renderFrame ?? routerStateOwner?.frame const rootRoute: AnyRoute = router.routesById[rootRouteId] @@ -117,9 +132,7 @@ function MatchesInner({ }: { activeFrame?: RouterRenderFrame renderFrame?: RouterRenderFrame - setRenderFrame: React.Dispatch< - React.SetStateAction - > + setRenderFrame: (frame: RouterRenderFrame | undefined) => void }) { const router = useRouter() const routerStateOwner = useRouterStateOwner() diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index 1d23929bb5c..18c41171de6 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -22,9 +22,7 @@ export function Transitioner({ setRenderFrame, }: { t: React.Dispatch> - setRenderFrame: React.Dispatch< - React.SetStateAction - > + setRenderFrame: (frame: RouterRenderFrame | undefined) => void }) { const router = useRouter() const routerStateOwner = useRouterStateOwner() diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 337673a38ac..11e4687fe3c 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -1169,6 +1169,7 @@ describe('concurrent render frames', () => { await waitFor(() => screen.getByRole('heading', { name: 'Second Title' })) }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From 1e6d280c9cdbdcf0bc145c6c09bbd531b7f6e521 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 23:53:16 +0000 Subject: [PATCH 27/74] fix: key a consumer's presented frame by its scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `frameId` only means anything within one scope: each router counts frames from its own start. A consumer carried across a scope change kept the identity it had, so a collision with the new router's counter would read as this consumer having accepted a frame it was never offered — and an urgent local update could then render destination state outside the transition. The identity now carries its scope and is rebased when the scope changes, keeping `revision` monotonic so a pending update cannot land on a value React considers unchanged. Also takes the refresh decision before calling `setPresenting`, which CodeRabbit is right about: the updater ran `stillHolds`, which runs user code and borrows the selector's cache, and React may call an updater more than once — twice in Strict Mode — so the selector could run more often than there were notifications. It also has to answer for the publication the notification described rather than whatever an updater running later resolves to. And the hook-order test now starts its second router at `/posts` on a memory history, so the assertions say which router was read. Both routers previously sat at `/` and agreed, which would have hidden a reader following the owner instead of the router it was handed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 46 +++++++++++++++---- .../tests/concurrent-render-frames.test.tsx | 20 ++++++-- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index fcd920ec109..7889daf79ef 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -395,11 +395,32 @@ export function useRouterStateSelector( // publication without the still-visible tree following it there. `revision` // makes every accepted update a distinct state value, so a progress-only // change — same frame, new status — still re-renders. + // + // The scope is part of it because `frameId` only means anything within one: + // a different router counts frames from its own start, so an identity + // carried over could collide and read as this consumer having accepted a + // frame it was never offered. A scope change starts the identity over — + // rebased rather than reset, so `revision` stays monotonic and a pending + // update cannot land on a value React considers unchanged. // eslint-disable-next-line react-hooks/rules-of-hooks - const [presenting, setPresenting] = React.useState(() => ({ + const [stored, setPresenting] = React.useState(() => ({ + scope, frameId: offeredFrame(scope).frameId, revision: 0, })) + // eslint-disable-next-line react-hooks/rules-of-hooks + const rebase = React.useCallback( + (previous: { scope: RouterStateScope; frameId: number; revision: number }) => + previous.scope === scope + ? previous + : { + scope, + frameId: offeredFrame(scope).frameId, + revision: previous.revision, + }, + [scope], + ) + const presenting = rebase(stored) // The publication this consumer is presenting, for `presentedFrame` to read // after the fact. Updated at commit, so it describes the tree on screen // rather than a render that may yet be discarded. @@ -499,16 +520,24 @@ export function useRouterStateSelector( } } + // Decided here rather than inside the updater: `stillHolds` runs user code + // and borrows the selector's cache, and React may call an updater more than + // once — twice in Strict Mode — so a side-effecting one would run the + // selector more often than there were notifications. It also has to answer + // for the publication this notification described, which an updater + // running later might not resolve to. const refresh = () => { const onScreen = committed.current if (!onScreen) { return } - setPresenting((previous) => - stillHolds(onScreen, resolveFrame(scope, previous.frameId)) - ? previous - : { ...previous, revision: previous.revision + 1 }, - ) + if (stillHolds(onScreen, resolveFrame(scope, presentingRef.current.frameId))) { + return + } + setPresenting((previous) => { + const base = rebase(previous) + return { ...base, revision: base.revision + 1 } + }) } const unsubscribe = scope.subscribe((offered) => { @@ -521,8 +550,9 @@ export function useRouterStateSelector( return } setPresenting((previous) => ({ + scope, frameId: offered.frameId, - revision: previous.revision + 1, + revision: rebase(previous).revision + 1, })) }) @@ -533,7 +563,7 @@ export function useRouterStateSelector( refresh() return unsubscribe - }, [scope]) + }, [rebase, scope]) return rendered } diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 11e4687fe3c..a09e0efb7c6 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -18,6 +18,7 @@ import { Outlet, RouterContextProvider, RouterProvider, + createMemoryHistory, createRootRoute, createRoute, createRouter, @@ -788,7 +789,7 @@ describe('concurrent render frames', () => { * rather than through the mode matrix. */ describe('concurrent render frames', () => { - const makeRouter = (frames = true) => { + const makeRouter = (frames = true, initialPath?: string) => { const rootRoute = createRootRoute({ component: () => }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, @@ -803,6 +804,13 @@ describe('concurrent render frames', () => { return createRouter({ routeTree: rootRoute.addChildren([indexRoute, postsRoute]), experimental_concurrentRenderFrames: frames, + // A memory history where a distinct starting location is wanted: both + // routers would otherwise read the same browser history and agree, + // which would hide a reader following the owner instead of the router + // it was handed. + ...(initialPath + ? { history: createMemoryHistory({ initialEntries: [initialPath] }) } + : {}), }) } @@ -814,7 +822,8 @@ describe('concurrent render frames', () => { */ test('a consumer whose router argument changes keeps its hook order', async () => { const first = makeRouter() - const second = makeRouter() + // Starts somewhere else, so the assertions say *which* router was read. + const second = makeRouter(true, '/posts') // Configured the other way, so the swap crosses the option itself and not // just scope identity. const plain = makeRouter(false) @@ -828,7 +837,8 @@ describe('concurrent render frames', () => { } // `second` has no owner above it here, so it resolves to a different scope - // than `first` does. + // than `first` does — and it is at `/posts`, so this also pins that the + // reader followed the router it was handed rather than the owner above it. const { rerender } = render( @@ -836,7 +846,7 @@ describe('concurrent render frames', () => { , ) - expect(screen.getByTestId('pathname')).toHaveTextContent('/') + expect(screen.getByTestId('pathname')).toHaveTextContent('/posts') const swap = (router: AnyRouter) => rerender( @@ -852,7 +862,7 @@ describe('concurrent render frames', () => { swap(first) expect(screen.getByTestId('pathname')).toHaveTextContent('/') swap(second) - expect(screen.getByTestId('pathname')).toHaveTextContent('/') + expect(screen.getByTestId('pathname')).toHaveTextContent('/posts') // And onto a router that is not on the frame path at all, which decides // the branch above `useRouterStateSelector` rather than inside it. swap(plain) From 262c61e65c047d8adeed00384066ef49be9930a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:07:20 +0000 Subject: [PATCH 28/74] fix: stop hydration remounting the route tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper at the root of the route tree decides an element *type*, and it was keyed on hydration: a fragment while hydrating, a `Suspense` boundary once hydrated. React reads a changed type as a replacement, so in any server-rendered app with this option on, the entire route subtree unmounted and remounted the moment hydration finished — mount effects re-run, and anything a component set up while hydrating discarded. Once per page load. The decision now comes from the option and whether the app renders on the server, both fixed for the tree's lifetime. A server-rendered app does not consolidate at all: it keeps upstream's route-level boundaries, which is what its streamed HTML already describes, and gives up atomic acknowledgement — a child that suspends resolves at its own boundary, so a frame can be acknowledged while part of the tree is still pending. That is upstream's behaviour today, and a far better trade than remounting the route tree. Tested against the repo's own hydration harness — server render, `hydrateRoot`, count the route component's mounts. Without the change it reports `['mount', 'unmount', 'mount']`. A first attempt at this test passed either way, because `useHydrated` returns true on the first render under `render()` and the flip never happens; it took real `hydrateRoot` plus `router.ssr` set the way the Start client sets it to reach the window at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterOptionsType.md | 2 +- .../react-router/src/routerStateContext.tsx | 27 +++--- ...oncurrent-render-frames-hydration.test.tsx | 94 +++++++++++++++++++ .../tests/concurrent-render-frames.test.tsx | 1 + 4 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 packages/react-router/tests/concurrent-render-frames-hydration.test.tsx diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index dcfc3850847..89fa4f602d9 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -430,7 +430,7 @@ remountDeps: ({ params }) => params Two behaviour changes to know about before enabling it: -- **Route-level pending components are not used after hydration.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. A child route that suspends bubbles to that boundary, whose fallback comes from the root route, so a child- or parent-specific `pendingComponent` is skipped. Provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`. +- **A client-rendered app does not use route-level pending components.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. A child route that suspends bubbles to that boundary, whose fallback comes from the root route, so a child- or parent-specific `pendingComponent` is skipped. Provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`. **A server-rendered app keeps its route-level boundaries** — that is what its streamed HTML describes, and the boundary decides an element type, so it cannot appear once hydration finishes without remounting the route tree. Such an app therefore gives up atomic acknowledgement: a child that suspends resolves at its own boundary, so a frame can be acknowledged while part of the tree is still pending, exactly as it is without this option. - **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working. - **A reader with no previous answer can see the route being prepared.** A consumer that mounts during a navigation, or whose `select` function changes while one is in flight, has no earlier selection to compare against and no way to tell which tree is rendering it, so that first render can read the staged route rather than the visible one. Consumers already mounted with a stable selector are isolated. - **Every reader goes through the frame path, including one that names a router explicitly.** `useRouterState({ router })` pointing at a router with no provider above it reads that router's store head — the same content as before — but through React state rather than `useSyncExternalStore`, so its updates are no longer flushed synchronously. diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 7889daf79ef..82049744ac9 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -3,7 +3,6 @@ import * as React from 'react' import { isServer } from '@tanstack/router-core/isServer' import { useLayoutEffect } from './utils' -import { useHydrated } from './ClientOnly' import type { AnyRouter, RouterState } from '@tanstack/router-core' import type { CacheableSelector } from './useMatch' @@ -571,20 +570,24 @@ export function useRouterStateSelector( /** * Whether this render should consolidate route suspension at the frame root. * - * Only the frame path asks, so `useHydrated` is never subscribed to on the - * default path. Within the frame branch the hook is unconditional, and the - * branch itself depends only on the option, which is fixed when the router is - * created. + * Answered from the option and whether the app renders on the server, both + * fixed for the tree's lifetime — deliberately, because this decides an + * element *type*. It first followed hydration, which meant the wrapper at the + * root of the route tree changed from a fragment to a `Suspense` boundary the + * moment hydration finished: React reads a changed type as a replacement, so + * the whole route subtree unmounted and remounted, re-running mount effects + * and discarding anything a component had set up while hydrating. + * + * A server-rendered app therefore does not consolidate at all. It keeps + * upstream's route-level boundaries, which is what its streamed HTML already + * describes, and gives up atomic acknowledgement: a child that suspends + * resolves at its own boundary, so a frame can be acknowledged while part of + * the tree is still pending. That is upstream's behaviour today, and a far + * better trade than remounting the route tree once per page load. */ export function useFrameRootBoundary( router: AnyRouter, isServerRender: boolean, ): boolean { - if (!useFrameMode(router)) { - return false - } - // eslint-disable-next-line react-hooks/rules-of-hooks - const hydrated = useHydrated() - const isHydrating = Boolean(router.ssr) && !hydrated - return !isServerRender && !isHydrating + return useFrameMode(router) && !isServerRender && !router.ssr } diff --git a/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx b/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx new file mode 100644 index 00000000000..a4b3c19cd83 --- /dev/null +++ b/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import { act } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const cleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (cleanups.length) { + await cleanups.pop()!() + } + document.body.innerHTML = '' +}) + +/** + * The wrapper at the root of the route tree decides an element *type*, so it + * must not depend on anything that changes under a mounted tree. Keyed on + * hydration it flipped from a fragment to a `Suspense` boundary the moment + * hydration finished, and React reads a changed type as a replacement: the + * whole route subtree unmounted and remounted, re-running mount effects and + * discarding whatever a component had set up while hydrating. + */ +test('hydration does not remount the route tree', async () => { + const lifecycle: Array = [] + + function IndexPage() { + React.useEffect(() => { + lifecycle.push('mount') + return () => lifecycle.push('unmount') + }, []) + return

Index Title

+ } + + const makeRouteTree = () => { + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexPage, + }) + return rootRoute.addChildren([indexRoute]) + } + + const makeRouter = () => + createRouter({ + routeTree: makeRouteTree(), + history: createMemoryHistory({ initialEntries: ['/'] }), + experimental_concurrentRenderFrames: true, + }) + + const serverRouter = makeRouter() + serverRouter.isServer = true + serverRouter.ssr = { manifest: undefined } + await serverRouter.load() + const html = renderToString() + expect(html).toContain('Index Title') + + const clientRouter = makeRouter() + // What the Start SSR client sets on hydration, and what the boundary keys + // on: an app whose HTML came from the server. + clientRouter.ssr = { manifest: undefined } + await clientRouter.load() + + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + + await act(async () => { + const root = hydrateRoot(container, , { + onRecoverableError: () => {}, + }) + cleanups.push(async () => { + await act(() => root.unmount()) + }) + await Promise.resolve() + }) + + // Let the hydration flag settle, which is what used to replace the wrapper. + await act(async () => { + await Promise.resolve() + }) + + expect(container).toHaveTextContent('Index Title') + expect(lifecycle).toEqual(['mount']) +}) diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index a09e0efb7c6..d235992547d 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -1180,6 +1180,7 @@ describe('concurrent render frames', () => { }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From e898b9e9cc21f228a900fe7d592acfc9f5b744e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:14:19 +0000 Subject: [PATCH 29/74] docs: say that replacing a router keeps the path it mounted with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frozen frame-mode decision has a consequence Codex is right to name: swapping a router for one configured differently leaves the tree on the setting it mounted with, so enabling the option on a replacement router does not activate it — `RouterContextProvider` keeps its frozen answer and never mounts the state provider. Not fixed here, and I would rather document the trade than guess at it. Unfreezing reintroduces the hook-order crash the freeze exists to prevent. Honouring the new router's setting means the provider must recreate the subtree when the router changes, which is a change to `RouterProvider` semantics for everyone, not just this option — and the scenario cannot be exercised end to end anyway, because the replacement router never loads (a two-router probe fails identically with the option on and off). The doc now tells an adopter to recreate the tree, with a `key`, when replacing a router configured differently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterOptionsType.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 89fa4f602d9..c9def3d3979 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -433,6 +433,7 @@ Two behaviour changes to know about before enabling it: - **A client-rendered app does not use route-level pending components.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. A child route that suspends bubbles to that boundary, whose fallback comes from the root route, so a child- or parent-specific `pendingComponent` is skipped. Provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`. **A server-rendered app keeps its route-level boundaries** — that is what its streamed HTML describes, and the boundary decides an element type, so it cannot appear once hydration finishes without remounting the route tree. Such an app therefore gives up atomic acknowledgement: a child that suspends resolves at its own boundary, so a frame can be acknowledged while part of the tree is still pending, exactly as it is without this option. - **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working. - **A reader with no previous answer can see the route being prepared.** A consumer that mounts during a navigation, or whose `select` function changes while one is in flight, has no earlier selection to compare against and no way to tell which tree is rendering it, so that first render can read the staged route rather than the visible one. Consumers already mounted with a stable selector are isolated. +- **Replacing the router under a mounted provider does not change which path the tree uses.** Whether a component reads through the frame path is decided at its first render, because that decision gates which hooks it calls; a component handed a router configured the other way would otherwise change hook shape and crash on the hook order. The consequence is that swapping a router for one with a different setting leaves the tree on the setting it mounted with — enabling the option on a replacement router does not activate it. Recreate the tree (a fresh `RouterProvider`, or a `key` on it) when you replace a router with one configured differently. - **Every reader goes through the frame path, including one that names a router explicitly.** `useRouterState({ router })` pointing at a router with no provider above it reads that router's store head — the same content as before — but through React state rather than `useSyncExternalStore`, so its updates are no longer flushed synchronously. ```tsx From 2755585f4f1d8237b8eaf2738c532dc3221a60f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:26:18 +0000 Subject: [PATCH 30/74] fix: publish a structural-sharing cache only once the render commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier fix guarded the offer probe. The render itself writes the same cache, and a staged render can be discarded — so a consumer above the changing route, which renders in the staged tree as well as the visible one, left the cache describing a selection nobody saw. The still-visible tree's next render then compared its own publication against that, found it different, and handed back a fresh object, taking every memoized child with it. The cache is now put back to what committed as soon as the render-time selector call returns, and this render's value is published from the commit effect. Write-then-restore of the same ref, so render is left as pure as it was found. Test: an object selection in the *root* component — not inside the route being replaced, so it renders in the staged tree — with the destination suspended and never committing, then an urgent update in the visible tree. Without the change it comes back deep-equal but not identical. Recorded from an effect rather than in render, because a render-phase recording also captures the discarded render and asserts on something that was never on screen; my first attempt at this test did exactly that and failed for the wrong reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 15 +++ .../tests/concurrent-render-frames.test.tsx | 95 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 82049744ac9..314b5a25afd 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -447,12 +447,27 @@ export function useRouterStateSelector( // earlier one commits, and the earlier tree's effect would then record a // selection that was never on its screen — enough to skip a re-render it // needed. + // + // A structural-sharing selector caches its last result to keep the selection + // referentially stable, and this render may be one of the discarded ones — + // so the cache is put back to what committed, and this render's value is + // published only once it commits, below. Left in place, a discarded render's + // write describes a selection nobody saw: the still-visible tree's next + // render would compare its own frame against that, find it different, and + // hand back a fresh object, taking every memoized child with it. The + // restore is a write-then-restore of the same ref, so it leaves render as + // pure as it found it. + const cachedBeforeRender = selector.snapshotCache?.() const rendered = selector(resolveFrame(scope, presenting.frameId)) + selector.restoreCache?.(cachedBeforeRender) // eslint-disable-next-line react-hooks/rules-of-hooks useLayoutEffect(() => { committed.current = { value: rendered, selector, compare } presentingRef.current = presenting + // This render is on screen now, so its selection is the one the next + // render should keep stable. + selector.restoreCache?.(rendered) }) // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index d235992547d..a59512507de 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -1181,6 +1181,101 @@ describe('concurrent render frames', () => { + + /** + * The same cache, mutated by the render rather than by the probe. A consumer + * that sits *above* the changing route renders in the staged tree too, so + * its selector runs against the staged publication — and if that render is + * discarded because something below it suspends, the cache is left + * describing a selection nobody ever saw. + */ + test('a discarded staged render does not disturb a structural-sharing selection', async () => { + let releaseNext: () => void = () => {} + let nextReady = false + const nextGate = new Promise((resolve) => { + releaseNext = () => { + nextReady = true + resolve() + } + }) + + function NextPage() { + if (!nextReady) { + throw nextGate + } + return

Next Title

+ } + + // Recorded from an effect, so only renders that *committed* count. A + // render-phase push would also record the staged render this test is + // arranging to have discarded, which is not what is on screen. + const committedSelections: Array<{ pathname: string }> = [] + + // In the root component, so it renders in the staged tree as well as the + // visible one — unlike a consumer inside the route being replaced. + function Shell() { + const [bumps, setBumps] = React.useState(0) + const selected = useRouterState({ + structuralSharing: true, + select: (state) => ({ pathname: state.location.pathname }), + }) + React.useEffect(() => { + committedSelections.push(selected) + }, [selected]) + return ( + <> + +
{`${selected.pathname}|${bumps}`}
+ + + ) + } + + const rootRoute = createRootRoute({ component: Shell }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: NextPage, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + const onScreen = committedSelections.at(-1)! + + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/next' }) + }) + await waitFor(() => + expect(router.stores.location.get().pathname).toBe('/next'), + ) + // The staged tree is suspended below the shell, so nothing it rendered has + // committed. + expect(screen.getByRole('heading', { name: 'Index Title' })).toBeVisible() + + fireEvent.click(screen.getByRole('button', { name: 'Bump' })) + expect(screen.getByTestId('shell').textContent).toBe('/|1') + expect(committedSelections.at(-1)).toBe(onScreen) + + await act(async () => { + releaseNext() + await nextGate + }) + await navigation + await waitFor(() => screen.getByRole('heading', { name: 'Next Title' })) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From 66de60550e2cec0deafc0102f69b65d42c9b5912 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:31:01 +0000 Subject: [PATCH 31/74] fix: resolve an imperative navigation from the visible route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useNavigate` resolved against the router head, so on the frame path a handler still on the old route inherited the pending navigation's search or params: with `/posts?page=1` visible and `/posts?page=5` staged, a functional `search` updater built page 6 from a handler whose route shows page 1. The same defect the link fix addressed, asked imperatively, and Codex is right that the presented location reached `Link` and `matchRoute` but stopped there. `usePresentedLocation` gives an imperative caller the publication its position presents, read at call time and subscribing to nothing: a scope's identity is stable for the router's lifetime, so reading it costs no re-renders — which matters here, because `useNavigate` returns a stable callback today and should keep doing so. It answers with the publication on screen, since that is the tree an event is delivered to. An explicit `_fromLocation` still wins. Test: a handler on the visible route while a later page is staged and held pending. Without the change the destination never arrives, because the click builds page 6 from the head instead of page 2 from the route the user is looking at. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 25 ++++++ packages/react-router/src/useNavigate.tsx | 12 ++- .../tests/concurrent-render-frames.test.tsx | 83 +++++++++++++++++++ 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 314b5a25afd..715739a9989 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -293,6 +293,31 @@ export function RouterStateFrame({ children }: { children: React.ReactNode }) { ) } +/** + * The publication this position is presenting, for code that runs outside + * render — an event handler resolving a navigation against the route the user + * is looking at, say. + * + * Returns a getter, and subscribes to nothing: a scope's identity is stable + * for the router's lifetime, so reading it costs no re-renders, and an + * imperative caller wants the answer at call time anyway. It gives the + * publication on screen: an event is delivered to the committed tree, so that + * is the one it should resolve against. `undefined` off the frame path, where + * the router's own head is the right source and already fresh. + */ +export function usePresentedLocation( + router: AnyRouter, +): (() => RouterRenderFrame['location'] | undefined) | undefined { + const scope = React.useContext(routerStateScopeContext) + const frameMode = useFrameMode(router) + return React.useMemo(() => { + if (!frameMode || !scope || scope.router !== router) { + return undefined + } + return () => scope.committed.location + }, [frameMode, router, scope]) +} + export function useRouterStateOwner() { return React.useContext(routerStateOwnerContext) } diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index ce95e5b4c1c..b3130a62e9b 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import { useLayoutEffect } from './utils' import { useRouter } from './useRouter' +import { usePresentedLocation } from './routerStateContext' import type { AnyRouter, FromPathOption, @@ -32,15 +33,22 @@ export function useNavigate< from?: FromPathOption }): UseNavigateResult { const router = useRouter() + // Resolve against the route the caller is looking at, not the one the router + // is preparing. With concurrent render frames a handler still on the old + // route would otherwise inherit the pending navigation's search or params — + // the same reason `Link` resolves its click from the location it rendered + // against. Read at call time; an explicit `_fromLocation` still wins. + const presentedLocation = usePresentedLocation(router) return React.useCallback( (options: NavigateOptions) => { return router.navigate({ + _fromLocation: presentedLocation?.(), ...options, from: options.from ?? _defaultOpts?.from, - }) + } as NavigateOptions) }, - [_defaultOpts?.from, router], + [_defaultOpts?.from, presentedLocation, router], ) as UseNavigateResult } diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index a59512507de..e7c6ac78fac 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -24,6 +24,7 @@ import { createRouter, useLocation, useMatchRoute, + useNavigate, useRouterState, } from '../src' import type { AnyRouter } from '@tanstack/router-core' @@ -1276,6 +1277,88 @@ describe('concurrent render frames', () => { await waitFor(() => screen.getByRole('heading', { name: 'Next Title' })) }) + + /** + * The same question as a link's click, asked imperatively: a handler on the + * route the user is looking at must resolve against that route, not the one + * the router is preparing. + */ + test('an imperative navigation resolves against the visible route', async () => { + const gate = deferred() + let loads = 0 + + function Controls() { + const navigate = useNavigate() + return ( + + ) + } + + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateSearch: (search: Record) => ({ + page: Number(search.page ?? 1), + }), + loaderDeps: ({ search }: { search: { page: number } }) => ({ + page: search.page, + }), + loader: async () => { + loads++ + if (loads > 1) { + await gate.promise + } + }, + component: () => { + const page = postsRoute.useSearch({ select: (s) => s.page }) + return

{`Posts ${page}`}

+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + experimental_concurrentRenderFrames: true, + }) + + window.history.replaceState(null, '', '/posts?page=1') + render() + await waitFor(() => screen.getByRole('heading', { name: 'Posts 1' })) + + // Head moves to page 5 and stays pending, so the visible route and the + // head disagree about what "the next page" is. + act(() => { + void router.navigate({ to: '/posts', search: { page: 5 } }) + }) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + screen.getByRole('heading', { name: 'Posts 1' }) + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Next page' })) + }) + gate.resolve() + + await waitFor(() => screen.getByRole('heading', { name: 'Posts 2' })) + expect(router.stores.location.get().search).toEqual({ page: 2 }) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From afd43d8a7f9a7f5da0b2567702189d2150bddf5e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:41:41 +0000 Subject: [PATCH 32/74] test: pin what a mount-effect navigation resolves against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex is right, and it reproduces: React runs layout effects bottom-up and `MatchesInner` commits the frame from an ancestor's, so a destination component calling `navigate` from its *mount* layout effect runs before the frame it rendered from has committed. It renders page 5 and navigates to page 2. Not fixed, because the information needed is which tree the caller is in, and nothing available outside render says. The same getter that gets this wrong is what gets a handler on the visible route right, which is the common case and the one a user can see; being right in both needs the per-tree frame identity that mount-time isolation needs, so it is documented alongside it. Worth being explicit that this case is a step *back* from the default path, where resolving from the head happens to be right for it — the reverse of the case the previous commit fixed. The test pins the behaviour so the trade cannot change silently, and says in as many words that it documents rather than endorses it. The options doc now tells an adopter to navigate from an event or a passive effect, or to pass `_fromLocation`, if it matters to them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterOptionsType.md | 1 + .../tests/concurrent-render-frames.test.tsx | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index c9def3d3979..3b3cb5986b5 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -432,6 +432,7 @@ Two behaviour changes to know about before enabling it: - **A client-rendered app does not use route-level pending components.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. A child route that suspends bubbles to that boundary, whose fallback comes from the root route, so a child- or parent-specific `pendingComponent` is skipped. Provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`. **A server-rendered app keeps its route-level boundaries** — that is what its streamed HTML describes, and the boundary decides an element type, so it cannot appear once hydration finishes without remounting the route tree. Such an app therefore gives up atomic acknowledgement: a child that suspends resolves at its own boundary, so a frame can be acknowledged while part of the tree is still pending, exactly as it is without this option. - **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working. +- **An imperative navigation resolves against the route on screen, which is wrong for one caller.** `useNavigate` resolves relative paths and search or param updaters against the publication its position presents, so a handler on the visible route navigates from what the user is looking at rather than from the route being prepared. A destination component calling `navigate` from its *mount* layout effect is the exception: React runs layout effects bottom-up, and the frame commits from an ancestor's, so that call runs before the frame it rendered from has committed and resolves against the route being left. Navigate from an event or a passive effect, or pass `_fromLocation` explicitly, if that matters to you. - **A reader with no previous answer can see the route being prepared.** A consumer that mounts during a navigation, or whose `select` function changes while one is in flight, has no earlier selection to compare against and no way to tell which tree is rendering it, so that first render can read the staged route rather than the visible one. Consumers already mounted with a stable selector are isolated. - **Replacing the router under a mounted provider does not change which path the tree uses.** Whether a component reads through the frame path is decided at its first render, because that decision gates which hooks it calls; a component handed a router configured the other way would otherwise change hook shape and crash on the hook order. The consequence is that swapping a router for one with a different setting leaves the tree on the setting it mounted with — enabling the option on a replacement router does not activate it. Recreate the tree (a fresh `RouterProvider`, or a `key` on it) when you replace a router with one configured differently. - **Every reader goes through the frame path, including one that names a router explicitly.** `useRouterState({ router })` pointing at a router with no provider above it reads that router's store head — the same content as before — but through React state rather than `useSyncExternalStore`, so its updates are no longer flushed synchronously. diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index e7c6ac78fac..0b1c53b6f38 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -1359,6 +1359,71 @@ describe('concurrent render frames', () => { expect(router.stores.location.get().search).toEqual({ page: 2 }) }) + + /** + * The limit of resolving an imperative navigation from the visible route, + * pinned rather than left to be discovered. + * + * React runs layout effects bottom-up, and `MatchesInner` commits the frame + * from a layout effect of its own — an ancestor's. So a destination + * component calling `navigate` from its *mount* layout effect runs before + * the frame it rendered from has committed, and resolves against the route + * being left: it renders page 5 and navigates to page 2. + * + * Which is right depends on where the caller is, and nothing available + * outside render says: the same getter that gets this case wrong is what + * gets a handler on the visible route right, and that is the common case. It + * needs the per-tree frame identity that mount-time isolation needs, and is + * documented with it. This test exists so the trade cannot change silently. + */ + test('an imperative navigation from a mount effect resolves against the route being left', async () => { + let redirected = false + const rendered: Array = [] + + const rootRoute = createRootRoute({ component: () => }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateSearch: (search: Record) => ({ + page: Number(search.page ?? 1), + }), + component: function Posts() { + const page = postsRoute.useSearch({ select: (s) => s.page }) + const navigate = useNavigate() + rendered.push(page) + React.useLayoutEffect(() => { + if (page === 5 && !redirected) { + redirected = true + void navigate({ + to: '/posts', + search: (prev: any) => ({ page: (prev.page ?? 1) + 1 }), + }) + } + }, [page, navigate]) + return

{`Posts ${page}`}

+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + experimental_concurrentRenderFrames: true, + }) + + window.history.replaceState(null, '', '/posts?page=1') + render() + await waitFor(() => screen.getByRole('heading', { name: 'Posts 1' })) + + act(() => { + void router.navigate({ to: '/posts', search: { page: 5 } }) + }) + await waitFor(() => expect(redirected).toBe(true)) + await waitFor(() => expect(router.stores.status.get()).toBe('idle')) + + // Rendered page 5, resolved from page 1. Documented, not desired. + expect(rendered).toContain(5) + expect(router.stores.location.get().search).toEqual({ page: 2 }) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From 07d4f0dc9a79728909fce4d53942f863be6550e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:45:51 +0000 Subject: [PATCH 33/74] test: pin InnerWrap reading the committed route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex reads `InnerWrap` sitting outside `RouterStateFrame` as a bug. It is the design: `InnerWrap` wraps the whole match tree, the `Transitioner` and the root Suspense boundary included, so it is outside the route tree and reads the committed publication like any other outside reader. The visible surroundings must not jump to the destination while the old route is still on screen — that is what lets `` pair an old and a new element at all. Moving it inside would also put a user's wrapper inside the root Suspense boundary, so the pending fallback would replace it: a behaviour change for every existing `InnerWrap` user, to make the surroundings read ahead. The half of the finding that is real is the deadlock: something inside `InnerWrap` that suspends until the wrapper describes the destination waits for a commit its own suspension prevents. That is a property of the committed scope rather than of `InnerWrap`, and it holds for any consumer outside the route tree, so it is documented as such. Test asserts the wrapper still describes `/` while the head is at `/next` and a gated navigation is staged, then follows once it commits — so the decision is pinned rather than left to be re-litigated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterOptionsType.md | 1 + .../tests/concurrent-render-frames.test.tsx | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 3b3cb5986b5..ee401796239 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -432,6 +432,7 @@ Two behaviour changes to know about before enabling it: - **A client-rendered app does not use route-level pending components.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. A child route that suspends bubbles to that boundary, whose fallback comes from the root route, so a child- or parent-specific `pendingComponent` is skipped. Provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`. **A server-rendered app keeps its route-level boundaries** — that is what its streamed HTML describes, and the boundary decides an element type, so it cannot appear once hydration finishes without remounting the route tree. Such an app therefore gives up atomic acknowledgement: a child that suspends resolves at its own boundary, so a frame can be acknowledged while part of the tree is still pending, exactly as it is without this option. - **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working. +- **`InnerWrap` is outside the route tree, so it reads the committed route.** It wraps the whole match tree, and like any consumer outside that tree it advances only when a navigation commits — deliberately, so the visible surroundings do not jump to the destination while the old route is still on screen. Something rendered inside it that suspends until it describes the destination would wait for a commit its own suspension prevents; that applies to any outside consumer, not just `InnerWrap`. - **An imperative navigation resolves against the route on screen, which is wrong for one caller.** `useNavigate` resolves relative paths and search or param updaters against the publication its position presents, so a handler on the visible route navigates from what the user is looking at rather than from the route being prepared. A destination component calling `navigate` from its *mount* layout effect is the exception: React runs layout effects bottom-up, and the frame commits from an ancestor's, so that call runs before the frame it rendered from has committed and resolves against the route being left. Navigate from an event or a passive effect, or pass `_fromLocation` explicitly, if that matters to you. - **A reader with no previous answer can see the route being prepared.** A consumer that mounts during a navigation, or whose `select` function changes while one is in flight, has no earlier selection to compare against and no way to tell which tree is rendering it, so that first render can read the staged route rather than the visible one. Consumers already mounted with a stable selector are isolated. - **Replacing the router under a mounted provider does not change which path the tree uses.** Whether a component reads through the frame path is decided at its first render, because that decision gates which hooks it calls; a component handed a router configured the other way would otherwise change hook shape and crash on the hook order. The consequence is that swapping a router for one with a different setting leaves the tree on the setting it mounted with — enabling the option on a replacement router does not activate it. Recreate the tree (a fresh `RouterProvider`, or a `key` on it) when you replace a router with one configured differently. diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 0b1c53b6f38..26d54b9a7df 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -1424,6 +1424,78 @@ describe('concurrent render frames', () => { expect(router.stores.location.get().search).toEqual({ page: 2 }) }) + + /** + * `InnerWrap` wraps the whole match tree — the `Transitioner` and the root + * Suspense boundary included — so it is *outside* the route tree, and reads + * the committed publication like any other outside reader. That is + * deliberate: the visible surroundings must not jump to the destination + * while the route on screen is still the old one, which is what lets + * `` pair an old and a new element at all. + * + * The cost, worth naming: something inside `InnerWrap` that suspends until + * the wrapper describes the destination would wait for a commit that its own + * suspension prevents. That is a property of the committed scope rather than + * of `InnerWrap`, and it applies to any consumer outside the route tree. + */ + test('InnerWrap reads the committed route while a navigation is staged', async () => { + const gate = deferred() + + function Wrap({ children }: { children: React.ReactNode }) { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + return ( + <> +
{pathname}
+ {children} + + ) + } + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => gate.promise, + component: () =>

Next Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + experimental_concurrentRenderFrames: true, + InnerWrap: Wrap, + }) + + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + expect(screen.getByTestId('wrap').textContent).toBe('/') + + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/next' }) + }) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + + // The head is at /next; the wrapper is still describing what is on screen. + expect(router.stores.location.get().pathname).toBe('/next') + expect(screen.getByTestId('wrap').textContent).toBe('/') + + await act(async () => { + gate.resolve() + await gate.promise + }) + await navigation + await waitFor(() => screen.getByRole('heading', { name: 'Next Title' })) + // And once it commits, it follows. + await waitFor(() => + expect(screen.getByTestId('wrap').textContent).toBe('/next'), + ) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From 1542bbd9c2446480d1c77052836834e8184c2eda Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:50:54 +0000 Subject: [PATCH 34/74] test: assert hydration reports no recoverable errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit is right, and it is the failure mode I have spent this session fixing elsewhere: `onRecoverableError: () => {}` swallowed mismatches, and a hydration that fell back to client rendering produces exactly one `mount` too — so the test could have passed while proving nothing about hydration. Recoverable errors are collected and asserted empty alongside the lifecycle assertion. Checked rather than assumed: the array *is* empty, so hydration was genuinely clean and the test was not passing for the wrong reason. It is a guard against that from here on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- ...concurrent-render-frames-hydration.test.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx b/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx index a4b3c19cd83..09c6153a90c 100644 --- a/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx @@ -31,6 +31,11 @@ afterEach(async () => { */ test('hydration does not remount the route tree', async () => { const lifecycle: Array = [] + // Swallowing these would let the test pass for the wrong reason: a + // hydration mismatch that falls back to client rendering also produces + // exactly one `mount`, and the point of the test is that the tree was + // *hydrated*, not replaced. + const recoverableErrors: Array = [] function IndexPage() { React.useEffect(() => { @@ -75,9 +80,15 @@ test('hydration does not remount the route tree', async () => { document.body.appendChild(container) await act(async () => { - const root = hydrateRoot(container, , { - onRecoverableError: () => {}, - }) + const root = hydrateRoot( + container, + , + { + onRecoverableError: (error) => { + recoverableErrors.push(error) + }, + }, + ) cleanups.push(async () => { await act(() => root.unmount()) }) @@ -90,5 +101,6 @@ test('hydration does not remount the route tree', async () => { }) expect(container).toHaveTextContent('Index Title') + expect(recoverableErrors).toEqual([]) expect(lifecycle).toEqual(['mount']) }) From c1c3661e6ecef8a8c541459745aaab94008a8a96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:59:39 +0000 Subject: [PATCH 35/74] fix: queue a render frame per router, not one slot with a tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex is right that the tag only guarded the read. The write was unconditional, so a stale dispatch from a replaced router replaced the current router's queued frame with its own; the read-side filter then rejected that entry and left the current router with no queued frame at all, so its acknowledgement never settled. Strictly worse than the problem the tag was added for. A slot per router means neither can clobber the other, and reading only this router's slot still keeps a foreign frame out of the tree — which is what matters, because `frameId` counts per router and a collision could otherwise commit the wrong router's snapshot. The updater returns the previous map unchanged when nothing moved, so a repeated write costs no render. Still no test, for the reason given on the earlier thread: swapping the router under a mounted `RouterProvider` does not render the replacement at all, on this path or the default one, so the scenario is unreachable from the harness. Mechanism only, and said plainly rather than implied. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 40 +++++++++++++++++++-------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 15713ddf26d..8e34dad28e0 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -58,20 +58,36 @@ declare module '@tanstack/router-core' { export function Matches() { const router = useRouter() const routerStateOwner = useRouterStateOwner() - // Tagged with the router that produced it. A router swapped under a mounted - // provider keeps its own navigation in flight, along with the - // `startTransition` override holding this dispatch, so its staged frame can - // still arrive here afterwards. Untagged it would mask the current router's - // own frame — and because `frameId` counts per router, a collision could - // commit the wrong router's snapshot outright. - const [queuedFrame, setQueuedFrame] = React.useState< - { router: AnyRouter; frame: RouterRenderFrame } | undefined - >() - const renderFrame = - queuedFrame?.router === router ? queuedFrame.frame : undefined + // Queued per router, because a router swapped under a mounted provider keeps + // its own navigation in flight — along with the `startTransition` override + // holding this dispatch — so its staged frame can still arrive here + // afterwards. + // + // One slot with a tag was not enough: the write is what the stale dispatch + // reaches, so it replaced the current router's entry with its own, and + // filtering on read then left that router with no queued frame at all. Its + // acknowledgement would never settle. A slot per router means neither can + // clobber the other, and reading only this router's slot keeps a foreign + // frame out of the tree — `frameId` counts per router, so a collision could + // otherwise commit the wrong router's snapshot outright. + const [queuedFrames, setQueuedFrames] = React.useState< + ReadonlyMap + >(() => new Map()) + const renderFrame = queuedFrames.get(router) const setRenderFrame = React.useCallback( (frame: RouterRenderFrame | undefined) => - setQueuedFrame(frame ? { router, frame } : undefined), + setQueuedFrames((previous) => { + if (previous.get(router) === frame) { + return previous + } + const next = new Map(previous) + if (frame) { + next.set(router, frame) + } else { + next.delete(router) + } + return next + }), [router], ) const activeFrame = renderFrame ?? routerStateOwner?.frame From b0b77adf3d7c35f0532d1f94fa1a603d4ce5d427 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 01:11:27 +0000 Subject: [PATCH 36/74] fix: own routers weakly, and keep the frame queue to one entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consequences of the last two commits, both found in review. The owner was a ref on the provider, and a ref is shared by every tree rendering it — so a render for another router, one that may be discarded, replaced the owner belonging to the tree still on screen. A later render for the original router then built a *new* owner seeded from that router's current store head, which during a staged navigation is the destination: the tree would expose the route being prepared and orphan the acknowledgement the first owner was waiting on. Owners now live in a `WeakMap` keyed by router, so one exists per router for that router's lifetime, building it is idempotent, and a discarded render costs nothing. Third instance of render-phase ref mutation on this branch, and the same fix shape as the other two. The per-router frame queue was a strong `Map`, and an entry inserted by a dispatch that outlived its router was only ever removed by that router's own tree — which never renders again. Repeated switches would retain every outgoing router and its route data for the life of the component. The render now keeps only the current router's slot, using React's own state-adjustment-during-render shape, so the map is bounded whatever a stale dispatch does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 11 ++++++ .../react-router/src/routerStateContext.tsx | 34 +++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 8e34dad28e0..4fac50ff74e 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -74,6 +74,17 @@ export function Matches() { ReadonlyMap >(() => new Map()) const renderFrame = queuedFrames.get(router) + // Keep only this router's slot. A dispatch that outlived its router can + // insert one for a router this tree will never render again, and nothing + // else would remove it — every outgoing router and its route data would be + // retained for the life of this component. Adjusting state during render is + // React's own answer to this shape; the write below re-renders immediately, + // so the map is bounded whatever a stale dispatch does. + if (queuedFrames.size > (renderFrame ? 1 : 0)) { + setQueuedFrames( + renderFrame ? new Map([[router, renderFrame]]) : new Map(), + ) + } const setRenderFrame = React.useCallback( (frame: RouterRenderFrame | undefined) => setQueuedFrames((previous) => { diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 715739a9989..92d46001d6a 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -251,6 +251,33 @@ function createOwner(router: AnyRouter): RouterStateOwner { return owner } +/** + * One owner per router, for the router's lifetime. + * + * Not a ref on the provider: a ref is shared by every tree rendering it, so a + * render for another router — one that may be discarded — would replace the + * owner belonging to the tree still on screen. A later render for the + * original router would then build a *new* owner, seeded from that router's + * current store head, which during a staged navigation is the destination: + * the tree would expose the route being prepared and orphan the + * acknowledgement the first owner is still waiting on. + * + * Keyed weakly, so an owner lives exactly as long as its router. Building one + * is idempotent per router, so a discarded render costs nothing and a + * surviving one finds the same owner. + */ +const ownersByRouter = new WeakMap() + +function ownerFor(router: AnyRouter): RouterStateOwner { + const existing = ownersByRouter.get(router) + if (existing) { + return existing + } + const owner = createOwner(router) + ownersByRouter.set(router, owner) + return owner +} + export function RouterStateProvider({ router, children, @@ -261,12 +288,7 @@ export function RouterStateProvider({ // Keyed by router identity. A mounted provider can be handed a different // router — a test rerender, HMR, switching tenant — and an owner built for // the previous one would keep reading and staging that router's state. - const ownerRef = React.useRef(undefined) - if (!ownerRef.current || ownerRef.current.router !== router) { - ownerRef.current = createOwner(router) - } - - const owner = ownerRef.current + const owner = ownerFor(router) useLayoutEffect(() => { const subscription = router.stores.__store.subscribe(() => owner.publish()) From e466ff9fcb9c48a2bae656df083a7876b640cbf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 01:16:04 +0000 Subject: [PATCH 37/74] fix: take the frame-path decision from the provider's owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freezing the decision per component assumed the option only changes with the router. It does not: `RouterContextProvider` forwards prop updates through `router.update`, so the option is mutable under a mounted tree. A component mounting after it changed froze the new answer while the tree around it still staged and acknowledged frames — and its subscription read the head synchronously inside a route still presenting the committed publication, which is the leak this option exists to close. The owner now carries the decision, taken when it is built, and every reader under a provider uses it. A reader with no owner for the router it names still freezes its own, which is the right answer where there is no tree to agree with. Test: the option is turned off underneath a mounted tree, then a reader is mounted urgently during a staged navigation. Without the change it reads `/slow` — the route being prepared — while `/` is on screen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 17 ++++- .../tests/concurrent-render-frames.test.tsx | 74 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 92d46001d6a..2cd6b0ad380 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -47,6 +47,15 @@ type RouterStateScope = { type RouterStateOwner = { router: AnyRouter + /** + * Whether this router's tree reads through the frame path, decided when the + * owner is built and never revisited. The option is mutable — + * `RouterContextProvider` forwards prop updates through `router.update` — + * so a component mounting later could otherwise freeze a different answer + * than the tree around it, and read the head synchronously inside a route + * that is still presenting the committed publication. + */ + frameMode: boolean /** The committed scope, for readers outside the route tree. */ root: RouterStateScope /** The presentation scope, for the route subtree. */ @@ -135,6 +144,7 @@ const routerStateOwnerContext = React.createContext< * than keep publishing through the old router's scopes. */ function createOwner(router: AnyRouter): RouterStateOwner { + const frameMode = Boolean(router.options.experimental_concurrentRenderFrames) const initial = router.stores.__store.get() const root = createScope(router, initial) const route = createScope(router, initial) @@ -176,6 +186,7 @@ function createOwner(router: AnyRouter): RouterStateOwner { const owner: RouterStateOwner = { router, + frameMode, root, route, get frame() { @@ -398,10 +409,14 @@ function detachedScope(router: AnyRouter): RouterStateScope { * keeps the isolation behaviour it mounted with. */ export function useFrameMode(router: AnyRouter): boolean { + // The tree's own answer wins where there is one, so every reader under a + // provider agrees with it however the option moves afterwards. Both hooks + // run unconditionally; only the choice between their values is conditional. + const owner = React.useContext(routerStateOwnerContext) const [mode] = React.useState(() => Boolean(router.options.experimental_concurrentRenderFrames), ) - return mode + return owner?.router === router ? owner.frameMode : mode } export function useRouterStateSelector( diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 26d54b9a7df..55c4378e255 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -1496,6 +1496,80 @@ describe('concurrent render frames', () => { ) }) + + /** + * The option is mutable: `RouterContextProvider` forwards prop updates + * through `router.update`. So freezing the decision per component is not + * enough — a component mounting after the option changed would freeze the + * new answer while the tree around it still stages and acknowledges frames, + * and its subscription would read the head synchronously inside a route + * that is still presenting the committed publication. + */ + test('a reader mounted after the option changed follows the provider', async () => { + const gate = deferred() + let showLateConsumer!: (show: boolean) => void + + function LateConsumer() { + const pathname = useLocation({ select: (l) => l.pathname }) + return
{pathname}
+ } + + const rootRoute = createRootRoute({ + component: function RootComponent() { + const [show, setShow] = React.useState(false) + showLateConsumer = setShow + return ( + <> + Slow + {show ? : null} + + + ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + component: () =>

Slow Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + // Turn the option off underneath the mounted tree, which keeps staging + // frames because its owner was built with it on. + act(() => { + router.update({ + ...router.options, + experimental_concurrentRenderFrames: false, + }) + }) + + fireEvent.click(screen.getByRole('link', { name: 'Slow' })) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + expect(router.stores.location.get().pathname).toBe('/slow') + + act(() => showLateConsumer(true)) + + // Follows the provider, not the option as it now reads: the route on + // screen is still `/`. + expect(screen.getByTestId('late').textContent).toBe('/') + + gate.resolve() + await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From a4b8383d4bc62e00cbe3487fd1934b2ec71c7e76 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 01:30:54 +0000 Subject: [PATCH 38/74] fix: keep the frame path a mounted component froze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking the tree's answer per render meant a mounted provider handed a router configured the other way changed the mode its descendants saw: the new owner's frameMode is false where the tree mounted on true, so readers switched between useRouterStateSelector and useStore and React failed on the hook order. Read the owner's answer once, at the reader's first render, and keep it — the reason the docstring already gave for freezing. Also document frameId on the RouterState API page: it is a required member of the exported type, so consumers meet it in selectors whether or not the experimental option is on, and the page enumerated the state without it. Two type errors in the frame tests, caught by test:unit's typecheck but not test:types, fixed alongside: an effect returning a value, and a functional `state` updater typed to return a full HistoryState. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterStateType.md | 12 +++++ .../react-router/src/routerStateContext.tsx | 15 ++++-- ...oncurrent-render-frames-hydration.test.tsx | 4 +- .../tests/concurrent-render-frames.test.tsx | 52 ++++++++++++++++++- 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/docs/router/api/router/RouterStateType.md b/docs/router/api/router/RouterStateType.md index 4da9cd9bb63..66f48035327 100644 --- a/docs/router/api/router/RouterStateType.md +++ b/docs/router/api/router/RouterStateType.md @@ -9,6 +9,7 @@ status. ```tsx type RouterState = { + frameId: number status: 'pending' | 'idle' isLoading: boolean matches: Array @@ -21,6 +22,17 @@ type RouterState = { The `RouterState` type contains all of the properties that are available on the router state. +### `frameId` property + +- Type: `number` +- Identity for one atomically assembled snapshot of the router state. Every + snapshot the router publishes gets a new, larger value; two reads that return + the same `frameId` are reads of the same snapshot. +- It identifies a snapshot rather than a navigation: a single navigation + publishes several, and the value carries no meaning beyond comparison and + ordering. Do not derive a location, a match, or a count of navigations from + it. + ### `status` property - Type: `'pending' | 'idle'` diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 2cd6b0ad380..45579bf110d 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -409,14 +409,19 @@ function detachedScope(router: AnyRouter): RouterStateScope { * keeps the isolation behaviour it mounted with. */ export function useFrameMode(router: AnyRouter): boolean { - // The tree's own answer wins where there is one, so every reader under a - // provider agrees with it however the option moves afterwards. Both hooks - // run unconditionally; only the choice between their values is conditional. + // The tree's own answer is taken where there is one, so a reader mounting + // after the option moved agrees with the tree that is already staging + // frames rather than with the option's current value. Read once, at this + // component's first render, and kept: a mounted provider can be handed a + // router configured the other way, and following the new owner's mode would + // change this reader's hook shape underneath it. const owner = React.useContext(routerStateOwnerContext) const [mode] = React.useState(() => - Boolean(router.options.experimental_concurrentRenderFrames), + owner?.router === router + ? owner.frameMode + : Boolean(router.options.experimental_concurrentRenderFrames), ) - return owner?.router === router ? owner.frameMode : mode + return mode } export function useRouterStateSelector( diff --git a/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx b/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx index 09c6153a90c..2550d80efd1 100644 --- a/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames-hydration.test.tsx @@ -40,7 +40,9 @@ test('hydration does not remount the route tree', async () => { function IndexPage() { React.useEffect(() => { lifecycle.push('mount') - return () => lifecycle.push('unmount') + return () => { + lifecycle.push('unmount') + } }, []) return

Index Title

} diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 55c4378e255..8037a51ae9d 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -872,6 +872,53 @@ describe('concurrent render frames', () => { expect(screen.getByTestId('pathname')).toHaveTextContent('/') }) + /** + * The other half of the same hazard, with the provider's router changing + * instead of the reader's argument. A mounted provider handed a router + * configured the other way installs an owner whose frame mode disagrees + * with the one the tree mounted on, and a reader that took the tree's + * answer per render would change hook shape underneath itself. The mounted + * path has to survive the swap even though the mode it names is no longer + * the one the current router asks for. + */ + test('a provider handed a router configured the other way keeps the mounted path', async () => { + const framed = makeRouter() + // Frames off, and somewhere else, so the assertion says which router was + // read as well as that the swap did not crash. + const plain = makeRouter(false, '/posts') + + function Probe() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + return
{pathname}
+ } + + const { rerender } = render( + + + , + ) + expect(screen.getByTestId('pathname')).toHaveTextContent('/') + + // Crosses the option itself: the reader mounted on the frame path and the + // new owner's mode is false. + rerender( + + + , + ) + expect(screen.getByTestId('pathname')).toHaveTextContent('/posts') + + // And back, so neither direction of the swap decides hook order. + rerender( + + + , + ) + expect(screen.getByTestId('pathname')).toHaveTextContent('/') + }) + /** * A link's `href` is built from the location it is rendered against, which @@ -964,7 +1011,10 @@ describe('concurrent render frames', () => { ({ from: prev.__TSR_index })} + // Cast: a functional `state` updater is typed to return a full + // `HistoryState`, and this one deliberately returns only the field + // the assertion reads. + state={((prev: any) => ({ from: prev.__TSR_index })) as any} > Fixed target From 5b1e5ff9ad707c45c3d96d3966ba4deb85f31ff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 01:49:16 +0000 Subject: [PATCH 39/74] fix: seed a new reader from the tree's frame path, not the owner's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider handed a router configured the other way keeps the frame path it mounted with, so it goes on staging frames through the replacement router's scopes. A reader mounting after that swap took its answer from the replacement owner's own mode, subscribed to the head, and read the route being prepared while the tree still presented the committed one. The decision now lives beside the owner rather than on it: the provider freezes it from its first owner and every reader under it takes that. Tested with a reader mounted urgently during a staged navigation after such a swap — it reads /slow without this change and / with it. Also corrects what the option's doc claims about pending UI, which was understated. Measured on both paths: the consolidated boundary mounts with the tree, so the first render shows its fallback as usual, but on a later navigation the boundary is already mounted and the navigation is a transition, so React keeps the route on screen rather than showing a fallback. No pending UI appears on a navigation, and pendingMs and pendingMinMs therefore have nothing to time. That is the concurrent behaviour the option exists to produce, but it is a behaviour change, so it is now stated plainly and pinned by a test against the store path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterOptionsType.md | 2 +- .../react-router/src/routerStateContext.tsx | 39 +++- .../tests/concurrent-render-frames.test.tsx | 166 ++++++++++++++++++ 3 files changed, 197 insertions(+), 10 deletions(-) diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index ee401796239..05194b950b9 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -430,7 +430,7 @@ remountDeps: ({ params }) => params Two behaviour changes to know about before enabling it: -- **A client-rendered app does not use route-level pending components.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. A child route that suspends bubbles to that boundary, whose fallback comes from the root route, so a child- or parent-specific `pendingComponent` is skipped. Provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`. **A server-rendered app keeps its route-level boundaries** — that is what its streamed HTML describes, and the boundary decides an element type, so it cannot appear once hydration finishes without remounting the route tree. Such an app therefore gives up atomic acknowledgement: a child that suspends resolves at its own boundary, so a frame can be acknowledged while part of the tree is still pending, exactly as it is without this option. +- **A client-rendered app does not present route pending UI on a navigation.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. On the first render that boundary mounts with the route tree, so its fallback — built from the root route — is shown as usual. On a later navigation it is already mounted, and the navigation is a transition: React keeps the route on screen rather than replacing it with a fallback. So for client navigations `pendingComponent` is not rendered, and `pendingMs` and `pendingMinMs` have nothing to time. That is the concurrent behaviour the option exists to produce — the previous route stays visible until the next one is ready — but it is a behaviour change, so provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`, which stay live throughout. **A server-rendered app keeps its route-level boundaries** — that is what its streamed HTML describes, and the boundary decides an element type, so it cannot appear once hydration finishes without remounting the route tree. Such an app therefore gives up atomic acknowledgement: a child that suspends resolves at its own boundary, so a frame can be acknowledged while part of the tree is still pending, exactly as it is without this option. - **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working. - **`InnerWrap` is outside the route tree, so it reads the committed route.** It wraps the whole match tree, and like any consumer outside that tree it advances only when a navigation commits — deliberately, so the visible surroundings do not jump to the destination while the old route is still on screen. Something rendered inside it that suspends until it describes the destination would wait for a commit its own suspension prevents; that applies to any outside consumer, not just `InnerWrap`. - **An imperative navigation resolves against the route on screen, which is wrong for one caller.** `useNavigate` resolves relative paths and search or param updaters against the publication its position presents, so a handler on the visible route navigates from what the user is looking at rather than from the route being prepared. A destination component calling `navigate` from its *mount* layout effect is the exception: React runs layout effects bottom-up, and the frame commits from an ancestor's, so that call runs before the frame it rendered from has committed and resolves against the route being left. Navigate from an event or a passive effect, or pass `_fromLocation` explicitly, if that matters to you. diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 45579bf110d..9fd542d59af 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -136,6 +136,21 @@ const routerStateOwnerContext = React.createContext< RouterStateOwner | undefined >(undefined) +/** + * The frame-path decision this provider tree mounted with. + * + * It lives beside the owner rather than on it because the owner belongs to a + * router and the decision belongs to the mounted tree. A provider handed a + * router configured the other way installs an owner whose own mode disagrees; + * the tree keeps staging and acknowledging frames, so a reader mounting after + * that swap has to take the tree's answer, not the replacement owner's, or it + * subscribes to the head inside a tree that is still presenting the committed + * publication. + */ +const routerStateFrameModeContext = React.createContext( + undefined, +) + /** * Everything a router's publications need, closed over that one router. * @@ -300,6 +315,9 @@ export function RouterStateProvider({ // router — a test rerender, HMR, switching tenant — and an owner built for // the previous one would keep reading and staging that router's state. const owner = ownerFor(router) + // The tree's decision, taken from the first owner this provider had and + // kept for as long as it is mounted. + const [frameMode] = React.useState(() => owner.frameMode) useLayoutEffect(() => { const subscription = router.stores.__store.subscribe(() => owner.publish()) @@ -309,9 +327,11 @@ export function RouterStateProvider({ return ( - - {children} - + + + {children} + + ) } @@ -410,15 +430,16 @@ function detachedScope(router: AnyRouter): RouterStateScope { */ export function useFrameMode(router: AnyRouter): boolean { // The tree's own answer is taken where there is one, so a reader mounting - // after the option moved agrees with the tree that is already staging + // after the option moved — or after the provider was handed a router + // configured the other way — agrees with the tree that is already staging // frames rather than with the option's current value. Read once, at this - // component's first render, and kept: a mounted provider can be handed a - // router configured the other way, and following the new owner's mode would - // change this reader's hook shape underneath it. + // component's first render, and kept, so a later swap cannot change this + // reader's hook shape underneath it either. const owner = React.useContext(routerStateOwnerContext) + const treeMode = React.useContext(routerStateFrameModeContext) const [mode] = React.useState(() => - owner?.router === router - ? owner.frameMode + owner?.router === router && treeMode !== undefined + ? treeMode : Boolean(router.options.experimental_concurrentRenderFrames), ) return mode diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 8037a51ae9d..19331b301db 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -251,6 +251,91 @@ describe.each(MODES)('%s', (_name, experimental_concurrentRenderFrames) => { expect(screen.queryByRole('heading', { name: 'First Title' })).toBeNull() expect(router.state.location.pathname).toBe('/second') }) + + /** + * What each path shows while the next route loads. + * + * On the frame path suspension consolidates at one boundary around the route + * tree. That boundary mounts with the tree, so the first render shows its + * fallback — built from the root route, which is why a route's own + * `pendingComponent` is not the one that appears. By the time a navigation + * happens the boundary is already mounted, and the navigation is a + * transition: React keeps the route on screen rather than replacing it with + * a fallback. So no pending UI appears on a navigation at all, and + * `pendingMs` / `pendingMinMs` have nothing to time. + * + * That is the behaviour the option exists to produce, not a defect, but it + * is a behaviour change large enough to pin against the store path rather + * than leave to be rediscovered. Progress UI is expected to read `status` + * and `isLoading`, which stay live on both paths. + */ + test('what stands in for the loading route differs by path', async () => { + const gate = deferred() + + const makePendingRouter = (initialPath: string) => { + const rootRoute = createRootRoute({ + pendingComponent: () =>

Root Pending

, + component: () => ( + <> + Slow + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + pendingMs: 0, + pendingComponent: () =>

Route Pending

, + component: () =>

Slow Title

, + }) + return createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + experimental_concurrentRenderFrames, + history: createMemoryHistory({ initialEntries: [initialPath] }), + }) + } + + // First render: the boundary mounts with the tree, so a fallback shows on + // both paths — the root route's on the frame path, the route's own on the + // store path. + render() + await waitFor(() => + screen.getByRole('heading', { + name: experimental_concurrentRenderFrames + ? 'Root Pending' + : 'Route Pending', + }), + ) + cleanup() + + // A navigation, with the boundary already mounted. + const router = makePendingRouter('/') + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + fireEvent.click(screen.getByRole('link', { name: 'Slow' })) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + + if (experimental_concurrentRenderFrames) { + // The route being left stays on screen instead of any fallback. + expect(screen.queryByRole('heading', { name: 'Route Pending' })).toBeNull() + expect(screen.queryByRole('heading', { name: 'Root Pending' })).toBeNull() + expect( + screen.getByRole('heading', { name: 'Index Title' }), + ).toBeInTheDocument() + } else { + await waitFor(() => screen.getByRole('heading', { name: 'Route Pending' })) + } + + gate.resolve() + await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) + }) }) describe('concurrent render frames', () => { @@ -1620,6 +1705,87 @@ describe('concurrent render frames', () => { await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) }) + /** + * And the same for a reader that mounts after the provider was handed a + * router configured the other way. The tree keeps the frame path it mounted + * with, so it goes on staging frames through the replacement router's + * scopes; a reader seeded from that owner's own mode would subscribe to the + * head instead and read the route being prepared. + * + * The reader sits outside the route tree on purpose. The root scope only + * advances when a navigation commits, so what it presents is unambiguous — + * and the replacement's route tree does not render at all (see the swap + * test above), so there is nowhere inside it to mount one. + */ + test('a reader mounted after a router swap follows the provider', async () => { + const gate = deferred() + let showLateConsumer!: (show: boolean) => void + + function LateConsumer() { + const pathname = useLocation({ select: (l) => l.pathname }) + return
{pathname}
+ } + + function Harness() { + const [show, setShow] = React.useState(false) + showLateConsumer = setShow + return show ? : null + } + + const makeSwapRouter = (frames: boolean) => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + component: () =>

Slow Title

, + }) + return createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames: frames, + }) + } + + const framed = makeSwapRouter(true) + const plain = makeSwapRouter(false) + + const { rerender } = render( + + + + , + ) + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + // The tree stays on the frame path; the owner it now reads through was + // built with the option off. + rerender( + + + + , + ) + + const navigation = plain.navigate({ to: '/slow' }) + await waitFor(() => expect(plain.stores.status.get()).toBe('pending')) + expect(plain.stores.location.get().pathname).toBe('/slow') + + // Mounted urgently, during that navigation. + act(() => showLateConsumer(true)) + + // The committed publication, not the head. + expect(screen.getByTestId('late').textContent).toBe('/') + + gate.resolve() + await navigation.catch(() => {}) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From a5eb269df14c8619669ad942f56965911acf1417 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 01:59:54 +0000 Subject: [PATCH 40/74] fix: freeze a provider's frame path from the option, not the owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners are cached per router for the router's lifetime, so seeding the tree's decision from the owner carried a stale answer across mounts: a router first given an owner while the option was off — which happens when a frame-path provider is handed it — could never be mounted on the frame path again, whatever the option said afterwards. The provider now reads the option itself, once, when it mounts. A swap inside one mount still keeps the mode the tree mounted with, which is what the previous two commits were for; only a fresh provider reads the option afresh. Tested: an owner is built for a frames-off router the way a swap builds one, the tree is unmounted, the option turned on, and a fresh provider mounted — the late reader reads /slow without this change and / with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 22 ++--- .../tests/concurrent-render-frames.test.tsx | 90 +++++++++++++++++++ 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 9fd542d59af..7c2e0ac15ca 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -47,15 +47,6 @@ type RouterStateScope = { type RouterStateOwner = { router: AnyRouter - /** - * Whether this router's tree reads through the frame path, decided when the - * owner is built and never revisited. The option is mutable — - * `RouterContextProvider` forwards prop updates through `router.update` — - * so a component mounting later could otherwise freeze a different answer - * than the tree around it, and read the head synchronously inside a route - * that is still presenting the committed publication. - */ - frameMode: boolean /** The committed scope, for readers outside the route tree. */ root: RouterStateScope /** The presentation scope, for the route subtree. */ @@ -159,7 +150,6 @@ const routerStateFrameModeContext = React.createContext( * than keep publishing through the old router's scopes. */ function createOwner(router: AnyRouter): RouterStateOwner { - const frameMode = Boolean(router.options.experimental_concurrentRenderFrames) const initial = router.stores.__store.get() const root = createScope(router, initial) const route = createScope(router, initial) @@ -201,7 +191,6 @@ function createOwner(router: AnyRouter): RouterStateOwner { const owner: RouterStateOwner = { router, - frameMode, root, route, get frame() { @@ -315,9 +304,14 @@ export function RouterStateProvider({ // router — a test rerender, HMR, switching tenant — and an owner built for // the previous one would keep reading and staging that router's state. const owner = ownerFor(router) - // The tree's decision, taken from the first owner this provider had and - // kept for as long as it is mounted. - const [frameMode] = React.useState(() => owner.frameMode) + // The tree's decision: the option as it stands when this provider mounts, + // kept for as long as it is mounted. Read from the router rather than from + // the owner, because an owner is cached for its router's lifetime — a + // router that was once mounted with the option off would otherwise be + // stuck on the store path in every later tree, whatever the option says. + const [frameMode] = React.useState(() => + Boolean(router.options.experimental_concurrentRenderFrames), + ) useLayoutEffect(() => { const subscription = router.stores.__store.subscribe(() => owner.publish()) diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 19331b301db..e781410a0f8 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -1705,6 +1705,96 @@ describe('concurrent render frames', () => { await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) }) + /** + * A fresh provider mount reads the option as it stands, not as it stood the + * last time this router was mounted. Owners are cached per router for the + * router's lifetime, so seeding the tree's decision from the owner meant a + * router first mounted with the option off could never be mounted with it + * on again — the second tree would silently stay on the store path. + */ + test('a fresh provider mount reads the option as it now stands', async () => { + const gate = deferred() + let showLateConsumer!: (show: boolean) => void + + function LateConsumer() { + const pathname = useLocation({ select: (l) => l.pathname }) + return
{pathname}
+ } + + const rootRoute = createRootRoute({ + component: function RootComponent() { + const [show, setShow] = React.useState(false) + showLateConsumer = setShow + return ( + <> + Slow + {show ? : null} + + + ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + component: () =>

Slow Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + defaultPendingMs: 0, + // Off, so nothing here builds an owner on its own. + experimental_concurrentRenderFrames: false, + }) + + // An owner is only ever built on the frame path — so build one for this + // router the way a swap does, under a provider that is already on it. + // That is what caches `frameMode: false` against this router for good. + const framed = createRouter({ + routeTree: createRootRoute({ component: () => null }).addChildren([]), + experimental_concurrentRenderFrames: true, + }) + const { rerender } = render( + +
+ , + ) + rerender( + +
+ , + ) + cleanup() + + act(() => { + router.update({ + ...router.options, + experimental_concurrentRenderFrames: true, + }) + }) + + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + fireEvent.click(screen.getByRole('link', { name: 'Slow' })) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + expect(router.stores.location.get().pathname).toBe('/slow') + + act(() => showLateConsumer(true)) + + // On the frame path, which is what the option now asks for. + expect(screen.getByTestId('late').textContent).toBe('/') + + gate.resolve() + await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) + }) + /** * And the same for a reader that mounts after the provider was handed a * router configured the other way. The tree keeps the frame path it mounted From 76239a34b732ca19d1b16d42efb83824b1de1c69 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 9 Sep 2026 19:02:21 -0700 Subject: [PATCH 41/74] fix(solid): avoid repeated hydration scans and script execution (#8270) * fix(solid): avoid repeated hydration scans and script execution * test: assert hydration boundary behavior * test(solid): cover script counts and local hydration lookups --- .changeset/solid-hydration-script-work.md | 6 ++ packages/solid-router/src/Asset.tsx | 4 +- packages/solid-router/tests/Scripts.test.tsx | 34 ++++++++++++ .../solid-start-client/src/GenericHydrate.tsx | 14 ++--- .../src/tests/GenericHydrate.test.tsx | 55 +++++++++++++++++++ 5 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 .changeset/solid-hydration-script-work.md create mode 100644 packages/solid-start-client/src/tests/GenericHydrate.test.tsx diff --git a/.changeset/solid-hydration-script-work.md b/.changeset/solid-hydration-script-work.md new file mode 100644 index 00000000000..eb24281fdd4 --- /dev/null +++ b/.changeset/solid-hydration-script-work.md @@ -0,0 +1,6 @@ +--- +'@tanstack/solid-router': patch +'@tanstack/solid-start-client': patch +--- + +Avoid duplicate external script execution when a Solid script includes both `src` and children. Reuse each Solid hydration boundary's marker element instead of scanning all document markers on mount. diff --git a/packages/solid-router/src/Asset.tsx b/packages/solid-router/src/Asset.tsx index bc6bbe58c3b..37c7c97a4e8 100644 --- a/packages/solid-router/src/Asset.tsx +++ b/packages/solid-router/src/Asset.tsx @@ -114,9 +114,7 @@ function Script({ script.parentNode.removeChild(script) } }) - } - - if (typeof children === 'string') { + } else if (typeof children === 'string') { const typeAttr = typeof attrs?.type === 'string' ? attrs.type : 'text/javascript' const nonceAttr = diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index 29f6553d568..ca87e2fba04 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -54,6 +54,40 @@ afterEach(() => { }) describe('ssr scripts', () => { + test.each([undefined, '', 'window.inlineRan = true'])( + 'mounts one external script with children %j and removes it on unmount', + async (children) => { + const rootRoute = createRootRoute({ + scripts: () => [{ src: '/external-script.js', children }], + component: Scripts, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory(), + }) + await router.load() + + const initialScriptCount = document.querySelectorAll('script').length + const { unmount } = render(() => ) + const scripts = document.querySelectorAll( + 'script[src="/external-script.js"]', + ) + expect(scripts).toHaveLength(1) + expect(scripts[0]?.textContent).toBe('') + expect(document.querySelectorAll('script')).toHaveLength( + initialScriptCount + 1, + ) + + unmount() + expect(document.querySelectorAll('script')).toHaveLength( + initialScriptCount, + ) + expect( + document.querySelectorAll('script[src="/external-script.js"]'), + ).toHaveLength(0) + }, + ) + test('updates route data scripts after client navigation', async () => { const rootRoute = createRootRoute({ component: () => ( diff --git a/packages/solid-start-client/src/GenericHydrate.tsx b/packages/solid-start-client/src/GenericHydrate.tsx index 4727afd77e3..feab8abb563 100644 --- a/packages/solid-start-client/src/GenericHydrate.tsx +++ b/packages/solid-start-client/src/GenericHydrate.tsx @@ -42,7 +42,6 @@ type PrefetchController = { promise?: Promise } -const hydrateIdSelector = `[${hydrateIdAttribute}]` const dynamicType = 'dynamic' const dynamicHydrateStrategy = { _t: dynamicType, @@ -170,14 +169,8 @@ export function GenericHydrate(props: InternalHydrateProps) { const currentPrefetchStrategy = prefetchStrategy() const currentHydrateType = currentHydrateStrategy._t! gate.when = currentHydrateType - for (const element of document.querySelectorAll( - hydrateIdSelector, - )) { - if (element.getAttribute(hydrateIdAttribute) === id) { - markerElement = element - saveFallbackHtml(id, element) - break - } + if (markerElement) { + saveFallbackHtml(id, markerElement) } if ( @@ -329,6 +322,9 @@ export function GenericHydrate(props: InternalHydrateProps) { : initialHydrateStrategy._a?.() const markerProps: HydrationMarkerDynamicProps = { component: 'div', + ref: (element) => { + markerElement = element + }, [hydrateIdAttribute]: id, [hydrateWhenAttribute]: markerHydrateType, ...markerAttributes, diff --git a/packages/solid-start-client/src/tests/GenericHydrate.test.tsx b/packages/solid-start-client/src/tests/GenericHydrate.test.tsx new file mode 100644 index 00000000000..d18c4dd1bc9 --- /dev/null +++ b/packages/solid-start-client/src/tests/GenericHydrate.test.tsx @@ -0,0 +1,55 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { render } from 'solid-js/web' +import { GenericHydrate } from '../GenericHydrate' +import { never } from '../hydration/never' + +vi.mock('@tanstack/router-core/isServer', () => ({ isServer: false })) + +const disposers: Array<() => void> = [] + +afterEach(() => { + disposers.splice(0).forEach((dispose) => dispose()) + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +test('passes each boundary marker to its prefetch callback', async () => { + const container = document.createElement('div') + document.body.append(container) + const prefetch = vi.fn(async () => {}) + const querySelector = vi.spyOn(document, 'querySelector') + const querySelectorAll = vi.spyOn(document, 'querySelectorAll') + disposers.push( + render( + () => ( + <> + + first + + + second + + + ), + container, + ), + ) + await Promise.resolve() + + const markers = container.querySelectorAll('[data-ts-hydrate-id]') + expect(markers).toHaveLength(2) + expect(prefetch).toHaveBeenCalledTimes(2) + expect(prefetch).toHaveBeenCalledWith( + expect.objectContaining({ element: markers[0] }), + ) + expect(prefetch).toHaveBeenCalledWith( + expect.objectContaining({ element: markers[1] }), + ) + for (const lookup of [querySelector, querySelectorAll]) { + expect( + lookup.mock.calls.some(([selector]) => + selector.includes('data-ts-hydrate-id'), + ), + ).toBe(false) + } +}) From 8c9066e95a2c32de87d223f3d642e60c83adabbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:11:32 +0000 Subject: [PATCH 42/74] fix: let a remounted tree adopt the frame still in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owners are cached per router, so a provider that unmounts mid-navigation and mounts again on the same router hands the second tree an owner whose frame is still staged. A fresh consumer seeds from `staged ?? committed`, so that tree rendered the staged frame while acknowledging against the committed one: nothing settled, the owner stayed gated on `pending`, and the router stayed `pending` with it — progress UI left on until something else navigated. `Matches` now seeds its queue from the owner's in-flight frame, so the tree acknowledges the frame it is actually rendering and the existing commit path finishes the navigation. Tested on both paths: the load is allowed to finish with nothing left to render it, then the provider mounts again. The store path reaches idle either way; the frame path reads pending without this change. The interrupted navigation's own promise never settles on either path, which is why the test asserts on the router's status rather than awaiting it. That is the same with the option off, so it is not this change's. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 14 +++- .../react-router/src/routerStateContext.tsx | 15 +++++ .../tests/concurrent-render-frames.test.tsx | 66 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 4fac50ff74e..b2da0260902 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -70,9 +70,21 @@ export function Matches() { // clobber the other, and reading only this router's slot keeps a foreign // frame out of the tree — `frameId` counts per router, so a collision could // otherwise commit the wrong router's snapshot outright. + // + // Seeded from the owner's in-flight frame, for the case where this tree is + // not the one that was offered it: a provider that unmounts mid-navigation + // and mounts again on the same router hands its cached owner to a fresh + // `Matches`, whose consumers seed from the staged publication. Without + // adopting it here, this tree renders that frame while acknowledging + // against the committed one, so the acknowledgement never settles and the + // navigation stays pending for good. const [queuedFrames, setQueuedFrames] = React.useState< ReadonlyMap - >(() => new Map()) + >(() => + routerStateOwner?.pending + ? new Map([[router, routerStateOwner.pending]]) + : new Map(), + ) const renderFrame = queuedFrames.get(router) // Keep only this router's slot. A dispatch that outlived its router can // insert one for a router this tree will never render again, and nothing diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 7c2e0ac15ca..c3795c42373 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -53,6 +53,18 @@ type RouterStateOwner = { route: RouterStateScope /** The committed frame. */ frame: RouterRenderFrame + /** + * The staged frame still waiting to be acknowledged, if any. + * + * An owner outlives the tree that was going to acknowledge its staged + * frame — the provider can unmount mid-navigation and mount again on the + * same router. A fresh `Matches` seeds its consumers from `staged`, so it + * renders that frame while acknowledging against the committed one, and + * nothing ever settles: the owner stays gated on `pending` and the router + * stays `pending` with it. Exposing it lets the new tree adopt the frame it + * is already rendering. + */ + pending: RouterRenderFrame | undefined begin: () => void stage: (frame: RouterRenderFrame) => RouterRenderFrame cancel: () => void @@ -196,6 +208,9 @@ function createOwner(router: AnyRouter): RouterStateOwner { get frame() { return root.committed }, + get pending() { + return pending + }, begin: () => { staging = true }, diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index e781410a0f8..517f7c96b8e 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -336,6 +336,72 @@ describe.each(MODES)('%s', (_name, experimental_concurrentRenderFrames) => { gate.resolve() await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) }) + + /** + * A provider can unmount while a navigation is loading and mount again on + * the same router. The frame path caches an owner per router, so the second + * tree inherits the first one's in-flight frame — which the first tree was + * going to acknowledge and never did. + * + * A fresh consumer seeds from `staged ?? committed`, so that tree renders + * the staged frame; acknowledging against the committed one instead left + * the owner gated on `pending` for good and the router `pending` with it, + * so progress UI stayed on until something else navigated. Asserting both + * paths pins the frame path back to what the store path does. + * + * The interrupted navigation's own promise never settles on either path — + * nothing is left to render it — which is why this asserts on the router's + * status rather than awaiting it. + */ + test('a provider remounted mid-navigation settles', async () => { + const gate = deferred() + + const rootRoute = createRootRoute({ + component: () => ( + <> + Slow + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + component: () =>

Slow Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + const navigation = router.navigate({ to: '/slow' }) + navigation.catch(() => {}) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + + // The tree goes away mid-navigation, and the load finishes with nothing + // left to render it — that is what leaves the frame in flight. + cleanup() + gate.resolve() + await act(async () => { + await gate.promise + }) + + // Then it comes back on the same router. + render() + + await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) + await waitFor(() => expect(router.stores.status.get()).toBe('idle')) + }) }) describe('concurrent render frames', () => { From b6cf7b6fb73fc5cf09504b3c088a14c061d24727 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:23:59 +0000 Subject: [PATCH 43/74] fix: resolve canGoBack from the browser history, not the frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `history.back()` acts on the browser's history, not on the frame on screen, so the answer has to describe the history the control would actually move. Reading the presented frame disagreed with it for exactly the staged window: a push from index 0 left the presented frame at 0, so a back control stayed disabled while the entry was already there to pop — and in the dangerous direction, a pending pop to index 0 left it at 1, where a back control would fire a second pop and leave the application. So the hook reverts to subscribing to the head, which also drops the last branch on the option in it. History capability is not presented route content, and it is now named as an exception in the option's doc beside progress and `matchRoute({ pending: true })`. Tested on both paths: during a gated push from index 0, the head reads index 1 with `/` still on screen; the hook reads true with this change and false without it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterOptionsType.md | 2 +- packages/react-router/src/useCanGoBack.ts | 24 +++---- .../tests/concurrent-render-frames.test.tsx | 71 +++++++++++++++++++ 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 05194b950b9..2e2abe43f27 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -431,7 +431,7 @@ remountDeps: ({ params }) => params Two behaviour changes to know about before enabling it: - **A client-rendered app does not present route pending UI on a navigation.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. On the first render that boundary mounts with the route tree, so its fallback — built from the root route — is shown as usual. On a later navigation it is already mounted, and the navigation is a transition: React keeps the route on screen rather than replacing it with a fallback. So for client navigations `pendingComponent` is not rendered, and `pendingMs` and `pendingMinMs` have nothing to time. That is the concurrent behaviour the option exists to produce — the previous route stays visible until the next one is ready — but it is a behaviour change, so provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`, which stay live throughout. **A server-rendered app keeps its route-level boundaries** — that is what its streamed HTML describes, and the boundary decides an element type, so it cannot appear once hydration finishes without remounting the route tree. Such an app therefore gives up atomic acknowledgement: a child that suspends resolves at its own boundary, so a frame can be acknowledged while part of the tree is still pending, exactly as it is without this option. -- **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working. +- **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working, and so does `useCanGoBack`: `history.back()` acts on the browser's history rather than on the frame on screen, so the answer has to describe the history the control would actually move. - **`InnerWrap` is outside the route tree, so it reads the committed route.** It wraps the whole match tree, and like any consumer outside that tree it advances only when a navigation commits — deliberately, so the visible surroundings do not jump to the destination while the old route is still on screen. Something rendered inside it that suspends until it describes the destination would wait for a commit its own suspension prevents; that applies to any outside consumer, not just `InnerWrap`. - **An imperative navigation resolves against the route on screen, which is wrong for one caller.** `useNavigate` resolves relative paths and search or param updaters against the publication its position presents, so a handler on the visible route navigates from what the user is looking at rather than from the route being prepared. A destination component calling `navigate` from its *mount* layout effect is the exception: React runs layout effects bottom-up, and the frame commits from an ancestor's, so that call runs before the frame it rendered from has committed and resolves against the route being left. Navigate from an event or a passive effect, or pass `_fromLocation` explicitly, if that matters to you. - **A reader with no previous answer can see the route being prepared.** A consumer that mounts during a navigation, or whose `select` function changes while one is in flight, has no earlier selection to compare against and no way to tell which tree is rendering it, so that first render can read the staged route rather than the visible one. Consumers already mounted with a stable selector are isolated. diff --git a/packages/react-router/src/useCanGoBack.ts b/packages/react-router/src/useCanGoBack.ts index a0afae298f1..fced04f1320 100644 --- a/packages/react-router/src/useCanGoBack.ts +++ b/packages/react-router/src/useCanGoBack.ts @@ -1,22 +1,22 @@ import { useStore } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' -import { - useFrameMode, - useRouterStateSelector, -} from './routerStateContext' +/** + * Whether the browser can go back, which is not presented route content. + * + * Deliberately reads the head rather than the publication this position is + * presenting, and so is an exception to the rule the rest of this adapter + * follows. `history.back()` acts on the browser's history, not on the frame + * on screen, so the answer has to describe the history the button would + * actually move. During a staged navigation the two disagree: a push from + * index 0 leaves the presented frame at 0 while the entry is already there to + * pop, and a pending pop to index 0 leaves it at 1 — where a back control + * would fire a second pop and leave the application. + */ export function useCanGoBack() { const router = useRouter() - if (useFrameMode(router)) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- frozen at mount - return useRouterStateSelector( - router, - (state) => state.location.state.__TSR_index !== 0, - ) - } - if (isServer ?? router.isServer) { return router.stores.location.get().state.__TSR_index !== 0 } diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 517f7c96b8e..a83bb382f54 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -22,6 +22,7 @@ import { createRootRoute, createRoute, createRouter, + useCanGoBack, useLocation, useMatchRoute, useNavigate, @@ -402,6 +403,76 @@ describe.each(MODES)('%s', (_name, experimental_concurrentRenderFrames) => { await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) await waitFor(() => expect(router.stores.status.get()).toBe('idle')) }) + + /** + * `useCanGoBack` is an exception to the rule the rest of the adapter + * follows, and this pins it. `history.back()` acts on the browser's + * history, not on the frame on screen, so the answer has to describe the + * history the button would actually move. + * + * Reading the presented frame instead disagrees with it for exactly the + * staged window: a push from index 0 leaves the presented frame at 0 and + * the control disabled while the entry is already there to pop — and the + * dangerous direction, a pending pop to index 0, leaves it at 1, where a + * back control fires a second pop and leaves the application. + */ + test('canGoBack follows the browser history, not the presented frame', async () => { + const gate = deferred() + const seen: Array = [] + + function BackProbe() { + const canGoBack = useCanGoBack() + seen.push(canGoBack) + return
{String(canGoBack)}
+ } + + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => gate.promise, + component: () =>

Slow Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + // Index 0: nothing to go back to. + expect(screen.getByTestId('back')).toHaveTextContent('false') + + const navigation = router.navigate({ to: '/slow' }) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + // The entry exists and can be popped, while `/` is still on screen. + expect( + screen.getByRole('heading', { name: 'Index Title' }), + ).toBeInTheDocument() + expect(router.stores.location.get().state.__TSR_index).toBe(1) + await waitFor(() => + expect(screen.getByTestId('back')).toHaveTextContent('true'), + ) + + gate.resolve() + await navigation + await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) + expect(screen.getByTestId('back')).toHaveTextContent('true') + expect(seen).toContain(true) + }) }) describe('concurrent render frames', () => { From 7f237d590c47a9eaa3adaa16fb4b178314866a5d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:32:20 +0000 Subject: [PATCH 44/74] fix: adopt an in-flight frame on a router this tree returns to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state initializer runs once, so adoption covered a tree mounting on a cached owner but not one switching away from a router mid-navigation and back. The queue is pruned to whichever router is current, so the return left that router with nothing queued while its owner still held a staged frame: the acknowledgement compared against the committed frame, never settled, and the router stayed pending. Adoption now also happens when the router changes — and only then, or at mount. A tree already rendering for this router receives its staged frame through the dispatch, inside `startTransition`; adopting on every render would let an urgent render pick up a frame it is not presenting and acknowledge it, which is the isolation this option exists to provide. Tested through the router's status, since swapping the router under a mounted provider does not render the replacement's route tree upstream either: the returned-to router reads pending without this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 18 +++++ .../tests/concurrent-render-frames.test.tsx | 65 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index b2da0260902..ca6a6beae85 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -85,6 +85,24 @@ export function Matches() { ? new Map([[router, routerStateOwner.pending]]) : new Map(), ) + // The same adoption again, for a router this tree returns to rather than + // mounts on. The initializer runs once, so switching away from a router + // mid-navigation and back — the map pruned to the other router meanwhile — + // left it with no queued frame while its owner still held one in flight. + // + // Deliberately only at mount and on a change of router, never on every + // render: a tree already rendering for this router receives its staged + // frame through the dispatch, inside `startTransition`. Adopting outside + // those two moments would let an urgent render pick up a frame it is not + // presenting and acknowledge it, which is the isolation this option exists + // to provide. + const [adoptedRouter, setAdoptedRouter] = React.useState(router) + if (adoptedRouter !== router) { + setAdoptedRouter(router) + if (!queuedFrames.get(router) && routerStateOwner?.pending) { + setQueuedFrames(new Map([[router, routerStateOwner.pending]])) + } + } const renderFrame = queuedFrames.get(router) // Keep only this router's slot. A dispatch that outlived its router can // insert one for a router this tree will never render again, and nothing diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index a83bb382f54..faf7ead8262 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -956,6 +956,71 @@ describe('concurrent render frames', () => { expect(router.stores.status.get()).toBe('idle') }) + /** + * The other half of adopting an in-flight frame, for a router this tree + * returns to rather than mounts on. `Matches` survives the prop change, so + * the state initializer does not run again; the queue is pruned to whatever + * router is current, so coming back to one whose owner still holds a staged + * frame left it with nothing queued. Its acknowledgement compared against + * the committed frame and never settled, so that router stayed `pending`. + * + * Asserted on the router's status: swapping the router under a mounted + * provider does not render the replacement's route tree upstream either, so + * there is no DOM to compare. + */ + test('a router returned to adopts the frame still in flight', async () => { + const gate = deferred() + + const makeSwapRouter = (slow: boolean) => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const slowRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: slow ? () => gate.promise : undefined, + component: () =>

Slow Title

, + }) + return createRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames: true, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + } + + const first = makeSwapRouter(false) + const second = makeSwapRouter(true) + const tree = (router: AnyRouter) => ( + + + + ) + + const { rerender } = render(tree(first)) + await waitFor(() => expect(first.stores.status.get()).toBe('idle')) + + // Onto the second router, with a navigation that cannot finish yet. + rerender(tree(second)) + const navigation = second.navigate({ to: '/slow' }) + navigation.catch(() => {}) + await waitFor(() => expect(second.stores.status.get()).toBe('pending')) + + // Away — which prunes the queue to the other router — and back, with the + // load finished in between so nothing new is staged on the return. + rerender(tree(first)) + gate.resolve() + await act(async () => { + await gate.promise + }) + rerender(tree(second)) + + await waitFor(() => expect(second.stores.status.get()).toBe('idle')) + }) + test('a provider handed a different router builds an owner for it', async () => { const owners: Array = [] From ac22387d60f6b6e86e83051cf0ae3bbfc155b714 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:35:55 +0000 Subject: [PATCH 45/74] fix: publish a tree's frame-path decision on both arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decision was carried on the frame owner, and only the frame path builds one — so a store-path tree published nothing. A reader mounting after the option was turned on under such a tree read the option afresh and froze the frame path while `Matches` and the `Transitioner` around it stayed on the store path. It reads the right state either way, since a reader with no owner resolves to the router's head, which is what the store path reads. But a tree should have one answer, and the frame arm already had this. The decision now travels in its own context, tagged with the router it belongs to, and `RouterProvider` publishes it whichever branch it takes. Tested: the option is turned on underneath a mounted store-path tree and a reader is mounted afterwards. It freezes the frame path without this change and the tree's own answer with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/RouterProvider.tsx | 9 ++- .../react-router/src/routerStateContext.tsx | 52 +++++++++++++--- .../tests/concurrent-render-frames.test.tsx | 59 +++++++++++++++++++ 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index 0950f5cc51c..b86e9fa38c2 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -5,6 +5,7 @@ import { hasKeys } from '@tanstack/router-core' import { Matches } from './Matches' import { routerContext } from './routerContext' import { + RouterStateFrameMode, RouterStateProvider, useFrameMode, } from './routerStateContext' @@ -41,11 +42,15 @@ export function RouterContextProvider< } // Frozen, like every other branch on the option: swapping the router for - // one configured differently must not unmount the whole tree. + // one configured differently must not unmount the whole tree. Published on + // both arms, so a reader mounting later agrees with this tree whichever way + // it went — only the frame path builds an owner to carry it. const childrenWithState = useFrameMode(router as AnyRouter) ? ( {children} ) : ( - children + + {children} + ) const provider = ( diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index c3795c42373..e7b3fdf351c 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -150,9 +150,44 @@ const routerStateOwnerContext = React.createContext< * subscribes to the head inside a tree that is still presenting the committed * publication. */ -const routerStateFrameModeContext = React.createContext( - undefined, -) +type RouterStateFrameMode = { + /** The router this tree is rendering. */ + router: AnyRouter + /** The decision that tree mounted with. */ + frameMode: boolean +} + +const routerStateFrameModeContext = React.createContext< + RouterStateFrameMode | undefined +>(undefined) + +/** + * Publish a tree's frame-path decision without owning frames for it. + * + * The store path needs this too. Only the frame path builds an owner, so + * without publishing the decision on both arms a reader mounting after the + * option changed would read the option afresh and freeze the other answer + * from the tree around it. + */ +export function RouterStateFrameMode({ + router, + frameMode, + children, +}: { + router: AnyRouter + frameMode: boolean + children: React.ReactNode +}) { + const value = React.useMemo( + () => ({ router, frameMode }), + [router, frameMode], + ) + return ( + + {children} + + ) +} /** * Everything a router's publications need, closed over that one router. @@ -336,11 +371,11 @@ export function RouterStateProvider({ return ( - + {children} - + ) } @@ -444,11 +479,10 @@ export function useFrameMode(router: AnyRouter): boolean { // frames rather than with the option's current value. Read once, at this // component's first render, and kept, so a later swap cannot change this // reader's hook shape underneath it either. - const owner = React.useContext(routerStateOwnerContext) - const treeMode = React.useContext(routerStateFrameModeContext) + const tree = React.useContext(routerStateFrameModeContext) const [mode] = React.useState(() => - owner?.router === router && treeMode !== undefined - ? treeMode + tree?.router === router + ? tree.frameMode : Boolean(router.options.experimental_concurrentRenderFrames), ) return mode diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index faf7ead8262..3123c54b5b1 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -10,6 +10,7 @@ import { import * as React from 'react' import { RouterStateProvider, + useFrameMode, useRouterStateOwner, } from '../src/routerStateContext' import { @@ -1907,6 +1908,64 @@ describe('concurrent render frames', () => { await waitFor(() => screen.getByRole('heading', { name: 'Slow Title' })) }) + /** + * The store path publishes its decision too. Only the frame path builds an + * owner, so when the tree's answer was carried on the owner there was + * nothing to read on the other arm: a reader mounting after the option was + * turned *on* under a store-path tree froze `true` from the option and took + * the frame path while `Matches` and the `Transitioner` around it stayed on + * the store path. + * + * Harmless in what it reads — with no owner it resolves to the router's + * head, which is what the store path reads anyway — but the tree should + * have one answer, and this is the same invariant as the arm above. + */ + test('a reader mounted after the option was turned on follows the store-path tree', async () => { + const modes: Array = [] + + function ModeProbe() { + modes.push(useFrameMode(router)) + return null + } + + let showProbe!: (show: boolean) => void + const rootRoute = createRootRoute({ + component: function RootComponent() { + const [show, setShow] = React.useState(false) + showProbe = setShow + return ( + <> + {show ? : null} + + + ) + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + experimental_concurrentRenderFrames: false, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + // Turned on underneath the mounted tree, which stays on the store path. + act(() => { + router.update({ + ...router.options, + experimental_concurrentRenderFrames: true, + }) + }) + act(() => showProbe(true)) + + expect(modes).toEqual([false]) + }) + /** * A fresh provider mount reads the option as it stands, not as it stood the * last time this router was mounted. Owners are cached per router for the From 369156a05a356c614700c8bed0007b3e423d005b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:40:47 +0000 Subject: [PATCH 46/74] docs: say what frameId identifies, and what it does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract I wrote for `frameId` said two states sharing one are the same snapshot. The adapter does not honour that and should not: it overlays `status` and `isLoading` onto a publication a component is already presenting, keeping the identity, because that identity is what an acknowledgement is matched against — a new one there would orphan the render it belongs to. So the contract is narrowed to what is true and useful: `frameId` identifies a snapshot of route content, and is not a change token for the whole state. Stated on the type, on the API page, and at the overlay itself, with the advice to select the fields you depend on rather than versioning on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- docs/router/api/router/RouterStateType.md | 15 +++++++++++---- packages/react-router/src/routerStateContext.tsx | 10 +++++++++- packages/router-core/src/router.ts | 10 +++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/router/api/router/RouterStateType.md b/docs/router/api/router/RouterStateType.md index 66f48035327..d69a053fe64 100644 --- a/docs/router/api/router/RouterStateType.md +++ b/docs/router/api/router/RouterStateType.md @@ -25,13 +25,20 @@ The `RouterState` type contains all of the properties that are available on the ### `frameId` property - Type: `number` -- Identity for one atomically assembled snapshot of the router state. Every - snapshot the router publishes gets a new, larger value; two reads that return - the same `frameId` are reads of the same snapshot. +- Identity for one atomically assembled snapshot of **route content** — + `location`, `matches` and `resolvedLocation`. Every state the router + assembles gets a new, larger value. - It identifies a snapshot rather than a navigation: a single navigation - publishes several, and the value carries no meaning beyond comparison and + assembles several, and the value carries no meaning beyond comparison and ordering. Do not derive a location, a match, or a count of navigations from it. +- **Not a change token for the whole state.** `status` and `isLoading` are + navigation progress rather than route content, and a framework adapter may + overlay them onto a snapshot a component is already presenting while keeping + its `frameId` — the identity is what the adapter matches an acknowledgement + against, so changing it there would orphan the render it belongs to. Two + states sharing a `frameId` therefore agree on route content but can differ in + progress. Select the fields you depend on rather than versioning on this. ### `status` property diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index e7b3fdf351c..b71b24a18de 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -95,7 +95,15 @@ function resolveFrame( return staged && staged.frameId === frameId ? staged : scope.committed } -/** Overlay navigation progress onto a publication without changing its content. */ +/** + * Overlay navigation progress onto a publication without changing its content. + * + * Deliberately keeps the publication's `frameId`. That identity is what an + * acknowledgement is matched against, so a new one here would orphan the + * render presenting this publication — and it identifies route content, which + * progress is not. Two states can therefore share a `frameId` and differ in + * `status`, which the `RouterState` docs state as part of the contract. + */ function withProgress( frame: RouterRenderFrame, head: RouterRenderFrame, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index a0bd6362077..1edfdb0bb39 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -574,7 +574,15 @@ export interface RouterState< in out TRouteTree extends AnyRoute = AnyRoute, in out TRouteMatch = MakeRouteMatchUnion, > { - /** Monotonic identity for one atomically assembled render snapshot. */ + /** + * Monotonic identity for one atomically assembled snapshot of route content + * — `location`, `matches`, `resolvedLocation`. + * + * Not a change token for the whole state: `status` and `isLoading` are + * progress, and an adapter may overlay them onto a snapshot a component is + * already presenting while keeping this identity, since it is what an + * acknowledgement is matched against. + */ frameId: number status: 'pending' | 'idle' isLoading: boolean From 1fa7d285ce1c4b13ec20ea695aca2ab092af0ccd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:46:57 +0000 Subject: [PATCH 47/74] fix: decide the queued frame once, rather than adopting then pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two plain state writes in one render do not compose — the second wins. Adoption wrote the map holding the frame this tree is returning to, and the prune that follows read the pre-adoption map, found a foreign slot, and wrote an empty one over it. The next render would not adopt again, because the router had already been recorded, so that frame could never be acknowledged. Adoption and pruning decide the same value, so they now resolve to one map during render and write at most once. Mechanism only. I could not open the window in the unit harness: the prune runs on every render, so a foreign slot is gone before the render where the router changes, and I could not get a dispatch write and a router change into the same render — six attempts, including resolving the outgoing router's load and swapping inside one `act`, and a destination that suspends so its frame cannot be acknowledged. The defect is in the composition of the two writes rather than in a timing window, which is why it is worth fixing without one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/Matches.tsx | 32 ++++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index ca6a6beae85..2fe643df95f 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -97,24 +97,34 @@ export function Matches() { // presenting and acknowledge it, which is the isolation this option exists // to provide. const [adoptedRouter, setAdoptedRouter] = React.useState(router) - if (adoptedRouter !== router) { + const adopting = adoptedRouter !== router + if (adopting) { setAdoptedRouter(router) - if (!queuedFrames.get(router) && routerStateOwner?.pending) { - setQueuedFrames(new Map([[router, routerStateOwner.pending]])) - } } - const renderFrame = queuedFrames.get(router) - // Keep only this router's slot. A dispatch that outlived its router can - // insert one for a router this tree will never render again, and nothing + + // Adoption and pruning decide the same value, so they are resolved together + // here rather than written separately. Two plain writes in one render do not + // compose — the second wins — so pruning against the pre-adoption map threw + // the adopted frame away, and the next render skipped adoption because the + // router had already been recorded. + // + // Pruning keeps only this router's slot. A dispatch that outlived its router + // can insert one for a router this tree will never render again, and nothing // else would remove it — every outgoing router and its route data would be // retained for the life of this component. Adjusting state during render is // React's own answer to this shape; the write below re-renders immediately, // so the map is bounded whatever a stale dispatch does. - if (queuedFrames.size > (renderFrame ? 1 : 0)) { - setQueuedFrames( - renderFrame ? new Map([[router, renderFrame]]) : new Map(), - ) + let effectiveFrames = queuedFrames + const queued = effectiveFrames.get(router) + if (adopting && !queued && routerStateOwner?.pending) { + effectiveFrames = new Map([[router, routerStateOwner.pending]]) + } else if (effectiveFrames.size > (queued ? 1 : 0)) { + effectiveFrames = queued ? new Map([[router, queued]]) : new Map() + } + if (effectiveFrames !== queuedFrames) { + setQueuedFrames(effectiveFrames) } + const renderFrame = effectiveFrames.get(router) const setRenderFrame = React.useCallback( (frame: RouterRenderFrame | undefined) => setQueuedFrames((previous) => { From a7c85207ca80789cd0d14caafa445a8768a23a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:15:08 +0000 Subject: [PATCH 48/74] fix: drop a staged frame the head has already left, and advance frameId with content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both reachable and both tested. A staged frame is offered to a tree that may be suspended, and a replacement navigation moves the head without publishing anything of its own until its own load resolves. The first tree could finish suspending inside that window and commit a destination the URL had already left, because the acknowledgement matched on frame identity alone and that identity was still the one the owner held. The owner already watches the head, so it now drops a pending frame whose location the head has moved away from: nothing has committed it, so consumers fall back to the publication they are presenting — the route still on screen — and the successor stages its own frame when ready. Separately, `frameId` counted reads rather than publications. The SSR store is non-reactive, so its getter runs again for every reader, and two consumers in one server render saw different identities for the same route content — a hydration difference for any application deriving markup from a field this branch makes public. It now advances when route content does, which also makes it match the contract documented for it: progress alone does not move it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 14 ++++ .../tests/concurrent-render-frames.test.tsx | 77 +++++++++++++++++++ packages/router-core/src/stores.ts | 39 ++++++++-- .../router-core/tests/render-frames.test.ts | 60 ++++++++++++++- 4 files changed, 181 insertions(+), 9 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index b71b24a18de..a67164cf3d5 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -302,6 +302,20 @@ function createOwner(router: AnyRouter): RouterStateOwner { }, publish: () => { const head = router.stores.__store.get() + if (pending && !staging && head.location.href !== pending.location.href) { + // Superseded before anything rendered it. A staged frame is offered + // to a tree that may be suspended, and a replacement navigation moves + // the head without publishing anything of its own until its own load + // resolves — so the first tree could finish suspending inside that + // window and commit a destination the URL had already left. + // + // Nothing has committed it, so dropping it costs nothing: consumers + // fall back to the publication they are already presenting, which is + // the route still on screen, and the successor stages its own frame + // when it is ready. + owner.cancel() + return + } if (staging || pending) { syncProgress(head) return diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 3123c54b5b1..c09a8d6a443 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -2137,6 +2137,83 @@ describe('concurrent render frames', () => { await navigation.catch(() => {}) }) + /** + * A staged frame is offered to a tree that may be suspended, and a + * replacement navigation moves the head without publishing anything of its + * own until its load resolves. The first tree could finish suspending + * inside that window and commit a destination the URL had already left — + * the acknowledgement matched on frame identity alone, and that identity + * was still the one the owner was holding. + */ + test('a superseded frame does not commit while its tree is suspended', async () => { + const suspense = deferred() + const slowLoader = deferred() + let thrown = false + + function SuspendsOnce() { + if (!thrown) { + thrown = true + throw suspense.promise + } + return

First Title

+ } + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () => , + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: () => slowLoader.promise, + component: () =>

Second Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + // Stages a frame whose tree suspends, so nothing commits it. + const first = router.navigate({ to: '/first' }) + first.catch(() => {}) + await act(async () => { + await Promise.resolve() + }) + + // The replacement moves the head. Its loader is slow and it has no + // pending component, so it publishes nothing for a while. + const second = router.navigate({ to: '/second' }) + second.catch(() => {}) + await waitFor(() => + expect(router.stores.location.get().pathname).toBe('/second'), + ) + + // The first tree finishes suspending inside that window. + suspense.resolve() + await act(async () => { + await suspense.promise + }) + await act(async () => { + await Promise.resolve() + }) + + // The route the URL left must not be on screen. + expect(screen.queryByRole('heading', { name: 'First Title' })).toBeNull() + + slowLoader.resolve() + await waitFor(() => screen.getByRole('heading', { name: 'Second Title' })) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts index e1fe0e69dd0..245e687fb15 100644 --- a/packages/router-core/src/stores.ts +++ b/packages/router-core/src/stores.ts @@ -110,14 +110,37 @@ export function createRouterStores( ) // compatibility "big" state store - const __store = createReadonlyStore(() => ({ - frameId: nextFrameId++, - status: status.get(), - isLoading: status.get() === 'pending', - matches: matches.get(), - location: location.get(), - resolvedLocation: resolvedLocation.get(), - })) + // + // `frameId` advances when route content does, not on every read. The SSR + // store is non-reactive — its getter runs again for each reader — so + // counting reads gave two consumers in one server render different ids for + // the same content, and anything derived from one would differ between the + // server and the client, whose store caches the assembly. Progress is + // deliberately not part of the comparison: the id identifies route content, + // which is the contract `RouterState` documents. + let previous: RouterState | undefined + const __store = createReadonlyStore(() => { + const nextMatches = matches.get() + const nextLocation = location.get() + const nextResolvedLocation = resolvedLocation.get() + const unchanged = + previous !== undefined && + previous.location === nextLocation && + previous.resolvedLocation === nextResolvedLocation && + arraysEqual(previous.matches, nextMatches) + ? previous + : undefined + const next: RouterState = { + frameId: unchanged ? unchanged.frameId : nextFrameId++, + status: status.get(), + isLoading: status.get() === 'pending', + matches: nextMatches, + location: nextLocation, + resolvedLocation: nextResolvedLocation, + } + previous = next + return next + }) function getMatchStore(routeId: string): MatchStore { let matchStore = byRoute.get(routeId) diff --git a/packages/router-core/tests/render-frames.test.ts b/packages/router-core/tests/render-frames.test.ts index cbae92669f9..40e579eaf84 100644 --- a/packages/router-core/tests/render-frames.test.ts +++ b/packages/router-core/tests/render-frames.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute } from '../src' +import { + BaseRootRoute, + BaseRoute, + createNonReactiveMutableStore, + createNonReactiveReadonlyStore, +} from '../src' +import { createRouterStores } from '../src/stores' import { createTestRouter } from './routerTestUtils' function deferred() { @@ -51,6 +57,58 @@ describe('render frames', () => { expect(third).toBeGreaterThan(second) }) + /** + * The SSR store is non-reactive: its getter runs again for every reader. So + * counting reads rather than publications gave two consumers in one server + * render different identities for the same route content, and anything an + * application derived from one would differ between the server and the + * client, whose store caches the assembly. + */ + test('repeated reads of unchanged route content share an identity', () => { + // Built with the SSR config on purpose: the client store caches its + // assembly, so only the non-reactive one reruns the getter per reader. + const stores = createRouterStores( + createMemoryHistory({ initialEntries: ['/about'] }).location as any, + { + createMutableStore: createNonReactiveMutableStore, + createReadonlyStore: createNonReactiveReadonlyStore, + batch: (fn) => fn(), + }, + ) + + const first = stores.__store.get() + const second = stores.__store.get() + const third = stores.__store.get() + + expect(second.frameId).toBe(first.frameId) + expect(third.frameId).toBe(first.frameId) + + // And it still advances when the content does. + stores.setMatches([ + { id: '__root__', routeId: '__root__' } as any, + ]) + expect(stores.__store.get().frameId).toBeGreaterThan(first.frameId) + }) + + /** + * Progress is not route content, so it does not advance the identity — the + * contract `RouterState` documents, and what lets the adapter overlay + * `status` onto a publication a component is already presenting. + */ + test('progress alone does not advance the frame identity', async () => { + const router = createRouter() + await router.navigate({ to: '/about' }) + + const before = router.stores.__store.get().frameId + router.stores.status.set('pending') + const during = router.stores.__store.get() + router.stores.status.set('idle') + + expect(during.frameId).toBe(before) + expect(during.status).toBe('pending') + expect(during.isLoading).toBe(true) + }) + test('a frame is a complete, self-consistent snapshot', async () => { const router = createRouter() await router.navigate({ to: '/posts/123' }) From 103bedf6599ca077150b87a27ba6d8ff0441fc8d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:55:22 +0000 Subject: [PATCH 49/74] fix: compare the history entry too when dropping a superseded frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A replacement navigation can target the same URL with different state — same href, different history entry — and the frame staged for the first is just as stale. The guard now compares `location.state.__TSR_key` as well as the href. Deliberately still the location rather than the frame identity, which was the other suggestion: a publication that changes matches without moving the location — a background refresh — is not a supersession, and cancelling on it would wedge the navigation it belongs to. Mechanism only for the same-href case. A probe with one route, `state` {n:1} then {n:2}, and the first tree suspended reads the replacement's state either way — with the guard disabled entirely as well — so something upstream already prevents that particular commit and I could not open the window. The differing-href case remains covered by `a superseded frame does not commit while its tree is suspended`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- packages/react-router/src/routerStateContext.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index a67164cf3d5..e97902ec30f 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -302,7 +302,12 @@ function createOwner(router: AnyRouter): RouterStateOwner { }, publish: () => { const head = router.stores.__store.get() - if (pending && !staging && head.location.href !== pending.location.href) { + const superseded = + pending !== undefined && + !staging && + (head.location.href !== pending.location.href || + head.location.state.__TSR_key !== pending.location.state.__TSR_key) + if (superseded) { // Superseded before anything rendered it. A staged frame is offered // to a tree that may be suspended, and a replacement navigation moves // the head without publishing anything of its own until its own load @@ -313,6 +318,14 @@ function createOwner(router: AnyRouter): RouterStateOwner { // fall back to the publication they are already presenting, which is // the route still on screen, and the successor stages its own frame // when it is ready. + // + // The history key is compared as well as the href, because a + // replacement can target the same URL with different state — same + // href, different entry — and that frame is just as stale. Comparing + // the location rather than the frame identity is deliberate: a + // publication that changes matches without moving the location, a + // background refresh say, is not a supersession, and cancelling on it + // would wedge the navigation it belongs to. owner.cancel() return } From e2ef78ae409ab5ed59a12be7285f04a693ccf54a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 04:08:02 +0000 Subject: [PATCH 50/74] fix: do not adopt a staged frame the head has already left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling a superseded frame runs from the store subscription a provider holds, so while no provider is mounted nothing notices the head moving. A tree mounting afterwards adopted whatever the owner still held, and because a descendant's layout effect runs before the provider's own it acknowledged and committed that frame before anything could drop it — putting the route the head had left back on screen, with `publish` no longer able to recognise it as pending. The owner's `pending` getter now reports a frame only while the head still names it, so both adoption sites inherit the test the owner already applies, and the supersession comparison itself is one function rather than two copies. Tested: a navigation in flight when the tree unmounts, its load finishing with nothing to render it, the head moving on, then a tree mounting before the successor stages. Without this change the first route is on screen while the head reads `/second`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 49 +++++++++---- .../tests/concurrent-render-frames.test.tsx | 71 +++++++++++++++++++ 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index e97902ec30f..26003f54cc7 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -95,6 +95,26 @@ function resolveFrame( return staged && staged.frameId === frameId ? staged : scope.committed } +/** + * Whether the head has moved away from a staged publication. + * + * The history key is compared as well as the href, because a replacement can + * target the same URL with different state — same href, different entry — and + * the frame staged before it is just as stale. The location decides this + * rather than the frame identity: a publication that changes matches without + * moving the location, a background refresh say, is not a supersession, and + * treating it as one would wedge the navigation it belongs to. + */ +function isSuperseded( + frame: RouterRenderFrame, + head: RouterRenderFrame, +): boolean { + return ( + head.location.href !== frame.location.href || + head.location.state.__TSR_key !== frame.location.state.__TSR_key + ) +} + /** * Overlay navigation progress onto a publication without changing its content. * @@ -252,7 +272,19 @@ function createOwner(router: AnyRouter): RouterStateOwner { return root.committed }, get pending() { - return pending + // Only while the head still names it. A tree adopting this frame is one + // that was never offered it — it mounted, or returned to this router — + // and nothing cancelled it in between, because cancelling happens from + // the store subscription a provider holds and there may have been no + // provider to hold one. Adopting it then would acknowledge and commit a + // route the head has already left, and `publish` could no longer + // recognise it as pending in order to drop it. + if (!pending) { + return undefined + } + return isSuperseded(pending, router.stores.__store.get()) + ? undefined + : pending }, begin: () => { staging = true @@ -302,12 +334,7 @@ function createOwner(router: AnyRouter): RouterStateOwner { }, publish: () => { const head = router.stores.__store.get() - const superseded = - pending !== undefined && - !staging && - (head.location.href !== pending.location.href || - head.location.state.__TSR_key !== pending.location.state.__TSR_key) - if (superseded) { + if (pending !== undefined && !staging && isSuperseded(pending, head)) { // Superseded before anything rendered it. A staged frame is offered // to a tree that may be suspended, and a replacement navigation moves // the head without publishing anything of its own until its own load @@ -318,14 +345,6 @@ function createOwner(router: AnyRouter): RouterStateOwner { // fall back to the publication they are already presenting, which is // the route still on screen, and the successor stages its own frame // when it is ready. - // - // The history key is compared as well as the href, because a - // replacement can target the same URL with different state — same - // href, different entry — and that frame is just as stale. Comparing - // the location rather than the frame identity is deliberate: a - // publication that changes matches without moving the location, a - // background refresh say, is not a supersession, and cancelling on it - // would wedge the navigation it belongs to. owner.cancel() return } diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index c09a8d6a443..3680854c199 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -2214,6 +2214,77 @@ describe('concurrent render frames', () => { await waitFor(() => screen.getByRole('heading', { name: 'Second Title' })) }) + /** + * Adoption has to apply the same supersession test as the owner does. + * + * Cancelling a superseded frame happens from the store subscription a + * provider holds — so while no provider is mounted, nothing notices the + * head moving. A tree mounting afterwards adopted whatever was staged, and + * because a descendant's layout effect runs before the provider's own, it + * acknowledged and committed that frame before anything could drop it: the + * route the head had left, back on screen. + */ + test('a tree does not adopt a frame the head has left', async () => { + const first = deferred() + const second = deferred() + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + loader: () => first.promise, + component: () =>

First Title

, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: () => second.promise, + component: () =>

Second Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + // The first navigation is in flight when the tree goes away, and its load + // finishes with nothing left to render it — so the owner holds it staged. + const toFirst = router.navigate({ to: '/first' }) + toFirst.catch(() => {}) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + cleanup() + first.resolve() + await act(async () => { + await first.promise + }) + + // The head moves on while there is no provider to notice. + const toSecond = router.navigate({ to: '/second' }) + toSecond.catch(() => {}) + await waitFor(() => + expect(router.stores.location.get().pathname).toBe('/second'), + ) + + // And a tree mounts before the successor stages anything. + render() + await act(async () => { + await Promise.resolve() + }) + + expect(screen.queryByRole('heading', { name: 'First Title' })).toBeNull() + + second.resolve() + await waitFor(() => screen.getByRole('heading', { name: 'Second Title' })) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From f2d49e6ab5bd55f38aa6bfeeaced7fcb7449e068 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 04:23:39 +0000 Subject: [PATCH 51/74] fix: do not seed a fresh reader from a staged frame the head has left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rejecting a stale frame for adoption was not enough. It is still in the staged slot, and that slot is what seeds a reader mounting for the first time — including `MatchesInner`'s own matches reader. So the route the stale frame names still mounted and ran its effects, and because descendant effects run before the provider's, a `` in that route would have fired a redirect from a frame nothing ever acknowledged, replacing the navigation the head actually names. `offeredFrame` now refuses a staged publication the head has moved away from, using the same test as the owner and adoption. Tested: the route's component records its mount effect, and after a remount over a superseded frame it records nothing. Instrumenting the seed showed the reader taking `staged=/first` while the head read `/second` before this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 18 ++++- .../tests/concurrent-render-frames.test.tsx | 74 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 26003f54cc7..4e1866657b4 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -74,9 +74,23 @@ type RouterStateOwner = { const defaultCompare = (a: unknown, b: unknown) => a === b -/** The publication a fresh reader at this position should start from. */ +/** + * The publication a fresh reader at this position should start from. + * + * A staged publication the head has already left is not offered. Cancelling + * one runs from the store subscription a provider holds, so while no provider + * was mounted nothing dropped it — and seeding from it would mount the route + * it names: descendant effects run before the provider's, so a `` + * in that route would fire a redirect from a frame nothing ever acknowledged. + */ function offeredFrame(scope: RouterStateScope): RouterRenderFrame { - return scope.staged ?? scope.committed + const staged = scope.staged + if (!staged) { + return scope.committed + } + return isSuperseded(staged, scope.router.stores.__store.get()) + ? scope.committed + : staged } /** diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 3680854c199..7c06b5087dc 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -2285,6 +2285,80 @@ describe('concurrent render frames', () => { await waitFor(() => screen.getByRole('heading', { name: 'Second Title' })) }) + /** + * Rejecting a stale frame for adoption is not enough: it is still in the + * staged slot, and that slot is what seeds a fresh reader. `MatchesInner`'s + * own matches reader is one, so the route the stale frame names still + * mounted and ran its effects — descendant effects run before the + * provider's, so a `` in that route would fire a redirect from a + * frame nothing ever acknowledged. The frame is refused at the seeding + * point too. + */ + test('a rejected staged frame does not mount its route', async () => { + const first = deferred() + const second = deferred() + const mounted: Array = [] + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + loader: () => first.promise, + component: function FirstComponent() { + React.useEffect(() => { + mounted.push('first') + }, []) + return

First Title

+ }, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: () => second.promise, + component: () =>

Second Title

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames: true, + }) + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + const toFirst = router.navigate({ to: '/first' }) + toFirst.catch(() => {}) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + cleanup() + first.resolve() + await act(async () => { + await first.promise + }) + + const toSecond = router.navigate({ to: '/second' }) + toSecond.catch(() => {}) + await waitFor(() => + expect(router.stores.location.get().pathname).toBe('/second'), + ) + + mounted.length = 0 + render() + await act(async () => { + await Promise.resolve() + }) + + // The stale route must not have mounted at all. + expect(mounted).toEqual([]) + + second.resolve() + await waitFor(() => screen.getByRole('heading', { name: 'Second Title' })) + }) + /** * A selector is user code, and the frame path runs it outside React's * render — from the Router's `startTransition`, to decide whether a From 6128277f800007a80a46043015e489565b836cf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 04:41:50 +0000 Subject: [PATCH 52/74] fix: revalidate the head at the acknowledgement boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `commit` matched the frame it was handed against `pending` by identity, and the frame satisfies that check — it really is the one that was staged. What identity cannot say is whether the head is still there. Withdrawing a superseded frame runs from the store subscription the provider installs in a layout effect, and layout effects run bottom-up. A tree that adopted the frame during a render React then yielded out of reaches the acknowledgement before that subscription exists, so a navigation starting inside the gap moves the head with nothing watching. The acknowledgement then committed the route the user had already left into both scopes, and cleared `pending`, so nothing could withdraw it afterwards. Check the head here too and cancel instead, exactly as `publish` would have. The earlier guards on adoption and on seeding a fresh reader both run before rendering; this is the one boundary that had none. The interleaving that produces the state needs a real concurrent yield, which `act` does not give. The state itself is exact, so the test drives the boundary directly: a genuine frame for a location the head has left, staged without moving the store so nothing publishes in between. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../react-router/src/routerStateContext.tsx | 16 ++++ .../tests/concurrent-render-frames.test.tsx | 79 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/packages/react-router/src/routerStateContext.tsx b/packages/react-router/src/routerStateContext.tsx index 4e1866657b4..75c4d3a0da6 100644 --- a/packages/react-router/src/routerStateContext.tsx +++ b/packages/react-router/src/routerStateContext.tsx @@ -326,6 +326,22 @@ function createOwner(router: AnyRouter): RouterStateOwner { if (pending?.frameId !== nextFrame.frameId) { return false } + if (isSuperseded(nextFrame, router.stores.__store.get())) { + // The head left this frame and nothing dropped it. Cancelling runs + // from the store subscription the provider installs in a layout + // effect, and layout effects run bottom-up: a tree that adopted the + // frame during a render React then yielded out of reaches this + // acknowledgement before that subscription exists, so a navigation + // starting inside the gap moves the head unobserved. + // + // The frame identity alone cannot tell: it still matches `pending`, + // because the frame is genuinely the one that was staged. Committing + // it here would put the route the user has already left into both + // scopes, and clear `pending` so nothing could withdraw it + // afterwards. Cancel instead, exactly as `publish` would have. + owner.cancel() + return false + } pending = undefined // The staged publication is now what everyone has committed, so the // staged slot empties and both scopes resolve to it. diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index 7c06b5087dc..c5c54b6a957 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -2441,4 +2441,83 @@ describe('concurrent render frames', () => { await waitFor(() => screen.getByTestId('caught')) expect(errors).toContain('selector boom') }) + + /** + * The acknowledgement boundary revalidates the head. + * + * `commit` matches the frame it is handed against `pending` by identity, + * and the frame satisfies that — it really is the one that was staged. + * What identity cannot say is whether the head is still there. Withdrawing + * a superseded frame runs from the store subscription the provider installs + * in a layout effect, and layout effects run bottom-up, so a tree that + * adopted the frame during a render React then yielded out of reaches this + * boundary before that subscription exists: a navigation starting inside + * the gap moves the head with nothing watching. + * + * That interleaving needs a real concurrent yield, which `act` does not + * produce. The state it leaves behind is what matters and is exact — a + * pending frame the head has left, and no publication in between for a + * subscription to have noticed — so the boundary is driven directly. + */ + test('an acknowledgement for a frame the head has left is refused', async () => { + let owner!: NonNullable> + + const rootRoute = createRootRoute({ + component: function RootComponent() { + owner = useRouterStateOwner()! + return + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index Title

, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + component: () =>

Posts Title

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + experimental_concurrentRenderFrames: true, + }) + + render() + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + + // A genuine frame, for a location the head then leaves. + let toPosts!: Promise + act(() => { + toPosts = router.navigate({ to: '/posts' }) + }) + await waitFor(() => screen.getByRole('heading', { name: 'Posts Title' })) + await toPosts + const postsFrame = owner.frame + let toIndex!: Promise + act(() => { + toIndex = router.navigate({ to: '/' }) + }) + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + await toIndex + + // Staged without moving the store, which is the gap: nothing publishes, + // so nothing withdraws it. + act(() => { + owner.begin() + owner.stage(postsFrame) + }) + + let accepted: boolean | undefined + act(() => { + accepted = owner.commit(postsFrame) + }) + + expect(accepted).toBe(false) + expect(owner.frame.location.pathname).toBe('/') + // And the refusal withdraws it rather than leaving it on offer, so the + // route subtree falls back to the route that is still on screen. + await waitFor(() => screen.getByRole('heading', { name: 'Index Title' })) + expect(screen.queryByRole('heading', { name: 'Posts Title' })).toBeNull() + }) }) From 45f3bf2c4136b1cd2917dc5552496d668fa4403e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 04:56:18 +0000 Subject: [PATCH 53/74] test: pin same-location refresh parity with the store path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A same-location refresh is not a supersession, and review asked whether it should be: two overlapping `invalidate()` calls for one history entry look identical to `isSuperseded`, so a suspended refresh frame can commit behind a successor that is still loading. It can, and that is exactly what the store path presents at the same moment. Measured on both arms, with the first refresh's data arriving, its tree suspending on it, a second refresh starting, and the suspended tree resuming while that second load is still in flight: both paths present the same sequence, 1 then 2 then 3, never backwards and never skipping a generation. The first refresh's data is the freshest that exists at that point — the successor has produced nothing yet — so refusing the frame would put back content older than what has already been rendered. Kept as a matrix test rather than a note, so the parity is the contract. It also gives the frame path its first coverage of `invalidate`, which the navigation-shaped tests never exercised. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AGGccw4N4WrJCpLZq7hRCw --- .../tests/concurrent-render-frames.test.tsx | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/packages/react-router/tests/concurrent-render-frames.test.tsx b/packages/react-router/tests/concurrent-render-frames.test.tsx index c5c54b6a957..63174a790d7 100644 --- a/packages/react-router/tests/concurrent-render-frames.test.tsx +++ b/packages/react-router/tests/concurrent-render-frames.test.tsx @@ -474,6 +474,100 @@ describe.each(MODES)('%s', (_name, experimental_concurrentRenderFrames) => { expect(screen.getByTestId('back')).toHaveTextContent('true') expect(seen).toContain(true) }) + + /** + * A same-location refresh is not a supersession, and the frame path presents + * exactly what the store path does. + * + * `isSuperseded` compares the location and the history entry, so two + * overlapping `invalidate()` calls for the same entry look identical to it — + * raised in review as a case where a suspended refresh frame could commit + * behind a successor that is still loading. It can, and that is the same + * content the store path shows at the same moment: the first refresh's data + * is the freshest that exists, the successor has produced nothing yet, and + * refusing the frame would put back content older than what has already been + * rendered. Asserted on both paths so the parity is the contract rather than + * an observation. + */ + test('overlapping refreshes of one location present the same sequence as the store path', async () => { + const firstRefresh = deferred() + const secondRefresh = deferred() + const resume = deferred() + let generation = 0 + let resumed = false + resume.promise.then(() => { + resumed = true + }) + const presented: Array = [] + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => { + const load = generation++ + return load === 0 + ? '1' + : load === 1 + ? firstRefresh.promise + : secondRefresh.promise + }, + component: function IndexComponent() { + const data = indexRoute.useLoaderData() + // The first refresh's tree suspends, so its frame is staged and + // rendered but cannot be acknowledged until `resume` resolves. + if (data === '2' && !resumed) { + throw resume.promise + } + presented.push(data) + return
{data}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + defaultPendingMs: 0, + experimental_concurrentRenderFrames, + }) + + render() + await waitFor(() => + expect(screen.getByTestId('data')).toHaveTextContent('1'), + ) + + // The first refresh's data arrives; its tree suspends on it. + const first = router.invalidate() + first.catch(() => {}) + await waitFor(() => expect(router.stores.status.get()).toBe('pending')) + firstRefresh.resolve('2') + await act(async () => { + await firstRefresh.promise + }) + expect(screen.getByTestId('data')).toHaveTextContent('1') + + // A second refresh of the same entry starts, and is still loading when + // the suspended tree resumes. + const second = router.invalidate() + second.catch(() => {}) + await act(async () => { + await Promise.resolve() + }) + resume.resolve() + await act(async () => { + await resume.promise + }) + expect(screen.getByTestId('data')).toHaveTextContent('2') + + secondRefresh.resolve('3') + await act(async () => { + await secondRefresh.promise + }) + await waitFor(() => + expect(screen.getByTestId('data')).toHaveTextContent('3'), + ) + + // Never backwards, and never a generation skipped. + expect(presented).toEqual(['1', '2', '3']) + }) }) describe('concurrent render frames', () => { From b1c92194397e57cecfab72de70e2dd8064a4f6d5 Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 10 Sep 2026 07:55:02 +0200 Subject: [PATCH 54/74] perf(solid-router): skip client setup for all server links (#8316) * perf(solid-router): skip client setup for all server links * test(solid-router): preserve caller props for internal SSR links * Merge branch 'main' into codex/solid-link-server-all [Self-Healing CI Rerun] --------- Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com> --- .changeset/silent-cycles-like.md | 5 + packages/solid-router/src/link.tsx | 192 +++++++++--------- .../solid-router/tests/server/link.test.tsx | 11 +- 3 files changed, 116 insertions(+), 92 deletions(-) create mode 100644 .changeset/silent-cycles-like.md diff --git a/.changeset/silent-cycles-like.md b/.changeset/silent-cycles-like.md new file mode 100644 index 00000000000..67087724ad3 --- /dev/null +++ b/.changeset/silent-cycles-like.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-router': patch +--- + +Skip client-only hydration, preloading, observer, and event-handler setup for all server-rendered links. diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 65fc0303a2a..95cc1e977f1 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -47,11 +47,6 @@ export function useLinkProps< options: UseLinkPropsOptions, ): Solid.ComponentProps<'a'> { const router = useRouter() - const shouldHydrateHash = !isServer && !!router.options.ssr - const hasHydrated = useHydrated() - - let hasRenderFetched = false - const [local, rest] = Solid.splitProps( Solid.mergeProps( { @@ -189,18 +184,8 @@ export function useLinkProps< return _href && getUrlScheme(_href) ? _href : undefined }) - const preload = Solid.createMemo(() => { - if ( - options.reloadDocument || - externalLink() !== undefined || - local.disabled - ) { - return false - } - return local.preload ?? router.options.defaultPreload - }) - const preloadDelay = () => - local.preloadDelay ?? router.options.defaultPreloadDelay ?? 0 + const shouldHydrateHash = !isServer && !!router.options.ssr + const hasHydrated = (isServer ?? router.isServer) ? undefined : useHydrated() const isActive = Solid.createMemo(() => { if (externalLink() !== undefined) { @@ -238,12 +223,98 @@ export function useLinkProps< if (activeOptions?.includeHash) { const currentHash = - shouldHydrateHash && !hasHydrated() ? '' : current.hash + shouldHydrateHash && !hasHydrated?.() ? '' : current.hash return currentHash === nextLocation.hash } return true }) + const simpleStyling = Solid.createMemo( + () => + local.activeProps === STATIC_ACTIVE_PROPS_GET && + local.inactiveProps === STATIC_INACTIVE_PROPS_GET && + local.class === undefined && + local.style === undefined, + ) + + type ResolvedLinkStateProps = Omit, 'style'> & { + style?: Solid.JSX.CSSProperties + } + + const resolveLinkStateProps = ( + base: Solid.ComponentProps<'a'> & { disabled?: boolean }, + ) => { + const active = isActive() + + if (simpleStyling()) { + return { + ...base, + ...(active && STATIC_DEFAULT_ACTIVE_ATTRIBUTES), + } + } + + // Active and inactive props are mutually exclusive. + const stateProps: ResolvedLinkStateProps = active + ? (functionalUpdate(local.activeProps as any, {}) ?? EMPTY_OBJECT) + : functionalUpdate(local.inactiveProps, {}) + const style = { + ...local.style, + ...stateProps.style, + } + const className = [local.class, stateProps.class].filter(Boolean).join(' ') + + return { + ...stateProps, + ...base, + ...(hasKeys(style) ? { style } : undefined), + ...(className ? { class: className } : undefined), + ...(active && STATIC_ACTIVE_ATTRIBUTES), + } as ResolvedLinkStateProps + } + + // Keep the guard inline so browser builds can drop the server return. + if (isServer ?? router.isServer) { + const external = externalLink() + const disabled = local.disabled || external === null + const props = resolveLinkStateProps({ + onClick: local.onClick, + onBlur: local.onBlur, + onFocus: local.onFocus, + onMouseEnter: local.onMouseEnter, + onMouseLeave: local.onMouseLeave, + onMouseOut: local.onMouseOut, + onMouseOver: local.onMouseOver, + onTouchStart: local.onTouchStart, + href: external === null ? undefined : external || hrefOption(), + ref: options.ref, + disabled, + target: local.target, + ...(disabled && STATIC_DISABLED_PROPS), + }) + // Avoid creating merged-prop getters for absent server event handlers. + for (const key of STATIC_EVENT_PROPS) { + if (props[key] === undefined) { + delete props[key] + } + } + return Solid.mergeProps(propsSafeToSpread, props) as any + } + + let hasRenderFetched = false + + const preload = Solid.createMemo(() => { + if ( + options.reloadDocument || + externalLink() !== undefined || + local.disabled + ) { + return false + } + return local.preload ?? router.options.defaultPreload + }) + const preloadDelay = () => + local.preloadDelay ?? router.options.defaultPreloadDelay ?? 0 + const doPreload = () => router .preloadRoute(options as Parameters[0]) @@ -302,41 +373,6 @@ export function useLinkProps< } }) - // SSR has no reactive destination changes or internal event handlers. - // Keep this guard inline so browser builds drop the entire shortcut. - if (isServer ?? router.isServer) { - const external = externalLink() - if ( - external !== undefined && - local.activeProps === STATIC_ACTIVE_PROPS_GET && - local.inactiveProps === STATIC_INACTIVE_PROPS_GET && - local.class === undefined && - local.style === undefined - ) { - const disabled = local.disabled || external === null - return Solid.mergeProps( - propsSafeToSpread, - Solid.splitProps(local, [ - 'target', - 'onClick', - 'onBlur', - 'onFocus', - 'onMouseEnter', - 'onMouseLeave', - 'onMouseOut', - 'onMouseOver', - 'onTouchStart', - ])[0], - { - ref: mergeRefs(setRef, options.ref), - href: external ?? undefined, - disabled, - ...(disabled && STATIC_DISABLED_PROPS), - }, - ) as any - } - } - // The click handler const handleClick = (e: MouseEvent) => { // Check actual element's target attribute as fallback @@ -381,14 +417,6 @@ export function useLinkProps< } } - const simpleStyling = Solid.createMemo( - () => - local.activeProps === STATIC_ACTIVE_PROPS_GET && - local.inactiveProps === STATIC_INACTIVE_PROPS_GET && - local.class === undefined && - local.style === undefined, - ) - const onClick = createComposedHandler(() => local.onClick, handleClick) const onBlur = createComposedHandler(() => local.onBlur, handleLeave) const onFocus = createComposedHandler(() => local.onFocus, enqueuePreload) @@ -410,12 +438,7 @@ export function useLinkProps< handleTouchStart, ) - type ResolvedLinkStateProps = Omit, 'style'> & { - style?: Solid.JSX.CSSProperties - } - const resolvedProps = Solid.createMemo(() => { - const active = isActive() const external = externalLink() const disabled = local.disabled || external === null @@ -435,35 +458,22 @@ export function useLinkProps< ...(disabled && STATIC_DISABLED_PROPS), } - if (simpleStyling()) { - return { - ...base, - ...(active && STATIC_DEFAULT_ACTIVE_ATTRIBUTES), - } - } - - // Active and inactive props are mutually exclusive. - const stateProps: ResolvedLinkStateProps = active - ? (functionalUpdate(local.activeProps as any, {}) ?? EMPTY_OBJECT) - : functionalUpdate(local.inactiveProps, {}) - const style = { - ...local.style, - ...stateProps.style, - } - const className = [local.class, stateProps.class].filter(Boolean).join(' ') - - return { - ...stateProps, - ...base, - ...(hasKeys(style) ? { style } : undefined), - ...(className ? { class: className } : undefined), - ...(active && STATIC_ACTIVE_ATTRIBUTES), - } as ResolvedLinkStateProps + return resolveLinkStateProps(base) }) return Solid.mergeProps(propsSafeToSpread, resolvedProps) as any } +const STATIC_EVENT_PROPS = [ + 'onClick', + 'onBlur', + 'onFocus', + 'onMouseEnter', + 'onMouseLeave', + 'onMouseOut', + 'onMouseOver', + 'onTouchStart', +] as const const STATIC_ACTIVE_PROPS = { class: 'active' } const STATIC_ACTIVE_PROPS_GET = () => STATIC_ACTIVE_PROPS const EMPTY_OBJECT = {} @@ -474,7 +484,7 @@ const STATIC_DEFAULT_ACTIVE_ATTRIBUTES = { 'aria-current': 'page', } const STATIC_DISABLED_PROPS = { - role: 'link', + role: 'link' as const, 'aria-disabled': true, } const STATIC_ACTIVE_ATTRIBUTES = { diff --git a/packages/solid-router/tests/server/link.test.tsx b/packages/solid-router/tests/server/link.test.tsx index 1c266b524c7..4baa81161c8 100644 --- a/packages/solid-router/tests/server/link.test.tsx +++ b/packages/solid-router/tests/server/link.test.tsx @@ -12,6 +12,8 @@ import { import type { JSX } from 'solid-js' test.each([ + { to: '/', href: '/' }, + { to: '/internal', href: '/internal' }, { to: 'https://example.com/', href: 'https://example.com/' }, { to: '/external', href: 'https://example.com/rewritten' }, { to: 'javascript:blocked()', href: undefined }, @@ -44,6 +46,8 @@ test.each([ onMouseOver: vi.fn(), onTouchStart: vi.fn(), } + const ref = vi.fn() + const callerProps = { ref, ...handlers } const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { renderToString(() => ( @@ -54,7 +58,7 @@ test.each([ href={to.startsWith('/') ? undefined : 'javascript:spoofed()'} target="_blank" title="custom link" - {...handlers} + {...callerProps} /> )} @@ -64,6 +68,11 @@ test.each([ target: '_blank', title: 'custom link', }) + const receivedRef = received?.ref as (element: HTMLAnchorElement) => void + expect(receivedRef).toBeTypeOf('function') + const element = {} as HTMLAnchorElement + receivedRef(element) + expect(ref).toHaveBeenCalledExactlyOnceWith(element) for (const name of Object.keys(handlers) as Array) { const handler = received?.[name] as (event: Event) => void expect(handler).toBeTypeOf('function') From 69c07c4bbfcb949035a69ad4ae9ca60600d9a386 Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 10 Sep 2026 11:05:54 +0200 Subject: [PATCH 55/74] test(router-core): cover search-derived context boundaries (#8334) * test(react-router): cover search-derived context boundaries * test(router-core): move search context boundary coverage to core --- .../tests/search-context-boundaries.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 packages/router-core/tests/search-context-boundaries.test.ts diff --git a/packages/router-core/tests/search-context-boundaries.test.ts b/packages/router-core/tests/search-context-boundaries.test.ts new file mode 100644 index 00000000000..5daec53aaab --- /dev/null +++ b/packages/router-core/tests/search-context-boundaries.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +const path = '/parent/child?name=Alice&__proto__=%7B%22isAdmin%22%3Atrue%7D' + +function createRouteTree() { + const rootRoute = new BaseRootRoute() + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: ({ search }): Record => search, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ context }) => ({ + name: context.name, + access: context.isAdmin ? 'admin' : 'visitor', + }), + }) + + return { + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + childRoute, + } +} + +test('search-derived ancestor context does not grant inherited properties to a client loader', async () => { + const { routeTree, childRoute } = createRouteTree() + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [path] }), + isServer: false, + }) + + await router.load() + + expect( + router.state.matches.find((match) => match.routeId === childRoute.id) + ?.loaderData, + ).toEqual({ name: 'Alice', access: 'visitor' }) +}) + +test('search-derived ancestor context does not grant inherited properties to an SSR loader', async () => { + const { routeTree, childRoute } = createRouteTree() + const router = createTestRouter({ routeTree, isServer: true }) + + const response = await loadServerResponse(router, path) + + expect(response.status).toBe(200) + expect( + router.state.matches.find((match) => match.routeId === childRoute.id) + ?.loaderData, + ).toEqual({ name: 'Alice', access: 'visitor' }) +}) From 3b27db6c38d24cf92b3ffd426db26b47897b5c0a Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Thu, 10 Sep 2026 12:32:59 +0200 Subject: [PATCH 56/74] docs: remove duplicate Router and Start page headings (#8339) --- docs/router/how-to/share-search-params-across-routes.md | 2 -- docs/start/framework/react/guide/cdn-asset-urls.md | 2 -- docs/start/framework/react/guide/client-entry-point.md | 2 -- docs/start/framework/react/guide/early-hints.md | 2 -- docs/start/framework/react/guide/server-entry-point.md | 2 -- docs/start/framework/solid/guide/client-entry-point.md | 2 -- 6 files changed, 12 deletions(-) diff --git a/docs/router/how-to/share-search-params-across-routes.md b/docs/router/how-to/share-search-params-across-routes.md index e1e24c1e0d1..23a202b1766 100644 --- a/docs/router/how-to/share-search-params-across-routes.md +++ b/docs/router/how-to/share-search-params-across-routes.md @@ -2,8 +2,6 @@ title: Share Search Parameters Across Routes --- -# How to Share Search Parameters Across Routes - Search parameters automatically inherit from parent routes in TanStack Router. When a parent route validates search parameters, child routes can access them via `Route.useSearch()` alongside their own parameters. ## How Parameter Inheritance Works diff --git a/docs/start/framework/react/guide/cdn-asset-urls.md b/docs/start/framework/react/guide/cdn-asset-urls.md index 28c9c8c0302..ac5f3e7b576 100644 --- a/docs/start/framework/react/guide/cdn-asset-urls.md +++ b/docs/start/framework/react/guide/cdn-asset-urls.md @@ -3,8 +3,6 @@ id: cdn-asset-urls title: CDN Asset URLs --- -# CDN Asset URLs - > **Experimental:** `transformAssets` is experimental and subject to change. Use this guide when you need TanStack Start to rewrite manifest-managed asset URLs at runtime. The most common use case is serving JavaScript and CSS from a CDN whose origin is known only when the server starts, or varies per request. diff --git a/docs/start/framework/react/guide/client-entry-point.md b/docs/start/framework/react/guide/client-entry-point.md index 02d0ecd426f..3d81a13ac21 100644 --- a/docs/start/framework/react/guide/client-entry-point.md +++ b/docs/start/framework/react/guide/client-entry-point.md @@ -3,8 +3,6 @@ id: client-entry-point title: Client Entry Point --- -# Client Entry Point - > [!NOTE] > The client entry point is **optional** out of the box. If not provided, TanStack Start will automatically handle the client entry point for you using the below as a default. diff --git a/docs/start/framework/react/guide/early-hints.md b/docs/start/framework/react/guide/early-hints.md index da633a84d75..a5913952e64 100644 --- a/docs/start/framework/react/guide/early-hints.md +++ b/docs/start/framework/react/guide/early-hints.md @@ -3,8 +3,6 @@ id: early-hints title: Early Hints --- -# Early Hints - > **Experimental:** Early Hints are experimental and subject to change. HTTP `103 Early Hints` lets your server tell the browser about important resources before the final HTML response is ready. TanStack Start can collect route assets and route `head().links`, then call your server entry so your runtime can send `103` responses. diff --git a/docs/start/framework/react/guide/server-entry-point.md b/docs/start/framework/react/guide/server-entry-point.md index 5acc8ee550b..127b04fc86b 100644 --- a/docs/start/framework/react/guide/server-entry-point.md +++ b/docs/start/framework/react/guide/server-entry-point.md @@ -3,8 +3,6 @@ id: server-entry-point title: Server Entry Point --- -# Server Entry Point - > [!NOTE] > The server entry point is **optional** out of the box. If not provided, TanStack Start will automatically handle the server entry point for you using the below as a default. diff --git a/docs/start/framework/solid/guide/client-entry-point.md b/docs/start/framework/solid/guide/client-entry-point.md index b73408e8e46..929efc7a2d6 100644 --- a/docs/start/framework/solid/guide/client-entry-point.md +++ b/docs/start/framework/solid/guide/client-entry-point.md @@ -3,8 +3,6 @@ id: client-entry-point title: Client Entry Point --- -# Client Entry Point - > [!NOTE] > The client entry point is **optional** out of the box. If not provided, TanStack Start will automatically handle the client entry point for you using the below as a default. From 130992b32f855abf26ba10207ea722d6d3f2c7f7 Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 10 Sep 2026 15:46:59 +0200 Subject: [PATCH 57/74] test(start): cover transformed script URL escaping in SSR (#8336) --- .../transform-asset-urls/src/server.ts | 16 ++++++- .../tests/script-url-escaping.spec.ts | 46 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 e2e/react-start/transform-asset-urls/tests/script-url-escaping.spec.ts diff --git a/e2e/react-start/transform-asset-urls/src/server.ts b/e2e/react-start/transform-asset-urls/src/server.ts index 241fc2fe51b..a51f29ad665 100644 --- a/e2e/react-start/transform-asset-urls/src/server.ts +++ b/e2e/react-start/transform-asset-urls/src/server.ts @@ -83,4 +83,18 @@ const handler = createStartHandler( : defaultStreamHandler, ) -export default createServerEntry({ fetch: handler }) +export default createServerEntry({ + fetch(request, options) { + const scriptUrl = request.headers.get('x-test-script-url') + if (scriptUrl) { + return createStartHandler({ + handler: defaultStreamHandler, + transformAssets: ({ kind, url }) => ({ + href: kind === 'script' ? scriptUrl : url, + }), + })(request, options) + } + + return handler(request, options) + }, +}) diff --git a/e2e/react-start/transform-asset-urls/tests/script-url-escaping.spec.ts b/e2e/react-start/transform-asset-urls/tests/script-url-escaping.spec.ts new file mode 100644 index 00000000000..22251df671c --- /dev/null +++ b/e2e/react-start/transform-asset-urls/tests/script-url-escaping.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' + +for (const [name, scriptUrl] of [ + [ + 'closing script tag', + '