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/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-core/src/router.ts b/packages/router-core/src/router.ts index e59c87bb409..a8bbb6c8aa1 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,8 @@ 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 +2429,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 +2444,7 @@ export class RouterCore< ], } }) + this.pendingMatchesInternal = undefined this.clearExpiredCache() }) @@ -2585,13 +2586,18 @@ 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) - ? 'matches' - : this.state.cachedMatches.some((d) => d.id === id) - ? 'cachedMatches' - : '' + 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' + : '' if (matchesKey) { this.__store.setState((s) => ({ @@ -2608,7 +2614,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 +2651,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 +2773,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 +2941,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..ebdfdf15b04 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -140,12 +140,12 @@ function RouteComp({ setActiveId: (id: string) => void }) { const styles = useStyles() - const matches = createMemo( - () => routerState().pendingMatches || routerState().matches, - ) - const match = createMemo(() => - routerState().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 { @@ -279,6 +279,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 +317,7 @@ export const BaseTanStackRouterDevtoolsPanel = const activeMatch = createMemo(() => { const matches = [ - ...(routerState().pendingMatches ?? []), + ...pendingMatches(), ...routerState().matches, ...routerState().cachedMatches, ] @@ -521,10 +529,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( + {pendingMatches().find( (d: any) => d.id === activeMatch()?.id, ) ? 'Pending' diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index b910c1340b4..1f995630ef1 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -29,10 +29,10 @@ 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 - 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 }) } function walkReplaceSegmentTree( 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..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,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 }); 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..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,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 }); 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..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,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 }); diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index 40a99e1487e..92ec5eecffd 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,19 @@ 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/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/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..ea7083b20ad 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,18 @@ 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/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 () => { 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 + }) + }) })