Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion docs/router/api/router/RouterType.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Remove cached route matches.

Loads all of the currently matched route matches and resolves when they are all loaded and ready to be rendered.

> ⚠️⚠️⚠️ **`router.load()` respects `route.staleTime` and will not forcefully reload a route match if it is still fresh. If you need to forcefully reload a route match, use `router.invalidate()` instead.**
> ⚠️⚠️⚠️ **`router.load()` respects `route.staleTime`: fresh matches stay fresh, but stale matches are revalidated even if their loader key did not change. If you need to forcefully reload all active matches regardless of freshness, use `router.invalidate()` instead.**

- Type: `(opts?: {sync?: boolean}) => Promise<void>`
- if `sync` is true, the promise returned by this function will only resolve once all loaders have finished.
Expand Down
4 changes: 2 additions & 2 deletions docs/router/guide/data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ To control router dependencies and "freshness", TanStack Router provides a pleth

### ⚠️ Some Important Defaults

- By default, the `staleTime` is set to `0`, meaning that the route's data will always be considered stale and will always be reloaded in the background when the route is rematched.
- By default, the `staleTime` is set to `0`, meaning that the route's data is immediately considered stale. Stale matches are reloaded in the background when the route is entered again, when its loader key changes (path params used by the route or `loaderDeps`), or when `router.load()` is called explicitly.
- By default, a previously preloaded route is considered fresh for **30 seconds**. This means if a route is preloaded, then preloaded again within 30 seconds, the second preload will be ignored. This prevents unnecessary preloads from happening too frequently. **When a route is loaded normally, the standard `staleTime` is used.**
- By default, the `gcTime` is set to **30 minutes**, meaning that any route data that has not been accessed in 30 minutes will be garbage collected and removed from the cache.
- `router.invalidate()` will force all active routes to reload their loaders immediately and mark every cached route's data as stale.
Expand Down Expand Up @@ -201,7 +201,7 @@ export const Route = createFileRoute('/posts')({

### Using `staleTime` to control how long data is considered fresh

By default, `staleTime` for navigations is set to `0`ms (and 30 seconds for preloads) which means that the route's data will always be considered stale and will always be reloaded in the background when the route is matched and navigated to.
By default, `staleTime` for navigations is set to `0`ms (and 30 seconds for preloads) which means that the route's data will always be considered stale. When a stale route is entered again, its loader key changes, or `router.load()` is called explicitly, the route will reload in the background.

**This is a good default for most use cases, but you may find that some route data is more static or potentially expensive to load.** In these cases, you can use the `staleTime` option to control how long the route's data is considered fresh for navigations. Let's take a look at an example:

Expand Down
6 changes: 2 additions & 4 deletions packages/react-router/src/fileRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useSearch } from './useSearch'
import { useParams } from './useParams'
import { useNavigate } from './useNavigate'
import { useRouter } from './useRouter'
import { useRouteContext } from './useRouteContext'
import type { UseParamsRoute } from './useParams'
import type { UseMatchRoute } from './useMatch'
import type { UseSearchRoute } from './useSearch'
Expand Down Expand Up @@ -229,10 +230,7 @@ export class LazyRoute<TRoute extends AnyRoute> {
}

useRouteContext: UseRouteContextRoute<TRoute['id']> = (opts) => {
return useMatch({
from: this.options.id,
select: (d: any) => (opts?.select ? opts.select(d.context) : d.context),
}) as any
return useRouteContext({ from: this.options.id, ...(opts as any) }) as any
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

useSearch: UseSearchRoute<TRoute['id']> = (opts) => {
Expand Down
18 changes: 4 additions & 14 deletions packages/react-router/src/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useParams } from './useParams'
import { useSearch } from './useSearch'
import { useNavigate } from './useNavigate'
import { useMatch } from './useMatch'
import { useRouteContext } from './useRouteContext'
import { useRouter } from './useRouter'
import { Link } from './link'
import type {
Expand Down Expand Up @@ -114,10 +115,7 @@ export class RouteApi<
}

useRouteContext: UseRouteContextRoute<TId> = (opts) => {
return useMatch({
from: this.id as any,
select: (d) => (opts?.select ? opts.select(d.context) : d.context),
}) as any
return useRouteContext({ from: this.id as any, ...(opts as any) }) as any
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

useSearch: UseSearchRoute<TId> = (opts) => {
Expand Down Expand Up @@ -272,11 +270,7 @@ export class Route<
}

useRouteContext: UseRouteContextRoute<TId> = (opts?) => {
return useMatch({
...opts,
from: this.id,
select: (d) => (opts?.select ? opts.select(d.context) : d.context),
}) as any
return useRouteContext({ from: this.id, ...(opts as any) }) as any
}

useSearch: UseSearchRoute<TId> = (opts) => {
Expand Down Expand Up @@ -548,11 +542,7 @@ export class RootRoute<
}

useRouteContext: UseRouteContextRoute<RootRouteId> = (opts) => {
return useMatch({
...opts,
from: this.id,
select: (d) => (opts?.select ? opts.select(d.context) : d.context),
}) as any
return useRouteContext({ from: this.id, ...(opts as any) }) as any
}

useSearch: UseSearchRoute<RootRouteId> = (opts) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,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).toBeGreaterThanOrEqual(10) // WARN: this is flaky, and sometimes (rarely) is 12
expect(updates).toBeGreaterThanOrEqual(9) // WARN: this is flaky, and sometimes (rarely) is 12
expect(updates).toBeLessThanOrEqual(13)
})

Expand All @@ -155,7 +155,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(4)
expect(updates).toBe(5)
})

test('sync beforeLoad', async () => {
Expand All @@ -171,7 +171,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).toBeGreaterThanOrEqual(9) // WARN: this is flaky
expect(updates).toBeGreaterThanOrEqual(8) // WARN: this is flaky
expect(updates).toBeLessThanOrEqual(12)
})

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

Expand Down Expand Up @@ -241,8 +241,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(8)
expect(updates).toBeLessThanOrEqual(9)
})

test('navigate, w/ preloaded & sync loaders', async () => {
Expand All @@ -258,7 +258,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(8)
})

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

test('preload a preloaded route w/ async loader', async () => {
Expand All @@ -292,6 +292,6 @@ 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(1)
expect(updates).toBe(2)
})
})
2 changes: 2 additions & 0 deletions packages/router-core/src/Matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ export interface RouteMatch<
/** @internal */
loaderPromise?: ControlledPromise<void>
/** @internal */
loaderKeyChanged?: boolean
/** @internal */
pendingTimeout?: ReturnType<typeof setTimeout>
loadPromise?: ControlledPromise<void>
displayPendingPromise?: Promise<void>
Expand Down
55 changes: 50 additions & 5 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type InnerLoadContext = {
updateMatch: UpdateMatchFn
matches: Array<AnyRouteMatch>
preload?: boolean
forceStaleReload?: boolean
onReady?: () => Promise<void>
sync?: boolean
}
Expand Down Expand Up @@ -166,7 +167,10 @@ const shouldSkipLoader = (
inner: InnerLoadContext,
matchId: string,
): boolean => {
const match = inner.router.getMatch(matchId)!
const match = inner.router.getMatch(matchId)
if (!match) {
return true
}
// upon hydration, we skip the loader if the match has been dehydrated on the server
if (!(isServer ?? inner.router.isServer) && match._nonReactive.dehydrated) {
return true
Expand All @@ -179,6 +183,36 @@ const shouldSkipLoader = (
return false
}

const shouldReloadStaleMatch = (
inner: InnerLoadContext,
match: AnyRouteMatch,
): boolean => {
if (inner.forceStaleReload) {
return true
}

if (match.cause === 'enter') {
return true
}

return !!match._nonReactive.loaderKeyChanged
}

const syncMatchContext = (
inner: InnerLoadContext,
matchId: string,
index: number,
): void => {
const nextContext = buildMatchContext(inner, index)

inner.updateMatch(matchId, (prev) => {
return {
...prev,
context: nextContext,
}
})
}

const handleSerialError = (
inner: InnerLoadContext,
index: number,
Expand Down Expand Up @@ -479,8 +513,6 @@ const executeBeforeLoad = (

batch(() => {
pending()
// Only store __beforeLoadContext here, don't update context yet
// Context will be updated in loadRouteMatch after loader completes
inner.updateMatch(matchId, (prev) => ({
...prev,
__beforeLoadContext: beforeLoadContext,
Expand Down Expand Up @@ -787,8 +819,11 @@ const loadRouteMatch = async (

// If the route is successful and still fresh, just resolve
const { status, invalid } = match
const staleMatchShouldReload =
age > staleAge && shouldReloadStaleMatch(inner, match)
loaderShouldRunAsync =
status === 'success' && (invalid || (shouldReload ?? age > staleAge))
status === 'success' &&
(invalid || (shouldReload ?? staleMatchShouldReload))
if (preload && route.options.preload === false) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Do nothing
} else if (loaderShouldRunAsync && !inner.sync) {
Expand All @@ -808,6 +843,8 @@ const loadRouteMatch = async (
})()
} else if (status !== 'success' || (loaderShouldRunAsync && inner.sync)) {
await runLoader(inner, matchPromises, matchId, index, route)
} else {
syncMatchContext(inner, matchId, index)
}
}

Expand All @@ -817,8 +854,15 @@ const loadRouteMatch = async (
const route = inner.router.looseRoutesById[routeId]!

if (shouldSkipLoader(inner, matchId)) {
const match = inner.router.getMatch(matchId)
if (!match) {
return inner.matches[index]!
}

syncMatchContext(inner, matchId, index)
Comment thread
schiller-manuel marked this conversation as resolved.

if (isServer ?? inner.router.isServer) {
return inner.router.getMatch(matchId)!
return match
Comment thread
schiller-manuel marked this conversation as resolved.
Outdated
}
} else {
const prevMatch = inner.router.getMatch(matchId)! // This is where all of the stale-while-revalidate magic happens
Expand Down Expand Up @@ -886,6 +930,7 @@ export async function loadMatches(arg: {
location: ParsedLocation
matches: Array<AnyRouteMatch>
preload?: boolean
forceStaleReload?: boolean
onReady?: () => Promise<void>
updateMatch: UpdateMatchFn
sync?: boolean
Expand Down
10 changes: 10 additions & 0 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1513,6 +1513,9 @@ export class RouterCore<
const existingMatch = this.getMatch(matchId)

const previousMatch = previousMatchesByRouteId.get(route.id)
const loaderKeyChanged = previousMatch
? previousMatch.id !== matchId
: false

const strictParams = existingMatch?._strictParams ?? usedParams

Expand Down Expand Up @@ -1545,6 +1548,10 @@ export class RouterCore<
if (existingMatch) {
match = {
...existingMatch,
_nonReactive: {
...existingMatch._nonReactive,
loaderKeyChanged,
},
cause,
params: previousMatch?.params ?? routeParams,
_strictParams: strictParams,
Expand Down Expand Up @@ -1583,6 +1590,7 @@ export class RouterCore<
__routeContext: undefined,
_nonReactive: {
loadPromise: createControlledPromise(),
loaderKeyChanged,
},
__beforeLoadContext: undefined,
context: {},
Expand Down Expand Up @@ -2364,6 +2372,7 @@ export class RouterCore<
let redirect: AnyRedirect | undefined
let notFound: NotFoundError | undefined
let loadPromise: Promise<void>
const previousLocation = this.state.resolvedLocation ?? this.state.location

// eslint-disable-next-line prefer-const
loadPromise = new Promise<void>((resolve) => {
Expand Down Expand Up @@ -2394,6 +2403,7 @@ export class RouterCore<
await loadMatches({
router: this,
sync: opts?.sync,
forceStaleReload: previousLocation.href === next.href,
matches: this.state.pendingMatches as Array<AnyRouteMatch>,
location: next,
updateMatch: this.updateMatch,
Expand Down
Loading
Loading