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) + }`, + ) + } + }) })