From 3450f93f2c84a8c0b742570140f96298e0a90e96 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 19 Jul 2026 20:38:04 +0200 Subject: [PATCH 1/5] fix(router-core): complete lane match loader rewrite --- docs/router/api/router/RouteMatchType.md | 15 +- docs/router/api/router/RouteOptionsType.md | 16 +- docs/router/api/router/RouterOptionsType.md | 4 +- docs/router/api/router/RouterStateType.md | 29 +- docs/router/api/router/RouterType.md | 47 +- docs/router/api/router/useChildMatchesHook.md | 5 +- docs/router/api/router/useMatchesHook.md | 2 +- .../router/api/router/useParentMatchesHook.md | 5 +- docs/router/guide/data-loading.md | 15 +- docs/router/guide/path-params.md | 3 + docs/router/guide/preloading.md | 39 +- docs/router/how-to/debug-router-issues.md | 11 +- .../selective-ssr/tests/app.spec.ts | 15 +- packages/react-router/src/CatchBoundary.tsx | 10 +- packages/react-router/src/Match.tsx | 334 +-- packages/react-router/src/Matches.tsx | 55 +- packages/react-router/src/RouterProvider.tsx | 4 +- packages/react-router/src/Scripts.tsx | 72 +- packages/react-router/src/Transitioner.tsx | 93 +- .../react-router/src/headContentUtils.tsx | 229 +- .../react-router/src/lazyRouteComponent.tsx | 48 +- .../react-router/src/ssr/RouterClient.tsx | 11 +- .../src/ssr/renderRouterToStream.tsx | 7 +- packages/react-router/src/useMatch.tsx | 24 +- packages/react-router/src/utils.ts | 5 +- packages/react-router/tests/Matches.test.tsx | 20 +- packages/react-router/tests/Scripts.test.tsx | 137 +- ...ancestor-loader-child-pending-min.test.tsx | 62 + ...mponent-preload-retry-pending-min.test.tsx | 26 + .../tests/component-preload-retry.test.tsx | 32 + .../tests/errorComponent.test.tsx | 244 ++ ...hydration-capped-boundary-pending.test.tsx | 25 +- .../tests/hydration-terminal-lane.test.tsx | 1 - .../issue-4467-lazy-route-pending.test.tsx | 66 + ...sue-4476-react-query-cancellation.test.tsx | 2 +- .../react-router/tests/not-found.test.tsx | 182 +- .../tests/preloaded-mount-resolution.test.tsx | 55 +- ...public-presentation-lane-contract.test.tsx | 244 ++ .../tests/renderRouterToStream.test.tsx | 34 +- .../tests/root-pending-min.test.tsx | 68 +- .../react-router/tests/routeContext.test.tsx | 32 +- .../router-client-stream-cleanup.test.tsx | 55 + packages/react-router/tests/router.test.tsx | 3 + .../store-updates-during-navigation.test.tsx | 4 +- .../tests/transactional-loading.test.tsx | 91 + .../tests/transitioner-render-ack.test.tsx | 93 +- packages/react-router/tests/useMatch.test.tsx | 153 +- .../react-start-client/src/hydrateStart.ts | 7 +- .../src/tests/hydrateStart.test.ts | 33 + packages/router-core/INTERNALS.md | 2321 +++++++++-------- packages/router-core/src/Matches.ts | 7 +- packages/router-core/src/hydrate.ts | 328 ++- packages/router-core/src/index.ts | 2 +- packages/router-core/src/load-client.ts | 1966 ++++++++------ packages/router-core/src/load-server.ts | 465 ++-- packages/router-core/src/rendered-matches.ts | 12 + packages/router-core/src/route-chunks.ts | 24 +- packages/router-core/src/router.ts | 608 ++--- .../src/ssr/createRequestHandler.ts | 76 +- .../router-core/src/ssr/handlerCallback.ts | 37 +- packages/router-core/src/ssr/server.ts | 3 +- packages/router-core/src/ssr/ssr-match-id.ts | 7 - packages/router-core/src/ssr/ssr-server.ts | 15 +- .../src/ssr/transformStreamWithRouter.ts | 38 +- packages/router-core/src/ssr/types.ts | 2 - packages/router-core/src/stores.ts | 202 +- .../tests/background-trim-abort.test.ts | 48 +- .../blocked-navigation-current-load.test.ts | 6 +- .../tests/boundary-component-chunk.test.ts | 99 +- .../tests/client-lane-adversarial.test.ts | 135 +- .../router-core/tests/granular-stores.test.ts | 236 +- packages/router-core/tests/hydrate.test.ts | 106 +- .../tests/hydration-boundary-chunks.test.ts | 7 +- .../tests/hydration-currentness.test.ts | 475 +--- ...ydration-terminal-error-child-head.test.ts | 9 +- .../invalidate-pre-rematch-failure.test.ts | 14 +- .../issue-3179-preload-cached-cause.test.ts | 8 +- ...4078-loader-notfound-root-boundary.test.ts | 8 +- ...-4444-param-parse-error-lazy-child.test.ts | 9 +- ...4572-preload-root-beforeload-flags.test.ts | 40 +- ...ue-5106-hydrated-notfound-boundary.test.ts | 13 +- ...27-hydration-root-global-not-found.test.ts | 76 - .../issue-6351-fuzzy-notfound-layout.test.ts | 14 +- packages/router-core/tests/load.test.ts | 80 +- .../loader-architecture-regressions.test.ts | 900 +++++++ .../tests/loader-self-abort.test.ts | 73 + .../tests/loader-thrown-promise.test.ts | 32 +- .../tests/parent-match-promise.test.ts | 43 +- .../tests/preflight-reentrant-context.test.ts | 50 - .../tests/preload-adoption.test.ts | 121 +- ...reload-background-parent-coherence.test.ts | 27 +- .../tests/preload-beforeload-reuse.test.ts | 183 +- .../tests/preload-navigation-adoption.test.ts | 115 +- .../preload-public-cache-behavior.test.ts | 204 ++ .../public-client-loading-contract.test.ts | 538 ++++ .../tests/public-hydration-contract.test.ts | 1089 ++++++++ .../public-preload-lane-contract.test.ts | 1081 ++++++++ .../server-beforeload-preload-flag.test.ts | 65 +- .../server-chunk-failure-lifecycle.test.ts | 2 +- .../server-concurrent-error-notfound.test.ts | 116 +- .../tests/server-loader-abort-error.test.ts | 238 +- .../tests/server-planning-redirect.test.ts | 39 +- .../tests/server-route-lifecycle.test.ts | 76 + .../tests/server-serial-ssr-notfound.test.ts | 13 +- .../tests/server-ssr-false-assets.test.ts | 42 + .../tests/server-ssr-option-error.test.ts | 43 + .../router-core/tests/ssr-match-id.test.ts | 26 - .../tests/ssr-server-cleanup.test.ts | 95 + .../tests/ssr-server-manifest.test.ts | 52 + .../tests/transformStreamWithRouter.test.ts | 32 + .../src/BaseTanStackRouterDevtoolsPanel.tsx | 40 +- .../router-devtools-core/src/useStyles.tsx | 3 +- packages/router-devtools-core/src/utils.tsx | 1 - .../tests/handle-route-update.test.ts | 5 +- packages/solid-router/src/CatchBoundary.tsx | 2 +- packages/solid-router/src/Match.tsx | 465 ++-- packages/solid-router/src/Matches.tsx | 25 +- packages/solid-router/src/Scripts.tsx | 105 +- packages/solid-router/src/Transitioner.tsx | 42 +- .../solid-router/src/headContentUtils.tsx | 205 +- packages/solid-router/src/matchContext.tsx | 2 - packages/solid-router/src/routerStores.ts | 45 +- .../solid-router/src/ssr/RouterClient.tsx | 13 +- .../src/ssr/renderRouterToStream.tsx | 2 +- packages/solid-router/src/useMatch.tsx | 2 +- packages/solid-router/tests/Scripts.test.tsx | 54 + packages/solid-router/tests/loaders.test.tsx | 9 +- .../tests/renderRouterToStream.test.tsx | 3 +- .../solid-router/tests/routeContext.test.tsx | 32 +- .../router-client-stream-cleanup.test.tsx | 39 + .../store-updates-during-navigation.test.tsx | 14 +- .../tests/transitioner-remount.test.tsx | 109 - .../tests/transitioner-render-ack.test.tsx | 5 +- packages/solid-router/tests/useMatch.test.tsx | 153 +- .../solid-start-client/src/hydrateStart.ts | 7 +- .../src/tests/hydrateStart.test.ts | 33 + .../src/client/hydrateStart.ts | 2 +- .../src/vite/dev-server-plugin/plugin.ts | 6 +- .../src/createStartHandler.ts | 186 +- .../tests/createStartHandler.test.ts | 220 +- packages/vue-router/src/CatchBoundary.tsx | 6 +- packages/vue-router/src/Match.tsx | 280 +- packages/vue-router/src/Matches.tsx | 77 +- packages/vue-router/src/Scripts.tsx | 72 +- packages/vue-router/src/Transitioner.tsx | 7 +- packages/vue-router/src/headContentUtils.tsx | 239 +- packages/vue-router/src/matchContext.tsx | 8 +- packages/vue-router/src/routerStores.ts | 27 +- packages/vue-router/src/ssr/RouterClient.tsx | 17 +- .../src/ssr/renderRouterToStream.tsx | 2 +- packages/vue-router/src/useMatch.tsx | 11 +- .../tests/disableGlobalCatchBoundary.test.tsx | 26 + ...hydration-capped-boundary-pending.test.tsx | 6 +- packages/vue-router/tests/loaders.test.tsx | 12 +- .../tests/renderRouterToStream.test.tsx | 3 +- .../router-client-stream-cleanup.test.tsx | 46 + .../store-updates-during-navigation.test.tsx | 14 +- .../transitioner-remount-rendered.test.tsx | 89 - packages/vue-router/tests/useMatch.test.tsx | 163 +- .../vue-start-client/src/hydrateStart.test.ts | 23 + packages/vue-start-client/src/hydrateStart.ts | 16 +- 161 files changed, 12310 insertions(+), 6221 deletions(-) create mode 100644 packages/react-router/tests/public-presentation-lane-contract.test.tsx create mode 100644 packages/react-router/tests/router-client-stream-cleanup.test.tsx create mode 100644 packages/react-start-client/src/tests/hydrateStart.test.ts create mode 100644 packages/router-core/src/rendered-matches.ts delete mode 100644 packages/router-core/src/ssr/ssr-match-id.ts delete mode 100644 packages/router-core/tests/issue-5427-hydration-root-global-not-found.test.ts create mode 100644 packages/router-core/tests/loader-architecture-regressions.test.ts create mode 100644 packages/router-core/tests/loader-self-abort.test.ts create mode 100644 packages/router-core/tests/public-client-loading-contract.test.ts create mode 100644 packages/router-core/tests/public-hydration-contract.test.ts create mode 100644 packages/router-core/tests/public-preload-lane-contract.test.ts create mode 100644 packages/router-core/tests/server-route-lifecycle.test.ts delete mode 100644 packages/router-core/tests/ssr-match-id.test.ts create mode 100644 packages/solid-router/tests/router-client-stream-cleanup.test.tsx delete mode 100644 packages/solid-router/tests/transitioner-remount.test.tsx create mode 100644 packages/solid-start-client/src/tests/hydrateStart.test.ts create mode 100644 packages/vue-router/tests/router-client-stream-cleanup.test.tsx create mode 100644 packages/vue-start-client/src/hydrateStart.test.ts diff --git a/docs/router/api/router/RouteMatchType.md b/docs/router/api/router/RouteMatchType.md index 251c2c031e..b5009ebe68 100644 --- a/docs/router/api/router/RouteMatchType.md +++ b/docs/router/api/router/RouteMatchType.md @@ -11,9 +11,8 @@ interface RouteMatch { routeId: string pathname: string params: Route['allParams'] - status: 'pending' | 'success' | 'error' | 'redirected' | 'notFound' + status: 'pending' | 'success' | 'error' | 'notFound' isFetching: false | 'beforeLoad' | 'loader' - showPending: boolean error: unknown paramsError: unknown searchError: unknown @@ -21,9 +20,17 @@ interface RouteMatch { loaderData?: Route['loaderData'] context: Route['allContext'] search: Route['fullSearchSchema'] - fetchedAt: number abortController: AbortController - cause: 'enter' | 'stay' + cause: 'preload' | 'enter' | 'stay' ssr?: boolean | 'data-only' } ``` + +`status` describes the match's render state. `isFetching` independently exposes +active `beforeLoad` or loader work. In particular, a successful match can report +`isFetching: 'loader'` while stale data remains visible during a background +reload. + +The router state can contain matches below the pending, error, or not-found +boundary. Those matches remain observable as part of the structurally matched +lane even though the route renderer stops at the boundary. diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index 1fb4c4eb30..a1c0cb42aa 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -56,6 +56,7 @@ The `RouteOptions` type accepts an object with the following properties: - Type: `(rawSearchParams: unknown) => TSearchSchema` - Optional - A function that will be called when this route is matched and passed the raw search params from the current location and return valid parsed search params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function does not throw, its return value will be used as the route's search params and the return type will be inferred into the rest of the router. +- This is a planning callback. It must be deterministic and side-effect-free for the same input; do not navigate or mutate application/router state from it. - Optionally, the parameter type can be tagged with the `SearchSchemaInput` type like this: `(searchParams: TSearchSchemaInput & SearchSchemaInput) => TSearchSchema`. If this tag is present, `TSearchSchemaInput` will be used to type the `search` property of `` and `navigate()` **instead of** `TSearchSchema`. The difference between `TSearchSchemaInput` and `TSearchSchema` can be useful, for example, to express optional search parameters. ### `search.middlewares` property @@ -70,6 +71,7 @@ The `RouteOptions` type accepts an object with the following properties: - Type: `(rawParams: Record) => TParams` - Optional - A function that will be called when this route is matched and passed the raw params from the current location and return valid parsed params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function does not throw, its return value will be used as the route's params and the return type will be inferred into the rest of the router. +- This is a planning callback. It must be deterministic and side-effect-free for the same input. ### `stringifyParams` method (⚠️ deprecated, use `params.stringify` instead) @@ -82,6 +84,7 @@ The `RouteOptions` type accepts an object with the following properties: - Type: `(rawParams: Record) => TParams | false` - Optional - A function that will be called when this route is matched and passed the raw params from the current location and return valid parsed params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function returns parsed params, its return value will be used as the route's params and the return type will be inferred into the rest of the router. +- This is a planning callback. It must be deterministic and side-effect-free for the same input. - Experimental: returning `false` during incoming route matching skips this route and allows matching to continue to another candidate route. ### `params.priority` property @@ -176,6 +179,7 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record - Optional - A function that will be called before this route is matched to provide additional unique identification to the route match and serve as a dependency tracker for when the match should be reloaded. It should return any serializable value that can uniquely identify the route match from navigation to navigation. +- This is a planning callback and cache-key function. It must be deterministic and side-effect-free for the same validated search input. The returned value and any serialization methods on it, such as `toJSON`, must also be side-effect-free. - By default, path params are already used to uniquely identify a route match, so it's unnecessary to return these here. - If your route match relies on search params for unique identification, it's required that you return them here so they can be made available in the `loader`'s `deps` argument. @@ -191,14 +195,14 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record - Type: `number` - Optional - Defaults to `routerOptions.defaultPreloadStaleTime`, which defaults to `30_000` ms (30 seconds) -- The amount of time in milliseconds that a route match's loader data will be considered fresh when preloading. If a route match is preloaded again within this time frame, its loader data will not be reloaded. If a route match is loaded (for navigation) within this time frame, the normal `staleTime` is used instead. +- The amount of time in milliseconds that loader data produced by a preload is considered fresh. Another preload or the first navigation can reuse it within this interval. After navigation accepts the generation, subsequent freshness uses `staleTime`. ### `gcTime` property - Type: `number` - Optional -- Defaults to `routerOptions.defaultGcTime`, which defaults to 30 minutes. -- The amount of time in milliseconds that a route match's loader data will be kept in memory after a preload or it is no longer in use. +- Defaults to `routerOptions.defaultGcTime`, which defaults to 5 minutes. +- The amount of time in milliseconds that loader data from an ordinary load will be kept in memory after it is no longer in use. ### `shouldReload` property @@ -234,12 +238,12 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record - Defaults to `routerOptions.defaultPendingMinMs` which defaults to `500` - The minimum amount of time in milliseconds that the pending component will be shown for if it is shown. This is useful to prevent the pending component from flashing on the screen for a split second. -### `preloadMaxAge` property +### `preloadGcTime` property - Type: `number` - Optional -- Defaults to `30_000` ms (30 seconds) -- The maximum amount of time in milliseconds that a route's preloaded route data will be cached for. If a route is not matched within this time frame, its loader data will be discarded. +- Defaults to `routerOptions.defaultPreloadGcTime`, which defaults to 5 minutes. +- The amount of time in milliseconds that loader data produced by a preload can remain in memory while it is not in use. This controls retention; use `preloadStaleTime` to control whether retained data is fresh enough to reuse without reloading. ### `preSearchFilters` property (⚠️ deprecated, use `search.middlewares` instead) diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 7127e1191e..91f13e0e50 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -129,14 +129,14 @@ The `RouterOptions` type accepts an object with the following properties and met - Type: `number` - Optional -- Defaults to `routerOptions.defaultGcTime`, which defaults to 30 minutes. +- Defaults to 5 minutes. - The default `preloadGcTime` a route should use if no preloadGcTime is provided. ### `defaultGcTime` property - Type: `number` - Optional -- Defaults to 30 minutes. +- Defaults to 5 minutes. - The default `gcTime` a route should use if no gcTime is provided. ### `defaultOnCatch` property diff --git a/docs/router/api/router/RouterStateType.md b/docs/router/api/router/RouterStateType.md index a77f4e775f..4da9cd9bb6 100644 --- a/docs/router/api/router/RouterStateType.md +++ b/docs/router/api/router/RouterStateType.md @@ -3,16 +3,17 @@ id: RouterStateType title: RouterState type --- -The `RouterState` type represents shape of the internal state of the router. The Router's internal state is useful, if you need to access certain internals of the router, such as any pending matches, is the router in its loading state, etc. +The `RouterState` type represents the observable state of the router, including +the requested and resolved locations, match presentation, and foreground loading +status. ```tsx type RouterState = { status: 'pending' | 'idle' isLoading: boolean - isTransitioning: boolean matches: Array location: ParsedLocation - resolvedLocation: ParsedLocation + resolvedLocation?: ParsedLocation } ``` @@ -23,22 +24,24 @@ The `RouterState` type contains all of the properties that are available on the ### `status` property - Type: `'pending' | 'idle'` -- The current status of the router. If the router is pending, it means that it is currently loading a route or the router is still transitioning to the new route. +- The current foreground status of the router. `pending` means the requested route is still loading or waiting for its framework transition to settle. ### `isLoading` property - Type: `boolean` -- `true` if the router is currently loading a route or waiting for a route to finish loading. - -### `isTransitioning` property - -- Type: `boolean` -- `true` if the router is currently transitioning to a new route. +- `true` when `status` is `pending`. +- Background loader refreshes do not change this value. Inspect each match's `isFetching` field to observe foreground `beforeLoad`/loader work and background loader work. ### `matches` property - Type: [`Array`](./RouteMatchType.md) -- An array of all of the route matches that have been resolved and are currently active. +- The match presentation currently exposed to the application. +- A navigation can keep the previous presentation visible until pending UI is + published. Once the destination is presented, this contains its complete + structurally matched lane, including descendants that are still loading. +- Error and not-found results also preserve the complete structurally matched + lane. Rendering stops at the selected pending or terminal boundary; it does + not truncate this array. ### `location` property @@ -47,5 +50,5 @@ The `RouterState` type contains all of the properties that are available on the ### `resolvedLocation` property -- Type: [`ParsedLocation`](./ParsedLocationType.md) -- The location that the router has resolved and loaded. +- Type: [`ParsedLocation`](./ParsedLocationType.md) | `undefined` +- The location whose load and framework transition have settled. It is `undefined` before the initial location resolves. diff --git a/docs/router/api/router/RouterType.md b/docs/router/api/router/RouterType.md index 757a6098ca..0252eb5b80 100644 --- a/docs/router/api/router/RouterType.md +++ b/docs/router/api/router/RouterType.md @@ -35,16 +35,6 @@ An instance of the `Router` has the following properties and methods: - Matches a pathname and search params against the router's route tree and returns an array of route matches. - If `opts.throwOnError` is `true`, any errors that occur during the matching process will be thrown (in addition to being returned in the route match's `error` property). -### `.cancelMatch` method - -- Type: `(matchId: string) => void` -- Cancels a route match that is currently pending by calling `match.abortController.abort()`. - -### `.cancelMatches` method - -- Type: `() => void` -- Cancels all route matches that are currently pending by calling `match.abortController.abort()` on each one. - ### `.buildLocation` method Builds a new parsed location object that can be used later to navigate to a new location. @@ -138,23 +128,27 @@ Navigates to a new location. ### `.invalidate` method -Invalidates route matches by forcing their `beforeLoad` and `load` functions to be called again. +Invalidates selected route-match generations, reruns their loading lifecycle, +reruns `beforeLoad`, and reloads their loaders through the normal loading +protocol. - Type: `(opts?: {filter?: (d: MakeRouteMatchUnion) => boolean, sync?: boolean, forcePending?: boolean }) => Promise` - This is useful any time your loader data might be out of date or stale. For example, if you have a route that displays a list of posts, and you have a loader function that fetches the list of posts from an API, you might want to invalidate the route matches for that route any time a new post is created so that the list of posts is always up-to-date. -- if `filter` is not supplied, all matches will be invalidated -- if `filter` is supplied, only matches for which `filter` returns `true` will be invalidated. -- if `sync` is true, the promise returned by this function will only resolve once all loaders have finished. -- if `forcePending` is true, the invalidated matches will be put into `'pending'` state regardless whether they are in `'error'` state or not. +- If `filter` is not supplied, all committed and cached match generations are invalidated. +- If `filter` is supplied, it is evaluated against committed and cached matches. Selecting one generation invalidates every committed or cached generation with the same match ID. +- Invalidation reruns `beforeLoad`; reusable loader data is marked stale and reloads through the normal loading protocol. Route-level `context` remains reusable while the match ID is unchanged. +- If `sync` is `true`, stale loader work is blocking and the returned promise resolves after it finishes instead of leaving a background refresh detached. +- If `forcePending` is `true`, selected routes that need loading enter the normal pending protocol even when successful data was already available. - You might also want to invalidate the Router if you imperatively `reset` the router's `CatchBoundary` to trigger loaders again. ### `.clearCache` method -Remove cached route matches. +Remove cached route matches and matching active preloads. - Type: `(opts?: {filter?: (d: MakeRouteMatchUnion) => boolean}) => void` -- if `filter` is not supplied, all cached matches will be removed -- if `filter` is supplied, only matches for which `filter` returns `true` will be removed. +- If `filter` is not supplied, all cached matches and active preload lanes are removed. +- If `filter` is supplied, matching cached matches are removed. An active preload lane is canceled when any match in that lane passes the filter. +- Current committed and presented matches are not removed. ### `.load` method @@ -170,16 +164,25 @@ Loads all of the currently matched route matches and resolves when they are all Preloads all of the matches that match the provided `NavigateOptions`. -> ⚠️⚠️⚠️ **Preloaded route matches are not stored long-term in the router state. They are only stored until the next attempted navigation action.** +An active preload is speculative and is not published as the current match +presentation. Successful loader data can enter the normal in-memory route cache +and remain reusable according to `preloadStaleTime` and `preloadGcTime`. + +Completed preload `beforeLoad` context is not cached. A later navigation reruns +`beforeLoad` unless it adopts an identical whole-route preload that is still +active. Adoption also requires unchanged router context, additional context, +and user-supplied location state. -- Type: `(opts?: NavigateOptions) => Promise` +- Type: `(opts: NavigateOptions) => Promise` - Properties - `opts` - Type: `NavigateOptions` - - Optional, defaults to the current location. + - Required. - The options that will be used to determine which route matches to preload. - Returns - - A promise that resolves with an array of all of the route matches that were preloaded. + - A promise that resolves with the speculative route-match lane. An ordinary error or not-found is represented by a terminal match array rather than a rejected promise. + - It resolves with `undefined` when cancellation or control flow does not produce a reusable lane. + - The method is also available on server router instances. It remains speculative and does not change the request's current location or presented matches. ### `.loadRouteChunk` method diff --git a/docs/router/api/router/useChildMatchesHook.md b/docs/router/api/router/useChildMatchesHook.md index d6de892192..13212e61e0 100644 --- a/docs/router/api/router/useChildMatchesHook.md +++ b/docs/router/api/router/useChildMatchesHook.md @@ -5,8 +5,9 @@ title: useChildMatches hook The `useChildMatches` hook returns all of the child [`RouteMatch`](./RouteMatchType.md) objects from the closest match down to the leaf-most match. **It does not include the current match, which can be obtained using the `useMatch` hook.** -> [!IMPORTANT] -> If the router has pending matches and they are showing their pending component fallbacks, pending matches are used instead of active matches. +The result is derived from the router's current match presentation. It can +include still-loading descendants and descendants below the current render +boundary. ## useChildMatches options diff --git a/docs/router/api/router/useMatchesHook.md b/docs/router/api/router/useMatchesHook.md index d7a1be8dc3..e57c6c2c7e 100644 --- a/docs/router/api/router/useMatchesHook.md +++ b/docs/router/api/router/useMatchesHook.md @@ -3,7 +3,7 @@ id: useMatchesHook title: useMatches hook --- -The `useMatches` hook returns all of the [`RouteMatch`](./RouteMatchType.md) objects from the router **regardless of its callers position in the React component tree**. +The `useMatches` hook returns the router's complete presented array of [`RouteMatch`](./RouteMatchType.md) objects **regardless of its caller's position in the component tree**. The array can include still-loading descendants or matches below a pending/error/not-found render boundary. > [!TIP] > If you only want the parent or child matches, then you can use the [`useParentMatches`](./useParentMatchesHook.md) or the [`useChildMatches`](./useChildMatchesHook.md) based on the selection you need. diff --git a/docs/router/api/router/useParentMatchesHook.md b/docs/router/api/router/useParentMatchesHook.md index 7afc5585a6..a0ce985411 100644 --- a/docs/router/api/router/useParentMatchesHook.md +++ b/docs/router/api/router/useParentMatchesHook.md @@ -5,8 +5,9 @@ title: useParentMatches hook The `useParentMatches` hook returns all of the parent [`RouteMatch`](./RouteMatchType.md) objects from the root down to the immediate parent of the current match in context. **It does not include the current match, which can be obtained using the `useMatch` hook.** -> [!IMPORTANT] -> If the router has pending matches and they are showing their pending component fallbacks, pending matches are used instead of active matches. +The result is derived from the router's current match presentation. During a +navigation that may still be the previous presentation; after pending UI is +published it is the destination's complete structurally matched lane. ## useParentMatches options diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index 2ecdf443ad..dad97b1e39 100644 --- a/docs/router/guide/data-loading.md +++ b/docs/router/guide/data-loading.md @@ -150,7 +150,7 @@ Using these dependencies as keys, TanStack Router will cache the data returned f To control router dependencies and "freshness", TanStack Router provides a plethora of options to control the keying and caching behavior of your route loaders. Let's take a look at them in the order that you are most likely to use them: - `routeOptions.loaderDeps` - - A function that supplies you the search params for a router and returns an object of dependencies for use in your `loader` function. When these deps changed from navigation to navigation, it will cause the route to reload regardless of `staleTime`s. The deps are compared using a deep equality check. + - A deterministic, side-effect-free function that supplies you the validated search params for a router and returns a serializable object of dependencies for use in your `loader` function. When these deps change from navigation to navigation, it will cause the route to reload regardless of `staleTime`s. The deps are compared using a deep equality check. - `routeOptions.staleTime` - `routerOptions.defaultStaleTime` - The number of milliseconds that a route's data should be considered fresh when attempting to load. @@ -170,7 +170,7 @@ To control router dependencies and "freshness", TanStack Router provides a pleth - 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. +- By default, `gcTime` and `preloadGcTime` are **5 minutes**, meaning unused loader data is removed from the in-memory cache after 5 minutes. They can be configured independently. - By default, `staleReloadMode` is `'background'`, so stale successful matches keep rendering with their existing `loaderData` while the loader revalidates in the background. - `router.invalidate()` will force all active routes to reload their loaders immediately and mark every cached route's data as stale. @@ -180,6 +180,11 @@ Imagine a `/posts` route supports some pagination via search params `offset` and Once we have these deps in place, the route will always reload when the deps change. +`loaderDeps` defines a cache key during route planning. For the same validated +search input it must return the same value without navigating or mutating state. +The returned value and custom serialization methods such as `toJSON` must also +be side-effect-free. + ```tsx // /routes/posts.tsx export const Route = createFileRoute('/posts')({ @@ -302,7 +307,11 @@ export const Route = createFileRoute('/posts')({ ### Opting out of caching while still preloading -Even though you may opt-out of short-term caching for your route data, you can still get the benefits of preloading! With the above configuration, preloading will still "just work" with the default `preloadGcTime`. This means that if a route is preloaded, then navigated to, the route's data will be considered fresh and will not be reloaded. +Even though you may opt out of retaining ordinary route data, you can still get +the benefits of preloading. Preloaded results use `preloadGcTime` for retention +and `preloadStaleTime` for freshness, so the default settings keep a recent +preload in memory and let the first navigation reuse it without another loader +call. To opt out of preloading, don't turn it on via the `routerOptions.defaultPreload` or `routeOptions.preload` options. diff --git a/docs/router/guide/path-params.md b/docs/router/guide/path-params.md index b1ae4f5d2f..d9d6d753b6 100644 --- a/docs/router/guide/path-params.md +++ b/docs/router/guide/path-params.md @@ -137,6 +137,9 @@ When multiple dynamic, optional, or wildcard routes can match the same URL, rout Higher `params.priority` values are tried first. The default priority is `0`, and if a higher-priority route's `params.parse` returns `false`, matching continues to the next candidate route. +`params.parse` runs during route planning and may be evaluated more than once. +It must be deterministic and side-effect-free for the same raw params. + ```tsx title="src/routes/posts.$postId.tsx" export const Route = createFileRoute('/posts/$postId')({ params: { diff --git a/docs/router/guide/preloading.md b/docs/router/guide/preloading.md index 092a869685..9498880ff8 100644 --- a/docs/router/guide/preloading.md +++ b/docs/router/guide/preloading.md @@ -18,10 +18,17 @@ Preloading in TanStack Router is a way to load a route before the user actually ## How long does preloaded data stay in memory? -Preloaded route matches are temporarily cached in memory with a few important caveats: - -- **Unused preloaded data is removed after 30 seconds by default.** This can be configured by setting the `defaultPreloadMaxAge` option on your router. -- **Obviously, when a route is loaded, its preloaded version is promoted to the router's normal pending matches state.** +Successful preloaded loader results can enter the router's in-memory cache with +two independent lifetimes: + +- **Freshness defaults to 30 seconds.** Configure it with + `defaultPreloadStaleTime` or a route's `preloadStaleTime`. +- **Unused retention defaults to 5 minutes.** Configure it with + `defaultPreloadGcTime` or a route's `preloadGcTime`. +- **The speculative lane is never promoted into router state.** Navigation + creates its own presentation and can reuse cached loader data. It reruns + `beforeLoad` unless it adopts the same whole lane while that preload is still + active. If you need more control over preloading, caching and/or garbage collection of preloaded data, you should use an external caching library like [TanStack Query](https://tanstack.com/query). @@ -87,9 +94,15 @@ const router = createRouter({ You can also set the `preloadDelay` prop on individual `` components to override the default behavior on a per-link basis. -## Built-in Preloading & `preloadStaleTime` +## Built-in Preloading, Freshness, and Retention + +If you're using the built-in loaders, you can control how long preloaded data is considered fresh by setting either `routerOptions.defaultPreloadStaleTime` or `routeOptions.preloadStaleTime` to a number of milliseconds. **By default, preloaded data is considered fresh for 30 seconds.** -If you're using the built-in loaders, you can control how long preloaded data is considered fresh until another preload is triggered by setting either `routerOptions.defaultPreloadStaleTime` or `routeOptions.preloadStaleTime` to a number of milliseconds. **By default, preloaded data is considered fresh for 30 seconds.**. +Freshness and retention are separate. `preloadStaleTime` controls whether the +retained loader result can be reused without another loader call. +`preloadGcTime` (or `defaultPreloadGcTime`) controls how long an unused preload +result can remain in the in-memory cache. Both preload GC options default to 5 +minutes. To change this, you can set the `defaultPreloadStaleTime` option on your router: @@ -125,12 +138,16 @@ Or, you can use the `routeOptions.preloadStaleTime` option on individual routes: // src/routes/posts.$postId.tsx export const Route = createFileRoute('/posts/$postId')({ loader: async ({ params }) => fetchPost(params.postId), - // Preload the route again if the preload cache is older than 10 seconds + // Reload preloaded data when it is more than 10 seconds old preloadStaleTime: 10_000, }) ``` -Client-side preloading also runs each new route's `beforeLoad` with `preload: true`. When a completed successful preload is still fresh under the built-in freshness and invalidation policy used for preloaded loader data, the client router can reuse the context returned by that invocation instead of calling `beforeLoad` again with `preload: false`. Reuse follows route match identity, including `loaderDeps`, and requires a reusable parent context. The `shouldReload` option remains loader-only. Pending, failed, invalidated, or stale preloads do not donate their `beforeLoad` context to a client navigation. +Client-side preloading also runs each new route's `beforeLoad` with `preload: true`. A completed preload never caches the context returned by `beforeLoad`; a later navigation calls `beforeLoad` again with `preload: false`, even when it reuses the preload's cached loader data. + +There is one exception: if navigation starts while an identical whole-route preload is still running, it can adopt that active work. Compatibility includes the complete ordered route lane, params, search, router context, additional context, and user-supplied location state. In that case navigation can use the active preload's `beforeLoad` result, or follow its redirect, without calling `beforeLoad` again. A completed, failed, not-found, or canceled preload does not donate `beforeLoad` context to a later navigation. The `shouldReload` option remains loader-only. + +If any route in the lane has `preload: false`, navigation does not adopt the active preload. It reruns the `beforeLoad` chain and performs the loader work that speculation skipped. ## Preloading with External Libraries @@ -170,7 +187,11 @@ This would then allow you, for instance, to use an option like React Query's `st ## Preloading Manually -If you need to manually preload a route, you can use the router's `preloadRoute` method. It accepts a standard TanStack `NavigateOptions` object and returns a promise that resolves when the route is preloaded. +If you need to manually preload a route, use the router's `preloadRoute` method. +It accepts a standard TanStack `NavigateOptions` object and returns the +speculative match lane. An ordinary error or not-found is represented in that +returned lane; cancellation or control flow that produces no reusable lane can +return `undefined`. diff --git a/docs/router/how-to/debug-router-issues.md b/docs/router/how-to/debug-router-issues.md index b4d20ec72f..27dadd361f 100644 --- a/docs/router/how-to/debug-router-issues.md +++ b/docs/router/how-to/debug-router-issues.md @@ -322,11 +322,16 @@ const route = createRoute({ ```tsx function DataLoadingDebug() { - const location = useLocation() + const state = useRouterState() console.log('Route status:', { - isLoading: location.isLoading, - isTransitioning: location.isTransitioning, + status: state.status, + isLoading: state.isLoading, + matches: state.matches.map((match) => ({ + routeId: match.routeId, + status: match.status, + isFetching: match.isFetching, + })), }) return null diff --git a/e2e/react-start/selective-ssr/tests/app.spec.ts b/e2e/react-start/selective-ssr/tests/app.spec.ts index 3d7053f6fa..89eff32cae 100644 --- a/e2e/react-start/selective-ssr/tests/app.spec.ts +++ b/e2e/react-start/selective-ssr/tests/app.spec.ts @@ -4,7 +4,7 @@ import { test } from '@tanstack/router-e2e-utils' const testCount = 7 test.describe('selective ssr', () => { - test('#4614: ssr false child receives context from its parent load generation', async ({ + test('#4614: cached parent loader data does not cache its beforeLoad context', async ({ page, }) => { await page.goto('/') @@ -25,12 +25,19 @@ test.describe('selective ssr', () => { } }) - expect(rootBeforeLoads).toEqual([]) + expect(rootBeforeLoads).toEqual([ + { + cause: 'preload', + preload: true, + root: 'client', + issue4614Context: 'client:cached', + }, + ]) expect(targetBeforeLoad).toEqual({ cause: 'preload', preload: true, - rootContext: 'server', - issue4614Context: 'server:cached', + rootContext: 'client', + issue4614Context: 'client:cached', scenario: 'cached', }) }) diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index 98743f9447..79afb87934 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -5,7 +5,7 @@ import type { ErrorRouteComponent } from './route' import type { ErrorInfo } from 'react' export function CatchBoundary(props: { - getResetKey: () => number | string + getResetKey: () => unknown children: React.ReactNode errorComponent?: ErrorRouteComponent onCatch?: (error: Error, errorInfo: ErrorInfo) => void @@ -31,18 +31,18 @@ export function CatchBoundary(props: { } class CatchBoundaryImpl extends React.Component<{ - getResetKey: () => number | string + getResetKey: () => unknown children: (props: { error: Error | null reset: () => void }) => React.ReactNode onCatch?: (error: Error, errorInfo: ErrorInfo) => void }> { - state = { error: null } as { error: Error | null; resetKey?: string | number } + state = { error: null } as { error: Error | null; resetKey?: unknown } static getDerivedStateFromProps( - props: { getResetKey: () => string | number }, - state: { resetKey?: string | number; error: Error | null }, + props: { getResetKey: () => unknown }, + state: { resetKey?: unknown; error: Error | null }, ) { const resetKey = props.getResetKey() diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 44aa3a8633..99028c173e 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { useStore } from '@tanstack/react-store' -import { invariant, isNotFound, rootRouteId } from '@tanstack/router-core' +import { isNotFound, rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouter } from './useRouter' @@ -18,113 +18,58 @@ import type { RootRouteOptions, } from '@tanstack/router-core' +export function renderPending( + router: ReturnType, + route?: AnyRoute, +) { + const PendingComponent = + route?.options.pendingComponent ?? router.options.defaultPendingComponent + return PendingComponent ? : null +} + type OutletMatchSelection = [ - routeId: string | undefined, parentGlobalNotFound: boolean, parentNotFoundError: unknown, ] -const matchViewFieldsEqual = (a: AnyRouteMatch, b: AnyRouteMatch) => - a.routeId === b.routeId && a.fetchCount === b.fetchCount +const matchViewFieldsEqual = ( + a: AnyRouteMatch | undefined, + b: AnyRouteMatch | undefined, +) => a?.abortController === b?.abortController && a?.status === b?.status const outletMatchSelectionEqual = ( a: OutletMatchSelection, b: OutletMatchSelection, -) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +) => a[0] === b[0] && a[1] === b[1] export const Match = React.memo(function MatchImpl({ - matchId, + routeId, }: { - matchId: string + routeId: string }) { const router = useRouter() if (isServer ?? router.isServer) { - const match = router.stores.matchStores.get(matchId)?.get() - if (!match) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - `Invariant failed: Could not find match for matchId "${matchId}". Please file an issue!`, - ) - } - - invariant() - } - - const routeId = match.routeId as string - const parentRouteId = (router.routesById[routeId] as AnyRoute).parentRoute - ?.id - - return ( - - ) + const match = router.stores.byRoute.get(routeId)!.get()! + return } - // Subscribe directly to the match store from the pool. - // The matchId prop is stable for this component's lifetime (set by Outlet), - // and reconcileMatchPool reuses stores for the same matchId. - - const matchStore = router.stores.matchStores.get(matchId) - if (!matchStore) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - `Invariant failed: Could not find match for matchId "${matchId}". Please file an issue!`, - ) - } - - invariant() - } + const matchStore = router.stores.getMatchStore(routeId) // eslint-disable-next-line react-hooks/rules-of-hooks const match = useStore(matchStore, (value) => value, matchViewFieldsEqual) - const routeId = match.routeId as string - const parentRouteId = (router.routesById[routeId] as AnyRoute).parentRoute?.id - - return ( - - ) + return }) -type MatchViewState = { - routeId: string - ssr: boolean | 'data-only' | undefined - parentRouteId: string | undefined -} - function MatchView({ router, - matchId, - resetKey, - matchState, + match, }: { router: ReturnType - matchId: string - resetKey: number - matchState: MatchViewState + match: AnyRouteMatch }) { - const route: AnyRoute = router.routesById[matchState.routeId] + const route: AnyRoute = router.routesById[match.routeId] - const PendingComponent = - route.options.pendingComponent ?? router.options.defaultPendingComponent - - const pendingElement = PendingComponent ? : null + const pendingElement = renderPending(router, route) const routeErrorComponent = route.options.errorComponent ?? router.options.defaultErrorComponent @@ -132,16 +77,15 @@ function MatchView({ const routeOnCatch = route.options.onCatch ?? router.options.defaultOnCatch const routeNotFoundComponent = route.isRoot - ? // If it's the root route, use the globalNotFound option, with fallback to the notFoundRoute's component + ? // If it's the root route, use the _notFound option, with fallback to the notFoundRoute's component (route.options.notFoundComponent ?? router.options.notFoundRoute?.options.component) : route.options.notFoundComponent - const resolvedNoSsr = - matchState.ssr === false || matchState.ssr === 'data-only' + const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only' const ResolvedSuspenseBoundary = (route.options.wrapInSuspense ?? - PendingComponent ?? + pendingElement ?? ((route.options.errorComponent as any)?.preload || resolvedNoSsr)) ? React.Suspense : SafeFragment @@ -159,146 +103,68 @@ function MatchView({ : SafeFragment return ( - + `${matchId}:${resetKey}`} - errorComponent={routeErrorComponent || ErrorComponent} + getResetKey={() => match.abortController} + errorComponent={routeErrorComponent as any} onCatch={(error, errorInfo) => { // Forward not found errors (we don't want to show the error component for these) if (isNotFound(error)) { - error.routeId ??= matchState.routeId as any + error.routeId ??= match.routeId throw error } if (process.env.NODE_ENV !== 'production') { - console.warn(`Warning: Error in route match: ${matchId}`) + console.warn(`Warning: Error in route match: ${match.id}`) } routeOnCatch?.(error, errorInfo) }} > { - error.routeId ??= matchState.routeId as any - - // If the current not found handler doesn't exist or it has a - // route ID which doesn't match the current route, rethrow the error - if ( - !routeNotFoundComponent || - (error.routeId && error.routeId !== matchState.routeId) || - (!error.routeId && !route.isRoot) - ) + error.routeId ??= match.routeId + + if (error.routeId !== match.routeId) { throw error + } - return React.createElement(routeNotFoundComponent, error as any) + return React.createElement( + routeNotFoundComponent!, + error as any, + ) }} > {resolvedNoSsr ? ( - + ) : ( - + )} - {matchState.parentRouteId === rootRouteId ? ( - router.options.scrollRestoration && (isServer ?? router.isServer) ? ( - - ) : null + {(isServer ?? router.isServer) && + route.parentRoute?.id === rootRouteId && + router.options.scrollRestoration ? ( + ) : null} ) } export const MatchInner = React.memo(function MatchInnerImpl({ - matchId, + match, }: { - matchId: string + match: AnyRouteMatch }): any { const router = useRouter() - - if (isServer ?? router.isServer) { - const match = router.stores.matchStores.get(matchId)?.get() - if (!match) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - `Invariant failed: Could not find match for matchId "${matchId}". Please file an issue!`, - ) - } - - invariant() - } - - const routeId = match.routeId as string - const route = router.routesById[routeId] as AnyRoute - const remountFn = - (router.routesById[routeId] as AnyRoute).options.remountDeps ?? - router.options.defaultRemountDeps - const remountDeps = remountFn?.({ - routeId, - loaderDeps: match.loaderDeps, - params: match._strictParams, - search: match._strictSearch, - }) - const key = remountDeps ? JSON.stringify(remountDeps) : undefined - const Comp = route.options.component ?? router.options.defaultComponent - const out = Comp ? : - - if (match.status === 'pending') { - invariant() - } - - if (match.status === 'notFound') { - if (!isNotFound(match.error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error('Invariant failed: Expected a notFound error') - } - - invariant() - } - return renderRouteNotFound(router, route, match.error) - } - - if (match.status === 'error') { - const RouteErrorComponent = - (route.options.errorComponent ?? - router.options.defaultErrorComponent) || - ErrorComponent - return ( - - ) - } - - return out - } - - const matchStore = router.stores.matchStores.get(matchId) - if (!matchStore) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - `Invariant failed: Could not find match for matchId "${matchId}". Please file an issue!`, - ) - } - - invariant() - } - // eslint-disable-next-line react-hooks/rules-of-hooks - const match = useStore(matchStore, (value) => value) - const routeId = match.routeId as string + const routeId = match.routeId const route = router.routesById[routeId] as AnyRoute - // eslint-disable-next-line react-hooks/rules-of-hooks const key = React.useMemo(() => { const remountFn = - (router.routesById[routeId] as AnyRoute).options.remountDeps ?? - router.options.defaultRemountDeps + route.options.remountDeps ?? router.options.defaultRemountDeps const remountDeps = remountFn?.({ routeId, loaderDeps: match.loaderDeps, @@ -311,42 +177,26 @@ export const MatchInner = React.memo(function MatchInnerImpl({ match.loaderDeps, match._strictParams, match._strictSearch, + route.options.remountDeps, router.options.defaultRemountDeps, - router.routesById, ]) - - // eslint-disable-next-line react-hooks/rules-of-hooks const out = React.useMemo(() => { const Comp = route.options.component ?? router.options.defaultComponent - if (Comp) { - return - } - return + return Comp ? : }, [key, route.options.component, router.options.defaultComponent]) if (match.status === 'pending') { - const PendingComponent = - route.options.pendingComponent ?? router.options.defaultPendingComponent - return PendingComponent ? : null + if (router._tx) { + throw router._tx[5] + } + return renderPending(router, route) } if (match.status === 'notFound') { - if (!isNotFound(match.error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error('Invariant failed: Expected a notFound error') - } - - invariant() - } return renderRouteNotFound(router, route, match.error) } if (match.status === 'error') { - // If we're on the server, we need to use React's new and super - // wonky api for throwing errors from a server side render inside - // of a suspense boundary. This is the only way to get - // renderToPipeableStream to not hang indefinitely. - // We'll serialize the error and rethrow it on the client. if (isServer ?? router.isServer) { const RouteErrorComponent = (route.options.errorComponent ?? @@ -362,7 +212,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({ /> ) } - throw match.error } @@ -377,77 +226,54 @@ export const MatchInner = React.memo(function MatchInnerImpl({ */ export const Outlet = React.memo(function OutletImpl() { const router = useRouter() - const matchId = React.useContext(matchContext) + const routeId = React.useContext(matchContext)! - let routeId: string | undefined - let parentGlobalNotFound = false + let parentGlobalNotFound: boolean let parentNotFoundError: unknown - let childMatchId: string | undefined + let childRouteId: string | undefined if (isServer ?? router.isServer) { const matches = router.stores.matches.get() - const parentIndex = matchId - ? matches.findIndex((match) => match.id === matchId) - : -1 - const parentMatch = parentIndex >= 0 ? matches[parentIndex] : undefined - routeId = parentMatch?.routeId as string | undefined - parentGlobalNotFound = parentMatch?.globalNotFound ?? false - parentNotFoundError = parentMatch?.error - childMatchId = - parentIndex >= 0 ? (matches[parentIndex + 1]?.id as string) : undefined + const parentIndex = matches.findIndex((match) => match.routeId === routeId) + const parentMatch = matches[parentIndex]! + parentGlobalNotFound = !!parentMatch._notFound + parentNotFoundError = parentMatch.error + childRouteId = matches[parentIndex + 1]?.routeId } else { - // Subscribe directly to the match store from the pool instead of - // the two-level byId → matchStore pattern. - const parentMatchStore = matchId - ? router.stores.matchStores.get(matchId) - : undefined + const parentMatchStore = router.stores.getMatchStore(routeId) // eslint-disable-next-line react-hooks/rules-of-hooks - ;[routeId, parentGlobalNotFound, parentNotFoundError] = useStore( + ;[parentGlobalNotFound, parentNotFoundError] = useStore( parentMatchStore, - (match): OutletMatchSelection => [ - match?.routeId as string | undefined, - match?.globalNotFound ?? false, - match?.error, - ], + (match): OutletMatchSelection => [!!match!._notFound, match!.error], outletMatchSelectionEqual, ) // eslint-disable-next-line react-hooks/rules-of-hooks - childMatchId = useStore(router.stores.matchesId, (ids) => { - const index = ids.findIndex((id) => id === matchId) - return ids[index + 1] + childRouteId = useStore(router.stores.ids, (ids) => { + return ids[ids.indexOf(routeId) + 1] }) } - const route = routeId ? router.routesById[routeId] : undefined - - const pendingElement = router.options.defaultPendingComponent ? ( - - ) : null - if (parentGlobalNotFound) { - if (!route) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: Could not resolve route for Outlet render', - ) - } - - invariant() - } - return renderRouteNotFound(router, route, parentNotFoundError) + return renderRouteNotFound( + router, + router.routesById[routeId], + parentNotFoundError, + ) } - if (!childMatchId) { + if (!childRouteId) { return null } - const nextMatch = + const nextMatch = if (routeId === rootRouteId) { return ( - {nextMatch} + + {nextMatch} + ) } diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 54422d8b7d..ba85b00816 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -4,13 +4,13 @@ import * as React from 'react' import { useStore } from '@tanstack/react-store' import { rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { CatchBoundary, ErrorComponent } from './CatchBoundary' +import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import { useStructuralSharing } from './useMatch' import { useLayoutEffect } from './utils' import { Transitioner } from './Transitioner' import { matchContext } from './matchContext' -import { Match } from './Match' +import { Match, renderPending } from './Match' import { SafeFragment } from './SafeFragment' import type { StructuralSharingOption, @@ -49,17 +49,11 @@ export function Matches() { const router = useRouter() const rootRoute: AnyRoute = router.routesById[rootRouteId] - const PendingComponent = - rootRoute.options.pendingComponent ?? router.options.defaultPendingComponent - - const pendingElement = PendingComponent ? : null + const pendingElement = renderPending(router, rootRoute) // Do not render a root Suspense during SSR or hydrating from SSR const ResolvedSuspense = - (isServer ?? router.isServer) || - (typeof document !== 'undefined' && router.ssr) - ? SafeFragment - : React.Suspense + (isServer ?? router.isServer) || router.ssr ? SafeFragment : React.Suspense const inner = ( <> @@ -85,22 +79,21 @@ function MatchesInner() { : // eslint-disable-next-line react-hooks/rules-of-hooks useStore(router.stores.matches, (value) => value) const match = matches[0] - const matchId = match?.id + const routeId = match?.routeId useLayoutEffect(() => { - router._rendered?.() + router._rendered!(matches) }, [matches, router]) - const matchComponent = matchId ? : null + const matchComponent = routeId ? : null return ( - + {router.options.disableGlobalCatchBoundary ? ( matchComponent ) : ( (match ? `${matchId}:${match.fetchCount}` : '')} - errorComponent={ErrorComponent} + getResetKey={() => match?.abortController} onCatch={ process.env.NODE_ENV !== 'production' ? (error) => { @@ -148,7 +141,7 @@ export function useMatchRoute() { if (!(isServer ?? router.isServer)) { // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.matchRouteDeps, (d) => d) + useStore(router.stores.loadDeps, (d) => d) } return React.useCallback( @@ -260,20 +253,8 @@ export function useMatches< } /** - * Read the full array of active route matches or select a derived subset. - * - * Useful for debugging, breadcrumbs, or aggregating metadata across matches. - * - * @returns The array of matches (or the selected value). - * @link https://tanstack.com/router/latest/docs/framework/react/api/router/useMatchesHook - */ - -/** - * Read the full array of active route matches or select a derived subset. - * - * Useful for debugging, breadcrumbs, or aggregating metadata across matches. - * - * @link https://tanstack.com/router/latest/docs/framework/react/api/router/useMatchesHook + * Read the presented route matches above the current match, or select a + * derived value from them. */ export function useParentMatches< TRouter extends AnyRouter = RegisteredRouter, @@ -283,13 +264,13 @@ export function useParentMatches< opts?: UseMatchesBaseOptions & StructuralSharingOption, ): UseMatchesResult { - const contextMatchId = React.useContext(matchContext) + const contextRouteId = React.useContext(matchContext) return useMatches({ select: (matches: Array>) => { matches = matches.slice( 0, - matches.findIndex((d) => d.id === contextMatchId), + matches.findIndex((d) => d.routeId === contextRouteId), ) return opts?.select ? opts.select(matches) : matches }, @@ -298,8 +279,8 @@ export function useParentMatches< } /** - * Read the array of active route matches that are children of the current - * match (or selected parent) in the match tree. + * Read the presented route matches below the current match, or select a + * derived value from them. */ export function useChildMatches< TRouter extends AnyRouter = RegisteredRouter, @@ -309,12 +290,12 @@ export function useChildMatches< opts?: UseMatchesBaseOptions & StructuralSharingOption, ): UseMatchesResult { - const contextMatchId = React.useContext(matchContext) + const contextRouteId = React.useContext(matchContext) return useMatches({ select: (matches: Array>) => { matches = matches.slice( - matches.findIndex((d) => d.id === contextMatchId) + 1, + matches.findIndex((d) => d.routeId === contextRouteId) + 1, ) return opts?.select ? opts.select(matches) : matches }, diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index 4846ab17fc..81c3fb4ece 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -50,8 +50,8 @@ export function RouterContextProvider< } /** - * Top-level component that renders the active route matches and provides the - * router to the React tree via context. + * Renders the current match presentation and provides the router to the React + * tree via context. * * Accepts the same options as `createRouter` via props to update the router * instance after creation. diff --git a/packages/react-router/src/Scripts.tsx b/packages/react-router/src/Scripts.tsx index 5285fbd8db..33f4961c2a 100644 --- a/packages/react-router/src/Scripts.tsx +++ b/packages/react-router/src/Scripts.tsx @@ -1,5 +1,5 @@ import { useStore } from '@tanstack/react-store' -import { deepEqual } from '@tanstack/router-core' +import { _getRenderedMatches, deepEqual } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { Asset } from './Asset' import { useRouter } from './useRouter' @@ -17,23 +17,38 @@ export const Scripts = () => { const router = useRouter() const nonce = router.options.ssr?.nonce - const getAssetScripts = (matches: Array) => { - const assetScripts: Array = [] + const getScripts = (matches: Array) => { + matches = _getRenderedMatches(matches) + const scripts = matches + .flatMap((match) => match.scripts ?? []) + .filter(Boolean) + .map( + ({ children, ...script }) => + ({ + tag: 'script', + attrs: { + ...script, + suppressHydrationWarning: true, + nonce, + }, + children, + }) satisfies RouterManagedTag, + ) as Array const manifest = router.ssr?.manifest if (!manifest) { - return [] + return scripts } for (const match of matches) { - const scripts = manifest.routes[match.routeId]?.scripts + const manifestScripts = manifest.routes[match.routeId]?.scripts - if (!scripts) { + if (!manifestScripts) { continue } - for (const asset of scripts) { - assetScripts.push({ + for (const asset of manifestScripts) { + scripts.push({ tag: 'script', attrs: { ...asset.attrs, nonce }, children: asset.children, @@ -44,64 +59,35 @@ export const Scripts = () => { } } - return assetScripts + return scripts } - const getScripts = (matches: Array): Array => - ( - matches - .map((match) => match.scripts!) - .flat(1) - .filter(Boolean) as Array - ).map( - ({ children, ...script }) => - ({ - tag: 'script', - attrs: { - ...script, - suppressHydrationWarning: true, - nonce, - }, - children, - }) satisfies RouterManagedTag, - ) - if (isServer ?? router.isServer) { const activeMatches = router.stores.matches.get() - const assetScripts = getAssetScripts(activeMatches) const scripts = getScripts(activeMatches) - return renderScripts(router, scripts, assetScripts) + return renderScripts(router, scripts) } - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const assetScripts = useStore( - router.stores.matches, - getAssetScripts, - deepEqual, - ) // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static const scripts = useStore(router.stores.matches, getScripts, deepEqual) - return renderScripts(router, scripts, assetScripts) + return renderScripts(router, scripts) } function renderScripts( router: ReturnType, - scripts: Array, - assetScripts: Array, + scripts: Array, ) { - const allScripts = [...scripts, ...assetScripts] as Array - if ((isServer ?? router.isServer) && router.serverSsr) { const serverBufferedScript = router.serverSsr.takeBufferedScripts() if (serverBufferedScript) { - allScripts.unshift(serverBufferedScript) + scripts.unshift(serverBufferedScript) } } return ( <> - {allScripts.map((asset, i) => ( + {scripts.map((asset, i) => ( ))} diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index 1d8d7b4762..9f494595ae 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -4,47 +4,57 @@ import * as React from 'react' import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' import { useLayoutEffect } from './utils' import { useRouter } from './useRouter' +import type { AnyRouteMatch } from '@tanstack/router-core' export function Transitioner() { const router = useRouter() - const mountLoadForRouter = React.useRef< - [typeof router, typeof router.history] | undefined + const acknowledgement = React.useRef< + [Array, (rendered: boolean) => void] | undefined >(undefined) + const mounted = + process.env.NODE_ENV !== 'production' + ? // eslint-disable-next-line react-hooks/rules-of-hooks + React.useRef(false) + : undefined - /** - * This effect is meant to "polyfill" the fact that React.startTransition - * does not return an awaitable promise. - * - * So we queue `acknowldgements` here, and `` will call - * `router._rendered` whenever it renders a new batch of matches. - */ - useLayoutEffect(() => { - const acknowledgements: Array<(rendered: boolean) => void> = [] - const rendered = (done: boolean) => { - for (const resolve of acknowledgements.splice(0)) { - resolve(done) - } + // `` precedes ``, so install the render + // acknowledgement before the latter can publish a rendered lane. + router._rendered = (matches) => { + const current = acknowledgement.current + if ( + current?.[0].length === matches.length && + current[0].every( + (match, index) => + match.id === matches[index]!.id && + match.abortController === matches[index]!.abortController && + match.status === matches[index]!.status, + ) + ) { + acknowledgement.current = undefined + current[1](true) } - const transition = (fn: () => void) => { - return new Promise((resolve) => { - acknowledgements.push(resolve) + } + router.startTransition = (fn, expected, urgent) => + new Promise((resolve) => { + acknowledgement.current?.[1](false) + acknowledgement.current = [expected, resolve] + if (urgent) { + fn() + } else { React.startTransition(fn) - }) - } - router._rendered = () => rendered(true) - router.startTransition = transition - return () => rendered(false) - }, [router]) + } + }) // Subscribe before canonicalizing so the initial URL has exactly one load. useLayoutEffect(() => { const unsub = router.history.subscribe(router.load) - const mounted = mountLoadForRouter.current - if (mounted?.[0] === router && mounted[1] === router.history) { - return unsub + if (mounted?.current) { + return process.env.NODE_ENV !== 'production' ? unsub : undefined + } + if (mounted) { + mounted.current = true } - mountLoadForRouter.current = [router, router.history] router.updateLatestLocation() const location = router.latestLocation @@ -58,14 +68,14 @@ export function Transitioner() { }) // Check if the current URL matches the canonical form. - // Compare publicHref (browser-facing URL) for consistency with - // the server-side redirect check in router.beforeLoad. + // Compare publicHref (browser-facing URL) consistently with server + // canonicalization. if ( trimPathRight(location.publicHref) !== trimPathRight(nextLocation.publicHref) ) { router.commitLocation({ ...nextLocation, replace: true }) - return unsub + return process.env.NODE_ENV !== 'production' ? unsub : undefined } const resolvedLocation = router.stores.resolvedLocation.get() @@ -73,15 +83,24 @@ export function Transitioner() { resolvedLocation?.href === location.href && resolvedLocation.state.__TSR_key === location.state.__TSR_key ) { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo(resolvedLocation, resolvedLocation), - }) - } else { + acknowledgement.current = [ + router.stores.matches.get(), + (rendered) => { + if (rendered) { + router.emit({ + type: 'onRendered', + ...getLocationChangeInfo(resolvedLocation, resolvedLocation), + }) + } + }, + ] + } else if (!router._tx) { router.load().catch(console.error) } - return unsub + return process.env.NODE_ENV !== 'production' ? unsub : undefined + // `mounted` exists only in development and is a stable ref when present. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [router, router.history]) return null diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index 5ed35bd1ce..49dd47caad 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { useStore } from '@tanstack/react-store' import { + _getRenderedMatches, appendUniqueUserTags, deepEqual, escapeHtml, @@ -22,6 +23,7 @@ function buildTagsFromMatches( matches: Array, assetCrossOrigin?: AssetCrossOriginConfig, ): Array { + matches = _getRenderedMatches(matches) const routeMeta = matches .map((match) => match.meta) .filter((meta) => meta !== undefined) @@ -187,7 +189,7 @@ function buildTagsFromMatches( } /** - * Build the list of head/link/meta/script tags to render for active matches. + * Build the head/link/meta/script tags from the renderable presented prefix. * Used internally by `HeadContent`. */ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { @@ -204,226 +206,11 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { } // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const routeMeta = useStore( - router.stores.matches, - (matches) => { - return matches - .map((match) => match.meta) - .filter((meta) => meta !== undefined) - }, - deepEqual, + const selectTags = React.useCallback( + (matches: Array) => + buildTagsFromMatches(router, nonce, matches, assetCrossOrigin), + [assetCrossOrigin, nonce, router], ) - - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const meta: Array = React.useMemo(() => { - const resultMeta: Array = [] - const metaByAttribute: Record = {} - let title: RouterManagedTag | undefined - for (let i = routeMeta.length - 1; i >= 0; i--) { - const metas = routeMeta[i]! - for (let j = metas.length - 1; j >= 0; j--) { - const m = metas[j] - if (!m) continue - - if (m.title) { - if (!title) { - title = { - tag: 'title', - children: m.title, - } - } - } else if ('script:ld+json' in m) { - // Handle JSON-LD structured data - // Content is HTML-escaped to prevent XSS when injected via dangerouslySetInnerHTML - try { - const json = JSON.stringify(m['script:ld+json']) - resultMeta.push({ - tag: 'script', - attrs: { - type: 'application/ld+json', - }, - children: escapeHtml(json), - }) - } catch { - // Skip invalid JSON-LD objects - } - } else { - const attribute = m.name ?? m.property - if (attribute) { - if (metaByAttribute[attribute]) { - continue - } else { - metaByAttribute[attribute] = true - } - } - - resultMeta.push({ - tag: 'meta', - attrs: { - ...m, - nonce, - }, - }) - } - } - } - - if (title) { - resultMeta.push(title) - } - - if (nonce) { - resultMeta.push({ - tag: 'meta', - attrs: { - property: 'csp-nonce', - content: nonce, - }, - }) - } - resultMeta.reverse() - - return resultMeta - }, [routeMeta, nonce]) - - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const links = useStore( - router.stores.matches, - (matches) => { - const constructed = matches - .flatMap((match) => match.links ?? []) - .filter((link) => link !== undefined) - .map((link) => ({ - tag: 'link', - attrs: { - ...link, - nonce, - }, - })) satisfies Array - - return constructed - }, - deepEqual, - ) - - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const manifestCssTags = useStore( - router.stores.matches, - (matches) => { - const manifest = router.ssr?.manifest - const tags: Array = [] - - if (!manifest) { - return tags - } - - matches.forEach((match) => { - manifest.routes[match.routeId]?.css?.forEach((link) => { - const resolvedLink = resolveManifestCssLink(link) - tags.push({ - tag: 'link', - attrs: { - rel: 'stylesheet', - ...resolvedLink, - crossOrigin: - getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? - resolvedLink.crossOrigin, - suppressHydrationWarning: true, - nonce, - }, - }) - }) - }) - - if (manifest.inlineStyle) { - tags.push({ - tag: 'style', - attrs: { - ...manifest.inlineStyle.attrs, - nonce, - }, - children: manifest.inlineStyle.children, - inlineCss: true, - }) - } - - return tags - }, - deepEqual, - ) - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const preloadLinks = useStore( - router.stores.matches, - (matches) => { - const preloadLinks: Array = [] - const manifest = router.ssr?.manifest - - if (!manifest) { - return preloadLinks - } - - matches.forEach((match) => { - manifest.routes[match.routeId]?.preloads?.forEach((preload) => { - preloadLinks.push({ - tag: 'link', - attrs: { - ...getScriptPreloadAttrs(manifest, preload, assetCrossOrigin), - nonce, - }, - }) - }) - }) - - return preloadLinks - }, - deepEqual, - ) - - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const styles = useStore( - router.stores.matches, - (matches) => { - return matches - .flatMap((match) => match.styles ?? []) - .filter((style) => style !== undefined) - .map(({ children, ...attrs }) => ({ - tag: 'style', - attrs: { - ...attrs, - nonce, - }, - children: children as string | undefined, - })) satisfies Array - }, - deepEqual, - ) - - // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const headScripts: Array = useStore( - router.stores.matches, - (matches) => { - return matches - .flatMap((match) => match.headScripts ?? []) - .filter((script) => script !== undefined) - .map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, - nonce, - }, - children: children as string | undefined, - })) satisfies Array - }, - deepEqual, - ) - - const tags: Array = [] - appendUniqueUserTags(tags, meta) - tags.push(...preloadLinks) - appendUniqueUserTags(tags, links) - tags.push(...manifestCssTags) - appendUniqueUserTags(tags, styles) - appendUniqueUserTags(tags, headScripts) - return tags + return useStore(router.stores.matches, selectTags, deepEqual) } diff --git a/packages/react-router/src/lazyRouteComponent.tsx b/packages/react-router/src/lazyRouteComponent.tsx index f918b55ea4..7b228ea632 100644 --- a/packages/react-router/src/lazyRouteComponent.tsx +++ b/packages/react-router/src/lazyRouteComponent.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { isModuleNotFoundError } from '@tanstack/router-core' +import { isServer } from '@tanstack/router-core/isServer' import { reactUse } from './utils' import type { AsyncRouteComponent } from './route' @@ -24,7 +25,6 @@ export function lazyRouteComponent< let loadPromise: Promise | undefined let comp: T[TKey] | T['default'] let error: any - let reload: boolean const load = () => { if (!loadPromise) { @@ -40,28 +40,6 @@ export function lazyRouteComponent< // there's nothing we want to do about module not found during preload. // Record the error, the rest is handled during the render path. error = err - // If the load fails due to module not found, it may mean a new version of - // the build was deployed and the user's browser is still using an old version. - // If this happens, the old version in the user's browser would have an outdated - // URL to the lazy module. - // In that case, we want to attempt one window refresh to get the latest. - if (isModuleNotFoundError(error)) { - if ( - error instanceof Error && - typeof window !== 'undefined' && - typeof sessionStorage !== 'undefined' - ) { - // Again, we want to reload one time on module not found error and not enter - // a reload loop if there is some other issue besides an old deploy. - // That's why we store our reload attempt in sessionStorage. - // Use error.message as key because it contains the module path that failed. - const storageKey = `tanstack_router_reload:${error.message}` - if (!sessionStorage.getItem(storageKey)) { - sessionStorage.setItem(storageKey, '1') - reload = true - } - } - } }) } @@ -69,15 +47,23 @@ export function lazyRouteComponent< } const lazyComp = function Lazy(props: any) { - // Now that we're out of preload and into actual render path, - if (reload) { - // If it was a module loading error, - // throw eternal suspense while we wait for window to reload - window.location.reload() - throw new Promise(() => {}) - } if (error) { - // Otherwise, just throw the error + // A missing module can mean that a newer deployment replaced the URL. + // Reload only for the error that is still current at render time, so a + // successful retry cannot leave a stale reload request armed. + if ( + isModuleNotFoundError(error) && + !(isServer ?? typeof window === 'undefined') && + typeof sessionStorage !== 'undefined' + ) { + const storageKey = `tanstack_router_reload:${error.message}` + if (!sessionStorage.getItem(storageKey)) { + sessionStorage.setItem(storageKey, '1') + window.location.reload() + // Suspend forever while the document reloads. + throw new Promise(() => {}) + } + } throw error } diff --git a/packages/react-router/src/ssr/RouterClient.tsx b/packages/react-router/src/ssr/RouterClient.tsx index 3271dcd63f..69abc97f0b 100644 --- a/packages/react-router/src/ssr/RouterClient.tsx +++ b/packages/react-router/src/ssr/RouterClient.tsx @@ -3,16 +3,11 @@ import { Await } from '../awaited' import { RouterProvider } from '../RouterProvider' import type { AnyRouter } from '@tanstack/router-core' -let hydrationPromise: Promise>> | undefined +let hydrationPromise: Promise | undefined export function RouterClient(props: { router: AnyRouter }) { - if (!hydrationPromise) { - if (!props.router.stores.matchesId.get().length) { - hydrationPromise = hydrate(props.router) - } else { - hydrationPromise = Promise.resolve() - } - } + hydrationPromise ??= hydrate(props.router).finally(() => window.$_TSR!.h()) + return ( stream.cancel().catch(() => {}) }, + { + signal: request.signal, + onAbort: () => stream.cancel().catch(() => {}), + }, ) return createSsrStreamResponse( router, @@ -180,7 +183,7 @@ export const renderRouterToStream = async ({ const responseStream = transformPipeableStreamWithRouter( router, reactAppPassthrough, - { onAbort: abortPipeable }, + { signal: request.signal, onAbort: abortPipeable }, ) responseAttached = true diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx index 6c1d03421d..35d6673962 100644 --- a/packages/react-router/src/useMatch.tsx +++ b/packages/react-router/src/useMatch.tsx @@ -20,12 +20,7 @@ import type { ThrowOrOptional, } from '@tanstack/router-core' -const dummyStore = { - get() {}, - subscribe() { - return { unsubscribe() {} } - }, -} as any +const dummyMatch = {} export function useStructuralSharing< TRouter extends AnyRouter, @@ -147,16 +142,15 @@ export function useMatch< >, ): ThrowOrOptional, TThrow> { const router = useRouter() - const nearestMatchId = React.useContext( + const nearestRouteId = React.useContext( opts.from ? dummyMatchContext : matchContext, ) - const matchStore = opts.from - ? router.stores.getRouteMatchStore(opts.from) - : router.stores.matchStores.get(nearestMatchId!) + const routeId = opts.from ?? nearestRouteId + const matchStore = router.stores.getMatchStore(routeId!) if (isServer ?? router.isServer) { - const match = matchStore?.get() + const match = matchStore.get() if (!match) { if (opts.shouldThrow ?? true) { if (process.env.NODE_ENV !== 'production') { @@ -179,12 +173,12 @@ export function useMatch< useStructuralSharing(opts, router) // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static - const matchSelection = useStore(matchStore ?? dummyStore, (match) => - match ? selector(match as any) : dummyStore, + const matchSelection = useStore(matchStore, (match) => + match ? selector(match as any) : dummyMatch, ) - if (matchSelection !== dummyStore) { - return matchSelection + if (matchSelection !== dummyMatch) { + return matchSelection as any } if (opts.shouldThrow ?? true) { diff --git a/packages/react-router/src/utils.ts b/packages/react-router/src/utils.ts index f3cbf54613..ca0653e350 100644 --- a/packages/react-router/src/utils.ts +++ b/packages/react-router/src/utils.ts @@ -1,5 +1,6 @@ 'use client' import * as React from 'react' +import { isServer } from '@tanstack/router-core/isServer' // Safe version of React.use() that will not cause compilation errors against // React 18 with Webpack, which statically analyzes imports and fails when it @@ -27,7 +28,9 @@ export function useStableCallback) => any>( } export const useLayoutEffect = - typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect + (isServer ?? typeof window === 'undefined') + ? React.useEffect + : React.useLayoutEffect /** * Taken from https://www.developerway.com/posts/implementing-advanced-use-previous-hook#part3 diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index f77a939a6a..f98e840f27 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -146,6 +146,7 @@ test('should show pendingComponent of root route', async () => { }) test('legacy notFoundRoute drops a stale parent layout after navigation', async () => { + let legacyLoads = 0 const root = createRootRoute({ component: Outlet }) const parent = createRoute({ getParentRoute: () => root, @@ -161,21 +162,19 @@ test('legacy notFoundRoute drops a stale parent layout after navigation', async getParentRoute: () => parent, path: '/known', }) - const home = createRoute({ - getParentRoute: () => root, - path: '/home', - component: () =>
Home
, - }) const legacyNotFound = createRoute({ getParentRoute: () => root, path: '/404', - loader: () => 'not found', + loader: () => { + legacyLoads++ + return 'not found' + }, component: () =>
Legacy not found
, staleTime: Infinity, gcTime: Infinity, }) const router = createRouter({ - routeTree: root.addChildren([parent.addChildren([known]), home]), + routeTree: root.addChildren([parent.addChildren([known])]), history: createMemoryHistory({ initialEntries: ['/parent/missing'] }), notFoundRoute: legacyNotFound, }) @@ -183,11 +182,7 @@ test('legacy notFoundRoute drops a stale parent layout after navigation', async const rendered = render() expect(await rendered.findByText('Parent layout')).toBeInTheDocument() expect(await rendered.findByText('Legacy not found')).toBeInTheDocument() - - await act(async () => { - await router.navigate({ to: '/home' }) - }) - expect(await rendered.findByText('Home')).toBeInTheDocument() + expect(legacyLoads).toBe(1) await act(async () => { await router.navigate({ to: '/missing' } as any) @@ -195,6 +190,7 @@ test('legacy notFoundRoute drops a stale parent layout after navigation', async expect(rendered.queryByText('Parent layout')).not.toBeInTheDocument() expect(await rendered.findByText('Legacy not found')).toBeInTheDocument() + expect(legacyLoads).toBe(1) rendered.unmount() }) diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index b7605c9402..0a3cfd4aef 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -158,15 +158,12 @@ describe('ssr scripts', () => { { src: 'script3.js' }, ]) - const { container } = await act(() => - render(), - ) - expect(await screen.findByTestId('root')).toBeInTheDocument() - expect(await screen.findByTestId('index')).toBeInTheDocument() - - expect(container.innerHTML).toEqual( - `
root
index
`, + const html = ReactDOMServer.renderToString( + , ) + expect(html).toContain('') + expect(html).toContain('') + expect(html).not.toContain('script2.js') }) }) @@ -839,6 +836,130 @@ describe('ssr HeadContent', () => { ), ).toHaveLength(1) }) + + test('does not render retained descendant assets past a terminal parent boundary', async () => { + let failParent = false + const childHeadScript = '{"source":"child-head"}' + const childBodyScript = '{"source":"child-body"}' + const childManifestScript = '{"source":"child-manifest"}' + const childPreload = '/terminal-child-preload.js' + const childHeadLink = '/terminal-child-head-link.js' + const rootRoute = createRootRoute({ + component: () => ( + <> + {createPortal(, document.head)} + + + + ), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + shouldReload: true, + loader: () => { + if (failParent) { + throw new Error('parent failed') + } + }, + component: Outlet, + errorComponent: () =>
Parent error
, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: () => ({ + meta: [{ name: 'terminal-child', content: 'visible' }], + links: [{ rel: 'preload', href: childHeadLink }], + styles: [{ children: '.terminal-child { color: red }' }], + scripts: [{ type: 'application/ld+json', children: childHeadScript }], + }), + scripts: () => [ + { type: 'application/ld+json', children: childBodyScript }, + ], + component: () =>
Child content
, + }) + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + }) + router.ssr = { + manifest: { + routes: { + [childRoute.id]: { + preloads: [childPreload], + scripts: [ + { + attrs: { type: 'application/ld+json' }, + children: childManifestScript, + }, + ], + }, + }, + }, + } + + await router.load() + await act(() => render()) + + await waitFor(() => { + expect( + document.head.querySelector('meta[name="terminal-child"]'), + ).not.toBeNull() + expect( + document.head.querySelector(`link[href="${childPreload}"]`), + ).not.toBeNull() + expect( + document.head.querySelector(`link[href="${childHeadLink}"]`), + ).not.toBeNull() + expect(document.head.textContent).toContain( + '.terminal-child { color: red }', + ) + expect(document.documentElement.textContent).toContain(childHeadScript) + expect(document.documentElement.textContent).toContain(childBodyScript) + expect(document.documentElement.textContent).toContain( + childManifestScript, + ) + }) + + failParent = true + await act(() => router.invalidate()) + await screen.findByText('Parent error') + + expect(router.state.matches).toHaveLength(3) + expect(router.state.matches[1]).toMatchObject({ + routeId: parentRoute.id, + status: 'error', + }) + expect(router.state.matches[2]).toMatchObject({ + routeId: childRoute.id, + meta: [{ name: 'terminal-child', content: 'visible' }], + scripts: [{ type: 'application/ld+json', children: childBodyScript }], + }) + await waitFor(() => { + expect( + document.head.querySelector('meta[name="terminal-child"]'), + ).toBeNull() + expect( + document.head.querySelector(`link[href="${childPreload}"]`), + ).toBeNull() + expect( + document.head.querySelector(`link[href="${childHeadLink}"]`), + ).toBeNull() + expect(document.head.textContent).not.toContain( + '.terminal-child { color: red }', + ) + expect(document.documentElement.textContent).not.toContain( + childHeadScript, + ) + expect(document.documentElement.textContent).not.toContain( + childBodyScript, + ) + expect(document.documentElement.textContent).not.toContain( + childManifestScript, + ) + }) + }) }) describe('data script rendering', () => { diff --git a/packages/react-router/tests/ancestor-loader-child-pending-min.test.tsx b/packages/react-router/tests/ancestor-loader-child-pending-min.test.tsx index a4d587e2f3..09945bb9af 100644 --- a/packages/react-router/tests/ancestor-loader-child-pending-min.test.tsx +++ b/packages/react-router/tests/ancestor-loader-child-pending-min.test.tsx @@ -93,3 +93,65 @@ test('a child fallback revealed after a fresh ancestor loader keeps its own pend expect(screen.queryByText('Child pending')).not.toBeInTheDocument() expect(screen.getByText('Child content')).toBeInTheDocument() }) + +test('advancing from a parent loader to a parallel child loader does not restart pendingMs', async () => { + vi.useFakeTimers() + + const parentLoader = createControlledPromise() + const childLoader = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => parentLoader, + component: Outlet, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + pendingMs: 1_000, + pendingMinMs: 0, + pendingComponent: () =>
Child pending
, + loader: () => childLoader, + component: () =>
Child content
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + render() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/parent/child' }) + await vi.advanceTimersByTimeAsync(900) + }) + expect(screen.queryByText('Child pending')).not.toBeInTheDocument() + + await act(async () => { + parentLoader.resolve() + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.queryByText('Child pending')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(screen.getByText('Child pending')).toBeInTheDocument() + + await act(async () => { + childLoader.resolve() + await navigation + }) + expect(screen.getByText('Child content')).toBeInTheDocument() +}) diff --git a/packages/react-router/tests/component-preload-retry-pending-min.test.tsx b/packages/react-router/tests/component-preload-retry-pending-min.test.tsx index b7963c0fa6..dcdd94af67 100644 --- a/packages/react-router/tests/component-preload-retry-pending-min.test.tsx +++ b/packages/react-router/tests/component-preload-retry-pending-min.test.tsx @@ -19,6 +19,32 @@ afterEach(() => { cleanup() }) +test('delayed component preload reveals pending UI', async () => { + const componentGate = createControlledPromise() + const Page = Object.assign(() =>
Page content
, { + preload: () => componentGate, + }) + const rootRoute = createRootRoute({}) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + pendingMs: 10, + pendingComponent: () =>
Loading page...
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + expect(await screen.findByText('Loading page...')).toBeInTheDocument() + componentGate.resolve() + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(screen.queryByText('Loading page...')).not.toBeInTheDocument() +}) + /** * A component-only route can fail while preloading its code, then retry from * its error UI through invalidate(). The retry is a fresh pending generation: diff --git a/packages/react-router/tests/component-preload-retry.test.tsx b/packages/react-router/tests/component-preload-retry.test.tsx index 4672228875..034276b2fb 100644 --- a/packages/react-router/tests/component-preload-retry.test.tsx +++ b/packages/react-router/tests/component-preload-retry.test.tsx @@ -15,6 +15,7 @@ import type { ErrorComponentProps } from '../src' afterEach(() => { cleanup() vi.restoreAllMocks() + sessionStorage.clear() }) test('a failed component download is retried from the route error UI', async () => { @@ -67,3 +68,34 @@ test('a failed component download is retried from the route error UI', async () screen.queryByRole('button', { name: 'Retry' }), ).not.toBeInTheDocument() }) + +test('renders after retrying a module download that failed during preload', async () => { + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce( + new TypeError( + 'Failed to fetch dynamically imported module: /assets/page.js', + ), + ) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + await Page.preload?.() + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) +}) diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index 560b1b5bfa..687473564a 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -7,6 +7,7 @@ import { Outlet, RouterProvider, createBrowserHistory, + createControlledPromise, createLazyRoute, createMemoryHistory, createRootRoute, @@ -318,6 +319,46 @@ test('errorComponent receives primitive errors thrown from beforeLoad', async () expect(screen.queryByText('About route content')).not.toBeInTheDocument() }) +test.each(['beforeLoad', 'loader'] as const)( + 'a Promise synchronously thrown from %s renders the route error UI', + async (hook) => { + const thrown = Promise.resolve('not route data') + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: + hook === 'beforeLoad' + ? () => { + throw thrown + } + : undefined, + loader: + hook === 'loader' + ? () => { + throw thrown + } + : undefined, + errorComponent: ({ error }) => ( +
+ {error instanceof Error && error.cause === thrown + ? 'Promise route error' + : 'Wrong route error'} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + expect(await screen.findByText('Promise route error')).toBeInTheDocument() + expect(screen.queryByText('Wrong route error')).not.toBeInTheDocument() + }, +) + test('SSR errorComponent receives primitive errors thrown from beforeLoad', async () => { const rootRoute = createRootRoute({ component: function Root() { @@ -357,6 +398,209 @@ test('SSR errorComponent receives primitive errors thrown from beforeLoad', asyn expect(html).toContain('primitive error thrown') }) +test('a later ancestor loader failure does not hide the first child error', async () => { + const parentStarted = createControlledPromise() + const childStarted = createControlledPromise() + const parentGate = createControlledPromise() + const childGate = createControlledPromise() + const childSettled = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: Outlet, + loader: async () => { + parentStarted.resolve() + await parentGate + throw new Error('later parent failure') + }, + errorComponent: () =>
Parent error boundary
, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async () => { + childStarted.resolve() + await childGate + childSettled.resolve() + throw new Error('first child failure') + }, + errorComponent: () =>
Child error boundary
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + render() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/parent/child' }) + await Promise.all([parentStarted, childStarted]) + }) + await act(async () => { + childGate.resolve() + await childSettled + parentGate.resolve() + await navigation + }) + + expect(screen.getByText('Child error boundary')).toBeInTheDocument() + expect(screen.queryByText('Parent error boundary')).not.toBeInTheDocument() +}) + +test('a losing ancestor loader still waits for its lazy component before rendering the child error', async () => { + const parentStarted = createControlledPromise() + const childStarted = createControlledPromise() + const parentGate = createControlledPromise() + const childGate = createControlledPromise() + const childSettled = createControlledPromise() + const lazyParentOptions = createLazyRoute('/parent')({ + component: () => ( + <> +
Lazy parent shell
+ + + ), + }) + const parentChunk = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: async () => { + parentStarted.resolve() + await parentGate + throw new Error('later parent failure') + }, + errorComponent: () =>
Parent error boundary
, + }).lazy(() => parentChunk) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async () => { + childStarted.resolve() + await childGate + childSettled.resolve() + throw new Error('first child failure') + }, + errorComponent: () =>
Child error boundary
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + render() + + let navigation: Promise | undefined + try { + await act(async () => { + navigation = router.navigate({ to: '/parent/child' }) + await Promise.all([parentStarted, childStarted]) + }) + await act(async () => { + childGate.resolve() + await childSettled + parentGate.resolve() + await Promise.resolve() + }) + + expect(parentChunk.status).toBe('pending') + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.queryByText('Child error boundary')).not.toBeInTheDocument() + } finally { + await act(async () => { + childGate.resolve() + parentGate.resolve() + parentChunk.resolve(lazyParentOptions) + await navigation + }) + } + + expect(screen.getByText('Lazy parent shell')).toBeInTheDocument() + expect(screen.getByText('Child error boundary')).toBeInTheDocument() + expect(screen.queryByText('Parent error boundary')).not.toBeInTheDocument() +}) + +test('SSR renders the first child loader error after a later ancestor failure', async () => { + const parentStarted = createControlledPromise() + const childStarted = createControlledPromise() + const parentGate = createControlledPromise() + const childGate = createControlledPromise() + const childSettled = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: Outlet, + loader: async () => { + parentStarted.resolve() + await parentGate + throw new Error('later parent failure') + }, + errorComponent: () =>
Parent error boundary
, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async () => { + childStarted.resolve() + await childGate + childSettled.resolve() + throw new Error('first child failure') + }, + errorComponent: () =>
Child error boundary
, + }) + const handler = createRequestHandler({ + request: new Request('http://localhost/parent/child'), + createRouter: () => + createRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + isServer: true, + }), + }) + + const responsePromise = handler(({ router, responseHeaders }) => + renderRouterToString({ + router, + responseHeaders, + children: , + }), + ) + await Promise.all([parentStarted, childStarted]) + childGate.resolve() + await childSettled + parentGate.resolve() + const response = await responsePromise + const html = await response.text() + + expect(response.status).toBe(500) + expect(html).toContain('Child error boundary') + expect(html).not.toContain('Parent error boundary') +}) + // https://github.com/TanStack/router/issues/4684 test('#4684: SSR renders head content when beforeLoad throws', async () => { const rootRoute = createRootRoute({ diff --git a/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx index d4607c946f..03c6b23871 100644 --- a/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx +++ b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx @@ -104,12 +104,12 @@ describe('hydrating a server-capped boundary lane', () => { await serverRouter.load() const serverMatches = serverRouter.stores.matches.get() - expect(serverMatches).toHaveLength(boundary === 'root' ? 1 : 2) - const serverBoundary = serverMatches.at(-1)! + expect(serverMatches).toHaveLength(3) + const serverBoundary = serverMatches[boundary === 'root' ? 0 : 1]! if (outcome === 'notFound' && boundary === 'root') { expect(serverBoundary).toMatchObject({ status: 'success', - globalNotFound: true, + _notFound: true, }) } else { expect(serverBoundary.status).toBe(outcome) @@ -133,15 +133,16 @@ describe('hydrating a server-capped boundary lane', () => { router: { manifest: { routes: {} }, dehydratedData: {}, - matches: serverMatches.map((match) => ({ - i: match.id, - u: match.updatedAt, - s: match.status, - l: match.loaderData, - e: match.error, - ssr: match.ssr, - ...(match.globalNotFound ? { g: true } : {}), - })), + matches: serverMatches + .slice(0, boundary === 'root' ? 1 : 2) + .map((match) => ({ + u: match.updatedAt, + s: match.status, + l: match.loaderData, + e: match.error, + ssr: match.ssr, + ...(match._notFound ? { g: true } : {}), + })), }, h: vi.fn(), e: vi.fn(), diff --git a/packages/react-router/tests/hydration-terminal-lane.test.tsx b/packages/react-router/tests/hydration-terminal-lane.test.tsx index 868f19ab27..9f4dc2132e 100644 --- a/packages/react-router/tests/hydration-terminal-lane.test.tsx +++ b/packages/react-router/tests/hydration-terminal-lane.test.tsx @@ -25,7 +25,6 @@ function bootstrap( router: { manifest: undefined, matches: matches.map(({ match, status, ssr, data, error }) => ({ - i: match.id, l: data, e: error, s: status, diff --git a/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx index 5a765c46ea..e05d2ee792 100644 --- a/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx +++ b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx @@ -78,3 +78,69 @@ test('default pending component renders while lazy route options load', async () }) } }) + +test('a lazy pending component is offered while the eager loader is still pending', async () => { + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index page

, + }) + const loader = createControlledPromise() + const lazyPageOptions = createLazyRoute('/page')({ + pendingComponent: () =>

Loading lazy page

, + component: () =>

Page

, + }) + const lazyOptions = createControlledPromise() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: () => loader, + }).lazy(() => lazyOptions) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + defaultPendingComponent: () =>

Loading default

, + }) + let navigation: Promise | undefined + + try { + render() + expect( + await screen.findByRole('heading', { name: 'Index page' }), + ).toBeInTheDocument() + + act(() => { + navigation = router.navigate({ to: '/page' }) + }) + expect(await screen.findByRole('status')).toHaveTextContent( + 'Loading default', + ) + + await act(async () => { + lazyOptions.resolve(lazyPageOptions) + }) + + expect(await screen.findByRole('status')).toHaveTextContent( + 'Loading lazy page', + ) + expect( + screen.queryByRole('heading', { name: 'Page' }), + ).not.toBeInTheDocument() + + await act(async () => { + loader.resolve() + await navigation + }) + + expect(screen.getByRole('heading', { name: 'Page' })).toBeInTheDocument() + } finally { + await act(async () => { + lazyOptions.resolve(lazyPageOptions) + loader.resolve() + await navigation + }) + } +}) diff --git a/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx b/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx index 080880517d..3bffbed1ac 100644 --- a/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx +++ b/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx @@ -112,7 +112,7 @@ test('#4476: pending navigation keeps the query observer mounted and its fetchQu await waitFor(() => expect(querySignal).toBeDefined()) expect(screen.getByTestId('page-one')).toBeInTheDocument() - expect(screen.queryByTestId('page-two-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('page-two-pending')).toBeInTheDocument() expect(querySignal?.aborted).toBe(false) queryGate.resolve(10) diff --git a/packages/react-router/tests/not-found.test.tsx b/packages/react-router/tests/not-found.test.tsx index 3754785d35..f27057c7ab 100644 --- a/packages/react-router/tests/not-found.test.tsx +++ b/packages/react-router/tests/not-found.test.tsx @@ -1,17 +1,24 @@ import { afterEach, beforeEach, expect, test } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen } from '@testing-library/react' import { Link, Outlet, RouterProvider, createBrowserHistory, + createControlledPromise, + createLazyRoute, createRootRoute, createRoute, createRouter, notFound, rootRouteId, } from '../src' +import { + RouterServer, + createRequestHandler, + renderRouterToString, +} from '../src/ssr/server' import type { NotFoundRouteProps, RouterHistory } from '../src' let history: RouterHistory @@ -27,6 +34,179 @@ afterEach(() => { cleanup() }) +test('navigating to an actively preloaded missing URL renders the global not-found boundary', async () => { + const missingLoaderStarted = createControlledPromise() + const missingLoader = createControlledPromise() + const rootRoute = createRootRoute({ + component: Outlet, + notFoundComponent: () =>
Missing URL boundary
, + loader: ({ location }) => { + if (location.pathname === '/missing') { + missingLoaderStarted.resolve() + return missingLoader + } + return + }, + shouldReload: true, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + expect(await screen.findByText('Home')).toBeInTheDocument() + + const preload = router.preloadRoute({ to: '/missing' } as any) + await missingLoaderStarted + const navigation = router.navigate({ to: '/missing' } as any) + missingLoader.resolve() + await act(() => Promise.all([preload, navigation])) + + expect(screen.getByText('Missing URL boundary')).toBeInTheDocument() + expect(screen.queryByText('Home')).not.toBeInTheDocument() +}) + +test('a lazy route notFoundComponent handles an eager beforeLoad failure', async () => { + const rootRoute = createRootRoute({ + component: Outlet, + notFoundComponent: () =>
Root not found
, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const failingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/lazy-not-found', + beforeLoad: () => { + throw notFound() + }, + }).lazy(() => + Promise.resolve( + createLazyRoute('/lazy-not-found')({ + notFoundComponent: () =>
Lazy route not found
, + }), + ), + ) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, failingRoute]), + history, + }) + + render() + expect(await screen.findByText('Home')).toBeInTheDocument() + + await act(() => router.navigate({ to: '/lazy-not-found' })) + + expect(screen.getByText('Lazy route not found')).toBeInTheDocument() + expect(screen.queryByText('Root not found')).not.toBeInTheDocument() +}) + +test('SSR uses a lazy route notFoundComponent for an eager beforeLoad failure', async () => { + const rootRoute = createRootRoute({ + component: Outlet, + notFoundComponent: () =>
Root not found
, + }) + const failingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/lazy-not-found', + beforeLoad: () => { + throw notFound() + }, + }).lazy(() => + Promise.resolve( + createLazyRoute('/lazy-not-found')({ + notFoundComponent: () =>
Lazy route not found
, + }), + ), + ) + const handler = createRequestHandler({ + request: new Request('http://localhost/lazy-not-found'), + createRouter: () => + createRouter({ + routeTree: rootRoute.addChildren([failingRoute]), + isServer: true, + }), + }) + + const response = await handler(({ router, responseHeaders }) => + renderRouterToString({ + router, + responseHeaders, + children: , + }), + ) + + expect(response.status).toBe(404) + const html = await response.text() + expect(html).toContain('Lazy route not found') + expect(html).not.toContain('Root not found') +}) + +test.each(['client', 'server'] as const)( + 'a lazy child boundary handles a fuzzy URL miss on the %s', + async (environment) => { + const rootRoute = createRootRoute({ + component: Outlet, + notFoundComponent: () =>
Root fuzzy boundary
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: Outlet, + notFoundComponent: () =>
Parent fuzzy boundary
, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + }).lazy(() => + Promise.resolve( + createLazyRoute('/parent/child')({ + notFoundComponent: () =>
Lazy child fuzzy boundary
, + }), + ), + ) + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + if (environment === 'client') { + const router = createRouter({ routeTree, history }) + render() + await act(() => router.navigate({ to: '/parent/child/missing' as any })) + + expect(screen.getByText('Lazy child fuzzy boundary')).toBeInTheDocument() + expect( + screen.queryByText('Parent fuzzy boundary'), + ).not.toBeInTheDocument() + return + } + + const response = await createRequestHandler({ + request: new Request('http://localhost/parent/child/missing'), + createRouter: () => createRouter({ routeTree, isServer: true }), + })(({ router, responseHeaders }) => + renderRouterToString({ + router, + responseHeaders, + children: , + }), + ) + + expect(response.status).toBe(404) + const html = await response.text() + expect(html).toContain('Lazy child fuzzy boundary') + expect(html).not.toContain('Parent fuzzy boundary') + }, +) + test.each([ { notFoundMode: 'fuzzy' as const, diff --git a/packages/react-router/tests/preloaded-mount-resolution.test.tsx b/packages/react-router/tests/preloaded-mount-resolution.test.tsx index 3b5fd0d173..b7527beb3e 100644 --- a/packages/react-router/tests/preloaded-mount-resolution.test.tsx +++ b/packages/react-router/tests/preloaded-mount-resolution.test.tsx @@ -5,6 +5,7 @@ import { createMemoryHistory } from '@tanstack/history' import { Outlet, RouterProvider, + createControlledPromise, createRootRoute, createRoute, createRouter, @@ -38,6 +39,13 @@ afterEach(() => { }) test('mounting after a settled load still resolves status and fires onRendered', async () => { + const lifecycle: Array<'layout' | 'rendered'> = [] + const Home = () => { + React.useLayoutEffect(() => { + lifecycle.push('layout') + }, []) + return
Home
+ } const rootRoute = createRootRoute({ component: () => , }) @@ -45,7 +53,7 @@ test('mounting after a settled load still resolves status and fires onRendered', getParentRoute: () => rootRoute, path: '/', loader: () => 'home data', - component: () =>
Home
, + component: Home, }) const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]), @@ -56,7 +64,10 @@ test('mounting after a settled load still resolves status and fires onRendered', const rendered = new Promise((resolve) => { resolveRendered = resolve }) - const onRendered = vi.fn(() => resolveRendered()) + const onRendered = vi.fn(() => { + lifecycle.push('rendered') + resolveRendered() + }) const onResolved = vi.fn() const onLoad = vi.fn() const unsubscribers = [ @@ -92,6 +103,7 @@ test('mounting after a settled load still resolves status and fires onRendered', expect(container.querySelector('[data-testid="home"]')).not.toBeNull() expect(onRendered).toHaveBeenCalledTimes(1) + expect(lifecycle).toEqual(['layout', 'rendered']) expect(router.state.status).toBe('idle') expect(router.state.resolvedLocation?.pathname).toBe('/') } finally { @@ -101,3 +113,42 @@ test('mounting after a settled load still resolves status and fires onRendered', container.remove() } }) + +test('mounting during a load keeps the existing generation', async () => { + const gate = createControlledPromise() + const beforeLoad = vi.fn() + const loader = vi.fn(() => gate) + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad, + loader, + component: () =>
Home
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const load = router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce()) + + const container = document.createElement('div') + document.body.appendChild(container) + const reactRoot = createRoot(container) + try { + reactRoot.render() + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledOnce()) + gate.resolve() + await load + await vi.waitFor(() => { + expect(container.querySelector('[data-testid="home"]')).not.toBeNull() + }) + + expect(beforeLoad).toHaveBeenCalledOnce() + expect(loader).toHaveBeenCalledOnce() + } finally { + reactRoot.unmount() + container.remove() + } +}) diff --git a/packages/react-router/tests/public-presentation-lane-contract.test.tsx b/packages/react-router/tests/public-presentation-lane-contract.test.tsx new file mode 100644 index 0000000000..862fffc93f --- /dev/null +++ b/packages/react-router/tests/public-presentation-lane-contract.test.tsx @@ -0,0 +1,244 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('public presentation lane contracts', () => { + test('visible pending UI publishes every matched route and its loading state', async () => { + const parentGate = createControlledPromise() + + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + pendingMs: 0, + pendingComponent: () =>
Loading parent
, + loader: () => parentGate, + component: Outlet, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => 'child data', + component: () =>
Child content
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Home')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/parent/child' }) + await Promise.resolve() + }) + + expect(await screen.findByText('Loading parent')).toBeInTheDocument() + expect(screen.queryByText('Child content')).not.toBeInTheDocument() + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ status: 'pending', isFetching: 'loader' }) + + await act(async () => { + parentGate.resolve('parent data') + await navigation + }) + + expect(screen.getByText('Child content')).toBeInTheDocument() + expect(router.state.status).toBe('idle') + }) + + test('same-boundary takeover republishes successor search without restarting pendingMinMs', async () => { + const firstGate = createControlledPromise() + const secondGate = createControlledPromise() + + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + validateSearch: (search: Record) => ({ + revision: Number(search.revision), + }), + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Loading page
, + beforeLoad: ({ search }) => + search.revision === 1 ? firstGate : secondGate, + component: () => { + const search = pageRoute.useSearch() + return
Page revision {search.revision}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Home')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + vi.useFakeTimers() + vi.setSystemTime(0) + + let successorSettled = false + let settledAtOriginalDeadline = false + let renderedAtOriginalDeadline = false + try { + await act(async () => { + void router.navigate({ + to: '/page', + search: { revision: 1 }, + }) + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByText('Loading page')).toBeInTheDocument() + expect(router.state.matches.at(-1)?.search).toMatchObject({ revision: 1 }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(25) + }) + + let secondNavigation!: Promise + await act(async () => { + secondNavigation = router.navigate({ + to: '/page', + search: { revision: 2 }, + }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(screen.getByText('Loading page')).toBeInTheDocument() + expect(router.state.location.search).toMatchObject({ revision: 2 }) + expect(router.state.matches.at(-1)?.search).toMatchObject({ revision: 2 }) + + void secondNavigation.then(() => { + successorSettled = true + }) + await act(async () => { + secondGate.resolve() + await Promise.resolve() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(74) + }) + expect(successorSettled).toBe(false) + expect(screen.getByText('Loading page')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await Promise.resolve() + }) + + settledAtOriginalDeadline = successorSettled + renderedAtOriginalDeadline = + screen.queryByText('Page revision 2') !== null + } finally { + // Finish a faulty implementation too, so a deadline assertion cannot + // strand this router and contaminate the following test. + firstGate.resolve() + secondGate.resolve() + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + }) + } + + expect({ + settled: settledAtOriginalDeadline, + rendered: renderedAtOriginalDeadline, + }).toEqual({ settled: true, rendered: true }) + expect(screen.getByText('Page revision 2')).toBeInTheDocument() + expect(screen.queryByText('Loading page')).not.toBeInTheDocument() + }) + + test('a reentrant navigation from onResolved suppresses the stale onRendered event', async () => { + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + component: () =>
Second
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Home')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const renderedPaths: Array = [] + let successor: Promise | undefined + const unsubscribeResolved = router.subscribe('onResolved', (event) => { + if (event.toLocation.pathname === '/first') { + successor = router.navigate({ to: '/second' }) + } + }) + const unsubscribeRendered = router.subscribe('onRendered', (event) => { + if (event.toLocation.pathname !== '/') { + renderedPaths.push(event.toLocation.pathname) + } + }) + + try { + await act(() => router.navigate({ to: '/first' })) + await act(async () => { + await successor + }) + + expect(screen.getByText('Second')).toBeInTheDocument() + expect(screen.queryByText('First')).not.toBeInTheDocument() + expect(renderedPaths).toEqual(['/second']) + } finally { + unsubscribeResolved() + unsubscribeRendered() + } + }) +}) diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index 74e180a552..b7bc885a91 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -52,6 +52,35 @@ function unwrapResponse( } describe('renderRouterToStream - pipeable sync errors', () => { + test('request abort cancels readable rendering without consuming the response body', async () => { + const cancel = vi.fn() + const stream = Object.assign(new ReadableStream({ cancel }), { + allReady: Promise.resolve(), + }) + reactDomServerMocks.renderToReadableStream = vi.fn(() => stream) + + const router = await buildRouter() + const controller = new AbortController() + try { + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/', { + signal: controller.signal, + }), + router, + responseHeaders: new Headers(), + children: null, + }), + ) + + expect(response.body).not.toBeNull() + controller.abort(new Error('request-gone')) + await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()) + } finally { + router.serverSsr?.cleanup() + } + }) + test('sync onError before pipeable is assigned still aborts pipeable', async () => { const abort = vi.fn() reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( @@ -197,7 +226,7 @@ describe('renderRouterToStream - pipeable sync errors', () => { } }) - test('request abort aborts pipeable and errors body', async () => { + test('request abort cancels pipeable rendering before the response body is consumed', async () => { const abort = vi.fn() reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( (_children, opts) => { @@ -220,13 +249,14 @@ describe('renderRouterToStream - pipeable sync errors', () => { }), ) + expect(response.body).not.toBeNull() controller.abort(new Error('request-gone')) + await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce()) const terminated = await Promise.race([ expectBodyRejects(response, 'request-gone').then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 2000)), ]) expect(terminated).toBe(true) - expect(abort).toHaveBeenCalledOnce() } finally { router.serverSsr?.cleanup() } diff --git a/packages/react-router/tests/root-pending-min.test.tsx b/packages/react-router/tests/root-pending-min.test.tsx index 636f871274..56bd7e8272 100644 --- a/packages/react-router/tests/root-pending-min.test.tsx +++ b/packages/react-router/tests/root-pending-min.test.tsx @@ -5,12 +5,10 @@ import { renderToString } from 'react-dom/server' import { afterEach, expect, test, vi } from 'vitest' import { hydrate } from '../src/ssr/client' import { - Outlet, RouterProvider, createControlledPromise, createMemoryHistory, createRootRoute, - createRoute, createRouter, } from '../src' @@ -44,7 +42,6 @@ test('a post-hydration root reload keeps its fallback through pendingMinMs', asy history: createMemoryHistory({ initialEntries: ['/'] }), }) const rootMatch = router.matchRoutes(router.latestLocation)[0]! - // This is the same public bootstrap shape produced by the server. Calling // hydrate() ensures router.ssr and the active match are established through // the real client hydration path rather than by mutating router stores. @@ -54,7 +51,6 @@ test('a post-hydration root reload keeps its fallback through pendingMinMs', asy dehydratedData: {}, matches: [ { - i: rootMatch.id, s: 'success', ssr: true, l: { generation: 1 }, @@ -87,7 +83,7 @@ test('a post-hydration root reload keeps its fallback through pendingMinMs', asy expect(rootLoader).toHaveBeenCalledTimes(1) expect(screen.getByTestId('root-pending')).toBeInTheDocument() - expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + expect(screen.getByTestId('root-content')).not.toBeVisible() await act(async () => { reloadGate.resolve() @@ -98,7 +94,6 @@ test('a post-hydration root reload keeps its fallback through pendingMinMs', asy await vi.advanceTimersByTimeAsync(99) }) expect(screen.getByTestId('root-pending')).toBeInTheDocument() - expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() await act(async () => { await vi.advanceTimersByTimeAsync(1) @@ -187,64 +182,3 @@ test('server rendering uses the root pending boundary for route component suspen // can wait for the same boundary instead. expect(html).toContain('Server root pending') }) - -test('Transitioner remount loads hydrated history changes made while unmounted', async () => { - const rootRoute = createRootRoute({ component: Outlet }) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Index route
, - }) - const nextRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/next', - component: () =>
Next route
, - }) - const history = createMemoryHistory({ initialEntries: ['/'] }) - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, nextRoute]), - history, - }) - const matches = router.matchRoutes(router.latestLocation) - const now = Date.now() - - window.$_TSR = { - router: { - manifest: { routes: {} }, - dehydratedData: {}, - matches: matches.map((match) => ({ - i: match.id, - s: 'success' as const, - ssr: true, - u: now, - })), - }, - h: vi.fn(), - e: vi.fn(), - c: vi.fn(), - p: vi.fn(), - buffer: [], - initialized: false, - } - - await hydrate(router) - const firstRender = render() - expect(screen.getByText('Index route')).toBeInTheDocument() - - firstRender.unmount() - history.push('/next') - - const secondRender = render() - expect(await screen.findByText('Next route')).toBeInTheDocument() - expect(router.state.location.pathname).toBe('/next') - - secondRender.unmount() - history.push('/next', { marker: 'new history entry' }) - - render() - await vi.waitFor(() => { - expect((router.state.location.state as any).marker).toBe( - 'new history entry', - ) - }) -}) diff --git a/packages/react-router/tests/routeContext.test.tsx b/packages/react-router/tests/routeContext.test.tsx index d11f86420e..e00228e280 100644 --- a/packages/react-router/tests/routeContext.test.tsx +++ b/packages/react-router/tests/routeContext.test.tsx @@ -141,7 +141,7 @@ describe('context function', () => { }) test('when loader deps change', async () => { - const mockContextFn = vi.fn() + let generation = 0 const rootRoute = createRootRoute() const indexRoute = createRoute({ @@ -153,14 +153,21 @@ describe('context function', () => { path: '/', loaderDeps: ({ search }) => ({ foo: search.foo }), context: ({ deps }) => { - mockContextFn(deps) + return { + generation: ++generation, + deps: JSON.stringify(deps), + } }, component: () => { const navigate = indexRoute.useNavigate() + const context = indexRoute.useRouteContext() return (

Index page

search: {JSON.stringify(indexRoute.useSearch())}

+

+ context: {context.generation}:{context.deps} +