From 63ed328b56e2069b3160dd1f88060d19d059ba80 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 15 Feb 2026 15:00:44 +0100 Subject: [PATCH 1/3] WIP remove pending from store (2nd attempt) --- docs/router/api/router/RouterStateType.md | 16 ++- docs/router/api/router/useChildMatchesHook.md | 3 - .../router/api/router/useParentMatchesHook.md | 3 - packages/router-core/src/router.ts | 31 +++-- packages/router-core/tests/load.test.ts | 16 +-- .../src/BaseTanStackRouterDevtoolsPanel.tsx | 26 ++-- .../src/core/route-hmr-statement.ts | 5 +- .../snapshots/react/arrow-function@true.tsx | 5 +- .../react/function-declaration@true.tsx | 5 +- .../snapshots/solid/arrow-function@true.tsx | 5 +- packages/solid-router/src/useMatch.tsx | 15 ++- packages/solid-router/tests/useMatch.test.tsx | 126 +++++++++++++++++ packages/vue-router/src/useMatch.tsx | 13 +- packages/vue-router/tests/useMatch.test.tsx | 127 ++++++++++++++++++ 14 files changed, 334 insertions(+), 62 deletions(-) diff --git a/docs/router/api/router/RouterStateType.md b/docs/router/api/router/RouterStateType.md index 6c4d2aca934..65dd31db9a6 100644 --- a/docs/router/api/router/RouterStateType.md +++ b/docs/router/api/router/RouterStateType.md @@ -3,7 +3,7 @@ id: RouterStateType title: RouterState type --- -The `RouterState` type represents shape of the internal state of the router. The Router's internal state is useful, if you need to access certain internals of the router, such as any pending matches, is the router in its loading state, etc. +The `RouterState` type represents shape of the internal state of the router. The Router's internal state is useful if you need to access certain internals of the router, such as whether it is currently loading. ```tsx type RouterState = { @@ -11,7 +11,6 @@ type RouterState = { isLoading: boolean isTransitioning: boolean matches: Array - pendingMatches: Array location: ParsedLocation resolvedLocation: ParsedLocation } @@ -21,6 +20,14 @@ type RouterState = { The `RouterState` type contains all of the properties that are available on the router state. +## Migration notes + +If you previously used `router.state.pendingMatches`, use one of these patterns instead: + +- `router.state.status === 'pending'` to detect an in-flight navigation. +- `router.state.isLoading` to detect active loading work. +- `router.matchRoute(...)` with `{ pending: true }` when you need to match against the pending location. + ### `status` property - Type: `'pending' | 'idle'` @@ -41,11 +48,6 @@ The `RouterState` type contains all of the properties that are available on the - Type: [`Array`](./RouteMatchType.md) - An array of all of the route matches that have been resolved and are currently active. -### `pendingMatches` property - -- Type: [`Array`](./RouteMatchType.md) -- An array of all of the route matches that are currently pending. - ### `location` property - Type: [`ParsedLocation`](./ParsedLocationType.md) diff --git a/docs/router/api/router/useChildMatchesHook.md b/docs/router/api/router/useChildMatchesHook.md index cf31d210160..93b6a5fc490 100644 --- a/docs/router/api/router/useChildMatchesHook.md +++ b/docs/router/api/router/useChildMatchesHook.md @@ -5,9 +5,6 @@ title: useChildMatches hook The `useChildMatches` hook returns all of the child [`RouteMatch`](./RouteMatchType.md) objects from the closest match down to the leaf-most match. **It does not include the current match, which can be obtained using the `useMatch` hook.** -> [!IMPORTANT] -> If the router has pending matches and they are showing their pending component fallbacks, `router.state.pendingMatches` will used instead of `router.state.matches`. - ## useChildMatches options The `useChildMatches` hook accepts a single _optional_ argument, an `options` object. diff --git a/docs/router/api/router/useParentMatchesHook.md b/docs/router/api/router/useParentMatchesHook.md index fe4068f3972..d22063b3ca3 100644 --- a/docs/router/api/router/useParentMatchesHook.md +++ b/docs/router/api/router/useParentMatchesHook.md @@ -5,9 +5,6 @@ title: useParentMatches hook The `useParentMatches` hook returns all of the parent [`RouteMatch`](./RouteMatchType.md) objects from the root down to the immediate parent of the current match in context. **It does not include the current match, which can be obtained using the `useMatch` hook.** -> [!IMPORTANT] -> If the router has pending matches and they are showing their pending component fallbacks, `router.state.pendingMatches` will used instead of `router.state.matches`. - ## useParentMatches options The `useParentMatches` hook accepts an optional `options` object. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e59c87bb409..538caf09fd3 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -531,7 +531,6 @@ export interface RouterState< isLoading: boolean isTransitioning: boolean matches: Array - pendingMatches?: Array cachedMatches: Array location: ParsedLocation> resolvedLocation?: ParsedLocation> @@ -963,6 +962,7 @@ export class RouterCore< origin?: string latestLocation!: ParsedLocation> pendingBuiltLocation?: ParsedLocation> + private pendingMatchesInternal?: Array basepath!: string routeTree!: TRouteTree routesById!: RoutesById @@ -1764,7 +1764,7 @@ export class RouterCore< (match) => match.isFetching === 'loader', ) const matchesToCancelArray = new Set([ - ...(this.state.pendingMatches ?? []), + ...(this.pendingMatchesInternal ?? []), ...currentPendingMatches, ...currentLoadingMatches, ]) @@ -2341,6 +2341,7 @@ export class RouterCore< // Match the routes const pendingMatches = this.matchRoutes(this.latestLocation) + this.pendingMatchesInternal = pendingMatches // Ingest the new matches this.__store.setState((s) => ({ @@ -2349,7 +2350,6 @@ export class RouterCore< statusCode: 200, isLoading: true, location: this.latestLocation, - pendingMatches, // If a cached moved to pendingMatches, remove it from cachedMatches cachedMatches: s.cachedMatches.filter( (d) => !pendingMatches.some((e) => e.id === d.id), @@ -2391,7 +2391,7 @@ export class RouterCore< await loadMatches({ router: this, sync: opts?.sync, - matches: this.state.pendingMatches as Array, + matches: this.pendingMatchesInternal ?? [], location: next, updateMatch: this.updateMatch, // eslint-disable-next-line @typescript-eslint/require-await @@ -2410,7 +2410,7 @@ export class RouterCore< batch(() => { this.__store.setState((s) => { const previousMatches = s.matches - const newMatches = s.pendingMatches || s.matches + const newMatches = this.pendingMatchesInternal || s.matches exitingMatches = previousMatches.filter( (match) => !newMatches.some((d) => d.id === match.id), @@ -2428,7 +2428,6 @@ export class RouterCore< isLoading: false, loadedAt: Date.now(), matches: newMatches, - pendingMatches: undefined, /** * When committing new matches, cache any exiting matches that are still usable. * Routes that resolved with `status: 'error'` or `status: 'notFound'` are @@ -2444,6 +2443,7 @@ export class RouterCore< ], } }) + this.pendingMatchesInternal = undefined this.clearExpiredCache() }) @@ -2585,9 +2585,14 @@ export class RouterCore< updateMatch: UpdateMatchFn = (id, updater) => { this.startTransition(() => { - const matchesKey = this.state.pendingMatches?.some((d) => d.id === id) - ? 'pendingMatches' - : this.state.matches.some((d) => d.id === id) + if (this.pendingMatchesInternal?.some((d) => d.id === id)) { + this.pendingMatchesInternal = this.pendingMatchesInternal.map((d) => + d.id === id ? updater(d) : d, + ) + return + } + + const matchesKey = this.state.matches.some((d) => d.id === id) ? 'matches' : this.state.cachedMatches.some((d) => d.id === id) ? 'cachedMatches' @@ -2608,7 +2613,7 @@ export class RouterCore< const findFn = (d: { id: string }) => d.id === matchId return ( this.state.cachedMatches.find(findFn) ?? - this.state.pendingMatches?.find(findFn) ?? + this.pendingMatchesInternal?.find(findFn) ?? this.state.matches.find(findFn) ) } @@ -2645,11 +2650,12 @@ export class RouterCore< return d } + this.pendingMatchesInternal = this.pendingMatchesInternal?.map(invalidate) + this.__store.setState((s) => ({ ...s, matches: s.matches.map(invalidate), cachedMatches: s.cachedMatches.map(invalidate), - pendingMatches: s.pendingMatches?.map(invalidate), })) this.shouldViewTransition = false @@ -2766,7 +2772,7 @@ export class RouterCore< }) const activeMatchIds = new Set( - [...this.state.matches, ...(this.state.pendingMatches ?? [])].map( + [...this.state.matches, ...(this.pendingMatchesInternal ?? [])].map( (d) => d.id, ), ) @@ -2934,7 +2940,6 @@ export function getInitialRouterState( resolvedLocation: undefined, location, matches: [], - pendingMatches: [], cachedMatches: [], statusCode: 200, } diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index b312cfe71c8..eb2b1ce4761 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -84,8 +84,9 @@ describe('beforeLoad skip or exec', () => { const router = setup({ beforeLoad }) const navigation = router.navigate({ to: '/foo' }) expect(beforeLoad).toHaveBeenCalledTimes(1) - expect(router.state.pendingMatches).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), + expect(router.state.status).toBe('pending') + expect(router.getMatch('/foo/foo')).toEqual( + expect.objectContaining({ id: '/foo/foo' }), ) await navigation expect(router.state.location.pathname).toBe('/foo') @@ -266,8 +267,9 @@ describe('loader skip or exec', () => { const router = setup({ loader }) const navigation = router.navigate({ to: '/foo' }) expect(loader).toHaveBeenCalledTimes(1) - expect(router.state.pendingMatches).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), + expect(router.state.status).toBe('pending') + expect(router.getMatch('/foo/foo')).toEqual( + expect.objectContaining({ id: '/foo/foo' }), ) await navigation expect(router.state.location.pathname).toBe('/foo') @@ -566,11 +568,7 @@ describe('params.parse notFound', () => { }) await router.load() - - const match = router.state.pendingMatches?.find( - (m) => m.routeId === testRoute.id, - ) - + const match = router.getMatch(testRoute.id + '/test/invalid') expect(match?.status).toBe('notFound') }) diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index c306d82d809..88fb0ff8d52 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -141,10 +141,13 @@ function RouteComp({ }) { const styles = useStyles() const matches = createMemo( - () => routerState().pendingMatches || routerState().matches, + () => + routerState().status === 'pending' + ? router().matchRoutes(router().latestLocation) + : routerState().matches, ) const match = createMemo(() => - routerState().matches.find((d) => d.routeId === route.id), + matches().find((d) => d.routeId === route.id), ) const param = createMemo(() => { @@ -279,6 +282,14 @@ export const BaseTanStackRouterDevtoolsPanel = const [history, setHistory] = createSignal>([]) const [hasHistoryOverflowed, setHasHistoryOverflowed] = createSignal(false) + const pendingMatches = createMemo(() => + routerState().status === 'pending' + ? router().matchRoutes(router().latestLocation) + : [], + ) + const displayedMatches = createMemo(() => + pendingMatches().length ? pendingMatches() : routerState().matches, + ) createEffect(() => { const matches = routerState().matches @@ -309,7 +320,7 @@ export const BaseTanStackRouterDevtoolsPanel = const activeMatch = createMemo(() => { const matches = [ - ...(routerState().pendingMatches ?? []), + ...pendingMatches(), ...routerState().matches, ...routerState().cachedMatches, ] @@ -521,10 +532,7 @@ export const BaseTanStackRouterDevtoolsPanel =
- {(routerState().pendingMatches?.length - ? routerState().pendingMatches - : routerState().matches - )?.map((match: any, _i: any) => { + {displayedMatches().map((match: any, _i: any) => { return (
State:
- {routerState().pendingMatches?.find( - (d: any) => d.id === activeMatch()?.id, - ) + {pendingMatches().find((d: any) => d.id === activeMatch()?.id) ? 'Pending' : routerState().matches.find( (d: any) => d.id === activeMatch()?.id, diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index b910c1340b4..e5ab9987895 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -29,9 +29,12 @@ function handleRouteUpdate( // TODO: how to rebuild the tree if we add a new route? walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree) const filter = (m: AnyRouteMatch) => m.routeId === oldRoute.id + const hasPendingRouteMatch = + router.state.status === 'pending' && + router.matchRoutes(router.latestLocation).some(filter) if ( router.state.matches.find(filter) || - router.state.pendingMatches?.find(filter) + hasPendingRouteMatch ) { router.invalidate({ filter }) } diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index 5a3588a7dc8..4b8538d87e8 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -26,7 +26,8 @@ if (import.meta.hot) { router.resolvePathCache.clear(); walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; - if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { + const hasPendingRouteMatch = router.state.status === 'pending' && router.matchRoutes(router.latestLocation).some(filter); + if (router.state.matches.find(filter) || hasPendingRouteMatch) { router.invalidate({ filter }); @@ -44,4 +45,4 @@ if (import.meta.hot) { })(Route, newModule.Route); } }); -} \ No newline at end of file +} diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index bee310532ff..f352e1e1b8d 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -26,7 +26,8 @@ if (import.meta.hot) { router.resolvePathCache.clear(); walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; - if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { + const hasPendingRouteMatch = router.state.status === 'pending' && router.matchRoutes(router.latestLocation).some(filter); + if (router.state.matches.find(filter) || hasPendingRouteMatch) { router.invalidate({ filter }); @@ -44,4 +45,4 @@ if (import.meta.hot) { })(Route, newModule.Route); } }); -} \ No newline at end of file +} diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index 3ad31b8eb2b..c3ccbca0a20 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -25,7 +25,8 @@ if (import.meta.hot) { router.resolvePathCache.clear(); walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; - if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { + const hasPendingRouteMatch = router.state.status === 'pending' && router.matchRoutes(router.latestLocation).some(filter); + if (router.state.matches.find(filter) || hasPendingRouteMatch) { router.invalidate({ filter }); @@ -43,4 +44,4 @@ if (import.meta.hot) { })(Route, newModule.Route); } }); -} \ No newline at end of file +} diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index 40a99e1487e..0ccccd22110 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -1,5 +1,6 @@ import * as Solid from 'solid-js' import invariant from 'tiny-invariant' +import { useRouter } from './useRouter' import { useRouterState } from './useRouterState' import { dummyMatchContext, matchContext } from './matchContext' import type { @@ -69,6 +70,7 @@ export function useMatch< ): Solid.Accessor< ThrowOrOptional, TThrow> > { + const router = useRouter() const nearestMatchId = Solid.useContext( opts.from ? dummyMatchContext : matchContext, ) @@ -84,14 +86,17 @@ export function useMatch< ) if (match === undefined) { - // During navigation transitions, check if the match exists in pendingMatches - const pendingMatch = state.pendingMatches?.find((d: any) => - opts.from ? opts.from === d.routeId : d.id === nearestMatchId(), - ) + const hasPendingMatch = + state.status === 'pending' && + router.matchRoutes(router.latestLocation).some((d: any) => + opts.from ? opts.from === d.routeId : d.id === nearestMatchId(), + ) // Determine if we should throw an error const shouldThrowError = - !pendingMatch && !state.isTransitioning && (opts.shouldThrow ?? true) + !hasPendingMatch && + !state.isTransitioning && + (opts.shouldThrow ?? true) return { match: undefined, shouldThrowError } } diff --git a/packages/solid-router/tests/useMatch.test.tsx b/packages/solid-router/tests/useMatch.test.tsx index f9c592601da..b8604294909 100644 --- a/packages/solid-router/tests/useMatch.test.tsx +++ b/packages/solid-router/tests/useMatch.test.tsx @@ -9,6 +9,7 @@ import { createRoute, createRouter, useMatch, + useRouterState, } from '../src' import type { RouteComponent, RouterHistory } from '../src' @@ -116,4 +117,129 @@ describe('useMatch', () => { }) }) }) + + describe('pending transitions', () => { + test('does not throw while transitioning to a pending matching route', async () => { + let resolvePostsLoader!: () => void + const postsLoaderPromise = new Promise((resolve) => { + resolvePostsLoader = resolve + }) + + function PendingProbe() { + useMatch({ from: '/posts', shouldThrow: true }) + return null + } + + function RootComponent() { + const routerState = useRouterState() + return ( + <> + {routerState().status === 'pending' ? : null} + + + ) + } + + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

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

PostsTitle

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + await screen.findByText('IndexTitle') + + const navigation = router.navigate({ to: '/posts' }) + await waitFor(() => { + expect(router.state.status).toBe('pending') + }) + + expect( + screen.queryByText( + 'Invariant failed: Could not find an active match from "/posts"', + ), + ).not.toBeInTheDocument() + + resolvePostsLoader() + await navigation + expect(await screen.findByText('PostsTitle')).toBeInTheDocument() + }) + + test('still throws during pending transition when route is not pending', async () => { + let resolveOtherLoader!: () => void + const otherLoaderPromise = new Promise((resolve) => { + resolveOtherLoader = resolve + }) + + function PendingProbe() { + useMatch({ from: '/posts', shouldThrow: true }) + return null + } + + function RootComponent() { + const routerState = useRouterState() + return ( + <> + {routerState().status === 'pending' ? : null} + + + ) + } + + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

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

PostsTitle

, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: async () => { + await otherLoaderPromise + }, + component: () =>

OtherTitle

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + await screen.findByText('IndexTitle') + + const navigation = router.navigate({ to: '/other' }).catch(() => {}) + const errorText = await screen.findByText( + 'Invariant failed: Could not find an active match from "/posts"', + ) + expect(errorText).toBeInTheDocument() + + resolveOtherLoader() + await navigation + }) + }) }) diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index 0c527f66816..7784f3018c1 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -1,4 +1,5 @@ import * as Vue from 'vue' +import { useRouter } from './useRouter' import { useRouterState } from './useRouterState' import { injectDummyMatch, injectMatch } from './matchContext' import type { @@ -68,6 +69,7 @@ export function useMatch< ): Vue.Ref< ThrowOrOptional, TThrow> > { + const router = useRouter() const nearestMatchId = opts.from ? injectDummyMatch() : injectMatch() // Store to track pending error for deferred throwing @@ -81,13 +83,14 @@ export function useMatch< ) if (match === undefined) { - // During navigation transitions, check if the match exists in pendingMatches - const pendingMatch = state.pendingMatches?.find((d: any) => - opts.from ? opts.from === d.routeId : d.id === nearestMatchId.value, - ) + const hasPendingMatch = + state.status === 'pending' && + router.matchRoutes(router.latestLocation).some((d: any) => + opts.from ? opts.from === d.routeId : d.id === nearestMatchId.value, + ) // If there's a pending match or we're transitioning, return undefined without throwing - if (pendingMatch || state.isTransitioning) { + if (hasPendingMatch || state.isTransitioning) { pendingError.value = null return undefined } diff --git a/packages/vue-router/tests/useMatch.test.tsx b/packages/vue-router/tests/useMatch.test.tsx index 2c3575f0523..7bd18916ae2 100644 --- a/packages/vue-router/tests/useMatch.test.tsx +++ b/packages/vue-router/tests/useMatch.test.tsx @@ -9,6 +9,7 @@ import { createRoute, createRouter, useMatch, + useRouterState, } from '../src' import type { RouteComponent, RouterHistory } from '../src' @@ -116,4 +117,130 @@ describe('useMatch', () => { }) }) }) + + describe('pending transitions', () => { + test('does not throw while transitioning to a pending matching route', async () => { + let resolvePostsLoader!: () => void + const postsLoaderPromise = new Promise((resolve) => { + resolvePostsLoader = resolve + }) + + function PendingProbe() { + useMatch({ from: '/posts', shouldThrow: true }) + return null + } + + function RootComponent() { + const routerState = useRouterState() + return ( + <> + {routerState.value.status === 'pending' ? : null} + + + ) + } + + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

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

PostsTitle

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + await screen.findByText('IndexTitle') + + const navigation = router.navigate({ to: '/posts' }) + await waitFor(() => { + expect(router.state.status).toBe('pending') + }) + + expect( + screen.queryByText( + 'Invariant failed: Could not find an active match from "/posts"', + ), + ).not.toBeInTheDocument() + + resolvePostsLoader() + await navigation + expect(await screen.findByText('PostsTitle')).toBeInTheDocument() + }) + + test('still throws during pending transition when route is not pending', async () => { + let resolveOtherLoader!: () => void + const otherLoaderPromise = new Promise((resolve) => { + resolveOtherLoader = resolve + }) + + function PendingProbe() { + useMatch({ from: '/posts', shouldThrow: true }) + return null + } + + function RootComponent() { + const routerState = useRouterState() + return ( + <> + {routerState.value.status === 'pending' ? : null} + + + ) + } + + const rootRoute = createRootRoute({ + component: RootComponent, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

IndexTitle

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

PostsTitle

, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: async () => { + await otherLoaderPromise + }, + component: () =>

OtherTitle

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + await screen.findByText('IndexTitle') + + const navigation = router.navigate({ to: '/other' }).catch(() => {}) + const errorText = await screen.findByText( + 'Invariant failed: Could not find an active match from "/posts"', + ) + expect(errorText).toBeInTheDocument() + + resolveOtherLoader() + await navigation + }) + }) }) From 9a21d5a02878a9307098c30a6d7c688d9cd20302 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 14:11:24 +0000 Subject: [PATCH 2/3] ci: apply automated fixes --- packages/router-core/src/router.ts | 11 ++++++----- .../src/BaseTanStackRouterDevtoolsPanel.tsx | 17 ++++++++--------- .../src/core/route-hmr-statement.ts | 5 +---- packages/solid-router/src/useMatch.tsx | 8 +++++--- packages/vue-router/src/useMatch.tsx | 10 +++++++--- 5 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 538caf09fd3..a8bbb6c8aa1 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2410,7 +2410,8 @@ export class RouterCore< batch(() => { this.__store.setState((s) => { const previousMatches = s.matches - const newMatches = this.pendingMatchesInternal || s.matches + const newMatches = + this.pendingMatchesInternal || s.matches exitingMatches = previousMatches.filter( (match) => !newMatches.some((d) => d.id === match.id), @@ -2593,10 +2594,10 @@ export class RouterCore< } const matchesKey = this.state.matches.some((d) => d.id === id) - ? 'matches' - : this.state.cachedMatches.some((d) => d.id === id) - ? 'cachedMatches' - : '' + ? 'matches' + : this.state.cachedMatches.some((d) => d.id === id) + ? 'cachedMatches' + : '' if (matchesKey) { this.__store.setState((s) => ({ diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 88fb0ff8d52..ebdfdf15b04 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -140,15 +140,12 @@ function RouteComp({ setActiveId: (id: string) => void }) { const styles = useStyles() - const matches = createMemo( - () => - routerState().status === 'pending' - ? router().matchRoutes(router().latestLocation) - : routerState().matches, - ) - const match = createMemo(() => - matches().find((d) => d.routeId === route.id), + const matches = createMemo(() => + routerState().status === 'pending' + ? router().matchRoutes(router().latestLocation) + : routerState().matches, ) + const match = createMemo(() => matches().find((d) => d.routeId === route.id)) const param = createMemo(() => { try { @@ -687,7 +684,9 @@ export const BaseTanStackRouterDevtoolsPanel =
State:
- {pendingMatches().find((d: any) => d.id === activeMatch()?.id) + {pendingMatches().find( + (d: any) => d.id === activeMatch()?.id, + ) ? 'Pending' : routerState().matches.find( (d: any) => d.id === activeMatch()?.id, diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index e5ab9987895..1f995630ef1 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -32,10 +32,7 @@ function handleRouteUpdate( const hasPendingRouteMatch = router.state.status === 'pending' && router.matchRoutes(router.latestLocation).some(filter) - if ( - router.state.matches.find(filter) || - hasPendingRouteMatch - ) { + if (router.state.matches.find(filter) || hasPendingRouteMatch) { router.invalidate({ filter }) } function walkReplaceSegmentTree( diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index 0ccccd22110..92ec5eecffd 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -88,9 +88,11 @@ export function useMatch< if (match === undefined) { const hasPendingMatch = state.status === 'pending' && - router.matchRoutes(router.latestLocation).some((d: any) => - opts.from ? opts.from === d.routeId : d.id === nearestMatchId(), - ) + router + .matchRoutes(router.latestLocation) + .some((d: any) => + opts.from ? opts.from === d.routeId : d.id === nearestMatchId(), + ) // Determine if we should throw an error const shouldThrowError = diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index 7784f3018c1..ea7083b20ad 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -85,9 +85,13 @@ export function useMatch< if (match === undefined) { const hasPendingMatch = state.status === 'pending' && - router.matchRoutes(router.latestLocation).some((d: any) => - opts.from ? opts.from === d.routeId : d.id === nearestMatchId.value, - ) + router + .matchRoutes(router.latestLocation) + .some((d: any) => + opts.from + ? opts.from === d.routeId + : d.id === nearestMatchId.value, + ) // If there's a pending match or we're transitioning, return undefined without throwing if (hasPendingMatch || state.isTransitioning) { From 89a7c50ebdc9426c75ca1b3bdf910a67155ad03a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 15 Feb 2026 16:43:13 +0100 Subject: [PATCH 3/3] update test expectations & snapshots --- .../store-updates-during-navigation.test.tsx | 25 ++++++++++--------- .../snapshots/react/arrow-function@true.tsx | 4 +-- .../react/function-declaration@true.tsx | 4 +-- .../snapshots/solid/arrow-function@true.tsx | 4 +-- .../store-updates-during-navigation.test.tsx | 23 +++++++++-------- .../store-updates-during-navigation.test.tsx | 18 ++++++++----- 6 files changed, 43 insertions(+), 35 deletions(-) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index dfb06f80d03..69f60ceb609 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -136,8 +136,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBeGreaterThanOrEqual(10) // WARN: this is flaky, and sometimes (rarely) is 12 - expect(updates).toBeLessThanOrEqual(13) + expect(updates).toBeGreaterThanOrEqual(8) + expect(updates).toBeLessThanOrEqual(11) }) test('redirection in preload', async () => { @@ -171,8 +171,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBeGreaterThanOrEqual(9) // WARN: this is flaky - expect(updates).toBeLessThanOrEqual(12) + expect(updates).toBeGreaterThanOrEqual(4) + expect(updates).toBeLessThanOrEqual(6) }) test('nothing', async () => { @@ -183,8 +183,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBeGreaterThanOrEqual(6) // WARN: this is flaky, and sometimes (rarely) is 9 - expect(updates).toBeLessThanOrEqual(9) + expect(updates).toBeGreaterThanOrEqual(3) + expect(updates).toBeLessThanOrEqual(5) }) test('not found in beforeLoad', async () => { @@ -199,7 +199,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(7) + expect(updates).toBe(2) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -225,7 +225,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(16) + expect(updates).toBeGreaterThanOrEqual(9) + expect(updates).toBeLessThanOrEqual(11) }) test('navigate, w/ preloaded & async loaders', async () => { @@ -241,8 +242,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBeGreaterThanOrEqual(7) - expect(updates).toBeLessThanOrEqual(8) + expect(updates).toBeGreaterThanOrEqual(3) + expect(updates).toBeLessThanOrEqual(4) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -258,7 +259,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(6) + expect(updates).toBe(3) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -274,7 +275,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(5) + expect(updates).toBe(3) }) test('preload a preloaded route w/ async loader', async () => { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index 4b8538d87e8..08f878f780f 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -26,7 +26,7 @@ if (import.meta.hot) { router.resolvePathCache.clear(); walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; - const hasPendingRouteMatch = router.state.status === 'pending' && router.matchRoutes(router.latestLocation).some(filter); + const hasPendingRouteMatch = router.state.status === "pending" && router.matchRoutes(router.latestLocation).some(filter); if (router.state.matches.find(filter) || hasPendingRouteMatch) { router.invalidate({ filter @@ -45,4 +45,4 @@ if (import.meta.hot) { })(Route, newModule.Route); } }); -} +} \ No newline at end of file diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index f352e1e1b8d..f03978a6210 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -26,7 +26,7 @@ if (import.meta.hot) { router.resolvePathCache.clear(); walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; - const hasPendingRouteMatch = router.state.status === 'pending' && router.matchRoutes(router.latestLocation).some(filter); + const hasPendingRouteMatch = router.state.status === "pending" && router.matchRoutes(router.latestLocation).some(filter); if (router.state.matches.find(filter) || hasPendingRouteMatch) { router.invalidate({ filter @@ -45,4 +45,4 @@ if (import.meta.hot) { })(Route, newModule.Route); } }); -} +} \ No newline at end of file diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index c3ccbca0a20..851d43583fa 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -25,7 +25,7 @@ if (import.meta.hot) { router.resolvePathCache.clear(); walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; - const hasPendingRouteMatch = router.state.status === 'pending' && router.matchRoutes(router.latestLocation).some(filter); + const hasPendingRouteMatch = router.state.status === "pending" && router.matchRoutes(router.latestLocation).some(filter); if (router.state.matches.find(filter) || hasPendingRouteMatch) { router.invalidate({ filter @@ -44,4 +44,4 @@ if (import.meta.hot) { })(Route, newModule.Route); } }); -} +} \ No newline at end of file diff --git a/packages/solid-router/tests/store-updates-during-navigation.test.tsx b/packages/solid-router/tests/store-updates-during-navigation.test.tsx index 4534853c70d..d377346ce87 100644 --- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx @@ -136,8 +136,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBeGreaterThanOrEqual(10) // WARN: this is flaky, and sometimes (rarely) is 12 - expect(updates).toBeLessThanOrEqual(13) + expect(updates).toBeGreaterThanOrEqual(8) + expect(updates).toBeLessThanOrEqual(10) }) test('redirection in preload', async () => { @@ -173,7 +173,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Solid has different update counts than React due to different reactivity - expect(updates).toBe(8) + expect(updates).toBe(4) }) test('nothing', async () => { @@ -184,8 +184,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBeGreaterThanOrEqual(6) // WARN: this is flaky - expect(updates).toBeLessThanOrEqual(10) + expect(updates).toBeGreaterThanOrEqual(3) + expect(updates).toBeLessThanOrEqual(5) }) test('not found in beforeLoad', async () => { @@ -200,7 +200,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(7) + expect(updates).toBe(2) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -226,7 +226,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(16) + expect(updates).toBeGreaterThanOrEqual(8) + expect(updates).toBeLessThanOrEqual(10) }) test('navigate, w/ preloaded & async loaders', async () => { @@ -242,8 +243,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBeGreaterThanOrEqual(9) // WARN: this is flaky, and sometimes (rarely) is 12 - expect(updates).toBeLessThanOrEqual(13) + expect(updates).toBeGreaterThanOrEqual(3) + expect(updates).toBeLessThanOrEqual(4) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -260,7 +261,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Solid has one fewer update than React due to different reactivity - expect(updates).toBe(7) + expect(updates).toBe(3) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -276,7 +277,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(5) + expect(updates).toBe(3) }) test('preload a preloaded route w/ async loader', async () => { diff --git a/packages/vue-router/tests/store-updates-during-navigation.test.tsx b/packages/vue-router/tests/store-updates-during-navigation.test.tsx index 6cf44c37257..f82b01799d4 100644 --- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx @@ -138,7 +138,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(27) + expect(updates).toBeGreaterThanOrEqual(25) + expect(updates).toBeLessThanOrEqual(27) }) test('redirection in preload', async () => { @@ -174,7 +175,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(25) + expect(updates).toBeGreaterThanOrEqual(22) + expect(updates).toBeLessThanOrEqual(24) }) test('nothing', async () => { @@ -204,7 +206,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(22) + expect(updates).toBeGreaterThanOrEqual(17) + expect(updates).toBeLessThanOrEqual(19) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -231,7 +234,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(38) + expect(updates).toBeGreaterThanOrEqual(33) + expect(updates).toBeLessThanOrEqual(35) }) test('navigate, w/ preloaded & async loaders', async () => { @@ -248,7 +252,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(18) + expect(updates).toBeGreaterThanOrEqual(15) + expect(updates).toBeLessThanOrEqual(17) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -265,7 +270,8 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(16) + expect(updates).toBeGreaterThanOrEqual(14) + expect(updates).toBeLessThanOrEqual(16) }) test('navigate, w/ previous navigation & async loader', async () => {