Skip to content

fix(router-core): use routeId for lifecycle hooks to fix onStay when loaderDeps change#6769

Open
sleitor wants to merge 3 commits intoTanStack:mainfrom
sleitor:fix/router-lifecycle-use-routeid
Open

fix(router-core): use routeId for lifecycle hooks to fix onStay when loaderDeps change#6769
sleitor wants to merge 3 commits intoTanStack:mainfrom
sleitor:fix/router-lifecycle-use-routeid

Conversation

@sleitor
Copy link

@sleitor sleitor commented Feb 26, 2026

Summary

Fixes #6765

When loaderDeps change (e.g. from search param updates), the match.id changes because it includes the loaderDepsHash. The lifecycle hooks (onLeave/onEnter/onStay) were computing exitingMatches, enteringMatches, and stayingMatches using match.id, which caused a loaderDeps change on the same route to be treated as "leave + re-enter" instead of "stay".

Root Cause

match.id is composed as routeId + interpolatedPath + loaderDepsHash. When loaderDeps change (e.g. due to a search param update), the hash changes and match.id changes, making the old and new matches appear as different entries.

The loader's cause parameter already handles this correctly by using routeId:

// router.ts — loader cause uses routeId ✅
const previousMatch = previousMatchesByRouteId.get(route.id)
const cause = previousMatch ? 'stay' : 'enter'

But the lifecycle hooks were using match.id:

// router.ts — lifecycle hooks used match.id ❌
exitingMatches = previousMatches.filter(
  (match) => !newMatches.some((d) => d.id === match.id),
)

Fix

Use match.routeId (consistent with the loader's cause param logic) when computing exitingMatches, enteringMatches, and stayingMatches.

Test

Added a test case in callbacks.test.ts that verifies onStay fires (not onLeave/onEnter) when navigating within the same route and only loaderDeps-affecting search params change.

Summary by CodeRabbit

  • Bug Fixes

    • Improved route transition handling so lifecycle callbacks (enter/leave/stay) fire reliably even when route parameters or loader-driven dependencies change, preventing unnecessary onLeave/onEnter during param-only updates.
  • Tests

    • Added tests verifying callback behavior when loader-related dependencies update via URL search params, ensuring onStay triggers instead of onEnter/onLeave.

…loaderDeps

When loaderDeps change (e.g. from search param updates), the match.id
changes because it includes the loaderDepsHash. This caused the lifecycle
hooks (onLeave/onEnter/onStay) to compute exiting/entering/staying matches
using match.id, which treated a loaderDeps change on the same route as
'leave + re-enter' instead of 'stay'.

Fix: use match.routeId (consistent with loader's cause param logic) when
computing exitingMatches, enteringMatches, and stayingMatches so that only
an actual route change triggers onLeave/onEnter.

Fixes TanStack#6765
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 97ca146 and c8be9de.

📒 Files selected for processing (1)
  • packages/router-core/src/router.ts

📝 Walkthrough

Walkthrough

Introduces separate identity groups for caching and lifecycle hooks: cache identity (id + path + loaderDeps) remains, while lifecycle-hook identity uses routeId so onEnter/onLeave/onStay are driven by route identity independent of loaderDeps changes. Tests added to verify onStay on search-param loaderDeps updates.

Changes

Cohort / File(s) Summary
Router core
packages/router-core/src/router.ts
Adds lifecycle-hook identity groups (hookExitingMatches, hookEnteringMatches, hookStayingMatches) distinct from cache identity matches; lifecycle dispatch now uses hook-based matches while cache logic (exiting/entering/staying by id) is preserved.
Lifecycle tests
packages/router-core/tests/callbacks.test.ts
Adds setupWithLoaderDeps helper and tests asserting onStay fires (instead of onLeave/onEnter) when loaderDeps change via search-param updates.
Manifest / metadata
packages/router-core/manifest.json
Minor lines added/removed reflecting the code/test changes.

Sequence Diagram(s)

sequenceDiagram
    participant Router
    participant PrevMatches
    participant NewMatches
    participant LifecycleDispatcher
    participant Cache

    Router->>PrevMatches: load previous matches (with id, routeId)
    Router->>NewMatches: compute new matches (with id, routeId)
    Router->>Cache: compute cache identity matches (exiting/entering/staying by id)
    Router->>LifecycleDispatcher: compute hook identity matches (exiting/entering/staying by routeId)
    LifecycleDispatcher->>Router: dispatch onLeave/onEnter/onStay based on routeId groups
    Cache->>Router: perform caching based on id-based groups
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel
  • Sheraff

Poem

🐰 I hopped through match and hash and trail,
Found routeIds steady where ids would flail,
Now onStay hums when params rearrange,
The hops stay true — delightfully strange! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: using routeId for lifecycle hooks to address the onStay issue when loaderDeps change.
Linked Issues check ✅ Passed The PR implementation fully addresses all coding requirements from issue #6765: using routeId instead of match.id for lifecycle hook identity matching, ensuring onStay fires correctly when only loaderDeps change.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the identified lifecycle hook issue; router.ts modifications separate cache and lifecycle identity semantics, and test additions verify the fix without introducing unrelated functionality.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/router-core/tests/callbacks.test.ts (1)

138-161: Consider asserting onStay payload semantics, not only counts.

Counts are good; adding cause: 'stay' assertions would make this regression test harder to accidentally bypass.

Suggested assertion hardening
       await router.navigate({ to: '/foo', search: { page: '2' } })
       expect(onEnter).toHaveBeenCalledTimes(1) // no new onEnter
       expect(onLeave).toHaveBeenCalledTimes(0) // no onLeave
       expect(onStay).toHaveBeenCalledTimes(1) // onStay fires
+      expect(onStay).toHaveBeenNthCalledWith(
+        1,
+        expect.objectContaining({
+          cause: 'stay',
+          search: expect.objectContaining({ page: '2' }),
+        }),
+      )

       // Update again — onStay fires again
       await router.navigate({ to: '/foo', search: { page: '3' } })
       expect(onEnter).toHaveBeenCalledTimes(1)
       expect(onLeave).toHaveBeenCalledTimes(0)
       expect(onStay).toHaveBeenCalledTimes(2)
+      expect(onStay).toHaveBeenNthCalledWith(
+        2,
+        expect.objectContaining({
+          cause: 'stay',
+          search: expect.objectContaining({ page: '3' }),
+        }),
+      )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/tests/callbacks.test.ts` around lines 138 - 161, The
test currently only checks invocation counts for onStay; strengthen it by
asserting the payload semantics include cause: 'stay' when loaderDeps change:
after calling setupWithLoaderDeps and performing router.navigate to the same
path with updated search (the second and third navigate calls), inspect the
arguments passed to the onStay mock (onStay.mock.calls) and add expectations
that the event object passed to onStay contains a property cause with value
'stay' (and optionally that other expected fields like the new search are
present) so the assertions validate both invocation and correct payload
semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/router-core/tests/callbacks.test.ts`:
- Around line 138-161: The test currently only checks invocation counts for
onStay; strengthen it by asserting the payload semantics include cause: 'stay'
when loaderDeps change: after calling setupWithLoaderDeps and performing
router.navigate to the same path with updated search (the second and third
navigate calls), inspect the arguments passed to the onStay mock
(onStay.mock.calls) and add expectations that the event object passed to onStay
contains a property cause with value 'stay' (and optionally that other expected
fields like the new search are present) so the assertions validate both
invocation and correct payload semantics.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c4027b6 and a8f88f2.

📒 Files selected for processing (2)
  • packages/router-core/src/router.ts
  • packages/router-core/tests/callbacks.test.ts

@nx-cloud
Copy link

nx-cloud bot commented Feb 26, 2026

View your CI Pipeline Execution ↗ for commit c8be9de

Command Status Duration Result
nx run tanstack-router-e2e-bundle-size:build --... ✅ Succeeded 1m 27s View ↗

☁️ Nx Cloud last updated this comment at 2026-02-27 09:56:12 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 26, 2026

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@6769

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@6769

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@6769

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@6769

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@6769

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@6769

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@6769

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@6769

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@6769

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@6769

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@6769

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@6769

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@6769

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@6769

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@6769

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@6769

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@6769

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@6769

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@6769

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@6769

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@6769

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@6769

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@6769

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@6769

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@6769

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@6769

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@6769

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@6769

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@6769

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@6769

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@6769

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@6769

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@6769

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@6769

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@6769

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@6769

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@6769

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@6769

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@6769

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@6769

commit: e17d44a

@Sheraff
Copy link
Contributor

Sheraff commented Feb 26, 2026

@schiller-manuel do you agree that when params change (e.g. nav from /a/123 to /a/456) we want to trigger the onStay callback for the /a/$id route? The PR makes sense to me but this is a slight semantic change. The docs aren't exactly super specific about this: they describe the stay/enter/leave concepts as something like "a route is matched after not being matched in the previous location".

To me this change makes sense, just asking for a second opinion.

@Sheraff
Copy link
Contributor

Sheraff commented Feb 26, 2026

@sleitor don't worry about the "Bundle Size" job failing, this is unrelated to your PR, i'm working on it.

@sleitor
Copy link
Author

sleitor commented Feb 26, 2026

Thanks for the heads up @Sheraff, appreciated!

Comment on lines 2418 to 2431
exitingMatches = previousMatches.filter(
(match) => !newMatches.some((d) => d.id === match.id),
(match) =>
!newMatches.some((d) => d.routeId === match.routeId),
)
enteringMatches = newMatches.filter(
(match) =>
!previousMatches.some((d) => d.id === match.id),
!previousMatches.some(
(d) => d.routeId === match.routeId,
),
)
stayingMatches = newMatches.filter((match) =>
previousMatches.some((d) => d.id === match.id),
previousMatches.some(
(d) => d.routeId === match.routeId,
),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change might mess up the caching behavior: a cache entry should be route id + params + deps (what we have currently). But I may agree that as far as lifecycle events are concerned, it may not exactly correlate with the cache changes.

I had codex whip up a quick test showing the expected cache behavior #6772. This test works on main and should fail w/ your PR.

Now we need to decide the semantics of those lifecycle events. Are they tied to the cache entries' lifecycles? Or are they more tied to the route itself (i.e. routeId)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great distinction to make. I think cache entries and lifecycle events serve different purposes and shouldn't necessarily share the same identity:

  • Cache entries are about data identity — route + params + deps makes sense here, because different params or deps mean different data
  • Lifecycle hooks are about route presence — onEnter/onStay/onLeave describe whether a route is appearing, persisting, or disappearing in the matched tree

From a user perspective, if I navigate from /posts/123 to /posts/456, I'd expect onStay for the /posts/$id route (the route is still there, just showing different data), not onLeave+onEnter. The cache correctly gets a new entry for 456, but the route itself didn't leave.

That said, I'm happy to defer to whatever semantics you and @schiller-manuel decide are correct here. If the conclusion is that lifecycle events should follow cache identity, I can revert and we can look for a different fix for #6765.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the semantics as you described them are correct. so we need to fix the caching regression that @Sheraff pointed out and then we can merge this

…ook identity (routeId)

Cache entries use match.id (routeId + params + loaderDeps) so navigating between
different params/deps correctly caches the previous match. Lifecycle hooks (onEnter/
onStay/onLeave) use routeId to track route *presence* in the matched tree — so
navigating /posts/123 → /posts/456 fires onStay for /posts/$id, not onLeave+onEnter.

Addresses caching regression spotted by @Sheraff in PR TanStack#6772 test.
@sleitor
Copy link
Author

sleitor commented Feb 27, 2026

Fixed in commit c8be9de — the caching regression is resolved by separating the two concerns:

  • Cache identity (//) still uses match.id (routeId + params + loaderDeps), so navigating /foo?page=1/foo?page=2 correctly moves the page=1 entry into cachedMatches
  • Lifecycle-hook identity uses routeId only via new hookExitingMatches/hookEnteringMatches/hookStayingMatches variables, so onStay fires for same-route param changes

Sheraff's test from #6772 should now pass with this change — the previous match gets cached and reused within staleTime as expected.

Copy link
Member

@SeanCassiere SeanCassiere left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In terms of the events being fired, could we check to see whether there's testing in-place, to confirm that the router instance still emits the onBeforeNavigate and onResolved events on navigate?

In particular, to make sure we don't break Sentry's tracing integration - https://github.com/getsentry/sentry-javascript/blob/develop/packages/react/src/tanstackrouter.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

onLeave/onEnter fire instead of onStay when loaderDeps change from search param updates

4 participants