Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 33 additions & 47 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}: {
Expand All @@ -47,24 +32,23 @@ 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!`,
)

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 (
<MatchView
router={router}
matchId={matchId}
resetKey={router.stores.loadedAt.state}
matchState={{
routeId: match.routeId as string,
routeId,
ssr: match.ssr,
_displayPending: match._displayPending,
parentRouteId,
Expand All @@ -73,29 +57,32 @@ 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
const match = useStore(matchStore, (value) => 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.byId.state[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.byId.state])
}, [match._displayPending, match.routeId, match.ssr, router.routesById])

return (
<MatchView
Expand Down Expand Up @@ -258,7 +245,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
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!`,
Expand Down Expand Up @@ -321,7 +308,11 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}

// 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 match = useStore(matchStore, (value) => value!)
const routeId = match.routeId as string
Expand Down Expand Up @@ -459,22 +450,17 @@ 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,
)

// eslint-disable-next-line react-hooks/rules-of-hooks
routeId = useStore(
parentMatchStore,
(match) => match?.routeId as string | 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
parentGlobalNotFound = useStore(
parentMatchStore,
(match) => 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) => {
Expand Down
47 changes: 22 additions & 25 deletions packages/react-router/src/useMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,23 @@ export function useMatch<
undefined,
)

if (isServer ?? router.isServer) {
const key = opts.from ?? nearestMatchId
const match = key
? opts.from
? router.stores.byRouteId.state[key]?.state
: router.stores.byId.state[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!'}`,
Expand All @@ -134,25 +143,13 @@ 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

// 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!'}`,
)

if (match === undefined) {
return undefined
}
Expand Down
3 changes: 1 addition & 2 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,8 @@ const triggerOnReady = (inner: InnerLoadContext): void | Promise<void> => {
}

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
})
}

Expand Down
38 changes: 25 additions & 13 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1400,7 +1400,15 @@ export class RouterCore<
: undefined

const matches = new Array<AnyRouteMatch>(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<string, AnyRouteMatch>()
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]!
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string>()
for (const s of this.stores.pendingMatchStoresById.values()) {
if (s.routeId) pendingRouteIds.add(s.routeId)
}
const activeRouteIds = new Set<string>()
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

Expand Down
Loading
Loading