Skip to content

Commit 442ada1

Browse files
authored
fix(router-core): use routeId for lifecycle hooks to fix onStay when loaderDeps change (#6769)
1 parent d958ef1 commit 442ada1

File tree

2 files changed

+209
-10
lines changed

2 files changed

+209
-10
lines changed

packages/router-core/src/router.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2406,9 +2406,16 @@ export class RouterCore<
24062406

24072407
// Commit the pending matches. If a previous match was
24082408
// removed, place it in the cachedMatches
2409+
//
2410+
// exitingMatches uses match.id (routeId + params + loaderDeps) so
2411+
// navigating /foo?page=1 → /foo?page=2 correctly caches the page=1 entry.
24092412
let exitingMatches: Array<AnyRouteMatch> = []
2410-
let enteringMatches: Array<AnyRouteMatch> = []
2411-
let stayingMatches: Array<AnyRouteMatch> = []
2413+
2414+
// Lifecycle-hook identity uses routeId only so that navigating between
2415+
// different params/deps of the same route fires onStay (not onLeave+onEnter).
2416+
let hookExitingMatches: Array<AnyRouteMatch> = []
2417+
let hookEnteringMatches: Array<AnyRouteMatch> = []
2418+
let hookStayingMatches: Array<AnyRouteMatch> = []
24122419

24132420
batch(() => {
24142421
this.__store.setState((s) => {
@@ -2418,12 +2425,22 @@ export class RouterCore<
24182425
exitingMatches = previousMatches.filter(
24192426
(match) => !newMatches.some((d) => d.id === match.id),
24202427
)
2421-
enteringMatches = newMatches.filter(
2428+
2429+
// Lifecycle-hook identity: routeId only (route presence in tree)
2430+
hookExitingMatches = previousMatches.filter(
2431+
(match) =>
2432+
!newMatches.some((d) => d.routeId === match.routeId),
2433+
)
2434+
hookEnteringMatches = newMatches.filter(
24222435
(match) =>
2423-
!previousMatches.some((d) => d.id === match.id),
2436+
!previousMatches.some(
2437+
(d) => d.routeId === match.routeId,
2438+
),
24242439
)
2425-
stayingMatches = newMatches.filter((match) =>
2426-
previousMatches.some((d) => d.id === match.id),
2440+
hookStayingMatches = newMatches.filter((match) =>
2441+
previousMatches.some(
2442+
(d) => d.routeId === match.routeId,
2443+
),
24272444
)
24282445

24292446
return {
@@ -2455,9 +2472,9 @@ export class RouterCore<
24552472
//
24562473
;(
24572474
[
2458-
[exitingMatches, 'onLeave'],
2459-
[enteringMatches, 'onEnter'],
2460-
[stayingMatches, 'onStay'],
2475+
[hookExitingMatches, 'onLeave'],
2476+
[hookEnteringMatches, 'onEnter'],
2477+
[hookStayingMatches, 'onStay'],
24612478
] as const
24622479
).forEach(([matches, hook]) => {
24632480
matches.forEach((match) => {

packages/router-core/tests/callbacks.test.ts

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { describe, expect, it, test, vi } from 'vitest'
22
import { createMemoryHistory } from '@tanstack/history'
33
import { BaseRootRoute, BaseRoute, RouterCore } from '../src'
44

@@ -39,6 +39,35 @@ describe('callbacks', () => {
3939

4040
return router
4141
}
42+
43+
const createFooRouter = (
44+
opts: {
45+
onEnter?: () => void
46+
onLeave?: () => void
47+
onStay?: () => void
48+
loader?: () => unknown
49+
staleTime?: number
50+
} = {},
51+
) => {
52+
const rootRoute = new BaseRootRoute({})
53+
const fooRoute = new BaseRoute({
54+
getParentRoute: () => rootRoute,
55+
path: '/foo',
56+
loaderDeps: ({ search }: { search: Record<string, unknown> }) => ({
57+
page: search['page'],
58+
}),
59+
onEnter: opts.onEnter,
60+
onLeave: opts.onLeave,
61+
onStay: opts.onStay,
62+
loader: opts.loader,
63+
staleTime: opts.staleTime,
64+
gcTime: opts.staleTime,
65+
})
66+
return new RouterCore({
67+
routeTree: rootRoute.addChildren([fooRoute]),
68+
history: createMemoryHistory(),
69+
})
70+
}
4271
describe('onEnter', () => {
4372
it('runs on navigate to a new route', async () => {
4473
const onEnter = vi.fn()
@@ -102,5 +131,158 @@ describe('callbacks', () => {
102131
expect.objectContaining({ id: '/foo/foo', search: { foo: 'quux' } }),
103132
)
104133
})
134+
135+
it('runs instead of onLeave/onEnter when loaderDeps change from search param updates', async () => {
136+
const onEnter = vi.fn()
137+
const onLeave = vi.fn()
138+
const onStay = vi.fn()
139+
const router = createFooRouter({ onEnter, onLeave, onStay })
140+
141+
// Navigate to foo — onEnter should fire
142+
await router.navigate({ to: '/foo', search: { page: '1' } })
143+
expect(onEnter).toHaveBeenCalledTimes(1)
144+
expect(onLeave).toHaveBeenCalledTimes(0)
145+
expect(onStay).toHaveBeenCalledTimes(0)
146+
147+
// Update search param that's in loaderDeps — onStay should fire, not onLeave+onEnter
148+
await router.navigate({ to: '/foo', search: { page: '2' } })
149+
expect(onEnter).toHaveBeenCalledTimes(1) // no new onEnter
150+
expect(onLeave).toHaveBeenCalledTimes(0) // no onLeave
151+
expect(onStay).toHaveBeenCalledTimes(1) // onStay fires
152+
153+
// Update again — onStay fires again
154+
await router.navigate({ to: '/foo', search: { page: '3' } })
155+
expect(onEnter).toHaveBeenCalledTimes(1)
156+
expect(onLeave).toHaveBeenCalledTimes(0)
157+
expect(onStay).toHaveBeenCalledTimes(2)
158+
})
159+
})
160+
161+
// Regression tests: switching lifecycle hooks to use routeId must NOT break
162+
// match-level caching, which still relies on match.id (routeId + params + loaderDeps).
163+
describe('same-route match caching', () => {
164+
const setupWithPathParams = ({
165+
loader,
166+
staleTime,
167+
}: {
168+
loader?: () => unknown
169+
staleTime?: number
170+
}) => {
171+
const rootRoute = new BaseRootRoute({})
172+
const postRoute = new BaseRoute({
173+
getParentRoute: () => rootRoute,
174+
path: '/posts/$postId',
175+
loader,
176+
staleTime,
177+
gcTime: staleTime,
178+
})
179+
const routeTree = rootRoute.addChildren([postRoute])
180+
return new RouterCore({
181+
routeTree,
182+
history: createMemoryHistory(),
183+
})
184+
}
185+
186+
test('keeps previous loaderDeps variant cached and reuses it within staleTime', async () => {
187+
const loader = vi.fn()
188+
const router = createFooRouter({ loader, staleTime: 60_000 })
189+
190+
await router.navigate({ to: '/foo', search: { page: '1' } })
191+
const page1MatchId = router.state.matches.find(
192+
(d) => d.routeId === '/foo',
193+
)?.id
194+
expect(page1MatchId).toBeDefined()
195+
196+
await router.navigate({ to: '/foo', search: { page: '2' } })
197+
const page2MatchId = router.state.matches.find(
198+
(d) => d.routeId === '/foo',
199+
)?.id
200+
expect(page2MatchId).toBeDefined()
201+
expect(page2MatchId).not.toBe(page1MatchId)
202+
expect(
203+
router.state.cachedMatches.some((d) => d.id === page1MatchId),
204+
).toBe(true)
205+
206+
await router.navigate({ to: '/foo', search: { page: '1' } })
207+
expect(loader).toHaveBeenCalledTimes(2)
208+
expect(router.state.matches.some((d) => d.id === page1MatchId)).toBe(true)
209+
})
210+
211+
test('keeps previous params variant cached and reuses it within staleTime', async () => {
212+
const loader = vi.fn()
213+
const router = setupWithPathParams({ loader, staleTime: 60_000 })
214+
215+
await router.navigate({ to: '/posts/$postId', params: { postId: '1' } })
216+
const post1MatchId = router.state.matches.find(
217+
(d) => d.routeId === '/posts/$postId',
218+
)?.id
219+
expect(post1MatchId).toBeDefined()
220+
221+
await router.navigate({ to: '/posts/$postId', params: { postId: '2' } })
222+
const post2MatchId = router.state.matches.find(
223+
(d) => d.routeId === '/posts/$postId',
224+
)?.id
225+
expect(post2MatchId).toBeDefined()
226+
expect(post2MatchId).not.toBe(post1MatchId)
227+
expect(
228+
router.state.cachedMatches.some((d) => d.id === post1MatchId),
229+
).toBe(true)
230+
231+
await router.navigate({ to: '/posts/$postId', params: { postId: '1' } })
232+
expect(loader).toHaveBeenCalledTimes(2)
233+
expect(router.state.matches.some((d) => d.id === post1MatchId)).toBe(true)
234+
})
235+
})
236+
237+
// Verify that router-level subscription events still fire correctly.
238+
// These are used by integrations like Sentry's TanStack Router instrumentation
239+
// (https://github.com/getsentry/sentry-javascript/blob/develop/packages/react/src/tanstackrouter.ts)
240+
// to track page transitions.
241+
describe('onBeforeNavigate event', () => {
242+
it('fires when navigating to a new route', async () => {
243+
const router = setup({})
244+
const onBeforeNavigate = vi.fn()
245+
router.subscribe('onBeforeNavigate', onBeforeNavigate)
246+
247+
await router.navigate({ to: '/foo' })
248+
expect(onBeforeNavigate).toHaveBeenCalledTimes(1)
249+
expect(onBeforeNavigate).toHaveBeenCalledWith(
250+
expect.objectContaining({
251+
type: 'onBeforeNavigate',
252+
pathChanged: true,
253+
}),
254+
)
255+
})
256+
257+
it('fires on every navigation including same-route loaderDeps changes', async () => {
258+
const router = createFooRouter({})
259+
const onBeforeNavigate = vi.fn()
260+
router.subscribe('onBeforeNavigate', onBeforeNavigate)
261+
262+
await router.navigate({ to: '/foo', search: { page: '1' } })
263+
expect(onBeforeNavigate).toHaveBeenCalledTimes(1)
264+
265+
// loaderDeps change — same route, different params
266+
await router.navigate({ to: '/foo', search: { page: '2' } })
267+
expect(onBeforeNavigate).toHaveBeenCalledTimes(2)
268+
})
269+
270+
it('includes toLocation and pathChanged in the event', async () => {
271+
const router = setup({})
272+
const events: Array<{ to: string; pathChanged: boolean }> = []
273+
router.subscribe('onBeforeNavigate', (e) => {
274+
events.push({
275+
to: e.toLocation.pathname,
276+
pathChanged: e.pathChanged,
277+
})
278+
})
279+
280+
await router.navigate({ to: '/foo' })
281+
await router.navigate({ to: '/bar' })
282+
283+
expect(events).toHaveLength(2)
284+
expect(events[0]).toMatchObject({ to: '/foo', pathChanged: true })
285+
expect(events[1]).toMatchObject({ to: '/bar', pathChanged: true })
286+
})
105287
})
106288
})

0 commit comments

Comments
 (0)