Skip to content

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

@Davit-000

Description

@Davit-000

Which project does this relate to?

Router

Describe the bug

The onLeave, onEnter, and onStay route lifecycle hooks use match.id to determine whether a route was entered, stayed, or left. However, match.id includes the loaderDepsHash, which means any change to loaderDeps (e.g. from search param updates) causes the match ID to change. This results in:

  • onLeave firing on the old match (old deps hash)
  • onEnter firing on the new match (new deps hash)
  • onStay never firing

This is inconsistent with the loader's cause parameter, which correctly uses routeId (not match.id) to determine "stay" vs "enter":

// router.js — loader cause uses routeId ✅
const previousMatch = previousMatchesByRouteId.get(route.id);
const cause = previousMatch ? "stay" : "enter";
// router.js — lifecycle hooks use match.id ❌
exitingMatches = previousMatches.filter(
  (match) => !newMatches.some((d) => d.id === match.id)
);
enteringMatches = newMatches.filter(
  (match) => !previousMatches.some((d) => d.id === match.id)
);
stayingMatches = newMatches.filter(
  (match) => previousMatches.some((d) => d.id === match.id)
);

The match ID is computed as route.id + interpolatedPath + loaderDepsHash, so when loaderDeps changes, the lifecycle hooks treat the same route as "left and re-entered" rather than "stayed".

Your Example Website or App

https://stackblitz.com/edit/github-1bd7mwws?file=src%2Froutes%2Findex.tsx

Steps to Reproduce the Bug or Issue

  1. Create a route with loaderDeps that depends on search params:
const DashboardRoute = createRoute({
  path: '/dashboard',
  validateSearch: DashboardSearchSchema,
  loaderDeps: ({ search }) => search,
  loader: async ({ context, deps }) => {
    await context.queryClient.ensureQueryData(getDataQueryOptions(deps));
  },
  onLeave: () => console.log('onLeave'),
  onStay: () => console.log('onStay'),
  onEnter: () => console.log('onEnter'),
  component: DashboardComponent,
});
  1. Navigate to /dashboard
  2. Observe: onEnter logs (correct)
  3. Change a search param via navigate({ search: (s) => ({ ...s, page: 2 }) })
  4. Observe: onLeave and onEnter log instead of onStay

Expected behavior

When navigating within the same route and only search params change:

  • onStay should fire (the route is still matched, just with different deps)
  • onLeave should not fire (the user has not left the route)
  • onEnter should not fire (the user was already on this route)

This would be consistent with the loader's cause parameter which correctly reports "stay" in this scenario.

Screenshots or Videos

No response

Platform

  • Router Version: 1.163.2
  • OS: macOS
  • Browser: Chrome
  • Bundler: Vite

Additional context

The practical impact is that onLeave cannot be used for cleanup logic that should only run when truly navigating away (e.g. invalidating TanStack Query caches). The workaround is to use a useEffect cleanup in the route component instead, but this defeats the purpose of having route-level lifecycle hooks.

The fix would likely involve using routeId instead of match.id when categorizing matches into exitingMatches, enteringMatches, and stayingMatches — the same approach already used for the loader's cause parameter.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions