Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions docs/router/api/router/RouterStateType.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ id: RouterStateType
title: RouterState type
---

The `RouterState` type represents shape of the internal state of the router. The Router's internal state is useful, if you need to access certain internals of the router, such as any pending matches, is the router in its loading state, etc.
The `RouterState` type represents shape of the internal state of the router. The Router's internal state is useful if you need to access certain internals of the router, such as whether it is currently loading.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Grammar: add “the” for readability.

✏️ Suggested edit
-The `RouterState` type represents shape of the internal state of the router.
+The `RouterState` type represents the shape of the internal state of the router.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
The `RouterState` type represents shape of the internal state of the router. The Router's internal state is useful if you need to access certain internals of the router, such as whether it is currently loading.
The `RouterState` type represents the shape of the internal state of the router.
🤖 Prompt for AI Agents
In `@docs/router/api/router/RouterStateType.md` at line 6, Update the
documentation sentence for the RouterState type to include the missing article
for clarity: change the phrase so it reads "The `RouterState` type represents
the shape of the internal state of the router." (refer to the `RouterState` type
description) and similarly ensure the following sentence reads "The Router's
internal state..." where appropriate for consistent readability.


```tsx
type RouterState = {
status: 'pending' | 'idle'
isLoading: boolean
isTransitioning: boolean
matches: Array<RouteMatch>
pendingMatches: Array<RouteMatch>
location: ParsedLocation
resolvedLocation: ParsedLocation
}
Expand All @@ -21,6 +20,14 @@ type RouterState = {

The `RouterState` type contains all of the properties that are available on the router state.

## Migration notes

If you previously used `router.state.pendingMatches`, use one of these patterns instead:

- `router.state.status === 'pending'` to detect an in-flight navigation.
- `router.state.isLoading` to detect active loading work.
- `router.matchRoute(...)` with `{ pending: true }` when you need to match against the pending location.

### `status` property

- Type: `'pending' | 'idle'`
Expand All @@ -41,11 +48,6 @@ The `RouterState` type contains all of the properties that are available on the
- Type: [`Array<RouteMatch>`](./RouteMatchType.md)
- An array of all of the route matches that have been resolved and are currently active.

### `pendingMatches` property

- Type: [`Array<RouteMatch>`](./RouteMatchType.md)
- An array of all of the route matches that are currently pending.

### `location` property

- Type: [`ParsedLocation`](./ParsedLocationType.md)
Expand Down
3 changes: 0 additions & 3 deletions docs/router/api/router/useChildMatchesHook.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ title: useChildMatches hook

The `useChildMatches` hook returns all of the child [`RouteMatch`](./RouteMatchType.md) objects from the closest match down to the leaf-most match. **It does not include the current match, which can be obtained using the `useMatch` hook.**

> [!IMPORTANT]
> If the router has pending matches and they are showing their pending component fallbacks, `router.state.pendingMatches` will used instead of `router.state.matches`.
## useChildMatches options

The `useChildMatches` hook accepts a single _optional_ argument, an `options` object.
Expand Down
3 changes: 0 additions & 3 deletions docs/router/api/router/useParentMatchesHook.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ title: useParentMatches hook

The `useParentMatches` hook returns all of the parent [`RouteMatch`](./RouteMatchType.md) objects from the root down to the immediate parent of the current match in context. **It does not include the current match, which can be obtained using the `useMatch` hook.**

> [!IMPORTANT]
> If the router has pending matches and they are showing their pending component fallbacks, `router.state.pendingMatches` will used instead of `router.state.matches`.

## useParentMatches options

The `useParentMatches` hook accepts an optional `options` object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ 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).toBeGreaterThanOrEqual(10) // WARN: this is flaky, and sometimes (rarely) is 12
expect(updates).toBeLessThanOrEqual(13)
expect(updates).toBeGreaterThanOrEqual(8)
expect(updates).toBeLessThanOrEqual(11)
})

test('redirection in preload', async () => {
Expand Down Expand Up @@ -171,8 +171,8 @@ 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).toBeGreaterThanOrEqual(9) // WARN: this is flaky
expect(updates).toBeLessThanOrEqual(12)
expect(updates).toBeGreaterThanOrEqual(4)
expect(updates).toBeLessThanOrEqual(6)
})

test('nothing', async () => {
Expand All @@ -183,8 +183,8 @@ 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).toBeGreaterThanOrEqual(6) // WARN: this is flaky, and sometimes (rarely) is 9
expect(updates).toBeLessThanOrEqual(9)
expect(updates).toBeGreaterThanOrEqual(3)
expect(updates).toBeLessThanOrEqual(5)
})

test('not found in beforeLoad', async () => {
Expand All @@ -199,7 +199,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(7)
expect(updates).toBe(2)
})

test('hover preload, then navigate, w/ async loaders', async () => {
Expand All @@ -225,7 +225,8 @@ 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(16)
expect(updates).toBeGreaterThanOrEqual(9)
expect(updates).toBeLessThanOrEqual(11)
})

test('navigate, w/ preloaded & async loaders', async () => {
Expand All @@ -241,8 +242,8 @@ 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).toBeGreaterThanOrEqual(7)
expect(updates).toBeLessThanOrEqual(8)
expect(updates).toBeGreaterThanOrEqual(3)
expect(updates).toBeLessThanOrEqual(4)
})

test('navigate, w/ preloaded & sync loaders', async () => {
Expand All @@ -258,7 +259,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(6)
expect(updates).toBe(3)
})

test('navigate, w/ previous navigation & async loader', async () => {
Expand All @@ -274,7 +275,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(3)
})

test('preload a preloaded route w/ async loader', async () => {
Expand Down
40 changes: 23 additions & 17 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,6 @@ export interface RouterState<
isLoading: boolean
isTransitioning: boolean
matches: Array<TRouteMatch>
pendingMatches?: Array<TRouteMatch>
cachedMatches: Array<TRouteMatch>
location: ParsedLocation<FullSearchSchema<TRouteTree>>
resolvedLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
Expand Down Expand Up @@ -963,6 +962,7 @@ export class RouterCore<
origin?: string
latestLocation!: ParsedLocation<FullSearchSchema<TRouteTree>>
pendingBuiltLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
private pendingMatchesInternal?: Array<AnyRouteMatch>
basepath!: string
routeTree!: TRouteTree
routesById!: RoutesById<TRouteTree>
Expand Down Expand Up @@ -1764,7 +1764,7 @@ export class RouterCore<
(match) => match.isFetching === 'loader',
)
const matchesToCancelArray = new Set([
...(this.state.pendingMatches ?? []),
...(this.pendingMatchesInternal ?? []),
...currentPendingMatches,
...currentLoadingMatches,
])
Expand Down Expand Up @@ -2341,6 +2341,7 @@ export class RouterCore<

// Match the routes
const pendingMatches = this.matchRoutes(this.latestLocation)
this.pendingMatchesInternal = pendingMatches

// Ingest the new matches
this.__store.setState((s) => ({
Expand All @@ -2349,7 +2350,6 @@ export class RouterCore<
statusCode: 200,
isLoading: true,
location: this.latestLocation,
pendingMatches,
// If a cached moved to pendingMatches, remove it from cachedMatches
cachedMatches: s.cachedMatches.filter(
(d) => !pendingMatches.some((e) => e.id === d.id),
Expand Down Expand Up @@ -2391,7 +2391,7 @@ export class RouterCore<
await loadMatches({
router: this,
sync: opts?.sync,
matches: this.state.pendingMatches as Array<AnyRouteMatch>,
matches: this.pendingMatchesInternal ?? [],
location: next,
updateMatch: this.updateMatch,
// eslint-disable-next-line @typescript-eslint/require-await
Expand All @@ -2410,7 +2410,8 @@ export class RouterCore<
batch(() => {
this.__store.setState((s) => {
const previousMatches = s.matches
const newMatches = s.pendingMatches || s.matches
const newMatches =
this.pendingMatchesInternal || s.matches

exitingMatches = previousMatches.filter(
(match) => !newMatches.some((d) => d.id === match.id),
Expand All @@ -2428,7 +2429,6 @@ export class RouterCore<
isLoading: false,
loadedAt: Date.now(),
matches: newMatches,
pendingMatches: undefined,
/**
* When committing new matches, cache any exiting matches that are still usable.
* Routes that resolved with `status: 'error'` or `status: 'notFound'` are
Expand All @@ -2444,6 +2444,7 @@ export class RouterCore<
],
}
})
this.pendingMatchesInternal = undefined
this.clearExpiredCache()
})

Expand Down Expand Up @@ -2585,13 +2586,18 @@ export class RouterCore<

updateMatch: UpdateMatchFn = (id, updater) => {
this.startTransition(() => {
const matchesKey = this.state.pendingMatches?.some((d) => d.id === id)
? 'pendingMatches'
: this.state.matches.some((d) => d.id === id)
? 'matches'
: this.state.cachedMatches.some((d) => d.id === id)
? 'cachedMatches'
: ''
if (this.pendingMatchesInternal?.some((d) => d.id === id)) {
this.pendingMatchesInternal = this.pendingMatchesInternal.map((d) =>
d.id === id ? updater(d) : d,
)
return
}

const matchesKey = this.state.matches.some((d) => d.id === id)
? 'matches'
: this.state.cachedMatches.some((d) => d.id === id)
? 'cachedMatches'
: ''

if (matchesKey) {
this.__store.setState((s) => ({
Expand All @@ -2608,7 +2614,7 @@ export class RouterCore<
const findFn = (d: { id: string }) => d.id === matchId
return (
this.state.cachedMatches.find(findFn) ??
this.state.pendingMatches?.find(findFn) ??
this.pendingMatchesInternal?.find(findFn) ??
this.state.matches.find(findFn)
)
}
Expand Down Expand Up @@ -2645,11 +2651,12 @@ export class RouterCore<
return d
}

this.pendingMatchesInternal = this.pendingMatchesInternal?.map(invalidate)

this.__store.setState((s) => ({
...s,
matches: s.matches.map(invalidate),
cachedMatches: s.cachedMatches.map(invalidate),
pendingMatches: s.pendingMatches?.map(invalidate),
}))

this.shouldViewTransition = false
Expand Down Expand Up @@ -2766,7 +2773,7 @@ export class RouterCore<
})

const activeMatchIds = new Set(
[...this.state.matches, ...(this.state.pendingMatches ?? [])].map(
[...this.state.matches, ...(this.pendingMatchesInternal ?? [])].map(
(d) => d.id,
),
)
Expand Down Expand Up @@ -2934,7 +2941,6 @@ export function getInitialRouterState(
resolvedLocation: undefined,
location,
matches: [],
pendingMatches: [],
cachedMatches: [],
statusCode: 200,
}
Expand Down
16 changes: 7 additions & 9 deletions packages/router-core/tests/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ describe('beforeLoad skip or exec', () => {
const router = setup({ beforeLoad })
const navigation = router.navigate({ to: '/foo' })
expect(beforeLoad).toHaveBeenCalledTimes(1)
expect(router.state.pendingMatches).toEqual(
expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]),
expect(router.state.status).toBe('pending')
expect(router.getMatch('/foo/foo')).toEqual(
expect.objectContaining({ id: '/foo/foo' }),
)
await navigation
expect(router.state.location.pathname).toBe('/foo')
Expand Down Expand Up @@ -266,8 +267,9 @@ describe('loader skip or exec', () => {
const router = setup({ loader })
const navigation = router.navigate({ to: '/foo' })
expect(loader).toHaveBeenCalledTimes(1)
expect(router.state.pendingMatches).toEqual(
expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]),
expect(router.state.status).toBe('pending')
expect(router.getMatch('/foo/foo')).toEqual(
expect.objectContaining({ id: '/foo/foo' }),
)
await navigation
expect(router.state.location.pathname).toBe('/foo')
Expand Down Expand Up @@ -566,11 +568,7 @@ describe('params.parse notFound', () => {
})

await router.load()

const match = router.state.pendingMatches?.find(
(m) => m.routeId === testRoute.id,
)

const match = router.getMatch(testRoute.id + '/test/invalid')
expect(match?.status).toBe('notFound')
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,12 @@ function RouteComp({
setActiveId: (id: string) => void
}) {
const styles = useStyles()
const matches = createMemo(
() => routerState().pendingMatches || routerState().matches,
)
const match = createMemo(() =>
routerState().matches.find((d) => d.routeId === route.id),
const matches = createMemo(() =>
routerState().status === 'pending'
? router().matchRoutes(router().latestLocation)
: routerState().matches,
)
const match = createMemo(() => matches().find((d) => d.routeId === route.id))

const param = createMemo(() => {
try {
Expand Down Expand Up @@ -279,6 +279,14 @@ export const BaseTanStackRouterDevtoolsPanel =

const [history, setHistory] = createSignal<Array<AnyRouteMatch>>([])
const [hasHistoryOverflowed, setHasHistoryOverflowed] = createSignal(false)
const pendingMatches = createMemo(() =>
routerState().status === 'pending'
? router().matchRoutes(router().latestLocation)
: [],
)
const displayedMatches = createMemo(() =>
pendingMatches().length ? pendingMatches() : routerState().matches,
)
Comment on lines +282 to +289
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate the file
find . -type f -name "BaseTanStackRouterDevtoolsPanel.tsx" | head -5

Repository: TanStack/router

Length of output: 130


🏁 Script executed:

# Check file size to determine how to read it
wc -l packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx

Repository: TanStack/router

Length of output: 132


🏁 Script executed:

# Read the relevant sections mentioned in the review
sed -n '280,295p' packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx

Repository: TanStack/router

Length of output: 665


🏁 Script executed:

# Also check lines 532 and 687-694
sed -n '530,535p' packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx

Repository: TanStack/router

Length of output: 311


🏁 Script executed:

sed -n '685,700p' packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx

Repository: TanStack/router

Length of output: 743


🏁 Script executed:

# Search for AnyRouteMatch type definition
rg "AnyRouteMatch" --type ts --type tsx

Repository: TanStack/router

Length of output: 86


🏁 Script executed:

# Check the imports in the file to understand available types
head -50 packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx

Repository: TanStack/router

Length of output: 1457


Tighten types for pending/displayed matches to keep strict TS guarantees.

Explicit any in the createMemo callbacks and map/find operations undermines strict typing. Adding explicit type parameters <AnyRouteMatch[]> to both createMemo calls removes the need for any type annotations and keeps usages typed throughout.

♻️ Proposed fix
-    const pendingMatches = createMemo(() =>
+    const pendingMatches = createMemo<AnyRouteMatch[]>(() =>
       routerState().status === 'pending'
         ? router().matchRoutes(router().latestLocation)
         : [],
     )
-    const displayedMatches = createMemo(() =>
+    const displayedMatches = createMemo<AnyRouteMatch[]>(() =>
       pendingMatches().length ? pendingMatches() : routerState().matches,
     )
-                    {displayedMatches().map((match: any, _i: any) => {
+                    {displayedMatches().map((match) => {
-                    {pendingMatches().find(
-                      (d: any) => d.id === activeMatch()?.id,
-                    )
+                    {pendingMatches().find(
+                      (d) => d.id === activeMatch()?.id,
+                    )

This addresses the coding guideline: **/*.{ts,tsx}: Use TypeScript strict mode with extensive type safety for all code. Similar issues exist at lines 532 and 687–694.

🤖 Prompt for AI Agents
In `@packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx` around
lines 282 - 289, The createMemo callbacks for pendingMatches and
displayedMatches are using implicit/any types which breaks strict TS guarantees;
update the createMemo calls for pendingMatches and displayedMatches to use
explicit generic type parameters (e.g., createMemo<AnyRouteMatch[]>) so the
returned arrays are strongly typed, remove any explicit any annotations in the
callbacks and downstream map/find usages, and ensure the expressions using
routerState(), router().matchRoutes(router().latestLocation) and
routerState().matches preserve the new typed signature.


createEffect(() => {
const matches = routerState().matches
Expand Down Expand Up @@ -309,7 +317,7 @@ export const BaseTanStackRouterDevtoolsPanel =

const activeMatch = createMemo(() => {
const matches = [
...(routerState().pendingMatches ?? []),
...pendingMatches(),
...routerState().matches,
...routerState().cachedMatches,
]
Expand Down Expand Up @@ -521,10 +529,7 @@ export const BaseTanStackRouterDevtoolsPanel =
</Match>
<Match when={currentTab() === 'matches'}>
<div>
{(routerState().pendingMatches?.length
? routerState().pendingMatches
: routerState().matches
)?.map((match: any, _i: any) => {
{displayedMatches().map((match: any, _i: any) => {
return (
<div
role="button"
Expand Down Expand Up @@ -679,7 +684,7 @@ export const BaseTanStackRouterDevtoolsPanel =
<div class={styles().matchDetailsInfoLabel}>
<div>State:</div>
<div class={styles().matchDetailsInfo}>
{routerState().pendingMatches?.find(
{pendingMatches().find(
(d: any) => d.id === activeMatch()?.id,
)
? 'Pending'
Expand Down
Loading
Loading