diff --git a/.changeset/warm-stores-select.md b/.changeset/warm-stores-select.md new file mode 100644 index 0000000000..d15081058c --- /dev/null +++ b/.changeset/warm-stores-select.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-router': patch +'@tanstack/vue-router': patch +--- + +Migrate to TanStack Store 0.11.1 and preserve subscription cleanup for Vue functional components. diff --git a/packages/react-router/package.json b/packages/react-router/package.json index e27f769c81..d175d2c970 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -93,7 +93,7 @@ }, "dependencies": { "@tanstack/history": "workspace:*", - "@tanstack/react-store": "^0.9.3", + "@tanstack/react-store": "^0.11.1", "@tanstack/router-core": "workspace:*", "isbot": "^5.1.22" }, diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 6bca031a4d..c8d7b201be 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { isNotFound, rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { CatchBoundary, ErrorComponent } from './CatchBoundary' @@ -51,7 +51,7 @@ export const Match = React.memo(function MatchImpl({ const matchStore = router.stores.getMatchStore(routeId) // eslint-disable-next-line react-hooks/rules-of-hooks - const match = useStore(matchStore, (value) => value) + const match = useSelector(matchStore) return }) @@ -238,14 +238,14 @@ export const Outlet = React.memo(function OutletImpl() { const parentMatchStore = router.stores.getMatchStore(routeId) // eslint-disable-next-line react-hooks/rules-of-hooks - ;[parentGlobalNotFound, parentNotFoundError] = useStore( + ;[parentGlobalNotFound, parentNotFoundError] = useSelector( parentMatchStore, (match): OutletMatchSelection => [!!match!._notFound, match!.error], - outletMatchSelectionEqual, + { compare: outletMatchSelectionEqual }, ) // eslint-disable-next-line react-hooks/rules-of-hooks - childRouteId = useStore(router.stores.ids, (ids) => { + childRouteId = useSelector(router.stores.ids, (ids) => { return ids[ids.indexOf(routeId) + 1] }) } diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 3820f99f89..8c64056527 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { CatchBoundary } from './CatchBoundary' @@ -87,7 +87,7 @@ function MatchesInner() { (isServer ?? router.isServer) ? router.stores.matches.get() : // eslint-disable-next-line react-hooks/rules-of-hooks - useStore( + useSelector( router.stores.matches, (value) => acknowledgement[0 /* offered */] ?? value, ) @@ -156,11 +156,11 @@ export function useMatchRoute() { if (!(isServer ?? router.isServer)) { // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.location, (location) => location.href) + useSelector(router.stores.location, (location) => location.href) // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.resolvedLocation, (location) => location?.href) + useSelector(router.stores.resolvedLocation, (location) => location?.href) // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.status, (status) => status) + useSelector(router.stores.status) } return React.useCallback( @@ -264,7 +264,7 @@ export function useMatches< } // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - return useStore( + return useSelector( router.stores.matches, // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static useStructuralSharing(opts, router), diff --git a/packages/react-router/src/Scripts.tsx b/packages/react-router/src/Scripts.tsx index a1b189d4c6..13679f553d 100644 --- a/packages/react-router/src/Scripts.tsx +++ b/packages/react-router/src/Scripts.tsx @@ -1,4 +1,4 @@ -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { _getAssetMatches, deepEqual } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { Asset } from './Asset' @@ -69,7 +69,9 @@ export const Scripts = () => { } // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const scripts = useStore(router.stores.matches, getScripts, deepEqual) + const scripts = useSelector(router.stores.matches, getScripts, { + compare: deepEqual, + }) return renderScripts(router, scripts) } diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index d61ca829db..d517e0e453 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { _getAssetMatches, appendUniqueUserTags, @@ -212,5 +212,5 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { [assetCrossOrigin, nonce, router], ) // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - return useStore(router.stores.matches, selectTags, deepEqual) + return useSelector(router.stores.matches, selectTags, { compare: deepEqual }) } diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 5347a52680..61998bd899 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { flushSync } from 'react-dom' import { deepEqual, @@ -400,11 +400,9 @@ export function useLinkProps< ) // eslint-disable-next-line react-hooks/rules-of-hooks - const currentLocation = useStore( - router.stores.location, - (l) => l, - (prev, next) => prev.href === next.href, - ) + const currentLocation = useSelector(router.stores.location, (l) => l, { + compare: (prev, next) => prev.href === next.href, + }) // eslint-disable-next-line react-hooks/rules-of-hooks const next = React.useMemo(() => { diff --git a/packages/react-router/src/not-found.tsx b/packages/react-router/src/not-found.tsx index ce3a04c748..f932b35bb7 100644 --- a/packages/react-router/src/not-found.tsx +++ b/packages/react-router/src/not-found.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { isNotFound } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import type { ErrorInfo } from 'react' @@ -44,12 +44,12 @@ export function CatchNotFound(props: { // TODO: Some way for the user to programmatically reset the not-found boundary? // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const pathname = useStore( + const pathname = useSelector( router.stores.location, (location) => location.pathname, ) // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const status = useStore(router.stores.status, (status) => status) + const status = useSelector(router.stores.status) const resetKey = `not-found-${pathname}-${status}` return ( diff --git a/packages/react-router/src/useCanGoBack.ts b/packages/react-router/src/useCanGoBack.ts index a20f947f43..2a8ce612b1 100644 --- a/packages/react-router/src/useCanGoBack.ts +++ b/packages/react-router/src/useCanGoBack.ts @@ -1,4 +1,4 @@ -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' @@ -10,7 +10,7 @@ export function useCanGoBack() { } // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - return useStore( + return useSelector( router.stores.location, (location) => location.state.__TSR_index !== 0, ) diff --git a/packages/react-router/src/useLocation.tsx b/packages/react-router/src/useLocation.tsx index 5c8c7c8d7e..5fa39fa92e 100644 --- a/packages/react-router/src/useLocation.tsx +++ b/packages/react-router/src/useLocation.tsx @@ -1,6 +1,6 @@ 'use client' -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' import { useStructuralSharing } from './useMatch' @@ -60,7 +60,7 @@ export function useLocation< } // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - return useStore( + return useSelector( router.stores.location, // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static useStructuralSharing(opts, router), diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index 35d6673962..6004164ca5 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { invariant, replaceEqualDeep } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { dummyMatchContext, matchContext } from './matchContext' @@ -173,7 +173,7 @@ export function useMatch< useStructuralSharing(opts, router) // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const matchSelection = useStore(matchStore, (match) => + const matchSelection = useSelector(matchStore, (match) => match ? selector(match as any) : dummyMatch, ) diff --git a/packages/react-router/src/useRouterState.tsx b/packages/react-router/src/useRouterState.tsx index 1e281645af..ec2a9e6c34 100644 --- a/packages/react-router/src/useRouterState.tsx +++ b/packages/react-router/src/useRouterState.tsx @@ -1,6 +1,6 @@ 'use client' -import { useStore } from '@tanstack/react-store' +import { useSelector } from '@tanstack/react-store' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' import { useStructuralSharing } from './useMatch' @@ -68,7 +68,7 @@ export function useRouterState< } // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - return useStore( + return useSelector( router.stores.__store, // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static useStructuralSharing(opts, router), diff --git a/packages/router-core/package.json b/packages/router-core/package.json index 44b424ddf4..8f4ca31972 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -188,7 +188,7 @@ "seroval-plugins": "^1.6.2" }, "devDependencies": { - "@tanstack/store": "^0.9.3", + "@tanstack/store": "^0.11.1", "@types/node": "25.0.9", "esbuild": "^0.27.4", "vite": "*" diff --git a/packages/vue-router/package.json b/packages/vue-router/package.json index 2f883f0d4e..fb075a9610 100644 --- a/packages/vue-router/package.json +++ b/packages/vue-router/package.json @@ -75,7 +75,7 @@ "dependencies": { "@tanstack/history": "workspace:*", "@tanstack/router-core": "workspace:*", - "@tanstack/vue-store": "^0.9.3", + "@tanstack/vue-store": "^0.11.1", "@vue/runtime-dom": "^3.5.25", "isbot": "^5.1.22" }, diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 96d81970a5..278b73389a 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -1,7 +1,7 @@ import * as Vue from 'vue' import { isNotFound, rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from '@tanstack/vue-store' +import { useSelector } from './useSelector' import { CatchBoundary } from './CatchBoundary' import { ClientOnly } from './ClientOnly' import { useRouter } from './useRouter' @@ -25,11 +25,7 @@ export const Match = Vue.defineComponent({ const routeId = props.routeId - const activeMatch = useStore( - router.stores.getMatchStore(routeId), - (value) => value, - { equal: Object.is }, - ) + const activeMatch = useSelector(router.stores.getMatchStore(routeId)) // Provide routeId context (stable string) for children. // MatchInner, Outlet, and useMatch all consume this. Vue.provide(routeIdContext, routeId) @@ -142,7 +138,7 @@ export const MatchInner = Vue.defineComponent({ // Use routeId from context (provided by parent Match) — stable string. const routeId = Vue.inject(routeIdContext)! - const activeMatch = useStore(router.stores.getMatchStore(routeId)) + const activeMatch = useSelector(router.stores.getMatchStore(routeId)) // Combined selector for match state AND remount key // This ensures both are computed in the same selector call with consistent data @@ -252,11 +248,11 @@ export const Outlet = Vue.defineComponent({ const router = useRouter() const parentRouteId = Vue.inject(routeIdContext)! - const parentMatch = useStore(router.stores.getMatchStore(parentRouteId)) + const parentMatch = useSelector(router.stores.getMatchStore(parentRouteId)) const route = router.routesById[parentRouteId]! - const childMatch = useStore(router.stores.matches, (matches) => { + const childMatch = useSelector(router.stores.matches, (matches) => { const index = matches.findIndex( (match) => match.routeId === parentRouteId, ) diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index 15799d2a5b..c5a98eeccb 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -1,6 +1,6 @@ import * as Vue from 'vue' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from '@tanstack/vue-store' +import { useSelector } from './useSelector' import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import { useTransitionerSetup } from './Transitioner' @@ -79,7 +79,7 @@ const MatchesInner = Vue.defineComponent({ setup() { const router = useRouter() - const matches = useStore(router.stores.matches) + const matches = useSelector(router.stores.matches) const routeId = Vue.computed(() => matches.value[0]?.routeId) return () => { @@ -126,12 +126,12 @@ export type UseMatchRouteOptions< export function useMatchRoute() { const router = useRouter() - const location = useStore(router.stores.location, (value) => value.href) - const resolvedLocation = useStore( + const location = useSelector(router.stores.location, (value) => value.href) + const resolvedLocation = useSelector( router.stores.resolvedLocation, (value) => value?.href, ) - const status = useStore(router.stores.status) + const status = useSelector(router.stores.status) return < const TFrom extends string = string, @@ -264,7 +264,7 @@ export function useMatches< opts?: UseMatchesBaseOptions, ): Vue.Ref> { const router = useRouter() - return useStore(router.stores.matches, (matches) => { + return useSelector(router.stores.matches, (matches) => { return opts?.select ? opts.select(matches as Array>) : (matches as any) diff --git a/packages/vue-router/src/Scripts.tsx b/packages/vue-router/src/Scripts.tsx index 4c0d8feed0..a1fcf8e14b 100644 --- a/packages/vue-router/src/Scripts.tsx +++ b/packages/vue-router/src/Scripts.tsx @@ -1,7 +1,7 @@ import * as Vue from 'vue' import { _getAssetMatches } from '@tanstack/router-core' -import { useStore } from '@tanstack/vue-store' import { isServer } from '@tanstack/router-core/isServer' +import { useSelector } from './useSelector' import { Asset } from './Asset' import { useRouter } from './useRouter' import type { RouterManagedTag } from '@tanstack/router-core' @@ -11,7 +11,7 @@ export const Scripts = Vue.defineComponent({ setup() { const router = useRouter() const nonce = router.options.ssr?.nonce - const matches = useStore(router.stores.matches, _getAssetMatches) + const matches = useSelector(router.stores.matches, _getAssetMatches) const scripts = Vue.computed(() => { const userScripts: Array = [] diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx index adfc156c72..a773e994e9 100644 --- a/packages/vue-router/src/headContentUtils.tsx +++ b/packages/vue-router/src/headContentUtils.tsx @@ -7,7 +7,7 @@ import { getScriptPreloadAttrs, resolveManifestCssLink, } from '@tanstack/router-core' -import { useStore } from '@tanstack/vue-store' +import { useSelector } from './useSelector' import { useRouter } from './useRouter' import type { AssetCrossOriginConfig, @@ -16,7 +16,7 @@ import type { export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const router = useRouter() - const matches = useStore(router.stores.matches, _getAssetMatches) + const matches = useSelector(router.stores.matches, _getAssetMatches) const tags = Vue.computed>(() => { const currentMatches = matches.value diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 75c1901e4e..35c5849b2f 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -9,7 +9,7 @@ import { } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from '@tanstack/vue-store' +import { useSelector } from './useSelector' import { useRouter } from './useRouter' import { useIntersectionObserver } from './utils' @@ -212,8 +212,8 @@ export function useLinkProps< ) as unknown as LinkHTMLAttributes } - const currentLocation = useStore(router.stores.location, (l) => l, { - equal: (prev, next) => prev.href === next.href, + const currentLocation = useSelector(router.stores.location, (l) => l, { + compare: (prev, next) => prev.href === next.href, }) const next = Vue.computed(() => { diff --git a/packages/vue-router/src/not-found.tsx b/packages/vue-router/src/not-found.tsx index f0ebf678ea..dca5e0a039 100644 --- a/packages/vue-router/src/not-found.tsx +++ b/packages/vue-router/src/not-found.tsx @@ -1,54 +1,59 @@ import * as Vue from 'vue' import { isNotFound } from '@tanstack/router-core' -import { useStore } from '@tanstack/vue-store' +import { useSelector } from './useSelector' import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import type { ErrorComponentProps, NotFoundError } from '@tanstack/router-core' -export function CatchNotFound(props: { +type CatchNotFoundProps = { fallback?: (error: NotFoundError) => Vue.VNode onCatch?: (error: Error) => void children: Vue.VNode -}) { - const router = useRouter() - // TODO: Some way for the user to programmatically reset the not-found boundary? - const pathname = useStore( - router.stores.location, - (location) => location.pathname, - ) - const status = useStore(router.stores.status) +} - // Create a function that returns a VNode to match the SyncRouteComponent signature - const errorComponentFn = (componentProps: ErrorComponentProps) => { - const error = componentProps.error +const CatchNotFoundImpl = Vue.defineComponent({ + name: 'CatchNotFound', + props: { + fallback: Function as Vue.PropType<(error: NotFoundError) => Vue.VNode>, + onCatch: Function as Vue.PropType<(error: Error) => void>, + children: { + type: Object as Vue.PropType, + required: true, + }, + }, + setup(props) { + const router = useRouter() + // TODO: Some way for the user to programmatically reset the not-found boundary? + const pathname = useSelector( + router.stores.location, + (location) => location.pathname, + ) + const status = useSelector(router.stores.status) - if (isNotFound(error)) { - // If a fallback is provided, use it - if (props.fallback) { - return props.fallback(error) - } - // Otherwise return a default not found message - return Vue.h('p', null, 'Not Found') - } else { - // Re-throw non-NotFound errors - throw error - } - } + return () => + Vue.h(CatchBoundary, { + getResetKey: () => `not-found-${pathname.value}-${status.value}`, + onCatch: (error: Error) => { + if (isNotFound(error)) { + props.onCatch?.(error) + } else { + throw error + } + }, + errorComponent: ({ error }: ErrorComponentProps) => { + if (isNotFound(error)) { + return props.fallback?.(error) ?? Vue.h('p', null, 'Not Found') + } else { + throw error + } + }, + children: props.children, + }) + }, +}) - return Vue.h(CatchBoundary, { - getResetKey: () => `not-found-${pathname.value}-${status.value}`, - onCatch: (error: Error) => { - if (isNotFound(error)) { - if (props.onCatch) { - props.onCatch(error) - } - } else { - throw error - } - }, - errorComponent: errorComponentFn, - children: props.children, - }) +export function CatchNotFound(props: CatchNotFoundProps) { + return Vue.h(CatchNotFoundImpl, props) } export const DefaultGlobalNotFound = Vue.defineComponent({ diff --git a/packages/vue-router/src/useCanGoBack.ts b/packages/vue-router/src/useCanGoBack.ts index 2149584979..e5f1f88ad8 100644 --- a/packages/vue-router/src/useCanGoBack.ts +++ b/packages/vue-router/src/useCanGoBack.ts @@ -1,9 +1,9 @@ -import { useStore } from '@tanstack/vue-store' +import { useSelector } from './useSelector' import { useRouter } from './useRouter' export function useCanGoBack() { const router = useRouter() - return useStore( + return useSelector( router.stores.location, (location) => location.state.__TSR_index !== 0, ) diff --git a/packages/vue-router/src/useLocation.tsx b/packages/vue-router/src/useLocation.tsx index 2d9e4314a2..5791ce5cba 100644 --- a/packages/vue-router/src/useLocation.tsx +++ b/packages/vue-router/src/useLocation.tsx @@ -1,4 +1,4 @@ -import { useStore } from '@tanstack/vue-store' +import { useSelector } from './useSelector' import { useRouter } from './useRouter' import type { AnyRouter, @@ -25,7 +25,7 @@ export function useLocation< opts?: UseLocationBaseOptions, ): Vue.Ref> { const router = useRouter() - return useStore(router.stores.location, (location) => + return useSelector(router.stores.location, (location) => opts?.select ? opts.select(location) : location, ) as Vue.Ref> } diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index a02b5ae109..4918c8e2be 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -1,7 +1,7 @@ import * as Vue from 'vue' import { invariant } from '@tanstack/router-core' -import { useStore } from '@tanstack/vue-store' import { isServer } from '@tanstack/router-core/isServer' +import { useSelector } from './useSelector' import { routeIdContext } from './matchContext' import { useRouter } from './useRouter' import type { @@ -117,13 +117,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 = useSelector(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 = useSelector(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/src/useRouterState.tsx b/packages/vue-router/src/useRouterState.tsx index 13b6f85236..a010fa22a9 100644 --- a/packages/vue-router/src/useRouterState.tsx +++ b/packages/vue-router/src/useRouterState.tsx @@ -1,6 +1,6 @@ import * as Vue from 'vue' import { isServer } from '@tanstack/router-core/isServer' -import { useStore } from '@tanstack/vue-store' +import { useSelector } from './useSelector' import { useRouter } from './useRouter' import type { AnyRouter, @@ -50,7 +50,7 @@ export function useRouterState< > } - return useStore(router.stores.__store, (state) => { + return useSelector(router.stores.__store, (state) => { if (opts?.select) return opts.select(state) return state diff --git a/packages/vue-router/src/useSelector.ts b/packages/vue-router/src/useSelector.ts new file mode 100644 index 0000000000..d375a66035 --- /dev/null +++ b/packages/vue-router/src/useSelector.ts @@ -0,0 +1,52 @@ +import * as Vue from 'vue' +import { useSelector as useTanStackSelector } from '@tanstack/vue-store' +import type { UseSelectorOptions } from '@tanstack/vue-store' + +type StoreSource = { + get: () => T + subscribe: (listener: (value: T) => void) => { + unsubscribe: () => void + } +} + +type FunctionalSelectorState = { + scope: Vue.EffectScope +} + +const functionalSelectorStates = new WeakMap() + +export function useSelector>( + store: StoreSource, + selector: (state: NoInfer) => TSelected = (state) => + state as unknown as TSelected, + options?: UseSelectorOptions, +): Readonly> { + const select = () => useTanStackSelector(store, selector, options) + + if (Vue.getCurrentScope()) { + return select() + } + + const instance = Vue.getCurrentInstance() + if (!instance) { + return select() + } + + let state = functionalSelectorStates.get(instance) + if (!state) { + const functionalState = { scope: Vue.effectScope(true) } + state = functionalState + functionalSelectorStates.set(instance, functionalState) + + Vue.onBeforeUpdate(() => { + functionalState.scope.stop() + functionalState.scope = Vue.effectScope(true) + }, instance) + Vue.onUnmounted(() => { + functionalState.scope.stop() + functionalSelectorStates.delete(instance) + }, instance) + } + + return state.scope.run(select)! +} diff --git a/packages/vue-router/tests/not-found.test.tsx b/packages/vue-router/tests/not-found.test.tsx index 3cb4a78ec3..dffba3e05a 100644 --- a/packages/vue-router/tests/not-found.test.tsx +++ b/packages/vue-router/tests/not-found.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from 'vitest' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/vue' import { @@ -126,6 +126,36 @@ test.each([ }, ) +test('does not recreate not-found subscriptions when the location changes', async () => { + const rootRoute = createRootRoute({ + component: Outlet, + notFoundComponent: () => ( + Root Not Found + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => Index, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + const subscribe = vi.spyOn(router.stores.location, 'subscribe') + + render() + await router.load() + + expect(subscribe).toHaveBeenCalledOnce() + + await router.navigate({ to: '/missing-one' as any }) + await screen.findByTestId('root-not-found') + await router.navigate({ to: '/missing-two' as any }) + + expect(subscribe).toHaveBeenCalledOnce() +}) + test('defaultNotFoundComponent and notFoundComponent receives data props via spread operator', async () => { const isCustomData = (data: unknown): data is typeof customData => { return 'message' in (data as typeof customData) diff --git a/packages/vue-router/tests/useSelector.test.tsx b/packages/vue-router/tests/useSelector.test.tsx new file mode 100644 index 0000000000..92e6bd3b58 --- /dev/null +++ b/packages/vue-router/tests/useSelector.test.tsx @@ -0,0 +1,64 @@ +import * as Vue from 'vue' +import { render } from '@testing-library/vue' +import { expect, test, vi } from 'vitest' +import { useSelector } from '../src/useSelector' + +function createStore(initialValue: number) { + let value = initialValue + const listeners = new Set<(value: number) => void>() + const unsubscribe = vi.fn((listener: (value: number) => void) => { + listeners.delete(listener) + }) + const subscribe = vi.fn((listener: (value: number) => void) => { + listeners.add(listener) + return { unsubscribe: () => unsubscribe(listener) } + }) + + return { + store: { + get: () => value, + subscribe, + }, + setValue(nextValue: number) { + value = nextValue + listeners.forEach((listener) => listener(value)) + }, + listenerCount: () => listeners.size, + subscribe, + unsubscribe, + } +} + +test('replaces functional component subscriptions on rerender', async () => { + const source = createStore(0) + const label = Vue.ref('first') + const Component = () => { + const value = useSelector(source.store) + return
{`${label.value}:${value.value}`}
+ } + + const view = render(Component) + + expect(source.subscribe).toHaveBeenCalledOnce() + expect(source.listenerCount()).toBe(1) + + label.value = 'second' + await Vue.nextTick() + + expect(source.subscribe).toHaveBeenCalledTimes(2) + expect(source.unsubscribe).toHaveBeenCalledOnce() + expect(source.listenerCount()).toBe(1) + + source.setValue(1) + await Vue.nextTick() + + expect(view.getByText('second:1')).toBeInTheDocument() + expect(source.subscribe).toHaveBeenCalledTimes(3) + expect(source.unsubscribe).toHaveBeenCalledTimes(2) + expect(source.listenerCount()).toBe(1) + + view.unmount() + + expect(source.unsubscribe).toHaveBeenCalledTimes(3) + expect(source.listenerCount()).toBe(0) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 430cfcfaa5..c843f88cda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13398,8 +13398,8 @@ importers: specifier: workspace:* version: link:../history '@tanstack/react-store': - specifier: ^0.9.3 - version: 0.9.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^0.11.1 + version: 0.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/router-core': specifier: workspace:* version: link:../router-core @@ -13689,8 +13689,8 @@ importers: version: 1.6.2(seroval@1.6.2) devDependencies: '@tanstack/store': - specifier: ^0.9.3 - version: 0.9.3 + specifier: ^0.11.1 + version: 0.11.1 '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -14315,8 +14315,8 @@ importers: specifier: workspace:* version: link:../router-core '@tanstack/vue-store': - specifier: ^0.9.3 - version: 0.9.3(vue@3.5.25(typescript@7.0.2)) + specifier: ^0.11.1 + version: 0.11.1(vue@3.5.25(typescript@7.0.2)) '@vue/runtime-dom': specifier: ^3.5.25 version: 3.5.25 @@ -20219,11 +20219,11 @@ packages: peerDependencies: react: ^19.2.3 - '@tanstack/react-store@0.9.3': - resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + '@tanstack/react-store@0.11.1': + resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} peerDependencies: - react: ^19.2.3 - react-dom: ^19.2.3 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@tanstack/react-virtual@3.13.0': resolution: {integrity: sha512-CchF0NlLIowiM2GxtsoKBkXA4uqSnY2KvnXo+kyUFD4a4ll6+J0qzoRsUPMwXV/H26lRsxgJIr/YmjYum2oEjg==} @@ -20253,8 +20253,8 @@ packages: peerDependencies: solid-js: 1.9.12 - '@tanstack/store@0.9.3': - resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} '@tanstack/virtual-core@3.13.0': resolution: {integrity: sha512-NBKJP3OIdmZY3COJdWkSonr50FMVIi+aj5ZJ7hI/DTpEKg2RMfo/KvP8A3B/zOSpMgIe52B5E2yn7rryULzA6g==} @@ -20292,8 +20292,8 @@ packages: '@vue/composition-api': optional: true - '@tanstack/vue-store@0.9.3': - resolution: {integrity: sha512-YZb5SAR3f2kLt58Ip6gig2+z8vRAfSkJK30Bq7enZ7cG4epyygmRsbrrDMxvmoYSJu33CY5uJ6MvI74KGP0ZvQ==} + '@tanstack/vue-store@0.11.1': + resolution: {integrity: sha512-0YmYwbiCQKhzfFTLpHybjoAVUh3GkQVYGguvGTzk/0wx0oWuni60huvRs1RqqRLJH6nig3PTYuLuQQ+X5s05gA==} peerDependencies: '@vue/composition-api': ^1.2.1 vue: ^2.5.0 || ^3.0.0 @@ -33685,9 +33685,9 @@ snapshots: '@tanstack/query-core': 5.99.0 react: 19.2.3 - '@tanstack/react-store@0.9.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-store@0.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/store': 0.9.3 + '@tanstack/store': 0.11.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) @@ -33723,7 +33723,7 @@ snapshots: '@tanstack/virtual-core': 3.13.12 solid-js: 1.9.12 - '@tanstack/store@0.9.3': {} + '@tanstack/store@0.11.1': {} '@tanstack/virtual-core@3.13.0': {} @@ -33791,9 +33791,9 @@ snapshots: vue: 3.5.25(typescript@7.0.2) vue-demi: 0.14.10(vue@3.5.25(typescript@7.0.2)) - '@tanstack/vue-store@0.9.3(vue@3.5.25(typescript@7.0.2))': + '@tanstack/vue-store@0.11.1(vue@3.5.25(typescript@7.0.2))': dependencies: - '@tanstack/store': 0.9.3 + '@tanstack/store': 0.11.1 vue: 3.5.25(typescript@7.0.2) vue-demi: 0.14.10(vue@3.5.25(typescript@7.0.2)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aca73336f9..53aee7e62d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,9 @@ cleanupUnusedCatalogs: true minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - '@tanstack/react-store@0.11.1' + - '@tanstack/store@0.11.1' + - '@tanstack/vue-store@0.11.1' linkWorkspacePackages: true preferWorkspacePackages: true blockExoticSubdeps: true