diff --git a/.changeset/lane-match-loader-rewrite.md b/.changeset/lane-match-loader-rewrite.md new file mode 100644 index 0000000000..ddd71fa4c5 --- /dev/null +++ b/.changeset/lane-match-loader-rewrite.md @@ -0,0 +1,29 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Rewrite match loading around a lane-based scheduler that tracks each navigation, preload, and background reload as an ordered unit of work. This fixes pending/redirect/retry state leaking between overlapping navigations, restores correct SSR status codes for redirects, errors, and not-found responses, and closes hydration gaps where the client re-ran work the server had already completed. + +- Route `headers()` now only runs on the server, matching the documented behavior — it is no longer invoked during client-side asset projection. +- Default `gcTime` and `preloadGcTime` are reduced from 30 minutes (`1_800_000`) to 5 minutes (`300_000`). + +**Removed / changed public API** + +- `RouterState` no longer includes `loadedAt`, `isTransitioning`, `statusCode`, or `redirect`. Use `match.updatedAt` in place of `loadedAt`; subscribe to `router.state.status` / `router.state.isLoading` in place of `isTransitioning`; server response status and redirect handling are now internal to the server loader and are no longer exposed on `router.state`. +- `RouteMatch.fetchCount` has been removed, with no replacement — it was purely informational. +- `RouteMatch.status` no longer includes `'redirected'` (it remains `'pending' | 'success' | 'error' | 'notFound'`) — redirected matches are dropped from the match list instead of being rendered. +- `RouteMatch.globalNotFound` has been renamed and privatized to the internal `_notFound` field. Use `match.status === 'notFound'` instead. +- Removed `RouterCore` members `getMatch()`, `updateMatch()`, `cancelMatch()`, and `cancelMatches()` — read matches from `router.state.matches` (e.g. `router.state.matches.find((m) => m.id === id)`); there is no replacement for mutating or cancelling an individual in-flight match from outside the router. +- Removed `hasNotFoundMatch()` — use `router.state.matches.some((m) => m.status === 'notFound')`. +- Removed `looseRoutesById` — use `routesById`. +- Removed `isPrerendering()`, `isViewTransitionTypesSupported`, and `viewTransitionPromise`, with no replacement. +- Removed `getParsedLocationHref()` and `clearExpiredCache()`, with no replacement — expired cache entries are now reconciled automatically as part of match commit. +- Removed `latestLoadPromise` and `beforeLoad()`, with no replacement. +- `commitLocationPromise` and `pendingBuiltLocation` are now private (`_commitPromise`, `_pendingLocation`) and no longer part of the public `RouterCore` surface. +- Removed the exported `GetMatchFn` and `UpdateMatchFn` types, along with the methods they typed. +- Removed the standalone `getMatchedRoutes()` export from `@tanstack/router-core` — use the `router.getMatchedRoutes()` instance method instead. +- `MatchRoutesOpts.preload` and `MatchRoutesOpts.dest` have been removed. +- `StartTransitionFn` is now `(fn, expected, urgent?) => Promise` (previously `(fn) => void`). This only affects custom framework adapters that implement `startTransition`. diff --git a/benchmarks/memory/client/scenarios/interrupted-navigations/shared.ts b/benchmarks/memory/client/scenarios/interrupted-navigations/shared.ts index f7f3a7cd8b..80f234f0c8 100644 --- a/benchmarks/memory/client/scenarios/interrupted-navigations/shared.ts +++ b/benchmarks/memory/client/scenarios/interrupted-navigations/shared.ts @@ -35,7 +35,6 @@ type RenderedEvent = { } type InterruptedNavigationRouter = { - latestLoadPromise: Promise | undefined load: () => Promise navigate: (options: { to: '/fast/$id' | '/slow/$id' @@ -104,18 +103,6 @@ function assertSlowNavigationSettlement(settlement: NavigationSettlement) { ) } -async function awaitExpectedLoadSettlement(loadPromise: Promise) { - try { - await loadPromise - } catch (reason) { - if (reasonHasAbortShape(reason) || reasonHasCancellationShape(reason)) { - return - } - - throw reason - } -} - function reasonHasAbortShape(reason: unknown) { return reason instanceof DOMException && reason.name === 'AbortError' } @@ -144,7 +131,6 @@ export function createWorkload( let navigateFast: (id: string) => Promise = uninitialized let startSlowNavigation: (id: string) => Promise = uninitializedSettlement - let getLatestLoadPromise: () => Promise | undefined = () => undefined function assertRenderedPage(page: 'shell' | 'fast', id?: string) { const element = container?.querySelector('[data-bench-page]') @@ -203,7 +189,6 @@ export function createWorkload( const mounted = mountTestApp(container) const router = mounted.router as InterruptedNavigationRouter unmount = mounted.unmount - getLatestLoadPromise = () => router.latestLoadPromise unsub = router.subscribe('onRendered', (event) => { if ( @@ -265,7 +250,6 @@ export function createWorkload( expectedRenderedPath = undefined navigateFast = uninitialized startSlowNavigation = uninitializedSettlement - getLatestLoadPromise = () => undefined } async function interrupt( @@ -276,12 +260,6 @@ export function createWorkload( const slowNavigation = startSlowNavigation(slowId) await waitForSlowLoader(slowId) - const slowLoadPromise = getLatestLoadPromise() - - if (!slowLoadPromise) { - throw new Error(`Slow navigation did not start a load for id: ${slowId}`) - } - await navigateFast(fastId) resolveSlowLoader(slowId) @@ -291,7 +269,6 @@ export function createWorkload( assertSlowNavigationSettlement(settlement) } - await awaitExpectedLoadSettlement(slowLoadPromise) await drainMicrotasks() } diff --git a/benchmarks/memory/server/scenarios/aborted-requests/shared.ts b/benchmarks/memory/server/scenarios/aborted-requests/shared.ts index f7f63c17e6..c98f462538 100644 --- a/benchmarks/memory/server/scenarios/aborted-requests/shared.ts +++ b/benchmarks/memory/server/scenarios/aborted-requests/shared.ts @@ -27,11 +27,11 @@ const textDecoder = new TextDecoder() const abortedRequestModes: Record = { react: { readMode: 'first-chunk', - cancelMode: 'plain', + cancelMode: 'swallow-abort-error', }, solid: { readMode: 'first-chunk', - cancelMode: 'plain', + cancelMode: 'swallow-abort-error', }, vue: { readMode: 'shell-before-deferred', 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 453294ab6e..669a11175d 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,11 +138,17 @@ 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`. 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, user-supplied location state, redirect depth, and any explicit mask's href, search, state, and `unmaskOnReload` value. Router-managed history and temporary-mask keys are ignored. 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 When integrating external caching libraries like React Query, which have their own mechanisms for determining stale data, you may want to override the default preloading and stale-while-revalidate logic of TanStack Router. These libraries often use options like staleTime to control the freshness of data. @@ -168,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-router/issue-7120/index.html b/e2e/react-router/issue-7120/index.html new file mode 100644 index 0000000000..04a3530c9d --- /dev/null +++ b/e2e/react-router/issue-7120/index.html @@ -0,0 +1,10 @@ + + + + + + Issues 7120 and 7367 + + + + diff --git a/e2e/react-router/issue-7120/package.json b/e2e/react-router/issue-7120/package.json new file mode 100644 index 0000000000..a16155eb61 --- /dev/null +++ b/e2e/react-router/issue-7120/package.json @@ -0,0 +1,27 @@ +{ + "name": "tanstack-router-e2e-react-issue-7120", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "dev:e2e": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "vite", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/router-plugin": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.61.0", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.14" + } +} diff --git a/e2e/react-router/issue-7120/playwright.config.ts b/e2e/react-router/issue-7120/playwright.config.ts new file mode 100644 index 0000000000..61a995a815 --- /dev/null +++ b/e2e/react-router/issue-7120/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + use: { baseURL }, + webServer: { + command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} pnpm dev:e2e --port ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-router/issue-7120/src/main.tsx b/e2e/react-router/issue-7120/src/main.tsx new file mode 100644 index 0000000000..0c983251af --- /dev/null +++ b/e2e/react-router/issue-7120/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +const router = createRouter({ + routeTree, + defaultViewTransition: true, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/e2e/react-router/issue-7120/src/redirectGate.ts b/e2e/react-router/issue-7120/src/redirectGate.ts new file mode 100644 index 0000000000..399d303aac --- /dev/null +++ b/e2e/react-router/issue-7120/src/redirectGate.ts @@ -0,0 +1,35 @@ +type RedirectGate = { + waiting: boolean + released: boolean + release: () => void +} + +declare global { + interface Window { + redirectGate: RedirectGate + } +} + +let releasePromise!: () => void +const promise = new Promise((resolve) => { + releasePromise = resolve +}) + +const redirectGate: RedirectGate = { + waiting: false, + released: false, + release: () => { + if (!redirectGate.released) { + redirectGate.released = true + releasePromise() + } + }, +} + +window.redirectGate = redirectGate + +export async function waitForRedirectGate() { + redirectGate.waiting = true + await promise + redirectGate.waiting = false +} diff --git a/e2e/react-router/issue-7120/src/routeTree.gen.ts b/e2e/react-router/issue-7120/src/routeTree.gen.ts new file mode 100644 index 0000000000..fc671589c7 --- /dev/null +++ b/e2e/react-router/issue-7120/src/routeTree.gen.ts @@ -0,0 +1,77 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as PostsRouteImport } from './routes/posts' +import { Route as IndexRouteImport } from './routes/index' + +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any).lazy(() => import('./routes/posts.lazy').then((d) => d.Route)) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/posts': typeof PostsRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/posts': typeof PostsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/posts': typeof PostsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/posts' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/posts' + id: '__root__' | '/' | '/posts' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + PostsRoute: typeof PostsRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + PostsRoute: PostsRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/e2e/react-router/issue-7120/src/routes/__root.tsx b/e2e/react-router/issue-7120/src/routes/__root.tsx new file mode 100644 index 0000000000..2f01516a55 --- /dev/null +++ b/e2e/react-router/issue-7120/src/routes/__root.tsx @@ -0,0 +1,33 @@ +import { Outlet, createRootRoute, redirect } from '@tanstack/react-router' +import { waitForRedirectGate } from '../redirectGate' + +let shouldRedirect = true + +export const Route = createRootRoute({ + pendingMs: 0, + pendingMinMs: 500, + pendingComponent: RootPendingComponent, + beforeLoad: async ({ location }) => { + if (location.pathname !== '/' || !shouldRedirect) { + return + } + + await waitForRedirectGate() + + if (!shouldRedirect) { + return + } + + shouldRedirect = false + throw redirect({ to: '/posts', replace: true }) + }, + component: () => , +}) + +function RootPendingComponent() { + return ( +
+ Loading route +
+ ) +} diff --git a/e2e/react-router/issue-7120/src/routes/index.tsx b/e2e/react-router/issue-7120/src/routes/index.tsx new file mode 100644 index 0000000000..1626ce706b --- /dev/null +++ b/e2e/react-router/issue-7120/src/routes/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: () =>
Home
, +}) diff --git a/e2e/react-router/issue-7120/src/routes/posts.lazy.tsx b/e2e/react-router/issue-7120/src/routes/posts.lazy.tsx new file mode 100644 index 0000000000..dc0b0cfd7f --- /dev/null +++ b/e2e/react-router/issue-7120/src/routes/posts.lazy.tsx @@ -0,0 +1,14 @@ +import { createLazyFileRoute } from '@tanstack/react-router' + +export const Route = createLazyFileRoute('/posts')({ + component: PostsComponent, +}) + +function PostsComponent() { + return ( +
+

Posts loaded

+

The redirected lazy route rendered.

+
+ ) +} diff --git a/e2e/react-router/issue-7120/src/routes/posts.tsx b/e2e/react-router/issue-7120/src/routes/posts.tsx new file mode 100644 index 0000000000..cc0519b74e --- /dev/null +++ b/e2e/react-router/issue-7120/src/routes/posts.tsx @@ -0,0 +1,3 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts')({}) diff --git a/e2e/react-router/issue-7120/src/vite-env.d.ts b/e2e/react-router/issue-7120/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/e2e/react-router/issue-7120/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts new file mode 100644 index 0000000000..081ae73691 --- /dev/null +++ b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test' + +test('#7120 / #7367: a root redirect cannot commit a stale match during a view transition', async ({ + page, +}) => { + const pageErrors: Array = [] + const consoleErrors: Array = [] + + page.on('pageerror', (error) => { + pageErrors.push(error.message || String(error)) + }) + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()) + } + }) + + await page.goto('/') + + const pending = page.getByTestId('root-pending') + await expect(pending).toBeVisible() + expect(await page.evaluate(() => window.redirectGate.waiting)).toBe(true) + + await page.evaluate(() => window.redirectGate.release()) + + await expect(page).toHaveURL(/\/posts$/) + await expect( + page.getByRole('heading', { name: 'Posts loaded' }), + ).toBeVisible() + await expect(pending).not.toBeVisible() + expect(pageErrors).toEqual([]) + expect(consoleErrors).toEqual([]) +}) diff --git a/e2e/react-router/issue-7120/tsconfig.json b/e2e/react-router/issue-7120/tsconfig.json new file mode 100644 index 0000000000..4f6089bc08 --- /dev/null +++ b/e2e/react-router/issue-7120/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/e2e/react-router/issue-7120/vite.config.ts b/e2e/react-router/issue-7120/vite.config.ts new file mode 100644 index 0000000000..5b1c613906 --- /dev/null +++ b/e2e/react-router/issue-7120/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [tanstackRouter({ target: 'react' }), react()], +}) diff --git a/e2e/react-router/issue-7457/index.html b/e2e/react-router/issue-7457/index.html new file mode 100644 index 0000000000..76369b5957 --- /dev/null +++ b/e2e/react-router/issue-7457/index.html @@ -0,0 +1,10 @@ + + + + + + Issue 7457 + + + + diff --git a/e2e/react-router/issue-7457/package.json b/e2e/react-router/issue-7457/package.json new file mode 100644 index 0000000000..3220a4dbb4 --- /dev/null +++ b/e2e/react-router/issue-7457/package.json @@ -0,0 +1,28 @@ +{ + "name": "tanstack-router-e2e-react-issue-7457", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "dev:e2e": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "vite", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-query": "^5.99.0", + "@tanstack/react-router": "workspace:^", + "@tanstack/router-plugin": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.61.0", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.14" + } +} diff --git a/e2e/react-router/issue-7457/playwright.config.ts b/e2e/react-router/issue-7457/playwright.config.ts new file mode 100644 index 0000000000..61a995a815 --- /dev/null +++ b/e2e/react-router/issue-7457/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + use: { baseURL }, + webServer: { + command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} pnpm dev:e2e --port ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-router/issue-7457/src/main.tsx b/e2e/react-router/issue-7457/src/main.tsx new file mode 100644 index 0000000000..1b6805edc0 --- /dev/null +++ b/e2e/react-router/issue-7457/src/main.tsx @@ -0,0 +1,32 @@ +import { StrictMode, useEffect } from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClient } from '@tanstack/react-query' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +const queryClient = new QueryClient() +const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPendingComponent: DefaultPendingComponent, + defaultPreloadStaleTime: 0, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +function DefaultPendingComponent() { + useEffect(() => { + ;(globalThis as any).__pendingSeen = true + }, []) + return
loading
+} + +createRoot(document.body).render( + + + , +) diff --git a/e2e/react-router/issue-7457/src/routeTree.gen.ts b/e2e/react-router/issue-7457/src/routeTree.gen.ts new file mode 100644 index 0000000000..72a8f23384 --- /dev/null +++ b/e2e/react-router/issue-7457/src/routeTree.gen.ts @@ -0,0 +1,77 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AnotherRouteImport } from './routes/another' +import { Route as IndexRouteImport } from './routes/index' + +const AnotherRoute = AnotherRouteImport.update({ + id: '/another', + path: '/another', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/another': typeof AnotherRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/another': typeof AnotherRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/another': typeof AnotherRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/another' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/another' + id: '__root__' | '/' | '/another' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AnotherRoute: typeof AnotherRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/another': { + id: '/another' + path: '/another' + fullPath: '/another' + preLoaderRoute: typeof AnotherRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AnotherRoute: AnotherRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/e2e/react-router/issue-7457/src/routes/__root.tsx b/e2e/react-router/issue-7457/src/routes/__root.tsx new file mode 100644 index 0000000000..988ba76263 --- /dev/null +++ b/e2e/react-router/issue-7457/src/routes/__root.tsx @@ -0,0 +1,25 @@ +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' +import type { QueryClient } from '@tanstack/react-query' + +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient +}>()({ + beforeLoad: async ({ context }) => { + await context.queryClient.ensureQueryData({ + queryKey: ['issue-7457-root'], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 1_500)) + return true + }, + }) + }, + component: RootComponent, +}) + +function RootComponent() { + return ( +
+ +
+ ) +} diff --git a/e2e/react-router/issue-7457/src/routes/another.tsx b/e2e/react-router/issue-7457/src/routes/another.tsx new file mode 100644 index 0000000000..6b6caa55b3 --- /dev/null +++ b/e2e/react-router/issue-7457/src/routes/another.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/another')({ + component: () =>
Hello "/another"!
, +}) diff --git a/e2e/react-router/issue-7457/src/routes/index.tsx b/e2e/react-router/issue-7457/src/routes/index.tsx new file mode 100644 index 0000000000..daaae2aeed --- /dev/null +++ b/e2e/react-router/issue-7457/src/routes/index.tsx @@ -0,0 +1,8 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + beforeLoad: () => { + throw redirect({ to: '/another', replace: true }) + }, + component: () =>
You should never see this!
, +}) diff --git a/e2e/react-router/issue-7457/src/vite-env.d.ts b/e2e/react-router/issue-7457/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/e2e/react-router/issue-7457/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/e2e/react-router/issue-7457/tests/issue-7457.repro.spec.ts b/e2e/react-router/issue-7457/tests/issue-7457.repro.spec.ts new file mode 100644 index 0000000000..8b20fac1eb --- /dev/null +++ b/e2e/react-router/issue-7457/tests/issue-7457.repro.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' + +test('#7457: initial child redirect after async root beforeLoad does not blank', async ({ + page, +}) => { + const pageErrors: Array = [] + const consoleErrors: Array = [] + page.on('pageerror', (error) => + pageErrors.push(error.message || String(error)), + ) + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()) + } + }) + + await page.goto('/') + + await expect(page).toHaveURL(/\/another$/) + const target = page.getByText('Hello "/another"!') + await expect + .poll(async () => ({ + targetCount: await target.count(), + pageErrors: [...pageErrors], + consoleErrors: [...consoleErrors], + })) + .toEqual({ targetCount: 1, pageErrors: [], consoleErrors: [] }) + await expect(target).toBeVisible() + await expect + .poll(() => page.evaluate(() => (globalThis as any).__pendingSeen)) + .toBe(true) + await expect(page.getByTestId('app-pending')).not.toBeVisible() + expect(pageErrors).toEqual([]) + expect(consoleErrors).toEqual([]) +}) diff --git a/e2e/react-router/issue-7457/tsconfig.json b/e2e/react-router/issue-7457/tsconfig.json new file mode 100644 index 0000000000..4f6089bc08 --- /dev/null +++ b/e2e/react-router/issue-7457/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/e2e/react-router/issue-7457/vite.config.js b/e2e/react-router/issue-7457/vite.config.js new file mode 100644 index 0000000000..9e39ea83d9 --- /dev/null +++ b/e2e/react-router/issue-7457/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackRouter({ target: 'react', autoCodeSplitting: true }), + react(), + ], +}) diff --git a/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts b/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts index f72d2d06ab..9c2d944dd5 100644 --- a/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts +++ b/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts @@ -2,6 +2,21 @@ import { expect, test } from '@playwright/test' import { linkOptions } from '@tanstack/react-router' import { toRuntimePath } from '@tanstack/router-e2e-utils' +type ScrollPaintTestState = { + sourceFrameScrollYs: Array + sourceWasReset: boolean + destinationMounted: boolean + destinationCommitScrollY: number | null + destinationFrameScrollYs: Array + cleanup: () => void +} + +declare global { + interface Window { + __scrollPaintTestState?: ScrollPaintTestState + } +} + test('Smoke - Renders home', async ({ page }) => { await page.goto(toRuntimePath('/')) await expect( @@ -9,6 +24,175 @@ test('Smoke - Renders home', async ({ page }) => { ).toBeVisible() }) +test('PUSH resets scroll before the destination can render a frame (#7815)', async ({ + page, +}) => { + await page.goto(toRuntimePath('/issue-7040-source')) + await expect(page.getByTestId('issue-7040-source-top')).toBeVisible() + + const sourceScrollY = await page.evaluate(async () => { + window.scrollTo(0, 500) + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + return window.scrollY + }) + + expect(sourceScrollY).toBeGreaterThan(0) + + await page.evaluate((expectedSourceScrollY) => { + const root = document.querySelector('#app') + if (!root) { + throw new Error('App root not found') + } + + const geometry = document.createElement('div') + geometry.setAttribute('aria-hidden', 'true') + geometry.style.height = `${window.innerHeight + expectedSourceScrollY + 100}px` + geometry.style.pointerEvents = 'none' + geometry.style.width = '1px' + document.body.append(geometry) + + const state: ScrollPaintTestState = { + sourceFrameScrollYs: [], + sourceWasReset: false, + destinationMounted: false, + destinationCommitScrollY: null, + destinationFrameScrollYs: [], + cleanup: () => {}, + } + + const getDestinationHeading = () => + Array.from(document.querySelectorAll('h3')).find( + (heading) => heading.textContent === 'normal-page', + ) + + const commitObserver = new MutationObserver(() => { + const destinationHeading = getDestinationHeading() + + if (!destinationHeading) { + return + } + + commitObserver.disconnect() + state.destinationMounted = true + state.destinationCommitScrollY = window.scrollY + }) + + commitObserver.observe(root, { childList: true, subtree: true }) + + const handleScroll = () => { + if ( + document.querySelector('[data-testid="issue-7040-source-top"]') && + Math.abs(window.scrollY - expectedSourceScrollY) > 1 + ) { + state.sourceWasReset = true + } + } + window.addEventListener('scroll', handleScroll, { passive: true }) + + const originalScrollTo = window.scrollTo + window.scrollTo = ((...args: Array) => { + const requestedTop = + typeof args[0] === 'number' + ? args[1] + : typeof args[0] === 'object' && args[0] !== null + ? (args[0] as ScrollToOptions).top + : undefined + if ( + typeof requestedTop === 'number' && + Math.abs(requestedTop - expectedSourceScrollY) > 1 && + document.querySelector('[data-testid="issue-7040-source-top"]') + ) { + state.sourceWasReset = true + } + Reflect.apply(originalScrollTo, window, args) + }) as typeof window.scrollTo + + let animationFrameId = 0 + const recordFrame = () => { + if (getDestinationHeading()) { + state.destinationFrameScrollYs.push(window.scrollY) + } else if ( + document.querySelector('[data-testid="issue-7040-source-top"]') + ) { + state.sourceFrameScrollYs.push(window.scrollY) + } + animationFrameId = requestAnimationFrame(recordFrame) + } + animationFrameId = requestAnimationFrame(recordFrame) + + state.cleanup = () => { + commitObserver.disconnect() + cancelAnimationFrame(animationFrameId) + window.removeEventListener('scroll', handleScroll) + window.scrollTo = originalScrollTo + geometry.remove() + delete window.__scrollPaintTestState + } + window.__scrollPaintTestState = state + }, sourceScrollY) + + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()) + }), + ) + expect(await page.evaluate(() => window.scrollY)).toBe(sourceScrollY) + + await page + .getByRole('link', { name: 'Head-/normal-page' }) + .dispatchEvent('click') + + await page.waitForFunction(() => { + const state = window.__scrollPaintTestState + if (!state?.destinationMounted) { + return false + } + + const lastFrames = state.destinationFrameScrollYs.slice(-2) + return ( + lastFrames.length === 2 && + lastFrames.every((scrollY) => Math.abs(scrollY) <= 1) + ) + }) + + const observation = await page.evaluate(() => { + const state = window.__scrollPaintTestState + if (!state) { + throw new Error('Scroll paint observer not found') + } + + return { + sourceFrameScrollYs: state.sourceFrameScrollYs, + sourceWasReset: state.sourceWasReset, + destinationCommitScrollY: state.destinationCommitScrollY, + destinationFrameScrollYs: state.destinationFrameScrollYs, + destinationMaxScrollY: + document.documentElement.scrollHeight - window.innerHeight, + finalScrollY: window.scrollY, + } + }) + + expect(observation.sourceFrameScrollYs.length).toBeGreaterThan(0) + expect( + observation.sourceFrameScrollYs.every( + (scrollY) => Math.abs(scrollY - sourceScrollY) <= 1, + ), + ).toBe(true) + expect(observation.sourceWasReset).toBe(false) + expect(observation.destinationMaxScrollY).toBeGreaterThanOrEqual( + sourceScrollY, + ) + expect(observation.finalScrollY).toBe(0) + expect(observation.destinationFrameScrollYs.length).toBeGreaterThanOrEqual(2) + expect( + observation.destinationFrameScrollYs.every( + (scrollY) => Math.abs(scrollY) <= 1, + ), + `Destination paint opportunities: ${observation.destinationFrameScrollYs.join(', ')}, commit: ${observation.destinationCommitScrollY}, final: ${observation.finalScrollY}`, + ).toBe(true) +}) + test('restores the prior scroll position after browser back then forward', async ({ page, }) => { diff --git a/e2e/react-start/selective-ssr/src/routes/__root.tsx b/e2e/react-start/selective-ssr/src/routes/__root.tsx index bc71c44c1c..3465abf298 100644 --- a/e2e/react-start/selective-ssr/src/routes/__root.tsx +++ b/e2e/react-start/selective-ssr/src/routes/__root.tsx @@ -9,10 +9,10 @@ import { createRootRoute, useRouterState, } from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' import { z } from 'zod' import { ssrSchema } from '~/search' import appCss from '~/styles/app.css?url' -import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' export const Route = createRootRoute({ head: () => ({ @@ -149,6 +149,14 @@ function RootDocument({ children }: { children: React.ReactNode }) { > Home + + Issue 4614 cached parent + { + test('#4614: cached parent loader data does not cache its beforeLoad context', async ({ + page, + }) => { + await page.goto('/') + await page.getByTestId('issue-4614-cached-link').hover() + + await expect + .poll(() => + page.evaluate(() => (globalThis as any).__issue4614TargetBeforeLoad), + ) + .not.toBeUndefined() + + const { rootBeforeLoads, targetBeforeLoad } = await page.evaluate(() => { + const rootBeforeLoads = + (globalThis as any).__issue4614RootBeforeLoads ?? [] + return { + rootBeforeLoads, + targetBeforeLoad: (globalThis as any).__issue4614TargetBeforeLoad, + } + }) + + expect(rootBeforeLoads).toEqual([ + { + cause: 'preload', + preload: true, + root: 'client', + issue4614Context: 'client:cached', + }, + ]) + expect(targetBeforeLoad).toEqual({ + cause: 'preload', + preload: true, + rootContext: 'client', + issue4614Context: 'client:cached', + scenario: 'cached', + }) + }) + test('new loaderDeps match generation propagates fresh parent context to child (control)', async ({ page, }) => { diff --git a/e2e/solid-start/selective-ssr/dev-server.ts b/e2e/solid-start/selective-ssr/dev-server.ts new file mode 100644 index 0000000000..519075f261 --- /dev/null +++ b/e2e/solid-start/selective-ssr/dev-server.ts @@ -0,0 +1,5 @@ +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +export const devPort = await getTestServerPort(`${packageJson.name}-dev`) +export const devBaseURL = `http://localhost:${devPort}` diff --git a/e2e/solid-start/selective-ssr/playwright.config.ts b/e2e/solid-start/selective-ssr/playwright.config.ts index badd6db0eb..09e5c50410 100644 --- a/e2e/solid-start/selective-ssr/playwright.config.ts +++ b/e2e/solid-start/selective-ssr/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from '@playwright/test' import { getTestServerPort } from '@tanstack/router-e2e-utils' import packageJson from './package.json' with { type: 'json' } +import { devBaseURL, devPort } from './dev-server' const PORT = await getTestServerPort(packageJson.name) const baseURL = `http://localhost:${PORT}` @@ -18,12 +19,21 @@ export default defineConfig({ baseURL, }, - webServer: { - command: `VITE_SERVER_PORT=${PORT} pnpm build && NODE_ENV=production PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, - url: baseURL, - reuseExistingServer: !process.env.CI, - stdout: 'pipe', - }, + webServer: [ + { + command: `VITE_SERVER_PORT=${PORT} pnpm build && NODE_ENV=production PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + { + command: `NODE_ENV=development VITE_SERVER_PORT=${devPort} pnpm dev:e2e --host localhost --port ${devPort} --strictPort`, + url: devBaseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + timeout: 90_000, + }, + ], projects: [ { diff --git a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts index a6984d095e..8fb128de38 100644 --- a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts +++ b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts @@ -9,14 +9,20 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' -import { Route as DataOnlyPendingComponentRouteImport } from './routes/data-only-pending-component' +import { Route as SsrFalsePendingMinRouteImport } from './routes/ssr-false-pending-min' import { Route as PostsRouteImport } from './routes/posts' +import { Route as DataOnlyPendingComponentRouteImport } from './routes/data-only-pending-component' +import { Route as IndexRouteImport } from './routes/index' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', +const SsrFalsePendingMinRoute = SsrFalsePendingMinRouteImport.update({ + id: '/ssr-false-pending-min', + path: '/ssr-false-pending-min', + getParentRoute: () => rootRouteImport, +} as any) +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', getParentRoute: () => rootRouteImport, } as any) const DataOnlyPendingComponentRoute = @@ -25,9 +31,9 @@ const DataOnlyPendingComponentRoute = path: '/data-only-pending-component', getParentRoute: () => rootRouteImport, } as any) -const PostsRoute = PostsRouteImport.update({ - id: '/posts', - path: '/posts', +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', getParentRoute: () => rootRouteImport, } as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ @@ -40,12 +46,14 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/data-only-pending-component': typeof DataOnlyPendingComponentRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/data-only-pending-component': typeof DataOnlyPendingComponentRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesById { @@ -53,18 +61,30 @@ export interface FileRoutesById { '/': typeof IndexRoute '/data-only-pending-component': typeof DataOnlyPendingComponentRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/data-only-pending-component' | '/posts' | '/posts/$postId' + fullPaths: + | '/' + | '/data-only-pending-component' + | '/posts' + | '/ssr-false-pending-min' + | '/posts/$postId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/data-only-pending-component' | '/posts' | '/posts/$postId' + to: + | '/' + | '/data-only-pending-component' + | '/posts' + | '/ssr-false-pending-min' + | '/posts/$postId' id: | '__root__' | '/' | '/data-only-pending-component' | '/posts' + | '/ssr-false-pending-min' | '/posts/$postId' fileRoutesById: FileRoutesById } @@ -72,15 +92,23 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute DataOnlyPendingComponentRoute: typeof DataOnlyPendingComponentRoute PostsRoute: typeof PostsRouteWithChildren + SsrFalsePendingMinRoute: typeof SsrFalsePendingMinRoute } declare module '@tanstack/solid-router' { interface FileRoutesByPath { - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport + '/ssr-false-pending-min': { + id: '/ssr-false-pending-min' + path: '/ssr-false-pending-min' + fullPath: '/ssr-false-pending-min' + preLoaderRoute: typeof SsrFalsePendingMinRouteImport + parentRoute: typeof rootRouteImport + } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport parentRoute: typeof rootRouteImport } '/data-only-pending-component': { @@ -90,11 +118,11 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof DataOnlyPendingComponentRouteImport parentRoute: typeof rootRouteImport } - '/posts': { - id: '/posts' - path: '/posts' - fullPath: '/posts' - preLoaderRoute: typeof PostsRouteImport + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } '/posts/$postId': { @@ -121,6 +149,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, DataOnlyPendingComponentRoute: DataOnlyPendingComponentRoute, PostsRoute: PostsRouteWithChildren, + SsrFalsePendingMinRoute: SsrFalsePendingMinRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx b/e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx new file mode 100644 index 0000000000..82425f9575 --- /dev/null +++ b/e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx @@ -0,0 +1,48 @@ +import { onCleanup, onMount } from 'solid-js' +import { createFileRoute } from '@tanstack/solid-router' + +const pendingMinMs = 1500 + +function recordEvent(type: string) { + if (typeof window === 'undefined') { + return + } + + const global = window as any + global.__events ??= [] + global.__events.push({ type, t: performance.now() }) +} + +function Pending() { + onMount(() => { + recordEvent('pending-mounted') + }) + onCleanup(() => { + recordEvent('pending-unmounted') + }) + + return
Loading SSR false route...
+} + +function Target() { + onMount(() => { + recordEvent('target-mounted') + }) + + return
SSR false route loaded
+} + +export const Route = createFileRoute('/ssr-false-pending-min')({ + ssr: false, + pendingMs: 0, + pendingMinMs, + pendingComponent: Pending, + loader: async () => { + recordEvent('loader-start') + await new Promise((resolve) => setTimeout(resolve, 50)) + recordEvent('loader-done') + + return { ok: true } + }, + component: Target, +}) diff --git a/e2e/solid-start/selective-ssr/tests/issue-7283-dev-hydration.spec.ts b/e2e/solid-start/selective-ssr/tests/issue-7283-dev-hydration.spec.ts new file mode 100644 index 0000000000..a054a0433a --- /dev/null +++ b/e2e/solid-start/selective-ssr/tests/issue-7283-dev-hydration.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test' +import { devBaseURL } from '../dev-server' +import type { Page } from '@playwright/test' + +// Regression test for https://github.com/TanStack/router/issues/7283 +// +// Selective SSR routes (`ssr: false` / `ssr: 'data-only'`) that declare a +// `pendingComponent` must hydrate cleanly in DEV mode. Solid only validates +// hydration markers in dev, so the production webServer configured in +// playwright.config.ts masks the mismatch ("template is not a function", +// route never reaches its loaded component). Playwright therefore owns a +// separate `vite dev` server and drives the app against it. + +function collectHydrationFailures(page: Page) { + const failures: Array = [] + const isHydrationFailure = (text: string) => + text.includes('template is not a function') || + text.includes("wasn't caught by any route") || + text.includes('Hydration Mismatch') + page.on('pageerror', (err) => { + if (isHydrationFailure(err.message)) failures.push(err.message) + }) + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warning') { + const text = msg.text() + if (isHydrationFailure(text)) failures.push(text) + } + }) + return failures +} + +test.describe('dev-mode hydration of selective SSR routes with pendingComponent', () => { + test.setTimeout(120_000) + + test('ssr:false route with pendingComponent hydrates cleanly and reaches its loaded component (issue #7283)', async ({ + page, + }) => { + const failures = collectHydrationFailures(page) + + await page.goto(`${devBaseURL}/ssr-false-pending-min`) + + // The loaded component is the issue oracle: main never reaches it. + await expect(page.getByTestId('ssr-false-target')).toBeVisible({ + timeout: 15_000, + }) + + // No dev hydration mismatch may occur along the way. + expect(failures).toEqual([]) + + await expect(page.getByTestId('ssr-false-pending')).not.toBeAttached() + + const lifecycleEvents = await page.evaluate(() => + ((globalThis as any).__events ?? []) + .map((event: { type: string }) => event.type) + .filter((type: string) => + ['pending-mounted', 'pending-unmounted', 'target-mounted'].includes( + type, + ), + ), + ) + expect(lifecycleEvents.slice(-3)).toEqual([ + 'pending-mounted', + 'pending-unmounted', + 'target-mounted', + ]) + }) + + test('data-only route with pendingComponent hydrates cleanly and reaches its loaded component (regression vs main)', async ({ + page, + }) => { + const failures = collectHydrationFailures(page) + + await page.goto(`${devBaseURL}/data-only-pending-component`) + + await expect( + page.getByTestId('data-only-pending-component-pending'), + ).toBeAttached() + await expect( + page.getByTestId('data-only-pending-component-ready-label'), + ).toHaveText('OK - loader finished', { timeout: 15_000 }) + + expect(failures).toEqual([]) + }) +}) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 042ad70e2c..9120fbacbf 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -100,6 +100,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", + "@tanstack/react-query": "catalog:", "@types/node": ">=20", "@vitejs/plugin-react": "^4.3.4", "combinate": "^1.1.11", diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index 98743f9447..d97e49f8f5 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -5,44 +5,25 @@ 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 }) { - const errorComponent = props.errorComponent ?? ErrorComponent - - return ( - { - if (error) { - return React.createElement(errorComponent, { - error, - reset, - }) - } - - return props.children - }} - /> - ) + return } class CatchBoundaryImpl extends React.Component<{ - getResetKey: () => number | string - children: (props: { - error: Error | null - reset: () => void - }) => React.ReactNode + getResetKey: () => unknown + children: React.ReactNode + errorComponent?: ErrorRouteComponent 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() @@ -55,21 +36,20 @@ class CatchBoundaryImpl extends React.Component<{ static getDerivedStateFromError(error: Error) { return { error } } - reset() { + reset = () => { this.setState({ error: null }) } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - if (this.props.onCatch) { - this.props.onCatch(error, errorInfo) - } + this.props.onCatch?.(error, errorInfo) } render() { - return this.props.children({ - error: this.state.error, - reset: () => { - this.reset() - }, - }) + const error = this.state.error + return error + ? React.createElement(this.props.errorComponent ?? ErrorComponent, { + error, + reset: this.reset, + }) + : this.props.children } } diff --git a/packages/react-router/src/ClientOnly.tsx b/packages/react-router/src/ClientOnly.tsx index 10f903b204..102859d75a 100644 --- a/packages/react-router/src/ClientOnly.tsx +++ b/packages/react-router/src/ClientOnly.tsx @@ -31,11 +31,7 @@ export interface ClientOnlyProps { * ``` */ export function ClientOnly({ children, fallback = null }: ClientOnlyProps) { - return useHydrated() ? ( - {children} - ) : ( - {fallback} - ) + return {useHydrated() ? children : fallback} } /** diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 5de5546aeb..6bca031a4d 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -2,14 +2,7 @@ import * as React from 'react' import { useStore } from '@tanstack/react-store' -import { - createControlledPromise, - getLocationChangeInfo, - invariant, - isNotFound, - isRedirect, - 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' @@ -19,131 +12,59 @@ import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import { ClientOnly } from './ClientOnly' -import { useLayoutEffect } from './utils' import type { AnyRoute, AnyRouteMatch, - ParsedLocation, 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._displayPending === b._displayPending - const outletMatchSelectionEqual = ( a: OutletMatchSelection, b: OutletMatchSelection, ) => 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() - } - // eslint-disable-next-line react-hooks/rules-of-hooks - const resetKey = useStore(router.stores.loadedAt, (loadedAt) => loadedAt) + const matchStore = router.stores.getMatchStore(routeId) // eslint-disable-next-line react-hooks/rules-of-hooks - const match = useStore(matchStore, (value) => value, matchViewFieldsEqual) - // eslint-disable-next-line react-hooks/rules-of-hooks - const matchState = React.useMemo(() => { - const routeId = match.routeId as string - const parentRouteId = (router.routesById[routeId] as AnyRoute).parentRoute - ?.id - - return { - routeId, - ssr: match.ssr, - _displayPending: match._displayPending, - parentRouteId: parentRouteId as string | undefined, - } satisfies MatchViewState - }, [match._displayPending, match.routeId, match.ssr, router.routesById]) - - return ( - - ) + const match = useStore(matchStore, (value) => value) + return }) -type MatchViewState = { - routeId: string - ssr: boolean | 'data-only' | undefined - _displayPending: boolean | 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 @@ -151,19 +72,16 @@ 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 = - // If we're on the root route, allow forcefully wrapping in suspense - (!route.isRoot || route.options.wrapInSuspense || resolvedNoSsr) && (route.options.wrapInSuspense ?? - PendingComponent ?? - ((route.options.errorComponent as any)?.preload || resolvedNoSsr)) + pendingElement ?? + ((route.options.errorComponent as any)?.preload || resolvedNoSsr)) ? React.Suspense : SafeFragment @@ -180,235 +98,68 @@ function MatchView({ : SafeFragment return ( - + resetKey} - errorComponent={routeErrorComponent || ErrorComponent} + getResetKey={() => match} + 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._displayPending ? ( + {resolvedNoSsr ? ( - + ) : ( - + )} - {matchState.parentRouteId === rootRouteId ? ( - <> - - {router.options.scrollRestoration && (isServer ?? router.isServer) ? ( - - ) : null} - + {(isServer ?? router.isServer) && + route.parentRoute?.id === rootRouteId && + router.options.scrollRestoration ? ( + ) : null} ) } -// On Rendered can't happen above the root layout because it needs to run after -// the route subtree has committed below the root layout. Keeping it here lets -// us fire onRendered even after a hydration mismatch above the root layout -// (like bad head/link tags, which is common). -function OnRendered() { - const router = useRouter() - - if (isServer ?? router.isServer) { - return null - } - - // Track the resolvedLocation as of the last render so that onRendered can - // report the correct fromLocation. By the time this effect fires, - // resolvedLocation has already been updated to the new location by - // Transitioner, so we cannot use router.stores.resolvedLocation.get() - // directly as the fromLocation. - // @ts-expect-error -- init to `undefined` but don't write `undefined` to shave bytes - // eslint-disable-next-line react-hooks/rules-of-hooks - const prevResolvedLocationRef = React.useRef< - ParsedLocation | undefined - >() - // eslint-disable-next-line react-hooks/rules-of-hooks - const renderedLocationKey = useStore( - router.stores.resolvedLocation, - (resolvedLocation) => resolvedLocation?.state.__TSR_key, - ) - - // eslint-disable-next-line react-hooks/rules-of-hooks - useLayoutEffect(() => { - const currentResolvedLocation = router.stores.resolvedLocation.get() - const previousResolvedLocation = prevResolvedLocationRef.current - - if ( - currentResolvedLocation && - (!previousResolvedLocation || - previousResolvedLocation.href !== currentResolvedLocation.href) - ) { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo( - router.stores.location.get(), - previousResolvedLocation ?? currentResolvedLocation, - ), - }) - } - prevResolvedLocationRef.current = currentResolvedLocation - }, [renderedLocationKey, router]) - - return null -} - export const MatchInner = React.memo(function MatchInnerImpl({ - matchId, + match, }: { - matchId: string + match: AnyRouteMatch }): any { const router = useRouter() - - const getMatchPromise = ( - match: { - id: string - _nonReactive: { - displayPendingPromise?: Promise - minPendingPromise?: Promise - loadPromise?: Promise - } - }, - key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise', - ) => { - return ( - router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key] - ) - } - - 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._displayPending) { - throw getMatchPromise(match, 'displayPendingPromise') - } - - if (match._forcePending) { - throw getMatchPromise(match, 'minPendingPromise') - } - - if (match.status === 'pending') { - throw getMatchPromise(match, 'loadPromise') - } - - 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 === 'redirected') { - if (!isRedirect(match.error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error('Invariant failed: Expected a redirect error') - } - - invariant() - } - throw getMatchPromise(match, 'loadPromise') - } - - 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, @@ -421,85 +172,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._displayPending) { - throw getMatchPromise(match, 'displayPendingPromise') - } - - if (match._forcePending) { - throw getMatchPromise(match, 'minPendingPromise') - } - - // see also hydrate() in packages/router-core/src/ssr/ssr-client.ts if (match.status === 'pending') { - // We're pending, and if we have a minPendingMs, we need to wait for it - const pendingMinMs = - route.options.pendingMinMs ?? router.options.defaultPendingMinMs - if (pendingMinMs) { - const routerMatch = router.getMatch(match.id) - if (routerMatch && !routerMatch._nonReactive.minPendingPromise) { - // Create a promise that will resolve after the minPendingMs - if (!(isServer ?? router.isServer)) { - const minPendingPromise = createControlledPromise() - - routerMatch._nonReactive.minPendingPromise = minPendingPromise - - setTimeout(() => { - minPendingPromise.resolve() - // We've handled the minPendingPromise, so we can delete it - routerMatch._nonReactive.minPendingPromise = undefined - }, pendingMinMs) - } - } + if (router._tx) { + throw router._tx[5] } - throw getMatchPromise(match, 'loadPromise') + 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 === 'redirected') { - // A match can be observed as redirected during an in-flight transition, - // especially when pending UI is already rendering. Suspend on the match's - // load promise so React can abandon this stale render and continue the - // redirect transition. - if (!isRedirect(match.error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error('Invariant failed: Expected a redirect error') - } - - invariant() - } - - throw getMatchPromise(match, 'loadPromise') - } - 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 ?? @@ -515,7 +207,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({ /> ) } - throw match.error } @@ -530,74 +221,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 childMatchId: string | undefined + let parentGlobalNotFound: boolean + let parentNotFoundError: unknown + 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 - 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] = useStore( + ;[parentGlobalNotFound, parentNotFoundError] = useStore( parentMatchStore, - (match): OutletMatchSelection => [ - match?.routeId as string | undefined, - match?.globalNotFound ?? false, - ], + (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, undefined) + 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 62d0fc42ae..d9977db272 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -4,12 +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, @@ -48,23 +49,19 @@ 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 = ( - + <> {!(isServer ?? router.isServer) && } - - + + + + ) return router.options.InnerWrap ? ( @@ -76,26 +73,27 @@ export function Matches() { function MatchesInner() { const router = useRouter() - const _isServer = isServer ?? router.isServer - const matchId = _isServer - ? router.stores.firstId.get() - : // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.firstId, (id) => id) - const resetKey = _isServer - ? router.stores.loadedAt.get() - : // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.loadedAt, (loadedAt) => loadedAt) + const matches = + (isServer ?? router.isServer) + ? router.stores.matches.get() + : // eslint-disable-next-line react-hooks/rules-of-hooks + useStore(router.stores.matches, (value) => value) + const match = matches[0] + const routeId = match?.routeId + + useLayoutEffect(() => { + router._rendered!(matches) + }, [matches, router]) - const matchComponent = matchId ? : null + const matchComponent = routeId ? : null return ( - + {router.options.disableGlobalCatchBoundary ? ( matchComponent ) : ( resetKey} - errorComponent={ErrorComponent} + getResetKey={() => match} onCatch={ process.env.NODE_ENV !== 'production' ? (error) => { @@ -143,7 +141,11 @@ 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.location, (location) => location.href) + // eslint-disable-next-line react-hooks/rules-of-hooks + useStore(router.stores.resolvedLocation, (location) => location?.href) + // eslint-disable-next-line react-hooks/rules-of-hooks + useStore(router.stores.status, (status) => status) } return React.useCallback( @@ -255,20 +257,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, @@ -278,13 +268,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 }, @@ -293,8 +283,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, @@ -304,12 +294,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..a1b189d4c6 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 { _getAssetMatches, 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 = _getAssetMatches(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 bf945571a6..acbb60b5bf 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -1,43 +1,96 @@ 'use client' import * as React from 'react' -import { batch, useStore } from '@tanstack/react-store' import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' -import { useLayoutEffect, usePrevious } from './utils' +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({ router, mounted: false }) - - const [isTransitioning, setIsTransitioning] = React.useState(false) - // Track pending state changes - const isLoading = useStore(router.stores.isLoading, (value) => value) - const hasPending = useStore(router.stores.hasPending, (value) => value) - - const previousIsLoading = usePrevious(isLoading) - - const isAnyPending = isLoading || isTransitioning || hasPending - const previousIsAnyPending = usePrevious(isAnyPending) - - const isPagePending = isLoading || hasPending - const previousIsPagePending = usePrevious(isPagePending) - - router.startTransition = (fn: () => void) => { - setIsTransitioning(true) - React.startTransition(() => { - fn() - setIsTransitioning(false) - }) + 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 + + // `` 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) + } + } + if (process.env.NODE_ENV === 'production') { + router.startTransition = (fn, expected, urgent) => + new Promise((resolve) => { + acknowledgement.current?.[1](false) + acknowledgement.current = [expected, resolve] + if (urgent) { + fn() + } else { + React.startTransition(fn) + } + }) + } else { + router.startTransition = (fn, expected, urgent) => + new Promise((resolve, reject) => { + acknowledgement.current?.[1](false) + const next: NonNullable = [ + expected, + resolve, + ] + acknowledgement.current = next + try { + if (urgent) { + fn() + } else { + React.startTransition(fn) + } + } catch (cause) { + if (acknowledgement.current === next) { + acknowledgement.current = undefined + } + reject(cause) + } + }) + ;( + router as typeof router & { _cancelTransition?: () => void } + )._cancelTransition = () => { + const current = acknowledgement.current + acknowledgement.current = undefined + current?.[1](false) + } } - // Subscribe to location changes - // and try to load the new location - React.useEffect(() => { + // Subscribe before canonicalizing so the initial URL has exactly one load. + useLayoutEffect(() => { const unsub = router.history.subscribe(router.load) + if (mounted?.current) { + return unsub + } + if (mounted) { + mounted.current = true + } + + router.updateLatestLocation() + const location = router.latestLocation const nextLocation = router.buildLocation({ - to: router.latestLocation.pathname, + to: location.pathname, search: true, params: true, hash: true, @@ -46,86 +99,44 @@ 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(router.latestLocation.publicHref) !== + trimPathRight(location.publicHref) !== trimPathRight(nextLocation.publicHref) ) { - router.commitLocation({ ...nextLocation, replace: true }) + router.commitLocation({ + ...nextLocation, + replace: true, + ignoreBlocker: true, + }) + return unsub } - return () => { - unsub() - } - }, [router, router.history]) - - // Try to load the initial location - useLayoutEffect(() => { + const resolvedLocation = router.stores.resolvedLocation.get() if ( - // if we are hydrating from SSR, loading is triggered in ssr-client - (typeof window !== 'undefined' && router.ssr) || - (mountLoadForRouter.current.router === router && - mountLoadForRouter.current.mounted) + resolvedLocation?.href === location.href && + resolvedLocation.state.__TSR_key === location.state.__TSR_key ) { - return - } - mountLoadForRouter.current = { router, mounted: true } - - const tryLoad = async () => { - try { - await router.load() - } catch (err) { - console.error(err) - } - } - - tryLoad() - }, [router]) - - useLayoutEffect(() => { - // The router was loading and now it's not - if (previousIsLoading && !isLoading) { - router.emit({ - type: 'onLoad', // When the new URL has committed, when the new matches have been loaded into state.matches - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - } - }, [previousIsLoading, router, isLoading]) - - useLayoutEffect(() => { - // emit onBeforeRouteMount - if (previousIsPagePending && !isPagePending) { - router.emit({ - type: 'onBeforeRouteMount', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) + 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) } - }, [isPagePending, previousIsPagePending, router]) - - useLayoutEffect(() => { - if (previousIsAnyPending && !isAnyPending) { - const changeInfo = getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ) - router.emit({ - type: 'onResolved', - ...changeInfo, - }) - batch(() => { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) - }) - } - }, [isAnyPending, previousIsAnyPending, router]) + return unsub + // `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..d61ca829db 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 { + _getAssetMatches, appendUniqueUserTags, deepEqual, escapeHtml, @@ -22,6 +23,7 @@ function buildTagsFromMatches( matches: Array, assetCrossOrigin?: AssetCrossOriginConfig, ): Array { + matches = _getAssetMatches(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 b4fe8703c5..7c387e0e31 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,42 +25,24 @@ export function lazyRouteComponent< let loadPromise: Promise | undefined let comp: T[TKey] | T['default'] let error: any - let reload: boolean const load = () => { if (!loadPromise) { + error = undefined loadPromise = importer() .then((res) => { - loadPromise = undefined + // Keep browser preload behavior unchanged; SSR can reuse the import. + if (!(isServer ?? typeof window === 'undefined')) { + loadPromise = undefined + } comp = res[exportName ?? 'default'] }) .catch((err) => { + loadPromise = undefined // We don't want an error thrown from preload in this case, because // 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 - } - } - } }) } @@ -67,15 +50,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, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }), ) @@ -177,7 +183,7 @@ export const renderRouterToStream = async ({ const responseStream = transformPipeableStreamWithRouter( router, reactAppPassthrough, - { onAbort: abortPipeable }, + { signal: request.signal, onAbort: abortPipeable }, ) responseAttached = true @@ -199,7 +205,10 @@ export const renderRouterToStream = async ({ return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }), ) diff --git a/packages/react-router/src/ssr/renderRouterToString.tsx b/packages/react-router/src/ssr/renderRouterToString.tsx index e9fe4ec779..5e299cc215 100644 --- a/packages/react-router/src/ssr/renderRouterToString.tsx +++ b/packages/react-router/src/ssr/renderRouterToString.tsx @@ -21,7 +21,10 @@ export const renderRouterToString = async ({ } return new Response(`${html}`, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }) } catch (error) { 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/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index 47e345960d..893cef01bc 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { act, cleanup, @@ -9,11 +9,14 @@ import { } from '@testing-library/react' import { createPortal } from 'react-dom' import ReactDOMServer from 'react-dom/server' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' import { HeadContent, Link, Outlet, + RouterContextProvider, RouterProvider, createBrowserHistory, createMemoryHistory, @@ -57,6 +60,7 @@ afterEach(() => { cleanup() browserHistories.splice(0).forEach((history) => history.destroy()) window.history.replaceState(null, 'root', '/') + delete window.$_TSR }) describe('ssr scripts', () => { @@ -334,6 +338,119 @@ describe('scripts with async/defer attributes', () => { }) describe('ssr HeadContent', () => { + test('renders descendant assets during a data-only hydration handoff', async () => { + const rootRoute = createRootRoute({}) + const dataOnlyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/report', + ssr: 'data-only', + loader: () => 'report', + }) + const childRoute = createRoute({ + getParentRoute: () => dataOnlyRoute, + path: '/details', + loader: () => 'details', + head: () => ({ + meta: [{ name: 'data-only-child', content: 'visible' }], + links: [{ rel: 'preload', href: '/data-only-head-link.js' }], + styles: [ + { + id: 'data-only-route-style', + children: '.data-only-child { color: green }', + }, + ], + scripts: [ + { + id: 'data-only-head-script', + type: 'application/json', + children: '{"source":"head"}', + }, + ], + }), + scripts: () => [ + { + id: 'data-only-body-script', + type: 'application/json', + children: '{"source":"body"}', + }, + ], + }) + const router = createRouter({ + history: createMemoryHistory({ + initialEntries: ['/report/details'], + }), + routeTree: rootRoute.addChildren([ + dataOnlyRoute.addChildren([childRoute]), + ]), + }) + const matches = router.matchRoutes(router.latestLocation) + window.$_TSR = { + router: { + dehydratedData: {}, + manifest: { + routes: { + [childRoute.id]: { + css: ['/data-only-manifest.css'], + preloads: ['/data-only-manifest.js'], + scripts: [ + { + attrs: { + id: 'data-only-manifest-script', + type: 'application/json', + }, + children: '{"source":"manifest"}', + }, + ], + }, + }, + }, + matches: matches.map((match, index) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success', + ssr: index === 1 ? 'data-only' : true, + l: index ? (index === 1 ? 'report' : 'details') : undefined, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + } + + await hydrate(router) + + expect(router.state.matches.map((match) => match.status)).toEqual([ + 'success', + 'pending', + 'success', + ]) + render( + + + + , + ) + + expect( + document.querySelector('meta[name="data-only-child"]'), + ).not.toBeNull() + expect( + document.querySelector('link[href="/data-only-head-link.js"]'), + ).not.toBeNull() + expect(document.querySelector('#data-only-route-style')).not.toBeNull() + expect(document.querySelector('#data-only-head-script')).not.toBeNull() + expect(document.querySelector('#data-only-body-script')).not.toBeNull() + expect( + document.querySelector('link[href="/data-only-manifest.css"]'), + ).not.toBeNull() + expect( + document.querySelector('link[href="/data-only-manifest.js"]'), + ).not.toBeNull() + expect(document.querySelector('#data-only-manifest-script')).not.toBeNull() + }) + test('derives title, dedupes meta, and allows non-loader HeadContent', async () => { const rootRoute = createRootRoute({ loader: () => @@ -836,6 +953,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 new file mode 100644 index 0000000000..09945bb9af --- /dev/null +++ b/packages/react-router/tests/ancestor-loader-child-pending-min.test.tsx @@ -0,0 +1,157 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +test('a child fallback revealed after a fresh ancestor loader keeps its own pendingMinMs', async () => { + vi.useFakeTimers() + + const parentLoader = createControlledPromise() + const childLoader = createControlledPromise() + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => parentLoader, + component: () => , + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Child pending
, + loader: () => childLoader, + component: () =>
Child content
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + }) + + await router.load() + render() + expect(screen.getByText('Index')).toBeInTheDocument() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/parent/child' }) + await vi.advanceTimersByTimeAsync(25) + }) + + expect(screen.queryByText('Child pending')).not.toBeInTheDocument() + + await act(async () => { + parentLoader.resolve() + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByText('Child pending')).toBeInTheDocument() + expect(screen.queryByText('Child content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(25) + childLoader.resolve() + await Promise.resolve() + }) + + // The minimum is measured from the child's first visible frame, not from + // the navigation start or the ancestor's completion. + await act(async () => { + await vi.advanceTimersByTimeAsync(74) + }) + expect(screen.getByText('Child pending')).toBeInTheDocument() + expect(screen.queryByText('Child content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await navigation + }) + 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 906e2334e5..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 @@ -1,5 +1,7 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' +import * as React from 'react' +import { act } from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { createControlledPromise } from '@tanstack/router-core' import { RouterProvider, @@ -7,9 +9,13 @@ import { createRootRoute, createRoute, createRouter, + useRouter, } from '../src' +import type { ErrorComponentProps } from '../src' afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() cleanup() }) @@ -38,3 +44,107 @@ test('delayed component preload reveals pending UI', async () => { 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: + * its fallback must remain visible until the retried component preload is + * ready and pendingMinMs has elapsed. + */ +test('component preload retry remains pending through pendingMinMs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const retryChunk = createControlledPromise() + let preloadAttempt = 0 + let retryInvalidation!: Promise + let retrySettled = false + + const Page = Object.assign( + () =>
Page content
, + { + preload: vi.fn(() => { + preloadAttempt++ + return preloadAttempt === 1 + ? Promise.reject(new Error('initial chunk request failed')) + : retryChunk + }), + }, + ) + + function RetryError({ reset }: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute({}) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RetryError, + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () => ( +
Loading page...
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + expect( + await screen.findByRole('button', { name: 'Retry chunk' }), + ).toBeInTheDocument() + expect(Page.preload).toHaveBeenCalledTimes(1) + + vi.useFakeTimers() + fireEvent.click(screen.getByRole('button', { name: 'Retry chunk' })) + + // Let pendingMs: 0 publish the retry lane. + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(Page.preload).toHaveBeenCalledTimes(2) + expect(screen.getByTestId('page-pending')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Retry chunk' }), + ).not.toBeInTheDocument() + + await act(async () => { + retryChunk.resolve() + await Promise.resolve() + }) + + expect.soft(retrySettled).toBe(false) + expect.soft(screen.queryByTestId('page-pending')).toBeInTheDocument() + expect.soft(screen.queryByTestId('page-content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('page-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await retryInvalidation + }) + + expect(screen.getByTestId('page-content')).toBeInTheDocument() + expect(screen.queryByTestId('page-pending')).not.toBeInTheDocument() +}) diff --git a/packages/react-router/tests/component-preload-retry.test.tsx b/packages/react-router/tests/component-preload-retry.test.tsx new file mode 100644 index 0000000000..2ce819f1bb --- /dev/null +++ b/packages/react-router/tests/component-preload-retry.test.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, + useRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + sessionStorage.clear() +}) + +test('a successful server component download is reused', async () => { + vi.stubGlobal('window', undefined) + const importer = vi.fn().mockResolvedValue({ default: () => null }) + const Page = lazyRouteComponent(importer) + + const preload = Page.preload?.() + await preload + + expect(Page.preload?.()).toBe(preload) + expect(importer).toHaveBeenCalledTimes(1) +}) + +test('a failed component download is retried from the route error UI', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(new Error('component download failed')) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + function RouteError({ reset }: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RouteError, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + const retryButton = await screen.findByRole('button', { name: 'Retry' }) + expect(importer).toHaveBeenCalledTimes(1) + + fireEvent.click(retryButton) + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) + expect( + 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 28757a0b98..b5ee4b6709 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -422,6 +422,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() { diff --git a/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx new file mode 100644 index 0000000000..57d99c8af5 --- /dev/null +++ b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx @@ -0,0 +1,264 @@ +import * as React from 'react' +import { act } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' +import { hydrate } from '../src/ssr/client' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + notFound, +} from '../src' +import type { TsrSsrGlobal } from '../src/ssr/client' + +declare global { + interface Window { + $_TSR?: TsrSsrGlobal + } +} + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + vi.restoreAllMocks() + window.$_TSR = undefined + document.body.innerHTML = '' +}) + +describe('hydrating a server-capped boundary lane', () => { + test('recovers a /404 payload against a missing browser URL', async () => { + function MissingPage() { + return
Missing page
+ } + + const makeRouteTree = () => { + const rootRoute = createRootRoute({ + component: Outlet, + notFoundComponent: MissingPage, + }) + const notFoundRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/404', + component: MissingPage, + }) + return rootRoute.addChildren([notFoundRoute]) + } + + const serverRouter = createRouter({ + routeTree: makeRouteTree(), + history: createMemoryHistory({ initialEntries: ['/404'] }), + }) + serverRouter.isServer = true + await serverRouter.load() + const serverMatches = serverRouter.stores.matches.get() + const serverHtml = renderToString() + expect(serverHtml).toContain('Missing page') + + const clientRouter = createRouter({ + routeTree: makeRouteTree(), + history: createMemoryHistory({ initialEntries: ['/missing'] }), + }) + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: serverMatches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + 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(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(clientRouter) + + const container = document.createElement('div') + container.innerHTML = serverHtml + document.body.appendChild(container) + let root!: ReturnType + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError: () => {}, + }) + testCleanups.push(async () => { + await act(() => root.unmount()) + }) + await Promise.resolve() + }) + + expect(container).toHaveTextContent('Missing page') + expect(clientRouter.state.resolvedLocation?.pathname).toBe('/missing') + expect(clientRouter.state.matches).toHaveLength(1) + expect(clientRouter.state.matches[0]).toMatchObject({ _notFound: true }) + }) + + test.each([ + ['error', 'parent'], + ['notFound', 'parent'], + ['error', 'root'], + ['notFound', 'root'], + ] as const)( + 'keeps the server-rendered %s %s boundary visible', + async (outcome, boundary) => { + const childLoader = vi.fn(() => 'child data') + const boundaryCommits = vi.fn() + + function BoundaryError() { + React.useEffect(() => { + boundaryCommits() + }, []) + return
Boundary error
+ } + + function BoundaryNotFound() { + React.useEffect(() => { + boundaryCommits() + }, []) + return
Boundary not found
+ } + + const makeRouteTree = () => { + const boundaryOptions = { + beforeLoad: () => { + throw outcome === 'notFound' + ? notFound() + : new Error('server route failure') + }, + pendingComponent: () => ( +
Boundary pending
+ ), + errorComponent: BoundaryError, + notFoundComponent: BoundaryNotFound, + } + const rootRoute = createRootRoute({ + component: Outlet, + ...(boundary === 'root' ? boundaryOptions : {}), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: Outlet, + ...(boundary === 'parent' ? boundaryOptions : {}), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + component: () =>
Child
, + }) + return { + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + } + } + + const serverRouter = createRouter({ + ...makeRouteTree(), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + serverRouter.isServer = true + await serverRouter.load() + + const serverMatches = serverRouter.stores.matches.get() + expect(serverMatches).toHaveLength(3) + const serverBoundary = serverMatches[boundary === 'root' ? 0 : 1]! + if (outcome === 'notFound' && boundary === 'root') { + expect(serverBoundary).toMatchObject({ + status: 'success', + _notFound: true, + }) + } else { + expect(serverBoundary.status).toBe(outcome) + } + const serverHtml = renderToString( + , + ) + expect(serverHtml).toContain( + outcome === 'notFound' ? 'Boundary not found' : 'Boundary error', + ) + expect(boundaryCommits).not.toHaveBeenCalled() + + const clientRouter = createRouter({ + ...makeRouteTree(), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: serverMatches + .slice(0, boundary === 'root' ? 1 : 2) + .map((match) => ({ + i: dehydrateSsrMatchId(match.id), + 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(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(clientRouter) + + const container = document.createElement('div') + container.innerHTML = serverHtml + document.body.appendChild(container) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + let root!: ReturnType + await act(async () => { + root = hydrateRoot(container, ) + testCleanups.push(async () => { + await act(() => root.unmount()) + }) + await Promise.resolve() + }) + + // A shorter dehydrated lane means SPA mode only for an actual shell. + // Here it is shorter because the server already rendered a terminal + // boundary, so hydration must not replace that boundary with pending UI. + expect(boundaryCommits).toHaveBeenCalledTimes(1) + expect(container).toHaveTextContent( + outcome === 'notFound' ? 'Boundary not found' : 'Boundary error', + ) + expect(container).not.toHaveTextContent('Boundary pending') + expect(consoleError.mock.calls.flat().join(' ')).not.toMatch( + /hydration|did not match/i, + ) + expect(childLoader).not.toHaveBeenCalled() + }, + ) +}) 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 new file mode 100644 index 0000000000..e05d2ee792 --- /dev/null +++ b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx @@ -0,0 +1,146 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' + +import { + Outlet, + RouterProvider, + createControlledPromise, + createLazyRoute, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(cleanup) + +// https://github.com/TanStack/router/issues/4467 +test('default pending component renders while lazy route options load', async () => { + const rootRoute = createRootRoute({ + component: Outlet, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index page

, + }) + const lazyPageOptions = createLazyRoute('/page')({ + component: () =>

Page

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

Loading page

, + }) + let navigationPromise: Promise | undefined + + try { + render() + + expect( + await screen.findByRole('heading', { name: 'Index page' }), + ).toBeInTheDocument() + + act(() => { + navigationPromise = router.navigate({ to: '/page' }) + }) + + expect(await screen.findByRole('status')).toHaveTextContent('Loading page') + expect( + screen.queryByRole('heading', { name: 'Page' }), + ).not.toBeInTheDocument() + expect(lazyOptions.status).toBe('pending') + expect(loadLazyOptions).toHaveBeenCalledTimes(1) + + await act(async () => { + lazyOptions.resolve(lazyPageOptions) + await navigationPromise + }) + + expect(screen.getByRole('heading', { name: 'Page' })).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(loadLazyOptions).toHaveBeenCalledTimes(1) + } finally { + await act(async () => { + if (lazyOptions.status === 'pending') { + lazyOptions.resolve(lazyPageOptions) + } + await navigationPromise + }) + } +}) + +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 new file mode 100644 index 0000000000..3bffbed1ac --- /dev/null +++ b/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx @@ -0,0 +1,129 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query' +import { afterEach, expect, test, vi } from 'vitest' +import { + Link, + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(cleanup) + +// https://github.com/TanStack/router/issues/4476 +test('#4476: pending navigation keeps the query observer mounted and its fetchQuery signal alive', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0 } }, + }) + const queryGate = createControlledPromise() + const queryKey = ['issue-4476'] as const + const routeError = vi.fn() + const pageTwoBeforeLoad = vi.fn() + const pendingComponentRendered = vi.fn() + let querySignal: AbortSignal | undefined + + const rootRoute = createRootRoute({ + component: () => ( + <> + + Page two + + + + ), + }) + function PageOneComponent() { + const query = useQuery({ + queryKey, + queryFn: () => Promise.resolve(3), + }) + return
Page one: {query.data}
+ } + const pageOneRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: PageOneComponent, + }) + const pageTwoRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page-two', + beforeLoad: async ({ preload }) => { + pageTwoBeforeLoad(preload) + if (preload) { + return + } + const data = await queryClient.fetchQuery({ + queryKey, + queryFn: ({ signal }) => { + querySignal = signal + return queryGate + }, + }) + return { data } + }, + errorComponent: ({ error }) => { + routeError(error) + return
{error.name}
+ }, + component: () => { + const { data } = pageTwoRoute.useRouteContext() + return
Page two: {data}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageOneRoute, pageTwoRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPreload: 'intent', + defaultPreloadDelay: 0, + defaultPreloadStaleTime: 0, + defaultPendingMs: 0, + defaultPendingComponent: () => { + pendingComponentRendered() + return
Loading page two
+ }, + }) + + try { + render( + + + , + ) + expect(await screen.findByText('Page one: 3')).toBeInTheDocument() + + const link = screen.getByRole('link', { name: 'Page two' }) + fireEvent.mouseOver(link) + await waitFor(() => expect(pageTwoBeforeLoad).toHaveBeenCalledWith(true)) + fireEvent.click(link) + + await waitFor(() => expect(querySignal).toBeDefined()) + expect(screen.getByTestId('page-one')).toBeInTheDocument() + expect(screen.getByTestId('page-two-pending')).toBeInTheDocument() + expect(querySignal?.aborted).toBe(false) + queryGate.resolve(10) + + expect(await screen.findByText('Page two: 10')).toBeInTheDocument() + expect(routeError).not.toHaveBeenCalled() + expect(screen.queryByTestId('page-one')).not.toBeInTheDocument() + expect(screen.queryByTestId('page-two-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('page-two-error')).not.toBeInTheDocument() + expect(router.state.location.pathname).toBe('/page-two') + } finally { + queryGate.resolve(10) + queryClient.clear() + } +}) diff --git a/packages/react-router/tests/issue-4759-pending-frame.test.tsx b/packages/react-router/tests/issue-4759-pending-frame.test.tsx new file mode 100644 index 0000000000..5903f2960b --- /dev/null +++ b/packages/react-router/tests/issue-4759-pending-frame.test.tsx @@ -0,0 +1,92 @@ +import * as React from 'react' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { + RouterProvider, + createBrowserHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { RouterHistory } from '../src' + +let history: RouterHistory + +beforeEach(() => { + history = createBrowserHistory() + expect(window.location.pathname).toBe('/') +}) + +afterEach(() => { + history.destroy() + window.history.replaceState(null, 'root', '/') + vi.resetAllMocks() + cleanup() +}) + +// Repro for https://github.com/TanStack/router/issues/4759 +// +// JSDOM cannot observe browser paints. This unit reduction verifies the event +// ordering behind the issue: pending DOM must be published before the first +// macrotask when pendingMs is 0. +describe('issue #4759: pendingMs 0 publishes pending DOM before a macrotask', () => { + test('pending fallback is committed on mount without waiting for a macrotask', async () => { + vi.useFakeTimers() + let resolveLoader!: (value: string) => void + const loaderPromise = new Promise((resolve) => { + resolveLoader = resolve + }) + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => loaderPromise, + component: () =>
loaded
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + let resolveRendered!: () => void + const rendered = new Promise((resolve) => { + resolveRendered = resolve + }) + const unsubscribe = router.subscribe('onRendered', (event) => { + if (event.toLocation.pathname === '/') { + resolveRendered() + } + }) + + try { + render( +
+ ( +
pending...
+ )} + /> +
, + ) + + // Fake timers keep the first macrotask frozen. An implementation that + // publishes pending state with setTimeout cannot satisfy this assertion. + await act(async () => {}) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + // Sanity: the load still completes normally afterwards. + resolveLoader('done') + await act(() => rendered) + expect(screen.getByTestId('loaded')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + } finally { + unsubscribe() + resolveLoader('done') + vi.useRealTimers() + } + }) +}) diff --git a/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx b/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx new file mode 100644 index 0000000000..22440fe119 --- /dev/null +++ b/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx @@ -0,0 +1,89 @@ +import * as React from 'react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + vi.restoreAllMocks() + cleanup() +}) + +// https://github.com/TanStack/router/issues/6107 +test('#6107: lazy chunk hover failure is non-fatal and navigation renders defaultErrorComponent', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const chunkError = new TypeError( + 'Failed to fetch dynamically imported module: /assets/posts.lazy.js', + ) + const defaultErrorRendered = vi.fn() + let lazyCalls = 0 + + const rootRoute = createRootRoute({ + component: () => ( + <> + + Posts + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }).lazy(() => { + lazyCalls++ + return Promise.reject(chunkError) + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPreloadDelay: 0, + defaultErrorComponent: ({ error }) => { + defaultErrorRendered(error) + return
{error.message}
+ }, + }) + const preloadRoute = vi.spyOn(router, 'preloadRoute') + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + + const link = screen.getByRole('link', { name: 'Posts' }) + fireEvent.mouseEnter(link) + await waitFor(() => expect(preloadRoute).toHaveBeenCalledTimes(1)) + await preloadRoute.mock.results[0]!.value + expect(lazyCalls).toBeGreaterThanOrEqual(1) + expect(screen.getByText('Index')).toBeInTheDocument() + expect(screen.queryByTestId('default-error')).not.toBeInTheDocument() + expect(defaultErrorRendered).not.toHaveBeenCalled() + + const callsAfterPreload = lazyCalls + fireEvent.click(link) + expect(await screen.findByTestId('default-error')).toHaveTextContent( + chunkError.message, + ) + expect(lazyCalls).toBeGreaterThan(callsAfterPreload) + expect(defaultErrorRendered).toHaveBeenCalledWith(chunkError) + expect(screen.queryByText('Index')).not.toBeInTheDocument() + expect(router.state.location.pathname).toBe('/posts') + expect(router.state.status).toBe('idle') +}) diff --git a/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx b/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx new file mode 100644 index 0000000000..811447a89a --- /dev/null +++ b/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx @@ -0,0 +1,171 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useLocation, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('#6371: initial search defaults produce one live canonical loader', async () => { + const loaderGate = createControlledPromise() + const canonicalLocation = createControlledPromise() + const abortedLoaderData = 'discarded aborted loader' + const loaderSignals: Array = [] + const loaderLocations: Array = [] + const errorComponentRendered = vi.fn() + const loader = vi.fn( + ({ + abortController, + location, + }: { + abortController: AbortController + location: { href: string } + }) => { + const signal = abortController.signal + loaderSignals.push(signal) + loaderLocations.push(location.href) + + return new Promise((resolve, reject) => { + const onAbort = () => { + resolve(abortedLoaderData) + } + + if (signal.aborted) { + onAbort() + return + } + + signal.addEventListener('abort', onAbort, { once: true }) + loaderGate.then((data) => { + signal.removeEventListener('abort', onAbort) + resolve(data) + }, reject) + }) + }, + ) + + const PendingLocation = () => { + const href = useLocation({ select: (location) => location.href }) + return
{href}
+ } + + const rootRoute = createRootRoute({ + component: () => , + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (search: Record) => ({ + page: typeof search.page === 'number' ? search.page : 1, + }), + loader, + component: () => ( +
{aboutRoute.useLoaderData()}
+ ), + errorComponent: ({ error }) => { + errorComponentRendered(error) + return
{error.message}
+ }, + }) + const history = createMemoryHistory({ initialEntries: ['/about'] }) + const unsubscribeHistory = history.subscribe(() => { + if (history.location.href === '/about?page=1') { + canonicalLocation.resolve() + } + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([aboutRoute]), + history, + defaultPendingMs: 0, + defaultPendingMinMs: 0, + defaultPendingComponent: PendingLocation, + }) + + try { + render() + + await act(async () => { + await canonicalLocation + }) + + expect(loader).toHaveBeenCalledTimes(1) + expect(loaderLocations).toEqual(['/about?page=1']) + expect(loaderSignals).toHaveLength(1) + expect(loaderSignals[0]?.aborted).toBe(false) + + expect( + await screen.findByText('/about?page=1', { + selector: '[data-testid="pending-location"]', + }), + ).toBeInTheDocument() + + await act(() => { + loaderGate.resolve('about data') + }) + + expect(await screen.findByTestId('about-data')).toHaveTextContent( + 'about data', + ) + expect(loader).toHaveBeenCalledTimes(1) + expect(loaderSignals[0]?.aborted).toBe(false) + expect(errorComponentRendered).not.toHaveBeenCalled() + expect(screen.queryByTestId('about-error')).not.toBeInTheDocument() + } finally { + unsubscribeHistory() + await act(() => { + canonicalLocation.resolve() + loaderGate.resolve('about data') + }) + } +}) + +test('initial canonicalization bypasses existing navigation blockers', async () => { + const loader = vi.fn( + ({ location }: { location: { href: string } }) => location.href, + ) + const rootRoute = createRootRoute({ + component: () => , + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (search: Record) => ({ + page: typeof search.page === 'number' ? search.page : 1, + }), + loader, + component: () =>
{aboutRoute.useLoaderData()}
, + }) + const history = createMemoryHistory({ initialEntries: ['/about'] }) + const blockerFn = vi.fn(() => true) + const unblock = history.block({ + blockerFn, + enableBeforeUnload: false, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([aboutRoute]), + history, + }) + + try { + render() + + expect(await screen.findByText('/about?page=1')).toBeInTheDocument() + expect(loader).toHaveBeenCalledTimes(1) + expect(history.location.href).toBe('/about?page=1') + expect(router.latestLocation.href).toBe('/about?page=1') + expect(router.state.location.href).toBe('/about?page=1') + expect(router.state.resolvedLocation?.href).toBe('/about?page=1') + expect(blockerFn).not.toHaveBeenCalled() + } finally { + unblock() + } +}) diff --git a/packages/react-router/tests/issue-7051-force-pending-suspense.test.tsx b/packages/react-router/tests/issue-7051-force-pending-suspense.test.tsx new file mode 100644 index 0000000000..5640cb7477 --- /dev/null +++ b/packages/react-router/tests/issue-7051-force-pending-suspense.test.tsx @@ -0,0 +1,195 @@ +import { act } from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + vi.clearAllMocks() + cleanup() +}) + +// Ported from PR #7051. A forced-pending reload must keep showing its pending +// fallback until fresh content commits instead of exposing the error boundary. +test('invalidate({ forcePending: true }) keeps rendering the pending fallback instead of the error boundary', async () => { + const history = createMemoryHistory({ + initialEntries: ['/force-pending'], + }) + const errorComponentRendered = vi.fn() + let shouldSuspendReload = false + const reloadGate = createControlledPromise() + + const rootRoute = createRootRoute({ + component: () => , + }) + + const forcePendingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/force-pending', + pendingMs: 0, + pendingMinMs: 10, + loader: async () => { + if (shouldSuspendReload) { + await reloadGate + } + + return 'done' + }, + component: () => ( +
+ {forcePendingRoute.useLoaderData()} +
+ ), + pendingComponent: () => ( +
Pending...
+ ), + errorComponent: ({ error }) => { + errorComponentRendered(error) + return
{String(error)}
+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([forcePendingRoute]), + history, + }) + + render() + + await act(() => router.load()) + expect(await screen.findByTestId('force-pending-route')).toHaveTextContent( + 'done', + ) + + shouldSuspendReload = true + let invalidation!: Promise + act(() => { + invalidation = router.invalidate({ forcePending: true }) + }) + + expect( + await screen.findByTestId('force-pending-fallback'), + ).toBeInTheDocument() + expect(errorComponentRendered).not.toHaveBeenCalled() + expect(screen.queryByTestId('force-pending-error')).not.toBeInTheDocument() + + act(() => { + reloadGate.resolve() + }) + + await act(() => invalidation) + expect(await screen.findByTestId('force-pending-route')).toHaveTextContent( + 'done', + ) + expect(screen.queryByTestId('force-pending-fallback')).not.toBeInTheDocument() + expect(screen.queryByTestId('force-pending-error')).not.toBeInTheDocument() + expect(errorComponentRendered).not.toHaveBeenCalled() + expect(router.state.location.pathname).toBe('/force-pending') + expect(router.state.status).toBe('idle') +}) + +test('regular navigation keeps the current pending fallback while its loader is aborted', async () => { + const firstLoaderAborted = createControlledPromise() + const secondLoaderStarted = createControlledPromise() + const secondLoaderGate = createControlledPromise() + const firstErrorComponentRendered = vi.fn() + let firstSignal: AbortSignal | undefined + + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home page
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + pendingMs: 0, + loader: async ({ abortController }) => { + firstSignal = abortController.signal + await new Promise((_resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => { + firstLoaderAborted.resolve() + reject(new DOMException('Aborted', 'AbortError')) + }, + { once: true }, + ) + }) + return 'first' + }, + component: () => ( +
{firstRoute.useLoaderData()}
+ ), + pendingComponent: () => ( +
Pending first route
+ ), + errorComponent: ({ error }) => { + firstErrorComponentRendered(error) + return
{String(error)}
+ }, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: async () => { + secondLoaderStarted.resolve() + await secondLoaderGate + return 'second' + }, + component: () => ( +
{secondRoute.useLoaderData()}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPreload: false, + }) + + render() + expect(await screen.findByTestId('home-page')).toBeInTheDocument() + + act(() => { + void router.navigate({ to: '/first' }) + }) + expect(await screen.findByTestId('first-pending')).toBeInTheDocument() + expect(firstSignal?.aborted).toBe(false) + + let secondNavigation!: Promise + act(() => { + secondNavigation = router.navigate({ to: '/second' }) + }) + await act(async () => { + await Promise.all([firstLoaderAborted, secondLoaderStarted]) + }) + + expect(firstSignal?.aborted).toBe(true) + expect(screen.getByTestId('first-pending')).toBeInTheDocument() + expect(screen.queryByTestId('first-error')).not.toBeInTheDocument() + expect(firstErrorComponentRendered).not.toHaveBeenCalled() + expect(router.state.location.pathname).toBe('/second') + expect(router.state.status).toBe('pending') + + act(() => { + secondLoaderGate.resolve() + }) + await act(() => secondNavigation) + + expect(await screen.findByTestId('second-page')).toHaveTextContent('second') + expect(screen.queryByTestId('first-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('first-error')).not.toBeInTheDocument() + expect(firstErrorComponentRendered).not.toHaveBeenCalled() + expect(router.state.location.pathname).toBe('/second') + expect(router.state.status).toBe('idle') +}) diff --git a/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx b/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx new file mode 100644 index 0000000000..56aed7b375 --- /dev/null +++ b/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx @@ -0,0 +1,100 @@ +import { createPortal } from 'react-dom' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + HeadContent, + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + document.head.innerHTML = '' +}) + +// https://github.com/TanStack/router/issues/7635 +test('#7635: a parent beforeLoad error replaces the previous child title', async () => { + const appError = new Error('App beforeLoad failed') + const appErrorRendered = vi.fn() + const childHead = vi.fn(() => ({ + meta: [{ title: 'Child success title' }], + })) + + const rootRoute = createRootRoute({ + component: () => ( + <> + {createPortal(, document.head)} + + Fail app load + + + + ), + }) + const appRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_app', + validateSearch: (search: Record) => ({ + fail: search.fail === true || search.fail === 'true', + }), + beforeLoad: ({ search }) => { + if (search.fail) { + throw appError + } + }, + head: ({ match }) => ({ + meta: [ + { + title: match.error ? 'App error title' : 'App success title', + }, + ], + }), + component: Outlet, + errorComponent: ({ error }) => { + appErrorRendered(error) + return
{error.message}
+ }, + }) + const childRoute = createRoute({ + getParentRoute: () => appRoute, + path: '/child', + head: childHead, + component: () =>
Child content
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([appRoute.addChildren([childRoute])]), + history: createMemoryHistory({ + initialEntries: ['/child?fail=false'], + }), + }) + + render() + + expect(await screen.findByTestId('child-content')).toBeInTheDocument() + await waitFor(() => expect(document.title).toBe('Child success title')) + expect(childHead).toHaveBeenCalled() + childHead.mockClear() + + fireEvent.click(screen.getByRole('link', { name: 'Fail app load' })) + + expect(await screen.findByTestId('app-error')).toHaveTextContent( + appError.message, + ) + expect(appErrorRendered).toHaveBeenCalledWith(appError) + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument() + await waitFor(() => expect(document.title).toBe('App error title')) + expect(childHead).not.toHaveBeenCalled() + expect(router.state.location.href).toBe('/child?fail=true') + expect(router.state.status).toBe('idle') +}) diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index 3b5790ef41..6859b963e2 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -729,20 +729,32 @@ test('does not show pending UI when loaders finish before their pending delays', expect(fooPendingComponentOnMountMock).not.toHaveBeenCalled() }) -test('throw abortError from loader upon initial load with basepath', async () => { - window.history.replaceState(null, 'root', '/app') +// https://github.com/TanStack/router/pull/7673 +test('#7673: a spontaneous loader AbortError renders the boundary without executing the route component', async () => { + history.replace('/app') + history.flush() const rootRoute = createRootRoute({}) + const abortError = new DOMException('Aborted', 'AbortError') + const routeComponentRendered = vi.fn() + const renderedError = vi.fn() + let routeSignal: AbortSignal | undefined const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - loader: async () => { - return Promise.reject(new DOMException('Aborted', 'AbortError')) + loader: async ({ abortController }): Promise<{ value: string }> => { + routeSignal = abortController.signal + return Promise.reject(abortError) + }, + component: () => { + routeComponentRendered() + const data = indexRoute.useLoaderData() + return
{data.value}
+ }, + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
}, - component: () =>
Index route content
, - errorComponent: () => ( -
indexErrorComponent
- ), }) const routeTree = rootRoute.addChildren([indexRoute]) @@ -750,10 +762,18 @@ test('throw abortError from loader upon initial load with basepath', async () => render() - const indexElement = await screen.findByText('Index route content') - expect(indexElement).toBeInTheDocument() - expect(screen.queryByTestId('index-error')).not.toBeInTheDocument() - expect(window.location.pathname.startsWith('/app')).toBe(true) + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() + expect(routeComponentRendered).not.toHaveBeenCalled() + expect(renderedError).toHaveBeenCalledWith(abortError) + expect(routeSignal?.aborted).toBe(false) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) + expect(window.location.pathname).toBe('/app') }) test('navigating away from a pending route aborts its loader', async () => { diff --git a/packages/react-router/tests/not-found.test.tsx b/packages/react-router/tests/not-found.test.tsx index bc6a1b9df1..f27057c7ab 100644 --- a/packages/react-router/tests/not-found.test.tsx +++ b/packages/react-router/tests/not-found.test.tsx @@ -7,12 +7,18 @@ import { 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 @@ -66,6 +72,141 @@ test('navigating to an actively preloaded missing URL renders the global not-fou 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/on-rendered-same-href-state.test.tsx b/packages/react-router/tests/on-rendered-same-href-state.test.tsx new file mode 100644 index 0000000000..76e7a0cb9d --- /dev/null +++ b/packages/react-router/tests/on-rendered-same-href-state.test.tsx @@ -0,0 +1,66 @@ +import { act } from 'react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void> = [] + +afterEach(() => { + while (testCleanups.length) { + testCleanups.pop()!() + } + cleanup() +}) + +test('onRendered fires for a same-href navigation with a new history key', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.href).toBe('/') + }) + const initialHistoryKey = router.state.resolvedLocation?.state.__TSR_key + expect(initialHistoryKey).toBeDefined() + + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + testCleanups.push(unsubscribe) + await act(() => + router.navigate({ + to: '/', + state: { sameHrefState: true } as any, + }), + ) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + const event = onRendered.mock.calls[0]![0] + expect(event.fromLocation?.state.sameHrefState).toBeUndefined() + expect(event.toLocation.state.sameHrefState).toBe(true) + expect(event.fromLocation?.href).toBe('/') + expect(event.toLocation.href).toBe('/') + expect(event.hrefChanged).toBe(false) + expect(event.fromLocation?.state.__TSR_key).toBe(initialHistoryKey) + expect(event.toLocation.state.__TSR_key).toBeDefined() + expect(event.toLocation.state.__TSR_key).not.toBe(initialHistoryKey) + expect(router.state.resolvedLocation?.state.__TSR_key).toBe( + event.toLocation.state.__TSR_key, + ) +}) diff --git a/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx new file mode 100644 index 0000000000..0f5ca3703d --- /dev/null +++ b/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx @@ -0,0 +1,138 @@ +import * as React from 'react' +import { act } from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { createControlledPromise } from '@tanstack/router-core' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { AnyRouter } from '../src' + +afterEach(() => { + vi.useRealTimers() + cleanup() +}) + +test.each(['child', 'root'] as const)( + 'a mounted %s pending fallback follows an overlapping load generation', + async (routeLevel) => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const routeOptions = { + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Loading...
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => ({ generation })) : { generation } + }, + } + + const makeRouter = (): AnyRouter => { + if (routeLevel === 'root') { + const rootRoute = createRootRoute({ + ...routeOptions, + component: () => ( +
+ Generation {rootRoute.useLoaderData().generation} +
+ ), + }) + return createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + } + + const rootRoute = createRootRoute({ + component: () => , + }) + const pageRoute = createRoute({ + ...routeOptions, + getParentRoute: () => rootRoute, + path: '/page', + component: () => ( +
+ Generation {pageRoute.useLoaderData().generation} +
+ ), + }) + return createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + } + const router = makeRouter() + + render() + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + + let firstInvalidation!: Promise + await act(async () => { + firstInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(2) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(25) + }) + + let secondInvalidation!: Promise + await act(async () => { + secondInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(3) + + await act(async () => { + firstReload.resolve() + await Promise.resolve() + }) + + // Completing the superseded generation cannot release the currently + // mounted fallback or restore its stale loader data. + expect(screen.getByTestId('pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + + let secondSettled = false + void secondInvalidation.then(() => { + secondSettled = true + }) + await act(async () => { + secondReload.resolve() + await Promise.resolve() + }) + + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(74) + }) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await Promise.all([firstInvalidation, secondInvalidation]) + }) + + expect(screen.getByText('Generation 3')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + }, +) diff --git a/packages/react-router/tests/preloaded-mount-resolution.test.tsx b/packages/react-router/tests/preloaded-mount-resolution.test.tsx new file mode 100644 index 0000000000..b7527beb3e --- /dev/null +++ b/packages/react-router/tests/preloaded-mount-resolution.test.tsx @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createControlledPromise, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +/** + * A load that settles before RouterProvider mounts (or completes within the + * mount effect's batch) gives the Transitioner no isLoading flip to observe. + * The router status must still resolve to 'idle' with resolvedLocation set, + * and onRendered must fire — otherwise consumers waiting on those signals + * deadlock forever (this hung the memory-client benchmark for 6 hours). + * + * Uses a raw createRoot without the act() test environment: act-driven + * flushing re-renders between the isLoading toggles and masks the race. + * + * Note: vitest's jsdom scheduler still observes the flip more often than the + * benchmark's environment, so this test pins the CONTRACT; the deterministic + * regression guard for the original hang is the memory-client:react + * benchmark (benchmarks/memory/client/scenarios/mount-unmount), which CI runs. + */ + +let prevActEnv: unknown + +beforeEach(() => { + prevActEnv = (globalThis as any).IS_REACT_ACT_ENVIRONMENT + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false +}) + +afterEach(() => { + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = prevActEnv +}) + +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: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => 'home data', + component: Home, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + let resolveRendered!: () => void + const rendered = new Promise((resolve) => { + resolveRendered = resolve + }) + const onRendered = vi.fn(() => { + lifecycle.push('rendered') + resolveRendered() + }) + const onResolved = vi.fn() + const onLoad = vi.fn() + const unsubscribers = [ + router.subscribe('onRendered', onRendered), + router.subscribe('onResolved', onResolved), + router.subscribe('onLoad', onLoad), + ] + const unsubscribe = () => unsubscribers.forEach((fn) => fn()) + const container = document.createElement('div') + document.body.appendChild(container) + const reactRoot = createRoot(container) + let renderedTimeout: ReturnType | undefined + + try { + // Load fully settles before the provider mounts — the exact shape of the + // memory benchmark's mount/unmount cycle. + await router.load() + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/') + expect(onLoad).toHaveBeenCalledTimes(1) + expect(onResolved).toHaveBeenCalledTimes(1) + expect(onRendered).not.toHaveBeenCalled() + + reactRoot.render() + await Promise.race([ + rendered, + new Promise((_, reject) => { + renderedTimeout = setTimeout(() => { + reject(new Error('Timed out waiting for onRendered')) + }, 2000) + }), + ]) + + 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 { + clearTimeout(renderedTimeout) + unsubscribe() + reactRoot.unmount() + 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 index aa744b87f6..862fffc93f 100644 --- a/packages/react-router/tests/public-presentation-lane-contract.test.tsx +++ b/packages/react-router/tests/public-presentation-lane-contract.test.tsx @@ -1,5 +1,5 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createControlledPromise } from '@tanstack/router-core' import { Outlet, @@ -12,6 +12,7 @@ import { afterEach(() => { cleanup() + vi.useRealTimers() }) describe('public presentation lane contracts', () => { @@ -77,6 +78,116 @@ describe('public presentation lane contracts', () => { 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({ diff --git a/packages/react-router/tests/issue-7457-redirect-chain-first-load.test.tsx b/packages/react-router/tests/redirect-chain-first-load.test.tsx similarity index 96% rename from packages/react-router/tests/issue-7457-redirect-chain-first-load.test.tsx rename to packages/react-router/tests/redirect-chain-first-load.test.tsx index 3583190d79..b3f4618a28 100644 --- a/packages/react-router/tests/issue-7457-redirect-chain-first-load.test.tsx +++ b/packages/react-router/tests/redirect-chain-first-load.test.tsx @@ -18,12 +18,13 @@ afterEach(() => { cleanup() }) -// https://github.com/TanStack/router/issues/7457 // A chain of async layout beforeLoad redirects during the very first load // (search-stripping self-redirect -> layout redirect -> child redirect) used // to leave a match rendering with a nulled loadPromise, crashing // MatchInnerImpl with an uncaught `undefined`. Pending UI is enabled for // every match (defaultPendingMs: 0) to force pending publication mid-chain. +// The production auto-code-splitting reproduction for issue #7457 lives in +// e2e/react-router/issue-7457. test('chained layout beforeLoad redirects on first load render the final target without throwing from MatchInner', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index a0d7906ac3..0c254fb098 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -16,7 +16,6 @@ import { Outlet, RouterProvider, createBrowserHistory, - createMemoryHistory, createRootRoute, createRoute, createRouter, @@ -76,6 +75,85 @@ describe('redirect', () => { expect(router.state.status).toBe('idle') }) + test('renders a root error after too many same-location redirects', async () => { + const loader = vi.fn(() => { + throw redirect({ to: '/' }) + }) + const rootRoute = createRootRoute({ + errorComponent: ({ error }) => ( +
Root: {error.message}
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader, + errorComponent: ({ error }) => ( +
Index: {error.message}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + expect(await screen.findByTestId('root-error')).toHaveTextContent( + 'Root: Too many redirects', + ) + expect(screen.queryByTestId('index-error')).not.toBeInTheDocument() + expect(window.location.pathname).toBe('/') + expect(loader).toHaveBeenCalledTimes(21) + expect(router.state.status).toBe('idle') + }) + + test('renders a root error after too many alternating redirects', async () => { + const indexLoader = vi.fn(() => { + throw redirect({ to: '/other' }) + }) + const otherLoader = vi.fn(() => { + throw redirect({ to: '/' }) + }) + const rootRoute = createRootRoute({ + errorComponent: ({ error }) => ( +
Root: {error.message}
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: indexLoader, + errorComponent: ({ error }) => ( +
Index: {error.message}
+ ), + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: otherLoader, + errorComponent: ({ error }) => ( +
Other: {error.message}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, otherRoute]), + history, + }) + + render() + + expect(await screen.findByTestId('root-error')).toHaveTextContent( + 'Root: Too many redirects', + ) + expect(screen.queryByTestId('index-error')).not.toBeInTheDocument() + expect(screen.queryByTestId('other-error')).not.toBeInTheDocument() + expect(window.location.pathname).toBe('/') + expect(indexLoader).toHaveBeenCalledTimes(11) + expect(otherLoader).toHaveBeenCalledTimes(10) + expect(router.state.status).toBe('idle') + }) + test('when `redirect` is thrown in `beforeLoad`', async () => { const nestedLoaderMock = vi.fn() const nestedFooLoaderMock = vi.fn() @@ -351,116 +429,4 @@ describe('redirect', () => { expect(window.location.pathname).toBe('/final') }) }) - - describe('SSR', () => { - test('when `redirect` is thrown in `beforeLoad`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - beforeLoad: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return 'About' - }, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - history: createMemoryHistory({ - initialEntries: ['/'], - }), - }) - - await router.load() - - expect(router.state.redirect).toBeDefined() - expect(router.state.redirect).toBeInstanceOf(Response) - const redirectResponse = router.state.redirect! - - expect(redirectResponse.options).toEqual({ - _fromLocation: expect.objectContaining({ - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - }), - to: '/about', - href: '/about', - statusCode: 307, - }) - }) - - test('when `redirect` is thrown in `loader`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - loader: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return 'About' - }, - }) - - const router = createRouter({ - history: createMemoryHistory({ - initialEntries: ['/'], - }), - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - }) - - await router.load() - - const currentRedirect = router.state.redirect - - expect(currentRedirect).toBeDefined() - expect(currentRedirect).toBeInstanceOf(Response) - const redirectResponse = currentRedirect! - expect(redirectResponse.status).toEqual(307) - expect(redirectResponse.headers.get('Location')).toEqual('/about') - expect(redirectResponse.options).toEqual({ - _fromLocation: { - external: false, - hash: '', - href: '/', - publicHref: '/', - pathname: '/', - search: {}, - searchStr: '', - state: { - __TSR_index: 0, - __TSR_key: redirectResponse.options._fromLocation!.state.__TSR_key, - key: redirectResponse.options._fromLocation!.state.key, - }, - }, - href: '/about', - to: '/about', - statusCode: 307, - }) - }) - }) }) diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index 116d8a259f..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( diff --git a/packages/react-router/tests/root-pending-min.test.tsx b/packages/react-router/tests/root-pending-min.test.tsx new file mode 100644 index 0000000000..0216ae4fec --- /dev/null +++ b/packages/react-router/tests/root-pending-min.test.tsx @@ -0,0 +1,186 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, expect, test, vi } from 'vitest' +import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' +import { hydrate } from '../src/ssr/client' +import { + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() + vi.useRealTimers() + delete window.$_TSR +}) + +test('a post-hydration root reload keeps its fallback through pendingMinMs', async () => { + const reloadGate = createControlledPromise() + const rootLoader = vi.fn(() => reloadGate.then(() => ({ generation: 2 }))) + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: rootLoader, + component: () => ( +
+ Generation {rootRoute.useLoaderData().generation} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute, + 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. + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: [ + { + i: dehydrateSsrMatchId(rootMatch.id), + s: 'success', + ssr: true, + l: { generation: 1 }, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + expect(router.ssr).toBeDefined() + + render() + expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 1') + expect(rootLoader).not.toHaveBeenCalled() + + vi.useFakeTimers() + + let invalidation!: Promise + await act(async () => { + invalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.getByTestId('root-content')).not.toBeVisible() + + await act(async () => { + reloadGate.resolve() + await Promise.resolve() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await invalidation + }) + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 2') +}) + +test('root route hydration preserves component state across its Suspense boundary', async () => { + const mounts = vi.fn() + const unmounts = vi.fn() + const initializers = vi.fn(() => 'preserved') + + const rootRoute = createRootRoute({ + pendingComponent: () =>
Root pending
, + component: function RootComponent() { + const [value] = React.useState(initializers) + React.useEffect(() => { + mounts() + return unmounts + }, []) + return
{value}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + + // Model the server render and the client router produced by hydrate(). The + // outer root boundary is intentionally absent from both trees, while the + // route's own pending boundary is present in both. + router.ssr = { manifest: { routes: {} } } + router.isServer = true + const html = renderToString() + router.isServer = false + expect(html).toContain('') + expect(html).toContain('preserved') + + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + let root!: ReturnType + await act(async () => { + root = hydrateRoot(container, ) + testCleanups.push(async () => { + await act(() => root.unmount()) + consoleError.mockRestore() + container.remove() + }) + await Promise.resolve() + }) + + expect(container).toHaveTextContent('preserved') + // One initializer belongs to the server render and one to client + // hydration. The stable boundary must preserve that hydrated client + // instance instead of creating a third one. + expect(initializers).toHaveBeenCalledTimes(2) + expect(mounts).toHaveBeenCalledTimes(1) + expect(unmounts).not.toHaveBeenCalled() + expect(consoleError).not.toHaveBeenCalled() +}) + +test('server rendering uses the root pending boundary for route component suspension', async () => { + const gate = createControlledPromise() + const rootRoute = createRootRoute({ + pendingComponent: () =>
Server root pending
, + component: () => { + throw gate + }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + router.isServer = true + await router.load() + + const html = renderToString() + + // renderToString cannot wait for Suspense, but the root route's stable + // boundary contains the suspension and emits its fallback. Streaming SSR + // can wait for the same boundary instead. + expect(html).toContain('Server root pending') +}) diff --git a/packages/react-router/tests/router-client-stream-cleanup.test.tsx b/packages/react-router/tests/router-client-stream-cleanup.test.tsx new file mode 100644 index 0000000000..865b7cd732 --- /dev/null +++ b/packages/react-router/tests/router-client-stream-cleanup.test.tsx @@ -0,0 +1,55 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { Component } from 'react' +import { createMemoryHistory } from '@tanstack/history' +import { RouterClient } from '../src/ssr/RouterClient' +import { createRootRoute, createRouter } from '../src' +import type { ReactNode } from 'react' + +const hydrate = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/router-core/ssr/client', () => ({ hydrate })) + +class ErrorBoundary extends Component< + { children: ReactNode }, + { error?: Error } +> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + render() { + return this.state.error ? this.state.error.message : this.props.children + } +} + +afterEach(() => { + cleanup() + delete window.$_TSR + hydrate.mockReset() +}) + +test('RouterClient signals streaming cleanup without hiding a hydration failure', async () => { + const error = new Error('hydration failed') + hydrate.mockRejectedValue(error) + const rootRoute = createRootRoute({ component: () =>
Ready
}) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const hydrated = vi.fn() + window.$_TSR = { h: hydrated } as any + + await act(async () => { + render( + + + , + ) + }) + + await waitFor(() => expect(hydrated).toHaveBeenCalledTimes(1)) + expect(await screen.findByText(error.message)).toBeInTheDocument() +}) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 0c4c1b4146..8a10e06339 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -136,7 +136,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(8) + expect(updates).toBe(7) }) test('redirection in preload', async () => { @@ -154,7 +154,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(1) + expect(updates).toBe(2) }) test('sync beforeLoad', async () => { @@ -196,7 +196,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(4) + expect(updates).toBe(3) }) test('hover preload, then navigate, w/ async loaders', async () => { diff --git a/packages/react-router/tests/transactional-loading.test.tsx b/packages/react-router/tests/transactional-loading.test.tsx index 433354bab4..a6769c6063 100644 --- a/packages/react-router/tests/transactional-loading.test.tsx +++ b/packages/react-router/tests/transactional-loading.test.tsx @@ -6,18 +6,32 @@ import { screen, waitFor, } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { Link, Outlet, RouterProvider, createBrowserHistory, + createMemoryHistory, createRootRoute, createRoute, createRouter, } from '../src' import type { RouterHistory } from '../src' +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((resolver) => { + resolve = resolver + }) + return { promise, resolve } +} + let history: RouterHistory beforeEach(() => { @@ -28,9 +42,136 @@ afterEach(() => { history.destroy() window.history.replaceState(null, 'root', '/') cleanup() + vi.useRealTimers() }) describe('transactional route loading', () => { + test('publishes a parent and child background refresh atomically after the child observes fresh parent data', async () => { + const parentRefresh = deferred() + const childRefresh = deferred() + const childObservedFreshParent = deferred() + let parentLoads = 0 + let childLoads = 0 + + const rootRoute = createRootRoute({ + component: () => ( + <> + Other route + Data route + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home route
, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () =>
Other route content
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + staleTime: 0, + gcTime: 60_000, + loader: async () => { + parentLoads += 1 + if (parentLoads === 1) { + return 'parent-v1' + } + return parentRefresh.promise + }, + component: () => ( + <> +
{parentRoute.useLoaderData()}
+ + + ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + staleTime: 0, + gcTime: 60_000, + loader: async ({ parentMatchPromise }) => { + childLoads += 1 + const parentMatch = await parentMatchPromise + const parentData = parentMatch.loaderData as string + if (childLoads > 1) { + childObservedFreshParent.resolve(undefined) + await childRefresh.promise + } + return `child-saw-${parentData}` + }, + component: () =>
{childRoute.useLoaderData()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + otherRoute, + parentRoute.addChildren([childRoute]), + ]), + history, + }) + + render() + + expect(await screen.findByText('Home route')).toBeInTheDocument() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/') + }) + fireEvent.click(screen.getByText('Data route')) + + expect(await screen.findByText('parent-v1')).toBeInTheDocument() + expect(await screen.findByText('child-saw-parent-v1')).toBeInTheDocument() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/parent/child') + }) + + fireEvent.click(screen.getByText('Other route')) + expect(await screen.findByText('Other route content')).toBeInTheDocument() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/other') + }) + + fireEvent.click(screen.getByText('Data route')) + + expect(await screen.findByText('parent-v1')).toBeInTheDocument() + expect(await screen.findByText('child-saw-parent-v1')).toBeInTheDocument() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/parent/child') + }) + + await act(async () => { + parentRefresh.resolve('parent-v2') + await childObservedFreshParent.promise + }) + + expect(screen.getByText('parent-v1')).toBeInTheDocument() + expect(screen.getByText('child-saw-parent-v1')).toBeInTheDocument() + expect(screen.queryByText('parent-v2')).not.toBeInTheDocument() + expect(screen.queryByText('child-saw-parent-v2')).not.toBeInTheDocument() + + await act(async () => { + childRefresh.resolve(undefined) + await Promise.resolve() + }) + + await waitFor(() => { + expect(screen.getByText('parent-v2')).toBeInTheDocument() + expect(screen.getByText('child-saw-parent-v2')).toBeInTheDocument() + expect(screen.queryByText('parent-v1')).not.toBeInTheDocument() + expect(screen.queryByText('child-saw-parent-v1')).not.toBeInTheDocument() + }) + }) + test('renders a leaf error at the leaf boundary when its background refresh fails', async () => { let loads = 0 const rootRoute = createRootRoute({ @@ -94,4 +235,94 @@ describe('transactional route loading', () => { expect(screen.getByText('Root shell')).toBeInTheDocument() expect(screen.getByText('Parent shell')).toBeInTheDocument() }) + + test('renders the first settled background failure after foreground work completes', async () => { + const rootRefresh = deferred() + const parentStarted = deferred() + const childStarted = deferred() + const parentGate = deferred() + const childGate = deferred() + const parentSettled = deferred() + const childSettled = deferred() + let rootLoads = 0 + let parentLoads = 0 + let childLoads = 0 + + const rootRoute = createRootRoute({ + loader: { + staleReloadMode: 'blocking', + handler: async () => { + if (++rootLoads > 1) { + await rootRefresh.promise + } + }, + }, + component: Outlet, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: { + staleReloadMode: 'background', + handler: async () => { + if (++parentLoads > 1) { + parentStarted.resolve(undefined) + await parentGate.promise + parentSettled.resolve(undefined) + throw new Error('later parent failure') + } + return 'parent data' + }, + }, + component: Outlet, + errorComponent: () =>
Parent refresh failed
, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: { + staleReloadMode: 'background', + handler: async () => { + if (++childLoads > 1) { + childStarted.resolve(undefined) + await childGate.promise + childSettled.resolve(undefined) + throw new Error('first child failure') + } + return 'child data' + }, + }, + component: () =>
{childRoute.useLoaderData()}
, + errorComponent: () =>
Child refresh failed
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + render() + expect(await screen.findByText('child data')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + let invalidation!: Promise + await act(async () => { + invalidation = router.invalidate() + await Promise.all([parentStarted.promise, childStarted.promise]) + }) + await act(async () => { + childGate.resolve(undefined) + await childSettled.promise + parentGate.resolve(undefined) + await parentSettled.promise + }) + expect(screen.getByText('child data')).toBeInTheDocument() + + await act(async () => { + rootRefresh.resolve(undefined) + await invalidation + }) + + expect(screen.getByText('Child refresh failed')).toBeInTheDocument() + expect(screen.queryByText('Parent refresh failed')).not.toBeInTheDocument() + }) }) diff --git a/packages/react-router/tests/transitioner-listener-errors.test.tsx b/packages/react-router/tests/transitioner-listener-errors.test.tsx new file mode 100644 index 0000000000..9682b8f60c --- /dev/null +++ b/packages/react-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,81 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() +}) + +test('a throwing load-event listener cannot interrupt route hooks or later navigations', async () => { + const firstOnEnter = vi.fn() + const secondOnEnter = vi.fn() + const listenerError = new Error('onLoad listener failed') + const laterOnLoad = vi.fn() + const loadedPaths: Array = [] + + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: firstOnEnter, + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: secondOnEnter, + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index route')).toBeInTheDocument() + + const unsubscribe = router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + throw listenerError + } + }) + const unsubscribeLater = router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname !== '/') { + loadedPaths.push(event.toLocation.pathname) + laterOnLoad(event) + } + }) + testCleanups.push(unsubscribe, unsubscribeLater) + + await act(() => router.navigate({ to: '/first' })) + + expect(screen.getByText('First route')).toBeInTheDocument() + expect(loadedPaths).toEqual(['/first']) + + unsubscribe() + await act(() => router.navigate({ to: '/second' })) + + expect(screen.getByText('Second route')).toBeInTheDocument() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + expect(secondOnEnter).toHaveBeenCalledTimes(1) + expect(laterOnLoad).toHaveBeenCalledTimes(2) + expect(loadedPaths).toEqual(['/first', '/second']) +}) diff --git a/packages/react-start-client/src/hydrateStart.ts b/packages/react-start-client/src/hydrateStart.ts index 22f4848de7..21f1fa2039 100644 --- a/packages/react-start-client/src/hydrateStart.ts +++ b/packages/react-start-client/src/hydrateStart.ts @@ -4,9 +4,6 @@ import type { AnyRouter } from '@tanstack/router-core' /** * React-specific wrapper for hydrateStart that signals hydration completion */ -export async function hydrateStart(): Promise { - const router = await coreHydrateStart() - // Signal that router hydration is complete so cleanup can happen if stream has ended - window.$_TSR?.h() - return router +export function hydrateStart(): Promise { + return coreHydrateStart().finally(() => window.$_TSR?.h()) } diff --git a/packages/react-start-client/src/tests/hydrateStart.test.ts b/packages/react-start-client/src/tests/hydrateStart.test.ts index 0610a8fa0c..2d34400009 100644 --- a/packages/react-start-client/src/tests/hydrateStart.test.ts +++ b/packages/react-start-client/src/tests/hydrateStart.test.ts @@ -21,3 +21,13 @@ test('signals streaming cleanup after hydration succeeds', async () => { await expect(hydrateStart()).resolves.toBe(router) expect(hydrated).toHaveBeenCalledTimes(1) }) + +test('signals streaming cleanup without hiding a hydration failure', async () => { + const error = new Error('hydration failed') + coreHydrateStart.mockRejectedValue(error) + const hydrated = vi.fn() + window.$_TSR = { h: hydrated } as any + + await expect(hydrateStart()).rejects.toBe(error) + expect(hydrated).toHaveBeenCalledTimes(1) +}) diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md new file mode 100644 index 0000000000..d8efe633cf --- /dev/null +++ b/packages/router-core/INTERNALS.md @@ -0,0 +1,1279 @@ +# Match loading internals + +This document describes the architecture that loads route matches on the +client, on the server, and across hydration. It is for maintainers of router +core and the framework adapters. + +The loader is intentionally small in concepts. Runtime state exists only when +it represents a real authority or an owned resource. Phase names and invalid +transitions belong in TypeScript whenever possible; they do not justify a +second runtime state machine. + +All `_`-prefixed fields mentioned here are internal. Their spelling and shape +may change, but the ownership rules in this document must continue to hold. + +## The architectural rule + +For each mutable result, there must be one answer to each of these questions: + +1. Who may publish it? +2. Who owns the work and cancellation signal that produce it? +3. What proves that the answer is still current after an `await`? + +Do not add a flag, counter, copied deadline, or second completion promise merely +to describe an existing fact. Add runtime state only when it removes an +independent writer, resource owner, or invalid transition. + +The main authorities are: + +| Authority | What it owns | +| ---------------------------- | ------------------------------------------------------------------- | +| `_tx` | The one client navigation allowed to commit, redirect, and complete | +| `_preflight` | The current client plan or asynchronous hydration reconstruction | +| `_handoff` | The temporary right to transfer one reconstructed SSR prefix | +| `_committed` | The accepted current lane and lifecycle/background CAS base | +| `stores.matches` | The current match presentation exposed to renderers and users | +| `router._cache` | Off-screen loader generations and first same-ID planning seeds | +| Active preload entry | One still-running speculative whole lane | +| Loader flight registry entry | The latest same-ID loader generation available to new consumers | +| Match flight lease | Ownership keeping that loader work alive | +| Pending session | One reveal/minimum-visible deadline and its current owner | +| React acknowledgement slot | The one requested publication whose render may settle a transition | +| Request signal | Lifetime of one server request and any accepted SSR stream | +| Accepted SSR stream response | Cleanup ownership transferred from the handler to the response body | + +These authorities are related, but none is a substitute for another. In +particular, presentation is not semantic authority, registry membership is not +resource ownership, and a promise settling is not permission to publish. + +## Code map + +- `src/router.ts` matches locations, creates match objects, owns public router + state, history, cache operations, and entry points into loading. +- `src/stores.ts` reconciles route-keyed presentation stores and their ordered + aggregate. +- `src/load-client.ts` owns client planning, transactions, preloads, loader + flights, reduction, pending presentation, background reloads, and commits. +- `src/load-server.ts` runs the request-local server lane. +- `src/route-chunks.ts` owns lazy route-option installation and component + readiness without adding a JavaScript-module cache. +- `src/hydrate.ts` reconstructs the server-resolved prefix and the initial + selective-SSR presentation. +- `src/ssr/createRequestHandler.ts` connects request lifetime, server loading, + dehydration, redirects, rendering, and cleanup. +- `src/ssr/handlerCallback.ts`, `ssr-server.ts`, and + `transformStreamWithRouter.ts` transfer stream ownership and coordinate + serialization, injection, abort, and cleanup. +- Framework `Transitioner` and `Matches` implementations acknowledge exact + publications and render only through the selected boundary. Framework + `RouterClient` and render-to-stream implementations complete hydration and + connect request abort to their renderer. + +## Semantic state and presentation state + +The rewrite deliberately separates two views of matches. + +### Semantic matches + +`_committed` is the accepted current semantic lane. It supplies lifecycle +identity and is the exact base for background compare-and-swap. Pending +presentation never replaces it as a planning base. + +For an individual match ID, matching first consults `router._cache` and then +falls back to `_committed`. A cached loader generation can therefore +shadow the currently displayed same-ID generation for future planning without +becoming current presentation or lifecycle authority. + +Semantic matches may own loader-flight leases. They are not mutated by losing +transactions or by pending presentation. + +### Presented matches + +`stores.matches` is the array visible to router state and framework stores. +Before `pendingMs` expires it can still contain the source presentation. Once +pending presentation is published, it contains the whole destination lane, +including descendants that are still loading. Terminal publication follows the +same membership rule: an ordinary error is retained at the throwing match, +while not-found is moved to its selected boundary, but neither removes +structurally matched descendants. The framework renderer derives its cutoff +from the first pending or terminal boundary instead of requiring core to hide +descendants. + +The same cutoff is used by every authoritative post-load output projection: +framework head and script construction, route response headers, SSR manifest +assets, dehydration, and cache exclusion during commit. A structural descendant +below the cutoff remains observable in state, but it cannot contribute those +outputs or evict a newer cache generation merely because it is present in the +lane. Start's static early hints are the deliberate exception: they are +speculative route-tree hints emitted before loading selects a terminal boundary, +and an already-sent 103 response cannot be retracted. + +The presentation pool is keyed by route ID, not match ID. `stores.ids` defines +active membership and order, while `stores.getMatchStore` obtains the one stable +mutable atom in `stores.byRoute` for a route. That atom contains the route's +presented match or `undefined`. An active route branch contains a route at most +once, so this shape cannot alias two visible matches. When params, search, or +loader dependencies produce a new semantic match ID for the same route, +reconciliation replaces the value in that route's existing atom. Leaving a +route tombstones that atom with `undefined`; re-entering fills the same atom +again. Route components and `useMatch({ from })` therefore keep one subscription +across match generations and A-to-B-to-A membership changes. `ids` is published +before departure tombstones so framework trees stop reading a leaving route +before its atom is cleared. The pool retains atoms for route IDs encountered +during the router's lifetime; it is not an LRU or a cache of match generations. +Semantic caches and loader flights remain keyed by match ID and must not use the +presentation pool as their authority. + +This distinction matters for user code. Once the destination is presented, a +selector can inspect every destination match and observe `isFetching` while the +renderer still shows only the valid prefix. Before that publication, same-ID +matches in the source presentation can expose updated fetching state, but a new +destination-only match is still private. + +Pending entries are flight-free snapshots. They may copy loader data and context +for presentation, but they never become a planning base and never own semantic +resources. + +### Location state + +`stores.location` is the requested location. It can advance before loading +finishes. For foreground client navigation, `stores.resolvedLocation` advances +only after the framework transition acknowledgement settles. Settlement, not a +`true` render result, is what permits navigation completion. Router `status` is +`pending` during that interval and returns to `idle` at completion. A `true` +result separately permits `onRendered` and pending minimum timing. Server +publication and hydration perform their request/initial-load handoffs directly, +as described below. + +### Canonical locations and rewrites + +Initial client and server canonicalization compares `publicHref`, the +browser-facing URL produced by the rewrite contract. Parsed semantic `href` and +rebuilt `href` are not necessarily symmetric: input and output rewrites run in +opposite directions. The client ignores a trailing-slash-only difference while +the server can redirect to the exact browser-facing canonical URL. + +## A lane and its phases + +A lane is one location plus an ordered array of work matches. Its phase is +encoded in TypeScript: + +```text +matched -> contextualized -> reduced -> projected +``` + +### Matched + +Matching establishes route order, params, validated search results, loader +dependencies, match ids, initial status, and possible semantic reuse. It does +not run `beforeLoad` or grant publication authority. + +A match id identifies loader/cache compatibility. It is derived from route +identity, interpolated path params, and serialized `loaderDeps`. Ordinary search +values affect loader identity only when the route includes them in +`loaderDeps`. + +Matching treats `params.parse`, `validateSearch`, and `loaderDeps` as pure +planning functions. For the same input they must return the same value without +navigating or mutating router/application state. A `loaderDeps` result and its +serialization hooks must also be side-effect-free. These callbacks may be +evaluated more than once; supporting reentrancy from them would add runtime +ownership checks to every planning step without representing a supported use. + +### Contextualized + +Contextualization walks parent to child. For each route it: + +1. computes route context from the completed parent context, +2. handles params/search validation, +3. runs `beforeLoad` when required, and +4. merges the result before moving to the child. + +This serial order guarantees that child route context, child `beforeLoad`, and +child loader context cannot observe a partially completed parent guard. + +Route context is synchronous in the public type contract. Its own contribution +is cached on the match as `_ctx`, and match identity includes route identity, +path params, and `loaderDeps`. A same-ID cache hit may therefore reuse that +route-local result, including one produced by a completed preload. The merged +context is still rebuilt parent-first from the current parent's merged context, +the route's `_ctx`, and the current `beforeLoad` result. Reusing `_ctx` must never +reuse an older merged context or `beforeLoad` contribution. + +Server requests have fresh matches and execute route context normally. +Hydration executes each accepted route context locally, stores its `_ctx`, and +then merges transported `beforeLoad` output. An active identical preload donates +the whole lane it is still contextualizing. + +### Reduced + +On the client, eligible loaders and normal component chunks start concurrently. +On the server, loaders reduce before normal render chunks are consumed. +Reduction turns their outcomes into one terminal semantic lane and one cutoff. +No task publishes while reduction is in progress. + +### Projected + +After semantic reduction, client asset hooks derive `meta`, `links`, styles, and +scripts from the final lane. Server projection additionally derives response +headers. Projection cannot replace the selected loader/before-load result. + +Only an owner that remains current after projection may publish. + +## Client planning and the single writer + +Planning is intentionally separate from transaction installation. Lifecycle +events and route execution callbacks can synchronously reenter the router. +Planning callbacks used for params, search, and loader-key derivation are the +pure functions described above and do not create a second reentrancy boundary. + +A planning controller is installed before `onBeforeNavigate`, `onBeforeLoad`, +and matching, invalidating an older synchronous plan. The planner checks its +authority after supported reentrant callbacks and before installing a +transaction. A stale plan exits without installing a transaction or altering +semantic state. + +Once planning succeeds, the router installs one `_tx`. The transaction owns: + +- the destination and its private matches, +- one lane cancellation controller, +- `done`, the transaction-completion promise used by current transaction + waiters and by React to retain the Suspense source tree while a published + pending match is still loading, +- redirect depth for the chain currently being executed, and +- any pending session transferred to it. + +`LoadTransaction`, the lane execution options, and the pending session are +labeled TypeScript tuples. They are deliberately not runtime state machines: +the labels make each slot type-safe for maintainers, while the compact runtime +shape avoids repeating property names throughout the client loader. Changing a +slot means updating the tuple declaration and every typed consumer together; +it must not introduce a second owner or completion signal. + +Throwing `done` from a pending React match does not stop the transaction's work. +Once the lane has reduced and projected, its successful destination is +published inside the framework transition; that render acknowledgement then +allows `done` to settle. `_commitPromise` is the internal promise backing public +history/navigation completion. Current completion resolves it, and a +superseding transaction can chain it forward, but it is also not permission to +publish. These promises describe different wait relationships; `_tx` remains +the only client writer authority. + +Redirect depth transfers only through the exact `_pendingLocation` created by +`followRedirect`. It is not inherited from the previous transaction: a user +navigation that reenters during a redirect starts a fresh chain even though the +redirecting transaction is still alive. + +The latest `_tx` remains installed after it settles. Its presence is writer +identity, not a loading flag; public loading state comes from router `status` +and per-match `isFetching`. + +Installing a successor removes the predecessor's publication authority. The +predecessor must still settle and release everything it owns; cancellation is a +liveness mechanism, not a license to abandon cleanup. + +Commit and cache handoff install the accepted semantic/cache recipients before +releasing replaced resources. Releasing the last flight lease aborts a public +signal and can synchronously reenter user code, so an old generation must never +be released while it still appears to be the accepted owner. The committed lane +is also removed from the transaction's private ownership before publication. + +Every asynchronous client publication checks `_tx` immediately before the +write. Background publication additionally checks the exact committed base +array from which it was derived. + +## `beforeLoad`: execution, active adoption, and hydration + +`beforeLoad` context is not a cache. + +A completed client preload never stores reusable `beforeLoad` output. When its +loader data enters the route cache, the merged context is discarded; the +same-ID route-local `_ctx` may remain reusable. A later navigation rebuilds the +merged context from the current parent and `_ctx`, then reruns `beforeLoad`, even +when it reuses completed loader data. + +There are two deliberate exceptions. + +### Identical active whole-lane adoption + +A navigation may latch onto an entry in `router._preloads` that is still running +only when the whole lane is identical. Admission is decided before rematching +from the preload's href and redirect depth, route-tree identity, semantic owner, +parsed search, router root context, additional context, user-supplied location +state, and any explicit mask's href, search, user state, and +`unmaskOnReload`. Router-managed history and temporary-mask keys do not +participate. With deterministic route planning, those inputs imply the same +ordered routes, params, validated search, and global-path-miss meaning. They may +be observed by `beforeLoad` even when the route lane is unchanged. +Adoption inputs are treated as immutable values: replace a context/state object +to express a generation change; in-place mutation is not detected. +Adoption is all-or-nothing. There is no prefix donation and no +completed-preload freshness window for `beforeLoad`. Matching derives +global-path-miss semantics for every lane, including preloads, so an otherwise +identical candidate cannot erase that terminal meaning during adoption. + +The active preload also captures the identity of `_committed` from which +it was planned. Whole-lane adoption requires that semantic owner to remain +current. Invalidation replaces the committed generation, so an older preload +may still donate an identical loader flight but cannot donate its pre-invalidation +`beforeLoad` context. + +The adopting navigation takes ownership of the active lane and its controller. +The original public preload call must then stop releasing that lane. Individual +successful loader tasks may still publish cache entries through their original +per-match compare-and-swap; that publication belongs to the transferred lane, +not to a second whole-lane owner. + +If the adopted speculative lane succeeds, the navigation may use the +`beforeLoad` calls that ran with `preload: true`. A redirect from that same +active lane is also accepted as navigation control flow. An ordinary failure, +not-found, or cancellation is not authoritative for navigation: the real +navigation replans and reruns `beforeLoad` with `preload: false`. Successful +loader work anywhere in the settled lane remains eligible for reuse, including +a descendant that completed after an ancestor loader failed. Each successful +loader generation has already attempted its own cache publication at settlement, +so retry does not scan the terminal lane or create another cache handoff. It +discards the failed speculative semantic lane and replans cache-first. The retry +rebuilds the serial context chain and reruns `beforeLoad`; only independently +successful loader generations cross that boundary through the normal +cache/flight rules. + +Routes with `preload: false` do not permit active whole-lane adoption. Their +speculative `beforeLoad` may run with `preload: true`, but navigation reruns the +serial chain and performs the skipped loader work. + +Rejecting whole-lane adoption does not require discarding loader work before +the real lane reaches its reload decisions. The rejected preload may remain a +resource-only donor until that lane settles. + +### Hydration + +The server-resolved prefix is authoritative for the initial document. Hydration +therefore restores transported `beforeLoad` output for the accepted prefix +without rerunning it on the client. This applies to the server-rendered prefix +in selective SSR; the unresolved client suffix follows ordinary navigation +rules. + +Hydration is not a general `beforeLoad` cache. Its temporary handoff is valid +only for the initial client load of the same document entry and exact committed +owner; rejection, invalidation, or any later load returns to normal serial +execution. The claim also requires the same route tree, root/additional context, +search, user history state, and exact browser history generation. That initial +load may transfer the accepted hydration prefix and +keep its transported context while it completes a selective-SSR suffix. A +preload never claims this prefix. Frameworks must start the initial client load +before descendant route code can preload; invoking a preload in the gap after +raw `hydrate()` and before that load is outside the supported handoff protocol. + +## Loader data, cache entries, and flights + +Loader data is designed to be reusable. It is independent from `beforeLoad` +provenance. + +### Completed cache entries + +Successful loader-backed matches can be cached by match id. Staleness, +invalidation, `shouldReload`, stale reload mode, and GC policy decide whether a +lane uses that data or runs the loader again. + +It is valid for cached loader data to have been produced under context from an +older `beforeLoad` generation. Loaders are the cache boundary; guards are not. +When a new loader run is needed, it receives the current lane's freshly built +context. + +An invalid successful entry may remain in the cache as stale data and an identity +carrier, but it can never satisfy freshness and must reload. Failed, canceled, +loaderless, and expired generations do not become reusable loader-cache +entries. + +A terminal preload lane can still contain independently successful loader +generations when its error or not-found came from `beforeLoad`, validation, or +another route. Each preload loader success attempts cache admission immediately, +before whole-lane reduction. The cache receives a non-terminal copy and an +additional flight lease; the speculative lane keeps its own lease and terminal +meaning until it is adopted or discarded. This works even below the eventual +render boundary and does not preserve the speculative parent chain: merged +context and `beforeLoad` output are cleared. Same-ID `_ctx`, loader identity, +and successful loader data remain reusable by design. + +Hydration retry is the transported-work exception because it did not run those +client loader tasks. There, `loaderData` membership together with +`invalid === false` proves a transported successful generation even when +terminal boundary state is attached to the match. Hydration normalizes that +copy before passing it through the same cache compare-and-swap and lease rules. + +The dehydrated payload omits `loaderData` both when no loader result exists and +when the accepted result is `undefined`. This deliberately keeps the HTML +payload smaller at the cost of making those states indistinguishable after +transport. Reconstruction preserves the absence and does not treat an omitted +value as reusable loader success during hydration retry. + +The cache may deliberately contain a successful generation with the same match +ID as a committed match. For example, a speculative lane can produce reusable +ancestor loader data before failing below it while the older committed +generation remains visible. Cache-first matching lets the next lane use that +newer loader generation. Its merged context and `beforeLoad` contribution have +been removed; the merged chain is rebuilt from current parents and same-ID +route-local context before `beforeLoad` reruns. Committing a lane removes cache +entries for its IDs. + +### Same-id in-flight work + +`router._flights` is the only registry from which a new consumer discovers +same-ID loader work. Every new loader generation registers there, whether it +was started by navigation, preload, or background refresh. A newer generation +replaces the registry entry synchronously. A flight has its own abort +controller; it does not use one consumer transaction's controller as its +lifetime. + +Two facts must remain separate: + +- registry membership means new consumers may join the flight; +- a match lease means an existing consumer keeps the flight alive. + +Registry membership itself owns no lease. A settled generation remains +discoverable while at least one semantic or cached match owns it. Releasing the +last lease removes that generation only if it is still the current registry +entry, then aborts its controller. Every copied semantic match that retains a +flight must acquire a lease, and every discarded match must release one exactly +once. Consequently, a flight referenced by a match or discoverable in the +registry always has a positive lease count. Acquisition code must not recover +from a referenced zero-lease flight; that would hide an ownership bug. + +Releasing a set of matches is deliberately two-phase. First every outgoing +match drops its `_flight` lease and every zero-owner generation is removed from +the discovery registry. Only after the entire outgoing set is detached are the +collected flight controllers aborted. Do not interleave one flight abort with +detaching later matches in the same replacement: an abort listener can +synchronously reenter, and that load must observe every logically removed lease +and registry entry as already gone. + +A successful accepted match may keep its lease after the loader promise has +settled. This keeps the loader's public `AbortSignal` alive for that semantic +generation. The signal aborts when the last active or cached owner is replaced, +unloaded, expired, or discarded; promise settlement alone does not end it. + +Loader error normalization, including route `onError`, runs only while the +originating match still owns that exact flight. Releasing the match before a +late rejection makes the generation semantically discarded; abort-triggered +rejection must not call user error hooks. This is an ownership check, not a +separate cancellation flag. A loader that aborts its own flight controller while +its match remains owned can still fulfill or reject normally. + +A planned match holds only the lease for the accepted generation copied from +cache or committed state. After `beforeLoad` and `shouldReload` decide that a +loader will run, it may synchronously acquire the registry's latest different +same-ID generation. It never reacquires its own accepted generation merely to +avoid a requested reload. A blocking reload replaces the accepted lease with +the donor; a background reload keeps accepted data visible and gives the donor +lease to its private candidate. This one lookup covers work started by active +preloads, navigation, and background refresh without scanning those owners. + +When a different lane supersedes an active preload at the same href, the old +lane stops being adoptable but retains its resources until the successor lane +settles. Its leased flights therefore remain discoverable for the successor's +late reload decisions without giving every planned match a second reservation +lease. The successor then releases the old lane; any borrowed flight remains +alive through the successor match's ordinary `_flight` lease. + +A joining navigation accepts successful shared loader work. A failure, +not-found, or cancellation produced under another speculative generation is not +allowed to govern it; the navigation releases that flight and registers its own +generation. Redirect remains control flow and is never cached as loader data. + +### Semantic parent chain + +`parentMatchPromise` represents the semantic parent generation, not merely the +currently displayed parent. + +This distinction is essential for mixed reload modes. If a parent is refreshing +in the background while a child reloads in blocking mode, the child borrows the +fresh parent candidate. The final lane must not combine fresh parent data with +child data derived from the stale visible parent. + +The same semantic-parent chain is used by blocking and background loader work. +Task arrays track readiness/outcomes; they are not a second parent authority. + +### Components and lazy route options + +There is no router-level component-promise cache. The browser module cache and +framework lazy-component machinery already cache loaded JavaScript. + +Lazy route loading retains only the authority needed to install lazy route +options, ignore obsolete HMR settlements, and retry failed imports. It is not a +general JavaScript-module cache. + +Lazy option installation has one route-owned promise. Success installs options +only while that promise is still the route's owner. Rejection clears the owner +so a later load can retry, and development refresh can clear ownership so an +obsolete import cannot install options afterward. + +Normal component readiness is part of route readiness, not merely an asset +prefetch side effect. Client loader and normal component work may run in +parallel, and a blocking match becomes successful only after both are ready. +Chunk settlement also wakes pending selection, so a `pendingComponent` supplied +by newly installed lazy options can become visible while the route's loader is +still running. This does not require retaining a component promise on the +match: the route's lazy-option owner and the framework/module loader provide the +necessary work identity. + +Client and server not-found boundary searches settle lazy options on each +candidate route before testing for `notFoundComponent`. A lazy rejection while +locating the boundary does not replace an already selected not-found, but +cancellation or request abort still stops the search. The selected terminal +boundary component is then loaded best effort. + +## Outcomes and failure selection + +Internal work normalizes returned and thrown values into a small closed set: + +```text +success | error | not-found | redirect | canceled/skipped +``` + +Redirect and cancellation are control flow, not committed match statuses. +Error and not-found are terminal semantic outcomes assigned once during +reduction. + +Returned and thrown redirects/not-founds normalize identically. Only an +ordinary error invokes route `onError`; if `onError` throws, its value is +normalized again and may itself become an error, not-found, or redirect. +Router cancellation and request abort bypass `onError`. Aborting the +`AbortController` exposed to a loader is not by itself proof that the router +discarded the work: a still-owned loader may fulfill or reject afterward, and +that settlement is normalized normally. On the client, zero flight leases prove +that an aborted generation was discarded. On the server, the request signal +proves request cancellation, while the already selected failure/control outcome +proves that an aborted descendant is obsolete. The client calls a discarded +non-result `canceled`, while the server calls it `skipped`; neither is a +publishable terminal state. + +The client and server follow the same chronological and required-prefix policy. + +### Serial phase + +Route context, validation, and `beforeLoad` run parent-first. The first terminal +serial outcome stops descent and wins over later ordinary work when its required +ancestor prefix remains renderable. + +An ordinary serial error allows loaders strictly above the throwing route to +finish. A serial not-found allows work through its effective ancestor boundary, +but never past the throwing route. A serial redirect or cancellation starts no +loader work. + +### Parallel loader phase + +Eligible loaders start concurrently. The first ordinary loader error or +not-found to settle becomes the provisional ordinary loader failure. This is +promise settlement chronology, not route-order ranking and not a +shallowest-boundary comparator. After settlement, the required render prefix is +checked root-to-leaf. The first failed ancestor without its own accepted +`loaderData` property replaces a deeper failure because that deeper boundary is +not reachable. Locally retained loader data, including an accepted `undefined`, +keeps the ancestor renderable with stale data and preserves settlement +chronology. An `undefined` result reconstructed from SSR is intentionally absent +under the transport policy above. + +A redirect is control flow and wins even after an ancestor loader has already +failed ordinarily. Reduction therefore waits for started descendant loader work +to reveal a redirect, including a descendant already refreshing in the +background. An ordinary failure does not cancel already-started descendants +before this selection completes. Once all relevant work has settled, the first +ordinary failure is used only if no redirect won. The client retains the full +structural branch and releases only work that no accepted semantic or background +candidate owns; the server may abort work below the selected boundary. + +Loader settlement does not directly install an error or not-found on the lane. +It leaves a failed attempt as a renderable, invalid success until reduction +installs the one selected terminal outcome. A losing ancestor remains renderable +only when it retains accepted loader data; otherwise required-prefix reduction +promotes it. Every swallowed losing attempt remains invalid and must reload +rather than being treated as fresh cache data. Semantic `parentMatchPromise` +snapshots still expose each loader's own outcome to its descendants. + +No error-over-not-found sort is performed. The selected not-found is moved to +its effective not-found boundary; an untargeted not-found searches eligible +ancestors, while a targeted not-found respects its target. + +A global path miss is terminal by `_notFound` even when the selected match +remains successful and has no error attached. An explicit not-found reduced to +root also attaches its error there. Both forms cap rendering and hydration and +produce a 404 response. + +For a fuzzy global miss, synchronous matching installs the best boundary visible +from eager route options. Before contextualization, client and server feed that +fallback through the same lazy-aware ancestor search used for explicit +not-found outcomes. This prevents serial hooks below the effective boundary +from running and prevents loader tasks from starting there, while retaining the +complete structural branch. The historical deepest-route-with-children fallback +still applies when no route supplies a not-found component. When `notFoundMode` +is `'root'`, the search is bypassed but the same execution cap applies at root. + +### Chunk readiness + +Normal route chunks needed before the selected cutoff are awaited. Although the +work may start concurrently, readiness outcomes are consumed root-to-leaf. The +first relevant ordinary chunk failure replaces a deeper selected serial or +loader failure because that boundary is no longer reachable; a redirect from +relevant readiness remains control flow. +The resolved boundary is retained while that selected failure remains current; +a later lazy retry in the same lane cannot expand the required prefix after its +readiness has already been consumed. +Terminal boundary-component preloading is best effort during normal loading; it +does not start a second failure-selection algorithm. + +On the client, lazy/chunk readiness starts independently of loader completion +and notifies pending selection when it settles. A `pendingComponent` installed +by lazy options can therefore become the visible boundary while an eager loader +is still unresolved. + +### Projection errors + +Client `head` and `scripts`, plus server `head`, `scripts`, and `headers`, are +decorative with respect to route control flow. They run only after semantic +reduction. Rejections are logged and swallowed; they never replace the chosen +loader/before-load outcome or trigger another boundary-selection pass. + +The implementation should stay this simple. If a proposed fix requires a new +error candidate list, ranking pass, boundary score, or convergence loop, it is +almost certainly rebuilding the discarded complex architecture. + +## Terminal commit and lifecycle + +A successful client lane remains private through projection. At commit, one +framework transition publishes semantic matches, cache changes, and route +lifecycle callbacks. + +The client order is: + +1. publish final matches/cache and run `onLeave`/`onEnter`/`onStay`, +2. emit `onLoad`, then `onBeforeRouteMount`, while the transaction is current, +3. wait for the framework transition acknowledgement to settle, +4. publish `resolvedLocation` and `idle`, +5. emit `onResolved`, and +6. emit `onRendered` only if the acknowledgement was `true` and the same + transaction is still current. + +Each reentrant callback can start another navigation. A currentness check after +each publication boundary suppresses stale later events. In particular, an +`onResolved` navigation suppresses the old transaction's `onRendered`. + +Route lifecycle callbacks are invoked directly and are expected not to throw. +The coordinator does not carry a second error-handling path for callback +failures. All `onLeave` callbacks run before callbacks for retained or newly +entered routes; the relative ordering of `onEnter` and `onStay` is not a public +contract. + +Server render results are published request-locally and run the documented +route `onLeave`/`onEnter`/`onStay` callbacks against the previous server +generation after final matches have been installed. Server redirects do not +publish a render lane or run those callbacks. + +Terminal outcomes do not change route membership. Client and server state keep +the complete structurally matched branch, lifecycle compares that full branch, +and renderers derive the visible prefix from match status. Projection likewise +stops after the terminal match. SSR transport may serialize only that terminal +prefix because the client can reconstruct hidden structural descendants without +executing them. + +## Pending presentation + +Pending UI is presentation, not partial semantic commit. + +The first unresolved boundary is the only pending candidate. Its route or the +router default must provide a pending component, and its effective `pendingMs` +must allow presentation. Core does not skip an ineligible ancestor to expose an +unrelated deeper fallback. + +A successful route without a loader still participates in chunk readiness and +projection, but is not changed back to pending merely because a descendant has +blocking work. + +When pending is offered: + +- `stores.matches` receives a flight-free snapshot of the complete destination + lane; +- the selected boundary is marked pending; +- descendants remain observable in state; and +- the renderer stops at the boundary. + +There is one pending session in `router._pending` and one absolute deadline: + +```text +reveal deadline -> exact render acknowledgement -> minimum-visible deadline +``` + +The session also remembers the pending component identity. A normal lazy route +chunk can install a more specific `pendingComponent` after the default fallback +has already rendered; chunk settlement re-offers the same lane so the framework +can replace that fallback without creating another pending session or deadline. + +The reveal deadline is anchored to the transaction's lane-level `startedAt`. +Discovering lazy pending options later, advancing to another boundary, or +retrying the offer does not restart `pendingMs`. If the absolute deadline is +already past when a pending component becomes eligible, core publishes it in +the current turn instead of introducing a `setTimeout(0)` race. + +`pendingMinMs` starts only after the framework confirms that the pending +publication rendered. A superseded publication that never rendered creates no +minimum-visible obligation. + +Hydration or a redirect can leave an already visible pending presentation +without a pending session that owns its original acknowledgement. On takeover, +core conservatively treats that presentation as rendered and starts its minimum +from the takeover time instead of delaying its reveal again. + +A successor may take over timing only when the boundary index and match id are +the same. It keeps the existing deadline but republishes a full snapshot from +the successor, so pending UI cannot show stale search, params, or context from +the superseded navigation. Changing the boundary discards the old session. + +## Exact framework acknowledgement + +`startTransition` returns a promise whose boolean result has a precise meaning: + +- settlement means the framework transition can no longer block navigation + completion; +- `true` means the exact requested match publication rendered; +- `false` means core must finish without emitting `onRendered` or starting a + pending minimum based on that publication. + +React cannot await `React.startTransition` directly. Its adapter has one current +acknowledgement slot, and `Matches` settles that slot from a layout effect. A new +expected publication first settles the previous slot as `false`, then installs +itself before the transition callback runs. This ordering matters because +publication can invoke a route lifecycle callback that synchronously starts and +publishes a successor navigation. Installing the expectation after the callback +would let the superseded publication replace the successor's slot. +The lifetime-owned Transitioner installs these callbacks during render, before +the sibling match tree can register its acknowledgement effect. + +An already-settled router mounted into React uses the same acknowledgement slot +for its initial `onRendered` event. It therefore emits only after the exact match +tree and its descendant layout effects have committed, rather than from the +earlier history-subscription effect. + +A generation is its array length plus the ordered sequence of match ID, +execution-controller identity, and render status. These are existing semantic +facts rather than a separate generation counter. A new transaction or +background candidate changes controller identity; pending-to-terminal +publication changes status. Object reference equality is deliberately not +required because `isFetching` and per-match store reconciliation can replace +objects without changing the logical publication. An exact signature settles +the current slot as `true`. A mismatched render is ignored; only installing a +successor settles the current slot as `false`. + +Solid awaits its transition, and Vue awaits its render tick. For an +already-settled Solid mount, history subscription remains before the route tree +while a post-match notifier emits the initial `onRendered` event after +descendant mount effects. The architecture assumes exactly one Transitioner is +mounted per router and remains mounted for the lifetime of the application. Its +history subscription and acknowledgement machinery are therefore +lifetime-owned; there is deliberately no second unmount-time completion +authority. + +Every core write passed to `startTransition` must notify the aggregate matches +store even if much of the lane is structurally reused. Suppressing the write can +strand the acknowledgement promise. + +## `isFetching` and background reloads + +`isFetching` is public presentation state. It is observable during normal +`beforeLoad`, normal loaders, and background loader refreshes. + +For a foreground navigation this means the destination match exposes its phase +once the destination lane is presented. Before pending publication, a +destination-only match remains private; a same-ID source match can be reconciled +to the active phase earlier. Background reloads operate on the already +presented lane and therefore expose their phase immediately. + +A background reload keeps successful loader data visible while a private +candidate runs. The full presented lane remains installed and the affected +match reports the active phase. Completion clears fetching state whether the +candidate publishes, fails, or is superseded. A successor may join its loader +flight, but never adopts the private candidate lane. + +Background loader and chunk work may begin and settle while the foreground +publication renders. Background reduction, projection, and publication do not +start until the foreground transition acknowledgement settles. A fast refresh +therefore cannot replace the exact generation that the framework still needs to +acknowledge. + +When background tasks exist, execution creates their settlement observer +eagerly alongside foreground reduction and retains that promise in the lane +result. `runBackground` consumes the same settlement chronology after the +foreground acknowledgement. Recreating the observer then would process work +that already settled by task attachment or iteration order and could change +which ordinary failure or descendant redirect wins. This promise is an outcome +witness, not another publication or completion authority. + +Background work starts from an exact committed base. Before projection it uses a +fully private lane, including clones of untouched matches, so asynchronous asset +hooks cannot mutate committed objects without a store publication. + +Final background publication requires both: + +```text +router._tx is the owner +router._committed is the exact base +``` + +If either check fails, the entire staged lane is discarded and all candidate +and clone resources are released. A successful background publication replaces +the semantic/presented lane atomically but does not change location, foreground +status, or foreground navigation lifecycle events. + +Foreground completion does not join background publication. After the +foreground acknowledgement, the refresh continues independently and may publish +before or after `resolvedLocation`, `idle`, and `onResolved`. Active work +remains publicly observable through `isFetching`; any later publication still +requires the owner/base compare-and-swap. + +An ordinary background error or not-found also stays private through reduction +and projection, then may atomically replace the successful base with the full +matched branch carrying its terminal boundary. Hidden descendants retain route +membership, so background publication does not synthesize leave/enter lifecycle +events. It is not published incrementally. + +Background redirects use the same control-flow and ownership rules as foreground +redirects. A losing background lane cannot redirect. + +## Invalidation, cache clearing, and development refresh + +Invalidation creates a new semantic generation and reloads through the ordinary +transaction path. It does not turn `stores.matches` into a planning lane. + +Filtered invalidation evaluates committed and cached matches and collects the +selected match IDs. Every committed and cached generation with one of those IDs +is then replaced as invalid. This ID-wide rule prevents a cache-first same-ID +generation from escaping invalidation merely because a different generation +was the one passed to the filter. Route context needs no invalidation marker; +every new lane rebuilds it regardless. + +Invalidated successful data may remain visible until pending or terminal +publication, depending on reload mode. Error/not-found generations reset through +the same loading protocol rather than becoming cache successes. + +Cache clearing derives the retained active-preload and cache maps, installs both +replacement authorities, bulk-detaches removed leases, lets that operation +abort zero-owner flight controllers, and only then aborts selected preload lane +controllers. A public loader signal can synchronously reenter from its abort +listener; that reentrant load must observe the cleared authorities and every +removed lease as already detached. Unselected concurrent preloads remain +independent, and every later cache publication must still pass the per-match +cache-entry identity check captured during planning. + +Development refresh is deliberately aggressive: it aborts flights, discards +active preloads and caches, drops the committed semantic lane, and rematches +from the current route definitions. This prevents same-ID reuse from retaining +obsolete params, context, loader data, or projected assets. Correct ownership +and eventual usable state matter more than preserving speculative HMR work. +Flight discovery entries are removed before their controllers are aborted. + +## Speculative preloading + +A preload uses the same match, contextualize, reduce, and project phases as +navigation, but it never becomes `_tx` and never publishes match presentation. + +Its match/cache ownership effects are limited to: + +- an active identical whole lane that a navigation may adopt while it is still + running; +- joinable same-id loader flights; and +- individual successful preload loader generations entering the loader cache as + they settle. + +A preload can also install durable lazy route options through the separate +route-chunk owner described above. That is route definition readiness, not +authority over a completed match lane or `beforeLoad` result. + +A completed preload's failure, not-found, redirect, cancellation, and route +context do not become authority for a later navigation. An adopted still-active +identical lane is the exception described above, including its redirect control +flow. Standalone preload redirects may be followed only while the preload +remains current and within its bounded redirect policy. A preload never performs +a document reload. + +The public `preloadRoute` result describes the speculative lane, not merely its +cacheable subset. An ordinary error or not-found therefore resolves with the +terminal match array while any eligible successful loader generations can still +enter the cache. Cancellation or control flow that does not yield a reusable +lane can resolve `undefined`. + +Each preload loader task compares the current cache entry for its match ID with +the entry captured when that task was planned. It cannot overwrite a cache +generation installed since that plan. Admission happens at loader settlement, +without waiting for whole-lane success. A distinct successful generation may +coexist with an older committed same-ID generation; this changes future +planning precedence, not current presentation. A duplicate sharing the already +accepted flight is discarded. + +`preloadRoute` also works on a server router. It runs the same speculative +protocol with `preload: true`, can return matches and populate that router's +loader cache, and does not replace the request's location, committed lane, or +presentation. Ordinary request loading still uses the request-local server lane +and calls its hooks with `preload: false`. + +## Server loading and request lifetime + +Server loading is request-local, so it does not need the client `_tx` +coordinator. It still uses the same semantic phases, context ordering, outcome +normalization, chronological failure policy, semantic parent promises, and +projection behavior. + +Each server match gets the public controller passed to its callbacks. The +request signal—not the mere fact that a callback aborted that controller—is the +request-liveness authority checked across contextualization, loaders, chunk +readiness, terminal boundary readiness, and projection. A request abort cancels +the whole lane. An ordinary loader failure does not abort already-started +descendants before selection, so a later descendant redirect can still win. +After selection, applying the terminal boundary aborts the hidden suffix that +the result no longer owns. Redirect aborts the whole request-local lane. + +The request signal also governs the surrounding request pipeline. The generic +handler races manifest lookup, route loading, custom dehydration, and the +handler/render callback. Start applies the same rule to entry and router +resolution, middleware, manifest work, and redirect finalization. An abort can +therefore settle the handler during every awaited phase rather than waiting for +user code that ignores its signal. + +A raced promise may still fulfill after abort. If that late value owns an SSR +stream, the race disposes it instead of allowing it to regain response or +cleanup authority. Other user promises may continue executing, but after abort +they cannot publish a response or inject SSR output. If cleanup occurs while +application dehydration is awaited, dehydration returns before starting +serialization; injection and serialization completion also ignore later work. + +A server result is a closed union: render status plus matches, or an HTTP +redirect. Redirects short-circuit framework rendering and preserve their real +status, `Location`, and custom headers. + +Projection parity is intentional: server `head`, `scripts`, and `headers` use +the final reduced lane and loader data. As on the client, failures are logged and +swallowed. + +### Stream cleanup handoff + +Until a response is accepted, the request handler owns router SSR cleanup. A +non-stream response, redirect, or failure leaves cleanup with the handler. An +accepted SSR stream transfers that ownership to the response and is immediately +bound to the request signal, so an abort after handoff still disposes it. + +Disposal is idempotent and severs router SSR ownership before best-effort body +cancellation. This order matters because a custom or framework stream may ignore +or indefinitely delay cancellation. Replacing a stream response, resolving a +redirect from it, or stripping a HEAD body disposes the old stream under the +same request signal before accepting the replacement. + +Framework renderers connect request abort to their upstream renderer as well as +the router stream transform. Normal completion, downstream cancellation, +request abort, renderer failure, and stream lifetime timeout all converge on the +same cleanup authority; none creates a second response owner. + +Once a server lane is accepted for rendering, its match controllers are +registered with that same SSR cleanup authority. They remain live through +dehydration and response streaming so deferred loader work can finish while the +response is active, then abort when the response or stream lifetime ends. + +## Selective SSR + +SSR policy is the first parent-to-child serial step, before route context, +params/search validation handling, and `beforeLoad`: + +- `true` runs server `beforeLoad` and loaders, loads render chunks, projects + assets, and renders the component. +- `'data-only'` runs server `beforeLoad` and loaders and projects `head`, + `scripts`, and `headers`, but does not render that route component. +- `false` skips server `beforeLoad`, loaders, and component chunks. The first + `false` boundary still projects `head`, `scripts`, and `headers` for its server + shell, then projection stops before descendants that inherit `false`. Route + context and params/search validation still run, so their errors remain real + server outcomes. + +A parent restriction cannot be relaxed by a child: `false` remains false, and a +`'data-only'` parent caps a child requesting `true` at `'data-only'`. + +If a functional `ssr` option throws, the inherited/default policy is established +before calling it. The failure therefore retains the correct boundary +renderability instead of leaving `ssr` undefined. +An ordinary policy failure still reconstructs route context for its boundary; +if route context also fails, the original policy failure keeps precedence. +Redirects remain control flow and skip route-context reconstruction. + +Shell mode resolves and dehydrates the root semantic match while the presented +server lane may include the first client-only pending boundary and its +descendants. This permits server and initial client presentation to agree. + +## Hydration handoff + +Hydration reconstructs server work; it does not run a competing hydration +loader. While reconstruction is asynchronous, its controller is `_preflight`. +No client transaction may exist. Framework entry points prevent navigation or +preloading during reconstruction. The same controller interrupts asynchronous +application hydration and chunk work, and every asynchronous phase rechecks +currentness before mutating or publishing. + +Once hydration has accepted a semantic prefix and is ready to publish it, +`_preflight` is no longer the right authority: no planning operation is in +progress, but the first ordinary client load may still need to continue that +prefix. Hydration therefore installs `_handoff`, a temporary two-phase +capability, and detaches its controller from `_preflight`. This runtime state is +justified because it replaces eligibility inferred from several mutable fields +with one continuation owner. It is not a second completion promise or a general +cache. + +The client router is fresh when hydration begins. Application `hydrate` hooks +may restore external integration state or update router options, but must not +start router loading or preloading before reconstruction finishes. This is a +supported-ordering contract, not another runtime guard. + +The high-level process is: + +1. install serialization adapters and application-dehydrated data, +2. match a fresh candidate lane for the browser location, +3. accept the identity-compatible serialized lane as the ordered prefix + guaranteed by the document protocol, +4. copy server loader data, `beforeLoad` context, terminal state, and effective + SSR policy into private candidates, and install each transported effective + SSR value on its route so a functional server policy is not re-evaluated, +5. start exactly the chunks required by the accepted prefix and any selected + terminal boundary concurrently, then consume their outcomes in route order + so the earliest failed position can retire its suffix without waiting for + irrelevant descendants, +6. rebuild route context parent-first, +7. project client `head` and `scripts` through the same projection + function used after an ordinary client load, and +8. publish accepted semantic work and the complete structural presentation. + +For ordinary SSR, the browser receives dehydrated data produced for the exact +document URL it requested, by the same route build, and the serialized matches +are an ordered prefix of the client lane. The payload carries compact per-match +IDs to prevent stale or cross-build data from attaching to a different local +match, but does not serialize a second URL identity. + +An SPA shell uses the same ordered-prefix protocol for its root-only payload. +The framework owns making that shell payload applicable to the document it +serves. Hydration does not add a second URL authority to second-guess that +transport contract. + +Ordinary and selective SSR payloads are ordered prefixes from the same route +build and exact document URL. Hydration bounds reconstruction to the local lane +and validates each transported position by compact match ID. A mismatch ends +the accepted prefix and leaves the local suffix for ordinary client loading. A +longer server lane is accepted only through a local global not-found boundary +that already caps the branch; otherwise no transported prefix is accepted. +A terminal server error, not-found, or global not-found caps client execution so omitted descendants do +not run. The client still reconstructs those descendants as unresolved matches +to preserve structural membership. Terminal hydration loads the selected +error/not-found component rather than waiting on the route's normal component, +and neither route context nor projection runs below the transported boundary. +The transported terminal route remains authoritative even when its effective +SSR policy is `false` or `'data-only'`: route context or validation was still +allowed to fail on the server, so hydration must not turn that outcome into an +unresolved client-only route. Before-load and loader work remain skipped there +according to the server policy. + +Every executed context, `head`, and `scripts` hook nevertheless +receives the complete locally matched candidate lane. Hook arguments describe +structural membership; the accepted prefix describes execution authority. + +If a required chunk or route-context reconstruction fails, hydration preserves +only the successfully reconstructed committed prefix. +The public presentation still contains the complete locally matched lane, so a +terminal server boundary does not make route membership disappear while its +client reconstruction is retried. Eligible transported loader successes cross +the normal loader-cache boundary with merged context and `beforeLoad` +contribution removed. Because `resolvedLocation` remains unset, ordinary +initial client loading retries the failed boundary or context and finishes the +unresolved suffix. + +For a non-terminal selective-SSR handoff, the semantic committed prefix is the +complete contiguous transported prefix accepted as resolved. The first +`'data-only'` match is the presentation continuation boundary, but it does not +truncate semantic adoption: later transported successful data-only matches are +also committed and keep their server loader and `beforeLoad` results. A `false`, +pending, or otherwise unresolved match is the first semantic continuation +boundary and is not committed. Presentation can still contain the complete +candidate lane and marks its first presentation boundary pending as needed. In +particular, a shorter non-terminal server payload ending in a pending +`ssr: false` match is a valid selective-SSR handoff: hydration accepts the +resolved ancestors, presents the complete local branch, and lets the ordinary +initial client load execute from that client-only boundary. + +For a successfully reconstructed terminal handoff, transport remains +prefix-capped but committed and presented membership use the complete locally +matched branch. Matches below the terminal boundary remain unresolved and +hidden; they own no transported `beforeLoad` result or loader data and do not +execute during hydration. This keeps public membership and lifecycle stable +without increasing the SSR payload or loading unreachable client chunks. If +reconstruction fails, only the accepted prefix is committed as described above; +the complete branch remains presentation, not semantic reuse authority. + +Only the subsequent ordinary client load may transfer the whole hydration +prefix, and only while the exact committed-prefix owner, structurally shared +parsed history-state generation, and live hydration controller remain current +with no active transaction. The payload does not serialize a second URL +authority: Core captures the reconstructed browser generation only to prevent a +supported reentrant lifecycle event from handing document work to a successor +location. The transported prefix must also pass finish-time match-ID validation. +Core does not coordinate speculative work started between raw `hydrate()` and +that load; framework adapters own the supported ordering. + +The two-phase transfer proceeds as follows: + +1. Before public navigation events or matching, the initial client-load planner + probes the capability without consuming it. +2. It installs its own `_preflight`, emits the events, matches a private lane, + and asks the capability to finish the transfer. +3. Finish revalidates capability identity, transaction absence, hydration + controller liveness, captured location identity, and the exact committed + owner. +4. On rejection, the capability clears itself before aborting its controller, + so abort listeners cannot reenter and claim a rejected handoff. +5. On acceptance, the prefix replaces the planner's private copies. A terminal + prefix releases and removes its suffix; otherwise the suffix transfers to the + hydration controller so one signal owns the continued lane. +6. The capability remains available across synchronous reentrancy until the + current load installs `_tx`. That load installs `_tx` before clearing the + capability; stale planners may release only their own private work. + +This ordering closes the gap between hydration publication and client +transaction installation without making the handoff an independent completion +authority. If reconstruction is superseded before publication, `_preflight` +currentness aborts its private work. If the published handoff becomes +incompatible, its compare-and-swap rejection retires the hydration controller +and ordinary client loading starts from a fresh preflight. + +Generic framework `RouterClient` components signal streaming hydration +completion after the hydration attempt settles, including rejection. This +finally-style handoff allows bootstrap globals to be removed once the server +stream has also ended without stranding the stream on a hydration failure. + +Each framework owns one module-level hydration promise for the document. The +SSR protocol provides one global bootstrap and one `RouterClient`; additional +client-only routers use `RouterProvider` and do not independently consume that +bootstrap. The promise deduplicates framework rendering/replay only. It replaces +neither `_preflight` reconstruction authority nor `_handoff` +continuation authority. + +## Resource and reentrancy checklist + +When changing this architecture, verify all of the following: + +1. Only current `_tx` commits, redirects, or completes a client navigation. +2. Only current `_preflight` may publish a client plan or asynchronous hydration + reconstruction; it is installed before supported reentrant lifecycle events + and route callbacks. After hydration publication, only the exact + `_handoff` may transfer that prefix to the initial client load, + which installs `_tx` before retiring the capability. Preloads never claim + the handoff. +3. Every async write checks its authority immediately before mutation or + publication. +4. `_committed`, not pending presentation, is the lifecycle and CAS base; + cache-first same-ID matching is the deliberate loader-generation seed. +5. Pending publication contains the full destination lane but owns no flights; + a successful loaderless ancestor is not needlessly returned to pending. +6. Each successful preload loader generation may enter the cache as it settles, + even if its whole lane later becomes terminal or is canceled. It may retain + same-ID route-local `_ctx`, but never merged context or `beforeLoad` output. +7. Active preload adoption is identical-whole-lane only, including router + context, additional context, and user-supplied location state. It also + requires the semantic committed owner captured during planning to remain + current; rejected lanes may donate loader flights, never `beforeLoad` + context. Retrying a terminal adopted lane may retain every independently + successful loader generation, but rebuilds merged context and reruns the + serial `beforeLoad` chain. +8. Hydration is the only completed-work exception for `beforeLoad` reuse. It + verifies each transported match identity, reconstructs the accepted ordered + prefix, reruns route context locally, preserves terminal/selective-SSR + authority, and presents the full local branch while executing only the + accepted prefix. Its two-phase handoff is claimable only by the supported + initial document load and revalidates the live controller, captured location, + and exact committed owner. The framework's exact-document contract remains + the URL authority; the captured location rejects supported reentrant + successors, while compact serialized match IDs guard prefix compatibility. + It clears before abort on rejection and remains reclaimable until `_tx` + exists. Framework adapters start that load before descendant route code can + create unsupported speculative work in the handoff gap. +9. Match-id cache compatibility and route-id lifecycle identity are not mixed. +10. Cache publication retains only the generation allowed by its planning CAS; + same-ID committed and cached generations invalidate together, and accepted + recipients are installed before replaced resources are released. +11. The registry is the sole discovery authority for the latest same-ID flight + started by navigation, preload, or background work. Registry joinability + and lease ownership remain separate; donor selection happens synchronously + after the reload decision, and accepted signal lifetime may outlive promise + settlement. +12. Child `parentMatchPromise` follows the fresh semantic parent generation. +13. The first parallel ordinary failure is chronological unless a required + ancestor failure has no accepted loader data or its required chunk fails; + any started descendant redirect can still win as control flow. +14. Projection errors are logged and swallowed, and global path misses retain + their `_notFound` representation, which may be a successful fuzzy/root + match without an attached error. +15. Terminal outcomes preserve the complete structurally matched branch; + rendering, head/scripts, headers, manifest assets, dehydration, and cache + exclusion derive the same cutoff, while SSR transport may cap its payload + at the terminal prefix. +16. Every discarded semantic match and background candidate releases resources + exactly once; bulk release detaches every lease and registry entry before + aborting any collected flight controller. +17. A background write checks both writer and exact base identity. A successor + may join its flight but never adopts its candidate lane. +18. Background publication waits for the foreground acknowledgement, then + remains detached from foreground completion. Its eager settlement witness + is retained across that wait so chronological failure/control selection is + not recomputed later. +19. `isFetching` clears for success, failure, cancellation, and supersession. +20. Transition acknowledgement settlement gates resolved/idle completion; React + identifies the exact generation by length plus ordered ID, controller, and + status, and installs it in one superseding slot before running the possibly + reentrant transition callback. Only `true` gates `onRendered` and pending + minimum timing. +21. The single Transitioner and its acknowledgement machinery live for the + router's entire application lifetime. +22. Reentrant callbacks suppress every stale later event or publication. +23. Canonicalization compares browser-facing `publicHref`; semantic `href` is + not a symmetric canonical key across input and output rewrites. +24. Request abort can settle every awaited server phase; late work cannot regain + response or injection authority. +25. Exactly one of the request handler or an accepted stream owns SSR cleanup. + Accepted streams remain bound to request abort, and disposal releases router + state before best-effort body cancellation. +26. Cleanup prevents a still-pending custom dehydration from beginning + serialization. + +If a fix violates one of these, consolidate ownership instead of layering a +special case on top. + +## Testing changes + +Tests must assert public behavior, not the internal tuple shape, phase tag, +private promise, timer, or field name used to implement it. + +Useful assertions include: + +- rendered pending/error/not-found content and the boundary that rendered it; +- the complete public matches array and `isFetching` transitions; +- loader/before-load call counts, contexts, parent promises, and abort signals; +- navigation completion, lifecycle callbacks, and their surrounding router + events, without asserting a relative `onEnter`/`onStay` order; +- preload/cache reuse observable through user loader calls; +- HTTP status, headers, redirects, and absence of renderer invocation; +- request-abort settlement, late-stream disposal, and single stream cleanup; +- hydration output, completion on rejection, and absence of client reruns below + server terminal boundaries; +- absence of stale data/assets after supersession; and +- framework acknowledgement tied to the actual rendered destination. + +While `tx.done` suspends a pending React destination, React may retain the previous +source tree in the DOM but hide it. Pending tests should assert visible user +content, not physical absence of the old route's nodes. + +When testing a boundary, give root, parent, and child visibly distinct output. +An assertion such as `/Not Found/` or `/Error/` can pass at the wrong boundary +and is not sufficient. + +Run focused category tests while changing the loader, then affected core and +framework unit/type suites, selective-SSR E2E tests for server/hydration changes, +and the bundle-size benchmark for any client runtime change. diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 852d186b67..41fd4018b5 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -9,7 +9,7 @@ import type { RouteIds, } from './routeInfo' import type { AnyRouter, RegisteredRouter, SSROption } from './router' -import type { Constrain, ControlledPromise } from './utils' +import type { Constrain } from './utils' export type AnyMatchAndValue = { match: any; value: any } @@ -131,47 +131,32 @@ export interface RouteMatch< pathname: string params: TAllParams _strictParams: TAllParams - status: 'pending' | 'success' | 'error' | 'redirected' | 'notFound' + status: 'pending' | 'success' | 'error' | 'notFound' isFetching: false | 'beforeLoad' | 'loader' error: unknown paramsError: unknown searchError: unknown updatedAt: number - _nonReactive: { - /** @internal */ - beforeLoadPromise?: ControlledPromise - /** @internal */ - loaderPromise?: ControlledPromise - /** @internal */ - pendingTimeout?: ReturnType - loadPromise?: ControlledPromise - displayPendingPromise?: Promise - minPendingPromise?: ControlledPromise - dehydrated?: boolean - /** @internal */ - error?: unknown - } loaderData?: TLoaderData + /** @internal Exclusive end of the SSR-verified asset prefix. */ + _assetEnd?: number /** @internal */ - __routeContext?: Record + _ctx?: Record /** @internal */ __beforeLoadContext?: Record context: TAllContext search: TFullSearchSchema _strictSearch: TFullSearchSchema - fetchCount: number abortController: AbortController cause: 'preload' | 'enter' | 'stay' loaderDeps: TLoaderDeps preload: boolean invalid: boolean headers?: Record - globalNotFound?: boolean + _notFound?: boolean staticData: StaticDataRouteOption /** This attribute is not reactive */ ssr?: SSROption - _forcePending?: boolean - _displayPending?: boolean } export interface PreValidationErrorHandlingRouteMatch< diff --git a/packages/router-core/src/await-signal.ts b/packages/router-core/src/await-signal.ts new file mode 100644 index 0000000000..c3506e2abd --- /dev/null +++ b/packages/router-core/src/await-signal.ts @@ -0,0 +1,27 @@ +export function waitForReason( + value: T | PromiseLike, + signal: AbortSignal, + onLate?: (value: T) => void, +): Promise { + const promise = Promise.resolve(value) + if (signal.aborted) { + if (!onLate) { + return Promise.race([Promise.reject(signal.reason), promise]) + } + void promise.then(onLate, () => {}) + return Promise.reject(signal.reason) + } + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + promise + .then((result) => { + if (signal.aborted) { + onLate?.(result) + } else { + resolve(result) + } + }, reject) + .finally(() => signal.removeEventListener('abort', abort)) + }) +} diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index fd673ca410..a296629ffa 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -103,6 +103,7 @@ export { resolveManifestCssLink, } from './manifest' export { isMatch } from './Matches' +export { _getAssetMatches, _getRenderedMatches } from './load-client' export type { AnyMatchAndValue, FindValueByIndex, @@ -242,7 +243,6 @@ export { SearchParamError, PathParamError, getInitialRouterState, - getMatchedRoutes, trailingSlashOptions, } from './router' @@ -274,9 +274,7 @@ export type { InjectedHtmlEntry, EmitFn, LoadFn, - GetMatchFn, SubscribeFn, - UpdateMatchFn, CommitLocationFn, GetMatchRoutesFn, MatchRoutesFn, diff --git a/packages/router-core/src/isServer/client.ts b/packages/router-core/src/isServer/client.ts index f2f86b6ec7..e12407f409 100644 --- a/packages/router-core/src/isServer/client.ts +++ b/packages/router-core/src/isServer/client.ts @@ -1 +1,2 @@ export const isServer = false +export const loadServerRoute: never = undefined as never diff --git a/packages/router-core/src/isServer/development.ts b/packages/router-core/src/isServer/development.ts index 3632fabf05..e4f13879ad 100644 --- a/packages/router-core/src/isServer/development.ts +++ b/packages/router-core/src/isServer/development.ts @@ -1,2 +1,3 @@ // Development/test mode - returns undefined so fallback to router.isServer is used export const isServer: boolean | undefined = undefined +export { loadServerRoute } from '../load-server' diff --git a/packages/router-core/src/isServer/server.ts b/packages/router-core/src/isServer/server.ts index cfa181440a..d13401a9e9 100644 --- a/packages/router-core/src/isServer/server.ts +++ b/packages/router-core/src/isServer/server.ts @@ -1 +1,2 @@ export const isServer = process.env.NODE_ENV === 'test' ? undefined : true +export { loadServerRoute } from '../load-server' diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts new file mode 100644 index 0000000000..897d479e09 --- /dev/null +++ b/packages/router-core/src/load-client.ts @@ -0,0 +1,2701 @@ +// Keep this filename free of a secondary extension so declaration generation +// can rewrite relative imports for both ESM and CJS. +import { isNotFound } from './not-found' +import { isRedirect } from './redirect' +import { + _getUserHistoryState, + getLocationChangeInfo, + runRouteLifecycle, +} from './router' +import { deepEqual } from './utils' +import { hydrateSsrMatchId } from './ssr/ssr-match-id' +import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './ssr/constants' +import type { AnySerializationAdapter } from './ssr/serializer/transformer' +import type { TsrSsrGlobal } from './ssr/types' +import type { ParsedLocation } from './location' +import type { AnyRouteMatch } from './Matches' +import type { NotFoundError } from './not-found' +import type { + AnyRoute, + BeforeLoadContextOptions, + LoaderFnContext, + RouteContextOptions, + RouteLoaderFn, +} from './route' +import type { AnyRedirect } from './redirect' +import type { AnyRouter } from './router' + +type RouteComponentType = + | 'component' + | 'pendingComponent' + | 'errorComponent' + | 'notFoundComponent' + +export function replaceRouteChunk( + route: AnyRoute, + lazyFn: AnyRoute['lazyFn'], +): void { + route.lazyFn = lazyFn ?? route.lazyFn + route._lazy = undefined +} + +function preloadComponent( + route: AnyRoute, + type: RouteComponentType, +): Promise | undefined { + return (route.options[type] as any)?.preload?.() +} + +function loadComponents(route: AnyRoute): Promise | undefined { + const component = preloadComponent(route, 'component') + const pending = preloadComponent(route, 'pendingComponent') + if (component && pending) { + return Promise.all([component, pending]).then(() => {}) + } + return component ?? pending +} + +export function loadRouteChunk( + route: AnyRoute, + // `false` waits only for lazy route options, before a boundary is selected. + componentType?: 'errorComponent' | 'notFoundComponent' | false, +): Promise | undefined { + const afterLazy = () => + componentType === false + ? undefined + : componentType + ? preloadComponent(route, componentType) + : loadComponents(route) + const current = route._lazy + if (current) { + return current === true ? afterLazy() : current.then(afterLazy) + } + if (!route.lazyFn) { + return afterLazy() + } + + const promise = route.lazyFn().then( + (lazyRoute) => { + // HMR clears the owner before an obsolete import can settle. + if (process.env.NODE_ENV === 'production' || route._lazy === promise) { + const { id: _id, ...options } = lazyRoute.options + Object.assign(route.options, options) + route._lazy = true + } + }, + (error) => { + if (process.env.NODE_ENV === 'production' || route._lazy === promise) { + route._lazy = undefined + } + throw error + }, + ) + route._lazy = promise + return promise.then(afterLazy) +} + +/** Return the structural lane through the first terminal render boundary. */ +export function _getRenderedMatches( + matches: Array, +): Array { + const end = + matches.findIndex( + (match) => match.status !== 'success' || match._notFound, + ) + 1 + return end && end < matches.length ? matches.slice(0, end) : matches +} + +/** Return the lane whose document assets belong to the current presentation. */ +export function _getAssetMatches( + matches: Array, +): Array { + let end = matches.length + for (let index = 0; index < end; index++) { + const match = matches[index]! + // `_assetEnd` is only ever set on hydration presentation clones that are + // `status: 'pending'`, `ssr: 'data-only'`, error-free, and not not-found + // (see hydrate.ts), and commits clear it — so its presence alone is the guard. + if (match._assetEnd !== undefined) { + end = Math.min(end, Math.max(index + 1, match._assetEnd)) + continue + } + if (match.status !== 'success' || match._notFound) { + end = index + 1 + break + } + } + // `end` only ever shrinks to `index + 1 >= 1`, so no zero guard is needed. + return end < matches.length ? matches.slice(0, end) : matches +} + +declare const lanePhase: unique symbol + +type LanePhase = 'matched' | 'contextualized' | 'reduced' | 'projected' + +/** + * Lane matches carry their lane's phase so functions can demand evidence of + * pipeline position (e.g. `commitMatches` only accepts a projected lane's + * matches). The brand is phantom — it never exists at runtime. + */ +type LaneMatches = Array & { + readonly [lanePhase]?: TPhase +} + +type Lane = [ + location: ParsedLocation, + matches: LaneMatches, + background?: Array, + backgroundSettlement?: Promise, +] & { readonly [lanePhase]?: TPhase } + +type MatchedLane = Lane<'matched'> +type ContextualizedLane = Lane<'contextualized'> +type ReducedLane = Lane<'reduced'> +type ProjectedLane = Lane<'projected'> + +const SUCCESS = 0 +const ERROR = 1 +const NOT_FOUND = 2 +// Control outcomes stay contiguous so the hot path can test them together. +const REDIRECTED = 3 +const CANCELED = 4 + +type LoaderOutcome = + | [typeof SUCCESS, data: unknown] + | [typeof ERROR, error: unknown] + | [typeof NOT_FOUND, error: NotFoundError] + | [typeof REDIRECTED, redirect: AnyRedirect] + | [typeof CANCELED] + +type IndexedOutcome = [index: number, outcome: LoaderOutcome, boundary?: number] + +export type LoaderFlight = [ + outcome: Promise, + controller: AbortController, + leases: number, +] + +type WorkMatch = AnyRouteMatch & { + _flight?: LoaderFlight +} + +declare const matchPhase: unique symbol + +/** + * A match whose loader outcome has been applied by `settleInto`, which is the + * sole granter of this brand (phantom, zero-runtime). Consumers that require + * it — e.g. `cacheLoaderMatch` — can only be reached after settlement, so the + * compiler enforces the loader→settle→cache ordering. Sources that arrive + * already settled (dehydrated server data) must cast at a named boundary. + */ +type SettledMatch = WorkMatch & { readonly [matchPhase]: 'settled' } + +export type LaneInputs = [ + routeTree: AnyRoute, + context: unknown, + additionalContext: unknown, + state: object, + search: object, + maskedLocation: + | [ + href: string, + state: object, + search: object, + unmaskOnReload: boolean | undefined, + ] + | undefined, +] + +export type ActivePreload = [ + matches: Array, + controller: AbortController, + result: Promise, + semanticOwner: Array, + inputs: LaneInputs, + redirects: number, +] + +export type LoadTransaction = [ + controller: AbortController, + redirects: number, + location: ParsedLocation, + matches: Array, + startedAt: number, + done: Promise, + /** + * Dev-only HMR refresh mode. Presence is the mode flag; a refresh always + * carries the presentation it started from, while the hydration handoff is + * genuinely optional — the tuple makes a half-armed refresh unrepresentable. + */ + refresh?: [ + presentation: Array, + handoff: NonNullable | undefined, + ], +] + +export type PendingSession = [ + owner: LoadTransaction, + boundary: number, + /** Pending reveal time until acknowledged, then minimum-visible-until time. */ + deadline: number, + timer?: ReturnType, + ack?: Promise, + component?: unknown, +] + +type CoordinatorRouter = AnyRouter & { + /** Whole speculative lanes that a matching navigation may adopt. */ + _preloads?: Map + _refreshNextLoad?: boolean + _rollbackRefresh?: () => void + _cancelTransition?: () => void +} + +type PublicationCheckpoint = { + previousMatches: Array + previousPresentation: Array + previousCache: Map + commitPromise: CoordinatorRouter['_commitPromise'] + published: boolean +} + +type LoaderTask = [ + index: number, + outcome: Promise, + ready: Promise, + candidate?: WorkMatch, +] + +type BackgroundLoaderTask = [ + index: number, + outcome: Promise, + ready: Promise, + candidate: WorkMatch, +] + +type ExecuteLaneOptions = [ + controller: AbortController, + redirects: number, + isCurrent: () => boolean, + base: Array, + preload?: boolean, + sync?: boolean, + forceStaleReload?: boolean, + resolvedPrefix?: number, + onReady?: () => void, +] + +type ControlOutcome = + | [typeof REDIRECTED, redirect: AnyRedirect] + | [typeof CANCELED] + +type LaneResult = ProjectedLane | ControlOutcome + +function isControl( + result: Lane | ControlOutcome, +): result is ControlOutcome { + return typeof result[0] === 'number' +} + +export function waitFor( + value: T | PromiseLike, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + return Promise.race([Promise.reject(signal), value]) + } + return new Promise((resolve, reject) => { + const abort = () => reject(signal) + signal.addEventListener('abort', abort, { once: true }) + Promise.resolve(value) + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', abort)) + }) +} + +export function getRoute(router: AnyRouter, match: WorkMatch): AnyRoute { + return (router.routesById as Record)[match.routeId]! +} + +function normalize( + value: unknown, + rejected: boolean, + routeId?: string, +): LoaderOutcome { + if (isRedirect(value)) { + return [REDIRECTED, value] + } + if (isNotFound(value)) { + value.routeId ||= routeId + return [NOT_FOUND, value] + } + if (rejected && typeof (value as any)?.then === 'function') { + value = new Error('A Promise was thrown', { cause: value }) + } + return rejected ? [ERROR, value] : [SUCCESS, value] +} + +function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome { + let outcome = normalize(cause, true, route.id) + if (outcome[0] !== ERROR) { + return outcome + } + try { + route.options.onError?.(outcome[1]) + } catch (onErrorCause) { + outcome = normalize(onErrorCause, true, route.id) + } + return outcome +} + +function normalizeLaneError( + route: AnyRoute, + cause: unknown, + options: ExecuteLaneOptions, +): LoaderOutcome { + if (options[0].signal.aborted || !options[2]()) { + options[0].abort() + return [CANCELED] + } + return normalizeError(route, cause) +} + +export function navigateFrom(router: AnyRouter, location: ParsedLocation) { + return (opts: any) => + router.navigate({ + ...opts, + _fromLocation: location, + }) +} + +async function contextualize( + router: AnyRouter, + lane: MatchedLane, + options: ExecuteLaneOptions, + end: number, +): Promise { + const [location, matches] = lane + const signal = options[0].signal + const preload = !!options[4] + for (let index = options[7] ?? 0; index < end; index++) { + const match = matches[index]! + const route = getRoute(router, match) + + match.abortController = options[0] + // Contextualization is serial, so the previous match already contains the + // complete parent context for this route. + const parentContext = + matches[index - 1]?.context ?? router.options.context ?? {} + const common = { + params: match.params, + location, + navigate: navigateFrom(router, location), + buildLocation: router.buildLocation, + cause: preload ? ('preload' as const) : match.cause, + abortController: options[0], + preload, + matches, + routeId: route.id, + } + let context = parentContext + try { + let routeContext = match._ctx + if (!routeContext && route.options.context) { + routeContext = match._ctx = + route.options.context({ + ...common, + deps: match.loaderDeps, + context: parentContext, + } satisfies RouteContextOptions) || {} + } + context = { + ...parentContext, + ...routeContext, + } + match.context = context + } catch (cause) { + releaseFlight(router, match) + return [index, normalizeLaneError(route, cause, options)] + } + if (signal.aborted || !options[2]()) { + options[0].abort() + return [index, [CANCELED]] + } + const validationError = match.paramsError ?? match.searchError + if (validationError !== undefined) { + releaseFlight(router, match) + return [index, normalizeLaneError(route, validationError, options)] + } + const beforeLoad = route.options.beforeLoad + if (!beforeLoad) { + continue + } + + const beforeLoadContext: BeforeLoadContextOptions< + any, + any, + any, + any, + any, + any, + any, + any, + any + > = { + ...common, + search: match.search, + context, + ...router.options.additionalContext, + } + + const previousStatus = match.status + if (previousStatus === 'success') { + match.status = 'pending' + } + options[8]?.() + try { + setFetching(router, match, 'beforeLoad', options[0]) + const result = await waitFor(beforeLoad(beforeLoadContext), signal) + if (!options[2]()) { + options[0].abort() + return [index, [CANCELED]] + } + const outcome = normalize(result, false, route.id) + if (outcome[0] !== SUCCESS) { + releaseFlight(router, match) + return [index, outcome] + } + match.context = { + ...context, + ...result, + } + } catch (cause) { + releaseFlight(router, match) + return [index, normalizeLaneError(route, cause, options)] + } finally { + if (previousStatus === 'success' && match.status === 'pending') { + match.status = 'success' + } + setFetching(router, match, false, options[0]) + } + } + + return +} + +function releaseOwnedFlight( + router: AnyRouter, + id: string, + flight?: LoaderFlight, +): AbortController | undefined { + if (!flight || --flight[2]) { + return + } + if (router._flights?.get(id) === flight) { + router._flights.delete(id) + } + return flight[1] +} + +function releaseFlight(router: AnyRouter, match: WorkMatch): void { + const flight = match._flight + match._flight = undefined + releaseOwnedFlight(router, match.id, flight)?.abort() +} +export function laneInputs( + router: AnyRouter, + location: ParsedLocation, +): LaneInputs { + const masked = location.maskedLocation + return [ + router.routeTree, + router.options.context ?? {}, + router.options.additionalContext, + _getUserHistoryState(location.state), + location.search, + masked && [ + masked.href, + _getUserHistoryState(masked.state), + masked.search, + masked.unmaskOnReload, + ], + ] +} + +function samePreloadLane( + preload: ActivePreload, + router: AnyRouter, + location: ParsedLocation, + redirects: number, +): boolean { + return ( + preload[3] === router._committed && + preload[5] === redirects && + deepEqual(preload[4], laneInputs(router, location)) && + !preload[0].some( + (match) => getRoute(router, match as WorkMatch).options.preload === false, + ) + ) +} + +/** + * Not passing in a `next` ownership recipient + * is equivalent to discarding the match resources + */ +export function transferMatchResources( + router: AnyRouter, + previous: Array, + next?: Array, +): void { + const abort: Array = [] + for (const match of previous as Array) { + if (!next?.includes(match)) { + const flight = match._flight + match._flight = undefined + const controller = releaseOwnedFlight(router, match.id, flight) + if (controller) { + abort.push(controller) + } + } + } + for (const controller of abort) { + controller.abort() + } +} + +function discardPreload(router: AnyRouter, preload: ActivePreload): void { + preload[1].abort() + transferMatchResources(router, preload[0]) +} + +function acquireMatchResources(matches: Array): void { + for (const match of matches as Array) { + const flight = match._flight + if (flight) { + flight[2]++ + } + } +} + +function setFetching( + router: AnyRouter, + match: WorkMatch, + value: AnyRouteMatch['isFetching'], + owner?: AbortController, +): void { + match.isFetching = value + if (owner && router._tx?.[0] !== owner) { + return + } + const store = router.stores.byRoute.get(match.routeId) + const presented = store?.get() + if (presented?.id === match.id) { + store!.set({ ...presented, isFetching: value }) + } +} + +function getLoaderContext( + router: AnyRouter, + lane: ContextualizedLane, + match: WorkMatch, + route: AnyRoute, + controller: AbortController, + parentMatchPromise: Promise | undefined, + preload: boolean, +): LoaderFnContext { + const location = lane[0] + return { + params: match.params, + location, + navigate: navigateFrom(router, location), + cause: preload ? ('preload' as const) : match.cause, + abortController: controller, + preload, + deps: match.loaderDeps, + parentMatchPromise: parentMatchPromise as any, + context: match.context, + route, + ...router.options.additionalContext, + } +} + +async function loadResource( + router: AnyRouter, + lane: ContextualizedLane, + match: WorkMatch, + route: AnyRoute, + loader: RouteLoaderFn | undefined, + parentMatchPromise: Promise | undefined, + preload: boolean, + owner: AbortController, +): Promise { + const signal = owner.signal + if (signal.aborted) { + return [CANCELED] + } + if (!loader) { + return [SUCCESS, undefined] + } + + let flight = match._flight + let joined = !!flight + setFetching(router, match, 'loader', owner) + try { + for (;;) { + if (!flight) { + const controller = new AbortController() + const outcome = Promise.resolve() + .then(() => + loader( + getLoaderContext( + router, + lane, + match, + route, + controller, + parentMatchPromise, + preload, + ), + ), + ) + .then( + (value) => normalize(value, false, route.id), + (cause) => normalize(cause, true, route.id), + ) + .then((result): LoaderOutcome => { + return result[0] === ERROR && match._flight === flight + ? normalizeError(route, result[1]) + : result + }) + flight = [outcome, controller, 1] + ;(router._flights ??= new Map()).set(match.id, flight) + } + match._flight = flight + match.abortController = flight[1] + try { + const outcome = await waitFor(flight[0], signal) + if (!joined || outcome[0] === SUCCESS || outcome[0] === REDIRECTED) { + return outcome + } + } catch (cause) { + if (cause === signal) { + releaseFlight(router, match) + return [CANCELED] + } + throw cause + } + releaseFlight(router, match) + if (signal.aborted) { + return [CANCELED] + } + flight = undefined + joined = false + } + } finally { + setFetching(router, match, false, owner) + } +} + +function settleInto( + match: WorkMatch, + result: LoaderOutcome, + preload: boolean, +): asserts match is SettledMatch { + if (result[0] === SUCCESS) { + match.loaderData = result[1] + match.error = undefined + match.status = 'success' + match.invalid = false + match.updatedAt = Date.now() + match.preload = preload + } else if (result[0] !== REDIRECTED) { + // Reduction installs only the selected terminal failure. Every other + // settled attempt remains a renderable, stale match in that lane. + match.status = 'success' + match.error = undefined + match.invalid = true + } +} + +export function cacheLoaderMatch( + router: CoordinatorRouter, + match: SettledMatch, + planned: AnyRouteMatch | undefined, +): void { + const current = router._cache.get(match.id) as WorkMatch | undefined + if ( + current !== planned || + router._committed.some( + (candidate) => + candidate.id === match.id && + (candidate as WorkMatch)._flight === match._flight, + ) + ) { + return + } + const cached = { + ...match, + _notFound: undefined, + context: {}, + } as WorkMatch + if (cached._flight) { + cached._flight[2]++ + } + router._cache.set(match.id, cached) + if (current) { + releaseFlight(router, current) + } +} + +function getParentSnapshot( + match: WorkMatch, + outcome: LoaderOutcome, +): WorkMatch { + if (outcome[0] === ERROR || outcome[0] === NOT_FOUND) { + return { + ...match, + status: outcome[0] === ERROR ? 'error' : 'notFound', + error: outcome[1], + _flight: undefined, + } + } + return match +} + +function createLoaderTask( + router: AnyRouter, + lane: ContextualizedLane, + index: number, + tasks: Array, + semanticParent: Promise | undefined, + options: ExecuteLaneOptions, +): Promise { + const match = lane[1][index]! + const route = getRoute(router, match) + const preload = !!options[4] + const plannedCacheMatch = preload ? router._cache.get(match.id) : undefined + let reload = false + let reloadFailure: LoaderOutcome | undefined + try { + let configured + if (match.status === 'success') { + configured = route.options.shouldReload + if (typeof configured === 'function') { + configured = configured( + getLoaderContext( + router, + lane, + match, + route, + options[0], + semanticParent, + preload, + ), + ) + } + if (!options[2]()) { + options[0].abort() + reloadFailure = [CANCELED] + } + } + if (!reloadFailure) { + if (match.status !== 'success') { + reload = true + } else { + const staleAge = + options[4] || match.preload + ? (route.options.preloadStaleTime ?? + router.options.defaultPreloadStaleTime ?? + 30_000) + : (route.options.staleTime ?? router.options.defaultStaleTime ?? 0) + reload = !!( + match.invalid || + configured || + (configured === undefined && + Date.now() - match.updatedAt >= staleAge && + (options[6] || + match.cause === 'enter' || + options[3].some( + (candidate) => + candidate.routeId === match.routeId && + candidate.id !== match.id, + ))) + ) + } + } + } catch (cause) { + match.invalid = true + releaseFlight(router, match) + reloadFailure = normalizeLaneError(route, cause, options) + } + const routeLoader = route.options.loader + const loader = + typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler + const background = !!( + routeLoader && + reload && + match.status === 'success' && + !preload && + !options[5] && + ((typeof routeLoader === 'function' + ? undefined + : routeLoader?.staleReloadMode) ?? + router.options.defaultStaleReloadMode) !== 'blocking' + ) + const loaded = reload && (!preload || route.options.preload !== false) + const blocking = + loaded && !background && (match.status !== 'success' || !!routeLoader) + if (loaded && !routeLoader) { + match.invalid = false + match.updatedAt = Date.now() + } + let donor = loaded && routeLoader ? router._flights?.get(match.id) : undefined + if (donor === match._flight) { + donor = undefined + } else if (donor) { + donor[2]++ + } + if (blocking) { + const acceptedFlight = match._flight + match._flight = donor + releaseOwnedFlight(router, match.id, acceptedFlight)?.abort() + // A successful route without a loader has no blocking work to present. It + // still gets a task so its chunk and derived assets participate in the + // lane, but putting it back into pending would hide an already-rendered + // ancestor while only a descendant is loading. + if (match.status === 'success') { + match.status = 'pending' + } + options[8]?.() + } + if (!loaded) { + match.isFetching = false + } + const rawOutcome = reloadFailure + ? Promise.resolve(reloadFailure) + : !blocking + ? Promise.resolve([SUCCESS, match.loaderData]) + : loadResource( + router, + lane, + match, + route, + loader, + semanticParent, + preload, + options[0], + ) + const outcome = rawOutcome.then((result) => { + if (blocking) { + settleInto(match, result, preload) + if (result[0] === SUCCESS) { + if (preload && routeLoader) { + cacheLoaderMatch(router, match, plannedCacheMatch) + } + // A route is renderable only after both its data and normal component + // chunk are ready. Its loader data is already available to descendants. + match.status = 'pending' + } + } + return result + }) + + const rawChunkFailure = waitFor( + Promise.resolve().then(() => loadRouteChunk(route)), + options[0].signal, + ).then( + () => { + options[8]?.() + return undefined + }, + (cause): IndexedOutcome => [ + index, + normalizeLaneError(route, cause, options), + ], + ) + const chunkFailure = rawChunkFailure.then((failure) => + outcome.then((result) => { + if ( + blocking && + !failure && + result[0] === SUCCESS && + match.status === 'pending' && + options[2]() + ) { + match.status = 'success' + options[8]?.() + } + return failure + }), + ) + tasks.push([index, outcome, chunkFailure]) + if (!background) { + return outcome.then((result) => getParentSnapshot(match, result)) + } + const candidate: WorkMatch = { + ...match, + status: 'pending', + preload: false, + _flight: donor, + } + match.invalid = false + match.isFetching = 'loader' + const backgroundOutcome = loadResource( + router, + lane, + candidate, + route, + loader, + semanticParent, + false, + options[0], + ).then((result) => { + match.isFetching = false + settleInto(candidate, result, false) + return result + }) + ;(lane[2] ??= []).push([index, backgroundOutcome, chunkFailure, candidate]) + return backgroundOutcome.then((result) => + getParentSnapshot(candidate, result), + ) +} + +async function getNotFoundBoundary( + router: AnyRouter, + matches: Array, + indexed: IndexedOutcome | undefined, + signal: AbortSignal, + fallback = 0, +): Promise { + const cause = indexed?.[1][1] as NotFoundError | undefined + let index = cause?.routeId + ? matches.findIndex((match) => match.routeId === cause.routeId) + : (indexed?.[0] ?? matches.length - 1) + if (index < 0) { + index = 0 + } + for (let i = index; i >= 0; i--) { + const route = getRoute(router, matches[i]!) + const loading = loadRouteChunk(route, false) + if (loading) { + try { + await waitFor(loading, signal) + } catch (cause) { + if (cause === signal) { + throw cause + } + } + } + if (route.options.notFoundComponent) { + return i + } + } + return cause?.routeId ? index : fallback +} + +function discardBackground(router: AnyRouter, lane: Lane): void { + if (lane[2]) { + transferMatchResources( + router, + lane[2].map((task) => task[3]), + ) + lane[2] = undefined + } +} + +async function settleTasks( + tasks: Array, + serialFailure?: IndexedOutcome, + redirectTasks?: Array, + gate?: number | Promise, +): Promise { + let loaderFailure: IndexedOutcome | undefined + + try { + await Promise.all( + tasks.map((task) => + task[1].then(async (outcome) => { + const taskIndex = task[0] + if (gate && taskIndex >= (await gate)) { + return + } + if (outcome[0] >= REDIRECTED) { + throw [taskIndex, outcome] as IndexedOutcome + } + if (!loaderFailure && outcome[0] !== SUCCESS) { + loaderFailure = [taskIndex, outcome] + // Every started descendant must settle before an ordinary failure + // wins because a redirect from any of them remains control flow. + await Promise.all( + (redirectTasks ?? []).map((nextTask) => { + if (nextTask[0] <= taskIndex) { + return + } + return nextTask[1].then((nextOutcome) => { + if (nextOutcome[0] === REDIRECTED) { + throw [nextTask[0], nextOutcome] as IndexedOutcome + } + }) + }), + ) + } + }), + ), + ) + } catch (cause) { + return cause as IndexedOutcome + } + return serialFailure ?? loaderFailure +} + +async function reduceLane( + router: AnyRouter, + lane: ContextualizedLane, + tasks: Array, + controller: AbortController, + redirects: number, + settlement: Promise, + onReady?: () => void, +): Promise { + const matches = lane[1] + let failure = await settlement + let redirectLimitExceeded = false + const plannedBoundary = matches.findIndex((match) => match._notFound) + const boundaryOf = (found: IndexedOutcome) => + found[1][0] === NOT_FOUND + ? getNotFoundBoundary(router, matches, found, controller.signal) + : found[0] + let readinessEnd = plannedBoundary < 0 ? matches.length : plannedBoundary + + if ((failure?.[1][0] ?? 0) >= REDIRECTED) { + readinessEnd = 0 + } else if (failure) { + readinessEnd = failure[2] ??= await boundaryOf(failure) + for (const task of tasks) { + if (task[0] >= readinessEnd) { + break + } + const outcome = await task[1] + // Presence means a loader previously succeeded, even with `undefined`. + if ( + outcome[0] !== SUCCESS && + outcome[0] < REDIRECTED && + !('loaderData' in matches[task[0]]!) + ) { + failure = [task[0], outcome] + readinessEnd = failure[2] = await boundaryOf(failure) + break + } + } + } + + for (const task of tasks) { + if (task[0] >= readinessEnd) { + break + } + const chunkFailure = await task[2] + if (!chunkFailure) { + continue + } + failure = chunkFailure + break + } + + if ((failure?.[1][0] ?? 0) >= REDIRECTED) { + const outcome = failure![1] + if ( + outcome[0] !== REDIRECTED || + outcome[1].options.reloadDocument || + redirects < 20 + ) { + discardBackground(router, lane) + return outcome as ControlOutcome + } + redirectLimitExceeded = true + failure = [0, [ERROR, new Error('Too many redirects')]] + } + + const boundary = failure + ? (failure[2] ?? (await boundaryOf(failure))) + : plannedBoundary + if (boundary >= 0) { + const outcome = failure?.[1] + const kind = outcome?.[0] + const match = matches[boundary]! + const cause = outcome?.[1] + const install = () => { + if (outcome) { + match._notFound = undefined + if (kind === ERROR) { + match.status = 'error' + } else { + ;(cause as NotFoundError).routeId = match.routeId + if (match.routeId === router.routeTree.id) { + match.status = 'success' + match._notFound = true + } else { + match.status = 'notFound' + } + } + match.error = cause + match.isFetching = false + } + } + install() + try { + await waitFor( + outcome + ? Promise.resolve().then(() => + loadRouteChunk( + getRoute(router, match), + kind === ERROR ? 'errorComponent' : 'notFoundComponent', + ), + ) + : Promise.all([ + loadRouteChunk(getRoute(router, match)), + loadRouteChunk(getRoute(router, match), 'notFoundComponent'), + ]), + controller.signal, + ) + } catch (cause) { + if (cause === controller.signal) { + discardBackground(router, lane) + return [CANCELED] + } + } + if (!outcome) { + match.status = 'success' + onReady?.() + } else if (redirectLimitExceeded) { + controller.abort() + await Promise.all([ + ...tasks.map((task) => task[1]), + ...tasks.map((task) => task[2]), + ...(lane[2] ?? []).map((task) => task[1]), + ]) + discardBackground(router, lane) + transferMatchResources(router, matches) + install() + } + } + + return lane as ReducedLane +} + +export async function projectLane( + router: AnyRouter, + lane: ReducedLane, + signal: AbortSignal, + start = 0, + end = lane[1].length, +): Promise { + const matches = lane[1] + for (let index = start; index < end; index++) { + const match = matches[index]! + const routeOptions = getRoute(router, match).options + if (routeOptions.head || routeOptions.scripts) { + try { + const context = { + ssr: router.options.ssr, + matches, + match, + params: match.params, + loaderData: match.loaderData, + } + const [head, scripts] = await waitFor( + Promise.all([ + routeOptions.head?.(context), + routeOptions.scripts?.(context), + ]), + signal, + ) + match.meta = head?.meta + match.links = head?.links + match.headScripts = head?.scripts + match.styles = head?.styles + match.scripts = scripts + } catch (cause) { + if (cause === signal) { + break + } + console.error(cause) + } + } + if (match.status !== 'success' || match._notFound) { + break + } + } + return lane as ProjectedLane +} + +async function executeClientLane( + router: AnyRouter, + location: ParsedLocation, + matches: Array, + options: ExecuteLaneOptions, +): Promise { + const matched = [location, matches as Array] as MatchedLane + let plannedBoundary = matches.findIndex((match) => match._notFound) + if (router.options.notFoundMode !== 'root' && plannedBoundary >= 0) { + const boundary = await getNotFoundBoundary( + router, + matched[1], + undefined, + options[0].signal, + plannedBoundary, + ) + if (boundary !== plannedBoundary) { + matches[plannedBoundary]!._notFound = undefined + matches[boundary]!._notFound = true + } + plannedBoundary = boundary + } + let end = plannedBoundary < 0 ? matches.length : plannedBoundary + 1 + // From here on `matched` is contextualized: `contextualize` communicates + // through mutation plus a failure return, so the phase brand is asserted at + // the two use sites below rather than granted by a (byte-costing) return. + const failure = await contextualize(router, matched, options, end) + if (failure) { + options[5] = true + } + const tasks: Array = [] + const start = options[7] ?? 0 + let semanticParent = start + ? Promise.resolve(matched[1][start - 1]!) + : undefined + end = failure?.[0] ?? end + if (failure?.[1][0] === NOT_FOUND) { + failure[2] = await getNotFoundBoundary( + router, + matched[1], + failure, + options[0].signal, + ) + end = Math.min(end, failure[2] + 1) + } else if ((failure?.[1][0] ?? 0) >= REDIRECTED) { + end = 0 + } + for (let index = start; index < end; index++) { + if (options[0].signal.aborted) { + break + } + semanticParent = createLoaderTask( + router, + matched as ContextualizedLane, + index, + tasks, + semanticParent, + options, + ) + } + let reduced: ReducedLane | ControlOutcome + try { + const reduction = reduceLane( + router, + matched as ContextualizedLane, + tasks, + options[0], + options[1], + settleTasks(tasks, failure, matched[2]), + options[8], + ) + if (matched[2]?.length) { + matched[3] = settleTasks( + matched[2], + undefined, + undefined, + reduction.then( + (foreground) => + isControl(foreground) + ? 0 + : _getRenderedMatches(foreground[1]).length, + () => 0, + ), + ) + } + reduced = await reduction + } catch (cause) { + discardBackground(router, matched) + throw cause + } + if (isControl(reduced)) { + return reduced + } + return projectLane( + router, + reduced, + options[0].signal, + options[7] === reduced[1].length ? options[7] : 0, + ) +} + +/** + * Finds the first route that should show pending UI and its two timing values. + * A fallback already on screen remains selected after its route loads, so we + * do not jump to a child fallback. Matches put back into pending by invalidation + * skip pendingMs, and a route without a usable fallback blocks pending UI for deeper routes. + */ +function pendingConfig( + router: AnyRouter, + matches: Array, +): + | [delay: number, boundary: number, min: number, component: unknown] + | undefined + | void { + const presented = router.stores.matches.get() + for (let index = 0; index < matches.length; index++) { + const match = matches[index]! + const success = match.status === 'success' + const visible = + success && + presented[index]?.id === match.id && + presented[index]?.status === 'pending' + if (success && !visible) { + continue + } + const route = getRoute(router, match as WorkMatch) + const delay = + visible || match.invalid + ? 0 + : (route.options.pendingMs ?? router.options.defaultPendingMs) + const component = + route.options.pendingComponent ?? + (router.options as any).defaultPendingComponent + return component && typeof delay === 'number' && delay !== Infinity + ? [ + delay, + index, + route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0, + component, + ] + : undefined + } +} + +/** + * Waits for `pendingMs`, then presents the complete lane. Rendering applies the + * selected boundary cutoff while retaining every match's structural state. + * A replacement load for the same match keeps the timer; choosing a different + * match resets it. `pendingMinMs` starts after the fallback renders. + */ +function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { + if (router._tx !== tx) { + return + } + let session = router._pending + let tookOver = false + const sessionMatchId = session?.[0][3][session[1]]?.id + if (session?.[0] !== tx) { + if (session && tx[3][session[1]]?.id === sessionMatchId) { + session[0] = tx + tookOver = true + } else { + clearTimeout(session?.[3]) + router._pending = session = undefined + } + } + const config = pendingConfig(router, tx[3]) + if (!config) { + return + } + const [delay, boundary, min, component] = config + const matchId = tx[3][boundary]!.id + if (!session || session[1] !== boundary || sessionMatchId !== matchId) { + // Hydration and redirects can preserve pending presentation without a session. + // Do not delay it again; conservatively start pendingMinMs from now. + clearTimeout(session?.[3]) + const presented = router.stores.matches.get()[boundary] + const visible = presented?.id === matchId && presented.status === 'pending' + router._pending = session = [ + tx, + boundary, + visible ? Date.now() + min : tx[4] + delay, + undefined, + visible ? Promise.resolve(true) : undefined, + component, + ] + } + if (session[4] && !tookOver && session[5] === component) { + return + } + session[5] = component + if (!session[4]) { + clearTimeout(session[3]) + const remaining = session[2] - Date.now() + if (remaining > 0) { + session[3] = setTimeout(() => { + offerPending(router, tx) + }, remaining) + return + } + session[2] = 0 + } + const offered = tx[3].map((match) => ({ + ...match, + _flight: undefined, + })) + offered[boundary]!.status = 'pending' + const ack = router + .startTransition(() => router.stores.setMatches(offered), offered, true) + .then((rendered) => { + if ( + rendered && + router._pending === session && + session[4] === ack && + !session[2] + ) { + session[2] = Date.now() + min + } + return rendered + }) + session[4] = ack +} + +/** + * Cancels pending UI timing when its load ends. The ownership check prevents + * an older, superseded load from clearing pending UI that a newer load took over. + */ +function finishPending(router: CoordinatorRouter, tx: LoadTransaction): void { + const session = router._pending + if (session?.[0] === tx) { + clearTimeout(session[3]) + router._pending = undefined + } +} + +function publishMatches( + router: CoordinatorRouter, + matches: Array, +): void { + router._committed = matches + router.stores.setMatches(matches) +} + +function discardLane(router: AnyRouter, lane: ProjectedLane): void { + transferMatchResources(router, lane[1]) + discardBackground(router, lane) +} + +function commitMatches( + router: CoordinatorRouter, + tx: LoadTransaction, + matches: LaneMatches<'projected'>, + resolvedPrefix?: number, +): void { + const previous = router._committed + const previousCached = router._cache + for (const match of matches) { + match.preload = false + if (resolvedPrefix) { + match._assetEnd = undefined + } + } + const cut = _getRenderedMatches(matches).length + const cached = new Map() + const now = Date.now() + for (const match of [...previous, ...previousCached.values()]) { + // Rendered-prefix ids and settled successes anywhere in the lane are + // authoritative: retaining an older same-id generation would shadow them + // at the next planning pass. Unsettled beyond-boundary matches are not — + // they must not evict a newer same-id preload. + if ( + match.status !== 'success' || + matches.some( + (candidate, index) => + candidate.id === match.id && + (index < cut || candidate.status === 'success'), + ) + ) { + continue + } + const work = match as WorkMatch + const route = getRoute(router, work) + if ( + !route.options.loader || + now - match.updatedAt >= + (match.preload + ? (route.options.preloadGcTime ?? + router.options.defaultPreloadGcTime ?? + 300_000) + : (route.options.gcTime ?? router.options.defaultGcTime ?? 300_000)) + ) { + continue + } + cached.set( + match.id, + previousCached.get(match.id) === match + ? match + : ({ + ...match, + _flight: undefined, + isFetching: false, + context: {}, + } as WorkMatch), + ) + } + // The lane becomes committed before publication can synchronously reenter. + tx[3] = [] + router._cache = cached + publishMatches(router, matches) + transferMatchResources( + router, + [...previousCached.values(), ...previous], + [...matches, ...cached.values()], + ) + runRouteLifecycle(router, previous, matches, () => router._tx === tx) +} + +function commitRefreshMatches( + router: CoordinatorRouter, + tx: LoadTransaction, + matches: LaneMatches<'projected'>, + checkpoint: PublicationCheckpoint, +): void { + const previous = router._committed + const previousCached = router._cache + for (const match of matches) { + match.preload = false + } + const cached = new Map() + // Delay releasing the previous owners until the HMR render is acknowledged. + // Old generations must not become reusable cache entries after refresh. + tx[3] = [] + router._cache = cached + checkpoint.previousMatches = previous + checkpoint.previousCache = previousCached + checkpoint.published = true + publishMatches(router, matches) + if (!checkpoint.published || router._tx !== tx) { + return + } + runRouteLifecycle(router, previous, matches, () => router._tx === tx) +} + +function settlePublication( + router: CoordinatorRouter, + checkpoint: PublicationCheckpoint, +): void { + if (!checkpoint.published) { + return + } + checkpoint.published = false + transferMatchResources( + router, + [...checkpoint.previousCache.values(), ...checkpoint.previousMatches], + [...router._cache.values(), ...router._committed], + ) +} + +function rollbackPublication( + router: CoordinatorRouter, + tx: LoadTransaction, + lane: ProjectedLane, + checkpoint: PublicationCheckpoint, +): boolean { + if ( + !checkpoint.published || + router._tx !== tx || + router._committed !== lane[1] + ) { + settlePublication(router, checkpoint) + return false + } + + const discarded = [...router._cache.values(), ...router._committed] + const restored = [ + ...checkpoint.previousCache.values(), + ...checkpoint.previousMatches, + ] + router._cache = checkpoint.previousCache + router._committed = checkpoint.previousMatches + checkpoint.published = false + + for (const match of discarded as Array) { + if ( + !restored.includes(match) && + match._flight && + router._flights?.get(match.id) === match._flight + ) { + router._flights.delete(match.id) + } + } + + finishPending(router, tx) + router.batch(() => { + router.stores.status.set('idle') + router.stores.setMatches(checkpoint.previousPresentation) + }) + tx[0].abort() + transferMatchResources(router, discarded, restored) + discardBackground(router, lane) + if (router._tx === tx && router._commitPromise === checkpoint.commitPromise) { + router._commitPromise?.resolve() + router._commitPromise = undefined + } + return true +} + +async function transitionRefresh( + router: CoordinatorRouter, + tx: LoadTransaction, + lane: ProjectedLane, + changeInfo: ReturnType, +): Promise { + const checkpoint: PublicationCheckpoint = { + previousMatches: router._committed, + previousPresentation: tx[6]?.[0] ?? router.stores.matches.get(), + previousCache: router._cache, + commitPromise: router._commitPromise, + published: false, + } + const commit = () => { + finishPending(router, tx) + router._rollbackRefresh = rollback + commitRefreshMatches(router, tx, lane[1], checkpoint) + if (!checkpoint.published || router._tx !== tx) { + return + } + router.emit({ type: 'onLoad', ...changeInfo }) + if (router._tx === tx) { + router.emit({ type: 'onBeforeRouteMount', ...changeInfo }) + } + } + const rollback = () => { + if (router._rollbackRefresh === rollback) { + router._rollbackRefresh = undefined + } + const restored = rollbackPublication(router, tx, lane, checkpoint) + router._cancelTransition?.() + return restored + } + try { + const rendered = await router.startTransition(commit, lane[1]) + if (router._rollbackRefresh === rollback) { + router._rollbackRefresh = undefined + } + if (checkpoint.published) { + const handoff = tx[6]?.[1] + if (handoff && router._handoff === handoff) { + handoff[1]() + } + if (router._tx === tx) { + tx[6] = undefined + } + } + settlePublication(router, checkpoint) + return rendered + } catch (cause) { + if (rollback()) { + return + } + throw cause + } +} + +async function awaitCurrent( + router: CoordinatorRouter, + owner?: LoadTransaction, +): Promise { + let current = router._tx + while (current && current !== owner) { + await current[5] + if (router._tx === current) { + return + } + current = router._tx + } +} + +async function followRedirect( + router: CoordinatorRouter, + tx: LoadTransaction, + redirect: AnyRedirect, +): Promise { + await router.navigate({ + ...redirect.options, + replace: true, + ignoreBlocker: true, + _redirects: tx[1] + 1, + } as any) +} + +function restoreCommitted( + router: CoordinatorRouter, + tx: LoadTransaction, +): void { + finishPending(router, tx) + tx[0].abort() + transferMatchResources(router, tx[3]) + tx[3] = [] + if (router._tx !== tx) { + return + } + router.batch(() => { + router.stores.status.set('idle') + router.stores.setMatches(router._committed) + }) + if (router._tx === tx) { + router._commitPromise?.resolve() + router._commitPromise = undefined + } +} + +async function runBackground( + router: CoordinatorRouter, + tx: LoadTransaction, + base: Array, + tasks: Array, + settlement: Promise, +): Promise { + const next = base.map((match) => ({ ...match })) + acquireMatchResources(next) + for (const task of tasks) { + releaseFlight(router, next[task[0]]!) + next[task[0]] = task[3] + } + // Phase jump: the clones inherit beforeLoad context from the committed + // foreground lane, which already ran `contextualize` for these matches. + const lane = [tx[2], next] as ContextualizedLane + let reduced: ReducedLane | ControlOutcome + try { + reduced = await reduceLane(router, lane, tasks, tx[0], tx[1], settlement) + } catch (cause) { + transferMatchResources(router, next) + throw cause + } + if (isControl(reduced)) { + transferMatchResources(router, next) + if ( + reduced[0] === REDIRECTED && + router._tx === tx && + router._committed === base + ) { + await followRedirect(router, tx, reduced[1]) + } + return + } + const projected = await projectLane(router, reduced, tx[0].signal) + if (router._tx !== tx || router._committed !== base) { + transferMatchResources(router, projected[1]) + return + } + for (const match of projected[1] as Array) { + const cached = router._cache.get(match.id) as WorkMatch | undefined + if (cached?._flight && cached._flight === match._flight) { + router._cache.delete(match.id) + releaseFlight(router, cached) + } + } + publishMatches(router, projected[1]) + transferMatchResources(router, base, projected[1]) +} + +async function runClientTransaction( + router: CoordinatorRouter, + tx: LoadTransaction, + forceStaleReload: boolean, + onReady?: () => void, + sync?: boolean, + resolvedPrefix?: number, + adopted?: ActivePreload, + retained?: ActivePreload, +): Promise { + const options: ExecuteLaneOptions = [ + tx[0], + tx[1], + () => router._tx === tx && !!tx[3].length, + router._committed, + undefined, + sync, + forceStaleReload, + resolvedPrefix, + onReady, + ] + let result: LaneResult + try { + result = adopted + ? await adopted[2] + : await executeClientLane(router, tx[2], tx[3], options) + } finally { + if (retained) { + discardPreload(router, retained) + } + } + if ( + adopted && + router._tx === tx && + ((isControl(result) && result[0] === CANCELED) || + (!isControl(result) && + result[1].some( + (match) => match.status !== 'success' || match._notFound, + ))) + ) { + // Successful loaders already seeded the cache; retry only the guard lane. + const donors = tx[3] as Array + tx[3] = [] + transferMatchResources(router, donors) + tx[0].abort() + if (router._tx !== tx) { + return + } + const controller = new AbortController() + tx[0] = options[0] = controller + tx[3] = router.matchRoutes(tx[2], { + _controller: controller, + }) + acquireMatchResources(tx[3]) + result = await executeClientLane(router, tx[2], tx[3], options) + } + + if (isControl(result)) { + if (result[0] === REDIRECTED && router._tx === tx) { + finishPending(router, tx) + transferMatchResources(router, tx[3]) + tx[3] = [] + if (router._tx === tx) { + if (process.env.NODE_ENV !== 'production' && tx[6]) { + router._refreshNextLoad = true + } + await followRedirect(router, tx, result[1]) + } + } else { + restoreCommitted(router, tx) + } + return + } + const pending = router._pending + if (pending?.[0] === tx) { + /** + * Loading finished, so cancel any pending reveal. If the fallback rendered, + * wait out the rest of `pendingMinMs` before replacing it. If it never + * rendered, there is no minimum wait; if another load took it over, that + * load owns the deadline. + */ + clearTimeout(pending[3]) + if (pending[4]) { + const signal = tx[0].signal + let rendered = false + try { + rendered = await waitFor(pending[4], signal) + } catch (cause) { + if (cause !== signal) { + throw cause + } + } + if (rendered && router._pending === pending && pending[0] === tx) { + const remaining = pending[2] - Date.now() + if (remaining > 0) { + try { + await waitFor( + new Promise((resolve) => { + pending[3] = setTimeout(resolve, remaining) + }), + signal, + ) + } catch {} + clearTimeout(pending[3]) + } + } + } + } + if (router._tx !== tx) { + finishPending(router, tx) + discardLane(router, result) + return + } + const toLocation = tx[2] + const changeInfo = getLocationChangeInfo( + toLocation, + router.stores.resolvedLocation.get(), + ) + const background = result[2] + await router.startViewTransition(async () => { + if (router._tx !== tx) { + discardLane(router, result) + return + } + const commit = () => { + finishPending(router, tx) + commitMatches(router, tx, result[1], resolvedPrefix) + if (router._tx !== tx) { + return + } + router.emit({ type: 'onLoad', ...changeInfo }) + if (router._tx === tx) { + router.emit({ type: 'onBeforeRouteMount', ...changeInfo }) + } + } + const rendered = + process.env.NODE_ENV !== 'production' && tx[6] + ? await transitionRefresh(router, tx, result, changeInfo) + : await router.startTransition(commit, result[1]) + if ( + process.env.NODE_ENV !== 'production' && + tx[6] && + rendered === undefined + ) { + return + } + if (router._tx !== tx) { + discardBackground(router, result) + return + } + if (background?.length) { + // Publish refreshes only after the foreground render acknowledgement. + // Otherwise a fast refresh can replace the acknowledged generation + // before the framework commits it and strand the navigation. + runBackground(router, tx, result[1], background, result[3]!).catch( + console.error, + ) + } + router.batch(() => { + router.stores.resolvedLocation.set(toLocation) + router.stores.status.set('idle') + if (router._tx === tx) { + router.emit({ type: 'onResolved', ...changeInfo }) + } + if (rendered && router._tx === tx) { + router.emit({ type: 'onRendered', ...changeInfo }) + } + }) + if (router._tx !== tx) { + return + } + router._commitPromise?.resolve() + router._commitPromise = undefined + }) +} + +export async function loadClientRoute( + router: CoordinatorRouter, + opts?: { sync?: boolean; _dedupe?: boolean }, +): Promise { + let rematerialize = false + if (process.env.NODE_ENV !== 'production') { + router._rollbackRefresh?.() + rematerialize = !!router._refreshNextLoad || !!router._tx?.[6] + } + const refreshPresentation = rematerialize + ? router.stores.matches.get() + : undefined + const previousOwner = router._tx + const resolvedLocation = router.stores.resolvedLocation.get() + const previousLocation = resolvedLocation ?? router.stores.location.get() + const location = router.latestLocation + const pendingLocation = router._pendingLocation as + | (ParsedLocation & { _redirects?: number }) + | undefined + const redirects = + pendingLocation?.href === location.href + ? (pendingLocation._redirects ?? 0) + : 0 + // A same-location navigation joins the transaction already loading it + // instead of restarting its work. Reload requests never carry the flag, + // and same-location redirects must restart the lane they came from. + if ( + opts?._dedupe && + !redirects && + previousOwner && + !rematerialize && + previousOwner[2].href === location.href && + router.stores.status.get() === 'pending' + ) { + await awaitCurrent(router) + return + } + const handoff = router._handoff + const hydrationController = rematerialize ? undefined : handoff?.[0]() + const preflight = new AbortController() + const previousPreflight = router._preflight + router._preflight = preflight + if (!rematerialize && !hydrationController) { + handoff?.[1]() + } + previousPreflight?.abort() + if (preflight.signal.aborted || router._tx !== previousOwner) { + await awaitCurrent(router, previousOwner) + return + } + + const changeInfo = getLocationChangeInfo(location, resolvedLocation) + router.emit({ type: 'onBeforeNavigate', ...changeInfo }) + if (!preflight.signal.aborted && router._tx === previousOwner) { + router.emit({ type: 'onBeforeLoad', ...changeInfo }) + } + if (preflight.signal.aborted || router._tx !== previousOwner) { + preflight.abort() + await awaitCurrent(router, previousOwner) + return + } + const sameHref = previousLocation.href === location.href + let adopted = router._preloads?.get(location.href) + let retained: ActivePreload | undefined + if (rematerialize && adopted) { + router._preloads!.delete(location.href) + discardPreload(router, adopted) + adopted = undefined + if (preflight.signal.aborted || router._tx !== previousOwner) { + preflight.abort() + await awaitCurrent(router, previousOwner) + return + } + } + if ( + adopted && + (hydrationController || + !samePreloadLane( + adopted, + router, + pendingLocation?.href === location.href ? pendingLocation : location, + redirects, + )) + ) { + router._preloads!.delete(location.href) + // Keep incompatible loader flights alive through the real lane's reload + // decisions so matching generations can still donate their work. + retained = adopted + adopted = undefined + } + let matches: Array + let controller = preflight + let resolvedPrefix: number | undefined + if (adopted) { + controller = adopted[1] + matches = adopted[0] + router._preloads!.delete(location.href) + } else { + try { + matches = + process.env.NODE_ENV !== 'production' && rematerialize + ? router.matchRoutes(location, { + _controller: preflight, + _rematerialize: true, + }) + : router.matchRoutes(location, { _controller: preflight }) + acquireMatchResources(matches) + } catch (cause) { + preflight.abort() + if (retained) { + discardPreload(router, retained) + } + if (!isRedirect(cause)) { + if (process.env.NODE_ENV !== 'production' && rematerialize) { + router._refreshNextLoad = undefined + } + await awaitCurrent(router) + router._commitPromise?.resolve() + router._commitPromise = undefined + return + } + await router.navigate({ + ...cause.options, + replace: true, + ignoreBlocker: true, + }) + await awaitCurrent(router, previousOwner) + return + } + resolvedPrefix = hydrationController ? handoff![1](matches) : undefined + if (resolvedPrefix) { + controller = hydrationController! + } else { + hydrationController?.abort() + } + } + if (router._preflight !== preflight || router._tx !== previousOwner) { + preflight.abort() + transferMatchResources(router, matches) + await awaitCurrent(router, previousOwner) + return + } + router._preflight = undefined + + const tx: LoadTransaction = [ + controller, + redirects, + location, + matches, + Date.now(), + Promise.resolve() + .then(() => + runClientTransaction( + router, + tx, + sameHref, + () => offerPending(router, tx), + opts?.sync, + resolvedPrefix, + adopted, + retained, + ), + ) + .catch(() => { + if (router._tx === tx) { + restoreCommitted(router, tx) + } + }), + ] + if (process.env.NODE_ENV !== 'production' && rematerialize) { + // `refreshPresentation` is always captured when `rematerialize` is set. + tx[6] = [refreshPresentation!, handoff] + router._refreshNextLoad = undefined + } + router._tx = tx + if (!rematerialize && router._handoff === handoff) { + router._handoff = undefined + } + if (previousOwner) { + for (const match of router.stores.matches.get() as Array) { + if (router._tx !== tx) { + break + } + if (match.isFetching) { + setFetching(router, match, false) + } + } + previousOwner[0].abort() + transferMatchResources(router, previousOwner[3]) + } + if (router._tx !== tx) { + transferMatchResources(router, tx[3]) + tx[3] = [] + await awaitCurrent(router, tx) + return + } + + router.batch(() => { + router.stores.status.set('pending') + router.stores.location.set(location) + }) + offerPending(router, tx) + try { + await tx[5] + } finally { + await awaitCurrent(router, tx) + } +} + +export async function refreshClientRoute( + router: CoordinatorRouter, +): Promise { + router._rollbackRefresh?.() + const pending = router._tx + if (pending && !pending[6] && router.stores.status.get() === 'pending') { + await pending[5] + if (router._tx !== pending) { + await awaitCurrent(router, pending) + } + } + // Existing owners remain alive for rollback but cannot donate stale work. + router._flights?.clear() + router.clearCache() + router._refreshNextLoad = true + await loadClientRoute(router, { sync: true }) +} + +function followPreloadRedirect( + router: CoordinatorRouter, + result: ControlOutcome, + location: ParsedLocation, + owner: LoadTransaction | undefined, + redirects: number, +): Promise | undefined> | undefined { + if ( + result[0] === REDIRECTED && + !result[1].options.reloadDocument && + router._tx === owner + ) { + return preloadClientRoute( + router, + { + ...result[1].options, + _fromLocation: location, + }, + redirects + 1, + ) + } + return +} + +export async function preloadClientRoute( + router: CoordinatorRouter, + opts: any, + redirects = 0, +): Promise | undefined> { + if (redirects > 20) { + return + } + const owner = router._tx + if ( + process.env.NODE_ENV !== 'production' && + (router._refreshNextLoad || owner?.[6]) + ) { + return + } + const location = opts._builtLocation ?? router.buildLocation(opts) + const base = router._committed + const controller = new AbortController() + let matches: Array | undefined + let preload: ActivePreload | undefined + let replaced: ActivePreload | undefined + try { + const pending = router._preloads?.get(location.href) + if (pending) { + if (samePreloadLane(pending, router, location, redirects)) { + const result = await pending[2] + return isControl(result) + ? followPreloadRedirect(router, result, location, owner, redirects) + : result[1] + } + router._preloads!.delete(location.href) + // Keep the superseded lane alive until this lane has made its reload + // decisions. Its active flights are the synchronous donor authority. + replaced = pending + } + matches = router.matchRoutes(location, { + _controller: controller, + }) + acquireMatchResources(matches) + const promise = Promise.resolve() + .then(() => + executeClientLane(router, location, matches!, [ + controller, + redirects, + // Preload lanes run to completion even when unrelated navigations + // commit: finished work seeds the cache, and adoption safety is + // enforced independently by samePreloadLane's base identity check. + () => true, + base, + true, + ]), + ) + .finally(() => { + if (replaced) { + discardPreload(router, replaced) + } + }) + preload = [ + matches, + controller, + promise, + base, + laneInputs(router, location), + redirects, + ] + ;(router._preloads ??= new Map()).set(location.href, preload) + const result = await promise + if (router._preloads?.get(location.href) !== preload) { + return isControl(result) ? undefined : result[1] + } + router._preloads.delete(location.href) + if (isControl(result)) { + controller.abort() + transferMatchResources(router, matches) + return followPreloadRedirect(router, result, location, owner, redirects) + } + + transferMatchResources(router, result[1]) + controller.abort() + return result[1] + } catch (cause) { + if (!preload || router._preloads?.get(location.href) === preload) { + if (preload) { + router._preloads!.delete(location.href) + } + controller.abort() + if (matches) { + transferMatchResources(router, matches) + } + } + if (router._tx !== owner) { + return + } + if (!isNotFound(cause)) { + console.error(cause) + } + return + } +} + +// --- SSR hydration (client entry via @tanstack/router-core/ssr/client) --- + +declare global { + interface Window { + [GLOBAL_TSR]?: TsrSsrGlobal + [GLOBAL_SEROVAL]?: any + } +} + +export async function hydrate(router: AnyRouter): Promise { + if (process.env.NODE_ENV !== 'production' && !window.$_TSR) { + throw new Error( + 'Invariant failed: Expected to find bootstrap data on window.$_TSR, but we did not. Please file an issue!', + ) + } + const tsr = window.$_TSR! + + const adapters = router.options.serializationAdapters as + | Array + | undefined + if (adapters?.length) { + tsr.t = new Map( + adapters.map((adapter) => [adapter.key, adapter.fromSerializable]), + ) + tsr.buffer.forEach((script) => script()) + } + tsr.initialized = true + + const dehydratedRouter = tsr.router + if (process.env.NODE_ENV !== 'production' && !dehydratedRouter) { + throw new Error( + 'Invariant failed: Expected to find a dehydrated data on window.$_TSR.router, but we did not. Please file an issue!', + ) + } + router.ssr = { manifest: dehydratedRouter!.manifest } + const nonce = ( + document.querySelector('meta[property="csp-nonce"]') as + | HTMLMetaElement + | undefined + )?.content + router.options.ssr = { nonce } + + const dehydratedMatches = dehydratedRouter!.matches + + const controller = new AbortController() + const previousPreflight = router._preflight + router._preflight = controller + previousPreflight?.abort() + const retire = (cause?: unknown) => { + if (router._preflight === controller) { + router._preflight = undefined + } + controller.abort(cause) + return false + } + const isCurrent = () => + (!router._tx && + router._preflight === controller && + !controller.signal.aborted) || + retire() + + let location!: AnyRouter['latestLocation'] + let candidates!: Array + let handoffInputs!: ReturnType + try { + await waitFor( + router.options.hydrate?.(dehydratedRouter!.dehydratedData), + controller.signal, + ) + if (!isCurrent()) { + return + } + router.updateLatestLocation() + location = router.latestLocation + router.stores.location.set(location) + handoffInputs = laneInputs(router, location) + candidates = router.matchRoutes(location, { + _controller: controller, + }) + } catch (cause) { + retire(cause) + if (cause !== controller.signal) { + throw cause + } + } + if (!isCurrent()) { + return + } + const committed: Array = [] + let pendingBoundary: number | undefined + let verifiedAssetEnd = 0 + const retryFrom = (index: number) => { + // The failing route's identity is still verified, but no descendant is. + verifiedAssetEnd = Math.min(verifiedAssetEnd, index + 1) + const removed = committed.splice(index) + for (const match of removed) { + if ( + getRoute(router, match).options.loader && + (match.status === 'success' || + (!match.invalid && 'loaderData' in match)) + ) { + cacheLoaderMatch( + router, + // Phase jump: dehydrated server data is already past the loader + // phase — the guard above verified a settled success (or transported + // loaderData), so this clone is settled without a client settleInto. + { + ...match, + status: 'success', + error: undefined, + preload: true, + } as SettledMatch, + router._cache.get(match.id), + ) + } + } + transferMatchResources(router, removed) + } + + // A longer server lane is valid only when the local match already caps the + // branch at a global not-found boundary. Otherwise no transported work is + // safe to attach to the shorter client lane. + const shared = + dehydratedMatches.length > candidates.length + ? candidates.findIndex((match) => match._notFound) + 1 + : dehydratedMatches.length + let isTerminal = false + for (let index = 0; index < shared; index++) { + const candidate = candidates[index]! + const dehydrated = dehydratedMatches[index]! + if ( + typeof dehydrated.i !== 'string' || + hydrateSsrMatchId(dehydrated.i) !== candidate.id + ) { + pendingBoundary ??= index + break + } + verifiedAssetEnd = index + 1 + const route = getRoute(router, candidate) + if ( + 'l' in dehydrated || + (dehydrated.s === 'success' && + dehydrated.e === undefined && + route.options.loader) + ) { + candidate.loaderData = dehydrated.l + } + candidate.status = dehydrated.s + candidate.ssr = dehydrated.ssr + route.options.ssr = candidate.ssr + candidate.updatedAt = dehydrated.u + candidate.error = dehydrated.e + candidate._notFound ||= dehydrated.g + const terminal = + candidate.status === 'error' || + candidate.status === 'notFound' || + candidate._notFound + if (terminal) { + isTerminal = true + committed.push(candidate) + if (candidate.ssr === false || candidate.ssr === 'data-only') { + pendingBoundary ??= index + } + break + } + if (candidate.status === 'pending') { + pendingBoundary ??= index + break + } + + committed.push(candidate) + if (candidate.ssr === 'data-only') { + pendingBoundary ??= index + } + } + let verifiedContextEnd = verifiedAssetEnd + + if ( + !isTerminal && + committed.length === shared && + shared < candidates.length + ) { + pendingBoundary = shared + } + + // Hooks observe structural membership. Execution remains limited to + // `committed`, the accepted server prefix. + const chunks = committed.map(async (match) => { + try { + const route = getRoute(router, match) + if (match._notFound) { + await Promise.all([ + loadRouteChunk(route), + loadRouteChunk(route, 'notFoundComponent'), + ]) + } else { + await loadRouteChunk( + route, + match.status === 'error' + ? 'errorComponent' + : match.status === 'notFound' + ? 'notFoundComponent' + : undefined, + ) + } + return true + } catch { + return false + } + }) + let chunkFailure = 0 + try { + while ( + chunkFailure < chunks.length && + (await waitFor(chunks[chunkFailure]!, controller.signal)) + ) { + chunkFailure++ + } + } catch { + isCurrent() + return + } + if (!isCurrent()) { + return + } + if (chunkFailure < committed.length) { + verifiedContextEnd = Math.min(verifiedContextEnd, chunkFailure) + retryFrom(chunkFailure) + } + + // The first pending match is already visible, so prepare its route context + // without granting its beforeLoad or loader any hydration authority. + const contextEnd = Math.max( + pendingBoundary === committed.length + ? committed.length + 1 + : committed.length, + verifiedContextEnd, + ) + for (let index = 0; index < contextEnd; index++) { + const match = candidates[index]! + const route = getRoute(router, match) + const parentContext = + candidates[index - 1]?.context ?? router.options.context ?? {} + let routeContext + if (route.options.context) { + try { + routeContext = match._ctx = + route.options.context({ + deps: match.loaderDeps, + params: match.params, + context: parentContext, + location, + navigate: navigateFrom(router, location), + buildLocation: router.buildLocation, + cause: match.cause, + abortController: controller, + preload: false, + matches: candidates, + routeId: route.id, + }) || {} + } catch { + if (!isCurrent()) { + return + } + if ( + match.status !== 'error' && + match.status !== 'notFound' && + !match._notFound + ) { + retryFrom(index) + break + } + } + if (!isCurrent()) { + return + } + } + match.context = { + ...parentContext, + ...routeContext, + ...(committed[index] && dehydratedMatches[index]!.b), + } + } + + await projectLane( + router, + [location, candidates] as any, + controller.signal, + 0, + verifiedAssetEnd, + ) + if (!isCurrent()) { + return + } + const needsClientLoad = + pendingBoundary !== undefined || committed.length < shared + const committedMatches = + isTerminal && committed.length === shared ? candidates : committed + let presented = needsClientLoad ? candidates : committedMatches + let dataOnlyAssetEnd: number | undefined + if (needsClientLoad && pendingBoundary !== undefined) { + const boundary = presented[pendingBoundary]! + dataOnlyAssetEnd = + boundary.status === 'success' && + boundary.ssr === 'data-only' && + boundary.error === undefined && + !boundary._notFound && + verifiedAssetEnd > pendingBoundary + 1 + ? verifiedAssetEnd + : undefined + presented = presented.slice() + presented[pendingBoundary] = { + ...boundary, + status: 'pending', + ssr: boundary.ssr === 'data-only' ? 'data-only' : false, + _assetEnd: dataOnlyAssetEnd, + } + } + + const claim = () => + needsClientLoad && + !router._tx && + router.latestLocation.state === location.state && + deepEqual(handoffInputs, laneInputs(router, router.latestLocation)) && + router._committed === committedMatches && + committedMatches.length && + !controller.signal.aborted + ? controller + : undefined + const handoff: NonNullable = [ + claim, + (matches) => { + if (router._handoff !== handoff) { + return + } + const prefix = committedMatches.length + if ( + !matches || + !claim() || + committedMatches.some((match, index) => match.id !== matches[index]?.id) + ) { + router._handoff = undefined + controller.abort() + return + } + let handoffAssetEnd = dataOnlyAssetEnd + if (handoffAssetEnd !== undefined) { + for (let index = prefix; index < handoffAssetEnd; index++) { + if (candidates[index]?.id !== matches[index]?.id) { + handoffAssetEnd = + index > (pendingBoundary ?? -1) + 1 ? index : undefined + break + } + } + } + const clones = committedMatches.map((match) => ({ ...match })) + if (handoffAssetEnd !== undefined) { + clones[pendingBoundary!]!._assetEnd = handoffAssetEnd + } + transferMatchResources(router, matches.splice(0, prefix, ...clones)) + for (let index = prefix; index < matches.length; index++) { + const match = matches[index]! + const hydrated = candidates[index] + if (hydrated?.id === match.id && hydrated._ctx) { + match._ctx = hydrated._ctx + } + match.abortController = controller + } + return prefix + }, + ] + router._committed = committedMatches + router._handoff = handoff + router._preflight = undefined + router.batch(() => { + router.stores.setMatches(presented) + router.stores.status.set('idle') + if (!needsClientLoad) { + router.stores.resolvedLocation.set(router.stores.location.get()) + } + }) +} diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts deleted file mode 100644 index f901a0c97d..0000000000 --- a/packages/router-core/src/load-matches.ts +++ /dev/null @@ -1,1278 +0,0 @@ -import { isServer } from '@tanstack/router-core/isServer' -import { invariant } from './invariant' -import { createControlledPromise, isPromise } from './utils' -import { isNotFound } from './not-found' -import { rootRouteId } from './root' -import { isRedirect } from './redirect' -import type { NotFoundError } from './not-found' -import type { ParsedLocation } from './location' -import type { - AnyRoute, - BeforeLoadContextOptions, - LoaderFnContext, - SsrContextOptions, -} from './route' -import type { AnyRouteMatch, MakeRouteMatch } from './Matches' -import type { AnyRouter, SSROption, UpdateMatchFn } from './router' - -/** - * An object of this shape is created when calling `loadMatches`. - * It contains everything we need for all other functions in this file - * to work. (It's basically the function's argument, plus a few mutable states) - */ -type InnerLoadContext = { - /** the calling router instance */ - router: AnyRouter - location: ParsedLocation - /** mutable state, scoped to a `loadMatches` call */ - firstBadMatchIndex?: number - /** mutable state, scoped to a `loadMatches` call */ - rendered?: boolean - serialError?: unknown - updateMatch: UpdateMatchFn - matches: Array - preload?: boolean - forceStaleReload?: boolean - onReady?: () => Promise - sync?: boolean -} - -const triggerOnReady = (inner: InnerLoadContext): void | Promise => { - if (!inner.rendered) { - inner.rendered = true - return inner.onReady?.() - } -} - -const hasForcePendingActiveMatch = (router: AnyRouter): boolean => { - return router.stores.matchesId.get().some((matchId) => { - return router.stores.matchStores.get(matchId)?.get()._forcePending - }) -} - -const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => { - return !!(inner.preload && !inner.router.stores.matchStores.has(matchId)) -} - -/** - * Builds the accumulated context from router options and all matches up to (and optionally including) the given index. - * Merges __routeContext and __beforeLoadContext from each match. - */ -const buildMatchContext = ( - inner: InnerLoadContext, - index: number, - includeCurrentMatch: boolean = true, -): Record => { - const context: Record = { - ...(inner.router.options.context ?? {}), - } - const end = includeCurrentMatch ? index : index - 1 - for (let i = 0; i <= end; i++) { - const innerMatch = inner.matches[i] - if (!innerMatch) continue - const m = inner.router.getMatch(innerMatch.id) - if (!m) continue - Object.assign(context, m.__routeContext, m.__beforeLoadContext) - } - return context -} - -const getNotFoundBoundaryIndex = ( - inner: InnerLoadContext, - err: NotFoundError, -): number | undefined => { - if (!inner.matches.length) { - return undefined - } - - const requestedRouteId = err.routeId - const matchedRootIndex = inner.matches.findIndex( - (m) => m.routeId === inner.router.routeTree.id, - ) - const rootIndex = matchedRootIndex >= 0 ? matchedRootIndex : 0 - - let startIndex = requestedRouteId - ? inner.matches.findIndex((match) => match.routeId === requestedRouteId) - : (inner.firstBadMatchIndex ?? inner.matches.length - 1) - - if (startIndex < 0) { - startIndex = rootIndex - } - - for (let i = startIndex; i >= 0; i--) { - const match = inner.matches[i]! - const route = inner.router.looseRoutesById[match.routeId]! - if (route.options.notFoundComponent) { - return i - } - } - - // If no boundary component is found, preserve explicit routeId targeting behavior, - // otherwise default to root for untargeted notFounds. - return requestedRouteId ? startIndex : rootIndex -} - -const handleRedirectAndNotFound = ( - inner: InnerLoadContext, - match: AnyRouteMatch | undefined, - err: unknown, -): void => { - if (!isRedirect(err) && !isNotFound(err)) return - - if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) { - throw err - } - - // in case of a redirecting match during preload, the match does not exist - if (match) { - match._nonReactive.beforeLoadPromise?.resolve() - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.beforeLoadPromise = undefined - match._nonReactive.loaderPromise = undefined - - match._nonReactive.error = err - - inner.updateMatch(match.id, (prev) => ({ - ...prev, - status: isRedirect(err) - ? 'redirected' - : isNotFound(err) - ? 'notFound' - : prev.status === 'pending' - ? 'success' - : prev.status, - context: buildMatchContext(inner, match.index), - isFetching: false, - error: err, - })) - - if (isNotFound(err) && !err.routeId) { - // Stamp the throwing match's routeId so that the finalization step in - // loadMatches knows where the notFound originated. The actual boundary - // resolution (walking up to the nearest notFoundComponent) is deferred to - // the finalization step, where firstBadMatchIndex is stable and - // headMaxIndex can be capped correctly. - err.routeId = match.routeId - } - - match._nonReactive.loadPromise?.resolve() - } - - if (isRedirect(err)) { - inner.rendered = true - err.options._fromLocation = inner.location - err.redirectHandled = true - err = inner.router.resolveRedirect(err) - } - - throw err -} - -const shouldSkipLoader = ( - inner: InnerLoadContext, - matchId: string, -): boolean => { - const match = inner.router.getMatch(matchId) - if (!match) { - return true - } - // upon hydration, we skip the loader if the match has been dehydrated on the server - if (!(isServer ?? inner.router.isServer) && match._nonReactive.dehydrated) { - return true - } - - if ((isServer ?? inner.router.isServer) && match.ssr === false) { - return true - } - - return false -} - -const syncMatchContext = ( - inner: InnerLoadContext, - matchId: string, - index: number, -): void => { - const nextContext = buildMatchContext(inner, index) - - inner.updateMatch(matchId, (prev) => { - return { - ...prev, - context: nextContext, - } - }) -} - -const handleSerialError = ( - inner: InnerLoadContext, - index: number, - err: any, -): void => { - const { id: matchId, routeId } = inner.matches[index]! - const route = inner.router.looseRoutesById[routeId]! - - // Much like suspense, we use a promise here to know if - // we've been outdated by a new loadMatches call and - // should abort the current async operation - if (err instanceof Promise) { - throw err - } - - inner.firstBadMatchIndex ??= index - handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err) - - try { - route.options.onError?.(err) - } catch (errorHandlerErr) { - err = errorHandlerErr - handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err) - } - - inner.updateMatch(matchId, (prev) => { - prev._nonReactive.beforeLoadPromise?.resolve() - prev._nonReactive.beforeLoadPromise = undefined - prev._nonReactive.loadPromise?.resolve() - - return { - ...prev, - error: err, - status: 'error', - isFetching: false, - updatedAt: Date.now(), - abortController: new AbortController(), - } - }) - - if (!inner.preload && !isRedirect(err) && !isNotFound(err)) { - inner.serialError ??= err - } -} - -const isBeforeLoadSsr = ( - inner: InnerLoadContext, - matchId: string, - index: number, - route: AnyRoute, -): void | Promise => { - const existingMatch = inner.router.getMatch(matchId)! - const parentMatchId = inner.matches[index - 1]?.id - const parentMatch = parentMatchId - ? inner.router.getMatch(parentMatchId)! - : undefined - - // in SPA mode, only SSR the root route - if (inner.router.isShell()) { - existingMatch.ssr = route.id === rootRouteId - return - } - - if (parentMatch?.ssr === false) { - existingMatch.ssr = false - return - } - - const parentOverride = (tempSsr: SSROption) => { - if (tempSsr === true && parentMatch?.ssr === 'data-only') { - return 'data-only' - } - return tempSsr - } - - const defaultSsr = inner.router.options.defaultSsr ?? true - - if (route.options.ssr === undefined) { - existingMatch.ssr = parentOverride(defaultSsr) - return - } - - if (typeof route.options.ssr !== 'function') { - existingMatch.ssr = parentOverride(route.options.ssr) - return - } - const { search, params } = existingMatch - - const ssrFnContext: SsrContextOptions = { - search: makeMaybe(search, existingMatch.searchError), - params: makeMaybe(params, existingMatch.paramsError), - location: inner.location, - matches: inner.matches.map((match) => ({ - index: match.index, - pathname: match.pathname, - fullPath: match.fullPath, - staticData: match.staticData, - id: match.id, - routeId: match.routeId, - search: makeMaybe(match.search, match.searchError), - params: makeMaybe(match.params, match.paramsError), - ssr: match.ssr, - })), - } - - const tempSsr = route.options.ssr(ssrFnContext) - if (isPromise(tempSsr)) { - return tempSsr.then((ssr) => { - existingMatch.ssr = parentOverride(ssr ?? defaultSsr) - }) - } - - existingMatch.ssr = parentOverride(tempSsr ?? defaultSsr) - return -} - -const setupPendingTimeout = ( - inner: InnerLoadContext, - matchId: string, - route: AnyRoute, - match: AnyRouteMatch, -): void => { - if (match._nonReactive.pendingTimeout !== undefined) return - - const pendingMs = - route.options.pendingMs ?? inner.router.options.defaultPendingMs - const shouldPending = !!( - inner.onReady && - !(isServer ?? inner.router.isServer) && - !resolvePreload(inner, matchId) && - (route.options.loader || - route.options.beforeLoad || - routeNeedsPreload(route)) && - typeof pendingMs === 'number' && - pendingMs !== Infinity && - (route.options.pendingComponent ?? - (inner.router.options as any)?.defaultPendingComponent) - ) - - if (shouldPending) { - const pendingTimeout = setTimeout(() => { - // Update the match and prematurely resolve the loadMatches promise so that - // the pending component can start rendering - triggerOnReady(inner) - }, pendingMs) - match._nonReactive.pendingTimeout = pendingTimeout - } -} - -const preBeforeLoadSetup = ( - inner: InnerLoadContext, - matchId: string, - route: AnyRoute, -): void | Promise => { - const existingMatch = inner.router.getMatch(matchId)! - - // If we are in the middle of a load, either of these will be present - // (not to be confused with `loadPromise`, which is always defined) - if ( - !existingMatch._nonReactive.beforeLoadPromise && - !existingMatch._nonReactive.loaderPromise - ) - return - - setupPendingTimeout(inner, matchId, route, existingMatch) - - const then = () => { - const match = inner.router.getMatch(matchId)! - if ( - match.preload && - (match.status === 'redirected' || match.status === 'notFound') - ) { - handleRedirectAndNotFound(inner, match, match.error) - } - } - - // Wait for the previous beforeLoad to resolve before we continue - return existingMatch._nonReactive.beforeLoadPromise - ? existingMatch._nonReactive.beforeLoadPromise.then(then) - : then() -} - -const executeBeforeLoad = ( - inner: InnerLoadContext, - matchId: string, - index: number, - route: AnyRoute, -): void | Promise => { - const match = inner.router.getMatch(matchId)! - - // explicitly capture the previous loadPromise - let prevLoadPromise = match._nonReactive.loadPromise - match._nonReactive.loadPromise = createControlledPromise(() => { - prevLoadPromise?.resolve() - prevLoadPromise = undefined - }) - - const { paramsError, searchError } = match - - if (paramsError) { - handleSerialError(inner, index, paramsError) - } - - if (searchError) { - handleSerialError(inner, index, searchError) - } - - setupPendingTimeout(inner, matchId, route, match) - - const abortController = new AbortController() - - let isPending = false - const pending = () => { - if (isPending) return - isPending = true - inner.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'beforeLoad', - fetchCount: prev.fetchCount + 1, - abortController, - // Note: We intentionally don't update context here. - // Context should only be updated after beforeLoad resolves to avoid - // components seeing incomplete context during async beforeLoad execution. - })) - } - - const resolve = () => { - match._nonReactive.beforeLoadPromise?.resolve() - match._nonReactive.beforeLoadPromise = undefined - inner.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: false, - })) - } - - // if there is no `beforeLoad` option, just mark as pending and resolve - // Context will be updated later in loadRouteMatch after loader completes - if (!route.options.beforeLoad) { - inner.router.batch(() => { - pending() - resolve() - }) - return - } - - match._nonReactive.beforeLoadPromise = createControlledPromise() - - // Build context from all parent matches, excluding current match's __beforeLoadContext - // (since we're about to execute beforeLoad for this match) - const context = { - ...buildMatchContext(inner, index, false), - ...match.__routeContext, - } - const { search, params, cause } = match - const preload = resolvePreload(inner, matchId) - const beforeLoadFnContext: BeforeLoadContextOptions< - any, - any, - any, - any, - any, - any, - any, - any, - any - > = { - search, - abortController, - params, - preload, - context, - location: inner.location, - navigate: (opts: any) => - inner.router.navigate({ - ...opts, - _fromLocation: inner.location, - }), - buildLocation: inner.router.buildLocation, - cause: preload ? 'preload' : cause, - matches: inner.matches, - routeId: route.id, - ...inner.router.options.additionalContext, - } - - const updateContext = (beforeLoadContext: any) => { - if (beforeLoadContext === undefined) { - inner.router.batch(() => { - pending() - resolve() - }) - return - } - if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { - pending() - handleSerialError(inner, index, beforeLoadContext) - } - - inner.router.batch(() => { - pending() - inner.updateMatch(matchId, (prev) => ({ - ...prev, - __beforeLoadContext: beforeLoadContext, - })) - resolve() - }) - } - - let beforeLoadContext - try { - beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext) - if (isPromise(beforeLoadContext)) { - pending() - return beforeLoadContext - .catch((err) => { - handleSerialError(inner, index, err) - }) - .then(updateContext) - } - } catch (err) { - pending() - handleSerialError(inner, index, err) - } - - updateContext(beforeLoadContext) - return -} - -const handleBeforeLoad = ( - inner: InnerLoadContext, - index: number, -): void | Promise => { - const { id: matchId, routeId } = inner.matches[index]! - const route = inner.router.looseRoutesById[routeId]! - - const serverSsr = () => { - // on the server, determine whether SSR the current match or not - if (isServer ?? inner.router.isServer) { - const maybePromise = isBeforeLoadSsr(inner, matchId, index, route) - if (isPromise(maybePromise)) return maybePromise.then(queueExecution) - } - return queueExecution() - } - - const execute = () => executeBeforeLoad(inner, matchId, index, route) - - const queueExecution = () => { - if (shouldSkipLoader(inner, matchId)) return - const result = preBeforeLoadSetup(inner, matchId, route) - return isPromise(result) ? result.then(execute) : execute() - } - - return serverSsr() -} - -const executeHead = ( - inner: InnerLoadContext, - matchId: string, - route: AnyRoute, -): void | Promise< - Pick< - AnyRouteMatch, - 'meta' | 'links' | 'headScripts' | 'headers' | 'scripts' | 'styles' - > -> => { - const match = inner.router.getMatch(matchId) - // in case of a redirecting match during preload, the match does not exist - if (!match) { - return - } - if (!route.options.head && !route.options.scripts && !route.options.headers) { - return - } - const assetContext = { - ssr: inner.router.options.ssr, - matches: inner.matches, - match, - params: match.params, - loaderData: match.loaderData, - } - - return Promise.all([ - route.options.head?.(assetContext), - route.options.scripts?.(assetContext), - route.options.headers?.(assetContext), - ]).then(([headFnContent, scripts, headers]) => { - const meta = headFnContent?.meta - const links = headFnContent?.links - const headScripts = headFnContent?.scripts - const styles = headFnContent?.styles - - return { - meta, - links, - headScripts, - headers, - scripts, - styles, - } - }) -} - -const getLoaderContext = ( - inner: InnerLoadContext, - matchPromises: Array>, - matchId: string, - index: number, - route: AnyRoute, -): LoaderFnContext => { - const parentMatchPromise = matchPromises[index - 1] as any - const { params, loaderDeps, abortController, cause } = - inner.router.getMatch(matchId)! - - const context = buildMatchContext(inner, index) - - const preload = resolvePreload(inner, matchId) - - return { - params, - deps: loaderDeps, - preload: !!preload, - parentMatchPromise, - abortController, - context, - location: inner.location, - navigate: (opts) => - inner.router.navigate({ - ...opts, - _fromLocation: inner.location, - }), - cause: preload ? 'preload' : cause, - route, - ...inner.router.options.additionalContext, - } -} - -const runLoader = async ( - inner: InnerLoadContext, - matchPromises: Array>, - matchId: string, - index: number, - route: AnyRoute, -): Promise => { - try { - // If the Matches component rendered - // the pending component and needs to show it for - // a minimum duration, we''ll wait for it to resolve - // before committing to the match and resolving - // the loadPromise - - const match = inner.router.getMatch(matchId)! - - // Actually run the loader and handle the result - try { - if (!(isServer ?? inner.router.isServer) || match.ssr === true) { - loadRouteChunk(route) - } - - // Kick off the loader! - const routeLoader = route.options.loader - const loader = - typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler - const loaderResult = loader?.( - getLoaderContext(inner, matchPromises, matchId, index, route), - ) - const loaderResultIsPromise = !!loader && isPromise(loaderResult) - - const willLoadSomething = !!( - loaderResultIsPromise || - route._lazyPromise || - route._componentsPromise || - route.options.head || - route.options.scripts || - route.options.headers || - match._nonReactive.minPendingPromise - ) - - if (willLoadSomething) { - inner.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'loader', - })) - } - - if (loader) { - const loaderData = loaderResultIsPromise - ? await loaderResult - : loaderResult - - handleRedirectAndNotFound( - inner, - inner.router.getMatch(matchId), - loaderData, - ) - if (loaderData !== undefined) { - inner.updateMatch(matchId, (prev) => ({ - ...prev, - loaderData, - })) - } - } - - // Lazy option can modify the route options, - // so we need to wait for it to resolve before - // we can use the options - if (route._lazyPromise) await route._lazyPromise - const pendingPromise = match._nonReactive.minPendingPromise - if (pendingPromise) await pendingPromise - - // Last but not least, wait for the components - // to be preloaded before we resolve the match - if (route._componentsPromise) await route._componentsPromise - inner.updateMatch(matchId, (prev) => ({ - ...prev, - error: undefined, - context: buildMatchContext(inner, index), - status: 'success', - isFetching: false, - updatedAt: Date.now(), - })) - } catch (e) { - let error = e - - if ((error as any)?.name === 'AbortError') { - if (match.abortController.signal.aborted) { - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loaderPromise = undefined - return - } - inner.updateMatch(matchId, (prev) => ({ - ...prev, - status: prev.status === 'pending' ? 'success' : prev.status, - isFetching: false, - context: buildMatchContext(inner, index), - })) - return - } - - const pendingPromise = match._nonReactive.minPendingPromise - if (pendingPromise) await pendingPromise - - if (isNotFound(e)) { - await (route.options.notFoundComponent as any)?.preload?.() - } - - handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), e) - - try { - route.options.onError?.(e) - } catch (onErrorError) { - error = onErrorError - handleRedirectAndNotFound( - inner, - inner.router.getMatch(matchId), - onErrorError, - ) - } - if (!isRedirect(error) && !isNotFound(error)) { - await loadRouteChunk(route, ['errorComponent']) - } - - inner.updateMatch(matchId, (prev) => ({ - ...prev, - error, - context: buildMatchContext(inner, index), - status: 'error', - isFetching: false, - })) - } - } catch (err) { - const match = inner.router.getMatch(matchId) - // in case of a redirecting match during preload, the match does not exist - if (match) { - match._nonReactive.loaderPromise = undefined - } - handleRedirectAndNotFound(inner, match, err) - } -} - -const loadRouteMatch = async ( - inner: InnerLoadContext, - matchPromises: Array>, - index: number, -): Promise => { - async function handleLoader( - preload: boolean, - prevMatch: AnyRouteMatch, - previousRouteMatchId: string | undefined, - match: AnyRouteMatch, - route: AnyRoute, - ) { - const age = Date.now() - prevMatch.updatedAt - - const staleAge = preload - ? (route.options.preloadStaleTime ?? - inner.router.options.defaultPreloadStaleTime ?? - 30_000) // 30 seconds for preloads by default - : (route.options.staleTime ?? inner.router.options.defaultStaleTime ?? 0) - - const shouldReloadOption = route.options.shouldReload - - // Default to reloading the route all the time - // Allow shouldReload to get the last say, - // if provided. - const shouldReload = - typeof shouldReloadOption === 'function' - ? shouldReloadOption( - getLoaderContext(inner, matchPromises, matchId, index, route), - ) - : shouldReloadOption - - // If the route is successful and still fresh, just resolve - const { status, invalid } = match - const staleMatchShouldReload = - age >= staleAge && - (!!inner.forceStaleReload || - match.cause === 'enter' || - (previousRouteMatchId !== undefined && - previousRouteMatchId !== match.id)) - loaderShouldRunAsync = - status === 'success' && - (invalid || (shouldReload ?? staleMatchShouldReload)) - if (preload && route.options.preload === false) { - // Do nothing - } else if ( - loaderShouldRunAsync && - !inner.sync && - shouldReloadInBackground - ) { - loaderIsRunningAsync = true - ;(async () => { - try { - await runLoader(inner, matchPromises, matchId, index, route) - const match = inner.router.getMatch(matchId)! - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - match._nonReactive.loaderPromise = undefined - match._nonReactive.loadPromise = undefined - } catch (err) { - if (isRedirect(err)) { - await inner.router.navigate(err.options) - } - } - })() - } else if (status !== 'success' || loaderShouldRunAsync) { - await runLoader(inner, matchPromises, matchId, index, route) - } else { - syncMatchContext(inner, matchId, index) - } - } - - const { id: matchId, routeId } = inner.matches[index]! - let loaderShouldRunAsync = false - let loaderIsRunningAsync = false - const route = inner.router.looseRoutesById[routeId]! - const routeLoader = route.options.loader - const shouldReloadInBackground = - ((typeof routeLoader === 'function' - ? undefined - : routeLoader?.staleReloadMode) ?? - inner.router.options.defaultStaleReloadMode) !== 'blocking' - - if (shouldSkipLoader(inner, matchId)) { - const match = inner.router.getMatch(matchId) - if (!match) { - return inner.matches[index]! - } - - syncMatchContext(inner, matchId, index) - - if (isServer ?? inner.router.isServer) { - return inner.router.getMatch(matchId)! - } - } else { - const prevMatch = inner.router.getMatch(matchId)! // This is where all of the stale-while-revalidate magic happens - const activeIdAtIndex = inner.router.stores.matchesId.get()[index] - const activeAtIndex = - (activeIdAtIndex && - inner.router.stores.matchStores.get(activeIdAtIndex)) || - null - const previousRouteMatchId = - activeAtIndex?.routeId === routeId - ? activeIdAtIndex - : inner.router.stores.matches.get().find((d) => d.routeId === routeId) - ?.id - const preload = resolvePreload(inner, matchId) - - // there is a loaderPromise, so we are in the middle of a load - if (prevMatch._nonReactive.loaderPromise) { - // do not block if we already have stale data we can show - // but only if the ongoing load is not a preload since error handling is different for preloads - // and we don't want to swallow errors - if ( - prevMatch.status === 'success' && - !inner.sync && - !prevMatch.preload && - shouldReloadInBackground - ) { - return prevMatch - } - await prevMatch._nonReactive.loaderPromise - const match = inner.router.getMatch(matchId)! - const error = match._nonReactive.error || match.error - if (error) { - handleRedirectAndNotFound(inner, match, error) - } - - if (match.status === 'pending') { - await handleLoader( - preload, - prevMatch, - previousRouteMatchId, - match, - route, - ) - } - } else { - const nextPreload = - preload && !inner.router.stores.matchStores.has(matchId) - const match = inner.router.getMatch(matchId)! - match._nonReactive.loaderPromise = createControlledPromise() - if (nextPreload !== match.preload) { - inner.updateMatch(matchId, (prev) => ({ - ...prev, - preload: nextPreload, - })) - } - - await handleLoader(preload, prevMatch, previousRouteMatchId, match, route) - } - } - const match = inner.router.getMatch(matchId)! - if (!loaderIsRunningAsync) { - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - match._nonReactive.loadPromise = undefined - } - - clearTimeout(match._nonReactive.pendingTimeout) - match._nonReactive.pendingTimeout = undefined - if (!loaderIsRunningAsync) match._nonReactive.loaderPromise = undefined - match._nonReactive.dehydrated = undefined - - const nextIsFetching = loaderIsRunningAsync ? match.isFetching : false - if (nextIsFetching !== match.isFetching || match.invalid !== false) { - inner.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: nextIsFetching, - invalid: false, - })) - return inner.router.getMatch(matchId)! - } else { - return match - } -} - -export async function loadMatches(arg: { - router: AnyRouter - location: ParsedLocation - matches: Array - preload?: boolean - forceStaleReload?: boolean - onReady?: () => Promise - updateMatch: UpdateMatchFn - sync?: boolean -}): Promise> { - const inner: InnerLoadContext = arg - const matchPromises: Array> = [] - - // make sure the pending component is immediately rendered when hydrating a match that is not SSRed - // the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached - if ( - !(isServer ?? inner.router.isServer) && - hasForcePendingActiveMatch(inner.router) - ) { - triggerOnReady(inner) - } - - let beforeLoadNotFound: NotFoundError | undefined - - // Execute all beforeLoads one by one - for (let i = 0; i < inner.matches.length; i++) { - try { - const beforeLoad = handleBeforeLoad(inner, i) - if (isPromise(beforeLoad)) await beforeLoad - } catch (err) { - if (isRedirect(err)) { - throw err - } - if (isNotFound(err)) { - beforeLoadNotFound = err - } else { - if (!inner.preload) throw err - } - break - } - - if (inner.serialError || inner.firstBadMatchIndex != null) { - break - } - } - - // Execute loaders once, with max index adapted for beforeLoad notFound handling. - const baseMaxIndexExclusive = inner.firstBadMatchIndex ?? inner.matches.length - - const boundaryIndex = - beforeLoadNotFound && !inner.preload - ? getNotFoundBoundaryIndex(inner, beforeLoadNotFound) - : undefined - - const maxIndexExclusive = - beforeLoadNotFound && inner.preload - ? 0 - : boundaryIndex !== undefined - ? Math.min(boundaryIndex + 1, baseMaxIndexExclusive) - : baseMaxIndexExclusive - - let firstNotFound: NotFoundError | undefined - let firstUnhandledRejection: unknown - - for (let i = 0; i < maxIndexExclusive; i++) { - matchPromises.push(loadRouteMatch(inner, matchPromises, i)) - } - - try { - await Promise.all(matchPromises) - } catch { - const settled = await Promise.allSettled(matchPromises) - - for (const result of settled) { - if (result.status !== 'rejected') continue - - const reason = result.reason - if (isRedirect(reason)) { - throw reason - } - if (isNotFound(reason)) { - firstNotFound ??= reason - } else { - firstUnhandledRejection ??= reason - } - } - - if (firstUnhandledRejection !== undefined) { - throw firstUnhandledRejection - } - } - - const notFoundToThrow = - firstNotFound ?? - (beforeLoadNotFound && !inner.preload ? beforeLoadNotFound : undefined) - - let headMaxIndex = - inner.firstBadMatchIndex !== undefined - ? inner.firstBadMatchIndex - : inner.matches.length - 1 - - if (!notFoundToThrow && beforeLoadNotFound && inner.preload) { - return inner.matches - } - - if (notFoundToThrow) { - // Determine once which matched route will actually render the - // notFoundComponent, then pass this precomputed index through the remaining - // finalization steps. - // This can differ from the throwing route when routeId targets an ancestor - // boundary (or when bubbling resolves to a parent/root boundary). - const renderedBoundaryIndex = getNotFoundBoundaryIndex( - inner, - notFoundToThrow, - ) - - if (renderedBoundaryIndex === undefined) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: Could not find match for notFound boundary', - ) - } - - invariant() - } - const boundaryMatch = inner.matches[renderedBoundaryIndex]! - - const boundaryRoute = inner.router.looseRoutesById[boundaryMatch.routeId]! - const defaultNotFoundComponent = (inner.router.options as any) - ?.defaultNotFoundComponent - - // Ensure a notFoundComponent exists on the boundary route - if (!boundaryRoute.options.notFoundComponent && defaultNotFoundComponent) { - boundaryRoute.options.notFoundComponent = defaultNotFoundComponent - } - - notFoundToThrow.routeId = boundaryMatch.routeId - - const boundaryIsRoot = boundaryMatch.routeId === inner.router.routeTree.id - - inner.updateMatch(boundaryMatch.id, (prev) => ({ - ...prev, - ...(boundaryIsRoot - ? // For root boundary, use globalNotFound so the root component's - // shell still renders and handles the not-found display, - // instead of replacing the entire root shell via status='notFound'. - { status: 'success' as const, globalNotFound: true, error: undefined } - : // For non-root boundaries, set status:'notFound' so MatchInner - // renders the notFoundComponent directly. - { status: 'notFound' as const, error: notFoundToThrow }), - isFetching: false, - })) - - headMaxIndex = renderedBoundaryIndex - - // Ensure the rendering boundary route chunk (and its lazy components, including - // lazy notFoundComponent) is loaded before we continue to head execution/render. - await loadRouteChunk(boundaryRoute, ['notFoundComponent']) - } else if (!inner.preload) { - // Clear stale root global-not-found state on normal navigations that do not - // throw notFound. This must live here (instead of only in runLoader success) - // because the root loader may be skipped when data is still fresh. - const rootMatch = inner.matches[0]! - // `rootMatch` is the next match for this navigation. If it is not global - // not-found, then any currently stored root global-not-found is stale. - if (!rootMatch.globalNotFound) { - // `currentRootMatch` is the current store state (from the previous - // navigation/load). Update only when a stale flag is actually present. - const currentRootMatch = inner.router.getMatch(rootMatch.id) - if (currentRootMatch?.globalNotFound) { - inner.updateMatch(rootMatch.id, (prev) => ({ - ...prev, - globalNotFound: false, - error: undefined, - })) - } - } - } - - // When a serial error occurred (e.g. beforeLoad threw a regular Error), - // the erroring route's lazy chunk wasn't loaded because loaders were skipped. - // We need to load it so the code-split errorComponent is available for rendering. - if (inner.serialError && inner.firstBadMatchIndex !== undefined) { - const errorRoute = - inner.router.looseRoutesById[ - inner.matches[inner.firstBadMatchIndex]!.routeId - ]! - await loadRouteChunk(errorRoute, ['errorComponent']) - } - - // serially execute heads once after loaders/notFound handling, ensuring - // all head functions get a chance even if one throws. - for (let i = 0; i <= headMaxIndex; i++) { - const match = inner.matches[i]! - const { id: matchId, routeId } = match - const route = inner.router.looseRoutesById[routeId]! - try { - const headResult = executeHead(inner, matchId, route) - if (headResult) { - const head = await headResult - inner.updateMatch(matchId, (prev) => ({ - ...prev, - ...head, - })) - } - } catch (err) { - console.error(`Error executing head for route ${routeId}:`, err) - } - } - - const readyPromise = triggerOnReady(inner) - if (isPromise(readyPromise)) { - await readyPromise - } - - if (notFoundToThrow) { - throw notFoundToThrow - } - - if (inner.serialError && !inner.preload && !inner.onReady) { - throw inner.serialError - } - - return inner.matches -} - -export type RouteComponentType = - | 'component' - | 'errorComponent' - | 'pendingComponent' - | 'notFoundComponent' - -function preloadRouteComponents( - route: AnyRoute, - componentTypesToLoad: Array, -): Promise | undefined { - const preloads = componentTypesToLoad - .map((type) => (route.options[type] as any)?.preload?.()) - .filter(Boolean) - - if (preloads.length === 0) return undefined - - return Promise.all(preloads) as any as Promise -} - -export function loadRouteChunk( - route: AnyRoute, - componentTypesToLoad: Array = componentTypes, -) { - if (!route._lazyLoaded && route._lazyPromise === undefined) { - if (route.lazyFn) { - route._lazyPromise = route.lazyFn().then((lazyRoute) => { - // explicitly don't copy over the lazy route's id - const { id: _id, ...options } = lazyRoute.options - Object.assign(route.options, options) - route._lazyLoaded = true - route._lazyPromise = undefined // gc promise, we won't need it anymore - }) - } else { - route._lazyLoaded = true - } - } - - const runAfterLazy = () => - route._componentsLoaded - ? undefined - : componentTypesToLoad === componentTypes - ? (() => { - if (route._componentsPromise === undefined) { - const componentsPromise = preloadRouteComponents( - route, - componentTypes, - ) - - if (componentsPromise) { - route._componentsPromise = componentsPromise.then(() => { - route._componentsLoaded = true - route._componentsPromise = undefined // gc promise, we won't need it anymore - }) - } else { - route._componentsLoaded = true - } - } - - return route._componentsPromise - })() - : preloadRouteComponents(route, componentTypesToLoad) - - return route._lazyPromise - ? route._lazyPromise.then(runAfterLazy) - : runAfterLazy() -} - -function makeMaybe( - value: TValue, - error: TError, -): { status: 'success'; value: TValue } | { status: 'error'; error: TError } { - if (error) { - return { status: 'error' as const, error } - } - return { status: 'success' as const, value } -} - -export function routeNeedsPreload(route: AnyRoute) { - for (const componentType of componentTypes) { - if ((route.options[componentType] as any)?.preload) { - return true - } - } - return false -} - -export const componentTypes: Array = [ - 'component', - 'errorComponent', - 'pendingComponent', - 'notFoundComponent', -] as const diff --git a/packages/router-core/src/load-server.ts b/packages/router-core/src/load-server.ts new file mode 100644 index 0000000000..083d7ffb40 --- /dev/null +++ b/packages/router-core/src/load-server.ts @@ -0,0 +1,849 @@ +// Keep this filename free of a secondary extension so declaration generation +// can rewrite relative imports for both ESM and CJS. +import { isNotFound } from './not-found' +import { isRedirect, redirect } from './redirect' +import { rootRouteId } from './root' +import { loadRouteChunk } from './load-client' +import { waitForReason } from './await-signal' +import { getLocationChangeInfo, runRouteLifecycle } from './router' +import type { ParsedLocation } from './location' +import type { AnyRouteMatch } from './Matches' +import type { NotFoundError } from './not-found' +import type { + AnyRoute, + BeforeLoadContextOptions, + LoaderFnContext, + RouteContextOptions, + SsrContextOptions, +} from './route' +import type { AnyRedirect } from './redirect' +import type { AnyRouter, SSROption } from './router' + +declare const serverLanePhase: unique symbol + +type ServerLane = { + readonly [serverLanePhase]: TPhase + location: ParsedLocation + matches: Array +} + +type MatchedLane = ServerLane<'matched'> + +type IndexedOutcome = [index: number, outcome: LoaderOutcome, boundary?: number] + +type ContextualizedLane = ServerLane<'contextualized'> & { + end: number + failure?: IndexedOutcome +} + +type ReducedLane = ServerLane<'reduced'> + +const SUCCESS = 0 +const ERROR = 1 +const NOT_FOUND = 2 +const REDIRECTED = 3 +const SKIPPED = 4 + +type LoaderOutcome = + | [typeof SUCCESS, data: unknown] + | [typeof ERROR, error: unknown] + | [typeof NOT_FOUND, error: NotFoundError] + | [typeof REDIRECTED, redirect: AnyRedirect] + | [typeof SKIPPED] + +type LoaderTask = { + index: number + outcome: Promise + match: Promise +} + +export type ServerLoadResult = + | { + type: 'render' + status: 200 | 404 | 500 + matches: Array + } + | { type: 'redirect'; redirect: AnyRedirect } + +function getRoute(router: AnyRouter, match: AnyRouteMatch): AnyRoute { + return router.routesById[match.routeId] +} + +function normalize(value: unknown, rejected: boolean): LoaderOutcome { + if (isRedirect(value)) { + return [REDIRECTED, value] + } + if (isNotFound(value)) { + return [NOT_FOUND, value] + } + if (rejected && typeof (value as any)?.then === 'function') { + value = new Error('A Promise was thrown', { cause: value }) + } + return rejected ? [ERROR, value] : [SUCCESS, value] +} + +function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome { + let outcome = normalize(cause, true) + if (outcome[0] !== ERROR) { + return outcome + } + try { + route.options.onError?.(outcome[1]) + } catch (onErrorCause) { + outcome = normalize(onErrorCause, true) + } + return outcome +} + +function maybe( + value: TValue, + cause: unknown, +): { status: 'success'; value: TValue } | { status: 'error'; error: unknown } { + if (cause !== undefined) { + return { status: 'error', error: cause } + } + return { status: 'success', value } +} + +function navigateFrom(router: AnyRouter, location: ParsedLocation) { + return (options: any) => + router.navigate({ + ...options, + _fromLocation: location, + }) +} + +function waitFor(value: Promise, signal?: AbortSignal): Promise { + return signal ? waitForReason(value, signal) : value +} + +async function resolveSsr( + router: AnyRouter, + lane: MatchedLane, + index: number, +): Promise { + const match = lane.matches[index]! + const route = getRoute(router, match) + const parentSsr = lane.matches[index - 1]?.ssr + + if (router.isShell()) { + return route.id === rootRouteId + } + if (parentSsr === false) { + return false + } + + const inherit = (value: SSROption): SSROption => { + return value === true && parentSsr === 'data-only' ? 'data-only' : value + } + const defaultSsr = router.options.defaultSsr ?? true + const inheritedDefault = inherit(defaultSsr) + // A functional override can fail. Establish the inherited policy first so + // the selected error boundary retains the route's actual renderability. + match.ssr = inheritedDefault + const option = route.options.ssr + if (option === undefined) { + return inheritedDefault + } + if (typeof option !== 'function') { + return inherit(option) + } + + const context: SsrContextOptions = { + search: maybe(match.search, match.searchError), + params: maybe(match.params, match.paramsError), + location: lane.location, + matches: lane.matches.map((candidate) => ({ + index: candidate.index, + pathname: candidate.pathname, + fullPath: candidate.fullPath, + staticData: candidate.staticData, + id: candidate.id, + routeId: candidate.routeId, + search: maybe(candidate.search, candidate.searchError), + params: maybe(candidate.params, candidate.paramsError), + ssr: candidate.ssr, + })), + } + return inherit((await option(context)) ?? defaultSsr) +} + +function stampNotFound( + match: AnyRouteMatch, + outcome: LoaderOutcome, +): LoaderOutcome { + if (outcome[0] === NOT_FOUND && !outcome[1].routeId) { + outcome[1].routeId = match.routeId + } + return outcome +} + +async function contextualize( + router: AnyRouter, + lane: MatchedLane, + signal?: AbortSignal, +): Promise { + const globalBoundary = lane.matches.findIndex((match) => match._notFound) + let end = globalBoundary < 0 ? lane.matches.length : globalBoundary + 1 + let failure: IndexedOutcome | undefined + let parentContext: Record = { + ...(router.options.context ?? {}), + } + + for (let index = 0; index < end; index++) { + const match = lane.matches[index]! + const route = getRoute(router, match) + try { + match.ssr = await resolveSsr(router, lane, index) + } catch (cause) { + signal?.throwIfAborted() + failure = [index, stampNotFound(match, normalizeError(route, cause))] + end = index + } + signal?.throwIfAborted() + if (failure?.[1][0] === REDIRECTED) { + break + } + + match.__beforeLoadContext = undefined + let context = parentContext + try { + let routeContext + if (route.options.context) { + const routeContextOptions: RouteContextOptions< + any, + any, + any, + any, + any + > = { + deps: match.loaderDeps, + params: match.params, + context: parentContext, + location: lane.location, + navigate: navigateFrom(router, lane.location), + buildLocation: router.buildLocation, + cause: match.cause, + abortController: match.abortController, + preload: false, + matches: lane.matches, + routeId: route.id, + } + routeContext = route.options.context(routeContextOptions) ?? undefined + } + context = { + ...parentContext, + ...routeContext, + } + match.context = context + } catch (cause) { + signal?.throwIfAborted() + if (!failure) { + failure = [index, stampNotFound(match, normalizeError(route, cause))] + } + end = index + break + } + signal?.throwIfAborted() + if (failure) { + break + } + const validationError = match.paramsError ?? match.searchError + if (validationError !== undefined) { + failure = [ + index, + stampNotFound(match, normalizeError(route, validationError)), + ] + end = index + break + } + signal?.throwIfAborted() + + if (match.ssr === false || !route.options.beforeLoad) { + parentContext = context + continue + } + + const abortController = match.abortController + const options: BeforeLoadContextOptions< + any, + any, + any, + any, + any, + any, + any, + any, + any + > = { + search: match.search, + abortController, + params: match.params, + preload: false, + context, + location: lane.location, + navigate: navigateFrom(router, lane.location), + buildLocation: router.buildLocation, + cause: match.cause, + matches: lane.matches, + routeId: route.id, + ...router.options.additionalContext, + } + + try { + const beforeLoadContext = await route.options.beforeLoad(options) + signal?.throwIfAborted() + const outcome = stampNotFound(match, normalize(beforeLoadContext, false)) + if (outcome[0] !== SUCCESS) { + failure = [index, outcome] + end = index + break + } + match.__beforeLoadContext = beforeLoadContext + match.context = { + ...context, + ...beforeLoadContext, + } + parentContext = match.context + } catch (cause) { + signal?.throwIfAborted() + failure = [index, stampNotFound(match, normalizeError(route, cause))] + end = index + break + } + } + + return { + location: lane.location, + matches: lane.matches, + end, + failure, + } as ContextualizedLane +} + +function getLoaderContext( + router: AnyRouter, + lane: ContextualizedLane, + match: AnyRouteMatch, + route: AnyRoute, + index: number, + tasks: Array, +): LoaderFnContext { + return { + params: match.params, + deps: match.loaderDeps, + preload: false, + parentMatchPromise: tasks[index - 1]?.match, + abortController: match.abortController, + context: match.context, + location: lane.location, + navigate: navigateFrom(router, lane.location), + cause: match.cause, + route, + ...router.options.additionalContext, + } +} + +function createLoaderTask( + router: AnyRouter, + lane: ContextualizedLane, + index: number, + tasks: Array, + signal?: AbortSignal, +): LoaderTask { + const match = lane.matches[index]! + const route = getRoute(router, match) + let outcome: Promise + + if (match.ssr === false) { + outcome = Promise.resolve([SKIPPED]) + } else { + const routeLoader = route.options.loader + const loader = + typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler + if (!loader) { + outcome = Promise.resolve([SUCCESS, undefined]) + } else { + outcome = Promise.resolve() + .then(() => + loader(getLoaderContext(router, lane, match, route, index, tasks)), + ) + .then( + (result) => normalize(result, false), + (cause) => normalize(cause, true), + ) + .then((result): LoaderOutcome => { + if ( + result[0] !== REDIRECTED && + (signal?.aborted || match.abortController.signal.reason === lane) + ) { + return [SKIPPED] + } + if (result[0] === ERROR) { + result = normalizeError(route, result[1]) + } + return stampNotFound(match, result) + }) + } + } + + const parentMatch = outcome.then((result) => { + const snapshot = { ...match } + if (result[0] === SUCCESS) { + snapshot.loaderData = result[1] + snapshot.status = 'success' + snapshot.error = undefined + snapshot.invalid = false + snapshot.isFetching = false + } else if (result[0] === ERROR) { + snapshot.status = 'error' + snapshot.error = result[1] + } else if (result[0] === NOT_FOUND) { + snapshot.status = 'notFound' + snapshot.error = result[1] + } + return snapshot + }) + + return { index, outcome, match: parentMatch } +} + +async function getNotFoundBoundary( + router: AnyRouter, + matches: Array, + indexed: IndexedOutcome | undefined, + signal?: AbortSignal, + fallback = 0, +): Promise { + const cause = indexed?.[1][1] as NotFoundError | undefined + let index = cause?.routeId + ? matches.findIndex((match) => match.routeId === cause.routeId) + : (indexed?.[0] ?? matches.length - 1) + if (index < 0) { + index = 0 + } + for (let candidate = index; candidate >= 0; candidate--) { + const route = getRoute(router, matches[candidate]!) + const loading = loadRouteChunk(route, false) + if (loading) { + try { + await loading + } catch { + signal?.throwIfAborted() + } + } + signal?.throwIfAborted() + if (route.options.notFoundComponent) { + return candidate + } + } + return cause?.routeId ? index : fallback +} + +function abortMatches( + matches: Array, + start = 0, + reason?: unknown, +): void { + for (let index = start; index < matches.length; index++) { + matches[index]!.abortController.abort(reason) + } +} + +function resolveServerRedirect( + router: AnyRouter, + location: ParsedLocation, + value: AnyRedirect, +): ServerLoadResult { + value.options._fromLocation = location + return { type: 'redirect', redirect: router.resolveRedirect(value) } +} + +async function applyFailure( + router: AnyRouter, + lane: ContextualizedLane, + indexed: IndexedOutcome | undefined, + signal?: AbortSignal, +): Promise<{ status: 200 | 404 | 500; boundary?: number; kind?: number }> { + if (!indexed) { + const boundary = lane.matches.findIndex((match) => match._notFound) + if (boundary >= 0) { + abortMatches(lane.matches, boundary + 1) + return { status: 404, boundary, kind: NOT_FOUND } + } + return { status: 200 } + } + + const [index, outcome] = indexed + if (outcome[0] === ERROR) { + const match = lane.matches[index]! + match._notFound = undefined + match.status = 'error' + match.error = outcome[1] + match.isFetching = false + abortMatches(lane.matches, index + 1) + return { status: 500, boundary: index, kind: ERROR } + } + + const boundary = + indexed[2] ?? + (await getNotFoundBoundary(router, lane.matches, indexed, signal)) + const match = lane.matches[boundary]! + const cause = outcome[1] as NotFoundError + cause.routeId = match.routeId + match._notFound = undefined + if (match.routeId === router.routeTree.id) { + match.status = 'success' + match._notFound = true + match.error = cause + } else { + match.status = 'notFound' + match.error = cause + } + match.isFetching = false + abortMatches(lane.matches, boundary + 1) + return { status: 404, boundary, kind: NOT_FOUND } +} + +async function loadNormalChunks( + router: AnyRouter, + lane: ContextualizedLane, + end: number, + signal?: AbortSignal, +): Promise { + const chunks: Array> = [] + for (let index = 0; index < lane.matches.length; index++) { + const match = lane.matches[index]! + if (index >= end || match.ssr !== true || match.status !== 'success') { + continue + } + const route = getRoute(router, match) + try { + const loading = loadRouteChunk(route) + if (loading) { + const chunk = loading.then( + () => { + signal?.throwIfAborted() + return undefined + }, + (cause) => { + signal?.throwIfAborted() + return [ + index, + stampNotFound(match, normalizeError(route, cause)), + ] as IndexedOutcome + }, + ) + // Route-order reduction can return before later chunks settle. + void chunk.catch(() => {}) + chunks.push(chunk) + } + } catch (cause) { + signal?.throwIfAborted() + chunks.push([index, stampNotFound(match, normalizeError(route, cause))]) + } + } + for (const chunk of chunks) { + const indexed = Array.isArray(chunk) ? chunk : await chunk + if (indexed) { + return indexed + } + } + return undefined +} + +async function projectLane( + router: AnyRouter, + lane: ReducedLane, + signal?: AbortSignal, +): Promise { + for (const match of lane.matches) { + const routeOptions = getRoute(router, match).options + if (routeOptions.head || routeOptions.scripts || routeOptions.headers) { + const context = { + ssr: router.options.ssr, + matches: lane.matches, + match, + params: match.params, + loaderData: match.loaderData, + } + try { + const [head, scripts, headers] = await Promise.all([ + routeOptions.head?.(context), + routeOptions.scripts?.(context), + routeOptions.headers?.(context), + ]) + signal?.throwIfAborted() + match.meta = head?.meta + match.links = head?.links + match.headScripts = head?.scripts + match.styles = head?.styles + match.scripts = scripts + match.headers = headers + } catch (cause) { + signal?.throwIfAborted() + console.error(cause) + } + } + if (match.ssr === false || match.status !== 'success' || match._notFound) { + break + } + } +} + +async function executeServerLane( + router: AnyRouter, + location: ParsedLocation, + matchedMatches: Array, + signal?: AbortSignal, +): Promise { + const matched = { + location, + matches: matchedMatches.map((match) => ({ + ...match, + __beforeLoadContext: undefined, + context: {}, + isFetching: false, + abortController: new AbortController(), + })), + } as MatchedLane + const abortLane = () => abortMatches(matched.matches, 0, signal?.reason) + if (signal?.aborted) { + abortLane() + signal.throwIfAborted() + } + signal?.addEventListener('abort', abortLane, { once: true }) + + try { + const plannedGlobalBoundary = matched.matches.findIndex( + (match) => match._notFound, + ) + if (router.options.notFoundMode !== 'root' && plannedGlobalBoundary >= 0) { + const boundary = await getNotFoundBoundary( + router, + matched.matches, + undefined, + signal, + plannedGlobalBoundary, + ) + if (boundary !== plannedGlobalBoundary) { + matched.matches[plannedGlobalBoundary]!._notFound = undefined + matched.matches[boundary]!._notFound = true + } + } + const lane = await contextualize(router, matched, signal) + signal?.throwIfAborted() + + let loaderEnd = lane.end + if (lane.failure?.[1][0] === REDIRECTED) { + loaderEnd = 0 + } else if (lane.failure?.[1][0] === NOT_FOUND) { + lane.failure[2] = await getNotFoundBoundary( + router, + lane.matches, + lane.failure, + signal, + ) + loaderEnd = Math.min(loaderEnd, lane.failure[2] + 1) + } + + const tasks: Array = [] + for (let index = 0; index < loaderEnd; index++) { + const task = createLoaderTask(router, lane, index, tasks, signal) + tasks.push(task) + } + + let loaderFailure: IndexedOutcome | undefined + let control = lane.failure?.[1][0] === REDIRECTED ? lane.failure : undefined + try { + await Promise.all( + tasks.map((task) => + task.outcome.then((loadedOutcome) => { + const match = lane.matches[task.index]! + const outcome = loadedOutcome + if (outcome[0] === SUCCESS) { + match.loaderData = outcome[1] + match.status = 'success' + match.error = undefined + match.invalid = false + match.isFetching = false + match.updatedAt = Date.now() + } else if (outcome[0] === REDIRECTED) { + control = [task.index, outcome] + throw control + } else { + // A selective-SSR skip must stay pending for hydration. Every + // settled server attempt is otherwise renderable unless + // reduction selects it as the lane's terminal failure. + if (match.ssr !== false) { + match.status = 'success' + match.error = undefined + match.invalid = true + match.isFetching = false + } + if (!loaderFailure && outcome[0] !== SKIPPED) { + loaderFailure = [task.index, outcome] + } + } + }), + ), + ) + } catch (cause) { + if (!Array.isArray(cause)) { + throw cause + } + control = cause as IndexedOutcome + } + signal?.throwIfAborted() + + if (control?.[1][0] === REDIRECTED) { + abortMatches(lane.matches, 0, lane) + return resolveServerRedirect(router, location, control[1][1]) + } + + let failure = lane.failure ?? loaderFailure + const plannedBoundary = lane.matches.findIndex((match) => match._notFound) + let readinessEnd: number + if (failure) { + const outcomeEnd = (failure[2] ??= + failure[1][0] === NOT_FOUND + ? await getNotFoundBoundary(router, lane.matches, failure, signal) + : failure[0]) + for (const task of tasks) { + if (task.index >= outcomeEnd) { + break + } + const outcome = await task.outcome + // Presence means a loader previously succeeded, even with `undefined`. + if ( + outcome[0] !== SUCCESS && + outcome[0] < REDIRECTED && + !('loaderData' in lane.matches[task.index]!) + ) { + failure = [task.index, outcome] + failure[2] = + outcome[0] === NOT_FOUND + ? await getNotFoundBoundary(router, lane.matches, failure, signal) + : task.index + break + } + } + readinessEnd = failure[2] + } else { + readinessEnd = plannedBoundary < 0 ? lane.matches.length : plannedBoundary + } + const requiredFailure = await loadNormalChunks( + router, + lane, + readinessEnd, + signal, + ) + signal?.throwIfAborted() + if (requiredFailure) { + if (requiredFailure[1][0] === REDIRECTED) { + abortMatches(lane.matches) + return resolveServerRedirect(router, location, requiredFailure[1][1]) + } + failure = requiredFailure + } + + const terminal = await applyFailure(router, lane, failure, signal) + if (terminal.boundary !== undefined) { + const match = lane.matches[terminal.boundary]! + if (match.ssr === true) { + const route = getRoute(router, match) + try { + if (terminal.kind === ERROR) { + await loadRouteChunk(route, 'errorComponent') + } else if (match._notFound) { + await Promise.all([ + loadRouteChunk(route), + loadRouteChunk(route, 'notFoundComponent'), + ]) + } else { + await loadRouteChunk(route, 'notFoundComponent') + } + } catch {} + signal?.throwIfAborted() + } + } + + signal?.throwIfAborted() + await projectLane( + router, + { + location: lane.location, + matches: lane.matches, + } as ReducedLane, + signal, + ) + signal?.throwIfAborted() + router.serverSsr?.onCleanup(abortLane) + return { type: 'render', status: terminal.status, matches: lane.matches } + } finally { + signal?.removeEventListener('abort', abortLane) + } +} + +type ServerLoadOptions = NonNullable[0]> & { + _signal?: AbortSignal +} + +export async function loadServerRoute( + router: AnyRouter, + opts?: ServerLoadOptions, +): Promise { + router.updateLatestLocation() + const next = router.latestLocation + const previous = router._committed + let result: ServerLoadResult + try { + const canonical = router.buildLocation({ + to: next.pathname, + search: true, + params: true, + hash: true, + state: true, + _includeValidateSearch: true, + }) + if (next.publicHref !== canonical.publicHref) { + const href = canonical.publicHref || '/' + throw canonical.external + ? redirect({ href }) + : redirect({ href, _builtLocation: canonical }) + } + + const fromLocation = router.stores.resolvedLocation.get() + const changeInfo = getLocationChangeInfo(next, fromLocation) + router.emit({ type: 'onBeforeNavigate', ...changeInfo }) + router.emit({ type: 'onBeforeLoad', ...changeInfo }) + opts?._signal?.throwIfAborted() + result = await waitFor( + executeServerLane(router, next, router.matchRoutes(next), opts?._signal), + opts?._signal, + ) + opts?._signal?.throwIfAborted() + } catch (cause) { + opts?._signal?.throwIfAborted() + if (!isRedirect(cause)) { + throw cause + } + result = resolveServerRedirect(router, next, cause) + } + + router._serverResult = result + router.batch(() => { + router.stores.location.set(next) + router.stores.status.set('idle') + if (result.type === 'render') { + router.stores.setMatches(result.matches) + router.stores.resolvedLocation.set(next) + } + }) + if (result.type === 'render') { + router._committed = result.matches + runRouteLifecycle(router, previous, result.matches) + } + router._commitPromise?.resolve() + router._commitPromise = undefined +} diff --git a/packages/router-core/src/redirect.ts b/packages/router-core/src/redirect.ts index b10fac6d7a..2ac387daeb 100644 --- a/packages/router-core/src/redirect.ts +++ b/packages/router-core/src/redirect.ts @@ -21,7 +21,6 @@ export type Redirect< */ _builtLocation?: ParsedLocation } - redirectHandled?: boolean } export type RedirectOptions< diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 2de7dc57f0..eae2f15e30 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -700,10 +700,6 @@ export interface Route< THandlers > isRoot: TParentRoute extends AnyRoute ? true : false - /** @internal */ - _componentsPromise?: Promise - /** @internal */ - _componentsLoaded?: boolean lazyFn?: () => Promise< LazyRoute< Route< @@ -729,9 +725,7 @@ export interface Route< > > /** @internal */ - _lazyPromise?: Promise - /** @internal */ - _lazyLoaded?: boolean + _lazy?: Promise | true rank: number to: TrimPathRight init: (opts: { originalIndex: number }) => void @@ -1711,10 +1705,7 @@ export class BaseRoute< > > /** @internal */ - _lazyPromise?: Promise - /** @internal */ - _componentsPromise?: Promise - + _lazy?: Promise | true constructor( options?: RouteOptions< TRegister, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index df9251a739..e2c9da3d25 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1,8 +1,7 @@ import { createBrowserHistory, parseHref } from '@tanstack/history' -import { isServer } from '@tanstack/router-core/isServer' +import { isServer, loadServerRoute } from '@tanstack/router-core/isServer' import { DEFAULT_PROTOCOL_ALLOWLIST, - createControlledPromise, decodePath, deepEqual, encodePathLikeUrl, @@ -35,8 +34,15 @@ import { isNotFound } from './not-found' import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' -import { isRedirect, redirect } from './redirect' -import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' +import { isRedirect } from './redirect' +import { + loadClientRoute, + loadRouteChunk, + preloadClientRoute, + refreshClientRoute, + replaceRouteChunk, + transferMatchResources, +} from './load-client' import { composeRewrites, executeRewriteInput, @@ -51,6 +57,13 @@ import type { } from './new-process-route-tree' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' +import type { + ActivePreload, + LoadTransaction, + LoaderFlight, + PendingSession, +} from './load-client' +import type { ServerLoadResult } from './load-server' import type { HistoryAction, HistoryLocation, @@ -62,7 +75,6 @@ import type { import type { Awaitable, Constrain, - ControlledPromise, NoInfer, NonNullableUpdater, PickAsRequired, @@ -75,8 +87,6 @@ import type { AnyRouteWithContext, LoaderStaleReloadMode, MakeRemountDepsOptionsUnion, - RouteContextOptions, - RouteLike, RouteMask, SearchMiddleware, SearchMiddlewareMeta, @@ -106,7 +116,6 @@ import type { } from './manifest' import type { AnySchema, AnyValidator } from './validators' import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link' -import type { NotFoundError } from './not-found' import type { AnySerializationAdapter, ValidateSerializableInput, @@ -263,9 +272,9 @@ export interface RouterOptions< */ defaultPreloadStaleTime?: number /** - * The default `defaultPreloadGcTime` a route should use if no preloadGcTime is provided. + * The default `preloadGcTime` a route should use if none is provided. * - * @default 1_800_000 `(30 minutes)` + * @default 300_000 `(5 minutes)` * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloadgctime-property) * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading) */ @@ -297,7 +306,7 @@ export interface RouterOptions< /** * The default `gcTime` a route should use if no gcTime is provided. * - * @default 1_800_000 `(30 minutes)` + * @default 300_000 `(5 minutes)` * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultgctime-property) * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#key-options) */ @@ -544,14 +553,10 @@ export interface RouterState< in out TRouteMatch = MakeRouteMatchUnion, > { status: 'pending' | 'idle' - loadedAt: number isLoading: boolean - isTransitioning: boolean matches: Array location: ParsedLocation> resolvedLocation?: ParsedLocation> - statusCode: number - redirect?: AnyRedirect } export interface BuildNextOptions { @@ -619,9 +624,21 @@ export type SubscribeFn = ( ) => () => void export interface MatchRoutesOpts { - preload?: boolean throwOnError?: boolean - dest?: BuildNextOptions + /** @internal */ + _controller?: AbortController + /** @internal */ + _rematerialize?: boolean +} + +function routeNeedsLoad(route: AnyRoute): unknown { + return ( + route.options.loader || + route.options.beforeLoad || + route.lazyFn || + (route.options.component as any)?.preload || + (route.options.pendingComponent as any)?.preload + ) } export type InferRouterContext = @@ -748,6 +765,8 @@ export type EmitFn = (routerEvent: RouterEvent) => void export type LoadFn = (opts?: { sync?: boolean action?: { type: HistoryAction } + _signal?: AbortSignal + _dedupe?: boolean }) => Promise export type CommitLocationFn = ({ @@ -756,7 +775,11 @@ export type CommitLocationFn = ({ ...next }: ParsedLocation & CommitLocationOptions) => Promise -export type StartTransitionFn = (fn: () => void) => void +export type StartTransitionFn = ( + fn: () => void, + expected: Array, + urgent?: boolean, +) => Promise export interface MatchRoutesFn { ( @@ -775,13 +798,6 @@ export interface MatchRoutesFn { ): Array } -export type GetMatchFn = (matchId: string) => AnyRouteMatch | undefined - -export type UpdateMatchFn = ( - id: string, - updater: (match: AnyRouteMatch) => AnyRouteMatch, -) => void - export type LoadRouteChunkFn = (route: AnyRoute) => Promise> export type ResolveRedirect = (err: AnyRedirect) => ResolvedRedirect @@ -896,12 +912,60 @@ export function getLocationChangeInfo( location: ParsedLocation, resolvedLocation?: ParsedLocation, ) { - const fromLocation = resolvedLocation - const toLocation = location - const pathChanged = fromLocation?.pathname !== toLocation.pathname - const hrefChanged = fromLocation?.href !== toLocation.href - const hashChanged = fromLocation?.hash !== toLocation.hash - return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged } + return { + fromLocation: resolvedLocation, + toLocation: location, + pathChanged: resolvedLocation?.pathname !== location.pathname, + hrefChanged: resolvedLocation?.href !== location.href, + hashChanged: resolvedLocation?.hash !== location.hash, + } +} + +/** + * Return only state owned by the application, excluding volatile history + * bookkeeping. Mask payloads (`__tempLocation`/`__tempKey`) are kept: they + * distinguish otherwise-identical locations. + */ +export function _getUserHistoryState({ + key: _key, + __TSR_key: _tsrKey, + __TSR_index: _tsrIndex, + __hashScrollIntoViewOptions: _hashScroll, + ...state +}: ParsedHistoryState): HistoryState { + return state +} + +/** Run route lifecycle callbacks in leave/enter/stay phases. */ +export function runRouteLifecycle( + router: AnyRouter, + previous: Array, + matches: Array, + isCurrent?: () => boolean, +): void { + for (const match of previous) { + if (isCurrent?.() === false) { + return + } + if (!matches.some((candidate) => candidate.routeId === match.routeId)) { + ;(router.routesById as Record)[ + match.routeId + ]!.options.onLeave?.(match) + } + } + for (const match of matches) { + if (isCurrent?.() === false) { + return + } + const route = (router.routesById as Record)[ + match.routeId + ]! + route.options[ + previous.some((candidate) => candidate.routeId === match.routeId) + ? 'onStay' + : 'onEnter' + ]?.(match) + } } type LightweightRouteMatchResult = { @@ -951,6 +1015,43 @@ declare global { | undefined } +export interface RouterCore< + in out TRouteTree extends AnyRoute, + in out TTrailingSlashOption extends TrailingSlashOption, + in out TDefaultStructuralSharingOption extends boolean, + in out TRouterHistory extends RouterHistory = RouterHistory, + in out TDehydrated extends Record = Record, +> { + shouldViewTransition?: boolean | ViewTransitionOptions + /** Current client load transaction and owner of navigation writes. */ + _tx?: LoadTransaction + /** Joinable in-flight loader generations keyed by match ID. */ + _flights?: Map + /** Whole speculative lanes that an identical navigation may adopt. */ + _preloads?: Map + /** Owns cancellable work before a client transaction publishes. */ + _preflight?: AbortController + /** Transfers one reconstructed SSR prefix into its initial client load. */ + _handoff?: HydrationHandoff + /** Pending-boundary reveal and minimum-visible timing state. */ + _pending?: PendingSession + /** Result of the latest server load, used to render or redirect. */ + _serverResult?: ServerLoadResult + /** Framework callback that acknowledges an exact matches publication. */ + _rendered?: (matches: Array) => void + /** Development-only HMR reload for a route and its descendants. */ + _refreshRoute: (() => Promise) | undefined + /** Development-only replacement for a route's lazy chunk owner. */ + _replaceRouteChunk: + | ((route: AnyRoute, lazyFn: AnyRoute['lazyFn']) => void) + | undefined +} + +export type HydrationHandoff = [ + claim: () => AbortController | undefined, + finish: (matches?: Array) => number | undefined, +] + /** * Core, framework-agnostic router engine that powers TanStack Router. * @@ -979,10 +1080,11 @@ export class RouterCore< restoration?: boolean reset?: boolean } = { next: true } - shouldViewTransition?: boolean | ViewTransitionOptions = undefined - isViewTransitionTypesSupported?: boolean = undefined subscribers = new Set>() - viewTransitionPromise?: ControlledPromise + /** Accepted off-screen loader generations keyed by match ID. */ + _cache = new Map() + /** Accepted semantic lane, excluding temporary pending presentation. */ + _committed: Array = [] // Must build in constructor stores!: RouterStores @@ -1003,7 +1105,7 @@ export class RouterCore< rewrite?: LocationRewrite origin?: string latestLocation!: ParsedLocation> - pendingBuiltLocation?: ParsedLocation> + _pendingLocation?: ParsedLocation> basepath!: string routeTree!: TRouteTree routesById!: RoutesById @@ -1048,24 +1150,19 @@ export class RouterCore< options.protocolAllowlist ?? DEFAULT_PROTOCOL_ALLOWLIST, }) - if (typeof document !== 'undefined') { + if (!(isServer ?? typeof document === 'undefined')) { self.__TSR_ROUTER__ = this } } - // This is a default implementation that can optionally be overridden - // by the router provider once rendered. We provide this so that the - // router can be used in a non-react environment if necessary - startTransition: StartTransitionFn = (fn) => fn() - + startTransition: StartTransitionFn = async (fn) => { + fn() + return false + } isShell() { return !!this.options.isShell } - isPrerendering() { - return !!this.options.isPrerendering - } - update: UpdateFn< TRouteTree, TTrailingSlashOption, @@ -1091,7 +1188,8 @@ export class RouterCore< ...newOptions, } - this.isServer = this.options.isServer ?? typeof document === 'undefined' + this.isServer = + this.options.isServer ?? isServer ?? typeof document === 'undefined' this.protocolAllowlist = new Set(this.options.protocolAllowlist) @@ -1165,17 +1263,13 @@ export class RouterCore< if (!this.stores && this.latestLocation) { const config = this.getStoreConfig(this) this.batch = config.batch - this.stores = createRouterStores( - getInitialRouterState(this.latestLocation), - config, - ) + this.stores = createRouterStores(this.latestLocation, config) if (!(isServer ?? this.isServer)) { setupScrollRestoration(this) } } - let needsLocationUpdate = false const nextBasepath = this.options.basepath ?? '/' const nextRewriteOption = this.options.rewrite const basepathChanged = basepathWasUnset || prevBasepath !== nextBasepath @@ -1208,21 +1302,9 @@ export class RouterCore< this.updateLatestLocation() } - needsLocationUpdate = true - } - - if (needsLocationUpdate && this.stores) { - this.stores.location.set(this.latestLocation) - } - - if ( - typeof window !== 'undefined' && - 'CSS' in window && - typeof window.CSS?.supports === 'function' - ) { - this.isViewTransitionTypesSupported = window.CSS.supports( - 'selector(:active-view-transition-type(a))', - ) + if (this.stores) { + this.stores.location.set(this.latestLocation) + } } } @@ -1293,11 +1375,15 @@ export class RouterCore< } emit: EmitFn = (routerEvent) => { - this.subscribers.forEach((listener) => { + for (const listener of this.subscribers) { if (listener.eventType === routerEvent.type) { - listener.fn(routerEvent) + try { + listener.fn(routerEvent) + } catch (e) { + console.error(e) + } } - }) + } } /** @@ -1405,10 +1491,6 @@ export class RouterCore< return branch } - get looseRoutesById() { - return this.routesById as Record - } - matchRoutes: MatchRoutesFn = ( pathnameOrNext: string | ParsedLocation, locationSearchOrOpts?: AnySchema | MatchRoutesOpts, @@ -1427,16 +1509,6 @@ export class RouterCore< return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts) } - private getParentContext(parentMatch?: AnyRouteMatch) { - const parentMatchId = parentMatch?.id - - const parentContext = !parentMatchId - ? ((this.options.context as any) ?? undefined) - : (parentMatch.context ?? this.options.context ?? undefined) - - return parentContext - } - private matchRoutesInternal( next: ParsedLocation, opts?: MatchRoutesOpts, @@ -1463,18 +1535,19 @@ export class RouterCore< } } - const globalNotFoundRouteId = isGlobalNotFound + const _notFoundRouteId = isGlobalNotFound ? findGlobalNotFoundRouteId(this.options.notFoundMode, matchedRoutes) : undefined const matches = new Array(matchedRoutes.length) - // Snapshot of active match state keyed by routeId, used to stabilise - // params/search across navigations. - const previousActiveMatchesByRouteId = new Map() - for (const store of this.stores.matchStores.values()) { - if (store.routeId) { - previousActiveMatchesByRouteId.set(store.routeId, store.get()) - } + const committed = this._committed + const previousAt = (route: AnyRoute, index: number) => { + const match = committed[index] + return match?.routeId === route.id + ? match + : route === this.options.notFoundRoute + ? committed.find((candidate) => candidate.routeId === route.id) + : undefined } for (let index = 0; index < matchedRoutes.length; index++) { @@ -1506,7 +1579,6 @@ export class RouterCore< ...strictSearch, } strictMatchSearch = { ...parentStrictSearch, ...strictSearch } - searchError = undefined } catch (err: any) { let searchParamError = err if (!(err instanceof SearchParamError)) { @@ -1524,19 +1596,25 @@ export class RouterCore< searchError = searchParamError } } - // This is where we need to call route.options.loaderDeps() to get any additional // deps that the route's loader function might need to run. We need to do this // before we create the match so that we can pass the deps to the route's // potential key function which is used to uniquely identify the route match in state - const loaderDeps = - route.options.loaderDeps?.({ - search: preMatchSearch, - }) ?? '' - - const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' - + let loaderDeps: any = '' + let loaderDepsHash = '' + try { + loaderDeps = + route.options.loaderDeps?.({ + search: preMatchSearch, + }) ?? '' + loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) || '' : '' + } catch (cause) { + if (opts?.throwOnError) { + throw cause + } + searchError ??= cause + } const { interpolatedPath, usedParams } = interpolatePath({ path: route.fullPath, params: routeParams, @@ -1544,12 +1622,9 @@ export class RouterCore< server: this.isServer, }) - // Waste not, want not. If we already have a match for this route, - // reuse it. This is important for layout routes, which might stick - // around between navigation actions that only change leaf routes. - - // Existing matches are matches that are already loaded along with - // pending matches that are still loading + // Seed planning from the accepted same-ID cache generation first, then + // from the committed generation for this route. Presentation stores are + // deliberately not a semantic reuse authority. const matchId = // route.id for disambiguation route.id + @@ -1558,13 +1633,16 @@ export class RouterCore< // explicit deps loaderDepsHash - const existingMatch = this.getMatch(matchId) - - const previousMatch = previousActiveMatchesByRouteId.get(route.id) + const previousMatch = previousAt(route, index) + const existingMatch = + process.env.NODE_ENV !== 'production' && opts?._rematerialize + ? undefined + : (this._cache.get(matchId) ?? + (previousMatch?.id === matchId ? previousMatch : undefined)) const strictParams = existingMatch?._strictParams ?? usedParams - let paramsError: unknown = undefined + let paramsError: unknown if (!existingMatch) { try { @@ -1600,15 +1678,10 @@ export class RouterCore< ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) : nullReplaceEqualDeep(existingMatch.search, preMatchSearch), _strictSearch: strictMatchSearch, + searchError, } } else { - const status = - route.options.loader || - route.options.beforeLoad || - route.lazyFn || - routeNeedsPreload(route) - ? 'pending' - : 'success' + const status = routeNeedsLoad(route) ? 'pending' : 'success' match = { id: matchId, @@ -1623,96 +1696,42 @@ export class RouterCore< ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) : preMatchSearch, _strictSearch: strictMatchSearch, - searchError: undefined, + searchError, status, isFetching: false, error: undefined, paramsError, - __routeContext: undefined, - _nonReactive: { - loadPromise: createControlledPromise(), - }, - __beforeLoadContext: undefined, context: {}, - abortController: new AbortController(), - fetchCount: 0, + abortController: opts?._controller ?? new AbortController(), cause, loaderDeps: previousMatch ? replaceEqualDeep(previousMatch.loaderDeps, loaderDeps) : loaderDeps, invalid: false, preload: false, - links: undefined, - scripts: undefined, - headScripts: undefined, - meta: undefined, staticData: route.options.staticData || {}, fullPath: route.fullPath, } } - if (!opts?.preload) { - // If we have a global not found, mark the right match as global not found - match.globalNotFound = globalNotFoundRouteId === route.id - } - - // update the searchError if there is one - match.searchError = searchError - - const parentContext = this.getParentContext(parentMatch) - - match.context = { - ...parentContext, - ...match.__routeContext, - ...match.__beforeLoadContext, + // If we have a global not found, mark the right match as global not found + const _notFound = _notFoundRouteId === route.id + if (match._notFound && !_notFound) { + match.error = undefined } + match._notFound = _notFound matches[index] = match } for (let index = 0; index < matches.length; index++) { const match = matches[index]! - const route = this.looseRoutesById[match.routeId]! - const existingMatch = this.getMatch(match.id) - - // Update the match's params - const previousMatch = previousActiveMatchesByRouteId.get(match.routeId) - match.params = previousMatch - ? nullReplaceEqualDeep(previousMatch.params, routeParams) - : routeParams - - if (!existingMatch) { - const parentMatch = matches[index - 1] - const parentContext = this.getParentContext(parentMatch) - - // Update the match's context - - if (route.options.context) { - const contextFnContext: RouteContextOptions = - { - deps: match.loaderDeps, - params: match.params, - context: parentContext ?? {}, - location: next, - navigate: (opts: any) => - this.navigate({ ...opts, _fromLocation: next }), - buildLocation: this.buildLocation, - cause: match.cause, - abortController: match.abortController, - preload: !!match.preload, - matches, - routeId: route.id, - } - // Get the route context - match.__routeContext = - route.options.context(contextFnContext) ?? undefined - } - - match.context = { - ...parentContext, - ...match.__routeContext, - ...match.__beforeLoadContext, - } + match.params = + match.cause === 'stay' + ? nullReplaceEqualDeep(match.params, routeParams) + : routeParams + if (opts?._controller) { + match.context = {} } } @@ -1720,22 +1739,35 @@ export class RouterCore< } getMatchedRoutes: GetMatchRoutesFn = (pathname) => { - return getMatchedRoutes({ - pathname, - routesById: this.routesById, - processedTree: this.processedTree, - }) + const routeParams: Record = Object.create(null) + const match = findRouteMatch( + trimPathRight(pathname), + this.processedTree, + true, + ) + if (match) { + Object.assign(routeParams, match.rawParams) + } + return { + matchedRoutes: match?.branch || [this.routesById[rootRouteId]!], + routeParams, + foundRoute: match?.route, + } } /** * Lightweight route matching for buildLocation. * Only computes fullPath, accumulated search, and params - skipping expensive - * operations like AbortController, ControlledPromise, loaderDeps, and full match objects. + * operations like AbortController, loaderDeps, and full match objects. */ private matchRoutesLightweight( location: ParsedLocation, ): LightweightRouteMatchResult { - const lastStateMatchId = last(this.stores.matchesId.get()) + const lastRouteId = last(this.stores.ids.get()) + const lastStateMatch = lastRouteId + ? this.stores.byRoute.get(lastRouteId)!.get() + : undefined + const lastStateMatchId = lastStateMatch?.id const cached = this.lightweightCache.get(location) if (cached && cached[0] === lastStateMatchId) { return cached[1] @@ -1769,8 +1801,6 @@ export class RouterCore< } // Determine params: reuse from state if possible, otherwise parse - const lastStateMatch = - lastStateMatchId && this.stores.matchStores.get(lastStateMatchId)?.get() const canReuseParams = lastStateMatch && lastStateMatch.routeId === lastRoute.id && @@ -1805,37 +1835,6 @@ export class RouterCore< return result } - cancelMatch = (id: string) => { - const match = this.getMatch(id) - - if (!match) return - - match.abortController.abort() - clearTimeout(match._nonReactive.pendingTimeout) - match._nonReactive.pendingTimeout = undefined - } - - cancelMatches = () => { - this.stores.pendingIds.get().forEach((matchId) => { - this.cancelMatch(matchId) - }) - - this.stores.matchesId.get().forEach((matchId) => { - if (this.stores.pendingMatchStores.has(matchId)) { - return - } - - const match = this.stores.matchStores.get(matchId)?.get() - if (!match) { - return - } - - if (match.status === 'pending' || match.isFetching === 'loader') { - this.cancelMatch(matchId) - } - }) - } - /** * Build the next ParsedLocation from navigation options without committing. * Resolves `to`/`from`, params/search/hash/state, applies search validation @@ -1851,7 +1850,7 @@ export class RouterCore< ): ParsedLocation => { // We allow the caller to override the current location const currentLocation = - dest._fromLocation || this.pendingBuiltLocation || this.latestLocation + dest._fromLocation || this._pendingLocation || this.latestLocation // Use lightweight matching - only computes what buildLocation needs // (fullPath, search, params) without creating full match objects @@ -1906,15 +1905,7 @@ export class RouterCore< : sourcePath // Resolve the next params - const nextParams = - dest.params === false || dest.params === null - ? Object.create(null) - : (dest.params ?? true) === true - ? fromParams - : Object.assign( - fromParams, - functionalUpdate(dest.params as any, fromParams), - ) + const nextParams = resolveNextParams(dest.params, fromParams) const destRoute = this.routesByPath[ trimPathRight(nextTo) as keyof typeof this.routesByPath @@ -2012,12 +2003,12 @@ export class RouterCore< nextSearch = validatedSearch } - nextSearch = applySearchMiddleware({ - search: nextSearch, + nextSearch = applySearchMiddleware( + nextSearch, dest, destRoutes, - _includeValidateSearch: opts._includeValidateSearch, - }) + opts._includeValidateSearch, + ) // Replace the equal deep nextSearch = nullReplaceEqualDeep(fromSearch, nextSearch) @@ -2117,12 +2108,7 @@ export class RouterCore< // If mask has a params function, call it with the matched params as context // Otherwise, use the matched params or the provided params value - const nextParams = - maskParams === false || maskParams === null - ? Object.create(null) - : (maskParams ?? true) === true - ? params - : Object.assign(params, functionalUpdate(maskParams, params)) + const nextParams = resolveNextParams(maskParams, params) maskedDest = { from: opts.from, @@ -2151,7 +2137,7 @@ export class RouterCore< return buildWithMatches(opts) } - commitLocationPromise: undefined | ControlledPromise + _commitPromise: (Promise & { resolve: () => void }) | undefined /** * Commit a previously built location to history (push/replace), optionally @@ -2163,38 +2149,27 @@ export class RouterCore< ...next }) => { let historyAction: HistoryAction | undefined - const isSameState = () => { - // the following props are ignored but may still be provided when navigating, - // temporarily add the previous values to the next state so they don't affect - // the comparison - const ignoredProps = [ - 'key', // TODO: Remove in v2 - use __TSR_key instead - '__TSR_key', - '__TSR_index', - '__hashScrollIntoViewOptions', - ] as const - ignoredProps.forEach((prop) => { - ;(next.state as any)[prop] = this.latestLocation.state[prop] - }) - const isEqual = deepEqual(next.state, this.latestLocation.state) - ignoredProps.forEach((prop) => { - delete next.state[prop] - }) - return isEqual - } - - const isSameUrl = - trimPathRight(this.latestLocation.href) === trimPathRight(next.href) + const isSameLocation = + trimPathRight(this.latestLocation.href) === trimPathRight(next.href) && + deepEqual( + _getUserHistoryState(next.state), + _getUserHistoryState(this.latestLocation.state), + ) - let previousCommitPromise = this.commitLocationPromise - this.commitLocationPromise = createControlledPromise(() => { + const previousCommitPromise = this._commitPromise + let resolve!: () => void + const commitPromise = new Promise((done) => { + resolve = done + }) as Promise & { resolve: () => void } + commitPromise.resolve = () => { + resolve() previousCommitPromise?.resolve() - previousCommitPromise = undefined - }) + } + this._commitPromise = commitPromise // Don't commit to history if nothing changed - if (isSameUrl && isSameState()) { - this.load() + if (isSameLocation) { + this.load({ _dedupe: true }) } else { let { // eslint-disable-next-line prefer-const @@ -2245,21 +2220,17 @@ export class RouterCore< nextHistory.state, { ignoreBlocker }, ) + + // Without a subscribed adapter the history commit cannot trigger the + // load itself. The same-location branch already loads directly. + if (!this.history.subscribers.size) { + this.load({ action: { type: historyAction } }) + } } this._scroll.next = next.resetScroll ?? true - if (!this.history.subscribers.size) { - this.load( - historyAction - ? { - action: { type: historyAction }, - } - : undefined, - ) - } - - return this.commitLocationPromise + return this._commitPromise } /** Convenience helper: build a location from options, then commit it. */ @@ -2269,9 +2240,11 @@ export class RouterCore< hashScrollIntoView, viewTransition, ignoreBlocker, + _redirects, href, ...rest - }: BuildNextOptions & CommitLocationOptions = {}) => { + }: BuildNextOptions & + CommitLocationOptions & { _redirects?: number } = {}) => { if (href) { const currentIndex = this.history.location.state.__TSR_index @@ -2295,8 +2268,12 @@ export class RouterCore< ...(rest as any), _includeValidateSearch: true, }) + if (_redirects) { + ;(location as typeof location & { _redirects?: number })._redirects = + _redirects + } - this.pendingBuiltLocation = location as ParsedLocation< + this._pendingLocation = location as ParsedLocation< FullSearchSchema > @@ -2312,8 +2289,8 @@ export class RouterCore< // Clear pending location after commit starts // We do this on next microtask to allow synchronous navigate calls to chain queueMicrotask(() => { - if (this.pendingBuiltLocation === location) { - this.pendingBuiltLocation = undefined + if (this._pendingLocation === location) { + this._pendingLocation = undefined } }) @@ -2409,253 +2386,17 @@ export class RouterCore< }) } - latestLoadPromise: undefined | Promise - - beforeLoad = () => { - // Cancel any pending matches - this.cancelMatches() - this.updateLatestLocation() - - if (isServer ?? this.isServer) { - // for SPAs on the initial load, this is handled by the Transitioner - const nextLocation = this.buildLocation({ - to: this.latestLocation.pathname, - search: true, - params: true, - hash: true, - state: true, - _includeValidateSearch: true, - }) - - // Check if location changed - origin check is unnecessary since buildLocation - // always uses this.origin when constructing URLs - if (this.latestLocation.publicHref !== nextLocation.publicHref) { - const href = this.getParsedLocationHref(nextLocation) - if (nextLocation.external) { - throw redirect({ href }) - } else { - throw redirect({ href, _builtLocation: nextLocation }) - } - } - } - - // Match the routes - const pendingMatches = this.matchRoutes(this.latestLocation) - - const nextCachedMatches = this.stores.cachedMatches - .get() - .filter((d) => !pendingMatches.some((e) => e.id === d.id)) - - // Ingest the new matches - this.batch(() => { - this.stores.status.set('pending') - this.stores.statusCode.set(200) - this.stores.isLoading.set(true) - this.stores.location.set(this.latestLocation) - this.stores.setPending(pendingMatches) - // If a cached match moved to pending matches, remove it from cached matches - this.stores.setCached(nextCachedMatches) - }) - } - load: LoadFn = async (opts): Promise => { - const historyAction = opts?.action?.type - let redirect: AnyRedirect | undefined - let notFound: NotFoundError | undefined - let loadPromise: Promise - const previousLocation = - this.stores.resolvedLocation.get() ?? this.stores.location.get() - - // eslint-disable-next-line prefer-const - loadPromise = new Promise((resolve) => { - this.startTransition(async () => { - try { - this.beforeLoad() - if (historyAction) { - this._scroll.hash = - historyAction === 'PUSH' || historyAction === 'REPLACE' - } - const next = this.latestLocation - const prevLocation = this.stores.resolvedLocation.get() - const locationChangeInfo = getLocationChangeInfo(next, prevLocation) - - if (!this.stores.redirect.get()) { - this.emit({ - type: 'onBeforeNavigate', - ...locationChangeInfo, - }) - } - - this.emit({ - type: 'onBeforeLoad', - ...locationChangeInfo, - }) - - await loadMatches({ - router: this, - sync: opts?.sync, - forceStaleReload: previousLocation.href === next.href, - matches: this.stores.pendingMatches.get(), - location: next, - updateMatch: this.updateMatch, - // eslint-disable-next-line @typescript-eslint/require-await - onReady: async () => { - // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) - this.startTransition(() => { - this.startViewTransition(async () => { - // this.viewTransitionPromise = createControlledPromise() - - // Commit the pending matches. If a previous match was - // removed, place it in the cachedMatches - // - // exitingMatches uses match.id (routeId + params + loaderDeps) so - // navigating /foo?page=1 → /foo?page=2 correctly caches the page=1 entry. - let exitingMatches: Array | null = null - - // Lifecycle-hook identity uses routeId only so that navigating between - // different params/deps of the same route fires onStay (not onLeave+onEnter). - let hookExitingMatches: Array | null = null - let hookEnteringMatches: Array | null = null - let hookStayingMatches: Array | null = null - - this.batch(() => { - const pendingMatches = this.stores.pendingMatches.get() - const mountPending = pendingMatches.length - const currentMatches = this.stores.matches.get() - - exitingMatches = mountPending - ? currentMatches.filter( - (match) => - !this.stores.pendingMatchStores.has(match.id), - ) - : null - - // Lifecycle-hook identity: routeId only (route presence in tree) - // Build routeId sets from pools to avoid derived stores. - const pendingRouteIds = new Set() - for (const s of this.stores.pendingMatchStores.values()) { - if (s.routeId) pendingRouteIds.add(s.routeId) - } - const activeRouteIds = new Set() - for (const s of this.stores.matchStores.values()) { - if (s.routeId) activeRouteIds.add(s.routeId) - } - - hookExitingMatches = mountPending - ? currentMatches.filter( - (match) => !pendingRouteIds.has(match.routeId), - ) - : null - hookEnteringMatches = mountPending - ? pendingMatches.filter( - (match) => !activeRouteIds.has(match.routeId), - ) - : null - hookStayingMatches = mountPending - ? pendingMatches.filter((match) => - activeRouteIds.has(match.routeId), - ) - : currentMatches - - this.stores.isLoading.set(false) - this.stores.loadedAt.set(Date.now()) - /** - * When committing new matches, cache any exiting matches that are still usable. - * Routes that resolved with `status: 'error'` or `status: 'notFound'` are - * deliberately excluded from `cachedMatches` so that subsequent invalidations - * or reloads re-run their loaders instead of reusing the failed/not-found data. - */ - if (mountPending) { - this.stores.setMatches(pendingMatches) - this.stores.setPending([]) - this.stores.setCached([ - ...this.stores.cachedMatches.get(), - ...exitingMatches!.filter( - (d) => - d.status !== 'error' && - d.status !== 'notFound' && - d.status !== 'redirected', - ), - ]) - this.clearExpiredCache() - } - }) - - // - for (const [matches, hook] of [ - [hookExitingMatches, 'onLeave'], - [hookEnteringMatches, 'onEnter'], - [hookStayingMatches, 'onStay'], - ] as const) { - if (!matches) continue - for (const match of matches as Array) { - this.looseRoutesById[match.routeId]!.options[hook]?.( - match, - ) - } - } - }) - }) - }, - }) - } catch (err) { - if (isRedirect(err)) { - redirect = err - if (!(isServer ?? this.isServer)) { - this.navigate({ - ...redirect.options, - replace: true, - ignoreBlocker: true, - }) - } - } else if (isNotFound(err)) { - notFound = err - } - - const nextStatusCode = redirect - ? redirect.status - : notFound - ? 404 - : this.stores.matches.get().some((d) => d.status === 'error') - ? 500 - : 200 - - this.batch(() => { - this.stores.statusCode.set(nextStatusCode) - this.stores.redirect.set(redirect) - }) - } - - if (this.latestLoadPromise === loadPromise) { - this.commitLocationPromise?.resolve() - this.latestLoadPromise = undefined - this.commitLocationPromise = undefined - } - - resolve() - }) - }) - - this.latestLoadPromise = loadPromise - - await loadPromise - - while ( - (this.latestLoadPromise as any) && - loadPromise !== this.latestLoadPromise - ) { - await this.latestLoadPromise + if (isServer ?? this.isServer) { + return loadServerRoute(this, opts) } - let newStatusCode: number | undefined = undefined - if (this.hasNotFoundMatch()) { - newStatusCode = 404 - } else if (this.stores.matches.get().some((d) => d.status === 'error')) { - newStatusCode = 500 - } - if (newStatusCode !== undefined) { - this.stores.statusCode.set(newStatusCode) + this.updateLatestLocation() + if (opts?.action) { + this._scroll.hash = + opts.action.type === 'PUSH' || opts.action.type === 'REPLACE' } + await loadClientRoute(this, opts) } startViewTransition = (fn: () => Promise) => { @@ -2670,9 +2411,8 @@ export class RouterCore< // Attempt to start a view transition (or just apply the changes if we can't) if ( shouldViewTransition && - typeof document !== 'undefined' && - 'startViewTransition' in document && - typeof document.startViewTransition === 'function' + !(isServer ?? typeof document === 'undefined') && + typeof (document as any).startViewTransition === 'function' ) { // lib.dom.ts doesn't support viewTransition types variant yet. // TODO: Fix this when dom types are updated @@ -2680,7 +2420,7 @@ export class RouterCore< if ( typeof shouldViewTransition === 'object' && - this.isViewTransitionTypesSupported + window.CSS?.supports?.('selector(:active-view-transition-type(a))') ) { const next = this.latestLocation const prevLocation = this.stores.resolvedLocation.get() @@ -2693,8 +2433,7 @@ export class RouterCore< : shouldViewTransition.types if (resolvedViewTransitionTypes === false) { - fn() - return + return fn() } startViewTransitionParams = { @@ -2705,58 +2444,19 @@ export class RouterCore< startViewTransitionParams = fn } - document.startViewTransition(startViewTransitionParams) - } else { - fn() + return (document as any).startViewTransition(startViewTransitionParams) + .updateCallbackDone } - } - - updateMatch: UpdateMatchFn = (id, updater) => { - this.startTransition(() => { - const pendingMatch = this.stores.pendingMatchStores.get(id) - if (pendingMatch) { - pendingMatch.set(updater) - return - } - - const activeMatch = this.stores.matchStores.get(id) - if (activeMatch) { - activeMatch.set(updater) - return - } - - const cachedMatch = this.stores.cachedMatchStores.get(id) - if (cachedMatch) { - const next = updater(cachedMatch.get()) - if (next.status === 'redirected') { - const deleted = this.stores.cachedMatchStores.delete(id) - if (deleted) { - this.stores.cachedIds.set((prev) => - prev.filter((matchId) => matchId !== id), - ) - } - } else { - cachedMatch.set(next) - } - } - }) - } - - getMatch: GetMatchFn = (matchId: string): AnyRouteMatch | undefined => { - return ( - this.stores.cachedMatchStores.get(matchId)?.get() ?? - this.stores.pendingMatchStores.get(matchId)?.get() ?? - this.stores.matchStores.get(matchId)?.get() - ) + return fn() } /** * Invalidate the current matches and optionally force them back into a pending state. * * - Marks all matches that pass the optional `filter` as `invalid: true`. - * - If `forcePending` is true, or a match is currently in `'error'` or `'notFound'` status, - * its status is reset to `'pending'` and its `error` cleared so that the loader is re-run - * on the next `load()` call (eg. after HMR or a manual invalidation). + * + * The next load decides when to publish pending UI, so invalidation does not + * mutate the currently rendered status. */ invalidate: InvalidateFn< RouterCore< @@ -2767,44 +2467,54 @@ export class RouterCore< TDehydrated > > = (opts) => { + const committedMatches = this._committed + const filter = opts?.filter + const invalidIds = filter + ? new Set( + [...committedMatches, ...this._cache.values()] + .filter((match) => filter(match as MakeRouteMatchUnion)) + .map((match) => match.id), + ) + : undefined const invalidate = (d: MakeRouteMatch) => { - if (opts?.filter?.(d as MakeRouteMatchUnion) ?? true) { - return { + if (!invalidIds || invalidIds.has(d.id)) { + const route = this.routesById[d.routeId] as AnyRoute + const next = { ...d, invalid: true, - ...(opts?.forcePending || - d.status === 'error' || - d.status === 'notFound' + ...((opts?.forcePending || + d.status === 'error' || + d.status === 'notFound') && + routeNeedsLoad(route) ? ({ status: 'pending', error: undefined } as const) : undefined), } + // Invalidation replaces this owner; it does not create another lease. + ;(d as AnyRouteMatch & { _flight?: LoaderFlight })._flight = undefined + return next } return d } - this.batch(() => { - this.stores.setMatches(this.stores.matches.get().map(invalidate)) - this.stores.setCached(this.stores.cachedMatches.get().map(invalidate)) - this.stores.setPending(this.stores.pendingMatches.get().map(invalidate)) - }) + const committed = committedMatches.map(invalidate) + this._committed = committed + const cache = new Map() + for (const [id, match] of this._cache) { + cache.set(id, invalidate(match)) + } + this._cache = cache this.shouldViewTransition = false return this.load({ sync: opts?.sync }) } - getParsedLocationHref = (location: ParsedLocation) => { - // For redirects and external use, we need publicHref (with rewrite output applied) - // href is the internal path after rewrite input, publicHref is user-facing - return location.publicHref || '/' - } - resolveRedirect = (redirect: AnyRedirect): AnyRedirect => { const locationHeader = redirect.headers.get('Location') if (!redirect.options.href || redirect.options._builtLocation) { const location = redirect.options._builtLocation ?? this.buildLocation(redirect.options) - const href = this.getParsedLocationHref(location) + const href = location.publicHref || '/' redirect.options.href = href redirect.headers.set('Location', href) } else if (locationHeader) { @@ -2841,43 +2551,37 @@ export class RouterCore< } clearCache: ClearCacheFn = (opts) => { + const cached = this._cache + const preloads = this._preloads const filter = opts?.filter - if (filter !== undefined) { - this.stores.setCached( - this.stores.cachedMatches - .get() - .filter((m) => !filter(m as MakeRouteMatchUnion)), - ) - } else { - this.stores.setCached([]) + const retained = new Map() + const discarded: Array = [] + for (const [id, match] of cached) { + if (filter && !filter(match as MakeRouteMatchUnion)) { + retained.set(id, match) + } else { + discarded.push(match) + } } - } - - clearExpiredCache = () => { - const now = Date.now() - // This is where all of the garbage collection magic happens - const filter = (d: MakeRouteMatch) => { - const route = this.looseRoutesById[d.routeId]! - - if (!route.options.loader) { - return true + const retainedPreloads = new Map() + const discardedPreloads: Array = [] + for (const [href, preload] of preloads ?? []) { + if (!filter || preload[0].some(filter as any)) { + discardedPreloads.push(preload) + discarded.push(...preload[0]) + } else { + retainedPreloads.set(href, preload) } + } - // If the route was preloaded, use the preloadGcTime - // otherwise, use the gcTime - const gcTime = - (d.preload - ? (route.options.preloadGcTime ?? this.options.defaultPreloadGcTime) - : (route.options.gcTime ?? this.options.defaultGcTime)) ?? - 5 * 60 * 1000 - - const isError = d.status === 'error' - if (isError) return true - - const gcEligible = now - d.updatedAt >= gcTime - return gcEligible + // Install both replacement authorities before releasing a public loader + // signal, whose abort listeners can synchronously reenter the router. + this._cache = retained + this._preloads = retainedPreloads + transferMatchResources(this, discarded) + for (const preload of discardedPreloads) { + preload[1].abort() } - this.clearCache({ filter }) } loadRouteChunk = loadRouteChunk @@ -2887,69 +2591,7 @@ export class RouterCore< TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory - > = async (opts) => { - const next = opts._builtLocation ?? this.buildLocation(opts as any) - - let matches = this.matchRoutes(next, { - throwOnError: true, - preload: true, - dest: opts, - }) - - const activeMatchIds = new Set([ - ...this.stores.matchesId.get(), - ...this.stores.pendingIds.get(), - ]) - - const loadedMatchIds = new Set([ - ...activeMatchIds, - ...this.stores.cachedIds.get(), - ]) - - // If the matches are already loaded, we need to add them to the cached matches. - const matchesToCache = matches.filter( - (match) => !loadedMatchIds.has(match.id), - ) - if (matchesToCache.length) { - const cachedMatches = this.stores.cachedMatches.get() - this.stores.setCached([...cachedMatches, ...matchesToCache]) - } - - try { - matches = await loadMatches({ - router: this, - matches, - location: next, - preload: true, - updateMatch: (id, updater) => { - // Don't update the match if it's currently loaded - if (activeMatchIds.has(id)) { - matches = matches.map((d) => (d.id === id ? updater(d) : d)) - } else { - this.updateMatch(id, updater) - } - }, - }) - - return matches - } catch (err) { - if (isRedirect(err)) { - if (err.options.reloadDocument) { - return undefined - } - - return await this.preloadRoute({ - ...err.options, - _fromLocation: next, - }) - } - if (!isNotFound(err)) { - // Preload errors are not fatal, but we should still log them - console.error(err) - } - return undefined - } - } + > = (opts) => preloadClientRoute(this, opts) matchRoute: MatchRouteFn< TRouteTree, @@ -2967,12 +2609,12 @@ export class RouterCore< } const next = this.buildLocation(matchLocation as any) - if (opts?.pending && this.stores.status.get() !== 'pending') { + const isPending = this.stores.status.get() === 'pending' + if (opts?.pending && !isPending) { return false } - const pending = - opts?.pending === undefined ? !this.stores.isLoading.get() : opts.pending + const pending = opts?.pending ?? !isPending const baseLocation = pending ? this.latestLocation @@ -3012,11 +2654,19 @@ export class RouterCore< serverSsr?: ServerSsr serverSsrLifecycle?: RouterSsrLifecycle +} - hasNotFoundMatch = () => { - return this.stores.matches - .get() - .some((d) => d.status === 'notFound' || d.globalNotFound) +/** + * In non-production environments, + * augment the RouterCore class with a `_refreshRoute` method + * dedicated to HMR. + */ +if (process.env.NODE_ENV !== 'production') { + RouterCore.prototype._replaceRouteChunk = replaceRouteChunk + RouterCore.prototype._refreshRoute = async function () { + this._serverResult = undefined + this.updateLatestLocation() + await refreshClientRoute(this) } } @@ -3053,14 +2703,11 @@ export function getInitialRouterState( location: ParsedLocation, ): RouterState { return { - loadedAt: 0, isLoading: false, - isTransitioning: false, status: 'idle', resolvedLocation: undefined, location, matches: [], - statusCode: 200, } } @@ -3092,56 +2739,12 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { return {} } -/** - * Build the matched route chain and extract params for a pathname. - * Falls back to the root route if no specific route is found. - */ -export function getMatchedRoutes({ - pathname, - routesById, - processedTree, -}: { - pathname: string - routesById: Record - processedTree: ProcessedTree -}) { - const routeParams: Record = Object.create(null) - const trimmedPath = trimPathRight(pathname) - - let foundRoute: TRouteLike | undefined = undefined - const match = findRouteMatch(trimmedPath, processedTree, true) - if (match) { - foundRoute = match.route - Object.assign(routeParams, match.rawParams) // Copy params, because they're cached - } - - const matchedRoutes = match?.branch || [routesById[rootRouteId]!] - - return { matchedRoutes, routeParams, foundRoute } -} - -/** - * TODO: once caches are persisted across requests on the server, - * we can cache the built middleware chain using `last(destRoutes)` as the key - */ -function applySearchMiddleware({ - search, - dest, - destRoutes, - _includeValidateSearch, -}: { - search: any - dest: { search?: unknown } - destRoutes: ReadonlyArray - _includeValidateSearch: boolean | undefined -}) { - const middleware = buildMiddlewareChain(destRoutes) - return middleware(search, dest, _includeValidateSearch ?? false) -} - -function buildMiddlewareChain(destRoutes: ReadonlyArray) { - let dest: BuildNextOptions - let includeValidateSearch: boolean | undefined +function applySearchMiddleware( + search: any, + dest: BuildNextOptions, + destRoutes: ReadonlyArray, + includeValidateSearch: boolean | undefined, +) { const middlewares = [] as Array> for (const route of destRoutes) { @@ -3234,15 +2837,7 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { return (middlewares[index]! as any)({ search: currentSearch, next, meta }) } - return function middleware( - search: any, - nextDest: BuildNextOptions, - _includeValidateSearch: boolean, - ) { - dest = nextDest - includeValidateSearch = _includeValidateSearch - return applyNext(0, search) - } + return applyNext(0, search) } function findGlobalNotFoundRouteId( @@ -3250,26 +2845,41 @@ function findGlobalNotFoundRouteId( routes: ReadonlyArray, ) { if (notFoundMode !== 'root') { + let fallback for (let i = routes.length - 1; i >= 0; i--) { const route = routes[i]! - if (route.children) { + if (route.options.notFoundComponent) { return route.id } + fallback ||= route.children && route.id + } + if (fallback) { + return fallback } } return rootRouteId } +function resolveNextParams( + spec: unknown, + base: Record, +): Record { + return spec === false || spec === null + ? Object.create(null) + : (spec ?? true) === true + ? base + : Object.assign(base, functionalUpdate(spec as any, base)) +} + function extractStrictParams( route: AnyRoute, accumulatedParams: Record, ) { const parseParams = route.options.params?.parse ?? route.options.parseParams if (parseParams) { - const result = parseParams(accumulatedParams as Record) - if (result === false) { - throw new Error('Route params.parse returned false for a matched route') - } - Object.assign(accumulatedParams, result) + Object.assign( + accumulatedParams, + parseParams(accumulatedParams as Record), + ) } } diff --git a/packages/router-core/src/ssr/createRequestHandler.ts b/packages/router-core/src/ssr/createRequestHandler.ts index 32377b2bea..a945effd01 100644 --- a/packages/router-core/src/ssr/createRequestHandler.ts +++ b/packages/router-core/src/ssr/createRequestHandler.ts @@ -1,11 +1,15 @@ import { createMemoryHistory } from '@tanstack/history' +import { _getRenderedMatches } from '../load-client' import { mergeHeaders } from './headers' import { attachRouterServerSsrUtils, getNormalizedURL, getOrigin, } from './ssr-server' -import { normalizeSsrResponse } from './handlerCallback' +import { + bindSsrResponseToRequest, + disposeSsrResponseDetached, +} from './handlerCallback' import type { HandlerCallback } from './handlerCallback' import type { AnyHeaders } from './headers' import type { AnyRouter } from '../router' @@ -15,6 +19,79 @@ export type RequestHandler = ( cb: HandlerCallback, ) => Promise +type RequestWaiter = ((reason: unknown) => void) | undefined + +const requestWaiters = new WeakMap>() + +function removeRequestWaiter( + waiters: Array, + index: number, + reject: (reason: unknown) => void, +) { + if (waiters[index] !== reject) { + return + } + if (index !== waiters.length - 1) { + waiters[index] = undefined + return + } + + waiters.pop() + while (waiters.length && waiters[waiters.length - 1] === undefined) { + waiters.pop() + } +} + +export function waitForRequest( + value: T | PromiseLike, + signal: AbortSignal, + onLate?: (value: T) => void, +): Promise { + const promise = Promise.resolve(value) + if (signal.aborted) { + void promise.then(onLate, () => {}) + return Promise.reject(signal.reason) + } + + return new Promise((resolve, reject) => { + let waiters = requestWaiters.get(signal) + let index: number + if (waiters) { + index = waiters.push(reject) - 1 + } else { + const newWaiters: Array = [reject] + waiters = newWaiters + index = 0 + requestWaiters.set(signal, newWaiters) + signal.addEventListener( + 'abort', + () => { + requestWaiters.delete(signal) + for (const rejectWaiter of newWaiters) { + rejectWaiter?.(signal.reason) + } + newWaiters.length = 0 + }, + { once: true }, + ) + } + void promise.then( + (result) => { + removeRequestWaiter(waiters, index, reject) + if (signal.aborted) { + onLate?.(result) + } else { + resolve(result) + } + }, + (error) => { + removeRequestWaiter(waiters, index, reject) + reject(error) + }, + ) + }) +} + export function createRequestHandler({ createRouter, request, @@ -25,13 +102,14 @@ export function createRequestHandler({ getRouterManifest?: () => ServerManifest | Promise }): RequestHandler { return async (cb) => { + request.signal.throwIfAborted() const router = createRouter() let responseOwnsCleanup = false try { attachRouterServerSsrUtils({ router, - manifest: await getRouterManifest?.(), + manifest: await waitForRequest(getRouterManifest?.(), request.signal), }) // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. @@ -50,20 +128,41 @@ export function createRequestHandler({ origin: router.options.origin ?? origin, }) - await router.load() + await router.load({ + _signal: request.signal, + }) + request.signal.throwIfAborted() + + const result = router._serverResult + if (result?.type === 'redirect') { + return result.redirect + } - await router.serverSsr?.dehydrate() + await waitForRequest(router.serverSsr?.dehydrate(), request.signal) + request.signal.throwIfAborted() const responseHeaders = getRequestHeaders({ router, }) - const response = await cb({ - request, + request.signal.throwIfAborted() + const response = await waitForRequest( + cb({ + request, + router, + responseHeaders, + }), + request.signal, + (late) => { + disposeSsrResponseDetached(late, request.signal.reason) + }, + ) + const ssrResponse = bindSsrResponseToRequest( router, - responseHeaders, - }) - const ssrResponse = normalizeSsrResponse(response) + response, + request.signal, + ) + request.signal.throwIfAborted() responseOwnsCleanup = ssrResponse.serverSsrCleanup === 'stream' return ssrResponse.response } finally { @@ -79,16 +178,10 @@ export function createRequestHandler({ function getRequestHeaders(opts: { router: AnyRouter }): Headers { const matchHeaders: Array = [] - for (const match of opts.router.stores.matches.get()) { + for (const match of _getRenderedMatches(opts.router.stores.matches.get())) { matchHeaders.push(match.headers) } - // Handle Redirects - const redirect = opts.router.stores.redirect.get() - if (redirect) { - matchHeaders.push(redirect.headers) - } - return mergeHeaders( { 'Content-Type': 'text/html; charset=UTF-8', diff --git a/packages/router-core/src/ssr/handlerCallback.ts b/packages/router-core/src/ssr/handlerCallback.ts index 22aa843063..368c8a2a61 100644 --- a/packages/router-core/src/ssr/handlerCallback.ts +++ b/packages/router-core/src/ssr/handlerCallback.ts @@ -30,6 +30,40 @@ export function normalizeSsrResponse( : { response: result, serverSsrCleanup: 'none' } } +export function disposeSsrResponse( + response: SsrResponse, + reason?: unknown, +): Promise { + if (response.serverSsrCleanup !== 'stream') { + return Promise.resolve() + } + try { + return Promise.resolve(response.dispose(reason)) + } catch (error) { + return Promise.reject(error) + } +} + +export function disposeSsrResponseDetached( + result: HandlerCallbackResult, + reason?: unknown, + onError: (error: unknown) => void = console.error, +): void { + const ssrResponse = normalizeSsrResponse(result) + if (ssrResponse.serverSsrCleanup === 'stream') { + void disposeSsrResponse(ssrResponse, reason).catch(onError) + return + } + + if (ssrResponse.response.body) { + try { + void ssrResponse.response.body.cancel(reason).catch(onError) + } catch (error) { + onError(error) + } + } +} + export function createSsrStreamResponse( router: TRouter, response: Response, @@ -43,29 +77,63 @@ export function createSsrStreamResponse( response, serverSsrCleanup: 'stream', async dispose(reason?: unknown) { - if (disposed) return + if (disposed) { + return + } disposed = true + // Sever router ownership before asking user/renderer stream machinery to + // cancel. A custom stream is allowed to ignore cancellation forever. + router.serverSsr?.cleanup() + try { await response.body!.cancel(reason) } catch { - // ignore; fallback cleanup below still releases router SSR state + // Cleanup above already released router SSR state. } - - router.serverSsr?.cleanup() }, } } +export function bindSsrResponseToRequest( + router: AnyRouter | undefined, + result: HandlerCallbackResult, + signal: AbortSignal, +): SsrResponse { + const ssrResponse = normalizeSsrResponse(result) + if (ssrResponse.serverSsrCleanup !== 'stream') { + if (signal.aborted) { + disposeSsrResponseDetached(result, signal.reason) + } + return ssrResponse + } + + const failed = (error: unknown) => { + router?.serverSsr?.cleanup() + console.error(error) + } + const abort = () => { + disposeSsrResponseDetached(ssrResponse, signal.reason, failed) + } + if (signal.aborted) { + abort() + return ssrResponse + } + + signal.addEventListener('abort', abort, { once: true }) + router?.serverSsr?.onCleanup(() => { + signal.removeEventListener('abort', abort) + }) + return ssrResponse +} + export async function replaceSsrResponse( result: HandlerCallbackResult, response: Response, reason?: unknown, ): Promise { const ssrResponse = normalizeSsrResponse(result) - if (ssrResponse.serverSsrCleanup === 'stream') { - await ssrResponse.dispose(reason) - } + await disposeSsrResponse(ssrResponse, reason) return { response, serverSsrCleanup: 'none' } } @@ -74,9 +142,7 @@ export async function stripSsrResponseBody( reason?: unknown, ): Promise { const ssrResponse = normalizeSsrResponse(result) - if (ssrResponse.serverSsrCleanup === 'stream') { - await ssrResponse.dispose(reason) - } + await disposeSsrResponse(ssrResponse, reason) return { response: new Response(null, ssrResponse.response), serverSsrCleanup: 'none', diff --git a/packages/router-core/src/ssr/server.ts b/packages/router-core/src/ssr/server.ts index df5b838676..93973999cb 100644 --- a/packages/router-core/src/ssr/server.ts +++ b/packages/router-core/src/ssr/server.ts @@ -1,8 +1,11 @@ -export { createRequestHandler } from './createRequestHandler' +export { createRequestHandler, waitForRequest } from './createRequestHandler' export type { RequestHandler } from './createRequestHandler' export { + bindSsrResponseToRequest, createSsrStreamResponse, defineHandlerCallback, + disposeSsrResponse, + disposeSsrResponseDetached, isSsrResponse, normalizeSsrResponse, replaceSsrResponse, diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 2aa5358ac0..6b0921c8db 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -1,310 +1 @@ -import { invariant } from '../invariant' -import { isNotFound } from '../not-found' -import { createControlledPromise } from '../utils' -import { hydrateSsrMatchId } from './ssr-match-id' -import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './constants' -import type { DehydratedMatch, TsrSsrGlobal } from './types' -import type { AnyRouteMatch } from '../Matches' -import type { AnyRouter } from '../router' -import type { RouteContextOptions } from '../route' -import type { AnySerializationAdapter } from './serializer/transformer' - -declare global { - interface Window { - [GLOBAL_TSR]?: TsrSsrGlobal - [GLOBAL_SEROVAL]?: any - } -} - -function hydrateMatch( - match: AnyRouteMatch, - deyhydratedMatch: DehydratedMatch, -): void { - match.id = deyhydratedMatch.i - match.__beforeLoadContext = deyhydratedMatch.b - match.loaderData = deyhydratedMatch.l - match.status = deyhydratedMatch.s - match.ssr = deyhydratedMatch.ssr - match.updatedAt = deyhydratedMatch.u - match.error = deyhydratedMatch.e - // Only hydrate global-not-found when a defined value is present in the - // dehydrated payload. If omitted, preserve the value computed from the - // current client location (important for SPA fallback HTML served at unknown - // URLs, where dehydrated matches may come from `/` but client matching marks - // root as globalNotFound). - if (deyhydratedMatch.g !== undefined) { - match.globalNotFound = deyhydratedMatch.g - } -} - -export async function hydrate(router: AnyRouter): Promise { - if (!window.$_TSR) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: Expected to find bootstrap data on window.$_TSR, but we did not. Please file an issue!', - ) - } - - invariant() - } - - const serializationAdapters = router.options.serializationAdapters as - | Array - | undefined - - if (serializationAdapters?.length) { - const fromSerializableMap = new Map() - serializationAdapters.forEach((adapter) => { - fromSerializableMap.set(adapter.key, adapter.fromSerializable) - }) - window.$_TSR.t = fromSerializableMap - window.$_TSR.buffer.forEach((script) => script()) - } - window.$_TSR.initialized = true - - if (!window.$_TSR.router) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: Expected to find a dehydrated data on window.$_TSR.router, but we did not. Please file an issue!', - ) - } - - invariant() - } - - const dehydratedRouter = window.$_TSR.router - dehydratedRouter.matches.forEach((dehydratedMatch) => { - dehydratedMatch.i = hydrateSsrMatchId(dehydratedMatch.i) - }) - if (dehydratedRouter.lastMatchId) { - dehydratedRouter.lastMatchId = hydrateSsrMatchId( - dehydratedRouter.lastMatchId, - ) - } - const { manifest, dehydratedData, lastMatchId } = dehydratedRouter - - router.ssr = { - manifest, - } - const meta = document.querySelector('meta[property="csp-nonce"]') as - | HTMLMetaElement - | undefined - const nonce = meta?.content - router.options.ssr = { - nonce, - } - - // Allow the user to handle custom hydration data before matching routes. - // This lets hydration install router config that affects matching, e.g. rewrites. - await router.options.hydrate?.(dehydratedData) - - // Hydrate the router state - const matches = router.matchRoutes(router.stores.location.get()) - - // kick off loading the route chunks - const routeChunkPromise = Promise.all( - matches.map((match) => - router.loadRouteChunk(router.looseRoutesById[match.routeId]!), - ), - ) - - function setMatchForcePending(match: AnyRouteMatch) { - // usually the minPendingPromise is created in the Match component if a pending match is rendered - // however, this might be too late if the match synchronously resolves - const route = router.looseRoutesById[match.routeId]! - const pendingMinMs = - route.options.pendingMinMs ?? router.options.defaultPendingMinMs - if (pendingMinMs) { - const minPendingPromise = createControlledPromise() - match._nonReactive.minPendingPromise = minPendingPromise - match._forcePending = true - - setTimeout(() => { - minPendingPromise.resolve() - // We've handled the minPendingPromise, so we can delete it - router.updateMatch(match.id, (prev) => { - prev._nonReactive.minPendingPromise = undefined - return { - ...prev, - _forcePending: undefined, - } - }) - }, pendingMinMs) - } - } - - function setRouteSsr(match: AnyRouteMatch) { - const route = router.looseRoutesById[match.routeId] - if (route) { - route.options.ssr = match.ssr - } - } - // Right after hydration and before the first render, we need to rehydrate each match - // First step is to reyhdrate loaderData and __beforeLoadContext - let firstNonSsrMatchIndex: number | undefined = undefined - matches.forEach((match) => { - const dehydratedMatch = dehydratedRouter.matches.find( - (d) => d.i === match.id, - ) - if (!dehydratedMatch) { - match._nonReactive.dehydrated = false - match.ssr = false - setRouteSsr(match) - return - } - - hydrateMatch(match, dehydratedMatch) - setRouteSsr(match) - - match._nonReactive.dehydrated = match.ssr !== false - - if (match.ssr === 'data-only' || match.ssr === false) { - if (firstNonSsrMatchIndex === undefined) { - firstNonSsrMatchIndex = match.index - setMatchForcePending(match) - } - } - }) - - router.stores.setMatches(matches) - - // now that all necessary data is hydrated: - // 1) fully reconstruct the route context - // 2) execute `head()` and `scripts()` for each match - const activeMatches = router.stores.matches.get() - const location = router.stores.location.get() - await Promise.all( - activeMatches.map(async (match) => { - try { - const route = router.looseRoutesById[match.routeId]! - - const parentMatch = activeMatches[match.index - 1] - const parentContext = parentMatch?.context ?? router.options.context - - // `context()` was already executed by `matchRoutes`, however route context was not yet fully reconstructed - // so run it again and merge route context - if (route.options.context) { - const contextFnContext: RouteContextOptions = - { - deps: match.loaderDeps, - params: match.params, - context: parentContext ?? {}, - location, - navigate: (opts: any) => - router.navigate({ - ...opts, - _fromLocation: location, - }), - buildLocation: router.buildLocation, - cause: match.cause, - abortController: match.abortController, - preload: false, - matches, - routeId: route.id, - } - match.__routeContext = - route.options.context(contextFnContext) ?? undefined - } - - match.context = { - ...parentContext, - ...match.__routeContext, - ...match.__beforeLoadContext, - } - - const assetContext = { - ssr: router.options.ssr, - matches: activeMatches, - match, - params: match.params, - loaderData: match.loaderData, - } - const headFnContent = await route.options.head?.(assetContext) - - const scripts = await route.options.scripts?.(assetContext) - - match.meta = headFnContent?.meta - match.links = headFnContent?.links - match.headScripts = headFnContent?.scripts - match.styles = headFnContent?.styles - match.scripts = scripts - } catch (err) { - if (isNotFound(err)) { - match.error = { isNotFound: true } - console.error( - `NotFound error during hydration for routeId: ${match.routeId}`, - err, - ) - } else { - match.error = err as any - console.error( - `Error during hydration for route ${match.routeId}:`, - err, - ) - throw err - } - } - }), - ) - - const isSpaMode = matches[matches.length - 1]!.id !== lastMatchId - const hasSsrFalseMatches = matches.some((m) => m.ssr === false) - // all matches have data from the server and we are not in SPA mode so we don't need to kick of router.load() - if (!hasSsrFalseMatches && !isSpaMode) { - matches.forEach((match) => { - // remove the dehydrated flag since we won't run router.load() which would remove it - match._nonReactive.dehydrated = undefined - }) - // Mark the current location as resolved so that later load cycles - // (e.g. preloads, invalidations) don't mistakenly detect a href change - // (resolvedLocation defaults to undefined and router.load() is skipped - // in the normal SSR hydration path). - router.stores.resolvedLocation.set(router.stores.location.get()) - return routeChunkPromise - } - - // schedule router.load() to run after the next tick so we can store the promise in the match before loading starts - const loadPromise = Promise.resolve() - .then(() => router.load()) - .catch((err) => { - console.error('Error during router hydration:', err) - }) - - // in SPA mode we need to keep the first match below the root route pending until router.load() is finished - // this will prevent that other pending components are rendered but hydration is not blocked - if (isSpaMode) { - const match = matches[1] - if (!match) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: Expected to find a match below the root match in SPA mode.', - ) - } - - invariant() - } - setMatchForcePending(match) - - match._displayPending = true - match._nonReactive.displayPendingPromise = loadPromise - - loadPromise.then(() => { - router.batch(() => { - // ensure router is not in status 'pending' anymore - // this usually happens in Transitioner but if loading synchronously resolves, - // Transitioner won't be rendered while loading so it cannot track the change from loading:true to loading:false - if (router.stores.status.get() === 'pending') { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) - } - // hide the pending component once the load is finished - router.updateMatch(match.id, (prev) => ({ - ...prev, - _displayPending: undefined, - displayPendingPromise: undefined, - })) - }) - }) - } - return routeChunkPromise -} +export { hydrate } from '../load-client' diff --git a/packages/router-core/src/ssr/ssr-match-id.ts b/packages/router-core/src/ssr/ssr-match-id.ts index 498c3a0c7e..6c7e2e9eaf 100644 --- a/packages/router-core/src/ssr/ssr-match-id.ts +++ b/packages/router-core/src/ssr/ssr-match-id.ts @@ -1,7 +1,16 @@ export function dehydrateSsrMatchId(id: string): string { - return id.replaceAll('/', '\0') + return id + .replaceAll('~', '~~') + .replaceAll('\0', '~0') + .replaceAll('\uFFFD', '~r') + .replaceAll('/', '\0') } export function hydrateSsrMatchId(id: string): string { - return id.replaceAll('\0', '/').replaceAll('\uFFFD', '/') + return id + .replaceAll('\0', '/') + .replaceAll('\uFFFD', '/') + .replace(/~([~0r])/g, (_, code) => + code === '0' ? '\0' : code === 'r' ? '\uFFFD' : code, + ) } diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 31db2f5e41..30d3bc4890 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -8,6 +8,7 @@ import { import { decodePath } from '../utils' import { createLRUCache } from '../lru-cache' import { rootRouteId } from '../root' +import { _getRenderedMatches } from '../load-client' import minifiedTsrBootStrapScript from './tsrScript?script-string' import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from './constants' import { dehydrateSsrMatchId } from './ssr-match-id' @@ -51,7 +52,7 @@ export function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch { dehydratedMatch[shorthand] = match[key] } } - if (match.globalNotFound) { + if (match._notFound) { dehydratedMatch.g = true } return dehydratedMatch @@ -389,7 +390,7 @@ export function attachRouterServerSsrUtils({ if (!manifest) return manifest const requestAssets = getRequestAssets?.() - const matches = router.stores.matches.get() + const matches = _getRenderedMatches(router.stores.matches.get()) const hasAssets = hasRequestAssets(requestAssets) if (!hasAssets && !manifest.inlineCss) { @@ -495,8 +496,9 @@ export function attachRouterServerSsrUtils({ invariant() } - let matchesToDehydrate = router.stores.matches.get() - if (router.isShell()) { + let matchesToDehydrate = _getRenderedMatches(router.stores.matches.get()) + const isShell = router.isShell() + if (isShell) { // In SPA mode we only want to dehydrate the root match matchesToDehydrate = matchesToDehydrate.slice(0, 1) } @@ -540,11 +542,10 @@ export function attachRouterServerSsrUtils({ manifest: manifestToDehydrate, matches, } - const lastMatchId = matchesToDehydrate[matchesToDehydrate.length - 1]?.id - if (lastMatchId) { - dehydratedRouter.lastMatchId = dehydrateSsrMatchId(lastMatchId) - } const dehydratedData = await router.options.dehydrate?.() + if (cleanupStarted) { + return + } if (dehydratedData) { dehydratedRouter.dehydratedData = dehydratedData } diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 4393f955b3..03c6ecf2e1 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -4,6 +4,8 @@ import { TSR_SCRIPT_BARRIER_ID } from './constants' import type { AnyRouter } from '../router' export type TransformStreamWithRouterOptions = { + /** The request lifetime that owns this response stream. */ + signal?: AbortSignal /** Timeout for serialization to complete after app render finishes (default: 60000ms) */ timeoutMs?: number /** Maximum lifetime of the stream transform (default: 120000ms). Safety net for cleanup. */ @@ -196,6 +198,22 @@ function createAbortNotifier(opts?: TransformStreamWithRouterOptions) { } } +function listenToAbort( + signal: AbortSignal | undefined, + onAbort: (reason?: unknown) => void, +) { + if (!signal) { + return + } + if (signal.aborted) { + onAbort(signal.reason) + return + } + const listener = () => onAbort(signal.reason) + signal.addEventListener('abort', listener, { once: true }) + return () => signal.removeEventListener('abort', listener) +} + export function transformStreamWithRouter( router: AnyRouter, appStream: ReadableStream, @@ -224,6 +242,7 @@ function makeFastPathStream( let controller: ReadableStreamDefaultController | undefined let state: MergeState = MergeState.ReadingBody let lifetimeTimeoutHandle: ReturnType | undefined + let stopListeningToAbort: (() => void) | undefined let stopListeningToInjectedHtml: (() => void) | undefined const readerState = createReaderState(appStream) const notifyAbort = createAbortNotifier(opts) @@ -251,6 +270,8 @@ function makeFastPathStream( clearTimeout(lifetimeTimeoutHandle) lifetimeTimeoutHandle = undefined } + stopListeningToAbort?.() + stopListeningToAbort = undefined try { stopListeningToInjectedHtml?.() } catch { @@ -360,6 +381,11 @@ function makeFastPathStream( }, }) + stopListeningToAbort = listenToAbort(opts?.signal, (reason) => { + safeError(reason) + cleanup(reason) + }) + return stream } @@ -380,6 +406,7 @@ function makeMainStream( let stopListeningToSerializationFinished: (() => void) | undefined let serializationTimeoutHandle: ReturnType | undefined let lifetimeTimeoutHandle: ReturnType | undefined + let stopListeningToAbort: (() => void) | undefined let cleanedUp = false let controller: ReadableStreamDefaultController | undefined @@ -509,6 +536,8 @@ function makeMainStream( } stopListeningToInjectedHtml = undefined stopListeningToSerializationFinished = undefined + stopListeningToAbort?.() + stopListeningToAbort = undefined if (serializationTimeoutHandle !== undefined) { clearTimeout(serializationTimeoutHandle) @@ -763,7 +792,14 @@ function makeMainStream( if (cleanedUp || isDone()) return stream } - // Transform the appStream + stopListeningToAbort = listenToAbort(opts?.signal, (reason) => { + safeError(reason) + cleanup(reason) + }) + if (cleanedUp || isDone()) + return stream + + // Transform the appStream ;(async () => { try { while (true) { diff --git a/packages/router-core/src/ssr/types.ts b/packages/router-core/src/ssr/types.ts index 70395cc9a0..27bf111843 100644 --- a/packages/router-core/src/ssr/types.ts +++ b/packages/router-core/src/ssr/types.ts @@ -15,7 +15,6 @@ export interface DehydratedMatch { export interface DehydratedRouter { manifest: Manifest | undefined dehydratedData?: any - lastMatchId?: string matches: Array } diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts index c2ab8311c3..8c2beeb2e7 100644 --- a/packages/router-core/src/stores.ts +++ b/packages/router-core/src/stores.ts @@ -1,11 +1,9 @@ -import { createLRUCache } from './lru-cache' import { arraysEqual, functionalUpdate } from './utils' import type { AnyRoute } from './route' import type { RouterState } from './router' import type { FullSearchSchema } from './routeInfo' import type { ParsedLocation } from './location' -import type { AnyRedirect } from './redirect' import type { AnyRouteMatch } from './Matches' export interface RouterReadableStore { @@ -34,12 +32,9 @@ export type StoreConfig = { createMutableStore: MutableStoreFactory createReadonlyStore: ReadonlyStoreFactory batch: RouterBatchFn - init?: (stores: RouterStores) => void } -type MatchStore = RouterWritableStore & { - routeId?: string -} +type MatchStore = RouterWritableStore type ReadableStore = RouterReadableStore /** SSR non-reactive createMutableStore */ @@ -71,272 +66,115 @@ export function createNonReactiveReadonlyStore( export interface RouterStores { status: RouterWritableStore['status']> - loadedAt: RouterWritableStore - isLoading: RouterWritableStore - isTransitioning: RouterWritableStore location: RouterWritableStore>> resolvedLocation: RouterWritableStore< ParsedLocation> | undefined > - statusCode: RouterWritableStore - redirect: RouterWritableStore - matchesId: RouterWritableStore> - pendingIds: RouterWritableStore> - /** @internal */ - cachedIds: RouterWritableStore> + ids: RouterWritableStore> matches: ReadableStore> - pendingMatches: ReadableStore> - cachedMatches: ReadableStore> - firstId: ReadableStore - hasPending: ReadableStore - matchRouteDeps: ReadableStore<{ - locationHref: string - resolvedLocationHref: string | undefined - status: RouterState['status'] - }> __store: RouterReadableStore> - matchStores: Map - pendingMatchStores: Map - cachedMatchStores: Map + byRoute: Map /** - * Get a computed store that resolves a routeId to its current match state. - * Returns the same cached store instance for repeated calls with the same key. - * The computed depends on matchesId + the individual match store, so - * subscribers are only notified when the resolved match state changes. + * Get the stable atom for a route's presented match. The atom remains in the + * pool when the route leaves and contains `undefined` until it re-enters. */ - getRouteMatchStore: ( + getMatchStore: ( routeId: string, ) => RouterReadableStore setMatches: (nextMatches: Array) => void - setPending: (nextMatches: Array) => void - setCached: (nextMatches: Array) => void } export function createRouterStores( - initialState: RouterState, + initialLocation: RouterState['location'], config: StoreConfig, ): RouterStores { - const { createMutableStore, createReadonlyStore, batch, init } = config + const { createMutableStore, createReadonlyStore, batch } = config // non reactive utilities - const matchStores = new Map() - const pendingMatchStores = new Map() - const cachedMatchStores = new Map() + const byRoute = new Map() // atoms - const status = createMutableStore(initialState.status) - const loadedAt = createMutableStore(initialState.loadedAt) - const isLoading = createMutableStore(initialState.isLoading) - const isTransitioning = createMutableStore(initialState.isTransitioning) - const location = createMutableStore(initialState.location) - const resolvedLocation = createMutableStore(initialState.resolvedLocation) - const statusCode = createMutableStore(initialState.statusCode) - const redirect = createMutableStore(initialState.redirect) - const matchesId = createMutableStore>([]) - const pendingIds = createMutableStore>([]) - const cachedIds = createMutableStore>([]) + const status = createMutableStore['status']>('idle') + const location = createMutableStore(initialLocation) + const resolvedLocation = + createMutableStore['resolvedLocation']>(undefined) + const ids = createMutableStore>([]) // 1st order derived stores const matches = createReadonlyStore(() => - readPoolMatches(matchStores, matchesId.get()), - ) - const pendingMatches = createReadonlyStore(() => - readPoolMatches(pendingMatchStores, pendingIds.get()), - ) - const cachedMatches = createReadonlyStore(() => - readPoolMatches(cachedMatchStores, cachedIds.get()), - ) - const firstId = createReadonlyStore(() => matchesId.get()[0]) - const hasPending = createReadonlyStore(() => - matchesId.get().some((matchId) => { - const store = matchStores.get(matchId) - return store?.get().status === 'pending' - }), + ids.get().map((id) => byRoute.get(id)!.get()!), ) - const matchRouteDeps = createReadonlyStore(() => ({ - locationHref: location.get().href, - resolvedLocationHref: resolvedLocation.get()?.href, - status: status.get(), - })) // compatibility "big" state store const __store = createReadonlyStore(() => ({ status: status.get(), - loadedAt: loadedAt.get(), - isLoading: isLoading.get(), - isTransitioning: isTransitioning.get(), + isLoading: status.get() === 'pending', matches: matches.get(), location: location.get(), resolvedLocation: resolvedLocation.get(), - statusCode: statusCode.get(), - redirect: redirect.get(), })) - // Per-routeId computed store cache. - // Each entry resolves routeId → match state through the signal graph, - // giving consumers a single store to subscribe to instead of the - // two-level byRouteId → matchStore pattern. - // - // 64 max size is arbitrary, this is only for active matches anyway so - // it should be plenty. And we already have a 32 limit due to route - // matching bitmask anyway. - const matchStoreByRouteIdCache = createLRUCache< - string, - RouterReadableStore - >(64) - - function getRouteMatchStore( - routeId: string, - ): RouterReadableStore { - let cached = matchStoreByRouteIdCache.get(routeId) - if (!cached) { - cached = createReadonlyStore(() => { - // Reading matchesId.get() tracks it as a dependency. - // When matchesId changes (navigation), this computed re-evaluates. - const ids = matchesId.get() - for (const id of ids) { - const matchStore = matchStores.get(id) - if (matchStore && matchStore.routeId === routeId) { - // Reading matchStore.get() tracks it as a dependency. - // When the match store's state changes, this re-evaluates. - return matchStore.get() - } - } - return undefined - }) - matchStoreByRouteIdCache.set(routeId, cached) + function getMatchStore(routeId: string): MatchStore { + let matchStore = byRoute.get(routeId) + if (!matchStore) { + matchStore = createMutableStore(undefined) + byRoute.set(routeId, matchStore) } - return cached + return matchStore } const store = { // atoms status, - loadedAt, - isLoading, - isTransitioning, location, resolvedLocation, - statusCode, - redirect, - matchesId, - pendingIds, - cachedIds, + ids, // derived matches, - pendingMatches, - cachedMatches, - firstId, - hasPending, - matchRouteDeps, // non-reactive state - matchStores, - pendingMatchStores, - cachedMatchStores, + byRoute, // compatibility "big" state __store, - // per-key computed stores - getRouteMatchStore, + // stable per-route presentation atoms + getMatchStore, // methods setMatches, - setPending, - setCached, } - // initialize the active matches - setMatches(initialState.matches as Array) - init?.(store) - // setters to update non-reactive utilities in sync with the reactive stores function setMatches(nextMatches: Array) { - reconcileMatchPool( - nextMatches, - matchStores, - matchesId, - createMutableStore, - batch, - ) - } - - function setPending(nextMatches: Array) { - reconcileMatchPool( - nextMatches, - pendingMatchStores, - pendingIds, - createMutableStore, - batch, - ) - } - - function setCached(nextMatches: Array) { - reconcileMatchPool( - nextMatches, - cachedMatchStores, - cachedIds, - createMutableStore, - batch, - ) - } - - return store -} - -function readPoolMatches( - pool: Map, - ids: Array, -): Array { - const matches: Array = [] - for (const id of ids) { - const matchStore = pool.get(id) - if (matchStore) { - matches.push(matchStore.get()) - } - } - return matches -} - -function reconcileMatchPool( - nextMatches: Array, - pool: Map, - idStore: RouterWritableStore>, - createMutableStore: MutableStoreFactory, - batch: RouterBatchFn, -): void { - const nextIds = nextMatches.map((d) => d.id) - const nextIdSet = new Set(nextIds) - - batch(() => { - for (const id of pool.keys()) { - if (!nextIdSet.has(id)) { - pool.delete(id) + const previousIds = ids.get() + const nextIds = nextMatches.map((match) => match.routeId) + + batch(() => { + // Publish lane membership first so framework trees reconcile departures + // before observers of a leaving route receive its tombstone. + if (!arraysEqual(previousIds, nextIds)) { + ids.set(nextIds) } - } - for (const nextMatch of nextMatches) { - const existing = pool.get(nextMatch.id) - if (!existing) { - const matchStore = createMutableStore(nextMatch) as MatchStore - matchStore.routeId = nextMatch.routeId - pool.set(nextMatch.id, matchStore) - continue + for (const id of previousIds) { + if (!nextIds.includes(id)) { + byRoute.get(id)!.set(() => undefined) + } } - existing.routeId = nextMatch.routeId - if (existing.get() !== nextMatch) { - existing.set(nextMatch) + for (const nextMatch of nextMatches) { + const matchStore = getMatchStore(nextMatch.routeId) + if (matchStore.get() !== nextMatch) { + matchStore.set(nextMatch) + } } - } + }) + } - if (!arraysEqual(idStore.get(), nextIds)) { - idStore.set(nextIds) - } - }) + return store } diff --git a/packages/router-core/tests/background-assets-stale.test.ts b/packages/router-core/tests/background-assets-stale.test.ts new file mode 100644 index 0000000000..1c39969185 --- /dev/null +++ b/packages/router-core/tests/background-assets-stale.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +describe('background decorative asset failure', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + test('commits fresh loader data while preserving the previous projected assets', async () => { + const projectionError = new Error('head projection failed') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + let resolveStaleReload!: (data: { title: string }) => void + let loaderCalls = 0 + const loader = () => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'old' } + } + + return new Promise<{ title: string }>((resolve) => { + resolveStaleReload = resolve + }) + } + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head: ({ loaderData }) => { + // The loader always resolves before head runs in this test, so + // loaderData is never undefined here. + if (loaderData!.title === 'fresh') { + throw projectionError + } + + return { + meta: [{ title: loaderData!.title }], + } + }, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + const matchId = router.state.matches.find( + (match) => match.routeId === fooRoute.id, + )!.id + const getMatch = () => + router.state.matches.find((match) => match.id === matchId) + + expect(getMatch()?.loaderData).toEqual({ title: 'old' }) + expect(getMatch()?.meta).toEqual([{ title: 'old' }]) + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + + resolveStaleReload({ title: 'fresh' }) + await vi.waitFor(() => + expect(getMatch()?.loaderData).toEqual({ title: 'fresh' }), + ) + + expect(getMatch()?.meta).toEqual([{ title: 'old' }]) + expect(log).toHaveBeenCalledWith(projectionError) + }) +}) diff --git a/packages/router-core/tests/background-trim-abort.test.ts b/packages/router-core/tests/background-trim-abort.test.ts index efedc410f3..aa992df111 100644 --- a/packages/router-core/tests/background-trim-abort.test.ts +++ b/packages/router-core/tests/background-trim-abort.test.ts @@ -1,6 +1,11 @@ import { expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute } from '../src' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, +} from '../src' import { createTestRouter } from './routerTestUtils' test('a background boundary keeps the matched branch and its lifecycle stable', async () => { @@ -62,6 +67,58 @@ test('a background boundary keeps the matched branch and its lifecycle stable', expect(childOnLeave).not.toHaveBeenCalled() }) +test('a background notFound stays private until its parent boundary is ready', async () => { + const boundaryReady = createControlledPromise() + const ParentNotFound = Object.assign(() => null, { + preload: () => boundaryReady, + }) + let childLoads = 0 + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + notFoundComponent: ParentNotFound, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + if (++childLoads > 1) { + throw notFound() + } + return 'initial child data' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + await router.invalidate({ + filter: (match) => match.routeId === childRoute.id, + }) + await vi.waitFor(() => expect(childLoads).toBe(2)) + + expect(router.state.matches.map((match) => match.status)).toEqual([ + 'success', + 'success', + 'success', + ]) + + boundaryReady.resolve() + await vi.waitFor(() => { + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]) + expect(router.state.matches[1]?.status).toBe('notFound') + expect(router.state.matches[2]?.status).toBe('success') + }) +}) + test('foreground supersession aborts every loader in a background batch', async () => { let parentLoads = 0 let childLoads = 0 diff --git a/packages/router-core/tests/boundary-component-chunk.test.ts b/packages/router-core/tests/boundary-component-chunk.test.ts index dc49380d06..06953b4543 100644 --- a/packages/router-core/tests/boundary-component-chunk.test.ts +++ b/packages/router-core/tests/boundary-component-chunk.test.ts @@ -8,6 +8,17 @@ import { } from '../src' import { createTestRouter } from './routerTestUtils' +/** + * boundary component preloads should not be blocked by an unrelated + * normal route component preload. A route load starts the normal component + * preload before the loader settles; if that loader throws, the error boundary + * asks to load only the route's errorComponent. + * + * This test uses a real client router load. It keeps the normal route component + * preload pending, resolves the errorComponent preload, and expects the route + * error state to be committed without waiting for the normal component chunk. + */ + const pendingGates: Array>> = [] const pendingLoads: Array> = [] @@ -23,6 +34,97 @@ afterEach(async () => { }) describe('route boundary component preloads', () => { + test('errorComponent preload resolves without waiting for a pending route component preload', async () => { + const componentGate = createControlledPromise() + const errorComponentGate = createControlledPromise() + const routeError = new Error('loader failed') + let errorComponentPreloadCalls = 0 + pendingGates.push(componentGate, errorComponentGate) + + const SlowRouteComponent = Object.assign(() => null, { + preload: () => componentGate, + }) + const ErrorBoundary = Object.assign(() => null, { + preload: () => { + errorComponentPreloadCalls++ + return errorComponentGate + }, + }) + + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/chunked', + loader: () => { + throw routeError + }, + component: SlowRouteComponent as any, + errorComponent: ErrorBoundary as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/chunked'] }), + }) + + const loadPromise = router.load() + pendingLoads.push(loadPromise) + + await vi.waitFor(() => expect(errorComponentPreloadCalls).toBe(1)) + errorComponentGate.resolve() + + // The route component chunk (componentGate) is still pending: the error + // state and the public load promise must settle without waiting for it. + await loadPromise + expect(componentGate.status).toBe('pending') + const match = router.state.matches.find((item) => item.routeId === route.id) + expect(match?.status).toBe('error') + expect(match?.error).toBe(routeError) + + componentGate.resolve() + }) + + test('global notFound does not wait for component chunks below its boundary', async () => { + const hiddenComponentGate = createControlledPromise() + const notFoundPreload = vi.fn(() => Promise.resolve()) + pendingGates.push(hiddenComponentGate) + + const NotFoundBoundary = Object.assign(() => null, { + preload: notFoundPreload, + }) + const HiddenComponent = Object.assign(() => null, { + preload: () => hiddenComponentGate, + }) + + const rootRoute = new BaseRootRoute({}) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + notFoundComponent: NotFoundBoundary as any, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/child', + component: HiddenComponent as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([layoutRoute.addChildren([childRoute])]), + history: createMemoryHistory({ + initialEntries: ['/child/does-not-exist'], + }), + notFoundMode: 'fuzzy', + }) + + const loading = router.load() + pendingLoads.push(loading) + await loading + + expect(hiddenComponentGate.status).toBe('pending') + expect(notFoundPreload).toHaveBeenCalledOnce() + expect(router.state.matches.find((match) => match._notFound)?.routeId).toBe( + layoutRoute.id, + ) + }) + test('a late normal component chunk cannot replace a selected not-found boundary', async () => { const componentGate = createControlledPromise() const notFoundGate = createControlledPromise() @@ -72,4 +174,34 @@ describe('route boundary component preloads', () => { loaderData: 'ready', }) }) + + test('a required ancestor lazy chunk failure wins over a deeper loader notFound', async () => { + const chunkError = new Error('ancestor lazy chunk failed') + const rootRoute = new BaseRootRoute({}) + const ancestorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/ancestor', + errorComponent: () => null, + }).lazy(() => Promise.reject(chunkError)) + const childRoute = new BaseRoute({ + getParentRoute: () => ancestorRoute, + path: '/child', + loader: () => { + throw notFound() + }, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + ancestorRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/ancestor/child'] }), + }) + + await router.load() + + expect( + router.state.matches.find((match) => match.routeId === ancestorRoute.id), + ).toMatchObject({ status: 'error', error: chunkError }) + }) }) diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 131fccf095..ce0d5c3c98 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -6,8 +6,23 @@ import { retainSearchParams, stripSearchParams, } from '../src' +import { _getUserHistoryState } from '../src/router' import { createTestRouter } from './routerTestUtils' +test('_getUserHistoryState removes volatile router bookkeeping but keeps mask payloads', () => { + expect( + _getUserHistoryState({ + key: 'legacy-key', + __TSR_key: 'key', + __TSR_index: 1, + __hashScrollIntoViewOptions: true, + __tempLocation: {} as any, + __tempKey: 'temp-key', + user: 'state', + } as any), + ).toEqual({ user: 'state', __tempLocation: {}, __tempKey: 'temp-key' }) +}) + describe('buildLocation - params function receives parsed params', () => { test('prev params should contain parsed params from route params.parse', async () => { const rootRoute = new BaseRootRoute({}) diff --git a/packages/router-core/tests/callbacks.test.ts b/packages/router-core/tests/callbacks.test.ts index a683edc14b..7a1b062d4f 100644 --- a/packages/router-core/tests/callbacks.test.ts +++ b/packages/router-core/tests/callbacks.test.ts @@ -200,7 +200,6 @@ describe('callbacks', () => { )?.id expect(page2MatchId).toBeDefined() expect(page2MatchId).not.toBe(page1MatchId) - await router.navigate({ to: '/foo', search: { page: '1' } }) expect(loader).toHaveBeenCalledTimes(2) expect(router.state.matches.some((d) => d.id === page1MatchId)).toBe(true) @@ -222,7 +221,6 @@ describe('callbacks', () => { )?.id expect(post2MatchId).toBeDefined() expect(post2MatchId).not.toBe(post1MatchId) - await router.navigate({ to: '/posts/$postId', params: { postId: '1' } }) expect(loader).toHaveBeenCalledTimes(2) expect(router.state.matches.some((d) => d.id === post1MatchId)).toBe(true) diff --git a/packages/router-core/tests/chunk-failure-lifecycle.test.ts b/packages/router-core/tests/chunk-failure-lifecycle.test.ts new file mode 100644 index 0000000000..bf9fa369d4 --- /dev/null +++ b/packages/router-core/tests/chunk-failure-lifecycle.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * primary route chunk failures should go through the same route-error + * lifecycle as loader and beforeLoad failures. Skipping normalization means + * route onError callbacks, redirects, and notFound values from lazy/chunk + * loading can behave differently from other route failures. + * + * This test uses a real router navigation to a lazy route whose chunk rejects. + * The match reaches error state with the chunk error, but the route's onError + * callback should also receive that error as part of normal route failure + * handling. + */ + +describe('route chunk failure lifecycle', () => { + test('a rejected lazy chunk is retried on the next load instead of replaying forever', async () => { + let lazyCalls = 0 + const chunkError = new Error('lazy chunk failed once') + + const rootRoute = new BaseRootRoute({}) + const lazyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/lazy', + loader: () => 'loaded', + }).lazy(() => { + lazyCalls++ + if (lazyCalls === 1) { + return Promise.reject(chunkError) + } + return Promise.resolve({ options: {} } as any) + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([lazyRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.navigate({ to: '/lazy' }) + expect( + router.state.matches.find((match) => match.routeId === lazyRoute.id) + ?.status, + ).toBe('error') + + await router.invalidate() + expect(lazyCalls).toBe(2) + expect( + router.state.matches.find((match) => match.routeId === lazyRoute.id) + ?.status, + ).toBe('success') + }) + + test('calls route onError when lazy route chunk rejects during navigation', async () => { + const chunkError = new Error('lazy chunk failed') + let capturedError: unknown + + const rootRoute = new BaseRootRoute({}) + const lazyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/lazy', + onError: (error) => { + capturedError = error + }, + }).lazy(() => Promise.reject(chunkError)) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([lazyRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.navigate({ to: '/lazy' }) + + const lazyMatch = router.state.matches.find( + (match) => match.routeId === lazyRoute.id, + ) + expect(lazyMatch?.status).toBe('error') + expect(lazyMatch?.error).toBe(chunkError) + expect(capturedError).toBe(chunkError) + }) +}) diff --git a/packages/router-core/tests/client-lane-adversarial.test.ts b/packages/router-core/tests/client-lane-adversarial.test.ts index 06c96f132c..acfe3fede4 100644 --- a/packages/router-core/tests/client-lane-adversarial.test.ts +++ b/packages/router-core/tests/client-lane-adversarial.test.ts @@ -1,9 +1,225 @@ -import { describe, expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute, redirect } from '../src' -import { createTestRouter } from './routerTestUtils' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, + redirect, +} from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +afterEach(() => { + vi.useRealTimers() +}) + +function abortAwareGate(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + const error = new Error('aborted') + error.name = 'AbortError' + reject(error) + }, + { once: true }, + ) + }) +} describe('adversarial client lane ownership', () => { + test('a navigation started by a pre-load listener supersedes the emitting load before it can continue', async () => { + const firstBeforeLoad = vi.fn() + const secondBeforeLoad = vi.fn() + const firstLoader = vi.fn(() => 'first data') + const secondLoader = vi.fn(() => 'second data') + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/first', + beforeLoad: firstBeforeLoad, + loader: firstLoader, + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/second', + beforeLoad: secondBeforeLoad, + loader: secondLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const beforeLoadLocations: Array = [] + router.subscribe('onBeforeLoad', (event) => { + beforeLoadLocations.push(event.toLocation.pathname) + }) + router.subscribe('onBeforeNavigate', (event) => { + if (event.toLocation.pathname === '/first') { + void router.navigate({ to: '/second' }) + } + }) + + await router.navigate({ to: '/first' }) + + expect(router.state.location.pathname).toBe('/second') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: secondRoute.id, + status: 'success', + }) + expect(beforeLoadLocations).toEqual(['/second']) + expect(firstBeforeLoad).not.toHaveBeenCalled() + expect(firstLoader).not.toHaveBeenCalled() + expect(secondBeforeLoad).toHaveBeenCalledTimes(1) + expect(secondLoader).toHaveBeenCalledTimes(1) + }) + + test.each(['onBeforeNavigate', 'onBeforeLoad'] as const)( + 'a throwing %s listener cannot interrupt later listeners or navigation finalization', + async (eventType) => { + const listenerError = new Error(`${eventType} listener failed`) + const laterListener = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetLoader = vi.fn(() => 'target data') + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: targetLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + router.subscribe(eventType, () => { + throw listenerError + }) + router.subscribe(eventType, laterListener) + + await router.navigate({ to: '/target' }) + + expect(laterListener).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + }) + expect(targetLoader).toHaveBeenCalledTimes(1) + }, + ) + + test('superseding a published pending lane diffs lifecycle hooks from the last final lane', async () => { + const aOnLeave = vi.fn() + const bOnEnter = vi.fn() + const bOnLeave = vi.fn() + const cOnEnter = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + onLeave: aOnLeave, + }) + const bRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/b', + pendingMs: 0, + pendingComponent: () => null, + loader: ({ abortController }) => abortAwareGate(abortController.signal), + onEnter: bOnEnter, + onLeave: bOnLeave, + }) + const cRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/c', + onEnter: cOnEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aRoute, bRoute, cRoute]), + history: createMemoryHistory({ initialEntries: ['/a'] }), + }) + + await router.load() + aOnLeave.mockClear() + + const bNavigation = router.navigate({ to: '/b' }) + await vi.waitFor(() => + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: bRoute.id, + status: 'pending', + }), + ) + + await router.navigate({ to: '/c' }) + await bNavigation + + expect(router.state.matches.at(-1)?.routeId).toBe(cRoute.id) + expect(aOnLeave).toHaveBeenCalledTimes(1) + expect(bOnEnter).not.toHaveBeenCalled() + expect(bOnLeave).not.toHaveBeenCalled() + expect(cOnEnter).toHaveBeenCalledTimes(1) + }) + + test('keeps a hidden child matched without projecting it below a parent error', async () => { + const parentError = new Error('parent failed') + const childHead = vi.fn(() => ({ + meta: [{ title: 'unreachable child' }], + })) + const childOnEnter = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + throw notFound() + }, + notFoundComponent: () => null, + head: childHead, + onEnter: childOnEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]) + expect(router.state.matches[1]).toMatchObject({ + routeId: parentRoute.id, + status: 'error', + error: parentError, + }) + expect(childHead).not.toHaveBeenCalled() + expect(childOnEnter).toHaveBeenCalledTimes(1) + }) + test('a redirect aborts the discarded loader generation', async () => { let redirectSignal: AbortSignal | undefined @@ -76,4 +292,256 @@ describe('adversarial client lane ownership', () => { loaderData: childData, }) }) + + test('a terminal preload retains reusable descendant loader data', async () => { + const parentError = new Error('preload parent failed') + const childLoader = vi.fn(() => 'child data') + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' }) + await router.navigate({ to: '/parent/child' }) + + expect(childLoader).toHaveBeenCalledTimes(1) + expect(router.state.matches[2]).toMatchObject({ + routeId: childRoute.id, + status: 'success', + loaderData: 'child data', + }) + }) + + test('navigation started by route context cannot be overwritten by the stale matching pass', async () => { + const bLoaderGate = createControlledPromise() + const bLoader = vi.fn(() => bLoaderGate) + let retainedLoaderSignal: AbortSignal | undefined + let abandonedContextSignal: AbortSignal | undefined + let redirected = false + + const rootRoute = new BaseRootRoute({ + staleTime: Infinity, + loader: ({ abortController }) => { + retainedLoaderSignal = abortController.signal + return 'root data' + }, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + context: ({ navigate, abortController }) => { + abandonedContextSignal = abortController.signal + if (!redirected) { + redirected = true + void navigate({ to: '/b' }) + } + return { fromA: true } + }, + }) + const bRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/b', + loader: bLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aRoute, bRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const navigation = router.navigate({ to: '/a' }) + await vi.waitFor(() => expect(bLoader).toHaveBeenCalledTimes(1)) + + bLoaderGate.resolve() + await navigation + + expect(router.state.location.pathname).toBe('/b') + expect(router.state.matches.at(-1)?.routeId).toBe(bRoute.id) + expect(abandonedContextSignal?.aborted).toBe(true) + expect(retainedLoaderSignal?.aborted).toBe(false) + }) + + test('a context-failure lane stays live until its terminal generation is replaced', async () => { + const contextError = new Error('context failed after starting work') + + let contextSignal: AbortSignal | undefined + let contextWorkAborted = false + const safeLoader = vi.fn(() => 'safe data') + + const rootRoute = new BaseRootRoute({}) + const brokenRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/broken', + context: ({ abortController }) => { + contextSignal = abortController.signal + abortController.signal.addEventListener( + 'abort', + () => { + contextWorkAborted = true + }, + { once: true }, + ) + throw contextError + }, + }) + const safeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/safe', + loader: safeLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([brokenRoute, safeRoute]), + history: createMemoryHistory({ initialEntries: ['/broken'] }), + }) + + await router.load() + expect(contextSignal?.aborted).toBe(false) + expect(contextWorkAborted).toBe(false) + + await router.navigate({ to: '/safe' }) + expect(router.state.location.pathname).toBe('/safe') + expect(safeLoader).toHaveBeenCalledTimes(1) + expect(contextSignal?.aborted).toBe(true) + expect(contextWorkAborted).toBe(true) + }) + + test.each( + ([false, true] as const).flatMap((isServer) => [ + { + isServer, + thrownType: 'AbortSignal', + createThrownValue: (signal: AbortSignal) => signal, + }, + { + isServer, + thrownType: 'AbortError', + createThrownValue: () => + new DOMException('The operation was aborted.', 'AbortError'), + }, + ]), + )( + 'treats a user-thrown $thrownType in beforeLoad as an ordinary route error (isServer=$isServer)', + async ({ isServer, createThrownValue }) => { + let matchSignal: AbortSignal | undefined + let thrownValue: unknown + const rootRoute = new BaseRootRoute({}) + const brokenRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/broken', + beforeLoad: ({ abortController }) => { + matchSignal = abortController.signal + thrownValue = createThrownValue(matchSignal) + throw thrownValue + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([brokenRoute]), + history: createMemoryHistory({ initialEntries: ['/broken'] }), + isServer, + }) + + if (isServer) { + const response = await loadServerResponse(router, '/broken') + expect(response.status).toBe(500) + } else { + await router.load() + } + + const match = router.state.matches.at(-1) + expect(matchSignal?.aborted).toBe(isServer) + expect(match).toMatchObject({ + routeId: brokenRoute.id, + status: 'error', + }) + expect(match?.error).toBe(thrownValue) + }, + ) + + test('a planning failure becomes the successor lane instead of stranding navigation', async () => { + const preflightError = new Error('next loaderDeps failed') + const pendingGate = createControlledPromise() + const brokenPreflightReached = createControlledPromise() + let pendingSignal: AbortSignal | undefined + const pendingStarted = createControlledPromise() + + const rootRoute = new BaseRootRoute({}) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + }) + const pendingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/pending', + pendingMs: 0, + pendingComponent: () => null, + loader: ({ abortController }) => { + pendingSignal = abortController.signal + pendingStarted.resolve() + return Promise.race([ + pendingGate, + abortAwareGate(abortController.signal), + ]) + }, + }) + const brokenRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/broken', + loaderDeps: (): Record => { + brokenPreflightReached.resolve() + throw preflightError + }, + loader: () => 'never runs', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aRoute, pendingRoute, brokenRoute]), + history: createMemoryHistory({ initialEntries: ['/a'] }), + }) + + await router.load() + const pendingNavigation = router.navigate({ to: '/pending' }) + await pendingStarted + + const brokenNavigation = router.navigate({ to: '/broken' }) + await brokenPreflightReached + + await vi.waitFor(() => expect(pendingSignal?.aborted).toBe(true)) + + pendingGate.resolve() + await Promise.all([pendingNavigation, brokenNavigation]) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: brokenRoute.id, + status: 'error', + error: preflightError, + }) + expect(router.state.location.pathname).toBe('/broken') + }) }) diff --git a/packages/router-core/tests/error-boundary-cache-generation.test.ts b/packages/router-core/tests/error-boundary-cache-generation.test.ts index b36103e3b6..447dace6ea 100644 --- a/packages/router-core/tests/error-boundary-cache-generation.test.ts +++ b/packages/router-core/tests/error-boundary-cache-generation.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' +afterEach(() => { + vi.restoreAllMocks() +}) + describe('cache retention across an error-boundary commit', () => { test('a superseded generation cannot shadow newer beyond-boundary data', async () => { let parentFails = false diff --git a/packages/router-core/tests/fatal-load-rejection.test.ts b/packages/router-core/tests/fatal-load-rejection.test.ts new file mode 100644 index 0000000000..bac78891b0 --- /dev/null +++ b/packages/router-core/tests/fatal-load-rejection.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + redirect, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * A genuinely fatal router rejection (not a route outcome — for example, + * redirect resolution throwing while finalizing a serial beforeLoad + * redirect) must settle without publishing matches whose load + * promises never settled: the serial redirect capped the loader prefix at 0, + * so the loader phase never ran for ancestor matches and their pending + * loadPromise would hang Suspense forever. + */ + +describe('fatal load rejection', () => { + test('fatal rejection during a serial-redirect-capped pass settles the lane', async () => { + const boom = new Error('resolveRedirect failed') + const errorComponentGate = createControlledPromise() + const errorComponentStarted = createControlledPromise() + const errorComponentPreload = vi.fn(() => { + errorComponentStarted.resolve() + return errorComponentGate + }) + const ErrorComponent = Object.assign(() => null, { + preload: errorComponentPreload, + }) + + const rootRoute = new BaseRootRoute({ + // Never runs: the serial redirect caps the loader prefix at 0. Its + // loadPromise (created for the beforeLoad phase) must still settle. + loader: () => 'root data', + errorComponent: ErrorComponent, + }) + const badRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bad', + beforeLoad: () => { + throw redirect({ + to: '/bad', + search: () => { + throw boom + }, + }) + }, + }) + const safeLoader = vi.fn(() => 'safe data') + const safeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/safe', + loader: safeLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([badRoute, safeRoute]), + history: createMemoryHistory({ initialEntries: ['/bad'] }), + }) + + const load = router.load() + const outcome = await Promise.race([ + load.then(() => 'load-settled' as const), + errorComponentStarted.then(() => 'error-preload-started' as const), + ]) + if (outcome === 'error-preload-started') { + errorComponentGate.resolve() + } + expect(outcome).toBe('load-settled') + await load + expect(errorComponentPreload).not.toHaveBeenCalled() + expect(router.state.status).toBe('idle') + + errorComponentGate.resolve() + await router.navigate({ to: '/safe' }) + expect(router.state.location.pathname).toBe('/safe') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: safeRoute.id, + status: 'success', + loaderData: 'safe data', + }) + expect(safeLoader).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/router-core/tests/granular-stores.test.ts b/packages/router-core/tests/granular-stores.test.ts index 9d23b19faf..8d5b98755a 100644 --- a/packages/router-core/tests/granular-stores.test.ts +++ b/packages/router-core/tests/granular-stores.test.ts @@ -31,58 +31,6 @@ function createRouter() { }) } -function createLoaderRouter({ - initialEntries = ['/posts/123'], - staleTime = 0, -}: { - initialEntries?: Array - staleTime?: number -} = {}) { - let resolveLoader: (() => void) | undefined - let callCount = 0 - - const rootRoute = new BaseRootRoute({}) - - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - - const aboutRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/about', - }) - - const postRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/posts/$postId', - staleTime, - loader: () => { - callCount += 1 - - if (callCount === 1) { - return { version: 'initial' } - } - - return new Promise<{ version: string }>((resolve) => { - resolveLoader = () => resolve({ version: 'reloaded' }) - }) - }, - }) - - const routeTree = rootRoute.addChildren([indexRoute, aboutRoute, postRoute]) - - return { - router: createTestRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries, - }), - }), - resolveLoader: () => resolveLoader?.(), - } -} - describe('granular stores', () => { test('keeps ordered route state current across match generations', async () => { const router = createRouter() @@ -112,198 +60,4 @@ describe('granular stores', () => { '/about', ]) }) - - test('match store updates are isolated to the touched active match', async () => { - const router = createRouter() - await router.navigate({ to: '/posts/123' }) - - const rootMatch = router.state.matches[0] - const leafMatch = router.state.matches[1] - - expect(rootMatch).toBeDefined() - expect(leafMatch).toBeDefined() - - if (!rootMatch || !leafMatch) { - throw new Error('Expected root and leaf matches to exist') - } - - const rootStore = router.stores.matchStores.get(rootMatch.id) - const leafStore = router.stores.matchStores.get(leafMatch.id) - - expect(rootStore).toBeDefined() - expect(leafStore).toBeDefined() - - if (!rootStore || !leafStore) { - throw new Error('Expected root and leaf match stores to exist') - } - - const rootBefore = rootStore.get() - const leafBefore = leafStore.get() - - router.updateMatch(leafMatch.id, (prev) => ({ - ...prev, - status: 'pending', - })) - - expect(rootStore.get()).toBe(rootBefore) - expect(leafStore.get()).not.toBe(leafBefore) - expect(leafStore.get().status).toBe('pending') - }) - - test('getRouteMatchStore caches store instances and clears when route is inactive', async () => { - const router = createRouter() - await router.navigate({ to: '/posts/123' }) - - const postsStore = router.stores.getRouteMatchStore('/posts/$postId') - - expect(router.stores.getRouteMatchStore('/posts/$postId')).toBe(postsStore) - expect(postsStore.get()?.routeId).toBe('/posts/$postId') - - await router.navigate({ to: '/about' }) - - expect(router.stores.getRouteMatchStore('/posts/$postId')).toBe(postsStore) - expect(postsStore.get()).toBeUndefined() - }) - - test('no-op match pool reconciliation preserves ids and match state references', async () => { - const router = createRouter() - await router.navigate({ to: '/posts/123' }) - - const activeMatches = router.state.matches - const activeIdsBefore = router.stores.matchesId.get() - const activeStoresBefore = activeMatches.map((match) => - router.stores.matchStores.get(match.id), - ) - const activeStatesBefore = activeStoresBefore.map((store) => store?.get()) - - router.stores.setMatches(activeMatches) - - expect(router.stores.matchesId.get()).toBe(activeIdsBefore) - expect(router.stores.matches.get()).toBe(activeMatches) - for (let i = 0; i < activeMatches.length; i++) { - const match = activeMatches[i]! - const store = router.stores.matchStores.get(match.id) - expect(store).toBe(activeStoresBefore[i]) - expect(store?.get()).toBe(activeStatesBefore[i]) - } - - const pendingMatches = activeMatches.map((match) => ({ - ...match, - id: `${match.id}__pending`, - status: 'pending' as const, - })) - - router.stores.setPending(pendingMatches) - - const pendingIdsBefore = router.stores.pendingIds.get() - const pendingStoresBefore = pendingMatches.map((match) => - router.stores.pendingMatchStores.get(match.id), - ) - const pendingStatesBefore = pendingStoresBefore.map((store) => store?.get()) - - router.stores.setPending(pendingMatches) - - expect(router.stores.pendingIds.get()).toBe(pendingIdsBefore) - for (let i = 0; i < pendingMatches.length; i++) { - const match = pendingMatches[i]! - const store = router.stores.pendingMatchStores.get(match.id) - expect(store).toBe(pendingStoresBefore[i]) - expect(store?.get()).toBe(pendingStatesBefore[i]) - } - }) - - test('updateMatch prefers the pending pool when active and pending share an id', async () => { - const { router, resolveLoader } = createLoaderRouter() - - await router.load() - - const activeLeaf = router.state.matches[1] - - expect(activeLeaf).toBeDefined() - - if (!activeLeaf) { - throw new Error('Expected active leaf match to exist') - } - - const activeStore = router.stores.matchStores.get(activeLeaf.id) - - expect(activeStore).toBeDefined() - - if (!activeStore) { - throw new Error('Expected active leaf store to exist') - } - - const activeBefore = activeStore.get() - - const reloadPromise = router.load() - await Promise.resolve() - - const pendingStore = router.stores.pendingMatchStores.get(activeLeaf.id) - - expect(pendingStore).toBeDefined() - expect(router.stores.matchStores.get(activeLeaf.id)).toBe(activeStore) - - if (!pendingStore) { - throw new Error('Expected pending leaf store to exist') - } - - router.updateMatch(activeLeaf.id, (prev) => ({ - ...prev, - status: 'error', - error: new Error('pending-only-update'), - })) - - expect(activeStore.get()).toBe(activeBefore) - expect(activeStore.get().status).toBe('success') - expect(pendingStore.get().status).toBe('error') - expect(pendingStore.get().error).toEqual(new Error('pending-only-update')) - - resolveLoader() - await reloadPromise - }) - - test('supports duplicate ids across pools without cross-pool contamination', async () => { - const router = createRouter() - await router.navigate({ to: '/posts/123' }) - - const activeLeaf = router.state.matches[1]! - const duplicatedId = activeLeaf.id - const pendingDuplicate = { - ...activeLeaf, - status: 'pending' as const, - } - const cachedDuplicate = { - ...activeLeaf, - status: 'success' as const, - } - - router.stores.setPending([pendingDuplicate]) - router.stores.setCached([cachedDuplicate]) - - router.stores.setMatches( - router.state.matches.map((match) => - match.id === duplicatedId - ? { - ...match, - status: 'error' as const, - error: new Error('active-only-update'), - } - : match, - ), - ) - - expect(router.stores.matchStores.get(duplicatedId)?.get().status).toBe( - 'error', - ) - expect( - router.stores.getRouteMatchStore(activeLeaf.routeId).get()?.status, - ).toBe('error') - // Pending pool has its own store for this id - expect( - router.stores.pendingMatchStores.get(duplicatedId)?.get().status, - ).toBe('pending') - expect(router.stores.pendingMatches.get()[0]?.status).toBe('pending') - expect(router.stores.cachedMatches.get()[0]?.status).toBe('success') - expect(router.getMatch(duplicatedId)?.status).toBe('success') - }) }) diff --git a/packages/router-core/tests/hmr-refresh-lifecycle.test.ts b/packages/router-core/tests/hmr-refresh-lifecycle.test.ts new file mode 100644 index 0000000000..2807c05951 --- /dev/null +++ b/packages/router-core/tests/hmr-refresh-lifecycle.test.ts @@ -0,0 +1,462 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + redirect, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +beforeEach(() => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('HMR route refresh', () => { + test('reloads retained routes with onStay lifecycle semantics', async () => { + let generation = 1 + const loader = vi.fn(() => generation) + const onEnter = vi.fn() + const onLeave = vi.fn() + const order: Array = [] + const onStay = vi.fn(() => order.push('onStay')) + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader, + onEnter, + onLeave, + onStay, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + const unsubLoad = router.subscribe('onLoad', () => order.push('onLoad')) + const unsubMount = router.subscribe('onBeforeRouteMount', () => + order.push('onBeforeRouteMount'), + ) + generation = 2 + await router._refreshRoute!() + + expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.loaderData).toBe(2) + expect(onEnter).toHaveBeenCalledTimes(1) + expect(onLeave).not.toHaveBeenCalled() + expect(onStay).toHaveBeenCalledTimes(1) + expect(order).toEqual(['onStay', 'onLoad', 'onBeforeRouteMount']) + unsubLoad() + unsubMount() + }) + + test('retires refresh mode after an acknowledged publication', async () => { + const otherLoader = vi.fn(() => 'other data') + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: otherLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + await router._refreshRoute!() + await router.preloadRoute({ to: '/other' }) + + expect(router._tx?.[6]).toBeUndefined() + expect(otherLoader).toHaveBeenCalledOnce() + }) + + test('passes rematerialization to a reentrant preflight load', async () => { + let generation = 1 + const loader = vi.fn(() => generation) + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + staleTime: Infinity, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + generation = 2 + let reenter = true + let reentrantLoad: Promise | undefined + const unsubscribe = router.subscribe('onBeforeNavigate', () => { + if (reenter) { + reenter = false + reentrantLoad = router.load({ sync: true }) + } + }) + try { + await router._refreshRoute!() + await reentrantLoad + } finally { + unsubscribe() + } + + expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.loaderData).toBe(2) + }) + + test('passes rematerialization to a load that supersedes refresh work', async () => { + let generation = 1 + let reenter = false + let reentrantLoad: Promise | undefined + let router!: ReturnType + const loader = vi.fn(() => { + if (reenter) { + reenter = false + reentrantLoad = router.load({ sync: true }) + } + return generation + }) + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + staleTime: Infinity, + loader, + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + generation = 2 + reenter = true + await router._refreshRoute!() + await reentrantLoad + + expect(loader).toHaveBeenCalledTimes(3) + expect(router.state.matches.at(-1)?.loaderData).toBe(2) + }) + + test('passes rematerialization through a refresh redirect', async () => { + let generation = 1 + let shouldRedirect = false + const rootLoader = vi.fn(() => generation) + const rootRoute = new BaseRootRoute({ + staleTime: Infinity, + loader: rootLoader, + }) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + beforeLoad: () => { + if (shouldRedirect) { + throw redirect({ to: '/target' }) + } + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/source'] }), + }) + + await router.load() + generation = 2 + shouldRedirect = true + await router._refreshRoute!() + + expect(router.state.location.pathname).toBe('/target') + expect(rootLoader).toHaveBeenCalledTimes(2) + expect(router.state.matches[0]?.loaderData).toBe(2) + }) + + test('restores the committed presentation when publication fails', async () => { + let generation = 1 + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: () => generation, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + const previousMatches = router.state.matches + generation = 2 + const startTransition = router.startTransition + router.startTransition = async (fn) => { + fn() + router.clearCache() + throw new Error('render failed') + } + + await router._refreshRoute!() + + expect(router.state.status).toBe('idle') + expect(router.state.matches).toHaveLength(previousMatches.length) + expect(router.state.matches.at(-1)?.loaderData).toBe(1) + + router.startTransition = startTransition + generation = 3 + await router._refreshRoute!() + expect(router.state.matches.at(-1)?.loaderData).toBe(3) + }) + + test('keeps the rendered generation alive until refresh is acknowledged', async () => { + let generation = 1 + const signals: Array = [] + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: ({ abortController }) => { + signals.push(abortController.signal) + return generation + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + const renderedSignal = signals[0]! + generation = 2 + const startTransition = router.startTransition + router.startTransition = async (fn) => { + fn() + throw new Error('render failed') + } + + await router._refreshRoute!() + + expect(renderedSignal.aborted).toBe(false) + expect(signals[1]?.aborted).toBe(true) + expect(router.state.matches.at(-1)?.loaderData).toBe(1) + + router.startTransition = startTransition + generation = 3 + await router._refreshRoute!() + expect(renderedSignal.aborted).toBe(true) + expect(router.state.matches.at(-1)?.loaderData).toBe(3) + }) + + test('rolls overlapping refreshes back to the last acknowledged generation', async () => { + let generation = 1 + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: () => generation, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + let transitionCount = 0 + let supersedeFirst!: () => void + router.startTransition = (fn) => { + transitionCount++ + fn() + if (transitionCount === 1) { + return new Promise((resolve) => { + supersedeFirst = () => resolve(false) + }) + } + return Promise.reject(new Error('replacement render failed')) + } + ;( + router as typeof router & { _cancelTransition?: () => void } + )._cancelTransition = () => supersedeFirst() + + generation = 2 + const firstRefresh = router._refreshRoute!() + await vi.waitFor(() => expect(transitionCount).toBe(1)) + + generation = 3 + const secondRefresh = router._refreshRoute!() + await Promise.all([firstRefresh, secondRefresh]) + + expect(router.state.status).toBe('idle') + expect(router.state.matches.at(-1)?.loaderData).toBe(1) + }) + + test('does not resolve a superseding navigation promise during rollback', async () => { + let generation = 1 + const destinationGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: () => generation, + }) + const destinationRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/destination', + loader: () => destinationGate, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute, destinationRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + let transitionCount = 0 + let cancelRefresh!: () => void + router.startTransition = (fn) => { + transitionCount++ + fn() + if (transitionCount === 1) { + return new Promise((resolve) => { + cancelRefresh = () => resolve(false) + }) + } + return Promise.resolve(true) + } + ;( + router as typeof router & { _cancelTransition?: () => void } + )._cancelTransition = () => cancelRefresh() + + generation = 2 + const refresh = router._refreshRoute!() + await vi.waitFor(() => expect(transitionCount).toBe(1)) + + const navigationSettled = vi.fn() + const navigation = router + .navigate({ to: '/destination' }) + .then(navigationSettled) + await vi.waitFor(() => + expect(router.state.location.pathname).toBe('/destination'), + ) + expect(navigationSettled).not.toHaveBeenCalled() + + destinationGate.resolve() + await Promise.all([refresh, navigation]) + expect(navigationSettled).toHaveBeenCalledOnce() + expect(router.state.matches.at(-1)?.routeId).toBe(destinationRoute.id) + }) + + test('waits for an ordinary pending navigation before refreshing', async () => { + const destinationGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + }) + const destinationRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/destination', + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: () => null, + loader: () => destinationGate, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute, destinationRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + const navigation = router.navigate({ to: '/destination' }) + await vi.waitFor(() => { + expect(router.state.status).toBe('pending') + expect(router.state.matches.at(-1)?.routeId).toBe(destinationRoute.id) + }) + destinationRoute.options.onStay = () => { + throw new Error('refreshed destination failed') + } + const refreshSettled = vi.fn() + const refresh = router._refreshRoute!().then(refreshSettled) + await Promise.resolve() + expect(refreshSettled).not.toHaveBeenCalled() + + destinationGate.resolve() + await Promise.all([navigation, refresh]) + + expect(router.state.status).toBe('idle') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: destinationRoute.id, + status: 'success', + }) + expect(refreshSettled).toHaveBeenCalledOnce() + }) + + test('rolls back when an HMR lifecycle callback throws', async () => { + let generation = 1 + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: () => generation, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + generation = 2 + pageRoute.options.onStay = () => { + throw new Error('lifecycle failed') + } + + await router._refreshRoute!() + + expect(router.state.status).toBe('idle') + expect(router.state.matches.at(-1)?.loaderData).toBe(1) + + pageRoute.options.onStay = undefined + generation = 3 + await router._refreshRoute!() + expect(router.state.matches.at(-1)?.loaderData).toBe(3) + }) + + test('does not adopt a preload created by HMR preflight hooks', async () => { + let generation = 1 + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: () => generation, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + generation = 2 + const unsubscribe = router.subscribe('onBeforeLoad', () => { + void router.preloadRoute({ to: '/page' }) + }) + try { + await router._refreshRoute!() + } finally { + unsubscribe() + } + + expect(router.state.matches.at(-1)?.loaderData).toBe(2) + }) +}) diff --git a/packages/router-core/tests/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts index 8c2a1a917b..7ecec4a4e5 100644 --- a/packages/router-core/tests/hydrate.test.ts +++ b/packages/router-core/tests/hydrate.test.ts @@ -4,7 +4,9 @@ import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, + _getAssetMatches, createSerializationAdapter, + isNotFound, notFound, } from '../src' import { hydrate } from '../src/ssr/client' @@ -68,17 +70,13 @@ describe('hydrate', () => { let mockHead: any beforeEach(() => { - // Reset global window mock mockWindow = {} - ;(global as any).window = mockWindow + vi.stubGlobal('window', mockWindow) - // Reset mock head function mockHead = vi.fn() const history = createMemoryHistory({ initialEntries: ['/'] }) - const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/', @@ -87,22 +85,16 @@ describe('hydrate', () => { head: mockHead, }) - const otherRoute = new BaseRoute({ - getParentRoute: () => indexRoute, - path: '/other', - component: () => 'Other', + mockRouter = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + isServer: false, }) - - const routeTree = rootRoute.addChildren([ - indexRoute.addChildren([otherRoute]), - ]) - - mockRouter = createTestRouter({ routeTree, history, isServer: true }) }) afterEach(() => { - vi.resetAllMocks() - delete (global as any).window + vi.restoreAllMocks() + vi.unstubAllGlobals() }) it.each([ @@ -141,78 +133,22 @@ describe('hydrate', () => { expect(mockRouter._preflight).toBeUndefined() }) - it('round-trips adapted loader data through the bootstrap protocol', async () => { - class AdaptedValue { - constructor(readonly value: string) {} - } - - const adapter = createSerializationAdapter({ - key: 'adapted-value', - test: (value: unknown): value is AdaptedValue => - value instanceof AdaptedValue, - toSerializable: (value: AdaptedValue) => value.value, - fromSerializable: (value: string) => new AdaptedValue(value), - }) - - const serverRootRoute = new BaseRootRoute({}) - const serverIndexRoute = new BaseRoute({ - getParentRoute: () => serverRootRoute, - path: '/', - loader: () => new AdaptedValue('server loader data'), - }) - const serverRouter = createTestRouter({ - routeTree: serverRootRoute.addChildren([serverIndexRoute]), - history: createMemoryHistory({ initialEntries: ['/'] }), - isServer: true, - }) - serverRouter.options.serializationAdapters = [adapter] - - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) - - const clientLoader = vi.fn(() => new AdaptedValue('client loader data')) - const clientRootRoute = new BaseRootRoute({}) - const clientIndexRoute = new BaseRoute({ - getParentRoute: () => clientRootRoute, - path: '/', - loader: clientLoader, - }) - const clientRouter = createTestRouter({ - routeTree: clientRootRoute.addChildren([clientIndexRoute]), - history: createMemoryHistory({ initialEntries: ['/'] }), - isServer: false, + it('clears hydration preflight when location reconstruction rejects', async () => { + const error = new Error('location parsing failed') + mockWindow.$_TSR = createMockBootstrap({ + manifest: testManifest, + dehydratedData: {}, + matches: [], }) - clientRouter.options.serializationAdapters = [adapter] - - await hydrate(clientRouter) - - const loaderData = clientRouter.state.matches.at(-1)?.loaderData - expect(loaderData).toBeInstanceOf(AdaptedValue) - expect(loaderData).toEqual(new AdaptedValue('server loader data')) - expect(clientLoader).not.toHaveBeenCalled() - }) - - it('should handle empty serialization adapters', async () => { - mockRouter.options.serializationAdapters = [] - - mockWindow.$_TSR = { - router: { - manifest: testManifest, - dehydratedData: {}, - lastMatchId: '/', - matches: [], - }, - h: vi.fn(), - e: vi.fn(), - c: vi.fn(), - p: vi.fn(), - buffer: [], - initialized: false, + mockRouter.options.hydrate = () => { + mockRouter.options.parseSearch = () => { + throw error + } } - await hydrate(mockRouter) + await expect(hydrate(mockRouter)).rejects.toBe(error) - expect(mockWindow.$_TSR.t).toBeUndefined() - expect(mockWindow.$_TSR.initialized).toBe(true) + expect(mockRouter._preflight).toBeUndefined() }) it('round-trips the manifest and matches without running client loaders', async () => { @@ -323,180 +259,405 @@ describe('hydrate', () => { expect(match).toHaveProperty('loaderData', undefined) }) - it('should hydrate globalNotFound when dehydrated flag is present', async () => { - const mockMatches = [ - { - id: '/', - routeId: '/', - index: 0, - ssr: undefined, - _nonReactive: {}, - }, - ] + it('preserves successful undefined loader data when selecting a later failure', async () => { + const serverRootRoute = new BaseRootRoute({}) + const serverParentRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/parent', + loader: () => undefined, + }) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverParentRoute, + path: '/child', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverParentRoute.addChildren([serverChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) - const dehydratedMatches = [ - { - i: '/', - s: 'success' as const, - ssr: true, - u: Date.now(), - g: true as const, + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + expect(mockWindow.$_TSR.router?.matches[1]).not.toHaveProperty('l') + + const parentError = new Error('parent reload failed') + const childError = new Error('child guard failed') + const clientRootRoute = new BaseRootRoute({}) + const clientParentRoute = new BaseRoute({ + getParentRoute: () => clientRootRoute, + path: '/parent', + loader: () => { + throw parentError }, - ] + errorComponent: () => null, + }) + const clientChildRoute = new BaseRoute({ + getParentRoute: () => clientParentRoute, + path: '/child', + beforeLoad: () => { + throw childError + }, + errorComponent: () => null, + }) + const clientRouter = createTestRouter({ + routeTree: clientRootRoute.addChildren([ + clientParentRoute.addChildren([clientChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: false, + }) - mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches) - mockRouter.state.matches = mockMatches + await hydrate(clientRouter) + await clientRouter.invalidate() - mockWindow.$_TSR = { - router: { - manifest: testManifest, - dehydratedData: {}, - lastMatchId: '/', - matches: dehydratedMatches, - }, - h: vi.fn(), - e: vi.fn(), - c: vi.fn(), - p: vi.fn(), - buffer: [], - initialized: false, - } + expect( + clientRouter.state.matches.find( + (match) => match.routeId === clientParentRoute.id, + ), + ).toMatchObject({ status: 'success', error: undefined }) + expect( + clientRouter.state.matches.find( + (match) => match.routeId === clientChildRoute.id, + ), + ).toMatchObject({ status: 'error', error: childError }) + }) - await hydrate(mockRouter) + it.each([ + ['mismatched', dehydrateSsrMatchId('/different-match')], + ['missing', undefined], + ['non-string', 42], + ])( + 'does not attach dehydrated data with a %s match identity', + async (_, identity) => { + const serverRootRoute = new BaseRootRoute({}) + const serverProductRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/products/$productId', + loader: () => 'server data', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverProductRoute]), + history: createMemoryHistory({ initialEntries: ['/products/42'] }), + isServer: true, + }) + + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR.router!.matches[1]!.i = identity as string + + const clientLoader = vi.fn(() => 'client data') + const clientRootRoute = new BaseRootRoute({}) + const clientProductRoute = new BaseRoute({ + getParentRoute: () => clientRootRoute, + path: '/products/$productId', + loader: clientLoader, + }) + const clientRouter = createTestRouter({ + routeTree: clientRootRoute.addChildren([clientProductRoute]), + history: createMemoryHistory({ initialEntries: ['/products/42'] }), + isServer: false, + }) + + await hydrate(clientRouter) + + const pendingMatch = clientRouter.state.matches.at(-1) + expect(pendingMatch).toMatchObject({ + routeId: clientProductRoute.id, + status: 'pending', + }) + expect(pendingMatch).not.toHaveProperty('loaderData') + expect(clientLoader).not.toHaveBeenCalled() + + await clientRouter.load() + + expect(clientRouter.state.matches.at(-1)).toMatchObject({ + routeId: clientProductRoute.id, + status: 'success', + loaderData: 'client data', + }) + expect(clientLoader).toHaveBeenCalledTimes(1) + }, + ) + + it('keeps an earlier data-only boundary when a descendant id mismatches', async () => { + const serverRootRoute = new BaseRootRoute({}) + const serverParentRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/parent', + ssr: 'data-only', + loader: () => 'server parent data', + }) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverParentRoute, + path: '/child', + loader: () => 'server child data', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverParentRoute.addChildren([serverChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR.router!.matches[2]!.i = dehydrateSsrMatchId( + '/different-child-match', + ) + + const childContext = vi.fn(() => ({ source: 'client child context' })) + const clientRootRoute = new BaseRootRoute({}) + const clientParentLoader = vi.fn(() => 'client parent data') + const clientParentRoute = new BaseRoute({ + getParentRoute: () => clientRootRoute, + path: '/parent', + ssr: 'data-only', + loader: clientParentLoader, + }) + const clientChildRoute = new BaseRoute({ + getParentRoute: () => clientParentRoute, + path: '/child', + context: childContext, + loader: () => 'client child data', + }) + const clientRouter = createTestRouter({ + routeTree: clientRootRoute.addChildren([ + clientParentRoute.addChildren([clientChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: false, + }) + + await hydrate(clientRouter) - expect((mockMatches[0] as AnyRouteMatch).globalNotFound).toBe(true) + expect(clientRouter.state.matches[1]).toMatchObject({ + routeId: clientParentRoute.id, + status: 'pending', + loaderData: 'server parent data', + }) + expect(childContext).not.toHaveBeenCalled() + expect(clientParentLoader).not.toHaveBeenCalled() + expect( + _getAssetMatches(clientRouter.state.matches).map( + (match) => match.routeId, + ), + ).toEqual([clientRootRoute.id, clientParentRoute.id]) + + await clientRouter.load() + + expect(childContext).toHaveBeenCalledTimes(1) + expect(clientParentLoader).not.toHaveBeenCalled() + expect(clientRouter.state.matches[1]).toMatchObject({ + routeId: clientParentRoute.id, + status: 'success', + loaderData: 'server parent data', + }) + expect(clientRouter.state.matches[2]).toMatchObject({ + routeId: clientChildRoute.id, + status: 'success', + loaderData: 'client child data', + }) }) - it('should leave globalNotFound undefined when dehydrated flag is omitted', async () => { - const mockMatches = [ - { - id: '/', - routeId: '/', - index: 0, - ssr: undefined, - _nonReactive: {}, - }, - ] + it('rejects a longer non-terminal server lane before attaching its data', async () => { + const serverRootRoute = new BaseRootRoute({}) + const serverParentRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/parent', + loader: () => 'server data', + }) + const serverIndexRoute = new BaseRoute({ + getParentRoute: () => serverParentRoute, + path: '/', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverParentRoute.addChildren([serverIndexRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + isServer: true, + }) - const dehydratedMatches = [ - { - i: '/', - s: 'success' as const, - ssr: true, - u: Date.now(), - }, - ] + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + expect(mockWindow.$_TSR.router?.matches).toHaveLength(3) + + const clientLoader = vi.fn(() => 'client data') + const clientRootRoute = new BaseRootRoute({}) + const clientParentRoute = new BaseRoute({ + getParentRoute: () => clientRootRoute, + path: '/parent', + loader: clientLoader, + }) + const clientRouter = createTestRouter({ + routeTree: clientRootRoute.addChildren([clientParentRoute]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + isServer: false, + }) - mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches) - mockRouter.state.matches = mockMatches + await hydrate(clientRouter) - mockWindow.$_TSR = { - router: { - manifest: testManifest, - dehydratedData: {}, - lastMatchId: '/', - matches: dehydratedMatches, - }, - h: vi.fn(), - e: vi.fn(), - c: vi.fn(), - p: vi.fn(), - buffer: [], - initialized: false, + expect(clientRouter.state.resolvedLocation).toBeUndefined() + expect(clientRouter.state.matches.at(-1)).not.toHaveProperty('loaderData') + expect(clientLoader).not.toHaveBeenCalled() + + await clientRouter.load() + + expect(clientRouter.state.matches.at(-1)).toMatchObject({ + routeId: clientParentRoute.id, + status: 'success', + loaderData: 'client data', + }) + expect(clientLoader).toHaveBeenCalledTimes(1) + }) + + it('round-trips adapted loader data through the bootstrap protocol', async () => { + class AdaptedValue { + constructor(readonly value: string) {} } - await hydrate(mockRouter) + const adapter = createSerializationAdapter({ + key: 'adapted-value', + test: (value: unknown): value is AdaptedValue => + value instanceof AdaptedValue, + toSerializable: (value: AdaptedValue) => value.value, + fromSerializable: (value: string) => new AdaptedValue(value), + }) - expect((mockMatches[0] as AnyRouteMatch).globalNotFound).toBeUndefined() + const serverRootRoute = new BaseRootRoute({}) + const serverIndexRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/', + loader: () => new AdaptedValue('server loader data'), + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverIndexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) + serverRouter.options.serializationAdapters = [adapter] + + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + + const clientLoader = vi.fn(() => new AdaptedValue('client loader data')) + const clientRootRoute = new BaseRootRoute({}) + const clientIndexRoute = new BaseRoute({ + getParentRoute: () => clientRootRoute, + path: '/', + loader: clientLoader, + }) + const clientRouter = createTestRouter({ + routeTree: clientRootRoute.addChildren([clientIndexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + }) + clientRouter.options.serializationAdapters = [adapter] + + await hydrate(clientRouter) + + const loaderData = clientRouter.state.matches.at(-1)?.loaderData + expect(loaderData).toBeInstanceOf(AdaptedValue) + expect(loaderData).toEqual(new AdaptedValue('server loader data')) + expect(clientLoader).not.toHaveBeenCalled() }) - it('should preserve existing globalNotFound when dehydrated flag is omitted', async () => { - const mockMatches = [ - { - id: '/', - routeId: '/', - index: 0, - ssr: undefined, - _nonReactive: {}, - globalNotFound: true, + it('round-trips a server root notFound lane as _notFound', async () => { + const serverRootRoute = new BaseRootRoute({ + notFoundComponent: () => 'Not Found', + }) + const serverMissingRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/missing', + loader: () => { + throw notFound({ data: { source: 'server loader' } }) }, - ] + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverMissingRoute]), + history: createMemoryHistory({ initialEntries: ['/missing'] }), + isServer: true, + }) - const dehydratedMatches = [ - { - i: '/', - s: 'success' as const, - ssr: true, - u: Date.now(), - }, - ] + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) - mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches) - mockRouter.state.matches = mockMatches + expect(serverRouter.state.matches).toHaveLength(2) + expect(serverRouter.state.matches[0]).toMatchObject({ + routeId: serverRootRoute.id, + status: 'success', + _notFound: true, + }) + expect(isNotFound(serverRouter.state.matches[0]?.error)).toBe(true) - mockWindow.$_TSR = { - router: { - manifest: testManifest, - dehydratedData: {}, - lastMatchId: '/', - matches: dehydratedMatches, - }, - h: vi.fn(), - e: vi.fn(), - c: vi.fn(), - p: vi.fn(), - buffer: [], - initialized: false, - } + const clientLoader = vi.fn(() => 'client data') + const clientRootRoute = new BaseRootRoute({ + notFoundComponent: () => 'Not Found', + }) + const clientMissingRoute = new BaseRoute({ + getParentRoute: () => clientRootRoute, + path: '/missing', + loader: clientLoader, + }) + const clientRouter = createTestRouter({ + routeTree: clientRootRoute.addChildren([clientMissingRoute]), + history: createMemoryHistory({ initialEntries: ['/missing'] }), + isServer: false, + }) - await hydrate(mockRouter) + await hydrate(clientRouter) - expect((mockMatches[0] as AnyRouteMatch).globalNotFound).toBe(true) + expect(clientRouter.state.matches).toHaveLength(2) + expect(clientRouter.state.matches[0]).toMatchObject({ + routeId: clientRootRoute.id, + status: 'success', + _notFound: true, + }) + expect(clientRouter.state.matches[1]).toMatchObject({ + routeId: clientMissingRoute.id, + status: 'pending', + }) + expect(isNotFound(clientRouter.state.matches[0]?.error)).toBe(true) + expect(clientRouter.state.matches[0]?.error).toEqual( + expect.objectContaining({ data: { source: 'server loader' } }), + ) + expect(clientLoader).not.toHaveBeenCalled() }) - it('should decode dehydrated match ids before hydration lookup and SPA-mode checks', async () => { - const loadSpy = vi.spyOn(mockRouter, 'load') + it('preserves a client-matched _notFound when a shell payload omits the flag', async () => { + const serverRootRoute = new BaseRootRoute({ + notFoundComponent: () => 'Not Found', + }) + const serverKnownRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/known', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverKnownRoute]), + history: createMemoryHistory({ initialEntries: ['/known'] }), + isServer: true, + isShell: true, + }) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) - const mockMatches = [ - { - id: '/', - routeId: '/', - index: 0, - ssr: undefined, - _nonReactive: {}, - }, - ] - - mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches) - mockRouter.state.matches = mockMatches - - mockWindow.$_TSR = { - router: { - manifest: testManifest, - dehydratedData: {}, - lastMatchId: dehydrateSsrMatchId('/'), - matches: [ - { - i: dehydrateSsrMatchId('/'), - l: { indexData: 'server-data' }, - s: 'success', - ssr: true, - u: Date.now(), - }, - ], - }, - h: vi.fn(), - e: vi.fn(), - c: vi.fn(), - p: vi.fn(), - buffer: [], - initialized: false, - } + const clientRootRoute = new BaseRootRoute({ + notFoundComponent: () => 'Not Found', + }) + const clientKnownRoute = new BaseRoute({ + getParentRoute: () => clientRootRoute, + path: '/known', + }) + const clientRouter = createTestRouter({ + routeTree: clientRootRoute.addChildren([clientKnownRoute]), + history: createMemoryHistory({ initialEntries: ['/does-not-exist'] }), + isServer: false, + }) - await hydrate(mockRouter) + await hydrate(clientRouter) - expect(loadSpy).not.toHaveBeenCalled() - expect((mockRouter.state.matches[0] as AnyRouteMatch).id).toBe('/') + expect(clientRouter.state.matches).toHaveLength(1) + expect(clientRouter.state.matches[0]).toMatchObject({ + routeId: clientRootRoute.id, + status: 'success', + _notFound: true, + }) }) it('applies round-tripped custom hydration before matching a rewritten location', async () => { @@ -578,54 +739,38 @@ describe('hydrate', () => { expect(clientLoader).not.toHaveBeenCalled() }) - it('should handle errors during route context hydration', async () => { + it('logs and swallows a notFound thrown by a hydrated route head', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) mockHead.mockImplementation(() => { - throw notFound() - }) - - const mockMatches = [ - { id: '/', routeId: '/', index: 0, ssr: true, _nonReactive: {} }, - ] - - mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches) - mockRouter.state.matches = mockMatches - - mockWindow.$_TSR = { - router: { - manifest: testManifest, - dehydratedData: {}, - lastMatchId: '/', - matches: [ - { - i: '/', - l: { data: 'test' }, - s: 'success', - ssr: true, - u: Date.now(), - }, - ], - }, - h: vi.fn(), - e: vi.fn(), - c: vi.fn(), - p: vi.fn(), - buffer: [], - initialized: false, - } + throw notFound({ data: { source: 'index head' } }) + }) + const matched = mockRouter.matchRoutes(mockRouter.stores.location.get()) - await hydrate(mockRouter) + mockWindow.$_TSR = createMockBootstrap({ + manifest: testManifest, + dehydratedData: {}, + matches: matched.map((match: AnyRouteMatch) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + }) - const match = mockRouter.state.matches[0] as AnyRouteMatch - expect(match.error).toEqual({ isNotFound: true }) + await hydrate(mockRouter) + const match = mockRouter.state.matches.find( + (candidate: AnyRouteMatch) => candidate.routeId === '/', + ) as AnyRouteMatch | undefined + expect(match).toBeDefined() + expect(match?.status).toBe('success') + expect(match?.error).toBeUndefined() + expect(mockHead).toHaveBeenCalledOnce() expect(consoleSpy).toHaveBeenCalledWith( - 'NotFound error during hydration for routeId: /', expect.objectContaining({ isNotFound: true, + data: { source: 'index head' }, }), ) - - consoleSpy.mockRestore() }) }) diff --git a/packages/router-core/tests/hydrated-stay-match-data.test.ts b/packages/router-core/tests/hydrated-stay-match-data.test.ts new file mode 100644 index 0000000000..5f8333310a --- /dev/null +++ b/packages/router-core/tests/hydrated-stay-match-data.test.ts @@ -0,0 +1,160 @@ +import { runInNewContext } from 'node:vm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { hydrate } from '../src/ssr/client' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouter } from '../src' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { ServerManifest } from '../src/manifest' + +const testManifest: ServerManifest = { routes: {} } + +async function dehydrateToBootstrap(router: AnyRouter): Promise { + attachRouterServerSsrUtils({ router, manifest: testManifest }) + try { + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + + const context: Record = { + document: { currentScript: { remove() {} } }, + } + context.self = context + runInNewContext(script!.children!, context) + + expect(context.$_TSR).toBeDefined() + return context.$_TSR + } finally { + router.serverSsr?.cleanup() + } +} + +describe('hydrated stay match data preservation', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + vi.stubGlobal('window', mockWindow) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('keeps server loader data while refreshing context on client navigation', async () => { + const serverRootBeforeLoad = vi.fn(() => ({ auth: 'server' })) + const serverRootLoader = vi.fn(() => ({ root: 'server' })) + const serverARouteLoader = vi.fn(() => 'a server data') + const serverRootRoute = new BaseRootRoute({ + beforeLoad: serverRootBeforeLoad, + loader: serverRootLoader, + }) + const serverARoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/a', + ssr: false, + loader: serverARouteLoader, + }) + const serverBRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/b', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverARoute, serverBRoute]), + history: createMemoryHistory({ initialEntries: ['/a'] }), + isServer: true, + }) + + const bootstrap = await dehydrateToBootstrap(serverRouter) + + expect(serverRootBeforeLoad).toHaveBeenCalledTimes(1) + expect(serverRootLoader).toHaveBeenCalledTimes(1) + expect(serverARouteLoader).not.toHaveBeenCalled() + expect(serverRouter.state.matches[0]).toMatchObject({ + routeId: serverRootRoute.id, + status: 'success', + context: { auth: 'server' }, + loaderData: { root: 'server' }, + }) + const rootBeforeLoad = vi.fn(() => ({ auth: 'client' })) + const rootLoader = vi.fn(() => ({ root: 'client' })) + const history = createMemoryHistory({ initialEntries: ['/a'] }) + + const rootRoute = new BaseRootRoute({ + beforeLoad: rootBeforeLoad, + loader: rootLoader, + }) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + ssr: false, + loader: () => 'a client data', + }) + const bRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/b', + loader: () => 'b client data', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aRoute, bRoute]), + history, + isServer: false, + }) + + mockWindow.$_TSR = bootstrap + + await hydrate(router) + + // Hydration adopts the server root before client loads can re-enter beforeLoad. + expect(rootBeforeLoad).not.toHaveBeenCalled() + expect(rootLoader).not.toHaveBeenCalled() + expect( + router.state.matches.find((m) => m.routeId === rootRoute.id), + ).toMatchObject({ + context: { auth: 'server' }, + loaderData: { root: 'server' }, + }) + + await router.load() + + expect( + router.state.matches.find((m) => m.routeId === aRoute.id)?.loaderData, + ).toBe('a client data') + expect(router.state.location.pathname).toBe('/a') + expect(router.state.resolvedLocation?.pathname).toBe('/a') + expect(rootLoader).not.toHaveBeenCalled() + expect( + router.state.matches.find((m) => m.routeId === rootRoute.id), + ).toMatchObject({ + context: { auth: 'server' }, + loaderData: { root: 'server' }, + }) + + // The root stay match refreshes beforeLoad context without rerunning its loader. + await router.navigate({ to: '/b' }) + + expect( + router.state.matches.find((m) => m.routeId === bRoute.id)?.loaderData, + ).toBe('b client data') + expect(router.state.location.pathname).toBe('/b') + expect(router.state.resolvedLocation?.pathname).toBe('/b') + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + bRoute.id, + ]) + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(rootLoader).not.toHaveBeenCalled() + expect( + router.state.matches.find((m) => m.routeId === rootRoute.id), + ).toMatchObject({ + context: { auth: 'client' }, + loaderData: { root: 'server' }, + }) + }) +}) diff --git a/packages/router-core/tests/hydration-asset-context-order.test.ts b/packages/router-core/tests/hydration-asset-context-order.test.ts new file mode 100644 index 0000000000..64ea28971e --- /dev/null +++ b/packages/router-core/tests/hydration-asset-context-order.test.ts @@ -0,0 +1,99 @@ +import { runInNewContext } from 'node:vm' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { hydrate } from '../src/ssr/client' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouteMatch, AnyRouter } from '../src' +import type { ServerManifest } from '../src/manifest' +import type { TsrSsrGlobal } from '../src/ssr/types' + +const testManifest: ServerManifest = { routes: {} } + +async function dehydrateToBootstrap(router: AnyRouter): Promise { + attachRouterServerSsrUtils({ router, manifest: testManifest }) + try { + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + + const context: Record = { + document: { currentScript: { remove() {} } }, + } + context.self = context + runInNewContext(script!.children!, context) + + expect(context.$_TSR).toBeDefined() + return context.$_TSR + } finally { + router.serverSsr?.cleanup() + } +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +test('hydration reconstructs every match context before ancestor head reads the lane', async () => { + const serverBeforeLoad = vi.fn(() => ({ + user: 'server authenticated user', + })) + const serverRootRoute = new BaseRootRoute({}) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/child', + beforeLoad: serverBeforeLoad, + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverChildRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: true, + }) + + const bootstrap = await dehydrateToBootstrap(serverRouter) + + expect(serverBeforeLoad).toHaveBeenCalledTimes(1) + expect(serverRouter.state.matches.at(-1)?.context).toMatchObject({ + user: 'server authenticated user', + }) + let childContextSeenByRootHead: unknown + const rootHead = vi.fn(({ matches }: { matches: Array }) => { + childContextSeenByRootHead = matches[1]?.context + return { meta: [{ title: 'hydrated' }] } + }) + const clientBeforeLoad = vi.fn(() => ({ user: 'client fallback' })) + const rootRoute = new BaseRootRoute({ + head: rootHead, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + beforeLoad: clientBeforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: false, + }) + + vi.stubGlobal('window', { $_TSR: bootstrap }) + + await hydrate(router) + + expect(childContextSeenByRootHead).toMatchObject({ + user: 'server authenticated user', + }) + expect(clientBeforeLoad).not.toHaveBeenCalled() + expect(rootHead).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/child') + expect(router.state.resolvedLocation?.pathname).toBe('/child') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: childRoute.id, + context: { user: 'server authenticated user' }, + }) + expect(router.state.matches[0]?.meta).toEqual([{ title: 'hydrated' }]) +}) diff --git a/packages/router-core/tests/hydration-boundary-chunks.test.ts b/packages/router-core/tests/hydration-boundary-chunks.test.ts new file mode 100644 index 0000000000..0e7d0eb1cd --- /dev/null +++ b/packages/router-core/tests/hydration-boundary-chunks.test.ts @@ -0,0 +1,209 @@ +import { runInNewContext } from 'node:vm' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, isNotFound, notFound } from '../src' +import { hydrate } from '../src/ssr/client' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouter } from '../src' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { ServerManifest } from '../src/manifest' + +const testManifest: ServerManifest = { routes: {} } + +async function dehydrateToBootstrap(router: AnyRouter): Promise { + attachRouterServerSsrUtils({ router, manifest: testManifest }) + try { + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + + const context: Record = { + document: { currentScript: { remove() {} } }, + } + context.self = context + runInNewContext(script!.children!, context) + + expect(context.$_TSR).toBeDefined() + return context.$_TSR + } finally { + router.serverSsr?.cleanup() + } +} + +describe('hydration route chunks below a server boundary', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + vi.stubGlobal('window', mockWindow) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + test('hydrates an error boundary without requiring its normal component chunk', async () => { + const serverError = new Error('server beforeLoad failed') + const serverBeforeLoad = vi.fn(() => { + throw serverError + }) + const serverRootRoute = new BaseRootRoute({}) + const serverAppRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/app', + beforeLoad: serverBeforeLoad, + errorComponent: () => null, + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverAppRoute]), + history: createMemoryHistory({ initialEntries: ['/app'] }), + isServer: true, + }) + + const bootstrap = await dehydrateToBootstrap(serverRouter) + + expect(serverBeforeLoad).toHaveBeenCalledTimes(1) + expect(serverRouter.state.location.pathname).toBe('/app') + expect(serverRouter.state.matches.map((match) => match.routeId)).toEqual([ + serverRootRoute.id, + serverAppRoute.id, + ]) + expect(serverRouter.state.matches.at(-1)).toMatchObject({ + status: 'error', + error: serverError, + }) + const transportedError = bootstrap.router?.matches.at(-1)?.e + expect(transportedError).not.toBe(serverError) + expect(transportedError).toMatchObject({ + name: serverError.name, + message: serverError.message, + }) + + const normalChunkError = new Error('normal component chunk unavailable') + const normalComponentPreload = vi.fn(() => Promise.reject(normalChunkError)) + const errorComponentPreload = vi.fn(() => Promise.resolve()) + const clientBeforeLoad = vi.fn(() => { + throw new Error('client beforeLoad should not run') + }) + const NormalComponent = Object.assign(() => null, { + preload: normalComponentPreload, + }) + const ErrorComponent = Object.assign(() => null, { + preload: errorComponentPreload, + }) + + const rootRoute = new BaseRootRoute({}) + const appRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/app', + beforeLoad: clientBeforeLoad, + component: NormalComponent, + errorComponent: ErrorComponent, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([appRoute]), + history: createMemoryHistory({ initialEntries: ['/app'] }), + isServer: false, + }) + mockWindow.$_TSR = bootstrap + + await expect(hydrate(router)).resolves.toBeUndefined() + + const appMatch = router.state.matches.find( + (match) => match.routeId === appRoute.id, + ) + expect(appMatch?.status).toBe('error') + expect(appMatch?.error).toBe(transportedError) + expect(appMatch?.error).toMatchObject({ + name: serverError.name, + message: serverError.message, + }) + expect(router.state.location.pathname).toBe('/app') + expect(router.state.resolvedLocation?.pathname).toBe('/app') + expect(clientBeforeLoad).not.toHaveBeenCalled() + expect(normalComponentPreload).not.toHaveBeenCalled() + expect(errorComponentPreload).toHaveBeenCalledTimes(1) + }) + + test('hydrates an ancestor notFound boundary without loading an omitted descendant chunk', async () => { + const serverRootRoute = new BaseRootRoute({}) + const serverParentRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/parent', + notFoundComponent: () => 'not found', + }) + const serverNotFound = notFound({ routeId: serverParentRoute.id }) + const serverChildLoader = vi.fn(() => { + throw serverNotFound + }) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverParentRoute, + path: '/child', + loader: serverChildLoader, + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverParentRoute.addChildren([serverChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + const bootstrap = await dehydrateToBootstrap(serverRouter) + + expect(serverChildLoader).toHaveBeenCalledTimes(1) + expect(serverRouter.state.matches.map((match) => match.routeId)).toEqual([ + serverRootRoute.id, + serverParentRoute.id, + serverChildRoute.id, + ]) + expect(serverRouter.state.matches[1]?.status).toBe('notFound') + expect(isNotFound(serverRouter.state.matches[1]?.error)).toBe(true) + const childChunkError = new Error('omitted child chunk unavailable') + const childComponentPreload = vi.fn(() => Promise.reject(childChunkError)) + const ChildComponent = Object.assign(() => null, { + preload: childComponentPreload, + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + notFoundComponent: () => 'not found', + }) + const childLoader = vi.fn(() => { + throw new Error('client child loader should not run') + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + component: ChildComponent, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: false, + }) + mockWindow.$_TSR = bootstrap + + await expect(hydrate(router)).resolves.toBeUndefined() + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]) + expect(router.state.matches[1]?.status).toBe('notFound') + expect(isNotFound(router.state.matches[1]?.error)).toBe(true) + expect(router.state.matches[2]?.status).toBe('pending') + expect(router.state.location.pathname).toBe('/parent/child') + expect(router.state.resolvedLocation?.pathname).toBe('/parent/child') + expect(childComponentPreload).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/router-core/tests/hydration-currentness.test.ts b/packages/router-core/tests/hydration-currentness.test.ts new file mode 100644 index 0000000000..8477ccfafd --- /dev/null +++ b/packages/router-core/tests/hydration-currentness.test.ts @@ -0,0 +1,669 @@ +import { runInNewContext } from 'node:vm' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + _getAssetMatches, + createControlledPromise, +} from '../src' +import { hydrate } from '../src/ssr/client' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouteMatch, AnyRouter, NavigateFn } from '../src' +import type { DehydratedRouter, TsrSsrGlobal } from '../src/ssr/types' +import type { ServerManifest } from '../src/manifest' + +const testManifest: ServerManifest = { routes: {} } + +async function dehydrateToBootstrap(router: AnyRouter): Promise { + attachRouterServerSsrUtils({ router, manifest: testManifest }) + try { + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + + const context: Record = { + document: { currentScript: { remove() {} } }, + } + context.self = context + runInNewContext(script!.children!, context) + + expect(context.$_TSR).toBeDefined() + return context.$_TSR + } finally { + router.serverSsr?.cleanup() + } +} + +// These tests install the hydration protocol directly so client-only hooks can +// be paused at deterministic ownership boundaries. +function installHydrationPayload( + mockWindow: { $_TSR?: TsrSsrGlobal }, + matches: DehydratedRouter['matches'], +) { + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches, + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } +} + +/** + * Hydration asset work is another private lane generation. If a public + * navigation commits while an old hydration head is pending, the hydration + * continuation must not execute more old-lane hooks or mark its captured + * location as resolved. + */ +describe('hydration asset currentness', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + vi.stubGlobal('window', mockWindow) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + test('shell hydration preserves the complete lane and its first pending boundary', async () => { + const childLoader = vi.fn(() => 'client data') + const rootRoute = new BaseRootRoute({}) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + ssr: false, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + ssr: true, + u: Date.now(), + }, + ]) + + await hydrate(router) + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + childRoute.id, + ]) + expect(router.state.matches[1]).toMatchObject({ + routeId: childRoute.id, + status: 'pending', + ssr: false, + }) + expect(router.state.resolvedLocation).toBeUndefined() + expect(childLoader).not.toHaveBeenCalled() + }) + + test('includes an identity-verified ssr:false boundary in a data-only asset prefix', async () => { + const serverChildLoader = vi.fn(() => 'server child data') + const serverRootRoute = new BaseRootRoute({}) + const serverParentRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/parent', + ssr: 'data-only', + }) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverParentRoute, + path: '/child', + ssr: false, + loader: serverChildLoader, + }) + const serverTailRoute = new BaseRoute({ + getParentRoute: () => serverChildRoute, + path: '/tail', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverParentRoute.addChildren([ + serverChildRoute.addChildren([serverTailRoute]), + ]), + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/child/tail'], + }), + isServer: true, + }) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + expect(serverChildLoader).not.toHaveBeenCalled() + + const childContext = vi.fn(() => ({ assetSource: 'child context' })) + const childHead = vi.fn(({ match }: { match: AnyRouteMatch }) => ({ + meta: [ + { + name: 'ssr-false-boundary', + content: (match.context as { assetSource?: string }).assetSource, + }, + ], + })) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + ssr: 'data-only', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + ssr: false, + loader: () => 'client child data', + context: childContext, + head: childHead, + }) + const tailRoute = new BaseRoute({ + getParentRoute: () => childRoute, + path: '/tail', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute.addChildren([tailRoute])]), + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/child/tail'], + }), + isServer: false, + }) + + await hydrate(router) + + expect( + _getAssetMatches(router.state.matches).map((match) => match.routeId), + ).toEqual([rootRoute.id, parentRoute.id, childRoute.id]) + expect(childContext).toHaveBeenCalledOnce() + expect(childHead).toHaveBeenCalledOnce() + expect(router.state.matches[2]?.meta).toEqual([ + { name: 'ssr-false-boundary', content: 'child context' }, + ]) + }) + + test('preserves a verified data-only asset prefix through its client continuation', async () => { + const serverRootRoute = new BaseRootRoute({}) + const serverParentRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/parent', + ssr: 'data-only', + }) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverParentRoute, + path: '/child', + ssr: false, + loader: () => 'server child data', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverParentRoute.addChildren([serverChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + + const childLoaderResult = createControlledPromise() + let continuationAssetEnd: number | undefined + let router!: AnyRouter + const childLoader = vi.fn(() => { + continuationAssetEnd = router._tx?.[3][1]?._assetEnd + return childLoaderResult + }) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + ssr: 'data-only', + pendingComponent: () => null, + pendingMs: 0, + pendingMinMs: 0, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + ssr: false, + loader: childLoader, + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: false, + }) + + await hydrate(router) + const load = router.load() + + await vi.waitFor(() => { + expect(childLoader).toHaveBeenCalledOnce() + expect(router.state.status).toBe('pending') + }) + expect(continuationAssetEnd).toBe(3) + expect( + _getAssetMatches(router.state.matches).map((match) => match.routeId), + ).toEqual([rootRoute.id, parentRoute.id, childRoute.id]) + expect(router.state.matches[1]?._assetEnd).toBe(3) + + childLoaderResult.resolve('client child data') + await load + + expect(router.state.matches[1]?._assetEnd).toBeUndefined() + expect(router.state.matches[2]).toMatchObject({ + status: 'success', + loaderData: 'client child data', + }) + }) + + test('settles when navigation supersedes a pending custom hydrate hook', async () => { + const hydrateGate = createControlledPromise() + const hydrateStarted = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + router.options.hydrate = () => { + hydrateStarted.resolve() + return hydrateGate + } + const oldMatches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + oldMatches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + ) + + const hydration = hydrate(router) + try { + await hydrateStarted + await router.navigate({ to: '/new' }) + await expect(hydration).resolves.toBeUndefined() + + expect(router.state.resolvedLocation?.pathname).toBe('/new') + expect(router.state.matches.at(-1)?.routeId).toBe(newRoute.id) + } finally { + hydrateGate.resolve() + await hydration + } + }) + + test('does not publish an old hydration lane after a newer navigation commits', async () => { + const oldChunkGate = createControlledPromise() + const oldChunkStarted = createControlledPromise() + const OldComponent = Object.assign(() => null, { + preload: () => { + oldChunkStarted.resolve() + return oldChunkGate + }, + }) + const rootRoute = new BaseRootRoute({}) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + component: OldComponent, + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + const oldMatches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + oldMatches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + ) + + const hydration = hydrate(router) + try { + await oldChunkStarted + await router.navigate({ to: '/new' }) + + expect(router.state.resolvedLocation?.pathname).toBe('/new') + expect(router.state.matches.at(-1)?.routeId).toBe(newRoute.id) + + await hydration + + expect(router.state.location.pathname).toBe('/new') + expect(router.state.resolvedLocation?.pathname).toBe('/new') + expect(router.state.matches.at(-1)?.routeId).toBe(newRoute.id) + } finally { + oldChunkGate.resolve() + await hydration + } + }) + + test('an aborted hydration handoff falls back to a fresh client continuation', async () => { + let hydrationController: AbortController | undefined + const childLoader = vi.fn(() => 'client data') + const rootRoute = new BaseRootRoute({ + context: ({ abortController }: { abortController: AbortController }) => { + hydrationController ??= abortController + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + ssr: false, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'pending', + ssr: false, + u: Date.now(), + }, + ]) + + await hydrate(router) + expect(hydrationController).toBeDefined() + + const unsubscribe = router.subscribe('onBeforeLoad', () => { + hydrationController!.abort() + }) + try { + await router.load() + } finally { + unsubscribe() + } + + expect(router.state.resolvedLocation?.pathname).toBe('/child') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: childRoute.id, + status: 'success', + loaderData: 'client data', + }) + expect(childLoader).toHaveBeenCalledTimes(1) + }) + + test('failed HMR restores a partial hydration presentation and handoff', async () => { + let hydrationController: AbortController | undefined + const childLoader = vi.fn(() => 'client child data') + const rootRoute = new BaseRootRoute({ + context: ({ abortController }: { abortController: AbortController }) => { + hydrationController ??= abortController + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + ssr: false, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + ssr: true, + u: Date.now(), + }, + ]) + + await hydrate(router) + const handoff = router._handoff + const startTransition = router.startTransition + router.startTransition = async (fn) => { + fn() + throw new Error('HMR render failed') + } + + await router._refreshRoute!() + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + childRoute.id, + ]) + expect(router.state.matches[1]).toMatchObject({ + status: 'pending', + ssr: false, + }) + expect(router._handoff).toBe(handoff) + expect(hydrationController?.signal.aborted).toBe(false) + + router.startTransition = startTransition + await router.load() + expect(router.state.matches[1]).toMatchObject({ + status: 'success', + loaderData: 'client child data', + }) + expect(childLoader).toHaveBeenCalledTimes(2) + }) + + test('a synchronous context navigation prevents stale hydration assets from running', async () => { + const oldHead = vi.fn(() => ({ meta: [{ title: 'Old route' }] })) + const oldScripts = vi.fn(() => [ + { children: 'window.oldHydrationLaneRan = true' }, + ]) + const newHead = vi.fn(() => ({ meta: [{ title: 'New route' }] })) + + let navigation: Promise | undefined + let navigateDuringHydration = false + const navigationStarted = vi.fn() + const oldContext = vi.fn(({ navigate }: { navigate: NavigateFn }) => { + if (navigateDuringHydration) { + navigateDuringHydration = false + navigationStarted() + navigation = navigate({ to: '/new' }) + } + return { source: 'old' } + }) + const rootRoute = new BaseRootRoute({}) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + context: oldContext, + head: oldHead, + scripts: oldScripts, + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + head: newHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + const oldMatches = router.matchRoutes(router.stores.location.get()) + + installHydrationPayload( + mockWindow, + oldMatches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + ) + + navigateDuringHydration = true + await hydrate(router) + expect(navigationStarted).toHaveBeenCalled() + expect(navigation).toBeDefined() + await navigation + + expect(router.state.location.pathname).toBe('/new') + expect(router.state.resolvedLocation?.pathname).toBe('/new') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: newRoute.id, + status: 'success', + meta: [{ title: 'New route' }], + }) + expect(oldContext).toHaveBeenCalled() + expect(oldHead).not.toHaveBeenCalled() + expect(oldScripts).not.toHaveBeenCalled() + expect(newHead).toHaveBeenCalled() + }) + + test('an adopted data-only error does not run its server-omitted descendants', async () => { + const serverError = new Error('server failed') + const serverBeforeLoad = vi.fn(() => { + throw serverError + }) + const serverRootRoute = new BaseRootRoute({}) + const serverParentRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/parent', + ssr: 'data-only', + beforeLoad: serverBeforeLoad, + }) + const serverChildLoader = vi.fn() + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverParentRoute, + path: '/child', + loader: serverChildLoader, + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverParentRoute.addChildren([serverChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + const bootstrap = await dehydrateToBootstrap(serverRouter) + + expect(serverBeforeLoad).toHaveBeenCalledTimes(1) + expect(serverChildLoader).not.toHaveBeenCalled() + expect(serverRouter.state.matches.map((match) => match.routeId)).toEqual([ + serverRootRoute.id, + serverParentRoute.id, + serverChildRoute.id, + ]) + expect(serverRouter.state.matches[1]).toMatchObject({ + routeId: serverParentRoute.id, + status: 'error', + ssr: 'data-only', + error: serverError, + }) + const transportedError = bootstrap.router?.matches.at(-1)?.e + expect(transportedError).not.toBe(serverError) + expect(transportedError).toMatchObject({ + name: serverError.name, + message: serverError.message, + }) + + const parentBeforeLoad = vi.fn() + const childBeforeLoad = vi.fn() + const childLoader = vi.fn() + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + ssr: 'data-only', + beforeLoad: parentBeforeLoad, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: childBeforeLoad, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: false, + }) + mockWindow.$_TSR = bootstrap + + await hydrate(router) + + 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', + error: transportedError, + }) + expect(parentBeforeLoad).not.toHaveBeenCalled() + expect(childBeforeLoad).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + expect( + _getAssetMatches(router.state.matches).map((match) => match.routeId), + ).toEqual([rootRoute.id, parentRoute.id]) + + await router.load() + + expect(router.state.location.pathname).toBe('/parent/child') + expect(router.state.resolvedLocation?.pathname).toBe('/parent/child') + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]) + expect(router.state.matches[1]).toMatchObject({ + status: 'error', + error: transportedError, + }) + expect(router.state.matches[1]?.error).toMatchObject({ + name: serverError.name, + message: serverError.message, + }) + expect(parentBeforeLoad).not.toHaveBeenCalled() + expect(childBeforeLoad).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/router-core/tests/hydration-terminal-error-child-head.test.ts b/packages/router-core/tests/hydration-terminal-error-child-head.test.ts new file mode 100644 index 0000000000..0e607f7a1c --- /dev/null +++ b/packages/router-core/tests/hydration-terminal-error-child-head.test.ts @@ -0,0 +1,175 @@ +import { runInNewContext } from 'node:vm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { hydrate } from '../src/ssr/client' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouter } from '../src' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { ServerManifest } from '../src/manifest' + +const testManifest: ServerManifest = { routes: {} } + +async function dehydrateToBootstrap(router: AnyRouter): Promise { + attachRouterServerSsrUtils({ router, manifest: testManifest }) + try { + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + + const context: Record = { + document: { currentScript: { remove() {} } }, + } + context.self = context + runInNewContext(script!.children!, context) + + expect(context.$_TSR).toBeDefined() + return context.$_TSR + } finally { + router.serverSsr?.cleanup() + } +} + +// This terminal-prefix case is an internal hydration protocol invariant, not +// an issue #7635 reproduction. The user-visible same-route navigation +// regression is covered in react-router, where the rendered boundary and +// document title can be observed directly. +describe('hydrated terminal error prefix', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + vi.stubGlobal('window', mockWindow) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('adopts the terminal prefix without running omitted child work', async () => { + const serverError = new Error('App beforeLoad failed') + const serverBeforeLoad = vi.fn(() => { + throw serverError + }) + const serverChildLoader = vi.fn(() => ({ child: 'server data' })) + const serverRootRoute = new BaseRootRoute({}) + const serverAppRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/app', + beforeLoad: serverBeforeLoad, + errorComponent: () => 'App error', + }) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverAppRoute, + path: '/child', + loader: serverChildLoader, + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverAppRoute.addChildren([serverChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/app/child'] }), + isServer: true, + }) + + const bootstrap = await dehydrateToBootstrap(serverRouter) + + expect(serverBeforeLoad).toHaveBeenCalledTimes(1) + expect(serverChildLoader).not.toHaveBeenCalled() + expect(serverRouter.state.matches.map((match) => match.routeId)).toEqual([ + serverRootRoute.id, + serverAppRoute.id, + serverChildRoute.id, + ]) + expect(serverRouter.state.matches[1]).toMatchObject({ + routeId: serverAppRoute.id, + status: 'error', + error: serverError, + }) + const transportedError = bootstrap.router?.matches.at(-1)?.e + expect(transportedError).not.toBe(serverError) + expect(transportedError).toMatchObject({ + name: serverError.name, + message: serverError.message, + }) + + const appHead = vi.fn(({ match }: any) => ({ + meta: [{ title: match.error ? 'App error title' : 'App success title' }], + })) + const childHead = vi.fn(() => ({ + meta: [{ title: 'Child success title' }], + })) + const childLoader = vi.fn(() => ({ child: 'data' })) + + const rootRoute = new BaseRootRoute({}) + const appRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/app', + head: appHead, + errorComponent: () => 'App error', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => appRoute, + path: '/child', + loader: childLoader, + head: childHead, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([appRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/app/child'] }), + isServer: false, + }) + + // Route matching confirms that the location has a child suffix which the + // terminal server payload omits. + const matches = router.matchRoutes(router.stores.location.get()) + expect(matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + appRoute.id, + childRoute.id, + ]) + mockWindow.$_TSR = bootstrap + + await hydrate(router) + + // Hydration reconstructs the full matched branch while adopting only the + // terminal prefix represented by the payload. + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + appRoute.id, + childRoute.id, + ]) + expect(router.state.matches[2]?.status).toBe('pending') + + const committedApp = router.state.matches.find( + (match) => match.routeId === appRoute.id, + ) + expect(router.state.location.pathname).toBe('/app/child') + expect(router.state.isLoading).toBe(false) + expect(committedApp?.status).toBe('error') + expect(committedApp?.error).toBe(transportedError) + + // No callback for the omitted suffix may run while adopting the prefix. + expect(childLoader).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + + // Core projects the error match's metadata; document ownership is covered + // by the framework-level test. + expect(appHead).toHaveBeenCalledOnce() + expect(appHead).toHaveBeenCalledWith( + expect.objectContaining({ + match: expect.objectContaining({ + routeId: appRoute.id, + status: 'error', + error: transportedError, + }), + }), + ) + expect(committedApp?.meta).toEqual([{ title: 'App error title' }]) + }) +}) diff --git a/packages/router-core/tests/invalidate-pre-rematch-failure.test.ts b/packages/router-core/tests/invalidate-pre-rematch-failure.test.ts new file mode 100644 index 0000000000..084fbe8d31 --- /dev/null +++ b/packages/router-core/tests/invalidate-pre-rematch-failure.test.ts @@ -0,0 +1,63 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * A planning failure is a route error generation and leaves the router usable + * for a later retry. + */ +test('a fatal pre-rematch failure publishes an error and permits retry', async () => { + const boom = new Error('loaderDeps failed during invalidation rematch') + let failLoaderDeps = false + const loaderSignals: Array = [] + + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loaderDeps: () => { + if (failLoaderDeps) { + throw boom + } + return {} + }, + loader: ({ abortController }) => { + loaderSignals.push(abortController.signal) + return 'target data' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + + await router.load() + const activeSignal = loaderSignals[0] + expect(router.state.matches.at(-1)?.status).toBe('success') + + failLoaderDeps = true + await router.invalidate({ forcePending: true }) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'error', + error: boom, + }) + expect(loaderSignals).toHaveLength(1) + expect(activeSignal?.aborted).toBe(true) + + failLoaderDeps = false + await router.invalidate() + const retrySignal = loaderSignals[1] + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + loaderData: 'target data', + }) + expect(loaderSignals).toHaveLength(2) + expect(retrySignal).not.toBe(activeSignal) + expect(activeSignal?.aborted).toBe(true) + expect(retrySignal?.aborted).toBe(false) +}) diff --git a/packages/router-core/tests/issue-3179-preload-cached-cause.test.ts b/packages/router-core/tests/issue-3179-preload-cached-cause.test.ts new file mode 100644 index 0000000000..c465961dc2 --- /dev/null +++ b/packages/router-core/tests/issue-3179-preload-cached-cause.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/3179 +test('#3179: preloading the active route does not replay its load callbacks', async () => { + const calls: Array<{ + phase: 'beforeLoad' | 'loader' + cause: string + preload: boolean + }> = [] + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + staleTime: 0, + preloadStaleTime: 0, + beforeLoad: ({ cause, preload }) => { + calls.push({ phase: 'beforeLoad', cause, preload }) + }, + loader: ({ cause, preload }) => { + calls.push({ phase: 'loader', cause, preload }) + }, + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/about'] }), + }) + + await router.load() + + // First hover Home from About, then click it. This is the sequence from + // the issue that used to cache the navigation's flags on the match. + await router.preloadRoute({ to: '/' }) + expect(calls).toEqual([ + { phase: 'beforeLoad', cause: 'preload', preload: true }, + { phase: 'loader', cause: 'preload', preload: true }, + ]) + + calls.length = 0 + await router.navigate({ to: '/' }) + expect(calls).toEqual([ + { phase: 'beforeLoad', cause: 'enter', preload: false }, + { phase: 'loader', cause: 'enter', preload: false }, + ]) + + // beforeLoad is contextualization, not loader cache data, so a later preload + // reruns it with fresh flags while retaining the accepted loader generation. + calls.length = 0 + await router.preloadRoute({ to: '/' }) + expect(calls).toEqual([ + { phase: 'beforeLoad', cause: 'preload', preload: true }, + ]) +}) diff --git a/packages/router-core/tests/issue-3920-on-before-navigate-after-initial-redirect.test.ts b/packages/router-core/tests/issue-3920-on-before-navigate-after-initial-redirect.test.ts new file mode 100644 index 0000000000..f0de5df8dd --- /dev/null +++ b/packages/router-core/tests/issue-3920-on-before-navigate-after-initial-redirect.test.ts @@ -0,0 +1,40 @@ +import { createMemoryHistory } from '@tanstack/history' +import { expect, test } from 'vitest' +import { BaseRootRoute, BaseRoute, redirect } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/3920 +test('onBeforeNavigate fires after the initial load redirects', async () => { + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + beforeLoad: () => { + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const thirdRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/third', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute, thirdRoute]), + history: createMemoryHistory({ initialEntries: ['/source'] }), + }) + + await router.load() + expect(router.state.location.pathname).toBe('/target') + + const navigatedPaths: Array = [] + router.subscribe('onBeforeNavigate', (event) => { + navigatedPaths.push(event.toLocation.pathname) + }) + + await router.navigate({ to: '/third' }) + + expect(navigatedPaths).toEqual(['/third']) +}) diff --git a/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts b/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts new file mode 100644 index 0000000000..313ef234a0 --- /dev/null +++ b/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +type LoaderInvocation = { + resolve: (value: string) => void + signal: AbortSignal +} + +const createAbortableInvocation = ( + abortController: AbortController, + onAbort?: () => void, +) => { + let resolve!: (value: string) => void + const promise = new Promise((promiseResolve, reject) => { + const handleAbort = () => { + onAbort?.() + reject(new DOMException('signal is aborted without reason', 'AbortError')) + } + resolve = (value) => { + abortController.signal.removeEventListener('abort', handleAbort) + promiseResolve(value) + } + if (abortController.signal.aborted) { + handleAbort() + } else { + abortController.signal.addEventListener('abort', handleAbort, { + once: true, + }) + } + }) + return { + invocation: { resolve, signal: abortController.signal }, + promise, + } +} + +// https://github.com/TanStack/router/issues/3928 +describe('issue #3928: rapid reloads of a reused parent', () => { + test('an aborted parent execution cannot clear the replacement load', async () => { + const indexAbort = vi.fn() + const rootInvocations = new Map() + const indexInvocations = new Map() + + const rootRoute = new BaseRootRoute({ + shouldReload: true, + loader: ({ abortController, location }) => { + const { invocation, promise } = + createAbortableInvocation(abortController) + const search = location.search as Record + const filter = typeof search.filter === 'string' ? search.filter : '' + rootInvocations.set(filter, invocation) + return promise + }, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (search: Record) => ({ + filter: typeof search.filter === 'string' ? search.filter : '', + }), + loaderDeps: ({ search }: { search: { filter: string } }) => ({ + filter: search.filter, + }), + loader: ({ + deps, + abortController, + }: { + deps: { filter: string } + abortController: AbortController + }) => { + const { invocation, promise } = createAbortableInvocation( + abortController, + indexAbort, + ) + indexInvocations.set(deps.filter, invocation) + return promise + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const initialLoad = router.load() + await vi.waitFor(() => { + expect(rootInvocations.has('')).toBe(true) + expect(indexInvocations.has('')).toBe(true) + }) + rootInvocations.get('')!.resolve('root:initial') + indexInvocations.get('')!.resolve('') + await initialLoad + + const navA = router.navigate({ to: '/', search: { filter: 'a' } }) + await vi.waitFor(() => { + expect(rootInvocations.has('a')).toBe(true) + expect(indexInvocations.has('a')).toBe(true) + }) + + const navAB = router.navigate({ to: '/', search: { filter: 'ab' } }) + await vi.waitFor(() => { + expect(rootInvocations.has('ab')).toBe(true) + expect(indexInvocations.has('ab')).toBe(true) + expect(indexInvocations.get('a')!.signal.aborted).toBe(true) + }) + + const navABC = router.navigate({ to: '/', search: { filter: 'abc' } }) + await vi.waitFor(() => { + expect(rootInvocations.has('abc')).toBe(true) + expect(indexInvocations.has('abc')).toBe(true) + expect(indexInvocations.get('ab')!.signal.aborted).toBe(true) + }) + + rootInvocations.get('abc')!.resolve('root:abc') + indexInvocations.get('abc')!.resolve('abc') + await Promise.all([navA, navAB, navABC]) + + expect(indexAbort).toHaveBeenCalledTimes(2) + expect(rootInvocations.get('abc')!.signal.aborted).toBe(false) + expect(indexInvocations.get('abc')!.signal.aborted).toBe(false) + expect(router.state.location.search).toEqual({ filter: 'abc' }) + expect(router.state.matches[0]).toMatchObject({ + status: 'success', + loaderData: 'root:abc', + }) + expect(router.state.matches[1]).toMatchObject({ + status: 'success', + loaderData: 'abc', + }) + expect(router.state.matches.some((match) => match.status === 'error')).toBe( + false, + ) + }) +}) diff --git a/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts b/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts index 45085a8e1f..66013c536a 100644 --- a/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts +++ b/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts @@ -18,7 +18,7 @@ import { createTestRouter } from './routerTestUtils' // root component + notFoundComponent, but a loader-thrown notFound did not. // // At Router Core's boundary, root attribution is represented by the root match -// with globalNotFound. These assertions do not distinguish the +// with _notFound. These assertions do not distinguish the // configured root notFoundComponent from the router default; that reported // rendering behavior requires framework-level coverage. describe('#4078 / #2255 existing Core root-boundary attribution', () => { @@ -63,7 +63,7 @@ describe('#4078 / #2255 existing Core root-boundary attribution', () => { return { routeId: rootMatch?.routeId, - globalNotFound: rootMatch?.globalNotFound, + _notFound: rootMatch?._notFound, } } @@ -84,7 +84,7 @@ describe('#4078 / #2255 existing Core root-boundary attribution', () => { expect(router.state.location.pathname).toBe('/about') expect(getRootBoundaryProjection(router)).toEqual({ routeId: rootRouteId, - globalNotFound: true, + _notFound: true, }) }) @@ -94,7 +94,7 @@ describe('#4078 / #2255 existing Core root-boundary attribution', () => { const unmatchedProjection = getRootBoundaryProjection(unmatched.router) expect(unmatchedProjection).toEqual({ routeId: rootRouteId, - globalNotFound: true, + _notFound: true, }) const loaderNotFound = setup(['/']) diff --git a/packages/router-core/tests/issue-4444-param-parse-error-lazy-child.test.ts b/packages/router-core/tests/issue-4444-param-parse-error-lazy-child.test.ts new file mode 100644 index 0000000000..9a996f9e4a --- /dev/null +++ b/packages/router-core/tests/issue-4444-param-parse-error-lazy-child.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, PathParamError } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue #4444: when a route's params.parse throws and one of its children is + * lazily loaded, the router used to stay "pending" forever: the lazy child's + * chunk was never requested, its match never settled, and framework + * transitioners could never reach idle. + * + * Desired behavior: the param parse error is a serial failure — the failing + * route commits status 'error' for its error boundary, descendants below the + * boundary are trimmed out of the lane with their load promises settled, and + * router.load() resolves with no match left pending. + */ +describe('issue #4444: param parse error on a route with a lazy child', () => { + test('load settles, commits the error boundary, and leaves no match stuck pending', async () => { + const rootRoute = new BaseRootRoute({}) + const langRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/lang/$lang', + params: { + parse: ({ lang }: { lang: string }) => { + if (lang !== 'en') { + throw new Error(`Unsupported language: ${lang}`) + } + return { lang } + }, + stringify: ({ lang }: { lang: string }) => ({ lang }), + }, + errorComponent: () => null, + }) + const lazyChildRoute = new BaseRoute({ + getParentRoute: () => langRoute, + path: '/lazy', + }) + const lazyFn = vi.fn(() => + Promise.resolve({ + options: { + id: lazyChildRoute.id, + component: () => null, + }, + }), + ) + lazyChildRoute.lazy(lazyFn) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + langRoute.addChildren([lazyChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/lang/es/lazy'] }), + }) + + // The reported bug: this load never reached a settled render state. + await router.load() + expect(router.state.status).toBe('idle') + + // The failing route commits the parse error for its error boundary… + const langMatch = router.state.matches.find( + (match) => match.routeId === langRoute.id, + ) + expect(langMatch?.status).toBe('error') + expect(langMatch?.error).toBeInstanceOf(PathParamError) + + // …while the structurally matched lazy child remains unresolved and hidden + // below that boundary without starting its chunk. + expect( + router.state.matches.find((match) => match.routeId === lazyChildRoute.id), + ).toMatchObject({ status: 'pending', isFetching: false }) + expect(lazyFn).not.toHaveBeenCalled() + expect(router.state.isLoading).toBe(false) + }) +}) diff --git a/packages/router-core/tests/issue-4572-preload-root-beforeload-flags.test.ts b/packages/router-core/tests/issue-4572-preload-root-beforeload-flags.test.ts new file mode 100644 index 0000000000..23495e5001 --- /dev/null +++ b/packages/router-core/tests/issue-4572-preload-root-beforeload-flags.test.ts @@ -0,0 +1,173 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue #4572: an intent preload used to re-trigger active beforeLoad + * callbacks with stale navigation flags. Direct preloadRoute calls provide + * the core-level reduction of the reported Link hover behavior. + * + * A completed beforeLoad result is never cached. Every new preload therefore + * contextualizes its full route lane with fresh preload flags. + */ + +function createPreloadFixture() { + const rootBeforeLoad = vi.fn() + const indexBeforeLoad = vi.fn() + const aboutBeforeLoad = vi.fn() + const bagBeforeLoad = vi.fn() + const nestedBeforeLoad = vi.fn() + + const rootRoute = new BaseRootRoute({ beforeLoad: rootBeforeLoad }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: indexBeforeLoad, + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: aboutBeforeLoad, + }) + const bagRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '_bag', + beforeLoad: bagBeforeLoad, + }) + const nestedRoute = new BaseRoute({ + getParentRoute: () => bagRoute, + path: '/nested', + beforeLoad: nestedBeforeLoad, + }) + + return { + rootBeforeLoad, + indexBeforeLoad, + aboutBeforeLoad, + bagBeforeLoad, + nestedBeforeLoad, + nestedRoute, + router: createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + aboutRoute, + bagRoute.addChildren([nestedRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }), + } +} + +test('#4572: sibling preload contextualizes the full target lane with preload flags', async () => { + const { + rootBeforeLoad, + indexBeforeLoad, + aboutBeforeLoad, + bagBeforeLoad, + nestedBeforeLoad, + router, + } = createPreloadFixture() + + await router.load() + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + expect(rootBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'enter', preload: false }), + ) + + await router.preloadRoute({ to: '/about' }) + expect(rootBeforeLoad).toHaveBeenCalledTimes(2) + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + expect(aboutBeforeLoad).toHaveBeenCalledTimes(1) + expect(aboutBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'preload', preload: true }), + ) + expect(rootBeforeLoad).toHaveBeenLastCalledWith( + expect.objectContaining({ cause: 'preload', preload: true }), + ) + expect(bagBeforeLoad).not.toHaveBeenCalled() + expect(nestedBeforeLoad).not.toHaveBeenCalled() +}) + +test('#4572: active-route preload reruns beforeLoad with preload flags', async () => { + const { + rootBeforeLoad, + indexBeforeLoad, + aboutBeforeLoad, + bagBeforeLoad, + nestedBeforeLoad, + router, + } = createPreloadFixture() + + await router.load() + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + expect(rootBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'enter', preload: false }), + ) + + rootBeforeLoad.mockClear() + indexBeforeLoad.mockClear() + await router.preloadRoute({ to: '/' }) + expect(rootBeforeLoad).toHaveBeenCalledOnce() + expect(indexBeforeLoad).toHaveBeenCalledOnce() + expect(rootBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'preload', preload: true }), + ) + expect(indexBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'preload', preload: true }), + ) + expect(aboutBeforeLoad).not.toHaveBeenCalled() + expect(bagBeforeLoad).not.toHaveBeenCalled() + expect(nestedBeforeLoad).not.toHaveBeenCalled() +}) + +test('#4572: active nested preload reruns its full pathless lane', async () => { + const { + rootBeforeLoad, + indexBeforeLoad, + aboutBeforeLoad, + bagBeforeLoad, + nestedBeforeLoad, + nestedRoute, + router, + } = createPreloadFixture() + + await router.load() + await router.navigate({ to: '/_bag/nested' }) + expect(rootBeforeLoad).toHaveBeenCalledTimes(2) + expect(rootBeforeLoad).toHaveBeenLastCalledWith( + expect.objectContaining({ cause: 'stay', preload: false }), + ) + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + expect(aboutBeforeLoad).not.toHaveBeenCalled() + expect(bagBeforeLoad).toHaveBeenCalledTimes(1) + expect(bagBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'enter', preload: false }), + ) + expect(nestedBeforeLoad).toHaveBeenCalledTimes(1) + expect(nestedBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'enter', preload: false }), + ) + expect( + router.state.matches.some((match) => match.routeId === nestedRoute.id), + ).toBe(true) + rootBeforeLoad.mockClear() + indexBeforeLoad.mockClear() + aboutBeforeLoad.mockClear() + bagBeforeLoad.mockClear() + nestedBeforeLoad.mockClear() + + await router.preloadRoute({ to: '/_bag/nested' }) + expect(rootBeforeLoad).toHaveBeenCalledOnce() + expect(indexBeforeLoad).not.toHaveBeenCalled() + expect(aboutBeforeLoad).not.toHaveBeenCalled() + expect(bagBeforeLoad).toHaveBeenCalledOnce() + expect(nestedBeforeLoad).toHaveBeenCalledOnce() + for (const beforeLoad of [rootBeforeLoad, bagBeforeLoad, nestedBeforeLoad]) { + expect(beforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'preload', preload: true }), + ) + } +}) diff --git a/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts b/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts index 05960c8290..6bf84e75ce 100644 --- a/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts +++ b/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts @@ -1,25 +1,23 @@ import { createMemoryHistory } from '@tanstack/history' -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, test } from 'vitest' import { BaseRootRoute, BaseRoute } from '../src' -import { createTestRouter } from './routerTestUtils' +import { createTestRouter, loadServerResponse } from './routerTestUtils' +// Existing Core projection coverage related to: // https://github.com/TanStack/router/issues/4684 // // When beforeLoad throws, head() must still execute for the retained lane — // the root head owns global stylesheets/title that the errorComponent page // depends on, and the failing route's own head can provide error-specific -// head content. -describe('issue #4684: head executes when beforeLoad throws', () => { +// head content. Rendering those projections through HeadContent is a framework +// concern; this suite asserts the Core-owned match projections. +describe('#4684 existing Core behavior: head executes when beforeLoad throws', () => { const setup = (isServer: boolean) => { const beforeLoadError = new Error('beforeLoad failed') - const rootHead = vi.fn(() => ({ + const rootHead = () => ({ meta: [{ title: 'Root title' }], links: [{ rel: 'stylesheet', href: '/global.css' }], - })) - const failingHead = vi.fn(({ match }: any) => ({ - meta: [{ title: match.error ? 'Error title' : 'Success title' }], - })) - + }) const rootRoute = new BaseRootRoute({ head: rootHead }) const failingRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -27,7 +25,9 @@ describe('issue #4684: head executes when beforeLoad throws', () => { beforeLoad: () => { throw beforeLoadError }, - head: failingHead, + head: ({ match }) => ({ + meta: [{ title: match.error ? 'Error title' : 'Success title' }], + }), errorComponent: () => 'error', }) @@ -37,18 +37,15 @@ describe('issue #4684: head executes when beforeLoad throws', () => { isServer, }) - return { router, rootRoute, failingRoute, rootHead, failingHead } + return { router, rootRoute, failingRoute, beforeLoadError } } test('server load projects root and failing-route heads for the error lane', async () => { - const { router, rootRoute, failingRoute, rootHead, failingHead } = - setup(true) + const { router, rootRoute, failingRoute, beforeLoadError } = setup(true) - await router.load() + const response = await loadServerResponse(router, '/fail') - expect(router.state.statusCode).toBe(500) - expect(rootHead).toHaveBeenCalledTimes(1) - expect(failingHead).toHaveBeenCalledTimes(1) + expect(response.status).toBe(500) const rootMatch = router.state.matches.find( (match) => match.routeId === rootRoute.id, @@ -61,18 +58,15 @@ describe('issue #4684: head executes when beforeLoad throws', () => { { rel: 'stylesheet', href: '/global.css' }, ]) expect(failingMatch?.status).toBe('error') + expect(failingMatch?.error).toBe(beforeLoadError) expect(failingMatch?.meta).toEqual([{ title: 'Error title' }]) }) test('client load projects root and failing-route heads for the error lane', async () => { - const { router, rootRoute, failingRoute, rootHead, failingHead } = - setup(false) + const { router, rootRoute, failingRoute, beforeLoadError } = setup(false) await router.load() - expect(rootHead).toHaveBeenCalledTimes(1) - expect(failingHead).toHaveBeenCalledTimes(1) - const rootMatch = router.state.matches.find( (match) => match.routeId === rootRoute.id, ) @@ -80,7 +74,11 @@ describe('issue #4684: head executes when beforeLoad throws', () => { (match) => match.routeId === failingRoute.id, ) expect(rootMatch?.meta).toEqual([{ title: 'Root title' }]) + expect(rootMatch?.links).toEqual([ + { rel: 'stylesheet', href: '/global.css' }, + ]) expect(failingMatch?.status).toBe('error') + expect(failingMatch?.error).toBe(beforeLoadError) expect(failingMatch?.meta).toEqual([{ title: 'Error title' }]) }) }) diff --git a/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts b/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts index b9e9a637d8..5ee450ee35 100644 --- a/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts +++ b/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts @@ -9,10 +9,11 @@ import type { Manifest } from '../src/manifest' const testManifest: Manifest = { routes: {} } -// Supplemental Core state coverage for +// The child-owned case is supplemental Core state coverage for // https://github.com/TanStack/router/issues/5106. The reported rendered React -// hydration symptom is covered by the React Start E2E test. -describe('hydrated child-owned notFound boundary coverage', () => { +// hydration symptom is not observable at this layer. The ancestor-owned case +// is a generic terminal-prefix invariant, not an issue #5106 reproduction. +describe('hydrated notFound boundary coverage', () => { let mockWindow: { $_TSR?: TsrSsrGlobal } beforeEach(() => { @@ -149,4 +150,125 @@ describe('hydrated child-owned notFound boundary coverage', () => { expect(postsLoader).not.toHaveBeenCalled() expect(postLoader).not.toHaveBeenCalled() }) + + it('generic terminal-prefix invariant: adopts an ancestor-owned boundary without presenting the omitted child', async () => { + const postsLoader = vi.fn(() => 'posts-data') + const postLoader = vi.fn(() => 'post-data') + const history = createMemoryHistory({ + initialEntries: ['/posts/i-do-not-exist'], + }) + + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: postsLoader, + component: () => 'Posts', + notFoundComponent: () => 'Posts not found', + }) + // The child has no notFoundComponent, so the synthetic payload below + // represents the parent as the selected boundary and omits the child. + const postRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/$postId', + loader: postLoader, + component: () => 'Post', + }) + const safeLoader = vi.fn(() => 'safe-data') + const safeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/safe', + loader: safeLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + postsRoute.addChildren([postRoute]), + safeRoute, + ]), + history, + isServer: false, + }) + + const matches = router.matchRoutes(router.stores.location.get()) + expect(matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + postsRoute.id, + postRoute.id, + ]) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + // The represented terminal lane is capped at the parent boundary. + matches: [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'notFound' as const, + l: 'posts-data', + e: { isNotFound: true }, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + + // Hydration reconstructs the complete branch but executes only the + // represented terminal prefix. + const stateMatches = router.state.matches + expect(router.state.location.pathname).toBe('/posts/i-do-not-exist') + expect(router.state.isLoading).toBe(false) + expect(stateMatches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + postsRoute.id, + postRoute.id, + ]) + expect(stateMatches[1]!.routeId).toBe(postsRoute.id) + expect(stateMatches[1]!.status).toBe('notFound') + expect(isNotFound(stateMatches[1]!.error)).toBe(true) + // The boundary keeps the parent loader data supplied by the payload. + expect(stateMatches[1]!.loaderData).toBe('posts-data') + expect(stateMatches[2]).toMatchObject({ + routeId: postRoute.id, + status: 'pending', + }) + + // Neither the omitted child nor the represented parent runs client-side. + expect(postLoader).not.toHaveBeenCalled() + expect(postsLoader).not.toHaveBeenCalled() + expect(safeLoader).not.toHaveBeenCalled() + + // A later client navigation remains usable after adopting the prefix. + await router.navigate({ to: '/safe' }) + expect(router.state.location.pathname).toBe('/safe') + expect(router.state.isLoading).toBe(false) + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + safeRoute.id, + ]) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: safeRoute.id, + status: 'success', + loaderData: 'safe-data', + }) + expect(safeLoader).toHaveBeenCalledTimes(1) + expect(postLoader).not.toHaveBeenCalled() + expect(postsLoader).not.toHaveBeenCalled() + }) }) diff --git a/packages/router-core/tests/issue-5427-hydration-root-global-not-found.test.ts b/packages/router-core/tests/issue-5427-hydration-root-global-not-found.test.ts new file mode 100644 index 0000000000..97d987fcc6 --- /dev/null +++ b/packages/router-core/tests/issue-5427-hydration-root-global-not-found.test.ts @@ -0,0 +1,141 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test, vi } from 'vitest' +import { BaseRootRoute, BaseRoute } from '../src' +import { hydrate } from '../src/ssr/client' +import { dehydrateMatch } from '../src/ssr/ssr-server' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' + +function createRouter(initialEntry: string, isServer: boolean) { + const rootRoute = new BaseRootRoute({ + notFoundComponent: () => 'Root not found', + }) + const notFoundRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/404', + component: () => 'Not found route', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([notFoundRoute]), + history: createMemoryHistory({ initialEntries: [initialEntry] }), + isServer, + }) + + return { rootRoute, router } +} + +function createLayoutRouter(initialEntry: string, isServer: boolean) { + const rootRoute = new BaseRootRoute({}) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + notFoundComponent: () => 'Layout not found', + }) + const notFoundRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/404', + }) + const notFoundIndexRoute = new BaseRoute({ + getParentRoute: () => notFoundRoute, + path: '/', + }) + const agentsRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/agents', + }) + const skillAgentRoute = new BaseRoute({ + getParentRoute: () => agentsRoute, + path: '/skill-agent', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + layoutRoute.addChildren([ + notFoundRoute.addChildren([notFoundIndexRoute]), + agentsRoute.addChildren([skillAgentRoute]), + ]), + ]), + history: createMemoryHistory({ initialEntries: [initialEntry] }), + isServer, + }) + + return { layoutRoute, router } +} + +describe('issue #5427: root-only global not-found hydration', () => { + test('resolves the client URL when the dehydrated /404 lane has a child match', async () => { + const { router: serverRouter } = createRouter('/404', true) + await serverRouter.load() + + const bootstrap: TsrSsrGlobal = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: serverRouter.state.matches.map(dehydrateMatch), + }, + h: () => {}, + e: () => {}, + c: () => {}, + p: () => {}, + buffer: [], + } + const previousBootstrap = window.$_TSR + window.$_TSR = bootstrap + + try { + const { rootRoute, router } = createRouter('/missing', false) + + await hydrate(router) + await vi.waitFor(() => { + expect(router.state.resolvedLocation?.pathname).toBe('/missing') + }) + + expect(router.state.matches).toHaveLength(1) + expect(router.state.matches[0]).toMatchObject({ + routeId: rootRoute.id, + _notFound: true, + }) + } finally { + window.$_TSR = previousBootstrap + } + }) + + test('resolves a fuzzy client URL capped by an ancestor layout boundary', async () => { + const { router: serverRouter } = createLayoutRouter('/404', true) + await serverRouter.load() + expect(serverRouter.state.matches).toHaveLength(4) + + const bootstrap: TsrSsrGlobal = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: serverRouter.state.matches.map(dehydrateMatch), + }, + h: () => {}, + e: () => {}, + c: () => {}, + p: () => {}, + buffer: [], + } + const previousBootstrap = window.$_TSR + window.$_TSR = bootstrap + + try { + const { layoutRoute, router } = createLayoutRouter( + '/agents/missing', + false, + ) + + await hydrate(router) + + expect(router.state.resolvedLocation?.pathname).toBe('/agents/missing') + expect(router.state.matches).toHaveLength(3) + expect(router.state.matches[1]).toMatchObject({ + routeId: layoutRoute.id, + _notFound: true, + }) + } finally { + window.$_TSR = previousBootstrap + } + }) +}) diff --git a/packages/router-core/tests/issue-6107-preload-chunk-failure.test.ts b/packages/router-core/tests/issue-6107-preload-chunk-failure.test.ts new file mode 100644 index 0000000000..32539b3676 --- /dev/null +++ b/packages/router-core/tests/issue-6107-preload-chunk-failure.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue #6107: going offline and hovering a link (preload intent) made the + * failed dynamic import surface as an unhandled rejection in the console, and + * the error never reached the route's error component. + * + * Desired behavior: + * - a preload whose lazy chunk rejects resolves non-fatally, and + * - the failed chunk is evicted, so the subsequent real navigation retries + * the import and commits the chunk error onto the match (status 'error'), + * which is what framework error components render. + */ +describe('issue #6107: failed dynamic import of a lazy route chunk', () => { + test('preloadRoute failure is non-fatal and navigation retries and commits the chunk error', async () => { + const chunkError = new TypeError( + 'Failed to fetch dynamically imported module: /assets/posts.lazy-abc123.js', + ) + const lazyRoute = vi.fn(() => Promise.reject(chunkError)) + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }).lazy(lazyRoute) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + + await router.preloadRoute({ to: '/posts' }) + expect(lazyRoute).toHaveBeenCalled() + const lazyCallsAfterPreload = lazyRoute.mock.calls.length + + await router.navigate({ to: '/posts' }) + const postsMatch = router.state.matches.find( + (match) => match.routeId === postsRoute.id, + ) + expect(lazyRoute.mock.calls.length).toBeGreaterThan(lazyCallsAfterPreload) + expect(postsMatch?.status).toBe('error') + expect(postsMatch?.error).toBe(chunkError) + }) +}) diff --git a/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts b/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts index 0024ca29a0..55cf11c277 100644 --- a/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts +++ b/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts @@ -1,5 +1,5 @@ import { createMemoryHistory } from '@tanstack/history' -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { BaseRootRoute, BaseRoute, @@ -9,6 +9,13 @@ import { import { createTestRouter } from './routerTestUtils' // https://github.com/TanStack/router/issues/6221 +// +// head() must never compute from loaderData older than the data the lane +// commits. Two reported shapes: +// 1. Revisiting a route whose previous visit ended in notFound — the head +// must see the fresh successful loaderData, not run before the loader. +// 2. Revisiting a cached success match that gets a stale reload — the head +// title must not lag one loaderData run behind. describe('issue #6221: head does not run before loader data is ready', () => { test('existing-behavior control: head sees fresh loaderData after a notFound visit', async () => { let authed = false @@ -88,4 +95,68 @@ describe('issue #6221: head does not run before loader data is ready', () => { unsubscribe?.() } }) + + test('head title does not lag one loaderData run behind on revisits', async () => { + const secondLoaderStarted = createControlledPromise() + const secondLoaderResponse = createControlledPromise<{ author: string }>() + let version = 0 + const quoteLoader = () => { + version += 1 + if (version === 2) { + secondLoaderStarted.resolve() + return secondLoaderResponse + } + + return { author: 'author-1' } + } + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const quoteRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/quote', + loader: quoteLoader, + staleTime: 0, + head: ({ loaderData }) => ({ + meta: [{ title: `Quote by ${loaderData?.author ?? '...'}` }], + }), + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, quoteRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const getQuoteMatch = () => + router.state.matches.find((match) => match.routeId === quoteRoute.id) + try { + await router.load() + + await router.navigate({ to: '/quote' }) + expect(getQuoteMatch()?.loaderData).toEqual({ author: 'author-1' }) + expect(getQuoteMatch()?.meta).toEqual([{ title: 'Quote by author-1' }]) + + await router.navigate({ to: '/' }) + const revisit = router.navigate({ to: '/quote' }) + await secondLoaderStarted + await revisit + + // The cached generation remains internally consistent while its stale + // loader is pending in the background. + expect(secondLoaderResponse.status).toBe('pending') + expect(getQuoteMatch()?.loaderData).toEqual({ author: 'author-1' }) + expect(getQuoteMatch()?.meta).toEqual([{ title: 'Quote by author-1' }]) + + secondLoaderResponse.resolve({ author: 'author-2' }) + await vi.waitFor(() => { + expect(getQuoteMatch()?.loaderData).toEqual({ author: 'author-2' }) + expect(getQuoteMatch()?.meta).toEqual([{ title: 'Quote by author-2' }]) + }) + } finally { + secondLoaderResponse.resolve({ author: 'author-2' }) + } + }) }) diff --git a/packages/router-core/tests/issue-6351-fuzzy-notfound-layout.test.ts b/packages/router-core/tests/issue-6351-fuzzy-notfound-layout.test.ts new file mode 100644 index 0000000000..9fec0992a4 --- /dev/null +++ b/packages/router-core/tests/issue-6351-fuzzy-notfound-layout.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * https://github.com/TanStack/router/issues/6351 + * + * A fuzzy (unmatched-URL) 404 must be attributed to the nearest matched + * ancestor that can actually render it: the deepest matched route with + * children that defines a `notFoundComponent`. Attributing it to the + * deepest matched route with children regardless of whether it has a + * `notFoundComponent` skips pathless-layout/ancestor `notFoundComponent`s + * and makes the framework fall back to `defaultNotFoundComponent`. + * + * When no matched route defines a `notFoundComponent`, the previous + * behavior (deepest matched route with children) is preserved, and + * `notFoundMode: 'root'` still attributes the 404 to the root route. + * This Core suite asserts boundary ownership; rendered fallback output needs + * framework-level coverage. + */ + +function setup(opts: { + layoutNotFoundComponent?: () => unknown + agentsNotFoundComponent?: () => unknown + agentsContext?: () => Record + agentsBeforeLoad?: () => Record + agentsLoader?: () => null + notFoundMode?: 'fuzzy' | 'root' + isServer?: boolean +}) { + const rootRoute = new BaseRootRoute({}) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_authenticated', + notFoundComponent: opts.layoutNotFoundComponent, + }) + const agentsRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/agents', + notFoundComponent: opts.agentsNotFoundComponent, + context: opts.agentsContext, + beforeLoad: opts.agentsBeforeLoad, + loader: opts.agentsLoader, + }) + const skillAgentRoute = new BaseRoute({ + getParentRoute: () => agentsRoute, + path: '/skill-agent', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + layoutRoute.addChildren([agentsRoute.addChildren([skillAgentRoute])]), + ]), + history: createMemoryHistory({ initialEntries: ['/agents/skill-agen'] }), + notFoundMode: opts.notFoundMode, + isServer: opts.isServer, + }) + + return { router, rootRoute, layoutRoute, agentsRoute } +} + +function _notFoundRouteIds(router: { + state: { matches: Array<{ routeId: string; _notFound?: boolean }> } +}) { + return router.state.matches + .filter((match) => match._notFound) + .map((match) => match.routeId) +} + +describe('issue #6351: fuzzy notFound honors pathless layout boundaries', () => { + test('lands on the pathless layout when the deeper route has no boundary', async () => { + const { router, rootRoute, layoutRoute, agentsRoute } = setup({ + layoutNotFoundComponent: () => 'layout not found', + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/agents/skill-agen') + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + layoutRoute.id, + agentsRoute.id, + ]) + expect(_notFoundRouteIds(router)).toEqual([layoutRoute.id]) + }) + + test('boundary-enabled control: prefers the deeper matched route', async () => { + const { router, agentsRoute } = setup({ + layoutNotFoundComponent: () => 'layout not found', + agentsNotFoundComponent: () => 'agents not found', + }) + + await router.load() + + expect(_notFoundRouteIds(router)).toEqual([agentsRoute.id]) + }) + + test.each(['client', 'server'] as const)( + 'fuzzy ancestor 404 skips hidden descendant lifecycle callbacks on the %s', + async (environment) => { + const context = vi.fn(() => ({})) + const beforeLoad = vi.fn(() => ({})) + const loader = vi.fn(() => null) + const { router } = setup({ + layoutNotFoundComponent: () => 'layout not found', + agentsContext: context, + agentsBeforeLoad: beforeLoad, + agentsLoader: loader, + notFoundMode: 'fuzzy', + isServer: environment === 'server', + }) + + await router.load() + + expect(context).not.toHaveBeenCalled() + expect(beforeLoad).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + }, + ) +}) + +describe('generic global notFound attribution controls', () => { + test('fuzzy mode falls back to the deepest matched route with children when no boundary exists', async () => { + const { router, agentsRoute } = setup({}) + + await router.load() + + expect(_notFoundRouteIds(router)).toEqual([agentsRoute.id]) + }) + + test('root mode attributes the notFound to root even when a layout boundary exists', async () => { + const { router, rootRoute } = setup({ + layoutNotFoundComponent: () => 'layout not found', + notFoundMode: 'root', + }) + + await router.load() + + expect(_notFoundRouteIds(router)).toEqual([rootRoute.id]) + }) +}) diff --git a/packages/router-core/tests/issue-7379-head-matches-direct-load.test.ts b/packages/router-core/tests/issue-7379-head-matches-direct-load.test.ts new file mode 100644 index 0000000000..1768988e47 --- /dev/null +++ b/packages/router-core/tests/issue-7379-head-matches-direct-load.test.ts @@ -0,0 +1,81 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test } from 'vitest' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/7379 +// +// On a direct (initial) load, head({ matches }) must observe loaderData and +// context on every ancestor match so a breadcrumb-style title can be built. +describe('issue #7379: head({ matches }) has loaderData and context on direct load', () => { + test('initial load executes head after loaders with populated matches', async () => { + const seenMatches: Array< + Array<{ routeId: string; loaderData: unknown; context: unknown }> + > = [] + + const rootRoute = new BaseRootRoute({}) + const nestedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async () => { + await Promise.resolve() + return { section: 'nested-section' } + }, + loader: async () => { + await Promise.resolve() + return { crumb: 'Nested' } + }, + }) + const evenRoute = new BaseRoute({ + getParentRoute: () => nestedRoute, + path: 'even', + loader: async () => { + await Promise.resolve() + return { crumb: 'Even' } + }, + head: ({ matches }) => { + seenMatches.push( + matches.map((match) => ({ + routeId: match.routeId, + loaderData: match.loaderData, + context: match.context, + })), + ) + const crumbs = matches + .map((match) => match.loaderData?.crumb) + .filter(Boolean) + return { + meta: [{ title: [...crumbs, 'Company Inc.'].join(' - ') }], + } + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([nestedRoute.addChildren([evenRoute])]), + history: createMemoryHistory({ initialEntries: ['/nested/even'] }), + }) + + // Direct load: this is the first and only load of the router. + await router.load() + + expect(seenMatches.length).toBeGreaterThan(0) + const seen = seenMatches.at(-1)! + + const seenNested = seen.find((entry) => entry.routeId === nestedRoute.id) + const seenEven = seen.find((entry) => entry.routeId === evenRoute.id) + + // loaderData is present on every match handed to head(). + expect(seenNested?.loaderData).toEqual({ crumb: 'Nested' }) + expect(seenEven?.loaderData).toEqual({ crumb: 'Even' }) + + // context (incl. beforeLoad context) is present as well. + expect(seenNested?.context).toMatchObject({ section: 'nested-section' }) + expect(seenEven?.context).toMatchObject({ section: 'nested-section' }) + + // The breadcrumb title is complete on the committed match. + const evenMatch = router.state.matches.find( + (match) => match.routeId === evenRoute.id, + ) + expect(evenMatch?.meta).toEqual([{ title: 'Nested - Even - Company Inc.' }]) + }) +}) diff --git a/packages/router-core/tests/issue-7602-parent-beforeload-context-child-loader.test.ts b/packages/router-core/tests/issue-7602-parent-beforeload-context-child-loader.test.ts new file mode 100644 index 0000000000..d74976cd86 --- /dev/null +++ b/packages/router-core/tests/issue-7602-parent-beforeload-context-child-loader.test.ts @@ -0,0 +1,109 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/7602 +test('#7602: browser Back republishes a cached child with fresh parent beforeLoad context', async () => { + const loaderContexts: Array<{ + number?: number + generation?: number + }> = [] + const reloadGate = createControlledPromise() + let loaderRuns = 0 + let beforeLoadRuns = 0 + + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reproRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/repro', + beforeLoad: async () => { + await Promise.resolve() + return { number: 42, generation: ++beforeLoadRuns } + }, + }) + const reproIndexRoute = new BaseRoute({ + getParentRoute: () => reproRoute, + path: '/', + loader: async ({ + context, + }: { + context: { number?: number; generation?: number } + }) => { + loaderRuns++ + loaderContexts.push({ + number: context.number, + generation: context.generation, + }) + if (loaderRuns === 2) { + await reloadGate + } + return { visit: loaderRuns } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + homeRoute, + reproRoute.addChildren([reproIndexRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/repro'] }), + }) + const getChildMatch = () => + router.state.matches.find((match) => match.routeId === reproIndexRoute.id) + // RouterProvider's Transitioner installs this subscription in applications. + // It is what turns a browser Back history event into a router load. + const unsubscribe = router.history.subscribe(router.load) + + try { + await router.load() + expect(router.state.location.pathname).toBe('/repro') + expect(getChildMatch()).toMatchObject({ + routeId: reproIndexRoute.id, + status: 'success', + loaderData: { visit: 1 }, + context: { number: 42, generation: 1 }, + }) + expect(loaderContexts).toEqual([{ number: 42, generation: 1 }]) + + await router.navigate({ to: '/' }) + expect(router.state.location.pathname).toBe('/') + expect(router.state.isLoading).toBe(false) + expect(getChildMatch()).toBeUndefined() + + router.history.back() + await vi.waitFor(() => expect(loaderContexts).toHaveLength(2)) + + // The cached success is visible while its stale loader reloads. It must + // never be published with the parent's context contribution missing. + expect(router.state.location.pathname).toBe('/repro') + expect(getChildMatch()).toMatchObject({ + status: 'success', + loaderData: { visit: 1 }, + context: { number: 42, generation: 2 }, + }) + expect(loaderContexts).toEqual([ + { number: 42, generation: 1 }, + { number: 42, generation: 2 }, + ]) + + reloadGate.resolve() + await vi.waitFor(() => + expect(getChildMatch()?.loaderData).toEqual({ visit: 2 }), + ) + expect(router.state.location.pathname).toBe('/repro') + expect(router.state.isLoading).toBe(false) + expect(getChildMatch()?.context).toMatchObject({ + number: 42, + generation: 2, + }) + expect(beforeLoadRuns).toBe(2) + expect(loaderRuns).toBe(2) + } finally { + reloadGate.resolve() + unsubscribe() + } +}) diff --git a/packages/router-core/tests/issue-7759-in-flight-preload-eviction.test.ts b/packages/router-core/tests/issue-7759-in-flight-preload-eviction.test.ts new file mode 100644 index 0000000000..72031499de --- /dev/null +++ b/packages/router-core/tests/issue-7759-in-flight-preload-eviction.test.ts @@ -0,0 +1,44 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('issue #7759: evicting an in-flight preload finishes without an internal error', async () => { + let signalLoaderStarted!: () => void + let resolveLoader!: (value: string) => void + + const loaderStarted = new Promise((resolve) => { + signalLoaderStarted = resolve + }) + const loaderResult = new Promise((resolve) => { + resolveLoader = resolve + }) + + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => { + signalLoaderStarted() + return loaderResult + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory(), + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const preload = router.preloadRoute({ to: '/target' }) + await loaderStarted + + router.clearCache() + resolveLoader('loaded') + + await preload + expect(consoleError).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } +}) diff --git a/packages/router-core/tests/load-client-wait-for.test.ts b/packages/router-core/tests/load-client-wait-for.test.ts new file mode 100644 index 0000000000..5ef9febb59 --- /dev/null +++ b/packages/router-core/tests/load-client-wait-for.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test, vi } from 'vitest' +import { waitForReason } from '../src/await-signal' +import { waitFor } from '../src/load-client' +import { waitForRequest } from '../src/ssr/createRequestHandler' + +describe('waitFor', () => { + test('observes a rejected value when the signal is already aborted', async () => { + const controller = new AbortController() + const error = new Error('late failure') + controller.abort() + + await expect(waitFor(undefined, controller.signal)).rejects.toBe( + controller.signal, + ) + await expect( + waitFor(Promise.reject(error), controller.signal), + ).rejects.toBe(controller.signal) + + await Promise.resolve() + }) + + test('preserves reason-based cancellation and observes a late value', async () => { + const controller = new AbortController() + const reason = new Error('request canceled') + const late = Promise.resolve('late response') + const onLate = vi.fn() + controller.abort(reason) + + await expect(waitForReason(late, controller.signal, onLate)).rejects.toBe( + reason, + ) + await vi.waitFor(() => expect(onLate).toHaveBeenCalledWith('late response')) + }) +}) + +describe('waitForRequest', () => { + test('shares one abort listener across concurrent and sequential waits', async () => { + const controller = new AbortController() + const addEventListener = vi.spyOn(controller.signal, 'addEventListener') + let resolveFirst!: (value: string) => void + let resolveSecond!: (value: string) => void + let resolveThird!: (value: string) => void + const first = new Promise((resolve) => { + resolveFirst = resolve + }) + const second = new Promise((resolve) => { + resolveSecond = resolve + }) + const third = new Promise((resolve) => { + resolveThird = resolve + }) + + const firstResult = waitForRequest(first, controller.signal) + const secondResult = waitForRequest(second, controller.signal) + + expect(addEventListener).toHaveBeenCalledTimes(1) + resolveFirst('first') + resolveSecond('second') + await expect(firstResult).resolves.toBe('first') + await expect(secondResult).resolves.toBe('second') + + const thirdResult = waitForRequest(third, controller.signal) + expect(addEventListener).toHaveBeenCalledTimes(1) + + const reason = new Error('request canceled') + controller.abort(reason) + await expect(thirdResult).rejects.toBe(reason) + resolveThird('third') + await Promise.resolve() + }) + + test('rejects all active waits and observes their late values', async () => { + const controller = new AbortController() + const reason = new Error('request canceled') + const onFirstLate = vi.fn() + const onSecondLate = vi.fn() + let resolveFirst!: (value: string) => void + let resolveSecond!: (value: string) => void + const first = new Promise((resolve) => { + resolveFirst = resolve + }) + const second = new Promise((resolve) => { + resolveSecond = resolve + }) + const firstResult = waitForRequest(first, controller.signal, onFirstLate) + const secondResult = waitForRequest(second, controller.signal, onSecondLate) + + controller.abort(reason) + await expect(firstResult).rejects.toBe(reason) + await expect(secondResult).rejects.toBe(reason) + + resolveFirst('first') + resolveSecond('second') + await vi.waitFor(() => { + expect(onFirstLate).toHaveBeenCalledWith('first') + expect(onSecondLate).toHaveBeenCalledWith('second') + }) + }) + + test('aborts remaining waits after an out-of-order rejection', async () => { + const controller = new AbortController() + const reason = new Error('request canceled') + const failure = new Error('wait failed') + const onLate = vi.fn() + let resolveFirst!: (value: string) => void + let rejectSecond!: (error: Error) => void + let rejectThird!: (error: Error) => void + const first = new Promise((resolve) => { + resolveFirst = resolve + }) + const second = new Promise((_, reject) => { + rejectSecond = reject + }) + const third = new Promise((_, reject) => { + rejectThird = reject + }) + const firstResult = waitForRequest(first, controller.signal, onLate) + const secondResult = waitForRequest(second, controller.signal) + const thirdResult = waitForRequest(third, controller.signal) + + rejectSecond(failure) + await expect(secondResult).rejects.toBe(failure) + + controller.abort(reason) + await expect(firstResult).rejects.toBe(reason) + await expect(thirdResult).rejects.toBe(reason) + + resolveFirst('late') + rejectThird(new Error('late failure')) + await vi.waitFor(() => expect(onLate).toHaveBeenCalledWith('late')) + }) + + test('isolates waits for different request signals', async () => { + const firstController = new AbortController() + const secondController = new AbortController() + const firstReason = new Error('first request canceled') + const firstAddEventListener = vi.spyOn( + firstController.signal, + 'addEventListener', + ) + const secondAddEventListener = vi.spyOn( + secondController.signal, + 'addEventListener', + ) + let resolveFirst!: (value: string) => void + let resolveSecond!: (value: string) => void + const first = new Promise((resolve) => { + resolveFirst = resolve + }) + const second = new Promise((resolve) => { + resolveSecond = resolve + }) + const firstResult = waitForRequest(first, firstController.signal) + const secondResult = waitForRequest(second, secondController.signal) + + expect(firstAddEventListener).toHaveBeenCalledOnce() + expect(secondAddEventListener).toHaveBeenCalledOnce() + + firstController.abort(firstReason) + await expect(firstResult).rejects.toBe(firstReason) + + resolveSecond('second result') + await expect(secondResult).resolves.toBe('second result') + resolveFirst('late first result') + await Promise.resolve() + }) + + test('observes fulfilled and rejected values when already aborted', async () => { + const controller = new AbortController() + const reason = new Error('request canceled') + const lateError = new Error('late failure') + const onLate = vi.fn() + controller.abort(reason) + + await expect( + waitForRequest(Promise.resolve('late'), controller.signal, onLate), + ).rejects.toBe(reason) + await expect( + waitForRequest(Promise.reject(lateError), controller.signal), + ).rejects.toBe(reason) + + await vi.waitFor(() => expect(onLate).toHaveBeenCalledWith('late')) + }) + + test('handles an abort during listener registration', async () => { + const controller = new AbortController() + const reason = new Error('request canceled') + const onLate = vi.fn() + const addEventListener = controller.signal.addEventListener.bind( + controller.signal, + ) + vi.spyOn(controller.signal, 'addEventListener').mockImplementation( + (type, listener, options) => { + addEventListener(type, listener, options) + controller.abort(reason) + }, + ) + let resolve!: (value: string) => void + const value = new Promise((resolveValue) => { + resolve = resolveValue + }) + + const result = waitForRequest(value, controller.signal, onLate) + await expect(result).rejects.toBe(reason) + + resolve('late') + await vi.waitFor(() => expect(onLate).toHaveBeenCalledWith('late')) + }) +}) diff --git a/packages/router-core/tests/load-route-chunk.test.ts b/packages/router-core/tests/load-route-chunk.test.ts new file mode 100644 index 0000000000..73179f9f32 --- /dev/null +++ b/packages/router-core/tests/load-route-chunk.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('router.loadRouteChunk exposes a failed lazy import and remains retryable', async () => { + const chunkError = new TypeError('Failed to fetch lazy route') + const component = () => null + let fail = true + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/route', + }).lazy(() => { + if (fail) { + fail = false + return Promise.reject(chunkError) + } + return Promise.resolve({ options: { component } } as any) + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await expect(router.loadRouteChunk(route as any)).rejects.toBe(chunkError) + await router.loadRouteChunk(route as any) + + expect(route.options.component).toBe(component) +}) diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index eda35f3fa2..7e465c20cb 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -7,10 +7,8 @@ import { redirect, rootRouteId, } from '../src' -import { createTestRouter } from './routerTestUtils' -import { loadMatches } from '../src/load-matches' +import { createTestRouter, loadServerResponse } from './routerTestUtils' import type { - AnyRouter, LoaderStaleReloadMode, RootRouteOptions, RouterCore, @@ -70,14 +68,10 @@ describe('redirect resolution', () => { isServer: true, }) - await router.load() + const response = await loadServerResponse(router, initialPath) - expect(router.state.redirect).toEqual( - expect.objectContaining({ - options: expect.objectContaining({ href: '/undefined' }), - }), - ) - expect(router.state.redirect?.headers.get('Location')).toBe('/undefined') + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('/undefined') }, ) }) @@ -218,12 +212,11 @@ describe('beforeLoad skip or exec', () => { const router = setup({ beforeLoad }) router.preloadRoute({ to: '/foo' }) await Promise.resolve() - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) await router.navigate({ to: '/foo' }) - expect(beforeLoad).toHaveBeenCalledTimes(2) + expect(router.state.location.pathname).toBe('/foo') + // An identical navigation may latch onto the still-active whole lane. + expect(beforeLoad).toHaveBeenCalledTimes(1) }) test('exec if rejected preload (notFound)', async () => { @@ -283,16 +276,11 @@ describe('beforeLoad skip or exec', () => { }) router.preloadRoute({ to: '/foo' }) await Promise.resolve() - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) await router.navigate({ to: '/foo' }) - expect(router.state.location.pathname).toBe('/foo') - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) - expect(beforeLoad).toHaveBeenCalledTimes(2) + expect(router.state.location.pathname).toBe('/bar') + expect(router.state.matches.at(-1)?.status).toBe('success') + expect(beforeLoad).toHaveBeenCalledTimes(1) }) test('exec if rejected preload (error)', async () => { @@ -426,13 +414,11 @@ describe('loader skip or exec', () => { const loader = vi.fn() const router = setup({ loader }) await router.preloadRoute({ to: '/foo' }) - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) await sleep(10) await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.location.pathname).toBe('/foo') + expect(loader).toHaveBeenCalledTimes(1) }) test('skip if resolved preload (success) within staleTime duration', async () => { @@ -472,7 +458,7 @@ describe('loader skip or exec', () => { expect(loader).toHaveBeenCalledTimes(2) }) - test('skip if pending preload (notFound)', async () => { + test('exec if pending preload returns notFound', async () => { const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw notFound() @@ -484,7 +470,9 @@ describe('loader skip or exec', () => { await Promise.resolve() await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/foo') + expect(router.state.matches.at(-1)?.status).toBe('success') + expect(loader).toHaveBeenCalledTimes(2) }) test('exec if rejected preload (redirect)', async () => { @@ -521,28 +509,6 @@ describe('loader skip or exec', () => { expect(loader).toHaveBeenCalledTimes(1) }) - test('updateMatch removes redirected matches from cachedMatches', async () => { - const loader = vi.fn() - const router = setup({ loader }) - - await router.preloadRoute({ to: '/foo' }) - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) - - router.updateMatch('/foo/foo', (prev) => ({ - ...prev, - status: 'redirected', - })) - - expect( - router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'), - ).toBe(false) - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) - }) - test('exec if rejected preload (error)', async () => { const loader: Loader = vi.fn(async ({ preload }) => { if (preload) throw new Error('error') @@ -558,7 +524,7 @@ describe('loader skip or exec', () => { expect(loader).toHaveBeenCalledTimes(2) }) - test('skip if pending preload (error)', async () => { + test('exec if pending preload errors', async () => { const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw new Error('error') @@ -570,7 +536,9 @@ describe('loader skip or exec', () => { await Promise.resolve() await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/foo') + expect(router.state.matches.at(-1)?.status).toBe('success') + expect(loader).toHaveBeenCalledTimes(2) }) }) @@ -665,22 +633,13 @@ describe('stale loader reload triggers', () => { const getMatchById = ( router: RouterCore, id: string, - ) => - router.state.matches.find((match) => match.id === id) ?? - router.stores.pendingMatches.get().find((match) => match.id === id) ?? - router.stores.cachedMatches.get().find((match) => match.id === id) + ) => router.state.matches.find((match) => match.id === id) const hasActiveMatch = ( router: RouterCore, id: string, ) => router.state.matches.some((match) => match.id === id) - const hasPendingMatch = ( - router: RouterCore, - id: string, - ) => - router.stores.pendingMatches.get().some((match) => match.id === id) ?? false - const setup = ({ loader, staleTime, @@ -750,24 +709,30 @@ describe('stale loader reload triggers', () => { await router.navigate({ to: '/bar' }) await vi.advanceTimersByTimeAsync(1) - const revisit = router.navigate({ to: '/foo' }) - await Promise.resolve() + let revisitSettled = false + const revisit = router.navigate({ to: '/foo' }).then(() => { + revisitSettled = true + }) - expect(loader).toHaveBeenCalledTimes(2) + await vi.waitFor(() => { + expect(loader).toHaveBeenCalledTimes(2) + }) + expect(revisitSettled).toBe(false) + expect(router.state.status).toBe('pending') + expect(router.state.isLoading).toBe(true) expect(hasActiveMatch(router, '/bar/bar')).toBe(true) expect(hasActiveMatch(router, '/foo/foo')).toBe(false) - expect(hasPendingMatch(router, '/foo/foo')).toBe(true) - expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ - value: 'first', - }) resolveStaleReload() await revisit + expect(revisitSettled).toBe(true) + expect(router.state.status).toBe('idle') + expect(router.state.isLoading).toBe(false) expect(loader).toHaveBeenCalledTimes(2) expect(hasActiveMatch(router, '/foo/foo')).toBe(true) - expect(hasPendingMatch(router, '/foo/foo')).toBe(false) expect(router.state.location.pathname).toBe('/foo') + expect(router.state.resolvedLocation?.pathname).toBe('/foo') expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ value: 'second', }) @@ -785,27 +750,28 @@ describe('stale loader reload triggers', () => { await router.navigate({ to: '/bar' }) await vi.advanceTimersByTimeAsync(1) - const revisit = router.navigate({ to: '/foo' }) - - expect(loader).toHaveBeenCalledTimes(2) - + let revisitSettled = false + const revisit = router.navigate({ to: '/foo' }).then(() => { + revisitSettled = true + }) await revisit - const backgroundReloadPromise = getMatchById(router, '/foo/foo') - ?._nonReactive.loaderPromise - expect(backgroundReloadPromise).toBeDefined() + expect(revisitSettled).toBe(true) + expect(router.state.status).toBe('idle') + expect(router.state.isLoading).toBe(false) + expect(loader).toHaveBeenCalledTimes(2) expect(hasActiveMatch(router, '/foo/foo')).toBe(true) - expect(hasPendingMatch(router, '/foo/foo')).toBe(false) expect(router.state.location.pathname).toBe('/foo') + expect(router.state.resolvedLocation?.pathname).toBe('/foo') expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ value: 'first', }) resolveStaleReload() - await backgroundReloadPromise - - expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ - value: 'second', + await vi.waitFor(() => { + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'second', + }) }) } @@ -867,7 +833,7 @@ describe('stale loader reload triggers', () => { expect(loader).toHaveBeenCalledTimes(2) }) - test('reloads a stale preloaded loader when switching to a different match id of the same route', async () => { + test('reuses a fresh preloaded loader when switching to a different match id of the same route', async () => { const rootRoute = new BaseRootRoute({}) const rootLoader = vi.fn(() => ({ ok: true })) const childLoader = vi.fn(() => ({ ok: true })) @@ -925,8 +891,8 @@ describe('stale loader reload triggers', () => { search: { page: '2' }, }) - expect(rootLoader).toHaveBeenCalledTimes(3) - expect(childLoader).toHaveBeenCalledTimes(3) + expect(rootLoader).toHaveBeenCalledTimes(2) + expect(childLoader).toHaveBeenCalledTimes(2) }) test('skips stale ancestor loader when only a child path param changes', async () => { @@ -1121,24 +1087,23 @@ describe('stale loader reload triggers', () => { }) }) -test('cancelMatches after pending timeout', async () => { - const WAIT_TIME = 5 - const onAbortMock = vi.fn() +test('navigating away from a pending route aborts its loader', async () => { + let loaderSignal: AbortSignal | undefined const rootRoute = new BaseRootRoute({}) const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/foo', - pendingMs: WAIT_TIME * 20, + pendingMs: 0, loader: async ({ abortController }) => { + loaderSignal = abortController.signal await new Promise((resolve) => { - const timer = setTimeout(() => { - resolve() - }, WAIT_TIME * 40) - abortController.signal.addEventListener('abort', () => { - onAbortMock() - clearTimeout(timer) - resolve() - }) + abortController.signal.addEventListener( + 'abort', + () => { + resolve() + }, + { once: true }, + ) }) }, pendingComponent: {}, @@ -1151,23 +1116,30 @@ test('cancelMatches after pending timeout', async () => { const router = createTestRouter({ routeTree, history: createMemoryHistory() }) await router.load() - router.navigate({ to: '/foo' }) - await sleep(WAIT_TIME * 30) + const fooNavigation = router.navigate({ to: '/foo' }) - // At this point, pending timeout should have triggered - const fooMatch = router.getMatch('/foo/foo') - expect(fooMatch).toBeDefined() + await vi.waitFor(() => { + expect(loaderSignal).toBeDefined() + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: fooRoute.id, + status: 'pending', + }) + }) - // Navigate away, which should cancel the pending match await router.navigate({ to: '/bar' }) - await router.latestLoadPromise + await fooNavigation expect(router.state.location.pathname).toBe('/bar') - - // Verify that abort was called and pending timeout was cleared - expect(onAbortMock).toHaveBeenCalled() - const cancelledFooMatch = router.getMatch('/foo/foo') - expect(cancelledFooMatch?._nonReactive.pendingTimeout).toBeUndefined() + expect(router.state.resolvedLocation?.pathname).toBe('/bar') + expect(router.state.status).toBe('idle') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: barRoute.id, + status: 'success', + }) + expect( + router.state.matches.some((match) => match.routeId === fooRoute.id), + ).toBe(false) + expect(loaderSignal?.aborted).toBe(true) }) describe('head execution', () => { @@ -1430,40 +1402,47 @@ describe('head execution', () => { name: string throwAtIndex: ThrowAtIndex parentFailures: ParentFailureMap - expectedErrorKind: 'notFound' | 'redirect' | 'error' + expectedErrorKind: 'notFound' | 'redirect' expectedErrorSource?: string - expectedErrorRouteIndex?: 0 | 1 | 2 | 3 - expectedLoaderMaxIndex: number - expectedRenderedHeadMaxIndex: number - withDefaultNotFoundComponent?: boolean + expectedBoundaryIndex?: 0 | 1 | 2 | 3 + expectedLoaderCount: number + expectedHeadTitles: Array beforeLoadNotFoundFactory?: ( routes: readonly [any, any, any, any], ) => ReturnType - expectRootNotFoundComponentAssigned?: boolean } const setupScenario = ({ throwAtIndex, parentFailures, beforeLoadNotFoundFactory, - withDefaultNotFoundComponent, + skipRootLoaderOnReload = false, }: { throwAtIndex: ThrowAtIndex parentFailures: ParentFailureMap beforeLoadNotFoundFactory?: Scenario['beforeLoadNotFoundFactory'] - withDefaultNotFoundComponent?: boolean + skipRootLoaderOnReload?: boolean }) => { const makeHead = (label: string) => vi.fn(() => ({ meta: [{ title: label }] })) const makeLoader = (index: number) => - vi.fn(() => { - const failure = parentFailures[index as 0 | 1 | 2] - if (failure === 'notFound') { - throw notFound({ data: { source: `loader-${index}` } }) - } - if (failure === 'redirect') { - throw redirect({ to: '/redirect-target' }) + vi.fn(({ location }: { location: { pathname: string } }) => { + // Keep failures scoped to the route under test. A root loader which + // redirects unconditionally would also redirect the destination and + // describe an infinite redirect loop rather than precedence between + // the original lane's settled outcomes. + if (location.pathname !== '/redirect-target') { + const failure = parentFailures[index as 0 | 1 | 2] + if (failure === 'notFound') { + throw notFound({ data: { source: `loader-${index}` } }) + } + if (failure === 'redirect') { + throw redirect({ to: '/redirect-target' }) + } + if (failure === 'error') { + throw new Error(`loader-${index}-error`) + } } return { level: index } }) @@ -1471,6 +1450,9 @@ describe('head execution', () => { const rootRoute = new BaseRootRoute({ loader: makeLoader(0), head: makeHead('Root'), + ...(skipRootLoaderOnReload + ? { staleTime: Infinity, shouldReload: () => false } + : {}), }) const level1Route = new BaseRoute({ @@ -1505,14 +1487,12 @@ describe('head execution', () => { ]) const routes = [rootRoute, level1Route, level2Route, level3Route] as const - - ;([0, 1, 2] as const).forEach((index) => { - if (parentFailures[index] === 'error') { - ;(routes[index].options as any).shouldReload = () => { - throw new Error(`loader-${index}-error`) - } - } - }) + const loaders = routes.map( + (route) => route.options.loader as ReturnType, + ) + const heads = routes.map( + (route) => route.options.head as ReturnType, + ) const throwRoute = routes[throwAtIndex]! throwRoute.options.beforeLoad = () => { @@ -1527,18 +1507,8 @@ describe('head execution', () => { history: createMemoryHistory({ initialEntries: ['/level-1/level-2/level-3'], }), - ...(withDefaultNotFoundComponent - ? { defaultNotFoundComponent: () => null } - : {}), }) - const loaders = routes.map( - (route) => route.options.loader as ReturnType, - ) - const heads = routes.map( - (route) => route.options.head as ReturnType, - ) - return { router, routes, @@ -1547,24 +1517,6 @@ describe('head execution', () => { } } - const runLoadMatchesAndCapture = async (router: AnyRouter) => { - const location = router.latestLocation - const matches = router.matchRoutes(location) - router.stores.setPending(matches) - - try { - await loadMatches({ - router, - location, - matches, - updateMatch: router.updateMatch, - }) - return { error: undefined, matches } - } catch (error) { - return { error, matches } - } - } - const scenarios = [ { name: 'throws beforeLoad notFound when parent loaders succeed', @@ -1572,35 +1524,39 @@ describe('head execution', () => { parentFailures: {} as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'beforeLoad-3', - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: 3, + expectedBoundaryIndex: 3, + expectedLoaderCount: 3, + expectedHeadTitles: ['Root', 'Level 1', 'Level 2', 'Level 3'], }, { - name: 'uses parent loader notFound when parent loader throws notFound', + name: 'promotes a fresh parent loader notFound above a serial child notFound', throwAtIndex: 3 as const, parentFailures: { 1: 'notFound' } as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'loader-1', - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: 1, + expectedBoundaryIndex: 1, + expectedLoaderCount: 3, + expectedHeadTitles: ['Root', 'Level 1'], }, { - name: 'uses first parent loader notFound when multiple parent loaders throw notFound', + name: 'promotes the first fresh parent loader failure above a serial child notFound', throwAtIndex: 3 as const, parentFailures: { 1: 'notFound', 2: 'notFound' } as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'loader-1', - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: 1, + expectedBoundaryIndex: 1, + expectedLoaderCount: 3, + expectedHeadTitles: ['Root', 'Level 1'], }, { - name: 'uses parent loader notFound when root loader throws notFound', + name: 'promotes a fresh root loader notFound above a serial child notFound', throwAtIndex: 2 as const, parentFailures: { 0: 'notFound' } as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'loader-0', - expectedLoaderMaxIndex: 1, - expectedRenderedHeadMaxIndex: 0, + expectedBoundaryIndex: 0, + expectedLoaderCount: 2, + expectedHeadTitles: ['Root'], }, { name: 'uses explicit routeId from beforeLoad notFound to target ancestor boundary', @@ -1608,9 +1564,9 @@ describe('head execution', () => { parentFailures: {} as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'beforeLoad-explicit-level1', - expectedErrorRouteIndex: 1, - expectedLoaderMaxIndex: 1, - expectedRenderedHeadMaxIndex: 1, + expectedBoundaryIndex: 1, + expectedLoaderCount: 2, + expectedHeadTitles: ['Root', 'Level 1'], beforeLoadNotFoundFactory: (routes) => notFound({ routeId: routes[1].id as never, @@ -1623,8 +1579,9 @@ describe('head execution', () => { parentFailures: {} as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'beforeLoad-invalid-route', - expectedLoaderMaxIndex: 0, - expectedRenderedHeadMaxIndex: 0, + expectedBoundaryIndex: 0, + expectedLoaderCount: 1, + expectedHeadTitles: ['Root'], beforeLoadNotFoundFactory: () => notFound({ routeId: '/does-not-exist' as never, @@ -1637,38 +1594,23 @@ describe('head execution', () => { parentFailures: {} as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'beforeLoad-non-exact-route', - expectedLoaderMaxIndex: 0, - expectedRenderedHeadMaxIndex: 0, + expectedBoundaryIndex: 0, + expectedLoaderCount: 1, + expectedHeadTitles: ['Root'], beforeLoadNotFoundFactory: (routes) => notFound({ routeId: `${routes[1].id}/` as never, data: { source: 'beforeLoad-non-exact-route' }, }), }, - { - name: 'assigns defaultNotFoundComponent on root when unknown routeId falls back to root', - throwAtIndex: 3 as const, - parentFailures: {} as ParentFailureMap, - expectedErrorKind: 'notFound' as const, - expectedErrorSource: 'beforeLoad-invalid-route-default', - expectedLoaderMaxIndex: 0, - expectedRenderedHeadMaxIndex: 0, - withDefaultNotFoundComponent: true, - expectRootNotFoundComponentAssigned: true, - beforeLoadNotFoundFactory: () => - notFound({ - routeId: '/does-not-exist' as never, - data: { source: 'beforeLoad-invalid-route-default' }, - }), - }, { name: 'prioritizes redirect when parent loader throws redirect', throwAtIndex: 3 as const, parentFailures: { 0: 'redirect' } as ParentFailureMap, expectedErrorKind: 'redirect' as const, expectedErrorSource: undefined, - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: -1, + expectedLoaderCount: 3, + expectedHeadTitles: ['Root'], }, { name: 'prioritizes redirect over root-loader notFound when both appear in settled loaders', @@ -1676,17 +1618,18 @@ describe('head execution', () => { parentFailures: { 0: 'notFound', 1: 'redirect' } as ParentFailureMap, expectedErrorKind: 'redirect' as const, expectedErrorSource: undefined, - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: -1, + expectedLoaderCount: 3, + expectedHeadTitles: ['Root'], }, { - name: 'propagates regular loader error when mixed with loader notFound in settled loaders', + name: 'promotes the first fresh parent failure when later loaders fail differently', throwAtIndex: 3 as const, parentFailures: { 1: 'notFound', 2: 'error' } as ParentFailureMap, - expectedErrorKind: 'error' as const, - expectedErrorSource: 'loader-2-error', - expectedLoaderMaxIndex: 1, - expectedRenderedHeadMaxIndex: -1, + expectedErrorKind: 'notFound' as const, + expectedErrorSource: 'loader-1', + expectedBoundaryIndex: 1, + expectedLoaderCount: 3, + expectedHeadTitles: ['Root', 'Level 1'], }, ] satisfies Array @@ -1695,60 +1638,68 @@ describe('head execution', () => { throwAtIndex: scenario.throwAtIndex, parentFailures: scenario.parentFailures, beforeLoadNotFoundFactory: scenario.beforeLoadNotFoundFactory, - withDefaultNotFoundComponent: scenario.withDefaultNotFoundComponent, }) - const { error, matches } = await runLoadMatchesAndCapture(router) - - for (let i = 0; i < routes.length; i++) { - const loader = loaders[i]! - const expectedCalls = i <= scenario.expectedLoaderMaxIndex ? 1 : 0 - expect(loader).toHaveBeenCalledTimes(expectedCalls) - } - - for (let i = 0; i < heads.length; i++) { - const head = heads[i]! - const expectedCalls = i <= scenario.expectedRenderedHeadMaxIndex ? 1 : 0 - expect(head).toHaveBeenCalledTimes(expectedCalls) - } + await router.load() + const matches = router.state.matches if (scenario.expectedErrorKind === 'redirect') { - expect(error).toEqual( + expect(router.state.location.pathname).toBe('/redirect-target') + expect(matches.at(-1)).toEqual( expect.objectContaining({ - redirectHandled: true, - options: expect.objectContaining({ - to: '/redirect-target', - }), + pathname: '/redirect-target', + status: 'success', }), ) - return - } - - if (scenario.expectedErrorKind === 'error') { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toBe(scenario.expectedErrorSource) - return - } - - expect(error).toEqual( - expect.objectContaining({ - isNotFound: true, - data: { source: scenario.expectedErrorSource }, - }), - ) + expect(matches.some((match) => match.error !== undefined)).toBe(false) + expect(matches.some((match) => match._notFound)).toBe(false) + } else { + const boundary = matches.find( + (match) => match.status === 'notFound' || match._notFound, + )! + expect(boundary.routeId).toBe( + routes[scenario.expectedBoundaryIndex!]!.id, + ) - if (scenario.expectedErrorRouteIndex !== undefined) { - expect((error as { routeId?: string }).routeId).toBe( - routes[scenario.expectedErrorRouteIndex]!.id, + expect(boundary.error).toEqual( + expect.objectContaining({ + isNotFound: true, + data: { source: scenario.expectedErrorSource }, + }), ) - } - if (scenario.expectRootNotFoundComponentAssigned) { - expect(routes[0].options.notFoundComponent).toBeTypeOf('function') + if (scenario.expectedBoundaryIndex === 0) { + expect(boundary.status).toBe('success') + expect(boundary._notFound).toBe(true) + } else { + expect(boundary.status).toBe('notFound') + expect(boundary.error).toEqual( + expect.objectContaining({ routeId: boundary.routeId }), + ) + } } + + loaders.forEach((loader, index) => { + const originalLaneCalls = loader.mock.calls.filter( + ([options]) => + options.location.pathname === '/level-1/level-2/level-3', + ) + expect(originalLaneCalls).toHaveLength( + index < scenario.expectedLoaderCount ? 1 : 0, + ) + }) + heads.forEach((head, index) => { + expect(head).toHaveBeenCalledTimes( + index < scenario.expectedHeadTitles.length ? 1 : 0, + ) + }) + expect( + matches.map(getTitle).filter((title) => title !== undefined), + ).toEqual(scenario.expectedHeadTitles) + expect(router.state.status).toBe('idle') }) - test('sets globalNotFound on root match when beforeLoad notFound targets root boundary', async () => { + test('sets _notFound on root match when beforeLoad notFound targets root boundary', async () => { const { router, routes } = setupScenario({ throwAtIndex: 3, parentFailures: {}, @@ -1759,28 +1710,27 @@ describe('head execution', () => { }), }) - const { error, matches } = await runLoadMatchesAndCapture(router) + await router.load() + + const rootMatch = router.state.matches.find( + (m) => m.routeId === routes[0].id, + ) - expect(error).toEqual( + expect(rootMatch?._notFound).toBe(true) + expect(rootMatch?.status).toBe('success') + expect(rootMatch?.error).toEqual( expect.objectContaining({ isNotFound: true, data: { source: 'beforeLoad-root-explicit' }, }), ) - - const rootMatch = router.stores.pendingMatches - .get() - .find((m) => m.routeId === routes[0].id) - - expect(rootMatch?.globalNotFound).toBe(true) - expect(rootMatch?.status).toBe('success') - expect(rootMatch?.error).toBeUndefined() }) - test('clears stale root globalNotFound on subsequent successful load', async () => { - const { router, routes } = setupScenario({ + test('clears stale root _notFound when the root loader is skipped on a successful reload', async () => { + const { router, routes, loaders } = setupScenario({ throwAtIndex: 3, parentFailures: {}, + skipRootLoaderOnReload: true, beforeLoadNotFoundFactory: (innerRoutes) => notFound({ routeId: innerRoutes[0].id as never, @@ -1788,87 +1738,31 @@ describe('head execution', () => { }), }) - const first = await runLoadMatchesAndCapture(router) - expect(first.error).toEqual(expect.objectContaining({ isNotFound: true })) + await router.load() + const initialRootMatch = router.state.matches.find( + (match) => match.routeId === routes[0].id, + ) + expect(initialRootMatch?._notFound).toBe(true) + expect(initialRootMatch?.error).toEqual( + expect.objectContaining({ + isNotFound: true, + data: { source: 'beforeLoad-root-explicit' }, + }), + ) + expect(loaders[0]).toHaveBeenCalledTimes(1) const throwingRoute = routes[3] throwingRoute.options.beforeLoad = undefined - const second = await runLoadMatchesAndCapture(router) - expect(second.error).toBeUndefined() - - const rootMatch = router.stores.pendingMatches - .get() - .find((m) => m.routeId === routes[0].id) - - expect(rootMatch?.globalNotFound).toBe(false) - }) - - test('clears stale root globalNotFound when root loader is skipped', async () => { - const rootLoader = vi.fn(() => ({ level: 0 })) - const rootRoute = new BaseRootRoute({ - loader: rootLoader, - staleTime: Infinity, - shouldReload: () => false, - }) - - const childLoader = vi.fn(() => ({ level: 1 })) - const childRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/test', - loader: childLoader, - staleTime: Infinity, - shouldReload: () => false, - }) - - const routeTree = rootRoute.addChildren([childRoute]) - - const router = createTestRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: ['/test'] }), - }) - - const first = await runLoadMatchesAndCapture(router) - expect(first.error).toBeUndefined() - expect(rootLoader).toHaveBeenCalledTimes(1) - - const staleRootNotFound = notFound({ data: { source: 'stale-root' } }) - const currentRootMatchId = router.stores.pendingMatches - .get() - .find((m) => m.routeId === rootRoute.id)!.id - - router.updateMatch(currentRootMatchId, (prev) => ({ - ...prev, - status: 'success', - globalNotFound: true, - error: staleRootNotFound, - })) - - const location = router.latestLocation - const matches = router.matchRoutes(location) - const pendingRootMatch = matches.find((m) => m.routeId === rootRoute.id)! - pendingRootMatch.status = 'success' - pendingRootMatch.globalNotFound = false - pendingRootMatch.error = undefined - router.stores.setPending(matches) - - await expect( - loadMatches({ - router, - location, - matches, - updateMatch: router.updateMatch, - }), - ).resolves.toBe(matches) - - expect(rootLoader).toHaveBeenCalledTimes(1) + await router.load() - const rootMatch = router.stores.pendingMatches - .get() - .find((m) => m.routeId === rootRoute.id) + const rootMatch = router.state.matches.find( + (m) => m.routeId === routes[0].id, + ) - expect(rootMatch?.globalNotFound).toBe(false) - expect(rootMatch?.error).toBeUndefined() + expect(rootMatch?._notFound).toBe(false) + expect(rootMatch?.status).toBe('success') + expect(loaders[0]).toHaveBeenCalledTimes(1) }) }) }) diff --git a/packages/router-core/tests/loader-architecture-regressions.test.ts b/packages/router-core/tests/loader-architecture-regressions.test.ts index 31ada9cc43..1efc42cc73 100644 --- a/packages/router-core/tests/loader-architecture-regressions.test.ts +++ b/packages/router-core/tests/loader-architecture-regressions.test.ts @@ -4,101 +4,247 @@ import { BaseRootRoute, BaseRoute, createControlledPromise, + notFound, redirect, } from '../src' -import { createTestRouter } from './routerTestUtils' +import { createTestRouter, loadServerResponse } from './routerTestUtils' -test('a child can redirect after observing its parent loader error', async () => { +test.each([ + ['error', () => new Error('parent failed')], + ['notFound', () => notFound()], +] as const)( + 'a child can redirect after observing its parent loader %s', + async (expectedStatus, createFailure) => { + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw createFailure() + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async ({ parentMatchPromise }) => { + const parent = await parentMatchPromise + if (parent.status === expectedStatus) { + throw redirect({ to: '/target' }) + } + return parent.status + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + }) + }, +) + +test.each(['client', 'server'] as const)( + 'a descendant self-abort cannot hide redirecting onError on the %s', + async (environment) => { + const parentGate = createControlledPromise() + const childGate = createControlledPromise() + const parentOnError = vi.fn() + const childOnError = vi.fn(() => { + throw redirect({ to: '/target' }) + }) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: async () => { + await parentGate + throw new Error('parent failed') + }, + onError: parentOnError, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async ({ abortController }) => { + await childGate + abortController.abort() + throw new Error('child failed') + }, + onError: childOnError, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: environment === 'server', + }) + + const loading = + environment === 'server' + ? loadServerResponse(router, '/parent/child') + : router.load() + parentGate.resolve() + await vi.waitFor(() => expect(parentOnError).toHaveBeenCalledOnce()) + childGate.resolve() + await vi.waitFor(() => expect(childOnError).toHaveBeenCalledOnce()) + + if (environment === 'server') { + const response = await loading + expect(response).toBeDefined() + if (!response) { + return + } + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('/target') + } else { + await loading + expect(router.state.location.pathname).toBe('/target') + } + }, +) + +test('superseding a load clears fetching state from the still-presented lane', async () => { + const firstGate = createControlledPromise() + const secondGate = createControlledPromise() const rootRoute = new BaseRootRoute({}) - const parentRoute = new BaseRoute({ + const pageRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/parent', - loader: () => { - throw new Error('parent failed') - }, - }) - const childRoute = new BaseRoute({ - getParentRoute: () => parentRoute, - path: '/child', - loader: async ({ parentMatchPromise }) => { - const parent = await parentMatchPromise - if (parent.status === 'error') { - throw redirect({ to: '/target' }) - } - return parent.status - }, + path: '/page', + validateSearch: (search: Record) => ({ + revision: Number(search.revision ?? 0), + }), + beforeLoad: ({ search }) => (search.revision === 1 ? firstGate : undefined), }) - const targetRoute = new BaseRoute({ + const otherRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/target', + path: '/other', + loader: () => secondGate, }) const router = createTestRouter({ - routeTree: rootRoute.addChildren([ - parentRoute.addChildren([childRoute]), - targetRoute, - ]), - history: createMemoryHistory({ - initialEntries: ['/parent/child'], - }), + routeTree: rootRoute.addChildren([pageRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), }) await router.load() + const firstNavigation = router.navigate({ + to: '/page', + search: { revision: 1 }, + }) + await vi.waitFor(() => + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + isFetching: 'beforeLoad', + }), + ) - expect(router.state.location.pathname).toBe('/target') - expect(router.state.matches.at(-1)).toMatchObject({ - routeId: targetRoute.id, - status: 'success', + const secondNavigation = router.navigate({ to: '/other' }) + await vi.waitFor(() => { + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + isFetching: false, + }) }) + + firstGate.resolve() + secondGate.resolve() + await Promise.all([firstNavigation, secondNavigation]) }) -test('a descendant self-abort cannot hide redirecting onError on the client', async () => { - const parentGate = createControlledPromise() - const childGate = createControlledPromise() - const parentOnError = vi.fn() - const childOnError = vi.fn(() => { - throw redirect({ to: '/target' }) - }) +test('an ordinary route-context failure still permits ancestor loaders', async () => { + const failure = new Error('child context failed') + const parentLoader = vi.fn(() => 'parent data') const rootRoute = new BaseRootRoute({}) const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/parent', - loader: async () => { - await parentGate - throw new Error('parent failed') - }, - onError: parentOnError, + loader: parentLoader, }) const childRoute = new BaseRoute({ getParentRoute: () => parentRoute, path: '/child', - loader: async ({ abortController }) => { - await childGate - abortController.abort() - throw new Error('child failed') + context: () => { + throw failure }, - onError: childOnError, }) - const targetRoute = new BaseRoute({ + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + + expect(parentLoader).toHaveBeenCalledOnce() + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ + status: 'success', + loaderData: 'parent data', + }) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: childRoute.id, + status: 'error', + error: failure, + }) +}) + +test('a failed route context is recomputed when the route is retried', async () => { + const failure = new Error('context failed') + let shouldFail = true + let generation = 0 + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/target', + path: '/', + context: () => { + generation++ + if (shouldFail) { + throw failure + } + return { generation } + }, + loader: ({ context }) => context.generation, }) const router = createTestRouter({ - routeTree: rootRoute.addChildren([ - parentRoute.addChildren([childRoute]), - targetRoute, - ]), - history: createMemoryHistory({ initialEntries: ['/parent/child'] }), - isServer: false, + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/'] }), }) - const loading = router.load() - parentGate.resolve() - await vi.waitFor(() => expect(parentOnError).toHaveBeenCalledOnce()) - childGate.resolve() - await vi.waitFor(() => expect(childOnError).toHaveBeenCalledOnce()) + await router.load() + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: route.id, + status: 'error', + error: failure, + }) - await loading - expect(router.state.location.pathname).toBe('/target') + shouldFail = false + await router.invalidate() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: route.id, + status: 'success', + loaderData: 2, + }) }) test('invalidation reruns the loader with same-id route context', async () => { @@ -133,6 +279,164 @@ test('invalidation reruns the loader with same-id route context', async () => { }) }) +test('a preload cannot abort its controller after navigation adopts its lane', async () => { + const suspended = createControlledPromise() + let adoptedSignal: AbortSignal | undefined + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + beforeLoad: ({ abortController }) => { + adoptedSignal = abortController.signal + throw suspended + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/guarded' }) + const navigation = router.navigate({ to: '/guarded' }) + + await preload + expect(adoptedSignal?.aborted).toBe(false) + + await navigation +}) + +test('a fast background candidate remains private when a reentrant navigation wins', async () => { + let parentLoads = 0 + let childLoads = 0 + let activeSignal: AbortSignal | undefined + let replacementSignal: AbortSignal | undefined + let reentrantNavigation: Promise | undefined + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + validateSearch: (search: Record) => ({ + reload: Number(search.reload ?? 0), + }), + shouldReload: ({ location }) => + (location.search as { reload: number }).reload === 1, + loader: ({ abortController }) => { + parentLoads++ + if (parentLoads === 1) { + activeSignal = abortController.signal + } else { + replacementSignal = abortController.signal + } + return { revision: parentLoads } + }, + onStay: (match) => { + if (match.search.reload === 1 && !reentrantNavigation) { + reentrantNavigation = router.navigate({ + to: '/parent/child', + search: { reload: 2 }, + }) + } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + shouldReload: ({ location }) => + (location.search as { reload: number }).reload === 1, + loader: () => { + childLoads++ + if (childLoads === 2) { + throw redirect({ to: '/target' }) + } + return 'child data' + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/child?reload=0'], + }), + }) + + await router.load() + await router.navigate({ + to: '/parent/child', + search: { reload: 1 }, + }) + await reentrantNavigation + + expect(router.state.location.search).toEqual({ reload: 2 }) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ loaderData: { revision: 1 } }) + expect(parentLoads).toBe(2) + expect(activeSignal?.aborted).toBe(false) + expect(replacementSignal?.aborted).toBe(true) +}) + +test('reentrant navigation from onError cancels stale serial work', async () => { + const failure = new Error('stale context failed') + const parentLoader = vi.fn(() => 'parent data') + let successor: Promise | undefined + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: () => { + throw failure + }, + onError: () => { + successor ??= router.navigate({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/parent/child' }) + await successor + + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)?.routeId).toBe(targetRoute.id) + expect(router.state.matches.some((match) => match.error === failure)).toBe( + false, + ) + expect(parentLoader).not.toHaveBeenCalled() +}) + test('a joined descendant loader redirect still wins after an ancestor loader fails', async () => { const childStarted = createControlledPromise() const childRedirect = createControlledPromise() @@ -207,6 +511,65 @@ test('a joined descendant loader redirect still wins after an ancestor loader fa expect(childLoads).toBe(1) }) +test('a loaderDeps error is handled by the route error boundary', async () => { + const failure = new Error('loader deps failed') + const rootRoute = new BaseRootRoute({}) + const brokenRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/broken', + loaderDeps: (): Record => { + throw failure + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([brokenRoute]), + history: createMemoryHistory({ initialEntries: ['/broken'] }), + }) + + await router.load() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: brokenRoute.id, + status: 'error', + error: failure, + }) +}) + +test('loaderDeps redirects use the bounded navigation redirect policy', async () => { + const rootRoute = new BaseRootRoute({}) + const routes = Array.from( + { length: 23 }, + (_, index) => + new BaseRoute({ + getParentRoute: () => rootRoute, + path: `/step-${index}`, + loaderDeps: + index === 22 + ? undefined + : () => { + throw redirect({ to: `/step-${index + 1}` }) + }, + }), + ) + const router = createTestRouter({ + routeTree: rootRoute.addChildren(routes as any) as any, + history: createMemoryHistory({ initialEntries: ['/step-0'] }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/step-20') + expect( + router.state.matches.find((match) => match.status !== 'success'), + ).toMatchObject({ + routeId: rootRoute.id, + status: 'error', + error: expect.objectContaining({ + message: 'Too many redirects', + }), + }) +}) + test('a reentrant user navigation starts a fresh redirect chain', async () => { let navigateFresh: () => void const rootRoute = new BaseRootRoute({}) @@ -255,3 +618,291 @@ test('a reentrant user navigation starts a fresh redirect chain', async () => { status: 'success', }) }) + +test('a superseded load settles without waiting for stale asset projection', async () => { + const slowHeadStarted = createControlledPromise() + const slowHead = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + head: () => { + slowHeadStarted.resolve() + return slowHead + }, + scripts: () => [{ children: 'window.route = "slow"' }], + headers: () => ({ 'x-route': 'slow' }), + }) + const fastRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/fast', + head: () => ({ meta: [{ title: 'fast' }] }), + scripts: () => [{ children: 'window.route = "fast"' }], + headers: () => ({ 'x-route': 'fast' }), + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute, fastRoute]), + history, + }) + + await router.load() + history.push('/slow') + const slowLoad = router.load() + await slowHeadStarted + + history.push('/fast') + const fastLoad = router.load() + await fastLoad + + const settledBeforeProjection = await Promise.race([ + slowLoad.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]) + + slowHead.resolve({ meta: [{ title: 'slow' }] }) + await slowLoad + await Promise.resolve() + + expect(settledBeforeProjection).toBe(true) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: fastRoute.id, + meta: [{ title: 'fast' }], + scripts: [{ children: 'window.route = "fast"' }], + }) +}) + +test('server response headers stop at the terminal render boundary', async () => { + let failParent = false + const rootRoute = new BaseRootRoute({ + headers: () => ({ 'x-root': 'root' }), + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + if (failParent) { + throw new Error('parent failed') + } + }, + headers: () => ({ 'x-parent': 'parent' }), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + headers: () => ({ 'x-child': 'child' }), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + await router.load() + expect(router.state.matches.at(-1)?.headers).toEqual({ 'x-child': 'child' }) + + failParent = true + const response = await loadServerResponse(router, '/parent/child') + + expect(router.state.matches).toHaveLength(3) + expect(router.state.matches[1]).toMatchObject({ + routeId: parentRoute.id, + status: 'error', + }) + expect(response.headers.get('x-root')).toBe('root') + expect(response.headers.get('x-parent')).toBe('parent') + expect(response.headers.get('x-child')).toBeNull() +}) + +test('a superseded load settles without waiting for its normal component chunk', async () => { + const chunkStarted = createControlledPromise() + const chunk = createControlledPromise() + const onError = vi.fn() + const component = Object.assign(() => null, { + preload: () => { + chunkStarted.resolve() + return chunk + }, + }) + const rootRoute = new BaseRootRoute({}) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + component, + onError, + }) + const fastRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/fast', + }) + const history = createMemoryHistory({ initialEntries: ['/slow'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([slowRoute, fastRoute]), + history, + }) + + const slowLoad = router.load() + await chunkStarted + history.push('/fast') + await router.load() + + expect( + await Promise.race([ + slowLoad.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]), + ).toBe(true) + expect(router.state.matches.at(-1)?.routeId).toBe(fastRoute.id) + + chunk.reject(new Error('obsolete chunk failed')) + await Promise.resolve() + expect(onError).not.toHaveBeenCalled() +}) + +test('a superseded load settles without waiting for its terminal boundary chunk', async () => { + const chunkStarted = createControlledPromise() + const chunk = createControlledPromise() + const loaderError = new Error('loader failed') + const onError = vi.fn() + const errorComponent = Object.assign(() => null, { + preload: () => { + chunkStarted.resolve() + return chunk + }, + }) + const rootRoute = new BaseRootRoute({}) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => { + throw loaderError + }, + errorComponent, + onError, + }) + const fastRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/fast', + }) + const history = createMemoryHistory({ initialEntries: ['/slow'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([slowRoute, fastRoute]), + history, + }) + + const slowLoad = router.load() + await chunkStarted + history.push('/fast') + await router.load() + + expect( + await Promise.race([ + slowLoad.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 100)), + ]), + ).toBe(true) + expect(router.state.matches.at(-1)?.routeId).toBe(fastRoute.id) + + chunk.reject(new Error('obsolete boundary chunk failed')) + await Promise.resolve() + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith(loaderError) +}) + +test('a stale shouldReload cannot continue a superseded preload', async () => { + const loader = vi.fn(() => 'cached') + const onError = vi.fn() + let reenter = false + let winner: Promise | undefined + let router: ReturnType + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const cachedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/cached', + preloadStaleTime: 0, + shouldReload: () => { + if (reenter) { + router.history.push('/winner') + winner = router.load() + throw new Error('obsolete shouldReload failure') + } + return true + }, + loader, + onError, + }) + const winnerRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/winner', + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, cachedRoute, winnerRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/cached' }) + reenter = true + await router.preloadRoute({ to: '/cached' }) + await winner + + expect(router.state.location.pathname).toBe('/winner') + expect(router.state.matches.at(-1)?.routeId).toBe(winnerRoute.id) + expect(loader).toHaveBeenCalledOnce() + // Preload lanes run to completion despite the navigation, so the stale + // shouldReload's failure surfaces through onError (matching pre-rewrite + // behavior for preload errors) — but it cannot restart the loader or + // disturb the winning navigation. + expect(onError).toHaveBeenCalledOnce() +}) + +test('an onLeave navigation to the in-flight destination joins it and enters once', async () => { + const firstEnter = vi.fn() + const firstStay = vi.fn() + let successor: Promise | undefined + let redirected = false + let router: ReturnType + + const rootRoute = new BaseRootRoute({}) + const fromRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/from', + onLeave: () => { + if (!redirected) { + redirected = true + successor = router.navigate({ to: '/first' }) + } + }, + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: firstEnter, + onStay: firstStay, + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([fromRoute, firstRoute]), + history: createMemoryHistory({ initialEntries: ['/from'] }), + }) + + await router.load() + await router.navigate({ to: '/first' }) + await successor + + expect(router.state.location.pathname).toBe('/first') + expect(router.state.matches.at(-1)?.routeId).toBe(firstRoute.id) + // The reentrant same-destination navigation joins the in-flight lane, so + // the route is entered exactly once rather than restarted into a stay. + expect(firstEnter).toHaveBeenCalledOnce() + expect(firstStay).not.toHaveBeenCalled() +}) diff --git a/packages/router-core/tests/loader-self-abort.test.ts b/packages/router-core/tests/loader-self-abort.test.ts index c1c1885759..68916b1d68 100644 --- a/packages/router-core/tests/loader-self-abort.test.ts +++ b/packages/router-core/tests/loader-self-abort.test.ts @@ -1,59 +1,73 @@ import { expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' -import { createTestRouter } from './routerTestUtils' +import { createTestRouter, loadServerResponse } from './routerTestUtils' -test('a loader can fulfill after aborting its controller with isServer=false', async () => { - const rootRoute = new BaseRootRoute({}) - const pageRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/page', - loader: ({ abortController }) => { - abortController.abort() - return 'accepted data' - }, - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([pageRoute]), - history: createMemoryHistory({ initialEntries: ['/page'] }), - isServer: false, - }) +test.each([false, true])( + 'a loader can fulfill after aborting its controller with isServer=%s', + async (isServer) => { + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: ({ abortController }) => { + abortController.abort() + return 'accepted data' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer, + }) - await router.load() + if (isServer) { + expect((await loadServerResponse(router, '/page')).status).toBe(200) + } else { + await router.load() + } - expect(router.state.matches.at(-1)).toMatchObject({ - routeId: pageRoute.id, - status: 'success', - loaderData: 'accepted data', - }) -}) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + loaderData: 'accepted data', + }) + }, +) -test('a loader rejection remains an error after aborting its controller with isServer=false', async () => { - const failure = new Error('loader failed after aborting its controller') - const onError = vi.fn() - const rootRoute = new BaseRootRoute({}) - const pageRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/page', - loader: ({ abortController }) => { - abortController.abort() - throw failure - }, - errorComponent: () => null, - onError, - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([pageRoute]), - history: createMemoryHistory({ initialEntries: ['/page'] }), - isServer: false, - }) +test.each([false, true])( + 'a loader rejection remains an error after aborting its controller with isServer=%s', + async (isServer) => { + const failure = new Error('loader failed after aborting its controller') + const onError = vi.fn() + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: ({ abortController }) => { + abortController.abort() + throw failure + }, + errorComponent: () => null, + onError, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer, + }) - await router.load() + if (isServer) { + expect((await loadServerResponse(router, '/page')).status).toBe(500) + } else { + await router.load() + } - expect(router.state.matches.at(-1)).toMatchObject({ - routeId: pageRoute.id, - status: 'error', - error: failure, - }) - expect(onError).toHaveBeenCalledWith(failure) -}) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'error', + error: failure, + }) + expect(onError).toHaveBeenCalledWith(failure) + }, +) diff --git a/packages/router-core/tests/loader-thrown-promise.test.ts b/packages/router-core/tests/loader-thrown-promise.test.ts new file mode 100644 index 0000000000..e45eede84f --- /dev/null +++ b/packages/router-core/tests/loader-thrown-promise.test.ts @@ -0,0 +1,57 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +test.each([ + ['beforeLoad', false], + ['beforeLoad', true], + ['loader', false], + ['loader', true], +] as const)( + 'a synchronously thrown %s Promise has the same meaning with isServer=%s', + async (hook, isServer) => { + const thrown = Promise.resolve('not loader data') + const onError = vi.fn() + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + beforeLoad: + hook === 'beforeLoad' + ? () => { + throw thrown + } + : undefined, + loader: + hook === 'loader' + ? () => { + throw thrown + } + : undefined, + onError, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer, + }) + + if (isServer) { + const response = await loadServerResponse(router, '/target') + expect(response.status).toBe(500) + } else { + await router.load() + } + + const error = router.state.matches.at(-1)?.error + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'error', + }) + expect(error).toBeInstanceOf(Error) + expect((error as Error).cause).toBe(thrown) + expect(onError).toHaveBeenCalledWith(error) + }, +) diff --git a/packages/router-core/tests/masked-location-state-commit.test.ts b/packages/router-core/tests/masked-location-state-commit.test.ts index 78e40856a2..9738b42261 100644 --- a/packages/router-core/tests/masked-location-state-commit.test.ts +++ b/packages/router-core/tests/masked-location-state-commit.test.ts @@ -1,49 +1,55 @@ -import { expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' -test('a same-href navigation clears an expired mask from history state', async () => { - const makeRoutes = () => { - const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - const realRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/real', - }) - const prettyRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/pretty', +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('masked location remnants in history state', () => { + test('a same-href navigation clears an expired mask from history state', async () => { + const makeRoutes = () => { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const realRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/real', + }) + const prettyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/pretty', + }) + return rootRoute.addChildren([indexRoute, realRoute, prettyRoute]) + } + const history = createMemoryHistory({ initialEntries: ['/'] }) + + const router1 = createTestRouter({ + routeTree: makeRoutes(), + history, + unmaskOnReload: true, }) - return rootRoute.addChildren([indexRoute, realRoute, prettyRoute]) - } - const history = createMemoryHistory({ initialEntries: ['/'] }) + await router1.load() + await router1.navigate({ to: '/real', mask: { to: '/pretty' } }) + expect(history.location.state.__tempLocation).toBeDefined() - const router1 = createTestRouter({ - routeTree: makeRoutes(), - history, - unmaskOnReload: true, - }) - await router1.load() - await router1.navigate({ to: '/real', mask: { to: '/pretty' } }) - expect(history.location.state.__tempLocation).toBeDefined() + // A fresh router over the same history models a page reload: the + // per-session temp key no longer matches, so the mask has expired and the + // literal URL resolves. + const router2 = createTestRouter({ + routeTree: makeRoutes(), + history, + unmaskOnReload: true, + }) + await router2.load() + expect(router2.state.location.pathname).toBe('/pretty') - // A fresh router over the same history models a page reload: the - // per-session temp key no longer matches, so the mask has expired and the - // literal URL resolves. - const router2 = createTestRouter({ - routeTree: makeRoutes(), - history, - unmaskOnReload: true, + // Navigating to the same href must still commit so the expired mask + // payload is dropped from history state, matching pre-lane behavior. + await router2.navigate({ to: '/pretty' }) + expect(history.location.state.__tempLocation).toBeUndefined() }) - await router2.load() - expect(router2.state.location.pathname).toBe('/pretty') - - // Navigating to the same href must still commit so the expired mask - // payload is dropped from history state, matching pre-lane behavior. - await router2.navigate({ to: '/pretty' }) - expect(history.location.state.__tempLocation).toBeUndefined() }) diff --git a/packages/router-core/tests/parent-match-promise.test.ts b/packages/router-core/tests/parent-match-promise.test.ts index 1e235cd9f5..5513067027 100644 --- a/packages/router-core/tests/parent-match-promise.test.ts +++ b/packages/router-core/tests/parent-match-promise.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'vitest' +import { expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' @@ -40,6 +40,49 @@ test.each([false, true])( }, ) +test('client preload=false parentMatchPromise exposes the skipped pending parent', async () => { + const seen: Array = [] + const parentLoader = vi.fn() + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + preload: false, + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async ({ parentMatchPromise }) => { + seen.push(await parentMatchPromise) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + }) + + await router.preloadRoute({ to: '/parent/child' }) + + expect(parentLoader).not.toHaveBeenCalled() + expect(seen).toHaveLength(1) + expect(seen[0]).toMatchObject({ + routeId: parentRoute.id, + status: 'pending', + invalid: false, + preload: false, + isFetching: false, + }) +}) + test('server parentMatchPromise exposes the error selected by onError', async () => { const original = new Error('original') const selected = new Error('selected') diff --git a/packages/router-core/tests/preflight-reentrant-context.test.ts b/packages/router-core/tests/preflight-reentrant-context.test.ts new file mode 100644 index 0000000000..415e5f0e50 --- /dev/null +++ b/packages/router-core/tests/preflight-reentrant-context.test.ts @@ -0,0 +1,101 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('synchronous navigation from route context stops abandoned descendant context work', async () => { + let redirected = false + let redirectNavigation: Promise | undefined + const childContext = vi.fn(() => ({ child: true })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: ({ navigate }) => { + if (!redirected) { + redirected = true + redirectNavigation = navigate({ to: '/target' }) + } + return { parent: true } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: childContext, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/parent/child' }) + await redirectNavigation + + expect(router.state.location.pathname).toBe('/target') + expect(childContext).not.toHaveBeenCalled() +}) + +test('synchronous navigation from preloaded route context lets the preload finish quietly', async () => { + let redirected = false + let redirectNavigation: Promise | undefined + const childContext = vi.fn(() => ({ child: true })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: ({ navigate, preload }) => { + if (preload && !redirected) { + redirected = true + redirectNavigation = navigate({ to: '/target' }) + } + return { parent: true } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: childContext, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' }) + await redirectNavigation + + expect(router.state.location.pathname).toBe('/target') + // Preload lanes are not cancelled by navigations: the descendant context + // still computes (and may seed the cache), while the navigation wins the + // presented state. Only navigation lanes stop abandoned descendant work. + expect(childContext).toHaveBeenCalledOnce() +}) diff --git a/packages/router-core/tests/preload-adoption.test.ts b/packages/router-core/tests/preload-adoption.test.ts index 4742278aca..18ec7385d5 100644 --- a/packages/router-core/tests/preload-adoption.test.ts +++ b/packages/router-core/tests/preload-adoption.test.ts @@ -7,6 +7,16 @@ afterEach(() => { vi.useRealTimers() }) +/** + * Preload adoption edge cases. The happy path (navigation adopts an + * in-flight preload's successful loader run) and the control-flow + * non-leakage path are pinned in load.test.ts; this file pins the + * adoption boundary: a donor must have its loader genuinely in flight. A + * preload still in its serial phase can itself be waiting on the navigation + * through the borrow protocol, while a stale successful donor can still have + * fresh loader work pending behind its cached snapshot. + */ + describe('preload adoption', () => { test('navigation waits for fresh data from an in-flight stale preload revalidation', async () => { vi.useFakeTimers() @@ -86,6 +96,111 @@ describe('preload adoption', () => { ).toEqual({ notifications: ['fresh'] }) }) + test('navigation adopts an identical preload still in its serial phase', async () => { + const beforeLoadGate = createControlledPromise() + const preloadSerialStarted = createControlledPromise() + let beforeLoadCalls = 0 + const loader = vi.fn(() => 'data') + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: async () => { + beforeLoadCalls++ + if (beforeLoadCalls === 1) { + // Keep the shared lane's serial phase in flight. + preloadSerialStarted.resolve() + await beforeLoadGate + } + }, + loader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + // Preload is stuck in its serial phase — its loader has NOT started. + const preload = router.preloadRoute({ to: '/foo' } as any) + await preloadSerialStarted + expect(beforeLoadCalls).toBe(1) + + const navigation = router.navigate({ to: '/foo' }) + await Promise.resolve() + expect(loader).not.toHaveBeenCalled() + + beforeLoadGate.resolve() + await Promise.all([navigation, preload]) + expect(beforeLoadCalls).toBe(1) + expect(loader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === fooRoute.id) + ?.status, + ).toBe('success') + }) + + test("a sibling preload adopts another preload lane's in-flight loader", async () => { + const loaderGate = createControlledPromise() + const loaderStarted = createControlledPromise() + let preloadBeforeLoadCalls = 0 + const loader = vi.fn(() => { + loaderStarted.resolve() + return loaderGate + }) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + preloadStaleTime: Infinity, + beforeLoad: ({ preload }) => { + if (preload) { + preloadBeforeLoadCalls++ + } + }, + loader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const first = router.preloadRoute({ to: '/foo' } as any) + await loaderStarted + + const second = router.preloadRoute({ to: '/foo' } as any) + await Promise.resolve() + expect(preloadBeforeLoadCalls).toBe(1) + expect(loader).toHaveBeenCalledTimes(1) + + loaderGate.resolve('once') + await Promise.all([first, second]) + expect(loader).toHaveBeenCalledTimes(1) + + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === fooRoute.id) + ?.loaderData, + ).toBe('once') + }) + test('a fulfilled undefined loader result is adopted as success', async () => { const loaderGate = createControlledPromise() const loaderStarted = createControlledPromise() @@ -130,4 +245,134 @@ describe('preload adoption', () => { expect(match?.status).toBe('success') expect(match?.loaderData).toBeUndefined() }) + + test('navigation retries a failed joined preload without starving sibling work', async () => { + const preloadParentStarted = createControlledPromise() + const preloadFailureGate = createControlledPromise() + const navigationStarted = createControlledPromise() + const navigationParentRetryStarted = createControlledPromise() + const childStarted = createControlledPromise() + const childGate = createControlledPromise() + let parentLoads = 0 + let childLoads = 0 + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: ({ preload }) => { + if (!preload) { + navigationStarted.resolve() + } + }, + loader: async ({ preload }) => { + parentLoads++ + if (preload) { + preloadParentStarted.resolve() + await preloadFailureGate + throw new Error('preload failed') + } + navigationParentRetryStarted.resolve() + return 'navigation data' + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async () => { + childLoads++ + childStarted.resolve() + return childGate + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/parent/child' } as any) + await Promise.all([preloadParentStarted, childStarted]) + expect(parentLoads).toBe(1) + expect(childLoads).toBe(1) + + const navigation = router.navigate({ to: '/parent/child' }) + await Promise.resolve() + expect(preloadFailureGate.status).toBe('pending') + expect(parentLoads).toBe(1) + expect(childLoads).toBe(1) + + preloadFailureGate.resolve() + // An ordinary ancestor failure waits for every started descendant so a + // later redirect can still win before navigation retries. + childGate.resolve('child data') + await navigationStarted + await navigationParentRetryStarted + expect(parentLoads).toBe(2) + expect(childLoads).toBe(1) + + await Promise.all([navigation, preload]) + + expect(parentLoads).toBe(2) + expect(childLoads).toBe(1) + expect(router.state.location.pathname).toBe('/parent/child') + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.loaderData, + ).toBe('navigation data') + expect( + router.state.matches.find((match) => match.routeId === childRoute.id) + ?.loaderData, + ).toBe('child data') + }) + + test('a route with preload disabled does not discard its preloaded ancestor', async () => { + const parentLoader = vi.fn(() => 'parent data') + const childLoader = vi.fn(() => 'child data') + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + preloadStaleTime: Infinity, + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + preload: false, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' } as any) + await router.navigate({ to: '/parent/child' }) + + expect(parentLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.loaderData, + ).toBe('parent data') + expect( + router.state.matches.find((match) => match.routeId === childRoute.id) + ?.loaderData, + ).toBe('child data') + }) }) diff --git a/packages/router-core/tests/preload-beforeload-reuse.test.ts b/packages/router-core/tests/preload-beforeload-reuse.test.ts index 9a15d00282..84cdb5bfde 100644 --- a/packages/router-core/tests/preload-beforeload-reuse.test.ts +++ b/packages/router-core/tests/preload-beforeload-reuse.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' -import { createTestRouter } from './routerTestUtils' +import { createTestRouter, loadServerResponse } from './routerTestUtils' afterEach(() => { vi.useRealTimers() @@ -90,6 +90,48 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { }) }) + test('adopts beforeLoad and loader from an identical active preload', async () => { + const loaderGate = createControlledPromise() + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + guard: preload ? 'preloaded' : 'loaded', + })) + const loader = vi.fn(() => loaderGate) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + preloadStaleTime: Infinity, + beforeLoad, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/guarded' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + + const navigation = router.navigate({ to: '/guarded' }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + ]) + expect(loader).toHaveBeenCalledTimes(1) + + loaderGate.resolve('shared loader data') + await Promise.all([preload, navigation]) + + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'preloaded' }) + expect(router.state.matches.at(-1)?.loaderData).toBe('shared loader data') + }) + test.each([ { age: 50, expected: [false, true, false], guard: 'loaded' }, { age: 100, expected: [false, true, false], guard: 'loaded' }, @@ -440,4 +482,185 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { expect(seen).toEqual([true, false]) expect(loader).toHaveBeenCalledTimes(1) }) + + test('shouldReload remains scoped to loader data', async () => { + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + guard: preload ? 'preloaded' : 'loaded', + })) + const loader = vi.fn(({ preload }: { preload: boolean }) => preload) + const shouldReload = vi.fn(() => true) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + staleTime: Infinity, + beforeLoad, + shouldReload, + loader: { + staleReloadMode: 'blocking', + handler: loader, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/guarded' }) + await router.navigate({ to: '/guarded' }) + + expect(beforeLoad).toHaveBeenCalledTimes(2) + expect(loader).toHaveBeenCalledTimes(2) + expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + false, + ]) + expect(shouldReload).toHaveBeenCalledTimes(1) + expect(shouldReload).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ guard: 'loaded' }), + preload: false, + }), + ) + }) + + test('shouldReload false does not keep stale beforeLoad context', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + guard: preload ? 'preloaded' : 'loaded', + })) + const loader = vi.fn(({ preload }: { preload: boolean }) => preload) + const shouldReload = vi.fn(() => false) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + staleTime: Infinity, + preloadStaleTime: 100, + beforeLoad, + shouldReload, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/guarded' }) + vi.setSystemTime(1_100) + await router.navigate({ to: '/guarded' }) + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + false, + ]) + expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + ]) + expect(shouldReload).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'loaded' }) + }) + + test.each([false, true])( + 'a beforeLoad failure clears context from the previous generation with isServer=%s', + async (isServer) => { + const failure = new Error('guard failed') + let fail = false + const rootRoute = new BaseRootRoute({}) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + context: () => ({ routeContext: true }), + beforeLoad: () => { + if (fail) { + throw failure + } + return { guard: 'accepted' } + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/guarded'] }), + isServer, + }) + + await router.load() + expect(router.state.matches.at(-1)?.context).toEqual({ + routeContext: true, + guard: 'accepted', + }) + + fail = true + if (isServer) { + const response = await loadServerResponse(router, '/guarded') + expect(response.status).toBe(500) + } else { + await router.load() + } + + const failedMatch = router.state.matches.at(-1) + expect(failedMatch).toMatchObject({ + status: 'error', + error: failure, + }) + expect(failedMatch?.context).toEqual({ routeContext: true }) + }, + ) + + test.each([false, true])( + 'a validation failure clears context from the previous generation with isServer=%s', + async (isServer) => { + let fail = false + const rootRoute = new BaseRootRoute({}) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + validateSearch: () => { + if (fail) { + throw new Error('invalid search') + } + return {} + }, + context: () => ({ routeContext: true }), + beforeLoad: () => ({ guard: 'accepted' }), + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/guarded'] }), + isServer, + }) + + await router.load() + expect(router.state.matches.at(-1)?.context).toEqual({ + routeContext: true, + guard: 'accepted', + }) + fail = true + if (isServer) { + const response = await loadServerResponse(router, '/guarded') + expect(response.status).toBe(500) + } else { + await router.load() + } + + const failedMatch = router.state.matches.at(-1) + expect(failedMatch).toMatchObject({ + status: 'error', + }) + expect(failedMatch?.context).toEqual({ routeContext: true }) + }, + ) }) diff --git a/packages/router-core/tests/preload-navigation-adoption.test.ts b/packages/router-core/tests/preload-navigation-adoption.test.ts index 4d656bb16d..675a69b037 100644 --- a/packages/router-core/tests/preload-navigation-adoption.test.ts +++ b/packages/router-core/tests/preload-navigation-adoption.test.ts @@ -3,7 +3,85 @@ import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' +// A navigation that adopts an in-flight preload's loader run must reuse that +// run: it must not abort the preload's loader signal or rerun the loader. This +// is generic router adoption coverage; issue #4476 is covered separately with +// the React Query observer-unmount sequence from its reproduction. describe('navigation adopting an in-flight preload', () => { + test('adopted preload loader runs once and its signal is not aborted', async () => { + const loaderGate = createControlledPromise() + const loaderStarted = createControlledPromise() + const beforeLoad = vi.fn() + let preloadSignal: AbortSignal | undefined + const loader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + preloadSignal = abortController.signal + loaderStarted.resolve() + return loaderGate + }, + ) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad, + loader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + // Start the preload and wait until its loader is actually in flight. + const preload = router.preloadRoute({ to: '/foo' }) + await loaderStarted + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + ]) + expect(loader).toHaveBeenCalledTimes(1) + expect(preloadSignal).toBeDefined() + expect(preloadSignal?.aborted).toBe(false) + + // The identical navigation adopts the whole active lane, including its + // already-completed beforeLoad context. + const navigation = router.navigate({ to: '/foo' }) + await Promise.resolve() + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + ]) + expect(loaderGate.status).toBe('pending') + expect(loader).toHaveBeenCalledTimes(1) + expect(preloadSignal?.aborted).toBe(false) + + // Resolve the shared loader and let both settle. + loaderGate.resolve('adopted') + await Promise.all([navigation, preload]) + + // Loader ran exactly once; the adopted run's signal was never aborted. + expect(loader).toHaveBeenCalledTimes(1) + expect(preloadSignal?.aborted).toBe(false) + + // Navigation committed with the adopted loaderData. + const committed = router.state.matches.find( + (match) => match.routeId === fooRoute.id, + ) + expect(committed?.status).toBe('success') + expect(committed?.loaderData).toBe('adopted') + + // Adoption transfers loader-data lifetime ownership to the active match. + // The shared request remains alive while rendered, then aborts on unload. + await router.navigate({ to: '/' }) + expect(preloadSignal?.aborted).toBe(true) + }) + test('reruns beforeLoad when router context changes during an active preload', async () => { const loaderGate = createControlledPromise() const beforeLoad = vi.fn( diff --git a/packages/router-core/tests/preload-public-cache-behavior.test.ts b/packages/router-core/tests/preload-public-cache-behavior.test.ts index 2d74c32da8..5a7b6b2b98 100644 --- a/packages/router-core/tests/preload-public-cache-behavior.test.ts +++ b/packages/router-core/tests/preload-public-cache-behavior.test.ts @@ -1,8 +1,118 @@ -import { expect, test, vi } from 'vitest' +import { afterEach, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +// https://github.com/TanStack/router/issues/2980 +// Repeated child preloads must borrow a stale active parent instead of rerunning +// its loader. +test('#2980: repeated child preloads do not rerun a stale active parent loader', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + + const layoutLoader = vi.fn(() => 'layout data') + const childLoader = vi.fn((childId: string) => `child ${childId}`) + const rootRoute = new BaseRootRoute({}) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/layout', + staleTime: 10, + loader: layoutLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '$childId', + loader: ({ params }) => childLoader(params.childId), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([layoutRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/layout'] }), + }) + + await router.load() + expect(layoutLoader).toHaveBeenCalledTimes(1) + vi.setSystemTime(2_000) + + await router.preloadRoute({ + to: '/layout/$childId', + params: { childId: 'one' }, + }) + expect(layoutLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenNthCalledWith(1, 'one') + + await router.preloadRoute({ + to: '/layout/$childId', + params: { childId: 'two' }, + }) + + expect(layoutLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(2) + expect(childLoader).toHaveBeenNthCalledWith(2, 'two') +}) + +// Public contract: the public stale/gc options determine whether user loader +// code runs. A read-only preload must not silently shorten a navigation-owned +// cache entry's lifetime. +test('fresh navigation data keeps its gc policy when a preload reuses it', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + + let revision = 0 + const loader = vi.fn(() => ++revision) + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + gcTime: 30 * 60_000, + preloadGcTime: 30_000, + preloadStaleTime: 10 * 60_000, + staleTime: Infinity, + loader, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, reportsRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/reports' }) + await router.navigate({ to: '/' }) + expect(loader).toHaveBeenCalledTimes(1) + + vi.setSystemTime(61_000) + await router.preloadRoute({ to: '/reports' }) + await router.navigate({ to: '/other' }) + await router.navigate({ to: '/reports' }) + expect(loader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === reportsRoute.id) + ?.loaderData, + ).toBe(1) + + await router.navigate({ to: '/' }) + vi.setSystemTime(1_000 + 31 * 60_000) + await router.navigate({ to: '/other' }) + await router.navigate({ to: '/reports' }) + expect(loader).toHaveBeenCalledTimes(2) + expect( + router.state.matches.find((match) => match.routeId === reportsRoute.id) + ?.loaderData, + ).toBe(2) +}) + // Public contract: clearCache must remain authoritative even if an older // preload finishes unrelated asset work after the clear. test('clearCache during an in-flight preload cannot resurrect its borrowed data', async () => { @@ -92,6 +202,171 @@ test('clearCache cancels a brand-new in-flight preload before it can populate th ).toBe('navigation data') }) +test('clearCache installs replacement authorities before abort listeners reenter', async () => { + let reentrantNavigation: Promise | undefined + let router: ReturnType + const loader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + const generation = loader.mock.calls.length + if (generation !== 2) { + return `generation ${generation}` + } + return new Promise((_resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => { + reentrantNavigation = router.navigate({ to: '/target' }) + reject(abortController.signal.reason) + }, + { once: true }, + ) + }) + }, + ) + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + staleTime: Infinity, + preloadStaleTime: 0, + loader, + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/target' }) + await router.navigate({ to: '/' }) + + const preload = router.preloadRoute({ to: '/target' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + router.clearCache() + + await reentrantNavigation + await preload + expect(loader).toHaveBeenCalledTimes(3) + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)?.loaderData).toBe('generation 3') +}) + +test('clearCache detaches every discarded flight before abort listeners reenter', async () => { + let reentrantNavigation: Promise | undefined + let router: ReturnType + const firstBeforeLoad = vi.fn( + ({ abortController }: { abortController: AbortController }) => + new Promise((_resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => { + reentrantNavigation = router.navigate({ to: '/second' }) + reject(abortController.signal.reason) + }, + { once: true }, + ) + }), + ) + const secondLoader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + const generation = secondLoader.mock.calls.length + if (generation > 1) { + return `generation ${generation}` + } + return new Promise((_resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => reject(abortController.signal.reason), + { once: true }, + ) + }) + }, + ) + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/first', + beforeLoad: firstBeforeLoad, + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: secondLoader, + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const firstPreload = router.preloadRoute({ to: '/first' }) + await vi.waitFor(() => expect(firstBeforeLoad).toHaveBeenCalledOnce()) + const secondPreload = router.preloadRoute({ to: '/second' }) + await vi.waitFor(() => expect(secondLoader).toHaveBeenCalledOnce()) + + router.clearCache() + + await reentrantNavigation + await Promise.all([firstPreload, secondPreload]) + expect(secondLoader).toHaveBeenCalledTimes(2) + expect(router.state.location.pathname).toBe('/second') + expect(router.state.matches.at(-1)?.loaderData).toBe('generation 2') +}) + +test('independent concurrent preloads both populate the cache', async () => { + const firstGate = createControlledPromise() + const secondGate = createControlledPromise() + const firstLoader = vi.fn(() => firstGate) + const secondLoader = vi.fn(() => secondGate) + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/first', + preloadStaleTime: Infinity, + loader: firstLoader, + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/second', + preloadStaleTime: Infinity, + loader: secondLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const firstPreload = router.preloadRoute({ to: '/first' }) + const secondPreload = router.preloadRoute({ to: '/second' }) + await vi.waitFor(() => { + expect(firstLoader).toHaveBeenCalledOnce() + expect(secondLoader).toHaveBeenCalledOnce() + }) + + firstGate.resolve('first data') + await firstPreload + secondGate.resolve('second data') + await secondPreload + + await router.navigate({ to: '/first' }) + await router.navigate({ to: '/second' }) + expect(firstLoader).toHaveBeenCalledOnce() + expect(secondLoader).toHaveBeenCalledOnce() +}) + // Probe, not a required cancellation policy: invalidating the accepted route // need not cancel unrelated private preload work, but the public operations // must both settle and leave navigation usable. diff --git a/packages/router-core/tests/preload-public-signal-lifetime.test.ts b/packages/router-core/tests/preload-public-signal-lifetime.test.ts new file mode 100644 index 0000000000..b1bc87e65c --- /dev/null +++ b/packages/router-core/tests/preload-public-signal-lifetime.test.ts @@ -0,0 +1,124 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +// Public contract: the AbortSignal supplied to loader code belongs to the +// accepted data generation. Adopting preloaded data keeps it alive; accepting +// replacement data aborts it; unloading aborts the replacement generation. +test('an adopted loader signal lives until public replacement and unload', async () => { + const replacementGate = createControlledPromise<{ generation: number }>() + let generation = 0 + let adoptedSignal: AbortSignal | undefined + let replacementSignal: AbortSignal | undefined + const loader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + generation++ + if (generation === 1) { + adoptedSignal = abortController.signal + return { generation } + } + + replacementSignal = abortController.signal + return replacementGate + }, + ) + + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + staleTime: Infinity, + preloadStaleTime: Infinity, + loader: { + staleReloadMode: 'blocking', + handler: loader, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/reports' }) + expect(adoptedSignal?.aborted).toBe(false) + + await router.navigate({ to: '/reports' }) + expect(loader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === reportsRoute.id) + ?.loaderData, + ).toEqual({ generation: 1 }) + expect(adoptedSignal?.aborted).toBe(false) + + const replacement = router.invalidate({ forcePending: true }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + expect(replacementGate.status).toBe('pending') + + // Until replacement data is accepted, the currently rendered data still + // owns the adopted signal. + expect(adoptedSignal?.aborted).toBe(false) + expect(replacementSignal?.aborted).toBe(false) + + replacementGate.resolve({ generation: 2 }) + await replacement + expect( + router.state.matches.find((match) => match.routeId === reportsRoute.id) + ?.loaderData, + ).toEqual({ generation: 2 }) + expect(adoptedSignal?.aborted).toBe(true) + expect(replacementSignal?.aborted).toBe(false) + + await router.navigate({ to: '/' }) + expect(replacementSignal?.aborted).toBe(true) +}) + +test('a superseded preload releases its borrowed loader signal lease', async () => { + let parentSignal: AbortSignal | undefined + let navigation!: Promise + + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: ({ abortController }) => { + parentSignal = abortController.signal + return 'parent data' + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: ({ navigate, preload }) => { + if (preload) { + navigation = navigate({ to: '/' }) + throw navigation + } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + homeRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + }) + + await router.load() + expect(parentSignal?.aborted).toBe(false) + + await router.preloadRoute({ to: '/parent/child' }) + await navigation + + expect(router.state.location.pathname).toBe('/') + expect(parentSignal?.aborted).toBe(true) +}) diff --git a/packages/router-core/tests/public-client-loading-contract.test.ts b/packages/router-core/tests/public-client-loading-contract.test.ts index 50b152ac6a..5df38ee0db 100644 --- a/packages/router-core/tests/public-client-loading-contract.test.ts +++ b/packages/router-core/tests/public-client-loading-contract.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, @@ -8,6 +8,11 @@ import { } from '../src' import { createTestRouter } from './routerTestUtils' +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + describe('public client loading contracts', () => { test('blocking loading is observable through match isFetching', async () => { const loaderGate = createControlledPromise() @@ -50,6 +55,209 @@ describe('public client loading contracts', () => { }) }) + test('background loading is observable while retaining committed data', async () => { + const reloadGate = createControlledPromise<{ generation: number }>() + let loaderCalls = 0 + const loader = vi.fn(() => + ++loaderCalls === 1 ? { generation: 1 } : reloadGate, + ) + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + staleTime: Infinity, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + const invalidation = router.invalidate() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + isFetching: 'loader', + loaderData: { generation: 1 }, + }) + + reloadGate.resolve({ generation: 2 }) + await invalidation + await vi.waitFor(() => + expect(router.state.matches.at(-1)).toMatchObject({ + status: 'success', + isFetching: false, + loaderData: { generation: 2 }, + }), + ) + }) + + test('a background loader can fulfill after aborting its controller', async () => { + let loaderCalls = 0 + const loader = vi.fn(({ abortController }) => { + loaderCalls++ + if (loaderCalls === 1) { + return { generation: 1 } + } + abortController.abort() + return { generation: 2 } + }) + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + staleTime: Infinity, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + await router.invalidate() + + expect(loader).toHaveBeenCalledTimes(2) + await vi.waitFor(() => + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + isFetching: false, + loaderData: { generation: 2 }, + }), + ) + expect(router.state.status).toBe('idle') + }) + + test('a current loader can fulfill after aborting its controller', async () => { + const loader = vi.fn(({ abortController }) => { + abortController.abort() + return 'accepted data' + }) + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + staleTime: Infinity, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + await router.load() + + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.status).toBe('idle') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + loaderData: 'accepted data', + }) + }) + + test('background redirects use the navigation redirect limit', async () => { + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls++ + if (loaderCalls === 1) { + return 'initial data' + } + if (loaderCalls < 30) { + throw redirect({ to: '/page' }) + } + return 'redirect limit was bypassed' + }) + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + await router.invalidate() + + expect(loader).toHaveBeenCalledTimes(22) + await vi.waitFor(() => + expect( + router.state.matches.find((match) => match.status !== 'success'), + ).toMatchObject({ + routeId: rootRoute.id, + status: 'error', + error: expect.objectContaining({ message: 'Too many redirects' }), + }), + ) + }) + + test('a blocking child observes the fresh semantic parent generation', async () => { + const parentReload = createControlledPromise<{ revision: number }>() + let parentCalls = 0 + const parentLoader = vi.fn(() => + ++parentCalls === 1 ? { revision: 1 } : parentReload, + ) + const childLoader = vi.fn(async ({ parentMatchPromise }) => { + const parent = await parentMatchPromise + return { + parentRevision: (parent.loaderData as { revision: number }).revision, + } + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + staleTime: Infinity, + shouldReload: true, + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: { + staleReloadMode: 'blocking', + handler: childLoader, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + }) + + await router.load() + + let settled = false + const navigation = router.navigate({ to: '/parent/child' }).then(() => { + settled = true + }) + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + + expect(parentReload.status).toBe('pending') + expect(settled).toBe(false) + + parentReload.resolve({ revision: 2 }) + await navigation + + await vi.waitFor(() => + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ loaderData: { revision: 2 } }), + ) + expect( + router.state.matches.find((match) => match.routeId === childRoute.id), + ).toMatchObject({ loaderData: { parentRevision: 2 } }) + }) + test('a background descendant redirect wins after a blocking ancestor loader fails', async () => { const childStarted = createControlledPromise() const childRedirect = createControlledPromise() @@ -112,4 +320,318 @@ describe('public client loading contracts', () => { router.state.matches.some((match) => match.error === parentError), ).toBe(false) }) + + test('a hidden background descendant failure cannot replace an ancestor failure', async () => { + const childStarted = createControlledPromise() + const parentError = new Error('parent failed') + const childError = new Error('child failed') + let parentLoads = 0 + let childLoads = 0 + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: { + staleReloadMode: 'blocking', + handler: async () => { + if (++parentLoads === 1) { + return 'parent data' + } + await childStarted + throw parentError + }, + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + if (++childLoads === 1) { + return 'child data' + } + childStarted.resolve() + throw childError + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + await router.invalidate() + + 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: 'error', error: parentError }) + expect( + router.state.matches.find((match) => match.routeId === childRoute.id), + ).toMatchObject({ + status: 'success', + error: undefined, + isFetching: false, + loaderData: 'child data', + }) + expect( + router.state.matches.some((match) => match.error === childError), + ).toBe(false) + }) + + test('background projection stays private until its lane wins publication', async () => { + const reloadGate = createControlledPromise<{ title: string }>() + const headGate = createControlledPromise() + const freshHeadStarted = createControlledPromise() + let loaderCalls = 0 + let allowReload = true + const loader = vi.fn(() => + ++loaderCalls === 1 ? { title: 'old' } : reloadGate, + ) + const head = vi.fn(async ({ loaderData }) => { + if (loaderData?.title === 'fresh') { + freshHeadStarted.resolve() + await headGate + } + return { meta: [{ title: loaderData?.title }] } + }) + + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + staleTime: Infinity, + shouldReload: () => allowReload, + loader, + head, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + allowReload = false + await router.load() + expect(router.state.matches.at(-1)).toMatchObject({ + loaderData: { title: 'old' }, + meta: [{ title: 'old' }], + }) + + allowReload = true + const invalidation = router.invalidate() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + reloadGate.resolve({ title: 'fresh' }) + await freshHeadStarted + + expect(router.state.matches.at(-1)).toMatchObject({ + loaderData: { title: 'old' }, + meta: [{ title: 'old' }], + }) + + await router.navigate({ to: '/other' }) + allowReload = false + headGate.resolve() + await invalidation + await Promise.resolve() + + await router.navigate({ to: '/page' }) + expect(router.state.matches.at(-1)).toMatchObject({ + loaderData: { title: 'old' }, + meta: [{ title: 'old' }], + isFetching: false, + }) + }) + + test('redirect depth bookkeeping is cleared after a redirect chain settles', async () => { + const hopBeforeLoad = vi.fn(({ params }) => { + const hop = Number(params.hop) + if (hop < 20) { + throw redirect({ + to: '/hop/$hop', + params: { hop: String(hop + 1) }, + } as any) + } + }) + const rootRoute = new BaseRootRoute({}) + const hopRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hop/$hop', + beforeLoad: hopBeforeLoad, + }) + const againRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/again', + beforeLoad: () => { + throw redirect({ to: '/final' }) + }, + }) + const finalRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/final', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([hopRoute, againRoute, finalRoute]), + history: createMemoryHistory({ initialEntries: ['/hop/0'] }), + }) + + await router.load() + expect(router.state.location.pathname).toBe('/hop/20') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: hopRoute.id, + status: 'success', + }) + + await router.navigate({ to: '/again' }) + expect(router.state.location.pathname).toBe('/final') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: finalRoute.id, + status: 'success', + }) + expect(router.state.status).toBe('idle') + }) + + test('a redirect-limit error aborts concurrent work in its final lane', async () => { + const finalRootAborted = createControlledPromise() + const finalRootData = createControlledPromise() + const errorComponentStarted = createControlledPromise() + const errorComponentGate = createControlledPromise() + let finalRootSignal: AbortSignal | undefined + const ErrorComponent = Object.assign(() => null, { + preload: () => { + errorComponentStarted.resolve() + return errorComponentGate + }, + }) + + const rootRoute = new BaseRootRoute({ + errorComponent: ErrorComponent as any, + loader: ({ location, abortController }) => { + if (location.pathname !== '/hop/20/child') { + return 'root data' + } + finalRootSignal = abortController.signal + finalRootSignal.addEventListener( + 'abort', + () => finalRootAborted.resolve(), + { once: true }, + ) + return finalRootData + }, + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hop/$hop', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ params }) => { + throw redirect({ + to: '/hop/$hop/child', + params: { hop: String(Number(params.hop) + 1) }, + } as any) + }, + }) + const cleanupRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/cleanup', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + cleanupRoute, + ]), + history: createMemoryHistory({ + initialEntries: ['/hop/0/child'], + }), + }) + const loading = router.load() + + try { + await errorComponentStarted + expect(finalRootData.status).toBe('pending') + errorComponentGate.resolve() + await finalRootAborted + await expect(loading).resolves.toBeUndefined() + + expect(router.state.location.pathname).toBe('/hop/20/child') + const firstTerminalMatch = router.state.matches.find( + (match) => match.status !== 'success', + ) + expect(firstTerminalMatch).toMatchObject({ + routeId: rootRoute.id, + status: 'error', + isFetching: false, + error: expect.objectContaining({ message: 'Too many redirects' }), + }) + expect(finalRootData.status).toBe('pending') + expect(finalRootSignal?.aborted).toBe(true) + expect( + router.state.matches.every((match) => match.isFetching === false), + ).toBe(true) + expect(router.state.status).toBe('idle') + expect(router.state.isLoading).toBe(false) + } finally { + finalRootData.resolve('late root data') + errorComponentGate.resolve() + await Promise.allSettled([loading]) + } + + await router.navigate({ to: '/cleanup' }) + expect(router.state.location.pathname).toBe('/cleanup') + expect( + router.state.matches.every((match) => match.status === 'success'), + ).toBe(true) + expect(router.state.status).toBe('idle') + }) + + test('a redirect is not blocked by an abort-ignoring sibling loader', async () => { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => new Promise(() => {}), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/source/child' }) + + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + }) + }) }) diff --git a/packages/router-core/tests/public-hydration-contract.test.ts b/packages/router-core/tests/public-hydration-contract.test.ts new file mode 100644 index 0000000000..b721135e2f --- /dev/null +++ b/packages/router-core/tests/public-hydration-contract.test.ts @@ -0,0 +1,1485 @@ +import { runInNewContext } from 'node:vm' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + redirect, +} from '../src' +import { hydrate } from '../src/ssr/client' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouteMatch, AnyRouter } from '../src' +import type { DehydratedRouter, TsrSsrGlobal } from '../src/ssr/types' +import type { ServerManifest } from '../src/manifest' + +const testManifest: ServerManifest = { routes: {} } + +async function dehydrateToBootstrap(router: AnyRouter): Promise { + attachRouterServerSsrUtils({ router, manifest: testManifest }) + try { + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + + const context: Record = { + document: { currentScript: { remove() {} } }, + } + context.self = context + runInNewContext(script!.children!, context) + + return context.$_TSR + } finally { + router.serverSsr?.cleanup() + } +} + +function installHydrationPayload( + mockWindow: { $_TSR?: TsrSsrGlobal }, + matches: DehydratedRouter['matches'], +) { + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches, + }, + h() {}, + e() {}, + c() {}, + p() {}, + buffer: [], + } +} + +describe('public hydration contracts', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + vi.stubGlobal('window', mockWindow) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + test('starts head and scripts together and swallows a head failure', async () => { + const error = new Error('head failed') + const headGate = createControlledPromise() + const head = vi.fn(async () => { + await headGate + throw error + }) + const scripts = vi.fn(() => [ + { children: 'window.hydrationScriptRan = true' }, + ]) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + head, + scripts, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + matches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + ) + + const hydration = hydrate(router) + await vi.waitFor(() => { + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + }) + + headGate.resolve() + await expect(hydration).resolves.toBeUndefined() + + expect(consoleError).toHaveBeenCalledWith(error) + expect(router.state.resolvedLocation?.pathname).toBe('/page') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + }) + }) + + test('adopts a shorter selective-SSR prefix ending at a pending client-only match', async () => { + const serverLoader = vi.fn(() => 'server data') + const serverRootRoute = new BaseRootRoute({}) + const serverClientOnlyRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/client-only', + ssr: false, + loader: serverLoader, + }) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverClientOnlyRoute, + path: '/child', + ssr: true, + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverClientOnlyRoute.addChildren([serverChildRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/client-only/child'], + }), + isServer: true, + }) + const bootstrap = await dehydrateToBootstrap(serverRouter) + expect(bootstrap.router?.matches).toHaveLength(2) + expect(bootstrap.router?.matches.at(-1)).toMatchObject({ + s: 'pending', + ssr: false, + }) + expect(serverLoader).not.toHaveBeenCalled() + + const routeContext = vi.fn(() => ({ routeContext: true })) + const beforeLoad = vi.fn(() => ({ clientContext: true })) + const loader = vi.fn( + ({ context }) => context.clientContext && context.routeContext, + ) + const rootRoute = new BaseRootRoute({}) + const clientOnlyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/client-only', + ssr: false, + context: routeContext, + beforeLoad, + loader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => clientOnlyRoute, + path: '/child', + ssr: true, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + clientOnlyRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/client-only/child'], + }), + isServer: false, + }) + mockWindow.$_TSR = bootstrap + + await hydrate(router) + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + clientOnlyRoute.id, + childRoute.id, + ]) + expect(router.state.matches.slice(1)).toMatchObject([ + { routeId: clientOnlyRoute.id, status: 'pending', ssr: false }, + { routeId: childRoute.id, ssr: true }, + ]) + expect(router.state.resolvedLocation).toBeUndefined() + expect(router.state.matches[1]?.context).toEqual({ routeContext: true }) + expect(routeContext).toHaveBeenCalledTimes(1) + expect(beforeLoad).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + + await router.load() + + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(loader).toHaveBeenCalledTimes(1) + expect(routeContext).toHaveBeenCalledTimes(1) + expect(router.state.matches[1]).toMatchObject({ + routeId: clientOnlyRoute.id, + status: 'success', + loaderData: true, + }) + expect(router.state.resolvedLocation?.href).toBe('/client-only/child') + }) + + test('selective SSR exposes the complete accepted lane to route context and head hooks', async () => { + const serverRootRoute = new BaseRootRoute({}) + const serverClientOnlyRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/client-only', + ssr: false, + }) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverClientOnlyRoute, + path: '/child', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverClientOnlyRoute.addChildren([serverChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/client-only/child'] }), + isServer: true, + }) + const bootstrap = await dehydrateToBootstrap(serverRouter) + + const contextLanes: Array> = [] + const headLanes: Array> = [] + const rootRoute = new BaseRootRoute({ + context: ({ matches }) => { + contextLanes.push(matches.map((match) => match.routeId)) + return { hydrated: true } + }, + head: ({ matches }) => { + headLanes.push(matches.map((match) => match.routeId)) + return { + meta: [ + { + title: String(matches[1]?.loaderData), + }, + ], + } + }, + }) + const clientOnlyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/client-only', + ssr: false, + loader: () => 'client data', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => clientOnlyRoute, + path: '/child', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + clientOnlyRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/client-only/child'] }), + isServer: false, + }) + mockWindow.$_TSR = bootstrap + + await hydrate(router) + + const fullLane = [rootRoute.id, clientOnlyRoute.id, childRoute.id] + expect(contextLanes).toEqual([fullLane]) + expect(headLanes).toEqual([fullLane]) + expect(router.state.matches.map((match) => match.routeId)).toEqual(fullLane) + expect(router.state.matches[0]?.context).toMatchObject({ hydrated: true }) + expect(router.state.matches[0]?.meta).toEqual([{ title: 'undefined' }]) + + await router.load() + + expect(router.state.matches[0]?.meta).toEqual([{ title: 'client data' }]) + }) + + test('shell hydration exposes the complete accepted lane to route context and head hooks', async () => { + const contextLanes: Array> = [] + const headLanes: Array> = [] + const rootRoute = new BaseRootRoute({ + context: ({ matches }) => { + contextLanes.push(matches.map((match) => match.routeId)) + return { hydrated: true } + }, + head: ({ matches }) => { + headLanes.push(matches.map((match) => match.routeId)) + return { meta: [{ title: 'Shell hydration' }] } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + ssr: false, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + ssr: true, + u: Date.now(), + }, + ]) + + await hydrate(router) + + const fullLane = [rootRoute.id, childRoute.id] + expect(contextLanes).toEqual([fullLane]) + expect(headLanes).toEqual([fullLane]) + expect(router.state.matches.map((match) => match.routeId)).toEqual(fullLane) + expect(router.state.matches[0]?.context).toMatchObject({ hydrated: true }) + expect(router.state.matches[0]?.meta).toEqual([ + { title: 'Shell hydration' }, + ]) + }) + + test('terminal hydration exposes the full structural lane to route context and head hooks', async () => { + const contextLanes: Array> = [] + const headLanes: Array> = [] + const observeContext = ({ matches }: { matches: Array }) => { + contextLanes.push(matches.map((match) => match.routeId)) + return {} + } + const observeHead = ({ matches }: { matches: Array }) => { + headLanes.push(matches.map((match) => match.routeId)) + return { meta: [{ title: 'Terminal hydration' }] } + } + const rootRoute = new BaseRootRoute({ + context: observeContext, + head: observeHead, + }) + const boundaryRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/app', + context: observeContext, + head: observeHead, + errorComponent: () => 'App error', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => boundaryRoute, + path: '/child', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + boundaryRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/app/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'error', + e: new Error('App failed on the server'), + ssr: true, + u: Date.now(), + }, + ]) + + await hydrate(router) + + const fullLane = [rootRoute.id, boundaryRoute.id, childRoute.id] + expect(contextLanes).toEqual([fullLane, fullLane]) + expect(headLanes).toEqual([fullLane, fullLane]) + expect(router.state.matches.map((match) => match.routeId)).toEqual(fullLane) + expect(router.state.matches[1]).toMatchObject({ + routeId: boundaryRoute.id, + status: 'error', + }) + expect(router.state.matches[2]?.status).toBe('success') + }) + + test('does not cache missing loader data when retrying a hydrated terminal boundary', async () => { + const serverError = new Error('Server boundary failed') + const chunkError = new Error('Boundary chunk failed during hydration') + const errorComponentPreload = vi + .fn<() => Promise>() + .mockRejectedValueOnce(chunkError) + .mockResolvedValue(undefined) + const ErrorComponent = Object.assign(() => 'App error', { + preload: errorComponentPreload, + }) + const beforeLoad = vi.fn() + const loader = vi.fn(() => 'client data') + const rootRoute = new BaseRootRoute({}) + const boundaryRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/app', + beforeLoad, + loader, + errorComponent: ErrorComponent, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => boundaryRoute, + path: '/child', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + boundaryRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/app/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'error', + e: serverError, + ssr: true, + u: Date.now(), + }, + ]) + + await hydrate(router) + + expect(errorComponentPreload).toHaveBeenCalledTimes(1) + expect(beforeLoad).not.toHaveBeenCalled() + expect(router.state.resolvedLocation).toBeUndefined() + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + boundaryRoute.id, + childRoute.id, + ]) + expect(router.state.matches[1]).toMatchObject({ + status: 'error', + error: serverError, + }) + + await router.load() + + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.resolvedLocation?.href).toBe('/app/child') + expect(router.state.matches[1]).toMatchObject({ + status: 'success', + error: undefined, + loaderData: 'client data', + }) + }) + + test('retries a terminal lane after ancestor context reconstruction fails', async () => { + const serverError = new Error('Server boundary failed') + const clientError = new Error('Client boundary failed') + const contextError = new Error('Root context failed during hydration') + const context = vi + .fn<() => { clientContext: true }>() + .mockImplementationOnce(() => { + throw contextError + }) + .mockReturnValue({ clientContext: true }) + const beforeLoad = vi.fn(() => { + throw clientError + }) + const rootRoute = new BaseRootRoute({ context }) + const boundaryRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/app', + beforeLoad, + errorComponent: () => 'App error', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => boundaryRoute, + path: '/child', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + boundaryRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/app/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'error', + e: serverError, + ssr: true, + u: Date.now(), + }, + ]) + + await hydrate(router) + + expect(context).toHaveBeenCalledTimes(1) + expect(beforeLoad).not.toHaveBeenCalled() + expect(router.state.resolvedLocation).toBeUndefined() + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + boundaryRoute.id, + childRoute.id, + ]) + expect(router.state.matches[1]).toMatchObject({ + status: 'error', + error: serverError, + }) + + await router.load() + + expect(context).toHaveBeenCalledTimes(2) + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(router.state.resolvedLocation?.href).toBe('/app/child') + expect(router.state.matches[0]?.context).toMatchObject({ + clientContext: true, + }) + expect(router.state.matches[1]).toMatchObject({ + status: 'error', + error: clientError, + }) + }) + + test('does not repeat data-only assets during client continuation', async () => { + const serverRootRoute = new BaseRootRoute({}) + const serverPageRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/page', + ssr: 'data-only', + loader: () => 'server data', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverPageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: true, + }) + const bootstrap = await dehydrateToBootstrap(serverRouter) + + const head = vi.fn(({ loaderData }) => ({ + meta: [{ title: String(loaderData) }], + })) + const scripts = vi.fn(({ loaderData }) => [ + { children: `window.pageData = ${JSON.stringify(loaderData)}` }, + ]) + const loader = vi.fn(() => 'client data') + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + ssr: 'data-only', + loader, + head, + scripts, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + mockWindow.$_TSR = bootstrap + + await hydrate(router) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'pending', + loaderData: 'server data', + meta: [{ title: 'server data' }], + scripts: [{ children: 'window.pageData = "server data"' }], + }) + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + expect(loader).not.toHaveBeenCalled() + + await router.load() + + expect(router.state.resolvedLocation?.pathname).toBe('/page') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + loaderData: 'server data', + meta: [{ title: 'server data' }], + scripts: [{ children: 'window.pageData = "server data"' }], + }) + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + expect(loader).not.toHaveBeenCalled() + }) + + test('adopts the complete nested data-only server prefix', async () => { + const serverParentBeforeLoad = vi.fn(() => ({ source: 'server' })) + const serverParentLoader = vi.fn(() => 'server parent') + const serverChildBeforeLoad = vi.fn() + const serverChildLoader = vi.fn(() => 'server child') + const serverRootRoute = new BaseRootRoute({}) + const serverParentRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/parent', + ssr: 'data-only', + beforeLoad: serverParentBeforeLoad, + loader: serverParentLoader, + }) + const serverChildRoute = new BaseRoute({ + getParentRoute: () => serverParentRoute, + path: '/child', + beforeLoad: serverChildBeforeLoad, + loader: serverChildLoader, + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([ + serverParentRoute.addChildren([serverChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + const bootstrap = await dehydrateToBootstrap(serverRouter) + + expect(serverParentBeforeLoad).toHaveBeenCalledTimes(1) + expect(serverChildBeforeLoad).toHaveBeenCalledTimes(1) + expect(serverParentLoader).toHaveBeenCalledTimes(1) + expect(serverChildLoader).toHaveBeenCalledTimes(1) + + const parentBeforeLoad = vi.fn(() => ({ source: 'client' })) + const parentLoader = vi.fn(() => 'client parent') + const childBeforeLoad = vi.fn() + const childLoader = vi.fn(() => 'client child') + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + ssr: 'data-only', + beforeLoad: parentBeforeLoad, + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: childBeforeLoad, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: false, + }) + mockWindow.$_TSR = bootstrap + + await hydrate(router) + + expect(router.state.matches[1]).toMatchObject({ + routeId: parentRoute.id, + status: 'pending', + loaderData: 'server parent', + }) + expect(router.state.matches[2]).toMatchObject({ + routeId: childRoute.id, + status: 'success', + loaderData: 'server child', + }) + expect(parentBeforeLoad).not.toHaveBeenCalled() + expect(childBeforeLoad).not.toHaveBeenCalled() + expect(parentLoader).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + + await router.load() + + expect(router.state.resolvedLocation?.pathname).toBe('/parent/child') + expect(router.state.matches[1]).toMatchObject({ + status: 'success', + context: { source: 'server' }, + loaderData: 'server parent', + }) + expect(router.state.matches[2]).toMatchObject({ + status: 'success', + loaderData: 'server child', + }) + expect(parentBeforeLoad).not.toHaveBeenCalled() + expect(childBeforeLoad).not.toHaveBeenCalled() + expect(parentLoader).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + }) + + test('keeps an ssr-false route-context error authoritative', async () => { + const serverError = new Error('server route context failed') + const serverRootRoute = new BaseRootRoute({}) + const serverPageRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/page', + ssr: false, + context: () => { + throw serverError + }, + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverPageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: true, + }) + const bootstrap = await dehydrateToBootstrap(serverRouter) + const transportedError = bootstrap.router?.matches.at(-1)?.e + + expect(transportedError).toMatchObject({ + name: serverError.name, + message: serverError.message, + }) + + const context = vi.fn(() => ({ source: 'client' })) + const beforeLoad = vi.fn() + const loader = vi.fn() + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + ssr: false, + context, + beforeLoad, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + mockWindow.$_TSR = bootstrap + + await hydrate(router) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'pending', + error: transportedError, + }) + expect(context).toHaveBeenCalledTimes(1) + expect(beforeLoad).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + + await router.load() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'error', + error: transportedError, + }) + expect(context).toHaveBeenCalledTimes(1) + expect(beforeLoad).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + }) + + test('aborts the hydrated route context generation when the route is later left', async () => { + const capturedSignals: Array = [] + const rootRoute = new BaseRootRoute({}) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + context: ({ abortController }) => { + capturedSignals.push(abortController.signal) + return { hydrated: true } + }, + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + matches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + ) + + await hydrate(router) + const hydratedSignal = capturedSignals.at(-1)! + await router.load() + const reloadedSignal = router.state.matches.find( + (match) => match.routeId === oldRoute.id, + )!.abortController.signal + expect(hydratedSignal.aborted).toBe(true) + expect(reloadedSignal).not.toBe(hydratedSignal) + expect(reloadedSignal.aborted).toBe(false) + + await router.navigate({ to: '/new' }) + + expect(reloadedSignal.aborted).toBe(true) + expect(router.state.resolvedLocation?.pathname).toBe('/new') + }) + + test('keeps hydrated route context alive when the initial load is synchronously superseded', async () => { + let capturedSignal: AbortSignal | undefined + const beforeLoadGate = createControlledPromise() + const rootRoute = new BaseRootRoute({ + context: ({ abortController }) => { + capturedSignal = abortController.signal + return { hydrated: true } + }, + }) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + ssr: false, + beforeLoad: async () => { + await beforeLoadGate + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + matches.map((match, index) => ({ + i: dehydrateSsrMatchId(match.id), + s: index ? ('pending' as const) : ('success' as const), + ssr: index ? false : true, + u: Date.now(), + })), + ) + + await hydrate(router) + expect(router.state.resolvedLocation).toBeUndefined() + let winningLoad: Promise | undefined + const unsubscribe = router.subscribe('onBeforeNavigate', () => { + unsubscribe() + winningLoad = router.load() + }) + + const supersededLoad = router.load() + await vi.waitFor(() => { + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'pending', + }) + }) + expect(router.state.matches.at(-1)!.abortController.signal.aborted).toBe( + false, + ) + + beforeLoadGate.resolve() + await supersededLoad + await winningLoad + + expect(capturedSignal?.aborted).toBe(false) + expect(router.state.resolvedLocation?.pathname).toBe('/page') + }) + + test('does not hand transported context to a reentrant navigation for another location', async () => { + const rootBeforeLoad = vi.fn(() => ({ source: 'client' })) + const newLoader = vi.fn( + ({ context }: { context: { source?: string } }) => context.source, + ) + const rootRoute = new BaseRootRoute({ beforeLoad: rootBeforeLoad }) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + ssr: false, + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + loader: newLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + b: { source: 'server old' }, + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'pending', + ssr: false, + u: Date.now(), + }, + ]) + + await hydrate(router) + let winningNavigation: Promise | undefined + const unsubscribe = router.subscribe('onBeforeNavigate', () => { + unsubscribe() + winningNavigation = router.navigate({ to: '/new' }) + }) + + await router.load() + await winningNavigation + + expect(router.state.resolvedLocation?.pathname).toBe('/new') + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(newLoader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.loaderData).toBe('client') + }) + + test('does not hand transported context to a reentrant history-state change', async () => { + const rootBeforeLoad = vi.fn(({ location }: { location: any }) => ({ + auth: location.state.auth, + })) + const pageLoader = vi.fn( + ({ context }: { context: { auth?: string } }) => context.auth, + ) + const rootRoute = new BaseRootRoute({ beforeLoad: rootBeforeLoad }) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + ssr: false, + loader: pageLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + b: { auth: 'server old' }, + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'pending', + ssr: false, + u: Date.now(), + }, + ]) + + await hydrate(router) + let winningNavigation: Promise | undefined + const unsubscribe = router.subscribe('onBeforeNavigate', () => { + unsubscribe() + winningNavigation = router.navigate({ + to: '/page', + state: { auth: 'client new' } as any, + }) + }) + + await router.load() + await winningNavigation + + expect(router.state.resolvedLocation?.pathname).toBe('/page') + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(pageLoader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.loaderData).toBe('client new') + }) + + test('keeps a hydration handoff when a provider initializes an empty context', async () => { + const rootBeforeLoad = vi.fn(() => ({ source: 'client' })) + const rootLoader = vi.fn(() => 'client root') + const pageLoader = vi.fn( + ({ context }: { context: { source?: string } }) => context.source, + ) + const rootRoute = new BaseRootRoute({ + beforeLoad: rootBeforeLoad, + loader: rootLoader, + }) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + ssr: false, + loader: pageLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + b: { source: 'server' }, + l: 'server root', + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'pending', + ssr: false, + u: Date.now(), + }, + ]) + + await hydrate(router) + router.update({ + ...router.options, + context: { ...router.options.context }, + }) + await router.load() + + expect(rootBeforeLoad).not.toHaveBeenCalled() + expect(rootLoader).not.toHaveBeenCalled() + expect(pageLoader).toHaveBeenCalledTimes(1) + expect(router.state.matches[0]).toMatchObject({ + context: { source: 'server' }, + loaderData: 'server root', + }) + expect(router.state.matches[1]).toMatchObject({ loaderData: 'server' }) + }) + + test('does not hand transported context across a router-context generation change', async () => { + const rootBeforeLoad = vi.fn(({ context }: { context: any }) => ({ + auth: context.auth, + })) + const pageLoader = vi.fn( + ({ context }: { context: { auth?: string } }) => context.auth, + ) + const rootRoute = new BaseRootRoute({ beforeLoad: rootBeforeLoad }) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + ssr: false, + loader: pageLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + context: { auth: 'server old' }, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + b: { auth: 'server old' }, + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'pending', + ssr: false, + u: Date.now(), + }, + ]) + + await hydrate(router) + router.update({ + ...router.options, + context: { auth: 'client new' }, + }) + await router.load() + + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(pageLoader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.loaderData).toBe('client new') + }) + + test('rejects a hydration handoff when its prefix identity changes before finish', async () => { + let generation = 'server' + const rootBeforeLoad = vi.fn(() => ({ source: 'client' })) + const pageLoader = vi.fn( + ({ context }: { context: { source?: string } }) => context.source, + ) + const rootRoute = new BaseRootRoute({ + loaderDeps: () => ({ generation }), + beforeLoad: rootBeforeLoad, + }) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + ssr: false, + loader: pageLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + b: { source: 'server' }, + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'pending', + ssr: false, + u: Date.now(), + }, + ]) + + await hydrate(router) + const unsubscribe = router.subscribe('onBeforeNavigate', () => { + unsubscribe() + generation = 'client' + }) + + await router.load() + + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(pageLoader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.loaderData).toBe('client') + }) + + test('turns a client route-context reconstruction failure into a route error', async () => { + const contextError = new Error('client context failed') + const onError = vi.fn() + const loader = vi.fn(() => 'client data') + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + context: () => { + throw contextError + }, + loader, + onError, + errorComponent: () => 'Page error', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + matches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + l: match.routeId === pageRoute.id ? 'server data' : undefined, + ssr: true, + u: Date.now(), + })), + ) + + await expect(hydrate(router)).resolves.toBeUndefined() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + error: undefined, + loaderData: 'server data', + }) + expect(router.state.resolvedLocation).toBeUndefined() + expect(onError).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + + await router.load() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'error', + error: contextError, + loaderData: 'server data', + }) + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(contextError) + expect(loader).not.toHaveBeenCalled() + }) + + test('preserves route-context redirect control during hydration reconstruction', async () => { + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + context: () => { + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/source'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + matches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + ) + + await expect(hydrate(router)).resolves.toBeUndefined() + + expect(router.state.location.pathname).toBe('/source') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: sourceRoute.id, + status: 'success', + }) + expect(router.state.resolvedLocation).toBeUndefined() + + await router.load() + + expect(router.state.location.pathname).toBe('/target') + expect(router.state.resolvedLocation?.pathname).toBe('/target') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + }) + }) + + test('keeps a transported terminal route-context error authoritative', async () => { + const serverError = new Error('server context failed') + const serverRootRoute = new BaseRootRoute({}) + const serverPageRoute = new BaseRoute({ + getParentRoute: () => serverRootRoute, + path: '/page', + context: () => { + throw serverError + }, + errorComponent: () => 'Page error', + }) + const serverRouter = createTestRouter({ + routeTree: serverRootRoute.addChildren([serverPageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: true, + }) + const bootstrap = await dehydrateToBootstrap(serverRouter) + const transportedError = bootstrap.router?.matches.at(-1)?.e + + const clientContextError = new Error('client context failed') + const beforeLoad = vi.fn() + const loader = vi.fn( + ({ context }: { context: { recovered?: boolean } }) => context.recovered, + ) + const onError = vi.fn() + const clientContext = vi.fn((): { recovered: boolean } => { + throw clientContextError + }) + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + context: clientContext, + beforeLoad, + loader, + onError, + errorComponent: () => 'Page error', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + mockWindow.$_TSR = bootstrap + + await expect(hydrate(router)).resolves.toBeUndefined() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'error', + error: transportedError, + }) + expect(router.state.matches.at(-1)?.error).not.toBe(clientContextError) + expect(router.state.resolvedLocation?.pathname).toBe('/page') + expect(onError).not.toHaveBeenCalled() + expect(beforeLoad).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + + clientContext.mockImplementation(() => ({ recovered: true })) + await router.invalidate() + + expect(clientContext).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)).toMatchObject({ + status: 'success', + context: { recovered: true }, + loaderData: true, + }) + }) + + test('turns a hydrated normal-component chunk failure into a route error without rerunning loader data', async () => { + const chunkError = new Error('page component failed to load') + const componentPreload = vi.fn(() => Promise.reject(chunkError)) + const Page = Object.assign(() => 'Page', { preload: componentPreload }) + const loader = vi.fn(() => 'client data') + const onError = vi.fn() + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: () => 'Page error', + loader, + onError, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + matches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + l: match.routeId === pageRoute.id ? 'server data' : undefined, + ssr: true, + u: Date.now(), + })), + ) + + await expect(hydrate(router)).resolves.toBeUndefined() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + error: undefined, + loaderData: 'server data', + }) + expect(router.state.resolvedLocation).toBeUndefined() + expect(onError).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + + await router.load() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'error', + error: chunkError, + loaderData: 'server data', + }) + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(chunkError) + expect(loader).not.toHaveBeenCalled() + }) + + test('retries from the earliest failed hydration chunk regardless of rejection order', async () => { + const rootChunkError = new Error('root component failed to load') + const childChunkError = new Error('child component failed to load') + const rootChunkGate = createControlledPromise() + const rootChunkStarted = createControlledPromise() + const RootComponent = Object.assign(() => 'Root', { + preload: () => { + rootChunkStarted.resolve() + return rootChunkGate + }, + }) + const childComponentPreload = vi.fn(() => Promise.reject(childChunkError)) + const ChildComponent = Object.assign(() => 'Child', { + preload: childComponentPreload, + }) + const rootContext = vi.fn(() => ({})) + const rootRoute = new BaseRootRoute({ + component: RootComponent, + context: rootContext, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + component: ChildComponent, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + matches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + ) + + const hydration = hydrate(router) + await rootChunkStarted + await vi.waitFor(() => { + expect(childComponentPreload).toHaveBeenCalledTimes(1) + }) + rootChunkGate.reject(rootChunkError) + await expect(hydration).resolves.toBeUndefined() + + expect(rootContext).not.toHaveBeenCalled() + expect(router.state.resolvedLocation).toBeUndefined() + }) + + test('does not wait for descendant chunks after an earlier chunk fails', async () => { + const rootChunkError = new Error('root component failed to load') + const childChunkGate = createControlledPromise() + const childChunkStarted = createControlledPromise() + const RootComponent = Object.assign(() => 'Root', { + preload: () => Promise.reject(rootChunkError), + }) + const ChildComponent = Object.assign(() => 'Child', { + preload: () => { + childChunkStarted.resolve() + return childChunkGate + }, + }) + const rootContext = vi.fn(() => ({})) + const rootRoute = new BaseRootRoute({ + component: RootComponent, + context: rootContext, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + component: ChildComponent, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + matches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + ) + + let settled = false + const hydration = hydrate(router).then(() => { + settled = true + }) + try { + await childChunkStarted + await vi.waitFor(() => { + expect(settled).toBe(true) + }) + } finally { + childChunkGate.resolve() + await hydration + } + + expect(rootContext).not.toHaveBeenCalled() + expect(router.state.resolvedLocation).toBeUndefined() + }) +}) diff --git a/packages/router-core/tests/public-preload-lane-contract.test.ts b/packages/router-core/tests/public-preload-lane-contract.test.ts index b57905548e..42170003ad 100644 --- a/packages/router-core/tests/public-preload-lane-contract.test.ts +++ b/packages/router-core/tests/public-preload-lane-contract.test.ts @@ -1,14 +1,243 @@ -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise, + notFound, redirect, } from '../src' import { createTestRouter } from './routerTestUtils' +afterEach(() => { + vi.useRealTimers() +}) + describe('public preload lane contracts', () => { + test('completed preload matches retain context without caching beforeLoad context', async () => { + let beforeLoadGeneration = 0 + const loader = vi.fn(() => 'loader data') + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + context: () => ({ routeContext: true }), + beforeLoad: () => ({ generation: ++beforeLoadGeneration }), + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preloaded = await router.preloadRoute({ to: '/target' }) + + expect(preloaded?.at(-1)?.context).toEqual({ + routeContext: true, + generation: 1, + }) + + await router.navigate({ to: '/target' }) + + expect(router.state.matches.at(-1)?.context).toEqual({ + routeContext: true, + generation: 2, + }) + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('active preloads share only an identical full lane', async () => { + const gates = new Map< + number, + ReturnType> + >() + const beforeLoad = vi.fn(({ search }: { search: { version: number } }) => { + const gate = createControlledPromise() + gates.set(search.version, gate) + return gate + }) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + validateSearch: (search: Record) => ({ + version: Number(search.version), + }), + beforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + stringifySearch: () => '', + }) + + await router.load() + const first = router.preloadRoute({ + to: '/target', + search: { version: 1 }, + }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) + const second = router.preloadRoute({ + to: '/target', + search: { version: 2 }, + }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) + gates.get(2)!.resolve(undefined) + await Promise.all([first, second]) + + const third = router.preloadRoute({ + to: '/target', + search: { version: 3 }, + }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(3)) + const identical = router.preloadRoute({ + to: '/target', + search: { version: 3 }, + }) + await Promise.resolve() + expect(beforeLoad).toHaveBeenCalledTimes(3) + gates.get(3)!.resolve(undefined) + await Promise.all([third, identical]) + }) + + test('navigation adopts beforeLoad from an identical active preload lane', async () => { + const beforeLoadGate = createControlledPromise() + const beforeLoad = vi.fn(async ({ preload }: { preload: boolean }) => { + await beforeLoadGate + return { source: preload ? 'preload' : 'navigation' } + }) + const loader = vi.fn(() => 'loader data') + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + beforeLoad, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/target' }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) + + const navigation = router.navigate({ to: '/target' }) + await Promise.resolve() + expect(beforeLoad).toHaveBeenCalledTimes(1) + + beforeLoadGate.resolve() + await Promise.all([preload, navigation]) + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + ]) + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)).toMatchObject({ + context: { source: 'preload' }, + loaderData: 'loader data', + }) + }) + + test('a depth-20 navigation does not adopt a depth-0 active preload redirect', async () => { + const redirectGate = createControlledPromise() + const sharedBeforeLoad = vi.fn(async (_context: { preload: boolean }) => { + await redirectGate + throw redirect({ to: '/target' }) + }) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const hopRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hop/$hop', + beforeLoad: ({ params }) => { + const hop = Number(params.hop) + if (hop < 19) { + throw redirect({ + to: '/hop/$hop', + params: { hop: String(hop + 1) }, + } as any) + } + throw redirect({ to: '/shared' }) + }, + }) + const sharedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/shared', + beforeLoad: sharedBeforeLoad, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + hopRoute, + sharedRoute, + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + let preload: Promise | undefined + let navigation: Promise | undefined + try { + await router.load() + preload = router.preloadRoute({ to: '/shared' }) + await vi.waitFor(() => expect(sharedBeforeLoad).toHaveBeenCalledOnce()) + + navigation = router.navigate({ + to: '/hop/$hop', + params: { hop: '0' }, + } as any) + await vi.waitFor(() => expect(sharedBeforeLoad).toHaveBeenCalledTimes(2)) + + redirectGate.resolve() + await Promise.all([preload, navigation]) + + expect( + sharedBeforeLoad.mock.calls.map(([context]) => context.preload), + ).toEqual([true, false]) + expect(router.state.location.pathname).toBe('/shared') + expect( + router.state.matches.find((match) => match.status !== 'success'), + ).toMatchObject({ + routeId: rootRoute.id, + status: 'error', + error: expect.objectContaining({ message: 'Too many redirects' }), + }) + expect(router.state.status).toBe('idle') + } finally { + redirectGate.resolve() + const activeWork: Array> = [] + if (preload) { + activeWork.push(preload) + } + if (navigation) { + activeWork.push(navigation) + } + await Promise.allSettled(activeWork) + } + }) + test.each([ { difference: 'pathname', @@ -142,63 +371,136 @@ describe('public preload lane contracts', () => { }, ) - test('navigation does not adopt beforeLoad from an active preload with different params', async () => { - const beforeLoadGate = createControlledPromise() - const beforeLoad = vi.fn(async ({ preload }: { preload: boolean }) => { - await beforeLoadGate - return { preload } - }) + test('identical active preloads await the same redirect chain', async () => { + const redirectGate = createControlledPromise() + const loaderGate = createControlledPromise() + const redirectedLoader = vi.fn(() => loaderGate) const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/', }) - const itemsRoute = new BaseRoute({ + const sourceRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/items/$itemId', - beforeLoad, - }) - const firstRoute = new BaseRoute({ - getParentRoute: () => itemsRoute, - path: '/first', + path: '/source', + beforeLoad: async () => { + await redirectGate + throw redirect({ to: '/target' }) + }, }) - const secondRoute = new BaseRoute({ - getParentRoute: () => itemsRoute, - path: '/second', + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: redirectedLoader, }) const router = createTestRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - itemsRoute.addChildren([firstRoute, secondRoute]), - ]), + routeTree: rootRoute.addChildren([indexRoute, sourceRoute, targetRoute]), history: createMemoryHistory({ initialEntries: ['/'] }), }) await router.load() - const preload = router.preloadRoute({ - to: '/items/$itemId/first', - params: { itemId: 'one' }, - }) - await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) - - const navigation = router.navigate({ - to: '/items/$itemId/first', - params: { itemId: 'two' }, + const first = router.preloadRoute({ to: '/source' }) + const second = router.preloadRoute({ to: '/source' }) + let secondSettled = false + void second.then(() => { + secondSettled = true }) - await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) - beforeLoadGate.resolve() - await Promise.all([preload, navigation]) + redirectGate.resolve() + await vi.waitFor(() => expect(redirectedLoader).toHaveBeenCalledOnce()) + expect(secondSettled).toBe(false) - expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ - true, - false, - ]) - expect(router.state.location.pathname).toBe('/items/two/first') - expect(router.state.location.search).toEqual({}) - expect(router.state.matches.at(-1)?.context).toEqual({ preload: false }) + loaderGate.resolve() + await Promise.all([first, second]) + expect(secondSettled).toBe(true) }) + test.each([ + { + name: 'route suffix', + preload: { to: '/items/one/first' }, + navigation: { to: '/items/one/second' }, + pathname: '/items/one/second', + search: {}, + }, + { + name: 'params', + preload: { to: '/items/one/first' }, + navigation: { to: '/items/two/first' }, + pathname: '/items/two/first', + search: {}, + }, + { + name: 'search', + preload: { + to: '/items/one/first', + search: { view: 'summary' }, + }, + navigation: { + to: '/items/one/first', + search: { view: 'detail' }, + }, + pathname: '/items/one/first', + search: { view: 'detail' }, + }, + ])( + 'navigation does not adopt beforeLoad from an active preload with different $name', + async ({ + preload: preloadOptions, + navigation: navigationOptions, + pathname, + search, + }) => { + const beforeLoadGate = createControlledPromise() + const beforeLoad = vi.fn(async ({ preload }: { preload: boolean }) => { + await beforeLoadGate + return { preload } + }) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const itemsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/items/$itemId', + beforeLoad, + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => itemsRoute, + path: '/first', + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => itemsRoute, + path: '/second', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + itemsRoute.addChildren([firstRoute, secondRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute(preloadOptions as any) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) + + const navigation = router.navigate(navigationOptions as any) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) + + beforeLoadGate.resolve() + await Promise.all([preload, navigation]) + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual( + [true, false], + ) + expect(router.state.location.pathname).toBe(pathname) + expect(router.state.location.search).toEqual(search) + expect(router.state.matches.at(-1)?.context).toEqual({ preload: false }) + }, + ) + test('a different full navigation lane reruns beforeLoad but reuses active same-id loader work', async () => { const loaderGate = createControlledPromise() const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ @@ -257,9 +559,15 @@ describe('public preload lane contracts', () => { }) }) - test('different same-href preload lanes rerun beforeLoad but share loader work by match id', async () => { + test('reserved preload loader work survives preload completion while navigation beforeLoad is pending', async () => { const loaderGate = createControlledPromise() - const beforeLoad = vi.fn() + const navigationBeforeLoadGate = createControlledPromise() + const beforeLoad = vi.fn(async ({ preload }: { preload: boolean }) => { + if (!preload) { + await navigationBeforeLoadGate + } + return { source: preload ? 'preload' : 'navigation' } + }) const loader = vi.fn(() => loaderGate) const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ @@ -269,9 +577,6 @@ describe('public preload lane contracts', () => { const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/parent', - validateSearch: (search: Record) => ({ - version: Number(search.version), - }), beforeLoad, loader, }) @@ -289,44 +594,201 @@ describe('public preload lane contracts', () => { parentRoute.addChildren([firstRoute, secondRoute]), ]), history: createMemoryHistory({ initialEntries: ['/'] }), - stringifySearch: () => '', }) await router.load() - const first = router.preloadRoute({ - to: '/parent/first', - search: { version: 1 }, - }) + const preload = router.preloadRoute({ to: '/parent/first' }) await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) - const second = router.preloadRoute({ - to: '/parent/first', - search: { version: 2 }, - }) + + const navigation = router.navigate({ to: '/parent/second' }) await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) + loaderGate.resolve('reserved parent data') + await preload expect(loader).toHaveBeenCalledTimes(1) - loaderGate.resolve('parent data') - await Promise.all([first, second]) - expect(beforeLoad).toHaveBeenCalledTimes(2) + navigationBeforeLoadGate.resolve() + await navigation + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + false, + ]) expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.routeId).toBe(secondRoute.id) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ + context: { source: 'navigation' }, + loaderData: 'reserved parent data', + }) }) - test('completed preloads cache loader data but not beforeLoad context', async () => { - const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ - source: preload ? 'preload' : 'navigation', - })) - const loader = vi.fn( - ({ context }: { context: { source: string } }) => context.source, + test('a background refresh consumes reserved preload work after that preload completes', async () => { + const refreshGate = createControlledPromise() + const navigationBeforeLoadGate = createControlledPromise() + const beforeLoad = vi.fn( + async ({ + preload, + search, + }: { + preload: boolean + search: { revision: number } + }) => { + if (!preload && search.revision === 2) { + await navigationBeforeLoadGate + } + return { source: preload ? 'preload' : 'navigation' } + }, + ) + const loader = vi.fn(() => + loader.mock.calls.length === 1 ? 'initial data' : refreshGate, ) const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ + const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/', + path: '/parent', + validateSearch: (search: Record) => ({ + revision: Number(search.revision ?? 0), + }), + beforeLoad, + shouldReload: ({ location }) => + (location.search as { revision: number }).revision > 0, + loader: { + handler: loader, + staleReloadMode: 'background', + }, }) - const targetRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/target', + const firstRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/first', + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/second', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([firstRoute, secondRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/first?revision=0'], + }), + }) + + await router.load() + const preload = router.preloadRoute({ + to: '/parent/first', + search: { revision: 1 }, + }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + let navigationSettled = false + const navigation = router + .navigate({ + to: '/parent/second', + search: { revision: 2 }, + }) + .then(() => { + navigationSettled = true + }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(3)) + + refreshGate.resolve('refreshed data') + await preload + expect(loader).toHaveBeenCalledTimes(2) + expect(navigationSettled).toBe(false) + + navigationBeforeLoadGate.resolve() + await navigation + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + false, + true, + false, + ]) + expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.routeId).toBe(secondRoute.id) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ + context: { source: 'navigation' }, + loaderData: 'refreshed data', + preload: false, + isFetching: false, + }) + }) + + test('different same-href preload lanes rerun beforeLoad but share loader work by match id', async () => { + const loaderGate = createControlledPromise() + const beforeLoad = vi.fn() + const loader = vi.fn(() => loaderGate) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + validateSearch: (search: Record) => ({ + version: Number(search.version), + }), + beforeLoad, + loader, + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/first', + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/second', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([firstRoute, secondRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + stringifySearch: () => '', + }) + + await router.load() + const first = router.preloadRoute({ + to: '/parent/first', + search: { version: 1 }, + }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + const second = router.preloadRoute({ + to: '/parent/first', + search: { version: 2 }, + }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) + + expect(loader).toHaveBeenCalledTimes(1) + loaderGate.resolve('parent data') + await Promise.all([first, second]) + + expect(beforeLoad).toHaveBeenCalledTimes(2) + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('completed preloads cache loader data but not beforeLoad context', async () => { + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + source: preload ? 'preload' : 'navigation', + })) + const loader = vi.fn( + ({ context }: { context: { source: string } }) => context.source, + ) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', staleTime: Infinity, preloadStaleTime: Infinity, beforeLoad, @@ -352,6 +814,42 @@ describe('public preload lane contracts', () => { }) }) + test('completed preloads reuse same-id route context and loader data', async () => { + const context = ({ preload }: { preload: boolean }) => ({ + source: preload ? 'preload' : 'navigation', + }) + const loader = vi.fn( + ({ context: value }: { context: { source: string } }) => value.source, + ) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + staleTime: Infinity, + preloadStaleTime: Infinity, + context, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/target' }) + await router.navigate({ to: '/target' }) + + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)).toMatchObject({ + context: { source: 'preload' }, + loaderData: 'preload', + }) + }) + test('navigation retries an adopted preload lane that failed', async () => { const preloadGate = createControlledPromise() const beforeLoad = vi.fn(async ({ preload }: { preload: boolean }) => { @@ -399,6 +897,219 @@ describe('public preload lane contracts', () => { }) }) + test('a retry reuses successful loader data from the failed preload lane', async () => { + const childGate = createControlledPromise() + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + source: preload ? 'preload' : 'navigation', + })) + let parentSignal: AbortSignal | undefined + const parentLoader = vi.fn(({ abortController }) => { + parentSignal = abortController.signal + return 'parent data' + }) + const childLoader = vi.fn(async ({ preload }: { preload: boolean }) => { + if (preload) { + await childGate + throw new Error('preload child failed') + } + return 'child data' + }) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad, + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/parent/child' }) + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(1)) + const navigation = router.navigate({ to: '/parent/child' }) + + childGate.resolve() + await Promise.all([preload, navigation]) + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + false, + ]) + expect(parentLoader).toHaveBeenCalledTimes(1) + expect(parentSignal?.aborted).toBe(false) + expect(childLoader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: childRoute.id, + status: 'success', + loaderData: 'child data', + }) + }) + + test('a terminal preload stays terminal publicly while caching its successful loader generation', async () => { + const loader = vi.fn( + ({ deps }: { deps: { version: number } }) => deps.version, + ) + const rootRoute = new BaseRootRoute({ + validateSearch: (search: Record) => ({ + version: Number(search.version ?? 0), + }), + loaderDeps: ({ search }) => ({ version: search.version }), + shouldReload: false, + loader, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + beforeLoad: () => { + throw notFound({ routeId: rootRoute.id as never }) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ + initialEntries: ['/?version=0'], + }), + }) + + await router.load() + const terminal = await router.preloadRoute({ + to: '/target', + search: { version: 1 }, + }) + + expect(loader).toHaveBeenCalledTimes(2) + expect(terminal).toHaveLength(2) + expect(terminal?.[0]).toMatchObject({ + routeId: rootRoute.id, + status: 'success', + _notFound: true, + error: { isNotFound: true }, + loaderData: 1, + }) + expect(terminal?.[1]).toMatchObject({ + routeId: targetRoute.id, + status: 'pending', + }) + + await router.navigate({ + to: '/target', + search: { version: 1 }, + }) + + expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.matches[0]).toMatchObject({ + status: 'success', + _notFound: true, + loaderData: 1, + }) + expect(router.state.matches[1]).toMatchObject({ + routeId: targetRoute.id, + status: 'pending', + }) + }) + + test('a terminal preload started during navigation borrows its loader generation', async () => { + const loaderGate = createControlledPromise() + const loader = vi.fn(() => + loader.mock.calls.length === 1 ? 'initial data' : loaderGate, + ) + const rootRoute = new BaseRootRoute({ + shouldReload: true, + loader, + }) + const beforeLoad = vi.fn(() => { + throw notFound({ routeId: rootRoute.id as never }) + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + beforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const navigation = router.navigate({ to: '/target' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + const preload = router.preloadRoute({ to: '/target' }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) + + expect(loader).toHaveBeenCalledTimes(2) + loaderGate.resolve('navigation data') + const [, terminal] = await Promise.all([navigation, preload]) + + expect(loader).toHaveBeenCalledTimes(2) + expect(terminal?.[0]).toMatchObject({ + routeId: rootRoute.id, + _notFound: true, + loaderData: 'navigation data', + }) + }) + + test('leaving a route preserves its newer same-id preload generation', async () => { + const loader = vi.fn(() => `loader data ${loader.mock.calls.length}`) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + validateSearch: (search: Record) => ({ + revision: Number(search.revision ?? 1), + }), + shouldReload: ({ location }) => + (location.search as { revision: number }).revision === 2, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ + initialEntries: ['/target?revision=1'], + }), + }) + + await router.load() + await router.preloadRoute({ + to: '/target', + search: { revision: 2 }, + }) + await router.navigate({ to: '/' }) + await router.navigate({ + to: '/target', + search: { revision: 1 }, + }) + + expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.loaderData).toBe('loader data 2') + }) + test('an expired same-id preload does not displace fresh unloaded data', async () => { const loader = vi.fn(() => `loader data ${loader.mock.calls.length}`) const rootRoute = new BaseRootRoute({}) @@ -475,6 +1186,77 @@ describe('public preload lane contracts', () => { expect(router.state.matches.at(-1)?.loaderData).toBe('loader data 3') }) + test('a hidden terminal suffix does not evict a newer same-id preload', async () => { + const failingBeforeLoadStarted = createControlledPromise() + const failingBeforeLoadGate = createControlledPromise() + const loader = vi.fn(() => 'preloaded child data') + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + validateSearch: (search: Record) => ({ + fail: search.fail === true, + }), + beforeLoad: async ({ search }) => { + if (search.fail) { + failingBeforeLoadStarted.resolve() + await failingBeforeLoadGate + throw new Error('parent failed') + } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + staleTime: Infinity, + preloadStaleTime: Infinity, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const failingNavigation = router.navigate({ + to: '/parent/child', + search: { fail: true }, + }) + await failingBeforeLoadStarted + + await router.preloadRoute({ + to: '/parent/child', + search: { fail: false }, + }) + expect(loader).toHaveBeenCalledTimes(1) + + failingBeforeLoadGate.resolve() + await failingNavigation + expect(router.state.matches).toHaveLength(3) + expect(router.state.matches[1]).toMatchObject({ + routeId: parentRoute.id, + status: 'error', + }) + + await router.navigate({ + to: '/parent/child', + search: { fail: false }, + }) + + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: childRoute.id, + loaderData: 'preloaded child data', + }) + }) + test('preload false skips speculation but performs a blocking navigation load', async () => { const loaderGate = createControlledPromise() const loader = vi.fn(() => loaderGate) @@ -515,6 +1297,76 @@ describe('public preload lane contracts', () => { }) }) + test('preloads the target of a twenty-hop redirect chain', async () => { + const beforeLoad = vi.fn(({ params }) => { + const hop = Number(params.hop) + if (hop < 20) { + throw redirect({ + to: '/hop/$hop', + params: { hop: String(hop + 1) }, + } as any) + } + }) + const rootRoute = new BaseRootRoute({}) + const hopRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hop/$hop', + beforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([hopRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const matches = await router.preloadRoute({ + to: '/hop/$hop', + params: { hop: '0' }, + } as any) + + expect(beforeLoad).toHaveBeenCalledTimes(21) + expect(matches?.at(-1)).toMatchObject({ + routeId: hopRoute.id, + status: 'success', + params: { hop: '20' }, + }) + }) + + test('stops a preload after the twenty-hop redirect limit', async () => { + const beforeLoad = vi.fn(({ params }) => { + const hop = Number(params.hop) + if (hop < 22) { + throw redirect({ + to: '/hop/$hop', + params: { hop: String(hop + 1) }, + } as any) + } + }) + const rootRoute = new BaseRootRoute({}) + const hopRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hop/$hop', + beforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([hopRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const matches = await router.preloadRoute({ + to: '/hop/$hop', + params: { hop: '0' }, + } as any) + + expect(beforeLoad).toHaveBeenCalledTimes(21) + expect(matches?.find((match) => match.status !== 'success')).toMatchObject({ + routeId: rootRoute.id, + status: 'error', + error: expect.objectContaining({ + message: 'Too many redirects', + }), + }) + }) + test('forwards a document redirect at the redirect limit', async () => { const beforeLoad = vi.fn(({ params }) => { const hop = Number(params.hop) @@ -543,4 +1395,29 @@ describe('public preload lane contracts', () => { expect(beforeLoad).toHaveBeenCalledTimes(21) expect(matches).toBeUndefined() }) + + test('reports a chunk redirect limit at root', async () => { + const chunkError = new Error('route chunk failed') + const rootRoute = new BaseRootRoute({}) + const loopRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/loop', + onError: () => { + throw redirect({ to: '/loop' }) + }, + }).lazy(() => Promise.reject(chunkError)) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([loopRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const matches = await router.preloadRoute({ to: '/loop' }) + + const terminal = matches?.find((match) => match.status !== 'success') + expect(terminal).toMatchObject({ + routeId: rootRoute.id, + status: 'error', + error: expect.objectContaining({ message: 'Too many redirects' }), + }) + }) }) diff --git a/packages/router-core/tests/rendered-matches.test.ts b/packages/router-core/tests/rendered-matches.test.ts new file mode 100644 index 0000000000..2a2ea362ae --- /dev/null +++ b/packages/router-core/tests/rendered-matches.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest' +import { _getAssetMatches } from '../src' +import type { AnyRouteMatch } from '../src' + +describe('_getAssetMatches', () => { + test('does not cross an ordinary data-only pending boundary', () => { + const matches = [ + { status: 'success' }, + { status: 'pending', ssr: 'data-only' }, + { status: 'success' }, + ] as Array + + expect(_getAssetMatches(matches)).toEqual(matches.slice(0, 2)) + }) + + test('limits a hydration handoff to its verified asset prefix', () => { + const matches = [ + { status: 'success' }, + { + status: 'pending', + ssr: 'data-only', + _assetEnd: 3, + }, + { status: 'success' }, + { status: 'success' }, + ] as Array + + expect(_getAssetMatches(matches)).toEqual(matches.slice(0, 3)) + }) +}) diff --git a/packages/router-core/tests/routerTestUtils.ts b/packages/router-core/tests/routerTestUtils.ts index b71c54e347..413b810b95 100644 --- a/packages/router-core/tests/routerTestUtils.ts +++ b/packages/router-core/tests/routerTestUtils.ts @@ -5,8 +5,10 @@ import { createNonReactiveMutableStore, createNonReactiveReadonlyStore, } from '../src' +import { createRequestHandler } from '../src/ssr/createRequestHandler' import type { RouterHistory } from '@tanstack/history' import type { + AnyRouter, AnyRoute, GetStoreConfig, RouterConstructorOptions, @@ -46,3 +48,24 @@ export function createTestRouter< ) { return new RouterCore(options, getStoreConfig) } + +/** Materialize the request-local server result as the HTTP response users see. */ +export function loadServerResponse( + router: AnyRouter, + path: string, + signal?: AbortSignal, +) { + return createRequestHandler({ + createRouter: () => router, + request: new Request(`http://localhost${path}`, { signal }), + })(({ router: loadedRouter, responseHeaders }) => { + const result = loadedRouter._serverResult + return new Response(null, { + status: + result?.type === 'redirect' + ? result.redirect.status + : (result?.status ?? 500), + headers: responseHeaders, + }) + }) +} diff --git a/packages/router-core/tests/same-destination-navigation-join.test.ts b/packages/router-core/tests/same-destination-navigation-join.test.ts index cf61a43053..cfe6c5ad45 100644 --- a/packages/router-core/tests/same-destination-navigation-join.test.ts +++ b/packages/router-core/tests/same-destination-navigation-join.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' +afterEach(() => { + vi.restoreAllMocks() +}) + describe('same-destination navigation while one is in flight', () => { function setup() { const gate = createControlledPromise() @@ -73,6 +77,20 @@ describe('same-destination navigation while one is in flight', () => { expect(loader).toHaveBeenCalledTimes(2) }) + test('invalidate during an in-flight navigation still reruns the loader', async () => { + const { router, loader, gate } = setup() + await router.load() + + const first = router.navigate({ to: '/target' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + const second = router.invalidate() + + gate.resolve('data') + await Promise.all([first, second]) + + expect(loader).toHaveBeenCalledTimes(2) + }) + test('a repeat navigation after settle reloads instead of joining', async () => { const { router, loader, gate, targetRoute } = setup() gate.resolve('data') diff --git a/packages/router-core/tests/server-async-headers-decorative-hang.test.ts b/packages/router-core/tests/server-async-headers-decorative-hang.test.ts new file mode 100644 index 0000000000..454e6a6bb8 --- /dev/null +++ b/packages/router-core/tests/server-async-headers-decorative-hang.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +afterEach(() => { + vi.restoreAllMocks() +}) + +test('a rejected projection hook is logged without failing the response', async () => { + const projectionError = new Error('scripts failed') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const headGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const headersGate = createControlledPromise>() + const head = vi.fn(() => headGate) + const scripts = vi.fn(async () => { + throw projectionError + }) + const headers = vi.fn(() => headersGate) + + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + head, + scripts, + headers, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/target') + + expect(headGate.status).toBe('pending') + expect(headersGate.status).toBe('pending') + expect(response.status).toBe(200) + expect(response.headers.get('x-response-header')).toBeNull() + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + expect(headers).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledWith(projectionError) + + headGate.resolve({ meta: [{ title: 'discarded' }] }) + headersGate.resolve({ 'x-response-header': 'discarded' }) + await Promise.all([headGate, headersGate]) +}) diff --git a/packages/router-core/tests/server-beforeload-preload-flag.test.ts b/packages/router-core/tests/server-beforeload-preload-flag.test.ts index 22509a86c7..4779001305 100644 --- a/packages/router-core/tests/server-beforeload-preload-flag.test.ts +++ b/packages/router-core/tests/server-beforeload-preload-flag.test.ts @@ -1,5 +1,5 @@ import { createMemoryHistory } from '@tanstack/history' -import { expect, test } from 'vitest' +import { expect, test, vi } from 'vitest' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' @@ -23,3 +23,66 @@ test('server beforeLoad receives preload false', async () => { expect(seen).toEqual([false]) }) + +test('server preloadRoute stays speculative and receives preload true', async () => { + const beforeLoad = vi.fn() + const loader = vi.fn(() => 'preloaded data') + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + beforeLoad, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, childRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) + + await router.load() + const matches = await router.preloadRoute({ to: '/child' }) + + expect(beforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ preload: true }), + ) + expect(loader).toHaveBeenCalledOnce() + expect(matches?.at(-1)).toMatchObject({ + routeId: childRoute.id, + loaderData: 'preloaded data', + }) + expect(router.state.location.pathname).toBe('/') + expect(router.state.matches.at(-1)?.routeId).toBe(indexRoute.id) +}) + +test('server child context receives its parent beforeLoad context', async () => { + const rootRoute = new BaseRootRoute({ + beforeLoad: () => ({ parent: 'ready' }), + }) + const childContext = vi.fn(({ context }) => ({ + child: `${context.parent}:child`, + })) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + context: childContext, + loader: ({ context }) => context.child, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: true, + }) + + await router.load() + + expect(router.state.matches.at(-1)).toMatchObject({ + context: { parent: 'ready', child: 'ready:child' }, + loaderData: 'ready:child', + }) + expect(childContext).toHaveBeenCalledTimes(1) +}) diff --git a/packages/router-core/tests/server-chunk-failure-lifecycle.test.ts b/packages/router-core/tests/server-chunk-failure-lifecycle.test.ts new file mode 100644 index 0000000000..3cf7ec7051 --- /dev/null +++ b/packages/router-core/tests/server-chunk-failure-lifecycle.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, + redirect, +} from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +/** + * A primary route chunk failure observed by the server loader phase (a route + * component preload or lazyFn rejection) must go through the same route + * failure lifecycle as loader failures: the route's onError runs, + * redirect/notFound conversion applies, and the errorComponent boundary + * chunk is loaded. Only boundary component chunk failures commit directly + * without recursing into another boundary chunk. + * + * These tests use real server router loads and public component preload/lazy + * route APIs. + */ + +describe('server route chunk failure lifecycle', () => { + test('calls route onError once and loads the errorComponent chunk when the route component chunk rejects', async () => { + const chunkError = new Error('component chunk failed') + const capturedErrors: Array = [] + const events: Array = [] + + const FailingComponent = Object.assign(() => null, { + preload: () => Promise.reject(chunkError), + }) + const ErrorBoundary = Object.assign(() => null, { + preload: () => { + events.push('errorComponent-preload') + return Promise.resolve() + }, + }) + + const rootRoute = new BaseRootRoute({}) + const chunkedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/chunked', + onError: (error: unknown) => { + capturedErrors.push(error) + events.push('onError') + }, + component: FailingComponent as any, + errorComponent: ErrorBoundary as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([chunkedRoute]), + history: createMemoryHistory({ initialEntries: ['/chunked'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/chunked') + + const match = router.state.matches.find( + (item) => item.routeId === chunkedRoute.id, + ) + expect(capturedErrors).toEqual([chunkError]) + expect(match?.status).toBe('error') + expect(match?.error).toBe(chunkError) + expect(events).toEqual(['onError', 'errorComponent-preload']) + expect(response.status).toBe(500) + }) + + test('a notFound thrown from onError for a route chunk failure sets 404 instead of 500', async () => { + const chunkError = new Error('component chunk failed') + + const FailingComponent = Object.assign(() => null, { + preload: () => Promise.reject(chunkError), + }) + + const rootRoute = new BaseRootRoute({}) + const chunkedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/chunked', + onError: () => { + throw notFound() + }, + component: FailingComponent as any, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([chunkedRoute]), + history: createMemoryHistory({ initialEntries: ['/chunked'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/chunked') + + const match = router.state.matches.find( + (item) => item.routeId === chunkedRoute.id, + ) + expect(response.status).toBe(404) + expect(match?.status).toBe('notFound') + expect(match?.error).toEqual(expect.objectContaining({ isNotFound: true })) + }) + + test('the first route-order chunk failure determines the response', async () => { + const rootError = new Error('root component failed') + const rootChunkGate = createControlledPromise() + const childConverted = createControlledPromise() + const RootComponent = Object.assign(() => null, { + preload: () => rootChunkGate, + }) + const ChildComponent = Object.assign(() => null, { + preload: () => Promise.reject(new Error('child component failed')), + }) + + const rootRoute = new BaseRootRoute({ + component: RootComponent as any, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + component: ChildComponent as any, + onError: () => { + childConverted.resolve() + throw redirect({ to: '/elsewhere' }) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: true, + }) + + const responsePromise = loadServerResponse(router, '/child') + await childConverted + rootChunkGate.reject(rootError) + const response = await responsePromise + + expect(response.status).toBe(500) + expect(response.headers.get('Location')).toBeNull() + expect(router.state.matches).toHaveLength(2) + expect(router.state.matches[0]).toMatchObject({ + routeId: rootRoute.id, + status: 'error', + error: rootError, + }) + }) + + test('does not leave later chunk rejections unhandled after request abort', async () => { + const rootError = new Error('root component failed') + const abortError = new Error('request closed') + const rootChunkGate = createControlledPromise() + const childChunkGate = createControlledPromise() + const rootChunkStarted = createControlledPromise() + const childChunkStarted = createControlledPromise() + const RootComponent = Object.assign(() => null, { + preload: () => { + rootChunkStarted.resolve() + return rootChunkGate + }, + }) + const ChildComponent = Object.assign(() => null, { + preload: () => { + childChunkStarted.resolve() + return childChunkGate + }, + }) + const rootRoute = new BaseRootRoute({ + component: RootComponent as any, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + component: ChildComponent as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: true, + }) + const controller = new AbortController() + const unhandled: Array = [] + const onUnhandled = (error: unknown) => { + unhandled.push(error) + } + process.on('unhandledRejection', onUnhandled) + + try { + const responsePromise = loadServerResponse( + router, + '/child', + controller.signal, + ) + await Promise.all([rootChunkStarted, childChunkStarted]) + + rootChunkGate.reject(rootError) + await expect(responsePromise).resolves.toMatchObject({ status: 500 }) + + controller.abort(abortError) + childChunkGate.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(unhandled).not.toContain(abortError) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + test('a required ancestor lazy chunk failure wins over a deeper loader notFound', async () => { + const chunkError = new Error('ancestor lazy chunk failed') + const rootRoute = new BaseRootRoute({}) + const ancestorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/ancestor', + errorComponent: () => null, + }).lazy(() => Promise.reject(chunkError)) + const childRoute = new BaseRoute({ + getParentRoute: () => ancestorRoute, + path: '/child', + loader: () => { + throw notFound() + }, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + ancestorRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/ancestor/child'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/ancestor/child') + + expect(response.status).toBe(500) + expect( + router.state.matches.find((match) => match.routeId === ancestorRoute.id), + ).toMatchObject({ status: 'error', error: chunkError }) + }) +}) diff --git a/packages/router-core/tests/server-concurrent-error-notfound.test.ts b/packages/router-core/tests/server-concurrent-error-notfound.test.ts new file mode 100644 index 0000000000..5fc3cc63f9 --- /dev/null +++ b/packages/router-core/tests/server-concurrent-error-notfound.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, + redirect, +} from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +const requiredPrefixModes = [ + ['fresh', 'error', false, 'child-first'], + ['fresh', 'error', true, 'child-first'], + ['fresh', 'notFound', false, 'child-first'], + ['fresh', 'notFound', true, 'child-first'], + ['retained', 'error', false, 'child-first'], + ['retained', 'notFound', false, 'child-first'], + ['retained', 'error', false, 'parent-first'], +] as const + +describe('concurrent route failure ordering', () => { + test.each(requiredPrefixModes)( + 'selects the reachable failure (parentData=%s, parent=%s, isServer=%s, order=%s)', + async (parentData, parentOutcome, isServer, settlementOrder) => { + const parentFailure = + parentOutcome === 'error' + ? new Error('parent loader failed') + : notFound() + const childFailure = + parentOutcome === 'error' + ? notFound() + : new Error('child loader failed') + const childOutcome = parentOutcome === 'error' ? 'notFound' : 'error' + const parentGate = createControlledPromise() + const childGate = createControlledPromise() + const parentStarted = createControlledPromise() + const childStarted = createControlledPromise() + const parentSettled = createControlledPromise() + const childSettled = createControlledPromise() + const settlements: Array = [] + let failing = parentData === 'fresh' + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + ssr: 'data-only', + loader: async () => { + if (!failing) { + return undefined + } + parentStarted.resolve() + await parentGate + settlements.push('parent') + parentSettled.resolve() + throw parentFailure + }, + errorComponent: () => null, + notFoundComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async () => { + if (!failing) { + return 'child data' + } + childStarted.resolve() + await childGate + settlements.push('child') + childSettled.resolve() + throw childFailure + }, + errorComponent: () => null, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer, + defaultStaleReloadMode: 'blocking', + }) + + if (parentData === 'retained') { + await router.load() + const parentMatch = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) + expect(parentMatch).toMatchObject({ + routeId: parentRoute.id, + status: 'success', + loaderData: undefined, + }) + failing = true + } + + const responsePromise = isServer + ? loadServerResponse(router, '/parent/child') + : (parentData === 'retained' + ? router.invalidate() + : router.load() + ).then(() => undefined) + await Promise.all([parentStarted, childStarted]) + if (settlementOrder === 'child-first') { + childGate.resolve() + await childSettled + parentGate.resolve() + await parentSettled + } else { + parentGate.resolve() + await parentSettled + childGate.resolve() + await childSettled + } + const response = await responsePromise + + const parentWon = + parentData === 'fresh' || settlementOrder === 'parent-first' + const selectedOutcome = parentWon ? parentOutcome : childOutcome + const selectedFailure = parentWon ? parentFailure : childFailure + if (isServer) { + expect(response?.status).toBe(selectedOutcome === 'error' ? 500 : 404) + } + expect(settlements).toEqual( + settlementOrder === 'child-first' + ? ['child', 'parent'] + : ['parent', 'child'], + ) + const terminalMatch = router.state.matches.find( + (match) => match.status === 'error' || match.status === 'notFound', + ) + expect(terminalMatch).toMatchObject({ + routeId: parentWon ? parentRoute.id : childRoute.id, + status: selectedOutcome, + error: selectedFailure, + }) + expect(router.state.status).toBe('idle') + }, + ) + + test.each([false, true])( + 'recomputes chunk readiness after a notFound ancestor promotes to root (isServer=%s)', + async (isServer) => { + const chunkError = new Error('intermediate lazy chunk failed') + const childError = new Error('child loader failed') + const rootNotFound = notFound() + const ancestorGate = createControlledPromise() + const childGate = createControlledPromise() + const ancestorStarted = createControlledPromise() + const childStarted = createControlledPromise() + const ancestorSettled = createControlledPromise() + const childSettled = createControlledPromise() + const settlements: Array = [] + const intermediateLazy = vi.fn(() => Promise.reject(chunkError)) + const rootRoute = new BaseRootRoute({ + notFoundComponent: () => null, + }) + const intermediateRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/intermediate', + errorComponent: () => null, + }).lazy(intermediateLazy) + const ancestorRoute = new BaseRoute({ + getParentRoute: () => intermediateRoute, + path: '/ancestor', + loader: async () => { + ancestorStarted.resolve() + await ancestorGate + settlements.push('ancestor') + ancestorSettled.resolve() + throw rootNotFound + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => ancestorRoute, + path: '/child', + loader: async () => { + childStarted.resolve() + await childGate + settlements.push('child') + childSettled.resolve() + throw childError + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + intermediateRoute.addChildren([ + ancestorRoute.addChildren([childRoute]), + ]), + ]), + history: createMemoryHistory({ + initialEntries: ['/intermediate/ancestor/child'], + }), + isServer, + }) + + const responsePromise = isServer + ? loadServerResponse(router, '/intermediate/ancestor/child') + : router.load().then(() => undefined) + await Promise.all([ancestorStarted, childStarted]) + childGate.resolve() + await childSettled + ancestorGate.resolve() + await ancestorSettled + const response = await responsePromise + + if (isServer) { + expect(response?.status).toBe(404) + } + expect(settlements).toEqual(['child', 'ancestor']) + expect(intermediateLazy).toHaveBeenCalled() + expect( + router.state.matches.find((match) => match.routeId === rootRoute.id), + ).toMatchObject({ + status: 'success', + error: rootNotFound, + }) + expect( + router.state.matches.some((match) => match.error === chunkError), + ).toBe(false) + }, + ) + + test.each([ + [false, 'beforeLoad'], + [false, 'loader'], + [true, 'beforeLoad'], + [true, 'loader'], + ] as const)( + 'keeps a resolved notFound boundary stable within one load (isServer=%s, phase=%s)', + async (isServer, phase) => { + const lazyError = new Error('lazy boundary lookup failed') + const notFoundError = notFound() + const resolveAt = phase === 'beforeLoad' ? 2 : 3 + let lazyCalls = 0 + const rootRoute = new BaseRootRoute({ + notFoundComponent: () => null, + }) + const intermediateRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/intermediate', + }).lazy(() => { + lazyCalls++ + if (lazyCalls < resolveAt) { + return Promise.reject(lazyError) + } + return Promise.resolve({ + options: { notFoundComponent: () => null }, + } as any) + }) + function throwNotFound() { + throw notFoundError + } + const childRoute = new BaseRoute({ + getParentRoute: () => intermediateRoute, + path: '/child', + beforeLoad: phase === 'beforeLoad' ? throwNotFound : undefined, + loader: phase === 'loader' ? throwNotFound : undefined, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + intermediateRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/intermediate/child'], + }), + isServer, + }) + + const response = isServer + ? await loadServerResponse(router, '/intermediate/child') + : await router.load().then(() => undefined) + + if (isServer) { + expect(response?.status).toBe(404) + } + expect(lazyCalls).toBe(!isServer && phase === 'loader' ? 2 : 1) + expect( + router.state.matches.find((match) => match.error === notFoundError) + ?.routeId, + ).toBe(rootRoute.id) + }, + ) + + test('a redirect aborts every generation in its discarded server lane', async () => { + const signals: Array = [] + const rootRoute = new BaseRootRoute({ + loader: ({ abortController }) => { + signals.push(abortController.signal) + return 'root data' + }, + }) + const redirectRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/redirect', + loader: ({ abortController }) => { + signals.push(abortController.signal) + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([redirectRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/redirect'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/redirect') + + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('/target') + expect(signals).toHaveLength(2) + expect(signals.every((signal) => signal.aborted)).toBe(true) + }) + + test('a started descendant can redirect after its ancestor fails', async () => { + const childStarted = createControlledPromise() + const childRedirect = createControlledPromise() + const parentError = new Error('parent failed') + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: async () => { + await childStarted + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async () => { + childStarted.resolve() + await childRedirect + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + const responsePromise = loadServerResponse(router, '/parent/child') + await childStarted + childRedirect.resolve() + const response = await responsePromise + + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('/target') + }) + + test('a redirect does not abort a later server generation on the same router', async () => { + let generation = 0 + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: () => { + generation++ + if (generation === 2) { + throw redirect({ to: '/target' }) + } + return `data ${generation}` + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: true, + }) + + expect((await loadServerResponse(router, '/page')).status).toBe(200) + expect((await loadServerResponse(router, '/page')).status).toBe(307) + expect((await loadServerResponse(router, '/page')).status).toBe(200) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + loaderData: 'data 3', + }) + }) + + test.each([false, true])( + 'an aborted child does not report its cancellation through onError (isServer=%s)', + async (isServer) => { + const childStarted = createControlledPromise() + const childLoader = createControlledPromise() + const cancellation = new Error('discarded request aborted') + const childOnError = vi.fn() + let childSignal: AbortSignal | undefined + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: async () => { + await childStarted + throw redirect({ to: '/target' }) + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => sourceRoute, + path: '/child', + loader: ({ abortController }) => { + childSignal = abortController.signal + abortController.signal.addEventListener( + 'abort', + () => childLoader.reject(cancellation), + { once: true }, + ) + childStarted.resolve() + return childLoader + }, + onError: childOnError, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + sourceRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/source/child'] }), + isServer, + }) + + const childCancellation = expect(childLoader).rejects.toBe(cancellation) + const loadPromise = isServer + ? loadServerResponse(router, '/source/child') + : router.load().then(() => undefined) + const [response] = await Promise.all([loadPromise, childCancellation]) + + expect(childSignal?.aborted).toBe(true) + expect(childOnError).not.toHaveBeenCalled() + if (isServer) { + expect(response?.status).toBe(307) + expect(response?.headers.get('Location')).toBe('/target') + } else { + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + }) + } + }, + ) +}) diff --git a/packages/router-core/tests/server-loader-abort-error.test.ts b/packages/router-core/tests/server-loader-abort-error.test.ts index eeab2cb3ca..bc079ce239 100644 --- a/packages/router-core/tests/server-loader-abort-error.test.ts +++ b/packages/router-core/tests/server-loader-abort-error.test.ts @@ -1,33 +1,289 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute } from '../src' -import { createTestRouter } from './routerTestUtils' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createRequestHandler } from '../src/ssr/server' +import { createTestRouter, loadServerResponse } from './routerTestUtils' describe('loader user-thrown abort values', () => { - test('treats a user-thrown AbortSignal as an ordinary route error (isServer=false)', async () => { - let matchSignal: AbortSignal | undefined + test.each( + ([false, true] as const).flatMap((isServer) => [ + { + isServer, + thrownType: 'AbortSignal', + createThrownValue: (signal: AbortSignal) => signal, + }, + { + isServer, + thrownType: 'AbortError', + createThrownValue: () => + new DOMException('The operation was aborted.', 'AbortError'), + }, + ]), + )( + 'treats a user-thrown $thrownType as an ordinary route error (isServer=$isServer)', + async ({ isServer, createThrownValue }) => { + let matchSignal: AbortSignal | undefined + let thrownValue: unknown + const rootRoute = new BaseRootRoute({}) + const abortingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/aborting', + loader: ({ abortController }) => { + matchSignal = abortController.signal + thrownValue = createThrownValue(matchSignal) + throw thrownValue + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([abortingRoute]), + history: createMemoryHistory({ initialEntries: ['/aborting'] }), + isServer, + }) + + if (isServer) { + const response = await loadServerResponse(router, '/aborting') + expect(response.status).toBe(500) + } else { + await router.load() + } + + const match = router.state.matches.find( + (item) => item.routeId === abortingRoute.id, + ) + // A server match remains ordinary route failure during reduction, then + // its controller is retired when the request response is cleaned up. + expect(matchSignal?.aborted).toBe(isServer) + expect(match?.status).toBe('error') + expect(match?.error).toBe(thrownValue) + }, + ) + + test('request cancellation reaches the controller shared by route hooks', async () => { + const loaderStarted = createControlledPromise() + const controllers: Array = [] + const onError = vi.fn() const rootRoute = new BaseRootRoute({}) - const abortingRoute = new BaseRoute({ + const route = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/aborting', + path: '/work', + context: ({ abortController }) => { + controllers.push(abortController) + }, + beforeLoad: ({ abortController }) => { + controllers.push(abortController) + }, loader: ({ abortController }) => { - matchSignal = abortController.signal - throw matchSignal + controllers.push(abortController) + loaderStarted.resolve() + return new Promise((_, reject) => { + abortController.signal.addEventListener( + 'abort', + () => reject(abortController.signal.reason), + { once: true }, + ) + }) }, + onError, }) const router = createTestRouter({ - routeTree: rootRoute.addChildren([abortingRoute]), - history: createMemoryHistory({ initialEntries: ['/aborting'] }), - isServer: false, + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/work'] }), + isServer: true, + }) + const requestController = new AbortController() + const render = vi.fn(() => new Response('must not render')) + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/work', { + signal: requestController.signal, + }), + }) + const response = handler(render) + await loaderStarted + + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + + await expect(response).rejects.toBe(cancellation) + expect(controllers).toHaveLength(3) + expect( + controllers.every((controller) => controller === controllers[0]), + ).toBe(true) + expect(controllers[0]?.signal.aborted).toBe(true) + expect(controllers[0]?.signal.reason).toBe(cancellation) + expect(onError).not.toHaveBeenCalled() + expect(render).not.toHaveBeenCalled() + }) + + test('response cleanup aborts the controller retained by deferred loader data', async () => { + const deferred = createControlledPromise() + let loaderSignal: AbortSignal | undefined + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/work', + loader: ({ abortController }) => { + loaderSignal = abortController.signal + return { deferred } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/work'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/work') + + expect(response.status).toBe(200) + expect(loaderSignal?.aborted).toBe(true) + deferred.resolve('late data') + }) + + test('request cancellation thrown from route context does not call onError', async () => { + const cancellation = new Error('request disconnected in context') + const requestController = new AbortController() + const beforeLoad = vi.fn() + const loader = vi.fn() + const onError = vi.fn() + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/work', + context: ({ abortController }) => { + requestController.abort(cancellation) + abortController.signal.throwIfAborted() + }, + beforeLoad, + loader, + onError, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/work'] }), + isServer: true, + }) + const render = vi.fn(() => new Response('must not render')) + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/work', { + signal: requestController.signal, + }), + }) + + await expect(handler(render)).rejects.toBe(cancellation) + + expect(onError).not.toHaveBeenCalled() + expect(beforeLoad).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + expect(render).not.toHaveBeenCalled() + }) + + test('request cancellation during beforeLoad does not start loaders', async () => { + const beforeLoadStarted = createControlledPromise() + const loader = vi.fn() + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/work', + beforeLoad: ({ abortController }) => { + beforeLoadStarted.resolve() + return new Promise((resolve) => { + abortController.signal.addEventListener('abort', () => resolve(), { + once: true, + }) + }) + }, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/work'] }), + isServer: true, + }) + const requestController = new AbortController() + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/work', { + signal: requestController.signal, + }), + }) + const response = handler(() => new Response('must not render')) + await beforeLoadStarted + + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + + await expect(response).rejects.toBe(cancellation) + expect(loader).not.toHaveBeenCalled() + }) + + test('request cancellation does not wait for manifest lookup', async () => { + const manifestStarted = createControlledPromise() + const manifest = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const loader = vi.fn() + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/work', + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/work'] }), + isServer: true, + }) + const requestController = new AbortController() + const render = vi.fn(() => new Response('must not render')) + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/work', { + signal: requestController.signal, + }), + getRouterManifest: () => { + manifestStarted.resolve() + return manifest + }, + }) + + const response = handler(render) + await manifestStarted + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + + await expect(response).rejects.toBe(cancellation) + expect(loader).not.toHaveBeenCalled() + expect(render).not.toHaveBeenCalled() + }) + + test('request cancellation does not wait for custom dehydration', async () => { + const dehydrateStarted = createControlledPromise() + const dehydrate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const router = createTestRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + dehydrate: () => { + dehydrateStarted.resolve() + return dehydrate + }, + }) + const requestController = new AbortController() + const render = vi.fn(() => new Response('must not render')) + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/', { + signal: requestController.signal, + }), }) - await router.load() + const response = handler(render) + await dehydrateStarted + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) - const match = router.state.matches.find( - (item) => item.routeId === abortingRoute.id, - ) - expect(matchSignal?.aborted).toBe(false) - expect(match?.status).toBe('error') - expect(match?.error).toBe(matchSignal) + await expect(response).rejects.toBe(cancellation) + expect(render).not.toHaveBeenCalled() }) }) diff --git a/packages/router-core/tests/server-planning-redirect.test.ts b/packages/router-core/tests/server-planning-redirect.test.ts new file mode 100644 index 0000000000..95a3a50317 --- /dev/null +++ b/packages/router-core/tests/server-planning-redirect.test.ts @@ -0,0 +1,79 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, redirect } from '../src' +import { createRequestHandler } from '../src/ssr/server' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +test.each([false, true])( + 'a route-context planning redirect is control with isServer=%s', + async (isServer) => { + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + context: () => { + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/source'] }), + isServer, + }) + + if (isServer) { + const response = await loadServerResponse(router, '/source') + + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('/target') + } else { + await router.load() + + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + }) + } + }, +) + +test('the generic request handler returns a redirect without rendering', async () => { + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => + redirect({ + to: '/target', + statusCode: 308, + headers: { 'x-redirect': 'route' }, + }), + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/source'] }), + isServer: true, + }) + const render = vi.fn(() => new Response('must not render')) + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/source'), + }) + + const response = await handler(render) + + expect(response.status).toBe(308) + expect(response.headers.get('Location')).toBe('/target') + expect(response.headers.get('x-redirect')).toBe('route') + expect(await response.text()).toBe('') + expect(render).not.toHaveBeenCalled() +}) diff --git a/packages/router-core/tests/server-route-lifecycle.test.ts b/packages/router-core/tests/server-route-lifecycle.test.ts new file mode 100644 index 0000000000..7c90f87b60 --- /dev/null +++ b/packages/router-core/tests/server-route-lifecycle.test.ts @@ -0,0 +1,76 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +test('server route lifecycle callbacks run after loaded matches are published', async () => { + const events: Array = [] + const onEnter = vi.fn((match) => { + events.push(`enter:${match.routeId}:${match.loaderData}`) + }) + const onStay = vi.fn((match) => { + events.push(`stay:${match.routeId}:${match.search.step}`) + }) + const onLeave = vi.fn((match) => { + events.push(`leave:${match.routeId}`) + }) + const barEnter = vi.fn((match) => { + events.push(`enter:${match.routeId}`) + }) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: () => { + events.push('beforeLoad:/foo') + return { ready: true } + }, + loader: ({ context }) => { + events.push(`loader:/foo:${context.ready}`) + return 'foo data' + }, + onEnter, + onStay, + onLeave, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + onEnter: barEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + isServer: true, + }) + + expect((await loadServerResponse(router, '/foo?step=one')).status).toBe(200) + expect(events).toEqual([ + 'beforeLoad:/foo', + 'loader:/foo:true', + `enter:${fooRoute.id}:foo data`, + ]) + expect(onEnter).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: fooRoute.id, + status: 'success', + loaderData: 'foo data', + context: expect.objectContaining({ ready: true }), + }), + ) + + events.length = 0 + expect((await loadServerResponse(router, '/foo?step=two')).status).toBe(200) + expect(events).toEqual([ + 'beforeLoad:/foo', + 'loader:/foo:true', + `stay:${fooRoute.id}:two`, + ]) + + events.length = 0 + expect((await loadServerResponse(router, '/bar')).status).toBe(200) + expect(events).toEqual([`leave:${fooRoute.id}`, `enter:${barRoute.id}`]) + expect(onLeave).toHaveBeenCalledTimes(1) + expect(barEnter).toHaveBeenCalledTimes(1) +}) diff --git a/packages/router-core/tests/server-serial-ssr-notfound.test.ts b/packages/router-core/tests/server-serial-ssr-notfound.test.ts new file mode 100644 index 0000000000..6f8c92b6ca --- /dev/null +++ b/packages/router-core/tests/server-serial-ssr-notfound.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, notFound } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +/** + * A notFound thrown from a route's ssr() option happens during the serial + * phase, before beforeLoad has run for that route and its descendants. It + * must cap the loader prefix like a beforeLoad notFound: the throwing route + * and its descendants must not run their loaders (their context is + * incomplete and their ssr status unresolved), and the response must be a + * 404 — a descendant loader failure must not be able to turn the outcome + * into a 200/500. + */ + +function setupRouter() { + const loaderCalls: Array = [] + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + notFoundComponent: () => null, + ssr: () => { + throw notFound() + }, + loader: () => { + loaderCalls.push('parent') + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + loaderCalls.push('child') + throw new Error('child loader failed') + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + return { router, rootRoute, parentRoute, childRoute, loaderCalls } +} + +describe('server serial ssr() notFound', () => { + test('caps the loader prefix at the boundary and sets 404', async () => { + const { router, rootRoute, parentRoute, childRoute, loaderCalls } = + setupRouter() + + const response = await loadServerResponse(router, '/parent/child') + + const parentMatch = router.state.matches.find( + (item) => item.routeId === parentRoute.id, + ) + expect(loaderCalls).toEqual([]) + expect(response.status).toBe(404) + expect(parentMatch?.status).toBe('notFound') + expect(parentMatch?.error).toEqual( + expect.objectContaining({ isNotFound: true }), + ) + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]) + expect(router.state.matches[1]?.status).toBe('notFound') + }) +}) diff --git a/packages/router-core/tests/server-ssr-false-assets.test.ts b/packages/router-core/tests/server-ssr-false-assets.test.ts new file mode 100644 index 0000000000..5ee27fa232 --- /dev/null +++ b/packages/router-core/tests/server-ssr-false-assets.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +/** + * The first `ssr: false` route contributes the assets needed for its server + * shell. Descendants inherit the client-only restriction and do not execute + * their route asset callbacks, even if they request `ssr: true` themselves. + */ +describe('server assets for ssr:false routes', () => { + test('executes assets at the client-only boundary but not below it', async () => { + const rootHead = vi.fn(() => ({ meta: [{ title: 'server root' }] })) + const rootScripts = vi.fn(() => [ + { + children: 'window.rootAssetRan = true', + }, + ]) + const rootHeaders = vi.fn(() => ({ + 'x-server-root': 'projected', + })) + const parentHead = vi.fn(() => ({ + meta: [{ title: 'client-only parent' }], + })) + const parentScripts = vi.fn(() => [ + { + children: 'window.parentAssetRan = true', + }, + ]) + const parentHeaders = vi.fn(() => ({ + 'x-client-only-parent': 'projected', + })) + const parentBeforeLoad = vi.fn(() => ({})) + const parentLoader = vi.fn(() => 'unexpected') + const childHead = vi.fn(() => ({ + meta: [{ title: 'client-only child' }], + })) + const childScripts = vi.fn(() => [ + { + children: 'window.childAssetRan = true', + }, + ]) + const childHeaders = vi.fn(() => ({ + 'x-client-only-child': 'unexpected', + })) + + const rootRoute = new BaseRootRoute({ + head: rootHead, + scripts: rootScripts, + headers: rootHeaders, + }) + const clientOnlyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/client-only', + ssr: false, + beforeLoad: parentBeforeLoad, + loader: parentLoader, + head: parentHead, + scripts: parentScripts, + headers: parentHeaders, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => clientOnlyRoute, + path: '/child', + // A child cannot relax its parent's client-only restriction. + ssr: true, + head: childHead, + scripts: childScripts, + headers: childHeaders, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + clientOnlyRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/client-only/child'], + }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/client-only/child') + + expect(response.status).toBe(200) + expect(rootHead).toHaveBeenCalledTimes(1) + expect(rootScripts).toHaveBeenCalledTimes(1) + expect(rootHeaders).toHaveBeenCalledTimes(1) + expect(response.headers.get('x-server-root')).toBe('projected') + expect(parentHead).toHaveBeenCalledTimes(1) + expect(parentScripts).toHaveBeenCalledTimes(1) + expect(parentHeaders).toHaveBeenCalledTimes(1) + expect(parentBeforeLoad).not.toHaveBeenCalled() + expect(parentLoader).not.toHaveBeenCalled() + expect(response.headers.get('x-client-only-parent')).toBe('projected') + expect( + router.state.matches.find( + (match) => match.routeId === clientOnlyRoute.id, + ), + ).toMatchObject({ + meta: [{ title: 'client-only parent' }], + scripts: [{ children: 'window.parentAssetRan = true' }], + headers: { 'x-client-only-parent': 'projected' }, + }) + expect(childHead).not.toHaveBeenCalled() + expect(childScripts).not.toHaveBeenCalled() + expect(childHeaders).not.toHaveBeenCalled() + expect(response.headers.get('x-client-only-child')).toBeNull() + }) + + test('executes assets when the root route is client-only', async () => { + const rootHead = vi.fn(() => ({ meta: [{ title: 'root shell' }] })) + const rootScripts = vi.fn(() => [ + { children: 'window.rootShellRan = true' }, + ]) + const rootHeaders = vi.fn(() => ({ 'x-root-shell': 'projected' })) + const childHead = vi.fn(() => ({ meta: [{ title: 'child' }] })) + const childScripts = vi.fn(() => [{ children: 'window.childRan = true' }]) + const childHeaders = vi.fn(() => ({ 'x-child': 'unexpected' })) + const rootRoute = new BaseRootRoute({ + ssr: false, + head: rootHead, + scripts: rootScripts, + headers: rootHeaders, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + ssr: true, + head: childHead, + scripts: childScripts, + headers: childHeaders, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/child') + + expect(response.status).toBe(200) + expect(rootHead).toHaveBeenCalledTimes(1) + expect(rootScripts).toHaveBeenCalledTimes(1) + expect(rootHeaders).toHaveBeenCalledTimes(1) + expect(response.headers.get('x-root-shell')).toBe('projected') + expect(childHead).not.toHaveBeenCalled() + expect(childScripts).not.toHaveBeenCalled() + expect(childHeaders).not.toHaveBeenCalled() + expect(response.headers.get('x-child')).toBeNull() + }) + + test('projects assets for a data-only branch', async () => { + const head = vi.fn(({ loaderData }) => ({ + meta: [{ title: `report: ${loaderData}` }], + })) + const scripts = vi.fn(({ loaderData }) => [ + { children: `window.report = ${JSON.stringify(loaderData)}` }, + ]) + const headers = vi.fn(({ loaderData }) => ({ + 'x-report': String(loaderData), + })) + const rootRoute = new BaseRootRoute({}) + const reportRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/report', + ssr: 'data-only', + loader: () => 'server data', + head, + scripts, + headers, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([reportRoute]), + history: createMemoryHistory({ initialEntries: ['/report'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/report') + const match = router.state.matches.at(-1) + + expect(response.status).toBe(200) + expect(response.headers.get('x-report')).toBe('server data') + expect(match).toMatchObject({ + ssr: 'data-only', + meta: [{ title: 'report: server data' }], + scripts: [{ children: 'window.report = "server data"' }], + headers: { 'x-report': 'server data' }, + }) + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + expect(headers).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/router-core/tests/server-ssr-option-error.test.ts b/packages/router-core/tests/server-ssr-option-error.test.ts new file mode 100644 index 0000000000..f1dcf64e14 --- /dev/null +++ b/packages/router-core/tests/server-ssr-option-error.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, redirect } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +/** + * A functional ssr() option is user route code in the server serial phase. + * If it throws an ordinary error, the route must not be committed as a + * successful 200 response with missing loader data. It follows the same + * renderable route-error lifecycle as beforeLoad and loader failures. + */ +describe('server functional ssr() errors', () => { + test.each(['sync', 'async'] as const)( + 'commits a %s failure with route context and returns 500', + async (failureMode) => { + const boom = new Error('feature flag lookup failed') + const loader = vi.fn(() => 'reports data') + const onError = vi.fn() + const beforeLoad = vi.fn() + + const rootRoute = new BaseRootRoute({ + context: () => ({ rootContext: true }), + beforeLoad: () => ({ rootBeforeLoadContext: true }), + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + context: ({ context }) => ({ + reportsContext: context.rootBeforeLoadContext, + }), + ssr: () => { + if (failureMode === 'sync') { + throw boom + } + return Promise.reject(boom) + }, + beforeLoad, + loader, + onError, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/reports'] }), + isServer: true, + context: { routerContext: true }, + }) + + const response = await loadServerResponse(router, '/reports') + + const reportsMatch = router.state.matches.find( + (match) => match.routeId === reportsRoute.id, + ) + expect(loader).not.toHaveBeenCalled() + expect(beforeLoad).not.toHaveBeenCalled() + expect(response.status).toBe(500) + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(boom) + expect(reportsMatch).toMatchObject({ + status: 'error', + error: boom, + context: { + routerContext: true, + rootContext: true, + rootBeforeLoadContext: true, + reportsContext: true, + }, + }) + }, + ) + + test('retains inherited renderability and loads the lazy error boundary', async () => { + const boom = new Error('SSR policy failed') + const errorBoundaryPreload = vi.fn(() => Promise.resolve()) + const ErrorBoundary = Object.assign(() => null, { + preload: errorBoundaryPreload, + }) + const rootRoute = new BaseRootRoute({}) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + ssr: async () => { + await Promise.resolve() + throw boom + }, + }) + const lazy = vi.fn(() => + Promise.resolve({ + options: { + id: reportsRoute.id, + errorComponent: ErrorBoundary, + }, + }), + ) + reportsRoute.lazy(lazy as any) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/reports'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/reports') + + expect(response.status).toBe(500) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: reportsRoute.id, + status: 'error', + error: boom, + ssr: true, + }) + expect(lazy).toHaveBeenCalledTimes(1) + expect(errorBoundaryPreload).toHaveBeenCalledTimes(1) + }) + + test('does not run route context after an SSR policy redirect', async () => { + const context = vi.fn(() => ({ reportsContext: true })) + const loader = vi.fn(() => 'reports data') + const rootRoute = new BaseRootRoute({}) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + ssr: () => { + throw redirect({ to: '/target' }) + }, + context, + loader, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([reportsRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/reports'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/reports') + + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('/target') + expect(context).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + }) + + test('preserves the SSR policy failure when route context also throws', async () => { + const policyError = new Error('SSR policy failed') + const contextError = new Error('route context failed') + const onError = vi.fn() + const rootRoute = new BaseRootRoute({}) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + ssr: () => { + throw policyError + }, + context: () => { + throw contextError + }, + onError, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/reports'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/reports') + + expect(response.status).toBe(500) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: reportsRoute.id, + status: 'error', + error: policyError, + }) + expect(onError).toHaveBeenCalledWith(policyError) + expect(onError).not.toHaveBeenCalledWith(contextError) + }) +}) diff --git a/packages/router-core/tests/ssr-match-id.bench.ts b/packages/router-core/tests/ssr-match-id.bench.ts new file mode 100644 index 0000000000..3f6a7c0a6e --- /dev/null +++ b/packages/router-core/tests/ssr-match-id.bench.ts @@ -0,0 +1,53 @@ +import { bench, describe, expect } from 'vitest' +import { dehydrateSsrMatchId, hydrateSsrMatchId } from '../src/ssr/ssr-match-id' + +const typicalIds = Array.from( + { length: 100 }, + (_, index) => + `/$orgId/projects/$projectId/acme/projects/project-${index}{"page":${index}}`, +) +const deepIds = Array.from( + { length: 100 }, + (_, index) => + `${Array.from({ length: 32 }, (__, depth) => `/route-${depth}`).join('')}/${index}`, +) +const reservedIds = Array.from( + { length: 100 }, + (_, index) => `~/\0/\uFFFD/~0/~r/${index}`, +) +const normalizedDeepIds = deepIds.map((id) => + dehydrateSsrMatchId(id).replaceAll('\0', '\uFFFD'), +) +let benchmarkSink = 0 + +for (const id of [...typicalIds, ...deepIds, ...reservedIds]) { + expect(hydrateSsrMatchId(dehydrateSsrMatchId(id))).toBe(id) +} + +describe('SSR match ID codec', () => { + bench('encode 100 typical match IDs', () => { + let size = 0 + for (const id of typicalIds) { + size += dehydrateSsrMatchId(id).length + } + benchmarkSink = size + }) + + bench('decode 100 normalized deep match IDs', () => { + let size = 0 + for (const id of normalizedDeepIds) { + size += hydrateSsrMatchId(id).length + } + benchmarkSink = size + }) + + bench('round-trip 100 reserved-character match IDs', () => { + let size = 0 + for (const id of reservedIds) { + size += hydrateSsrMatchId(dehydrateSsrMatchId(id)).length + } + benchmarkSink = size + }) +}) + +void benchmarkSink diff --git a/packages/router-core/tests/ssr-match-id.test.ts b/packages/router-core/tests/ssr-match-id.test.ts index c6a7437535..65e1cc4ebb 100644 --- a/packages/router-core/tests/ssr-match-id.test.ts +++ b/packages/router-core/tests/ssr-match-id.test.ts @@ -20,6 +20,17 @@ describe('ssr match id codec', () => { expect(hydrateSsrMatchId(id)).toBe(id) }) + it('round-trips reserved and browser-normalized characters', () => { + const id = '~/\0/\uFFFD/~0/~r' + const dehydratedId = dehydrateSsrMatchId(id) + const normalize = (value: string) => value.replaceAll('\0', '\uFFFD') + + expect(hydrateSsrMatchId(normalize(dehydratedId))).toBe(id) + expect(normalize(dehydrateSsrMatchId('/r'))).not.toBe( + normalize(dehydrateSsrMatchId('\uFFFDr')), + ) + }) + it('decodes browser-normalized replacement chars back to slashes', () => { const normalized = dehydrateSsrMatchId('/posts/1').replaceAll( '\0', diff --git a/packages/router-core/tests/ssr-server-cleanup.test.ts b/packages/router-core/tests/ssr-server-cleanup.test.ts index 0cc8832abd..d137fb3ae4 100644 --- a/packages/router-core/tests/ssr-server-cleanup.test.ts +++ b/packages/router-core/tests/ssr-server-cleanup.test.ts @@ -1,8 +1,11 @@ import { createMemoryHistory } from '@tanstack/history' -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { BaseRootRoute, BaseRoute } from '../src' import { createRequestHandler } from '../src/ssr/createRequestHandler' -import { createSsrStreamResponse } from '../src/ssr/handlerCallback' +import { + bindSsrResponseToRequest, + createSsrStreamResponse, +} from '../src/ssr/handlerCallback' import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { transformStreamWithRouter } from '../src/ssr/transformStreamWithRouter' import { createTestRouter } from './routerTestUtils' @@ -38,6 +41,10 @@ function deferred() { return { promise, resolve } } +afterEach(() => { + vi.restoreAllMocks() +}) + describe('serverSsr.cleanup', () => { test('onCleanup listeners run exactly once', () => { const router = buildRouter() @@ -338,6 +345,225 @@ describe('serverSsr.cleanup', () => { expect(router.serverSsr).toBeUndefined() }) + test('request abort settles while the render callback is still pending', async () => { + const router = buildRouter() + const requestController = new AbortController() + const renderStarted = deferred() + const renderResult = deferred>() + let cleanupCalls = 0 + let cancelCalls = 0 + let lateStreamResponse!: ReturnType + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/', { + signal: requestController.signal, + }), + }) + + const response = handler(({ router: requestRouter }) => { + const serverSsr = requestRouter.serverSsr! + const cleanup = serverSsr.cleanup + serverSsr.cleanup = () => { + cleanupCalls++ + cleanup() + } + lateStreamResponse = createSsrStreamResponse( + requestRouter, + new Response( + new ReadableStream({ + cancel() { + cancelCalls++ + return new Promise(() => {}) + }, + }), + ), + ) + renderStarted.resolve() + return renderResult.promise + }) + + await renderStarted.promise + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + + await expect(response).rejects.toBe(cancellation) + expect(cleanupCalls).toBe(1) + expect(router.serverSsr).toBeUndefined() + + renderResult.resolve(lateStreamResponse) + await Promise.resolve() + await Promise.resolve() + expect(cleanupCalls).toBe(1) + expect(cancelCalls).toBe(1) + expect(router.serverSsr).toBeUndefined() + }) + + test('request abort cancels a plain response resolved by the callback later', async () => { + const router = buildRouter() + const requestController = new AbortController() + const callbackStarted = deferred() + const callbackResult = deferred() + const cancel = vi.fn((_reason: unknown) => new Promise(() => {})) + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/', { + signal: requestController.signal, + }), + }) + + const response = handler(() => { + callbackStarted.resolve() + return callbackResult.promise + }) + + await callbackStarted.promise + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + + await expect(response).rejects.toBe(cancellation) + callbackResult.resolve(new Response(new ReadableStream({ cancel }))) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledTimes(1) + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + }) + + test.each(['throw', 'reject'] as const)( + 'reports a %s from disposal of a late render response', + async (failureMode) => { + const router = buildRouter() + const requestController = new AbortController() + const renderStarted = deferred() + const renderResult = deferred() + const cleanupError = new Error('late stream cleanup failed') + const dispose = vi.fn(() => { + if (failureMode === 'throw') { + throw cleanupError + } + return Promise.reject(cleanupError) + }) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/', { + signal: requestController.signal, + }), + }) + + try { + const response = handler(() => { + renderStarted.resolve() + return renderResult.promise + }) + + await renderStarted.promise + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + await expect(response).rejects.toBe(cancellation) + + renderResult.resolve({ + response: new Response('stream'), + serverSsrCleanup: 'stream', + dispose, + }) + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledWith(cleanupError) + }) + expect(dispose).toHaveBeenCalledOnce() + } finally { + router.serverSsr?.cleanup() + consoleError.mockRestore() + } + }, + ) + + test('request abort disposes a stream after response handoff', async () => { + const router = buildRouter() + const requestController = new AbortController() + let cleanupCalls = 0 + let cancelCalls = 0 + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/', { + signal: requestController.signal, + }), + }) + + const response = await handler(({ router: requestRouter }) => { + const serverSsr = requestRouter.serverSsr! + const cleanup = serverSsr.cleanup + serverSsr.cleanup = () => { + cleanupCalls++ + cleanup() + } + return createSsrStreamResponse( + requestRouter, + new Response( + new ReadableStream({ + cancel() { + cancelCalls++ + return new Promise(() => {}) + }, + }), + ), + ) + }) + + expect(response.body).not.toBeNull() + expect(cleanupCalls).toBe(0) + requestController.abort(new Error('request disconnected')) + await Promise.resolve() + + expect(cleanupCalls).toBe(1) + expect(cancelCalls).toBe(1) + expect(router.serverSsr).toBeUndefined() + }) + + test.each(['throw', 'reject'] as const)( + 'reports a custom stream disposal %s after request abort', + async (failureMode) => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + let cleanupCalls = 0 + const cleanup = router.serverSsr!.cleanup + router.serverSsr!.cleanup = () => { + cleanupCalls++ + cleanup() + } + const cleanupError = new Error('custom stream cleanup failed') + const dispose = vi.fn(() => { + if (failureMode === 'throw') { + throw cleanupError + } + return Promise.reject(cleanupError) + }) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const requestController = new AbortController() + + bindSsrResponseToRequest( + router, + { + response: new Response('stream'), + serverSsrCleanup: 'stream', + dispose, + }, + requestController.signal, + ) + requestController.abort(new Error('request disconnected')) + + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledWith(cleanupError) + }) + expect(dispose).toHaveBeenCalledOnce() + expect(cleanupCalls).toBe(1) + expect(router.serverSsr).toBeUndefined() + }, + ) + test('request handler defers cleanup for stream response metadata', async () => { const router = buildRouter() let cleanupCalls = 0 diff --git a/packages/router-core/tests/ssr-server-manifest.test.ts b/packages/router-core/tests/ssr-server-manifest.test.ts index f76535622f..6cdc98a87b 100644 --- a/packages/router-core/tests/ssr-server-manifest.test.ts +++ b/packages/router-core/tests/ssr-server-manifest.test.ts @@ -401,4 +401,56 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { '/assets/index.js', ]) }) + + test('omits descendant assets past a terminal parent boundary', async () => { + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw new Error('parent failed') + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + const manifest: ServerManifest = { + inlineCss: { + styles: { + '/assets/root.css': '.root{}', + '/assets/parent.css': '.parent{}', + '/assets/child.css': '.child{}', + }, + }, + routes: { + [rootRoute.id]: { css: ['/assets/root.css'] }, + [parentRoute.id]: { css: ['/assets/parent.css'] }, + [childRoute.id]: { + css: ['/assets/child.css'], + preloads: ['/assets/child.js'], + }, + }, + } + + attachRouterServerSsrUtils({ router, manifest }) + await router.load() + + expect(router.state.matches).toHaveLength(3) + expect(router.ssr!.manifest?.inlineStyle?.children).toBe('.root{}.parent{}') + + await router.serverSsr!.dehydrate() + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + const dehydratedManifest = parseSerializedRouter( + script!.children!, + ).manifest! + + expect(dehydratedManifest.routes[childRoute.id]).toBeUndefined() + }) }) diff --git a/packages/router-core/tests/stay-match-abort.test.ts b/packages/router-core/tests/stay-match-abort.test.ts new file mode 100644 index 0000000000..a389d2be76 --- /dev/null +++ b/packages/router-core/tests/stay-match-abort.test.ts @@ -0,0 +1,247 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.restoreAllMocks() +}) + +/** + * A settled success stay-match must keep its abortController un-aborted + * across navigations and invalidations: loaders can hand that signal to + * still-streaming deferred data (fetch(url, { signal }) consumed via + * ), and the documented contract only cancels the signal when the + * route unloads or the loader call becomes outdated. Only pending or + * actively-fetching matches are cancelled at load start. + */ + +function setup(reloadGate?: Promise) { + let capturedSignal: AbortSignal | undefined + let deferredRequestAbortCount = 0 + let loaderCalls = 0 + + const rootRoute = new BaseRootRoute({}) + const dashboardRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + staleTime: Infinity, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderCalls++ + capturedSignal = abortController.signal + + // A common streaming pattern is to return immediately-available data + // alongside a request consumed later by . That request must stay + // alive while this layout is reused, then be cancelled when it unloads. + const deferredRequest = new Promise<'aborted'>((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => { + deferredRequestAbortCount++ + resolve('aborted') + }, + { once: true }, + ) + }) + + const data = { critical: 'dashboard data', deferredRequest } + return reloadGate && loaderCalls > 1 ? reloadGate.then(() => data) : data + }, + }) + const settingsRoute = new BaseRoute({ + getParentRoute: () => dashboardRoute, + path: '/settings', + }) + const loginRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/login', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + dashboardRoute.addChildren([settingsRoute]), + loginRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/dashboard'] }), + }) + + return { + router, + dashboardRoute, + getSignal: () => capturedSignal, + getDeferredRequestAbortCount: () => deferredRequestAbortCount, + } +} + +describe('stay-match abort scope', () => { + test('unloading a reused match aborts the signal exposed to its loader', async () => { + const { router, getSignal, getDeferredRequestAbortCount } = setup() + + await router.load() + const dashboardLoaderSignal = getSignal() + + // Reusing the dashboard layout for a child must preserve the lifetime of + // its loader's deferred request. + await router.navigate({ to: '/dashboard/settings' }) + expect(dashboardLoaderSignal?.aborted).toBe(false) + expect(getDeferredRequestAbortCount()).toBe(0) + + // Leaving the layout altogether must now cancel that same request. + await router.navigate({ to: '/login' }) + expect(dashboardLoaderSignal?.aborted).toBe(true) + expect(getDeferredRequestAbortCount()).toBe(1) + }) + + test('background invalidation keeps the old loader signal until fresh data commits', async () => { + const reloadGate = createControlledPromise() + const { router, dashboardRoute, getSignal } = setup(reloadGate) + + await router.load() + const firstSignal = getSignal() + expect(firstSignal?.aborted).toBe(false) + + const invalidation = router.invalidate() + await vi.waitFor(() => expect(getSignal()).not.toBe(firstSignal)) + const replacementSignal = getSignal() + + // The old loader data is still the published generation while the + // background replacement is private. + expect(firstSignal?.aborted).toBe(false) + expect(replacementSignal?.aborted).toBe(false) + + reloadGate.resolve() + await invalidation + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: dashboardRoute.id, + status: 'success', + }) + expect(replacementSignal?.aborted).toBe(false) + }) + + test.each(['beforeLoad', 'shouldReload'] as const)( + '%s failure replacing a successful stay aborts its loader signal', + async (failureStage) => { + const failure = new Error(`${failureStage} failed`) + const onAbort = vi.fn() + let shouldFail = false + let loaderSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const accountRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/account', + staleTime: Infinity, + beforeLoad: () => { + if (shouldFail && failureStage === 'beforeLoad') { + throw failure + } + }, + shouldReload: () => { + if (shouldFail && failureStage === 'shouldReload') { + throw failure + } + return false + }, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderSignal = abortController.signal + loaderSignal.addEventListener('abort', onAbort, { once: true }) + return { user: 'Flo' } + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([accountRoute]), + history: createMemoryHistory({ initialEntries: ['/account'] }), + }) + + await router.load() + expect(loaderSignal?.aborted).toBe(false) + + shouldFail = true + await router.load() + + const match = router.state.matches.find( + (candidate) => candidate.routeId === accountRoute.id, + ) + expect(match?.status).toBe('error') + expect(match?.error).toBe(failure) + expect(loaderSignal?.aborted).toBe(true) + expect(onAbort).toHaveBeenCalledTimes(1) + }, + ) + + test('background notFound replacing successful data aborts its old loader signal', async () => { + let loaderCalls = 0 + let initialSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const accountRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/account', + staleTime: Infinity, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderCalls++ + if (loaderCalls === 1) { + initialSignal = abortController.signal + return { user: 'Flo' } + } + throw notFound() + }, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([accountRoute]), + history: createMemoryHistory({ initialEntries: ['/account'] }), + }) + + await router.load() + expect(initialSignal?.aborted).toBe(false) + + await router.invalidate() + await vi.waitFor(() => + expect(router.state.matches.at(-1)?.status).toBe('notFound'), + ) + expect(initialSignal?.aborted).toBe(true) + }) + + test('decorative background asset failure still transfers loader signal ownership', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + let loaderCalls = 0 + const loaderSignals: Array = [] + + const rootRoute = new BaseRootRoute({}) + const accountRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/account', + staleTime: Infinity, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderSignals.push(abortController.signal) + return { revision: ++loaderCalls } + }, + head: ({ loaderData }) => { + if (loaderData?.revision === 2) { + throw new Error('fresh head failed') + } + return { meta: [{ title: 'account' }] } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([accountRoute]), + history: createMemoryHistory({ initialEntries: ['/account'] }), + }) + + await router.load() + await router.invalidate() + + expect(router.state.matches.at(-1)?.loaderData).toEqual({ revision: 2 }) + expect(loaderSignals).toHaveLength(2) + expect(loaderSignals[0]?.aborted).toBe(true) + expect(loaderSignals[1]?.aborted).toBe(false) + }) +}) diff --git a/packages/router-core/tests/transformStreamWithRouter.test.ts b/packages/router-core/tests/transformStreamWithRouter.test.ts index 13b0072e24..903efeb0c9 100644 --- a/packages/router-core/tests/transformStreamWithRouter.test.ts +++ b/packages/router-core/tests/transformStreamWithRouter.test.ts @@ -436,6 +436,38 @@ describe('transformStreamWithRouter — real SSR scripts', () => { }) describe('transformStreamWithRouter — cleanup side-effects', () => { + test.each([ + ['fast', true], + ['main', false], + ] as const)( + 'request abort cleans the %s transform without consuming its response body', + async (_path, fastPath) => { + const { router, cleanupCalls } = makeRouter({ + isSerializationFinished: () => fastPath, + reserveStreamFastPath: () => fastPath, + takeBufferedHtml: () => undefined, + }) + const upstream = makeManualUpstream() + const request = new AbortController() + + const responseBody = transformStreamWithRouter( + router as any, + upstream.stream as any, + { signal: request.signal }, + ) + + expect(responseBody.locked).toBe(false) + + const reason = new Error('request-gone') + request.abort(reason) + await flush() + + expect(responseBody.locked).toBe(false) + expect(upstream.cancelled).toEqual({ value: true, reason }) + expect(cleanupCalls.count).toBe(1) + }, + ) + test('downstream cancel propagates upstream and calls serverSsr.cleanup once', async () => { // Fast path: simpler, no scanner. Verifies cancel + cleanup contract. const { router, cleanupCalls } = makeRouter({ diff --git a/packages/router-devtools-core/package.json b/packages/router-devtools-core/package.json index 9dd2165884..043626815e 100644 --- a/packages/router-devtools-core/package.json +++ b/packages/router-devtools-core/package.json @@ -25,7 +25,8 @@ ], "scripts": { "clean": "rimraf ./dist && rimraf ./coverage", - "test:eslint": "eslint ./src", + "test:eslint": "eslint ./src ./tests", + "test:unit": "vitest", "test:types": "pnpm run \"/^test:types:ts[0-9]{2}$/\"", "test:types:ts56": "node ../../node_modules/typescript56/lib/tsc.js", "test:types:ts57": "node ../../node_modules/typescript57/lib/tsc.js", @@ -66,6 +67,7 @@ "goober": "^2.1.16" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", "solid-js": "^1.9.10", "vite": "*", "vite-plugin-solid": "^2.11.10" diff --git a/packages/router-devtools-core/src/AgeTicker.tsx b/packages/router-devtools-core/src/AgeTicker.tsx index 27d179fda1..e3bd657358 100644 --- a/packages/router-devtools-core/src/AgeTicker.tsx +++ b/packages/router-devtools-core/src/AgeTicker.tsx @@ -1,6 +1,6 @@ import { clsx as cx } from 'clsx' import { useStyles } from './useStyles' -import type { AnyRouteMatch, AnyRouter } from '@tanstack/router-core' +import type { AnyRoute, AnyRouteMatch, AnyRouter } from '@tanstack/router-core' import type { Accessor } from 'solid-js' function formatTime(ms: number) { @@ -35,7 +35,9 @@ export function AgeTicker({ return null } - const route = router().looseRoutesById[match.routeId]! + const route = (router().routesById as Record)[ + match.routeId + ]! if (!route.options.loader) { return null diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 79566656b0..966eb32bc5 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -282,46 +282,40 @@ export const BaseTanStackRouterDevtoolsPanel = const [history, setHistory] = createSignal>([]) const [hasHistoryOverflowed, setHasHistoryOverflowed] = createSignal(false) - let pendingMatches: Accessor> - let cachedMatches: Accessor> - // subscribable implementation - if ('subscribe' in router().stores.pendingMatches) { - const [_pendingMatches, setPending] = createSignal>( - [], - ) - pendingMatches = _pendingMatches - - const [_cachedMatches, setCached] = createSignal>([]) - cachedMatches = _cachedMatches - - type Subscribe = (fn: () => void) => { unsubscribe: () => void } - createEffect(() => { - const pendingMatchesStore = router().stores.pendingMatches - setPending(pendingMatchesStore.get()) - const subscription = ( - (pendingMatchesStore as any).subscribe as Subscribe - )(() => { - setPending(pendingMatchesStore.get()) - }) - onCleanup(() => subscription.unsubscribe()) - }) - - createEffect(() => { - const cachedMatchesStore = router().stores.cachedMatches - setCached(cachedMatchesStore.get()) - const subscription = ( - (cachedMatchesStore as any).subscribe as Subscribe - )(() => { - setCached(cachedMatchesStore.get()) - }) - onCleanup(() => subscription.unsubscribe()) - }) - } - // signal implementation - else { - pendingMatches = () => router().stores.pendingMatches.get() - cachedMatches = () => router().stores.cachedMatches.get() + const pendingMatches = () => { + const matches = routerState().matches + return matches.some((match: AnyRouteMatch) => match.status === 'pending') + ? matches + : [] } + let cache = router()._cache + let cacheMatches = [...cache.values()] + const [cachedMatches, setCachedMatches] = createSignal(cacheMatches) + + createEffect(() => { + const refreshCache = () => { + const next = router()._cache + let valuesChanged = next.size !== cacheMatches.length + if (!valuesChanged) { + let index = 0 + for (const match of next.values()) { + if (match !== cacheMatches[index++]) { + valuesChanged = true + break + } + } + } + + if (next !== cache || valuesChanged) { + cache = next + cacheMatches = [...next.values()] + setCachedMatches(cacheMatches) + } + } + refreshCache() + const interval = setInterval(refreshCache, 500) + onCleanup(() => clearInterval(interval)) + }) createEffect(() => { const matches = routerState().matches @@ -392,7 +386,6 @@ export const BaseTanStackRouterDevtoolsPanel = 'stores', 'basepath', 'subscribers', - 'latestLoadPromise', '_scroll', 'tempLocationKey', 'latestLocation', @@ -719,7 +712,9 @@ export const BaseTanStackRouterDevtoolsPanel =
State:
- {pendingMatches().find((d) => d.id === activeMatch()?.id) + {pendingMatches().find( + (d: AnyRouteMatch) => d.id === activeMatch()?.id, + ) ? 'Pending' : routerState().matches.find( (d: any) => d.id === activeMatch()?.id, diff --git a/packages/router-devtools-core/src/useStyles.tsx b/packages/router-devtools-core/src/useStyles.tsx index 5524ed0ae8..32908c524d 100644 --- a/packages/router-devtools-core/src/useStyles.tsx +++ b/packages/router-devtools-core/src/useStyles.tsx @@ -428,7 +428,7 @@ const stylesFactory = (shadowDOMTarget?: ShadowRoot) => { line-height: ${tokens.font.lineHeight.sm}; `, matchStatus: ( - status: 'pending' | 'success' | 'error' | 'notFound' | 'redirected', + status: 'pending' | 'success' | 'error' | 'notFound', isFetching: false | 'beforeLoad' | 'loader', ) => { const colorMap = { @@ -436,7 +436,6 @@ const stylesFactory = (shadowDOMTarget?: ShadowRoot) => { success: 'green', error: 'red', notFound: 'purple', - redirected: 'gray', } as const const color = diff --git a/packages/router-devtools-core/src/utils.tsx b/packages/router-devtools-core/src/utils.tsx index c14bfd80be..962cdaf0f3 100644 --- a/packages/router-devtools-core/src/utils.tsx +++ b/packages/router-devtools-core/src/utils.tsx @@ -25,7 +25,6 @@ export function getStatusColor(match: AnyRouteMatch) { success: 'green', error: 'red', notFound: 'purple', - redirected: 'gray', } as const return match.isFetching && match.status === 'success' diff --git a/packages/router-devtools-core/tests/cache-replacement.test.ts b/packages/router-devtools-core/tests/cache-replacement.test.ts new file mode 100644 index 0000000000..1da523a1fb --- /dev/null +++ b/packages/router-devtools-core/tests/cache-replacement.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { TanStackRouterDevtoolsPanelCore } from '../src/TanStackRouterDevtoolsPanelCore' +import type { AnyRouteMatch, AnyRouter } from '@tanstack/router-core' + +function createCachedMatch(loaderData: string): AnyRouteMatch { + return { + id: 'cached-match', + routeId: '/cached', + pathname: '/cached', + params: {}, + search: {}, + status: 'success', + isFetching: false, + updatedAt: Date.now(), + loaderData, + } as AnyRouteMatch +} + +describe('cached matches', () => { + let panel: TanStackRouterDevtoolsPanelCore | undefined + + // Warm Vite's lazy transform of the panel chunk outside any test so its + // cost never counts against a test's timeout budget on slow CI runners. + beforeAll(async () => { + await import('../src/BaseTanStackRouterDevtoolsPanel') + }, 30_000) + + afterEach(() => { + panel?.unmount() + panel = undefined + document.body.innerHTML = '' + try { + window.localStorage.clear() + } catch {} + vi.useRealTimers() + }) + + it('refreshes when an existing cache entry is replaced', async () => { + vi.useFakeTimers() + + const route = { + id: '/cached', + path: 'cached', + fullPath: '/cached', + rank: 0, + children: [], + options: { loader: () => undefined }, + } + const routeTree = { + id: '__root__', + path: '/', + fullPath: '/', + rank: 0, + children: [route], + options: {}, + } + const cache = new Map([['cached-match', createCachedMatch('old data')]]) + const router = { + _cache: cache, + routeTree, + routesById: { + __root__: routeTree, + '/cached': route, + }, + options: {}, + navigate: vi.fn(), + } as unknown as AnyRouter + const routerState = { + location: { + href: '/', + pathname: '/', + search: {}, + searchStr: '', + hash: '', + }, + matches: [], + } + const container = document.createElement('div') + document.body.append(container) + + panel = new TanStackRouterDevtoolsPanelCore({ router, routerState }) + panel.mount(container) + + await vi.waitFor(() => { + expect( + container.querySelector( + '[aria-label="Open match details for cached-match"]', + ), + ).not.toBeNull() + }) + + const cachedMatch = container.querySelector( + '[aria-label="Open match details for cached-match"]', + ) as HTMLElement + cachedMatch.click() + expect(container.textContent).toContain('old data') + + cache.set('cached-match', createCachedMatch('new data')) + await vi.advanceTimersByTimeAsync(500) + + expect(container.textContent).toContain('new data') + expect(container.textContent).not.toContain('old data') + }, 10_000) +}) diff --git a/packages/router-devtools-core/tsconfig.json b/packages/router-devtools-core/tsconfig.json index 1b802b64d1..e24672b5de 100644 --- a/packages/router-devtools-core/tsconfig.json +++ b/packages/router-devtools-core/tsconfig.json @@ -4,5 +4,5 @@ "jsx": "preserve", "jsxImportSource": "solid-js" }, - "include": ["src", "vite.config.ts"] + "include": ["src", "tests", "vite.config.ts"] } diff --git a/packages/router-devtools-core/vite.config.ts b/packages/router-devtools-core/vite.config.ts index 53da7f8839..b14dd2bdf5 100644 --- a/packages/router-devtools-core/vite.config.ts +++ b/packages/router-devtools-core/vite.config.ts @@ -1,9 +1,23 @@ import { defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' import solid from 'vite-plugin-solid' +import packageJson from './package.json' const config = defineConfig({ plugins: [solid()], + test: { + name: packageJson.name, + dir: './tests', + watch: false, + environment: 'jsdom', + typecheck: { enabled: true }, + setupFiles: [], + server: { + deps: { + inline: [/solid-js/], + }, + }, + }, }) const merged = mergeConfig( diff --git a/packages/router-plugin/src/core/hmr/handle-route-update.ts b/packages/router-plugin/src/core/hmr/handle-route-update.ts index c0325b06e9..23b51d2432 100644 --- a/packages/router-plugin/src/core/hmr/handle-route-update.ts +++ b/packages/router-plugin/src/core/hmr/handle-route-update.ts @@ -1,15 +1,9 @@ -import type { - AnyRoute, - AnyRouteMatch, - AnyRouter, - RouterWritableStore, -} from '@tanstack/router-core' +import type { AnyRoute, AnyRouter } from '@tanstack/router-core' type AnyRouteWithPrivateProps = AnyRoute & { options: Record parentRoute: AnyRoute - _componentsPromise?: Promise - _lazyPromise?: Promise + _lazy?: Promise | true update: (options: Record) => unknown _path: string _id: string @@ -17,37 +11,19 @@ type AnyRouteWithPrivateProps = AnyRoute & { _to: string } -type AnyRouterWithPrivateMaps = AnyRouter & { +type AnyRouterWithPrivateState = AnyRouter & { routesById: Record buildRouteTree: () => Parameters[0] setRoutes: AnyRouter['setRoutes'] - stores: AnyRouter['stores'] & { - cachedMatchStores: Map< - string, - Pick, 'get' | 'set'> - > - pendingMatchStores: Map< - string, - Pick, 'get' | 'set'> - > - matchStores: Map< - string, - Pick, 'get' | 'set'> - > - } -} - -type AnyRouteMatchWithPrivateProps = AnyRouteMatch & { - __beforeLoadContext?: unknown - __routeContext?: Record - context?: Record + _refreshRoute?: () => Promise + _replaceRouteChunk: (route: AnyRoute, lazyFn: AnyRoute['lazyFn']) => void } function handleRouteUpdate( routeId: string, newRoute: AnyRouteWithPrivateProps, ) { - const router = window.__TSR_ROUTER__ as AnyRouterWithPrivateMaps + const router = window.__TSR_ROUTER__ as AnyRouterWithPrivateState const oldRoute = router.routesById[routeId] as | AnyRouteWithPrivateProps | undefined @@ -66,14 +42,6 @@ function handleRouteUpdate( } }) - const removedKeys = new Set() - Object.keys(oldRoute.options).forEach((key) => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key) - delete oldRoute.options[key] - } - }) - const oldHasShellComponent = 'shellComponent' in oldRoute.options const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = @@ -106,60 +74,12 @@ function handleRouteUpdate( oldRoute.options = nextOptions oldRoute.update(nextOptions) - oldRoute._componentsPromise = undefined - oldRoute._lazyPromise = undefined + router._replaceRouteChunk(oldRoute, newRoute.lazyFn) router.setRoutes(router.buildRouteTree()) syncHotRouteExport(oldRoute) router.resolvePathCache.clear() - - const filter = (m: AnyRouteMatch) => m.routeId === oldRoute.id - const activeMatch = router.stores.matches.get().find(filter) - const pendingMatch = router.stores.pendingMatches.get().find(filter) - const cachedMatches = router.stores.cachedMatches.get().filter(filter) - - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - // Clear stale match data for removed route options BEFORE invalidating. - // Without this, router.invalidate() -> matchRoutes() reuses the existing - // match from the store (via ...existingMatch spread) and the stale - // loaderData / __beforeLoadContext survives the reload cycle. - // - // We must update the store directly (not via router.updateMatch) because - // updateMatch wraps in startTransition which may defer the state update, - // and we need the clear to be visible before invalidate reads the store. - if (removedKeys.has('loader') || removedKeys.has('beforeLoad')) { - const matchIds = [ - activeMatch?.id, - pendingMatch?.id, - ...cachedMatches.map((match) => match.id), - ].filter(Boolean) as Array - router.batch(() => { - for (const matchId of matchIds) { - const store = - router.stores.pendingMatchStores.get(matchId) || - router.stores.matchStores.get(matchId) || - router.stores.cachedMatchStores.get(matchId) - if (store) { - store.set((prev) => { - const next: AnyRouteMatchWithPrivateProps = { ...prev } - - if (removedKeys.has('loader')) { - next.loaderData = undefined - } - if (removedKeys.has('beforeLoad')) { - next.__beforeLoadContext = undefined - next.context = rebuildMatchContextWithoutBeforeLoad(next) - } - - return next - }) - } - } - }) - } - - router.invalidate({ filter, sync: true }) - } + void router._refreshRoute?.() function syncHotRouteExport(liveRoute: AnyRouteWithPrivateProps) { // routeTree.gen.ts mutates the original module export with generated @@ -172,66 +92,6 @@ function handleRouteUpdate( newRoute._fullPath = liveRoute._fullPath newRoute._to = liveRoute._to } - - function getStoreMatch(matchId: string) { - return ( - router.stores.pendingMatchStores.get(matchId)?.get() || - router.stores.matchStores.get(matchId)?.get() || - router.stores.cachedMatchStores.get(matchId)?.get() - ) - } - - function getMatchList(matchId: string) { - const pendingMatches = router.stores.pendingMatches.get() - if (pendingMatches.some((match) => match.id === matchId)) { - return pendingMatches - } - - const activeMatches = router.stores.matches.get() - if (activeMatches.some((match) => match.id === matchId)) { - return activeMatches - } - - const cachedMatches = router.stores.cachedMatches.get() - if (cachedMatches.some((match) => match.id === matchId)) { - return cachedMatches - } - - return [] - } - - function getParentMatch(match: AnyRouteMatch) { - const matchList = getMatchList(match.id) - const matchIndex = matchList.findIndex((item) => item.id === match.id) - - if (matchIndex <= 0) { - return undefined - } - - const parentMatch = matchList[matchIndex - 1]! - return getStoreMatch(parentMatch.id) || parentMatch - } - - function rebuildMatchContextWithoutBeforeLoad( - match: AnyRouteMatchWithPrivateProps, - ) { - const parentMatch = getParentMatch(match) - const getParentContext = ( - router as unknown as { - getParentContext?: ( - parentMatch?: AnyRouteMatch, - ) => Record | undefined - } - ).getParentContext - const parentContext = getParentContext - ? getParentContext.call(router, parentMatch) - : (parentMatch?.context ?? router.options.context) - - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}), - } - } } const handleRouteUpdateStr = handleRouteUpdate.toString() diff --git a/packages/router-plugin/tests/add-hmr.test.ts b/packages/router-plugin/tests/add-hmr.test.ts index b0481bb7f8..98b3e61a5f 100644 --- a/packages/router-plugin/tests/add-hmr.test.ts +++ b/packages/router-plugin/tests/add-hmr.test.ts @@ -120,6 +120,36 @@ describe('add-hmr works', () => { expect(output).toContain('newModule') }) + it('generates lazy reset and scoped route refresh without route context mutation', async () => { + const filename = 'arrow-function.tsx' + const framework = 'react' + const file = await readFile( + path.join(getFrameworkDir(framework).files, filename), + ) + const code = file.toString() + const compileResult = compileCodeSplitReferenceRoute({ + code, + filename, + id: filename, + addHmr: true, + codeSplitGroupings: defaultCodeSplitGroupings, + targetFramework: framework, + compilerPlugins: getFrameworkHmrCompilerPlugins({ + targetFramework: framework, + }), + }) + if (!compileResult) { + throw new Error('Expected the reference route to be transformed') + } + const output = compileResult.code + + expect(output).toContain( + 'router._replaceRouteChunk(oldRoute, newRoute.lazyFn);', + ) + expect(output).toContain('void router._refreshRoute?.();') + expect(output).not.toContain('__routeContext') + }) + it('uses a generated route id fallback for Vite HMR', async () => { const statement = createRouteHmrStatement([], { hmrStyle: 'vite', diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index a463d9ab22..4f32369e8f 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -52,13 +52,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -77,48 +70,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -127,46 +83,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx index 44908e32a9..12e47f429e 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx @@ -38,13 +38,6 @@ if (import.meta.webpackHot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -63,48 +56,11 @@ if (import.meta.webpackHot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -113,46 +69,6 @@ if (import.meta.webpackHot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } })(routeId, Route); try { const tsrReactRefreshUtils = typeof __react_refresh_utils__ !== 'undefined' ? __react_refresh_utils__ : undefined; diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx index b04afe83f4..70c428363e 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx @@ -63,13 +63,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -88,48 +81,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -138,46 +94,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx index 9ef182f4b7..e5a473dc14 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx @@ -41,13 +41,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -66,48 +59,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -116,46 +72,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx index d3eae5cb36..cb5014cd35 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx @@ -59,13 +59,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -84,48 +77,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -134,46 +90,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx index 9789370efc..7ba271c212 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx @@ -43,13 +43,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -68,48 +61,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -118,46 +74,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx index 6c7073a8f3..b66449aebf 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx @@ -46,13 +46,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -71,48 +64,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -121,46 +77,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx index bee793d372..fee0b2f2e6 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx @@ -40,13 +40,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -65,48 +58,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -115,46 +71,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index 75b457b778..4a925828e2 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -52,13 +52,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -77,48 +70,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -127,46 +83,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx index 7f28b7b3d3..ca2a7ab2e2 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx @@ -62,13 +62,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -87,48 +80,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -137,46 +93,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx index 6bee992948..b6f67e67b1 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx @@ -62,13 +62,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -87,48 +80,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -137,46 +93,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index 2d1be7e32b..754da5c902 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -23,13 +23,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -48,48 +41,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + router._replaceRouteChunk(oldRoute, newRoute.lazyFn); router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -98,46 +54,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/handle-route-update.test.ts b/packages/router-plugin/tests/handle-route-update.test.ts index d6d1b4c766..684f4d5189 100644 --- a/packages/router-plugin/tests/handle-route-update.test.ts +++ b/packages/router-plugin/tests/handle-route-update.test.ts @@ -9,7 +9,14 @@ import { trimPathRight, } from '@tanstack/router-core' import { describe, expect, it, vi } from 'vitest' +import { Fragment, act, createElement } from 'react' +import { createPortal } from 'react-dom' +import { createRoot } from 'react-dom/client' import { createMemoryHistory } from '../../history/src' +import { HeadContent } from '../../react-router/src/HeadContent' +import { RouterContextProvider } from '../../react-router/src/RouterProvider' +import { Scripts } from '../../react-router/src/Scripts' +import { Router as ReactRouter } from '../../react-router/src/router' import { getHandleRouteUpdateCode } from '../src/core/hmr' import type { AnyRoute, GetStoreConfig } from '@tanstack/router-core' @@ -89,6 +96,18 @@ function createClientTestRouter( ) } +function createReactClientTestRouter( + routeTree: TRouteTree, + initialEntry: string, +) { + return new ReactRouter({ + routeTree: asAnyRoute(routeTree), + history: createMemoryHistory({ initialEntries: [initialEntry] }), + isServer: false, + origin: 'http://localhost', + }) +} + function withWindowRouter(router: RouterCore) { const testWindow = globalThis.window as any const hadOwnRouter = Object.prototype.hasOwnProperty.call( @@ -271,6 +290,52 @@ describe('handleRouteUpdate', () => { expect((newRoute as any).options).toBe((itemRoute as any).options) }) + it('clears lazy ownership and delegates rematerialization to the router', () => { + const rootRoute = new BaseRootRoute({}) + const itemRoute = new BaseRoute({ + getParentRoute: () => asAnyRoute(rootRoute), + path: '/items/$itemId', + }) + const router = createTestRouter( + rootRoute.addChildren([asAnyRoute(itemRoute)]), + ) + const lazyOwner = Promise.resolve() + const refreshRoute = vi.fn(async () => {}) + ;(itemRoute as any)._lazy = lazyOwner + ;(router as any)._refreshRoute = refreshRoute + + expect((itemRoute as any)._lazy).toBe(lazyOwner) + + runHandleRouteUpdate(router, itemRoute.id, new BaseRoute({} as any)) + + expect((itemRoute as any)._lazy).toBeUndefined() + expect(refreshRoute).toHaveBeenCalledTimes(1) + expect(refreshRoute).toHaveBeenCalledWith() + }) + + it('uses a fresh source lazy importer without erasing an omitted one', () => { + const oldImporter = vi.fn(async () => ({ options: {} }) as any) + const newImporter = vi.fn(async () => ({ options: {} }) as any) + const rootRoute = new BaseRootRoute({}) + const itemRoute = new BaseRoute({ + getParentRoute: () => asAnyRoute(rootRoute), + path: '/items/$itemId', + }).lazy(oldImporter) + const router = createTestRouter( + rootRoute.addChildren([asAnyRoute(itemRoute)]), + ) + + runHandleRouteUpdate( + router, + itemRoute.id, + new BaseRoute({} as any).lazy(newImporter), + ) + expect(itemRoute.lazyFn).toBe(newImporter) + + runHandleRouteUpdate(router, itemRoute.id, new BaseRoute({} as any)) + expect(itemRoute.lazyFn).toBe(newImporter) + }) + it('removes stale loader data when a hot route removes its loader', async () => { const rootRoute = new BaseRootRoute({}) const hotRoute = new BaseRoute({ @@ -305,6 +370,167 @@ describe('handleRouteUpdate', () => { } }) + it('keeps hot loader data when an older refresh settles later', async () => { + let resolveStaleRefresh!: (value: string) => void + const staleRefresh = new Promise((resolve) => { + resolveStaleRefresh = resolve + }) + const oldLoader = vi + .fn<() => string | Promise>() + .mockReturnValueOnce('initial') + .mockReturnValueOnce(staleRefresh) + const newLoader = vi.fn(() => 'hot') + const rootRoute = new BaseRootRoute({ loader: oldLoader }) + const router = createClientTestRouter(rootRoute, '/') + + await router.load() + expect(router.state.matches[0]?.loaderData).toBe('initial') + + const oldRefresh = router.invalidate() + await vi.waitFor(() => expect(oldLoader).toHaveBeenCalledTimes(2)) + + runHandleRouteUpdate( + router, + rootRoute.id, + new BaseRootRoute({ loader: newLoader }), + ) + + await vi.waitFor(() => { + expect(router.state.matches[0]?.loaderData).toBe('hot') + }) + + resolveStaleRefresh('stale') + await oldRefresh + + expect(newLoader).toHaveBeenCalledTimes(1) + expect(router.state.matches[0]!.routeId).toBe(rootRoute.id) + expect(router.state.matches[0]!.loaderData).toBe('hot') + }) + + it('does not cache an obsolete inactive preload that settles after a hot update', async () => { + let resolveOldLoader!: (value: string) => void + const oldLoaderResult = new Promise((resolve) => { + resolveOldLoader = resolve + }) + const oldLoader = vi.fn(() => oldLoaderResult) + const newLoader = vi.fn(() => 'hot') + const rootRoute = new BaseRootRoute({}) + const hotRoute = new BaseRoute({ + getParentRoute: () => asAnyRoute(rootRoute), + path: '/hot', + loader: oldLoader, + preloadStaleTime: Infinity, + }) + const router = createClientTestRouter( + rootRoute.addChildren([asAnyRoute(hotRoute)]), + '/', + ) + + await router.load() + const preload = router.preloadRoute({ to: '/hot' }) + await vi.waitFor(() => expect(oldLoader).toHaveBeenCalledOnce()) + + runHandleRouteUpdate( + router, + hotRoute.id, + new BaseRoute({ loader: newLoader } as any), + ) + + resolveOldLoader('obsolete') + await preload + await router.navigate({ to: '/hot' }) + + expect(newLoader).toHaveBeenCalledOnce() + expect(router.state.matches.at(-1)?.loaderData).toBe('hot') + }) + + it('observes hot parser and route-context changes on the active route', async () => { + const rootRoute = new BaseRootRoute({}) + const itemRoute = new BaseRoute({ + getParentRoute: () => asAnyRoute(rootRoute), + path: '/items/$itemId', + params: { + parse: ({ itemId }: { itemId: string }) => ({ + itemId, + parser: 'old', + }), + }, + context: () => ({ source: 'old' }), + }) + const router = createClientTestRouter( + rootRoute.addChildren([asAnyRoute(itemRoute)]), + '/items/abc', + ) + + await router.load() + expect(router.state.matches[1]!.routeId).toBe(itemRoute.id) + expect(router.state.matches[1]!.params).toEqual({ + itemId: 'abc', + parser: 'old', + }) + expect(router.state.matches[1]!.context).toMatchObject({ source: 'old' }) + + runHandleRouteUpdate( + router, + itemRoute.id, + new BaseRoute({ + params: { + parse: ({ itemId }: { itemId: string }) => ({ + itemId, + parser: 'new', + }), + }, + context: () => ({ source: 'new' }), + } as any), + ) + + await vi.waitFor(() => { + expect(router.state.matches[1]!.routeId).toBe(itemRoute.id) + expect(router.state.matches[1]!.params).toEqual({ + itemId: 'abc', + parser: 'new', + }) + expect(router.state.matches[1]!.context).toMatchObject({ source: 'new' }) + }) + }) + + it('rematerializes descendants when a parent route definition changes', async () => { + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => asAnyRoute(rootRoute), + path: '/parent', + context: () => ({ source: 'old' }), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => asAnyRoute(parentRoute), + path: '/child', + context: ({ context }) => ({ inheritedSource: context.source }), + }) + const router = createClientTestRouter( + rootRoute.addChildren([ + parentRoute.addChildren([asAnyRoute(childRoute)]), + ]), + '/parent/child', + ) + + await router.load() + expect(router.state.matches[2]?.context).toMatchObject({ + inheritedSource: 'old', + }) + + runHandleRouteUpdate( + router, + parentRoute.id, + new BaseRoute({ context: () => ({ source: 'new' }) } as any), + ) + + await vi.waitFor(() => { + expect(router.state.matches[2]?.context).toMatchObject({ + inheritedSource: 'new', + }) + }) + }) + it('does not run removed loader or beforeLoad callbacks on a later visit', async () => { const beforeLoad = vi.fn(() => ({ hotBeforeLoad: true })) const loader = vi.fn(() => 'hot loader data') @@ -344,40 +570,91 @@ describe('handleRouteUpdate', () => { expect(match!.context).not.toHaveProperty('hotBeforeLoad') }) - it('does not cache an obsolete inactive preload that settles after a hot update', async () => { - let resolveOldLoader!: (value: string) => void - const oldLoaderResult = new Promise((resolve) => { - resolveOldLoader = resolve - }) - const oldLoader = vi.fn(() => oldLoaderResult) - const newLoader = vi.fn(() => 'hot') + it('removes rendered HeadContent and Scripts output after a hot update', async () => { const rootRoute = new BaseRootRoute({}) const hotRoute = new BaseRoute({ getParentRoute: () => asAnyRoute(rootRoute), path: '/hot', - loader: oldLoader, - preloadStaleTime: Infinity, + head: () => ({ + meta: [{ title: 'hot title' }], + scripts: [{ src: '/hot-head.js' }], + }), + scripts: () => [{ src: '/hot-body.js' }], }) - const router = createClientTestRouter( + const router = createReactClientTestRouter( rootRoute.addChildren([asAnyRoute(hotRoute)]), - '/', + '/hot', ) await router.load() - const preload = router.preloadRoute({ to: '/hot' }) - await vi.waitFor(() => expect(oldLoader).toHaveBeenCalledOnce()) + expect(router.state.matches[1]!).toMatchObject({ + routeId: hotRoute.id, + meta: [{ title: 'hot title' }], + headScripts: [{ src: '/hot-head.js' }], + scripts: [{ src: '/hot-body.js' }], + }) - runHandleRouteUpdate( - router, - hotRoute.id, - new BaseRoute({ loader: newLoader } as any), - ) + const container = document.createElement('div') + document.body.appendChild(container) + const reactRoot = createRoot(container) + const previousActEnvironment = (globalThis as any).IS_REACT_ACT_ENVIRONMENT + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true - resolveOldLoader('obsolete') - await preload - await router.navigate({ to: '/hot' }) + try { + await act(async () => { + reactRoot.render( + createElement(RouterContextProvider, { + router, + children: createElement( + Fragment, + null, + createPortal(createElement(HeadContent), document.head), + createElement(Scripts), + ), + }), + ) + }) - expect(newLoader).toHaveBeenCalledOnce() - expect(router.state.matches.at(-1)?.loaderData).toBe('hot') + expect(document.head.querySelector('title')?.textContent).toBe( + 'hot title', + ) + expect( + document.head.querySelector('script[src="/hot-head.js"]'), + ).not.toBeNull() + expect( + document.head.querySelector('script[src="/hot-body.js"]'), + ).not.toBeNull() + + await act(async () => { + runHandleRouteUpdate(router, hotRoute.id, new BaseRoute({} as any)) + await vi.waitFor(() => { + expect(router.state.matches[1]!.routeId).toBe(hotRoute.id) + expect(router.state.matches[1]!.meta).toBeUndefined() + expect(router.state.matches[1]!.headScripts).toBeUndefined() + expect(router.state.matches[1]!.scripts).toBeUndefined() + }) + }) + + expect(document.head.querySelector('title')).toBeNull() + expect( + document.head.querySelector('script[src="/hot-head.js"]'), + ).toBeNull() + expect( + document.head.querySelector('script[src="/hot-body.js"]'), + ).toBeNull() + } finally { + try { + await act(async () => { + reactRoot.unmount() + }) + } finally { + container.remove() + if (previousActEnvironment === undefined) { + delete (globalThis as any).IS_REACT_ACT_ENVIRONMENT + } else { + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment + } + } + } }) }) diff --git a/packages/solid-router/src/CatchBoundary.tsx b/packages/solid-router/src/CatchBoundary.tsx index 10d5e83ca6..12e4446581 100644 --- a/packages/solid-router/src/CatchBoundary.tsx +++ b/packages/solid-router/src/CatchBoundary.tsx @@ -4,7 +4,7 @@ import type { ErrorRouteComponent } from './route' export function CatchBoundary( props: { - getResetKey: () => number | string + getResetKey: () => unknown children: Solid.JSX.Element errorComponent?: ErrorRouteComponent onCatch?: (error: Error) => void @@ -16,7 +16,7 @@ export function CatchBoundary( props.onCatch?.(error) Solid.createEffect( - Solid.on([props.getResetKey], () => reset(), { defer: true }), + Solid.on(props.getResetKey, () => reset(), { defer: true }), ) return ( diff --git a/packages/solid-router/src/ClientOnly.tsx b/packages/solid-router/src/ClientOnly.tsx index a7c4b71819..621d41974a 100644 --- a/packages/solid-router/src/ClientOnly.tsx +++ b/packages/solid-router/src/ClientOnly.tsx @@ -59,7 +59,9 @@ export function ClientOnly(props: ClientOnlyProps) { let globalHydrated = false export function useHydrated(): Solid.Accessor { - const [hydrated, setHydrated] = Solid.createSignal(globalHydrated) + const [hydrated, setHydrated] = Solid.createSignal( + globalHydrated && !Solid.sharedConfig.context, + ) Solid.onMount(() => { globalHydrated = true diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 205e778689..8a400a8580 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -1,12 +1,5 @@ import * as Solid from 'solid-js' -import { - createControlledPromise, - getLocationChangeInfo, - invariant, - isNotFound, - isRedirect, - rootRouteId, -} from '@tanstack/router-core' +import { rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { Dynamic } from 'solid-js/web' import { CatchBoundary, ErrorComponent } from './CatchBoundary' @@ -16,436 +9,221 @@ import { nearestMatchContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' -import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' +import { ClientOnly } from './ClientOnly' +import type { + AnyRoute, + AnyRouter, + RootRouteOptions, +} from '@tanstack/router-core' -export const Match = (props: { matchId: string }) => { +// Keep the client constant undefined so the server-only script can be dropped. +const renderScrollRestoration = + isServer === false + ? undefined + : (router: AnyRouter, route: AnyRoute) => + (isServer ?? router.isServer) && + route.parentRoute?.id === rootRouteId && + router.options.scrollRestoration ? ( + + ) : null + +export const Match = (props: { routeId: string }) => { const router = useRouter() - const match = Solid.createMemo(() => { - const id = props.matchId - if (!id) return undefined - return router.stores.matchStores.get(id)?.get() - }) - - const rawMatchState = Solid.createMemo(() => { - const currentMatch = match() - if (!currentMatch) { - return null - } - - const routeId = currentMatch.routeId as string - const parentRouteId = (router.routesById[routeId] as AnyRoute)?.parentRoute - ?.id - - return { - matchId: currentMatch.id, - routeId, - ssr: currentMatch.ssr, - _displayPending: currentMatch._displayPending, - parentRouteId: parentRouteId as string | undefined, - } - }) + const currentMatch = Solid.createMemo( + () => router.stores.byRoute.get(props.routeId)!.get()!, + ) - const hasPendingMatch = Solid.createMemo(() => { - const currentRouteId = rawMatchState()?.routeId - return currentRouteId - ? Boolean(router.stores.pendingRouteIds.get()[currentRouteId]) - : false - }) const nearestMatch = { - matchId: () => rawMatchState()?.matchId, - routeId: () => rawMatchState()?.routeId, - match, - hasPending: hasPendingMatch, + routeId: () => props.routeId, + match: currentMatch, } - return ( - - {(currentMatchState) => { - const route: () => AnyRoute = () => - router.routesById[currentMatchState().routeId] - - const resolvePendingComponent = () => - route().options.pendingComponent ?? - router.options.defaultPendingComponent - - const routeErrorComponent = () => - route().options.errorComponent ?? router.options.defaultErrorComponent - - 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 - (route().options.notFoundComponent ?? - router.options.notFoundRoute?.options.component) - : route().options.notFoundComponent + const route: AnyRoute = router.routesById[props.routeId] - const resolvedNoSsr = - currentMatchState().ssr === false || - currentMatchState().ssr === 'data-only' + // Lazy route option mutations become observable with the next client match + // publication. Server stores are non-reactive and options load before render. + const routeOptions = + (isServer ?? router.isServer) + ? () => route.options + : () => { + currentMatch() + return route.options + } - const shouldSkipSuspenseFallback = - (isServer ?? router.isServer) - ? resolvedNoSsr - : currentMatchState().ssr === 'data-only' + const resolvePendingComponent = () => + routeOptions().pendingComponent ?? router.options.defaultPendingComponent - const ResolvedSuspenseBoundary = () => Solid.Suspense + const routeErrorComponent = () => + routeOptions().errorComponent ?? router.options.defaultErrorComponent - const ResolvedCatchBoundary = () => - routeErrorComponent() ? CatchBoundary : SafeFragment + const routeNotFoundComponent = () => + route.isRoot + ? // If it's the root route, use the _notFound option, with fallback to the notFoundRoute's component + (routeOptions().notFoundComponent ?? + router.options.notFoundRoute?.options.component) + : routeOptions().notFoundComponent - const ResolvedNotFoundBoundary = () => - routeNotFoundComponent() ? CatchNotFound : SafeFragment + const resolvedNoSsr = () => + currentMatch().ssr === false || currentMatch().ssr === 'data-only' - const ShellComponent = route().isRoot - ? ((route().options as RootRouteOptions).shellComponent ?? - SafeFragment) - : SafeFragment + const shouldSkipSuspenseFallback = () => + (isServer ?? router.isServer) + ? resolvedNoSsr() + : currentMatch().ssr === 'data-only' - return ( - - - - ) - } - > - router.stores.loadedAt.get()} - errorComponent={routeErrorComponent() || ErrorComponent} - onCatch={(error: Error) => { - // Forward not found errors (we don't want to show the error component for these) - const notFoundError = getNotFound(error) - if (notFoundError) { - notFoundError.routeId ??= currentMatchState() - .routeId as any - throw notFoundError - } - if (process.env.NODE_ENV !== 'production') { - console.warn( - `Warning: Error in route match: ${currentMatchState().routeId}`, - ) - } - routeOnCatch()?.(error) - }} - > - { - const notFoundError = getNotFound(error) ?? error + const ShellComponent = route.isRoot + ? ((route.options as RootRouteOptions).shellComponent ?? SafeFragment) + : SafeFragment - notFoundError.routeId ??= currentMatchState() - .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() || - (notFoundError.routeId && - notFoundError.routeId !== - currentMatchState().routeId) || - (!notFoundError.routeId && !route().isRoot) - ) - throw notFoundError - - return ( - - ) - }} - > - - - - } - > - - - - - - - - - - - - - {currentMatchState().parentRouteId === rootRouteId ? ( - <> - - {router.options.scrollRestoration && - (isServer ?? router.isServer) ? ( - - ) : null} - - ) : null} - - ) - }} + const MatchContent = () => ( + } + > + ) -} - -// On Rendered can't happen above the root layout because it needs to run -// after the app has committed below the root layout. Keeping it here lets us -// fire onRendered even after a hydration mismatch above the root layout -// (like bad head/link tags, which is common). -function OnRendered() { - const router = useRouter() - - const location = Solid.createMemo( - () => router.stores.resolvedLocation.get()?.state.__TSR_key, - ) - Solid.createEffect( - Solid.on([location], () => { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - }), - ) - return null -} - -export const MatchInner = (): any => { - const router = useRouter() - const match = Solid.useContext(nearestMatchContext).match - - const rawMatchState = Solid.createMemo(() => { - const currentMatch = match() - if (!currentMatch) { - return null - } - - const routeId = currentMatch.routeId as string - - const remountFn = - (router.routesById[routeId] as AnyRoute).options.remountDeps ?? - router.options.defaultRemountDeps - const remountDeps = remountFn?.({ - routeId, - loaderDeps: currentMatch.loaderDeps, - params: currentMatch._strictParams, - search: currentMatch._strictSearch, - }) - const key = remountDeps ? JSON.stringify(remountDeps) : undefined - - return { - key, - routeId, - match: { - id: currentMatch.id, - status: currentMatch.status, - error: currentMatch.error, - _forcePending: currentMatch._forcePending ?? false, - _displayPending: currentMatch._displayPending ?? false, - }, - } - }) return ( - - {(currentMatchState) => { - const route = () => router.routesById[currentMatchState().routeId]! - - const currentMatch = () => currentMatchState().match - - const componentKey = () => - currentMatchState().key ?? currentMatchState().match.id - - const out = () => { - const Comp = - route().options.component ?? router.options.defaultComponent - if (Comp) { - return + + + + ) } - return - } - - const getLoadPromise = ( - matchId: string, - fallbackMatch: - | { - _nonReactive: { - loadPromise?: Promise - } + > + { + // Forward not found errors (we don't want to show the error component for these) + const notFoundError = getNotFound(error) + if (notFoundError) { + notFoundError.routeId ??= currentMatch().routeId + throw notFoundError } - | undefined, - ) => { - return ( - router.getMatch(matchId)?._nonReactive.loadPromise ?? - fallbackMatch?._nonReactive.loadPromise - ) - } - - const keyedOut = () => ( - - {(_key) => out()} - - ) - - return ( - - - {(_) => { - const [displayPendingResult] = Solid.createResource( - () => - router.getMatch(currentMatch().id)?._nonReactive - .displayPendingPromise, + if (process.env.NODE_ENV !== 'production') { + console.warn( + `Warning: Error in route match: ${currentMatch().routeId}`, ) + } + ;(route.options.onCatch ?? router.options.defaultOnCatch)?.(error) + }} + > + { + const notFoundError = getNotFound(error) ?? error - return <>{displayPendingResult()} - }} - - - {(_) => { - const [minPendingResult] = Solid.createResource( - () => - router.getMatch(currentMatch().id)?._nonReactive - .minPendingPromise, - ) - - return <>{minPendingResult()} - }} - - - {(_) => { - const pendingMinMs = - route().options.pendingMinMs ?? - router.options.defaultPendingMinMs - - if (pendingMinMs) { - const routerMatch = router.getMatch(currentMatch().id) - if ( - routerMatch && - !routerMatch._nonReactive.minPendingPromise - ) { - // Create a promise that will resolve after the minPendingMs - if (!(isServer ?? router.isServer)) { - const minPendingPromise = createControlledPromise() - - routerMatch._nonReactive.minPendingPromise = - minPendingPromise - - setTimeout(() => { - minPendingPromise.resolve() - // We've handled the minPendingPromise, so we can delete it - routerMatch._nonReactive.minPendingPromise = undefined - }, pendingMinMs) - } - } - } - - const [loaderResult] = Solid.createResource(async () => { - await Promise.resolve() - return router.getMatch(currentMatch().id)?._nonReactive - .loadPromise - }) - - const FallbackComponent = - route().options.pendingComponent ?? - router.options.defaultPendingComponent - - return ( - <> - {FallbackComponent && pendingMinMs > 0 ? ( - - ) : null} - {loaderResult()} - - ) - }} - - - {(_) => { - if (!isNotFound(currentMatch().error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: Expected a notFound error', - ) - } + notFoundError.routeId ??= currentMatch().routeId - invariant() + if (notFoundError.routeId !== currentMatch().routeId) { + throw notFoundError } - // Use Show with keyed to ensure re-render when routeId changes return ( - - {(_routeId) => - renderRouteNotFound(router, route(), currentMatch().error) - } - + ) }} - - - {(_) => { - const matchId = currentMatch().id - const routerMatch = router.getMatch(matchId) - - if (!isRedirect(currentMatch().error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: Expected a redirect error', - ) - } + > + + + } + > + + + + + + + + + + + + + {renderScrollRestoration?.(router, route)} + + ) +} - invariant() - } +export const MatchInner = (): any => { + const router = useRouter() + const nearestMatch = Solid.useContext(nearestMatchContext) + const match = nearestMatch.match + const routeId = nearestMatch.routeId + const route = router.routesById[routeId()!]! + const currentMatch = () => match()! + + const componentKey = () => { + const current = currentMatch() + const remount = + route.options.remountDeps ?? router.options.defaultRemountDeps + const deps = remount?.({ + routeId: routeId()!, + loaderDeps: current.loaderDeps, + params: current._strictParams, + search: current._strictSearch, + }) + return deps ? JSON.stringify(deps) : current.id + } - const [loaderResult] = Solid.createResource(async () => { - await Promise.resolve() - return getLoadPromise(matchId, routerMatch) - }) + const out = () => { + const Comp = route.options.component ?? router.options.defaultComponent + if (Comp) { + return + } + return + } - return <>{loaderResult()} - }} - - - {(_) => { - if (isServer ?? router.isServer) { - const RouteErrorComponent = - (route().options.errorComponent ?? - router.options.defaultErrorComponent) || - ErrorComponent + const keyedOut = () => ( + + {(_key) => out()} + + ) - return ( - - ) - } + return ( + + + {(_) => renderRouteNotFound(router, route, currentMatch().error)} + + + {(_) => { + if (isServer ?? router.isServer) { + const RouteErrorComponent = + (route.options.errorComponent ?? + router.options.defaultErrorComponent) || + ErrorComponent + + return ( + + ) + } - throw currentMatch().error - }} - - - {keyedOut()} - - - ) - }} - + throw currentMatch().error + }} + + + {keyedOut()} + + ) } @@ -454,61 +232,38 @@ export const Outlet = () => { const nearestParentMatch = Solid.useContext(nearestMatchContext) const parentMatch = nearestParentMatch.match const routeId = nearestParentMatch.routeId - const route = Solid.createMemo(() => - routeId() ? router.routesById[routeId()!] : undefined, - ) - - const parentGlobalNotFound = Solid.createMemo( - () => parentMatch()?.globalNotFound ?? false, - ) + const route = router.routesById[routeId()!]! - const childMatchId = Solid.createMemo(() => { - const currentRouteId = routeId() - return currentRouteId - ? router.stores.childMatchIdByRouteId.get()[currentRouteId] - : undefined - }) - - const childMatchStatus = Solid.createMemo(() => { - const id = childMatchId() - if (!id) return undefined - return router.stores.matchStores.get(id)?.get().status - }) - - const shouldShowNotFound = () => - childMatchStatus() !== 'redirected' && parentGlobalNotFound() - - const childRouteKey = Solid.createMemo(() => { - if (shouldShowNotFound()) return undefined - const cid = childMatchId() - if (!cid) return undefined - return router.stores.matchStores.get(cid)?.routeId ?? cid - }) + const childRouteId = () => { + if (parentMatch()!._notFound) { + return + } + const ids = router.stores.ids.get() + return ids[ids.indexOf(routeId()!) + 1] + } return ( - {(resolvedRoute) => - renderRouteNotFound(router, resolvedRoute(), undefined) - } - + parentMatch()!._notFound + ? renderRouteNotFound(router, route, parentMatch()!.error) + : undefined } > {(_routeKey: string) => { return ( } + fallback={} > } > - + ) diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index 75586f862d..ff270ecbd9 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -3,7 +3,7 @@ import { replaceEqualDeep, rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouter } from './useRouter' -import { Transitioner } from './Transitioner' +import { Rendered, Transitioner } from './Transitioner' import { nearestMatchContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { Match } from './Match' @@ -55,6 +55,7 @@ export function Matches() { > + ) @@ -62,26 +63,18 @@ export function Matches() { function MatchesInner() { const router = useRouter() - const matchId = () => router.stores.firstId.get() - const routeId = () => (matchId() ? rootRouteId : undefined) + const routeId = () => router.stores.ids.get()[0] const match = () => - routeId() ? router.stores.getRouteMatchStore(rootRouteId).get() : undefined - const hasPendingMatch = () => - routeId() - ? Boolean(router.stores.pendingRouteIds.get()[rootRouteId]) - : false - const resetKey = () => router.stores.loadedAt.get() + routeId() ? router.stores.byRoute.get(routeId()!)?.get() : undefined const nearestMatch = { - matchId, routeId, match, - hasPending: hasPendingMatch, } const matchComponent = () => { return ( - - + + ) } @@ -92,7 +85,7 @@ function MatchesInner() { matchComponent() ) : ( resetKey()} + getResetKey={match} errorComponent={ErrorComponent} onCatch={ process.env.NODE_ENV !== 'production' @@ -140,7 +133,9 @@ export function useMatchRoute() { return Solid.createMemo(() => { const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts - router.stores.matchRouteDeps.get() + router.stores.location.get() + router.stores.resolvedLocation.get() + router.stores.status.get() return router.matchRoute(rest as any, { pending, caseSensitive, @@ -224,13 +219,13 @@ export function useParentMatches< >( opts?: UseMatchesBaseOptions, ): Solid.Accessor> { - const contextMatchId = Solid.useContext(nearestMatchContext).matchId + const contextRouteId = Solid.useContext(nearestMatchContext).routeId 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 }, @@ -243,12 +238,12 @@ export function useChildMatches< >( opts?: UseMatchesBaseOptions, ): Solid.Accessor> { - const contextMatchId = Solid.useContext(nearestMatchContext).matchId + const contextRouteId = Solid.useContext(nearestMatchContext).routeId 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/solid-router/src/Scripts.tsx b/packages/solid-router/src/Scripts.tsx index 4c1ece99c6..0a547521e2 100644 --- a/packages/solid-router/src/Scripts.tsx +++ b/packages/solid-router/src/Scripts.tsx @@ -1,84 +1,53 @@ import * as Solid from 'solid-js' +import { _getAssetMatches, replaceEqualDeep } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { Asset } from './Asset' import { useRouter } from './useRouter' -import type { AnyRouteMatch, RouterManagedTag } from '@tanstack/router-core' +import type { RouterManagedTag } from '@tanstack/router-core' export const Scripts = () => { const router = useRouter() const nonce = router.options.ssr?.nonce - const getAssetScripts = (matches: Array) => { - const assetScripts: Array = [] - const manifest = router.ssr?.manifest - - if (!manifest) { - return [] - } - - for (const match of matches) { - const scripts = manifest.routes[match.routeId]?.scripts - - if (!scripts) { - continue + const scripts = Solid.createMemo( + (previous: Array | undefined) => { + const matches = _getAssetMatches(router.stores.matches.get()) + const next: Array = [] + const assets: Array = [] + const manifest = router.ssr?.manifest + for (const match of matches) { + for (const script of match.scripts ?? []) { + if (!script) { + continue + } + const { children, ...attrs } = script + next.push({ + tag: 'script', + attrs: { ...attrs, nonce }, + children: children as string | undefined, + }) + } + for (const asset of manifest?.routes[match.routeId]?.scripts ?? []) { + assets.push({ + tag: 'script', + attrs: { ...asset.attrs, nonce }, + children: asset.children, + }) + } } - - for (const asset of scripts) { - assetScripts.push({ - tag: 'script', - attrs: { ...asset.attrs, nonce }, - children: asset.children, - }) - } - } - - return assetScripts - } - - const getScripts = (matches: Array): Array => - ( - matches - .map((match) => match.scripts!) - .flat(1) - .filter(Boolean) as Array - ).map( - ({ children, ...script }) => - ({ - tag: 'script', - attrs: { - ...script, - nonce, - }, - children, - }) satisfies RouterManagedTag, - ) - - const activeMatches = Solid.createMemo(() => router.stores.matches.get()) - const assetScripts = Solid.createMemo(() => getAssetScripts(activeMatches())) - const scripts = Solid.createMemo(() => getScripts(activeMatches())) - - return renderScripts(router, scripts(), assetScripts()) -} - -function renderScripts( - router: ReturnType, - scripts: Array, - assetScripts: Array, -) { - const allScripts = [...scripts, ...assetScripts] as Array - - if ((isServer ?? router.isServer) && router.serverSsr) { - const serverBufferedScript = router.serverSsr.takeBufferedScripts() - if (serverBufferedScript) { - allScripts.unshift(serverBufferedScript) - } - } + next.push(...assets) + return previous ? replaceEqualDeep(previous, next) : next + }, + ) + const serverBufferedScript = + (isServer ?? router.isServer) && router.serverSsr + ? router.serverSsr.takeBufferedScripts() + : undefined return ( <> - {allScripts.map((asset) => ( - - ))} + {serverBufferedScript && } + {(asset) => } ) } diff --git a/packages/solid-router/src/Transitioner.tsx b/packages/solid-router/src/Transitioner.tsx index 331aff5864..58b3dca076 100644 --- a/packages/solid-router/src/Transitioner.tsx +++ b/packages/solid-router/src/Transitioner.tsx @@ -3,36 +3,33 @@ import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useRouter } from './useRouter' +function getResolvedLocation(router: ReturnType) { + const resolvedLocation = router.stores.resolvedLocation.get() + if ( + resolvedLocation?.href === router.latestLocation.href && + resolvedLocation.state.__TSR_key === router.latestLocation.state.__TSR_key + ) { + return resolvedLocation + } + return +} + export function Transitioner() { const router = useRouter() - let mountLoadForRouter = { router, mounted: false } - const isLoading = Solid.createMemo(() => router.stores.isLoading.get()) if (isServer ?? router.isServer) { return null } - const [isSolidTransitioning, startSolidTransition] = Solid.useTransition() - - // Track pending state changes - const hasPending = Solid.createMemo(() => router.stores.hasPending.get()) - - const isAnyPending = Solid.createMemo( - () => isLoading() || isSolidTransitioning() || hasPending(), - ) - - const isPagePending = Solid.createMemo(() => isLoading() || hasPending()) - - router.startTransition = (fn: () => void | Promise) => { - Solid.startTransition(() => { - startSolidTransition(fn) - }) + router.startTransition = async (fn) => { + await Solid.startTransition(fn) + return true } // Subscribe to location changes // and try to load the new location Solid.onMount(() => { - const unsub = router.history.subscribe(router.load) + Solid.onCleanup(router.history.subscribe(router.load)) const nextLocation = router.buildLocation({ to: router.latestLocation.pathname, @@ -44,95 +41,38 @@ 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(router.latestLocation.publicHref) !== trimPathRight(nextLocation.publicHref) ) { - router.commitLocation({ ...nextLocation, replace: true }) - } - - Solid.onCleanup(() => { - unsub() - }) - }) - - // Try to load the initial location - Solid.createRenderEffect(() => { - Solid.untrack(() => { - if ( - // if we are hydrating from SSR, loading is triggered in ssr-client - (typeof window !== 'undefined' && router.ssr) || - (mountLoadForRouter.router === router && mountLoadForRouter.mounted) - ) { - return - } - mountLoadForRouter = { router, mounted: true } - const tryLoad = async () => { - try { - await router.load() - } catch (err) { - console.error(err) - } - } - tryLoad() - }) - }) - - Solid.createRenderEffect((previousIsLoading = false) => { - const currentIsLoading = isLoading() - - if (previousIsLoading && !currentIsLoading) { - router.emit({ - type: 'onLoad', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), + router.commitLocation({ + ...nextLocation, + replace: true, + ignoreBlocker: true, }) + return } - return currentIsLoading - }) - - Solid.createComputed((previousIsPagePending = false) => { - const currentIsPagePending = isPagePending() - - if (previousIsPagePending && !currentIsPagePending) { - router.emit({ - type: 'onBeforeRouteMount', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) + if (!getResolvedLocation(router) && !router._tx) { + router.load().catch(console.error) } - - return currentIsPagePending }) - Solid.createRenderEffect((previousIsAnyPending = false) => { - const currentIsAnyPending = isAnyPending() + return null +} - if (previousIsAnyPending && !currentIsAnyPending) { - const changeInfo = getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ) +export function Rendered() { + const router = useRouter() + Solid.onMount(() => { + const resolvedLocation = getResolvedLocation(router) + if (resolvedLocation) { router.emit({ - type: 'onResolved', - ...changeInfo, - }) - - Solid.batch(() => { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) + type: 'onRendered', + ...getLocationChangeInfo(resolvedLocation, resolvedLocation), }) } - - return currentIsAnyPending }) - return null } diff --git a/packages/solid-router/src/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx index b4ca3e8a4e..9dcad352fb 100644 --- a/packages/solid-router/src/headContentUtils.tsx +++ b/packages/solid-router/src/headContentUtils.tsx @@ -1,5 +1,6 @@ import * as Solid from 'solid-js' import { + _getAssetMatches, appendUniqueUserTags, escapeHtml, getAssetCrossOrigin, @@ -13,29 +14,27 @@ import type { } from '@tanstack/router-core' /** - * 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) => { const router = useRouter() const nonce = router.options.ssr?.nonce - const activeMatches = Solid.createMemo(() => router.stores.matches.get()) - const routeMeta = Solid.createMemo(() => - activeMatches() - .map((match) => match.meta) - .filter((meta) => meta !== undefined), - ) - - const meta: Solid.Accessor> = Solid.createMemo(() => { + return Solid.createMemo((prev: Array | undefined) => { + const matches = _getAssetMatches(router.stores.matches.get()) const resultMeta: Array = [] const metaByAttribute: Record = {} let title: RouterManagedTag | undefined - const routeMetasArray = routeMeta() - for (let i = routeMetasArray.length - 1; i >= 0; i--) { - const metas = routeMetasArray[i]! + for (let i = matches.length - 1; i >= 0; i--) { + const metas = matches[i]!.meta + if (!metas) { + continue + } for (let j = metas.length - 1; j >= 0; j--) { const m = metas[j] - if (!m) continue + if (!m) { + continue + } if (m.title) { if (!title) { @@ -95,126 +94,98 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { } resultMeta.reverse() - return resultMeta - }) - - const links = Solid.createMemo(() => { - const matches = activeMatches() - const constructed = matches - .flatMap((match) => match.links ?? []) - .filter((link) => link !== undefined) - .map((link) => ({ - tag: 'link', - attrs: { - ...link, - nonce, - }, - })) satisfies Array - - return constructed - }) - - const manifestCssTags = Solid.createMemo(() => { + const next: Array = [] + appendUniqueUserTags(next, resultMeta) const manifest = router.ssr?.manifest - const tags: Array = [] - - if (!manifest) { - return tags - } - - for (const match of activeMatches()) { - manifest.routes[match.routeId]?.css?.forEach((link) => { - const resolvedLink = resolveManifestCssLink(link) - tags.push({ + const preloads: Array = [] + for (const match of matches) { + for (const preload of manifest?.routes[match.routeId]?.preloads ?? []) { + if (!preload) { + continue + } + preloads.push({ tag: 'link', attrs: { - rel: 'stylesheet', - ...resolvedLink, - crossOrigin: - getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? - resolvedLink.crossOrigin, + ...getScriptPreloadAttrs(manifest, preload, assetCrossOrigin), nonce, }, }) - }) + } } + next.push(...preloads) - if (manifest.inlineStyle) { - tags.push({ - tag: 'style', - attrs: { - ...manifest.inlineStyle.attrs, - nonce, - }, - children: manifest.inlineStyle.children, - inlineCss: true, - }) + const links: Array = [] + for (const match of matches) { + for (const link of match.links ?? []) { + if (link === undefined) { + continue + } + links.push({ tag: 'link', attrs: { ...link, nonce } }) + } } + appendUniqueUserTags(next, links) - return tags - }) - - const preloadLinks = Solid.createMemo(() => { - const matches = activeMatches() - const preloadLinks: Array = [] - - matches.forEach((match) => - router.ssr?.manifest?.routes[match.routeId]?.preloads - ?.filter(Boolean) - .forEach((preload) => { - preloadLinks.push({ + if (manifest) { + for (const match of matches) { + for (const link of manifest.routes[match.routeId]?.css ?? []) { + const resolvedLink = resolveManifestCssLink(link) + next.push({ tag: 'link', attrs: { - ...getScriptPreloadAttrs( - router.ssr?.manifest, - preload, - assetCrossOrigin, - ), + rel: 'stylesheet', + ...resolvedLink, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? + resolvedLink.crossOrigin, nonce, }, }) - }), - ) - - return preloadLinks - }) - - const styles = Solid.createMemo(() => { - return activeMatches() - .flatMap((match) => match.styles ?? []) - .filter((style) => style !== undefined) - .map(({ children, ...style }) => ({ - tag: 'style', - attrs: { - ...style, - nonce, - }, - children: children as string | undefined, - })) satisfies Array - }) - - const headScripts = Solid.createMemo(() => { - return activeMatches() - .flatMap((match) => match.headScripts ?? []) - .filter((script) => script !== undefined) - .map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, - nonce, - }, - children: children as string | undefined, - })) satisfies Array - }) + } + } + if (manifest.inlineStyle) { + next.push({ + tag: 'style', + attrs: { + ...manifest.inlineStyle.attrs, + nonce, + }, + children: manifest.inlineStyle.children, + inlineCss: true, + }) + } + } - return Solid.createMemo((prev: Array | undefined) => { - const next: Array = [] - appendUniqueUserTags(next, meta()) - next.push(...preloadLinks()) - appendUniqueUserTags(next, links()) - next.push(...manifestCssTags()) - appendUniqueUserTags(next, styles()) - appendUniqueUserTags(next, headScripts()) + const styles: Array = [] + const headScripts: Array = [] + for (const match of matches) { + for (const style of match.styles ?? []) { + if (style === undefined) { + continue + } + const { children, ...attrs } = style + styles.push({ + tag: 'style', + attrs: { ...attrs, nonce }, + children: children as string | undefined, + }) + } + for (const script of match.headScripts ?? []) { + if (script === undefined) { + continue + } + const { children, ...attrs } = script + headScripts.push({ + tag: 'script', + attrs: { + ...attrs, + nonce, + }, + children: children as string | undefined, + }) + } + } + appendUniqueUserTags(next, styles) + appendUniqueUserTags(next, headScripts) if (prev === undefined) { return next diff --git a/packages/solid-router/src/lazyRouteComponent.tsx b/packages/solid-router/src/lazyRouteComponent.tsx index 424940522b..64bf129337 100644 --- a/packages/solid-router/src/lazyRouteComponent.tsx +++ b/packages/solid-router/src/lazyRouteComponent.tsx @@ -1,6 +1,7 @@ import { Dynamic } from 'solid-js/web' import { createResource } from 'solid-js' import { isModuleNotFoundError } from '@tanstack/router-core' +import { isServer } from '@tanstack/router-core/isServer' import type { AsyncRouteComponent } from './route' export function lazyRouteComponent< @@ -18,13 +19,18 @@ export function lazyRouteComponent< const load = () => { if (!loadPromise) { + error = undefined loadPromise = importer() .then((res) => { - loadPromise = undefined + // Keep browser preload behavior unchanged; SSR can reuse the import. + if (!(isServer ?? typeof window === 'undefined')) { + loadPromise = undefined + } comp = res[exportName ?? 'default'] return comp }) .catch((err) => { + loadPromise = undefined error = err }) } diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 295b8ec230..935d0a3d2b 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -131,18 +131,16 @@ export function useLinkProps< { equals: (prev, next) => prev.href === next.href }, ) - const _options = () => options - const next = Solid.createMemo(() => { // Rebuild when inherited search/hash or the current route context changes. const _fromLocation = currentLocation() - const options = { _fromLocation, ..._options() } as any + const nextOptions = { _fromLocation, ...options } as any // untrack because router-core will also access stores, which are signals in solid - return Solid.untrack(() => router.buildLocation(options)) + return Solid.untrack(() => router.buildLocation(nextOptions)) }) const hrefOption = Solid.createMemo(() => { - if (_options().disabled) return undefined + if (options.disabled) return undefined // Use publicHref - it contains the correct href for display // When a rewrite changes the origin, publicHref is the full URL // Otherwise it's the origin-stripped path @@ -173,7 +171,7 @@ export function useLinkProps< } return _href.href } - const to = _options().to + const to = options.to const safeInternal = isSafeInternal(to) if (safeInternal) return undefined if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined @@ -192,7 +190,7 @@ export function useLinkProps< }) const preload = Solid.createMemo(() => { - if (_options().reloadDocument || externalLink()) { + if (options.reloadDocument || externalLink()) { return false } return local.preload ?? router.options.defaultPreload @@ -251,7 +249,7 @@ export function useLinkProps< const doPreload = () => router - .preloadRoute({ ..._options(), _builtLocation: next() } as any) + .preloadRoute({ ...options, _builtLocation: next() } as any) .catch((err: any) => { console.warn(err) console.warn(preloadWarning) @@ -288,7 +286,7 @@ export function useLinkProps< return Solid.mergeProps( propsSafeToSpread, { - ref: mergeRefs(setRef, _options().ref), + ref: mergeRefs(setRef, options.ref), href: externalLink(), }, Solid.splitProps(local, [ @@ -336,7 +334,7 @@ export function useLinkProps< // All is well? Navigate! // N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing router.navigate({ - ..._options(), + ...options, replace: local.replace, resetScroll: local.resetScroll, hashScrollIntoView: local.hashScrollIntoView, @@ -425,7 +423,7 @@ export function useLinkProps< const base = { href: hrefOption()?.href, - ref: mergeRefs(setRef, _options().ref), + ref: mergeRefs(setRef, options.ref), onClick, onBlur, onFocus, diff --git a/packages/solid-router/src/matchContext.tsx b/packages/solid-router/src/matchContext.tsx index f6efc37c6b..50ab0d4b76 100644 --- a/packages/solid-router/src/matchContext.tsx +++ b/packages/solid-router/src/matchContext.tsx @@ -2,17 +2,13 @@ import * as Solid from 'solid-js' import type { AnyRouteMatch } from '@tanstack/router-core' export type NearestMatchContextValue = { - matchId: Solid.Accessor routeId: Solid.Accessor match: Solid.Accessor - hasPending: Solid.Accessor } const defaultNearestMatchContext: NearestMatchContextValue = { - matchId: () => undefined, routeId: () => undefined, match: () => undefined, - hasPending: () => false, } export const nearestMatchContext = diff --git a/packages/solid-router/src/routerStores.ts b/packages/solid-router/src/routerStores.ts index 11c06a35ef..93c8b5ea8f 100644 --- a/packages/solid-router/src/routerStores.ts +++ b/packages/solid-router/src/routerStores.ts @@ -5,53 +5,11 @@ import { } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import type { - AnyRoute, GetStoreConfig, RouterReadableStore, - RouterStores, RouterWritableStore, } from '@tanstack/router-core' -declare module '@tanstack/router-core' { - export interface RouterStores { - /** Maps each active routeId to the matchId of its child in the match tree. */ - childMatchIdByRouteId: RouterReadableStore> - /** Maps each pending routeId to true for quick lookup. */ - pendingRouteIds: RouterReadableStore> - } -} - -function initRouterStores( - stores: RouterStores, - createReadonlyStore: ( - read: () => TValue, - ) => RouterReadableStore, -) { - stores.childMatchIdByRouteId = createReadonlyStore(() => { - const ids = stores.matchesId.get() - const obj: Record = {} - for (let i = 0; i < ids.length - 1; i++) { - const parentStore = stores.matchStores.get(ids[i]!) - if (parentStore?.routeId) { - obj[parentStore.routeId] = ids[i + 1]! - } - } - return obj - }) - - stores.pendingRouteIds = createReadonlyStore(() => { - const ids = stores.pendingIds.get() - const obj: Record = {} - for (const id of ids) { - const store = stores.pendingMatchStores.get(id) - if (store?.routeId) { - obj[store.routeId] = true - } - } - return obj - }) -} - function createSolidMutableStore( initialValue: TValue, ): RouterWritableStore { @@ -60,22 +18,10 @@ function createSolidMutableStore( return { get: signal, set: setSignal } } -let finalizationRegistry: FinalizationRegistry<() => void> | null = null -if (typeof globalThis !== 'undefined' && 'FinalizationRegistry' in globalThis) { - finalizationRegistry = new FinalizationRegistry((cb) => cb()) -} - function createSolidReadonlyStore( read: () => TValue, ): RouterReadableStore { - let dispose!: () => void - const memo = Solid.createRoot((d) => { - dispose = d - return Solid.createMemo(read) - }) - const store = { get: memo } - finalizationRegistry?.register(store, dispose) - return store + return { get: Solid.createRoot(() => Solid.createMemo(read)) } } export const getStoreFactory: GetStoreConfig = (opts) => { @@ -84,8 +30,6 @@ export const getStoreFactory: GetStoreConfig = (opts) => { createMutableStore: createNonReactiveMutableStore, createReadonlyStore: createNonReactiveReadonlyStore, batch: (fn) => fn(), - init: (stores) => - initRouterStores(stores, createNonReactiveReadonlyStore), } } @@ -93,6 +37,5 @@ export const getStoreFactory: GetStoreConfig = (opts) => { createMutableStore: createSolidMutableStore, createReadonlyStore: createSolidReadonlyStore, batch: Solid.batch, - init: (stores) => initRouterStores(stores, createSolidReadonlyStore), } } diff --git a/packages/solid-router/src/ssr/RouterClient.tsx b/packages/solid-router/src/ssr/RouterClient.tsx index 15e0dcc39f..c2389495db 100644 --- a/packages/solid-router/src/ssr/RouterClient.tsx +++ b/packages/solid-router/src/ssr/RouterClient.tsx @@ -5,21 +5,16 @@ import { RouterProvider } from '../RouterProvider' import type { AnyRouter } from '@tanstack/router-core' import type { JSXElement } from 'solid-js' -let hydrationPromise: Promise>> | undefined +let hydrationPromise: Promise | undefined const Dummy = (props: { children?: JSXElement }) => <>{props.children} 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 ( true)} children={() => ( diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 8914c117d9..7432f19510 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -167,12 +167,15 @@ export const renderRouterToStream = async ({ const responseStream = transformReadableStreamWithRouter( router, readable as unknown as ReadableStream, - { onAbort: abortSolidPipe }, + { signal: request.signal, onAbort: abortSolidPipe }, ) return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }), ) diff --git a/packages/solid-router/src/ssr/renderRouterToString.tsx b/packages/solid-router/src/ssr/renderRouterToString.tsx index 7da982b392..421ac234bc 100644 --- a/packages/solid-router/src/ssr/renderRouterToString.tsx +++ b/packages/solid-router/src/ssr/renderRouterToString.tsx @@ -32,7 +32,10 @@ export const renderRouterToString = ({ html = html.replace(``, () => `${injectedHtml}`) } return new Response(`${html}`, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }) } catch (error) { diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index 858d75be6b..97880329db 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -70,13 +70,12 @@ export function useMatch< ThrowOrOptional, TThrow> > { const router = useRouter() - const nearestMatch = opts.from - ? undefined - : Solid.useContext(nearestMatchContext) + const contextMatch = Solid.useContext(nearestMatchContext) + const nearestMatch = opts.from ? undefined : contextMatch const match = () => { if (opts.from) { - return router.stores.getRouteMatchStore(opts.from).get() + return router.stores.getMatchStore(opts.from).get() } return nearestMatch?.match() @@ -87,15 +86,7 @@ export function useMatch< return } - const hasPendingMatch = opts.from - ? Boolean(router.stores.pendingRouteIds.get()[opts.from!]) - : (nearestMatch?.hasPending() ?? false) - - if ( - !hasPendingMatch && - !router.stores.isTransitioning.get() && - (opts.shouldThrow ?? true) - ) { + if (opts.shouldThrow ?? true) { if (process.env.NODE_ENV !== 'production') { throw new Error( `Invariant failed: Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, @@ -110,17 +101,6 @@ export function useMatch< const selectedMatch = match() if (selectedMatch === undefined) { - const hasPendingMatch = opts.from - ? Boolean(router.stores.pendingRouteIds.get()[opts.from!]) - : (nearestMatch?.hasPending() ?? false) - - if ( - prev !== undefined && - (hasPendingMatch || router.stores.isTransitioning.get()) - ) { - return prev - } - return undefined } diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index b15faaef19..29f6553d56 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { cleanup, fireEvent, @@ -6,11 +6,14 @@ import { screen, waitFor, } from '@solidjs/testing-library' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' import { HeadContent, Link, Outlet, + RouterContextProvider, RouterProvider, createBrowserHistory, createMemoryHistory, @@ -47,9 +50,64 @@ afterEach(() => { cleanup() browserHistories.splice(0).forEach((history) => history.destroy()) window.history.replaceState(null, 'root', '/') + delete window.$_TSR }) describe('ssr scripts', () => { + test('updates route data scripts after client navigation', async () => { + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + scripts: () => [ + { + id: 'first-route-data', + type: 'application/json', + children: 'first', + }, + ], + component: () => Second, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + scripts: () => [ + { + id: 'second-route-data', + type: 'application/json', + children: 'second', + }, + ], + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + const { container } = render(() => ) + await screen.findByRole('link', { name: 'Second' }) + expect(container.querySelector('#first-route-data')?.textContent).toBe( + 'first', + ) + + fireEvent.click(screen.getByRole('link', { name: 'Second' })) + expect(await screen.findByText('Second route')).toBeInTheDocument() + await waitFor(() => { + expect(container.querySelector('#first-route-data')).toBeNull() + expect(container.querySelector('#second-route-data')?.textContent).toBe( + 'second', + ) + }) + }) + test('it works', async () => { const rootRoute = createRootRoute({ // loader: () => new Promise((r) => setTimeout(r, 1)), @@ -453,6 +511,104 @@ describe('ssr scripts', () => { }) describe('ssr HeadContent', () => { + test('renders descendant assets during a data-only hydration handoff', async () => { + const rootRoute = createRootRoute({}) + const dataOnlyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/report', + ssr: 'data-only', + loader: () => 'report', + }) + const childRoute = createRoute({ + getParentRoute: () => dataOnlyRoute, + path: '/details', + loader: () => 'details', + head: () => ({ + meta: [{ name: 'solid-data-only-child', content: 'visible' }], + }), + scripts: () => [ + { + id: 'solid-data-only-body-script', + type: 'application/json', + children: '{"source":"body"}', + }, + ], + }) + const router = createRouter({ + history: createMemoryHistory({ + initialEntries: ['/report/details'], + }), + routeTree: rootRoute.addChildren([ + dataOnlyRoute.addChildren([childRoute]), + ]), + }) + const matches = router.matchRoutes(router.latestLocation) + window.$_TSR = { + router: { + dehydratedData: {}, + manifest: { + routes: { + [childRoute.id]: { + preloads: ['/solid-data-only-manifest.js'], + scripts: [ + { + attrs: { + id: 'solid-data-only-manifest-script', + type: 'application/json', + }, + children: '{"source":"manifest"}', + }, + ], + }, + }, + }, + matches: matches.map((match, index) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success', + ssr: index === 1 ? 'data-only' : true, + l: index ? (index === 1 ? 'report' : 'details') : undefined, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + } + + await hydrate(router) + + expect(router.state.matches.map((match) => match.status)).toEqual([ + 'success', + 'pending', + 'success', + ]) + render(() => ( + + {() => ( + <> + + + + )} + + )) + + expect( + document.querySelector('meta[name="solid-data-only-child"]'), + ).not.toBeNull() + expect( + document.querySelector('link[href="/solid-data-only-manifest.js"]'), + ).not.toBeNull() + expect( + document.querySelector('#solid-data-only-body-script'), + ).not.toBeNull() + expect( + document.querySelector('#solid-data-only-manifest-script'), + ).not.toBeNull() + }) + test('derives title, dedupes meta, and allows non-loader HeadContent', async () => { const rootRoute = createRootRoute({ loader: () => diff --git a/packages/solid-router/tests/component-preload-retry.test.tsx b/packages/solid-router/tests/component-preload-retry.test.tsx index a311a41f26..0a26f0af26 100644 --- a/packages/solid-router/tests/component-preload-retry.test.tsx +++ b/packages/solid-router/tests/component-preload-retry.test.tsx @@ -1,6 +1,15 @@ import { afterEach, expect, test, vi } from 'vitest' -import { cleanup, render, screen } from '@solidjs/testing-library' -import { lazyRouteComponent } from '../src' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, + useRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' afterEach(() => { cleanup() @@ -8,6 +17,18 @@ afterEach(() => { vi.unstubAllGlobals() }) +test('a successful server component download is reused', async () => { + vi.stubGlobal('window', undefined) + const importer = vi.fn().mockResolvedValue({ default: () => null }) + const Page = lazyRouteComponent(importer) + + const preload = Page.preload?.() + await preload + + expect(Page.preload?.()).toBe(preload) + expect(importer).toHaveBeenCalledTimes(1) +}) + test('a component loads when rendered before preload', async () => { const importer = vi.fn().mockResolvedValue({ default: () =>
Page content
, @@ -19,3 +40,47 @@ test('a component loads when rendered before preload', async () => { expect(await screen.findByText('Page content')).toBeInTheDocument() expect(importer).toHaveBeenCalledTimes(1) }) + +test('a failed component download is retried from the route error UI', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(new Error('component download failed')) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + function RouteError(props: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RouteError, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render(() => ) + + fireEvent.click(await screen.findByRole('button', { name: 'Retry' })) + + expect(await screen.findByText('Page content')).toBeInTheDocument() +}) diff --git a/packages/solid-router/tests/createLazyRoute.test.tsx b/packages/solid-router/tests/createLazyRoute.test.tsx index 215ef93a1b..7c76f14162 100644 --- a/packages/solid-router/tests/createLazyRoute.test.tsx +++ b/packages/solid-router/tests/createLazyRoute.test.tsx @@ -97,6 +97,71 @@ describe('preload: matched routes', { timeout: 20000 }, () => { }) }) +// https://github.com/TanStack/router/issues/4467 +it('replaces default pending UI when delayed lazy options provide pending UI', async () => { + const loader = createControlledPromise() + const componentPreload = createControlledPromise() + const preloadComponent = vi.fn(() => componentPreload) + const Page = Object.assign(() =>

Page

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

Loading lazy page

, + component: Page, + }) + const lazyOptions = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index page

, + }) + 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.findByText('Index page')).toBeInTheDocument() + + navigation = router.navigate({ to: '/page' }) + expect(await screen.findByRole('status')).toHaveTextContent( + 'Loading default', + ) + + lazyOptions.resolve(lazyPageOptions) + await vi.waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page'), + ) + expect(preloadComponent).toHaveBeenCalledOnce() + expect(componentPreload.status).toBe('pending') + expect(screen.queryByText('Page')).not.toBeInTheDocument() + + componentPreload.resolve() + loader.resolve() + await navigation + + expect(await screen.findByText('Page')).toBeInTheDocument() + } finally { + lazyOptions.resolve(lazyPageOptions) + componentPreload.resolve() + loader.resolve() + if (navigation) { + await Promise.allSettled([navigation]) + } + } +}) + it('renders an eager loader error with a delayed lazy errorComponent', async () => { const loader = createControlledPromise() const loaderErrorHandled = createControlledPromise() diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx index 4ee1abf3f1..6cf9e48d64 100644 --- a/packages/solid-router/tests/errorComponent.test.tsx +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -1,8 +1,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { createControlledPromise } from '@tanstack/router-core' import { Link, + Outlet, RouterProvider, createLazyRoute, createRootRoute, @@ -160,3 +162,104 @@ describe.each([true, false])( ) }, ) + +test('global catch boundary resets when a background child generation recovers', async () => { + const refresh = createControlledPromise() + let loaderCalls = 0 + const rootRoute = createRootRoute({ component: Outlet }) + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: { + staleReloadMode: 'background', + handler: () => (++loaderCalls === 1 ? 1 : refresh), + }, + component: () => { + const revision = childRoute.useLoaderData() + if (revision() === 1) { + throw new Error('stale child render failed') + } + return
Recovered child revision {revision()}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([childRoute]), + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + render(() => ) + expect( + await screen.findByText('stale child render failed'), + ).toBeInTheDocument() + + const invalidation = router.invalidate() + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + expect(screen.getByText('stale child render failed')).toBeInTheDocument() + expect(screen.queryByText(/Recovered child revision/)).not.toBeInTheDocument() + refresh.resolve(2) + await invalidation + + expect( + await screen.findByText('Recovered child revision 2'), + ).toBeInTheDocument() + expect( + screen.queryByText('stale child render failed'), + ).not.toBeInTheDocument() +}) + +test('ancestor route errorComponent resets when a background child generation recovers', async () => { + const refresh = createControlledPromise() + let loaderCalls = 0 + const rootRoute = createRootRoute({ + component: Outlet, + errorComponent: ({ error }) =>
Ancestor error: {error.message}
, + }) + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: { + staleReloadMode: 'background', + handler: () => (++loaderCalls === 1 ? 1 : refresh), + }, + component: () => { + const revision = childRoute.useLoaderData() + if (revision() === 1) { + throw new Error('stale child render failed') + } + return
Recovered child revision {revision()}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([childRoute]), + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + let invalidation: Promise | undefined + try { + render(() => ) + expect( + await screen.findByText('Ancestor error: stale child render failed'), + ).toBeInTheDocument() + + invalidation = router.invalidate({ + filter: (match) => match.routeId === childRoute.id, + }) + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + expect( + screen.getByText('Ancestor error: stale child render failed'), + ).toBeInTheDocument() + refresh.resolve(2) + await invalidation + + expect( + await screen.findByText('Recovered child revision 2'), + ).toBeInTheDocument() + } finally { + refresh.resolve(2) + if (invalidation) { + await Promise.allSettled([invalidation]) + } + } +}) diff --git a/packages/solid-router/tests/loaders.test.tsx b/packages/solid-router/tests/loaders.test.tsx index b2ace7b43d..63ac8e3e2e 100644 --- a/packages/solid-router/tests/loaders.test.tsx +++ b/packages/solid-router/tests/loaders.test.tsx @@ -11,17 +11,16 @@ import { createRootRoute, createRoute, createRouter, - useLoaderData, useRouter, } from '../src' import { sleep } from './utils' afterEach(() => { + cleanup() vi.useRealTimers() vi.resetAllMocks() window.history.replaceState(null, 'root', '/') - cleanup() }) const WAIT_TIME = 100 @@ -322,31 +321,64 @@ test('throw error from beforeLoad when navigating to route', async () => { expect(indexElement).toBeInTheDocument() }) -test('throw abortError from loader upon initial load with basepath', async () => { +// https://github.com/TanStack/router/pull/7673 +test('#7673: a spontaneous loader AbortError renders the boundary without executing the route component', async () => { window.history.replaceState(null, 'root', '/app') const rootRoute = createRootRoute({}) + const abortError = new DOMException('Aborted', 'AbortError') + const routeComponentRendered = vi.fn() + const renderedError = vi.fn() + let routeSignal: AbortSignal | undefined const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - loader: async () => { - return Promise.reject(new DOMException('Aborted', 'AbortError')) + loader: async ({ abortController }): Promise<{ value: string }> => { + routeSignal = abortController.signal + return Promise.reject(abortError) + }, + component: () => { + routeComponentRendered() + const data = indexRoute.useLoaderData() + return
{data().value}
+ }, + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
}, - component: () =>
Index route content
, - errorComponent: () => ( -
indexErrorComponent
- ), }) const routeTree = rootRoute.addChildren([indexRoute]) const router = createRouter({ routeTree, basepath: '/app' }) + const rendered = new Promise((resolve) => { + const unsubscribe = router.subscribe('onRendered', () => { + unsubscribe() + resolve() + }) + }) render(() => ) - const indexElement = await screen.findByText('Index route content') - expect(indexElement).toBeInTheDocument() - expect(screen.queryByTestId('index-error')).not.toBeInTheDocument() - expect(window.location.pathname.startsWith('/app')).toBe(true) + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + await rendered + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() + expect(routeComponentRendered).not.toHaveBeenCalled() + // jsdom creates DOMException in another realm, so Solid wraps the error. + expect(renderedError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Unknown error', + cause: abortError, + }), + ) + expect(routeSignal?.aborted).toBe(false) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) + expect(window.location.pathname).toBe('/app') + expect(router.state.status).toBe('idle') }) test('reproducer #4245', async () => { @@ -747,51 +779,6 @@ test('does not show pending UI when loaders finish before their pending delays', expect(fooPendingComponentOnMountMock).not.toHaveBeenCalled() }) -test('useLoaderData retains previous data while route match is pending', async () => { - const history = createMemoryHistory({ initialEntries: ['/app'] }) - const rootRoute = createRootRoute({ - component: () => { - const loaderData = useLoaderData({ from: '/app' }) - - return ( - <> -
{`${loaderData().length}:0`}
- - - ) - }, - }) - const appRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/app', - loader: () => 'loaded', - component: () =>
App route
, - }) - const routeTree = rootRoute.addChildren([appRoute]) - const router = createRouter({ routeTree, history }) - - render(() => ) - - expect(await screen.findByTestId('combined')).toHaveTextContent('6:0') - - const appMatch = router.state.matches.find( - (match) => match.routeId === '/app', - ) - - expect(appMatch).toBeDefined() - - if (!appMatch) { - throw new Error('Expected /app match to be active') - } - - router.stores.setPending([{ ...appMatch, id: `${appMatch.id}__pending` }]) - router.stores.setMatches( - router.state.matches.filter((match) => match.routeId !== '/app'), - ) - - expect(screen.getByTestId('combined')).toHaveTextContent('6:0') -}) - test('navigating away from a pending route aborts its loader', async () => { function getPendingComponent(onMount: () => void) { const PendingComponent = () => { diff --git a/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx new file mode 100644 index 0000000000..1b1ac2268c --- /dev/null +++ b/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx @@ -0,0 +1,179 @@ +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { AnyRouter } from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + const pendingCleanups = testCleanups + .splice(0) + .reverse() + .map((testCleanup) => testCleanup()) + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } + await Promise.allSettled(pendingCleanups) + cleanup() + vi.useRealTimers() +}) + +test.each(['child', 'root'] as const)( + 'a mounted %s pending fallback follows an overlapping load generation', + async (routeLevel) => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const routeOptions = { + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => generation) : generation + }, + } + + const makeRouter = (): AnyRouter => { + if (routeLevel === 'root') { + const rootRoute = createRootRoute({ + ...routeOptions, + component: () =>
Generation {rootRoute.useLoaderData()()}
, + }) + return createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + } + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + ...routeOptions, + getParentRoute: () => rootRoute, + path: '/page', + component: () =>
Generation {pageRoute.useLoaderData()()}
, + }) + return createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + } + const router = makeRouter() + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + const firstInvalidation = router.invalidate({ forcePending: true }) + const invalidations = [firstInvalidation] + testCleanups.push(async () => { + firstReload.resolve() + secondReload.resolve() + await Promise.allSettled(invalidations) + }) + await vi.advanceTimersByTimeAsync(0) + expect(loaderCall).toBe(2) + expect(screen.getByTestId('pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 1')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(25) + const secondInvalidation = router.invalidate({ forcePending: true }) + invalidations.push(secondInvalidation) + await vi.advanceTimersByTimeAsync(0) + expect(loaderCall).toBe(3) + + firstReload.resolve() + await vi.advanceTimersByTimeAsync(0) + + expect(screen.getByTestId('pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + + let secondSettled = false + void secondInvalidation.then(() => { + secondSettled = true + }) + secondReload.resolve() + await vi.advanceTimersByTimeAsync(0) + + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(74) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await Promise.all(invalidations) + expect(screen.getByText('Generation 3')).toBeInTheDocument() + expect(screen.queryByText('Generation 1')).not.toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') + }, +) + +test('forcePending honors pendingMinMs when the reload settles before pendingMs', async () => { + const reload = createControlledPromise() + let loaderCall = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: async () => { + if (++loaderCall > 1) { + await reload + } + return loaderCall + }, + component: () =>
Generation {pageRoute.useLoaderData()()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + const invalidation = router.invalidate({ forcePending: true }) + testCleanups.push(async () => { + reload.resolve() + await Promise.allSettled([invalidation]) + }) + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('fast-pending')).toBeInTheDocument() + + let settled = false + void invalidation.then(() => { + settled = true + }) + reload.resolve() + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(99) + expect(settled).toBe(false) + expect(screen.getByTestId('fast-pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await invalidation + expect(screen.getByText('Generation 2')).toBeInTheDocument() + expect(screen.queryByText('Generation 1')).not.toBeInTheDocument() + expect(screen.queryByTestId('fast-pending')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') +}) diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index 34681a8f85..2192f4f962 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -8,7 +8,6 @@ import { Outlet, RouterProvider, createBrowserHistory, - createMemoryHistory, createRootRoute, createRoute, createRouter, @@ -313,116 +312,4 @@ describe('redirect', () => { expect(window.location.pathname).toBe('/final') }) }) - - describe('SSR', () => { - test('when `redirect` is thrown in `beforeLoad`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - beforeLoad: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return 'About' - }, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - history: createMemoryHistory({ - initialEntries: ['/'], - }), - }) - - await router.load() - - expect(router.state.redirect).toBeDefined() - expect(router.state.redirect).toBeInstanceOf(Response) - const redirectResponse = router.state.redirect! - - expect(redirectResponse.options).toEqual({ - _fromLocation: expect.objectContaining({ - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - }), - to: '/about', - href: '/about', - statusCode: 307, - }) - }) - }) - - test('when `redirect` is thrown in `loader`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - loader: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return 'About' - }, - }) - - const router = createRouter({ - history: createMemoryHistory({ - initialEntries: ['/'], - }), - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - }) - - await router.load() - - const currentRedirect = router.state.redirect - - expect(currentRedirect).toBeDefined() - expect(currentRedirect).toBeInstanceOf(Response) - const redirectResponse = currentRedirect! - expect(redirectResponse.status).toEqual(307) - expect(redirectResponse.headers.get('Location')).toEqual('/about') - expect(redirectResponse.options).toEqual({ - _fromLocation: { - external: false, - publicHref: '/', - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - state: { - __TSR_index: 0, - __TSR_key: redirectResponse.options._fromLocation!.state.__TSR_key, - key: redirectResponse.options._fromLocation!.state.key, - }, - }, - href: '/about', - to: '/about', - statusCode: 307, - }) - }) }) diff --git a/packages/solid-router/tests/router-client-stream-cleanup.test.tsx b/packages/solid-router/tests/router-client-stream-cleanup.test.tsx new file mode 100644 index 0000000000..0ea9e57077 --- /dev/null +++ b/packages/solid-router/tests/router-client-stream-cleanup.test.tsx @@ -0,0 +1,39 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { ErrorBoundary } from 'solid-js' +import { RouterClient } from '../src/ssr/RouterClient' +import { createMemoryHistory, createRootRoute, createRouter } from '../src' + +const hydrate = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/router-core/ssr/client', () => ({ hydrate })) + +afterEach(() => { + cleanup() + delete window.$_TSR + hydrate.mockReset() +}) + +test.runIf(typeof window !== 'undefined')( + 'RouterClient signals streaming cleanup without hiding a hydration failure', + async () => { + const error = new Error('hydration failed') + hydrate.mockRejectedValue(error) + const rootRoute = createRootRoute({ component: () =>
Ready
}) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const hydrated = vi.fn() + window.$_TSR = { h: hydrated } as any + + render(() => ( + caught.message}> + + + )) + + await waitFor(() => expect(hydrated).toHaveBeenCalledTimes(1)) + expect(await screen.findByText(error.message)).toBeInTheDocument() + }, +) diff --git a/packages/solid-router/tests/store-updates-during-navigation.test.tsx b/packages/solid-router/tests/store-updates-during-navigation.test.tsx index 69b570122d..3ef09b437f 100644 --- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx @@ -136,7 +136,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(9) + expect(updates).toBe(6) }) test('redirection in preload', async () => { @@ -172,7 +172,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Solid has different update counts than React due to different reactivity - expect(updates).toBe(5) + expect(updates).toBe(4) }) test('nothing', async () => { @@ -183,7 +183,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(2) }) test('not found in beforeLoad', async () => { @@ -198,7 +198,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(4) + expect(updates).toBe(2) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -240,7 +240,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(2) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -256,7 +256,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(2) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -272,7 +272,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(3) + expect(updates).toBe(2) }) test('preload a preloaded route w/ async loader', async () => { diff --git a/packages/solid-router/tests/transitioner-listener-errors.test.tsx b/packages/solid-router/tests/transitioner-listener-errors.test.tsx new file mode 100644 index 0000000000..83f39a5791 --- /dev/null +++ b/packages/solid-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,81 @@ +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('a throwing load-event listener cannot interrupt route hooks or later navigations', async () => { + const lifecycle: Array = [] + const listenerError = new Error('onLoad listener failed') + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: () => lifecycle.push('enter:/first'), + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: () => lifecycle.push('enter:/second'), + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Index route')).toBeInTheDocument() + + const unsubscribers = [ + router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + lifecycle.push('throw:/first') + throw listenerError + } + }), + router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname !== '/') { + lifecycle.push(`load:${event.toLocation.pathname}`) + } + }), + ] + + try { + await router.navigate({ to: '/first' }) + expect(await screen.findByText('First route')).toBeInTheDocument() + expect(screen.queryByText('Index route')).not.toBeInTheDocument() + expect(lifecycle).toEqual(['enter:/first', 'throw:/first', 'load:/first']) + + await router.navigate({ to: '/second' }) + expect(await screen.findByText('Second route')).toBeInTheDocument() + expect(screen.queryByText('First route')).not.toBeInTheDocument() + expect(lifecycle).toEqual([ + 'enter:/first', + 'throw:/first', + 'load:/first', + 'enter:/second', + 'load:/second', + ]) + } finally { + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + } +}) diff --git a/packages/solid-router/tests/transitioner-render-ack.test.tsx b/packages/solid-router/tests/transitioner-render-ack.test.tsx new file mode 100644 index 0000000000..9b9751505b --- /dev/null +++ b/packages/solid-router/tests/transitioner-render-ack.test.tsx @@ -0,0 +1,341 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { HistoryState } from '../src' + +afterEach(() => { + cleanup() +}) + +test('onResolved precedes onRendered and both observe the committed destination DOM', async () => { + const nextLoader = createControlledPromise() + const rootRoute = createRootRoute({ component: () => }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + render(() => ) + expect(await screen.findByText('First')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const lifecycle: Array<{ + event: 'onResolved' | 'onRendered' + pathname: string + destination: boolean + outgoing: boolean + }> = [] + const unsubscribers = [ + router.subscribe('onResolved', (event) => { + lifecycle.push({ + event: 'onResolved', + pathname: event.toLocation.pathname, + destination: screen.queryByText('Next') !== null, + outgoing: screen.queryByText('First') !== null, + }) + }), + router.subscribe('onRendered', (event) => { + lifecycle.push({ + event: 'onRendered', + pathname: event.toLocation.pathname, + destination: screen.queryByText('Next') !== null, + outgoing: screen.queryByText('First') !== null, + }) + }), + ] + + try { + const navigation = router.navigate({ to: '/next' }) + + expect(screen.queryByText('Next')).toBeNull() + expect(screen.getByText('First')).toBeInTheDocument() + expect(lifecycle).toEqual([]) + + nextLoader.resolve() + await navigation + await waitFor(() => + expect(lifecycle).toEqual([ + { + event: 'onResolved', + pathname: '/next', + destination: true, + outgoing: false, + }, + { + event: 'onRendered', + pathname: '/next', + destination: true, + outgoing: false, + }, + ]), + ) + } finally { + nextLoader.resolve() + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + } +}) + +test('onRendered describes each committed navigation from the previously rendered location', async () => { + type SameHrefTestState = HistoryState & { sameHrefState: true } + const isSameHrefTestState = ( + state: HistoryState | undefined, + ): state is SameHrefTestState => + state !== undefined && + 'sameHrefState' in state && + state.sameHrefState === true + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const renderedChanges: Array<{ + fromPathname: string | undefined + toPathname: string + fromHref: string | undefined + toHref: string + pathChanged: boolean + hrefChanged: boolean + fromSameHrefState: boolean + toSameHrefState: boolean + renderedRoute: 'Index' | 'Next' | undefined + }> = [] + const unsubscribe = router.subscribe('onRendered', (event) => { + renderedChanges.push({ + fromPathname: event.fromLocation?.pathname, + toPathname: event.toLocation.pathname, + fromHref: event.fromLocation?.href, + toHref: event.toLocation.href, + pathChanged: event.pathChanged, + hrefChanged: event.hrefChanged, + fromSameHrefState: isSameHrefTestState(event.fromLocation?.state), + toSameHrefState: isSameHrefTestState(event.toLocation.state), + renderedRoute: screen.queryByText('Next') + ? 'Next' + : screen.queryByText('Index') + ? 'Index' + : undefined, + }) + }) + + try { + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + await waitFor(() => + expect(renderedChanges).toEqual([ + { + fromPathname: '/', + toPathname: '/next', + fromHref: '/', + toHref: '/next', + pathChanged: true, + hrefChanged: true, + fromSameHrefState: false, + toSameHrefState: false, + renderedRoute: 'Next', + }, + ]), + ) + + const sameHrefState: SameHrefTestState = { sameHrefState: true } + await router.navigate({ + to: '/next', + state: sameHrefState, + }) + await waitFor(() => + expect(renderedChanges).toEqual([ + { + fromPathname: '/', + toPathname: '/next', + fromHref: '/', + toHref: '/next', + pathChanged: true, + hrefChanged: true, + fromSameHrefState: false, + toSameHrefState: false, + renderedRoute: 'Next', + }, + { + fromPathname: '/next', + toPathname: '/next', + fromHref: '/next', + toHref: '/next', + pathChanged: false, + hrefChanged: false, + fromSameHrefState: false, + toSameHrefState: true, + renderedRoute: 'Next', + }, + ]), + ) + } finally { + unsubscribe() + } +}) + +test('an older rendered destination cannot resolve a superseding navigation', async () => { + const nextLoader = createControlledPromise() + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const lifecycle: Array<{ + event: 'onResolved' | 'onRendered' + pathname: string + destination: boolean + superseded: boolean + }> = [] + let nextNavigation: Promise | undefined + const unsubscribers = [ + router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + nextNavigation = router.navigate({ to: '/next' }) + } + }), + router.subscribe('onResolved', (event) => { + lifecycle.push({ + event: 'onResolved', + pathname: event.toLocation.pathname, + destination: screen.queryByText('Next') !== null, + superseded: screen.queryByText('First') !== null, + }) + }), + router.subscribe('onRendered', (event) => { + lifecycle.push({ + event: 'onRendered', + pathname: event.toLocation.pathname, + destination: screen.queryByText('Next') !== null, + superseded: screen.queryByText('First') !== null, + }) + }), + ] + + try { + const firstNavigation = router.navigate({ to: '/first' }) + + await waitFor(() => { + expect(nextNavigation).toBeDefined() + }) + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.queryByText('Next')).not.toBeInTheDocument() + expect(lifecycle).toEqual([]) + + nextLoader.resolve() + await Promise.all([firstNavigation, nextNavigation!]) + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument() + }) + expect(lifecycle).toEqual([ + { + event: 'onResolved', + pathname: '/next', + destination: true, + superseded: false, + }, + { + event: 'onRendered', + pathname: '/next', + destination: true, + superseded: false, + }, + ]) + } finally { + nextLoader.resolve() + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + } +}) + +test('same-location invalidation resolves after its refreshed DOM commits', async () => { + let generation = 0 + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: { + staleReloadMode: 'blocking', + handler: () => ++generation, + }, + component: () =>
Generation {indexRoute.useLoaderData()()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + const refreshedDomWasVisible: Array = [] + const unsubscribe = router.subscribe('onResolved', () => { + refreshedDomWasVisible.push(screen.queryByText('Generation 2') !== null) + }) + + try { + await router.invalidate() + expect(await screen.findByText('Generation 2')).toBeInTheDocument() + expect(refreshedDomWasVisible).toEqual([true]) + } finally { + unsubscribe() + } +}) diff --git a/packages/solid-start-client/src/hydrateStart.ts b/packages/solid-start-client/src/hydrateStart.ts index e4299cb9c7..cb353576c0 100644 --- a/packages/solid-start-client/src/hydrateStart.ts +++ b/packages/solid-start-client/src/hydrateStart.ts @@ -4,9 +4,6 @@ import type { AnyRouter } from '@tanstack/router-core' /** * Solid-specific wrapper for hydrateStart that signals hydration completion */ -export async function hydrateStart(): Promise { - const router = await coreHydrateStart() - // Signal that router hydration is complete so cleanup can happen if stream has ended - window.$_TSR?.h() - return router +export function hydrateStart(): Promise { + return coreHydrateStart().finally(() => window.$_TSR?.h()) } diff --git a/packages/solid-start-client/src/tests/hydrateStart.test.ts b/packages/solid-start-client/src/tests/hydrateStart.test.ts index 0610a8fa0c..2d34400009 100644 --- a/packages/solid-start-client/src/tests/hydrateStart.test.ts +++ b/packages/solid-start-client/src/tests/hydrateStart.test.ts @@ -21,3 +21,13 @@ test('signals streaming cleanup after hydration succeeds', async () => { await expect(hydrateStart()).resolves.toBe(router) expect(hydrated).toHaveBeenCalledTimes(1) }) + +test('signals streaming cleanup without hiding a hydration failure', async () => { + const error = new Error('hydration failed') + coreHydrateStart.mockRejectedValue(error) + const hydrated = vi.fn() + window.$_TSR = { h: hydrated } as any + + await expect(hydrateStart()).rejects.toBe(error) + expect(hydrated).toHaveBeenCalledTimes(1) +}) diff --git a/packages/start-client-core/src/client/hydrateStart.ts b/packages/start-client-core/src/client/hydrateStart.ts index 83b9c468af..d3742e5ff6 100644 --- a/packages/start-client-core/src/client/hydrateStart.ts +++ b/packages/start-client-core/src/client/hydrateStart.ts @@ -52,7 +52,7 @@ async function hydrateStart(): Promise { basepath: process.env.TSS_ROUTER_BASEPATH, ...{ serializationAdapters }, }) - if (!router.stores.matchesId.get().length) { + if (!router.stores.ids.get().length) { await hydrate(router) } diff --git a/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts b/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts index 18d3e54e2e..064c7623d4 100644 --- a/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts @@ -63,7 +63,7 @@ export function devServerPlugin({ // Parse route IDs from query param const urlObj = new URL(url, 'http://localhost') const routesParam = urlObj.searchParams.get('routes') - const routeIds = routesParam ? routesParam.split(',') : [] + const ids = routesParam ? routesParam.split(',') : [] // Build entries list from route file paths const entries: Array = [] @@ -74,8 +74,8 @@ export function devServerPlugin({ | Record }> | undefined - if (routesManifest && routeIds.length > 0) { - for (const routeId of routeIds) { + if (routesManifest && ids.length > 0) { + for (const routeId of ids) { const route = routesManifest[routeId] if (route?.filePath) { entries.push(route.filePath) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 90d4e706f9..20c4763137 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -8,18 +8,22 @@ import { safeObjectMerge, } from '@tanstack/start-client-core' import { + _getRenderedMatches, executeRewriteInput, isRedirect, isResolvedRedirect, } from '@tanstack/router-core' import { attachRouterServerSsrUtils, + bindSsrResponseToRequest, + disposeSsrResponseDetached, getNormalizedURL, getOrigin, isSsrResponse, normalizeSsrResponse, replaceSsrResponse, stripSsrResponseBody, + waitForRequest, } from '@tanstack/router-core/ssr/server' import { getStartContext, @@ -74,7 +78,7 @@ function getStartResponseHeaders(opts: { router: AnyRouter }) { { 'Content-Type': 'text/html; charset=utf-8', }, - ...opts.router.stores.matches.get().map((match) => { + ..._getRenderedMatches(opts.router.stores.matches.get()).map((match) => { return match.headers }), ) @@ -208,17 +212,36 @@ function handleCtxResult(result: TODO) { return result } +function disposeLateResponse(result: TODO, signal: AbortSignal): void { + const response = handleCtxResult(result)?.response + if (isSsrResponse(response) || isSpecialResponse(response)) { + disposeSsrResponseDetached(response, signal.reason) + } +} + +function isSignalAborted(signal: AbortSignal): boolean { + return signal.aborted +} + /** * Execute a middleware chain */ async function executeMiddleware( middlewares: Array, ctx: TODO, + signal: AbortSignal, ): Promise<{ ctx: TODO; response: HandlerCallbackResult }> { let index = -1 let streamResponse: | Extract | undefined + let retiredStreamIdentities: WeakSet | undefined + + const isResponseAlias = (candidate: unknown, response: Response) => + candidate === response || + (candidate instanceof Response && + response.body !== null && + candidate.body === response.body) const setResponse = (response: TODO) => { if (isSsrResponse(response)) { @@ -232,25 +255,44 @@ async function executeMiddleware( ctx.response = response } - const disposeStreamResponse = async (reason: string) => { + const disposeStreamResponse = async (reason: unknown) => { const response = streamResponse if (!response) { return } streamResponse = undefined + retiredStreamIdentities ??= new WeakSet() + retiredStreamIdentities.add(response.response) + if (response.response.body) { + retiredStreamIdentities.add(response.response.body) + } const currentResponse = ctx.response - if ( - currentResponse === response.response || - (currentResponse instanceof Response && - response.response.body !== null && - currentResponse.body === response.response.body) - ) { + if (isResponseAlias(currentResponse, response.response)) { ctx.response = undefined } await response.dispose(reason) } + const disposeAbandonedResult = (result: TODO) => { + const exposed = handleCtxResult(result)?.response + const response = isSsrResponse(exposed) ? exposed.response : exposed + if (streamResponse && isResponseAlias(response, streamResponse.response)) { + void disposeStreamResponse(signal.reason).catch(console.error) + return + } + if ( + response instanceof Response && + retiredStreamIdentities && + (retiredStreamIdentities.has(response) || + (response.body !== null && retiredStreamIdentities.has(response.body))) + ) { + return + } + + disposeLateResponse(result, signal) + } + const getFinalResponse = async (): Promise => { const response = ctx.response if (!response) { @@ -276,7 +318,19 @@ async function executeMiddleware( return response } - const next = async (nextCtx?: TODO): Promise => { + let nextPromise: Promise | undefined + + function next(nextCtx?: TODO): Promise { + const result = runNext(nextCtx) + nextPromise = result + return result + } + + async function runNext(nextCtx?: TODO): Promise { + if (signal.aborted) { + throw signal.reason + } + // Merge context if provided using safeObjectMerge for prototype pollution prevention if (nextCtx) { if (nextCtx.context) { @@ -298,13 +352,26 @@ async function executeMiddleware( let result: TODO try { - result = await middleware({ ...ctx, next }) + const pending = middleware({ ...ctx, next }) + // A directly returned next() promise already propagates request aborts. + if (pending === nextPromise) { + nextPromise = undefined + result = await pending + if (isSignalAborted(signal)) { + disposeAbandonedResult(result) + throw signal.reason + } + } else { + result = await waitForRequest(pending, signal, disposeAbandonedResult) + } } catch (err) { + if (isSignalAborted(signal)) { + throw signal.reason + } if (isSpecialResponse(err)) { setResponse(err) return ctx } - await disposeStreamResponse('middleware error') throw err } @@ -321,8 +388,27 @@ async function executeMiddleware( return ctx } - await next() - return { ctx, response: await getFinalResponse() } + try { + await runNext() + const response = await waitForRequest( + getFinalResponse(), + signal, + disposeAbandonedResult, + ) + if (signal.aborted) { + disposeAbandonedResult(response) + throw signal.reason + } + return { ctx, response } + } catch (err) { + const disposal = disposeStreamResponse(signal.aborted ? signal.reason : err) + if (signal.aborted) { + void disposal.catch(console.error) + } else { + await disposal + } + throw err + } } /** @@ -404,6 +490,7 @@ export function createStartHandler( let responseOwnsCleanup = false as boolean try { + request.signal.throwIfAborted() // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. // during normalization paths like '//posts' are flattened to '/posts'. // in these cases we would prefer to redirect to the new path @@ -415,11 +502,13 @@ export function createStartHandler( return Response.redirect(url, 308) } - const entries = await getEntries() + const entries = await waitForRequest(getEntries(), request.signal) const hasStartInstance = !!entries.startEntry.startInstance const startOptions: AnyStartInstanceOptions = - (await entries.startEntry.startInstance?.getOptions()) || - ({} as AnyStartInstanceOptions) + (await waitForRequest( + entries.startEntry.startInstance?.getOptions(), + request.signal, + )) || ({} as AnyStartInstanceOptions) const { hasPluginAdapters, pluginSerializationAdapters } = entries.pluginAdapters @@ -452,7 +541,10 @@ export function createStartHandler( const getRouter = async (): Promise => { if (router) return router - router = await entries.routerEntry.getRouter() + router = await waitForRequest( + entries.routerEntry.getRouter(), + request.signal, + ) let isShell = IS_SHELL_ENV if (IS_PRERENDERING && !isShell) { @@ -529,13 +621,17 @@ export function createStartHandler( handlerType: 'serverFn', context: createNullProtoObject(requestOpts?.context), }, + request.signal, ) const result = await handleRedirectResponse( middlewareResponse, request, getRouter, + request.signal, ) + bindSsrResponseToRequest(router ?? undefined, result, request.signal) + request.signal.throwIfAborted() responseOwnsCleanup = result.serverSsrCleanup === 'stream' return result.response } @@ -562,11 +658,14 @@ export function createStartHandler( ) } - const manifest = await resolveManifestForRequest({ - request, - requestInlineCss: requestOpts?.inlineCss, - getBaseManifest: () => getBaseManifest(matchedRoutes), - }) + const manifest = await waitForRequest( + resolveManifestForRequest({ + request, + requestInlineCss: requestOpts?.inlineCss, + getBaseManifest: () => getBaseManifest(matchedRoutes), + }), + request.signal, + ) const earlyHints = createEarlyHintsForRequest({ onEarlyHints: requestOpts?.onEarlyHints, @@ -587,29 +686,41 @@ export function createStartHandler( // `additionalContext` is request-scoped and only read from router.options // during load; avoid a full router.update() and redundant location parse. routerInstance.options.additionalContext = { serverContext } - await routerInstance.load() + await routerInstance.load({ _signal: request.signal }) + request.signal.throwIfAborted() - if (routerInstance.state.redirect) { - return normalizeSsrResponse(routerInstance.state.redirect) + if (routerInstance._serverResult?.type === 'redirect') { + return normalizeSsrResponse(routerInstance._serverResult.redirect) } - earlyHints?.collectDynamic(routerInstance.stores.matches.get()) + earlyHints?.collectDynamic( + _getRenderedMatches(routerInstance.stores.matches.get()), + ) // Pass request-scoped assets to dehydrate for manifest injection const ctx = getStartContext({ throwIfNotFound: false }) - await routerInstance.serverSsr!.dehydrate({ - requestAssets: ctx?.requestAssets, - }) + await waitForRequest( + routerInstance.serverSsr!.dehydrate({ + requestAssets: ctx?.requestAssets, + }), + request.signal, + ) + request.signal.throwIfAborted() const responseHeaders = getStartResponseHeaders({ router: routerInstance, }) earlyHints?.appendResponseHeaders(responseHeaders) - const response = await cb({ - request, - router: routerInstance, - responseHeaders, - }) + request.signal.throwIfAborted() + const response = await waitForRequest( + cb({ + request, + router: routerInstance, + responseHeaders, + }), + request.signal, + (late) => disposeLateResponse(late, request.signal), + ) return normalizeSsrResponse(response) } @@ -655,13 +766,17 @@ export function createStartHandler( handlerType: 'router', context: createNullProtoObject(requestOpts?.context), }, + request.signal, ) const response = await handleRedirectResponse( middlewareResponse, request, getRouter, + request.signal, ) + bindSsrResponseToRequest(router ?? undefined, response, request.signal) + request.signal.throwIfAborted() responseOwnsCleanup = response.serverSsrCleanup === 'stream' return response.response } finally { @@ -682,7 +797,9 @@ async function handleRedirectResponse( response: HandlerCallbackResult, request: Request, getRouter: () => Promise, + signal: AbortSignal, ): Promise { + signal.throwIfAborted() const ssrResponse = normalizeSsrResponse(response) if (!isRedirect(ssrResponse.response)) { return ssrResponse @@ -690,13 +807,16 @@ async function handleRedirectResponse( if (isResolvedRedirect(ssrResponse.response)) { if (request.headers.get('x-tsr-serverFn') === 'true') { - return replaceSsrResponse( - ssrResponse, - Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, + return waitForRequest( + replaceSsrResponse( + ssrResponse, + Response.json( + { ...ssrResponse.response.options, isSerializedRedirect: true }, + { headers: ssrResponse.response.headers }, + ), + 'redirect response replaced', ), - 'redirect response replaced', + signal, ) } return ssrResponse @@ -724,21 +844,29 @@ async function handleRedirectResponse( ) } - const router = await getRouter() + signal.throwIfAborted() + const router = await waitForRequest(getRouter(), signal) + signal.throwIfAborted() const redirect = router.resolveRedirect(ssrResponse.response) if (request.headers.get('x-tsr-serverFn') === 'true') { - return replaceSsrResponse( - ssrResponse, - Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, + return waitForRequest( + replaceSsrResponse( + ssrResponse, + Response.json( + { ...ssrResponse.response.options, isSerializedRedirect: true }, + { headers: ssrResponse.response.headers }, + ), + 'redirect response replaced', ), - 'redirect response replaced', + signal, ) } - return replaceSsrResponse(ssrResponse, redirect, 'redirect response replaced') + return waitForRequest( + replaceSsrResponse(ssrResponse, redirect, 'redirect response replaced'), + signal, + ) } async function handleServerRoutes({ @@ -831,13 +959,17 @@ async function handleServerRoutes({ routeMiddlewares.push(((ctx: TODO) => executeRouter(ctx.context, matchedRoutes)) as TODO) - const { ctx, response } = await executeMiddleware(routeMiddlewares, { - request, - context, - params: routeParams, - pathname, - handlerType: 'router', - }) + const { ctx, response } = await executeMiddleware( + routeMiddlewares, + { + request, + context, + params: routeParams, + pathname, + handlerType: 'router', + }, + request.signal, + ) // RFC 9110 §9.3.2: HEAD must carry the same header fields as GET but no body. // Resolve any redirect before stripping so the Location header survives. @@ -846,8 +978,16 @@ async function handleServerRoutes({ throwRouteHandlerError() } - const resolved = await handleRedirectResponse(response, request, getRouter) - return stripSsrResponseBody(resolved, 'HEAD body stripped') + const resolved = await handleRedirectResponse( + response, + request, + getRouter, + request.signal, + ) + return waitForRequest( + stripSsrResponseBody(resolved, 'HEAD body stripped'), + request.signal, + ) } return normalizeSsrResponse(response) diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index 2e0011ac0b..a4c8dc16f6 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -3,7 +3,12 @@ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { createMiddleware } from '@tanstack/start-client-core' -import { BaseRootRoute, BaseRoute, RouterCore } from '@tanstack/router-core' +import { + BaseRootRoute, + BaseRoute, + RouterCore, + type AnyRouter, +} from '@tanstack/router-core' import { createNonReactiveMutableStore, createNonReactiveReadonlyStore, @@ -25,7 +30,7 @@ const startMocks = vi.hoisted(() => { previousServerFnBase, requestMiddleware: [] as Array, serverFnResult: undefined as undefined | Response | object, - router: undefined as undefined | ReturnType, + router: undefined as undefined | AnyRouter, } }) @@ -70,6 +75,38 @@ function makeRouter() { return router } +function makeRouterWithRouteWork(routeWork: { + beforeLoad?: (ctx: { abortController: AbortController }) => unknown + loader?: (ctx: { abortController: AbortController }) => unknown +}) { + const rootRoute = new BaseRootRoute({}) + const workRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/work', + component: () => null, + ...routeWork, + }) + const router = new RouterCore( + { + history: createMemoryHistory({ initialEntries: ['/work'] }), + routeTree: rootRoute.addChildren([workRoute]), + }, + getStoreConfig, + ) + router.isServer = true + return router +} + +function waitForAbortOrRelease(signal: AbortSignal) { + return new Promise((resolve) => { + const release = () => { + signal.removeEventListener('abort', release) + resolve() + } + signal.addEventListener('abort', release, { once: true }) + }) +} + function makeStreamResponse(router: ReturnType) { attachRouterServerSsrUtils({ router: router as any, manifest: undefined }) const stream = new ReadableStream({ @@ -327,6 +364,630 @@ describe('createStartHandler SSR cleanup ownership', () => { }) }) +describe('createStartHandler request cancellation', () => { + it.each(['beforeLoad', 'loader'] as const)( + 'aborts route %s work and does not render HTML', + async (hook) => { + let routeSignal: AbortSignal | undefined + let notifyStarted: (() => void) | undefined + const started = new Promise((resolve) => { + notifyStarted = resolve + }) + const routeWork = ({ + abortController, + }: { + abortController: AbortController + }) => { + routeSignal = abortController.signal + notifyStarted?.() + return waitForAbortOrRelease(abortController.signal) + } + const router = makeRouterWithRouteWork({ [hook]: routeWork }) + startMocks.router = router + const requestController = new AbortController() + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + const response = handler( + new Request('http://localhost/work', { + signal: requestController.signal, + }), + {}, + ) + + await started + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + + expect((await response).status).toBe(500) + expect(routeSignal?.aborted).toBe(true) + expect(routeSignal?.reason).toBe(cancellation) + expect(render).not.toHaveBeenCalled() + }, + ) + + it('settles and cleans up while the render callback is still pending', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + let notifyRenderStarted!: () => void + const renderStarted = new Promise((resolve) => { + notifyRenderStarted = resolve + }) + let resolveRender!: ( + value: ReturnType, + ) => void + const renderResult = new Promise< + ReturnType + >((resolve) => { + resolveRender = resolve + }) + let cleanupCalls = 0 + let cancelCalls = 0 + let lateStreamResponse!: ReturnType + const handler = createStartHandler(({ router: requestRouter }) => { + const serverSsr = requestRouter.serverSsr! + const cleanup = serverSsr.cleanup + serverSsr.cleanup = () => { + cleanupCalls++ + cleanup() + } + lateStreamResponse = createSsrStreamResponse( + requestRouter, + new Response( + new ReadableStream({ + cancel() { + cancelCalls++ + return new Promise(() => {}) + }, + }), + ), + ) + notifyRenderStarted() + return renderResult + }) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await renderStarted + requestController.abort(new Error('request disconnected')) + + expect((await response).status).toBe(500) + expect(cleanupCalls).toBe(1) + expect(router.serverSsr).toBeUndefined() + + resolveRender(lateStreamResponse) + await Promise.resolve() + await Promise.resolve() + expect(cleanupCalls).toBe(1) + expect(cancelCalls).toBe(1) + expect(router.serverSsr).toBeUndefined() + }) + + it('cancels a plain response resolved by the render callback later', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + let notifyRenderStarted!: () => void + const renderStarted = new Promise((resolve) => { + notifyRenderStarted = resolve + }) + let resolveRender!: (value: Response) => void + const renderResult = new Promise((resolve) => { + resolveRender = resolve + }) + const cancel = vi.fn((_reason: unknown) => new Promise(() => {})) + const handler = createStartHandler(() => { + notifyRenderStarted() + return renderResult + }) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await renderStarted + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + + expect((await response).status).toBe(500) + resolveRender(new Response(new ReadableStream({ cancel }))) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledTimes(1) + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + }) + + it('cancels a plain response resolved by request middleware later', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + let notifyMiddlewareStarted!: () => void + const middlewareStarted = new Promise((resolve) => { + notifyMiddlewareStarted = resolve + }) + let resolveMiddleware!: (value: Response) => void + const middlewareResult = new Promise((resolve) => { + resolveMiddleware = resolve + }) + const cancel = vi.fn((_reason: unknown) => new Promise(() => {})) + startMocks.requestMiddleware = [ + createMiddleware().server(() => { + notifyMiddlewareStarted() + return middlewareResult + }), + ] + const handler = createStartHandler(() => new Response('must not render')) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await middlewareStarted + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + + expect((await response).status).toBe(500) + resolveMiddleware(new Response(new ReadableStream({ cancel }))) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledTimes(1) + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + }) + + it.each(['throw', 'reject'] as const)( + 'reports a %s from disposal of a late render response', + async (failureMode) => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + const cleanupError = new Error('late stream cleanup failed') + const dispose = vi.fn(() => { + if (failureMode === 'throw') { + throw cleanupError + } + return Promise.reject(cleanupError) + }) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + let notifyRenderStarted!: () => void + const renderStarted = new Promise((resolve) => { + notifyRenderStarted = resolve + }) + let resolveRender!: (value: any) => void + const renderResult = new Promise((resolve) => { + resolveRender = resolve + }) + + try { + const handler = createStartHandler(() => { + notifyRenderStarted() + return renderResult + }) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await renderStarted + requestController.abort(new Error('request disconnected')) + expect((await response).status).toBe(500) + + resolveRender({ + response: new Response('stream'), + serverSsrCleanup: 'stream', + dispose, + }) + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledWith(cleanupError) + }) + expect(dispose).toHaveBeenCalledOnce() + } finally { + router.serverSsr?.cleanup() + consoleError.mockRestore() + } + }, + ) + + it.each(['throw', 'reject'] as const)( + 'reports a stream disposal %s when middleware is aborted', + async (failureMode) => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + const cleanupError = new Error('custom stream cleanup failed') + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const ssrResponse = makeStreamResponse(router) + const dispose = vi.fn(() => { + if (failureMode === 'throw') { + throw cleanupError + } + return Promise.reject(cleanupError) + }) + ;(ssrResponse as any).dispose = dispose + startMocks.serverFnResult = ssrResponse + let notifyMiddlewareStarted!: () => void + const middlewareStarted = new Promise((resolve) => { + notifyMiddlewareStarted = resolve + }) + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + await next() + notifyMiddlewareStarted() + return new Promise(() => {}) + }), + ] + + try { + const handler = createStartHandler(() => new Response('unused')) + const response = handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + signal: requestController.signal, + }), + {}, + ) + + await middlewareStarted + requestController.abort(new Error('request disconnected')) + + expect((await response).status).toBe(500) + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledWith(cleanupError) + }) + expect(dispose).toHaveBeenCalledOnce() + } finally { + router.serverSsr?.cleanup() + consoleError.mockRestore() + } + }, + ) + + it('disposes a stream when the request aborts after response handoff', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + let cancelCalls = 0 + const handler = createStartHandler(({ router: requestRouter }) => + createSsrStreamResponse( + requestRouter, + new Response( + new ReadableStream({ + cancel() { + cancelCalls++ + return new Promise(() => {}) + }, + }), + ), + ), + ) + + const response = await handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + expect(response.body).not.toBeNull() + expect(router.serverSsr).toBeDefined() + + requestController.abort(new Error('request disconnected')) + await Promise.resolve() + + expect(cancelCalls).toBe(1) + expect(router.serverSsr).toBeUndefined() + }) + + it('settles when request middleware ignores cancellation', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + let notifyMiddlewareStarted!: () => void + const middlewareStarted = new Promise((resolve) => { + notifyMiddlewareStarted = resolve + }) + const dispose = vi.fn(() => Promise.resolve()) + let releaseMiddleware!: (response: any) => void + const middlewareResult = new Promise((resolve) => { + releaseMiddleware = resolve + }) + startMocks.requestMiddleware = [ + createMiddleware().server(() => { + notifyMiddlewareStarted() + return middlewareResult + }), + ] + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await middlewareStarted + requestController.abort(new Error('request disconnected')) + + expect((await response).status).toBe(500) + expect(render).not.toHaveBeenCalled() + + releaseMiddleware({ + response: new Response('late'), + serverSsrCleanup: 'stream', + dispose, + }) + await vi.waitFor(() => expect(dispose).toHaveBeenCalledOnce()) + expect(render).not.toHaveBeenCalled() + }) + + it('unwinds nested middleware when an inner operation ignores cancellation', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + const outerFinally = vi.fn() + let notifyInnerStarted!: () => void + const innerStarted = new Promise((resolve) => { + notifyInnerStarted = resolve + }) + const pending = new Promise(() => {}) + startMocks.requestMiddleware = [ + createMiddleware().server(({ next }) => next()), + createMiddleware().server(async ({ next }) => { + try { + return await next() + } finally { + outerFinally() + } + }), + createMiddleware().server(() => { + notifyInnerStarted() + return pending + }), + ] + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await innerStarted + requestController.abort(new Error('request disconnected')) + + expect((await response).status).toBe(500) + expect(outerFinally).toHaveBeenCalledOnce() + expect(render).not.toHaveBeenCalled() + }) + + it('cancels an all-synchronous direct next chain', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + let notifyInnerStarted!: () => void + const innerStarted = new Promise((resolve) => { + notifyInnerStarted = resolve + }) + startMocks.requestMiddleware = [ + createMiddleware().server(({ next }) => next()), + createMiddleware().server(({ next }) => next()), + createMiddleware().server(() => { + notifyInnerStarted() + return new Promise(() => {}) + }), + ] + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await innerStarted + requestController.abort(new Error('request disconnected')) + + expect((await response).status).toBe(500) + expect(render).not.toHaveBeenCalled() + }) + + it('preserves the abort reason when direct next rejects during abort', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + const reason = new Error('request disconnected') + startMocks.requestMiddleware = [ + createMiddleware().server(({ next }) => next()), + createMiddleware().server(() => { + requestController.abort(reason) + throw new Response('must not escape', { status: 418 }) + }), + ] + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + + const response = await handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + expect(response.status).toBe(500) + expect(render).not.toHaveBeenCalled() + }) + + it('preserves aborts that race with a fulfilled direct next promise', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + const reason = new Error('request disconnected') + const observedErrors: Array = [] + const afterNext = vi.fn() + const ssrResponse = makeStreamResponse(router) + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + const cancel = vi.spyOn(ssrResponse.response.body!, 'cancel') + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + try { + const result = await next() + afterNext() + return result + } catch (error) { + observedErrors.push(error) + throw error + } + }), + createMiddleware().server(({ next }) => { + const pending = next() + void Promise.resolve(pending).then(() => + requestController.abort(reason), + ) + return pending + }), + createMiddleware().server(() => ssrResponse as any), + ] + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + + const response = await handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + expect(response.status).toBe(500) + await vi.waitFor(() => expect(observedErrors).toEqual([reason])) + await vi.waitFor(() => { + expect(dispose).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledWith(reason) + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(reason) + }) + expect(afterNext).not.toHaveBeenCalled() + expect(render).not.toHaveBeenCalled() + }) + + it('disposes a tagged final response once when abort wins handoff', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + const reason = new Error('request disconnected') + const ssrResponse = makeStreamResponse(router) + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + const cancel = vi.spyOn(ssrResponse.response.body!, 'cancel') + startMocks.requestMiddleware = [ + createMiddleware().server(() => { + queueMicrotask(() => { + queueMicrotask(() => requestController.abort(reason)) + }) + return ssrResponse as any + }), + ] + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + + const response = await handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + expect(response.status).toBe(500) + await vi.waitFor(() => { + expect(dispose).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledWith(reason) + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(reason) + }) + expect(router.serverSsr).toBeUndefined() + expect(render).not.toHaveBeenCalled() + }) + + it('ignores a late same-body alias after catch disposes its owner', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + const reason = new Error('request disconnected') + let notifyResponseCaptured!: () => void + const responseCaptured = new Promise((resolve) => { + notifyResponseCaptured = resolve + }) + let releaseMiddleware!: () => void + const middlewareRelease = new Promise((resolve) => { + releaseMiddleware = resolve + }) + let notifyLateResultDelivered!: () => void + const lateResultDelivered = new Promise((resolve) => { + notifyLateResultDelivered = resolve + }) + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + const wrapped = new Response(result.response.body, result.response) + notifyResponseCaptured() + await middlewareRelease + queueMicrotask(() => { + queueMicrotask(notifyLateResultDelivered) + }) + return wrapped + }), + ] + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('stream')) + }, + }), + ) + const cancel = vi.spyOn(response.body!, 'cancel') + let ssrResponse!: ReturnType + const render = vi.fn(({ router: requestRouter }) => { + ssrResponse = createSsrStreamResponse(requestRouter, response) + return ssrResponse + }) + const handler = createStartHandler(render) + const result = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await responseCaptured + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + requestController.abort(reason) + + expect((await result).status).toBe(500) + releaseMiddleware() + await lateResultDelivered + await vi.waitFor(() => { + expect(dispose).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledWith(reason) + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(reason) + }) + expect(router.serverSsr).toBeUndefined() + }) +}) + describe('createStartHandler inlineCss option', () => { const request = new Request('https://example.com/') diff --git a/packages/vue-router/src/CatchBoundary.tsx b/packages/vue-router/src/CatchBoundary.tsx index 5d75de8d6d..e912445158 100644 --- a/packages/vue-router/src/CatchBoundary.tsx +++ b/packages/vue-router/src/CatchBoundary.tsx @@ -1,13 +1,8 @@ import * as Vue from 'vue' import type { ErrorRouteComponent } from './route' -interface ErrorComponentProps { - error: Error - reset: () => void -} - type CatchBoundaryProps = { - getResetKey: () => number | string + getResetKey: () => unknown children: Vue.VNode errorComponent?: ErrorRouteComponent | Vue.Component onCatch?: (error: Error) => void @@ -17,12 +12,12 @@ const VueErrorBoundary = Vue.defineComponent({ name: 'VueErrorBoundary', props: { onError: Function, - resetKey: [String, Number], + resetKey: [String, Number, Object], + children: null, + errorComponent: null, }, - emits: ['catch'], - setup(props, { slots }) { + setup(props) { const error = Vue.ref(null) - const resetFn = Vue.ref<(() => void) | null>(null) const reset = () => { error.value = null @@ -46,7 +41,6 @@ const VueErrorBoundary = Vue.defineComponent({ } error.value = err - resetFn.value = reset if (props.onError) { props.onError(err) @@ -55,56 +49,26 @@ const VueErrorBoundary = Vue.defineComponent({ return false }) - return () => { - if (error.value && slots.fallback) { - const fallbackContent = slots.fallback({ - error: error.value, - reset, - }) - return Array.isArray(fallbackContent) && fallbackContent.length === 1 - ? fallbackContent[0] - : fallbackContent - } - - const defaultContent = slots.default && slots.default() - return Array.isArray(defaultContent) && defaultContent.length === 1 - ? defaultContent[0] - : defaultContent - } - }, -}) - -const CatchBoundaryWrapper = Vue.defineComponent({ - name: 'CatchBoundary', - inheritAttrs: false, - props: ['getResetKey', 'children', 'errorComponent', 'onCatch'] as any, - setup(props: CatchBoundaryProps) { - const resetKey = Vue.computed(() => props.getResetKey()) - - return () => { - return Vue.h( - VueErrorBoundary, - { - resetKey: resetKey.value, - onError: props.onCatch, - }, - { - default: () => props.children, - fallback: ({ error, reset }: ErrorComponentProps) => { - if (props.errorComponent) { - return Vue.h(props.errorComponent, { error, reset }) - } - return Vue.h(ErrorComponent, { error, reset }) - }, - }, - ) - } + return () => + error.value + ? Vue.h(props.errorComponent ?? ErrorComponent, { + error: error.value, + reset, + }) + : (props.children as Vue.VNode) }, }) export function CatchBoundary(props: CatchBoundaryProps) { - return Vue.h(CatchBoundaryWrapper, props as any) + return Vue.h(VueErrorBoundary, { + resetKey: props.getResetKey() as any, + onError: props.onCatch, + children: props.children, + errorComponent: props.errorComponent, + }) } +CatchBoundary.inheritAttrs = false +CatchBoundary.props = ['getResetKey', 'children', 'errorComponent', 'onCatch'] export const ErrorComponent = Vue.defineComponent({ name: 'ErrorComponent', diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 44e9aca0cd..9fc4ed6c8d 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -1,23 +1,12 @@ import * as Vue from 'vue' -import { - createControlledPromise, - getLocationChangeInfo, - invariant, - isNotFound, - isRedirect, - rootRouteId, -} from '@tanstack/router-core' +import { isNotFound, rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useStore } from '@tanstack/vue-store' -import { CatchBoundary, ErrorComponent } from './CatchBoundary' +import { CatchBoundary } from './CatchBoundary' import { ClientOnly } from './ClientOnly' import { useRouter } from './useRouter' import { CatchNotFound } from './not-found' -import { - matchContext, - pendingMatchContext, - routeIdContext, -} from './matchContext' +import { routeIdContext } from './matchContext' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import type { VNode } from 'vue' @@ -26,7 +15,7 @@ import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' export const Match = Vue.defineComponent({ name: 'Match', props: { - matchId: { + routeId: { type: String, required: true, }, @@ -34,127 +23,47 @@ export const Match = Vue.defineComponent({ setup(props) { const router = useRouter() - // Derive routeId from initial props.matchId — stable for this component's - // lifetime. The routeId never changes for a given route position in the - // tree, even when matchId changes (loaderDepsHash, etc). - const routeId = router.stores.matchStores.get(props.matchId)?.routeId + const routeId = props.routeId - if (!routeId) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - `Invariant failed: Could not find routeId for matchId "${props.matchId}". Please file an issue!`, - ) - } - - invariant() - } - - // Static route-tree check: is this route a direct child of the root? - // parentRoute is set at build time, so no reactive tracking needed. - const isChildOfRoot = - (router.routesById[routeId] as AnyRoute)?.parentRoute?.id === rootRouteId - - // Single stable store subscription — getRouteMatchStore returns a - // cached computed store that resolves routeId → current match state - // through the signal graph. No bridge needed. const activeMatch = useStore( - router.stores.getRouteMatchStore(routeId), + router.stores.getMatchStore(routeId), (value) => value, - ) - const isPendingMatchRef = useStore( - router.stores.pendingRouteIds, - (pendingRouteIds) => Boolean(pendingRouteIds[routeId]), { equal: Object.is }, ) - const loadedAt = useStore(router.stores.loadedAt, (value) => value) - - const matchData = Vue.computed(() => { - const match = activeMatch.value - if (!match) { - return null - } - - return { - matchId: match.id, - routeId, - loadedAt: loadedAt.value, - ssr: match.ssr, - _displayPending: match._displayPending, - } - }) - - const route = Vue.computed(() => - matchData.value ? router.routesById[matchData.value.routeId] : null, - ) - - const PendingComponent = Vue.computed( - () => - route.value?.options?.pendingComponent ?? - router?.options?.defaultPendingComponent, - ) - - const pendingElement = Vue.computed(() => - PendingComponent.value ? Vue.h(PendingComponent.value) : undefined, - ) - - const routeErrorComponent = Vue.computed( - () => - route.value?.options?.errorComponent ?? - router?.options?.defaultErrorComponent, - ) - - const routeOnCatch = Vue.computed( - () => route.value?.options?.onCatch ?? router?.options?.defaultOnCatch, - ) - - const routeNotFoundComponent = Vue.computed(() => - route.value?.isRoot - ? // If it's the root route, use the globalNotFound option, with fallback to the notFoundRoute's component - (route.value?.options?.notFoundComponent ?? - router?.options?.notFoundRoute?.options?.component) - : route.value?.options?.notFoundComponent, - ) - - const hasShellComponent = Vue.computed(() => { - if (!route.value?.isRoot) return false - return !!(route.value.options as RootRouteOptions).shellComponent - }) - - const ShellComponent = Vue.computed(() => - hasShellComponent.value - ? ((route.value!.options as RootRouteOptions).shellComponent as any) - : null, - ) - // Provide routeId context (stable string) for children. // MatchInner, Outlet, and useMatch all consume this. Vue.provide(routeIdContext, routeId) - // Provide reactive nearest-match context for hooks that slice the active - // matches array relative to the current match. - const matchIdRef = Vue.computed( - () => activeMatch.value?.id ?? props.matchId, - ) - Vue.provide(matchContext, matchIdRef) - - Vue.provide(pendingMatchContext, isPendingMatchRef) - return (): VNode => { - const actualMatchId = matchData.value?.matchId ?? props.matchId - - const resolvedNoSsr = - matchData.value?.ssr === false || matchData.value?.ssr === 'data-only' - const shouldClientOnly = - resolvedNoSsr || !!matchData.value?._displayPending + const match = activeMatch.value + const route = match ? (router.routesById[routeId] as AnyRoute) : undefined + const PendingComponent = + route?.options.pendingComponent ?? + router.options.defaultPendingComponent + const pendingElement = PendingComponent + ? Vue.h(PendingComponent) + : undefined + const routeErrorComponent = + route?.options.errorComponent ?? router.options.defaultErrorComponent + const routeOnCatch = + route?.options.onCatch ?? router.options.defaultOnCatch + const routeNotFoundComponent = route?.isRoot + ? (route.options.notFoundComponent ?? + router.options.notFoundRoute?.options.component) + : route?.options.notFoundComponent + const ShellComponent = route?.isRoot + ? ((route.options as RootRouteOptions).shellComponent as any) + : undefined + const resolvedNoSsr = match?.ssr === false || match?.ssr === 'data-only' const renderMatchContent = (): VNode => { - const matchInner = Vue.h(MatchInner, { matchId: actualMatchId }) + const matchInner = Vue.h(MatchInner) - let content: VNode = shouldClientOnly + let content: VNode = resolvedNoSsr ? Vue.h( ClientOnly, { - fallback: pendingElement.value, + fallback: pendingElement, }, { default: () => matchInner, @@ -163,73 +72,60 @@ export const Match = Vue.defineComponent({ : matchInner // Wrap in NotFound boundary if needed - if (routeNotFoundComponent.value) { + if (routeNotFoundComponent) { content = Vue.h(CatchNotFound, { fallback: (error: any) => { - error.routeId ??= matchData.value?.routeId - - // 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.value || - (error.routeId && error.routeId !== matchData.value?.routeId) || - (!error.routeId && route.value && !route.value.isRoot) - ) + error.routeId ??= routeId + + if (error.routeId !== routeId) { throw error + } - return Vue.h(routeNotFoundComponent.value, error) + return Vue.h(routeNotFoundComponent, error) }, children: content, }) } // Wrap in error boundary if needed - if (routeErrorComponent.value) { + if (routeErrorComponent) { content = CatchBoundary({ - getResetKey: () => matchData.value?.loadedAt ?? 0, - errorComponent: routeErrorComponent.value || ErrorComponent, + getResetKey: () => activeMatch.value, + errorComponent: routeErrorComponent, onCatch: (error: Error) => { // Forward not found errors (we don't want to show the error component for these) if (isNotFound(error)) { - error.routeId ??= matchData.value?.routeId + error.routeId ??= routeId throw error } if (process.env.NODE_ENV !== 'production') { - console.warn(`Warning: Error in route match: ${actualMatchId}`) + console.warn(`Warning: Error in route match: ${match?.id}`) } - routeOnCatch.value?.(error) + routeOnCatch?.(error) }, children: content, }) } - // Add scroll restoration if needed - const withScrollRestoration: Array = [ - content, - isChildOfRoot - ? Vue.h(Vue.Fragment, null, [ - Vue.h(OnRendered), - router.options.scrollRestoration && - (isServer ?? router.isServer) - ? Vue.h(ScrollRestoration) - : null, - ]) - : null, - ].filter(Boolean) as Array - - // Return single child directly to avoid Fragment wrapper that causes hydration mismatch - if (withScrollRestoration.length === 1) { - return withScrollRestoration[0]! + if (route?.parentRoute?.id !== rootRouteId) { + return content } - return Vue.h(Vue.Fragment, null, withScrollRestoration) + return Vue.h(Vue.Fragment, null, [ + content, + Vue.h(Vue.Fragment, null, [ + (isServer ?? router.isServer) && router.options.scrollRestoration + ? Vue.h(ScrollRestoration) + : null, + ]), + ]) } - if (!hasShellComponent.value) { + if (!ShellComponent) { return renderMatchContent() } - return Vue.h(ShellComponent.value, null, { + return Vue.h(ShellComponent, null, { // Important: return a fresh VNode on each slot invocation so that shell // components can re-render without reusing a cached VNode instance. default: () => renderMatchContent(), @@ -239,65 +135,14 @@ export const Match = Vue.defineComponent({ }) // On Rendered can't happen above the root layout because it actually -// renders a dummy dom element to track the rendered state of the app. -// We render a script tag with a key that changes based on the current -// location state.__TSR_key. Also, because it's below the root layout, it -// allows us to fire onRendered events even after a hydration mismatch -// error that occurred above the root layout (like bad head/link tags, -// which is common). -const OnRendered = Vue.defineComponent({ - name: 'OnRendered', - setup() { - const router = useRouter() - - const location = useStore( - router.stores.resolvedLocation, - (resolvedLocation) => resolvedLocation?.state.__TSR_key, - ) - - let prevHref: string | undefined - - Vue.watch( - location, - () => { - if (location.value) { - const currentHref = router.latestLocation.href - if (prevHref === undefined || prevHref !== currentHref) { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - prevHref = currentHref - } - } - }, - { immediate: true }, - ) - - return () => null - }, -}) - export const MatchInner = Vue.defineComponent({ name: 'MatchInner', - props: { - matchId: { - type: String, - required: true, - }, - }, - setup(props) { + setup() { const router = useRouter() // Use routeId from context (provided by parent Match) — stable string. const routeId = Vue.inject(routeIdContext)! - const activeMatch = useStore( - router.stores.getRouteMatchStore(routeId), - (value) => value, - ) + const activeMatch = useStore(router.stores.getMatchStore(routeId)) // Combined selector for match state AND remount key // This ensures both are computed in the same selector call with consistent data @@ -315,110 +160,45 @@ export const MatchInner = Vue.defineComponent({ (router.routesById[matchRouteId] as AnyRoute).options.remountDeps ?? router.options.defaultRemountDeps - let remountKey: string | undefined - if (remountFn) { - const remountDeps = remountFn({ - routeId: matchRouteId, - loaderDeps: match.loaderDeps, - params: match._strictParams, - search: match._strictSearch, - }) - remountKey = remountDeps ? JSON.stringify(remountDeps) : undefined - } - - return { - routeId: matchRouteId, - match: { - id: match.id, - status: match.status, - error: match.error, - ssr: match.ssr, - _forcePending: match._forcePending, - _displayPending: match._displayPending, - _nonReactive: match._nonReactive, - }, - remountKey, - } - }) + const remountDeps = remountFn + ? remountFn({ + routeId: matchRouteId, + loaderDeps: match.loaderDeps, + params: match._strictParams, + search: match._strictSearch, + }) + : undefined - const route = Vue.computed(() => { - if (!combinedState.value) return null - return router.routesById[combinedState.value.routeId]! + return [ + match, + remountDeps ? JSON.stringify(remountDeps) : undefined, + ] as const }) - const match = Vue.computed(() => combinedState.value?.match) - const remountKey = Vue.computed(() => combinedState.value?.remountKey) - - const getMatchPromise = ( - match: { - id: string - _nonReactive: { - displayPendingPromise?: Promise - minPendingPromise?: Promise - loadPromise?: Promise - } - }, - key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise', - ) => { - return ( - router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key] - ) - } - return (): VNode | null => { // If match doesn't exist, return null (component is being unmounted or not ready) - if (!combinedState.value || !match.value || !route.value) return null - - // Handle different match statuses - if (match.value._displayPending) { - const PendingComponent = - route.value.options.pendingComponent ?? - router.options.defaultPendingComponent - - return PendingComponent ? Vue.h(PendingComponent) : null - } - - if (match.value._forcePending) { - const PendingComponent = - route.value.options.pendingComponent ?? - router.options.defaultPendingComponent - - return PendingComponent ? Vue.h(PendingComponent) : null - } - - if (match.value.status === 'notFound') { - if (!isNotFound(match.value.error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error('Invariant failed: Expected a notFound error') - } - - invariant() - } - return renderRouteNotFound(router, route.value, match.value.error) + const state = combinedState.value + if (!state) { + return null } + const [match, remountKey] = state + const route = router.routesById[match.routeId]! - if (match.value.status === 'redirected') { - if (!isRedirect(match.value.error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error('Invariant failed: Expected a redirect error') - } - - invariant() - } - throw getMatchPromise(match.value, 'loadPromise') + // Handle different match statuses + if (match.status === 'notFound') { + return renderRouteNotFound(router, route, match.error) } - if (match.value.status === 'error') { + if (match.status === 'error') { // Check if this route or any parent has an error component const RouteErrorComponent = - route.value.options.errorComponent ?? - router.options.defaultErrorComponent + route.options.errorComponent ?? router.options.defaultErrorComponent // If this route has an error component, render it directly // This is more reliable than relying on Vue's error boundary if (RouteErrorComponent) { return Vue.h(RouteErrorComponent, { - error: match.value.error, + error: match.error, reset: () => { router.invalidate() }, @@ -430,37 +210,14 @@ export const MatchInner = Vue.defineComponent({ // If there's no error component for this route, throw the error // so it can bubble up to the nearest parent with an error component - throw match.value.error + throw match.error } - if (match.value.status === 'pending') { - const pendingMinMs = - route.value.options.pendingMinMs ?? router.options.defaultPendingMinMs - - const routerMatch = router.getMatch(match.value.id) - if ( - pendingMinMs && - routerMatch && - !routerMatch._nonReactive.minPendingPromise - ) { - // Create a promise that will resolve after the minPendingMs - if (!(isServer ?? router.isServer)) { - const minPendingPromise = createControlledPromise() - - routerMatch._nonReactive.minPendingPromise = minPendingPromise - - setTimeout(() => { - minPendingPromise.resolve() - // We've handled the minPendingPromise, so we can delete it - routerMatch._nonReactive.minPendingPromise = undefined - }, pendingMinMs) - } - } - + if (match.status === 'pending') { // In Vue, we render the pending component directly instead of throwing a promise // because Vue's Suspense doesn't catch thrown promises like React does const PendingComponent = - route.value.options.pendingComponent ?? + route.options.pendingComponent ?? router.options.defaultPendingComponent if (PendingComponent) { @@ -472,16 +229,19 @@ export const MatchInner = Vue.defineComponent({ } // Success status - render the component with remount key - const Comp = - route.value.options.component ?? router.options.defaultComponent - const key = remountKey.value - + const Comp = route.options.component ?? router.options.defaultComponent if (Comp) { // Pass key as a prop - Vue.h properly handles 'key' as a special prop - return Vue.h(Comp, key !== undefined ? { key } : undefined) + return Vue.h( + Comp, + remountKey !== undefined ? { key: remountKey } : undefined, + ) } - return Vue.h(Outlet, key !== undefined ? { key } : undefined) + return Vue.h( + Outlet, + remountKey !== undefined ? { key: remountKey } : undefined, + ) } }, }) @@ -490,66 +250,41 @@ export const Outlet = Vue.defineComponent({ name: 'Outlet', setup() { const router = useRouter() - const parentRouteId = Vue.inject(routeIdContext) - - if (!parentRouteId) { - return (): VNode | null => null - } - - // Parent state via stable routeId store — single subscription - const parentMatch = useStore( - router.stores.getRouteMatchStore(parentRouteId), - (v) => v, - ) + const parentRouteId = Vue.inject(routeIdContext)! - const route = Vue.computed(() => - parentMatch.value - ? router.routesById[parentMatch.value.routeId as string]! - : undefined, - ) - - const parentGlobalNotFound = Vue.computed( - () => parentMatch.value?.globalNotFound ?? false, - ) + const parentMatch = useStore(router.stores.getMatchStore(parentRouteId)) - // Child match lookup: read the child matchId from the shared derived - // map (one reactive node for the whole tree), then grab match state - // directly from the pool. - const childMatchIdMap = useStore( - router.stores.childMatchIdByRouteId, - (v) => v, - ) + const route = router.routesById[parentRouteId]! - const childMatchData = Vue.computed(() => { - const childId = childMatchIdMap.value[parentRouteId] - if (!childId) return null - const child = router.stores.matchStores.get(childId)?.get() - if (!child) return null - - return { - id: child.id, - // Key based on routeId + params only (not loaderDeps) - // This ensures component recreates when params change, - // but NOT when only loaderDeps change - paramsKey: child.routeId + JSON.stringify(child._strictParams), - } + const childMatch = useStore(router.stores.matches, (matches) => { + const index = matches.findIndex( + (match) => match.routeId === parentRouteId, + ) + const child = matches[index + 1] + return child + ? ([ + child.routeId, + child.routeId + JSON.stringify(child._strictParams), + ] as const) + : undefined }) return (): VNode | null => { - if (parentGlobalNotFound.value) { - if (!route.value) { - return null - } - return renderRouteNotFound(router, route.value, undefined) + if (parentMatch.value?._notFound) { + return renderRouteNotFound(router, route, parentMatch.value.error) } - if (!childMatchData.value) { + const child = childMatch.value + if (!child) { return null } const nextMatch = Vue.h(Match, { - matchId: childMatchData.value.id, - key: childMatchData.value.paramsKey, + routeId: child[0], + // Key based on routeId + params only (not loaderDeps) + // This ensures component recreates when params change, + // but NOT when only loaderDeps change + key: child[1], }) // Note: We intentionally do NOT wrap in Suspense here. diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index da8220db29..15799d2a5b 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -4,7 +4,7 @@ import { useStore } from '@tanstack/vue-store' import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import { useTransitionerSetup } from './Transitioner' -import { matchContext } from './matchContext' +import { routeIdContext } from './matchContext' import { Match } from './Match' import type { AnyRouter, @@ -32,51 +32,31 @@ declare module '@tanstack/router-core' { } } -// Create a component that renders MatchesInner with Transitioner's setup logic inlined. -// This is critical for proper hydration - we call useTransitionerSetup() as a composable -// rather than rendering it as a component, which avoids Fragment/element mismatches. -const MatchesContent = Vue.defineComponent({ - name: 'MatchesContent', - setup() { - // IMPORTANT: We need to ensure Transitioner's setup() runs. - // Transitioner sets up critical functionality: - // - router.startTransition - // - History subscription via router.history.subscribe(router.load) - // - Watchers for router events - // - // We inline Transitioner's setup logic here. Since Transitioner returns null, - // we can call its setup function directly without affecting the render tree. - // This is done by importing and calling useTransitionerSetup. - useTransitionerSetup() - - return () => Vue.h(MatchesInner) - }, -}) - export const Matches = Vue.defineComponent({ name: 'Matches', setup() { const router = useRouter() + useTransitionerSetup() return () => { - const pendingElement = router?.options?.defaultPendingComponent + const pendingElement = router.options.defaultPendingComponent ? Vue.h(router.options.defaultPendingComponent) : null // Do not render a root Suspense during SSR or hydrating from SSR const inner = - (isServer ?? router?.isServer ?? false) || - (typeof document !== 'undefined' && router?.ssr) - ? Vue.h(MatchesContent) + (isServer ?? router.isServer) || + (typeof document !== 'undefined' && router.ssr) + ? Vue.h(MatchesInner) : Vue.h( Vue.Suspense, { fallback: pendingElement }, { - default: () => Vue.h(MatchesContent), + default: () => Vue.h(MatchesInner), }, ) - return router?.options?.InnerWrap + return router.options.InnerWrap ? Vue.h(router.options.InnerWrap, null, { default: () => inner }) : inner } @@ -99,19 +79,13 @@ const MatchesInner = Vue.defineComponent({ setup() { const router = useRouter() - const matchId = useStore(router.stores.firstId, (id) => id) - const resetKey = useStore(router.stores.loadedAt, (loadedAt) => loadedAt) - - // Create a ref for the match id to provide - const matchIdRef = Vue.computed(() => matchId.value) - - // Provide the matchId for child components using the InjectionKey - Vue.provide(matchContext, matchIdRef) + const matches = useStore(router.stores.matches) + const routeId = Vue.computed(() => matches.value[0]?.routeId) return () => { - // Generate a placeholder element if matchId.value is not present - const childElement = matchId.value - ? Vue.h(Match, { matchId: matchId.value }) + // Generate a placeholder element if routeId.value is not present + const childElement = routeId.value + ? Vue.h(Match, { routeId: routeId.value }) : Vue.h('div') // If disableGlobalCatchBoundary is true, don't wrap in CatchBoundary @@ -120,7 +94,7 @@ const MatchesInner = Vue.defineComponent({ } return Vue.h(CatchBoundary, { - getResetKey: () => resetKey.value, + getResetKey: () => matches.value, errorComponent: errorComponentFn, onCatch: process.env.NODE_ENV !== 'production' @@ -152,7 +126,12 @@ export type UseMatchRouteOptions< export function useMatchRoute() { const router = useRouter() - const routerState = useStore(router.stores.matchRouteDeps, (value) => value) + const location = useStore(router.stores.location, (value) => value.href) + const resolvedLocation = useStore( + router.stores.resolvedLocation, + (value) => value?.href, + ) + const status = useStore(router.stores.status) return < const TFrom extends string = string, @@ -164,12 +143,11 @@ export function useMatchRoute() { ): Vue.Ref< false | ResolveRoute['types']['allParams'] > => { - const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts - const matchRoute = Vue.computed(() => { - // Access routerState to establish dependency - - routerState.value + location.value + resolvedLocation.value + status.value + const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts return router.matchRoute(rest as any, { pending, caseSensitive, @@ -248,27 +226,20 @@ export const MatchRoute = Vue.defineComponent({ }, }, setup(props, { slots }) { - const router = useRouter() - const status = useStore( - router.stores.matchRouteDeps, - (value) => value.status, - ) + const params = useMatchRoute()(props) return () => { - if (!status.value) return null - - const matchRoute = useMatchRoute() - const params = matchRoute(props).value as boolean + const match = params.value as boolean // Create a component that renders the slot in a reactive manner - if (!params || !slots.default) { + if (!match || !slots.default) { return null } // For function slots, pass the params if (typeof slots.default === 'function') { // Use h to create a wrapper component that will call the slot function - return Vue.h(Vue.Fragment, null, slots.default(params)) + return Vue.h(Vue.Fragment, null, slots.default(match)) } // For normal slots, just render them @@ -306,15 +277,13 @@ export function useParentMatches< >( opts?: UseMatchesBaseOptions, ): Vue.Ref> { - // Use matchContext with proper type - const contextMatchId = Vue.inject>(matchContext) - const safeMatchId = Vue.computed(() => contextMatchId?.value || '') + const contextRouteId = Vue.inject(routeIdContext) return useMatches({ select: (matches: Array>) => { matches = matches.slice( 0, - matches.findIndex((d) => d.id === safeMatchId.value), + matches.findIndex((d) => d.routeId === contextRouteId), ) return opts?.select ? opts.select(matches) : matches }, @@ -327,14 +296,12 @@ export function useChildMatches< >( opts?: UseMatchesBaseOptions, ): Vue.Ref> { - // Use matchContext with proper type - const contextMatchId = Vue.inject>(matchContext) - const safeMatchId = Vue.computed(() => contextMatchId?.value || '') + const contextRouteId = Vue.inject(routeIdContext) return useMatches({ select: (matches: Array>) => { matches = matches.slice( - matches.findIndex((d) => d.id === safeMatchId.value) + 1, + matches.findIndex((d) => d.routeId === contextRouteId) + 1, ) return opts?.select ? opts.select(matches) : matches }, diff --git a/packages/vue-router/src/Scripts.tsx b/packages/vue-router/src/Scripts.tsx index 32c9eef8b5..8c0a1f87f3 100644 --- a/packages/vue-router/src/Scripts.tsx +++ b/packages/vue-router/src/Scripts.tsx @@ -1,4 +1,5 @@ import * as Vue from 'vue' +import { _getAssetMatches } from '@tanstack/router-core' import { useStore } from '@tanstack/vue-store' import { isServer } from '@tanstack/router-core/isServer' import { Asset } from './Asset' @@ -17,68 +18,49 @@ export const Scripts = Vue.defineComponent({ setup() { const router = useRouter() const nonce = router.options.ssr?.nonce - const matches = useStore(router.stores.matches, (value) => value) + const matches = useStore(router.stores.matches, _getAssetMatches) - const getAssetScripts = (matches: Array) => { + const scripts = Vue.computed(() => { + const userScripts: Array = [] const assetScripts: Array = [] const manifest = router.ssr?.manifest - - if (!manifest) { - return [] - } - - matches.forEach((match) => { - const routeManifest = manifest.routes[match.routeId] - - routeManifest?.scripts?.forEach((asset) => { + for (const match of matches.value) { + for (const script of match.scripts ?? []) { + if (!script) { + continue + } + const { children, ...attrs } = script + userScripts.push({ + tag: 'script', + attrs: { ...attrs, nonce }, + children, + }) + } + for (const asset of manifest?.routes[match.routeId]?.scripts ?? []) { assetScripts.push({ tag: 'script', attrs: { ...asset.attrs, nonce }, children: asset.children, }) - }) - }) - - return assetScripts - } - - const getScripts = (matches: Array): Array => - ( - matches - .map((match) => match.scripts!) - .flat(1) - .filter(Boolean) as Array - ).map( - ({ children, ...script }) => - ({ - tag: 'script', - attrs: { - ...script, - nonce, - }, - children, - }) satisfies RouterManagedTag, - ) - - const assetScripts = Vue.computed>(() => - getAssetScripts(matches.value), - ) - const scripts = Vue.computed>(() => - getScripts(matches.value), - ) + } + } + return [userScripts, assetScripts] as const + }) const mounted = Vue.ref(false) Vue.onMounted(() => { mounted.value = true }) - return () => - renderScripts(router, { - scripts: scripts.value, - assetScripts: assetScripts.value, + return () => { + const [userScripts, assetScripts] = scripts.value + return renderScripts(router, { + scripts: userScripts, + assetScripts, mounted: mounted.value, nonce, }) + } }, }) diff --git a/packages/vue-router/src/Transitioner.tsx b/packages/vue-router/src/Transitioner.tsx index b0133d965c..96f98b7bf3 100644 --- a/packages/vue-router/src/Transitioner.tsx +++ b/packages/vue-router/src/Transitioner.tsx @@ -1,109 +1,23 @@ import * as Vue from 'vue' import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { batch, useStore } from '@tanstack/vue-store' import { useRouter } from './useRouter' -import { usePrevious } from './utils' -// Track mount state per router to avoid double-loading -let mountLoadForRouter = { router: null as any, mounted: false } - -/** - * Composable that sets up router transition logic. - * This is called from MatchesContent to set up: - * - router.startTransition - * - router.startViewTransition - * - History subscription - * - Router event watchers - * - * Must be called during component setup phase. - */ export function useTransitionerSetup() { const router = useRouter() - - // Skip on server - no transitions needed if (isServer ?? router.isServer) { return } - const isLoading = useStore(router.stores.isLoading, (value) => value) - - // Track if we're in a transition - using a ref to track async transitions - const isTransitioning = Vue.ref(false) - - // Track pending state changes - const hasPending = useStore(router.stores.hasPending, (value) => value) - - const previousIsLoading = usePrevious(() => isLoading.value) - - const isAnyPending = Vue.computed( - () => isLoading.value || isTransitioning.value || hasPending.value, - ) - const previousIsAnyPending = usePrevious(() => isAnyPending.value) - - const isPagePending = Vue.computed(() => isLoading.value || hasPending.value) - const previousIsPagePending = usePrevious(() => isPagePending.value) - - // Implement startTransition similar to React/Solid - // Vue doesn't have a native useTransition like React 18, so we simulate it - // We also update the router state's isTransitioning flag so useMatch can check it - router.startTransition = (fn: () => void | Promise) => { - isTransitioning.value = true - // Also update the router state so useMatch knows we're transitioning - try { - router.stores.isTransitioning.set(true) - } catch { - // Ignore errors if component is unmounted - } - - // Helper to end the transition - const endTransition = () => { - // Use nextTick to ensure Vue has processed all reactive updates - Vue.nextTick(() => { - try { - isTransitioning.value = false - router.stores.isTransitioning.set(false) - } catch { - // Ignore errors if component is unmounted - } - }) - } - - // Execute the function synchronously - // The function internally may call startViewTransition which schedules async work - // via document.startViewTransition, but we don't need to wait for it here - // because Vue's reactivity will trigger re-renders when state changes + const transition = async (fn: () => void) => { fn() - - // End the transition on next tick to allow Vue to process reactive updates - endTransition() + await Vue.nextTick() + return true } - - // Vue updates DOM asynchronously (next tick). The View Transitions API expects the - // update callback promise to resolve only after the DOM has been updated. - // Wrap the router-core implementation to await a Vue flush before resolving. - const originalStartViewTransition: - | undefined - | ((fn: () => Promise) => void) = - (router as any).__tsrOriginalStartViewTransition ?? - router.startViewTransition - - ;(router as any).__tsrOriginalStartViewTransition = - originalStartViewTransition - - router.startViewTransition = (fn: () => Promise) => { - return originalStartViewTransition?.(async () => { - await fn() - await Vue.nextTick() - }) - } - - // Subscribe to location changes - // and try to load the new location - let unsubscribe: (() => void) | undefined + router.startTransition = transition Vue.onMounted(() => { - unsubscribe = router.history.subscribe(router.load) + Vue.onUnmounted(router.history.subscribe(router.load)) const nextLocation = router.buildLocation({ to: router.latestLocation.pathname, @@ -114,130 +28,34 @@ export function useTransitionerSetup() { _includeValidateSearch: true, }) - // 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. if ( trimPathRight(router.latestLocation.publicHref) !== trimPathRight(nextLocation.publicHref) ) { - router.commitLocation({ ...nextLocation, replace: true }) - } - }) - - // Track if component is mounted to prevent updates after unmount - const isMounted = Vue.ref(false) - - Vue.onMounted(() => { - isMounted.value = true - if (!isAnyPending.value) { - if (router.stores.status.get() === 'pending') { - batch(() => { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) - }) - } - } - }) - - Vue.onUnmounted(() => { - isMounted.value = false - if (unsubscribe) { - unsubscribe() + router.commitLocation({ + ...nextLocation, + replace: true, + ignoreBlocker: true, + }) + return } - }) - // Try to load the initial location - Vue.onMounted(() => { + const resolvedLocation = router.stores.resolvedLocation.get() if ( - (typeof window !== 'undefined' && router.ssr) || - (mountLoadForRouter.router === router && mountLoadForRouter.mounted) + resolvedLocation?.href === router.latestLocation.href && + resolvedLocation.state.__TSR_key === router.latestLocation.state.__TSR_key ) { - return - } - mountLoadForRouter = { router, mounted: true } - const tryLoad = async () => { - try { - await router.load() - } catch (err) { - console.error(err) - } - } - tryLoad() - }) - - // Setup watchers for emitting events - // All watchers check isMounted to prevent updates after unmount - Vue.watch( - () => isLoading.value, - (newValue) => { - if (!isMounted.value) return - try { - if (previousIsLoading.value.previous && !newValue) { - router.emit({ - type: 'onLoad', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - } - } catch { - // Ignore errors if component is unmounted - } - }, - ) - - Vue.watch(isPagePending, (newValue) => { - if (!isMounted.value) return - try { - // emit onBeforeRouteMount - if (previousIsPagePending.value.previous && !newValue) { - router.emit({ - type: 'onBeforeRouteMount', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - } - } catch { - // Ignore errors if component is unmounted - } - }) - - Vue.watch(isAnyPending, (newValue) => { - if (!isMounted.value) return - try { - if (!newValue && router.stores.status.get() === 'pending') { - batch(() => { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) - }) - } - - // The router was pending and now it's not - if (previousIsAnyPending.value.previous && !newValue) { - const changeInfo = getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ) - router.emit({ - type: 'onResolved', - ...changeInfo, - }) - } - } catch { - // Ignore errors if component is unmounted + router.emit({ + type: 'onRendered', + ...getLocationChangeInfo(resolvedLocation, resolvedLocation), + }) + } else if (!router._tx) { + router.load().catch(console.error) } }) } -/** - * @deprecated Use useTransitionerSetup() composable instead. - * This component is kept for backwards compatibility but the setup logic - * has been moved to useTransitionerSetup() for better SSR hydration. - */ +/** @deprecated Use useTransitionerSetup() instead. */ export const Transitioner = Vue.defineComponent({ name: 'Transitioner', setup() { diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx index 1f1f8ff973..adfc156c72 100644 --- a/packages/vue-router/src/headContentUtils.tsx +++ b/packages/vue-router/src/headContentUtils.tsx @@ -1,5 +1,6 @@ import * as Vue from 'vue' import { + _getAssetMatches, appendUniqueUserTags, escapeHtml, getAssetCrossOrigin, @@ -15,132 +16,16 @@ import type { export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const router = useRouter() - const matches = useStore(router.stores.matches, (value) => value) + const matches = useStore(router.stores.matches, _getAssetMatches) - const meta = Vue.computed>(() => { - const resultMeta: Array = [] - const metaByAttribute: Record = {} - let title: RouterManagedTag | undefined - ;[...matches.value.map((match) => match.meta!).filter(Boolean)] - .reverse() - .forEach((metas) => { - ;[...metas].reverse().forEach((m) => { - if (!m) return - - 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 innerHTML - 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]) { - return - } else { - metaByAttribute[attribute] = true - } - } - - resultMeta.push({ - tag: 'meta', - attrs: { - ...m, - }, - }) - } - }) - }) - - if (title) { - resultMeta.push(title) - } - - resultMeta.reverse() - - return resultMeta - }) - - const links = Vue.computed>( - () => - matches.value - .map((match) => match.links!) - .filter(Boolean) - .flat(1) - .map((link) => ({ - tag: 'link', - attrs: { - ...link, - }, - })) as Array, - ) - - const preloadMeta = Vue.computed>(() => { - const preloadMeta: Array = [] - - matches.value.forEach((match) => { - router.ssr?.manifest?.routes[match.routeId]?.preloads - ?.filter(Boolean) - .forEach((preload) => { - preloadMeta.push({ - tag: 'link', - attrs: { - ...getScriptPreloadAttrs( - router.ssr?.manifest, - preload, - assetCrossOrigin, - ), - }, - }) - }) - }) - - return preloadMeta - }) - - const headScripts = Vue.computed>(() => - ( - matches.value - .map((match) => match.headScripts!) - .flat(1) - .filter(Boolean) as Array - ).map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, - }, - children, - })), - ) - - const manifestAssets = Vue.computed>(() => { + const tags = Vue.computed>(() => { + const currentMatches = matches.value + const tags: Array = [] const manifest = router.ssr?.manifest - - const assets: Array = [] - - matches.value.forEach((match) => { - const routeManifest = manifest?.routes[match.routeId] - - routeManifest?.css?.forEach((link) => { + for (const match of currentMatches) { + for (const link of manifest?.routes[match.routeId]?.css ?? []) { const resolvedLink = resolveManifestCssLink(link) - assets.push({ + tags.push({ tag: 'link', attrs: { rel: 'stylesheet', @@ -150,11 +35,10 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { resolvedLink.crossOrigin, }, }) - }) - }) - + } + } if (manifest?.inlineStyle) { - assets.push({ + tags.push({ tag: 'style', attrs: manifest.inlineStyle.attrs, children: manifest.inlineStyle.children, @@ -162,16 +46,97 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { }) } - return assets - }) + const resultMeta: Array = [] + const metaByAttribute: Record = {} + let title: RouterManagedTag | undefined + for (let i = currentMatches.length - 1; i >= 0; i--) { + const metas = currentMatches[i]!.meta + if (!metas) { + continue + } + 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) { + // Content is HTML-escaped to prevent XSS when injected via innerHTML. + try { + resultMeta.push({ + tag: 'script', + attrs: { type: 'application/ld+json' }, + children: escapeHtml(JSON.stringify(m['script:ld+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, + }, + }) + } + } + } - return () => { - const tags: Array = [] - tags.push(...manifestAssets.value) - appendUniqueUserTags(tags, meta.value) - tags.push(...preloadMeta.value) - appendUniqueUserTags(tags, links.value) - appendUniqueUserTags(tags, headScripts.value) + if (title) { + resultMeta.push(title) + } + resultMeta.reverse() + appendUniqueUserTags(tags, resultMeta) + + const preloads: Array = [] + const links: Array = [] + const headScripts: Array = [] + for (const match of currentMatches) { + for (const preload of manifest?.routes[match.routeId]?.preloads ?? []) { + if (preload) { + preloads.push({ + tag: 'link', + attrs: { + ...getScriptPreloadAttrs(manifest, preload, assetCrossOrigin), + }, + }) + } + } + for (const link of match.links ?? []) { + if (link) { + links.push({ tag: 'link', attrs: { ...link } }) + } + } + for (const script of match.headScripts ?? []) { + if (script) { + const { children, ...attrs } = script + headScripts.push({ + tag: 'script', + attrs: { ...attrs }, + children: children as string | undefined, + }) + } + } + } + tags.push(...preloads) + appendUniqueUserTags(tags, links) + appendUniqueUserTags(tags, headScripts) return tags - } + }) + return () => tags.value } diff --git a/packages/vue-router/src/lazyRouteComponent.tsx b/packages/vue-router/src/lazyRouteComponent.tsx index 77a2233573..527f3d15ad 100644 --- a/packages/vue-router/src/lazyRouteComponent.tsx +++ b/packages/vue-router/src/lazyRouteComponent.tsx @@ -44,6 +44,7 @@ export function lazyRouteComponent< // Use existing promise or create new one if (!loadPromise) { + error = undefined loadPromise = importer() .then((res) => { loadPromise = undefined diff --git a/packages/vue-router/src/matchContext.tsx b/packages/vue-router/src/matchContext.tsx index 1a8b7fb167..01ed6d35de 100644 --- a/packages/vue-router/src/matchContext.tsx +++ b/packages/vue-router/src/matchContext.tsx @@ -1,39 +1,8 @@ -import * as Vue from 'vue' - -// Reactive nearest-match context used by hooks that work relative to the -// current match in the tree. -export const matchContext = Symbol('TanStackRouterMatch') as Vue.InjectionKey< - Vue.Ref -> - -// Pending match context for nearest-match lookups -export const pendingMatchContext = Symbol( - 'TanStackRouterPendingMatch', -) as Vue.InjectionKey> - -// Dummy pending context when nearest pending state is not needed -export const dummyPendingMatchContext = Symbol( - 'TanStackRouterDummyPendingMatch', -) as Vue.InjectionKey> +import type { InjectionKey } from 'vue' // Stable routeId context — a plain string (not reactive) that identifies // which route this component belongs to. Provided by Match, consumed by // MatchInner, Outlet, and useMatch for routeId-based store lookups. export const routeIdContext = Symbol( 'TanStackRouterRouteId', -) as Vue.InjectionKey - -/** - * Retrieves nearest pending-match state from the component tree - */ -export function injectPendingMatch(): Vue.Ref { - return Vue.inject(pendingMatchContext, Vue.ref(false)) -} - -/** - * Retrieves dummy pending-match state from the component tree - * This only exists so we can conditionally inject a value when we are not interested in the nearest pending match - */ -export function injectDummyPendingMatch(): Vue.Ref { - return Vue.inject(dummyPendingMatchContext, Vue.ref(false)) -} +) as InjectionKey diff --git a/packages/vue-router/src/not-found.tsx b/packages/vue-router/src/not-found.tsx index f676c66e99..f0ebf678ea 100644 --- a/packages/vue-router/src/not-found.tsx +++ b/packages/vue-router/src/not-found.tsx @@ -16,7 +16,7 @@ export function CatchNotFound(props: { router.stores.location, (location) => location.pathname, ) - const status = useStore(router.stores.status, (value) => value) + const status = useStore(router.stores.status) // Create a function that returns a VNode to match the SyncRouteComponent signature const errorComponentFn = (componentProps: ErrorComponentProps) => { diff --git a/packages/vue-router/src/routerStores.ts b/packages/vue-router/src/routerStores.ts index da943fe990..a7aafa0287 100644 --- a/packages/vue-router/src/routerStores.ts +++ b/packages/vue-router/src/routerStores.ts @@ -1,19 +1,9 @@ import { batch, createAtom } from '@tanstack/vue-store' -import type { - AnyRoute, - GetStoreConfig, - RouterStores, -} from '@tanstack/router-core' +import type { GetStoreConfig } from '@tanstack/router-core' import type { Readable } from '@tanstack/vue-store' declare module '@tanstack/router-core' { export interface RouterReadableStore extends Readable {} - export interface RouterStores { - /** Maps each active routeId to the matchId of its child in the match tree. */ - childMatchIdByRouteId: RouterReadableStore> - /** Maps each pending routeId to true for quick lookup. */ - pendingRouteIds: RouterReadableStore> - } } export const getStoreFactory: GetStoreConfig = (_opts) => { @@ -21,34 +11,5 @@ export const getStoreFactory: GetStoreConfig = (_opts) => { createMutableStore: createAtom, createReadonlyStore: createAtom, batch, - init: (stores: RouterStores) => { - // Single derived store: one reactive node that maps every active - // routeId to its child's matchId. Depends only on matchesId + - // the pool's routeId tags (which are set during reconciliation). - // Outlet reads the map and then does a direct pool lookup. - stores.childMatchIdByRouteId = createAtom(() => { - const ids = stores.matchesId.get() - const obj: Record = {} - for (let i = 0; i < ids.length - 1; i++) { - const parentStore = stores.matchStores.get(ids[i]!) - if (parentStore?.routeId) { - obj[parentStore.routeId] = ids[i + 1]! - } - } - return obj - }) - - stores.pendingRouteIds = createAtom(() => { - const ids = stores.pendingIds.get() - const obj: Record = {} - for (const id of ids) { - const store = stores.pendingMatchStores.get(id) - if (store?.routeId) { - obj[store.routeId] = true - } - } - return obj - }) - }, } } diff --git a/packages/vue-router/src/ssr/RouterClient.tsx b/packages/vue-router/src/ssr/RouterClient.tsx index 8ed9401669..b21f0fc9f5 100644 --- a/packages/vue-router/src/ssr/RouterClient.tsx +++ b/packages/vue-router/src/ssr/RouterClient.tsx @@ -4,7 +4,7 @@ import { HeadContent } from '../HeadContent' import { RouterProvider } from '../RouterProvider' import type { AnyRouter } from '@tanstack/router-core' -let hydrationPromise: Promise>> | undefined +let hydrationPromise: Promise | undefined export const RouterClient = Vue.defineComponent({ name: 'RouterClient', @@ -17,25 +17,14 @@ export const RouterClient = Vue.defineComponent({ setup(props) { const isHydrated = Vue.ref(false) - 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()) Vue.onMounted(() => { - hydrationPromise!.then(() => { + return hydrationPromise!.then(() => { isHydrated.value = true }) }) - // For SSR, we're already hydrated - if (typeof window === 'undefined') { - isHydrated.value = true - } - return () => { if (!isHydrated.value) { return null diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index efad4fc2eb..d352c52497 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -112,7 +112,10 @@ export const renderRouterToStream = async ({ } return new Response(`${fullHtml}`, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }) } finally { @@ -211,13 +214,16 @@ export const renderRouterToStream = async ({ const responseStream = transformReadableStreamWithRouter( router, doctypedStream, - { onAbort: abortVuePipe }, + { signal: request.signal, onAbort: abortVuePipe }, ) return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }), ) diff --git a/packages/vue-router/src/ssr/renderRouterToString.tsx b/packages/vue-router/src/ssr/renderRouterToString.tsx index b551dc5dfc..723aa0b91e 100644 --- a/packages/vue-router/src/ssr/renderRouterToString.tsx +++ b/packages/vue-router/src/ssr/renderRouterToString.tsx @@ -24,7 +24,10 @@ export const renderRouterToString = async ({ } return new Response(`${html}`, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }) } catch (error) { diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index 29047e5d48..a02b5ae109 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -2,11 +2,7 @@ import * as Vue from 'vue' import { invariant } from '@tanstack/router-core' import { useStore } from '@tanstack/vue-store' import { isServer } from '@tanstack/router-core/isServer' -import { - injectDummyPendingMatch, - injectPendingMatch, - routeIdContext, -} from './matchContext' +import { routeIdContext } from './matchContext' import { useRouter } from './useRouter' import type { AnyRouter, @@ -83,7 +79,7 @@ export function useMatch< const nearestRouteId = opts.from ? undefined : Vue.inject(routeIdContext) const matchStore = (opts.from ?? nearestRouteId) - ? router.stores.getRouteMatchStore(opts.from ?? nearestRouteId!) + ? router.stores.getMatchStore(opts.from ?? nearestRouteId!) : undefined const match = matchStore?.get() @@ -114,53 +110,37 @@ export function useMatch< > } - const hasPendingNearestMatch = opts.from - ? injectDummyPendingMatch() - : injectPendingMatch() // Set up reactive match value based on lookup strategy. let match: Readonly> + const nearestRouteId = Vue.inject(routeIdContext) if (opts.from) { - // routeId case: single subscription via per-routeId computed store. - // The store reference is stable (cached by routeId). - const matchStore = router.stores.getRouteMatchStore(opts.from) - match = useStore(matchStore, (value) => value) + // routeId case: subscribe to the stable per-route presentation atom. + const matchStore = router.stores.getMatchStore(opts.from) + match = useStore(matchStore) } else { - // matchId case: use routeId from context for stable store lookup. + // Nearest-match case: use the routeId from context for stable lookup. // The routeId is provided by the nearest Match component and doesn't // change for the component's lifetime, so the store is stable. - const nearestRouteId = Vue.inject(routeIdContext) if (nearestRouteId) { - match = useStore( - router.stores.getRouteMatchStore(nearestRouteId), - (value) => value, - ) + match = useStore(router.stores.getMatchStore(nearestRouteId)) } else { // No route context — will fall through to error handling below match = Vue.ref(undefined) as Readonly> } } - const hasPendingRouteMatch = opts.from - ? useStore(router.stores.pendingRouteIds, (ids) => ids) - : undefined - const isTransitioning = useStore( - router.stores.isTransitioning, - (value) => value, - { equal: Object.is }, - ) - - const result = Vue.computed(() => { - const selectedMatch = match.value + const ownsMatch = + nearestRouteId !== undefined && + (opts.from ?? nearestRouteId) === nearestRouteId + let lastOwnedMatch: any + const result = Vue.computed(() => { + if (match.value !== undefined && ownsMatch) { + lastOwnedMatch = match.value + } + const selectedMatch = match.value ?? lastOwnedMatch if (selectedMatch === undefined) { - const hasPendingMatch = opts.from - ? Boolean(hasPendingRouteMatch?.value[opts.from!]) - : hasPendingNearestMatch.value - if ( - !hasPendingMatch && - !isTransitioning.value && - (opts.shouldThrow ?? true) - ) { + if (opts.shouldThrow ?? true) { if (process.env.NODE_ENV !== 'production') { throw new Error( `Invariant failed: Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, diff --git a/packages/vue-router/tests/Scripts.test.tsx b/packages/vue-router/tests/Scripts.test.tsx index 5cd2eacd4f..8d6724076b 100644 --- a/packages/vue-router/tests/Scripts.test.tsx +++ b/packages/vue-router/tests/Scripts.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { cleanup, fireEvent, @@ -7,11 +7,14 @@ import { waitFor, } from '@testing-library/vue' import { Teleport } from 'vue' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' import { HeadContent, Link, Outlet, + RouterContextProvider, RouterProvider, createBrowserHistory, createMemoryHistory, @@ -48,6 +51,7 @@ afterEach(() => { cleanup() browserHistories.splice(0).forEach((history) => history.destroy()) window.history.replaceState(null, 'root', '/') + delete window.$_TSR }) describe('ssr scripts', () => { @@ -148,6 +152,98 @@ describe('ssr scripts', () => { }) describe('ssr HeadContent', () => { + test('renders descendant assets during a data-only hydration handoff', async () => { + const rootRoute = createRootRoute({}) + const dataOnlyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/report', + ssr: 'data-only', + loader: () => 'report', + }) + const childRoute = createRoute({ + getParentRoute: () => dataOnlyRoute, + path: '/details', + loader: () => 'details', + head: () => ({ + meta: [{ name: 'vue-data-only-child', content: 'visible' }], + }), + scripts: () => [ + { + id: 'vue-data-only-body-script', + type: 'application/json', + children: '{"source":"body"}', + }, + ], + }) + const router = createRouter({ + history: createMemoryHistory({ + initialEntries: ['/report/details'], + }), + routeTree: rootRoute.addChildren([ + dataOnlyRoute.addChildren([childRoute]), + ]), + }) + const matches = router.matchRoutes(router.latestLocation) + window.$_TSR = { + router: { + dehydratedData: {}, + manifest: { + routes: { + [childRoute.id]: { + preloads: ['/vue-data-only-manifest.js'], + scripts: [ + { + attrs: { + id: 'vue-data-only-manifest-script', + type: 'application/json', + }, + children: '{"source":"manifest"}', + }, + ], + }, + }, + }, + matches: matches.map((match, index) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success', + ssr: index === 1 ? 'data-only' : true, + l: index ? (index === 1 ? 'report' : 'details') : undefined, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + } + + await hydrate(router) + + expect(router.state.matches.map((match) => match.status)).toEqual([ + 'success', + 'pending', + 'success', + ]) + render( + + + + , + ) + + expect( + document.querySelector('meta[name="vue-data-only-child"]'), + ).not.toBeNull() + expect( + document.querySelector('link[href="/vue-data-only-manifest.js"]'), + ).not.toBeNull() + expect(document.querySelector('#vue-data-only-body-script')).not.toBeNull() + expect( + document.querySelector('#vue-data-only-manifest-script'), + ).not.toBeNull() + }) + test('applies assetCrossOrigin to manifest stylesheets and preloads', async () => { const history = createTestBrowserHistory() diff --git a/packages/vue-router/tests/Transitioner.test.tsx b/packages/vue-router/tests/Transitioner.test.tsx index 6ae0841d14..2422df93d4 100644 --- a/packages/vue-router/tests/Transitioner.test.tsx +++ b/packages/vue-router/tests/Transitioner.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, waitFor } from '@testing-library/vue' import { RouterProvider, + createControlledPromise, createMemoryHistory, createRootRoute, createRoute, @@ -38,4 +39,38 @@ describe('Transitioner', () => { expect(view.getByText('Index')).toBeInTheDocument() }) }) + + it('reuses a public load already in progress when the provider mounts', async () => { + const loaderGate = createControlledPromise() + const beforeLoad = vi.fn() + const loader = vi.fn(() => loaderGate) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad, + loader, + component: () =>
Index
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const load = router.load() + try { + await waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + + const view = render() + loaderGate.resolve() + await load + + expect(await view.findByText('Index')).toBeInTheDocument() + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(loader).toHaveBeenCalledTimes(1) + } finally { + loaderGate.resolve() + await Promise.allSettled([load]) + } + }) }) diff --git a/packages/vue-router/tests/component-preload-retry.test.tsx b/packages/vue-router/tests/component-preload-retry.test.tsx new file mode 100644 index 0000000000..9fb8ad5f5a --- /dev/null +++ b/packages/vue-router/tests/component-preload-retry.test.tsx @@ -0,0 +1,65 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, + useRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +test('a failed component download is retried from the route error UI', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(new Error('component download failed')) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + function RouteError(props: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RouteError, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + const retryButton = await screen.findByRole('button', { name: 'Retry' }) + expect(importer).toHaveBeenCalledTimes(1) + + await fireEvent.click(retryButton) + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) +}) diff --git a/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx new file mode 100644 index 0000000000..e8e0db4b67 --- /dev/null +++ b/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx @@ -0,0 +1,153 @@ +import * as Vue from 'vue' +import { renderToString } from 'vue/server-renderer' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { hydrate as hydrateRouter } from '@tanstack/router-core/ssr/client' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + notFound, +} from '../src' +import { dehydrateToBootstrap } from './ssr-test-utils' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +declare global { + interface Window { + $_TSR?: TsrSsrGlobal + } +} + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + vi.restoreAllMocks() + window.$_TSR = undefined + document.body.innerHTML = '' +}) + +describe('hydrating a server-capped boundary lane', () => { + test.each([ + ['error', 'parent'], + ['notFound', 'parent'], + ['error', 'root'], + ['notFound', 'root'], + ] as const)( + 'keeps the server-rendered %s %s boundary visible through hydration', + async (outcome, boundary) => { + const childLoader = vi.fn(() => 'child data') + const makeRouteTree = () => { + const boundaryOptions = { + beforeLoad: () => { + throw outcome === 'notFound' + ? notFound() + : new Error('server route failure') + }, + pendingComponent: () => ( +
Boundary pending
+ ), + errorComponent: () => ( +
Boundary error
+ ), + notFoundComponent: () => ( +
Boundary not found
+ ), + } + const rootRoute = createRootRoute({ + component: Outlet, + ...(boundary === 'root' ? boundaryOptions : {}), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: Outlet, + ...(boundary === 'parent' ? boundaryOptions : {}), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + component: () =>
Child
, + }) + return rootRoute.addChildren([parentRoute.addChildren([childRoute])]) + } + + const serverRouter = createRouter({ + routeTree: makeRouteTree(), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + serverRouter.isServer = true + testCleanups.push(() => serverRouter.serverSsr?.cleanup()) + window.$_TSR = await dehydrateToBootstrap(serverRouter) + + const serverMatches = serverRouter.stores.matches.get() + expect(serverMatches).toHaveLength(3) + const serverBoundary = serverMatches[boundary === 'root' ? 0 : 1]! + if (outcome === 'notFound' && boundary === 'root') { + expect(serverBoundary).toMatchObject({ + status: 'success', + _notFound: true, + }) + } else { + expect(serverBoundary.status).toBe(outcome) + } + + const serverApp = Vue.createSSRApp( + Vue.defineComponent({ + setup: () => () => , + }), + ) + const serverHtml = await renderToString(serverApp) + const expectedBoundary = + outcome === 'notFound' ? 'Boundary not found' : 'Boundary error' + expect(serverHtml).toContain(expectedBoundary) + + const clientRouter = createRouter({ + routeTree: makeRouteTree(), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + + await hydrateRouter(clientRouter) + + const container = document.createElement('div') + container.innerHTML = serverHtml + document.body.appendChild(container) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const clientApp = Vue.createSSRApp( + Vue.defineComponent({ + setup: () => () => , + }), + ) + let clientAppMounted = false + testCleanups.push(() => { + if (clientAppMounted) { + clientApp.unmount() + } + container.remove() + }) + + clientApp.mount(container) + clientAppMounted = true + await Vue.nextTick() + + expect(container).toHaveTextContent(expectedBoundary) + expect(container).not.toHaveTextContent('Boundary pending') + expect( + [consoleError.mock.calls, consoleWarn.mock.calls].flat(2).join(' '), + ).not.toMatch(/hydration|mismatch/i) + expect(childLoader).not.toHaveBeenCalled() + }, + ) +}) diff --git a/packages/vue-router/tests/loaders.test.tsx b/packages/vue-router/tests/loaders.test.tsx index edc3b2a9bd..99ef9ef5d1 100644 --- a/packages/vue-router/tests/loaders.test.tsx +++ b/packages/vue-router/tests/loaders.test.tsx @@ -282,6 +282,44 @@ test('throw error from loader upon initial load', async () => { expect(errorElement).toBeInTheDocument() }) +// https://github.com/TanStack/router/pull/7673 +test('#7673: aborted loader does not render the route component with undefined loaderData', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + const routeComponentRendered = vi.fn() + const renderedError = vi.fn() + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: (): Promise<{ value: string }> => Promise.reject(abortError), + component: () => { + routeComponentRendered() + const data = indexRoute.useLoaderData() + return
{data.value.value}
+ }, + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + }) + + render() + + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() + expect(routeComponentRendered).not.toHaveBeenCalled() + expect(renderedError).toHaveBeenCalledWith(abortError) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) +}) + test('throw error from beforeLoad when navigating to route', async () => { const rootRoute = createRootRoute({}) diff --git a/packages/vue-router/tests/redirect.test.tsx b/packages/vue-router/tests/redirect.test.tsx index 0d7c2c92ef..e26fe9a372 100644 --- a/packages/vue-router/tests/redirect.test.tsx +++ b/packages/vue-router/tests/redirect.test.tsx @@ -302,108 +302,4 @@ describe('redirect', () => { expect(window.location.pathname).toBe('/final') }) }) - - describe('SSR', () => { - test('when `redirect` is thrown in `beforeLoad`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - beforeLoad: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return <>About - }, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - history: createMemoryHistory({ - initialEntries: ['/'], - }), - }) - - await router.load() - - expect(router.state.redirect).toBeDefined() - expect(router.state.redirect).toBeInstanceOf(Response) - const redirectResponse = router.state.redirect! - - expect(redirectResponse.options).toEqual({ - _fromLocation: expect.objectContaining({ - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - }), - to: '/about', - href: '/about', - statusCode: 307, - }) - }) - - test('when `redirect` is thrown in `loader`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - loader: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return <>About - }, - }) - - const router = createRouter({ - history: createMemoryHistory({ - initialEntries: ['/'], - }), - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - }) - - await router.load() - - const currentRedirect = router.state.redirect - - expect(currentRedirect).toBeDefined() - expect(currentRedirect).toBeInstanceOf(Response) - const redirectResponse = currentRedirect! - - expect(redirectResponse.options).toEqual({ - _fromLocation: expect.objectContaining({ - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - }), - to: '/about', - href: '/about', - statusCode: 307, - }) - }) - }) }) diff --git a/packages/vue-router/tests/router-client-stream-cleanup.test.tsx b/packages/vue-router/tests/router-client-stream-cleanup.test.tsx new file mode 100644 index 0000000000..071df9cfce --- /dev/null +++ b/packages/vue-router/tests/router-client-stream-cleanup.test.tsx @@ -0,0 +1,46 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/vue' +import { createMemoryHistory } from '@tanstack/history' +import * as Vue from 'vue' +import { RouterClient } from '../src/ssr/RouterClient' +import { createRootRoute, createRouter } from '../src' + +const hydrate = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/router-core/ssr/client', () => ({ hydrate })) + +afterEach(() => { + cleanup() + delete window.$_TSR + hydrate.mockReset() +}) + +test('RouterClient signals streaming cleanup without hiding a hydration failure', async () => { + const error = new Error('hydration failed') + hydrate.mockRejectedValue(error) + const rootRoute = createRootRoute({ component: () =>
Ready
}) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const hydrated = vi.fn() + window.$_TSR = { h: hydrated } as any + + const Boundary = Vue.defineComponent({ + setup(_, { slots }) { + const caught = Vue.ref() + Vue.onErrorCaptured((value) => { + caught.value = value + return false + }) + return () => caught.value?.message ?? slots.default?.() + }, + }) + + render(Boundary, { + slots: { default: () => Vue.h(RouterClient, { router }) }, + }) + + await waitFor(() => expect(hydrated).toHaveBeenCalledTimes(1)) + expect(await screen.findByText(error.message)).toBeInTheDocument() +}) diff --git a/packages/vue-router/tests/ssr-test-utils.ts b/packages/vue-router/tests/ssr-test-utils.ts new file mode 100644 index 0000000000..7d8c5c80f3 --- /dev/null +++ b/packages/vue-router/tests/ssr-test-utils.ts @@ -0,0 +1,43 @@ +import { runInNewContext } from 'node:vm' +import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' +import type { AnyRouter } from '@tanstack/router-core' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +export async function dehydrateToBootstrap( + router: AnyRouter, +): Promise { + try { + attachRouterServerSsrUtils({ router, manifest: { routes: {} } }) + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + if (typeof script?.children !== 'string') { + throw new Error( + 'Expected server dehydration to produce a bootstrap script', + ) + } + + const context: { + document: { currentScript: { remove: () => void } } + self?: unknown + $_TSR?: TsrSsrGlobal + } = { + document: { + currentScript: { + remove() {}, + }, + }, + } + context.self = context + runInNewContext(script.children, context) + + if (!context.$_TSR) { + throw new Error('Expected bootstrap script to initialize $_TSR') + } + return context.$_TSR + } catch (error) { + router.serverSsr?.cleanup() + throw error + } +} diff --git a/packages/vue-router/tests/store-updates-during-navigation.test.tsx b/packages/vue-router/tests/store-updates-during-navigation.test.tsx index 1c40bf7be7..8ad9a4b423 100644 --- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx @@ -138,7 +138,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(16) + expect(updates).toBe(6) }) test('redirection in preload', async () => { @@ -157,7 +157,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(5) + expect(updates).toBe(2) }) test('sync beforeLoad', async () => { @@ -174,7 +174,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(12) + expect(updates).toBe(4) }) test('nothing', async () => { @@ -186,7 +186,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(6) + expect(updates).toBe(2) }) test('not found in beforeLoad', async () => { @@ -202,7 +202,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(9) + expect(updates).toBe(2) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -229,7 +229,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(17) + expect(updates).toBe(3) }) test('navigate, w/ preloaded & async loaders', async () => { @@ -246,7 +246,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(10) + expect(updates).toBe(2) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -263,7 +263,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(6) + expect(updates).toBe(2) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -280,7 +280,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(6) + expect(updates).toBe(2) }) test('preload a preloaded route w/ async loader', async () => { @@ -299,6 +299,6 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(2) + expect(updates).toBe(0) }) }) diff --git a/packages/vue-router/tests/transitioner-listener-errors.test.tsx b/packages/vue-router/tests/transitioner-listener-errors.test.tsx new file mode 100644 index 0000000000..c53a0e02f5 --- /dev/null +++ b/packages/vue-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,76 @@ +import { cleanup, render, screen } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('a throwing load-event listener cannot interrupt later listeners, route hooks, or navigations', async () => { + const firstOnEnter = vi.fn() + const secondOnEnter = vi.fn() + const listenerError = new Error('onLoad listener failed') + const throwingOnLoad = vi.fn() + const laterOnLoad = vi.fn() + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: firstOnEnter, + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: secondOnEnter, + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index route')).toBeTruthy() + + const unsubscribeThrowing = router.subscribe('onLoad', (event) => { + throwingOnLoad(event.toLocation.pathname) + if (event.toLocation.pathname === '/first') { + throw listenerError + } + }) + const unsubscribeLater = router.subscribe('onLoad', (event) => { + laterOnLoad(event.toLocation.pathname) + }) + + try { + await router.navigate({ to: '/first' }) + expect(await screen.findByText('First route')).toBeTruthy() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + expect(throwingOnLoad.mock.calls).toEqual([['/first']]) + expect(laterOnLoad.mock.calls).toEqual([['/first']]) + + await router.navigate({ to: '/second' }) + expect(await screen.findByText('Second route')).toBeTruthy() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + expect(secondOnEnter).toHaveBeenCalledTimes(1) + expect(throwingOnLoad.mock.calls).toEqual([['/first'], ['/second']]) + expect(laterOnLoad.mock.calls).toEqual([['/first'], ['/second']]) + } finally { + unsubscribeThrowing() + unsubscribeLater() + } +}) diff --git a/packages/vue-router/tests/transitioner-remount-rendered.test.tsx b/packages/vue-router/tests/transitioner-remount-rendered.test.tsx index 179a7434f7..e39aaeee06 100644 --- a/packages/vue-router/tests/transitioner-remount-rendered.test.tsx +++ b/packages/vue-router/tests/transitioner-remount-rendered.test.tsx @@ -31,11 +31,11 @@ function setup() { routeTree: rootRoute.addChildren([indexRoute, nextRoute]), history, }) - return router + return { history, router } } test('onRendered runs after the destination DOM has committed', async () => { - const router = setup() + const { router } = setup() const initialRendered = vi.fn() const unsubscribeInitial = router.subscribe('onRendered', initialRendered) render() @@ -54,3 +54,28 @@ test('onRendered runs after the destination DOM has committed', async () => { unsubscribe() }) + +test('onRendered fires for a same-href navigation with a new history key', async () => { + const { router } = setup() + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + render() + expect(await screen.findByText('Index')).toBeTruthy() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + onRendered.mockClear() + + await router.navigate({ + to: '/', + state: { sameHrefState: true } as any, + }) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + const event = onRendered.mock.calls[0]![0] + expect(event.fromLocation?.state.sameHrefState).toBeUndefined() + expect(event.toLocation.state.sameHrefState).toBe(true) + expect(event.fromLocation?.href).toBe('/') + expect(event.toLocation.href).toBe('/') + expect(event.hrefChanged).toBe(false) + + unsubscribe() +}) diff --git a/packages/vue-router/tests/useMatch.test.tsx b/packages/vue-router/tests/useMatch.test.tsx index 3a802798d2..7a73cdbea7 100644 --- a/packages/vue-router/tests/useMatch.test.tsx +++ b/packages/vue-router/tests/useMatch.test.tsx @@ -235,6 +235,44 @@ describe('useMatch', () => { } }) + test('an outgoing component never observes its own match disappear', async () => { + const observedRouteIds: Array = [] + const rootRoute = createRootRoute({ component: () => }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: Vue.defineComponent({ + setup() { + const match = useMatch({ + from: '/first', + shouldThrow: false, + }) + Vue.watchEffect(() => observedRouteIds.push(match.value?.routeId), { + flush: 'sync', + }) + return () =>
First route
+ }, + }), + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + render() + expect(await screen.findByText('First route')).toBeInTheDocument() + + await router.navigate({ to: '/next' }) + + expect(await screen.findByText('Next route')).toBeInTheDocument() + expect(observedRouteIds).not.toContain(undefined) + }) + describe('when match is not found', () => { test.each([undefined, true])( 'throws if shouldThrow = %s', diff --git a/packages/vue-start-client/src/hydrateStart.test.ts b/packages/vue-start-client/src/hydrateStart.test.ts new file mode 100644 index 0000000000..c338122c6c --- /dev/null +++ b/packages/vue-start-client/src/hydrateStart.test.ts @@ -0,0 +1,23 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { hydrateStart } from './hydrateStart' + +const coreHydrateStart = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/start-client-core/client', () => ({ + hydrateStart: coreHydrateStart, +})) + +afterEach(() => { + delete window.$_TSR + coreHydrateStart.mockReset() +}) + +test('signals streaming cleanup without hiding a hydration failure', async () => { + const error = new Error('hydration failed') + coreHydrateStart.mockRejectedValue(error) + const hydrated = vi.fn() + window.$_TSR = { h: hydrated } as any + + await expect(hydrateStart()).rejects.toBe(error) + expect(hydrated).toHaveBeenCalledTimes(1) +}) diff --git a/packages/vue-start-client/src/hydrateStart.ts b/packages/vue-start-client/src/hydrateStart.ts index b9e4860b57..7a90e6d27c 100644 --- a/packages/vue-start-client/src/hydrateStart.ts +++ b/packages/vue-start-client/src/hydrateStart.ts @@ -89,9 +89,15 @@ export function configureHydrationSuppressions( * suppression for routes with ssr: false or ssr: 'data-only' before returning */ export async function hydrateStart(): Promise { - const router = await coreHydrateStart() - // Install console suppression before app.mount() is called - // This catches the "Hydration completed but contains mismatches" error - suppressSsrHydrationMismatches(router) - return router + try { + const router = await coreHydrateStart() + // Install console suppression before app.mount() is called + // This catches the "Hydration completed but contains mismatches" error + suppressSsrHydrationMismatches(router) + return router + } catch (error) { + // A failed router hydration never mounts StartClient, so release the stream here. + window.$_TSR?.h() + throw error + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f792350f4f..348e23ca7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1457,6 +1457,77 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-router/issue-7120: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + + e2e/react-router/issue-7457: + dependencies: + '@tanstack/react-query': + specifier: ^5.99.0 + version: 5.99.0(react@19.2.3) + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-router/view-transitions: dependencies: '@tailwindcss/vite': @@ -13256,6 +13327,9 @@ importers: specifier: ^5.1.22 version: 5.1.28 devDependencies: + '@tanstack/react-query': + specifier: ^5.99.0 + version: 5.99.0(react@19.2.3) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -13590,6 +13664,9 @@ importers: specifier: ^2.1.16 version: 2.1.16(csstype@3.2.3) devDependencies: + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 solid-js: specifier: 1.9.12 version: 1.9.12