Skip to content
Open
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
144 changes: 85 additions & 59 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,14 @@ type RouterStateStore<TState> = {
setState: (updater: (prev: TState) => TState) => void
}

type DevtoolsMatchesState = {
pendingMatches?: Array<AnyRouteMatch>
cachedMatches: Array<AnyRouteMatch>
}

const filterRedirectedMatches = (matches: Array<AnyRouteMatch>) =>
matches.filter((match) => match.status !== 'redirected')

function createServerStore<TState>(
initialState: TState,
): RouterStateStore<TState> {
Expand Down Expand Up @@ -946,6 +954,10 @@ export class RouterCore<

// Must build in constructor
__store!: Store<RouterState<TRouteTree>>
__storeDevtoolsMatches = new Store<DevtoolsMatchesState>({
pendingMatches: [],
cachedMatches: [],
})
options!: PickAsRequired<
RouterOptions<
TRouteTree,
Expand All @@ -961,8 +973,6 @@ export class RouterCore<
origin?: string
latestLocation!: ParsedLocation<FullSearchSchema<TRouteTree>>
pendingBuiltLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
private pendingMatchesInternal?: Array<AnyRouteMatch>
private cachedMatchesInternal: Array<AnyRouteMatch> = []
basepath!: string
routeTree!: TRouteTree
routesById!: RoutesById<TRouteTree>
Expand Down Expand Up @@ -1017,25 +1027,6 @@ export class RouterCore<
return !!this.options.isPrerendering
}

private setCachedMatchesInternal = (matches: Array<AnyRouteMatch>) => {
this.cachedMatchesInternal = matches.filter(
(match) => match.status !== 'redirected',
)
}

private updateCachedMatchesInternal = (
updater: (matches: Array<AnyRouteMatch>) => Array<AnyRouteMatch>,
) => {
this.setCachedMatchesInternal(updater(this.cachedMatchesInternal))
}

private appendCachedMatchesInternal = (matches: Array<AnyRouteMatch>) => {
if (!matches.length) {
return
}
this.setCachedMatchesInternal([...this.cachedMatchesInternal, ...matches])
}

update: UpdateFn<
TRouteTree,
TTrailingSlashOption,
Expand Down Expand Up @@ -1774,7 +1765,7 @@ export class RouterCore<
(match) => match.isFetching === 'loader',
)
const matchesToCancelArray = new Set([
...(this.pendingMatchesInternal ?? []),
...(this.__storeDevtoolsMatches.state.pendingMatches ?? []),
...currentPendingMatches,
...currentLoadingMatches,
])
Expand Down Expand Up @@ -2351,12 +2342,14 @@ export class RouterCore<

// Match the routes
const pendingMatches = this.matchRoutes(this.latestLocation)
this.pendingMatchesInternal = pendingMatches
const pendingMatchIds = new Set(pendingMatches.map((match) => match.id))

this.updateCachedMatchesInternal((matches) =>
matches.filter((match) => !pendingMatchIds.has(match.id)),
)
this.__storeDevtoolsMatches.setState((s) => ({
...s,
pendingMatches,
cachedMatches: s.cachedMatches.filter(
(match) => !pendingMatchIds.has(match.id),
),
}))

// Ingest the new matches
this.__store.setState((s) => ({
Expand Down Expand Up @@ -2402,7 +2395,7 @@ export class RouterCore<
await loadMatches({
router: this,
sync: opts?.sync,
matches: this.pendingMatchesInternal ?? [],
matches: this.__storeDevtoolsMatches.state.pendingMatches ?? [],
location: next,
updateMatch: this.updateMatch,
// eslint-disable-next-line @typescript-eslint/require-await
Expand All @@ -2422,7 +2415,8 @@ export class RouterCore<
this.__store.setState((s) => {
const previousMatches = s.matches
const newMatches =
this.pendingMatchesInternal || s.matches
this.__storeDevtoolsMatches.state.pendingMatches ||
s.matches

exitingMatches = previousMatches.filter(
(match) => !newMatches.some((d) => d.id === match.id),
Expand All @@ -2442,14 +2436,18 @@ export class RouterCore<
matches: newMatches,
}
})
this.appendCachedMatchesInternal(
exitingMatches.filter(
(match) =>
match.status !== 'error' &&
match.status !== 'notFound',
),
)
this.pendingMatchesInternal = undefined
this.__storeDevtoolsMatches.setState((s) => ({
...s,
pendingMatches: undefined,
cachedMatches: filterRedirectedMatches([
...s.cachedMatches,
...exitingMatches.filter(
(match) =>
match.status !== 'error' &&
match.status !== 'notFound',
),
]),
}))
this.clearExpiredCache()
})

Expand Down Expand Up @@ -2591,10 +2589,17 @@ export class RouterCore<

updateMatch: UpdateMatchFn = (id, updater) => {
this.startTransition(() => {
if (this.pendingMatchesInternal?.some((d) => d.id === id)) {
this.pendingMatchesInternal = this.pendingMatchesInternal.map((d) =>
d.id === id ? updater(d) : d,
if (
this.__storeDevtoolsMatches.state.pendingMatches?.some(
(d) => d.id === id,
)
) {
this.__storeDevtoolsMatches.setState((s) => ({
...s,
pendingMatches: s.pendingMatches?.map((d) =>
d.id === id ? updater(d) : d,
),
}))
return
}

Expand All @@ -2606,19 +2611,24 @@ export class RouterCore<
return
}

if (this.cachedMatchesInternal.some((d) => d.id === id)) {
this.updateCachedMatchesInternal((matches) =>
matches.map((d) => (d.id === id ? updater(d) : d)),
)
if (
this.__storeDevtoolsMatches.state.cachedMatches.some((d) => d.id === id)
) {
this.__storeDevtoolsMatches.setState((s) => ({
...s,
cachedMatches: filterRedirectedMatches(
s.cachedMatches.map((d) => (d.id === id ? updater(d) : d)),
),
}))
}
})
}

getMatch: GetMatchFn = (matchId: string): AnyRouteMatch | undefined => {
const findFn = (d: { id: string }) => d.id === matchId
return (
this.cachedMatchesInternal.find(findFn) ??
this.pendingMatchesInternal?.find(findFn) ??
this.__storeDevtoolsMatches.state.cachedMatches.find(findFn) ??
this.__storeDevtoolsMatches.state.pendingMatches?.find(findFn) ??
this.state.matches.find(findFn)
)
}
Expand Down Expand Up @@ -2655,8 +2665,11 @@ export class RouterCore<
return d
}

this.pendingMatchesInternal = this.pendingMatchesInternal?.map(invalidate)
this.updateCachedMatchesInternal((matches) => matches.map(invalidate))
this.__storeDevtoolsMatches.setState((s) => ({
...s,
pendingMatches: s.pendingMatches?.map(invalidate),
cachedMatches: filterRedirectedMatches(s.cachedMatches.map(invalidate)),
}))

this.__store.setState((s) => ({
...s,
Expand Down Expand Up @@ -2716,11 +2729,19 @@ export class RouterCore<
clearCache: ClearCacheFn<this> = (opts) => {
const filter = opts?.filter
if (filter !== undefined) {
this.updateCachedMatchesInternal((matches) =>
matches.filter((m) => !filter(m as MakeRouteMatchUnion<this>)),
)
this.__storeDevtoolsMatches.setState((s) => ({
...s,
cachedMatches: filterRedirectedMatches(
s.cachedMatches.filter(
(m) => !filter(m as MakeRouteMatchUnion<this>),
),
),
}))
} else {
this.setCachedMatchesInternal([])
this.__storeDevtoolsMatches.setState((s) => ({
...s,
cachedMatches: [],
}))
}
}

Expand Down Expand Up @@ -2767,20 +2788,25 @@ export class RouterCore<
})

const activeMatchIds = new Set(
[...this.state.matches, ...(this.pendingMatchesInternal ?? [])].map(
(d) => d.id,
),
[
...this.state.matches,
...(this.__storeDevtoolsMatches.state.pendingMatches ?? []),
].map((d) => d.id),
)

const loadedMatchIds = new Set([
...activeMatchIds,
...this.cachedMatchesInternal.map((d) => d.id),
...this.__storeDevtoolsMatches.state.cachedMatches.map((d) => d.id),
])

// If the matches are already loaded, we need to add them to the cachedMatches
this.appendCachedMatchesInternal(
matches.filter((match) => !loadedMatchIds.has(match.id)),
)
this.__storeDevtoolsMatches.setState((s) => ({
...s,
cachedMatches: filterRedirectedMatches([
...s.cachedMatches,
...matches.filter((match) => !loadedMatchIds.has(match.id)),
]),
}))

try {
matches = await loadMatches({
Expand Down
110 changes: 110 additions & 0 deletions packages/router-core/tests/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,116 @@ describe('routeId in context options', () => {
})
})

describe('internal devtools matches store', () => {
test('tracks pending during navigation and clears it after commit', async () => {
const rootRoute = new BaseRootRoute({})
const fooRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/foo',
loader: async () => {
await sleep(20)
return { page: 'foo' }
},
})
const barRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/bar',
loader: async () => {
await sleep(20)
return { page: 'bar' }
},
})
const routeTree = rootRoute.addChildren([fooRoute, barRoute])
const router = new RouterCore({
routeTree,
history: createMemoryHistory(),
})

await router.navigate({ to: '/foo' })

const navigation = router.navigate({ to: '/bar' })
await Promise.resolve()

expect(
router.__storeDevtoolsMatches.state.pendingMatches?.some(
(match) => match.routeId === '/bar',
),
).toBe(true)

await navigation

expect(router.__storeDevtoolsMatches.state.pendingMatches).toBeUndefined()
expect(
router.__storeDevtoolsMatches.state.cachedMatches.some(
(match) => match.routeId === '/foo',
),
).toBe(true)
})

test('tracks preload cache entries and clearCache', async () => {
const rootRoute = new BaseRootRoute({})
const fooRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/foo',
loader: async () => {
await sleep(5)
return { page: 'foo' }
},
})
const routeTree = rootRoute.addChildren([fooRoute])
const router = new RouterCore({
routeTree,
history: createMemoryHistory(),
})

await router.preloadRoute({ to: '/foo' })

expect(
router.__storeDevtoolsMatches.state.cachedMatches.some(
(match) => match.routeId === '/foo',
),
).toBe(true)

router.clearCache()

expect(router.__storeDevtoolsMatches.state.cachedMatches).toEqual([])
})

test('invalidates cached entries via invalidate(filter)', async () => {
const rootRoute = new BaseRootRoute({})
const fooRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/foo',
loader: async () => {
await sleep(5)
return { page: 'foo' }
},
})
const routeTree = rootRoute.addChildren([fooRoute])
const router = new RouterCore({
routeTree,
history: createMemoryHistory(),
})

await router.preloadRoute({ to: '/foo' })

await router.invalidate({
filter: (match) => match.routeId === '/foo',
forcePending: true,
})

expect(router.__storeDevtoolsMatches.state.cachedMatches).toEqual(
expect.arrayContaining([
expect.objectContaining({
routeId: '/foo',
invalid: true,
status: 'pending',
}),
]),
)
})
})

function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
Loading
Loading