From f9c85eeb678bea40c2dfa067eb6388d39816021d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 08:08:35 +0200 Subject: [PATCH] fix(vue-router): path useMatch memory leak --- packages/vue-router/src/useMatch.tsx | 47 ++++++- .../tests/match-subscription-cleanup.test.tsx | 127 ++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 packages/vue-router/tests/match-subscription-cleanup.test.tsx diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index a02b5ae109..f74a49af2f 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -14,6 +14,49 @@ import type { ThrowOrOptional, } from '@tanstack/router-core' +const functionalMatchStoreRefs = new WeakMap< + object, + WeakMap>> +>() + +type ComponentEffectScope = { + run: (fn: () => T) => T | undefined +} + +function useMatchStore[0]>( + matchStore: TStore, +): Readonly>> { + const instance = Vue.getCurrentInstance() + + if ( + !instance || + typeof instance.type !== 'function' || + Vue.getCurrentScope() + ) { + return useStore(matchStore) + } + + let refsByStore = functionalMatchStoreRefs.get(instance) + if (!refsByStore) { + refsByStore = new WeakMap() + functionalMatchStoreRefs.set(instance, refsByStore) + } + + let match = refsByStore.get(matchStore) + if (!match) { + // Vue runs plain functional components outside their effect scope. Re-enter + // that scope so Vue owns the watcher, then reuse it on later renders of the + // same component instead of subscribing again on every render. + const componentScope = ( + instance as unknown as { scope: ComponentEffectScope } + ).scope + match = componentScope.run(() => useStore(matchStore))! + refsByStore.set(matchStore, match) + } + + return match as Readonly>> +} + export interface UseMatchBaseOptions< TRouter extends AnyRouter, TFrom, @@ -117,13 +160,13 @@ export function useMatch< if (opts.from) { // routeId case: subscribe to the stable per-route presentation atom. const matchStore = router.stores.getMatchStore(opts.from) - match = useStore(matchStore) + match = useMatchStore(matchStore) } else { // Nearest-match case: use the routeId from context for stable lookup. // The routeId is provided by the nearest Match component and doesn't // change for the component's lifetime, so the store is stable. if (nearestRouteId) { - match = useStore(router.stores.getMatchStore(nearestRouteId)) + match = useMatchStore(router.stores.getMatchStore(nearestRouteId)) } else { // No route context — will fall through to error handling below match = Vue.ref(undefined) as Readonly> diff --git a/packages/vue-router/tests/match-subscription-cleanup.test.tsx b/packages/vue-router/tests/match-subscription-cleanup.test.tsx new file mode 100644 index 0000000000..0408d9c0a8 --- /dev/null +++ b/packages/vue-router/tests/match-subscription-cleanup.test.tsx @@ -0,0 +1,127 @@ +import { afterEach, expect, test } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('releases match-store subscriptions when route params replace a match', async () => { + const observedIds: Array = [] + const rootRoute = createRootRoute({ + validateSearch: (search: Record) => ({ + q: typeof search.q === 'string' ? search.q : '', + }), + loaderDeps: ({ search }) => ({ q: search.q }), + loader: ({ deps }) => `root:${deps.q}`, + component: RootComponent, + }) + function RootComponent() { + const rootData = rootRoute.useLoaderData() + return ( +
+ {rootData.value} + +
+ ) + } + const itemRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/items/$id', + validateSearch: (search: Record) => ({ + q: typeof search.q === 'string' ? search.q : '', + }), + loaderDeps: ({ search }) => ({ q: search.q }), + loader: ({ params, deps }) => `${params.id}:${deps.q}`, + component: ItemComponent, + }) + function ItemComponent() { + const id = itemRoute.useLoaderData() + const rootData = rootRoute.useLoaderData() + return ( + + ) + } + const router = createRouter({ + routeTree: rootRoute.addChildren([itemRoute]), + history: createMemoryHistory({ initialEntries: ['/items/initial'] }), + defaultGcTime: 0, + }) + const matchStore = router.stores.getMatchStore('/items/$id') + const originalSubscribe = matchStore.subscribe.bind(matchStore) + let activeSubscriptions = 0 + let subscriptions = 0 + let unsubscriptions = 0 + + matchStore.subscribe = (observer) => { + subscriptions++ + activeSubscriptions++ + const subscription = Reflect.apply(originalSubscribe, matchStore, [ + observer, + ]) as ReturnType + let active = true + + return { + unsubscribe() { + if (active) { + active = false + activeSubscriptions-- + unsubscriptions++ + } + subscription.unsubscribe() + }, + } + } + + render() + expect(await screen.findByTestId('item-id')).toHaveTextContent( + 'initial:|root:', + ) + const initialSubscriptions = activeSubscriptions + + for (let index = 0; index < 50; index++) { + await router.navigate({ + to: '/items/$id', + params: { id: `item-${index}` }, + search: { q: `query-${index}` }, + replace: true, + }) + } + expect(screen.getByTestId('item-id')).toHaveTextContent( + 'item-49:query-49|root:query-49', + ) + expect(activeSubscriptions).toBe(initialSubscriptions) + + const subscriptionsAfterParamChanges = subscriptions + for (let index = 0; index < 50; index++) { + await router.navigate({ + to: '/items/$id', + params: { id: 'item-49' }, + search: { q: `same-param-query-${index}` }, + replace: true, + }) + } + expect(screen.getByTestId('item-id')).toHaveTextContent( + 'item-49:same-param-query-49|root:same-param-query-49', + ) + await fireEvent.click(screen.getByTestId('item-id')) + expect(observedIds).toEqual([ + 'item-49:same-param-query-49|root:same-param-query-49', + ]) + + expect(activeSubscriptions).toBe(initialSubscriptions) + expect(subscriptions - unsubscriptions).toBe(initialSubscriptions) + expect(subscriptions).toBe(subscriptionsAfterParamChanges) +})