diff --git a/docs/framework/react/guide/data-loading.md b/docs/framework/react/guide/data-loading.md
index dd443825451..1ca26c7189f 100644
--- a/docs/framework/react/guide/data-loading.md
+++ b/docs/framework/react/guide/data-loading.md
@@ -452,7 +452,7 @@ export const Route = createFileRoute('/posts')({
})
```
-The `reset` function can be used to show a `retry` button. If you want to retry the route loading, you need to additionally call `router.invalidate()`:
+The `reset` function can be used to allow the user to retry rendering the error boundaries normal children:
```tsx
// routes/posts.tsx
@@ -468,7 +468,31 @@ export const Route = createFileRoute('/posts')({
onClick={() => {
// Reset the router error boundary
reset()
- // Invalidate the route to reload the loader
+ }}
+ >
+ retry
+
+
+ )
+ },
+})
+```
+
+If the error was the result of a route load, you should instead call `router.invalidate()`, which will coordinate both a router reload and an error boundary reset:
+
+```tsx
+// routes/posts.tsx
+export const Route = createFileRoute('/posts')({
+ loader: () => fetchPosts(),
+ errorComponent: ({ error, reset }) => {
+ const router = useRouter()
+
+ return (
+
+ {error.message}
+
{
+ // Invalidate the route to reload the loader, which will also reset the error boundary
router.invalidate()
}}
>
diff --git a/docs/framework/react/guide/external-data-loading.md b/docs/framework/react/guide/external-data-loading.md
index b764ac85c3f..64b8f88e9a7 100644
--- a/docs/framework/react/guide/external-data-loading.md
+++ b/docs/framework/react/guide/external-data-loading.md
@@ -123,9 +123,7 @@ export const Route = createFileRoute('/posts')({
{error.message}
{
- // Reset the router error boundary
- reset()
- // Invalidate the route to reload the loader
+ // Invalidate the route to reload the loader, and reset any router error boundaries
router.invalidate()
}}
>
diff --git a/docs/framework/react/guide/server-functions.md b/docs/framework/react/guide/server-functions.md
index d9286913d5d..8628fc2ef50 100644
--- a/docs/framework/react/guide/server-functions.md
+++ b/docs/framework/react/guide/server-functions.md
@@ -38,7 +38,7 @@ function Component() {
if (!el) return
el.addEventListener('click', async () => {
const result = await yourFn()
- console.log(result)
+ console.info(result)
})
}
@@ -56,7 +56,7 @@ Or from another server function:
```typescript
const yourFn2 = createServerFn('POST', async () => {
const result = await yourFn()
- console.log(result)
+ console.info(result)
})
```
@@ -140,7 +140,7 @@ const yourFn = createServerFn('POST', async () => {
// Server-side code lives here
})
-console.log(yourFn.url)
+console.info(yourFn.url)
```
And pass this to the `action` attribute of the form:
diff --git a/examples/react/basic-react-query-file-based/src/routes/posts.$postId.tsx b/examples/react/basic-react-query-file-based/src/routes/posts.$postId.tsx
index 06a692c9626..78455fdcdf7 100644
--- a/examples/react/basic-react-query-file-based/src/routes/posts.$postId.tsx
+++ b/examples/react/basic-react-query-file-based/src/routes/posts.$postId.tsx
@@ -35,7 +35,6 @@ export function PostErrorComponent({ error, reset }: ErrorComponentProps) {
{
- reset()
router.invalidate()
}}
>
diff --git a/examples/react/basic-react-query/src/main.tsx b/examples/react/basic-react-query/src/main.tsx
index 62daae5b633..69edfd35c33 100644
--- a/examples/react/basic-react-query/src/main.tsx
+++ b/examples/react/basic-react-query/src/main.tsx
@@ -201,7 +201,6 @@ function PostErrorComponent({ error, reset }: ErrorComponentProps) {
{
- reset()
router.invalidate()
}}
>
diff --git a/examples/react/start-trellaux/app/components/Column.tsx b/examples/react/start-trellaux/app/components/Column.tsx
index 3c363380608..66fd02309f7 100644
--- a/examples/react/start-trellaux/app/components/Column.tsx
+++ b/examples/react/start-trellaux/app/components/Column.tsx
@@ -3,7 +3,7 @@ import invariant from 'tiny-invariant'
import { twMerge } from 'tailwind-merge'
import { flushSync } from 'react-dom'
-import { CONTENT_TYPES, INTENTS, type RenderedItem } from '../types'
+import { CONTENT_TYPES, type RenderedItem } from '../types'
import { Icon } from '../icons/icons'
import {
useDeleteColumnMutation,
@@ -119,8 +119,6 @@ export const Column = forwardRef(
acceptColumnDrop === 'left' ? previousOrder : nextOrder
const moveOrder = (droppedOrder + order) / 2
- console.log('moveOrder', moveOrder)
-
updateColumnMutation.mutate({
boardId,
id: transfer.id,
diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts
index b91c680a0e8..0c66ec93003 100644
--- a/packages/history/src/index.ts
+++ b/packages/history/src/index.ts
@@ -7,6 +7,7 @@ export interface NavigateOptions {
}
export interface RouterHistory {
location: HistoryLocation
+ subscribers: Set<() => void>
subscribe: (cb: () => void) => () => void
push: (path: string, state?: any, navigateOpts?: NavigateOptions) => void
replace: (path: string, state?: any, navigateOpts?: NavigateOptions) => void
@@ -100,6 +101,7 @@ export function createHistory(opts: {
get location() {
return location
},
+ subscribers,
subscribe: (cb: () => void) => {
subscribers.add(cb)
@@ -269,6 +271,10 @@ export function createBrowserHistory(opts?: {
}
if (!scheduled) {
+ if (process.env.NODE_ENV === 'test') {
+ flush()
+ return
+ }
// Schedule an update to the browser history
scheduled = Promise.resolve().then(() => flush())
}
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 703aa96a33d..d74d62081d9 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -34,7 +34,7 @@
"test:types:ts54": "node ../../node_modules/typescript54/lib/tsc.js -p tsconfig.legacy.json",
"test:types:ts55": "tsc",
"test:unit": "vitest",
- "test:unit:dev": "pnpm run test:unit --watch",
+ "test:unit:dev": "pnpm run test:unit --watch --hideSkippedTests",
"test:build": "publint --strict",
"build": "vite build"
},
diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx
index 15f170be5e6..68197b2eb6e 100644
--- a/packages/react-router/src/CatchBoundary.tsx
+++ b/packages/react-router/src/CatchBoundary.tsx
@@ -3,7 +3,7 @@ import type { ErrorRouteComponent } from './route'
import type { ErrorInfo } from 'react'
export function CatchBoundary(props: {
- getResetKey: () => string
+ getResetKey: () => number | string
children: React.ReactNode
errorComponent?: ErrorRouteComponent
onCatch?: (error: Error, errorInfo: ErrorInfo) => void
@@ -29,7 +29,7 @@ export function CatchBoundary(props: {
}
class CatchBoundaryImpl extends React.Component<{
- getResetKey: () => string
+ getResetKey: () => number | string
children: (props: {
error: Error | null
reset: () => void
@@ -64,8 +64,12 @@ class CatchBoundaryImpl extends React.Component<{
}
}
render() {
+ // If the resetKey has changed, don't render the error
return this.props.children({
- error: this.state.error,
+ error:
+ this.state.resetKey !== this.props.getResetKey()
+ ? null
+ : this.state.error,
reset: () => {
this.reset()
},
diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx
index 5ca75a18e52..dacea3e81f5 100644
--- a/packages/react-router/src/Match.tsx
+++ b/packages/react-router/src/Match.tsx
@@ -63,7 +63,7 @@ export function Match({ matchId }: { matchId: string }) {
: SafeFragment
const resetKey = useRouterState({
- select: (s) => s.resolvedLocation.state.key!,
+ select: (s) => s.loadedAt,
})
return (
@@ -108,23 +108,44 @@ function MatchInner({ matchId }: { matchId: string }): any {
const route = router.routesById[routeId]!
- const [match, matchIndex] = useRouterState({
+ const matchIndex = useRouterState({
+ select: (s) => {
+ return s.matches.findIndex((d) => d.id === matchId)
+ },
+ })
+
+ const match = useRouterState({
select: (s) => {
- const matchIndex = s.matches.findIndex((d) => d.id === matchId)
const match = s.matches[matchIndex]!
- return [
- pick(match, [
- 'id',
- 'status',
- 'error',
- 'loadPromise',
- 'minPendingPromise',
- ]),
- matchIndex,
- ] as const
+ return pick(match, [
+ 'id',
+ 'status',
+ 'error',
+ 'loadPromise',
+ 'minPendingPromise',
+ ])
},
})
+ // function useChangedDiff(value: any) {
+ // const ref = React.useRef(value)
+ // const changed = ref.current !== value
+ // if (changed) {
+ // console.log(
+ // 'Changed:',
+ // value,
+ // Object.fromEntries(
+ // Object.entries(value).filter(
+ // ([key, val]) => val !== ref.current[key],
+ // ),
+ // ),
+ // )
+ // }
+ // ref.current = value
+ // }
+
+ // useChangedDiff(match)
+
const RouteErrorComponent =
(route.options.errorComponent ?? router.options.defaultErrorComponent) ||
ErrorComponent
@@ -190,36 +211,24 @@ function MatchInner({ matchId }: { matchId: string }): any {
if (pendingMinMs && !match.minPendingPromise) {
// Create a promise that will resolve after the minPendingMs
- match.minPendingPromise = createControlledPromise()
-
if (!router.isServer) {
+ const minPendingPromise = createControlledPromise()
+
Promise.resolve().then(() => {
- router.__store.setState((s) => ({
- ...s,
- matches: s.matches.map((d) =>
- d.id === match.id
- ? { ...d, minPendingPromise: createControlledPromise() }
- : d,
- ),
+ router.updateMatch(match.id, (prev) => ({
+ ...prev,
+ minPendingPromise,
}))
})
setTimeout(() => {
+ minPendingPromise.resolve()
+
// We've handled the minPendingPromise, so we can delete it
- router.__store.setState((s) => {
- return {
- ...s,
- matches: s.matches.map((d) =>
- d.id === match.id
- ? {
- ...d,
- minPendingPromise:
- (d.minPendingPromise?.resolve(), undefined),
- }
- : d,
- ),
- }
- })
+ router.updateMatch(match.id, (prev) => ({
+ ...prev,
+ minPendingPromise: undefined,
+ }))
}, pendingMinMs)
}
}
diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx
index d842b7507ea..eb7ef20135d 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -47,8 +47,10 @@ export interface RouteMatch<
paramsError: unknown
searchError: unknown
updatedAt: number
- loadPromise: ControlledPromise
- loaderPromise: Promise
+ componentsPromise?: Promise>
+ loadPromise?: ControlledPromise
+ beforeLoadPromise?: ControlledPromise
+ loaderPromise?: ControlledPromise
loaderData?: TLoaderData
routeContext: TRouteContext
context: TAllContext
@@ -132,7 +134,7 @@ function MatchesInner() {
})
const resetKey = useRouterState({
- select: (s) => s.resolvedLocation.state.key!,
+ select: (s) => s.loadedAt,
})
return (
diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx
index 3cfec5ee494..6e5f038753d 100644
--- a/packages/react-router/src/RouterProvider.tsx
+++ b/packages/react-router/src/RouterProvider.tsx
@@ -104,17 +104,6 @@ export function RouterProvider<
)
}
-export function getRouteMatch(
- state: RouterState,
- id: string,
-): undefined | MakeRouteMatch {
- return [
- ...state.cachedMatches,
- ...(state.pendingMatches ?? []),
- ...state.matches,
- ].find((d) => d.id === id)
-}
-
export type RouterProps<
TRouter extends AnyRouter = RegisteredRouter,
TDehydrated extends Record = Record,
diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx
index 639ad14be54..d742cca84c1 100644
--- a/packages/react-router/src/Transitioner.tsx
+++ b/packages/react-router/src/Transitioner.tsx
@@ -2,6 +2,7 @@ import * as React from 'react'
import { pick, useLayoutEffect, usePrevious } from './utils'
import { useRouter } from './useRouter'
import { useRouterState } from './useRouterState'
+import { trimPathRight } from '.'
export function Transitioner() {
const router = useRouter()
@@ -40,7 +41,10 @@ export function Transitioner() {
state: true,
})
- if (router.state.location.href !== nextLocation.href) {
+ if (
+ trimPathRight(router.latestLocation.href) !==
+ trimPathRight(nextLocation.href)
+ ) {
router.commitLocation({ ...nextLocation, replace: true })
}
diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx
index d6cf02af991..76990d9c37a 100644
--- a/packages/react-router/src/index.tsx
+++ b/packages/react-router/src/index.tsx
@@ -207,7 +207,6 @@ export {
export {
RouterProvider,
RouterContextProvider,
- getRouteMatch,
type RouterProps,
type CommitLocationOptions,
type MatchLocation,
diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts
index 05a5d67f4c7..3148ec7c334 100644
--- a/packages/react-router/src/route.ts
+++ b/packages/react-router/src/route.ts
@@ -638,6 +638,7 @@ export class Route<
router?: AnyRouter
rank!: number
lazyFn?: () => Promise>
+ _lazyPromise?: Promise
/**
* @deprecated Use the `createRoute` function instead.
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index 42e2cc4e22e..e1f2848bedf 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -12,7 +12,6 @@ import {
pick,
replaceEqualDeep,
} from './utils'
-import { getRouteMatch } from './RouterProvider'
import {
cleanPath,
interpolatePath,
@@ -365,6 +364,12 @@ export interface RouterOptions<
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#trailingslash-property)
*/
trailingSlash?: TTrailingSlashOption
+ /**
+ * Defaults to `typeof document !== 'undefined'`
+ * While usually automatic, sometimes it can be useful to force the router into a server-side state, e.g. when using the router in a non-browser environment that has access to a global.document object.
+ * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#isserver property)
+ */
+ isServer?: boolean
}
export interface RouterTransformer {
@@ -381,6 +386,7 @@ export interface RouterState<
TRouteMatch = MakeRouteMatch,
> {
status: 'pending' | 'idle'
+ loadedAt: number
isLoading: boolean
isTransitioning: boolean
matches: Array
@@ -517,7 +523,6 @@ export class Router<
)}`
resetNextScroll = true
shouldViewTransition?: boolean = undefined
- latestLoadPromise: Promise = Promise.resolve()
subscribers = new Set>()
dehydratedData?: TDehydrated
viewTransitionPromise?: ControlledPromise
@@ -561,6 +566,7 @@ export class Router<
routesById!: RoutesById
routesByPath!: RoutesByPath
flatRoutes!: Array
+ isServer!: boolean
/**
* @deprecated Use the `createRouter` function instead
@@ -588,8 +594,6 @@ export class Router<
}
}
- isServer = typeof document === 'undefined'
-
// These are default implementations that can optionally be overridden
// by the router provider once rendered. We provide these so that the
// router can be used in a non-react environment if necessary
@@ -615,6 +619,8 @@ export class Router<
...newOptions,
}
+ this.isServer = this.options.isServer ?? typeof document === 'undefined'
+
if (
!this.basepath ||
(newOptions.basepath && newOptions.basepath !== previousOptions.basepath)
@@ -637,11 +643,11 @@ export class Router<
) {
this.history =
this.options.history ??
- (typeof document !== 'undefined'
- ? createBrowserHistory()
- : createMemoryHistory({
- initialEntries: [this.options.basepath || '/'],
- }))
+ (this.isServer
+ ? createMemoryHistory({
+ initialEntries: [this.basepath || '/'],
+ })
+ : createBrowserHistory())
this.latestLocation = this.parseLocation()
}
@@ -808,12 +814,6 @@ export class Router<
})
}
- checkLatest = (promise: Promise): void => {
- if (this.latestLoadPromise !== promise) {
- throw this.latestLoadPromise
- }
- }
-
parseLocation = (
previousLocation?: ParsedLocation>,
): ParsedLocation> => {
@@ -827,7 +827,7 @@ export class Router<
const searchStr = this.options.stringifySearch(parsedSearch)
return {
- pathname: pathname,
+ pathname,
searchStr,
search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any,
hash: hash.split('#').reverse()[0] ?? '',
@@ -1044,7 +1044,7 @@ export class Router<
// Waste not, want not. If we already have a match for this route,
// reuse it. This is important for layout routes, which might stick
// around between navigation actions that only change leaf routes.
- const existingMatch = getRouteMatch(this.state, matchId)
+ const existingMatch = this.getMatch(matchId)
const cause = this.state.matches.find((d) => d.id === matchId)
? 'stay'
@@ -1060,17 +1060,10 @@ export class Router<
}
} else {
const status =
- route.options.loader || route.options.beforeLoad
+ route.options.loader || route.options.beforeLoad || route.lazyFn
? 'pending'
: 'success'
- const loadPromise = createControlledPromise()
-
- // If it's already a success, resolve the load promise
- if (status === 'success') {
- loadPromise.resolve()
- }
-
match = {
id: matchId,
index,
@@ -1080,12 +1073,10 @@ export class Router<
updatedAt: Date.now(),
search: {} as any,
searchError: undefined,
- status: 'pending',
+ status,
isFetching: false,
error: undefined,
paramsError: parseErrors[index],
- loaderPromise: Promise.resolve(),
- loadPromise,
routeContext: undefined!,
context: undefined!,
abortController: new AbortController(),
@@ -1097,6 +1088,7 @@ export class Router<
links: route.options.links?.(),
scripts: route.options.scripts?.(),
staticData: route.options.staticData || {},
+ loadPromise: createControlledPromise(),
}
}
@@ -1134,7 +1126,7 @@ export class Router<
}
cancelMatch = (id: string) => {
- getRouteMatch(this.state, id)?.abortController.abort()
+ this.getMatch(id)?.abortController.abort()
}
cancelMatches = () => {
@@ -1367,12 +1359,13 @@ export class Router<
return buildWithMatches(opts)
}
- commitLocation = async ({
- startTransition,
+ commitLocationPromise: undefined | ControlledPromise
+
+ commitLocation = ({
viewTransition,
ignoreBlocker,
...next
- }: ParsedLocation & CommitLocationOptions) => {
+ }: ParsedLocation & CommitLocationOptions): Promise => {
const isSameState = () => {
// `state.key` is ignored but may still be provided when navigating,
// temporarily add the previous key to the next state so it doesn't affect
@@ -1386,6 +1379,11 @@ export class Router<
const isSameUrl = this.latestLocation.href === next.href
+ const previousCommitPromise = this.commitLocationPromise
+ this.commitLocationPromise = createControlledPromise(() => {
+ previousCommitPromise?.resolve()
+ })
+
// Don't commit to history if nothing changed
if (isSameUrl && isSameState()) {
this.load()
@@ -1432,13 +1430,16 @@ export class Router<
this.resetNextScroll = next.resetScroll ?? true
- return this.latestLoadPromise
+ if (!this.history.subscribers.size) {
+ this.load()
+ }
+
+ return this.commitLocationPromise
}
buildAndCommitLocation = ({
replace,
resetScroll,
- startTransition,
viewTransition,
ignoreBlocker,
...rest
@@ -1446,7 +1447,6 @@ export class Router<
const location = this.buildLocation(rest as any)
return this.commitLocation({
...location,
- startTransition,
viewTransition,
replace,
resetScroll,
@@ -1471,7 +1471,7 @@ export class Router<
invariant(
!isExternal,
- 'Attempting to navigate to external url with this.navigate!',
+ 'Attempting to navigate to external url with router.navigate!',
)
return this.buildAndCommitLocation({
@@ -1482,156 +1482,174 @@ export class Router<
})
}
+ latestLoadPromise: undefined | Promise
+
load = async (): Promise => {
this.latestLocation = this.parseLocation(this.latestLocation)
- if (this.state.location === this.latestLocation) {
- return
- }
+ this.__store.setState((s) => ({
+ ...s,
+ loadedAt: Date.now(),
+ }))
- const promise = createControlledPromise()
- this.latestLoadPromise = promise
let redirect: ResolvedRedirect | undefined
let notFound: NotFoundError | undefined
- this.startReactTransition(async () => {
- try {
- const next = this.latestLocation
- const prevLocation = this.state.resolvedLocation
- const pathDidChange = prevLocation.href !== next.href
-
- // Cancel any pending matches
- this.cancelMatches()
-
- let pendingMatches!: Array
-
- this.__store.batch(() => {
- // this call breaks a route context of destination route after a redirect
- // we should be fine not eagerly calling this since we call it later
- // this.cleanCache()
-
- // Match the routes
- pendingMatches = this.matchRoutes(next.pathname, next.search)
+ const loadPromise = new Promise((resolve) => {
+ this.startReactTransition(async () => {
+ try {
+ const next = this.latestLocation
+ const prevLocation = this.state.resolvedLocation
+ const pathDidChange = prevLocation.href !== next.href
+
+ // Cancel any pending matches
+ this.cancelMatches()
+
+ let pendingMatches!: Array
+
+ this.__store.batch(() => {
+ // this call breaks a route context of destination route after a redirect
+ // we should be fine not eagerly calling this since we call it later
+ // this.cleanCache()
+
+ // Match the routes
+ pendingMatches = this.matchRoutes(next.pathname, next.search)
+
+ // Ingest the new matches
+ this.__store.setState((s) => ({
+ ...s,
+ status: 'pending',
+ isLoading: true,
+ location: next,
+ pendingMatches,
+ // If a cached moved to pendingMatches, remove it from cachedMatches
+ cachedMatches: s.cachedMatches.filter((d) => {
+ return !pendingMatches.find((e) => e.id === d.id)
+ }),
+ }))
+ })
- // Ingest the new matches
- this.__store.setState((s) => ({
- ...s,
- status: 'pending',
- isLoading: true,
- location: next,
- pendingMatches,
- // If a cached moved to pendingMatches, remove it from cachedMatches
- cachedMatches: s.cachedMatches.filter((d) => {
- return !pendingMatches.find((e) => e.id === d.id)
- }),
- }))
- })
+ if (!this.state.redirect) {
+ this.emit({
+ type: 'onBeforeNavigate',
+ fromLocation: prevLocation,
+ toLocation: next,
+ pathChanged: pathDidChange,
+ })
+ }
- if (!this.state.redirect) {
this.emit({
- type: 'onBeforeNavigate',
+ type: 'onBeforeLoad',
fromLocation: prevLocation,
toLocation: next,
pathChanged: pathDidChange,
})
- }
-
- this.emit({
- type: 'onBeforeLoad',
- fromLocation: prevLocation,
- toLocation: next,
- pathChanged: pathDidChange,
- })
-
- await this.loadMatches({
- matches: pendingMatches,
- location: next,
- checkLatest: () => this.checkLatest(promise),
- onReady: async () => {
- await this.startViewTransition(async () => {
- // this.viewTransitionPromise = createControlledPromise()
-
- // Commit the pending matches. If a previous match was
- // removed, place it in the cachedMatches
- let exitingMatches!: Array
- let enteringMatches!: Array
- let stayingMatches!: Array
-
- this.__store.batch(() => {
- this.__store.setState((s) => {
- const previousMatches = s.matches
- const newMatches = s.pendingMatches || s.matches
-
- exitingMatches = previousMatches.filter(
- (match) => !newMatches.find((d) => d.id === match.id),
- )
- enteringMatches = newMatches.filter(
- (match) => !previousMatches.find((d) => d.id === match.id),
- )
- stayingMatches = previousMatches.filter((match) =>
- newMatches.find((d) => d.id === match.id),
- )
- return {
- ...s,
- isLoading: false,
- matches: newMatches,
- pendingMatches: undefined,
- cachedMatches: [
- ...s.cachedMatches,
- ...exitingMatches.filter((d) => d.status !== 'error'),
- ],
- }
+ await this.loadMatches({
+ matches: pendingMatches,
+ location: next,
+ // eslint-disable-next-line ts/require-await
+ onReady: async () => {
+ // eslint-disable-next-line ts/require-await
+ this.startViewTransition(async () => {
+ // this.viewTransitionPromise = createControlledPromise()
+
+ // Commit the pending matches. If a previous match was
+ // removed, place it in the cachedMatches
+ let exitingMatches!: Array
+ let enteringMatches!: Array
+ let stayingMatches!: Array
+
+ this.__store.batch(() => {
+ this.__store.setState((s) => {
+ const previousMatches = s.matches
+ const newMatches = s.pendingMatches || s.matches
+
+ exitingMatches = previousMatches.filter(
+ (match) => !newMatches.find((d) => d.id === match.id),
+ )
+ enteringMatches = newMatches.filter(
+ (match) =>
+ !previousMatches.find((d) => d.id === match.id),
+ )
+ stayingMatches = previousMatches.filter((match) =>
+ newMatches.find((d) => d.id === match.id),
+ )
+
+ return {
+ ...s,
+ isLoading: false,
+ matches: newMatches,
+ pendingMatches: undefined,
+ cachedMatches: [
+ ...s.cachedMatches,
+ ...exitingMatches.filter((d) => d.status !== 'error'),
+ ],
+ }
+ })
+ this.cleanCache()
})
- this.cleanCache()
- })
- //
- ;(
- [
- [exitingMatches, 'onLeave'],
- [enteringMatches, 'onEnter'],
- [stayingMatches, 'onStay'],
- ] as const
- ).forEach(([matches, hook]) => {
- matches.forEach((match) => {
- this.looseRoutesById[match.routeId]!.options[hook]?.(match)
+ //
+ ;(
+ [
+ [exitingMatches, 'onLeave'],
+ [enteringMatches, 'onEnter'],
+ [stayingMatches, 'onStay'],
+ ] as const
+ ).forEach(([matches, hook]) => {
+ matches.forEach((match) => {
+ this.looseRoutesById[match.routeId]!.options[hook]?.(match)
+ })
})
})
- })
- },
- })
- } catch (err) {
- if (isResolvedRedirect(err)) {
- redirect = err
- if (!this.isServer) {
- this.navigate({ ...err, replace: true, __isRedirect: true })
- // this.load()
+ },
+ })
+ } catch (err) {
+ if (isResolvedRedirect(err)) {
+ redirect = err
+ if (!this.isServer) {
+ this.navigate({ ...err, replace: true, __isRedirect: true })
+ }
+ } else if (isNotFound(err)) {
+ notFound = err
}
- } else if (isNotFound(err)) {
- notFound = err
- }
- this.__store.setState((s) => ({
- ...s,
- statusCode: redirect
- ? redirect.statusCode
- : notFound
- ? 404
- : s.matches.some((d) => d.status === 'error')
- ? 500
- : 200,
- redirect,
- }))
- }
+ this.__store.setState((s) => ({
+ ...s,
+ statusCode: redirect
+ ? redirect.statusCode
+ : notFound
+ ? 404
+ : s.matches.some((d) => d.status === 'error')
+ ? 500
+ : 200,
+ redirect,
+ }))
+ }
- promise.resolve()
+ if (this.latestLoadPromise === loadPromise) {
+ this.commitLocationPromise?.resolve()
+ this.latestLoadPromise = undefined
+ this.commitLocationPromise = undefined
+ }
+ resolve()
+ })
})
- return this.latestLoadPromise
+ this.latestLoadPromise = loadPromise
+
+ await loadPromise
+
+ while (
+ (this.latestLoadPromise as any) &&
+ loadPromise !== this.latestLoadPromise
+ ) {
+ await this.latestLoadPromise
+ }
}
- startViewTransition = async (fn: () => Promise) => {
+ startViewTransition = (fn: () => Promise) => {
// Determine if we should start a view transition from the navigation
// or from the router default
const shouldViewTransition =
@@ -1648,18 +1666,54 @@ export class Router<
?.startViewTransition?.(fn) || fn()
}
+ updateMatch = (
+ id: string,
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
+ ) => {
+ let updated!: AnyRouteMatch
+ const isPending = this.state.pendingMatches?.find((d) => d.id === id)
+ const isMatched = this.state.matches.find((d) => d.id === id)
+
+ const matchesKey = isPending
+ ? 'pendingMatches'
+ : isMatched
+ ? 'matches'
+ : 'cachedMatches'
+
+ this.__store.setState((s) => ({
+ ...s,
+ [matchesKey]: s[matchesKey]?.map((d) =>
+ d.id === id ? (updated = updater(d)) : d,
+ ),
+ }))
+
+ return updated
+ }
+
+ getMatch = (matchId: string) => {
+ return [
+ ...this.state.cachedMatches,
+ ...(this.state.pendingMatches ?? []),
+ ...this.state.matches,
+ ].find((d) => d.id === matchId)
+ }
+
loadMatches = async ({
- checkLatest,
location,
matches,
preload,
onReady,
+ updateMatch = this.updateMatch,
}: {
- checkLatest: () => void
location: ParsedLocation
matches: Array
preload?: boolean
onReady?: () => Promise
+ updateMatch?: (
+ id: string,
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
+ ) => void
+ getMatch?: (matchId: string) => AnyRouteMatch | undefined
}): Promise> => {
let firstBadMatchIndex: number | undefined
let rendered = false
@@ -1675,38 +1729,10 @@ export class Router<
triggerOnReady()
}
- const updateMatch = (
- id: string,
- updater: (match: AnyRouteMatch) => AnyRouteMatch,
- opts?: { remove?: boolean },
- ) => {
- let updated!: AnyRouteMatch
- const isPending = this.state.pendingMatches?.find((d) => d.id === id)
- const isMatched = this.state.matches.find((d) => d.id === id)
-
- const matchesKey = isPending
- ? 'pendingMatches'
- : isMatched
- ? 'matches'
- : 'cachedMatches'
-
- this.__store.setState((s) => ({
- ...s,
- [matchesKey]: opts?.remove
- ? s[matchesKey]?.filter((d) => d.id !== id)
- : s[matchesKey]?.map((d) =>
- d.id === id ? (updated = updater(d)) : d,
- ),
- }))
-
- return updated
- }
-
const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
if (isResolvedRedirect(err)) throw err
if (isRedirect(err) || isNotFound(err)) {
- // if (!rendered) {
updateMatch(match.id, (prev) => ({
...prev,
status: isRedirect(err)
@@ -1716,19 +1742,26 @@ export class Router<
: 'error',
isFetching: false,
error: err,
+ beforeLoadPromise: undefined,
+ loaderPromise: undefined,
}))
- // }
if (!(err as any).routeId) {
;(err as any).routeId = match.routeId
}
+ match.beforeLoadPromise?.resolve()
+ match.loaderPromise?.resolve()
+ match.loadPromise?.resolve()
+
if (isRedirect(err)) {
rendered = true
err = this.resolveRedirect({ ...err, _fromLocation: location })
throw err
} else if (isNotFound(err)) {
- this.handleNotFound(matches, err)
+ this._handleNotFound(matches, err, {
+ updateMatch,
+ })
throw err
}
}
@@ -1738,397 +1771,426 @@ export class Router<
await new Promise((resolveAll, rejectAll) => {
;(async () => {
try {
- // Check each match middleware to see if the route can be accessed
- // eslint-disable-next-line prefer-const
- for (let [index, match] of matches.entries()) {
- const parentMatch = matches[index - 1]
- const route = this.looseRoutesById[match.routeId]!
- const abortController = new AbortController()
- let loadPromise = match.loadPromise
-
- const pendingMs =
- route.options.pendingMs ?? this.options.defaultPendingMs
-
- const shouldPending = !!(
- onReady &&
- !this.isServer &&
- !preload &&
- (route.options.loader || route.options.beforeLoad) &&
- typeof pendingMs === 'number' &&
- pendingMs !== Infinity &&
- (route.options.pendingComponent ??
- this.options.defaultPendingComponent)
- )
-
- if (shouldPending) {
- // If we might show a pending component, we need to wait for the
- // pending promise to resolve before we start showing that state
- setTimeout(() => {
- try {
- checkLatest()
- // Update the match and prematurely resolve the loadMatches promise so that
- // the pending component can start rendering
- triggerOnReady()
- } catch {}
- }, pendingMs)
+ const handleSerialError = (
+ index: number,
+ err: any,
+ routerCode: string,
+ ) => {
+ const { id: matchId, routeId } = matches[index]!
+ const route = this.looseRoutesById[routeId]!
+
+ // Much like suspense, we use a promise here to know if
+ // we've been outdated by a new loadMatches call and
+ // should abort the current async operation
+ if (err instanceof Promise) {
+ throw err
}
- if (match.isFetching) {
- continue
- }
+ err.routerCode = routerCode
+ firstBadMatchIndex = firstBadMatchIndex ?? index
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
- const previousResolve = loadPromise.resolve
- // Create a new one
- loadPromise = createControlledPromise(
- // Resolve the old when we we resolve the new one
- previousResolve,
- )
-
- // Otherwise, load the route
- matches[index] = match = updateMatch(match.id, (prev) => ({
- ...prev,
- isFetching: 'beforeLoad',
- loadPromise,
- }))
-
- const handleSerialError = (err: any, routerCode: string) => {
- // If the error is a promise, it means we're outdated and
- // should abort the current async operation
- if (err instanceof Promise) {
- throw err
- }
+ try {
+ route.options.onError?.(err)
+ } catch (errorHandlerErr) {
+ err = errorHandlerErr
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
+ }
- err.routerCode = routerCode
- firstBadMatchIndex = firstBadMatchIndex ?? index
- handleRedirectAndNotFound(match, err)
+ updateMatch(matchId, (prev) => {
+ prev.beforeLoadPromise?.resolve()
- try {
- route.options.onError?.(err)
- } catch (errorHandlerErr) {
- err = errorHandlerErr
- handleRedirectAndNotFound(match, err)
- }
-
- matches[index] = match = updateMatch(match.id, () => ({
- ...match,
+ return {
+ ...prev,
error: err,
status: 'error',
+ isFetching: false,
updatedAt: Date.now(),
abortController: new AbortController(),
- }))
- }
-
- if (match.paramsError) {
- handleSerialError(match.paramsError, 'PARSE_PARAMS')
- }
-
- if (match.searchError) {
- handleSerialError(match.searchError, 'VALIDATE_SEARCH')
- }
-
- try {
- const parentContext =
- parentMatch?.context ?? this.options.context ?? {}
-
- // Make sure the match has parent context set before going further
- matches[index] = match = {
- ...match,
- routeContext: replaceEqualDeep(
- match.routeContext,
- parentContext,
- ),
- context: replaceEqualDeep(match.context, parentContext),
- abortController,
+ beforeLoadPromise: undefined,
}
+ })
+ }
- const beforeLoadFnContext = {
- search: match.search,
- abortController,
- params: match.params,
- preload: !!preload,
- context: match.routeContext,
- location,
- navigate: (opts: any) =>
- this.navigate({ ...opts, _fromLocation: location }),
- buildLocation: this.buildLocation,
- cause: preload ? 'preload' : match.cause,
- }
+ for (const [index, { id: matchId, routeId }] of matches.entries()) {
+ const existingMatch = this.getMatch(matchId)!
+
+ if (
+ // If we are in the middle of a load, either of these will be present
+ // (not to be confused with `loadPromise`, which is always defined)
+ existingMatch.beforeLoadPromise ||
+ existingMatch.loaderPromise
+ ) {
+ // Wait for the beforeLoad to resolve before we continue
+ await existingMatch.beforeLoadPromise
+ } else {
+ // If we are not in the middle of a load, start it
+ try {
+ updateMatch(matchId, (prev) => ({
+ ...prev,
+ loadPromise: createControlledPromise(() => {
+ prev.loadPromise?.resolve()
+ }),
+ beforeLoadPromise: createControlledPromise(),
+ }))
+
+ const route = this.looseRoutesById[routeId]!
+ const abortController = new AbortController()
+
+ const parentMatchId = matches[index - 1]?.id
+
+ const getParentContext = () => {
+ if (!parentMatchId) {
+ return (this.options.context as any) ?? {}
+ }
- const beforeLoadContext = route.options.beforeLoad
- ? ((await route.options.beforeLoad(beforeLoadFnContext)) ??
- {})
- : {}
+ return (
+ this.getMatch(parentMatchId)!.context ??
+ this.options.context ??
+ {}
+ )
+ }
- checkLatest()
+ const pendingMs =
+ route.options.pendingMs ?? this.options.defaultPendingMs
+
+ const shouldPending = !!(
+ onReady &&
+ !this.isServer &&
+ !preload &&
+ (route.options.loader || route.options.beforeLoad) &&
+ typeof pendingMs === 'number' &&
+ pendingMs !== Infinity &&
+ (route.options.pendingComponent ??
+ this.options.defaultPendingComponent)
+ )
- if (
- isRedirect(beforeLoadContext) ||
- isNotFound(beforeLoadContext)
- ) {
- handleSerialError(beforeLoadContext, 'BEFORE_LOAD')
- }
+ if (shouldPending) {
+ // If we might show a pending component, we need to wait for the
+ // pending promise to resolve before we start showing that state
+ setTimeout(() => {
+ try {
+ // Update the match and prematurely resolve the loadMatches promise so that
+ // the pending component can start rendering
+ triggerOnReady()
+ } catch {}
+ }, pendingMs)
+ }
- const context = {
- ...parentContext,
- ...beforeLoadContext,
- }
+ const { paramsError, searchError } = this.getMatch(matchId)!
- matches[index] = match = {
- ...match,
- routeContext: replaceEqualDeep(
- match.routeContext,
- beforeLoadContext,
- ),
- context: replaceEqualDeep(match.context, context),
- abortController,
- }
- updateMatch(match.id, () => match)
- } catch (err) {
- handleSerialError(err, 'BEFORE_LOAD')
- break
- }
- }
+ if (paramsError) {
+ handleSerialError(index, paramsError, 'PARSE_PARAMS')
+ }
- checkLatest()
+ if (searchError) {
+ handleSerialError(index, searchError, 'VALIDATE_SEARCH')
+ }
- const validResolvedMatches = matches.slice(0, firstBadMatchIndex)
- const matchPromises: Array> = []
+ const parentContext = getParentContext()
+
+ updateMatch(matchId, (prev) => ({
+ ...prev,
+ isFetching: 'beforeLoad',
+ fetchCount: prev.fetchCount + 1,
+ routeContext: replaceEqualDeep(
+ prev.routeContext,
+ parentContext,
+ ),
+ context: replaceEqualDeep(prev.context, parentContext),
+ abortController,
+ }))
+
+ const { search, params, routeContext, cause } =
+ this.getMatch(matchId)!
+
+ const beforeLoadFnContext = {
+ search,
+ abortController,
+ params,
+ preload: !!preload,
+ context: routeContext,
+ location,
+ navigate: (opts: any) =>
+ this.navigate({ ...opts, _fromLocation: location }),
+ buildLocation: this.buildLocation,
+ cause: preload ? 'preload' : cause,
+ }
- validResolvedMatches.forEach((match, index) => {
- const createValidateResolvedMatchPromise = async () => {
- const parentMatchPromise = matchPromises[index - 1]
- const route = this.looseRoutesById[match.routeId]!
-
- const loaderContext: LoaderFnContext = {
- params: match.params,
- deps: match.loaderDeps,
- preload: !!preload,
- parentMatchPromise,
- abortController: match.abortController,
- context: match.context,
- location,
- navigate: (opts) =>
- this.navigate({ ...opts, _fromLocation: location }),
- cause: preload ? 'preload' : match.cause,
- route,
- }
+ const beforeLoadContext =
+ (await route.options.beforeLoad?.(beforeLoadFnContext)) ??
+ {}
- const fetchAndResolveInLoaderLifetime = async () => {
- const existing = getRouteMatch(this.state, match.id)!
- let lazyPromise = Promise.resolve()
- let componentsPromise = Promise.resolve() as Promise
- let loaderPromise = existing.loaderPromise
-
- // If the Matches component rendered
- // the pending component and needs to show it for
- // a minimum duration, we''ll wait for it to resolve
- // before committing to the match and resolving
- // the loadPromise
- const potentialPendingMinPromise = async () => {
- const latestMatch = getRouteMatch(this.state, match.id)
-
- if (latestMatch?.minPendingPromise) {
- await latestMatch.minPendingPromise
-
- checkLatest()
-
- updateMatch(latestMatch.id, (prev) => ({
- ...prev,
- minPendingPromise: undefined,
- }))
- }
+ if (
+ isRedirect(beforeLoadContext) ||
+ isNotFound(beforeLoadContext)
+ ) {
+ handleSerialError(index, beforeLoadContext, 'BEFORE_LOAD')
}
- try {
- if (match.isFetching === 'beforeLoad') {
- // If the user doesn't want the route to reload, just
- // resolve with the existing loader data
-
- // if (match.fetchCount && match.status === 'success') {
- // resolve()
- // }
-
- // Otherwise, load the route
- matches[index] = match = updateMatch(
- match.id,
- (prev) => ({
- ...prev,
- isFetching: 'loader',
- fetchCount: match.fetchCount + 1,
- }),
- )
-
- lazyPromise =
- route.lazyFn?.().then((lazyRoute) => {
- Object.assign(route.options, lazyRoute.options)
- }) || Promise.resolve()
-
- // If for some reason lazy resolves more lazy components...
- // We'll wait for that before pre attempt to preload any
- // components themselves.
- componentsPromise = lazyPromise.then(() =>
- Promise.all(
- componentTypes.map(async (type) => {
- const component = route.options[type]
-
- if ((component as any)?.preload) {
- await (component as any).preload()
- }
- }),
- ),
- )
-
- // Lazy option can modify the route options,
- // so we need to wait for it to resolve before
- // we can use the options
- await lazyPromise
-
- checkLatest()
-
- // Kick off the loader!
- loaderPromise = route.options.loader?.(loaderContext)
-
- matches[index] = match = updateMatch(
- match.id,
- (prev) => ({
- ...prev,
- loaderPromise,
- }),
- )
+ updateMatch(matchId, (prev) => {
+ const routeContext = {
+ ...prev.routeContext,
+ ...beforeLoadContext,
}
- let loaderData = await loaderPromise
- if (this.serializeLoaderData) {
- loaderData = this.serializeLoaderData(loaderData, {
- router: this,
- match,
- })
+ return {
+ ...prev,
+ routeContext: replaceEqualDeep(
+ prev.routeContext,
+ routeContext,
+ ),
+ context: replaceEqualDeep(prev.context, routeContext),
+ abortController,
}
- checkLatest()
+ })
+ } catch (err) {
+ handleSerialError(index, err, 'BEFORE_LOAD')
+ }
- handleRedirectAndNotFound(match, loaderData)
+ updateMatch(matchId, (prev) => {
+ prev.beforeLoadPromise?.resolve()
- await potentialPendingMinPromise()
- checkLatest()
+ return {
+ ...prev,
+ beforeLoadPromise: undefined,
+ isFetching: false,
+ }
+ })
+ }
+ }
- const meta = route.options.meta?.({
- matches,
- match,
- params: match.params,
- loaderData,
- })
+ const validResolvedMatches = matches.slice(0, firstBadMatchIndex)
+ const matchPromises: Array> = []
- const headers = route.options.headers?.({
- loaderData,
- })
+ validResolvedMatches.forEach(({ id: matchId, routeId }, index) => {
+ matchPromises.push(
+ (async () => {
+ const { loaderPromise: prevLoaderPromise } =
+ this.getMatch(matchId)!
+
+ if (prevLoaderPromise) {
+ await prevLoaderPromise
+ } else {
+ const parentMatchPromise = matchPromises[index - 1]
+ const route = this.looseRoutesById[routeId]!
+
+ const getLoaderContext = (): LoaderFnContext => {
+ const {
+ params,
+ loaderDeps,
+ abortController,
+ context,
+ cause,
+ } = this.getMatch(matchId)!
+
+ return {
+ params,
+ deps: loaderDeps,
+ preload: !!preload,
+ parentMatchPromise,
+ abortController: abortController,
+ context,
+ location,
+ navigate: (opts) =>
+ this.navigate({ ...opts, _fromLocation: location }),
+ cause: preload ? 'preload' : cause,
+ route,
+ }
+ }
- matches[index] = match = updateMatch(match.id, (prev) => ({
- ...prev,
- error: undefined,
- status: 'success',
- isFetching: false,
- updatedAt: Date.now(),
- loaderData,
- meta,
- headers,
- }))
- } catch (e) {
- checkLatest()
- let error = e
+ // This is where all of the stale-while-revalidate magic happens
+ const age = Date.now() - this.getMatch(matchId)!.updatedAt
- await potentialPendingMinPromise()
- checkLatest()
+ const staleAge = preload
+ ? (route.options.preloadStaleTime ??
+ this.options.defaultPreloadStaleTime ??
+ 30_000) // 30 seconds for preloads by default
+ : (route.options.staleTime ??
+ this.options.defaultStaleTime ??
+ 0)
- handleRedirectAndNotFound(match, e)
+ const shouldReloadOption = route.options.shouldReload
- try {
- route.options.onError?.(e)
- } catch (onErrorError) {
- error = onErrorError
- handleRedirectAndNotFound(match, onErrorError)
- }
+ // Default to reloading the route all the time
+ // Allow shouldReload to get the last say,
+ // if provided.
+ const shouldReload =
+ typeof shouldReloadOption === 'function'
+ ? shouldReloadOption(getLoaderContext())
+ : shouldReloadOption
- matches[index] = match = updateMatch(match.id, (prev) => ({
+ updateMatch(matchId, (prev) => ({
...prev,
- error,
- status: 'error',
- isFetching: false,
+ loaderPromise: createControlledPromise(),
+ preload:
+ !!preload &&
+ !this.state.matches.find((d) => d.id === matchId),
}))
- }
- // Last but not least, wait for the the component
- // to be preloaded before we resolve the match
- await componentsPromise
-
- checkLatest()
+ const runLoader = async () => {
+ try {
+ // If the Matches component rendered
+ // the pending component and needs to show it for
+ // a minimum duration, we''ll wait for it to resolve
+ // before committing to the match and resolving
+ // the loadPromise
+ const potentialPendingMinPromise = async () => {
+ const latestMatch = this.getMatch(matchId)!
+
+ if (latestMatch.minPendingPromise) {
+ await latestMatch.minPendingPromise
+ }
+ }
+
+ // Actually run the loader and handle the result
+ try {
+ route._lazyPromise =
+ route._lazyPromise ||
+ (route.lazyFn
+ ? route.lazyFn().then((lazyRoute) => {
+ Object.assign(
+ route.options,
+ lazyRoute.options,
+ )
+ })
+ : Promise.resolve())
+
+ // If for some reason lazy resolves more lazy components...
+ // We'll wait for that before pre attempt to preload any
+ // components themselves.
+ const componentsPromise =
+ this.getMatch(matchId)!.componentsPromise ||
+ route._lazyPromise.then(() =>
+ Promise.all(
+ componentTypes.map(async (type) => {
+ const component = route.options[type]
+
+ if ((component as any)?.preload) {
+ await (component as any).preload()
+ }
+ }),
+ ),
+ )
+
+ // Otherwise, load the route
+ updateMatch(matchId, (prev) => ({
+ ...prev,
+ isFetching: 'loader',
+ componentsPromise,
+ }))
+
+ // Lazy option can modify the route options,
+ // so we need to wait for it to resolve before
+ // we can use the options
+ await route._lazyPromise
+
+ // Kick off the loader!
+ let loaderData =
+ await route.options.loader?.(getLoaderContext())
+
+ if (this.serializeLoaderData) {
+ loaderData = this.serializeLoaderData(loaderData, {
+ router: this,
+ match: this.getMatch(matchId)!,
+ })
+ }
+
+ handleRedirectAndNotFound(
+ this.getMatch(matchId)!,
+ loaderData,
+ )
+
+ await potentialPendingMinPromise()
+
+ const meta = route.options.meta?.({
+ matches,
+ match: this.getMatch(matchId)!,
+ params: this.getMatch(matchId)!.params,
+ loaderData,
+ })
+
+ const headers = route.options.headers?.({
+ loaderData,
+ })
+
+ updateMatch(matchId, (prev) => ({
+ ...prev,
+ error: undefined,
+ status: 'success',
+ isFetching: false,
+ updatedAt: Date.now(),
+ loaderData,
+ meta,
+ headers,
+ }))
+ } catch (e) {
+ let error = e
+
+ await potentialPendingMinPromise()
+
+ handleRedirectAndNotFound(this.getMatch(matchId)!, e)
+
+ try {
+ route.options.onError?.(e)
+ } catch (onErrorError) {
+ error = onErrorError
+ handleRedirectAndNotFound(
+ this.getMatch(matchId)!,
+ onErrorError,
+ )
+ }
+
+ updateMatch(matchId, (prev) => ({
+ ...prev,
+ error,
+ status: 'error',
+ isFetching: false,
+ }))
+ }
+
+ // Last but not least, wait for the the component
+ // to be preloaded before we resolve the match
+ await this.getMatch(matchId)!.componentsPromise
+ } catch (err) {
+ handleRedirectAndNotFound(this.getMatch(matchId)!, err)
+ }
+ }
- match.loadPromise.resolve()
- }
+ // If the route is successful and still fresh, just resolve
+ const { status, invalid } = this.getMatch(matchId)!
+
+ if (
+ status === 'success' &&
+ (invalid || (shouldReload ?? age > staleAge))
+ ) {
+ ;(async () => {
+ try {
+ await runLoader()
+ } catch (err) {}
+ })()
+ } else if (status !== 'success') {
+ await runLoader()
+ }
- // This is where all of the stale-while-revalidate magic happens
- const age = Date.now() - match.updatedAt
-
- const staleAge = preload
- ? (route.options.preloadStaleTime ??
- this.options.defaultPreloadStaleTime ??
- 30_000) // 30 seconds for preloads by default
- : (route.options.staleTime ??
- this.options.defaultStaleTime ??
- 0)
-
- const shouldReloadOption = route.options.shouldReload
-
- // Default to reloading the route all the time
- // Allow shouldReload to get the last say,
- // if provided.
- const shouldReload =
- typeof shouldReloadOption === 'function'
- ? shouldReloadOption(loaderContext)
- : shouldReloadOption
-
- matches[index] = match = {
- ...match,
- preload:
- !!preload &&
- !this.state.matches.find((d) => d.id === match.id),
- }
+ const { loaderPromise, loadPromise } =
+ this.getMatch(matchId)!
- const fetchWithRedirectAndNotFound = async () => {
- try {
- await fetchAndResolveInLoaderLifetime()
- } catch (err) {
- checkLatest()
- handleRedirectAndNotFound(match, err)
+ loaderPromise?.resolve()
+ loadPromise?.resolve()
}
- }
-
- // If the route is successful and still fresh, just resolve
- if (
- match.status === 'success' &&
- (match.invalid || (shouldReload ?? age > staleAge))
- ) {
- ;(async () => {
- try {
- await fetchWithRedirectAndNotFound()
- } catch (err) {}
- })()
- return
- }
- if (match.status !== 'success') {
- await fetchWithRedirectAndNotFound()
- }
-
- return
- }
-
- matchPromises.push(createValidateResolvedMatchPromise())
+ updateMatch(matchId, (prev) => ({
+ ...prev,
+ isFetching: false,
+ loaderPromise: undefined,
+ }))
+ })(),
+ )
})
await Promise.all(matchPromises)
- checkLatest()
-
resolveAll()
} catch (err) {
rejectAll(err)
@@ -2152,7 +2214,9 @@ export class Router<
const invalidate = (d: MakeRouteMatch) => ({
...d,
invalid: true,
- ...(d.status === 'error' ? ({ status: 'pending' } as const) : {}),
+ ...(d.status === 'error'
+ ? ({ status: 'pending', error: undefined } as const)
+ : {}),
})
this.__store.setState((s) => ({
@@ -2242,29 +2306,24 @@ export class Router<
})
})
- // If the preload leaf match is the same as the current or pending leaf match,
- // do not preload as it could cause a mutation of the current route.
- // The user should specify proper loaderDeps (which are used to uniquely identify a route)
- // to trigger preloads for routes with the same pathname, but different deps
-
- const leafMatch = last(matches)
- const currentLeafMatch = last(this.state.matches)
- const pendingLeafMatch = last(this.state.pendingMatches ?? [])
-
- if (
- leafMatch &&
- (currentLeafMatch?.id === leafMatch.id ||
- pendingLeafMatch?.id === leafMatch.id)
- ) {
- return undefined
- }
+ const activeMatchIds = new Set(
+ [...this.state.matches, ...(this.state.pendingMatches ?? [])].map(
+ (d) => d.id,
+ ),
+ )
try {
matches = await this.loadMatches({
matches,
location: next,
preload: true,
- checkLatest: () => undefined,
+ updateMatch: (id, updater) => {
+ if (activeMatchIds.has(id)) {
+ matches = matches.map((d) => (d.id === id ? updater(d) : d))
+ } else {
+ this.updateMatch(id, updater)
+ }
+ },
})
return matches
@@ -2363,7 +2422,7 @@ export class Router<
}
}
- hydrate = async () => {
+ hydrate = () => {
// Client hydrates from window
let ctx: HydrationCtx | undefined
@@ -2457,7 +2516,18 @@ export class Router<
)
}
- handleNotFound = (matches: Array, err: NotFoundError) => {
+ _handleNotFound = (
+ matches: Array,
+ err: NotFoundError,
+ {
+ updateMatch = this.updateMatch,
+ }: {
+ updateMatch?: (
+ id: string,
+ updater: (match: AnyRouteMatch) => AnyRouteMatch,
+ ) => void
+ } = {},
+ ) => {
const matchesByRouteId = Object.fromEntries(
matches.map((match) => [match.routeId, match]),
) as Record
@@ -2488,11 +2558,20 @@ export class Router<
invariant(match, 'Could not find match for route: ' + routeCursor.id)
// Assign the error to the match
- Object.assign(match, {
+
+ updateMatch(match.id, (prev) => ({
+ ...prev,
status: 'notFound',
error: err,
isFetching: false,
- } as AnyRouteMatch)
+ }))
+
+ if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) {
+ err.routeId = routeCursor.parentRoute.id
+ this._handleNotFound(matches, err, {
+ updateMatch,
+ })
+ }
}
hasNotFoundMatch = () => {
@@ -2500,12 +2579,6 @@ export class Router<
(d) => d.status === 'notFound' || d.globalNotFound,
)
}
-
- // resolveMatchPromise = (matchId: string, key: string, value: any) => {
- // state.matches
- // .find((d) => d.id === matchId)
- // ?.__promisesByKey[key]?.resolve(value)
- // }
}
// A function that takes an import() argument which is a function and returns a new function that will
@@ -2531,6 +2604,7 @@ export function getInitialRouterState(
location: ParsedLocation,
): RouterState {
return {
+ loadedAt: 0,
isLoading: false,
isTransitioning: false,
status: 'idle',
diff --git a/packages/react-router/tests/createLazyRoute.test.tsx b/packages/react-router/tests/createLazyRoute.test.tsx
new file mode 100644
index 00000000000..ce33c4c6bb8
--- /dev/null
+++ b/packages/react-router/tests/createLazyRoute.test.tsx
@@ -0,0 +1,102 @@
+import React, { act } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import '@testing-library/jest-dom/vitest'
+import { cleanup, configure, render, screen } from '@testing-library/react'
+import {
+ Link,
+ RouterProvider,
+ createBrowserHistory,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+import type { RouterHistory } from '../src'
+
+// TODO: Move this setup logic including the '@testing-library/jest-dom/vitest' into its own setup file
+// @ts-expect-error
+global.IS_REACT_ACT_ENVIRONMENT = true
+
+afterEach(() => {
+ vi.resetAllMocks()
+ cleanup()
+})
+
+function createTestRouter(initialHistory?: RouterHistory) {
+ const history =
+ initialHistory ?? createMemoryHistory({ initialEntries: ['/'] })
+
+ const rootRoute = createRootRoute({})
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => (
+
+
Index Route
+
Link to heavy
+
+ ),
+ })
+
+ const heavyRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/heavy',
+ }).lazy(() => import('./lazy/heavy').then((d) => d.default('/heavy')))
+
+ const routeTree = rootRoute.addChildren([indexRoute, heavyRoute])
+
+ const router = createRouter({ routeTree, history })
+
+ return {
+ router,
+ routes: { indexRoute, heavyRoute },
+ }
+}
+
+describe('preload: matched routes', { timeout: 20000 }, () => {
+ configure({ reactStrictMode: true })
+
+ it('should wait for lazy options to be streamed in before ', async () => {
+ const { router } = createTestRouter(
+ createMemoryHistory({ initialEntries: ['/'] }),
+ )
+
+ await router.load()
+
+ // Preload the route and navigate to it
+ router.preloadRoute({ to: '/heavy' })
+ await router.navigate({ to: '/heavy' })
+
+ await router.invalidate()
+
+ expect(router.state.location.pathname).toBe('/heavy')
+
+ const lazyRoute = router.routesByPath['/heavy']
+
+ expect(lazyRoute.options.component).toBeDefined()
+ })
+
+ it('should render the heavy/lazy component', async () => {
+ const { router } = createTestRouter(createBrowserHistory())
+
+ await act(() => render( ))
+
+ const linkToHeavy = await screen.findByText('Link to heavy')
+ expect(linkToHeavy).toBeInTheDocument()
+
+ expect(router.state.location.pathname).toBe('/')
+ expect(window.location.pathname).toBe('/')
+
+ // click the link to navigate to the heavy route
+ act(() => linkToHeavy.click())
+
+ const heavyElement = await screen.findByText('I am sooo heavy')
+ expect(heavyElement).toBeInTheDocument()
+
+ expect(router.state.location.pathname).toBe('/heavy')
+ expect(window.location.pathname).toBe('/heavy')
+
+ const lazyRoute = router.routesByPath['/heavy']
+ expect(lazyRoute.options.component).toBeDefined()
+ })
+})
diff --git a/packages/react-router/tests/index.test.tsx b/packages/react-router/tests/index.test.tsx
index ad2c5de7cce..5fcdf95632c 100644
--- a/packages/react-router/tests/index.test.tsx
+++ b/packages/react-router/tests/index.test.tsx
@@ -556,7 +556,7 @@ import {
// })
// })
-describe('ssr redirects', async () => {
+describe('ssr redirects', () => {
test('via throw in beforeLoad', async () => {
const rootRoute = createRootRoute()
@@ -579,15 +579,11 @@ describe('ssr redirects', async () => {
})
const router = createRouter({
- history: createMemoryHistory({
- initialEntries: ['/'],
- }),
routeTree: rootRoute.addChildren([indexRoute, aboutRoute]),
+ // Mock server mode
+ isServer: true,
})
- // Mock server mode
- router.isServer = true
-
await router.load()
expect(router.state.redirect).toEqual({
diff --git a/packages/react-router/tests/lazy/heavy.tsx b/packages/react-router/tests/lazy/heavy.tsx
new file mode 100644
index 00000000000..d7ca39c40f3
--- /dev/null
+++ b/packages/react-router/tests/lazy/heavy.tsx
@@ -0,0 +1,8 @@
+import { createLazyRoute } from '../../src'
+import HeavyComponent from './mockHeavyDependenciesRoute'
+
+export default function Route(id: string) {
+ return createLazyRoute(id)({
+ component: HeavyComponent,
+ })
+}
diff --git a/packages/react-router/tests/lazy/mockHeavyDependenciesRoute.tsx b/packages/react-router/tests/lazy/mockHeavyDependenciesRoute.tsx
new file mode 100644
index 00000000000..68fafcd3776
--- /dev/null
+++ b/packages/react-router/tests/lazy/mockHeavyDependenciesRoute.tsx
@@ -0,0 +1,6 @@
+// This mimicks the waiting of heavy dependencies, which need to be streamed in before the component is available.
+await new Promise((resolve) => setTimeout(resolve, 2500))
+
+export default function HeavyComponent() {
+ return I am sooo heavy
+}
diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx
index a07ee630a1f..afed66b8d32 100644
--- a/packages/react-router/tests/link.test.tsx
+++ b/packages/react-router/tests/link.test.tsx
@@ -18,14 +18,14 @@ import {
createRootRoute,
createRootRouteWithContext,
createRoute,
- createRouter,
createRouteMask,
+ createRouter,
redirect,
useLoaderData,
+ useMatchRoute,
useParams,
- useSearch,
useRouteContext,
- useMatchRoute,
+ useSearch,
} from '../src'
afterEach(() => {
diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx
index 24cc5bff7cc..b8e5d799f7e 100644
--- a/packages/react-router/tests/loaders.test.tsx
+++ b/packages/react-router/tests/loaders.test.tsx
@@ -12,11 +12,11 @@ import { afterEach, describe, expect, test, vi } from 'vitest'
import {
Link,
+ Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
- redirect,
} from '../src'
import { sleep } from './utils'
@@ -114,132 +114,56 @@ describe('loaders are being called', () => {
expect(nestedLoaderMock).toHaveBeenCalled()
expect(nestedFooLoaderMock).toHaveBeenCalled()
})
+})
- test('both are called on /nested/foo when redirected in "beforeLoad" from /about', async () => {
+describe('loaders parentMatchPromise', () => {
+ test('parentMatchPromise is defined in a child route', async () => {
const nestedLoaderMock = vi.fn()
- const nestedFooLoaderMock = vi.fn()
const rootRoute = createRootRoute({})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
- component: () => {
- return (
-
-
Index page
- link to about
-
- )
- },
- })
- const aboutRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/about',
- beforeLoad: async () => {
- await sleep(WAIT_TIME)
- throw redirect({ to: '/nested/foo' })
- },
+ component: () => (
+
+ Index page
+ link to foo
+
+ ),
})
const nestedRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/nested',
loader: async () => {
await sleep(WAIT_TIME)
- nestedLoaderMock('nested')
},
+ component: () => ,
})
const fooRoute = createRoute({
getParentRoute: () => nestedRoute,
path: '/foo',
- loader: async () => {
- await sleep(WAIT_TIME)
- nestedFooLoaderMock('foo')
+ loader: ({ parentMatchPromise }) => {
+ nestedLoaderMock(parentMatchPromise)
},
component: () => Nested Foo page
,
})
const routeTree = rootRoute.addChildren([
nestedRoute.addChildren([fooRoute]),
- aboutRoute,
indexRoute,
])
const router = await act(() => createRouter({ routeTree }))
await act(() => render( ))
- const linkToAbout = await screen.findByText('link to about')
- await act(() => fireEvent.click(linkToAbout))
-
- const fooElement = await screen.findByText('Nested Foo page')
- expect(fooElement).toBeInTheDocument()
-
- expect(router.state.location.href).toBe('/nested/foo')
- expect(window.location.pathname).toBe('/nested/foo')
-
- expect(nestedLoaderMock).toHaveBeenCalled()
- expect(nestedFooLoaderMock).toHaveBeenCalled()
- })
-
- test('both are called on /nested/foo when redirected in "loader" from /about', async () => {
- const nestedLoaderMock = vi.fn()
- const nestedFooLoaderMock = vi.fn()
-
- const rootRoute = createRootRoute({})
- const indexRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- component: () => {
- return (
-
-
Index page
- link to about
-
- )
- },
- })
- const aboutRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/about',
- loader: async () => {
- await sleep(WAIT_TIME)
- throw redirect({ to: '/nested/foo' })
- },
- })
- const nestedRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/nested',
- loader: async () => {
- await sleep(WAIT_TIME)
- nestedLoaderMock('nested')
- },
- })
- const fooRoute = createRoute({
- getParentRoute: () => nestedRoute,
- path: '/foo',
- loader: async () => {
- await sleep(WAIT_TIME)
- nestedFooLoaderMock('foo')
- },
- component: () => Nested Foo page
,
- })
- const routeTree = rootRoute.addChildren([
- nestedRoute.addChildren([fooRoute]),
- aboutRoute,
- indexRoute,
- ])
- const router = await act(() => createRouter({ routeTree }))
-
- await act(() => render( ))
+ const linkToFoo = await screen.findByRole('link', { name: 'link to foo' })
+ expect(linkToFoo).toBeInTheDocument()
- const linkToAbout = await screen.findByText('link to about')
- await act(() => fireEvent.click(linkToAbout))
+ await act(() => fireEvent.click(linkToFoo))
const fooElement = await screen.findByText('Nested Foo page')
expect(fooElement).toBeInTheDocument()
- expect(router.state.location.href).toBe('/nested/foo')
- expect(window.location.pathname).toBe('/nested/foo')
-
expect(nestedLoaderMock).toHaveBeenCalled()
- expect(nestedFooLoaderMock).toHaveBeenCalled()
+ expect(nestedLoaderMock.mock.calls[0][0]).toBeInstanceOf(Promise)
})
})
diff --git a/packages/react-router/tests/redirects.test.tsx b/packages/react-router/tests/navigate.test.tsx
similarity index 99%
rename from packages/react-router/tests/redirects.test.tsx
rename to packages/react-router/tests/navigate.test.tsx
index 6f0d2eb95d6..61e34f55cd4 100644
--- a/packages/react-router/tests/redirects.test.tsx
+++ b/packages/react-router/tests/navigate.test.tsx
@@ -1,11 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
+ type RouterHistory,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
- type RouterHistory,
} from '../src'
afterEach(() => {
@@ -402,6 +402,7 @@ describe('router.navigate navigation using layout routes resolves correctly', as
to: '/u/$username',
params: { username: 'tkdodo' },
})
+
await router.invalidate()
expect(router.state.location.pathname).toBe('/u/tkdodo')
@@ -437,6 +438,7 @@ describe('router.navigate navigation using layout routes resolves correctly', as
to: '/g/$username',
params: { username: 'tkdodo' },
})
+
await router.invalidate()
expect(router.state.location.pathname).toBe('/g/tkdodo')
diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx
new file mode 100644
index 00000000000..3162b06f4b0
--- /dev/null
+++ b/packages/react-router/tests/redirect.test.tsx
@@ -0,0 +1,165 @@
+import React, { act } from 'react'
+import '@testing-library/jest-dom/vitest'
+import {
+ cleanup,
+ configure,
+ fireEvent,
+ render,
+ screen,
+} from '@testing-library/react'
+
+import { afterEach, describe, expect, test, vi } from 'vitest'
+
+import {
+ Link,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ redirect,
+} from '../src'
+
+import { sleep } from './utils'
+
+afterEach(() => {
+ vi.clearAllMocks()
+ vi.resetAllMocks()
+ window.history.replaceState(null, 'root', '/')
+ cleanup()
+})
+
+const WAIT_TIME = 100
+
+describe('redirect', () => {
+ configure({ reactStrictMode: true })
+
+ describe('`beforeLoad` and `loader` are called when redirecting', () => {
+ test('when `redirect` is thrown in `beforeLoad`', async () => {
+ const nestedLoaderMock = vi.fn()
+ const nestedFooLoaderMock = vi.fn()
+
+ const rootRoute = createRootRoute({})
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => {
+ return (
+
+
Index page
+ link to about
+
+ )
+ },
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ beforeLoad: async () => {
+ await sleep(WAIT_TIME)
+ throw redirect({ to: '/nested/foo' })
+ },
+ })
+ const nestedRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/nested',
+ loader: async () => {
+ await sleep(WAIT_TIME)
+ nestedLoaderMock('nested')
+ },
+ })
+ const fooRoute = createRoute({
+ getParentRoute: () => nestedRoute,
+ path: '/foo',
+ loader: async () => {
+ await sleep(WAIT_TIME)
+ nestedFooLoaderMock('foo')
+ },
+ component: () => Nested Foo page
,
+ })
+ const routeTree = rootRoute.addChildren([
+ nestedRoute.addChildren([fooRoute]),
+ aboutRoute,
+ indexRoute,
+ ])
+ const router = await act(() => createRouter({ routeTree }))
+
+ await act(() => render( ))
+
+ const linkToAbout = await screen.findByText('link to about')
+ await act(() => fireEvent.click(linkToAbout))
+
+ const fooElement = await screen.findByText('Nested Foo page')
+ expect(fooElement).toBeInTheDocument()
+
+ expect(router.state.location.href).toBe('/nested/foo')
+ expect(window.location.pathname).toBe('/nested/foo')
+
+ expect(nestedLoaderMock).toHaveBeenCalled()
+ expect(nestedFooLoaderMock).toHaveBeenCalled()
+ })
+
+ test('when `redirect` is thrown in `loader`', async () => {
+ const nestedLoaderMock = vi.fn()
+ const nestedFooLoaderMock = vi.fn()
+
+ const rootRoute = createRootRoute({})
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => {
+ return (
+
+
Index page
+ link to about
+
+ )
+ },
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ loader: async () => {
+ await sleep(WAIT_TIME)
+ throw redirect({ to: '/nested/foo' })
+ },
+ })
+ const nestedRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/nested',
+ loader: async () => {
+ await sleep(WAIT_TIME)
+ nestedLoaderMock('nested')
+ },
+ })
+ const fooRoute = createRoute({
+ getParentRoute: () => nestedRoute,
+ path: '/foo',
+ loader: async () => {
+ await sleep(WAIT_TIME)
+ nestedFooLoaderMock('foo')
+ },
+ component: () => Nested Foo page
,
+ })
+ const routeTree = rootRoute.addChildren([
+ nestedRoute.addChildren([fooRoute]),
+ aboutRoute,
+ indexRoute,
+ ])
+ const router = await act(() => createRouter({ routeTree }))
+
+ await act(() => render( ))
+
+ const linkToAbout = await screen.findByText('link to about')
+ await act(() => fireEvent.click(linkToAbout))
+
+ const fooElement = await screen.findByText('Nested Foo page')
+ expect(fooElement).toBeInTheDocument()
+
+ expect(router.state.location.href).toBe('/nested/foo')
+ expect(window.location.pathname).toBe('/nested/foo')
+
+ expect(nestedLoaderMock).toHaveBeenCalled()
+ expect(nestedFooLoaderMock).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/react-router/tests/route.test-d.tsx b/packages/react-router/tests/route.test-d.tsx
index 5637180917d..7e2eededae2 100644
--- a/packages/react-router/tests/route.test-d.tsx
+++ b/packages/react-router/tests/route.test-d.tsx
@@ -5,6 +5,7 @@ import {
createRoute,
createRouter,
} from '../src'
+import type { ControlledPromise } from '../src'
test('when creating the root', () => {
const rootRoute = createRootRoute()
@@ -611,7 +612,9 @@ test('when creating a child route with context, search, params, loader, loaderDe
search: TExpectedSearch
context: TExpectedContext
loaderDeps: { detailPage: number; invoicePage: number }
- loaderPromise: Promise
+ beforeLoadPromise?: ControlledPromise
+ loaderPromise?: ControlledPromise
+ componentsPromise?: Promise>
loaderData?: TExpectedLoaderData
routeContext: TExpectedRouteContext
}
diff --git a/packages/react-router/tests/routeContext.test.tsx b/packages/react-router/tests/routeContext.test.tsx
index 455020a4c07..db003eb908c 100644
--- a/packages/react-router/tests/routeContext.test.tsx
+++ b/packages/react-router/tests/routeContext.test.tsx
@@ -381,6 +381,108 @@ describe('beforeLoad in the route definition', () => {
expect(mock).toHaveBeenCalledTimes(1)
})
+ // TODO: Move this test to the loader section
+ test("on navigate (with preload using router methods), loader isn't invoked with undefined context if beforeLoad is pending when navigation happens", async () => {
+ const mock = vi.fn()
+
+ const rootRoute = createRootRoute()
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ beforeLoad: async () => {
+ await sleep(WAIT_TIME)
+ return { mock }
+ },
+ loader: async ({ context }) => {
+ await sleep(WAIT_TIME)
+ expect(context.mock).toBe(mock)
+ context.mock()
+ },
+ })
+
+ const routeTree = rootRoute.addChildren([aboutRoute, indexRoute])
+ const router = createRouter({
+ routeTree,
+ context: { foo: 'bar' },
+ })
+
+ await router.load()
+
+ // Don't await, simulate user clicking before preload is done
+ router.preloadRoute(aboutRoute)
+
+ await router.navigate(aboutRoute)
+ await router.invalidate()
+
+ // Expect only a single call as the one from preload and the one from navigate are deduped
+ expect(mock).toHaveBeenCalledOnce()
+ })
+
+ // TODO: Move this test to the loader section
+ test("on navigate (with preload), loader isn't invoked with undefined context if beforeLoad is pending when navigation happens", async () => {
+ const mock = vi.fn()
+
+ const rootRoute = createRootRoute()
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => {
+ return (
+
+
Index page
+
+ link to about
+
+
+ )
+ },
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ beforeLoad: async () => {
+ await sleep(WAIT_TIME)
+ return { mock }
+ },
+ loader: async ({ context }) => {
+ await sleep(WAIT_TIME)
+ expect(context.mock).toBe(mock)
+ context.mock()
+ },
+ component: () => About page
,
+ })
+
+ const routeTree = rootRoute.addChildren([aboutRoute, indexRoute])
+ const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ context: { foo: 'bar' },
+ })
+
+ render( )
+
+ const linkToAbout = await screen.findByRole('link', {
+ name: 'link to about',
+ })
+ expect(linkToAbout).toBeInTheDocument()
+
+ // Don't await, simulate user clicking before preload is done
+ linkToAbout.focus()
+ linkToAbout.click()
+
+ const aboutElement = await screen.findByText('About page')
+ expect(aboutElement).toBeInTheDocument()
+
+ expect(window.location.pathname).toBe('/about')
+
+ // Expect only a single call as the one from preload and the one from navigate are deduped
+ expect(mock).toHaveBeenCalledOnce()
+ })
+
// Check if context returned by /nested/about, is the same as its parent route /nested on navigate
test('nested destination on navigate, route context in the /nested/about route is correctly inherited from the /nested parent', async () => {
const mock = vi.fn()
diff --git a/packages/react-router/tests/useMatch.test.tsx b/packages/react-router/tests/useMatch.test.tsx
index eed1151b59a..cf5f2f07a6f 100644
--- a/packages/react-router/tests/useMatch.test.tsx
+++ b/packages/react-router/tests/useMatch.test.tsx
@@ -1,7 +1,7 @@
-import { afterEach, describe, expect, it, test, vi } from 'vitest'
+import { afterEach, describe, expect, test, vi } from 'vitest'
import '@testing-library/jest-dom/vitest'
import React from 'react'
-import { render, screen } from '@testing-library/react'
+import { cleanup, render, screen } from '@testing-library/react'
import {
Link,
Outlet,
@@ -15,6 +15,11 @@ import {
import type { RouteComponent, RouterHistory } from '../src'
describe('useMatch', () => {
+ afterEach(() => {
+ window.history.replaceState(null, 'root', '/')
+ cleanup()
+ })
+
function setup({
RootComponent,
history,
diff --git a/packages/router-devtools/src/devtools.tsx b/packages/router-devtools/src/devtools.tsx
index 394aaf00462..8280b1bb171 100644
--- a/packages/router-devtools/src/devtools.tsx
+++ b/packages/router-devtools/src/devtools.tsx
@@ -1097,7 +1097,9 @@ const stylesFactory = (shadowDOMTarget?: ShadowRoot) => {
return classes
},
- matchIndicator: (color: 'green' | 'red' | 'yellow' | 'gray' | 'blue') => {
+ matchIndicator: (
+ color: 'green' | 'red' | 'yellow' | 'gray' | 'blue' | 'purple',
+ ) => {
const base = css`
flex: 0 0 auto;
width: ${size[3]};
diff --git a/packages/router-devtools/src/utils.ts b/packages/router-devtools/src/utils.ts
index 686eb93ebaa..fd9488a9780 100644
--- a/packages/router-devtools/src/utils.ts
+++ b/packages/router-devtools/src/utils.ts
@@ -31,15 +31,19 @@ type StyledComponent = T extends 'button'
: never
export function getStatusColor(match: AnyRouteMatch) {
- return match.status === 'success' && match.isFetching
- ? 'blue'
- : match.status === 'pending'
- ? 'yellow'
- : match.status === 'error'
- ? 'red'
- : match.status === 'success'
- ? 'green'
- : 'gray'
+ const colorMap = {
+ pending: 'yellow',
+ success: 'green',
+ error: 'red',
+ notFound: 'purple',
+ redirected: 'gray',
+ } as const
+
+ return match.isFetching && match.status === 'success'
+ ? match.isFetching === 'beforeLoad'
+ ? 'purple'
+ : 'blue'
+ : colorMap[match.status]
}
export function getRouteStatusColor(
diff --git a/packages/router-plugin/src/code-splitter.ts b/packages/router-plugin/src/code-splitter.ts
index 850fd2688a4..bd6c1f3e60a 100644
--- a/packages/router-plugin/src/code-splitter.ts
+++ b/packages/router-plugin/src/code-splitter.ts
@@ -219,7 +219,7 @@ export const unpluginRouterCodeSplitterFactory: UnpluginFactory<
compiler.options.mode === 'production'
) {
compiler.hooks.done.tap(PLUGIN_NAME, (stats) => {
- console.log('✅ ' + PLUGIN_NAME + ': code-splitting done!')
+ console.info('✅ ' + PLUGIN_NAME + ': code-splitting done!')
setTimeout(() => {
process.exit(0)
})
diff --git a/packages/router-plugin/src/router-generator.ts b/packages/router-plugin/src/router-generator.ts
index 0617f25aa87..065b7e9263d 100644
--- a/packages/router-plugin/src/router-generator.ts
+++ b/packages/router-plugin/src/router-generator.ts
@@ -119,7 +119,7 @@ export const unpluginRouterGeneratorFactory: UnpluginFactory<
if (compiler.options.mode === 'production') {
compiler.hooks.done.tap(PLUGIN_NAME, (stats) => {
- console.log('✅ ' + PLUGIN_NAME + ': route-tree generation done')
+ console.info('✅ ' + PLUGIN_NAME + ': route-tree generation done')
setTimeout(() => {
process.exit(0)
})
diff --git a/packages/start/src/client/renderRSC.tsx b/packages/start/src/client/renderRSC.tsx
index 656315fa6a5..fbe5d0cbdb8 100644
--- a/packages/start/src/client/renderRSC.tsx
+++ b/packages/start/src/client/renderRSC.tsx
@@ -13,7 +13,7 @@ export function renderRsc(input: any): JSX.Element {
input.state = {
status: 'pending',
promise: Promise.resolve()
- .then(async () => {
+ .then(() => {
let element
// We're in node