From 1769f5f94fcc5a7438399a8472f2f4f6cf5c6960 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 17 May 2026 13:54:45 +0200 Subject: [PATCH] fix(router-core): keep latest load alive through match commit A router load now records its controlled load promise as latest before the transition body starts. This gives the load a stable identity for the entire navigation, including the synchronous part of startTransition. Pass that identity into loadMatches so stale loads cannot call onReady and promote their pending matches after a newer load has started. Also guard the onReady commit itself before scheduling the view-transition update and again inside the update callback, so a load that becomes stale while waiting for the view transition cannot mutate the active match stores. Add a local commitPromise around the pending-to-active match commit. The router's startViewTransition wrapper is fire-and-forget, so without this latch onReady could resolve, loadMatches could finish, and load() could clear latestLoadPromise before the view-transition update callback actually committed the matches. React can then observe a stale pending/redirected match without an in-flight load promise to suspend on, which is the blank-render race reproduced by the issue-7120 e2e test. Only the latest load now writes global redirect/status state, resolves the navigation commit promise, and clears latestLoadPromise. Stale loads still resolve their own load promise so callers do not hang, but they cannot complete the router-level commit for a newer navigation. Also make load() resolve only the commitLocationPromise that belonged to that specific load. Previously, load() resolved this.commitLocationPromise at completion time. That field is mutable and can be replaced by a later navigate()/commitLocation() before the older load finishes. In most navigation paths latestLoadPromise keeps that safe, because the newer navigation also starts a newer load. But an async blocker can create a window where a newer commitLocationPromise has already been installed while its corresponding load has not started yet. In that window, the older load could resolve the newer navigation's promise early. The caller awaiting navigate() would observe the navigation as complete even though the blocker had not released and the target route had not loaded. Capture this.commitLocationPromise when load() starts, resolve only that captured promise, and clear the router field only if it still points at the same promise. This preserves the ownership chain between commitLocation() and the load that is actually completing, while still allowing newer navigations to replace the global commit promise safely. Add a router-core regression test where an onEnter callback starts a second navigation that is held by an async blocker. Without this fix, the second navigate() promise resolves before the blocker is released; with the fix, it stays pending until the blocked navigation actually completes. --- e2e/react-router/issue-7120/index.html | 12 ++ e2e/react-router/issue-7120/package.json | 26 ++++ .../issue-7120/playwright.config.ts | 27 ++++ e2e/react-router/issue-7120/src/main.tsx | 67 +++++++++ .../issue-7120/src/posts.lazy.tsx | 17 +++ e2e/react-router/issue-7120/src/styles.css | 7 + .../issue-7120/tests/issue-7120.repro.spec.ts | 18 +++ e2e/react-router/issue-7120/tsconfig.json | 15 ++ e2e/react-router/issue-7120/vite.config.js | 6 + packages/react-router/src/Match.tsx | 11 +- .../store-updates-during-navigation.test.tsx | 4 +- packages/router-core/src/load-matches.ts | 73 +++++---- packages/router-core/src/router.ts | 139 ++++++++++-------- packages/router-core/tests/callbacks.test.ts | 59 ++++++++ .../store-updates-during-navigation.test.tsx | 4 +- .../store-updates-during-navigation.test.tsx | 2 +- pnpm-lock.yaml | 31 ++++ 17 files changed, 424 insertions(+), 94 deletions(-) create mode 100644 e2e/react-router/issue-7120/index.html create mode 100644 e2e/react-router/issue-7120/package.json create mode 100644 e2e/react-router/issue-7120/playwright.config.ts create mode 100644 e2e/react-router/issue-7120/src/main.tsx create mode 100644 e2e/react-router/issue-7120/src/posts.lazy.tsx create mode 100644 e2e/react-router/issue-7120/src/styles.css create mode 100644 e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts create mode 100644 e2e/react-router/issue-7120/tsconfig.json create mode 100644 e2e/react-router/issue-7120/vite.config.js diff --git a/e2e/react-router/issue-7120/index.html b/e2e/react-router/issue-7120/index.html new file mode 100644 index 0000000000..de92cad2a3 --- /dev/null +++ b/e2e/react-router/issue-7120/index.html @@ -0,0 +1,12 @@ + + + + + + Issue 7120 + + +
+ + + diff --git a/e2e/react-router/issue-7120/package.json b/e2e/react-router/issue-7120/package.json new file mode 100644 index 0000000000..c379ca791c --- /dev/null +++ b/e2e/react-router/issue-7120/package.json @@ -0,0 +1,26 @@ +{ + "name": "tanstack-router-e2e-react-issue-7120", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "dev:e2e": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "vite", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + } +} diff --git a/e2e/react-router/issue-7120/playwright.config.ts b/e2e/react-router/issue-7120/playwright.config.ts new file mode 100644 index 0000000000..6ba60eaff1 --- /dev/null +++ b/e2e/react-router/issue-7120/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + use: { + baseURL, + }, + webServer: { + command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} pnpm build && pnpm preview --port ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-router/issue-7120/src/main.tsx b/e2e/react-router/issue-7120/src/main.tsx new file mode 100644 index 0000000000..473e8e1d75 --- /dev/null +++ b/e2e/react-router/issue-7120/src/main.tsx @@ -0,0 +1,67 @@ +import ReactDOM from 'react-dom/client' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + redirect, +} from '@tanstack/react-router' +import './styles.css' + +const posts = [ + { + id: '1', + title: + 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit', + }, +] + +const rootRoute = createRootRoute({ + component: () => , + pendingMs: 0, + pendingComponent: () =>
loading
, + beforeLoad: async ({ matches }) => { + if (matches.find((match) => match.routeId === '/posts')) { + return + } + + await new Promise((resolve) => setTimeout(resolve, 1000)) + throw redirect({ to: '/posts' }) + }, +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, +}) + +const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + loader: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return posts + }, +}).lazy(() => import('./posts.lazy').then((d) => d.Route)) + +const routeTree = rootRoute.addChildren([indexRoute, postsRoute]) + +const router = createRouter({ + routeTree, + defaultViewTransition: true, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app')! + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + root.render() +} diff --git a/e2e/react-router/issue-7120/src/posts.lazy.tsx b/e2e/react-router/issue-7120/src/posts.lazy.tsx new file mode 100644 index 0000000000..c902ed7cba --- /dev/null +++ b/e2e/react-router/issue-7120/src/posts.lazy.tsx @@ -0,0 +1,17 @@ +import { createLazyRoute } from '@tanstack/react-router' + +export const Route = createLazyRoute('/posts')({ + component: PostsComponent, +}) + +function PostsComponent() { + const posts = Route.useLoaderData() + + return ( +
    + {posts.map((post) => ( +
  • {post.title.slice(0, 20)}
  • + ))} +
+ ) +} diff --git a/e2e/react-router/issue-7120/src/styles.css b/e2e/react-router/issue-7120/src/styles.css new file mode 100644 index 0000000000..c3298a2e5e --- /dev/null +++ b/e2e/react-router/issue-7120/src/styles.css @@ -0,0 +1,7 @@ +html { + color-scheme: light dark; +} + +body { + font-family: system-ui, sans-serif; +} diff --git a/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts new file mode 100644 index 0000000000..b53f0fe68f --- /dev/null +++ b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test' + +test('root beforeLoad redirect does not blank when pending UI and view transitions are enabled', async ({ + page, +}) => { + const pageErrors: Array = [] + + page.on('pageerror', (error) => { + pageErrors.push(error.message) + }) + + await page.goto('/') + + await expect(page).toHaveURL(/\/posts$/) + await expect(page.getByText('sunt aut facere repe')).toBeVisible() + await expect(page.getByTestId('root-pending')).not.toBeVisible() + expect(pageErrors).toEqual([]) +}) diff --git a/e2e/react-router/issue-7120/tsconfig.json b/e2e/react-router/issue-7120/tsconfig.json new file mode 100644 index 0000000000..4f6089bc08 --- /dev/null +++ b/e2e/react-router/issue-7120/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/e2e/react-router/issue-7120/vite.config.js b/e2e/react-router/issue-7120/vite.config.js new file mode 100644 index 0000000000..9ffcc67574 --- /dev/null +++ b/e2e/react-router/issue-7120/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index fefd256bf6..a5b1def939 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -22,6 +22,8 @@ import { ClientOnly } from './ClientOnly' import { useLayoutEffect } from './utils' import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' +const resolvedPromise = Promise.resolve() + export const Match = React.memo(function MatchImpl({ matchId, }: { @@ -277,6 +279,11 @@ export const MatchInner = React.memo(function MatchInnerImpl({ ) } + const getRedirectPromise = (match: Parameters[0]) => + getMatchPromise(match, 'loadPromise') ?? + router.latestLoadPromise ?? + resolvedPromise + if (isServer ?? router.isServer) { const match = router.stores.matchStores.get(matchId)?.get() if (!match) { @@ -313,7 +320,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ } if (match.status === 'pending') { - throw getMatchPromise(match, 'loadPromise') + throw getRedirectPromise(match) } if (match.status === 'notFound') { @@ -431,7 +438,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ } } } - throw getMatchPromise(match, 'loadPromise') + throw getRedirectPromise(match) } if (match.status === 'notFound') { diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 0c4c1b4146..2dd12cc2b9 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -136,7 +136,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(8) + expect(updates).toBe(7) }) test('redirection in preload', async () => { @@ -170,7 +170,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(5) + expect(updates).toBe(4) }) test('nothing', async () => { diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index da2d0244a6..eb3f669404 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -34,10 +34,21 @@ type InnerLoadContext = { preload?: boolean forceStaleReload?: boolean onReady?: () => Promise + isLatest?: () => boolean sync?: boolean + cancelled?: boolean } +const isStaleLoad = (inner: InnerLoadContext): boolean => + inner.isLatest?.() === false || + (!inner.preload && inner.router.latestLocation.href !== inner.location.href) + const triggerOnReady = (inner: InnerLoadContext): void | Promise => { + if (isStaleLoad(inner)) { + inner.cancelled = true + return + } + if (!inner.rendered) { inner.rendered = true return inner.onReady?.() @@ -117,52 +128,59 @@ const handleRedirectAndNotFound = ( match: AnyRouteMatch | undefined, err: unknown, ): void => { - if (!isRedirect(err) && !isNotFound(err)) return + const redirect = isRedirect(err) ? err : undefined + const notFound = isNotFound(err) ? err : undefined - if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) { - throw err + if (!redirect && !notFound) return + + if (redirect?.redirectHandled && !redirect.options.reloadDocument) { + throw redirect } // in case of a redirecting match during preload, the match does not exist if (match) { + // If a rendered client match redirects, the stale render may observe the + // redirected status before the redirect navigation replaces it. + const keepLoadPromise = + redirect && + inner.rendered && + !(isServer ?? inner.router.isServer) && + !inner.preload + match._nonReactive.beforeLoadPromise?.resolve() match._nonReactive.loaderPromise?.resolve() match._nonReactive.beforeLoadPromise = undefined match._nonReactive.loaderPromise = undefined - match._nonReactive.error = err + if (!keepLoadPromise) { + match._nonReactive.error = err - inner.updateMatch(match.id, (prev) => ({ - ...prev, - status: isRedirect(err) - ? 'redirected' - : isNotFound(err) - ? 'notFound' - : prev.status === 'pending' - ? 'success' - : prev.status, - context: buildMatchContext(inner, match.index), - isFetching: false, - error: err, - })) + inner.updateMatch(match.id, (prev) => ({ + ...prev, + status: redirect ? 'redirected' : 'notFound', + context: buildMatchContext(inner, match.index), + isFetching: false, + error: err, + })) + + match._nonReactive.loadPromise?.resolve() + } - if (isNotFound(err) && !err.routeId) { + if (notFound && !notFound.routeId) { // Stamp the throwing match's routeId so that the finalization step in // loadMatches knows where the notFound originated. The actual boundary // resolution (walking up to the nearest notFoundComponent) is deferred to // the finalization step, where firstBadMatchIndex is stable and // headMaxIndex can be capped correctly. - err.routeId = match.routeId + notFound.routeId = match.routeId } - - match._nonReactive.loadPromise?.resolve() } - if (isRedirect(err)) { + if (redirect) { inner.rendered = true - err.options._fromLocation = inner.location - err.redirectHandled = true - err = inner.router.resolveRedirect(err) + redirect.options._fromLocation = inner.location + redirect.redirectHandled = true + err = inner.router.resolveRedirect(redirect) } throw err @@ -967,6 +985,7 @@ export async function loadMatches(arg: { preload?: boolean forceStaleReload?: boolean onReady?: () => Promise + isLatest?: () => boolean updateMatch: UpdateMatchFn sync?: boolean }): Promise> { @@ -1001,6 +1020,10 @@ export async function loadMatches(arg: { break } + if (inner.cancelled) { + return inner.matches + } + if (inner.serialError || inner.firstBadMatchIndex != null) { break } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 3fbcd91387..54310102a5 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2406,43 +2406,55 @@ export class RouterCore< load: LoadFn = async (opts?: { sync?: boolean }): Promise => { let redirect: AnyRedirect | undefined let notFound: NotFoundError | undefined - let loadPromise: Promise const previousLocation = this.stores.resolvedLocation.get() ?? this.stores.location.get() - // eslint-disable-next-line prefer-const - loadPromise = new Promise((resolve) => { - this.startTransition(async () => { - try { - this.beforeLoad() - const next = this.latestLocation - const prevLocation = this.stores.resolvedLocation.get() - const locationChangeInfo = getLocationChangeInfo(next, prevLocation) - - if (!this.stores.redirect.get()) { - this.emit({ - type: 'onBeforeNavigate', - ...locationChangeInfo, - }) - } + const loadPromise = createControlledPromise() + const commitLocationPromise = this.commitLocationPromise + this.latestLoadPromise = loadPromise + this.startTransition(async () => { + try { + this.beforeLoad() + const next = this.latestLocation + const prevLocation = this.stores.resolvedLocation.get() + const locationChangeInfo = getLocationChangeInfo(next, prevLocation) + + if (!this.stores.redirect.get()) { this.emit({ - type: 'onBeforeLoad', + type: 'onBeforeNavigate', ...locationChangeInfo, }) + } + + this.emit({ + type: 'onBeforeLoad', + ...locationChangeInfo, + }) + + await loadMatches({ + router: this, + sync: opts?.sync, + forceStaleReload: previousLocation.href === next.href, + matches: this.stores.pendingMatches.get(), + location: next, + updateMatch: this.updateMatch, + isLatest: () => this.latestLoadPromise === loadPromise, + onReady: async () => { + if (this.latestLoadPromise !== loadPromise) { + return + } + + const commitPromise = createControlledPromise() + + // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) + this.startTransition(() => { + this.startViewTransition(async () => { + try { + if (this.latestLoadPromise !== loadPromise) { + return + } - await loadMatches({ - router: this, - sync: opts?.sync, - forceStaleReload: previousLocation.href === next.href, - matches: this.stores.pendingMatches.get(), - location: next, - updateMatch: this.updateMatch, - // eslint-disable-next-line @typescript-eslint/require-await - onReady: async () => { - // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) - this.startTransition(() => { - this.startViewTransition(async () => { // this.viewTransitionPromise = createControlledPromise() // Commit the pending matches. If a previous match was @@ -2511,10 +2523,7 @@ export class RouterCore< this.stores.setCached([ ...this.stores.cachedMatches.get(), ...exitingMatches!.filter( - (d) => - d.status !== 'error' && - d.status !== 'notFound' && - d.status !== 'redirected', + (d) => d.status === 'success', ), ]) this.clearExpiredCache() @@ -2534,50 +2543,56 @@ export class RouterCore< ) } } - }) + } finally { + commitPromise.resolve() + } }) - }, - }) - } catch (err) { - if (isRedirect(err)) { - redirect = err - if (!(isServer ?? this.isServer)) { - this.navigate({ - ...redirect.options, - replace: true, - ignoreBlocker: true, - }) - } - } else if (isNotFound(err)) { - notFound = err + }) + + await commitPromise + }, + }) + } catch (err) { + if (isRedirect(err)) { + redirect = err + if (!(isServer ?? this.isServer)) { + this.navigate({ + ...redirect.options, + replace: true, + ignoreBlocker: true, + }) } + } else if (isNotFound(err)) { + notFound = err + } - const nextStatusCode = redirect - ? redirect.status - : notFound - ? 404 - : this.stores.matches.get().some((d) => d.status === 'error') - ? 500 - : 200 + const nextStatusCode = redirect + ? redirect.status + : notFound + ? 404 + : this.stores.matches.get().some((d) => d.status === 'error') + ? 500 + : 200 + if (this.latestLoadPromise === loadPromise) { this.batch(() => { this.stores.statusCode.set(nextStatusCode) this.stores.redirect.set(redirect) }) } + } - if (this.latestLoadPromise === loadPromise) { - this.commitLocationPromise?.resolve() - this.latestLoadPromise = undefined + if (this.latestLoadPromise === loadPromise) { + commitLocationPromise?.resolve() + this.latestLoadPromise = undefined + if (this.commitLocationPromise === commitLocationPromise) { this.commitLocationPromise = undefined } + } - resolve() - }) + loadPromise.resolve() }) - this.latestLoadPromise = loadPromise - await loadPromise while ( diff --git a/packages/router-core/tests/callbacks.test.ts b/packages/router-core/tests/callbacks.test.ts index 1673626f7e..20e331807b 100644 --- a/packages/router-core/tests/callbacks.test.ts +++ b/packages/router-core/tests/callbacks.test.ts @@ -88,6 +88,65 @@ describe('callbacks', () => { expect.objectContaining({ id: '/bar/bar' }), ) }) + + it('does not resolve a newer commit promise from an older load', async () => { + const history = createMemoryHistory() + let unblock!: () => void + const blockerPromise = new Promise((resolve) => { + unblock = resolve + }) + let blockerCalled = false + let nestedNavigatePromise: Promise | undefined + let nestedNavigateSettled = false + + const rootRoute = new BaseRootRoute({}) + + let router!: ReturnType + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + onEnter: () => { + history.block({ + blockerFn: () => { + blockerCalled = true + return blockerPromise.then(() => false) + }, + }) + nestedNavigatePromise = router.navigate({ to: '/bar' }) + nestedNavigatePromise.then(() => { + nestedNavigateSettled = true + }) + }, + }) + + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history, + }) + + const unsubscribe = history.subscribe(() => router.load()) + try { + await router.navigate({ to: '/foo' }) + expect(blockerCalled).toBe(true) + expect(nestedNavigatePromise).toBeDefined() + + await Promise.resolve() + expect(nestedNavigateSettled).toBe(false) + + unblock() + await nestedNavigatePromise + + expect(nestedNavigateSettled).toBe(true) + expect(router.state.location.pathname).toBe('/bar') + } finally { + unsubscribe() + } + }) }) describe('onLeave', () => { diff --git a/packages/solid-router/tests/store-updates-during-navigation.test.tsx b/packages/solid-router/tests/store-updates-during-navigation.test.tsx index 69b570122d..b341dcad29 100644 --- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx @@ -136,7 +136,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(9) + expect(updates).toBe(8) }) test('redirection in preload', async () => { @@ -172,7 +172,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Solid has different update counts than React due to different reactivity - expect(updates).toBe(5) + expect(updates).toBe(4) }) test('nothing', async () => { diff --git a/packages/vue-router/tests/store-updates-during-navigation.test.tsx b/packages/vue-router/tests/store-updates-during-navigation.test.tsx index 1c40bf7be7..0cade46f9e 100644 --- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx @@ -202,7 +202,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(9) + expect(updates).toBe(8) }) test('hover preload, then navigate, w/ async loaders', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0823c60b4a..237f88fd2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1020,6 +1020,37 @@ importers: specifier: ^8.0.0 version: 8.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + e2e/react-router/issue-7120: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.58.0 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + vite: + specifier: ^8.0.0 + version: 8.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + e2e/react-router/js-only-file-based: dependencies: '@tailwindcss/vite':