diff --git a/CLAUDE.md b/CLAUDE.md index c1d160130..5a230a205 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,24 @@ Auth updates reach the memoized router via the `RouterProvider` `context` prop p `router.invalidate()` effect in `src/AppRouted.tsx` — that invalidate is what re-runs `beforeLoad` guards (e.g. the sign-out redirect in `dashboardRoute.ts`), so don't remove it. +## Routing — `@tanstack/router-core` is patched (preload eviction `_nonReactive` TypeError) + +`patches/@tanstack__router-core@1.171.14.patch` (wired via `patchedDependencies` in +`pnpm-workspace.yaml`) ports the fix from TanStack/router PR #7003 for upstream issue +#7759 / studio #1387: when a hover-intent preload's cached match is evicted mid-flight +(user navigates, `router.invalidate()`, cache GC), `load-matches.js` re-read the match +after an `await` and threw `TypeError: Cannot read properties of undefined (reading +'_nonReactive')`, which `preloadRoute` then `console.error`'d — polluting Datadog RUM on +every hover-then-navigate race. The patch turns the eviction into a quiet cancellation +(resolves the evicted match's controlled promises, aborts it, and `preloadRoute` returns +undefined). Regression tests: `src/router/__tests__/preloadEvictionRepro.test.ts` — they +fail on the unpatched package. + +On the next `@tanstack/react-router`/`router-core` bump the patch will stop applying +(pnpm errors on the version mismatch — do not just delete it). Check whether upstream +shipped #7003/#7006 first; if not, re-create the patch against the new version and keep +the regression tests green. + ## pnpm — dependency overrides go in `pnpm-workspace.yaml`, not `package.json` This repo uses pnpm 11. `overrides` (and other settings like `minimumReleaseAge`, diff --git a/patches/@tanstack__router-core@1.171.14.patch b/patches/@tanstack__router-core@1.171.14.patch new file mode 100644 index 000000000..304a70478 --- /dev/null +++ b/patches/@tanstack__router-core@1.171.14.patch @@ -0,0 +1,332 @@ +diff --git a/dist/cjs/load-matches.cjs b/dist/cjs/load-matches.cjs +index c09976a80c2c112b7dac6f941e643c81eeaf8d52..9858f1f29c91f4e859ad4cf1e586722730c44754 100644 +--- a/dist/cjs/load-matches.cjs ++++ b/dist/cjs/load-matches.cjs +@@ -5,6 +5,31 @@ const require_root = require("./root.cjs"); + const require_redirect = require("./redirect.cjs"); + let _tanstack_router_core_isServer = require("@tanstack/router-core/isServer"); + //#region src/load-matches.ts ++const matchRemovedReason = "Match removed before complete load"; ++class MatchLoadCancelledError extends Error { ++ constructor() { ++ super(matchRemovedReason); ++ this.name = "MatchCancel"; ++ } ++} ++const isMatchLoadCancelledError = (err) => err instanceof MatchLoadCancelledError || err instanceof Error && err.name === "MatchCancel"; ++const getMatchOrThrowCancelled = (inner, matchId, cleanupMatch) => { ++ const match = inner.router.getMatch(matchId); ++ if (match) return match; ++ if (cleanupMatch) { ++ const s = cleanupMatch._nonReactive; ++ s.beforeLoadPromise?.resolve(); ++ s.loaderPromise?.resolve(); ++ s.loadPromise?.resolve(); ++ cleanupMatch.abortController.abort(matchRemovedReason); ++ clearTimeout(s.pendingTimeout); ++ s.beforeLoadPromise = void 0; ++ s.loaderPromise = void 0; ++ s.loadPromise = void 0; ++ s.pendingTimeout = void 0; ++ } ++ throw new MatchLoadCancelledError(); ++}; + const triggerOnReady = (inner) => { + if (!inner.rendered) { + inner.rendered = true; +@@ -435,46 +460,51 @@ const loadRouteMatch = async (inner, matchPromises, index) => { + loaderShouldRunAsync = status === "success" && (invalid || (shouldReload ?? staleMatchShouldReload)); + if (preload && route.options.preload === false) {} else if (loaderShouldRunAsync && !inner.sync && shouldReloadInBackground) { + loaderIsRunningAsync = true; ++ const matchForCleanup = prevMatch; + (async () => { + try { + await runLoader(inner, matchPromises, matchId, index, route); +- const match = inner.router.getMatch(matchId); +- match._nonReactive.loaderPromise?.resolve(); +- match._nonReactive.loadPromise?.resolve(); +- match._nonReactive.loaderPromise = void 0; +- match._nonReactive.loadPromise = void 0; + } catch (err) { + if (require_redirect.isRedirect(err)) await inner.router.navigate(err.options); ++ } finally { ++ matchForCleanup._nonReactive.loaderPromise?.resolve(); ++ matchForCleanup._nonReactive.loadPromise?.resolve(); ++ matchForCleanup._nonReactive.loaderPromise = void 0; ++ matchForCleanup._nonReactive.loadPromise = void 0; + } + })(); + } else if (status !== "success" || loaderShouldRunAsync) await runLoader(inner, matchPromises, matchId, index, route); + else syncMatchContext(inner, matchId, index); + } + const { id: matchId, routeId } = inner.matches[index]; ++ let cleanupMatch; + let loaderShouldRunAsync = false; + let loaderIsRunningAsync = false; + const route = inner.router.looseRoutesById[routeId]; + const routeLoader = route.options.loader; + const shouldReloadInBackground = ((typeof routeLoader === "function" ? void 0 : routeLoader?.staleReloadMode) ?? inner.router.options.defaultStaleReloadMode) !== "blocking"; + if (shouldSkipLoader(inner, matchId)) { +- if (!inner.router.getMatch(matchId)) return inner.matches[index]; ++ cleanupMatch = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); + syncMatchContext(inner, matchId, index); + if (_tanstack_router_core_isServer.isServer ?? inner.router.isServer) return inner.router.getMatch(matchId); + } else { +- const prevMatch = inner.router.getMatch(matchId); ++ const prevMatch = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); ++ cleanupMatch = prevMatch; + const activeIdAtIndex = inner.router.stores.matchesId.get()[index]; + const previousRouteMatchId = (activeIdAtIndex && inner.router.stores.matchStores.get(activeIdAtIndex) || null)?.routeId === routeId ? activeIdAtIndex : inner.router.stores.matches.get().find((d) => d.routeId === routeId)?.id; + const preload = resolvePreload(inner, matchId); + if (prevMatch._nonReactive.loaderPromise) { + if (prevMatch.status === "success" && !inner.sync && !prevMatch.preload && shouldReloadInBackground) return prevMatch; + await prevMatch._nonReactive.loaderPromise; +- const match = inner.router.getMatch(matchId); ++ const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); ++ cleanupMatch = match; + const error = match._nonReactive.error || match.error; + if (error) handleRedirectAndNotFound(inner, match, error); + if (match.status === "pending") await handleLoader(preload, prevMatch, previousRouteMatchId, match, route); + } else { + const nextPreload = preload && !inner.router.stores.matchStores.has(matchId); +- const match = inner.router.getMatch(matchId); ++ const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); ++ cleanupMatch = match; + match._nonReactive.loaderPromise = require_utils.createControlledPromise(); + if (nextPreload !== match.preload) inner.updateMatch(matchId, (prev) => ({ + ...prev, +@@ -483,7 +513,7 @@ const loadRouteMatch = async (inner, matchPromises, index) => { + await handleLoader(preload, prevMatch, previousRouteMatchId, match, route); + } + } +- const match = inner.router.getMatch(matchId); ++ const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); + if (!loaderIsRunningAsync) { + match._nonReactive.loaderPromise?.resolve(); + match._nonReactive.loadPromise?.resolve(); +@@ -500,8 +530,8 @@ const loadRouteMatch = async (inner, matchPromises, index) => { + isFetching: nextIsFetching, + invalid: false + })); +- return inner.router.getMatch(matchId); +- } else return match; ++ } ++ return match; + }; + async function loadMatches(arg) { + const inner = arg; +@@ -525,6 +555,7 @@ async function loadMatches(arg) { + const maxIndexExclusive = beforeLoadNotFound && inner.preload ? 0 : boundaryIndex !== void 0 ? Math.min(boundaryIndex + 1, baseMaxIndexExclusive) : baseMaxIndexExclusive; + let firstNotFound; + let firstUnhandledRejection; ++ let firstCancelledMatch; + for (let i = 0; i < maxIndexExclusive; i++) matchPromises.push(loadRouteMatch(inner, matchPromises, i)); + try { + await Promise.all(matchPromises); +@@ -533,10 +564,15 @@ async function loadMatches(arg) { + for (const result of settled) { + if (result.status !== "rejected") continue; + const reason = result.reason; ++ if (isMatchLoadCancelledError(reason)) { ++ firstCancelledMatch ??= reason; ++ continue; ++ } + if (require_redirect.isRedirect(reason)) throw reason; + if (require_not_found.isNotFound(reason)) firstNotFound ??= reason; + else firstUnhandledRejection ??= reason; + } ++ if (firstCancelledMatch) throw firstCancelledMatch; + if (firstUnhandledRejection !== void 0) throw firstUnhandledRejection; + } + const notFoundToThrow = firstNotFound ?? (beforeLoadNotFound && !inner.preload ? beforeLoadNotFound : void 0); +@@ -651,6 +687,7 @@ const componentTypes = [ + "notFoundComponent" + ]; + //#endregion ++exports.isMatchLoadCancelledError = isMatchLoadCancelledError; + exports.loadMatches = loadMatches; + exports.loadRouteChunk = loadRouteChunk; + exports.routeNeedsPreload = routeNeedsPreload; +diff --git a/dist/cjs/router.cjs b/dist/cjs/router.cjs +index b928d955ea81a572f2770baf30bfc9001e4a9011..c9f95fdc964f0bb03474c8b4c3914b73feb61395 100644 +--- a/dist/cjs/router.cjs ++++ b/dist/cjs/router.cjs +@@ -768,6 +768,7 @@ var RouterCore = class { + }); + return matches; + } catch (err) { ++ if (require_load_matches.isMatchLoadCancelledError(err)) return; + if (require_redirect.isRedirect(err)) { + if (err.options.reloadDocument) return; + return await this.preloadRoute({ +diff --git a/dist/esm/load-matches.js b/dist/esm/load-matches.js +index bcea2e0d88d037a01b93bf36dd6e643f98028e83..f8253bec33e2b4aba3e412b1195c4cfbb9e68a70 100644 +--- a/dist/esm/load-matches.js ++++ b/dist/esm/load-matches.js +@@ -5,6 +5,31 @@ import { rootRouteId } from "./root.js"; + import { isRedirect } from "./redirect.js"; + import { isServer } from "@tanstack/router-core/isServer"; + //#region src/load-matches.ts ++const matchRemovedReason = "Match removed before complete load"; ++class MatchLoadCancelledError extends Error { ++ constructor() { ++ super(matchRemovedReason); ++ this.name = "MatchCancel"; ++ } ++} ++const isMatchLoadCancelledError = (err) => err instanceof MatchLoadCancelledError || err instanceof Error && err.name === "MatchCancel"; ++const getMatchOrThrowCancelled = (inner, matchId, cleanupMatch) => { ++ const match = inner.router.getMatch(matchId); ++ if (match) return match; ++ if (cleanupMatch) { ++ const s = cleanupMatch._nonReactive; ++ s.beforeLoadPromise?.resolve(); ++ s.loaderPromise?.resolve(); ++ s.loadPromise?.resolve(); ++ cleanupMatch.abortController.abort(matchRemovedReason); ++ clearTimeout(s.pendingTimeout); ++ s.beforeLoadPromise = void 0; ++ s.loaderPromise = void 0; ++ s.loadPromise = void 0; ++ s.pendingTimeout = void 0; ++ } ++ throw new MatchLoadCancelledError(); ++}; + const triggerOnReady = (inner) => { + if (!inner.rendered) { + inner.rendered = true; +@@ -435,46 +460,51 @@ const loadRouteMatch = async (inner, matchPromises, index) => { + loaderShouldRunAsync = status === "success" && (invalid || (shouldReload ?? staleMatchShouldReload)); + if (preload && route.options.preload === false) {} else if (loaderShouldRunAsync && !inner.sync && shouldReloadInBackground) { + loaderIsRunningAsync = true; ++ const matchForCleanup = prevMatch; + (async () => { + try { + await runLoader(inner, matchPromises, matchId, index, route); +- const match = inner.router.getMatch(matchId); +- match._nonReactive.loaderPromise?.resolve(); +- match._nonReactive.loadPromise?.resolve(); +- match._nonReactive.loaderPromise = void 0; +- match._nonReactive.loadPromise = void 0; + } catch (err) { + if (isRedirect(err)) await inner.router.navigate(err.options); ++ } finally { ++ matchForCleanup._nonReactive.loaderPromise?.resolve(); ++ matchForCleanup._nonReactive.loadPromise?.resolve(); ++ matchForCleanup._nonReactive.loaderPromise = void 0; ++ matchForCleanup._nonReactive.loadPromise = void 0; + } + })(); + } else if (status !== "success" || loaderShouldRunAsync) await runLoader(inner, matchPromises, matchId, index, route); + else syncMatchContext(inner, matchId, index); + } + const { id: matchId, routeId } = inner.matches[index]; ++ let cleanupMatch; + let loaderShouldRunAsync = false; + let loaderIsRunningAsync = false; + const route = inner.router.looseRoutesById[routeId]; + const routeLoader = route.options.loader; + const shouldReloadInBackground = ((typeof routeLoader === "function" ? void 0 : routeLoader?.staleReloadMode) ?? inner.router.options.defaultStaleReloadMode) !== "blocking"; + if (shouldSkipLoader(inner, matchId)) { +- if (!inner.router.getMatch(matchId)) return inner.matches[index]; ++ cleanupMatch = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); + syncMatchContext(inner, matchId, index); + if (isServer ?? inner.router.isServer) return inner.router.getMatch(matchId); + } else { +- const prevMatch = inner.router.getMatch(matchId); ++ const prevMatch = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); ++ cleanupMatch = prevMatch; + const activeIdAtIndex = inner.router.stores.matchesId.get()[index]; + const previousRouteMatchId = (activeIdAtIndex && inner.router.stores.matchStores.get(activeIdAtIndex) || null)?.routeId === routeId ? activeIdAtIndex : inner.router.stores.matches.get().find((d) => d.routeId === routeId)?.id; + const preload = resolvePreload(inner, matchId); + if (prevMatch._nonReactive.loaderPromise) { + if (prevMatch.status === "success" && !inner.sync && !prevMatch.preload && shouldReloadInBackground) return prevMatch; + await prevMatch._nonReactive.loaderPromise; +- const match = inner.router.getMatch(matchId); ++ const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); ++ cleanupMatch = match; + const error = match._nonReactive.error || match.error; + if (error) handleRedirectAndNotFound(inner, match, error); + if (match.status === "pending") await handleLoader(preload, prevMatch, previousRouteMatchId, match, route); + } else { + const nextPreload = preload && !inner.router.stores.matchStores.has(matchId); +- const match = inner.router.getMatch(matchId); ++ const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); ++ cleanupMatch = match; + match._nonReactive.loaderPromise = createControlledPromise(); + if (nextPreload !== match.preload) inner.updateMatch(matchId, (prev) => ({ + ...prev, +@@ -483,7 +513,7 @@ const loadRouteMatch = async (inner, matchPromises, index) => { + await handleLoader(preload, prevMatch, previousRouteMatchId, match, route); + } + } +- const match = inner.router.getMatch(matchId); ++ const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch); + if (!loaderIsRunningAsync) { + match._nonReactive.loaderPromise?.resolve(); + match._nonReactive.loadPromise?.resolve(); +@@ -500,8 +530,8 @@ const loadRouteMatch = async (inner, matchPromises, index) => { + isFetching: nextIsFetching, + invalid: false + })); +- return inner.router.getMatch(matchId); +- } else return match; ++ } ++ return match; + }; + async function loadMatches(arg) { + const inner = arg; +@@ -525,6 +555,7 @@ async function loadMatches(arg) { + const maxIndexExclusive = beforeLoadNotFound && inner.preload ? 0 : boundaryIndex !== void 0 ? Math.min(boundaryIndex + 1, baseMaxIndexExclusive) : baseMaxIndexExclusive; + let firstNotFound; + let firstUnhandledRejection; ++ let firstCancelledMatch; + for (let i = 0; i < maxIndexExclusive; i++) matchPromises.push(loadRouteMatch(inner, matchPromises, i)); + try { + await Promise.all(matchPromises); +@@ -533,10 +564,15 @@ async function loadMatches(arg) { + for (const result of settled) { + if (result.status !== "rejected") continue; + const reason = result.reason; ++ if (isMatchLoadCancelledError(reason)) { ++ firstCancelledMatch ??= reason; ++ continue; ++ } + if (isRedirect(reason)) throw reason; + if (isNotFound(reason)) firstNotFound ??= reason; + else firstUnhandledRejection ??= reason; + } ++ if (firstCancelledMatch) throw firstCancelledMatch; + if (firstUnhandledRejection !== void 0) throw firstUnhandledRejection; + } + const notFoundToThrow = firstNotFound ?? (beforeLoadNotFound && !inner.preload ? beforeLoadNotFound : void 0); +@@ -651,6 +687,6 @@ const componentTypes = [ + "notFoundComponent" + ]; + //#endregion +-export { loadMatches, loadRouteChunk, routeNeedsPreload }; ++export { isMatchLoadCancelledError, loadMatches, loadRouteChunk, routeNeedsPreload }; + + //# sourceMappingURL=load-matches.js.map +\ No newline at end of file +diff --git a/dist/esm/router.js b/dist/esm/router.js +index 05cd2d21ab580df0956d03e2b4aabb29649edb40..ba24e4a0ef4e7008e3fec5651e6b85b156a01f47 100644 +--- a/dist/esm/router.js ++++ b/dist/esm/router.js +@@ -7,7 +7,7 @@ import { setupScrollRestoration } from "./scroll-restoration.js"; + import { defaultParseSearch, defaultStringifySearch } from "./searchParams.js"; + import { rootRouteId } from "./root.js"; + import { isRedirect, redirect } from "./redirect.js"; +-import { loadMatches, loadRouteChunk, routeNeedsPreload } from "./load-matches.js"; ++import { isMatchLoadCancelledError, loadMatches, loadRouteChunk, routeNeedsPreload } from "./load-matches.js"; + import { composeRewrites, executeRewriteInput, executeRewriteOutput, rewriteBasepath } from "./rewrite.js"; + import { createRouterStores } from "./stores.js"; + import { createBrowserHistory, parseHref } from "@tanstack/history"; +@@ -768,6 +768,7 @@ var RouterCore = class { + }); + return matches; + } catch (err) { ++ if (isMatchLoadCancelledError(err)) return; + if (isRedirect(err)) { + if (err.options.reloadDocument) return; + return await this.preloadRoute({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f20fb4f1b..6501caeac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ overrides: js-yaml: ^4.2.0 mdast-util-to-hast: ^13.2.1 +patchedDependencies: + '@tanstack/router-core@1.171.14': 5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442 + importers: .: @@ -108,7 +111,7 @@ importers: version: 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-router-devtools': specifier: 1.167.0 - version: 1.167.0(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.14)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.167.0(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442))(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-table': specifier: ^8.21.2 version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -9661,14 +9664,14 @@ snapshots: '@tanstack/query-core': 5.101.2 react: 19.2.7 - '@tanstack/react-router-devtools@1.167.0(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.14)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-router-devtools@1.167.0(@tanstack/react-router@1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442))(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/react-router': 1.170.17(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-devtools-core': 1.168.0(@tanstack/router-core@1.171.14)(csstype@3.2.3) + '@tanstack/router-devtools-core': 1.168.0(@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442))(csstype@3.2.3) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@tanstack/router-core': 1.171.14 + '@tanstack/router-core': 1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442) transitivePeerDependencies: - csstype @@ -9676,7 +9679,7 @@ snapshots: dependencies: '@tanstack/history': 1.162.0 '@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-core': 1.171.14 + '@tanstack/router-core': 1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442) isbot: 5.1.32 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9694,16 +9697,16 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@tanstack/router-core@1.171.14': + '@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442)': dependencies: '@tanstack/history': 1.162.0 cookie-es: 3.1.1 seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) - '@tanstack/router-devtools-core@1.168.0(@tanstack/router-core@1.171.14)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.168.0(@tanstack/router-core@1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442))(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.171.14 + '@tanstack/router-core': 1.171.14(patch_hash=5c637ef4d89ce19f66b526ed9d261865a43bcdb3bee998cc4f35a8a8b3584442) clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 82e2f3d23..fe175604f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -42,3 +42,5 @@ overrides: dompurify: '^3.4.11' js-yaml: '^4.2.0' mdast-util-to-hast: '^13.2.1' +patchedDependencies: + '@tanstack/router-core@1.171.14': patches/@tanstack__router-core@1.171.14.patch diff --git a/src/router/__tests__/preloadEvictionRepro.test.ts b/src/router/__tests__/preloadEvictionRepro.test.ts new file mode 100644 index 000000000..9705e9d9d --- /dev/null +++ b/src/router/__tests__/preloadEvictionRepro.test.ts @@ -0,0 +1,118 @@ +import { createMemoryHistory, createRootRoute, createRoute, createRouter } from '@tanstack/react-router'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +/** + * Regression coverage for the RUM error tracked in #1387: + * TypeError: Cannot read properties of undefined (reading '_nonReactive') + * at ... async preloadRoute + * + * When a hover-intent preload's cached match is evicted while its loader is + * still in flight (real navigation, router.invalidate(), or cache GC), + * @tanstack/router-core@1.171.14 re-reads the match after an await and + * dereferences `._nonReactive` without a guard, then console.error()s the + * TypeError from preloadRoute — which Datadog RUM records on every + * hover-then-navigate race. Upstream report: TanStack/router#7759; fix ported + * from TanStack/router#7003 via patches/@tanstack__router-core@1.171.14.patch. + */ +describe('preloadRoute survives its match being evicted mid-flight', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function setup(loader: () => Promise) { + const rootRoute = createRootRoute({}); + const fooRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + preloadGcTime: 0, + }); + const barRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + loader: () => ({ ok: true }), + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + defaultPreloadGcTime: 0, + }); + return { router, fooRoute }; + } + + test('cache GC clearing an in-flight preload does not console.error a TypeError', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let resolveLoader: ((value: { ok: true }) => void) | undefined; + const { router } = setup( + () => + new Promise((resolve) => { + resolveLoader = resolve; + }), + ); + + const preloadPromise = router.preloadRoute({ to: '/foo' }); + await Promise.resolve(); + + router.clearExpiredCache(); + + resolveLoader?.({ ok: true }); + await expect(preloadPromise).resolves.toBeUndefined(); + + // preloadRoute swallows load errors via console.error(err) — that is + // exactly what RUM picks up, so the assertion is on console.error. + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + test('invalidate() during an in-flight preload does not console.error a TypeError', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let resolveLoader: ((value: { ok: true }) => void) | undefined; + const { router } = setup( + () => + new Promise((resolve) => { + resolveLoader = resolve; + }), + ); + + await router.navigate({ to: '/bar' }); + + const preloadPromise = router.preloadRoute({ to: '/foo' }); + await Promise.resolve(); + + const invalidatePromise = router.invalidate(); + await Promise.resolve(); + + resolveLoader?.({ ok: true }); + await expect(preloadPromise).resolves.toBeUndefined(); + await invalidatePromise; + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + test('eviction during concurrent preloads of the same route cleans up without errors or hangs', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let resolveLoader: ((value: { ok: true }) => void) | undefined; + const { router } = setup( + () => + new Promise((resolve) => { + resolveLoader = resolve; + }), + ); + + // second preload of the same route joins the first one's in-flight + // loaderPromise (the concurrent-load branch of loadRouteMatch) + const firstPreload = router.preloadRoute({ to: '/foo' }); + await Promise.resolve(); + const secondPreload = router.preloadRoute({ to: '/foo' }); + await Promise.resolve(); + + router.clearExpiredCache(); + + resolveLoader?.({ ok: true }); + // both preloads must settle (eviction cleanup resolves the controlled + // promises the second preload is parked on) and neither may console.error + await expect(firstPreload).resolves.toBeUndefined(); + await expect(secondPreload).resolves.toBeUndefined(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +});