TanStack / TanStack/router

Solid Router route-scoped accessors can throw during navigation teardown

Open
#7,331 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

package: solid-router
Dominant language
TypeScript
Stars
15.1k
Forks
1.9k
Avg merge
1d 20h
Merged PRs (30d)
143

Description

Which project does this relate to?

Router

Describe the bug

In Solid 2.0 and TanStack Solid Router, route scoped accessors such as Route.useParams() and Route.useSearch() can throw after navigation has moved away from the route that created the accessor.

The test creates a route component that calls postRoute.useParams(), schedules a delayed read of that accessor, navigates away, and then lets the delayed read run. On current code, the delayed read throws:

Invariant failed: Could not find an active match from "/posts/$postId"

Obviosly you would never write such code for the actual applications, but the async reading of accessors can be triggered in createEffect() calls.

Reduced test code snippet:

component: function PostComponent() {
  const params = postRoute.useParams()

  // Simulated async work that reads params accessor
  delayedRead = delayedReadGate.then(() => {
    try {
      params()
    } catch (err) {
      delayedError = err
    }
  })

  return <h1>Post {params().postId}</h1>
}
await router.navigate({ to: '/other' })
// Trigger async work
releaseDelayedRead()
await delayedRead

if (delayedError) {
  throw new Error(
    `Route-scoped useParams threw after navigation away: ${
      delayedError instanceof Error ? delayedError.message : String(delayedError)
    }`,
  )
}

The current behavior demonstrates that a route-created accessor can become throwing solely because active matches no longer contain that route id.

Complete minimal reproducer

https://github.com/TanStack/router/pull/7330

Steps to Reproduce the Bug

Run the added test in the pull request

CI=1 NX_DAEMON=false pnpm nx run @tanstack/solid-router:test:unit --outputStyle=stream --skipRemoteCache -- tests/useMatch.test.tsx
@tanstack/solid-router: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
@tanstack/solid-router:  FAIL   @tanstack/solid-router  tests/useMatch.test.tsx > useMatch > route-scoped useParams should remain readable from async work created by the route during navigation away
@tanstack/solid-router: Error: Route-scoped useParams threw after navigation away: Invariant failed: Could not find an active match from "/posts/$postId"
@tanstack/solid-router:  ❯ tests/useMatch.test.tsx:170:13
@tanstack/solid-router:     168|
@tanstack/solid-router:     169|     if (delayedError) {
@tanstack/solid-router:     170|       throw new Error(
@tanstack/solid-router:        |             ^
@tanstack/solid-router:     171|         `Route-scoped useParams threw after navigation away: ${
@tanstack/solid-router:     172|           delayedError instanceof Error
@tanstack/solid-router: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
@tanstack/solid-router:  Test Files  1 failed | 44 passed (45)
@tanstack/solid-router:       Tests  1 failed | 802 passed | 2 skipped (805)
@tanstack/solid-router: Type Errors  no errors
@tanstack/solid-router:    Start at  09:26:49
@tanstack/solid-router:    Duration  52.71s (transform 95.49s, setup 12.36s, import 110.95s, tests 93.44s, environment 30.98s, typecheck 3.90s)
@tanstack/solid-router:  ELIFECYCLE  Command failed with exit code 1.
Expected behavior

Expected behaviour here is up to debate, since route is no longer active and JavaScript does not support proper abort handling.

I have created a workaround for my application that provides params as T | undefined, this removes the errors but discards all the hard work Tanstack router has done on type safe path and search params. This is not the fix I would like to use long term.

type RegisteredRouteId = RouteIds<RegisteredRouter["routeTree"]>;

export type RouteParams<TFrom extends RegisteredRouteId> = Expand<
  RouteById<RegisteredRouter["routeTree"], TFrom>["types"]["allParams"]
>;

export const useStableParams = <const TFrom extends RegisteredRouteId>(
  from: TFrom,
): Accessor<RouteParams<TFrom> | undefined> =>
  useParams<RegisteredRouter, TFrom, true, false, unknown>({
    from,
    shouldThrow: false,
  });

From my perspective there are two sane options, Route.useParams() could

  1. return the last value instead of throwing on route changes
  2. support the extra api (e.g.: Route.useParams({ shouldThrow: false })) and let the developer of the application handle
Screenshots or Videos

No response

Platform
  • Router / Start Version: Router: 2.0.0-beta.17 for the router and 2.0.0-beta.18 for the start
  • OS: N/A
  • Browser: N/A
  • Browser Version: N/A
  • Bundler: N/A
  • Bundler Version: N/A
Additional context

No response

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by running the added case in tests/useMatch.test.tsx with the provided pnpm nx command, then trace the implementation behind route-scoped Route.useParams() and Route.useSearch(). Confirm the desired post-navigation behavior with maintainers, since the issue presents returning the last value and allowing shouldThrow: false as alternatives; done means the test passes without losing type-safe route parameters.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.