From 8085591614b5ab250def268f8af818c9464f88a7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 4 Mar 2026 17:27:26 +0100 Subject: [PATCH 1/7] refactor: signals implementation without dual store subscriptions --- packages/react-router/src/Match.tsx | 47 ++-- packages/react-router/src/useMatch.tsx | 40 ++-- packages/router-core/src/load-matches.ts | 3 +- packages/router-core/src/router.ts | 38 ++-- packages/router-core/src/stores.ts | 101 ++++----- .../router-core/tests/granular-stores.test.ts | 88 ++++---- packages/solid-router/src/Match.tsx | 96 +++++---- packages/solid-router/src/storeOfStores.ts | 10 - packages/solid-router/src/useMatch.tsx | 33 +-- packages/vue-router/src/Match.tsx | 203 +++++++----------- packages/vue-router/src/matchContext.tsx | 7 + packages/vue-router/src/storeOfStores.ts | 62 ------ packages/vue-router/src/useMatch.tsx | 57 +++-- 13 files changed, 358 insertions(+), 427 deletions(-) delete mode 100644 packages/solid-router/src/storeOfStores.ts delete mode 100644 packages/vue-router/src/storeOfStores.ts diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index f652d4a599a..fc4636fb351 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -24,21 +24,6 @@ import type { RootRouteOptions, } from '@tanstack/router-core' -function useActiveMatchStore(matchId: string, shouldThrow: boolean = true) { - const router = useRouter() - - return useStore(router.stores.byId, (activeStores) => { - const store = activeStores[matchId] - if (shouldThrow) { - invariant( - store, - `Could not find match for matchId "${matchId}". Please file an issue!`, - ) - } - return store - }) -} - export const Match = React.memo(function MatchImpl({ matchId, }: { @@ -47,7 +32,7 @@ export const Match = React.memo(function MatchImpl({ const router = useRouter() if (isServer ?? router.isServer) { - const match = router.stores.byId.state[matchId]?.state + const match = router.stores.activeMatchStoresById.get(matchId)?.state invariant( match, `Could not find match for matchId "${matchId}". Please file an issue!`, @@ -73,8 +58,15 @@ export const Match = React.memo(function MatchImpl({ ) } + // Subscribe directly to the match store from the pool. + // The matchId prop is stable for this component's lifetime (set by Outlet), + // and reconcileMatchPool reuses stores for the same matchId. // eslint-disable-next-line react-hooks/rules-of-hooks - const matchStore = useActiveMatchStore(matchId) + const matchStore = router.stores.activeMatchStoresById.get(matchId) + invariant( + matchStore, + `Could not find match for matchId "${matchId}". Please file an issue!`, + ) // eslint-disable-next-line react-hooks/rules-of-hooks const resetKey = useStore(router.stores.loadedAt, (loadedAt) => loadedAt) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -86,7 +78,7 @@ export const Match = React.memo(function MatchImpl({ // eslint-disable-next-line react-hooks/rules-of-hooks const matchState = React.useMemo(() => { const parentRouteId = parentMatchId - ? router.stores.byId.state[parentMatchId]?.state.routeId + ? router.stores.activeMatchStoresById.get(parentMatchId)?.state.routeId : undefined return { @@ -95,7 +87,7 @@ export const Match = React.memo(function MatchImpl({ _displayPending: match._displayPending, parentRouteId: parentRouteId as string | undefined, } satisfies MatchViewState - }, [parentMatchId, match, router.stores.byId.state]) + }, [parentMatchId, match, router.stores.activeMatchStoresById]) return ( value!) const routeId = match.routeId as string @@ -459,10 +455,11 @@ export const Outlet = React.memo(function OutletImpl() { childMatchId = parentIndex >= 0 ? (matches[parentIndex + 1]?.id as string) : undefined } else { - // eslint-disable-next-line react-hooks/rules-of-hooks - const parentMatchStore = useStore(router.stores.byId, (stores) => - matchId ? stores[matchId] : undefined, - ) + // Subscribe directly to the match store from the pool instead of + // the two-level byId → matchStore pattern. + const parentMatchStore = matchId + ? router.stores.activeMatchStoresById.get(matchId) + : undefined // eslint-disable-next-line react-hooks/rules-of-hooks routeId = useStore( diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index 5180a780e02..dd0dbf9d19b 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -118,8 +118,8 @@ export function useMatch< const key = opts.from ?? nearestMatchId const match = key ? opts.from - ? router.stores.byRouteId.state[key]?.state - : router.stores.byId.state[key]?.state + ? router.stores.getMatchStoreByRouteId(key).state + : router.stores.activeMatchStoresById.get(key)?.state : undefined invariant( @@ -134,25 +134,29 @@ export function useMatch< return (opts.select ? opts.select(match as any) : match) as any } - const matchStore = - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - useStore( - opts.from ? router.stores.byRouteId : router.stores.byId, - (activeMatchStores) => { - const key = opts.from ?? nearestMatchId - const store = key ? activeMatchStores[key] : undefined - - invariant( - !((opts.shouldThrow ?? true) && !store), - `Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, - ) - - return store - }, - ) ?? dummyStore + const key = opts.from ?? nearestMatchId + + // Single subscription: instead of two useStore calls (one to resolve + // the store, one to read from it), we resolve the store at this level + // and subscribe to it directly. + // + // - by-routeId (opts.from): uses a per-routeId computed store from the + // signal graph that resolves routeId → match state in one step. + // - by-matchId (matchContext): subscribes directly to the match store + // from the pool — the matchId from context is stable for this component. + const matchStore = key + ? opts.from + ? router.stores.getMatchStoreByRouteId(key) + : (router.stores.activeMatchStoresById.get(key) ?? dummyStore) + : dummyStore // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static return useStore(matchStore, (match) => { + invariant( + !((opts.shouldThrow ?? true) && !match), + `Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, + ) + if (match === undefined) { return undefined } diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 83bcc9562bb..ea6e4b61dd2 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -45,9 +45,8 @@ const triggerOnReady = (inner: InnerLoadContext): void | Promise => { } const hasForcePendingActiveMatch = (router: AnyRouter): boolean => { - const byId = router.stores.byId.state return router.stores.matchesId.state.some((matchId) => { - return byId[matchId]?.state._forcePending + return router.stores.activeMatchStoresById.get(matchId)?.state._forcePending }) } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index b0cf60c45cd..7a82603b7e1 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1400,7 +1400,15 @@ export class RouterCore< : undefined const matches = new Array(matchedRoutes.length) - const previousActiveMatchesByRouteId = this.stores.byRouteId.state + // Snapshot of active match state keyed by routeId, used to stabilise + // params/search across navigations. Built from the non-reactive pool + // so we don't pull in the byRouteId derived store. + const previousActiveMatchesByRouteId = new Map() + for (const store of this.stores.activeMatchStoresById.values()) { + if (store.routeId) { + previousActiveMatchesByRouteId.set(store.routeId, store.state) + } + } for (let index = 0; index < matchedRoutes.length; index++) { const route = matchedRoutes[index]! @@ -1485,7 +1493,7 @@ export class RouterCore< const existingMatch = this.getMatch(matchId) - const previousMatch = previousActiveMatchesByRouteId[route.id]?.state + const previousMatch = previousActiveMatchesByRouteId.get(route.id) const strictParams = existingMatch?._strictParams ?? usedParams @@ -1601,7 +1609,7 @@ export class RouterCore< const existingMatch = this.getMatch(match.id) // Update the match's params - const previousMatch = previousActiveMatchesByRouteId[match.routeId]?.state + const previousMatch = previousActiveMatchesByRouteId.get(match.routeId) match.params = previousMatch ? replaceEqualDeep(previousMatch.params, routeParams) : routeParams @@ -2413,25 +2421,29 @@ export class RouterCore< : null // Lifecycle-hook identity: routeId only (route presence in tree) + // Build routeId sets from pools to avoid derived stores. + const pendingRouteIds = new Set() + for (const s of this.stores.pendingMatchStoresById.values()) { + if (s.routeId) pendingRouteIds.add(s.routeId) + } + const activeRouteIds = new Set() + for (const s of this.stores.activeMatchStoresById.values()) { + if (s.routeId) activeRouteIds.add(s.routeId) + } + hookExitingMatches = mountPending ? currentMatches.filter( - (match) => - !( - match.routeId in - this.stores.pendingByRouteId.state - ), + (match) => !pendingRouteIds.has(match.routeId), ) : null hookEnteringMatches = mountPending ? pendingMatches.filter( - (match) => - !(match.routeId in this.stores.byRouteId.state), + (match) => !activeRouteIds.has(match.routeId), ) : null hookStayingMatches = mountPending - ? pendingMatches.filter( - (match) => - match.routeId in this.stores.byRouteId.state, + ? pendingMatches.filter((match) => + activeRouteIds.has(match.routeId), ) : currentMatches diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts index cba94082e86..ab9f07874b6 100644 --- a/packages/router-core/src/stores.ts +++ b/packages/router-core/src/stores.ts @@ -39,7 +39,6 @@ export type StoreConfig = { type MatchStore = RouterWritableStore & { routeId?: string } -type MatchStoreLookup = Record type ReadableStore = RouterReadableStore /** SSR non-reactive createMutableStore */ @@ -84,12 +83,6 @@ export interface RouterStores { pendingMatchesId: RouterWritableStore> /** @internal */ cachedMatchesId: RouterWritableStore> - /** store of stores */ - byId: RouterReadableStore - /** store of stores */ - byRouteId: RouterReadableStore - /** store of stores, solid/vue only */ - pendingByRouteId: RouterReadableStore activeMatchesSnapshot: ReadableStore> pendingMatchesSnapshot: ReadableStore> cachedMatchesSnapshot: ReadableStore> @@ -108,6 +101,16 @@ export interface RouterStores { pendingMatchStoresById: Map cachedMatchStoresById: Map + /** + * Get a computed store that resolves a routeId to its current match state. + * Returns the same cached store instance for repeated calls with the same key. + * The computed depends on matchesId + the individual match store, so + * subscribers are only notified when the resolved match state changes. + */ + getMatchStoreByRouteId: ( + routeId: string, + ) => RouterReadableStore + setActiveMatches: (nextMatches: Array) => void setPendingMatches: (nextMatches: Array) => void setCachedMatches: (nextMatches: Array) => void @@ -138,15 +141,6 @@ export function createRouterStores( const cachedMatchesId = createMutableStore>([]) // 1st order derived stores - const byId = createReadonlyStore(() => - readPoolLookup(activeMatchStoresById, matchesId.state), - ) - const byRouteId = createReadonlyStore(() => - readPoolLookupByRouteId(activeMatchStoresById, matchesId.state), - ) - const pendingByRouteId = createReadonlyStore(() => - readPoolLookupByRouteId(pendingMatchStoresById, pendingMatchesId.state), - ) const activeMatchesSnapshot = createReadonlyStore(() => readPoolMatches(activeMatchStoresById, matchesId.state), ) @@ -183,6 +177,44 @@ export function createRouterStores( redirect: redirect.state, })) + // Per-routeId computed store cache. + // Each entry resolves routeId → match state through the signal graph, + // giving consumers a single store to subscribe to instead of the + // two-level byRouteId → matchStore pattern. + // + // Cache size is bounded by the route tree (routeIds are static strings + // defined at app init). Unwatched computed stores are inert in + // alien-signals — they purge dependency links and don't participate + // in the reactive graph until re-subscribed. + const matchStoreByRouteIdCache = new Map< + string, + RouterReadableStore + >() + + function getMatchStoreByRouteId( + routeId: string, + ): RouterReadableStore { + let cached = matchStoreByRouteIdCache.get(routeId) + if (!cached) { + cached = createReadonlyStore(() => { + // Reading matchesId.state tracks it as a dependency. + // When matchesId changes (navigation), this computed re-evaluates. + const ids = matchesId.state + for (const id of ids) { + const matchStore = activeMatchStoresById.get(id) + if (matchStore && matchStore.routeId === routeId) { + // Reading matchStore.state tracks it as a dependency. + // When the match store's state changes, this re-evaluates. + return matchStore.state + } + } + return undefined + }) + matchStoreByRouteIdCache.set(routeId, cached) + } + return cached + } + const store = { // atoms status, @@ -196,9 +228,6 @@ export function createRouterStores( matchesId, pendingMatchesId, cachedMatchesId, - byId, - byRouteId, - pendingByRouteId, // derived activeMatchesSnapshot, @@ -217,6 +246,9 @@ export function createRouterStores( // compatibility "big" state __store, + // per-key computed stores + getMatchStoreByRouteId, + // methods setActiveMatches, setPendingMatches, @@ -275,37 +307,6 @@ function readPoolMatches( return matches } -function readPoolLookup( - pool: Map, - ids: Array, -): MatchStoreLookup { - const lookup: MatchStoreLookup = {} - for (const id of ids) { - const matchStore = pool.get(id) - if (matchStore) { - lookup[id] = matchStore - } - } - return lookup -} - -function readPoolLookupByRouteId( - pool: Map, - ids: Array, -): MatchStoreLookup { - const lookup: MatchStoreLookup = {} - for (const id of ids) { - const matchStore = pool.get(id) - if (matchStore) { - const routeId = matchStore.routeId - if (routeId) { - lookup[routeId] = matchStore - } - } - } - return lookup -} - function reconcileMatchPool( nextMatches: Array, pool: Map, diff --git a/packages/router-core/tests/granular-stores.test.ts b/packages/router-core/tests/granular-stores.test.ts index e9d24210f7f..095ed185e9a 100644 --- a/packages/router-core/tests/granular-stores.test.ts +++ b/packages/router-core/tests/granular-stores.test.ts @@ -32,11 +32,26 @@ function createRouter() { } describe('granular stores', () => { - test('keeps lookup stores correct across active/pending/cached transitions', async () => { + test('keeps pool stores correct across active/pending/cached transitions', async () => { const router = createRouter() await router.navigate({ to: '/posts/123' }) const activeMatches = router.state.matches + + // Active pool contains all active matches with correct routeIds + expect(router.stores.matchesId.state).toEqual( + activeMatches.map((match) => match.id), + ) + activeMatches.forEach((match) => { + const store = router.stores.activeMatchStoresById.get(match.id) + expect(store).toBeDefined() + expect(store!.routeId).toBe(match.routeId) + // getMatchStoreByRouteId resolves to the same state + expect(router.stores.getMatchStoreByRouteId(match.routeId).state).toBe( + store!.state, + ) + }) + const pendingMatches = [...activeMatches].reverse().map((match, index) => ({ ...match, id: `${match.id}__pending_${index}`, @@ -46,21 +61,6 @@ describe('granular stores', () => { id: `${match.id}__cached_${index}`, })) - expect(Object.keys(router.stores.byId.state)).toEqual( - activeMatches.map((match) => match.id), - ) - expect(Object.keys(router.stores.byRouteId.state)).toEqual( - activeMatches.map((match) => match.routeId), - ) - activeMatches.forEach((match) => { - expect(router.stores.byId.state[match.id]).toBe( - router.stores.activeMatchStoresById.get(match.id), - ) - expect(router.stores.byRouteId.state[match.routeId]).toBe( - router.stores.activeMatchStoresById.get(match.id), - ) - }) - router.stores.setPendingMatches(pendingMatches) router.stores.setCachedMatches(cachedMatches) @@ -73,23 +73,18 @@ describe('granular stores', () => { expect(router.stores.cachedMatchesId.state).toEqual( cachedMatches.map((match) => match.id), ) - expect(Object.keys(router.stores.pendingByRouteId.state)).toEqual( - pendingMatches.map((match) => match.routeId), - ) - const activeStoreByRouteId = Object.fromEntries( - activeMatches.map((match) => [ - match.routeId, - router.stores.activeMatchStoresById.get(match.id), - ]), - ) + + // Pending pool has correct routeIds pendingMatches.forEach((match) => { - expect(router.stores.pendingByRouteId.state[match.routeId]).toBe( - router.stores.pendingMatchStoresById.get(match.id), - ) - expect(router.stores.byId.state[match.id]).toBeUndefined() - expect(router.stores.byRouteId.state[match.routeId]).toBe( - activeStoreByRouteId[match.routeId], - ) + const pendingStore = router.stores.pendingMatchStoresById.get(match.id) + expect(pendingStore).toBeDefined() + expect(pendingStore!.routeId).toBe(match.routeId) + // Pending match is NOT in the active pool + expect(router.stores.activeMatchStoresById.get(match.id)).toBeUndefined() + // Active pool still has a match for this routeId + expect( + router.stores.getMatchStoreByRouteId(match.routeId).state, + ).toBeDefined() }) const nextActiveMatches = activeMatches.map((match, index) => ({ @@ -101,18 +96,12 @@ describe('granular stores', () => { expect(router.stores.matchesId.state).toEqual( nextActiveMatches.map((match) => match.id), ) - expect(Object.keys(router.stores.byId.state)).toEqual( - nextActiveMatches.map((match) => match.id), - ) - expect(Object.keys(router.stores.byRouteId.state)).toEqual( - nextActiveMatches.map((match) => match.routeId), - ) nextActiveMatches.forEach((match) => { - expect(router.stores.byId.state[match.id]).toBe( - router.stores.activeMatchStoresById.get(match.id), - ) - expect(router.stores.byRouteId.state[match.routeId]).toBe( - router.stores.activeMatchStoresById.get(match.id), + const store = router.stores.activeMatchStoresById.get(match.id) + expect(store).toBeDefined() + expect(store!.routeId).toBe(match.routeId) + expect(router.stores.getMatchStoreByRouteId(match.routeId).state).toBe( + store!.state, ) }) }) @@ -131,8 +120,8 @@ describe('granular stores', () => { throw new Error('Expected root and leaf matches to exist') } - const rootStore = router.stores.byId.state[rootMatch.id] - const leafStore = router.stores.byId.state[leafMatch.id] + const rootStore = router.stores.activeMatchStoresById.get(rootMatch.id) + const leafStore = router.stores.activeMatchStoresById.get(leafMatch.id) expect(rootStore).toBeDefined() expect(leafStore).toBeDefined() @@ -184,12 +173,15 @@ describe('granular stores', () => { ), ) - expect(router.stores.byId.state[duplicatedId]?.state.status).toBe('error') expect( - router.stores.byRouteId.state[activeLeaf.routeId]?.state.status, + router.stores.activeMatchStoresById.get(duplicatedId)?.state.status, + ).toBe('error') + expect( + router.stores.getMatchStoreByRouteId(activeLeaf.routeId).state?.status, ).toBe('error') + // Pending pool has its own store for this id expect( - router.stores.pendingByRouteId.state[activeLeaf.routeId]?.state.status, + router.stores.pendingMatchStoresById.get(duplicatedId)?.state.status, ).toBe('pending') expect(router.stores.pendingMatchesSnapshot.state[0]?.status).toBe( 'pending', diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 29c5971005f..9e3f4ee645c 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -17,40 +17,48 @@ import { matchContext, pendingMatchContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' -import { useStoreOfStoresValue } from './storeOfStores' import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' -function useActiveMatchStore(matchId: Solid.Accessor) { - const router = useRouter() - const byId = Solid.createMemo(() => router.stores.byId.state) - return Solid.createMemo(() => { - const id = matchId() - return id ? byId()[id] : undefined - }) -} - +/** + * Resolve the active match state for a given matchId, with fallback + * to routeId-based lookup during same-route transitions. + * + * Uses direct pool access instead of the byId/byRouteId derived stores, + * avoiding intermediate lookup object allocations. + */ function useResolvedActiveMatch(matchId: Solid.Accessor) { const router = useRouter() - const activeMatchStore = useActiveMatchStore(matchId) - const activeMatch = useStoreOfStoresValue(activeMatchStore, (value) => value) // Keep the last seen routeId to recover from transient stale matchId values // during same-route transitions (e.g. loaderDepsHash changes). - const fallbackRouteId = Solid.createMemo( - (previousRouteId) => - (activeMatch()?.routeId as string | undefined) ?? previousRouteId, - ) - const byRouteId = Solid.createMemo(() => router.stores.byRouteId.state) - const fallbackMatchStore = Solid.createMemo(() => { - const routeId = fallbackRouteId() - return routeId ? byRouteId()[routeId] : undefined - }) - const fallbackMatch = useStoreOfStoresValue( - fallbackMatchStore, - (value) => value, + const lastKnownRouteId = Solid.createMemo( + (previousRouteId) => { + const id = matchId() + if (!id) return previousRouteId + // Track matchesId so this re-evaluates when the pool changes + router.stores.matchesId.state + const routeId = router.stores.activeMatchStoresById.get(id)?.routeId + return routeId ?? previousRouteId + }, ) - return Solid.createMemo(() => activeMatch() ?? fallbackMatch()) + return Solid.createMemo(() => { + const id = matchId() + if (!id) return undefined + + // Track matchesId for pool changes + router.stores.matchesId.state + + // Primary: look up by matchId from the pool directly + const store = router.stores.activeMatchStoresById.get(id) + if (store) return store.state + + // Fallback: matchId is stale, resolve by routeId through the signal graph + const routeId = lastKnownRouteId() + if (routeId) return router.stores.getMatchStoreByRouteId(routeId).state + + return undefined + }) } export const Match = (props: { matchId: string }) => { @@ -70,7 +78,7 @@ export const Match = (props: { matchId: string }) => { ) const parentMatchId = activeMatchIds()[matchIndex - 1] const parentRouteId = parentMatchId - ? router.stores.byId.state[parentMatchId]?.state.routeId + ? router.stores.activeMatchStoresById.get(parentMatchId)?.routeId : undefined return { @@ -421,32 +429,42 @@ export const MatchInner = (props: { matchId: string }): any => { export const Outlet = () => { const router = useRouter() const matchId = Solid.useContext(matchContext) - const parentMatchStore = useActiveMatchStore(() => matchId()) - const routeId = useStoreOfStoresValue( - parentMatchStore, - (parentMatch) => parentMatch?.routeId as string | undefined, + + const matchIds = Solid.createMemo(() => router.stores.matchesId.state) + + // Read parent match state directly from pool. + // matchesId tracks pool changes; store.state tracks match state. + const parentMatch = Solid.createMemo(() => { + const id = matchId() + if (!id) return undefined + matchIds() // track pool changes + return router.stores.activeMatchStoresById.get(id)?.state + }) + const routeId = Solid.createMemo( + () => parentMatch()?.routeId as string | undefined, ) const route = Solid.createMemo(() => routeId() ? router.routesById[routeId()!] : undefined, ) - const parentGlobalNotFound = useStoreOfStoresValue( - parentMatchStore, - (parentMatch) => parentMatch?.globalNotFound ?? false, + const parentGlobalNotFound = Solid.createMemo( + () => parentMatch()?.globalNotFound ?? false, ) - const matchIds = Solid.createMemo(() => router.stores.matchesId.state) const childMatchId = Solid.createMemo(() => { const ids = matchIds() const index = ids.findIndex((id) => id === matchId()) return ids[index + 1] }) - const childMatchStore = useActiveMatchStore(childMatchId) - const childMatchStatus = useStoreOfStoresValue( - childMatchStore, - (childMatch) => childMatch?.status, - ) + // Read child match state directly from pool. + const childMatch = Solid.createMemo(() => { + const id = childMatchId() + if (!id) return undefined + matchIds() // track pool changes + return router.stores.activeMatchStoresById.get(id)?.state + }) + const childMatchStatus = Solid.createMemo(() => childMatch()?.status) // Only show not-found if we're not in a redirected state const shouldShowNotFound = () => diff --git a/packages/solid-router/src/storeOfStores.ts b/packages/solid-router/src/storeOfStores.ts deleted file mode 100644 index 31aa6890244..00000000000 --- a/packages/solid-router/src/storeOfStores.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as Solid from 'solid-js' - -export function useStoreOfStoresValue( - storeAccessor: Solid.Accessor<{ state: TValue } | undefined>, - selector: (value: TValue | undefined) => TSelected, -): Solid.Accessor { - return Solid.createMemo(() => selector(storeAccessor()?.state), undefined, { - equals: Object.is, - }) -} diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index b63ceb669d9..4ca093333f7 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -6,7 +6,6 @@ import { matchContext, pendingMatchContext, } from './matchContext' -import { useStoreOfStoresValue } from './storeOfStores' import { useRouter } from './useRouter' import type { AnyRouter, @@ -83,27 +82,33 @@ export function useMatch< opts.from ? dummyPendingMatchContext : pendingMatchContext, ) - const activeMatchStore = Solid.createMemo(() => { - const stores = opts.from - ? router.stores.byRouteId.state - : router.stores.byId.state + const match = Solid.createMemo(() => { const key = opts.from ?? nearestMatchId() - return key ? stores[key] : undefined + if (!key) return undefined + if (opts.from) { + // Per-routeId computed store resolves routeId → match state + // through the signal graph in a single step. + return router.stores.getMatchStoreByRouteId(key).state + } + // Track matchesId for pool changes, then read from pool directly. + // Both reads are reactive signals in Solid's tracking system. + router.stores.matchesId.state + return router.stores.activeMatchStoresById.get(key)?.state }) const hasPendingRouteMatch = opts.from - ? Solid.createMemo(() => - Boolean(router.stores.pendingByRouteId.state[opts.from as string]), - ) + ? Solid.createMemo(() => { + // Track pending pool changes + router.stores.pendingMatchesId.state + for (const s of router.stores.pendingMatchStoresById.values()) { + if (s.routeId === opts.from) return true + } + return false + }) : undefined const isTransitioning = Solid.createMemo( () => router.stores.isTransitioning.state, ) - const match = useStoreOfStoresValue( - () => activeMatchStore(), - (value) => value, - ) - return Solid.createMemo((previous) => { const selectedMatch = match() if (selectedMatch === undefined) { diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 2070e230892..1400e7c1c40 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -9,15 +9,18 @@ import { rootRouteId, } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from './store' +import { createVueReadonlyStore, useStore } from './store' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { ClientOnly } from './ClientOnly' import { useRouter } from './useRouter' import { CatchNotFound } from './not-found' -import { matchContext, pendingMatchContext } from './matchContext' +import { + matchContext, + pendingMatchContext, + routeIdContext, +} from './matchContext' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' -import { useStoreOfStoresValue } from './storeOfStores' import type { VNode } from 'vue' import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' @@ -32,25 +35,22 @@ export const Match = Vue.defineComponent({ setup(props) { const router = useRouter() - // Track the last known routeId to handle stale props during same-route transitions. - const lastKnownRouteId = Vue.shallowRef(undefined) - const activeMatchesById = useStore(router.stores.byId, (stores) => stores) - const activeMatchesByRouteId = useStore( - router.stores.byRouteId, - (stores) => stores, - ) - const activeMatchStore = Vue.computed(() => { - return activeMatchesById.value[props.matchId] - }) - const fallbackMatchStore = Vue.computed(() => { - const routeId = lastKnownRouteId.value - return routeId ? activeMatchesByRouteId.value[routeId] : undefined - }) - const selectedMatchStore = Vue.computed( - () => activeMatchStore.value ?? fallbackMatchStore.value, + // Derive routeId from initial props.matchId — stable for this component's + // lifetime. The routeId never changes for a given route position in the + // tree, even when matchId changes (loaderDepsHash, etc). + const routeId = router.stores.activeMatchStoresById.get(props.matchId) + ?.routeId as string | undefined + + invariant( + routeId, + `Could not find routeId for matchId "${props.matchId}". Please file an issue!`, ) - const activeMatch = useStoreOfStoresValue( - selectedMatchStore, + + // Single stable store subscription — getMatchStoreByRouteId returns a + // cached computed store that resolves routeId → current match state + // through the signal graph. No bridge needed. + const activeMatch = useStore( + router.stores.getMatchStoreByRouteId(routeId), (value) => value, ) const activeMatchIds = useStore(router.stores.matchesId, (ids) => ids) @@ -60,32 +60,22 @@ export const Match = Vue.defineComponent({ ) const loadedAt = useStore(router.stores.loadedAt, (value) => value) - Vue.watchEffect(() => { - const match = activeMatch.value - if (match) { - lastKnownRouteId.value = match.routeId as string - } - }) - - // Combined selector that returns all needed data including the actual matchId - // This handles stale props.matchId during same-route transitions const matchData = Vue.computed(() => { const match = activeMatch.value if (!match) { return null } - const routeId = match.routeId as string const matchIndex = activeMatchIds.value.findIndex((id) => id === match.id) const parentMatchId = matchIndex > 0 ? activeMatchIds.value[matchIndex - 1] : undefined const parentRouteId = parentMatchId - ? ((router.stores.byId.state[parentMatchId]?.state.routeId as string) ?? - null) + ? ((router.stores.activeMatchStoresById.get(parentMatchId) + ?.routeId as string) ?? null) : null return { - matchId: match.id, // Return the actual matchId (may differ from props.matchId) + matchId: match.id, routeId, parentRouteId, loadedAt: loadedAt.value, @@ -94,11 +84,6 @@ export const Match = Vue.defineComponent({ } }) - invariant( - matchData.value, - `Could not find routeId for matchId "${props.matchId}". Please file an issue!`, - ) - const route = Vue.computed(() => matchData.value ? router.routesById[matchData.value.routeId] : null, ) @@ -142,32 +127,23 @@ export const Match = Vue.defineComponent({ : null, ) - // Create a ref for the current matchId that we provide to child components - // This ref is updated to the ACTUAL matchId found (which may differ from props during transitions) - const matchIdRef = Vue.ref(matchData.value?.matchId ?? props.matchId) - - // Watch both props.matchId and matchData to keep matchIdRef in sync - // This ensures Outlet gets the correct matchId even during transitions - Vue.watch( - [() => props.matchId, () => matchData.value?.matchId], - ([propsMatchId, dataMatchId]) => { - // Prefer the matchId from matchData (which handles fallback) - // Fall back to props.matchId if matchData is null - matchIdRef.value = dataMatchId ?? propsMatchId - }, - { immediate: true }, + // Provide routeId context (stable string) for children. + // MatchInner, Outlet, and useMatch all consume this. + Vue.provide(routeIdContext, routeId) + + // Provide matchId ref for backward compat and pending check. + // Derived from the reactive match state — always reflects the current matchId. + const matchIdRef = Vue.computed( + () => activeMatch.value?.id ?? props.matchId, ) + Vue.provide(matchContext, matchIdRef) const isPendingMatchRef = Vue.computed(() => pendingMatchIds.value.includes(matchIdRef.value), ) - - // Provide the matchId to child components - Vue.provide(matchContext, matchIdRef) Vue.provide(pendingMatchContext, isPendingMatchRef) return (): VNode => { - // Use the actual matchId from matchData, not props (which may be stale) const actualMatchId = matchData.value?.matchId ?? props.matchId const resolvedNoSsr = @@ -297,34 +273,14 @@ export const MatchInner = Vue.defineComponent({ }, setup(props) { const router = useRouter() - const lastKnownRouteId = Vue.shallowRef(undefined) - const activeMatchesById = useStore(router.stores.byId, (stores) => stores) - const activeMatchesByRouteId = useStore( - router.stores.byRouteId, - (stores) => stores, - ) - const activeMatchStore = Vue.computed(() => { - return activeMatchesById.value[props.matchId] - }) - const fallbackMatchStore = Vue.computed(() => { - const routeId = lastKnownRouteId.value - return routeId ? activeMatchesByRouteId.value[routeId] : undefined - }) - const selectedMatchStore = Vue.computed( - () => activeMatchStore.value ?? fallbackMatchStore.value, - ) - const activeMatch = useStoreOfStoresValue( - selectedMatchStore, + + // Use routeId from context (provided by parent Match) — stable string. + const routeId = Vue.inject(routeIdContext)! + const activeMatch = useStore( + router.stores.getMatchStoreByRouteId(routeId), (value) => value, ) - Vue.watchEffect(() => { - const match = activeMatch.value - if (match) { - lastKnownRouteId.value = match.routeId as string - } - }) - // Combined selector for match state AND remount key // This ensures both are computed in the same selector call with consistent data const combinedState = Vue.computed(() => { @@ -334,17 +290,17 @@ export const MatchInner = Vue.defineComponent({ return null } - const routeId = match.routeId as string + const matchRouteId = match.routeId as string // Compute remount key const remountFn = - (router.routesById[routeId] as AnyRoute).options.remountDeps ?? + (router.routesById[matchRouteId] as AnyRoute).options.remountDeps ?? router.options.defaultRemountDeps let remountKey: string | undefined if (remountFn) { const remountDeps = remountFn({ - routeId, + routeId: matchRouteId, loaderDeps: match.loaderDeps, params: match._strictParams, search: match._strictSearch, @@ -353,7 +309,7 @@ export const MatchInner = Vue.defineComponent({ } return { - routeId, + routeId: matchRouteId, match: { id: match.id, status: match.status, @@ -487,42 +443,43 @@ export const Outlet = Vue.defineComponent({ name: 'Outlet', setup() { const router = useRouter() - const matchId = Vue.inject(matchContext) - const safeMatchId = Vue.computed(() => matchId?.value || '') - const activeMatchIds = useStore(router.stores.matchesId, (ids) => ids) - const activeMatchesById = useStore(router.stores.byId, (stores) => stores) - const parentMatchStore = Vue.computed(() => { - const id = safeMatchId.value - return id ? activeMatchesById.value[id] : undefined - }) + const parentRouteId = Vue.inject(routeIdContext) - const routeId = useStoreOfStoresValue( - parentMatchStore, - (parentMatch) => parentMatch?.routeId as string | undefined, + if (!parentRouteId) { + return (): VNode | null => null + } + + // Parent state via stable routeId store — single subscription + const parentMatch = useStore( + router.stores.getMatchStoreByRouteId(parentRouteId), + (v) => v, ) const route = Vue.computed(() => - routeId.value ? router.routesById[routeId.value]! : undefined, + parentMatch.value + ? router.routesById[parentMatch.value.routeId as string]! + : undefined, ) - const parentGlobalNotFound = useStoreOfStoresValue( - parentMatchStore, - (parentMatch) => parentMatch?.globalNotFound ?? false, + const parentGlobalNotFound = Vue.computed( + () => parentMatch.value?.globalNotFound ?? false, ) - const childMatchId = Vue.computed(() => { - const index = activeMatchIds.value.findIndex( - (id) => id === safeMatchId.value, - ) - return activeMatchIds.value[index + 1] - }) - - const childMatchStore = Vue.computed(() => { - const id = childMatchId.value - return id ? activeMatchesById.value[id] : undefined + // Child match via inline computed store — finds the next match after + // the parent in the active matches array. Dependencies tracked in the + // signal graph: matchesId (pool changes) + child matchStore.state. + const childMatchStore = createVueReadonlyStore(() => { + const ids = router.stores.matchesId.state + let parentFound = false + for (const id of ids) { + const store = router.stores.activeMatchStoresById.get(id) + if (!store) continue + if (parentFound) return store.state + if (store.routeId === parentRouteId) parentFound = true + } + return undefined }) - - const childMatch = useStoreOfStoresValue(childMatchStore, (value) => value) + const childMatch = useStore(childMatchStore, (v) => v) const childMatchData = Vue.computed(() => { const child = childMatch.value @@ -554,20 +511,12 @@ export const Outlet = Vue.defineComponent({ key: childMatchData.value.paramsKey, }) - if (safeMatchId.value === rootRouteId) { - return Vue.h( - Vue.Suspense, - { - fallback: router.options.defaultPendingComponent - ? Vue.h(router.options.defaultPendingComponent) - : null, - }, - { - default: () => nextMatch, - }, - ) - } - + // Note: We intentionally do NOT wrap in Suspense here. + // The top-level Suspense in Matches already covers the root. + // The old code compared matchId (e.g. "__root__/") with rootRouteId ("__root__") + // which never matched, so this Suspense was effectively dead code. + // With routeId-based lookup, parentRouteId === rootRouteId would match, + // causing a double-Suspense that corrupts Vue's DOM during updates. return nextMatch } }, diff --git a/packages/vue-router/src/matchContext.tsx b/packages/vue-router/src/matchContext.tsx index 410c0f6a83d..349de9cf625 100644 --- a/packages/vue-router/src/matchContext.tsx +++ b/packages/vue-router/src/matchContext.tsx @@ -21,6 +21,13 @@ export const dummyPendingMatchContext = Symbol( 'TanStackRouterDummyPendingMatch', ) as Vue.InjectionKey> +// Stable routeId context — a plain string (not reactive) that identifies +// which route this component belongs to. Provided by Match, consumed by +// MatchInner, Outlet, and useMatch for routeId-based store lookups. +export const routeIdContext = Symbol( + 'TanStackRouterRouteId', +) as Vue.InjectionKey + /** * Provides a match ID to child components */ diff --git a/packages/vue-router/src/storeOfStores.ts b/packages/vue-router/src/storeOfStores.ts deleted file mode 100644 index f25cc8a46d0..00000000000 --- a/packages/vue-router/src/storeOfStores.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as Vue from 'vue' - -type SubscribableStore = { - state: TValue - subscribe?: ( - listener: () => void, - ) => (() => void) | { unsubscribe: () => void } -} - -// TODO (injectable stores) why is this implementation so complicated when the Solid one is so simple? -export function useStoreOfStoresValue( - storeRef: Vue.Ref | undefined>, - selector: (value: TValue | undefined) => TSelected, - equal: (a: TSelected, b: TSelected) => boolean = Object.is, -): Readonly> { - const selected = Vue.shallowRef( - selector(storeRef.value?.state), - ) as Vue.ShallowRef - - const syncSelected = () => { - const next = selector(storeRef.value?.state) - if (!equal(selected.value, next)) { - selected.value = next - } - } - - Vue.watch( - storeRef, - (store, _previous, onCleanup) => { - syncSelected() - - if (!store?.subscribe) { - return - } - - const unsubscribe = store.subscribe(() => { - syncSelected() - }) - - onCleanup(() => { - if (typeof unsubscribe === 'function') { - unsubscribe() - return - } - - unsubscribe.unsubscribe() - }) - }, - { immediate: true }, - ) - - Vue.watch( - () => selector(storeRef.value?.state), - () => { - if (!storeRef.value?.subscribe) { - syncSelected() - } - }, - ) - - return Vue.readonly(selected) as Readonly> -} diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index f8b5de5d5da..35241214ae2 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -1,12 +1,10 @@ import * as Vue from 'vue' import { useStore } from './store' import { - injectDummyMatch, injectDummyPendingMatch, - injectMatch, injectPendingMatch, + routeIdContext, } from './matchContext' -import { useStoreOfStoresValue } from './storeOfStores' import { useRouter } from './useRouter' import type { AnyRouter, @@ -76,24 +74,47 @@ export function useMatch< ThrowOrOptional, TThrow> > { const router = useRouter() - const nearestMatchId = opts.from ? injectDummyMatch() : injectMatch() const hasPendingNearestMatch = opts.from ? injectDummyPendingMatch() : injectPendingMatch() - const activeMatchesLookup = useStore( - opts.from ? router.stores.byRouteId : router.stores.byId, - (stores) => stores, - ) - const activeMatchStore = Vue.computed(() => { - const key = opts.from ?? nearestMatchId.value - return key ? activeMatchesLookup.value[key] : undefined - }) - const hasPendingRouteMatch = opts.from - ? useStore( - router.stores.pendingByRouteId, - (stores) => Boolean(stores[opts.from as string]), - { equal: Object.is }, + // Set up reactive match value based on lookup strategy. + let match: Readonly> + + if (opts.from) { + // routeId case: single subscription via per-routeId computed store. + // The store reference is stable (cached by routeId). + const matchStore = router.stores.getMatchStoreByRouteId(opts.from) + match = useStore(matchStore, (value) => value) + } else { + // matchId case: use routeId from context for stable store lookup. + // The routeId is provided by the nearest Match component and doesn't + // change for the component's lifetime, so the store is stable. + const nearestRouteId = Vue.inject(routeIdContext) + if (nearestRouteId) { + match = useStore( + router.stores.getMatchStoreByRouteId(nearestRouteId), + (value) => value, ) + } else { + // No route context — will fall through to error handling below + match = Vue.ref(undefined) as Readonly> + } + } + + const hasPendingRouteMatch = opts.from + ? (() => { + const pendingIds = useStore( + router.stores.pendingMatchesId, + (ids) => ids, + ) + return Vue.computed(() => { + void pendingIds.value // track pending pool changes + for (const s of router.stores.pendingMatchStoresById.values()) { + if (s.routeId === opts.from) return true + } + return false + }) + })() : undefined const isTransitioning = useStore( router.stores.isTransitioning, @@ -101,8 +122,6 @@ export function useMatch< { equal: Object.is }, ) - const match = useStoreOfStoresValue(activeMatchStore, (value) => value) - const result = Vue.computed(() => { const selectedMatch = match.value if (selectedMatch === undefined) { From 468da49b440c2e49c4bbc191a0209bea814b6e7d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 4 Mar 2026 18:46:30 +0100 Subject: [PATCH 2/7] more vue refactoring using signals instead of black magic --- packages/vue-router/src/Match.tsx | 46 +++++++++---------------- packages/vue-router/src/routerStores.ts | 32 +++++++++++++++++ packages/vue-router/src/useMatch.tsx | 16 ++------- 3 files changed, 51 insertions(+), 43 deletions(-) diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 1400e7c1c40..9600fb97297 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -9,7 +9,7 @@ import { rootRouteId, } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { createVueReadonlyStore, useStore } from './store' +import { useStore } from './store' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { ClientOnly } from './ClientOnly' import { useRouter } from './useRouter' @@ -46,6 +46,11 @@ export const Match = Vue.defineComponent({ `Could not find routeId for matchId "${props.matchId}". Please file an issue!`, ) + // Static route-tree check: is this route a direct child of the root? + // parentRoute is set at build time, so no reactive tracking needed. + const isChildOfRoot = + (router.routesById[routeId] as AnyRoute)?.parentRoute?.id === rootRouteId + // Single stable store subscription — getMatchStoreByRouteId returns a // cached computed store that resolves routeId → current match state // through the signal graph. No bridge needed. @@ -53,7 +58,6 @@ export const Match = Vue.defineComponent({ router.stores.getMatchStoreByRouteId(routeId), (value) => value, ) - const activeMatchIds = useStore(router.stores.matchesId, (ids) => ids) const pendingMatchIds = useStore( router.stores.pendingMatchesId, (ids) => ids, @@ -66,18 +70,9 @@ export const Match = Vue.defineComponent({ return null } - const matchIndex = activeMatchIds.value.findIndex((id) => id === match.id) - const parentMatchId = - matchIndex > 0 ? activeMatchIds.value[matchIndex - 1] : undefined - const parentRouteId = parentMatchId - ? ((router.stores.activeMatchStoresById.get(parentMatchId) - ?.routeId as string) ?? null) - : null - return { matchId: match.id, routeId, - parentRouteId, loadedAt: loadedAt.value, ssr: match.ssr, _displayPending: match._displayPending, @@ -203,8 +198,7 @@ export const Match = Vue.defineComponent({ // Add scroll restoration if needed const withScrollRestoration: Array = [ content, - matchData.value?.parentRouteId === rootRouteId && - router.options.scrollRestoration + isChildOfRoot && router.options.scrollRestoration ? Vue.h(Vue.Fragment, null, [ Vue.h(OnRendered), Vue.h(ScrollRestoration), @@ -465,24 +459,18 @@ export const Outlet = Vue.defineComponent({ () => parentMatch.value?.globalNotFound ?? false, ) - // Child match via inline computed store — finds the next match after - // the parent in the active matches array. Dependencies tracked in the - // signal graph: matchesId (pool changes) + child matchStore.state. - const childMatchStore = createVueReadonlyStore(() => { - const ids = router.stores.matchesId.state - let parentFound = false - for (const id of ids) { - const store = router.stores.activeMatchStoresById.get(id) - if (!store) continue - if (parentFound) return store.state - if (store.routeId === parentRouteId) parentFound = true - } - return undefined - }) - const childMatch = useStore(childMatchStore, (v) => v) + // Child match lookup: read the child matchId from the shared derived + // map (one reactive node for the whole tree), then grab match state + // directly from the pool. + const childMatchIdMap = useStore( + router.stores.childMatchIdByRouteId, + (v) => v, + ) const childMatchData = Vue.computed(() => { - const child = childMatch.value + const childId = childMatchIdMap.value[parentRouteId] + if (!childId) return null + const child = router.stores.activeMatchStoresById.get(childId)?.state if (!child) return null return { diff --git a/packages/vue-router/src/routerStores.ts b/packages/vue-router/src/routerStores.ts index 2d4faa5704d..28aae398486 100644 --- a/packages/vue-router/src/routerStores.ts +++ b/packages/vue-router/src/routerStores.ts @@ -9,6 +9,10 @@ import type { declare module '@tanstack/router-core' { export interface RouterStores { lastMatchRouteFullPath: RouterReadableStore + /** Maps each active routeId to the matchId of its child in the match tree. */ + childMatchIdByRouteId: RouterReadableStore> + /** Maps each pending routeId to true for quick lookup. */ + pendingRouteIds: RouterReadableStore> } } @@ -25,6 +29,34 @@ export const getStoreFactory: GetStoreConfig = (_opts) => { } return stores.activeMatchStoresById.get(id)?.state.fullPath }) + + // Single derived store: one reactive node that maps every active + // routeId to its child's matchId. Depends only on matchesId + + // the pool's routeId tags (which are set during reconciliation). + // Outlet reads the map and then does a direct pool lookup. + stores.childMatchIdByRouteId = createVueReadonlyStore(() => { + const ids = stores.matchesId.state + const obj: Record = {} + for (let i = 0; i < ids.length - 1; i++) { + const parentStore = stores.activeMatchStoresById.get(ids[i]!) + if (parentStore?.routeId) { + obj[parentStore.routeId] = ids[i + 1]! + } + } + return obj + }) + + stores.pendingRouteIds = createVueReadonlyStore(() => { + const ids = stores.pendingMatchesId.state + const obj: Record = {} + for (const id of ids) { + const store = stores.pendingMatchStoresById.get(id) + if (store?.routeId) { + obj[store.routeId] = true + } + } + return obj + }) }, } } diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index 35241214ae2..bb395fa3d69 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -102,19 +102,7 @@ export function useMatch< } const hasPendingRouteMatch = opts.from - ? (() => { - const pendingIds = useStore( - router.stores.pendingMatchesId, - (ids) => ids, - ) - return Vue.computed(() => { - void pendingIds.value // track pending pool changes - for (const s of router.stores.pendingMatchStoresById.values()) { - if (s.routeId === opts.from) return true - } - return false - }) - })() + ? useStore(router.stores.pendingRouteIds, (ids) => ids) : undefined const isTransitioning = useStore( router.stores.isTransitioning, @@ -126,7 +114,7 @@ export function useMatch< const selectedMatch = match.value if (selectedMatch === undefined) { const hasPendingMatch = opts.from - ? Boolean(hasPendingRouteMatch?.value) + ? Boolean(hasPendingRouteMatch?.value[opts.from!]) : hasPendingNearestMatch.value const shouldThrowError = !hasPendingMatch && !isTransitioning.value && (opts.shouldThrow ?? true) From c0b2de0ede3537f2ddd1927d3df6bcaa2218fea7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 4 Mar 2026 20:18:05 +0100 Subject: [PATCH 3/7] same but for solid --- packages/solid-router/src/Match.tsx | 164 +++++++++++---------- packages/solid-router/src/Matches.tsx | 52 ++++--- packages/solid-router/src/matchContext.tsx | 8 +- packages/solid-router/src/routerStores.ts | 45 ++++++ packages/solid-router/src/useMatch.tsx | 53 ++----- 5 files changed, 175 insertions(+), 147 deletions(-) diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 9e3f4ee645c..673faea1f32 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -13,7 +13,11 @@ import { Dynamic } from 'solid-js/web' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouter } from './useRouter' import { CatchNotFound } from './not-found' -import { matchContext, pendingMatchContext } from './matchContext' +import { + matchContext, + pendingMatchContext, + routeIdContext, +} from './matchContext' import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' @@ -64,7 +68,6 @@ function useResolvedActiveMatch(matchId: Solid.Accessor) { export const Match = (props: { matchId: string }) => { const router = useRouter() const match = useResolvedActiveMatch(() => props.matchId) - const activeMatchIds = Solid.createMemo(() => router.stores.matchesId.state) const resetKey = Solid.createMemo(() => router.stores.loadedAt.state) const rawMatchState = Solid.createMemo(() => { @@ -73,17 +76,13 @@ export const Match = (props: { matchId: string }) => { return null } - const matchIndex = activeMatchIds().findIndex( - (id) => id === currentMatch.id, - ) - const parentMatchId = activeMatchIds()[matchIndex - 1] - const parentRouteId = parentMatchId - ? router.stores.activeMatchStoresById.get(parentMatchId)?.routeId - : undefined + const routeId = currentMatch.routeId as string + const parentRouteId = (router.routesById[routeId] as AnyRoute)?.parentRoute + ?.id return { matchId: currentMatch.id, - routeId: currentMatch.routeId as string, + routeId, ssr: currentMatch.ssr, _displayPending: currentMatch._displayPending, parentRouteId: parentRouteId as string | undefined, @@ -95,12 +94,12 @@ export const Match = (props: { matchId: string }) => { null, ) - const isPendingMatch = Solid.createMemo( - () => router.stores.pendingMatchesId.state, + const pendingRouteIds = Solid.createMemo( + () => router.stores.pendingRouteIds.state, ) const hasPendingMatch = Solid.createMemo(() => { - const currentMatchId = matchState()?.matchId - return currentMatchId ? isPendingMatch().includes(currentMatchId) : false + const currentRouteId = matchState()?.routeId + return currentRouteId ? Boolean(pendingRouteIds()[currentRouteId]) : false }) // If match doesn't exist yet, return null (component is being unmounted or not ready) @@ -142,64 +141,72 @@ export const Match = (props: { matchId: string }) => { return ( matchState()!.matchId}> - - - ) - } - > + matchState()!.routeId}> + resetKey()} - errorComponent={routeErrorComponent() || ErrorComponent} - onCatch={(error: Error) => { - // Forward not found errors (we don't want to show the error component for these) - if (isNotFound(error)) throw error - warning(false, `Error in route match: ${matchState()!.routeId}`) - routeOnCatch()?.(error) - }} + component={ResolvedSuspenseBoundary()} + fallback={ + // Don't show fallback on server when using no-ssr mode to avoid hydration mismatch + (isServer ?? router.isServer) && resolvedNoSsr ? undefined : ( + + ) + } > { - // If the current not found handler doesn't exist or it has a - // route ID which doesn't match the current route, rethrow the error - if ( - !routeNotFoundComponent() || - (error.routeId && - error.routeId !== matchState()!.routeId) || - (!error.routeId && !route().isRoot) - ) - throw error - - return ( - + component={ResolvedCatchBoundary()} + getResetKey={() => resetKey()} + errorComponent={routeErrorComponent() || ErrorComponent} + onCatch={(error: Error) => { + // Forward not found errors (we don't want to show the error component for these) + if (isNotFound(error)) throw error + warning( + false, + `Error in route match: ${matchState()!.routeId}`, ) + routeOnCatch()?.(error) }} > - - - - } - > + { + // If the current not found handler doesn't exist or it has a + // route ID which doesn't match the current route, rethrow the error + if ( + !routeNotFoundComponent() || + (error.routeId && + error.routeId !== matchState()!.routeId) || + (!error.routeId && !route().isRoot) + ) + throw error + + return ( + + ) + }} + > + + + + } + > + + + + - - - - - - + + + - - + + {matchState()?.parentRouteId === rootRouteId ? ( @@ -428,18 +435,15 @@ export const MatchInner = (props: { matchId: string }): any => { export const Outlet = () => { const router = useRouter() - const matchId = Solid.useContext(matchContext) - - const matchIds = Solid.createMemo(() => router.stores.matchesId.state) + const parentRouteIdContext = Solid.useContext(routeIdContext) - // Read parent match state directly from pool. - // matchesId tracks pool changes; store.state tracks match state. const parentMatch = Solid.createMemo(() => { - const id = matchId() - if (!id) return undefined - matchIds() // track pool changes - return router.stores.activeMatchStoresById.get(id)?.state + const routeId = parentRouteIdContext() + return routeId + ? router.stores.getMatchStoreByRouteId(routeId).state + : undefined }) + const routeId = Solid.createMemo( () => parentMatch()?.routeId as string | undefined, ) @@ -451,20 +455,20 @@ export const Outlet = () => { () => parentMatch()?.globalNotFound ?? false, ) + const childMatchIdByRouteId = Solid.createMemo( + () => router.stores.childMatchIdByRouteId.state, + ) + const childMatchId = Solid.createMemo(() => { - const ids = matchIds() - const index = ids.findIndex((id) => id === matchId()) - return ids[index + 1] + const currentRouteId = routeId() + return currentRouteId ? childMatchIdByRouteId()[currentRouteId] : undefined }) - // Read child match state directly from pool. - const childMatch = Solid.createMemo(() => { + const childMatchStatus = Solid.createMemo(() => { const id = childMatchId() if (!id) return undefined - matchIds() // track pool changes - return router.stores.activeMatchStoresById.get(id)?.state + return router.stores.activeMatchStoresById.get(id)?.state.status }) - const childMatchStatus = Solid.createMemo(() => childMatch()?.status) // Only show not-found if we're not in a redirected state const shouldShowNotFound = () => diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index 8f25f15241d..fdcdeef4257 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -6,7 +6,7 @@ import { shallow } from './store' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouter } from './useRouter' import { Transitioner } from './Transitioner' -import { matchContext } from './matchContext' +import { matchContext, routeIdContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { Match } from './Match' import type { @@ -68,6 +68,12 @@ export function Matches() { function MatchesInner() { const router = useRouter() const matchId = Solid.createMemo(() => router.stores.firstMatchId.state) + const routeId = Solid.createMemo(() => { + const id = matchId() + if (!id) return undefined + router.stores.matchesId.state + return router.stores.activeMatchStoresById.get(id)?.routeId + }) const resetKey = Solid.createMemo(() => router.stores.loadedAt.state) const matchComponent = () => { @@ -80,28 +86,30 @@ function MatchesInner() { return ( - {router.options.disableGlobalCatchBoundary ? ( - matchComponent() - ) : ( - resetKey()} - errorComponent={ErrorComponent} - onCatch={ - process.env.NODE_ENV !== 'production' - ? (error) => { - warning( - false, - `The following error wasn't caught by any route! At the very leas + + {router.options.disableGlobalCatchBoundary ? ( + matchComponent() + ) : ( + resetKey()} + errorComponent={ErrorComponent} + onCatch={ + process.env.NODE_ENV !== 'production' + ? (error) => { + warning( + false, + `The following error wasn't caught by any route! At the very leas t, consider setting an 'errorComponent' in your RootRoute!`, - ) - warning(false, error.message || error.toString()) - } - : undefined - } - > - {matchComponent()} - - )} + ) + warning(false, error.message || error.toString()) + } + : undefined + } + > + {matchComponent()} + + )} + ) } diff --git a/packages/solid-router/src/matchContext.tsx b/packages/solid-router/src/matchContext.tsx index 7c0932a9f8c..94029445248 100644 --- a/packages/solid-router/src/matchContext.tsx +++ b/packages/solid-router/src/matchContext.tsx @@ -4,16 +4,10 @@ export const matchContext = Solid.createContext< Solid.Accessor >(() => undefined) -// N.B. this only exists so we can conditionally call useContext on it when we are not interested in the nearest match -export const dummyMatchContext = Solid.createContext< +export const routeIdContext = Solid.createContext< Solid.Accessor >(() => undefined) export const pendingMatchContext = Solid.createContext>( () => false, ) - -// N.B. this only exists so we can conditionally call useContext on it when we are not interested in the nearest match -export const dummyPendingMatchContext = Solid.createContext< - Solid.Accessor ->(() => false) diff --git a/packages/solid-router/src/routerStores.ts b/packages/solid-router/src/routerStores.ts index e233cd08fd3..2547f3aa481 100644 --- a/packages/solid-router/src/routerStores.ts +++ b/packages/solid-router/src/routerStores.ts @@ -5,11 +5,53 @@ import { } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import type { + AnyRoute, GetStoreConfig, RouterReadableStore, + RouterStores, RouterWritableStore, } from '@tanstack/router-core' +declare module '@tanstack/router-core' { + export interface RouterStores { + /** Maps each active routeId to the matchId of its child in the match tree. */ + childMatchIdByRouteId: RouterReadableStore> + /** Maps each pending routeId to true for quick lookup. */ + pendingRouteIds: RouterReadableStore> + } +} + +function initRouterStores( + stores: RouterStores, + createReadonlyStore: ( + read: () => TValue, + ) => RouterReadableStore, +) { + stores.childMatchIdByRouteId = createReadonlyStore(() => { + const ids = stores.matchesId.state + const obj: Record = {} + for (let i = 0; i < ids.length - 1; i++) { + const parentStore = stores.activeMatchStoresById.get(ids[i]!) + if (parentStore?.routeId) { + obj[parentStore.routeId] = ids[i + 1]! + } + } + return obj + }) + + stores.pendingRouteIds = createReadonlyStore(() => { + const ids = stores.pendingMatchesId.state + const obj: Record = {} + for (const id of ids) { + const store = stores.pendingMatchStoresById.get(id) + if (store?.routeId) { + obj[store.routeId] = true + } + } + return obj + }) +} + function createSolidMutableStore( initialValue: TValue, ): RouterWritableStore { @@ -44,6 +86,8 @@ export const getStoreFactory: GetStoreConfig = (opts) => { createMutableStore: createNonReactiveMutableStore, createReadonlyStore: createNonReactiveReadonlyStore, batch: (fn) => fn(), + init: (stores) => + initRouterStores(stores, createNonReactiveReadonlyStore), } } @@ -51,5 +95,6 @@ export const getStoreFactory: GetStoreConfig = (opts) => { createMutableStore: createSolidMutableStore, createReadonlyStore: createSolidReadonlyStore, batch: Solid.batch, + init: (stores) => initRouterStores(stores, createSolidReadonlyStore), } } diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index 4ca093333f7..b05d191dc97 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -1,11 +1,6 @@ import * as Solid from 'solid-js' import invariant from 'tiny-invariant' -import { - dummyMatchContext, - dummyPendingMatchContext, - matchContext, - pendingMatchContext, -} from './matchContext' +import { pendingMatchContext, routeIdContext } from './matchContext' import { useRouter } from './useRouter' import type { AnyRouter, @@ -75,39 +70,19 @@ export function useMatch< ThrowOrOptional, TThrow> > { const router = useRouter() - const nearestMatchId = Solid.useContext( - opts.from ? dummyMatchContext : matchContext, - ) - const hasPendingNearestMatch = Solid.useContext( - opts.from ? dummyPendingMatchContext : pendingMatchContext, - ) + const nearestRouteId: Solid.Accessor = opts.from + ? () => undefined + : Solid.useContext(routeIdContext) + const hasPendingNearestMatch: Solid.Accessor = opts.from + ? () => false + : Solid.useContext(pendingMatchContext) const match = Solid.createMemo(() => { - const key = opts.from ?? nearestMatchId() - if (!key) return undefined - if (opts.from) { - // Per-routeId computed store resolves routeId → match state - // through the signal graph in a single step. - return router.stores.getMatchStoreByRouteId(key).state - } - // Track matchesId for pool changes, then read from pool directly. - // Both reads are reactive signals in Solid's tracking system. - router.stores.matchesId.state - return router.stores.activeMatchStoresById.get(key)?.state + const routeId = opts.from ?? nearestRouteId() + return routeId + ? router.stores.getMatchStoreByRouteId(routeId).state + : undefined }) - const hasPendingRouteMatch = opts.from - ? Solid.createMemo(() => { - // Track pending pool changes - router.stores.pendingMatchesId.state - for (const s of router.stores.pendingMatchStoresById.values()) { - if (s.routeId === opts.from) return true - } - return false - }) - : undefined - const isTransitioning = Solid.createMemo( - () => router.stores.isTransitioning.state, - ) return Solid.createMemo((previous) => { const selectedMatch = match() @@ -118,10 +93,12 @@ export function useMatch< } const hasPendingMatch = opts.from - ? Boolean(hasPendingRouteMatch?.()) + ? Boolean(router.stores.pendingRouteIds.state[opts.from!]) : hasPendingNearestMatch() const shouldThrowError = - !hasPendingMatch && !isTransitioning() && (opts.shouldThrow ?? true) + !hasPendingMatch && + !router.stores.isTransitioning.state && + (opts.shouldThrow ?? true) invariant( !shouldThrowError, From 821815e89e92d82f0f84e3a3fad06717be874444 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 4 Mar 2026 21:02:23 +0100 Subject: [PATCH 4/7] minor simplification of react-router --- packages/react-router/src/Match.tsx | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index fc4636fb351..726a29860dc 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -38,10 +38,9 @@ export const Match = React.memo(function MatchImpl({ `Could not find match for matchId "${matchId}". Please file an issue!`, ) - const matches = router.state.matches - const matchIndex = matches.findIndex((d) => d.id === matchId) - const parentRouteId = - matchIndex > 0 ? (matches[matchIndex - 1]?.routeId as string) : undefined + const routeId = match.routeId as string + const parentRouteId = (router.routesById[routeId] as AnyRoute).parentRoute + ?.id return ( value!) // eslint-disable-next-line react-hooks/rules-of-hooks - const parentMatchId = useStore(router.stores.matchesId, (ids) => { - return ids[ids.findIndex((id) => id === matchId) - 1] - }) - // eslint-disable-next-line react-hooks/rules-of-hooks const matchState = React.useMemo(() => { - const parentRouteId = parentMatchId - ? router.stores.activeMatchStoresById.get(parentMatchId)?.state.routeId - : undefined + const routeId = match.routeId as string + const parentRouteId = (router.routesById[routeId] as AnyRoute).parentRoute + ?.id return { - routeId: match.routeId as string, + routeId, ssr: match.ssr, _displayPending: match._displayPending, parentRouteId: parentRouteId as string | undefined, } satisfies MatchViewState - }, [parentMatchId, match, router.stores.activeMatchStoresById]) + }, [match._displayPending, match.routeId, match.ssr, router.routesById]) return ( Date: Wed, 4 Mar 2026 21:20:31 +0100 Subject: [PATCH 5/7] minor cleanup --- packages/react-router/src/Match.tsx | 10 ++----- packages/react-router/src/useMatch.tsx | 41 +++++++++++--------------- packages/solid-router/src/Match.tsx | 6 +--- 3 files changed, 20 insertions(+), 37 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 726a29860dc..a8e288d288e 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -457,15 +457,9 @@ export const Outlet = React.memo(function OutletImpl() { : undefined // eslint-disable-next-line react-hooks/rules-of-hooks - routeId = useStore( + ;[routeId, parentGlobalNotFound] = useStore( parentMatchStore, - (match) => match?.routeId as string | undefined, - ) - - // eslint-disable-next-line react-hooks/rules-of-hooks - parentGlobalNotFound = useStore( - parentMatchStore, - (match) => match?.globalNotFound ?? false, + (match) => [match?.routeId as string | undefined, match?.globalNotFound ?? false], ) // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index dd0dbf9d19b..ebc083b86a8 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -114,14 +114,23 @@ export function useMatch< undefined, ) - if (isServer ?? router.isServer) { - const key = opts.from ?? nearestMatchId - const match = key - ? opts.from - ? router.stores.getMatchStoreByRouteId(key).state - : router.stores.activeMatchStoresById.get(key)?.state - : undefined + // Single subscription: instead of two useStore calls (one to resolve + // the store, one to read from it), we resolve the store at this level + // and subscribe to it directly. + // + // - by-routeId (opts.from): uses a per-routeId computed store from the + // signal graph that resolves routeId → match state in one step. + // - by-matchId (matchContext): subscribes directly to the match store + // from the pool — the matchId from context is stable for this component. + const key = opts.from ?? nearestMatchId + const matchStore = key + ? opts.from + ? router.stores.getMatchStoreByRouteId(key) + : router.stores.activeMatchStoresById.get(key) + : undefined + if (isServer ?? router.isServer) { + const match = matchStore?.state invariant( !((opts.shouldThrow ?? true) && !match), `Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, @@ -134,24 +143,8 @@ export function useMatch< return (opts.select ? opts.select(match as any) : match) as any } - const key = opts.from ?? nearestMatchId - - // Single subscription: instead of two useStore calls (one to resolve - // the store, one to read from it), we resolve the store at this level - // and subscribe to it directly. - // - // - by-routeId (opts.from): uses a per-routeId computed store from the - // signal graph that resolves routeId → match state in one step. - // - by-matchId (matchContext): subscribes directly to the match store - // from the pool — the matchId from context is stable for this component. - const matchStore = key - ? opts.from - ? router.stores.getMatchStoreByRouteId(key) - : (router.stores.activeMatchStoresById.get(key) ?? dummyStore) - : dummyStore - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - return useStore(matchStore, (match) => { + return useStore(matchStore ?? dummyStore, (match) => { invariant( !((opts.shouldThrow ?? true) && !match), `Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 673faea1f32..b5c6eb9bb07 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -455,13 +455,9 @@ export const Outlet = () => { () => parentMatch()?.globalNotFound ?? false, ) - const childMatchIdByRouteId = Solid.createMemo( - () => router.stores.childMatchIdByRouteId.state, - ) - const childMatchId = Solid.createMemo(() => { const currentRouteId = routeId() - return currentRouteId ? childMatchIdByRouteId()[currentRouteId] : undefined + return currentRouteId ? router.stores.childMatchIdByRouteId.state[currentRouteId] : undefined }) const childMatchStatus = Solid.createMemo(() => { From 00538cb2f9c1bfcac87c0192130fcadf26f9147a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:21:45 +0000 Subject: [PATCH 6/7] ci: apply automated fixes --- packages/react-router/src/Match.tsx | 8 ++++---- packages/solid-router/src/Match.tsx | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index a8e288d288e..8373e0c015c 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -457,10 +457,10 @@ export const Outlet = React.memo(function OutletImpl() { : undefined // eslint-disable-next-line react-hooks/rules-of-hooks - ;[routeId, parentGlobalNotFound] = useStore( - parentMatchStore, - (match) => [match?.routeId as string | undefined, match?.globalNotFound ?? false], - ) + ;[routeId, parentGlobalNotFound] = useStore(parentMatchStore, (match) => [ + match?.routeId as string | undefined, + match?.globalNotFound ?? false, + ]) // eslint-disable-next-line react-hooks/rules-of-hooks childMatchId = useStore(router.stores.matchesId, (ids) => { diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index b5c6eb9bb07..b0a3a2197b5 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -457,7 +457,9 @@ export const Outlet = () => { const childMatchId = Solid.createMemo(() => { const currentRouteId = routeId() - return currentRouteId ? router.stores.childMatchIdByRouteId.state[currentRouteId] : undefined + return currentRouteId + ? router.stores.childMatchIdByRouteId.state[currentRouteId] + : undefined }) const childMatchStatus = Solid.createMemo(() => { From a06a3d9a288977908bf201d5b8081c3f5dcdc1a9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 4 Mar 2026 22:18:44 +0100 Subject: [PATCH 7/7] fix onRendered --- packages/vue-router/src/Match.tsx | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 9600fb97297..3839026746d 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -244,14 +244,24 @@ const OnRendered = Vue.defineComponent({ (resolvedLocation) => resolvedLocation?.state.key, ) - Vue.watchEffect(() => { - if (location.value) { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo(router.state), - }) - } - }) + let prevHref: string | undefined + + Vue.watch( + location, + () => { + if (location.value) { + const currentHref = router.latestLocation.href + if (prevHref === undefined || prevHref !== currentHref) { + router.emit({ + type: 'onRendered', + ...getLocationChangeInfo(router.state), + }) + prevHref = currentHref + } + } + }, + { immediate: true }, + ) return () => null },