From db4b860c4a6429e4077e498fcafa0ac602778e8c Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Thu, 11 Jun 2026 18:44:02 -0500 Subject: [PATCH] fix(solid-router): keep route-scoped accessors readable after navigation teardown Route-scoped accessors like Route.useParams() are backed by a lazy memo in useMatch. When async work created by a route component read the accessor after navigating away, the memo re-executed against the new matches and threw "Invariant failed: Could not find an active match". Track disposal of the owning reactive scope via onCleanup and return the last known value for reads that happen after teardown, while still throwing for mounted components that read a genuinely missing match. Fixes #7331 Closes #7330 Co-Authored-By: Claude Fable 5 --- .changeset/spotty-moons-listen.md | 5 ++ packages/solid-router/src/useMatch.tsx | 15 +++++ packages/solid-router/tests/useMatch.test.tsx | 58 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 .changeset/spotty-moons-listen.md diff --git a/.changeset/spotty-moons-listen.md b/.changeset/spotty-moons-listen.md new file mode 100644 index 0000000000..5127cc53cc --- /dev/null +++ b/.changeset/spotty-moons-listen.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-router': patch +--- + +fix: prevent route-scoped accessors (e.g. `Route.useParams()`, `Route.useSearch()`) from throwing when read from async work after navigating away. Once the owning scope is disposed, the accessor returns its last known value instead of throwing "Could not find an active match". diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index a4d6bdf6ab..ed3f25f79d 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -84,10 +84,25 @@ export function useMatch< return nearestMatch?.match() } + // The returned accessor can be read after the owning scope has been + // disposed (e.g. async work started by a route component that resolves + // after navigating away). Once disposed, keep returning the last known + // value instead of throwing on the now-missing match. + let isDisposed = false + if (Solid.getOwner()) { + Solid.onCleanup(() => { + isDisposed = true + }) + } + return Solid.createMemo((prev: TSelected | undefined) => { const selectedMatch = match() if (selectedMatch === undefined) { + if (prev !== undefined && isDisposed) { + return prev + } + const hasPendingMatch = opts.from ? Boolean(router.stores.pendingRouteIds.get()[opts.from!]) : (nearestMatch?.hasPending() ?? false) diff --git a/packages/solid-router/tests/useMatch.test.tsx b/packages/solid-router/tests/useMatch.test.tsx index 5375f7e3b7..9de000544b 100644 --- a/packages/solid-router/tests/useMatch.test.tsx +++ b/packages/solid-router/tests/useMatch.test.tsx @@ -118,4 +118,62 @@ describe('useMatch', () => { }) }) }) + + test('route-scoped useParams should remain readable from async work created by the route during navigation away', async () => { + let releaseDelayedRead: () => void = () => {} + const delayedReadGate = new Promise((resolve) => { + releaseDelayedRead = resolve + }) + let delayedRead!: Promise + let delayedError: unknown + + const rootRoute = createRootRoute({ + component: () => , + }) + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + component: function PostComponent() { + const params = postRoute.useParams() + + delayedRead = delayedReadGate.then(() => { + try { + params() + } catch (err) { + delayedError = err + } + }) + + return

Post {params().postId}

+ }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () =>

OtherTitle

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([postRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/posts/one'] }), + }) + + render(() => ) + expect(await screen.findByText('Post one')).toBeInTheDocument() + + await router.navigate({ to: '/other' }) + expect(await screen.findByText('OtherTitle')).toBeInTheDocument() + + releaseDelayedRead() + await delayedRead + + if (delayedError) { + throw new Error( + `Route-scoped useParams threw after navigation away: ${ + delayedError instanceof Error + ? delayedError.message + : String(delayedError) + }`, + ) + } + }) })