From d0afeb1c3faed494b87f2f2a228ea3ac81e10b79 Mon Sep 17 00:00:00 2001 From: Chiheb Bencheikh Date: Mon, 13 Jul 2026 19:14:25 +0100 Subject: [PATCH] fix(router-core): fire onBeforeNavigate after beforeLoad redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `onBeforeNavigate` emission was gated on `stores.redirect` being unset, but that store was populated whenever `beforeLoad` threw `redirect(...)` and never cleared. Every navigation after the first in-`beforeLoad` redirect silently skipped `onBeforeNavigate` — the neighboring `onBeforeLoad` emission has no such gate. Remove the gate so `onBeforeNavigate` matches `onBeforeLoad` and fires on every navigation, including navigations that follow a redirect. Fixes #3920. --- .../fix-on-before-navigate-after-redirect.md | 11 ++++ packages/router-core/src/router.ts | 10 ++-- packages/router-core/tests/callbacks.test.ts | 52 ++++++++++++++++++- 3 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-on-before-navigate-after-redirect.md diff --git a/.changeset/fix-on-before-navigate-after-redirect.md b/.changeset/fix-on-before-navigate-after-redirect.md new file mode 100644 index 0000000000..0e7e378935 --- /dev/null +++ b/.changeset/fix-on-before-navigate-after-redirect.md @@ -0,0 +1,11 @@ +--- +'@tanstack/router-core': patch +--- + +fix(router-core): fire `onBeforeNavigate` on every navigation, including those following a `beforeLoad` `redirect(...)` + +`onBeforeNavigate` was gated on `stores.redirect` being unset, but that store was populated whenever `beforeLoad` threw `redirect(...)` and never cleared. As a result, every navigation after the first in-`beforeLoad` redirect silently skipped the `onBeforeNavigate` emission (`onBeforeLoad` next to it never had this gate). This broke Sentry's TanStack Router tracing integration and any userland pattern (e.g. form-state persistence) that hooks into `onBeforeNavigate`. + +The gate is removed so `onBeforeNavigate` now matches `onBeforeLoad` and fires on every navigation. + +Fixes #3920. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..fa25337d0c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2483,12 +2483,10 @@ export class RouterCore< const prevLocation = this.stores.resolvedLocation.get() const locationChangeInfo = getLocationChangeInfo(next, prevLocation) - if (!this.stores.redirect.get()) { - this.emit({ - type: 'onBeforeNavigate', - ...locationChangeInfo, - }) - } + this.emit({ + type: 'onBeforeNavigate', + ...locationChangeInfo, + }) this.emit({ type: 'onBeforeLoad', diff --git a/packages/router-core/tests/callbacks.test.ts b/packages/router-core/tests/callbacks.test.ts index 1673626f7e..49dfd44df5 100644 --- a/packages/router-core/tests/callbacks.test.ts +++ b/packages/router-core/tests/callbacks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute } from '../src' +import { BaseRootRoute, BaseRoute, redirect } from '../src' import { createTestRouter } from './routerTestUtils' describe('callbacks', () => { @@ -285,5 +285,55 @@ describe('callbacks', () => { expect(events[0]).toMatchObject({ to: '/foo', pathChanged: true }) expect(events[1]).toMatchObject({ to: '/bar', pathChanged: true }) }) + + // Regression test for https://github.com/TanStack/router/issues/3920 + // After a `beforeLoad` throws `redirect(...)`, `stores.redirect` was left + // populated forever, so the `!stores.redirect.get()` gate around the + // `onBeforeNavigate` emission suppressed the event for every subsequent + // navigation. This broke Sentry's TanStack Router tracing integration and + // any userland pattern (e.g. form-state persistence) that hooks into + // `onBeforeNavigate`. + it('fires on subsequent navigations after a redirect in beforeLoad (#3920)', async () => { + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: () => { + throw redirect({ to: '/bar' }) + }, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const bazRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/baz', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute, bazRoute]), + history: createMemoryHistory(), + }) + + const onBeforeNavigate = vi.fn() + router.subscribe('onBeforeNavigate', onBeforeNavigate) + + // First navigation: /foo triggers a redirect to /bar in beforeLoad. + await router.navigate({ to: '/foo' }) + expect(onBeforeNavigate).toHaveBeenCalled() + const callsAfterRedirect = onBeforeNavigate.mock.calls.length + + // Second, unrelated navigation must still emit onBeforeNavigate. + await router.navigate({ to: '/baz' }) + expect(onBeforeNavigate.mock.calls.length).toBeGreaterThan( + callsAfterRedirect, + ) + expect(onBeforeNavigate).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'onBeforeNavigate', + pathChanged: true, + }), + ) + }) }) })