From b6f3d1c5450e162366fb1ba6bd7b01034877c8a5 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 31 May 2026 11:27:03 +0200 Subject: [PATCH] feat: add route lifecycle result serialization --- PLAN.md | 174 ++ docs/router/guide/data-loading.md | 7 +- docs/router/guide/router-context.md | 31 +- docs/start/config.json | 8 + .../guide/lifecycle-result-serialization.md | 559 ++++ .../guide/lifecycle-result-serialization.md | 9 + .../basic-file-based/src/routeTree.gen.ts | 4 +- .../router-lifecycle-methods/.gitignore | 17 + .../router-lifecycle-methods/package.json | 37 + .../playwright.config.ts | 39 + .../src/routeTree.gen.ts | 426 +++ .../router-lifecycle-methods/src/router.tsx | 11 + .../src/routes/__root.tsx | 142 + .../src/routes/dehydrate-all-false.tsx | 47 + .../src/routes/dehydrate-all-true.tsx | 46 + .../src/routes/dehydrate-beforeload-false.tsx | 41 + .../src/routes/dehydrate-context-true.tsx | 41 + .../src/routes/dehydrate-defaults.tsx | 51 + .../src/routes/dehydrate-fn.tsx | 87 + .../src/routes/dehydrate-loader-false.tsx | 41 + .../src/routes/dehydrate-mixed.tsx | 50 + .../src/routes/dehydrate-partial.tsx | 159 ++ .../src/routes/index.tsx | 27 + .../src/routes/posts.$postId.comments.tsx | 37 + .../src/routes/posts.$postId.tsx | 34 + .../src/routes/posts.index.tsx | 13 + .../src/routes/posts.tsx | 42 + .../src/routes/revalidate-context-fn.tsx | 67 + .../src/routes/revalidate-context.tsx | 50 + .../src/routes/stale-revalidate.tsx | 68 + .../router-lifecycle-methods/src/start.ts | 36 + .../src/styles/app.css | 36 + .../src/utils/posts.ts | 36 + .../tests/app.spec.ts | 1082 ++++++++ .../tests/utils/dehydrateDefaults.ts | 47 + .../router-lifecycle-methods/tsconfig.json | 22 + .../router-lifecycle-methods/vite.config.ts | 17 + .../src/routeTree.gen.ts | 64 +- .../src/routes/search/searchPlaceholder.tsx | 2 +- .../src/routeTree.gen.ts | 64 +- packages/react-router/src/fileRoute.ts | 35 +- packages/react-router/src/index.tsx | 4 +- packages/react-router/src/route.tsx | 110 +- .../tests/errorComponent.test.tsx | 247 ++ .../react-router/tests/fileRoute.test-d.tsx | 175 +- packages/react-router/tests/redirect.test.tsx | 240 ++ packages/react-router/tests/route.test-d.tsx | 1448 ++++++++++- .../react-router/tests/routeContext.test.tsx | 2296 ++++++++++++++++- .../tests/useRouteContext.test-d.tsx | 337 +++ packages/router-core/src/Matches.ts | 2 + packages/router-core/src/config.ts | 27 +- packages/router-core/src/fileRoute.ts | 85 +- packages/router-core/src/index.ts | 20 +- packages/router-core/src/lifecycle.ts | 182 ++ packages/router-core/src/load-matches.ts | 158 +- packages/router-core/src/route.ts | 958 +++++-- packages/router-core/src/routeInfo.ts | 24 +- packages/router-core/src/router.ts | 48 +- .../src/ssr/serializer/transformer.ts | 9 +- packages/router-core/src/ssr/ssr-client.ts | 218 +- packages/router-core/src/ssr/ssr-server.ts | 83 +- packages/router-core/src/ssr/types.ts | 2 + packages/router-core/tests/hydrate.test.ts | 1211 ++++++++- packages/router-core/tests/lifecycle.test.ts | 210 ++ packages/router-core/tests/load.test.ts | 898 ++++++- .../src/BaseTanStackRouterDevtoolsPanel.tsx | 2 +- .../src/core/code-splitter/compilers.ts | 244 +- packages/router-plugin/src/core/config.ts | 33 +- packages/router-plugin/src/index.ts | 4 + .../router-plugin/tests/delete-nodes.test.ts | 178 +- packages/solid-router/src/fileRoute.ts | 35 +- packages/solid-router/src/index.tsx | 4 +- packages/solid-router/src/route.tsx | 129 +- .../tests/errorComponent.test.tsx | 247 ++ .../solid-router/tests/fileRoute.test-d.tsx | 74 +- packages/solid-router/tests/redirect.test.tsx | 240 ++ packages/solid-router/tests/route.test-d.tsx | 849 +++++- .../solid-router/tests/routeContext.test.tsx | 1478 ++++++++++- .../tests/useRouteContext.test-d.tsx | 351 +++ .../src/client/hydrateStart.ts | 1 + .../start-client-core/src/createMiddleware.ts | 4 +- packages/start-client-core/src/createStart.ts | 24 +- packages/start-client-core/src/serverRoute.ts | 18 +- .../src/rsbuild/start-router-plugin.ts | 12 +- .../route-option-delete-nodes.ts | 59 + .../src/vite/start-router-plugin/plugin.ts | 11 +- .../tests/route-option-delete-nodes.test.ts | 68 + .../src/createStartHandler.ts | 1 + packages/vue-router/src/fileRoute.ts | 35 +- packages/vue-router/src/index.tsx | 4 +- packages/vue-router/src/route.ts | 123 +- .../vue-router/tests/errorComponent.test.tsx | 247 ++ packages/vue-router/tests/redirect.test.tsx | 240 ++ packages/vue-router/tests/route.test-d.tsx | 825 +++++- .../vue-router/tests/routeContext.test.tsx | 1522 ++++++++++- .../tests/useRouteContext.test-d.tsx | 351 +++ pnpm-lock.yaml | 69 + 97 files changed, 19507 insertions(+), 773 deletions(-) create mode 100644 PLAN.md create mode 100644 docs/start/framework/react/guide/lifecycle-result-serialization.md create mode 100644 docs/start/framework/solid/guide/lifecycle-result-serialization.md create mode 100644 e2e/react-start/router-lifecycle-methods/.gitignore create mode 100644 e2e/react-start/router-lifecycle-methods/package.json create mode 100644 e2e/react-start/router-lifecycle-methods/playwright.config.ts create mode 100644 e2e/react-start/router-lifecycle-methods/src/routeTree.gen.ts create mode 100644 e2e/react-start/router-lifecycle-methods/src/router.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/__root.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-false.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-true.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-beforeload-false.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-context-true.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-defaults.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-fn.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-loader-false.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-mixed.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-partial.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/index.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/posts.$postId.comments.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/posts.$postId.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/posts.index.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/posts.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/revalidate-context-fn.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/revalidate-context.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/routes/stale-revalidate.tsx create mode 100644 e2e/react-start/router-lifecycle-methods/src/start.ts create mode 100644 e2e/react-start/router-lifecycle-methods/src/styles/app.css create mode 100644 e2e/react-start/router-lifecycle-methods/src/utils/posts.ts create mode 100644 e2e/react-start/router-lifecycle-methods/tests/app.spec.ts create mode 100644 e2e/react-start/router-lifecycle-methods/tests/utils/dehydrateDefaults.ts create mode 100644 e2e/react-start/router-lifecycle-methods/tsconfig.json create mode 100644 e2e/react-start/router-lifecycle-methods/vite.config.ts create mode 100644 packages/router-core/src/lifecycle.ts create mode 100644 packages/router-core/tests/lifecycle.test.ts create mode 100644 packages/start-plugin-core/src/start-router-plugin/route-option-delete-nodes.ts create mode 100644 packages/start-plugin-core/tests/route-option-delete-nodes.test.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000000..607759af0ad --- /dev/null +++ b/PLAN.md @@ -0,0 +1,174 @@ +# Router Lifecycle API Rewrite Status + +This branch replaces the old route lifecycle `serialize` / `invalidate` API with a unified lifecycle object API for `context`, `beforeLoad`, and `loader`. + +Hard break: there are no compatibility aliases for `serialize` or `invalidate`. + +## Current API + +Each route lifecycle can use the existing function form or the new object form. + +```ts +type LifecycleMethod = 'context' | 'beforeLoad' | 'loader' + +type DehydrateOption = + | undefined + | false + | true + | ((ctx: { data: TValue }) => TWire) + +type HydrateOption = (ctx: { data: TWire }) => TValue +``` + +### Function form + +Function form is still the simplest option. It uses the effective dehydration default for that lifecycle method. + +```ts +context: (ctx) => value +beforeLoad: (ctx) => value +loader: (ctx) => value +``` + +### Object form + +Object form carries the lifecycle handler plus lifecycle-specific behavior. + +```ts +context: { + handler: (ctx) => value, + revalidate?: boolean | ((ctx: ContextFnOptions & { prev: value | undefined }) => value), + dehydrate?: boolean | ((ctx: { data: value }) => wire), + hydrate?: (ctx: { data: wire }) => value, +} + +beforeLoad: { + handler: (ctx) => value, + dehydrate?: boolean | ((ctx: { data: value }) => wire), + hydrate?: (ctx: { data: wire }) => value, +} + +loader: { + handler: (ctx) => value, + staleReloadMode?: 'background' | 'blocking', + dehydrate?: boolean | ((ctx: { data: value }) => wire), + hydrate?: (ctx: { data: wire }) => value, +} +``` + +Rules: + +- If object form is used, `handler` is required. +- `revalidate` is only supported on `context`. +- `revalidate: true` re-runs the `handler` when context is invalid or stale. +- `revalidate: fn` runs that function instead of `handler` when context is invalid or stale and receives `prev`. +- `dehydrate: true` stores the lifecycle result in the SSR payload and requires that result to be serializable. +- `dehydrate: false` omits the lifecycle result from the SSR payload; the client re-runs the lifecycle during hydration. +- `dehydrate: fn` stores the function return value in the SSR payload and requires that wire value to be serializable. +- If `dehydrate` is a function, `hydrate` is required and receives the exact wire type returned by `dehydrate`. +- Both `dehydrate` and `hydrate` use object arguments: `{ data }`. + +## Effective Dehydration Defaults + +Built-in defaults mirror the old Start behavior: + +- `context`: `dehydrate: false` +- `beforeLoad`: `dehydrate: true` +- `loader`: `dehydrate: true` + +Priority: + +1. Method-level route option +2. Start/router-level `defaultDehydrate` +3. Built-in default + +Start config example: + +```ts +import { createStart } from '@tanstack/react-start' + +export const startInstance = createStart(() => ({ + defaultDehydrate: { + context: false, + beforeLoad: true, + loader: true, + }, +})) +``` + +## Execution Semantics + +On a route load, route matches execute parent-to-child: + +1. `context` +2. `beforeLoad` +3. `loader` + +`context` and `beforeLoad` run serially for each matched route so child routes can see parent route context. After that serial phase, eligible loaders run with the accumulated context. + +During SSR hydration: + +1. The server runs the matched route lifecycle methods. +2. The server dehydrates lifecycle results according to the effective `dehydrate` configuration. +3. The client restores dehydrated values. +4. Any lifecycle method that was not dehydrated re-runs on the client during hydration. +5. Route `head`, `scripts`, and rendering continue with the reconstructed match state. + +`dehydrate` only controls the SSR-to-client hydration payload. It does not make a lifecycle server-only. Lifecycle methods remain isomorphic and can still run on the client during client-side navigation unless your route/app design avoids that path. + +## Implementation Status + +Completed: + +- Replaced `serialize` / `invalidate` lifecycle options with `handler`, `revalidate`, `dehydrate`, and `hydrate`. +- Added `packages/router-core/src/lifecycle.ts` helpers for function/object lifecycle forms. +- Added built-in lifecycle dehydration defaults and router-level `defaultDehydrate` config. +- Added route-level type inference for function form, object form, `dehydrate`, `hydrate`, and `revalidate`. +- Ensured `hydrate` input is inferred from the return type of `dehydrate`. +- Ensured route-level `dehydrate` return values are checked with `Constrain<..., ValidateSerializableInput<...>>`. +- Ensured `revalidate` returns the same data shape as the `context` handler and receives typed `prev`. +- Added object-form generic propagation through React, Solid, Vue, file routes, and Start server routes. +- Updated SSR server dehydration and client hydration for `{ data }` dehydrate/hydrate arguments. +- Re-runs non-dehydrated lifecycle methods during client hydration. +- Handles `notFound` during client hydration lifecycle re-execution. +- Added unit/type coverage for object forms, serializability, inferred hydrate input, and framework wrappers. +- Reworked `e2e/react-start/router-lifecycle-methods` around `dehydrate`, `hydrate`, `revalidate`, defaults, and partial hydration. + +Implementation notes: + +- `context` revalidation uses route `staleTime` / `preloadStaleTime` only when the route opts in with `revalidate`. +- Context staleness defaults to `Infinity` when no route/router stale time is configured, unlike loader stale behavior. +- Context revalidation updates `__routeContext` and the merged match context, but does not update `match.updatedAt`; loader completion owns `updatedAt` so context-only revalidation does not accidentally suppress loader stale checks. +- `beforeLoad` and `loader` do not have a `revalidate` option. Loader reloading remains controlled by the existing loader cache/staleness APIs. + +## E2E Fixture Coverage + +`e2e/react-start/router-lifecycle-methods` currently covers: + +- Function-form defaults for `context`, `beforeLoad`, and `loader`. +- Method-level `dehydrate: true` and `dehydrate: false`. +- Router-level `defaultDehydrate` overrides from `src/start.ts`. +- Built-in defaults: context omitted, beforeLoad/loader included. +- Custom `dehydrate` / `hydrate` round trips for `Date` values. +- Partial dehydration that strips non-serializable values and reconstructs `Date`, `RegExp`, and functions on hydrate. +- `context` revalidation with `revalidate: true`. +- Functional `context` revalidation with typed `prev`. +- Stale-time-triggered context revalidation. +- SSR, client navigation, and post-hydration round trips. + +## Verification Status + +Recently passing before the current documentation work: + +- `pnpm build` +- `pnpm test:unit` +- `pnpm test:types` + +The full `pnpm test:e2e` sweep was started after Playwright was installed. It exposed unrelated/generated e2e noise and at least one Prisma migration failure in an auth fixture. Per current direction, those failing e2e tests are being ignored for now while lifecycle documentation is added. + +## Documentation Status + +Completed: + +- Replaced the obsolete Start lifecycle serialization guide with a lifecycle methods guide covering `context`, `beforeLoad`, `loader`, execution order, caching, revalidation, defaults, dehydration, hydration, and partial dehydration. +- Kept Solid docs as a framework reference to the React Start guide with package-name replacements. diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index 2ecdf443add..ffeac26a232 100644 --- a/docs/router/guide/data-loading.md +++ b/docs/router/guide/data-loading.md @@ -16,7 +16,8 @@ Every time a URL/history update is detected, the router executes the following s - Route Matching (Top-Down) - `route.params.parse` - `route.validateSearch` -- Route Pre-Loading (Serial) +- Route Pre-Loading (Serial, Parent → Child) + - `route.context` - `route.beforeLoad` - `route.onError` - `route.errorComponent` / `parentRoute.errorComponent` / `router.defaultErrorComponent` @@ -88,7 +89,7 @@ The `loader` function receives a single object with the following properties: - `stay` - When the route is matched and loaded after being matched in the previous location. - `context` - The route's context object, which is a merged union of: - Parent route context - - This route's context as provided by the `beforeLoad` option + - This route's context as provided by `route.context` and `route.beforeLoad` - `deps` - The object value returned from the `Route.loaderDeps` function. If `Route.loaderDeps` is not defined, an empty object will be provided instead. - `location` - The current location - `params` - The route's path params @@ -324,7 +325,7 @@ This will ensure that every preload, load, and reload event will trigger your `l The `context` argument passed to the `loader` function is an object containing a merged union of: - Parent route context -- This route's context as provided by the `beforeLoad` option +- This route's context as provided by `route.context` and `route.beforeLoad` Starting at the very top of the router, you can pass an initial context to the router via the `context` option. This context will be available to all routes in the router and get copied and extended by each route as they are matched. This happens by passing a context to a route via the `beforeLoad` option. This context will be available to all the route's child routes. The resulting context will be available to the route's `loader` function. diff --git a/docs/router/guide/router-context.md b/docs/router/guide/router-context.md index 5fc4001010f..d57d40f12af 100644 --- a/docs/router/guide/router-context.md +++ b/docs/router/guide/router-context.md @@ -15,7 +15,7 @@ These are just suggested uses of the router context. You can use it for whatever ## Typed Router Context -Like everything else, the root router context is strictly typed. This type can be augmented via any route's `beforeLoad` option as it is merged down the route match tree. To constrain the type of the root router context, you must use the `createRootRouteWithContext()(routeOptions)` function to create a new router context instead of the `createRootRoute()` function to create your root route. Here's an example: +Like everything else, the root router context is strictly typed. This type can be augmented via any route's `context` and `beforeLoad` options as it is merged down the route match tree. To constrain the type of the root router context, you must use the `createRootRouteWithContext()(routeOptions)` function to create a new router context instead of the `createRootRoute()` function to create your root route. Here's an example: @@ -76,7 +76,7 @@ const router = createRouter({ > [!TIP] -> `MyRouterContext` only needs to contain content that will be passed directly to `createRouter` below. All other context added in `beforeLoad` will be inferred. +> `MyRouterContext` only needs to contain content that will be passed directly to `createRouter` below. All other context added in `context` and `beforeLoad` will be inferred. ## Passing the initial Router Context @@ -125,7 +125,9 @@ const router = createRouter({ ### Invalidating the Router Context -If you need to invalidate the context state you are passing into the router, you can call the `invalidate` method to tell the router to recompute the context. This is useful when you need to update the context state and have the router recompute the context for all routes. +If the data you pass to the router context changes (e.g. auth state), update the router context you provide to `` and call `router.invalidate()` to re-run route lifecycles (eg. `beforeLoad`/`loader`) with the new context. + +By default, a route's `context` handler runs for new matches, but does not automatically re-run on `router.invalidate()`. If you want a route's `context` handler to re-run on invalidation, use the object form and set `invalidate: true`. @@ -379,7 +381,14 @@ export const Route = createFileRoute('/posts')({ ## Modifying the Router Context -The router context is passed down the route tree and is merged at each route. This means that you can modify the context at each route and the modifications will be available to all child routes. Here's an example: +The router context is passed down the route tree and is merged at each route. This means that you can add to the context at each route and the additions will be available to all child routes. + +Context is built serially from parent to child: + +- `route.context` runs first (serial, parent to child) +- `route.beforeLoad` runs next (serial, parent to child) + +Both can return an object that is merged into the match context. @@ -425,14 +434,16 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/todos')({ component: Todos, - beforeLoad: () => { - return { - bar: true, - } - }, + context: () => ({ + bar: true, + }), + beforeLoad: () => ({ + baz: true, + }), loader: ({ context }) => { context.foo // true context.bar // true + context.baz // true }, }) ``` @@ -497,6 +508,8 @@ export const Route = createFileRoute('/todos')({ +If you need to do redirects/guards, `beforeLoad` is a good place for that, and it can also return additional context. + ## Processing Accumulated Route Context Context, especially the isolated route `context` objects, make it trivial to accumulate and process the route context objects for all matched routes. Here's an example where we use all of the matched route contexts to generate a breadcrumb trail: diff --git a/docs/start/config.json b/docs/start/config.json index b5b0c8753f1..bcb15e7d28f 100644 --- a/docs/start/config.json +++ b/docs/start/config.json @@ -125,6 +125,10 @@ "label": "Selective SSR", "to": "framework/react/guide/selective-ssr" }, + { + "label": "Route Lifecycle Methods", + "to": "framework/react/guide/lifecycle-result-serialization" + }, { "label": "SPA Mode", "to": "framework/react/guide/spa-mode" @@ -258,6 +262,10 @@ "label": "Selective SSR", "to": "framework/solid/guide/selective-ssr" }, + { + "label": "Route Lifecycle Methods", + "to": "framework/solid/guide/lifecycle-result-serialization" + }, { "label": "SPA Mode", "to": "framework/solid/guide/spa-mode" diff --git a/docs/start/framework/react/guide/lifecycle-result-serialization.md b/docs/start/framework/react/guide/lifecycle-result-serialization.md new file mode 100644 index 00000000000..a899a5df440 --- /dev/null +++ b/docs/start/framework/react/guide/lifecycle-result-serialization.md @@ -0,0 +1,559 @@ +--- +id: lifecycle-result-serialization +title: Route Lifecycle Methods +--- + +TanStack Start route lifecycle methods let a route derive context, guard access, and load data before rendering. The three route-level lifecycle methods are: + +| Method | Primary job | Result is available as | +| ------------ | ---------------------------------------------------- | ------------------------------------------------ | +| `context` | Add route-specific values to inherited route context | `context` for this route and its children | +| `beforeLoad` | Guard or prepare a route before loaders run | `context` for this route and its children | +| `loader` | Load route data for rendering | loader data, for example `Route.useLoaderData()` | + +These methods are isomorphic. They can run on the server during SSR and in the browser during hydration or client-side navigation. Dehydration controls which lifecycle results are sent from the server to the client during SSR hydration. It does not make the lifecycle handler itself server-only. + +For inline route definitions, Start strips custom `dehydrate` implementations from the client build while preserving the dehydration marker, and removes custom `hydrate` functions and context `revalidate` callbacks from the server build. That means a custom `dehydrate` function can use server-only APIs, custom `hydrate` and `context.revalidate` callbacks can use browser-only APIs, and both bundles avoid retaining code they cannot execute. + +## Execution Order + +For a matched route branch, Start loads routes from parent to child. + +For each matched route, the serial phase runs: + +1. `context` +2. `beforeLoad` + +After every eligible matched route finishes that serial phase, loaders run with the accumulated context. + +```tsx +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard')({ + context: ({ context }) => { + return { + auth: context.auth, + } + }, + beforeLoad: ({ context, location }) => { + if (!context.auth.user) { + throw redirect({ + to: '/login', + search: { redirect: location.href }, + }) + } + + return { + canViewDashboard: true, + } + }, + loader: async ({ context }) => { + return context.queryClient.ensureQueryData({ + queryKey: ['dashboard'], + queryFn: fetchDashboard, + }) + }, +}) +``` + +In this example: + +- `context` adds `auth` to the route context. +- `beforeLoad` reads `auth`, redirects if needed, and adds `canViewDashboard`. +- `loader` reads the accumulated context and loads data. +- Child routes can also read `auth` and `canViewDashboard` from context. + +## Choose The Right Lifecycle + +Use `context` when you want to add values to the route context for this route and its children. Common examples include request/session summaries, tenant IDs, feature flags, dependency handles, and values that `beforeLoad` or `loader` should consume. + +Use `beforeLoad` when you need to decide whether the route is allowed to continue. Common examples include auth guards, permission checks, redirects, `notFound()` decisions, and adding guard results to context. + +Use `loader` when the route needs data for rendering. Loader data is read with route loader APIs like `Route.useLoaderData()`. Loaders also participate in the router's existing stale-while-revalidate cache behavior. + +## Function Form + +Function form is best when you only need the lifecycle handler and are happy with the effective dehydration default for that method. + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + context: ({ params }) => ({ + postId: params.postId, + }), + beforeLoad: ({ context }) => ({ + auditScope: `post:${context.postId}`, + }), + loader: async ({ context }) => { + return fetchPost(context.postId) + }, +}) +``` + +Function form is equivalent to object form without extra lifecycle options. For example, this: + +```tsx +loader: async ({ params }) => fetchPost(params.postId) +``` + +is the simple version of this: + +```tsx +loader: { + handler: async ({ params }) => fetchPost(params.postId), +} +``` + +Because function form uses the effective dehydration default, a `beforeLoad` or `loader` function result must be serializable by default. Use object form with `dehydrate: false` or a custom `dehydrate` function when the runtime value is not directly serializable. + +## Object Form + +Use object form when you need lifecycle options such as `revalidate`, `dehydrate`, `hydrate`, or loader `staleReloadMode`. + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + context: { + handler: ({ params }) => ({ + postId: params.postId, + openedAt: Date.now(), + }), + revalidate: true, + dehydrate: false, + }, + beforeLoad: { + handler: ({ context }) => ({ + auditScope: `post:${context.postId}`, + }), + dehydrate: true, + }, + loader: { + handler: async ({ context }) => fetchPost(context.postId), + staleReloadMode: 'background', + dehydrate: true, + }, +}) +``` + +Object form always requires `handler`. + +| Option | Supported on | What it does | +| ----------------- | --------------------------------- | -------------------------------------------------------------------- | +| `handler` | `context`, `beforeLoad`, `loader` | The lifecycle function itself | +| `revalidate` | `context` only | Lets route context update when the match is invalid or stale | +| `dehydrate` | `context`, `beforeLoad`, `loader` | Controls whether and how the result is included in the SSR payload | +| `hydrate` | `context`, `beforeLoad`, `loader` | Rebuilds a full lifecycle result from a custom dehydrated wire value | +| `staleReloadMode` | `loader` only | Controls loader behavior when stale data reloads | + +## TanStack Query Options In Context + +A common `context` use case is to provide stable TanStack Query options to a route branch. + +```tsx +import { queryOptions } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' + +const postQueryOptions = (postId: string) => + queryOptions({ + queryKey: ['post', postId], + queryFn: () => fetchPost(postId), + }) + +export const Route = createFileRoute('/posts/$postId')({ + context: ({ params }) => ({ + postQueryOptions: postQueryOptions(params.postId), + }), + loader: ({ context }) => { + return context.queryClient.ensureQueryData(context.postQueryOptions) + }, +}) +``` + +This is the default pattern for values like `queryOptions`: the route context value is created once for the match and stays stable for that match. It does not re-run just because the match becomes stale or because `router.invalidate()` is called. + +Use object form when part of the route context should be invalidatable. For example, auth status often needs to be refreshed, while query options should stay stable. + +```tsx +import { queryOptions } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' + +const accountQueryOptions = () => + queryOptions({ + queryKey: ['account'], + queryFn: fetchAccount, + }) + +export const Route = createFileRoute('/account')({ + staleTime: 30_000, + context: { + handler: async () => ({ + accountQueryOptions: accountQueryOptions(), + authStatus: await getAuthStatus(), + }), + revalidate: async ({ prev }) => ({ + accountQueryOptions: prev?.accountQueryOptions ?? accountQueryOptions(), + authStatus: await getAuthStatus(), + }), + }, +}) +``` + +The `handler` creates the initial value. The `revalidate` callback receives `prev`, so it can keep stable values, such as `queryOptions`, and only refresh the values that should change. + +Use `revalidate: true` when everything in the context can be recomputed by the `handler`. + +```tsx +export const Route = createFileRoute('/session')({ + staleTime: 30_000, + context: { + handler: async () => ({ + authStatus: await getAuthStatus(), + }), + revalidate: true, + }, +}) +``` + +With `revalidate: true`, the route context can re-run when the match becomes stale or when `router.invalidate()` marks it invalid. + +## Caching And Revalidation + +`loader` keeps the existing router loader cache behavior. Route options such as `staleTime`, `preloadStaleTime`, `gcTime`, `shouldReload`, and loader `staleReloadMode` control when cached loader data is fresh, stale, retained, or reloaded. + +`context` has a separate opt-in revalidation API. By default, once a route context value exists for a match, it is kept for that match. To allow context to update when the match is invalidated or stale, add `revalidate` to the `context` object form. + +```tsx +export const Route = createFileRoute('/account')({ + staleTime: 30_000, + context: { + handler: async ({ context }) => { + return { + session: await context.auth.getSession(), + refreshedAt: Date.now(), + } + }, + revalidate: true, + }, +}) +``` + +With `revalidate: true`, Start re-runs the `handler` when context revalidation is needed. + +Use a function when revalidation should use the previous value. + +```tsx +export const Route = createFileRoute('/account')({ + staleTime: 30_000, + context: { + handler: async ({ context }) => { + return { + session: await context.auth.getSession(), + refreshCount: 0, + } + }, + revalidate: async ({ context, prev }) => { + return { + session: await context.auth.getSession(), + refreshCount: (prev?.refreshCount ?? 0) + 1, + } + }, + }, +}) +``` + +The `revalidate` function must return the same data shape as `handler`. It receives `prev`, which is the previous route context result for that lifecycle, or `undefined` if none exists. + +`revalidate` is only supported on `context`. `beforeLoad` is a guard phase, and `loader` already has router loader cache and reload controls. + +## SSR Hydration And Dehydration + +During SSR, Start can include lifecycle results in the dehydrated payload. The client can then reuse those values during hydration instead of immediately re-running that lifecycle. + +The built-in defaults are: + +| Lifecycle | Built-in `dehydrate` default | +| ------------ | ---------------------------- | +| `context` | `false` | +| `beforeLoad` | `true` | +| `loader` | `true` | + +The effective value is resolved in this order: + +1. Route method option, such as `loader: { dehydrate: false }` +2. Start/router-level `defaultDehydrate` +3. Built-in default + +When a lifecycle result is not dehydrated, the client re-runs that lifecycle during hydration. + +Client-side navigation is different from SSR hydration. On client-side navigation, route lifecycle methods run in the browser as needed for that navigation. + +## Set Defaults In `start.ts` + +Use `defaultDehydrate` in your Start instance to change app-wide defaults. + +```tsx +import { createStart } from '@tanstack/react-start' + +export const startInstance = createStart(() => ({ + defaultDehydrate: { + context: false, + beforeLoad: true, + loader: true, + }, +})) +``` + +You can set only the methods you want to override. + +```tsx +export const startInstance = createStart(() => ({ + defaultDehydrate: { + context: true, + }, +})) +``` + +Per-route settings still win over `defaultDehydrate`. + +## `dehydrate: true` + +Use `dehydrate: true` when the lifecycle result is serializable, safe to send to the browser, and useful to reuse during hydration. + +```tsx +export const Route = createFileRoute('/profile')({ + context: { + handler: async ({ context }) => ({ + user: await context.auth.getPublicUser(), + }), + dehydrate: true, + }, + loader: { + handler: async ({ context }) => fetchProfile(context.user.id), + dehydrate: true, + }, +}) +``` + +TypeScript checks serializability when a lifecycle is effectively dehydrated. That includes `dehydrate: true`, router-level defaults, and built-in defaults. + +## `dehydrate: false` + +Use `dehydrate: false` when the result is large, not serializable, should not be sent to the browser, or should be recomputed on the client during hydration. + +```tsx +export const Route = createFileRoute('/editor')({ + beforeLoad: { + handler: () => ({ + editorSession: createEditorSession(), + }), + dehydrate: false, + }, +}) +``` + +Remember that `dehydrate: false` causes the client to re-run the lifecycle during hydration. Do not put server-only code directly in a lifecycle that can re-run in the browser. + +## Custom `dehydrate` And `hydrate` + +Use a `dehydrate` function when the runtime value is not the shape you want to send over the wire. The function receives `{ data }`, where `data` is the lifecycle handler result. Its return value is the wire value. + +When `dehydrate` is a function, `hydrate` is required. `hydrate` also receives `{ data }`, where `data` is inferred from the return type of `dehydrate`. The `hydrate` return value should reconstruct the full lifecycle result shape that the route expects. + +In Start builds, inline custom serializers and revalidators are split by environment: + +| Route option | Runs in | Other build output | +| ---------------------------- | -------------------- | --------------------------------------------- | +| Custom `dehydrate` function | Server SSR build | Replaced with `dehydrate: true` in the client | +| `dehydrate: true` or `false` | Both builds | Preserved | +| `hydrate` | Browser client build | Removed from the server | +| `context.revalidate` | Browser client build | Removed from the server | + +The lifecycle `handler` remains isomorphic unless another route option prevents it from running in one environment. + +```tsx +export const Route = createFileRoute('/report')({ + loader: { + handler: async () => { + const report = await fetchReport() + + return { + report, + generatedAt: new Date(), + formatCurrency: (value: number) => `$${value.toFixed(2)}`, + } + }, + dehydrate: ({ data }) => ({ + report: data.report, + generatedAt: data.generatedAt.toISOString(), + }), + hydrate: ({ data }) => ({ + report: data.report, + generatedAt: new Date(data.generatedAt), + formatCurrency: (value: number) => `$${value.toFixed(2)}`, + }), + }, +}) +``` + +In this example, the function is not sent over the wire. The wire payload contains only `report` and an ISO date string, and `hydrate` reconstructs the full loader result for the client. + +## Partial Dehydration + +Partial dehydration is the same pattern applied deliberately: send only the serializable subset that the client needs, then rebuild the full value in `hydrate`. + +```tsx +export const Route = createFileRoute('/invoice/$invoiceId')({ + context: { + handler: ({ params }) => { + return { + invoiceId: params.invoiceId, + loadedAt: new Date(), + canEdit: (role: string) => role === 'admin', + } + }, + dehydrate: ({ data }) => ({ + invoiceId: data.invoiceId, + loadedAt: data.loadedAt.toISOString(), + }), + hydrate: ({ data }) => ({ + invoiceId: data.invoiceId, + loadedAt: new Date(data.loadedAt), + canEdit: (role: string) => role === 'admin', + }), + }, +}) +``` + +Use partial dehydration when: + +- The full lifecycle result contains functions, class instances, database handles, caches, or other values that should not be serialized. +- The client only needs part of the value immediately after hydration. +- You want a smaller SSR payload than `dehydrate: true` would produce. +- You can safely reconstruct the client-side runtime shape from a serializable wire value. + +Partial dehydration pairs well with TanStack Query options. Send the serializable data that should be reused during hydration, and recreate the query options in `hydrate`. + +```tsx +import { queryOptions } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' + +const postQueryOptions = (postId: string) => + queryOptions({ + queryKey: ['post', postId], + queryFn: () => fetchPost(postId), + }) + +export const Route = createFileRoute('/posts/$postId')({ + context: { + handler: async ({ params }) => ({ + postId: params.postId, + postQueryOptions: postQueryOptions(params.postId), + authStatus: await getAuthStatus(), + }), + revalidate: async ({ params, prev }) => ({ + postId: prev?.postId ?? params.postId, + postQueryOptions: + prev?.postQueryOptions ?? postQueryOptions(params.postId), + authStatus: await getAuthStatus(), + }), + dehydrate: ({ data }) => ({ + postId: data.postId, + authStatus: data.authStatus, + }), + hydrate: ({ data }) => ({ + postId: data.postId, + postQueryOptions: postQueryOptions(data.postId), + authStatus: data.authStatus, + }), + }, +}) +``` + +Here, `authStatus` is included in the SSR payload. `postQueryOptions` is not serialized; it is recreated on the client from the serialized `postId`. + +## Common Patterns + +### Auth Guards + +Use `context` to expose auth state, then `beforeLoad` to enforce access. + +```tsx +export const Route = createFileRoute('/settings')({ + context: async ({ context }) => ({ + session: await context.auth.getSession(), + }), + beforeLoad: ({ context, location }) => { + if (!context.session.user) { + throw redirect({ + to: '/login', + search: { redirect: location.href }, + }) + } + }, +}) +``` + +### Data Prefetching + +Use `loader` for data that the route component reads. + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params, context }) => { + return context.queryClient.ensureQueryData({ + queryKey: ['post', params.postId], + queryFn: () => fetchPost(params.postId), + }) + }, + component: PostPage, +}) + +function PostPage() { + const post = Route.useLoaderData() + return
{post.title}
+} +``` + +### Non-Serializable Context + +Use `dehydrate: false` when route context is a runtime-only object. + +```tsx +export const Route = createFileRoute('/canvas')({ + context: { + handler: () => ({ + tools: createDrawingTools(), + }), + dehydrate: false, + }, +}) +``` + +### Serializable Guard Results + +Use `beforeLoad` with the default dehydration behavior when it returns a small serializable result. + +```tsx +export const Route = createFileRoute('/admin')({ + beforeLoad: ({ context }) => { + if (!context.auth.user?.roles.includes('admin')) { + throw redirect({ to: '/' }) + } + + return { + adminAccessChecked: true, + } + }, +}) +``` + +Because `beforeLoad` is dehydrated by default, this value is reused during hydration. + +## Summary + +- Use `context` for inherited route values. +- Use `beforeLoad` for guards and route-enter decisions. +- Use `loader` for render data and router-managed data caching. +- Use function form for simple handlers. +- Use object form for `revalidate`, `dehydrate`, `hydrate`, and loader `staleReloadMode`. +- Use `dehydrate: true` for small, safe, serializable values. +- Use `dehydrate: false` for values that should not be in the SSR payload. +- Use custom `dehydrate` / `hydrate` for wire-safe transformations and partial dehydration. diff --git a/docs/start/framework/solid/guide/lifecycle-result-serialization.md b/docs/start/framework/solid/guide/lifecycle-result-serialization.md new file mode 100644 index 00000000000..3ff940deef3 --- /dev/null +++ b/docs/start/framework/solid/guide/lifecycle-result-serialization.md @@ -0,0 +1,9 @@ +--- +ref: docs/start/framework/react/guide/lifecycle-result-serialization.md +replace: + { + '@tanstack/react-start': '@tanstack/solid-start', + '@tanstack/react-router': '@tanstack/solid-router', + 'React': 'SolidJS', + } +--- diff --git a/e2e/react-router/basic-file-based/src/routeTree.gen.ts b/e2e/react-router/basic-file-based/src/routeTree.gen.ts index 7ee0051549a..daf304c064c 100644 --- a/e2e/react-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/react-router/basic-file-based/src/routeTree.gen.ts @@ -780,9 +780,9 @@ const NonNestedDeepBazBarFooQuxRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/fullpath-test': typeof FullpathTestRouteRouteWithChildren + '/fullpath-test': typeof FullpathTestLayoutRouteRouteWithChildren '/non-nested': typeof NonNestedRouteRouteWithChildren - '/pathless-layout': typeof PathlessLayoutRouteRouteWithChildren + '/pathless-layout': typeof PathlessLayoutLayoutRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/대한민국': typeof Char45824Char54620Char48124Char44397RouteRouteWithChildren '/anchor': typeof AnchorRoute diff --git a/e2e/react-start/router-lifecycle-methods/.gitignore b/e2e/react-start/router-lifecycle-methods/.gitignore new file mode 100644 index 00000000000..aa8c51b58ac --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/.gitignore @@ -0,0 +1,17 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.env +.vercel +.output +/build/ +/api/ +/server/build +/public/build +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/react-start/router-lifecycle-methods/package.json b/e2e/react-start/router-lifecycle-methods/package.json new file mode 100644 index 00000000000..3814edbca24 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/package.json @@ -0,0 +1,37 @@ +{ + "name": "tanstack-react-start-e2e-router-lifecycle-methods", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:allTrue": "VITE_DEHYDRATE_DEFAULTS=all-true vite dev --port 3000", + "dev:allFalse": "VITE_DEHYDRATE_DEFAULTS=all-false vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "pnpm run test:e2e:defaults && pnpm run test:e2e:allTrue && pnpm run test:e2e:allFalse", + "test:e2e:defaults": "rm -rf dist; rm -rf port*.txt; playwright test --project=chromium", + "test:e2e:allTrue": "rm -rf dist; rm -rf port*.txt; DEHYDRATE_DEFAULTS=all-true playwright test --project=chromium", + "test:e2e:allFalse": "rm -rf dist; rm -rf port*.txt; DEHYDRATE_DEFAULTS=all-false playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^5.1.4" + }, + "devDependencies": { + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "srvx": "^0.11.2", + "typescript": "^5.7.2" + } +} diff --git a/e2e/react-start/router-lifecycle-methods/playwright.config.ts b/e2e/react-start/router-lifecycle-methods/playwright.config.ts new file mode 100644 index 00000000000..f984734a9e0 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import { dehydrateDefaultsMode } from './tests/utils/dehydrateDefaults' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort( + `${packageJson.name}${dehydrateDefaultsMode ? `_${dehydrateDefaultsMode}` : ''}`, +) +const baseURL = `http://localhost:${PORT}` + +console.log( + 'running with DEHYDRATE_DEFAULTS:', + dehydrateDefaultsMode || '(builtin defaults)', +) + +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} VITE_DEHYDRATE_DEFAULTS=${dehydrateDefaultsMode} pnpm build && NODE_ENV=production PORT=${PORT} VITE_SERVER_PORT=${PORT} VITE_DEHYDRATE_DEFAULTS=${dehydrateDefaultsMode} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/router-lifecycle-methods/src/routeTree.gen.ts b/e2e/react-start/router-lifecycle-methods/src/routeTree.gen.ts new file mode 100644 index 00000000000..dbaad027121 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routeTree.gen.ts @@ -0,0 +1,426 @@ +/* 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 StaleRevalidateRouteImport } from './routes/stale-revalidate' +import { Route as RevalidateContextFnRouteImport } from './routes/revalidate-context-fn' +import { Route as RevalidateContextRouteImport } from './routes/revalidate-context' +import { Route as PostsRouteImport } from './routes/posts' +import { Route as DehydratePartialRouteImport } from './routes/dehydrate-partial' +import { Route as DehydrateMixedRouteImport } from './routes/dehydrate-mixed' +import { Route as DehydrateLoaderFalseRouteImport } from './routes/dehydrate-loader-false' +import { Route as DehydrateFnRouteImport } from './routes/dehydrate-fn' +import { Route as DehydrateDefaultsRouteImport } from './routes/dehydrate-defaults' +import { Route as DehydrateContextTrueRouteImport } from './routes/dehydrate-context-true' +import { Route as DehydrateBeforeloadFalseRouteImport } from './routes/dehydrate-beforeload-false' +import { Route as DehydrateAllTrueRouteImport } from './routes/dehydrate-all-true' +import { Route as DehydrateAllFalseRouteImport } from './routes/dehydrate-all-false' +import { Route as IndexRouteImport } from './routes/index' +import { Route as PostsIndexRouteImport } from './routes/posts.index' +import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' +import { Route as PostsPostIdCommentsRouteImport } from './routes/posts.$postId.comments' + +const StaleRevalidateRoute = StaleRevalidateRouteImport.update({ + id: '/stale-revalidate', + path: '/stale-revalidate', + getParentRoute: () => rootRouteImport, +} as any) +const RevalidateContextFnRoute = RevalidateContextFnRouteImport.update({ + id: '/revalidate-context-fn', + path: '/revalidate-context-fn', + getParentRoute: () => rootRouteImport, +} as any) +const RevalidateContextRoute = RevalidateContextRouteImport.update({ + id: '/revalidate-context', + path: '/revalidate-context', + getParentRoute: () => rootRouteImport, +} as any) +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const DehydratePartialRoute = DehydratePartialRouteImport.update({ + id: '/dehydrate-partial', + path: '/dehydrate-partial', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateMixedRoute = DehydrateMixedRouteImport.update({ + id: '/dehydrate-mixed', + path: '/dehydrate-mixed', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateLoaderFalseRoute = DehydrateLoaderFalseRouteImport.update({ + id: '/dehydrate-loader-false', + path: '/dehydrate-loader-false', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateFnRoute = DehydrateFnRouteImport.update({ + id: '/dehydrate-fn', + path: '/dehydrate-fn', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateDefaultsRoute = DehydrateDefaultsRouteImport.update({ + id: '/dehydrate-defaults', + path: '/dehydrate-defaults', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateContextTrueRoute = DehydrateContextTrueRouteImport.update({ + id: '/dehydrate-context-true', + path: '/dehydrate-context-true', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateBeforeloadFalseRoute = + DehydrateBeforeloadFalseRouteImport.update({ + id: '/dehydrate-beforeload-false', + path: '/dehydrate-beforeload-false', + getParentRoute: () => rootRouteImport, + } as any) +const DehydrateAllTrueRoute = DehydrateAllTrueRouteImport.update({ + id: '/dehydrate-all-true', + path: '/dehydrate-all-true', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateAllFalseRoute = DehydrateAllFalseRouteImport.update({ + id: '/dehydrate-all-false', + path: '/dehydrate-all-false', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const PostsIndexRoute = PostsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => PostsRoute, +} as any) +const PostsPostIdRoute = PostsPostIdRouteImport.update({ + id: '/$postId', + path: '/$postId', + getParentRoute: () => PostsRoute, +} as any) +const PostsPostIdCommentsRoute = PostsPostIdCommentsRouteImport.update({ + id: '/comments', + path: '/comments', + getParentRoute: () => PostsPostIdRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/dehydrate-all-false': typeof DehydrateAllFalseRoute + '/dehydrate-all-true': typeof DehydrateAllTrueRoute + '/dehydrate-beforeload-false': typeof DehydrateBeforeloadFalseRoute + '/dehydrate-context-true': typeof DehydrateContextTrueRoute + '/dehydrate-defaults': typeof DehydrateDefaultsRoute + '/dehydrate-fn': typeof DehydrateFnRoute + '/dehydrate-loader-false': typeof DehydrateLoaderFalseRoute + '/dehydrate-mixed': typeof DehydrateMixedRoute + '/dehydrate-partial': typeof DehydratePartialRoute + '/posts': typeof PostsRouteWithChildren + '/revalidate-context': typeof RevalidateContextRoute + '/revalidate-context-fn': typeof RevalidateContextFnRoute + '/stale-revalidate': typeof StaleRevalidateRoute + '/posts/$postId': typeof PostsPostIdRouteWithChildren + '/posts/': typeof PostsIndexRoute + '/posts/$postId/comments': typeof PostsPostIdCommentsRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/dehydrate-all-false': typeof DehydrateAllFalseRoute + '/dehydrate-all-true': typeof DehydrateAllTrueRoute + '/dehydrate-beforeload-false': typeof DehydrateBeforeloadFalseRoute + '/dehydrate-context-true': typeof DehydrateContextTrueRoute + '/dehydrate-defaults': typeof DehydrateDefaultsRoute + '/dehydrate-fn': typeof DehydrateFnRoute + '/dehydrate-loader-false': typeof DehydrateLoaderFalseRoute + '/dehydrate-mixed': typeof DehydrateMixedRoute + '/dehydrate-partial': typeof DehydratePartialRoute + '/revalidate-context': typeof RevalidateContextRoute + '/revalidate-context-fn': typeof RevalidateContextFnRoute + '/stale-revalidate': typeof StaleRevalidateRoute + '/posts/$postId': typeof PostsPostIdRouteWithChildren + '/posts': typeof PostsIndexRoute + '/posts/$postId/comments': typeof PostsPostIdCommentsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/dehydrate-all-false': typeof DehydrateAllFalseRoute + '/dehydrate-all-true': typeof DehydrateAllTrueRoute + '/dehydrate-beforeload-false': typeof DehydrateBeforeloadFalseRoute + '/dehydrate-context-true': typeof DehydrateContextTrueRoute + '/dehydrate-defaults': typeof DehydrateDefaultsRoute + '/dehydrate-fn': typeof DehydrateFnRoute + '/dehydrate-loader-false': typeof DehydrateLoaderFalseRoute + '/dehydrate-mixed': typeof DehydrateMixedRoute + '/dehydrate-partial': typeof DehydratePartialRoute + '/posts': typeof PostsRouteWithChildren + '/revalidate-context': typeof RevalidateContextRoute + '/revalidate-context-fn': typeof RevalidateContextFnRoute + '/stale-revalidate': typeof StaleRevalidateRoute + '/posts/$postId': typeof PostsPostIdRouteWithChildren + '/posts/': typeof PostsIndexRoute + '/posts/$postId/comments': typeof PostsPostIdCommentsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/dehydrate-all-false' + | '/dehydrate-all-true' + | '/dehydrate-beforeload-false' + | '/dehydrate-context-true' + | '/dehydrate-defaults' + | '/dehydrate-fn' + | '/dehydrate-loader-false' + | '/dehydrate-mixed' + | '/dehydrate-partial' + | '/posts' + | '/revalidate-context' + | '/revalidate-context-fn' + | '/stale-revalidate' + | '/posts/$postId' + | '/posts/' + | '/posts/$postId/comments' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/dehydrate-all-false' + | '/dehydrate-all-true' + | '/dehydrate-beforeload-false' + | '/dehydrate-context-true' + | '/dehydrate-defaults' + | '/dehydrate-fn' + | '/dehydrate-loader-false' + | '/dehydrate-mixed' + | '/dehydrate-partial' + | '/revalidate-context' + | '/revalidate-context-fn' + | '/stale-revalidate' + | '/posts/$postId' + | '/posts' + | '/posts/$postId/comments' + id: + | '__root__' + | '/' + | '/dehydrate-all-false' + | '/dehydrate-all-true' + | '/dehydrate-beforeload-false' + | '/dehydrate-context-true' + | '/dehydrate-defaults' + | '/dehydrate-fn' + | '/dehydrate-loader-false' + | '/dehydrate-mixed' + | '/dehydrate-partial' + | '/posts' + | '/revalidate-context' + | '/revalidate-context-fn' + | '/stale-revalidate' + | '/posts/$postId' + | '/posts/' + | '/posts/$postId/comments' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + DehydrateAllFalseRoute: typeof DehydrateAllFalseRoute + DehydrateAllTrueRoute: typeof DehydrateAllTrueRoute + DehydrateBeforeloadFalseRoute: typeof DehydrateBeforeloadFalseRoute + DehydrateContextTrueRoute: typeof DehydrateContextTrueRoute + DehydrateDefaultsRoute: typeof DehydrateDefaultsRoute + DehydrateFnRoute: typeof DehydrateFnRoute + DehydrateLoaderFalseRoute: typeof DehydrateLoaderFalseRoute + DehydrateMixedRoute: typeof DehydrateMixedRoute + DehydratePartialRoute: typeof DehydratePartialRoute + PostsRoute: typeof PostsRouteWithChildren + RevalidateContextRoute: typeof RevalidateContextRoute + RevalidateContextFnRoute: typeof RevalidateContextFnRoute + StaleRevalidateRoute: typeof StaleRevalidateRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/stale-revalidate': { + id: '/stale-revalidate' + path: '/stale-revalidate' + fullPath: '/stale-revalidate' + preLoaderRoute: typeof StaleRevalidateRouteImport + parentRoute: typeof rootRouteImport + } + '/revalidate-context-fn': { + id: '/revalidate-context-fn' + path: '/revalidate-context-fn' + fullPath: '/revalidate-context-fn' + preLoaderRoute: typeof RevalidateContextFnRouteImport + parentRoute: typeof rootRouteImport + } + '/revalidate-context': { + id: '/revalidate-context' + path: '/revalidate-context' + fullPath: '/revalidate-context' + preLoaderRoute: typeof RevalidateContextRouteImport + parentRoute: typeof rootRouteImport + } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-partial': { + id: '/dehydrate-partial' + path: '/dehydrate-partial' + fullPath: '/dehydrate-partial' + preLoaderRoute: typeof DehydratePartialRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-mixed': { + id: '/dehydrate-mixed' + path: '/dehydrate-mixed' + fullPath: '/dehydrate-mixed' + preLoaderRoute: typeof DehydrateMixedRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-loader-false': { + id: '/dehydrate-loader-false' + path: '/dehydrate-loader-false' + fullPath: '/dehydrate-loader-false' + preLoaderRoute: typeof DehydrateLoaderFalseRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-fn': { + id: '/dehydrate-fn' + path: '/dehydrate-fn' + fullPath: '/dehydrate-fn' + preLoaderRoute: typeof DehydrateFnRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-defaults': { + id: '/dehydrate-defaults' + path: '/dehydrate-defaults' + fullPath: '/dehydrate-defaults' + preLoaderRoute: typeof DehydrateDefaultsRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-context-true': { + id: '/dehydrate-context-true' + path: '/dehydrate-context-true' + fullPath: '/dehydrate-context-true' + preLoaderRoute: typeof DehydrateContextTrueRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-beforeload-false': { + id: '/dehydrate-beforeload-false' + path: '/dehydrate-beforeload-false' + fullPath: '/dehydrate-beforeload-false' + preLoaderRoute: typeof DehydrateBeforeloadFalseRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-all-true': { + id: '/dehydrate-all-true' + path: '/dehydrate-all-true' + fullPath: '/dehydrate-all-true' + preLoaderRoute: typeof DehydrateAllTrueRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-all-false': { + id: '/dehydrate-all-false' + path: '/dehydrate-all-false' + fullPath: '/dehydrate-all-false' + preLoaderRoute: typeof DehydrateAllFalseRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/': { + id: '/posts/' + path: '/' + fullPath: '/posts/' + preLoaderRoute: typeof PostsIndexRouteImport + parentRoute: typeof PostsRoute + } + '/posts/$postId': { + id: '/posts/$postId' + path: '/$postId' + fullPath: '/posts/$postId' + preLoaderRoute: typeof PostsPostIdRouteImport + parentRoute: typeof PostsRoute + } + '/posts/$postId/comments': { + id: '/posts/$postId/comments' + path: '/comments' + fullPath: '/posts/$postId/comments' + preLoaderRoute: typeof PostsPostIdCommentsRouteImport + parentRoute: typeof PostsPostIdRoute + } + } +} + +interface PostsPostIdRouteChildren { + PostsPostIdCommentsRoute: typeof PostsPostIdCommentsRoute +} + +const PostsPostIdRouteChildren: PostsPostIdRouteChildren = { + PostsPostIdCommentsRoute: PostsPostIdCommentsRoute, +} + +const PostsPostIdRouteWithChildren = PostsPostIdRoute._addFileChildren( + PostsPostIdRouteChildren, +) + +interface PostsRouteChildren { + PostsPostIdRoute: typeof PostsPostIdRouteWithChildren + PostsIndexRoute: typeof PostsIndexRoute +} + +const PostsRouteChildren: PostsRouteChildren = { + PostsPostIdRoute: PostsPostIdRouteWithChildren, + PostsIndexRoute: PostsIndexRoute, +} + +const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + DehydrateAllFalseRoute: DehydrateAllFalseRoute, + DehydrateAllTrueRoute: DehydrateAllTrueRoute, + DehydrateBeforeloadFalseRoute: DehydrateBeforeloadFalseRoute, + DehydrateContextTrueRoute: DehydrateContextTrueRoute, + DehydrateDefaultsRoute: DehydrateDefaultsRoute, + DehydrateFnRoute: DehydrateFnRoute, + DehydrateLoaderFalseRoute: DehydrateLoaderFalseRoute, + DehydrateMixedRoute: DehydrateMixedRoute, + DehydratePartialRoute: DehydratePartialRoute, + PostsRoute: PostsRouteWithChildren, + RevalidateContextRoute: RevalidateContextRoute, + RevalidateContextFnRoute: RevalidateContextFnRoute, + StaleRevalidateRoute: StaleRevalidateRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.ts' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + config: Awaited> + } +} diff --git a/e2e/react-start/router-lifecycle-methods/src/router.tsx b/e2e/react-start/router-lifecycle-methods/src/router.tsx new file mode 100644 index 00000000000..82a730704ad --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/router.tsx @@ -0,0 +1,11 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const router = createRouter({ + routeTree, + scrollRestoration: true, + }) + + return router +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/__root.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/__root.tsx new file mode 100644 index 00000000000..c07601bb049 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/__root.tsx @@ -0,0 +1,142 @@ +/// +import * as React from 'react' +import { + ClientOnly, + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import appCss from '~/styles/app.css?url' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Router Lifecycle Methods E2E' }, + ], + links: [{ rel: 'stylesheet', href: appCss }], + }), + context: () => { + return { rootContextCtx: 'root-context' } + }, + beforeLoad: () => { + return { rootBeforeLoadCtx: 'root-beforeLoad' } + }, + shellComponent: RootDocument, + component: RootComponent, +}) + +function RootComponent() { + const context = Route.useRouteContext() + + return ( +
+
{context.rootContextCtx}
+
{context.rootBeforeLoadCtx}
+ +
+ ) +} + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + +
+ +
hydrated
+
+ {children} + + + + + ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-false.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-false.tsx new file mode 100644 index 00000000000..764f0614873 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-false.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +const getContext = createIsomorphicFn() + .server(() => 'server-daf-context') + .client(() => 'client-daf-context') + +const getBeforeLoad = createIsomorphicFn() + .server(() => 'server-daf-beforeLoad') + .client(() => 'client-daf-beforeLoad') + +const getLoader = createIsomorphicFn() + .server(() => 'server-daf-loader') + .client(() => 'client-daf-loader') + +export const Route = createFileRoute('/dehydrate-all-false')({ + // All object form with dehydrate: false — nothing dehydrated, all re-executed on client + context: { + handler: () => ({ dafContextCtx: getContext() }), + dehydrate: false, + }, + beforeLoad: { + handler: () => ({ dafBeforeLoadCtx: getBeforeLoad() }), + dehydrate: false, + }, + loader: { + handler: () => ({ dafLoaderData: getLoader() }), + dehydrate: false, + }, + // data-only SSR — client re-executes all methods, so server render would mismatch + ssr: 'data-only', + component: DehydrateAllFalseComponent, +}) + +function DehydrateAllFalseComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Dehydrate All False

+
{context.dafContextCtx}
+
{context.dafBeforeLoadCtx}
+
{loaderData.dafLoaderData}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-true.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-true.tsx new file mode 100644 index 00000000000..87f342daf0f --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-true.tsx @@ -0,0 +1,46 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +const getContext = createIsomorphicFn() + .server(() => 'server-dat-context') + .client(() => 'client-dat-context') + +const getBeforeLoad = createIsomorphicFn() + .server(() => 'server-dat-beforeLoad') + .client(() => 'client-dat-beforeLoad') + +const getLoader = createIsomorphicFn() + .server(() => 'server-dat-loader') + .client(() => 'client-dat-loader') + +export const Route = createFileRoute('/dehydrate-all-true')({ + // All object form with dehydrate: true — everything dehydrated from server + context: { + handler: () => ({ datContextCtx: getContext() }), + dehydrate: true, + }, + beforeLoad: { + handler: () => ({ datBeforeLoadCtx: getBeforeLoad() }), + dehydrate: true, + }, + loader: { + handler: () => ({ datLoaderData: getLoader() }), + dehydrate: true, + }, + ssr: 'data-only', + component: DehydrateAllTrueComponent, +}) + +function DehydrateAllTrueComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Dehydrate All True

+
{context.datContextCtx}
+
{context.datBeforeLoadCtx}
+
{loaderData.datLoaderData}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-beforeload-false.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-beforeload-false.tsx new file mode 100644 index 00000000000..bfb9244961b --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-beforeload-false.tsx @@ -0,0 +1,41 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +const getContext = createIsomorphicFn() + .server(() => 'server-dbf-context') + .client(() => 'client-dbf-context') + +const getBeforeLoad = createIsomorphicFn() + .server(() => 'server-dbf-beforeLoad') + .client(() => 'client-dbf-beforeLoad') + +const getLoader = createIsomorphicFn() + .server(() => 'server-dbf-loader') + .client(() => 'client-dbf-loader') + +export const Route = createFileRoute('/dehydrate-beforeload-false')({ + // Only beforeLoad uses object form with dehydrate: false; rest use function form (defaults) + context: () => ({ dbfContextCtx: getContext() }), + beforeLoad: { + handler: () => ({ dbfBeforeLoadCtx: getBeforeLoad() }), + dehydrate: false, + }, + loader: () => ({ dbfLoaderData: getLoader() }), + // data-only SSR — beforeLoad re-executes on client + ssr: 'data-only', + component: DehydrateBeforeloadFalseComponent, +}) + +function DehydrateBeforeloadFalseComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Dehydrate BeforeLoad False

+
{context.dbfContextCtx}
+
{context.dbfBeforeLoadCtx}
+
{loaderData.dbfLoaderData}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-context-true.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-context-true.tsx new file mode 100644 index 00000000000..29857f1efaf --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-context-true.tsx @@ -0,0 +1,41 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +const getContext = createIsomorphicFn() + .server(() => 'server-dct-context') + .client(() => 'client-dct-context') + +const getBeforeLoad = createIsomorphicFn() + .server(() => 'server-dct-beforeLoad') + .client(() => 'client-dct-beforeLoad') + +const getLoader = createIsomorphicFn() + .server(() => 'server-dct-loader') + .client(() => 'client-dct-loader') + +export const Route = createFileRoute('/dehydrate-context-true')({ + // Only context uses object form with dehydrate: true; rest use function form (defaults) + context: { + handler: () => ({ dctContextCtx: getContext() }), + dehydrate: true, + }, + beforeLoad: () => ({ dctBeforeLoadCtx: getBeforeLoad() }), + loader: () => ({ dctLoaderData: getLoader() }), + // data-only SSR + ssr: 'data-only', + component: DehydrateContextTrueComponent, +}) + +function DehydrateContextTrueComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Dehydrate Context True

+
{context.dctContextCtx}
+
{context.dctBeforeLoadCtx}
+
{loaderData.dctLoaderData}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-defaults.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-defaults.tsx new file mode 100644 index 00000000000..a0b8f671c12 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-defaults.tsx @@ -0,0 +1,51 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +// Each lifecycle method returns different values on server vs client. +// With function form (no explicit dehydrate), the effective behavior depends on +// the router-level defaultDehydrate config (set via start.ts / VITE_DEHYDRATE_DEFAULTS). +// +// Builtin defaults: beforeLoad=true, loader=true, context=false + +const getContext = createIsomorphicFn() + .server(() => 'server-dd-context') + .client(() => 'client-dd-context') + +const getBeforeLoad = createIsomorphicFn() + .server(() => 'server-dd-beforeLoad') + .client(() => 'client-dd-beforeLoad') + +const getLoader = createIsomorphicFn() + .server(() => 'server-dd-loader') + .client(() => 'client-dd-loader') + +export const Route = createFileRoute('/dehydrate-defaults')({ + // All function form — uses whatever defaults are in effect + context: () => { + return { ddContextCtx: getContext() } + }, + beforeLoad: () => { + return { ddBeforeLoadCtx: getBeforeLoad() } + }, + loader: () => { + return { ddLoaderData: getLoader() } + }, + // Use data-only SSR because depending on defaultDehydrate config, + // some methods may re-execute on client producing different values. + ssr: 'data-only', + component: DehydrateDefaultsComponent, +}) + +function DehydrateDefaultsComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Dehydrate Defaults

+
{context.ddContextCtx}
+
{context.ddBeforeLoadCtx}
+
{loaderData.ddLoaderData}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-fn.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-fn.tsx new file mode 100644 index 00000000000..1f7c1f43045 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-fn.tsx @@ -0,0 +1,87 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +// Each lifecycle method returns a value that includes a Date (non-serializable). +// The dehydrate function converts to a plain object with ISO string. +// The hydrate function reconstructs the Date from the ISO string. +// +// We use createIsomorphicFn to produce different dates on server vs client, +// so the test can distinguish which environment produced the value. + +const getContextDate = createIsomorphicFn() + .server(() => new Date('2020-01-01T00:00:00.000Z')) + .client(() => new Date('2099-01-01T00:00:00.000Z')) + +const getBeforeLoadDate = createIsomorphicFn() + .server(() => new Date('2020-06-15T00:00:00.000Z')) + .client(() => new Date('2099-06-15T00:00:00.000Z')) + +const getLoaderDate = createIsomorphicFn() + .server(() => new Date('2020-12-25T00:00:00.000Z')) + .client(() => new Date('2099-12-25T00:00:00.000Z')) + +export const Route = createFileRoute('/dehydrate-fn')({ + context: { + handler: () => ({ createdAt: getContextDate() }), + dehydrate: ({ data }) => ({ + createdAt: data.createdAt.toISOString(), + }), + hydrate: ({ data }) => ({ + createdAt: new Date(data.createdAt), + }), + }, + beforeLoad: { + handler: () => ({ processedAt: getBeforeLoadDate() }), + dehydrate: ({ data }) => ({ + processedAt: data.processedAt.toISOString(), + }), + hydrate: ({ data }) => ({ + processedAt: new Date(data.processedAt), + }), + }, + loader: { + handler: () => ({ loadedAt: getLoaderDate() }), + dehydrate: ({ data }) => ({ + loadedAt: data.loadedAt.toISOString(), + }), + hydrate: ({ data }) => ({ + loadedAt: new Date(data.loadedAt), + }), + }, + ssr: 'data-only', + component: DehydrateFnComponent, +}) + +function DehydrateFnComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + // Verify hydrate reconstructed Date objects (not strings) + const contextIsDate = context.createdAt instanceof Date + const beforeLoadIsDate = context.processedAt instanceof Date + const loaderIsDate = loaderData.loadedAt instanceof Date + + return ( +
+

Dehydrate Functions

+ {/* Show ISO string representation of the dates */} +
+ {contextIsDate ? context.createdAt.toISOString() : 'NOT_A_DATE'} +
+
+ {beforeLoadIsDate ? context.processedAt.toISOString() : 'NOT_A_DATE'} +
+
+ {loaderIsDate ? loaderData.loadedAt.toISOString() : 'NOT_A_DATE'} +
+ {/* Show whether hydrate correctly reconstructed Date instances */} +
+ {contextIsDate ? 'Date' : 'other'} +
+
+ {beforeLoadIsDate ? 'Date' : 'other'} +
+
{loaderIsDate ? 'Date' : 'other'}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-loader-false.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-loader-false.tsx new file mode 100644 index 00000000000..b89e972ba94 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-loader-false.tsx @@ -0,0 +1,41 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +const getContext = createIsomorphicFn() + .server(() => 'server-dlf-context') + .client(() => 'client-dlf-context') + +const getBeforeLoad = createIsomorphicFn() + .server(() => 'server-dlf-beforeLoad') + .client(() => 'client-dlf-beforeLoad') + +const getLoader = createIsomorphicFn() + .server(() => 'server-dlf-loader') + .client(() => 'client-dlf-loader') + +export const Route = createFileRoute('/dehydrate-loader-false')({ + // Only loader uses object form with dehydrate: false; rest use function form (defaults) + context: () => ({ dlfContextCtx: getContext() }), + beforeLoad: () => ({ dlfBeforeLoadCtx: getBeforeLoad() }), + loader: { + handler: () => ({ dlfLoaderData: getLoader() }), + dehydrate: false, + }, + // data-only SSR — loader re-executes on client + ssr: 'data-only', + component: DehydrateLoaderFalseComponent, +}) + +function DehydrateLoaderFalseComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Dehydrate Loader False

+
{context.dlfContextCtx}
+
{context.dlfBeforeLoadCtx}
+
{loaderData.dlfLoaderData}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-mixed.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-mixed.tsx new file mode 100644 index 00000000000..bd2d97a66be --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-mixed.tsx @@ -0,0 +1,50 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +const getContext = createIsomorphicFn() + .server(() => 'server-dm-context') + .client(() => 'client-dm-context') + +const getBeforeLoad = createIsomorphicFn() + .server(() => 'server-dm-beforeLoad') + .client(() => 'client-dm-beforeLoad') + +const getLoader = createIsomorphicFn() + .server(() => 'server-dm-loader') + .client(() => 'client-dm-loader') + +export const Route = createFileRoute('/dehydrate-mixed')({ + // Mixed: inverted from builtin defaults + // context: dehydrate: true (builtin default is false) + // beforeLoad: dehydrate: false (builtin default is true) + // loader: dehydrate: true (matches builtin default) + context: { + handler: () => ({ dmContextCtx: getContext() }), + dehydrate: true, + }, + beforeLoad: { + handler: () => ({ dmBeforeLoadCtx: getBeforeLoad() }), + dehydrate: false, + }, + loader: { + handler: () => ({ dmLoaderData: getLoader() }), + dehydrate: true, + }, + // data-only SSR — beforeLoad re-executes on client + ssr: 'data-only', + component: DehydrateMixedComponent, +}) + +function DehydrateMixedComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Dehydrate Mixed

+
{context.dmContextCtx}
+
{context.dmBeforeLoadCtx}
+
{loaderData.dmLoaderData}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-partial.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-partial.tsx new file mode 100644 index 00000000000..8df73884d3f --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-partial.tsx @@ -0,0 +1,159 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +// --------------------------------------------------------------------------- +// Partial hydration test +// +// Each lifecycle returns a mixed object containing: +// - serializable fields (string, number) +// - non-serializable fields (Date, function, RegExp) +// +// `dehydrate` strips the non-serializable parts, keeping only the wire-safe +// subset. `hydrate` reconstructs the full shape — re-creating non-serializable +// values from the serializable data that was transmitted. +// +// Server vs client use different string prefixes so the test can tell which +// environment produced each value. +// --------------------------------------------------------------------------- + +// -- Isomorphic fns --------------------------------------------------------- + +const getContextLabel = createIsomorphicFn() + .server(() => 'server-ctx') + .client(() => 'client-ctx') + +const getBeforeLoadTag = createIsomorphicFn() + .server(() => 'server-bl') + .client(() => 'client-bl') + +const getLoaderTitle = createIsomorphicFn() + .server(() => 'server-ldr') + .client(() => 'client-ldr') + +// -- Route ------------------------------------------------------------------ + +export const Route = createFileRoute('/dehydrate-partial')({ + ssr: 'data-only', + context: { + handler: () => { + const label = getContextLabel() + return { + label, + createdAt: new Date('2024-03-15T12:00:00.000Z'), + format: (v: string) => `[${label}] ${v}`, + } + }, + dehydrate: ({ data }) => ({ + label: data.label, + createdAtISO: data.createdAt.toISOString(), + }), + hydrate: ({ data }) => ({ + label: data.label, + createdAt: new Date(data.createdAtISO), + format: (v: string) => `[${data.label}] ${v}`, + }), + }, + + beforeLoad: { + handler: () => ({ + tag: getBeforeLoadTag(), + count: 42, + pattern: /^hello-\d+$/i, + }), + dehydrate: ({ data }) => ({ + tag: data.tag, + count: data.count, + patternSource: data.pattern.source, + patternFlags: data.pattern.flags, + }), + hydrate: ({ data }) => ({ + tag: data.tag, + count: data.count, + pattern: new RegExp(data.patternSource, data.patternFlags), + }), + }, + + loader: { + handler: () => { + const scores = [10, 20, 30] + return { + title: getLoaderTitle(), + scores, + computeAvg: () => scores.reduce((a, b) => a + b, 0) / scores.length, + } + }, + dehydrate: ({ data }) => ({ + title: data.title, + scores: data.scores, + }), + hydrate: ({ data }) => ({ + title: data.title, + scores: data.scores, + computeAvg: () => + data.scores.reduce((a, b) => a + b, 0) / data.scores.length, + }), + }, + + component: DehydratePartialComponent, +}) + +function DehydratePartialComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + // Verify non-serializable parts were reconstructed correctly + const contextDateOk = context.createdAt instanceof Date + const contextFormatOk = typeof context.format === 'function' + const blPatternOk = context.pattern instanceof RegExp + const loaderComputeOk = typeof loaderData.computeAvg === 'function' + + return ( +
+

Dehydrate Partial

+ + {/* Serializable fields — verify server vs client origin */} +
{context.label}
+
{context.tag}
+
{loaderData.title}
+ + {/* Serializable field (number) */} +
{String(context.count)}
+ + {/* Non-serializable: Date — show ISO + type check */} +
+ {contextDateOk ? context.createdAt.toISOString() : 'NOT_DATE'} +
+
+ {contextDateOk ? 'Date' : 'other'} +
+ + {/* Non-serializable: function — call it and show result */} +
+ {contextFormatOk ? context.format('test') : 'NOT_FN'} +
+
+ {contextFormatOk ? 'function' : 'other'} +
+ + {/* Non-serializable: RegExp — show source + test it */} +
+ {blPatternOk ? context.pattern.source : 'NOT_REGEXP'} +
+
+ {blPatternOk ? 'RegExp' : 'other'} +
+
+ {blPatternOk ? String(context.pattern.test('hello-123')) : 'N/A'} +
+ + {/* Non-serializable: function (computeAvg) — call and show */} +
{loaderData.scores.join(',')}
+
+ {loaderComputeOk ? String(loaderData.computeAvg()) : 'NOT_FN'} +
+
+ {loaderComputeOk ? 'function' : 'other'} +
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/index.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/index.tsx new file mode 100644 index 00000000000..c0c86432dbb --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/index.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + context: () => { + return { indexContextCtx: 'index-context' } + }, + beforeLoad: () => { + return { indexBeforeLoadCtx: 'index-beforeLoad' } + }, + loader: () => { + return { indexLoaderData: 'index-loader' } + }, + component: IndexComponent, +}) +function IndexComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Home

+
{context.indexContextCtx}
+
{context.indexBeforeLoadCtx}
+
{loaderData.indexLoaderData}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/posts.$postId.comments.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/posts.$postId.comments.tsx new file mode 100644 index 00000000000..7e40dc8c1e5 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/posts.$postId.comments.tsx @@ -0,0 +1,37 @@ +import { createFileRoute } from '@tanstack/react-router' +import { getPostComments } from '~/utils/posts' + +export const Route = createFileRoute('/posts/$postId/comments')({ + context: ({ params }) => { + return { commentsContextCtx: `comments-context-${params.postId}` } + }, + beforeLoad: ({ params }) => { + return { commentsBeforeLoadCtx: `comments-beforeLoad-${params.postId}` } + }, + loader: ({ params }) => { + return { comments: getPostComments(Number(params.postId)) } + }, + component: CommentsComponent, +}) + +function CommentsComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Comments

+
{context.commentsContextCtx}
+
+ {context.commentsBeforeLoadCtx} +
+
    + {loaderData.comments.map((c) => ( +
  • + {c.author}: {c.text} +
  • + ))} +
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/posts.$postId.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/posts.$postId.tsx new file mode 100644 index 00000000000..8e98346f649 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/posts.$postId.tsx @@ -0,0 +1,34 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router' +import { getPost } from '~/utils/posts' + +export const Route = createFileRoute('/posts/$postId')({ + context: ({ params }) => { + return { postIdContextCtx: `postId-context-${params.postId}` } + }, + beforeLoad: ({ params }) => { + return { postIdBeforeLoadCtx: `postId-beforeLoad-${params.postId}` } + }, + loader: ({ params }) => { + const post = getPost(Number(params.postId)) + if (!post) { + throw new Error(`Post ${params.postId} not found`) + } + return { post } + }, + component: PostComponent, +}) + +function PostComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

{loaderData.post.title}

+

{loaderData.post.body}

+
{context.postIdContextCtx}
+
{context.postIdBeforeLoadCtx}
+ +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/posts.index.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/posts.index.tsx new file mode 100644 index 00000000000..d8a18e7f2c2 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/posts.index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/')({ + component: PostsIndexComponent, +}) + +function PostsIndexComponent() { + return ( +
+

Select a post from the list above.

+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/posts.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/posts.tsx new file mode 100644 index 00000000000..50411c9bb01 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/posts.tsx @@ -0,0 +1,42 @@ +import { createFileRoute, Link, Outlet } from '@tanstack/react-router' +import { posts } from '~/utils/posts' + +export const Route = createFileRoute('/posts')({ + context: () => { + return { postsContextCtx: 'posts-context' } + }, + beforeLoad: () => { + return { postsBeforeLoadCtx: 'posts-beforeLoad' } + }, + loader: () => { + return { posts } + }, + component: PostsComponent, +}) + +function PostsComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Posts Layout

+
{context.postsContextCtx}
+
{context.postsBeforeLoadCtx}
+
+ {loaderData.posts.map((post) => ( +
+ + {post.title} + +
+ ))} +
+ +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/revalidate-context-fn.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/revalidate-context-fn.tsx new file mode 100644 index 00000000000..4c28054ad6f --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/revalidate-context-fn.tsx @@ -0,0 +1,67 @@ +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +// Separate counters so tests can verify whether revalidation came from +// the handler or the revalidate callback. +let handlerRunCount = 0 +let revalidateRunCount = 0 + +const getContextSource = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') + +export const Route = createFileRoute('/revalidate-context-fn')({ + context: { + handler: () => { + handlerRunCount++ + return { + source: getContextSource(), + value: handlerRunCount, + revalidated: false, + revalidateRunCount, + } + }, + revalidate: ({ prev }) => { + revalidateRunCount++ + return { + source: getContextSource(), + value: (prev?.value ?? 0) + 1, + revalidated: true, + revalidateRunCount, + } + }, + dehydrate: true, + }, + beforeLoad: () => ({ rcfBeforeLoadCtx: 'revalidate-fn-beforeLoad' }), + loader: () => ({ rcfLoaderData: 'revalidate-fn-loader' }), + ssr: 'data-only', + component: RevalidateContextFnComponent, +}) + +function RevalidateContextFnComponent() { + const router = useRouter() + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Revalidate Context Function

+
{context.source}
+
{String(context.value)}
+
+ {String(context.revalidated)} +
+
+ {String(context.revalidateRunCount)} +
+
{context.rcfBeforeLoadCtx}
+
{loaderData.rcfLoaderData}
+ +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/revalidate-context.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/revalidate-context.tsx new file mode 100644 index 00000000000..41af64ba925 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/revalidate-context.tsx @@ -0,0 +1,50 @@ +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +// Track how many times context handler has run (global counter). +// On server, this resets per request. On client, it persists. +let contextRunCount = 0 + +const getContextSource = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') + +export const Route = createFileRoute('/revalidate-context')({ + context: { + handler: () => { + contextRunCount++ + return { + source: getContextSource(), + runCount: contextRunCount, + } + }, + revalidate: true, + dehydrate: true, + }, + beforeLoad: () => ({ rcBeforeLoadCtx: 'revalidate-beforeLoad' }), + loader: () => ({ rcLoaderData: 'revalidate-loader' }), + ssr: 'data-only', + component: RevalidateContextComponent, +}) + +function RevalidateContextComponent() { + const router = useRouter() + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Revalidate Context

+
{context.source}
+
{String(context.runCount)}
+
{context.rcBeforeLoadCtx}
+
{loaderData.rcLoaderData}
+ +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/stale-revalidate.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/stale-revalidate.tsx new file mode 100644 index 00000000000..0b7e0ef208b --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/stale-revalidate.tsx @@ -0,0 +1,68 @@ +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +// --------------------------------------------------------------------------- +// Stale-time-triggered context revalidation test +// +// The route uses a short staleTime (200 ms) together with `revalidate: true`. +// Each time the context handler runs it records a timestamp and increments a +// global counter. +// +// Test strategy: +// 1. SSR page load → server values (runCount=1) +// 2. Client-navigate away, wait past staleTime, navigate back → handler +// re-runs because the cached context is now stale (runCount increments). +// --------------------------------------------------------------------------- + +let contextRunCount = 0 + +const getSource = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') + +export const Route = createFileRoute('/stale-revalidate')({ + // Short staleTime so the test can trigger a stale revalidation quickly. + staleTime: 200, + + context: { + handler: () => { + contextRunCount++ + return { + source: getSource(), + runCount: contextRunCount, + timestamp: Date.now(), + } + }, + revalidate: true, + dehydrate: true, + }, + + beforeLoad: () => ({ srBeforeLoadCtx: 'stale-beforeLoad' }), + loader: () => ({ srLoaderData: 'stale-loader' }), + + ssr: 'data-only', + component: StaleRevalidateComponent, +}) + +function StaleRevalidateComponent() { + const router = useRouter() + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Stale Revalidate

+
{context.source}
+
{String(context.runCount)}
+
{String(context.timestamp)}
+
{context.srBeforeLoadCtx}
+
{loaderData.srLoaderData}
+ +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/start.ts b/e2e/react-start/router-lifecycle-methods/src/start.ts new file mode 100644 index 00000000000..97f10ce52be --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/start.ts @@ -0,0 +1,36 @@ +import { createStart } from '@tanstack/react-start' + +/** + * Default dehydrate configuration controlled by VITE_DEHYDRATE_DEFAULTS env var. + * + * Values: + * - "" (unset): No defaultDehydrate set — uses builtin defaults + * { beforeLoad: true, loader: true, context: false } + * - "all-true": All methods dehydrate by default + * { beforeLoad: true, loader: true, context: true } + * - "all-false": No methods dehydrate by default + * { beforeLoad: false, loader: false, context: false } + */ +function getDefaultDehydrate() { + const mode = import.meta.env.VITE_DEHYDRATE_DEFAULTS || '' + switch (mode) { + case 'all-true': + return { + beforeLoad: true as const, + loader: true as const, + context: true as const, + } + case 'all-false': + return { + beforeLoad: false as const, + loader: false as const, + context: false as const, + } + default: + return undefined + } +} + +export const startInstance = createStart(() => ({ + defaultDehydrate: getDefaultDehydrate(), +})) diff --git a/e2e/react-start/router-lifecycle-methods/src/styles/app.css b/e2e/react-start/router-lifecycle-methods/src/styles/app.css new file mode 100644 index 00000000000..ce9f0cbdad4 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/styles/app.css @@ -0,0 +1,36 @@ +body { + font-family: + system-ui, + -apple-system, + sans-serif; + margin: 0; + padding: 16px; +} + +a { + color: #0070f3; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +nav { + display: flex; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid #eee; + margin-bottom: 16px; +} + +.lifecycle-log { + background: #f5f5f5; + border: 1px solid #ddd; + padding: 8px; + margin: 8px 0; + font-family: monospace; + font-size: 12px; + max-height: 300px; + overflow-y: auto; +} diff --git a/e2e/react-start/router-lifecycle-methods/src/utils/posts.ts b/e2e/react-start/router-lifecycle-methods/src/utils/posts.ts new file mode 100644 index 00000000000..2c17ac36509 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/utils/posts.ts @@ -0,0 +1,36 @@ +/** + * Inline post data for testing - no external API needed + */ +export interface Post { + id: number + title: string + body: string +} + +export interface Comment { + id: number + postId: number + author: string + text: string +} + +export const posts: Array = [ + { id: 1, title: 'First Post', body: 'This is the first post body.' }, + { id: 2, title: 'Second Post', body: 'This is the second post body.' }, + { id: 3, title: 'Third Post', body: 'This is the third post body.' }, +] + +export const comments: Array = [ + { id: 1, postId: 1, author: 'Alice', text: 'Great first post!' }, + { id: 2, postId: 1, author: 'Bob', text: 'Welcome to the blog.' }, + { id: 3, postId: 2, author: 'Charlie', text: 'Interesting read.' }, + { id: 4, postId: 3, author: 'Dave', text: 'Thanks for sharing.' }, +] + +export function getPost(id: number): Post | undefined { + return posts.find((p) => p.id === id) +} + +export function getPostComments(postId: number): Array { + return comments.filter((c) => c.postId === postId) +} diff --git a/e2e/react-start/router-lifecycle-methods/tests/app.spec.ts b/e2e/react-start/router-lifecycle-methods/tests/app.spec.ts new file mode 100644 index 00000000000..c49f71ab029 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/app.spec.ts @@ -0,0 +1,1082 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { getEffectiveDefaults } from './utils/dehydrateDefaults' + +test.describe('lifecycle methods - SSR', () => { + test('home page renders root and index lifecycle context', async ({ + page, + }) => { + await page.goto('/') + + // Root lifecycle context (always visible) + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('root-beforeLoad')).toHaveText( + 'root-beforeLoad', + ) + + // Index route lifecycle context + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await expect(page.getByTestId('index-context')).toHaveText('index-context') + await expect(page.getByTestId('index-beforeLoad')).toHaveText( + 'index-beforeLoad', + ) + + // Index loader data + await expect(page.getByTestId('index-loader')).toHaveText('index-loader') + }) + + test('post detail page renders all lifecycle context for root, posts, and post', async ({ + page, + }) => { + await page.goto('/posts/1') + + // Root lifecycle context + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('root-beforeLoad')).toHaveText( + 'root-beforeLoad', + ) + + // Posts layout lifecycle context + await expect(page.getByTestId('posts-heading')).toHaveText('Posts Layout') + await expect(page.getByTestId('posts-context')).toHaveText('posts-context') + await expect(page.getByTestId('posts-beforeLoad')).toHaveText( + 'posts-beforeLoad', + ) + + // Post detail lifecycle context (param-dependent) + await expect(page.getByTestId('post-heading')).toHaveText('First Post') + await expect(page.getByTestId('post-body')).toHaveText( + 'This is the first post body.', + ) + await expect(page.getByTestId('post-context')).toHaveText( + 'postId-context-1', + ) + await expect(page.getByTestId('post-beforeLoad')).toHaveText( + 'postId-beforeLoad-1', + ) + }) + + test('comments page renders full lifecycle context chain', async ({ + page, + }) => { + await page.goto('/posts/1/comments') + + // Root + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('root-beforeLoad')).toHaveText( + 'root-beforeLoad', + ) + + // Posts + await expect(page.getByTestId('posts-context')).toHaveText('posts-context') + await expect(page.getByTestId('posts-beforeLoad')).toHaveText( + 'posts-beforeLoad', + ) + + // Post (param-dependent) + await expect(page.getByTestId('post-context')).toHaveText( + 'postId-context-1', + ) + await expect(page.getByTestId('post-beforeLoad')).toHaveText( + 'postId-beforeLoad-1', + ) + + // Comments (param-dependent) + await expect(page.getByTestId('comments-heading')).toHaveText('Comments') + await expect(page.getByTestId('comments-context')).toHaveText( + 'comments-context-1', + ) + await expect(page.getByTestId('comments-beforeLoad')).toHaveText( + 'comments-beforeLoad-1', + ) + + // Comments loader data + await expect(page.getByTestId('comment-1')).toHaveText( + 'Alice: Great first post!', + ) + await expect(page.getByTestId('comment-2')).toHaveText( + 'Bob: Welcome to the blog.', + ) + }) + + test('lifecycle context includes correct param values for post 2', async ({ + page, + }) => { + await page.goto('/posts/2') + + await expect(page.getByTestId('post-heading')).toHaveText('Second Post') + await expect(page.getByTestId('post-body')).toHaveText( + 'This is the second post body.', + ) + await expect(page.getByTestId('post-context')).toHaveText( + 'postId-context-2', + ) + await expect(page.getByTestId('post-beforeLoad')).toHaveText( + 'postId-beforeLoad-2', + ) + }) +}) + +test.describe('lifecycle methods - client navigation', () => { + test('navigating from home to post detail renders correct context', async ({ + page, + }) => { + await page.goto('/') + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId('link-post-1').click() + await expect(page.getByTestId('post-heading')).toHaveText('First Post') + + await expect(page.getByTestId('post-context')).toHaveText( + 'postId-context-1', + ) + await expect(page.getByTestId('post-beforeLoad')).toHaveText( + 'postId-beforeLoad-1', + ) + + // Root context should still be visible + await expect(page.getByTestId('root-context')).toHaveText('root-context') + }) + + test('navigating between posts updates param-dependent context', async ({ + page, + }) => { + await page.goto('/posts/1') + await expect(page.getByTestId('post-heading')).toHaveText('First Post') + await expect(page.getByTestId('post-context')).toHaveText( + 'postId-context-1', + ) + + await page.getByTestId('link-post-2').click() + await expect(page.getByTestId('post-heading')).toHaveText('Second Post') + + await expect(page.getByTestId('post-context')).toHaveText( + 'postId-context-2', + ) + await expect(page.getByTestId('post-beforeLoad')).toHaveText( + 'postId-beforeLoad-2', + ) + }) + + test('navigating from post back to home renders index context', async ({ + page, + }) => { + await page.goto('/posts/1') + await expect(page.getByTestId('post-heading')).toHaveText('First Post') + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await expect(page.getByTestId('index-context')).toHaveText('index-context') + await expect(page.getByTestId('index-beforeLoad')).toHaveText( + 'index-beforeLoad', + ) + await expect(page.getByTestId('index-loader')).toHaveText('index-loader') + }) + + test('navigating to comments renders nested context chain', async ({ + page, + }) => { + await page.goto('/') + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId('link-post-1-comments').click() + await expect(page.getByTestId('comments-heading')).toHaveText('Comments') + + // All levels of context should be visible simultaneously + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('posts-context')).toHaveText('posts-context') + await expect(page.getByTestId('post-context')).toHaveText( + 'postId-context-1', + ) + await expect(page.getByTestId('comments-context')).toHaveText( + 'comments-context-1', + ) + await expect(page.getByTestId('comments-beforeLoad')).toHaveText( + 'comments-beforeLoad-1', + ) + }) +}) + +test.describe('lifecycle methods - context accumulation', () => { + test('all ancestor context is visible when viewing deeply nested route', async ({ + page, + }) => { + await page.goto('/posts/1/comments') + + // Root context + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('root-beforeLoad')).toHaveText( + 'root-beforeLoad', + ) + + // Posts layout context + await expect(page.getByTestId('posts-context')).toHaveText('posts-context') + await expect(page.getByTestId('posts-beforeLoad')).toHaveText( + 'posts-beforeLoad', + ) + + // Post detail context + await expect(page.getByTestId('post-context')).toHaveText( + 'postId-context-1', + ) + await expect(page.getByTestId('post-beforeLoad')).toHaveText( + 'postId-beforeLoad-1', + ) + + // Comments own context + await expect(page.getByTestId('comments-context')).toHaveText( + 'comments-context-1', + ) + await expect(page.getByTestId('comments-beforeLoad')).toHaveText( + 'comments-beforeLoad-1', + ) + }) + + test('posts layout shows posts index when navigating to /posts', async ({ + page, + }) => { + await page.goto('/posts') + + await expect(page.getByTestId('posts-heading')).toHaveText('Posts Layout') + await expect(page.getByTestId('posts-context')).toHaveText('posts-context') + await expect(page.getByTestId('posts-beforeLoad')).toHaveText( + 'posts-beforeLoad', + ) + + // Posts index should be rendered in the outlet + await expect(page.getByTestId('posts-index-text')).toHaveText( + 'Select a post from the list above.', + ) + + // Post-level context should NOT be visible (no post route mounted) + await expect(page.getByTestId('post-context')).not.toBeVisible() + }) +}) + +// ============================================================================ +// Dehydrate tests — blackbox testing using createIsomorphicFn +// +// Each route's lifecycle methods return 'server-{prefix}-{method}' on server +// and 'client-{prefix}-{method}' on client (via createIsomorphicFn). +// +// Dehydrated methods → server runs handler, value is sent to client via wire +// → DOM shows 'server-{prefix}-{method}' +// Non-dehydrated methods → client re-executes the handler +// → DOM shows 'client-{prefix}-{method}' +// +// This applies to both SSR page loads and client-side navigation. +// +// The effective dehydration depends on three levels (highest priority first): +// 1. Method-level: { handler, dehydrate: true/false } on the route option +// 2. Router-level: defaultDehydrate from start.ts (controlled by DEHYDRATE_DEFAULTS env var) +// 3. Builtin defaults: { beforeLoad: true, loader: true, context: false } +// ============================================================================ + +/** + * Compute the expected rendered value for a method. + * @param prefix - Route prefix (e.g. 'dd', 'dat') + * @param method - Method name (e.g. 'context', 'beforeLoad') + * @param dehydrated - Whether the method is effectively dehydrated + */ +function expectedValue( + prefix: string, + method: string, + dehydrated: boolean, +): string { + const env = dehydrated ? 'server' : 'client' + return `${env}-${prefix}-${method}` +} + +// Route-specific dehydrate configs: +// Each entry describes whether each method is effectively dehydrated. +// For routes with explicit dehydrate flags, those always win. +// For routes using function form (no explicit dehydrate), the effective +// default comes from getEffectiveDefaults() which reads DEHYDRATE_DEFAULTS. + +interface RouteDehydrateConfig { + path: string + prefix: string + heading: string + headingTestId: string + // For each method: true if dehydrate flag is explicit, with the value. + // null means "use defaults" (function form). + context: boolean | null + beforeLoad: boolean | null + loader: boolean | null +} + +const dehydrateRoutes: Array = [ + { + path: '/dehydrate-defaults', + prefix: 'dd', + heading: 'Dehydrate Defaults', + headingTestId: 'dd-heading', + context: null, // function form → uses defaults + beforeLoad: null, + loader: null, + }, + { + path: '/dehydrate-all-true', + prefix: 'dat', + heading: 'Dehydrate All True', + headingTestId: 'dat-heading', + context: true, + beforeLoad: true, + loader: true, + }, + { + path: '/dehydrate-all-false', + prefix: 'daf', + heading: 'Dehydrate All False', + headingTestId: 'daf-heading', + context: false, + beforeLoad: false, + loader: false, + }, + { + path: '/dehydrate-mixed', + prefix: 'dm', + heading: 'Dehydrate Mixed', + headingTestId: 'dm-heading', + context: true, + beforeLoad: false, + loader: true, + }, + { + path: '/dehydrate-beforeload-false', + prefix: 'dbf', + heading: 'Dehydrate BeforeLoad False', + headingTestId: 'dbf-heading', + context: null, + beforeLoad: false, + loader: null, + }, + { + path: '/dehydrate-loader-false', + prefix: 'dlf', + heading: 'Dehydrate Loader False', + headingTestId: 'dlf-heading', + context: null, + beforeLoad: null, + loader: false, + }, + { + path: '/dehydrate-context-true', + prefix: 'dct', + heading: 'Dehydrate Context True', + headingTestId: 'dct-heading', + context: true, + beforeLoad: null, + loader: null, + }, +] + +/** + * Resolve the effective dehydrate flag for a method on a route. + * Method-level explicit flag wins; otherwise uses the router-level default. + */ +function isEffectivelyDehydrated( + explicitFlag: boolean | null, + defaultFlag: boolean, +): boolean { + if (explicitFlag !== null) return explicitFlag + return defaultFlag +} + +/** + * Execution path modes for getExpectedValues: + * + * 'ssr' — Direct page.goto(route). All 3 methods run on server during SSR. + * Dehydrated methods → server value sent to client via wire → 'server-*' + * Non-dehydrated methods → client re-executes after hydration → 'client-*' + * + * 'clientNav' — Client-side navigation (link click) after hydration. No server + * involvement. All methods run locally on the client → 'client-*' for everything. + * + * 'roundTrip' — page.goto(route), nav to '/', nav back to route. Route was + * visited during SSR, so caches may exist. On the return client navigation: + * - context: CACHED (match already exists, never re-runs) → retains SSR hydration value + * - beforeLoad: ALWAYS re-runs on client → 'client-*' + * - loader: RE-RUNS on client (staleTime=0) → 'client-*' + */ +type ExecutionPath = 'ssr' | 'clientNav' | 'roundTrip' + +/** + * Get the full set of expected values for a route, given the current defaults + * and the execution path. + */ +function getExpectedValues( + route: RouteDehydrateConfig, + mode: ExecutionPath = 'ssr', +) { + const defaults = getEffectiveDefaults() + const { prefix } = route + + if (mode === 'clientNav') { + // Client navigation: everything runs on the client, no server calls + return { + context: expectedValue(prefix, 'context', false), + beforeLoad: expectedValue(prefix, 'beforeLoad', false), + loader: expectedValue(prefix, 'loader', false), + } + } + + // For SSR, dehydrate config determines which env runs each method + const contextDehydrated = isEffectivelyDehydrated( + route.context, + defaults.context, + ) + const beforeLoadDehydrated = isEffectivelyDehydrated( + route.beforeLoad, + defaults.beforeLoad, + ) + const loaderDehydrated = isEffectivelyDehydrated( + route.loader, + defaults.loader, + ) + + if (mode === 'ssr') { + return { + context: expectedValue(prefix, 'context', contextDehydrated), + beforeLoad: expectedValue(prefix, 'beforeLoad', beforeLoadDehydrated), + loader: expectedValue(prefix, 'loader', loaderDehydrated), + } + } + + // 'roundTrip' mode: SSR page load first, then nav away and back. + // Cached values retain whatever they were after SSR hydration. + // Re-running methods always run on the client (no server calls). + return { + // context: CACHED from SSR hydration (never re-runs for existing match) + context: expectedValue(prefix, 'context', contextDehydrated), + // beforeLoad: ALWAYS re-runs on client + beforeLoad: expectedValue(prefix, 'beforeLoad', false), + // loader: RE-RUNS on client (staleTime=0, considered stale) + loader: expectedValue(prefix, 'loader', false), + } +} + +test.describe('dehydrate - SSR rendering and hydration', () => { + for (const route of dehydrateRoutes) { + test(`${route.path}: renders correct values after hydration`, async ({ + page, + }) => { + await page.goto(route.path) + + const expected = getExpectedValues(route) + + // Wait for the route to load + await expect(page.getByTestId(route.headingTestId)).toHaveText( + route.heading, + ) + + // Assert all 3 lifecycle method values + await expect(page.getByTestId(`${route.prefix}-context`)).toHaveText( + expected.context, + ) + await expect(page.getByTestId(`${route.prefix}-beforeLoad`)).toHaveText( + expected.beforeLoad, + ) + await expect(page.getByTestId(`${route.prefix}-loader`)).toHaveText( + expected.loader, + ) + }) + } +}) + +test.describe('dehydrate - client navigation', () => { + // Client-side navigation runs all lifecycle methods on the client. + // No server involvement. We must wait for hydration before clicking links, + // otherwise the click may trigger a full page navigation (SSR) instead. + for (const route of dehydrateRoutes) { + test(`client nav to ${route.path}: values match dehydrate config`, async ({ + page, + }) => { + await page.goto('/') + // Wait for hydration to complete before clicking any links + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId(`link-${route.path.slice(1)}`).click() + await expect(page.getByTestId(route.headingTestId)).toHaveText( + route.heading, + ) + + const expected = getExpectedValues(route, 'clientNav') + + await expect(page.getByTestId(`${route.prefix}-context`)).toHaveText( + expected.context, + ) + await expect(page.getByTestId(`${route.prefix}-beforeLoad`)).toHaveText( + expected.beforeLoad, + ) + await expect(page.getByTestId(`${route.prefix}-loader`)).toHaveText( + expected.loader, + ) + }) + } + + test('navigating between dehydrate routes preserves root context', async ({ + page, + }) => { + await page.goto('/dehydrate-defaults') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('dd-heading')).toHaveText( + 'Dehydrate Defaults', + ) + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('root-beforeLoad')).toHaveText( + 'root-beforeLoad', + ) + + // Navigate to dehydrate-all-true + await page.getByTestId('link-dehydrate-all-true').click() + await expect(page.getByTestId('dat-heading')).toHaveText( + 'Dehydrate All True', + ) + + // Root context should still be correct + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('root-beforeLoad')).toHaveText( + 'root-beforeLoad', + ) + }) + + test('navigating from dehydrate route back to home works correctly', async ({ + page, + }) => { + await page.goto('/dehydrate-all-false') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('daf-heading')).toHaveText( + 'Dehydrate All False', + ) + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await expect(page.getByTestId('index-context')).toHaveText('index-context') + await expect(page.getByTestId('index-beforeLoad')).toHaveText( + 'index-beforeLoad', + ) + await expect(page.getByTestId('index-loader')).toHaveText('index-loader') + }) +}) + +test.describe('dehydrate - post-hydration round-trip', () => { + // After SSR + hydration, navigate away and back. On the return trip: + // - context: CACHED from SSR (match already exists) → retains SSR hydration value + // - beforeLoad: ALWAYS re-runs → 'client-*' + // - loader: RE-RUNS (staleTime=0, considered stale) → 'client-*' + for (const route of dehydrateRoutes) { + test(`${route.path}: round-trip has correct caching behavior`, async ({ + page, + }) => { + // Step 1: SSR page load — wait for hydration + await page.goto(route.path) + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId(route.headingTestId)).toHaveText( + route.heading, + ) + + const ssrExpected = getExpectedValues(route, 'ssr') + + // Verify SSR values + await expect(page.getByTestId(`${route.prefix}-context`)).toHaveText( + ssrExpected.context, + ) + await expect(page.getByTestId(`${route.prefix}-beforeLoad`)).toHaveText( + ssrExpected.beforeLoad, + ) + await expect(page.getByTestId(`${route.prefix}-loader`)).toHaveText( + ssrExpected.loader, + ) + + // Step 2: Navigate away + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + // Step 3: Navigate back (client navigation with existing caches) + await page.getByTestId(`link-${route.path.slice(1)}`).click() + await expect(page.getByTestId(route.headingTestId)).toHaveText( + route.heading, + ) + + const roundTripExpected = getExpectedValues(route, 'roundTrip') + + await expect(page.getByTestId(`${route.prefix}-context`)).toHaveText( + roundTripExpected.context, + ) + await expect(page.getByTestId(`${route.prefix}-beforeLoad`)).toHaveText( + roundTripExpected.beforeLoad, + ) + await expect(page.getByTestId(`${route.prefix}-loader`)).toHaveText( + roundTripExpected.loader, + ) + }) + } +}) + +// ============================================================================ +// Dehydrate functions — dehydrate/hydrate function pairs +// +// The dehydrate-fn route uses Date objects in each lifecycle method. +// dehydrate converts Date → ISO string for the wire. +// hydrate reconstructs Date from ISO string on the client. +// +// Server dates are 2020-* and client dates are 2099-*. +// After SSR hydration, all values should be server dates (dehydrated + hydrated). +// After client navigation, all values should be client dates (handler runs locally). +// ============================================================================ + +test.describe('dehydrate-fn - dehydrate/hydrate function pairs', () => { + test('SSR: all dates are server-side and hydrated as Date instances', async ({ + page, + }) => { + await page.goto('/dehydrate-fn') + await expect(page.getByTestId('dfn-heading')).toHaveText( + 'Dehydrate Functions', + ) + + // All values should be server dates (dehydrated from server, hydrated on client) + await expect(page.getByTestId('dfn-context')).toHaveText( + '2020-01-01T00:00:00.000Z', + ) + await expect(page.getByTestId('dfn-beforeLoad')).toHaveText( + '2020-06-15T00:00:00.000Z', + ) + await expect(page.getByTestId('dfn-loader')).toHaveText( + '2020-12-25T00:00:00.000Z', + ) + + // Verify hydrate correctly reconstructed Date instances + await expect(page.getByTestId('dfn-context-type')).toHaveText('Date') + await expect(page.getByTestId('dfn-beforeLoad-type')).toHaveText('Date') + await expect(page.getByTestId('dfn-loader-type')).toHaveText('Date') + }) + + test('client nav: all dates are client-side Date instances', async ({ + page, + }) => { + await page.goto('/') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId('link-dehydrate-fn').click() + await expect(page.getByTestId('dfn-heading')).toHaveText( + 'Dehydrate Functions', + ) + + // All values should be client dates (handler runs locally) + await expect(page.getByTestId('dfn-context')).toHaveText( + '2099-01-01T00:00:00.000Z', + ) + await expect(page.getByTestId('dfn-beforeLoad')).toHaveText( + '2099-06-15T00:00:00.000Z', + ) + await expect(page.getByTestId('dfn-loader')).toHaveText( + '2099-12-25T00:00:00.000Z', + ) + + // Verify Date instances + await expect(page.getByTestId('dfn-context-type')).toHaveText('Date') + await expect(page.getByTestId('dfn-beforeLoad-type')).toHaveText('Date') + await expect(page.getByTestId('dfn-loader-type')).toHaveText('Date') + }) + + test('round-trip: context retains SSR date, beforeLoad/loader re-run on client', async ({ + page, + }) => { + // Step 1: SSR page load + await page.goto('/dehydrate-fn') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('dfn-heading')).toHaveText( + 'Dehydrate Functions', + ) + + // Verify SSR values + await expect(page.getByTestId('dfn-context')).toHaveText( + '2020-01-01T00:00:00.000Z', + ) + await expect(page.getByTestId('dfn-beforeLoad')).toHaveText( + '2020-06-15T00:00:00.000Z', + ) + await expect(page.getByTestId('dfn-loader')).toHaveText( + '2020-12-25T00:00:00.000Z', + ) + + // Step 2: Navigate away + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + // Step 3: Navigate back (client navigation with existing caches) + await page.getByTestId('link-dehydrate-fn').click() + await expect(page.getByTestId('dfn-heading')).toHaveText( + 'Dehydrate Functions', + ) + + // context: CACHED from SSR hydration → retains server date + await expect(page.getByTestId('dfn-context')).toHaveText( + '2020-01-01T00:00:00.000Z', + ) + // beforeLoad: ALWAYS re-runs on client → client date + await expect(page.getByTestId('dfn-beforeLoad')).toHaveText( + '2099-06-15T00:00:00.000Z', + ) + // loader: RE-RUNS on client (staleTime=0) → client date + await expect(page.getByTestId('dfn-loader')).toHaveText( + '2099-12-25T00:00:00.000Z', + ) + + // All should still be Date instances + await expect(page.getByTestId('dfn-context-type')).toHaveText('Date') + await expect(page.getByTestId('dfn-beforeLoad-type')).toHaveText('Date') + await expect(page.getByTestId('dfn-loader-type')).toHaveText('Date') + }) +}) + +// ============================================================================ +// Revalidate context tests +// +// The revalidate-context route has a context handler that tracks how many times +// it has been called (global counter). It uses revalidate: true to re-run the +// handler when the route is invalidated. +// +// The route also dehydrates the context (dehydrate: true) so SSR values +// are sent via wire. +// ============================================================================ + +test.describe('revalidate-context - context revalidation', () => { + test('SSR: context shows server source and runCount=1', async ({ page }) => { + await page.goto('/revalidate-context') + await expect(page.getByTestId('rc-heading')).toHaveText( + 'Revalidate Context', + ) + + await expect(page.getByTestId('rc-context-source')).toHaveText('server') + await expect(page.getByTestId('rc-context-runCount')).toHaveText('1') + }) + + test('client nav: context shows client source and runCount=1', async ({ + page, + }) => { + await page.goto('/') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId('link-revalidate-context').click() + await expect(page.getByTestId('rc-heading')).toHaveText( + 'Revalidate Context', + ) + + await expect(page.getByTestId('rc-context-source')).toHaveText('client') + await expect(page.getByTestId('rc-context-runCount')).toHaveText('1') + }) + + test('invalidation: clicking invalidate re-runs context handler', async ({ + page, + }) => { + // Use client-side navigation to avoid server module counter interference + // between tests (the server process persists across test cases). + await page.goto('/') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + + // Client-nav to revalidate-context route (server counter not involved) + await page.getByTestId('link-revalidate-context').click() + await expect(page.getByTestId('rc-heading')).toHaveText( + 'Revalidate Context', + ) + + // After client navigation, handler runs on client — runCount=1, source='client' + await expect(page.getByTestId('rc-context-source')).toHaveText('client') + await expect(page.getByTestId('rc-context-runCount')).toHaveText('1') + + // Click invalidate — should trigger revalidation since revalidate: true + await page.getByTestId('rc-invalidate-btn').click() + + // After invalidation the handler re-runs on the client. + // Client module counter increments: 1→2 + await expect(page.getByTestId('rc-context-source')).toHaveText('client') + await expect(page.getByTestId('rc-context-runCount')).toHaveText('2') + }) +}) + +// ============================================================================ +// Revalidate context function tests +// +// The revalidate-context-fn route uses `revalidate` as a function and reads +// `ctx.prev` to derive the next context value. This verifies the callback path +// (not just `revalidate: true`) and confirms `prev` is wired correctly. +// ============================================================================ + +test.describe('revalidate-context-fn - functional revalidation with prev', () => { + test('SSR: context comes from handler (not revalidate fn)', async ({ + page, + }) => { + await page.goto('/revalidate-context-fn') + await expect(page.getByTestId('rcf-heading')).toHaveText( + 'Revalidate Context Function', + ) + + await expect(page.getByTestId('rcf-context-source')).toHaveText('server') + await expect(page.getByTestId('rcf-context-value')).toHaveText('1') + await expect(page.getByTestId('rcf-context-revalidated')).toHaveText( + 'false', + ) + await expect(page.getByTestId('rcf-context-revalidateRunCount')).toHaveText( + '0', + ) + }) + + test('client nav: initial context comes from handler on client', async ({ + page, + }) => { + await page.goto('/') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId('link-revalidate-context-fn').click() + await expect(page.getByTestId('rcf-heading')).toHaveText( + 'Revalidate Context Function', + ) + + await expect(page.getByTestId('rcf-context-source')).toHaveText('client') + await expect(page.getByTestId('rcf-context-value')).toHaveText('1') + await expect(page.getByTestId('rcf-context-revalidated')).toHaveText( + 'false', + ) + await expect(page.getByTestId('rcf-context-revalidateRunCount')).toHaveText( + '0', + ) + }) + + test('invalidation: revalidate fn runs and uses prev value', async ({ + page, + }) => { + await page.goto('/') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await page.getByTestId('link-revalidate-context-fn').click() + await expect(page.getByTestId('rcf-heading')).toHaveText( + 'Revalidate Context Function', + ) + + await expect(page.getByTestId('rcf-context-value')).toHaveText('1') + await expect(page.getByTestId('rcf-context-revalidated')).toHaveText( + 'false', + ) + await expect(page.getByTestId('rcf-context-revalidateRunCount')).toHaveText( + '0', + ) + + // First invalidation: prev.value=1 -> revalidate returns value=2 + await page.getByTestId('rcf-invalidate-btn').click() + await expect(page.getByTestId('rcf-context-source')).toHaveText('client') + await expect(page.getByTestId('rcf-context-value')).toHaveText('2') + await expect(page.getByTestId('rcf-context-revalidated')).toHaveText('true') + await expect(page.getByTestId('rcf-context-revalidateRunCount')).toHaveText( + '1', + ) + + // Second invalidation: prev.value=2 -> revalidate returns value=3 + await page.getByTestId('rcf-invalidate-btn').click() + await expect(page.getByTestId('rcf-context-value')).toHaveText('3') + await expect(page.getByTestId('rcf-context-revalidateRunCount')).toHaveText( + '2', + ) + }) +}) + +// ============================================================================ +// Dehydrate partial — partial hydration of mixed serializable/non-serializable +// +// Each lifecycle returns an object with both serializable fields (string, +// number, array) and non-serializable fields (Date, function, RegExp). +// `dehydrate` strips the non-serializable parts for the wire payload. +// `hydrate` reconstructs the full shape on the client from the wire data. +// +// Server prefixes are "server-*", client prefixes are "client-*". +// After SSR: serializable fields show server values, non-serializable parts +// are reconstructed via hydrate. +// After client nav: everything runs locally with client prefixes. +// ============================================================================ + +test.describe('dehydrate-partial - partial hydration', () => { + test('SSR: all fields present with server origin, non-serializable parts reconstructed', async ({ + page, + }) => { + await page.goto('/dehydrate-partial') + await expect(page.getByTestId('dp-heading')).toHaveText('Dehydrate Partial') + + // Serializable fields — server origin + await expect(page.getByTestId('dp-context-label')).toHaveText('server-ctx') + await expect(page.getByTestId('dp-beforeLoad-tag')).toHaveText('server-bl') + await expect(page.getByTestId('dp-loader-title')).toHaveText('server-ldr') + await expect(page.getByTestId('dp-beforeLoad-count')).toHaveText('42') + + // Non-serializable: Date reconstructed via hydrate + await expect(page.getByTestId('dp-context-date')).toHaveText( + '2024-03-15T12:00:00.000Z', + ) + await expect(page.getByTestId('dp-context-date-type')).toHaveText('Date') + + // Non-serializable: function reconstructed via hydrate + await expect(page.getByTestId('dp-context-format')).toHaveText( + '[server-ctx] test', + ) + await expect(page.getByTestId('dp-context-format-type')).toHaveText( + 'function', + ) + + // Non-serializable: RegExp reconstructed via hydrate + await expect(page.getByTestId('dp-beforeLoad-pattern')).toHaveText( + '^hello-\\d+$', + ) + await expect(page.getByTestId('dp-beforeLoad-pattern-type')).toHaveText( + 'RegExp', + ) + await expect(page.getByTestId('dp-beforeLoad-pattern-test')).toHaveText( + 'true', + ) + + // Non-serializable: computeAvg function reconstructed via hydrate + await expect(page.getByTestId('dp-loader-scores')).toHaveText('10,20,30') + await expect(page.getByTestId('dp-loader-avg')).toHaveText('20') + await expect(page.getByTestId('dp-loader-avg-type')).toHaveText('function') + }) + + test('client nav: all fields present with client origin', async ({ + page, + }) => { + await page.goto('/') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId('link-dehydrate-partial').click() + await expect(page.getByTestId('dp-heading')).toHaveText('Dehydrate Partial') + + // Serializable fields — client origin + await expect(page.getByTestId('dp-context-label')).toHaveText('client-ctx') + await expect(page.getByTestId('dp-beforeLoad-tag')).toHaveText('client-bl') + await expect(page.getByTestId('dp-loader-title')).toHaveText('client-ldr') + await expect(page.getByTestId('dp-beforeLoad-count')).toHaveText('42') + + // Non-serializable: all types present (handler ran locally) + await expect(page.getByTestId('dp-context-date-type')).toHaveText('Date') + await expect(page.getByTestId('dp-context-format')).toHaveText( + '[client-ctx] test', + ) + await expect(page.getByTestId('dp-context-format-type')).toHaveText( + 'function', + ) + await expect(page.getByTestId('dp-beforeLoad-pattern-type')).toHaveText( + 'RegExp', + ) + await expect(page.getByTestId('dp-beforeLoad-pattern-test')).toHaveText( + 'true', + ) + await expect(page.getByTestId('dp-loader-avg')).toHaveText('20') + await expect(page.getByTestId('dp-loader-avg-type')).toHaveText('function') + }) + + test('round-trip: context retains SSR hydrated values, beforeLoad/loader re-run on client', async ({ + page, + }) => { + // Step 1: SSR page load + await page.goto('/dehydrate-partial') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('dp-heading')).toHaveText('Dehydrate Partial') + + // Verify SSR values + await expect(page.getByTestId('dp-context-label')).toHaveText('server-ctx') + await expect(page.getByTestId('dp-context-format')).toHaveText( + '[server-ctx] test', + ) + + // Step 2: Navigate away + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + // Step 3: Navigate back (client navigation with existing caches) + await page.getByTestId('link-dehydrate-partial').click() + await expect(page.getByTestId('dp-heading')).toHaveText('Dehydrate Partial') + + // context: CACHED from SSR hydration → retains server values + await expect(page.getByTestId('dp-context-label')).toHaveText('server-ctx') + await expect(page.getByTestId('dp-context-date-type')).toHaveText('Date') + await expect(page.getByTestId('dp-context-format')).toHaveText( + '[server-ctx] test', + ) + await expect(page.getByTestId('dp-context-format-type')).toHaveText( + 'function', + ) + + // beforeLoad: ALWAYS re-runs → client values + await expect(page.getByTestId('dp-beforeLoad-tag')).toHaveText('client-bl') + await expect(page.getByTestId('dp-beforeLoad-pattern-type')).toHaveText( + 'RegExp', + ) + + // loader: RE-RUNS (staleTime=0) → client values + await expect(page.getByTestId('dp-loader-title')).toHaveText('client-ldr') + await expect(page.getByTestId('dp-loader-avg-type')).toHaveText('function') + }) +}) + +// ============================================================================ +// Stale revalidate — staleTime-triggered context revalidation +// +// The route uses staleTime: 200ms with revalidate: true. When the context +// cache is older than 200ms, navigating back to the route triggers the +// context handler to re-run. +// ============================================================================ + +test.describe('stale-revalidate - staleTime-triggered context revalidation', () => { + test('SSR: context shows server source and runCount=1', async ({ page }) => { + await page.goto('/stale-revalidate') + await expect(page.getByTestId('sr-heading')).toHaveText('Stale Revalidate') + + await expect(page.getByTestId('sr-context-source')).toHaveText('server') + await expect(page.getByTestId('sr-context-runCount')).toHaveText('1') + }) + + test('client nav: context shows client source and runCount=1', async ({ + page, + }) => { + await page.goto('/') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId('link-stale-revalidate').click() + await expect(page.getByTestId('sr-heading')).toHaveText('Stale Revalidate') + + await expect(page.getByTestId('sr-context-source')).toHaveText('client') + await expect(page.getByTestId('sr-context-runCount')).toHaveText('1') + }) + + test('stale revalidation: context re-runs after staleTime elapses', async ({ + page, + }) => { + // Step 1: Navigate to the page (client-side, fresh context) + await page.goto('/') + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + + await page.getByTestId('link-stale-revalidate').click() + await expect(page.getByTestId('sr-heading')).toHaveText('Stale Revalidate') + await expect(page.getByTestId('sr-context-runCount')).toHaveText('1') + + // Step 2: Navigate away + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + // Step 3: Wait past the staleTime (200ms + buffer) + await page.waitForTimeout(400) + + // Step 4: Navigate back — context is now stale, should re-run + await page.getByTestId('link-stale-revalidate').click() + await expect(page.getByTestId('sr-heading')).toHaveText('Stale Revalidate') + + // Context handler re-ran due to staleness → runCount incremented + await expect(page.getByTestId('sr-context-runCount')).toHaveText('2') + await expect(page.getByTestId('sr-context-source')).toHaveText('client') + }) +}) diff --git a/e2e/react-start/router-lifecycle-methods/tests/utils/dehydrateDefaults.ts b/e2e/react-start/router-lifecycle-methods/tests/utils/dehydrateDefaults.ts new file mode 100644 index 00000000000..8186e5e329e --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/utils/dehydrateDefaults.ts @@ -0,0 +1,47 @@ +/** + * The DEHYDRATE_DEFAULTS env var controls what defaultDehydrate config + * is passed to createStart(). Tests read this to know what the effective + * defaults are and compute expected values accordingly. + * + * Values: + * - "" (unset): builtin defaults { beforeLoad: true, loader: true, context: false } + * - "all-true": { beforeLoad: true, loader: true, context: true } + * - "all-false": { beforeLoad: false, loader: false, context: false } + */ +export const dehydrateDefaultsMode: string = + process.env.DEHYDRATE_DEFAULTS || '' + +export interface MethodDefaults { + context: boolean + beforeLoad: boolean + loader: boolean +} + +/** + * Returns the effective default dehydrate flag for each lifecycle method, + * considering the DEHYDRATE_DEFAULTS env var (router-level defaultDehydrate) + * and the builtin defaults. + */ +export function getEffectiveDefaults(): MethodDefaults { + switch (dehydrateDefaultsMode) { + case 'all-true': + return { + context: true, + beforeLoad: true, + loader: true, + } + case 'all-false': + return { + context: false, + beforeLoad: false, + loader: false, + } + default: + // Builtin defaults + return { + context: false, + beforeLoad: true, + loader: true, + } + } +} diff --git a/e2e/react-start/router-lifecycle-methods/tsconfig.json b/e2e/react-start/router-lifecycle-methods/tsconfig.json new file mode 100644 index 00000000000..3a9fb7cd716 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/router-lifecycle-methods/vite.config.ts b/e2e/react-start/router-lifecycle-methods/vite.config.ts new file mode 100644 index 00000000000..04033010e8f --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart(), + viteReact(), + ], +}) diff --git a/examples/react/basic-virtual-inside-file-based/src/routeTree.gen.ts b/examples/react/basic-virtual-inside-file-based/src/routeTree.gen.ts index 947fd037d9e..13c88dd36d0 100644 --- a/examples/react/basic-virtual-inside-file-based/src/routeTree.gen.ts +++ b/examples/react/basic-virtual-inside-file-based/src/routeTree.gen.ts @@ -12,13 +12,13 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as PostsRouteImport } from './routes/posts' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as IndexRouteImport } from './routes/index' -import { Route as PostsDetailsRouteImport } from './routes/posts/details' +import { Route as postsDetailsRouteImport } from './routes/posts/details' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' -import { Route as PostsHomeRouteImport } from './routes/posts/home' -import { Route as PostsLetsGoIndexRouteImport } from './routes/posts/lets-go/index' +import { Route as postsHomeRouteImport } from './routes/posts/home' +import { Route as postsLetsGoIndexRouteImport } from './routes/posts/lets-go/index' import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b' import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layout-2/layout-a' -import { Route as PostsLetsGoDeeperHomeRouteImport } from './routes/posts/lets-go/deeper/home' +import { Route as postsLetsGoDeeperHomeRouteImport } from './routes/posts/lets-go/deeper/home' const PostsRoute = PostsRouteImport.update({ id: '/posts', @@ -34,7 +34,7 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) -const PostsDetailsRoute = PostsDetailsRouteImport.update({ +const postsDetailsRoute = postsDetailsRouteImport.update({ id: '/$postId', path: '/$postId', getParentRoute: () => PostsRoute, @@ -43,12 +43,12 @@ const LayoutLayout2Route = LayoutLayout2RouteImport.update({ id: '/_layout-2', getParentRoute: () => LayoutRoute, } as any) -const PostsHomeRoute = PostsHomeRouteImport.update({ +const postsHomeRoute = postsHomeRouteImport.update({ id: '/', path: '/', getParentRoute: () => PostsRoute, } as any) -const PostsLetsGoIndexRoute = PostsLetsGoIndexRouteImport.update({ +const postsLetsGoIndexRoute = postsLetsGoIndexRouteImport.update({ id: '/inception/', path: '/inception/', getParentRoute: () => PostsRoute, @@ -63,7 +63,7 @@ const LayoutLayout2LayoutARoute = LayoutLayout2LayoutARouteImport.update({ path: '/layout-a', getParentRoute: () => LayoutLayout2Route, } as any) -const PostsLetsGoDeeperHomeRoute = PostsLetsGoDeeperHomeRouteImport.update({ +const postsLetsGoDeeperHomeRoute = postsLetsGoDeeperHomeRouteImport.update({ id: '/inception/deeper/', path: '/inception/deeper/', getParentRoute: () => PostsRoute, @@ -72,34 +72,34 @@ const PostsLetsGoDeeperHomeRoute = PostsLetsGoDeeperHomeRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren - '/posts/': typeof PostsHomeRoute - '/posts/$postId': typeof PostsDetailsRoute + '/posts/': typeof postsHomeRoute + '/posts/$postId': typeof postsDetailsRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute - '/posts/inception/': typeof PostsLetsGoIndexRoute - '/posts/inception/deeper/': typeof PostsLetsGoDeeperHomeRoute + '/posts/inception/': typeof postsLetsGoIndexRoute + '/posts/inception/deeper/': typeof postsLetsGoDeeperHomeRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/posts': typeof PostsHomeRoute - '/posts/$postId': typeof PostsDetailsRoute + '/posts': typeof postsHomeRoute + '/posts/$postId': typeof postsDetailsRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute - '/posts/inception': typeof PostsLetsGoIndexRoute - '/posts/inception/deeper': typeof PostsLetsGoDeeperHomeRoute + '/posts/inception': typeof postsLetsGoIndexRoute + '/posts/inception/deeper': typeof postsLetsGoDeeperHomeRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_layout': typeof LayoutRouteWithChildren '/posts': typeof PostsRouteWithChildren - '/posts/': typeof PostsHomeRoute + '/posts/': typeof postsHomeRoute '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren - '/posts/$postId': typeof PostsDetailsRoute + '/posts/$postId': typeof postsDetailsRoute '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute - '/posts/inception/': typeof PostsLetsGoIndexRoute - '/posts/inception/deeper/': typeof PostsLetsGoDeeperHomeRoute + '/posts/inception/': typeof postsLetsGoIndexRoute + '/posts/inception/deeper/': typeof postsLetsGoDeeperHomeRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -168,7 +168,7 @@ declare module '@tanstack/react-router' { id: '/posts/$postId' path: '/$postId' fullPath: '/posts/$postId' - preLoaderRoute: typeof PostsDetailsRouteImport + preLoaderRoute: typeof postsDetailsRouteImport parentRoute: typeof PostsRoute } '/_layout/_layout-2': { @@ -182,14 +182,14 @@ declare module '@tanstack/react-router' { id: '/posts/' path: '/' fullPath: '/posts/' - preLoaderRoute: typeof PostsHomeRouteImport + preLoaderRoute: typeof postsHomeRouteImport parentRoute: typeof PostsRoute } '/posts/inception/': { id: '/posts/inception/' path: '/inception' fullPath: '/posts/inception/' - preLoaderRoute: typeof PostsLetsGoIndexRouteImport + preLoaderRoute: typeof postsLetsGoIndexRouteImport parentRoute: typeof PostsRoute } '/_layout/_layout-2/layout-b': { @@ -210,7 +210,7 @@ declare module '@tanstack/react-router' { id: '/posts/inception/deeper/' path: '/inception/deeper' fullPath: '/posts/inception/deeper/' - preLoaderRoute: typeof PostsLetsGoDeeperHomeRouteImport + preLoaderRoute: typeof postsLetsGoDeeperHomeRouteImport parentRoute: typeof PostsRoute } } @@ -242,17 +242,17 @@ const LayoutRouteWithChildren = LayoutRoute._addFileChildren(LayoutRouteChildren) interface PostsRouteChildren { - PostsHomeRoute: typeof PostsHomeRoute - PostsDetailsRoute: typeof PostsDetailsRoute - PostsLetsGoIndexRoute: typeof PostsLetsGoIndexRoute - PostsLetsGoDeeperHomeRoute: typeof PostsLetsGoDeeperHomeRoute + postsHomeRoute: typeof postsHomeRoute + postsDetailsRoute: typeof postsDetailsRoute + postsLetsGoIndexRoute: typeof postsLetsGoIndexRoute + postsLetsGoDeeperHomeRoute: typeof postsLetsGoDeeperHomeRoute } const PostsRouteChildren: PostsRouteChildren = { - PostsHomeRoute: PostsHomeRoute, - PostsDetailsRoute: PostsDetailsRoute, - PostsLetsGoIndexRoute: PostsLetsGoIndexRoute, - PostsLetsGoDeeperHomeRoute: PostsLetsGoDeeperHomeRoute, + postsHomeRoute: postsHomeRoute, + postsDetailsRoute: postsDetailsRoute, + postsLetsGoIndexRoute: postsLetsGoIndexRoute, + postsLetsGoDeeperHomeRoute: postsLetsGoDeeperHomeRoute, } const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) diff --git a/examples/react/start-large/src/routes/search/searchPlaceholder.tsx b/examples/react/start-large/src/routes/search/searchPlaceholder.tsx index bcf6bc777f8..07f32d2a5ff 100644 --- a/examples/react/start-large/src/routes/search/searchPlaceholder.tsx +++ b/examples/react/start-large/src/routes/search/searchPlaceholder.tsx @@ -51,7 +51,7 @@ export const Route = createFileRoute('/search/searchPlaceholder')({ component: SearchComponent, validateSearch: search, loaderDeps: ({ search }) => ({ search }), - context: (ctx) => ({ + onLoad: (ctx) => ({ searchQueryOptions: queryOptions({ queryKey: ['searchPlaceholder'], queryFn: () => fn({ data: ctx.deps.search }), diff --git a/examples/solid/basic-virtual-inside-file-based/src/routeTree.gen.ts b/examples/solid/basic-virtual-inside-file-based/src/routeTree.gen.ts index 037dac78a39..551e8a9b4d1 100644 --- a/examples/solid/basic-virtual-inside-file-based/src/routeTree.gen.ts +++ b/examples/solid/basic-virtual-inside-file-based/src/routeTree.gen.ts @@ -12,13 +12,13 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as PostsRouteImport } from './routes/posts' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as IndexRouteImport } from './routes/index' -import { Route as PostsDetailsRouteImport } from './routes/posts/details' +import { Route as postsDetailsRouteImport } from './routes/posts/details' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' -import { Route as PostsHomeRouteImport } from './routes/posts/home' -import { Route as PostsLetsGoIndexRouteImport } from './routes/posts/lets-go/index' +import { Route as postsHomeRouteImport } from './routes/posts/home' +import { Route as postsLetsGoIndexRouteImport } from './routes/posts/lets-go/index' import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b' import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layout-2/layout-a' -import { Route as PostsLetsGoDeeperHomeRouteImport } from './routes/posts/lets-go/deeper/home' +import { Route as postsLetsGoDeeperHomeRouteImport } from './routes/posts/lets-go/deeper/home' const PostsRoute = PostsRouteImport.update({ id: '/posts', @@ -34,7 +34,7 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) -const PostsDetailsRoute = PostsDetailsRouteImport.update({ +const postsDetailsRoute = postsDetailsRouteImport.update({ id: '/$postId', path: '/$postId', getParentRoute: () => PostsRoute, @@ -43,12 +43,12 @@ const LayoutLayout2Route = LayoutLayout2RouteImport.update({ id: '/_layout-2', getParentRoute: () => LayoutRoute, } as any) -const PostsHomeRoute = PostsHomeRouteImport.update({ +const postsHomeRoute = postsHomeRouteImport.update({ id: '/', path: '/', getParentRoute: () => PostsRoute, } as any) -const PostsLetsGoIndexRoute = PostsLetsGoIndexRouteImport.update({ +const postsLetsGoIndexRoute = postsLetsGoIndexRouteImport.update({ id: '/inception/', path: '/inception/', getParentRoute: () => PostsRoute, @@ -63,7 +63,7 @@ const LayoutLayout2LayoutARoute = LayoutLayout2LayoutARouteImport.update({ path: '/layout-a', getParentRoute: () => LayoutLayout2Route, } as any) -const PostsLetsGoDeeperHomeRoute = PostsLetsGoDeeperHomeRouteImport.update({ +const postsLetsGoDeeperHomeRoute = postsLetsGoDeeperHomeRouteImport.update({ id: '/inception/deeper/', path: '/inception/deeper/', getParentRoute: () => PostsRoute, @@ -72,34 +72,34 @@ const PostsLetsGoDeeperHomeRoute = PostsLetsGoDeeperHomeRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren - '/posts/': typeof PostsHomeRoute - '/posts/$postId': typeof PostsDetailsRoute + '/posts/': typeof postsHomeRoute + '/posts/$postId': typeof postsDetailsRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute - '/posts/inception/': typeof PostsLetsGoIndexRoute - '/posts/inception/deeper/': typeof PostsLetsGoDeeperHomeRoute + '/posts/inception/': typeof postsLetsGoIndexRoute + '/posts/inception/deeper/': typeof postsLetsGoDeeperHomeRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/posts': typeof PostsHomeRoute - '/posts/$postId': typeof PostsDetailsRoute + '/posts': typeof postsHomeRoute + '/posts/$postId': typeof postsDetailsRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute - '/posts/inception': typeof PostsLetsGoIndexRoute - '/posts/inception/deeper': typeof PostsLetsGoDeeperHomeRoute + '/posts/inception': typeof postsLetsGoIndexRoute + '/posts/inception/deeper': typeof postsLetsGoDeeperHomeRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_layout': typeof LayoutRouteWithChildren '/posts': typeof PostsRouteWithChildren - '/posts/': typeof PostsHomeRoute + '/posts/': typeof postsHomeRoute '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren - '/posts/$postId': typeof PostsDetailsRoute + '/posts/$postId': typeof postsDetailsRoute '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute - '/posts/inception/': typeof PostsLetsGoIndexRoute - '/posts/inception/deeper/': typeof PostsLetsGoDeeperHomeRoute + '/posts/inception/': typeof postsLetsGoIndexRoute + '/posts/inception/deeper/': typeof postsLetsGoDeeperHomeRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -168,7 +168,7 @@ declare module '@tanstack/solid-router' { id: '/posts/$postId' path: '/$postId' fullPath: '/posts/$postId' - preLoaderRoute: typeof PostsDetailsRouteImport + preLoaderRoute: typeof postsDetailsRouteImport parentRoute: typeof PostsRoute } '/_layout/_layout-2': { @@ -182,14 +182,14 @@ declare module '@tanstack/solid-router' { id: '/posts/' path: '/' fullPath: '/posts/' - preLoaderRoute: typeof PostsHomeRouteImport + preLoaderRoute: typeof postsHomeRouteImport parentRoute: typeof PostsRoute } '/posts/inception/': { id: '/posts/inception/' path: '/inception' fullPath: '/posts/inception/' - preLoaderRoute: typeof PostsLetsGoIndexRouteImport + preLoaderRoute: typeof postsLetsGoIndexRouteImport parentRoute: typeof PostsRoute } '/_layout/_layout-2/layout-b': { @@ -210,7 +210,7 @@ declare module '@tanstack/solid-router' { id: '/posts/inception/deeper/' path: '/inception/deeper' fullPath: '/posts/inception/deeper/' - preLoaderRoute: typeof PostsLetsGoDeeperHomeRouteImport + preLoaderRoute: typeof postsLetsGoDeeperHomeRouteImport parentRoute: typeof PostsRoute } } @@ -242,17 +242,17 @@ const LayoutRouteWithChildren = LayoutRoute._addFileChildren(LayoutRouteChildren) interface PostsRouteChildren { - PostsHomeRoute: typeof PostsHomeRoute - PostsDetailsRoute: typeof PostsDetailsRoute - PostsLetsGoIndexRoute: typeof PostsLetsGoIndexRoute - PostsLetsGoDeeperHomeRoute: typeof PostsLetsGoDeeperHomeRoute + postsHomeRoute: typeof postsHomeRoute + postsDetailsRoute: typeof postsDetailsRoute + postsLetsGoIndexRoute: typeof postsLetsGoIndexRoute + postsLetsGoDeeperHomeRoute: typeof postsLetsGoDeeperHomeRoute } const PostsRouteChildren: PostsRouteChildren = { - PostsHomeRoute: PostsHomeRoute, - PostsDetailsRoute: PostsDetailsRoute, - PostsLetsGoIndexRoute: PostsLetsGoIndexRoute, - PostsLetsGoDeeperHomeRoute: PostsLetsGoDeeperHomeRoute, + postsHomeRoute: postsHomeRoute, + postsDetailsRoute: postsDetailsRoute, + postsLetsGoIndexRoute: postsLetsGoIndexRoute, + postsLetsGoDeeperHomeRoute: postsLetsGoDeeperHomeRoute, } const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index 46b96240a47..b1fbe7b5a93 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -17,6 +17,7 @@ import type { AnyRouter, Constrain, ConstrainLiteral, + DefaultLifecycleDehydrateFn, FileBaseRouteOptions, FileRoutesByPath, LazyRouteOptions, @@ -86,7 +87,7 @@ export class FileRoute< TRegister = Register, TSearchValidator = undefined, TParams = ResolveParams, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -94,6 +95,9 @@ export class FileRoute< TSSR = unknown, const TMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: FileBaseRouteOptions< TRegister, @@ -105,24 +109,27 @@ export class FileRoute< TLoaderDeps, TLoaderFn, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, AnyContext, TSSR, TMiddlewares, - THandlers + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn > & UpdatableRouteOptions< - TParentRoute, - TId, - TFullPath, - TParams, - TSearchValidator, - TLoaderFn, - TLoaderDeps, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, AnyContext, - TRouteContextFn, - TBeforeLoadFn + NoInfer, + NoInfer >, ): Route< TRegister, @@ -134,7 +141,7 @@ export class FileRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -176,7 +183,7 @@ export function FileRouteLoader< TRoute['types']['params'], TRoute['types']['loaderDeps'], TRoute['types']['routerContext'], - TRoute['types']['routeContextFn'], + TRoute['types']['contextFn'], TRoute['types']['beforeLoadFn'] > >, diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 6637c7542bc..e50d2920156 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -67,7 +67,6 @@ export type { InferAllContext, LooseReturnType, LooseAsyncReturnType, - ContextReturnType, ContextAsyncReturnType, ResolveLoaderData, ResolveRouteContext, @@ -189,7 +188,8 @@ export type { MakeRouteMatchUnion, RouteMatch, AnyRouteMatch, - RouteContextFn, + ContextFn, + ContextFnOptions, RouteContextOptions, BeforeLoadContextOptions, ContextOptions, diff --git a/packages/react-router/src/route.tsx b/packages/react-router/src/route.tsx index 58f5198d56c..b0662224d39 100644 --- a/packages/react-router/src/route.tsx +++ b/packages/react-router/src/route.tsx @@ -19,6 +19,7 @@ import type { AnyRoute, AnyRouter, ConstrainLiteral, + DefaultLifecycleDehydrateFn, ErrorComponentProps, NotFoundError, NotFoundRouteProps, @@ -48,6 +49,9 @@ import type { UseSearchRoute } from './useSearch' import type { UseRouteContextRoute } from './useRouteContext' import type { LinkComponentRoute } from './link' +type NormalizeRouteContext = [T] extends [never] ? AnyContext : T +type NormalizeRouteLoader = [T] extends [never] ? undefined : T + declare module '@tanstack/router-core' { export interface UpdatableRouteOptionsExtensions { component?: RouteComponent @@ -182,7 +186,7 @@ export class Route< in out TSearchValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TLoaderDeps extends Record = {}, in out TLoaderFn = undefined, @@ -202,7 +206,7 @@ export class Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -223,7 +227,7 @@ export class Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -250,7 +254,7 @@ export class Route< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, TServerMiddlewares, @@ -336,13 +340,17 @@ export function createRoute< >, TSearchValidator = undefined, TParams = ResolveParams, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, TSSR = unknown, const TServerMiddlewares = unknown, + THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options: RouteOptions< TRegister, @@ -356,10 +364,14 @@ export function createRoute< TLoaderDeps, TLoaderFn, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, - TServerMiddlewares + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ): Route< TRegister, @@ -371,13 +383,15 @@ export function createRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, TChildren, + unknown, TSSR, - TServerMiddlewares + TServerMiddlewares, + THandlers > { return new Route< TRegister, @@ -389,13 +403,15 @@ export function createRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, TChildren, + unknown, TSSR, - TServerMiddlewares + TServerMiddlewares, + THandlers >( // TODO: Help us TypeChris, you're our only hope! options as any, @@ -413,6 +429,7 @@ export type AnyRootRoute = RootRoute< any, any, any, + any, any > @@ -428,37 +445,48 @@ export type AnyRootRoute = RootRoute< export function createRootRouteWithContext() { return < TRegister = Register, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, TServerMiddlewares = unknown, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: RootRouteOptions< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, - TServerMiddlewares + TServerMiddlewares, + undefined, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ) => { return createRootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, - TServerMiddlewares - >(options) + TServerMiddlewares, + undefined, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn + >(options as any) } } @@ -471,7 +499,7 @@ export class RootRoute< in out TRegister = unknown, in out TSearchValidator = undefined, in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TLoaderDeps extends Record = {}, in out TLoaderFn = undefined, @@ -485,7 +513,7 @@ export class RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -500,7 +528,7 @@ export class RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -519,7 +547,7 @@ export class RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -594,34 +622,40 @@ export function createRootRoute< TRegister = Register, TSearchValidator = undefined, TRouterContext = {}, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, const TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: RootRouteOptions< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares, - THandlers + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ): RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, unknown, unknown, TSSR, @@ -632,16 +666,16 @@ export function createRootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, unknown, unknown, TSSR, TServerMiddlewares, THandlers - >(options) + >(options as any) } export function createRouteMask< @@ -677,7 +711,7 @@ export class NotFoundRoute< TRegister, TParentRoute extends AnyRootRoute, TRouterContext = AnyContext, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, TLoaderDeps extends Record = {}, @@ -695,7 +729,7 @@ export class NotFoundRoute< TSearchValidator, {}, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -717,7 +751,7 @@ export class NotFoundRoute< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, TServerMiddlewares diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index 0779c2e8c42..3a9bf39c96e 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -441,3 +441,250 @@ describe('notFoundComponent is rendered when an error is thrown in params.parse' expect(notFoundComponent).toBeInTheDocument() }) }) + +describe('errorComponent is rendered when an Error is thrown in lifecycle methods', () => { + test('an Error thrown in `context` renders errorComponent on navigate', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: () => { + throw new Error('context error thrown') + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: context error thrown', + undefined, + { timeout: 1500 }, + ) + await expect(screen.findByText('About route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an Error thrown in `context` with invalidate renders errorComponent on navigate', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: () => { + throw new Error('context invalidate error thrown') + }, + revalidate: true, + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: context invalidate error thrown', + undefined, + { timeout: 1500 }, + ) + await expect(screen.findByText('About route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an Error thrown in `context` renders errorComponent on first load', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + throw new Error('context error thrown') + }, + component: function Home() { + return
Index route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const errorComponent = await screen.findByText( + 'Error: context error thrown', + undefined, + { timeout: 750 }, + ) + await expect(screen.findByText('Index route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an Error thrown in `context` with invalidate renders errorComponent on first load', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + throw new Error('context invalidate error thrown') + }, + revalidate: true, + }, + component: function Home() { + return
Index route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const errorComponent = await screen.findByText( + 'Error: context invalidate error thrown', + undefined, + { timeout: 750 }, + ) + await expect(screen.findByText('Index route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an async Error thrown in `context` renders errorComponent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + throw new Error('async context error') + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: async context error', + undefined, + { timeout: 1500 }, + ) + expect(errorComponent).toBeInTheDocument() + }) + + test('an async Error thrown in `context` with invalidate renders errorComponent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + throw new Error('async context invalidate error') + }, + revalidate: true, + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: async context invalidate error', + undefined, + { timeout: 1500 }, + ) + expect(errorComponent).toBeInTheDocument() + }) +}) diff --git a/packages/react-router/tests/fileRoute.test-d.tsx b/packages/react-router/tests/fileRoute.test-d.tsx index cc0e7b265a6..62d2c465772 100644 --- a/packages/react-router/tests/fileRoute.test-d.tsx +++ b/packages/react-router/tests/fileRoute.test-d.tsx @@ -1,5 +1,6 @@ import { expectTypeOf, test } from 'vitest' -import { createFileRoute, createRootRoute } from '../src' +import { Link, createFileRoute, createRootRoute, createRouter } from '../src' +import type { AnyRouteMatch } from '@tanstack/router-core' const rootRoute = createRootRoute() @@ -17,6 +18,14 @@ const postRoute = createFileRoute('/_postLayout/posts/$postId_')() const protectedRoute = createFileRoute('/(auth)/protected')() +const optionalSearchRoute = createFileRoute('/optional-search')({ + validateSearch: (): { preload?: false } => ({}), +}) + +const optionalSearchIndexRoute = createFileRoute('/optional-search/')() + +const optionalSearchChildRoute = createFileRoute('/optional-search/child')() + const attachmentRoute = createFileRoute( '/projects/$observationDocId/attachments/$driveId/$type/$variant/$name', )({ @@ -58,6 +67,27 @@ declare module '@tanstack/router-core' { fullPath: '/protected' path: '(auth)/protected' } + '/optional-search': { + preLoaderRoute: typeof optionalSearchRoute + parentRoute: typeof rootRoute + id: '/optional-search' + fullPath: '/optional-search' + path: '/optional-search' + } + '/optional-search/': { + preLoaderRoute: typeof optionalSearchIndexRoute + parentRoute: typeof optionalSearchRoute + id: '/optional-search/' + fullPath: '/optional-search/' + path: '/' + } + '/optional-search/child': { + preLoaderRoute: typeof optionalSearchChildRoute + parentRoute: typeof optionalSearchRoute + id: '/optional-search/child' + fullPath: '/optional-search/child' + path: '/child' + } '/invoices': { preLoaderRoute: typeof invoicesRoute parentRoute: typeof indexRoute @@ -103,6 +133,49 @@ declare module '@tanstack/router-core' { } } +interface OptionalSearchFileRoutesByFullPath { + '/optional-search': typeof optionalSearchRouteWithChildren + '/optional-search/': typeof optionalSearchIndexRoute + '/optional-search/child': typeof optionalSearchChildRoute +} + +interface OptionalSearchFileRoutesByTo { + '/optional-search': typeof optionalSearchIndexRoute + '/optional-search/child': typeof optionalSearchChildRoute +} + +interface OptionalSearchFileRoutesById { + '/optional-search': typeof optionalSearchRouteWithChildren + '/optional-search/': typeof optionalSearchIndexRoute + '/optional-search/child': typeof optionalSearchChildRoute +} + +interface OptionalSearchFileRouteTypes { + fileRoutesByFullPath: OptionalSearchFileRoutesByFullPath + fullPaths: '/optional-search' | '/optional-search/' | '/optional-search/child' + to: '/optional-search' | '/optional-search/child' + fileRoutesByTo: OptionalSearchFileRoutesByTo + id: '/optional-search' | '/optional-search/' | '/optional-search/child' + fileRoutesById: OptionalSearchFileRoutesById +} + +const optionalSearchRouteWithChildren = optionalSearchRoute._addFileChildren({ + OptionalSearchIndexRoute: optionalSearchIndexRoute, + OptionalSearchChildRoute: optionalSearchChildRoute, +}) + +const fileRouteTree = rootRoute + ._addFileChildren({ + OptionalSearchRoute: optionalSearchRouteWithChildren, + }) + ._addFileTypes() + +const fileRouter = createRouter({ + routeTree: fileRouteTree, +}) + +type FileRouter = typeof fileRouter + test('when creating a file route with a static route', () => { expectTypeOf<'/invoices'>(invoicesRoute.fullPath) expectTypeOf<'/invoices'>(invoicesRoute.id) @@ -133,6 +206,106 @@ test('when creating a folder group', () => { expectTypeOf<'/protected'>(protectedRoute.id) }) +test('file route relative Link to child route keeps inherited optional search optional', () => { + const FileRouterLink = Link + + ; + child + + ; + child + +}) + +test('file route object lifecycle options compile', () => { + const objectRoute = createFileRoute('/invoices')({ + context: { + handler: () => ({ createdAt: new Date() }), + revalidate: true, + dehydrate: ({ data }) => ({ + createdAt: data.createdAt.toISOString(), + }), + hydrate: ({ data }) => ({ + createdAt: new Date(data.createdAt), + }), + }, + beforeLoad: { + handler: (ctx) => { + expectTypeOf(ctx.context).toEqualTypeOf<{ createdAt: Date }>() + return { permission: 'view' as const } + }, + dehydrate: true, + }, + loader: { + handler: (ctx) => { + expectTypeOf(ctx.context).toEqualTypeOf<{ + createdAt: Date + permission: 'view' + }>() + return { loadedAt: new Date() } + }, + dehydrate: ({ data }) => ({ + loadedAt: data.loadedAt.toISOString(), + }), + hydrate: ({ data }) => ({ + loadedAt: new Date(data.loadedAt), + }), + }, + }) + + expectTypeOf(objectRoute.fullPath).toEqualTypeOf<'/invoices'>() +}) + +test('file route dehydrate fn requires hydrate', () => { + createFileRoute('/invoices')({ + // @ts-expect-error dehydrate function requires hydrate + context: { + handler: () => ({ createdAt: new Date() }), + dehydrate: ({ data }) => ({ + createdAt: data.createdAt.toISOString(), + }), + }, + }) +}) + +test('file route context revalidate function infers prev from handler', () => { + const revalidateRoute = createFileRoute('/invoices')({ + context: { + handler: () => ({ + value: 1, + revalidated: false, + revalidateRunCount: 0, + }), + revalidate: ({ prev, params, deps, matches }) => { + expectTypeOf(prev).toEqualTypeOf< + | { + value: number + revalidated: boolean + revalidateRunCount: number + } + | undefined + >() + expectTypeOf(params).toEqualTypeOf<{}>() + expectTypeOf(deps).toEqualTypeOf<{}>() + expectTypeOf(matches).toEqualTypeOf>() + + return { + value: (prev?.value ?? 0) + 1, + revalidated: true, + revalidateRunCount: (prev?.revalidateRunCount ?? 0) + 1, + } + }, + dehydrate: true, + }, + }) + + expectTypeOf(revalidateRoute.fullPath).toEqualTypeOf<'/invoices'>() +}) + test('when file route params.parse returns a discriminated union', () => { expectTypeOf(attachmentRoute.types.params).toEqualTypeOf< | { diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index cc15f0da36a..a2b2ffc4e0a 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -310,6 +310,145 @@ describe('redirect', () => { expect(await screen.findByText('Final')).toBeInTheDocument() expect(window.location.pathname).toBe('/final') }) + + test('when `redirect` is thrown in `context`', async () => { + const nestedLoaderMock = vi.fn() + const nestedFooLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/foo' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + nestedLoaderMock('nested') + }, + }) + const fooRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async () => { + await sleep(WAIT_TIME) + nestedFooLoaderMock('foo') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history }) + + render() + + const linkToAbout = await screen.findByText('link to about') + + expect(linkToAbout).toBeInTheDocument() + + fireEvent.click(linkToAbout) + + const fooElement = await screen.findByText('Nested Foo page') + + expect(fooElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/foo') + expect(window.location.pathname).toBe('/nested/foo') + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedFooLoaderMock).toHaveBeenCalled() + }) + + test('when `redirect` is thrown in `context` with invalidate', async () => { + const nestedLoaderMock = vi.fn() + const nestedFooLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/foo' }) + }, + revalidate: true, + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + nestedLoaderMock('nested') + }, + }) + const fooRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async () => { + await sleep(WAIT_TIME) + nestedFooLoaderMock('foo') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree, history }) + + render() + + const linkToAbout = await screen.findByText('link to about') + + expect(linkToAbout).toBeInTheDocument() + + fireEvent.click(linkToAbout) + + const fooElement = await screen.findByText('Nested Foo page') + + expect(fooElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/foo') + expect(window.location.pathname).toBe('/nested/foo') + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedFooLoaderMock).toHaveBeenCalled() + }) }) describe('SSR', () => { @@ -422,5 +561,106 @@ describe('redirect', () => { statusCode: 307, }) }) + + test('when `redirect` is thrown in `context`', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + context: () => { + throw redirect({ + to: '/about', + }) + }, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => { + return 'About' + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + isServer: true, + history: createMemoryHistory({ + initialEntries: ['/'], + }), + }) + + await router.load() + + const stateRedirect = router.state.redirect + expect(stateRedirect).toBeDefined() + expect(stateRedirect).toBeInstanceOf(Response) + + expect(stateRedirect!.options).toEqual({ + _fromLocation: expect.objectContaining({ + hash: '', + href: '/', + pathname: '/', + search: {}, + searchStr: '', + }), + to: '/about', + href: '/about', + statusCode: 307, + }) + }) + + test('when `redirect` is thrown in `context` with invalidate', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + context: { + handler: () => { + throw redirect({ + to: '/about', + }) + }, + revalidate: true, + }, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => { + return 'About' + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + isServer: true, + history: createMemoryHistory({ + initialEntries: ['/'], + }), + }) + + await router.load() + + const stateRedirect = router.state.redirect + expect(stateRedirect).toBeDefined() + expect(stateRedirect).toBeInstanceOf(Response) + + expect(stateRedirect!.options).toEqual({ + _fromLocation: expect.objectContaining({ + hash: '', + href: '/', + pathname: '/', + search: {}, + searchStr: '', + }), + to: '/about', + href: '/about', + statusCode: 307, + }) + }) }) }) diff --git a/packages/react-router/tests/route.test-d.tsx b/packages/react-router/tests/route.test-d.tsx index 41b003e5d1f..f29f9103f74 100644 --- a/packages/react-router/tests/route.test-d.tsx +++ b/packages/react-router/tests/route.test-d.tsx @@ -15,6 +15,7 @@ import type { } from '../src' import type { AnyRoute, + AnyRouteMatch, AnyRouter, Expand, MakeRouteMatchFromRoute, @@ -30,19 +31,19 @@ test('when creating the root', () => { expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() }) -test('when creating the root with routeContext', () => { +test('when creating the root with context', () => { const rootRoute = createRootRoute({ context: (opts) => { expectTypeOf(opts).toEqualTypeOf<{ abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: {} - deps: {} matches: Array routeId: '__root__' }>() @@ -78,6 +79,33 @@ test('when creating the root with beforeLoad', () => { expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() }) +test('when creating the root with context using object form with revalidate', () => { + const rootRoute = createRootRoute({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + matches: Array + routeId: '__root__' + }>() + }, + revalidate: true, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() +}) + test('when creating the root with a loader', () => { const rootRoute = createRootRoute({ loader: (opts) => { @@ -101,20 +129,39 @@ test('when creating the root with a loader', () => { expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() }) -test('when creating the root route with context and routeContext', () => { +test('when creating the root route with context and context option', () => { const createRouteResult = createRootRouteWithContext<{ userId: string }>() const rootRoute = createRouteResult({ - context: (opts) => { - expectTypeOf(opts).toEqualTypeOf<{ + context: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} + matches: Array + routeId: '__root__' + }>() + + return { + env: 'env1' as const, + } + }, + beforeLoad: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; env: 'env1' } + search: {} matches: Array routeId: '__root__' }>() @@ -133,13 +180,16 @@ test('when creating the root route with context and routeContext', () => { expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf<{ userId: string + env: 'env1' }>() expectTypeOf(rootRoute.useRouteContext) .parameter(0) .exclude() .toHaveProperty('select') - .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() + .toEqualTypeOf< + ((context: { userId: string; env: 'env1' }) => unknown) | undefined + >() }) test('when creating the root route with context and beforeLoad', () => { @@ -184,6 +234,45 @@ test('when creating the root route with context and beforeLoad', () => { .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() }) +test('when creating the root route with context and context option with revalidate', () => { + const createRouteResult = createRootRouteWithContext<{ userId: string }>() + + const rootRoute = createRouteResult({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + matches: Array + routeId: '__root__' + }>() + }, + revalidate: true, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute, + context: { userId: '123' }, + }) + + expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + }>() +}) + test('when creating the root route with context and a loader', () => { const createRouteResult = createRootRouteWithContext<{ userId: string }>() @@ -225,7 +314,7 @@ test('when creating the root route with context and a loader', () => { .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() }) -test('when creating the root route with context, routeContext, beforeLoad and a loader', () => { +test('when creating the root route with context, context option, beforeLoad and a loader', () => { const createRouteResult = createRootRouteWithContext<{ userId: string }>() const rootRoute = createRouteResult({ @@ -234,12 +323,12 @@ test('when creating the root route with context, routeContext, beforeLoad and a abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '__root__' }>() @@ -311,6 +400,78 @@ test('when creating the root route with context, routeContext, beforeLoad and a >() }) +test('when creating the root route with context, context option, beforeLoad and a loader (context does not see beforeLoad)', () => { + const createRouteResult = createRootRouteWithContext<{ userId: string }>() + + const rootRoute = createRouteResult({ + context: (opt) => { + expectTypeOf(opt).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + matches: Array + routeId: '__root__' + }>() + + return { + env: 'env1' as const, + } + }, + beforeLoad: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; env: 'env1' } + search: {} + matches: Array + routeId: '__root__' + }>() + return { permission: 'view' as const } + }, + loader: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + context: { + userId: string + permission: 'view' + env: 'env1' + } + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: never + cause: 'preload' | 'enter' | 'stay' + route: AnyRoute + }>() + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute, + context: { userId: '123' }, + }) + + expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + permission: 'view' + env: 'env1' + }>() +}) + test('when creating a child route from the root route', () => { const rootRoute = createRootRoute() @@ -349,7 +510,7 @@ test('when creating a child route from the root route with context', () => { .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() }) -test('when creating a child route with routeContext from the root route with context', () => { +test('when creating a child route with context from the root route with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() createRoute({ @@ -360,12 +521,12 @@ test('when creating a child route with routeContext from the root route with con abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices' }>() @@ -401,6 +562,33 @@ test('when creating a child route with beforeLoad from the root route with conte }) }) +test('when creating a child route with context option with revalidate from the root route with context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + matches: Array + routeId: '/invoices' + }>() + }, + revalidate: true, + }, + }) +}) + test('when creating a child route with a loader from the root route', () => { const rootRoute = createRootRoute() @@ -745,7 +933,7 @@ test('when creating a child route with params, search, loader and loaderDeps fro }) }) -test('when creating a child route with params, search with routeContext from the root route with context', () => { +test('when creating a child route with params, search with context from the root route with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() createRoute({ @@ -757,12 +945,12 @@ test('when creating a child route with params, search with routeContext from the abortController: AbortController preload: boolean params: { invoiceId: string } + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices/$invoiceId' }>() @@ -794,7 +982,36 @@ test('when creating a child route with params, search with beforeLoad from the r }) }) -test('when creating a child route with params, search with routeContext, beforeLoad and a loader from the root route with context', () => { +test('when creating a child route with params, search, loaderDeps with context option with revalidate from the root route with context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + path: 'invoices/$invoiceId', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + loaderDeps: (deps) => ({ page: deps.search.page }), + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + deps: { page: number } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + matches: Array + routeId: '/invoices/$invoiceId' + }>() + }, + revalidate: true, + }, + }) +}) + +test('when creating a child route with params, search with context, beforeLoad and a loader from the root route with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() createRoute({ @@ -806,12 +1023,12 @@ test('when creating a child route with params, search with routeContext, beforeL abortController: AbortController preload: boolean params: { invoiceId: string } + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices/$invoiceId' }>() @@ -935,7 +1152,7 @@ test('when creating a child route with search from a parent with search', () => .toEqualTypeOf() }) -test('when creating a child route with routeContext from a parent with routeContext', () => { +test('when creating a child route with context from a parent with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() const invoicesRoute = createRoute({ @@ -946,12 +1163,12 @@ test('when creating a child route with routeContext from a parent with routeCont abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices' }>() @@ -968,12 +1185,12 @@ test('when creating a child route with routeContext from a parent with routeCont abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string; invoiceId: string } - deps: {} matches: Array routeId: '/invoices/details' }>() @@ -1083,7 +1300,7 @@ test('when creating a child route with beforeLoad from a parent with beforeLoad' >() }) -test('when creating a child route with routeContext, beforeLoad, search, params, loaderDeps and loader', () => { +test('when creating a child route with context, beforeLoad, search, params, loaderDeps and loader', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() const invoicesRoute = createRoute({ @@ -1095,12 +1312,12 @@ test('when creating a child route with routeContext, beforeLoad, search, params, abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices' }>() @@ -1138,6 +1355,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params, abortController: AbortController preload: boolean params: { invoiceId: string } + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn @@ -1147,7 +1365,6 @@ test('when creating a child route with routeContext, beforeLoad, search, params, env: string invoicePermissions: readonly ['view'] } - deps: {} matches: Array routeId: '/invoices/$invoiceId/details' }>() @@ -1188,6 +1405,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params, abortController: AbortController preload: boolean params: { invoiceId: string; detailId: string } + deps: { detailPage: number; invoicePage: number } location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn @@ -1199,7 +1417,6 @@ test('when creating a child route with routeContext, beforeLoad, search, params, detailEnv: string detailsPermissions: readonly ['view'] } - deps: { detailPage: number; invoicePage: number } matches: Array routeId: '/invoices/$invoiceId/details/$detailId' }>() @@ -1632,7 +1849,7 @@ test('when creating a child route with params.parse and params.stringify with me }>() }) -test('when routeContext throws', () => { +test('when context throws', () => { const rootRoute = createRootRoute() const invoicesRoute = createRoute({ getParentRoute: () => rootRoute, @@ -1944,3 +2161,1188 @@ test('when creating a route with escaped path param', () => { expectTypeOf(prefixSuffixRoute.useParams()).toEqualTypeOf<{}>() }) + +// --------------------------------------------------------------------------- +// Object form lifecycle methods — type-level tests +// --------------------------------------------------------------------------- + +test('object form context is accepted on root route', () => { + const rootRoute = createRootRoute({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + matches: Array + routeId: '__root__' + }>() + return { env: 'production' } + }, + dehydrate: false, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('object form beforeLoad is accepted on root route', () => { + const rootRoute = createRootRoute({ + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + search: {} + matches: Array + routeId: '__root__' + }>() + return { perm: 'admin' } + }, + dehydrate: true, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('object form context with revalidate is accepted on root route', () => { + const rootRoute = createRootRoute({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + matches: Array + routeId: '__root__' + deps: {} + }>() + return { cache: 'initialized' } + }, + revalidate: true, + dehydrate: false, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('object form context revalidate infers prev from handler', () => { + const rootRoute = createRootRoute() + + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + context: { + handler: () => ({ + source: 'handler', + value: 1, + revalidated: false, + revalidateRunCount: 0, + }), + revalidate: ({ prev, params, deps, matches }) => { + expectTypeOf(prev).toEqualTypeOf< + | { + source: string + value: number + revalidated: boolean + revalidateRunCount: number + } + | undefined + >() + expectTypeOf(params).toEqualTypeOf<{}>() + expectTypeOf(deps).toEqualTypeOf<{}>() + expectTypeOf(matches).toEqualTypeOf>() + + return { + source: 'revalidate', + value: (prev?.value ?? 0) + 1, + revalidated: true, + revalidateRunCount: (prev?.revalidateRunCount ?? 0) + 1, + } + }, + dehydrate: true, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([childRoute]), + }) + + expectTypeOf(childRoute.useRouteContext()).toEqualTypeOf<{ + source: string + value: number + revalidated: boolean + revalidateRunCount: number + }>() +}) + +test('object form context revalidate must return handler data', () => { + const rootRoute = createRootRoute() + + createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + context: { + handler: () => ({ value: 1, revalidated: false }), + // @ts-expect-error revalidate must return the same data shape as handler + revalidate: ({ prev }) => ({ + value: (prev?.value ?? 0) + 1, + }), + }, + }) +}) + +test('object form loader is accepted on child route', () => { + const rootRoute = createRootRoute() + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + loader: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + context: {} + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise> + cause: 'preload' | 'enter' | 'stay' + route: AnyRoute + }>() + return { data: 'loaded' } + }, + dehydrate: true, + }, + }) + + expectTypeOf(childRoute.fullPath).toEqualTypeOf<'/child'>() +}) + +test('object form context flows into beforeLoad handler context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { + handler: () => ({ env: 'production' }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + // beforeLoad should see context's return + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'admin' } + }, + }, + }) +}) + +test('object form context -> beforeLoad -> loader full context chain', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { + handler: () => ({ env: 'prod' }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'view' as const } + }, + dehydrate: true, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + perm: 'view' + }>() + return { items: ['a', 'b'] } + }, + dehydrate: true, + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + context: { userId: '123' }, + }) + + expectTypeOf(invoicesRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + env: string + perm: 'view' + }>() +}) + +test('mixed function and object form on the same route', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + // function form for context + context: () => ({ env: 'staging' }), + // object form for beforeLoad + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'edit' as const } + }, + dehydrate: false, + }, + // object form for loader + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + perm: 'edit' + }>() + return { data: [1, 2, 3] } + }, + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + context: { userId: '123' }, + }) + + expectTypeOf(invoicesRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + env: string + perm: 'edit' + }>() +}) + +test('object form parent-child context propagation', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'parent', + context: { + handler: () => ({ parentEnv: 'env1' }), + dehydrate: true, + }, + beforeLoad: { + handler: () => ({ parentPerm: 'admin' as const }), + dehydrate: false, + }, + }) + + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: 'child', + context: { + handler: (opts) => { + // child's context sees parent's full allContext (context + beforeLoad) + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + }>() + return { childEnv: 'env2' } + }, + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + childEnv: string + }>() + return { childPerm: 'viewer' as const } + }, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + childEnv: string + childPerm: 'viewer' + }>() + return { items: [1, 2] } + }, + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + context: { userId: '123' }, + }) + + expectTypeOf(parentRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + }>() + + expectTypeOf(childRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + childEnv: string + childPerm: 'viewer' + }>() +}) + +test('object form without dehydrate: full context chain with useRouteContext and useLoaderData', () => { + const rootRoute = createRootRouteWithContext<{ appId: string }>()() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ appId: string }>() + return { env: 'test' } + }, + // no dehydrate specified + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + appId: string + env: string + }>() + return { perm: 'view' as const } + }, + // no dehydrate specified + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + appId: string + env: string + perm: 'view' + }>() + return { data: [1, 2, 3] } + }, + // no serialize specified + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + context: { appId: 'app1' }, + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf<{ + appId: string + env: string + perm: 'view' + }>() + + expectTypeOf(testRoute.useLoaderData()).toEqualTypeOf<{ + data: Array + }>() +}) + +test('object form non-serializable returns flow into context chain', () => { + const rootRoute = createRootRoute() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => ({ cleanup: () => console.log('cleanup') }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + // beforeLoad sees context's non-serializable return in context + expectTypeOf(opts.context).toEqualTypeOf<{ + cleanup: () => void + }>() + return { compute: (x: number) => x * 2 } + }, + dehydrate: false, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + cleanup: () => void + compute: (x: number) => number + }>() + return { items: ['a'] } + }, + dehydrate: false, + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf<{ + cleanup: () => void + compute: (x: number) => number + }>() +}) + +test('object form dehydrate and hydrate infer data across lifecycle methods', () => { + const rootRoute = createRootRoute() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => { + const label = 'ctx' + return { + label, + createdAt: new Date(), + format: (value: string) => `${label}:${value}`, + } + }, + dehydrate: ({ data }) => { + expectTypeOf(data).toEqualTypeOf<{ + label: string + createdAt: Date + format: (value: string) => string + }>() + + return { + label: data.label, + createdAtISO: data.createdAt.toISOString(), + } + }, + hydrate: ({ data }) => { + expectTypeOf(data).toEqualTypeOf<{ + label: string + createdAtISO: string + }>() + + return { + label: data.label, + createdAt: new Date(data.createdAtISO), + format: (value: string) => `${data.label}:${value}`, + } + }, + }, + beforeLoad: { + handler: () => ({ + tag: 'beforeLoad', + count: 42, + pattern: /^hello-\d+$/i, + }), + dehydrate: ({ data }) => { + expectTypeOf(data).toEqualTypeOf<{ + tag: string + count: number + pattern: RegExp + }>() + + return { + tag: data.tag, + count: data.count, + patternSource: data.pattern.source, + patternFlags: data.pattern.flags, + } + }, + hydrate: ({ data }) => { + expectTypeOf(data).toEqualTypeOf<{ + tag: string + count: number + patternSource: string + patternFlags: string + }>() + + return { + tag: data.tag, + count: data.count, + pattern: new RegExp(data.patternSource, data.patternFlags), + } + }, + }, + loader: { + handler: () => { + const scores = [10, 20, 30] + return { + title: 'loader', + scores, + computeAvg: () => + scores.reduce((total, score) => total + score, 0) / scores.length, + } + }, + dehydrate: ({ data }) => { + expectTypeOf(data).toEqualTypeOf<{ + title: string + scores: Array + computeAvg: () => number + }>() + + return { + title: data.title, + scores: data.scores, + } + }, + hydrate: ({ data }) => { + expectTypeOf(data).toEqualTypeOf<{ + title: string + scores: Array + }>() + + return { + title: data.title, + scores: data.scores, + computeAvg: () => + data.scores.reduce((total, score) => total + score, 0) / + data.scores.length, + } + }, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf<{ + label: string + createdAt: Date + format: (value: string) => string + tag: string + count: number + pattern: RegExp + }>() + + expectTypeOf(testRoute.useLoaderData()).toEqualTypeOf<{ + title: string + scores: Array + computeAvg: () => number + }>() +}) + +test('object form dehydrate return must be serializable', () => { + const rootRoute = createRootRoute() + + createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + // @ts-expect-error dehydrate wire data must be serializable + context: { + handler: () => ({ label: 'ctx' }), + dehydrate: ({ data }) => ({ + label: data.label, + format: () => data.label, + }), + hydrate: ({ data }) => ({ + label: data.label, + }), + }, + }) +}) + +test('object form with params and search', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: () => ({ page: 0 }), + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + deps: {} + context: { userId: string } + matches: Array + routeId: '/invoices' + }>() + return { invoiceEnv: 'prod' } + }, + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; invoiceEnv: string } + search: { page: number } + matches: Array + routeId: '/invoices' + }>() + return { invoicePermissions: ['view'] as const } + }, + }, + }) + + const invoiceRoute = createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + loaderDeps: (deps) => ({ + currentPage: deps.search.page, + }), + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + deps: { currentPage: number } + context: { + userId: string + invoiceEnv: string + invoicePermissions: readonly ['view'] + } + matches: Array + routeId: '/invoices/$invoiceId' + }>() + return { detailEnv: 'staging' } + }, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.params).toEqualTypeOf<{ invoiceId: string }>() + expectTypeOf(opts.deps).toEqualTypeOf<{ currentPage: number }>() + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + invoiceEnv: string + invoicePermissions: readonly ['view'] + detailEnv: string + }>() + return { invoice: { id: 'inv1', amount: 100 } } + }, + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + ]), + context: { userId: '123' }, + }) + + expectTypeOf(invoiceRoute.useRouteContext()).toEqualTypeOf<{ + userId: string + invoiceEnv: string + invoicePermissions: readonly ['view'] + detailEnv: string + }>() + + expectTypeOf(invoiceRoute.useLoaderData()).toEqualTypeOf<{ + invoice: { id: string; amount: number } + }>() + + expectTypeOf(invoiceRoute.useParams()).toEqualTypeOf<{ + invoiceId: string + }>() +}) + +test('object form useLoaderData with select and structuralSharing', () => { + const rootRoute = createRootRoute() + + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + loader: { + handler: () => + ({ items: ['a', 'b'], count: 2 }) as const satisfies { + items: ReadonlyArray + count: number + }, + }, + }) + + const routeTree = rootRoute.addChildren([childRoute]) + const router = createRouter({ routeTree }) + + expectTypeOf(childRoute.useLoaderData()).toEqualTypeOf<{ + readonly items: readonly ['a', 'b'] + readonly count: 2 + }>() + + expectTypeOf(childRoute.useLoaderData) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { + readonly items: readonly ['a', 'b'] + readonly count: 2 + }) => string) + | undefined + >() + + expectTypeOf(childRoute.useLoaderData) + .parameter(0) + .exclude() + .toHaveProperty('structuralSharing') + .toEqualTypeOf() +}) + +test('object form useRouteContext with select', () => { + const rootRoute = createRootRouteWithContext<{ appId: string }>()() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => ({ env: 'prod' }), + }, + beforeLoad: { + handler: () => ({ perm: 'admin' as const }), + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + context: { appId: 'app1' }, + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf<{ + appId: string + env: string + perm: 'admin' + }>() + + expectTypeOf(testRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((context: { appId: string; env: string; perm: 'admin' }) => unknown) + | undefined + >() +}) + +test('object form onEnter, onStay, onLeave match types', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + beforeLoad: { handler: () => ({ invoicePermissions: ['view'] as const }) }, + }) + + type TExpectedParams = {} + type TExpectedSearch = { page: number } + type TExpectedContext = { + userId: string + invoicePermissions: readonly ['view'] + } + type TExpectedLoaderData = { totalInvoices: number } + type TExpectedMatch = { + params: TExpectedParams + search: TExpectedSearch + context: TExpectedContext + loaderDeps: {} + beforeLoadPromise?: ControlledPromise + loaderPromise?: ControlledPromise + componentsPromise?: Promise> + loaderData?: TExpectedLoaderData + } + + createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + context: { handler: () => ({ detailPermission: true }) }, + loader: { handler: () => ({ totalInvoices: 42 }) }, + onEnter: (match) => expectTypeOf(match).toMatchTypeOf(), + onStay: (match) => expectTypeOf(match).toMatchTypeOf(), + onLeave: (match) => expectTypeOf(match).toMatchTypeOf(), + }) +}) + +test('object form void-returning context does not add to context', () => { + const rootRoute = createRootRouteWithContext<{ appId: string }>()() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => {}, + }, + beforeLoad: { + handler: () => ({ perm: 'admin' as const }), + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + context: { appId: 'app1' }, + }) + + // void context should not add anything — useRouteContext shows only root + beforeLoad + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf<{ + appId: string + perm: 'admin' + }>() +}) + +test('three-level object form context accumulation', () => { + const rootRoute = createRootRouteWithContext<{ rootCtx: string }>()() + + const level1 = createRoute({ + getParentRoute: () => rootRoute, + path: 'l1', + context: { handler: () => ({ l1Ctx: 'a' }) }, + beforeLoad: { handler: () => ({ l1Before: 'b' }) }, + }) + + const level2 = createRoute({ + getParentRoute: () => level1, + path: 'l2', + context: { handler: () => ({ l2Ctx: 'd' }) }, + beforeLoad: { handler: () => ({ l2Before: 'e' }) }, + }) + + const level3 = createRoute({ + getParentRoute: () => level2, + path: 'l3', + context: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + rootCtx: string + l1Ctx: string + l1Before: string + l2Ctx: string + l2Before: string + }>() + return { l3Ctx: 'g' } + }, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + rootCtx: string + l1Ctx: string + l1Before: string + l2Ctx: string + l2Before: string + l3Ctx: string + }>() + return { data: 'final' } + }, + }, + }) + + // eslint-disable-next-line unused-imports/no-unused-vars + const router = createRouter({ + routeTree: rootRoute.addChildren([ + level1.addChildren([level2.addChildren([level3])]), + ]), + context: { rootCtx: 'root' }, + }) + + expectTypeOf(level3.useRouteContext()).toEqualTypeOf<{ + rootCtx: string + l1Ctx: string + l1Before: string + l2Ctx: string + l2Before: string + l3Ctx: string + }>() +}) + +// --------------------------------------------------------------------------- +// dehydrate: fn requires hydrate (RequireHydrateIfDehydrateFn) +// --------------------------------------------------------------------------- + +test('dehydrate function with hydrate compiles on context', () => { + const rootRoute = createRootRoute({ + context: { + handler: () => ({ createdAt: new Date() }), + dehydrate: ({ data }) => ({ + createdAt: data.createdAt.toISOString(), + }), + hydrate: ({ data }) => ({ + createdAt: new Date(data.createdAt), + }), + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('dehydrate function with hydrate compiles on beforeLoad', () => { + const rootRoute = createRootRoute({ + beforeLoad: { + handler: () => ({ ts: new Date() }), + dehydrate: ({ data }) => ({ ts: data.ts.toISOString() }), + hydrate: ({ data }) => ({ ts: new Date(data.ts) }), + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('dehydrate function with hydrate compiles on loader', () => { + const rootRoute = createRootRoute() + + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + loader: { + handler: () => ({ loadedAt: new Date() }), + dehydrate: ({ data }) => ({ + loadedAt: data.loadedAt.toISOString(), + }), + hydrate: ({ data }) => ({ + loadedAt: new Date(data.loadedAt), + }), + }, + }) + + expectTypeOf(childRoute.fullPath).toEqualTypeOf<'/child'>() +}) + +test('dehydrate: true compiles without hydrate on context', () => { + const rootRoute = createRootRoute({ + context: { + handler: () => ({ count: 42 }), + dehydrate: true, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('dehydrate: false compiles without hydrate on context', () => { + const rootRoute = createRootRoute({ + context: { + handler: () => ({ fn: () => 'hello' }), + dehydrate: false, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('dehydrate function WITHOUT hydrate is a type error on context', () => { + createRootRoute({ + // @ts-expect-error dehydrate function requires hydrate + context: { + handler: () => ({ createdAt: new Date() }), + dehydrate: ({ data }) => ({ + createdAt: data.createdAt.toISOString(), + }), + // hydrate intentionally omitted — should be a type error + }, + }) +}) + +test('dehydrate function WITHOUT hydrate is a type error on beforeLoad', () => { + createRootRoute({ + // @ts-expect-error dehydrate function requires hydrate + beforeLoad: { + handler: () => ({ ts: new Date() }), + dehydrate: ({ data }) => ({ ts: data.ts.toISOString() }), + // hydrate intentionally omitted + }, + }) +}) + +test('dehydrate function WITHOUT hydrate is a type error on loader', () => { + const rootRoute = createRootRoute() + + createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + // @ts-expect-error dehydrate function requires hydrate + loader: { + handler: () => ({ loadedAt: new Date() }), + dehydrate: ({ data }) => ({ + loadedAt: data.loadedAt.toISOString(), + }), + // hydrate intentionally omitted + }, + }) +}) + +// --------------------------------------------------------------------------- +// dehydrate fn with mixed serializable + non-serializable handler return +// +// The handler return type contains functions/Dates/RegExp (non-serializable). +// The dehydrate fn strips those, returning only the serializable subset. +// This must compile — ValidateIfSerializable should NOT check the handler +// return when dehydrate is a function (only the dehydrate output is checked). +// --------------------------------------------------------------------------- + +test('dehydrate fn with non-serializable handler return compiles on context', () => { + const rootRoute = createRootRoute({ + context: { + handler: () => ({ + label: 'hello', + createdAt: new Date(), + format: (v: string) => `[${v}]`, + }), + dehydrate: ({ data }) => ({ + label: data.label, + createdAtISO: data.createdAt.toISOString(), + }), + hydrate: ({ data }) => ({ + label: data.label, + createdAt: new Date(data.createdAtISO), + format: (v: string) => `[${v}]`, + }), + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('dehydrate fn with non-serializable handler return compiles on beforeLoad', () => { + const rootRoute = createRootRoute({ + beforeLoad: { + handler: () => ({ + tag: 'bl', + count: 42, + pattern: /^hello-\d+$/i, + }), + dehydrate: ({ data }) => ({ + tag: data.tag, + count: data.count, + patternSource: data.pattern.source, + }), + hydrate: ({ data }) => ({ + tag: data.tag, + count: data.count, + pattern: new RegExp(data.patternSource), + }), + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('dehydrate fn with non-serializable handler return compiles on loader', () => { + const rootRoute = createRootRoute() + + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + loader: { + handler: () => ({ + title: 'data', + scores: [10, 20, 30], + computeAvg: () => 20, + }), + dehydrate: ({ data }) => ({ + title: data.title, + scores: data.scores, + }), + hydrate: ({ data }) => ({ + title: data.title, + scores: data.scores, + computeAvg: () => + data.scores.reduce((a, b) => a + b, 0) / data.scores.length, + }), + }, + }) + + expectTypeOf(childRoute.fullPath).toEqualTypeOf<'/child'>() +}) + +test('dehydrate fn returning non-serializable wire is a type error on context', () => { + createRootRoute({ + // @ts-expect-error dehydrate output must be serializable + context: { + handler: () => ({ createdAt: new Date() }), + dehydrate: () => ({ + toISO: () => new Date().toISOString(), + }), + hydrate: () => ({ + createdAt: new Date(), + }), + }, + }) +}) + +test('dehydrate fn returning non-serializable wire is a type error on beforeLoad', () => { + createRootRoute({ + // @ts-expect-error dehydrate output must be serializable + beforeLoad: { + handler: () => ({ count: 1 }), + dehydrate: () => ({ + getCount: () => 1, + }), + hydrate: () => ({ count: 1 }), + }, + }) +}) + +test('dehydrate fn returning non-serializable wire is a type error on loader', () => { + const rootRoute = createRootRoute() + + createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + // @ts-expect-error dehydrate output must be serializable + loader: { + handler: () => ({ loadedAt: new Date() }), + dehydrate: () => ({ + format: () => new Date().toISOString(), + }), + hydrate: () => ({ + loadedAt: new Date(), + }), + }, + }) +}) diff --git a/packages/react-router/tests/routeContext.test.tsx b/packages/react-router/tests/routeContext.test.tsx index d11f86420e6..880fe7feca3 100644 --- a/packages/react-router/tests/routeContext.test.tsx +++ b/packages/react-router/tests/routeContext.test.tsx @@ -5,6 +5,7 @@ import { fireEvent, render, screen, + waitFor, } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { z } from 'zod' @@ -152,8 +153,11 @@ describe('context function', () => { }), path: '/', loaderDeps: ({ search }) => ({ foo: search.foo }), - context: ({ deps }) => { - mockContextFn(deps) + context: { + handler: () => { + mockContextFn() + }, + revalidate: true, }, component: () => { const navigate = indexRoute.useNavigate() @@ -210,13 +214,11 @@ describe('context function', () => { await findByText(`search: ${JSON.stringify({})}`) expect(mockContextFn).toHaveBeenCalledOnce() - expect(mockContextFn).toHaveBeenCalledWith({}) mockContextFn.mockClear() await clickButton('foo-1') await findByText(`search: ${JSON.stringify({ foo: 'foo-1' })}`) expect(mockContextFn).toHaveBeenCalledOnce() - expect(mockContextFn).toHaveBeenCalledWith({ foo: 'foo-1' }) mockContextFn.mockClear() await clickButton('foo-1') @@ -233,7 +235,7 @@ describe('context function', () => { await findByText( `search: ${JSON.stringify({ foo: 'foo-2', bar: 'bar-1' })}`, ) - expect(mockContextFn).toHaveBeenCalledWith({ foo: 'foo-2' }) + expect(mockContextFn).toHaveBeenCalledOnce() mockContextFn.mockClear() await clickButton('bar-2') @@ -244,8 +246,9 @@ describe('context function', () => { await clickButton('clear') await findByText(`search: ${JSON.stringify({})}`) - expect(mockContextFn).toHaveBeenCalledOnce() - expect(mockContextFn).toHaveBeenCalledWith({}) + // context with invalidate does NOT re-run: the cached match (from the initial load with + // the same loaderDeps hash) is restored and needsContext is already consumed. + expect(mockContextFn).not.toHaveBeenCalled() }) }) @@ -3427,3 +3430,2282 @@ describe('useRouteContext in the component', () => { expect(content).toBeInTheDocument() }) }) + +describe('lifecycle method semantics', () => { + configure({ reactStrictMode: true }) + + describe('context caching behavior', () => { + test('context does NOT re-run on search param changes (match ID unchanged)', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + validateSearch: z.object({ + tab: z.string().optional(), + }), + path: '/', + context: () => { + mockContext() + return { fromContext: true } + }, + component: () => { + const navigate = indexRoute.useNavigate() + const search = indexRoute.useSearch() + return ( +
+

Index page

+ {JSON.stringify(search)} + + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-heading') + // context runs once on initial match creation + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Change search params — match ID doesn't change + fireEvent.click(await screen.findByTestId('change-tab')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ tab: 'settings' }), + ) + }) + expect(mockContext).not.toHaveBeenCalled() + + // Change search params again + fireEvent.click(await screen.findByTestId('change-tab-again')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ tab: 'profile' }), + ) + }) + expect(mockContext).not.toHaveBeenCalled() + }) + + test('context does NOT re-run on router.invalidate()', async () => { + const mockContext = vi.fn() + const mockLoader = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + mockContext() + return { fromContext: true } + }, + loader: () => { + mockLoader() + }, + component: () =>
Index page
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + expect(mockLoader).toHaveBeenCalledTimes(1) + mockContext.mockClear() + mockLoader.mockClear() + + // Invalidate should re-run loader but NOT context + await act(async () => { + await router.invalidate() + }) + + expect(mockContext).not.toHaveBeenCalled() + expect(mockLoader).toHaveBeenCalledTimes(1) + }) + + test('context does NOT re-run when navigating away and back (match cached)', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + mockContext('index') + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+

Other page

+ +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-heading') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Navigate away — match goes to cachedMatches + fireEvent.click(await screen.findByTestId('go-to-other')) + await screen.findByTestId('other-heading') + + // Navigate back — match is found in cache, context should NOT fire again + fireEvent.click(await screen.findByTestId('go-to-index')) + await screen.findByTestId('index-heading') + expect(mockContext).not.toHaveBeenCalled() + }) + + test('context re-runs when navigating away and back after match is GC-ed', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + mockContext('index') + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+

Other page

+ +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ + routeTree, + history, + defaultGcTime: 0, + }) + + render() + + await screen.findByTestId('index-heading') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Navigate away — match goes to cachedMatches then is immediately GC-ed + fireEvent.click(await screen.findByTestId('go-to-other')) + await screen.findByTestId('other-heading') + + // Navigate back — match was evicted from cache, so it is re-created + fireEvent.click(await screen.findByTestId('go-to-index')) + await screen.findByTestId('index-heading') + expect(mockContext).toHaveBeenCalledTimes(1) + }) + }) + + describe('context with revalidate caching behavior', () => { + test('context with revalidate: true re-runs on router.invalidate()', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + }, + revalidate: true, + }, + component: () =>
Index page
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Invalidate should re-run context with revalidate: true + await act(async () => { + await router.invalidate() + }) + + expect(mockContext).toHaveBeenCalledTimes(1) + }) + + test('context with revalidate does NOT re-run on search param changes that do not affect loaderDeps', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + validateSearch: z.object({ + foo: z.string().optional(), + bar: z.string().optional(), + }), + path: '/', + loaderDeps: ({ search }) => ({ foo: search.foo }), + context: { + handler: () => { + mockContext() + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + const search = indexRoute.useSearch() + return ( +
+

Index page

+ {JSON.stringify(search)} + + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-heading') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Change bar (not in loaderDeps) — context should NOT re-run + fireEvent.click(await screen.findByTestId('change-bar')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ bar: 'bar-1' }), + ) + }) + expect(mockContext).not.toHaveBeenCalled() + + // Change foo (in loaderDeps) — context with revalidate SHOULD re-run + fireEvent.click(await screen.findByTestId('change-foo')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ bar: 'bar-1', foo: 'foo-1' }), + ) + }) + expect(mockContext).toHaveBeenCalledTimes(1) + }) + + test('context with revalidate does NOT re-run when navigating away and back (match cached)', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+

Other page

+ +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-heading') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Navigate away — match goes to cachedMatches + fireEvent.click(await screen.findByTestId('go-to-other')) + await screen.findByTestId('other-heading') + + // Navigate back — match is found in cache, needsContext already consumed + fireEvent.click(await screen.findByTestId('go-to-index')) + await screen.findByTestId('index-heading') + expect(mockContext).not.toHaveBeenCalled() + }) + + test('context with revalidate does NOT re-run when navigating away and back (cached by loader + context)', async () => { + const mockContext = vi.fn() + const mockLoader = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + }, + revalidate: true, + }, + loader: () => { + mockLoader() + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+

Index page

+ +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+

Other page

+ +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-heading') + expect(mockContext).toHaveBeenCalledTimes(1) + expect(mockLoader).toHaveBeenCalledTimes(1) + mockContext.mockClear() + mockLoader.mockClear() + + // Navigate away — match goes to cachedMatches + fireEvent.click(await screen.findByTestId('go-to-other')) + await screen.findByTestId('other-heading') + + // Navigate back — match is found in cache, needsContext consumed. + // Loader re-runs (staleTime=0, stale) but context does not. + fireEvent.click(await screen.findByTestId('go-to-index')) + await screen.findByTestId('index-heading') + expect(mockContext).not.toHaveBeenCalled() + // Loader still re-runs based on its own staleTime logic + expect(mockLoader).toHaveBeenCalledTimes(1) + }) + }) + + describe('context flow through all lifecycle methods', () => { + test('context accumulates through context -> beforeLoad -> loader on a single route', async () => { + const mockLoader = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { fromContext: 'context-value' } + }, + beforeLoad: ({ context }) => { + // Should see context return + expect(context).toEqual( + expect.objectContaining({ fromContext: 'context-value' }), + ) + return { fromBeforeLoad: 'beforeload-value' } + }, + loader: ({ context }) => { + // Should see context + beforeLoad context + mockLoader(context) + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + + expect(context).toEqual({ + fromContext: 'context-value', + fromBeforeLoad: 'beforeload-value', + }) + + expect(mockLoader).toHaveBeenCalledWith( + expect.objectContaining({ + fromContext: 'context-value', + fromBeforeLoad: 'beforeload-value', + }), + ) + }) + + test('context accumulates with router-level context through all lifecycle methods', async () => { + const mockLoader = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: ({ context }) => { + return { ...context, fromContext: true } + }, + beforeLoad: ({ context }) => { + return { ...context, fromBeforeLoad: true } + }, + loader: ({ context }) => { + mockLoader(context) + }, + component: () =>
Index page
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ + routeTree, + history, + context: { appName: 'test' }, + }) + + render() + + await screen.findByTestId('index-page') + + expect(mockLoader).toHaveBeenCalledWith( + expect.objectContaining({ + appName: 'test', + fromContext: true, + fromBeforeLoad: true, + }), + ) + }) + }) + + describe('parent-child context inheritance (3+ levels)', () => { + test('child routes inherit accumulated context from all ancestors', async () => { + const mockGrandchildLoader = vi.fn() + + const rootRoute = createRootRoute({ + context: () => ({ rootContext: 'root' }), + beforeLoad: () => ({ rootBeforeLoad: 'root-bl' }), + component: () => ( +
+ Root +
+ ), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: ({ context }) => { + // Should see root's full context + expect(context).toEqual( + expect.objectContaining({ + rootContext: 'root', + rootBeforeLoad: 'root-bl', + }), + ) + return { parentContext: 'parent' } + }, + beforeLoad: () => ({ parentBeforeLoad: 'parent-bl' }), + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: ({ context }) => { + // Should see root + parent full context + expect(context).toEqual( + expect.objectContaining({ + rootContext: 'root', + rootBeforeLoad: 'root-bl', + parentContext: 'parent', + parentBeforeLoad: 'parent-bl', + }), + ) + return { childContext: 'child' } + }, + beforeLoad: () => ({ childBeforeLoad: 'child-bl' }), + loader: ({ context }) => { + mockGrandchildLoader(context) + }, + component: () => { + const context = childRoute.useRouteContext() + return ( +
{JSON.stringify(context)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await act(() => router.navigate({ to: '/parent/child' })) + + render() + + const contextEl = await screen.findByTestId('child-context') + const context = JSON.parse(contextEl.textContent) + + expect(context).toEqual( + expect.objectContaining({ + rootContext: 'root', + rootBeforeLoad: 'root-bl', + parentContext: 'parent', + parentBeforeLoad: 'parent-bl', + childContext: 'child', + childBeforeLoad: 'child-bl', + }), + ) + + expect(mockGrandchildLoader).toHaveBeenCalledWith( + expect.objectContaining({ + rootContext: 'root', + rootBeforeLoad: 'root-bl', + parentContext: 'parent', + parentBeforeLoad: 'parent-bl', + childContext: 'child', + childBeforeLoad: 'child-bl', + }), + ) + }) + }) + + describe('async support', () => { + test('context supports async execution', async () => { + const executionOrder: Array = [] + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: async () => { + executionOrder.push('context-start') + await sleep(WAIT_TIME) + executionOrder.push('context-end') + return { fromAsyncContext: 'async-value' } + }, + beforeLoad: ({ context }) => { + executionOrder.push('beforeLoad') + // context should have completed before beforeLoad runs + expect(context).toEqual( + expect.objectContaining({ fromAsyncContext: 'async-value' }), + ) + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + + expect(context).toEqual( + expect.objectContaining({ fromAsyncContext: 'async-value' }), + ) + expect(executionOrder).toEqual([ + 'context-start', + 'context-end', + 'beforeLoad', + ]) + }) + }) + + describe('combined invalidation behavior', () => { + test('router.invalidate() re-runs context with revalidate and loader but NOT plain context', async () => { + let contextCount = 0 + let loaderCount = 0 + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + contextCount++ + return { contextRun: contextCount } + }, + revalidate: true, + }, + loader: () => { + loaderCount++ + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
+ {JSON.stringify(context)} +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('context') + expect(contextCount).toBe(1) + expect(loaderCount).toBe(1) + + // First invalidation + await act(async () => { + await router.invalidate() + }) + + expect(contextCount).toBe(2) // Re-run (revalidate: true) + expect(loaderCount).toBe(2) // Re-run + + // Second invalidation + await act(async () => { + await router.invalidate() + }) + + expect(contextCount).toBe(3) // Re-run again + expect(loaderCount).toBe(3) // Re-run again + }) + }) + + describe('execution order guarantees', () => { + test('parent serial phases complete before child serial phases (parent context → beforeLoad → child context → beforeLoad)', async () => { + const executionOrder: Array = [] + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: async () => { + executionOrder.push('parent-context-start') + await sleep(WAIT_TIME) + executionOrder.push('parent-context-end') + return { parentContext: true } + }, + beforeLoad: async () => { + executionOrder.push('parent-beforeLoad-start') + await sleep(WAIT_TIME) + executionOrder.push('parent-beforeLoad-end') + return { parentBeforeLoad: true } + }, + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: async () => { + executionOrder.push('child-context-start') + await sleep(WAIT_TIME) + executionOrder.push('child-context-end') + return { childContext: true } + }, + beforeLoad: async () => { + executionOrder.push('child-beforeLoad-start') + await sleep(WAIT_TIME) + executionOrder.push('child-beforeLoad-end') + return { childBeforeLoad: true } + }, + component: () =>
Child page
, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + render() + await screen.findByTestId('index-page') + + // Clear any entries from initial load + executionOrder.length = 0 + + fireEvent.click(screen.getByTestId('go-parent-child')) + await screen.findByTestId('child-page') + + expect(executionOrder).toEqual([ + 'parent-context-start', + 'parent-context-end', + 'parent-beforeLoad-start', + 'parent-beforeLoad-end', + 'child-context-start', + 'child-context-end', + 'child-beforeLoad-start', + 'child-beforeLoad-end', + ]) + }) + + test('all serial phases complete before loaders fire', async () => { + const executionOrder: Array = [] + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => { + executionOrder.push('parent-context') + return {} + }, + beforeLoad: () => { + executionOrder.push('parent-beforeLoad') + return {} + }, + loader: () => { + executionOrder.push('parent-loader') + }, + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: () => { + executionOrder.push('child-context') + return {} + }, + beforeLoad: () => { + executionOrder.push('child-beforeLoad') + return {} + }, + loader: () => { + executionOrder.push('child-loader') + }, + component: () =>
Child page
, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + render() + await screen.findByTestId('index-page') + + // Clear any entries from initial load + executionOrder.length = 0 + + fireEvent.click(screen.getByTestId('go-parent-child')) + await screen.findByTestId('child-page') + + // All serial phases (context, beforeLoad) must come before any loader + const loaderIndices = executionOrder + .map((entry, i) => (entry.includes('loader') ? i : -1)) + .filter((i) => i >= 0) + const serialIndices = executionOrder + .map((entry, i) => (!entry.includes('loader') ? i : -1)) + .filter((i) => i >= 0) + + const lastSerial = Math.max(...serialIndices) + const firstLoader = Math.min(...loaderIndices) + expect(lastSerial).toBeLessThan(firstLoader) + }) + }) + + describe('context edge cases', () => { + test('context on root route fires exactly once and never again on navigation', async () => { + const mockRootContext = vi.fn() + + const rootRoute = createRootRoute({ + context: () => { + mockRootContext() + return { rootMatched: true } + }, + component: () => ( +
+ Root +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+ Other + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(mockRootContext).toHaveBeenCalledTimes(1) + mockRootContext.mockClear() + + // Navigate to other + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + expect(mockRootContext).not.toHaveBeenCalled() + + // Navigate back + fireEvent.click(await screen.findByTestId('go-index')) + await screen.findByTestId('index-page') + expect(mockRootContext).not.toHaveBeenCalled() + }) + + test('context returning undefined does not clobber parent context', async () => { + const rootRoute = createRootRoute({ + beforeLoad: () => ({ rootValue: 'from-root' }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + // returning undefined — should not overwrite context + return undefined + }, + beforeLoad: ({ context }) => { + // rootValue should still be visible + return { sawRootValue: context.rootValue } + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + rootValue: 'from-root', + sawRootValue: 'from-root', + }), + ) + }) + + test('context receives cause "enter" on fresh match creation', async () => { + const receivedCause = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + context: ({ cause }) => { + receivedCause(cause) + }, + component: () =>
Other
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + expect(receivedCause).toHaveBeenCalledTimes(1) + expect(receivedCause).toHaveBeenCalledWith('enter') + }) + + test('context receives correct params', async () => { + const receivedParams = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const userRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/user/$userId', + context: ({ params }) => { + receivedParams(params) + return { userId: params.userId } + }, + component: () => { + const context = userRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, userRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + fireEvent.click(await screen.findByTestId('go-user')) + const contextEl = await screen.findByTestId('user-context') + const context = JSON.parse(contextEl.textContent) + + expect(receivedParams).toHaveBeenCalledWith({ userId: '42' }) + expect(context).toEqual(expect.objectContaining({ userId: '42' })) + }) + }) + + describe('context with revalidate edge cases', () => { + test('context with revalidate re-runs when loaderDeps change (new matchId)', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: z.object({ + page: z.number().optional(), + }), + loaderDeps: ({ search }) => ({ page: search.page }), + context: { + handler: () => { + mockContext() + return { loadedPage: undefined } + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + const search = indexRoute.useSearch() + return ( +
+ {JSON.stringify(search)} + + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('search')).toBeInTheDocument() + }) + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Change to page=1 — new loaderDeps → new matchId → context re-runs + fireEvent.click(await screen.findByTestId('go-page-1')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ page: 1 }), + ) + }) + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Change to page=2 — new loaderDeps → new matchId → context re-runs + fireEvent.click(await screen.findByTestId('go-page-2')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ page: 2 }), + ) + }) + expect(mockContext).toHaveBeenCalledTimes(1) + }) + + test('context with revalidate re-runs after GC (navigating away, match evicted, navigating back)', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+ Other + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ + routeTree, + history, + defaultGcTime: 0, + }) + + render() + + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Navigate away — match evicted immediately (gcTime: 0) + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + // Navigate back — fresh match, context should re-run + fireEvent.click(await screen.findByTestId('go-index')) + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + }) + + test('context returning undefined does not clobber context from parent and beforeLoad', async () => { + const rootRoute = createRootRoute({ + context: () => { + return { fromParent: 'parent-val' } + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: () => { + return { fromBeforeLoad: 'bl-val' } + }, + context: { + handler: () => { + // returning undefined + return undefined + }, + revalidate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + fromParent: 'parent-val', + fromBeforeLoad: 'bl-val', + }), + ) + }) + + test('context with revalidate receives correct deps (loaderDeps)', async () => { + const receivedDeps = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: z.object({ + sort: z.string().optional(), + }), + loaderDeps: ({ search }) => ({ sort: search.sort }), + context: { + handler: () => { + receivedDeps() + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(receivedDeps).toHaveBeenCalledTimes(1) + receivedDeps.mockClear() + + fireEvent.click(await screen.findByTestId('set-sort')) + await waitFor(() => { + expect(receivedDeps).toHaveBeenCalledTimes(1) + }) + }) + + test('context with revalidate receives cause "enter" on fresh navigation', async () => { + const receivedCause = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + context: { + handler: ({ cause }) => { + receivedCause(cause) + }, + revalidate: true, + }, + component: () =>
Other
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + expect(receivedCause).toHaveBeenCalledTimes(1) + expect(receivedCause).toHaveBeenCalledWith('enter') + }) + }) + + describe('context visibility per callback', () => { + test('beforeLoad sees context return from same route', async () => { + const beforeLoadContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { fromContext: 'visible' } + }, + beforeLoad: ({ context }) => { + beforeLoadContext(context) + return { fromBeforeLoad: 'bl' } + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + expect(beforeLoadContext).toHaveBeenCalledWith( + expect.objectContaining({ fromContext: 'visible' }), + ) + }) + + test('loader sees context + beforeLoad from same route', async () => { + const loaderContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { fromContext: 'ctx' } + }, + beforeLoad: () => { + return { fromBeforeLoad: 'bl' } + }, + loader: ({ context }) => { + loaderContext(context) + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + expect(loaderContext).toHaveBeenCalledWith( + expect.objectContaining({ + fromContext: 'ctx', + fromBeforeLoad: 'bl', + }), + ) + }) + + test('context does NOT see same-route beforeLoad (only parent full context)', async () => { + const childContextFn = vi.fn() + + const rootRoute = createRootRoute() + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ parentContext: 'pctx' }), + beforeLoad: () => ({ parentBeforeLoad: 'pbl' }), + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: ({ context }) => { + childContextFn(context) + return { childContext: 'cctx' } + }, + beforeLoad: () => ({ childBeforeLoad: 'cbl' }), + component: () =>
Child
, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await act(() => router.navigate({ to: '/parent/child' })) + + render() + + await screen.findByTestId('child-page') + + // Child's context should see parent's FULL context (context + beforeLoad) + expect(childContextFn).toHaveBeenCalledWith( + expect.objectContaining({ + parentContext: 'pctx', + parentBeforeLoad: 'pbl', + }), + ) + // But NOT child's own beforeLoad or context + const calledWith = childContextFn.mock.calls[0]![0] + expect(calledWith).not.toHaveProperty('childBeforeLoad') + expect(calledWith).not.toHaveProperty('childContext') + }) + }) + + describe('parent-child selective GC', () => { + test('when child match is GC-ed but parent is not, only child context re-runs', async () => { + const parentContext = vi.fn() + const childContext = vi.fn() + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => { + parentContext() + return { parentMatched: true } + }, + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + gcTime: 0, + context: () => { + childContext() + return { childMatched: true } + }, + component: () => { + const navigate = childRoute.useNavigate() + return ( +
+ Child + +
+ ) + }, + }) + const parentIndexRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/', + component: () => { + const navigate = parentIndexRoute.useNavigate() + return ( +
+ Parent Index + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute, parentIndexRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await act(() => router.navigate({ to: '/parent/child' })) + + render() + + await screen.findByTestId('child-page') + expect(parentContext).toHaveBeenCalledTimes(1) + expect(childContext).toHaveBeenCalledTimes(1) + parentContext.mockClear() + childContext.mockClear() + + // Navigate to parent index — child match goes to cache and gets GC-ed (gcTime: 0) + // Parent match stays active + fireEvent.click(await screen.findByTestId('go-parent-only')) + await screen.findByTestId('parent-index-page') + + // Parent should NOT re-run (still active) + expect(parentContext).not.toHaveBeenCalled() + + // Navigate back to child — child match was GC-ed, fresh creation + fireEvent.click(await screen.findByTestId('go-child')) + await screen.findByTestId('child-page') + + // Parent still should NOT re-run (stayed matched the whole time) + expect(parentContext).not.toHaveBeenCalled() + + // Child should re-run (fresh match after GC) + expect(childContext).toHaveBeenCalledTimes(1) + }) + }) + + describe('context-with-revalidate-only routes (no loader)', () => { + test('route with only context (revalidate) and no loader works correctly', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + // No loader — only context with revalidate + context: { + handler: () => { + mockContext() + return { contextValue: 'from-context' } + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + const context = indexRoute.useRouteContext() + return ( +
+ {JSON.stringify(context)} + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+ Other + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ contextValue: 'from-context' }), + ) + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + // Navigate away + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + // Navigate back — match is cached, context should NOT re-run + fireEvent.click(await screen.findByTestId('go-index')) + const contextEl2 = await screen.findByTestId('context') + expect(JSON.parse(contextEl2.textContent)).toEqual( + expect.objectContaining({ contextValue: 'from-context' }), + ) + expect(mockContext).not.toHaveBeenCalled() + }) + + test('route with only context (revalidate) re-runs on invalidate', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + return { contextRun: mockContext.mock.calls.length } + }, + revalidate: true, + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + await act(async () => { + await router.invalidate() + }) + + expect(mockContext).toHaveBeenCalledTimes(1) + }) + }) + + describe('context updates on invalidation', () => { + test('context with revalidate updates after each invalidation', async () => { + let counter = 0 + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + counter++ + return { counter } + }, + revalidate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await waitFor(() => { + const el = screen.getByTestId('context') + expect(JSON.parse(el.textContent).counter).toBe(1) + }) + + // First invalidation + await act(async () => { + await router.invalidate() + }) + + await waitFor(() => { + const el = screen.getByTestId('context') + expect(JSON.parse(el.textContent).counter).toBe(2) + }) + + // Second invalidation + await act(async () => { + await router.invalidate() + }) + + await waitFor(() => { + const el = screen.getByTestId('context') + expect(JSON.parse(el.textContent).counter).toBe(3) + }) + }) + }) + + describe('context-only routes (no beforeLoad, no loader)', () => { + test('route with only context provides context to component', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { onlyContext: 'value' } + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ onlyContext: 'value' }), + ) + }) + }) + + describe('context overriding between lifecycle methods', () => { + test('later lifecycle methods can override earlier context keys', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { shared: 'from-context', contextOnly: 'ctx' } + }, + beforeLoad: () => { + return { shared: 'from-beforeLoad', beforeLoadOnly: 'bl' } + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + + // beforeLoad runs after context, so it wins for 'shared' + expect(context.shared).toBe('from-beforeLoad') + expect(context.contextOnly).toBe('ctx') + expect(context.beforeLoadOnly).toBe('bl') + }) + }) + + describe('object form lifecycle methods', () => { + test('object form beforeLoad handler runs and provides context', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: { + handler: () => ({ blValue: 'from-object-form' }), + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ blValue: 'from-object-form' }), + ) + }) + + test('object form context handler runs and provides context', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => ({ ctxValue: 'from-object-form' }), + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ ctxValue: 'from-object-form' }), + ) + }) + + test('object form context with revalidate handler runs and provides context', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => ({ ctxInvValue: 'from-object-form' }), + revalidate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ ctxInvValue: 'from-object-form' }), + ) + }) + + test('object form loader handler runs and provides loaderData', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: { + handler: () => ({ ldValue: 'from-object-form' }), + }, + component: () => { + const data = indexRoute.useLoaderData() + return
{JSON.stringify(data)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const dataEl = await screen.findByTestId('loader-data') + expect(JSON.parse(dataEl.textContent)).toEqual({ + ldValue: 'from-object-form', + }) + }) + + test('mixed function and object form on the same route', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => ({ ctxObj: 'object-form' }), + dehydrate: false, + }, + beforeLoad: { + handler: () => ({ blObj: 'object-form' }), + }, + loader: () => ({ ldFunc: 'function-form' }), + component: () => { + const context = indexRoute.useRouteContext() + const data = indexRoute.useLoaderData() + return ( +
+ {JSON.stringify(context)} + {JSON.stringify(data)} +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + ctxObj: 'object-form', + blObj: 'object-form', + }), + ) + + const dataEl = screen.getByTestId('loader-data') + expect(JSON.parse(dataEl.textContent)).toEqual({ + ldFunc: 'function-form', + }) + }) + + test('object form with dehydrate flag still runs handler on client navigation', async () => { + const contextHandler = vi.fn(() => ({ ctxVal: 'matched' })) + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: contextHandler, + dehydrate: true, // dehydrate flag has no effect on SPA navigation + }, + component: () => { + const context = aboutRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree, history }) + + render() + await screen.findByTestId('index-page') + + // Navigate to about + fireEvent.click(screen.getByTestId('go-about')) + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ ctxVal: 'matched' }), + ) + expect(contextHandler).toHaveBeenCalledTimes(1) + }) + + test('object form backward compat: function form still works identically', async () => { + // This test verifies that routes using function form continue to work + // exactly as before, ensuring backward compatibility + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => ({ ctxFn: 'fn' }), + beforeLoad: () => ({ blFn: 'fn' }), + loader: () => ({ ldFn: 'fn' }), + component: () => { + const context = indexRoute.useRouteContext() + const data = indexRoute.useLoaderData() + return ( +
+ {JSON.stringify(context)} + {JSON.stringify(data)} +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + ctxFn: 'fn', + blFn: 'fn', + }), + ) + + const dataEl = screen.getByTestId('loader-data') + expect(JSON.parse(dataEl.textContent)).toEqual({ ldFn: 'fn' }) + }) + + test('object form context chain flows correctly parent to child', async () => { + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: { + handler: () => ({ parentOM: 'p-om' }), + }, + beforeLoad: { + handler: () => ({ parentBL: 'p-bl' }), + dehydrate: false, + }, + component: () => ( +
+ +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: { + handler: ({ context }) => ({ + childCtx: 'c-ctx', + sawParentOM: context.parentOM, + sawParentBL: context.parentBL, + }), + revalidate: true, + }, + component: () => { + const context = childRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await act(() => router.navigate({ to: '/parent/child' })) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + parentOM: 'p-om', + parentBL: 'p-bl', + childCtx: 'c-ctx', + sawParentOM: 'p-om', + sawParentBL: 'p-bl', + }), + ) + }) + + test('object form context runs only once even with dehydrate flag', async () => { + const contextCount = vi.fn() + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + contextCount() + return { matched: true } + }, + dehydrate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context)}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('context') + expect(contextCount).toHaveBeenCalledTimes(1) + + // Invalidate and verify context doesn't re-run (no invalidate flag) + await act(async () => { + await router.invalidate() + }) + + expect(contextCount).toHaveBeenCalledTimes(1) + }) + + test('object form context with revalidate re-runs on invalidation', async () => { + const contextCount = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + contextCount() + return { loadCount: contextCount.mock.calls.length } + }, + revalidate: true, + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(contextCount).toHaveBeenCalledTimes(1) + + await act(async () => { + await router.invalidate() + }) + + // context with revalidate should re-run on invalidation + expect(contextCount).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/packages/react-router/tests/useRouteContext.test-d.tsx b/packages/react-router/tests/useRouteContext.test-d.tsx index 7d0a14ef4a8..82bdd4722aa 100644 --- a/packages/react-router/tests/useRouteContext.test-d.tsx +++ b/packages/react-router/tests/useRouteContext.test-d.tsx @@ -214,6 +214,343 @@ test('when there are multiple contexts', () => { >() }) +test('when context returns context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ invoicePermissions: true }), + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + invoicePermissions: boolean + }>() + + // child inherits parent context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + invoicePermissions: boolean + }>() +}) + +test('when context with revalidate returns context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { handler: () => ({ invoiceList: [1, 2, 3] }), revalidate: true }, + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + invoiceList: Array + }>() + + // child inherits parent context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + invoiceList: Array + }>() +}) + +test('when context + beforeLoad all return context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ fromContext: 'match-data' }), + beforeLoad: () => ({ fromBeforeLoad: 'before-data' }), + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + fromContext: string + fromBeforeLoad: string + }>() +}) + +test('when child route sees parent context + beforeLoad context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'parent', + context: () => ({ parentContext: 'match' }), + beforeLoad: () => ({ parentBeforeLoad: 'before' }), + }) + + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: 'child', + context: () => ({ childContext: 'child-match' }), + beforeLoad: () => ({ childBeforeLoad: 'child-before' }), + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // parent only has its own context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + parentContext: string + parentBeforeLoad: string + }>() + + // child inherits all parent context plus its own + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + parentContext: string + parentBeforeLoad: string + childContext: string + childBeforeLoad: string + }>() +}) + +test('when context uses as const return type', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ status: 'active' }) as const, + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + readonly status: 'active' + }>() +}) + +test('when overlapping keys across context and beforeLoad', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ shared: 'from-context' }) as const, + beforeLoad: () => ({ shared: 'from-beforeLoad' }) as const, + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // beforeLoad wins because it's the last Assign in the chain + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + readonly shared: 'from-beforeLoad' + }>() +}) + +test('when non-strict mode with context across routes', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ invoiceData: 'data' }), + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + context: { handler: () => ({ postData: 'data' }), revalidate: true }, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + invoicesRoute, + postsRoute, + ]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // non-strict mode unions all possible context shapes + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId?: string + invoiceData?: string + postData?: string + }>() +}) + +test('when root route has context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()({ + context: () => ({ rootContext: 'root-match' }), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ invoiceContext: 'inv-match' }), + }) + + const routeTree = rootRoute.addChildren([indexRoute, invoicesRoute]) + + // eslint-disable-next-line unused-imports/no-unused-vars + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // index route inherits root context + expectTypeOf(useRouteContext).returns.toEqualTypeOf<{ + userId: string + rootContext: string + }>() + + // invoices route has root + its own context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf<{ + userId: string + rootContext: string + invoiceContext: string + }>() +}) + test('when there are overlapping contexts', () => { interface Context { userId: string diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 852d186b67d..79b184c7c38 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -149,6 +149,8 @@ export interface RouteMatch< minPendingPromise?: ControlledPromise dehydrated?: boolean /** @internal */ + needsContext?: boolean + /** @internal */ error?: unknown } loaderData?: TLoaderData diff --git a/packages/router-core/src/config.ts b/packages/router-core/src/config.ts index d6c41fb40f6..365af3f2b58 100644 --- a/packages/router-core/src/config.ts +++ b/packages/router-core/src/config.ts @@ -1,42 +1,59 @@ import type { SSROption } from './router' +import type { DefaultDehydrateConfig } from './lifecycle' import type { AnySerializationAdapter } from './ssr/serializer/transformer' export interface RouterConfigOptions< in out TSerializationAdapters, in out TDefaultSsr, + in out TDefaultDehydrate, > { serializationAdapters?: TSerializationAdapters defaultSsr?: TDefaultSsr + defaultDehydrate?: TDefaultDehydrate } export interface RouterConfig< in out TSerializationAdapters, in out TDefaultSsr, + in out TDefaultDehydrate, > { - '~types': RouterConfigTypes + '~types': RouterConfigTypes< + TSerializationAdapters, + TDefaultSsr, + TDefaultDehydrate + > serializationAdapters: TSerializationAdapters defaultSsr: TDefaultSsr | undefined + defaultDehydrate: TDefaultDehydrate | undefined } export interface RouterConfigTypes< in out TSerializationAdapters, in out TDefaultSsr, + in out TDefaultDehydrate, > { serializationAdapters: TSerializationAdapters defaultSsr: TDefaultSsr + defaultDehydrate: TDefaultDehydrate } export const createRouterConfig = < const TSerializationAdapters extends ReadonlyArray = [], TDefaultSsr extends SSROption = SSROption, + TDefaultDehydrate extends DefaultDehydrateConfig = DefaultDehydrateConfig, >( - options: RouterConfigOptions, -): RouterConfig => { + options: RouterConfigOptions< + TSerializationAdapters, + TDefaultSsr, + TDefaultDehydrate + >, +): RouterConfig => { return { serializationAdapters: options.serializationAdapters, defaultSsr: options.defaultSsr, - } as RouterConfig + defaultDehydrate: options.defaultDehydrate, + } as RouterConfig } -export type AnyRouterConfig = RouterConfig +export type AnyRouterConfig = RouterConfig diff --git a/packages/router-core/src/fileRoute.ts b/packages/router-core/src/fileRoute.ts index 90b47ab6437..98512ad2175 100644 --- a/packages/router-core/src/fileRoute.ts +++ b/packages/router-core/src/fileRoute.ts @@ -3,6 +3,7 @@ import type { AnyContext, AnyPathParams, AnyRoute, + DefaultLifecycleDehydrateFn, FileBaseRouteOptions, ResolveParams, Route, @@ -33,7 +34,7 @@ export interface FileRoutesByPath { // } } -export interface FileRouteOptions< +export type FileRouteOptions< TRegister, TFilePath extends string, TParentRoute extends AnyRoute, @@ -42,44 +43,48 @@ export interface FileRouteOptions< TFullPath extends RouteConstraints['TFullPath'], TSearchValidator = undefined, TParams = ResolveParams, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, TServerMiddlewares = unknown, THandlers = undefined, -> - extends - FileBaseRouteOptions< - TRegister, - TParentRoute, - TId, - TPath, - TSearchValidator, - TParams, - TLoaderDeps, - TLoaderFn, - AnyContext, - TRouteContextFn, - TBeforeLoadFn, - AnyContext, - TSSR, - TServerMiddlewares, - THandlers - >, - UpdatableRouteOptions< - TParentRoute, - TId, - TFullPath, - TParams, - TSearchValidator, - TLoaderFn, - TLoaderDeps, - AnyContext, - TRouteContextFn, - TBeforeLoadFn - > {} + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, +> = FileBaseRouteOptions< + TRegister, + TParentRoute, + TId, + TPath, + TSearchValidator, + TParams, + TLoaderDeps, + TLoaderFn, + AnyContext, + TContextFn, + TBeforeLoadFn, + AnyContext, + TSSR, + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn +> & + UpdatableRouteOptions< + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + AnyContext, + NoInfer, + NoInfer + > export type CreateFileRoute< TFilePath extends string, @@ -91,13 +96,16 @@ export type CreateFileRoute< TRegister = Register, TSearchValidator = undefined, TParams = ResolveParams, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: FileRouteOptions< TRegister, @@ -108,13 +116,16 @@ export type CreateFileRoute< TFullPath, TSearchValidator, TParams, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares, - THandlers + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ) => Route< TRegister, @@ -126,7 +137,7 @@ export type CreateFileRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index fd673ca4103..5ca760d29ad 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -137,6 +137,16 @@ export { encode, decode } from './qss' export { rootRouteId } from './root' export type { RootRouteId } from './root' +export { + builtinDefaultDehydrate, + resolveHandler, + shouldDehydrate, + getDehydrateFn, + getHydrateFn, + getRevalidateFn, +} from './lifecycle' +export type { LifecycleOption, DefaultDehydrateConfig } from './lifecycle' + export { BaseRoute, BaseRouteApi, BaseRootRoute } from './route' export type { AnyPathParams, @@ -168,7 +178,6 @@ export type { StringifyParamsFn, ParamsOptions, UpdatableStaticRouteOption, - ContextReturnType, ContextAsyncReturnType, ResolveRouteContext, ResolveLoaderData, @@ -202,11 +211,18 @@ export type { RouteLoaderFn, RouteLoaderEntry, LoaderFnContext, - RouteContextFn, + ContextFn, + ContextLifecycleOption, + BeforeLoadLifecycleOption, + LoaderLifecycleOption, + DefaultLifecycleDehydrateFn, ContextOptions, RouteContextOptions, + ContextFnOptions, + ContextObjectWithDehydrateInput, SsrContextOptions, BeforeLoadContextOptions, + LifecycleObjectWithDehydrateInput, RootRouteOptions, RootRouteOptionsExtensions, UpdatableRouteOptionsExtensions, diff --git a/packages/router-core/src/lifecycle.ts b/packages/router-core/src/lifecycle.ts new file mode 100644 index 00000000000..e091c5b9c85 --- /dev/null +++ b/packages/router-core/src/lifecycle.ts @@ -0,0 +1,182 @@ +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DehydrateOption = + | boolean + | ((ctx: { data: TValue }) => TWire) + +export type HydrateOption = (ctx: { + data: TWire +}) => TValue + +/** + * Lifecycle methods (context/beforeLoad/loader) accept either a plain handler + * function or an object form with additional capabilities. + */ +export type LifecycleOption< + TFn, + TValue = unknown, + TWire = unknown, + TRevalidateCtx = unknown, +> = + | TFn + | ({ + handler: TFn + /** + * Context-only: Controls invalid/stale behavior. + * - true: handler can re-run on invalid/stale + * - function: called instead of handler on invalid/stale + */ + revalidate?: + | boolean + | (( + ctx: TRevalidateCtx & { prev: TValue | undefined }, + ) => TValue | Promise) + } & ( + | { + dehydrate?: undefined | false + hydrate?: HydrateOption + } + | { + dehydrate: true + hydrate?: HydrateOption + } + | { + dehydrate: (ctx: { data: TValue }) => TWire + hydrate: HydrateOption + } + )) + +type AnyLifecycleFunction = (...args: Array) => any + +type AnyLifecycleObject = { + handler: AnyLifecycleFunction + revalidate?: boolean | ((ctx: any) => any) + dehydrate?: boolean | ((ctx: { data: any }) => any) + hydrate?: (ctx: { data: any }) => any +} + +type AnyLifecycleOption = AnyLifecycleFunction | AnyLifecycleObject + +type ResolveHandlerFromOption = TOption extends { + handler: infer THandler +} + ? THandler + : TOption + +type ResolveDehydrateFromOption = TOption extends { + dehydrate?: infer TDehydrate +} + ? Extract + : never + +type ResolveHydrateFromOption = TOption extends { + hydrate?: infer THydrate +} + ? Extract + : never + +type ResolveRevalidateFromOption = TOption extends { + revalidate?: infer TRevalidate +} + ? Extract + : never + +type FallbackLifecycleFunction = [TFn] extends [never] + ? AnyLifecycleFunction + : TFn + +export interface DefaultDehydrateConfig { + beforeLoad?: boolean + loader?: boolean + context?: boolean +} + +/** + * Built-in default SSR dehydration behavior. + * Used as the final fallback when neither the method nor the router provides a value. + */ +export const builtinDefaultDehydrate: Required = { + beforeLoad: true, + loader: true, + context: false, +} + +// --------------------------------------------------------------------------- +// Runtime helpers +// --------------------------------------------------------------------------- + +/** + * Extract just the handler from either the function form or object form. + * Zero allocations — no intermediate object created. + */ +export function resolveHandler( + option: TOption | undefined, +): FallbackLifecycleFunction> | undefined { + if (option === undefined) return undefined + if (typeof option === 'function') { + return option as FallbackLifecycleFunction< + ResolveHandlerFromOption + > + } + return option.handler as FallbackLifecycleFunction< + ResolveHandlerFromOption + > +} + +/** + * Determine whether a lifecycle method's return value should be dehydrated + * (included in the dehydrated SSR payload). + * + * Three-level priority: + * method-level object form > router-level defaultDehydrate > built-in default + * + * Only call this when the lifecycle option actually exists on the route. + * + * @param option The lifecycle option (function or object form) + * @param routerDefault The router-level default for this specific method + * @param builtinDefault The built-in default for this method + */ +export function shouldDehydrate( + option: TOption, + routerDefault: boolean | undefined, + builtinDefault: boolean, +): boolean { + if (typeof option !== 'function') { + const d = option.dehydrate + if (typeof d === 'boolean') return d + if (typeof d === 'function') return true + } + return routerDefault ?? builtinDefault +} + +export function getDehydrateFn( + option: TOption | undefined, +): FallbackLifecycleFunction> | undefined { + if (!option || typeof option === 'function') return undefined + const d = option.dehydrate + return (typeof d === 'function' ? d : undefined) as + | FallbackLifecycleFunction> + | undefined +} + +export function getHydrateFn( + option: TOption | undefined, +): FallbackLifecycleFunction> | undefined { + if (!option || typeof option === 'function') return undefined + const h = option.hydrate + return (typeof h === 'function' ? h : undefined) as + | FallbackLifecycleFunction> + | undefined +} + +export function getRevalidateFn( + option: TOption | undefined, +): FallbackLifecycleFunction> | undefined { + if (!option || typeof option === 'function') return undefined + const r = option.revalidate + return (typeof r === 'function' ? r : undefined) as + | FallbackLifecycleFunction> + | undefined +} diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index f901a0c97dd..2cc88a58344 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -4,11 +4,13 @@ import { createControlledPromise, isPromise } from './utils' import { isNotFound } from './not-found' import { rootRouteId } from './root' import { isRedirect } from './redirect' +import { resolveHandler } from './lifecycle' import type { NotFoundError } from './not-found' import type { ParsedLocation } from './location' import type { AnyRoute, BeforeLoadContextOptions, + ContextFnOptions, LoaderFnContext, SsrContextOptions, } from './route' @@ -452,6 +454,7 @@ const executeBeforeLoad = ( // Build context from all parent matches, excluding current match's __beforeLoadContext // (since we're about to execute beforeLoad for this match) + // Include current match's __routeContext since context runs before beforeLoad const context = { ...buildMatchContext(inner, index, false), ...match.__routeContext, @@ -512,7 +515,9 @@ const executeBeforeLoad = ( let beforeLoadContext try { - beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext) + beforeLoadContext = resolveHandler(route.options.beforeLoad)!( + beforeLoadFnContext, + ) if (isPromise(beforeLoadContext)) { pending() return beforeLoadContext @@ -530,6 +535,147 @@ const executeBeforeLoad = ( return } +const executeContext = ( + inner: InnerLoadContext, + matchId: string, + index: number, + route: AnyRoute, +): void | Promise => { + const match = inner.router.getMatch(matchId)! + + const needsContext = !!match._nonReactive.needsContext + const contextOption = route.options.context + const revalidateOption = + typeof contextOption === 'function' ? undefined : contextOption?.revalidate + const optedIn = + revalidateOption === true || typeof revalidateOption === 'function' + + // Determine cause: initial / invalid / stale + let contextCause: 'initial' | 'invalid' | 'stale' | 'none' = 'none' + if (needsContext) { + contextCause = 'initial' + } else if (match.invalid && optedIn) { + contextCause = 'invalid' + } else if (!match.invalid && optedIn) { + // Check staleness — context defaults to Infinity (never stale) unlike + // loader which defaults to 0. An explicit route-level staleTime still + // applies when provided. + const preload = resolvePreload(inner, matchId) + const age = Date.now() - match.updatedAt + const staleAge = preload + ? (route.options.preloadStaleTime ?? + inner.router.options.defaultPreloadStaleTime ?? + 30_000) + : (route.options.staleTime ?? + inner.router.options.defaultStaleTime ?? + Infinity) + + if (match.status === 'success' && age > staleAge) { + contextCause = 'stale' + } + } + + if (contextCause === 'none') return + + if (!contextOption) { + // Clear the flag even if there's no context handler + match._nonReactive.needsContext = false + return + } + + // Clear early so it never lingers if context throws + match._nonReactive.needsContext = false + + // Build context from all parent matches (excluding current match) + const context = buildMatchContext(inner, index, false) + const { params, cause, loaderDeps } = match + const preload = resolvePreload(inner, matchId) + + const contextFnContext: ContextFnOptions = { + params, + preload, + context, + deps: loaderDeps, + 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, + abortController: match.abortController, + ...inner.router.options.additionalContext, + } + + const updateRouteContext = (routeContext: any) => { + if (routeContext === undefined) return + if (isRedirect(routeContext) || isNotFound(routeContext)) { + handleSerialError(inner, index, routeContext) + } + + // First commit __routeContext so buildMatchContext can read it + inner.updateMatch(matchId, (prev) => ({ + ...prev, + __routeContext: routeContext, + })) + + // Now rebuild the merged context from the committed store. + // We do NOT update updatedAt here — that is managed by the loader + // completion so the loader's stale-time check isn't short-circuited + // by a context-only revalidation. + inner.updateMatch(matchId, (prev) => ({ + ...prev, + context: buildMatchContext(inner, index), + })) + } + + let routeContext + try { + const shouldRevalidate = + contextCause === 'invalid' || contextCause === 'stale' + + routeContext = + shouldRevalidate && typeof revalidateOption === 'function' + ? revalidateOption({ + ...contextFnContext, + prev: match.__routeContext, + }) + : resolveHandler(contextOption)!(contextFnContext) + if (isPromise(routeContext)) { + return routeContext + .catch((err) => { + handleSerialError(inner, index, err) + }) + .then(updateRouteContext) + } + } catch (err) { + handleSerialError(inner, index, err) + } + + updateRouteContext(routeContext) + return +} + +const handleContext = ( + inner: InnerLoadContext, + index: number, +): void | Promise => { + const { id: matchId, routeId } = inner.matches[index]! + const route = inner.router.looseRoutesById[routeId]! + + const skipResult = shouldSkipLoader(inner, matchId) + if (skipResult) return + + // Skip context execution during preload when route opts out of preloading + const preload = resolvePreload(inner, matchId) + if (preload && route.options.preload === false) return + + return executeContext(inner, matchId, index, route) +} + const handleBeforeLoad = ( inner: InnerLoadContext, index: number, @@ -661,9 +807,7 @@ const runLoader = async ( } // Kick off the loader! - const routeLoader = route.options.loader - const loader = - typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler + const loader = resolveHandler(route.options.loader) const loaderResult = loader?.( getLoaderContext(inner, matchPromises, matchId, index, route), ) @@ -982,9 +1126,11 @@ export async function loadMatches(arg: { let beforeLoadNotFound: NotFoundError | undefined - // Execute all beforeLoads one by one + // Execute context and beforeLoad serially per-route, parent → child for (let i = 0; i < inner.matches.length; i++) { try { + const ctx = handleContext(inner, i) + if (isPromise(ctx)) await ctx const beforeLoad = handleBeforeLoad(inner, i) if (isPromise(beforeLoad)) await beforeLoad } catch (err) { @@ -1209,7 +1355,7 @@ export function loadRouteChunk( ) { if (!route._lazyLoaded && route._lazyPromise === undefined) { if (route.lazyFn) { - route._lazyPromise = route.lazyFn().then((lazyRoute) => { + route._lazyPromise = route.lazyFn().then((lazyRoute: any) => { // explicitly don't copy over the lazy route's id const { id: _id, ...options } = lazyRoute.options Object.assign(route.options, options) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index d7288e41045..a354310e0f4 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -17,7 +17,13 @@ import type { } from './Matches' import type { RootRouteId } from './root' import type { ParseRoute, RouteById, RouteIds, RoutePaths } from './routeInfo' -import type { AnyRouter, Register, RegisteredRouter, SSROption } from './router' +import type { + AnyRouter, + Register, + RegisteredConfigType, + RegisteredRouter, + SSROption, +} from './router' import type { BuildLocationFn, NavigateFn } from './RouterProvider' import type { Assign, @@ -25,7 +31,7 @@ import type { Constrain, Expand, IntersectAssign, - LooseAsyncReturnType, + IsAny, LooseReturnType, NoInfer, } from './utils' @@ -43,7 +49,56 @@ import type { ValidatorFn, ValidatorObj, } from './validators' -import type { ValidateSerializableLifecycleResult } from './ssr/serializer/transformer' +import type { + ValidateSerializableInput, + ValidateSerializableLifecycleResult, +} from './ssr/serializer/transformer' +import type { DefaultDehydrateConfig } from './lifecycle' + +// --------------------------------------------------------------------------- +// Type-level dehydrate resolution helpers +// --------------------------------------------------------------------------- + +/** + * Read `defaultDehydrate` from the registered config (via TRegister). + * Returns `unknown` if no config registered. + */ +type RegisteredDefaultDehydrate = RegisteredConfigType< + TRegister, + 'defaultDehydrate' +> + +/** + * Resolve the registered default dehydrate flag for a specific method. + * Falls back to TBuiltin if the registered config doesn't specify the method. + */ +type MethodDefaultDehydrate< + TRegister, + TMethod extends keyof DefaultDehydrateConfig, + TBuiltin extends boolean, +> = + unknown extends RegisteredDefaultDehydrate + ? TBuiltin + : RegisteredDefaultDehydrate extends DefaultDehydrateConfig + ? undefined extends RegisteredDefaultDehydrate[TMethod] + ? TBuiltin + : NonNullable[TMethod]> + : TBuiltin + +/** + * Conditionally apply serialization validation based on the effective dehydrate flag. + * When dehydrate is true, validates that the handler return type is serializable. + * When dehydrate is false, allows any return type. + */ +type ValidateIfSerializable< + TRegister, + TParentRoute extends AnyRoute, + TSSR, + TFn, + TDehydrate, +> = TDehydrate extends true + ? ValidateSerializableLifecycleResult + : unknown export type AnyPathParams = {} @@ -287,23 +342,75 @@ export type TrimPathRight = T extends '/' ? TrimPathRight : T -export type ContextReturnType = unknown extends TContextFn - ? TContextFn - : LooseReturnType extends never +type ResolveLifecycleHandler = TOption extends { + handler: infer THandler +} + ? THandler + : TOption + +export type ContextReturnType = IsAny< + TContextFn, + AnyContext, + unknown extends TContextFn ? AnyContext - : LooseReturnType + : ResolveLifecycleHandler extends (...args: Array) => any + ? LooseReturnType> extends never + ? AnyContext + : LooseReturnType> + : [ResolveLifecycleHandler] extends [never] + ? AnyContext + : [ResolveLifecycleHandler] extends [undefined] + ? AnyContext + : ResolveLifecycleHandler +> -export type ContextAsyncReturnType = unknown extends TContextFn - ? TContextFn - : LooseAsyncReturnType extends never +export type ContextAsyncReturnType = IsAny< + TContextFn, + AnyContext, + unknown extends TContextFn ? AnyContext - : LooseAsyncReturnType + : ResolveLifecycleHandler extends (...args: Array) => any + ? LooseReturnType> extends Promise< + infer TReturn + > + ? TReturn extends never + ? AnyContext + : TReturn + : LooseReturnType> extends never + ? AnyContext + : LooseReturnType> + : [Awaited>] extends [never] + ? AnyContext + : [Awaited>] extends [undefined] + ? AnyContext + : Awaited> +> -export type ResolveRouteContext = Assign< - ContextReturnType, +export type ResolveRouteContext = Assign< + ContextReturnType, ContextAsyncReturnType > +export type ResolveLoaderData = IsAny< + TLoaderFn, + any, + unknown extends TLoaderFn + ? TLoaderFn + : ResolveLifecycleHandler extends (...args: Array) => any + ? LooseReturnType> extends Promise< + infer TReturn + > + ? TReturn extends never + ? undefined + : TReturn + : LooseReturnType> extends never + ? undefined + : LooseReturnType> + : [Awaited>] extends [never] + ? undefined + : Awaited> +> + export type ResolveRouteLoaderFn = TLoaderFn extends { handler: infer THandler } @@ -337,12 +444,6 @@ export type RouteLoaderObject< staleReloadMode?: LoaderStaleReloadMode } -export type ResolveLoaderData = unknown extends TLoaderFn - ? TLoaderFn - : LooseAsyncReturnType> extends never - ? undefined - : LooseAsyncReturnType> - export type ResolveFullSearchSchema< TParentRoute extends AnyRoute, TSearchValidator, @@ -376,19 +477,19 @@ export type RouteContextParameter< export type BeforeLoadContextParameter< TParentRoute extends AnyRoute, TRouterContext, - TRouteContextFn, + TContextFn, > = Assign< RouteContextParameter, - ContextReturnType + ContextReturnType > export type ResolveAllContext< TParentRoute extends AnyRoute, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, > = Assign< - BeforeLoadContextParameter, + BeforeLoadContextParameter, ContextAsyncReturnType > export interface FullSearchSchemaOption< @@ -434,7 +535,7 @@ export interface RouteTypes< in out TSearchValidator, in out TParams, in out TRouterContext, - in out TRouteContextFn, + in out TContextFn, in out TBeforeLoadFn, in out TLoaderDeps, in out TLoaderFn, @@ -461,13 +562,13 @@ export interface RouteTypes< params: TParams allParams: ResolveAllParamsFromParent routerContext: TRouterContext - routeContext: ResolveRouteContext - routeContextFn: TRouteContextFn + routeContext: ResolveRouteContext + contextFn: TContextFn beforeLoadFn: TBeforeLoadFn allContext: ResolveAllContext< TParentRoute, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn > children: TChildren @@ -516,7 +617,7 @@ export type RouteAddChildrenFn< in out TSearchValidator, in out TParams, in out TRouterContext, - in out TRouteContextFn, + in out TContextFn, in out TBeforeLoadFn, in out TLoaderDeps extends Record, in out TLoaderFn, @@ -539,7 +640,7 @@ export type RouteAddChildrenFn< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -560,7 +661,7 @@ export type RouteAddFileChildrenFn< in out TSearchValidator, in out TParams, in out TRouterContext, - in out TRouteContextFn, + in out TContextFn, in out TBeforeLoadFn, in out TLoaderDeps extends Record, in out TLoaderFn, @@ -580,7 +681,7 @@ export type RouteAddFileChildrenFn< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -601,7 +702,7 @@ export type RouteAddFileTypesFn< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps extends Record, TLoaderFn, @@ -619,7 +720,7 @@ export type RouteAddFileTypesFn< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -640,7 +741,7 @@ export interface Route< in out TSearchValidator, in out TParams, in out TRouterContext, - in out TRouteContextFn, + in out TContextFn, in out TBeforeLoadFn, in out TLoaderDeps extends Record, in out TLoaderFn, @@ -663,7 +764,7 @@ export interface Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -685,7 +786,7 @@ export interface Route< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, TServerMiddlewares, @@ -708,7 +809,7 @@ export interface Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -737,7 +838,7 @@ export interface Route< TLoaderFn, TLoaderDeps, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn >, ) => this @@ -752,7 +853,7 @@ export interface Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -773,7 +874,7 @@ export interface Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -792,7 +893,7 @@ export interface Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -811,7 +912,7 @@ export interface Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -830,26 +931,47 @@ export interface Route< redirect: RedirectFnRoute } -export type AnyRoute = Route< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any -> +export type AnyRoute = Omit< + Route< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + >, + | 'options' + | 'types' + | 'lazyFn' + | 'addChildren' + | '_addFileChildren' + | '_addFileTypes' + | 'updateLoader' + | 'update' + | 'lazy' +> & { + options: any + types: any + lazyFn?: any + addChildren?: any + _addFileChildren?: any + _addFileTypes?: any + updateLoader?: any + update?: any + lazy?: any +} export type AnyRouteWithContext = AnyRoute & { types: { allContext: TContext } @@ -867,11 +989,14 @@ export type RouteOptions< TLoaderDeps extends Record = {}, TLoaderFn = undefined, TRouterContext = {}, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSSR = unknown, TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, > = BaseRouteOptions< TRegister, TParentRoute, @@ -883,11 +1008,14 @@ export type RouteOptions< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, TServerMiddlewares, - THandlers + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn > & UpdatableRouteOptions< NoInfer, @@ -898,26 +1026,440 @@ export type RouteOptions< NoInfer, NoInfer, NoInfer, - NoInfer, + NoInfer, NoInfer > -export type RouteContextFn< +export type ContextFn< in out TParentRoute extends AnyRoute, - in out TSearchValidator, in out TParams, in out TRouterContext, in out TRouteId, + in out TLoaderDeps, > = ( - ctx: RouteContextOptions< + ctx: ContextFnOptions< TParentRoute, - TSearchValidator, TParams, TRouterContext, - TRouteId + TRouteId, + TLoaderDeps >, ) => any +type ContextRevalidateFn< + TParentRoute extends AnyRoute, + TParams, + TRouterContext, + TId extends string, + TLoaderDeps extends Record, + TContextData, +> = ( + ctx: Omit< + ContextFnOptions, + 'matches' + > & { + matches: Array + prev: NoInfer | undefined + }, +) => Awaitable> + +type LifecycleReturn = + ResolveLifecycleHandler extends ( + ...args: Array + ) => infer TReturn + ? TReturn + : ResolveLifecycleHandler + +type LifecycleValue = IsAny< + TValue, + any, + [Awaited>] extends [never] + ? TFallback + : [Awaited>] extends [undefined] + ? TFallback + : Awaited> +> + +type LifecycleWire = TDehydrateFn extends ( + ...args: Array +) => infer TWire + ? TWire + : unknown + +type DehydrateLifecycleInput = [Awaited] extends [never] + ? unknown + : [Exclude, undefined>] extends [never] + ? NoInfer> + : NoInfer, undefined>> + +type LifecycleDehydrateFn = (ctx: { + data: DehydrateLifecycleInput +}) => unknown + +export type DefaultLifecycleDehydrateFn = unknown + +type SerializableDehydrateFn = (ctx: { + data: DehydrateLifecycleInput +}) => Constrain> + +type LifecycleRevalidateOption< + TParentRoute extends AnyRoute, + TParams, + TRouterContext, + TId extends string, + TLoaderDeps extends Record, + TContextData, +> = + | boolean + | ContextRevalidateFn< + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + LifecycleValue, AnyContext> + > + +type ContextHandler< + TRegister, + TParentRoute extends AnyRoute, + TParams, + TRouterContext, + TId extends string, + TLoaderDeps extends Record, + TContextData, + TSSR, + TDehydrate, +> = ( + ctx: ContextFnOptions< + TParentRoute, + TParams, + TRouterContext, + TId, + NoInfer + >, +) => TContextData & + ValidateIfSerializable< + TRegister, + TParentRoute, + TSSR, + TContextData, + TDehydrate + > + +type LifecycleHandler< + TRegister, + TParentRoute extends AnyRoute, + TContext, + TLifecycleData, + TSSR, + TDehydrate, +> = ( + ctx: TContext, +) => TLifecycleData & + ValidateIfSerializable< + TRegister, + TParentRoute, + TSSR, + TLifecycleData, + TDehydrate + > + +export type ContextLifecycleOption< + TRegister, + TParentRoute extends AnyRoute, + TParams, + TRouterContext, + TId extends string, + TLoaderDeps extends Record, + TContextFn, + TContextDehydrateFn, + TSSR, +> = + | ContextHandler< + TRegister, + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + TContextFn, + TSSR, + MethodDefaultDehydrate + > + | { + handler: ContextHandler< + TRegister, + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + TContextFn, + TSSR, + MethodDefaultDehydrate + > + revalidate?: LifecycleRevalidateOption< + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + NoInfer + > + dehydrate?: undefined + hydrate?: undefined + } + | { + handler: ContextHandler< + TRegister, + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + TContextFn, + TSSR, + true + > + revalidate?: LifecycleRevalidateOption< + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + NoInfer + > + dehydrate: true + hydrate?: undefined + } + | { + handler: ContextHandler< + TRegister, + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + TContextFn, + TSSR, + false + > + revalidate?: LifecycleRevalidateOption< + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + NoInfer + > + dehydrate: false + hydrate?: undefined + } + | { + handler: ( + ctx: ContextFnOptions< + TParentRoute, + TParams, + TRouterContext, + TId, + NoInfer + >, + ) => TContextFn + revalidate?: LifecycleRevalidateOption< + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + NoInfer + > + dehydrate: SerializableDehydrateFn< + TRegister, + TContextFn, + TContextDehydrateFn + > + hydrate: (ctx: { + data: NoInfer + }) => NoInfer> + } + +type LifecycleOption< + TRegister, + TParentRoute extends AnyRoute, + TContext, + TLifecycleFn, + TLifecycleDehydrateFn, + TSSR, + TMethod extends keyof DefaultDehydrateConfig, + TExtra extends object = {}, +> = + | LifecycleHandler< + TRegister, + TParentRoute, + TContext, + TLifecycleFn, + TSSR, + MethodDefaultDehydrate + > + | ({ + handler: LifecycleHandler< + TRegister, + TParentRoute, + TContext, + TLifecycleFn, + TSSR, + MethodDefaultDehydrate + > + dehydrate?: undefined + hydrate?: undefined + } & TExtra) + | ({ + handler: LifecycleHandler< + TRegister, + TParentRoute, + TContext, + TLifecycleFn, + TSSR, + true + > + dehydrate: true + hydrate?: undefined + } & TExtra) + | ({ + handler: LifecycleHandler< + TRegister, + TParentRoute, + TContext, + TLifecycleFn, + TSSR, + false + > + dehydrate: false + hydrate?: undefined + } & TExtra) + | ({ + handler: (ctx: TContext) => TLifecycleFn + dehydrate: SerializableDehydrateFn< + TRegister, + TLifecycleFn, + TLifecycleDehydrateFn + > + hydrate: (ctx: { + data: NoInfer + }) => NoInfer> + } & TExtra) + +export type BeforeLoadLifecycleOption< + TRegister, + TParentRoute extends AnyRoute, + TSearchValidator, + TParams, + TRouterContext, + TContextFn, + TId extends string, + TServerMiddlewares, + THandlers, + TBeforeLoadFn, + TBeforeLoadDehydrateFn, + TSSR, +> = LifecycleOption< + TRegister, + TParentRoute, + BeforeLoadContextOptions< + TRegister, + TParentRoute, + TSearchValidator, + TParams, + TRouterContext, + TContextFn, + TId, + TServerMiddlewares, + THandlers + >, + TBeforeLoadFn, + TBeforeLoadDehydrateFn, + TSSR, + 'beforeLoad' +> + +export type LoaderLifecycleOption< + TRegister, + TParentRoute extends AnyRoute, + TId extends string, + TParams, + TLoaderDeps extends Record, + TRouterContext, + TContextFn, + TBeforeLoadFn, + TServerMiddlewares, + THandlers, + TLoaderFn, + TLoaderDehydrateFn, + TSSR, +> = LifecycleOption< + TRegister, + TParentRoute, + LoaderFnContext< + TRegister, + TParentRoute, + TId, + TParams, + NoInfer, + TRouterContext, + TContextFn, + TBeforeLoadFn, + TServerMiddlewares, + THandlers + >, + TLoaderFn, + TLoaderDehydrateFn, + TSSR, + 'loader', + { staleReloadMode?: LoaderStaleReloadMode } +> + +export type ContextObjectWithDehydrateInput< + TParentRoute extends AnyRoute, + TParams, + TRouterContext, + TId extends string, + TLoaderDeps extends Record, + TContextFn, +> = { + handler: ( + ctx: ContextFnOptions< + TParentRoute, + TParams, + TRouterContext, + TId, + NoInfer + >, + ) => TContextFn + revalidate?: + | boolean + | ContextRevalidateFn< + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + NoInfer + > + dehydrate: LifecycleDehydrateFn + hydrate: (ctx: { + data: NoInfer>> + }) => NoInfer> +} + +export type LifecycleObjectWithDehydrateInput = { + handler: (ctx: TContext) => TData + dehydrate: LifecycleDehydrateFn + hydrate: (ctx: { + data: NoInfer>> + }) => NoInfer> +} + export type FileBaseRouteOptions< TRegister, TParentRoute extends AnyRoute = AnyRoute, @@ -928,12 +1470,15 @@ export type FileBaseRouteOptions< TLoaderDeps extends Record = {}, TLoaderFn = undefined, TRouterContext = {}, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TRemountDepsFn = AnyContext, TSSR = unknown, TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, > = ParamsOptions & FilebaseRouteOptionsInterface< TRegister, @@ -945,12 +1490,15 @@ export type FileBaseRouteOptions< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TRemountDepsFn, TSSR, TServerMiddlewares, - THandlers + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn > export interface FilebaseRouteOptionsInterface< @@ -963,12 +1511,15 @@ export interface FilebaseRouteOptionsInterface< TLoaderDeps extends Record = {}, TLoaderFn = undefined, TRouterContext = {}, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TRemountDepsFn = AnyContext, TSSR = unknown, TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, > { validateSearch?: Constrain @@ -980,26 +1531,25 @@ export interface FilebaseRouteOptionsInterface< TParentRoute, TId, TParams, - TLoaderDeps, + NoInfer, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TServerMiddlewares, THandlers >, ) => any) - context?: Constrain< - TRouteContextFn, - ( - ctx: RouteContextOptions< - TParentRoute, - TParams, - TRouterContext, - TLoaderDeps, - TId - >, - ) => any + context?: ContextLifecycleOption< + TRegister, + TParentRoute, + TParams, + TRouterContext, + TId, + TLoaderDeps, + TContextFn, + TContextDehydrateFn, + TSSR > ssr?: Constrain< @@ -1015,26 +1565,19 @@ export interface FilebaseRouteOptionsInterface< // If an error is thrown here, the route's loader will not be called. // If thrown during a navigation, the navigation will be cancelled and the error will be passed to the `onError` function. // If thrown during a preload event, the error will be logged to the console. - beforeLoad?: Constrain< + beforeLoad?: BeforeLoadLifecycleOption< + TRegister, + TParentRoute, + TSearchValidator, + TParams, + TRouterContext, + TContextFn, + TId, + TServerMiddlewares, + THandlers, TBeforeLoadFn, - ( - ctx: BeforeLoadContextOptions< - TRegister, - TParentRoute, - TSearchValidator, - TParams, - TRouterContext, - TRouteContextFn, - TId, - TServerMiddlewares, - THandlers - >, - ) => ValidateSerializableLifecycleResult< - TRegister, - TParentRoute, - TSSR, - TBeforeLoadFn - > + TBeforeLoadDehydrateFn, + TSSR > loaderDeps?: ( @@ -1048,37 +1591,25 @@ export interface FilebaseRouteOptionsInterface< TId, ResolveFullSearchSchema, Expand>, - TLoaderDeps + NoInfer >, ) => any > - loader?: Constrain< + loader?: LoaderLifecycleOption< + TRegister, + TParentRoute, + TId, + TParams, + TLoaderDeps, + TRouterContext, + TContextFn, + TBeforeLoadFn, + TServerMiddlewares, + THandlers, TLoaderFn, - | RouteLoaderFn< - TRegister, - TParentRoute, - TId, - TParams, - TLoaderDeps, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn, - TServerMiddlewares, - THandlers - > - | RouteLoaderObject< - TRegister, - TParentRoute, - TId, - TParams, - TLoaderDeps, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn, - TServerMiddlewares, - THandlers - > + TLoaderDehydrateFn, + TSSR > } @@ -1093,11 +1624,14 @@ export type BaseRouteOptions< TLoaderDeps extends Record = {}, TLoaderFn = undefined, TRouterContext = {}, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSSR = unknown, TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, > = RoutePathOptions & FileBaseRouteOptions< TRegister, @@ -1109,12 +1643,15 @@ export type BaseRouteOptions< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, AnyContext, TSSR, TServerMiddlewares, - THandlers + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn > & { getParentRoute: () => TParentRoute } @@ -1142,11 +1679,22 @@ export interface RouteContextOptions< in out TParentRoute extends AnyRoute, in out TParams, in out TRouterContext, - in out TLoaderDeps, in out TRouteId, + in out TLoaderDeps, > extends ContextOptions { + context: Expand> deps: TLoaderDeps +} + +export interface ContextFnOptions< + in out TParentRoute extends AnyRoute, + in out TParams, + in out TRouterContext, + in out TRouteId, + in out TLoaderDeps, +> extends ContextOptions { context: Expand> + deps: TLoaderDeps } export interface SsrContextOptions< @@ -1176,7 +1724,7 @@ export interface BeforeLoadContextOptions< in out TSearchValidator, in out TParams, in out TRouterContext, - in out TRouteContextFn, + in out TContextFn, in out TRouteId, in out TServerMiddlewares, in out THandlers, @@ -1185,7 +1733,7 @@ export interface BeforeLoadContextOptions< ContextOptions, FullSearchSchemaOption { context: Expand< - BeforeLoadContextParameter + BeforeLoadContextParameter > } @@ -1197,7 +1745,7 @@ type AssetFnContextOptions< in out TSearchValidator, in out TLoaderFn, in out TRouterContext, - in out TRouteContextFn, + in out TContextFn, in out TBeforeLoadFn, in out TLoaderDeps, > = { @@ -1214,7 +1762,7 @@ type AssetFnContextOptions< ResolveAllContext< TParentRoute, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn >, TLoaderDeps @@ -1226,12 +1774,7 @@ type AssetFnContextOptions< ResolveAllParamsFromParent, ResolveFullSearchSchema, ResolveLoaderData, - ResolveAllContext< - TParentRoute, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn - >, + ResolveAllContext, TLoaderDeps > params: ResolveAllParamsFromParent @@ -1256,7 +1799,7 @@ export interface UpdatableRouteOptions< in out TLoaderFn, in out TLoaderDeps, in out TRouterContext, - in out TRouteContextFn, + in out TContextFn, in out TBeforeLoadFn, > extends UpdatableStaticRouteOption, UpdatableRouteOptionsExtensions { @@ -1312,7 +1855,7 @@ export interface UpdatableRouteOptions< ResolveAllContext< TParentRoute, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn >, TLoaderDeps @@ -1328,7 +1871,7 @@ export interface UpdatableRouteOptions< ResolveAllContext< TParentRoute, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn >, TLoaderDeps @@ -1344,7 +1887,7 @@ export interface UpdatableRouteOptions< ResolveAllContext< TParentRoute, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn >, TLoaderDeps @@ -1359,7 +1902,7 @@ export interface UpdatableRouteOptions< TSearchValidator, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps >, @@ -1373,7 +1916,7 @@ export interface UpdatableRouteOptions< TSearchValidator, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps >, @@ -1392,7 +1935,7 @@ export interface UpdatableRouteOptions< TSearchValidator, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps >, @@ -1415,7 +1958,7 @@ export type RouteLoaderFn< in out TParams = {}, in out TLoaderDeps = {}, in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TServerMiddlewares = unknown, in out THandlers = undefined, @@ -1427,7 +1970,7 @@ export type RouteLoaderFn< TParams, TLoaderDeps, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TServerMiddlewares, THandlers @@ -1480,7 +2023,7 @@ export interface LoaderFnContext< in out TParams = {}, in out TLoaderDeps = {}, in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TServerMiddlewares = unknown, in out THandlers = undefined, @@ -1490,12 +2033,7 @@ export interface LoaderFnContext< params: Expand> deps: TLoaderDeps context: Expand< - ResolveAllContext< - TParentRoute, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn - > + ResolveAllContext > location: ParsedLocation // Do not supply search schema here so as to demotivate people from trying to shortcut loaderDeps /** @@ -1520,13 +2058,16 @@ export interface RootRouteOptions< TRegister = unknown, TSearchValidator = undefined, TRouterContext = {}, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, > extends Omit< @@ -1542,11 +2083,14 @@ export interface RootRouteOptions< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, TServerMiddlewares, - THandlers + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, | 'path' | 'id' @@ -1623,7 +2167,7 @@ export class BaseRoute< in out TSearchValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TLoaderDeps extends Record = {}, in out TLoaderFn = undefined, @@ -1646,7 +2190,7 @@ export class BaseRoute< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, TServerMiddlewares, @@ -1692,7 +2236,7 @@ export class BaseRoute< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -1722,7 +2266,7 @@ export class BaseRoute< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, TServerMiddlewares, @@ -1747,7 +2291,7 @@ export class BaseRoute< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -1761,30 +2305,30 @@ export class BaseRoute< init = (opts: { originalIndex: number }): void => { this.originalIndex = opts.originalIndex - const options = this.options as - | (RouteOptions< - TRegister, - TParentRoute, - TId, - TCustomId, - TFullPath, - TPath, - TSearchValidator, - TParams, - TLoaderDeps, - TLoaderFn, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn, - TSSR, - TServerMiddlewares - > & - RoutePathOptionsIntersection) - | undefined + const options = this.options as RouteOptions< + TRegister, + TParentRoute, + TId, + TCustomId, + TFullPath, + TPath, + TSearchValidator, + TParams, + TLoaderDeps, + TLoaderFn, + TRouterContext, + TContextFn, + TBeforeLoadFn, + TSSR, + TServerMiddlewares, + THandlers + > & + RoutePathOptionsIntersection - const isRoot = !options?.path && !options?.id + const isRoot = !options.path && !options.id - this.parentRoute = this.options.getParentRoute?.() + const parentRoute = (this.options as any).getParentRoute?.() + this.parentRoute = parentRoute if (isRoot) { this._path = rootRouteId as TPath @@ -1798,14 +2342,14 @@ export class BaseRoute< invariant() } - let path: undefined | string = isRoot ? rootRouteId : options?.path + let path: undefined | string = isRoot ? rootRouteId : options.path // If the path is anything other than an index path, trim it up if (path && path !== '/') { path = trimPathLeft(path) } - const customId = options?.id || path + const customId = options.id || path // Strip the parentId prefix from the first level of children let id = isRoot @@ -1842,7 +2386,7 @@ export class BaseRoute< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -1864,7 +2408,7 @@ export class BaseRoute< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -1894,7 +2438,7 @@ export class BaseRoute< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -1916,7 +2460,7 @@ export class BaseRoute< TParams, TLoaderDeps, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn > > @@ -1932,7 +2476,7 @@ export class BaseRoute< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TNewLoaderFn, @@ -1954,7 +2498,7 @@ export class BaseRoute< TLoaderFn, TLoaderDeps, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn >, ): this => { @@ -1973,7 +2517,7 @@ export class BaseRoute< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -2026,7 +2570,7 @@ export interface RootRoute< in out TRegister, in out TSearchValidator = undefined, in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TLoaderDeps extends Record = {}, in out TLoaderFn = undefined, @@ -2045,7 +2589,7 @@ export interface RootRoute< TSearchValidator, // TSearchValidator {}, // TParams TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -2060,7 +2604,7 @@ export class BaseRootRoute< in out TRegister = Register, in out TSearchValidator = undefined, in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TLoaderDeps extends Record = {}, in out TLoaderFn = undefined, @@ -2079,7 +2623,7 @@ export class BaseRootRoute< TSearchValidator, // TSearchValidator {}, // TParams TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -2094,7 +2638,7 @@ export class BaseRootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, diff --git a/packages/router-core/src/routeInfo.ts b/packages/router-core/src/routeInfo.ts index 6c9249e091a..362f4c1e769 100644 --- a/packages/router-core/src/routeInfo.ts +++ b/packages/router-core/src/routeInfo.ts @@ -44,9 +44,15 @@ export type RoutesById = ? CodeRoutesById : InferFileRouteTypes['fileRoutesById'] -export type RouteById = Extract< - RoutesById[TId & keyof RoutesById], - AnyRoute +type RouteFromMap = TMap[TKey & keyof TMap] extends infer TRoute + ? TRoute extends { types: any } + ? TRoute + : never + : never + +export type RouteById = RouteFromMap< + RoutesById, + TId > export type CodeRouteIds = @@ -96,9 +102,9 @@ export type RoutesByPath = ? CodeRoutesByPath : InferFileRouteTypes['fileRoutesByFullPath'] -export type RouteByPath = Extract< - RoutesByPath[TPath & keyof RoutesByPath], - AnyRoute +export type RouteByPath = RouteFromMap< + RoutesByPath, + TPath > export type CodeRoutePaths = @@ -185,9 +191,9 @@ export type RoutesByToPath = ? CodeRoutesByToPath : InferFileRouteTypes['fileRoutesByTo'] -export type CodeRouteByToPath = Extract< - RoutesByToPath[TTo & keyof RoutesByToPath], - AnyRoute +export type CodeRouteByToPath = RouteFromMap< + RoutesByToPath, + TTo > export type FileRouteByToPath = diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2efb43cd42a..6db85773cee 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -43,6 +43,7 @@ import { executeRewriteOutput, rewriteBasepath, } from './rewrite' +import type { DefaultDehydrateConfig } from './lifecycle' import { createRouterStores } from './stores' import type { LRUCache } from './lru-cache' import type { @@ -75,9 +76,9 @@ import type { AnyRouteWithContext, LoaderStaleReloadMode, MakeRemountDepsOptionsUnion, - RouteContextOptions, RouteLike, RouteMask, + SearchFilter, SearchMiddleware, } from './route' import type { @@ -419,6 +420,15 @@ export interface RouterOptions< */ defaultSsr?: SSROption + /** + * Default dehydrate configuration for lifecycle methods. + * Controls whether each method's return value is included in the + * dehydrated SSR payload. + * + * Built-in defaults: `{ beforeLoad: true, loader: true, context: false }` + */ + defaultDehydrate?: DefaultDehydrateConfig + search?: { /** * Configures how unknown search params (= not returned by any `validateSearch`) are treated. @@ -1588,6 +1598,7 @@ export class RouterCore< const status = route.options.loader || route.options.beforeLoad || + route.options.context || route.lazyFn || routeNeedsPreload(route) ? 'pending' @@ -1614,6 +1625,7 @@ export class RouterCore< __routeContext: undefined, _nonReactive: { loadPromise: createControlledPromise(), + needsContext: true, }, __beforeLoadContext: undefined, context: {}, @@ -1655,7 +1667,6 @@ export class RouterCore< 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 @@ -1668,29 +1679,6 @@ export class RouterCore< 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, @@ -2839,7 +2827,7 @@ export class RouterCore< const filter = (d: MakeRouteMatch) => { const route = this.looseRoutesById[d.routeId]! - if (!route.options.loader) { + if (!route.options.loader && !route.options.context) { return true } @@ -2978,11 +2966,11 @@ export class RouterCore< if (opts?.includeSearch ?? true) { return deepEqual(baseLocation.search, next.search, { partial: true }) - ? match.rawParams + ? (match.rawParams as any) : false } - return match.rawParams + return match.rawParams as any } ssr?: { @@ -3145,7 +3133,7 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { route.options.preSearchFilters ) { nextSearch = route.options.preSearchFilters.reduce( - (prev, next) => next(prev), + (prev: any, next: SearchFilter) => next(prev), search, ) } @@ -3157,7 +3145,7 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { route.options.postSearchFilters ) { return route.options.postSearchFilters.reduce( - (prev, next) => next(prev), + (prev: any, next: SearchFilter) => next(prev), result, ) } diff --git a/packages/router-core/src/ssr/serializer/transformer.ts b/packages/router-core/src/ssr/serializer/transformer.ts index 6eeed9b6e66..e775a74a3b6 100644 --- a/packages/router-core/src/ssr/serializer/transformer.ts +++ b/packages/router-core/src/ssr/serializer/transformer.ts @@ -6,7 +6,6 @@ import type { RegisteredSsr, SSROption, } from '../../router' -import type { LooseReturnType } from '../../utils' import type { AnyRoute, ResolveAllSSR } from '../../route' import type { RawStream } from './RawStream' @@ -273,7 +272,7 @@ export type ValidateSerializableLifecycleResult< TFn, > = false extends RegisteredSsr - ? any + ? unknown : ValidateSerializableLifecycleResultSSR< TRegister, TParentRoute, @@ -290,10 +289,10 @@ export type ValidateSerializableLifecycleResultSSR< TFn, > = ResolveAllSSR extends false - ? any + ? unknown : RegisteredSSROption extends false - ? any - : ValidateSerializableInput> + ? unknown + : ValidateSerializableInput export type RegisteredReadableStream = unknown extends SerializerExtensions['ReadableStream'] diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 2aa5358ac09..1e57c35f78c 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -1,12 +1,18 @@ import { invariant } from '../invariant' import { isNotFound } from '../not-found' import { createControlledPromise } from '../utils' +import { + builtinDefaultDehydrate, + getHydrateFn, + resolveHandler, + shouldDehydrate, +} from '../lifecycle' 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 { BeforeLoadContextOptions, ContextFnOptions } from '../route' import type { AnySerializationAdapter } from './serializer/transformer' declare global { @@ -21,8 +27,6 @@ function hydrateMatch( 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 @@ -156,6 +160,30 @@ export async function hydrate(router: AnyRouter): Promise { hydrateMatch(match, dehydratedMatch) setRouteSsr(match) + const route = router.looseRoutesById[match.routeId]! + if (dehydratedMatch.m !== undefined) { + const hydrateFn = getHydrateFn(route.options.context) + match.__routeContext = hydrateFn + ? (hydrateFn({ + data: dehydratedMatch.m, + }) as typeof match.__routeContext) + : dehydratedMatch.m + } + if (dehydratedMatch.b !== undefined) { + const hydrateFn = getHydrateFn(route.options.beforeLoad) + match.__beforeLoadContext = hydrateFn + ? (hydrateFn({ + data: dehydratedMatch.b, + }) as typeof match.__beforeLoadContext) + : dehydratedMatch.b + } + if (dehydratedMatch.l !== undefined) { + const hydrateFn = getHydrateFn(route.options.loader) + match.loaderData = hydrateFn + ? hydrateFn({ data: dehydratedMatch.l }) + : dehydratedMatch.l + } + match._nonReactive.dehydrated = match.ssr !== false if (match.ssr === 'data-only' || match.ssr === false) { @@ -170,48 +198,165 @@ export async function hydrate(router: AnyRouter): Promise { // now that all necessary data is hydrated: // 1) fully reconstruct the route context - // 2) execute `head()` and `scripts()` for each match + // 2) re-run non-dehydrated lifecycle methods + // 3) execute `head()` and `scripts()` for each match + const defaults = router.options.defaultDehydrate + const additionalContext = router.options.additionalContext 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 navigate = (opts: any) => + router.navigate({ ...opts, _fromLocation: location }) + const loaderTasks: Array<() => Promise> = [] + + for (const match of activeMatches) { + try { + const route = router.looseRoutesById[match.routeId]! + + const parentMatch = activeMatches[match.index - 1] + const parentContext = parentMatch?.context ?? router.options.context + + if ( + route.options.context && + !shouldDehydrate( + route.options.context, + defaults?.context, + builtinDefaultDehydrate.context, + ) + ) { + const contextFnContext: ContextFnOptions = { + deps: match.loaderDeps, + params: match.params, + context: parentContext ?? {}, + location, + navigate, + buildLocation: router.buildLocation, + cause: match.cause, + abortController: match.abortController, + preload: false, + matches, + routeId: route.id, + ...additionalContext, + } + match.__routeContext = ((await resolveHandler(route.options.context)!( + contextFnContext, + )) ?? undefined) as typeof match.__routeContext + } + match._nonReactive.needsContext = false + + const contextForBeforeLoad = { + ...parentContext, + ...match.__routeContext, + } + + if ( + route.options.beforeLoad && + !shouldDehydrate( + route.options.beforeLoad, + defaults?.beforeLoad, + builtinDefaultDehydrate.beforeLoad, + ) + ) { + const beforeLoadFnContext: BeforeLoadContextOptions< + any, + any, + any, + any, + any, + any, + any, + any, + any + > = { + search: match.search, + params: match.params, + context: contextForBeforeLoad, + location, + navigate, + buildLocation: router.buildLocation, + cause: match.cause, + abortController: match.abortController, + preload: false, + matches, + routeId: route.id, + ...additionalContext, + } + match.__beforeLoadContext = ((await resolveHandler( + route.options.beforeLoad, + )!(beforeLoadFnContext)) ?? + undefined) as typeof match.__beforeLoadContext + } - 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 ?? {}, + match.context = { + ...parentContext, + ...match.__routeContext, + ...match.__beforeLoadContext, + } + + if ( + route.options.loader && + !shouldDehydrate( + route.options.loader, + defaults?.loader, + builtinDefaultDehydrate.loader, + ) + ) { + const capturedMatch = match + const capturedRoute = route + const contextForLoader = capturedMatch.context + loaderTasks.push(async () => { + try { + const loaderFnContext = { + params: capturedMatch.params, + deps: capturedMatch.loaderDeps, + context: contextForLoader, location, - navigate: (opts: any) => - router.navigate({ - ...opts, - _fromLocation: location, - }), + navigate, buildLocation: router.buildLocation, - cause: match.cause, - abortController: match.abortController, + cause: capturedMatch.cause, + abortController: capturedMatch.abortController, preload: false, - matches, - routeId: route.id, + parentMatchPromise: Promise.resolve() as any, + route: capturedRoute, + ...additionalContext, } - match.__routeContext = - route.options.context(contextFnContext) ?? undefined - } + const loaderData = await resolveHandler( + capturedRoute.options.loader, + )!(loaderFnContext) + if (loaderData !== undefined) { + capturedMatch.loaderData = loaderData + } + } catch (err) { + capturedMatch.error = err as any + console.error( + `Error during hydration loader re-execution for route ${capturedMatch.routeId}:`, + err, + ) + } + }) + } + } 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 + } + } + } - match.context = { - ...parentContext, - ...match.__routeContext, - ...match.__beforeLoadContext, - } + if (loaderTasks.length > 0) { + await Promise.all(loaderTasks.map((task) => task())) + } + await Promise.all( + activeMatches.map(async (match) => { + try { + const route = router.looseRoutesById[match.routeId]! const assetContext = { ssr: router.options.ssr, matches: activeMatches, @@ -220,7 +365,6 @@ export async function hydrate(router: AnyRouter): Promise { loaderData: match.loaderData, } const headFnContent = await route.options.head?.(assetContext) - const scripts = await route.options.scripts?.(assetContext) match.meta = headFnContent?.meta diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 31db2f5e410..4ae71beeb99 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -7,6 +7,11 @@ import { } from '../manifest' import { decodePath } from '../utils' import { createLRUCache } from '../lru-cache' +import { + builtinDefaultDehydrate, + getDehydrateFn, + shouldDehydrate, +} from '../lifecycle' import { rootRouteId } from '../root' import minifiedTsrBootStrapScript from './tsrScript?script-string' import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from './constants' @@ -32,25 +37,79 @@ const TSR_PREFIX = GLOBAL_TSR + '.router=' const P_PREFIX = GLOBAL_TSR + '.p(()=>' const P_SUFFIX = ')' -export function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch { +export function dehydrateMatch( + match: AnyRouteMatch, + router: AnyRouter, +): DehydratedMatch { + const route = router.looseRoutesById[match.routeId]! + const defaults = router.options.defaultDehydrate + const dehydratedMatch: DehydratedMatch = { i: dehydrateSsrMatchId(match.id), u: match.updatedAt, s: match.status, } - const properties = [ - ['__beforeLoadContext', 'b'], - ['loaderData', 'l'], - ['error', 'e'], - ['ssr', 'ssr'], - ] as const + // Conditionally include beforeLoad context + if ( + match.__beforeLoadContext !== undefined && + route.options.beforeLoad && + shouldDehydrate( + route.options.beforeLoad, + defaults?.beforeLoad, + builtinDefaultDehydrate.beforeLoad, + ) + ) { + const dehydrateFn = getDehydrateFn(route.options.beforeLoad) + dehydratedMatch.b = dehydrateFn + ? (dehydrateFn({ + data: match.__beforeLoadContext, + }) as typeof match.__beforeLoadContext) + : match.__beforeLoadContext + } + + // Conditionally include loader data + if ( + match.loaderData !== undefined && + route.options.loader && + shouldDehydrate( + route.options.loader, + defaults?.loader, + builtinDefaultDehydrate.loader, + ) + ) { + const dehydrateFn = getDehydrateFn(route.options.loader) + dehydratedMatch.l = dehydrateFn + ? (dehydrateFn({ data: match.loaderData }) as typeof match.loaderData) + : match.loaderData + } + + // Conditionally include route context + if ( + match.__routeContext !== undefined && + route.options.context && + shouldDehydrate( + route.options.context, + defaults?.context, + builtinDefaultDehydrate.context, + ) + ) { + const dehydrateFn = getDehydrateFn(route.options.context) + dehydratedMatch.m = dehydrateFn + ? (dehydrateFn({ + data: match.__routeContext, + }) as typeof match.__routeContext) + : match.__routeContext + } - for (const [key, shorthand] of properties) { - if (match[key] !== undefined) { - dehydratedMatch[shorthand] = match[key] - } + // Always include error and ssr if present + if (match.error !== undefined) { + dehydratedMatch.e = match.error } + if (match.ssr !== undefined) { + dehydratedMatch.ssr = match.ssr + } + if (match.globalNotFound) { dehydratedMatch.g = true } @@ -500,7 +559,7 @@ export function attachRouterServerSsrUtils({ // In SPA mode we only want to dehydrate the root match matchesToDehydrate = matchesToDehydrate.slice(0, 1) } - const matches = matchesToDehydrate.map(dehydrateMatch) + const matches = matchesToDehydrate.map((m) => dehydrateMatch(m, router)) let manifestToDehydrate: Manifest | undefined = undefined // Only currently matched routes are dehydrated. Other route assets are diff --git a/packages/router-core/src/ssr/types.ts b/packages/router-core/src/ssr/types.ts index 70395cc9a05..dc65333df9b 100644 --- a/packages/router-core/src/ssr/types.ts +++ b/packages/router-core/src/ssr/types.ts @@ -5,6 +5,8 @@ export interface DehydratedMatch { i: MakeRouteMatch['id'] b?: MakeRouteMatch['__beforeLoadContext'] l?: MakeRouteMatch['loaderData'] + /** route context — only present when dehydrated for context */ + m?: MakeRouteMatch['__routeContext'] e?: MakeRouteMatch['error'] u: MakeRouteMatch['updatedAt'] s: MakeRouteMatch['status'] diff --git a/packages/router-core/tests/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts index efac230e494..8b72c1c8fe6 100644 --- a/packages/router-core/tests/hydrate.test.ts +++ b/packages/router-core/tests/hydrate.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute, notFound } from '../src' +import { BaseRootRoute, BaseRoute, notFound, rootRouteId } from '../src' import { hydrate } from '../src/ssr/client' import { createTestRouter } from './routerTestUtils' import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' @@ -517,4 +517,1213 @@ describe('hydrate', () => { consoleSpy.mockRestore() }) + + it('re-executes context during hydration and preserves parent->child context', async () => { + const contextOrder: Array = [] + + const rootRoute = new BaseRootRoute({ + context: vi.fn(() => { + contextOrder.push('root') + return { fromRootContext: 1 } + }), + }) + + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => 'Index', + context: vi.fn(({ context }) => { + contextOrder.push('index') + expect(context).toEqual({ fromRootContext: 1 }) + return { fromIndexContext: 3 } + }), + head: mockHead, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const history = createMemoryHistory({ initialEntries: ['/'] }) + mockRouter = createTestRouter({ routeTree, history, isServer: true }) + + const initialMatches = mockRouter.matchRoutes(mockRouter.state.location) + const rootMatch = initialMatches.find( + (m: AnyRouteMatch) => m.routeId === rootRouteId, + )! + const indexMatch = initialMatches.find( + (m: AnyRouteMatch) => m.routeId === indexRoute.id, + )! + + mockWindow.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + lastMatchId: indexMatch.id, + matches: [ + { + i: rootMatch.id, + b: undefined, + l: {}, + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: indexMatch.id, + b: { fromServerBeforeLoad: 99 }, + l: {}, + s: 'success', + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } as any + + await hydrate(mockRouter) + + // parent->child order should be preserved + expect(contextOrder).toEqual(['root', 'index']) + + const hydratedIndexMatch = mockRouter.state.matches[1] as AnyRouteMatch + expect(hydratedIndexMatch.context).toEqual({ + fromRootContext: 1, + fromServerBeforeLoad: 99, + fromIndexContext: 3, + }) + }) + + describe('needsContext flag after hydration', () => { + function setupHydration({ + rootContext, + indexContext, + aboutContext, + indexBeforeLoadContext, + }: { + rootContext?: any + indexContext?: any + aboutContext?: any + indexBeforeLoadContext?: Record + }) { + const rootRoute = new BaseRootRoute({ + context: rootContext, + }) + + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => 'Index', + context: indexContext, + head: mockHead, + }) + + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () => 'About', + context: aboutContext, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ routeTree, history, isServer: true }) + + const initialMatches = router.matchRoutes(router.state.location) + const rootMatch = initialMatches.find( + (m: AnyRouteMatch) => m.routeId === rootRouteId, + )! + const indexMatch = initialMatches.find( + (m: AnyRouteMatch) => m.routeId === indexRoute.id, + )! + + mockWindow.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + lastMatchId: indexMatch.id, + matches: [ + { + i: rootMatch.id, + b: undefined, + l: {}, + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: indexMatch.id, + b: indexBeforeLoadContext ?? {}, + l: {}, + s: 'success', + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } as any + + return { router, rootRoute, indexRoute, aboutRoute } + } + + it('clears needsContext on all matches after hydration (with context handler)', async () => { + const rootContextFn = vi.fn(() => ({ rootCtx: 1 })) + const indexContextFn = vi.fn(() => ({ indexCtx: 2 })) + + const { router } = setupHydration({ + rootContext: rootContextFn, + indexContext: indexContextFn, + }) + + await hydrate(router) + + for (const match of router.state.matches) { + expect(match._nonReactive.needsContext).toBe(false) + } + }) + + it('clears needsContext even when routes have no context handlers', async () => { + const { router } = setupHydration({}) + + await hydrate(router) + + for (const match of router.state.matches) { + expect(match._nonReactive.needsContext).toBe(false) + } + }) + + it('clears needsContext for parent and child matches independently', async () => { + const rootContextFn = vi.fn(() => ({ rootM: 1 })) + + const { router } = setupHydration({ + rootContext: rootContextFn, + // root has context, index does not + }) + + await hydrate(router) + + const rootMatch = router.state.matches[0]! + const indexMatch = router.state.matches[1]! + + expect(rootMatch._nonReactive.needsContext).toBe(false) + expect(indexMatch._nonReactive.needsContext).toBe(false) + }) + + it('clears needsContext after async context during hydration', async () => { + const rootContextFn = vi.fn(async () => { + await new Promise((r) => setTimeout(r, 10)) + return { asyncRootM: 1 } + }) + const indexContextFn = vi.fn(async () => { + await new Promise((r) => setTimeout(r, 10)) + return { asyncIndexM: 2 } + }) + + const { router } = setupHydration({ + rootContext: rootContextFn, + indexContext: indexContextFn, + }) + + await hydrate(router) + + for (const match of router.state.matches) { + expect(match._nonReactive.needsContext).toBe(false) + } + + // Verify the handlers were actually called + expect(rootContextFn).toHaveBeenCalledTimes(1) + expect(indexContextFn).toHaveBeenCalledTimes(1) + }) + + it('context is called exactly once during hydration and not re-executed on same-match navigation', async () => { + const rootContextFn = vi.fn(() => ({ rootM: 1 })) + const indexContextFn = vi.fn(() => ({ indexM: 2 })) + + const { router } = setupHydration({ + rootContext: rootContextFn, + indexContext: indexContextFn, + }) + + await hydrate(router) + + expect(rootContextFn).toHaveBeenCalledTimes(1) + expect(indexContextFn).toHaveBeenCalledTimes(1) + + // Simulate a client-side navigation to the same route (e.g. search param change) + // by calling router.load() — this is what happens when navigating to the same match + await router.load() + + // context should NOT have been called again + expect(rootContextFn).toHaveBeenCalledTimes(1) + expect(indexContextFn).toHaveBeenCalledTimes(1) + }) + + it('context with revalidate:true is re-executed after router.invalidate() post-hydration', async () => { + const indexContextFn = vi.fn(() => ({ indexCtx: 1 })) + + const { router } = setupHydration({ + indexContext: { handler: indexContextFn, revalidate: true }, + }) + + await hydrate(router) + + expect(indexContextFn).toHaveBeenCalledTimes(1) + + // Invalidate — router.invalidate() internally calls router.load() + await router.invalidate() + + // context with revalidate:true should be called again because invalidation sets invalid=true + expect(indexContextFn).toHaveBeenCalledTimes(2) + }) + + it('context (without revalidate) is NOT re-executed after router.invalidate() post-hydration', async () => { + const indexContextFn = vi.fn(() => ({ indexM: 1 })) + + const { router } = setupHydration({ + indexContext: indexContextFn, + }) + + await hydrate(router) + + expect(indexContextFn).toHaveBeenCalledTimes(1) + + // Invalidate — router.invalidate() internally calls router.load() + await router.invalidate() + + // context (without revalidate) should NOT be called again + expect(indexContextFn).toHaveBeenCalledTimes(1) + }) + + it('context is executed for a NEW match after hydration', async () => { + const aboutContextFn = vi.fn(() => ({ aboutM: 1 })) + + const { router } = setupHydration({ + aboutContext: aboutContextFn, + }) + + await hydrate(router) + + // about route was not matched during hydration + expect(aboutContextFn).toHaveBeenCalledTimes(0) + + // Navigate to /about (a new match) + await router.navigate({ to: '/about' }) + await router.load() + + expect(aboutContextFn).toHaveBeenCalledTimes(1) + }) + + it('context from hydration is preserved across same-match reload', async () => { + const rootContextFn = vi.fn(() => ({ rootM: 10 })) + const indexContextFn = vi.fn(() => ({ indexM: 30 })) + + const { router } = setupHydration({ + rootContext: rootContextFn, + indexContext: indexContextFn, + indexBeforeLoadContext: { fromServer: 99 }, + }) + + await hydrate(router) + + const indexMatch = router.state.matches[1]! + expect(indexMatch.context).toEqual({ + rootM: 10, + fromServer: 99, + indexM: 30, + }) + + // After a same-match reload, context should be preserved + await router.load() + + const reloadedIndexMatch = router.state.matches[1]! + expect(reloadedIndexMatch.context).toEqual({ + rootM: 10, + fromServer: 99, + indexM: 30, + }) + }) + + it('context handler: flags cleared, no double execution', async () => { + const rootContextFn = vi.fn(() => ({ rM: 1 })) + const indexContextFn = vi.fn(() => ({ iM: 3 })) + + const { router } = setupHydration({ + rootContext: rootContextFn, + indexContext: indexContextFn, + }) + + await hydrate(router) + + // All called once during hydration + expect(rootContextFn).toHaveBeenCalledTimes(1) + expect(indexContextFn).toHaveBeenCalledTimes(1) + + // All flags cleared + for (const match of router.state.matches) { + expect(match._nonReactive.needsContext).toBe(false) + } + + // Reload — nothing should be re-executed + await router.load() + + expect(rootContextFn).toHaveBeenCalledTimes(1) + expect(indexContextFn).toHaveBeenCalledTimes(1) + }) + }) + + describe('dehydrate flag combinations during hydration', () => { + /** + * Setup helper for dehydrate-aware hydration tests. + * Allows context, beforeLoad, and loader lifecycle methods with object form + * (dehydrate flag), and configurable dehydrated match payloads including m? field. + */ + function setupDehydrateHydration({ + rootOptions, + indexOptions, + dehydratedRoot, + dehydratedIndex, + routerDefaultDehydrate, + }: { + rootOptions?: { + context?: any + beforeLoad?: any + loader?: any + } + indexOptions?: { + context?: any + beforeLoad?: any + loader?: any + } + dehydratedRoot: Partial<{ + b: any + l: any + m: any + e: any + ssr: any + }> + dehydratedIndex: Partial<{ + b: any + l: any + m: any + e: any + ssr: any + }> + routerDefaultDehydrate?: { + beforeLoad?: boolean + loader?: boolean + context?: boolean + } + }) { + const rootRoute = new BaseRootRoute({ + context: rootOptions?.context, + beforeLoad: rootOptions?.beforeLoad, + loader: rootOptions?.loader, + }) + + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => 'Index', + context: indexOptions?.context, + beforeLoad: indexOptions?.beforeLoad, + loader: indexOptions?.loader, + head: mockHead, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const routerOptions: any = { + routeTree, + history, + isServer: true, + } + if (routerDefaultDehydrate) { + routerOptions.defaultDehydrate = routerDefaultDehydrate + } + const router = createTestRouter(routerOptions) + + const initialMatches = router.matchRoutes(router.state.location) + const rootMatch = initialMatches.find( + (m: AnyRouteMatch) => m.routeId === rootRouteId, + )! + const indexMatch = initialMatches.find( + (m: AnyRouteMatch) => m.routeId === indexRoute.id, + )! + + mockWindow.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + lastMatchId: indexMatch.id, + matches: [ + { + i: rootMatch.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + ...dehydratedRoot, + }, + { + i: indexMatch.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + ...dehydratedIndex, + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } as any + + return { router, rootRoute, indexRoute } + } + + // --- beforeLoad with dehydrate: false --- + + it('beforeLoad with dehydrate: false — NOT in dehydrated data, re-executed on client', async () => { + const indexBeforeLoad = vi.fn(() => ({ fromBL: 'client-reexec' })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + beforeLoad: { handler: indexBeforeLoad, dehydrate: false }, + }, + dehydratedRoot: {}, + // No `b` field in dehydrated data (dehydrate:false means server didn't include it) + dehydratedIndex: { l: {} }, + }) + + await hydrate(router) + + // Handler re-executed on client + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__beforeLoadContext).toEqual({ + fromBL: 'client-reexec', + }) + expect(indexMatch.context).toEqual( + expect.objectContaining({ fromBL: 'client-reexec' }), + ) + }) + + it('beforeLoad with dehydrate: true (default) — in dehydrated data, NOT re-executed on client', async () => { + const indexBeforeLoad = vi.fn(() => ({ + fromBL: 'should-not-run', + })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + // function form = default dehydrate: true for beforeLoad + beforeLoad: indexBeforeLoad, + }, + dehydratedRoot: {}, + // Server provided `b` field (dehydrate:true means it's dehydrated) + dehydratedIndex: { + b: { fromBL: 'from-server' }, + l: {}, + }, + }) + + await hydrate(router) + + // Handler NOT re-executed (data came from wire) + expect(indexBeforeLoad).not.toHaveBeenCalled() + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__beforeLoadContext).toEqual({ fromBL: 'from-server' }) + expect(indexMatch.context).toEqual( + expect.objectContaining({ fromBL: 'from-server' }), + ) + }) + + // --- loader with dehydrate: false --- + + it('loader with dehydrate: false — NOT in dehydrated data, re-executed on client', async () => { + const indexLoader = vi.fn(() => ({ loaderVal: 'client-reexec' })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + loader: { handler: indexLoader, dehydrate: false }, + }, + dehydratedRoot: {}, + // No `l` field with data (dehydrate:false means server didn't include it) + dehydratedIndex: { b: {} }, + }) + + await hydrate(router) + + // Handler re-executed on client (in parallel phase) + expect(indexLoader).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.loaderData).toEqual({ loaderVal: 'client-reexec' }) + }) + + it('loader with dehydrate: true (default) — in dehydrated data, NOT re-executed on client', async () => { + const indexLoader = vi.fn(() => ({ + loaderVal: 'should-not-run', + })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + // function form = default dehydrate: true for loader + loader: indexLoader, + }, + dehydratedRoot: {}, + dehydratedIndex: { + b: {}, + l: { loaderVal: 'from-server' }, + }, + }) + + await hydrate(router) + + // Handler NOT re-executed (data came from wire) + expect(indexLoader).not.toHaveBeenCalled() + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.loaderData).toEqual({ loaderVal: 'from-server' }) + }) + + // --- context with dehydrate: true --- + + it('context with dehydrate: true — IS in dehydrated data, NOT re-executed on client', async () => { + const indexContextFn = vi.fn(() => ({ + fromCtx: 'should-not-run', + })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { handler: indexContextFn, dehydrate: true }, + }, + dehydratedRoot: {}, + // Server provided `m` field (dehydrate:true means it's dehydrated) + dehydratedIndex: { + m: { fromCtx: 'from-server' }, + l: {}, + }, + }) + + await hydrate(router) + + // Handler NOT re-executed (data came from wire) + expect(indexContextFn).not.toHaveBeenCalled() + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__routeContext).toEqual({ fromCtx: 'from-server' }) + expect(indexMatch.context).toEqual( + expect.objectContaining({ fromCtx: 'from-server' }), + ) + }) + + it('context with dehydrate: false (default) — NOT in dehydrated data, re-executed on client', async () => { + const indexContextFn = vi.fn(() => ({ + fromCtx: 'client-reexec', + })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + // function form = default dehydrate: false for context + context: indexContextFn, + }, + dehydratedRoot: {}, + // No `m` field (dehydrate:false means server didn't include it) + dehydratedIndex: { l: {} }, + }) + + await hydrate(router) + + // Handler re-executed on client + expect(indexContextFn).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__routeContext).toEqual({ fromCtx: 'client-reexec' }) + expect(indexMatch.context).toEqual( + expect.objectContaining({ fromCtx: 'client-reexec' }), + ) + }) + + // --- needsContext flags with dehydrate combinations --- + + it('needsContext cleared regardless of dehydrate flag (dehydrate: true)', async () => { + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { handler: () => ({ v: 1 }), dehydrate: true }, + }, + dehydratedRoot: {}, + dehydratedIndex: { m: { v: 1 }, l: {} }, + }) + + await hydrate(router) + + for (const match of router.state.matches) { + expect(match._nonReactive.needsContext).toBe(false) + } + }) + + it('needsContext cleared regardless of dehydrate flag (dehydrate: false)', async () => { + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { handler: () => ({ v: 1 }), dehydrate: false }, + }, + dehydratedRoot: {}, + dehydratedIndex: { l: {} }, + }) + + await hydrate(router) + + for (const match of router.state.matches) { + expect(match._nonReactive.needsContext).toBe(false) + } + }) + + // --- Mixed dehydrate: inverted from defaults --- + + it('mixed dehydrate: beforeLoad=false, loader=true, context=true', async () => { + const indexBeforeLoad = vi.fn(() => ({ bl: 'reexec' })) + const indexLoader = vi.fn(() => ({ ld: 'should-not-run' })) + const indexContextFn = vi.fn(() => ({ ctx: 'should-not-run' })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + beforeLoad: { handler: indexBeforeLoad, dehydrate: false }, + loader: { handler: indexLoader, dehydrate: true }, + context: { handler: indexContextFn, dehydrate: true }, + }, + dehydratedRoot: {}, + dehydratedIndex: { + // beforeLoad NOT included (dehydrate:false) + // loader IS included (dehydrate:true) + l: { ld: 'from-server' }, + // context IS included (dehydrate:true) + m: { ctx: 'from-server' }, + }, + }) + + await hydrate(router) + + // beforeLoad was re-executed (dehydrate:false) + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + // loader was NOT re-executed (dehydrate:true, data from wire) + expect(indexLoader).not.toHaveBeenCalled() + // context was NOT re-executed (dehydrate:true, data from wire) + expect(indexContextFn).not.toHaveBeenCalled() + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__routeContext).toEqual({ ctx: 'from-server' }) + expect(indexMatch.__beforeLoadContext).toEqual({ bl: 'reexec' }) + expect(indexMatch.loaderData).toEqual({ ld: 'from-server' }) + expect(indexMatch.context).toEqual( + expect.objectContaining({ + ctx: 'from-server', + bl: 'reexec', + }), + ) + }) + + // --- All dehydrate: true (everything from wire) --- + + it('all dehydrate: true — no handlers re-executed, all data from wire', async () => { + const indexContextFn = vi.fn(() => ({ ctx: 'nope' })) + const indexBeforeLoad = vi.fn(() => ({ bl: 'nope' })) + const indexLoader = vi.fn(() => ({ ld: 'nope' })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { handler: indexContextFn, dehydrate: true }, + beforeLoad: { handler: indexBeforeLoad, dehydrate: true }, + loader: { handler: indexLoader, dehydrate: true }, + }, + dehydratedRoot: {}, + dehydratedIndex: { + b: { bl: 'server' }, + l: { ld: 'server' }, + m: { ctx: 'server' }, + }, + }) + + await hydrate(router) + + expect(indexContextFn).not.toHaveBeenCalled() + expect(indexBeforeLoad).not.toHaveBeenCalled() + expect(indexLoader).not.toHaveBeenCalled() + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__routeContext).toEqual({ ctx: 'server' }) + expect(indexMatch.__beforeLoadContext).toEqual({ bl: 'server' }) + expect(indexMatch.loaderData).toEqual({ ld: 'server' }) + }) + + // --- All dehydrate: false (everything re-executed) --- + + it('all dehydrate: false — all handlers re-executed, no data from wire', async () => { + const indexContextFn = vi.fn(() => ({ ctx: 'reexec' })) + const indexBeforeLoad = vi.fn(() => ({ bl: 'reexec' })) + const indexLoader = vi.fn(() => ({ ld: 'reexec' })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { handler: indexContextFn, dehydrate: false }, + beforeLoad: { handler: indexBeforeLoad, dehydrate: false }, + loader: { handler: indexLoader, dehydrate: false }, + }, + dehydratedRoot: {}, + // No b/l/m — nothing serialized + dehydratedIndex: {}, + }) + + await hydrate(router) + + expect(indexContextFn).toHaveBeenCalledTimes(1) + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + expect(indexLoader).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__routeContext).toEqual({ ctx: 'reexec' }) + expect(indexMatch.__beforeLoadContext).toEqual({ bl: 'reexec' }) + expect(indexMatch.loaderData).toEqual({ ld: 'reexec' }) + }) + + // --- Context chain integrity with mixed dehydrate across parent-child --- + + it('parent-child mixed dehydrate: parent beforeLoad=false, child context=true — context chain intact', async () => { + const rootBeforeLoad = vi.fn(() => ({ + rootBL: 'root-client-reexec', + })) + const rootContextFn = vi.fn(() => ({ + rootCtx: 'root-client-reexec', + })) + const indexContextFn = vi.fn(() => ({ + indexCtx: 'should-not-run', + })) + + const { router } = setupDehydrateHydration({ + rootOptions: { + beforeLoad: { handler: rootBeforeLoad, dehydrate: false }, + context: rootContextFn, // function form — default dehydrate: false for context + }, + indexOptions: { + context: { handler: indexContextFn, dehydrate: true }, + }, + dehydratedRoot: { + // No `b` — beforeLoad not serialized + // No `m` — context not serialized (default) + }, + dehydratedIndex: { + // `m` IS present — context serialized + m: { indexCtx: 'from-server' }, + l: {}, + }, + }) + + await hydrate(router) + + // Root handlers re-executed (dehydrate:false) + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(rootContextFn).toHaveBeenCalledTimes(1) + + // Index context NOT re-executed (dehydrate:true, data from wire) + expect(indexContextFn).not.toHaveBeenCalled() + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__routeContext).toEqual({ indexCtx: 'from-server' }) + + // The context chain should contain root's re-executed context + index's wire context + expect(indexMatch.context).toEqual( + expect.objectContaining({ + rootBL: 'root-client-reexec', + rootCtx: 'root-client-reexec', + indexCtx: 'from-server', + }), + ) + }) + + // --- Router-level defaultDehydrate overrides --- + + it('router-level defaultDehydrate overrides builtin defaults', async () => { + // Override: context defaults to dehydrate:true (builtin is false) + // Override: beforeLoad defaults to dehydrate:false (builtin is true) + const indexContextFn = vi.fn(() => ({ ctx: 'should-not-run' })) + const indexBeforeLoad = vi.fn(() => ({ bl: 'reexec' })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + context: indexContextFn, // function form — router default: true + beforeLoad: indexBeforeLoad, // function form — router default: false + }, + dehydratedRoot: {}, + dehydratedIndex: { + // context IS in payload because router default is true + m: { ctx: 'from-server' }, + // beforeLoad NOT in payload because router default is false + l: {}, + }, + routerDefaultDehydrate: { + context: true, + beforeLoad: false, + }, + }) + + await hydrate(router) + + // context NOT re-executed (router default: dehydrate true) + expect(indexContextFn).not.toHaveBeenCalled() + // beforeLoad IS re-executed (router default: dehydrate false) + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__routeContext).toEqual({ ctx: 'from-server' }) + expect(indexMatch.__beforeLoadContext).toEqual({ bl: 'reexec' }) + }) + + // --- method-level dehydrate overrides router-level defaults --- + + it('method-level dehydrate overrides router-level defaults', async () => { + // Router default: context=true, but method says dehydrate:false + const indexContextFn = vi.fn(() => ({ ctx: 'reexec' })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { handler: indexContextFn, dehydrate: false }, + }, + dehydratedRoot: {}, + dehydratedIndex: { + // No `m` — method-level dehydrate:false wins + l: {}, + }, + routerDefaultDehydrate: { + context: true, // would normally prevent re-execution + }, + }) + + await hydrate(router) + + // Method-level dehydrate:false wins — handler IS re-executed + expect(indexContextFn).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__routeContext).toEqual({ ctx: 'reexec' }) + }) + + // --- loader handler sees accumulated context from earlier phases --- + + it('re-executed loader sees context from serialized context and re-executed beforeLoad', async () => { + let loaderCtxCapture: any = null + + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { handler: () => ({ ctx: 'wire' }), dehydrate: true }, + beforeLoad: { + handler: () => ({ bl: 'client' }), + dehydrate: false, + }, + loader: { + handler: ({ context }: { context: any }) => { + loaderCtxCapture = context + return { ld: 'reexec' } + }, + dehydrate: false, + }, + }, + dehydratedRoot: {}, + dehydratedIndex: { + m: { ctx: 'wire' }, + // No `b` — beforeLoad not serialized + }, + }) + + await hydrate(router) + + expect(loaderCtxCapture).toEqual( + expect.objectContaining({ + ctx: 'wire', + bl: 'client', + }), + ) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.loaderData).toEqual({ ld: 'reexec' }) + }) + + // --- loader sees full context chain from context + beforeLoad --- + + it('re-executed loader gets full context from context + beforeLoad', async () => { + let loaderCtxCapture: any = null + + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { + handler: () => ({ ctx: 'from-wire' }), + dehydrate: true, + }, + beforeLoad: { + handler: () => ({ bl: 'from-client' }), + dehydrate: false, + }, + loader: { + handler: ({ context }: { context: any }) => { + loaderCtxCapture = context + return { ld: 'reexec' } + }, + dehydrate: false, + }, + }, + dehydratedRoot: {}, + dehydratedIndex: { + m: { ctx: 'from-wire' }, + }, + }) + + await hydrate(router) + + expect(loaderCtxCapture).toEqual( + expect.objectContaining({ + ctx: 'from-wire', + bl: 'from-client', + }), + ) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.loaderData).toEqual({ ld: 'reexec' }) + }) + + // --- Object form without explicit dehydrate uses builtin defaults --- + + it('object form without dehydrate property uses builtin defaults', async () => { + // beforeLoad object form without dehydrate → builtin default: true → NOT re-executed + const indexBeforeLoad = vi.fn(() => ({ bl: 'should-not-run' })) + // context object form without dehydrate → builtin default: false → re-executed + const indexContextFn = vi.fn(() => ({ ctx: 'reexec' })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + beforeLoad: { handler: indexBeforeLoad }, + context: { handler: indexContextFn }, + }, + dehydratedRoot: {}, + dehydratedIndex: { + b: { bl: 'from-server' }, + // No `m` — context default is false + l: {}, + }, + }) + + await hydrate(router) + + // beforeLoad NOT re-executed (builtin default: true → from wire) + expect(indexBeforeLoad).not.toHaveBeenCalled() + // context IS re-executed (builtin default: false → not dehydrated) + expect(indexContextFn).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.__beforeLoadContext).toEqual({ bl: 'from-server' }) + expect(indexMatch.__routeContext).toEqual({ ctx: 'reexec' }) + }) + + // --- Multiple loaders re-execute in parallel --- + + it('multiple loader re-executions run in parallel (not sequentially)', async () => { + const executionLog: Array = [] + + const rootLoader = vi.fn(async () => { + executionLog.push('root-loader-start') + await new Promise((r) => setTimeout(r, 50)) + executionLog.push('root-loader-end') + return { rootLd: 1 } + }) + const indexLoader = vi.fn(async () => { + executionLog.push('index-loader-start') + await new Promise((r) => setTimeout(r, 50)) + executionLog.push('index-loader-end') + return { indexLd: 2 } + }) + + const { router } = setupDehydrateHydration({ + rootOptions: { + loader: { handler: rootLoader, dehydrate: false }, + }, + indexOptions: { + loader: { handler: indexLoader, dehydrate: false }, + }, + dehydratedRoot: {}, + dehydratedIndex: {}, + }) + + await hydrate(router) + + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(indexLoader).toHaveBeenCalledTimes(1) + + // Both loaders should start before either ends (parallel execution) + const rootStartIdx = executionLog.indexOf('root-loader-start') + const indexStartIdx = executionLog.indexOf('index-loader-start') + const rootEndIdx = executionLog.indexOf('root-loader-end') + const indexEndIdx = executionLog.indexOf('index-loader-end') + + // Both started before either ended + expect(rootStartIdx).toBeLessThan(rootEndIdx) + expect(indexStartIdx).toBeLessThan(indexEndIdx) + expect(rootStartIdx).toBeLessThan(indexEndIdx) + expect(indexStartIdx).toBeLessThan(rootEndIdx) + + const rootMatch = router.state.matches[0] as AnyRouteMatch + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(rootMatch.loaderData).toEqual({ rootLd: 1 }) + expect(indexMatch.loaderData).toEqual({ indexLd: 2 }) + }) + + // --- dehydrate:false handler throws during hydration --- + + it('beforeLoad with dehydrate:false that throws sets match.error and re-throws', async () => { + const thrownError = new Error('beforeLoad boom') + const indexBeforeLoad = vi.fn(() => { + throw thrownError + }) + + const { router } = setupDehydrateHydration({ + indexOptions: { + beforeLoad: { handler: indexBeforeLoad, dehydrate: false }, + }, + dehydratedRoot: {}, + dehydratedIndex: { l: {} }, + }) + + await expect(hydrate(router)).rejects.toThrow('beforeLoad boom') + + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.error).toBe(thrownError) + }) + + it('context with dehydrate:false that throws sets match.error and re-throws', async () => { + const thrownError = new Error('context boom') + const indexContextFn = vi.fn(() => { + throw thrownError + }) + + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { handler: indexContextFn, dehydrate: false }, + }, + dehydratedRoot: {}, + dehydratedIndex: { l: {} }, + }) + + await expect(hydrate(router)).rejects.toThrow('context boom') + + expect(indexContextFn).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.error).toBe(thrownError) + }) + + it('loader with dehydrate:false that throws captures error on match (no re-throw)', async () => { + const thrownError = new Error('loader boom') + const indexLoader = vi.fn(() => { + throw thrownError + }) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { router } = setupDehydrateHydration({ + indexOptions: { + loader: { handler: indexLoader, dehydrate: false }, + }, + dehydratedRoot: {}, + dehydratedIndex: {}, + }) + + // Should NOT reject — loader errors are captured, not re-thrown + await hydrate(router) + + expect(indexLoader).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect(indexMatch.error).toBe(thrownError) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Error during hydration loader re-execution'), + thrownError, + ) + + consoleSpy.mockRestore() + }) + + it('dehydrate fn + hydrate fn: wire payload is transformed then reconstructed', async () => { + const ctxHydrate = vi.fn(({ data }: { data: { iso: string } }) => ({ + dt: new Date(data.iso), + })) + const blHydrate = vi.fn(({ data }: { data: { iso: string } }) => ({ + dt: new Date(data.iso), + })) + const ldHydrate = vi.fn(({ data }: { data: { iso: string } }) => ({ + dt: new Date(data.iso), + })) + + const { router } = setupDehydrateHydration({ + indexOptions: { + context: { + handler: () => ({ dt: new Date('1900-01-01T00:00:00.000Z') }), + dehydrate: ({ data }: { data: { dt: Date } }) => ({ + iso: data.dt.toISOString(), + }), + hydrate: ctxHydrate, + }, + beforeLoad: { + handler: () => ({ dt: new Date('1900-01-01T00:00:00.000Z') }), + dehydrate: ({ data }: { data: { dt: Date } }) => ({ + iso: data.dt.toISOString(), + }), + hydrate: blHydrate, + }, + loader: { + handler: () => ({ dt: new Date('1900-01-01T00:00:00.000Z') }), + dehydrate: ({ data }: { data: { dt: Date } }) => ({ + iso: data.dt.toISOString(), + }), + hydrate: ldHydrate, + }, + }, + dehydratedRoot: {}, + dehydratedIndex: { + m: { iso: '2020-01-01T00:00:00.000Z' }, + b: { iso: '2021-01-01T00:00:00.000Z' }, + l: { iso: '2022-01-01T00:00:00.000Z' }, + }, + }) + + await hydrate(router) + + expect(ctxHydrate).toHaveBeenCalledTimes(1) + expect(blHydrate).toHaveBeenCalledTimes(1) + expect(ldHydrate).toHaveBeenCalledTimes(1) + + const indexMatch = router.state.matches[1] as AnyRouteMatch + expect((indexMatch.__routeContext as any).dt).toBeInstanceOf(Date) + expect( + ((indexMatch.__routeContext as any).dt as Date).toISOString(), + ).toBe('2020-01-01T00:00:00.000Z') + expect((indexMatch.__beforeLoadContext as any).dt).toBeInstanceOf(Date) + expect( + ((indexMatch.__beforeLoadContext as any).dt as Date).toISOString(), + ).toBe('2021-01-01T00:00:00.000Z') + expect((indexMatch.loaderData as any).dt).toBeInstanceOf(Date) + expect(((indexMatch.loaderData as any).dt as Date).toISOString()).toBe( + '2022-01-01T00:00:00.000Z', + ) + }) + }) }) diff --git a/packages/router-core/tests/lifecycle.test.ts b/packages/router-core/tests/lifecycle.test.ts new file mode 100644 index 00000000000..14192ccffe3 --- /dev/null +++ b/packages/router-core/tests/lifecycle.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from 'vitest' +import { + getDehydrateFn, + getHydrateFn, + getRevalidateFn, + resolveHandler, + shouldDehydrate, +} from '../src/lifecycle' + +describe('resolveHandler', () => { + test('returns undefined for undefined input', () => { + expect(resolveHandler(undefined)).toBeUndefined() + }) + + test('returns the function for function form', () => { + const fn = () => 'hello' + expect(resolveHandler(fn)).toBe(fn) + }) + + test('returns the handler for object form', () => { + const handler = () => 'hello' + expect(resolveHandler({ handler })).toBe(handler) + }) + + test('returns the handler for object form with dehydrate', () => { + const handler = () => 'hello' + expect(resolveHandler({ handler, dehydrate: true })).toBe(handler) + }) + + test('returns the handler for object form with dehydrate: false', () => { + const handler = () => 'hello' + expect(resolveHandler({ handler, dehydrate: false })).toBe(handler) + }) +}) + +describe('shouldDehydrate', () => { + // Function form — no method-level dehydrate, falls to defaults + test('function form uses router default when available', () => { + const fn = () => 'hello' + expect(shouldDehydrate(fn, true, false)).toBe(true) + expect(shouldDehydrate(fn, false, true)).toBe(false) + }) + + test('function form uses builtin default when router default is undefined', () => { + const fn = () => 'hello' + expect(shouldDehydrate(fn, undefined, true)).toBe(true) + expect(shouldDehydrate(fn, undefined, false)).toBe(false) + }) + + // Object form without dehydrate — falls to defaults + test('object form without dehydrate uses router default', () => { + const option = { handler: () => 'hello' } + expect(shouldDehydrate(option, true, false)).toBe(true) + expect(shouldDehydrate(option, false, true)).toBe(false) + }) + + test('object form without dehydrate uses builtin default when router default undefined', () => { + const option = { handler: () => 'hello' } + expect(shouldDehydrate(option, undefined, true)).toBe(true) + expect(shouldDehydrate(option, undefined, false)).toBe(false) + }) + + // Object form with explicit dehydrate — overrides everything + test('object form with dehydrate: true overrides router default false', () => { + const option = { handler: () => 'hello', dehydrate: true } + expect(shouldDehydrate(option, false, false)).toBe(true) + }) + + test('object form with dehydrate: false overrides router default true', () => { + const option = { handler: () => 'hello', dehydrate: false } + expect(shouldDehydrate(option, true, true)).toBe(false) + }) + + test('object form with dehydrate: true overrides builtin default false', () => { + const option = { handler: () => 'hello', dehydrate: true } + expect(shouldDehydrate(option, undefined, false)).toBe(true) + }) + + test('object form with dehydrate: false overrides builtin default true', () => { + const option = { handler: () => 'hello', dehydrate: false } + expect(shouldDehydrate(option, undefined, true)).toBe(false) + }) + + // Three-level priority chain + test('method-level > router default > builtin default', () => { + // All three present, method-level wins + expect( + shouldDehydrate({ handler: () => {}, dehydrate: true }, false, false), + ).toBe(true) + expect( + shouldDehydrate({ handler: () => {}, dehydrate: false }, true, true), + ).toBe(false) + + // No method-level, router default wins over builtin + expect(shouldDehydrate({ handler: () => {} }, true, false)).toBe(true) + expect(shouldDehydrate({ handler: () => {} }, false, true)).toBe(false) + + // No method-level, no router default, builtin wins + expect(shouldDehydrate({ handler: () => {} }, undefined, true)).toBe(true) + expect(shouldDehydrate({ handler: () => {} }, undefined, false)).toBe(false) + }) + + // dehydrate as a function — always counts as truthy + test('object form with dehydrate function is treated as truthy', () => { + const option = { + handler: () => ({ dt: new Date() }), + dehydrate: ({ data }: { data: { dt: Date } }) => ({ + iso: data.dt.toISOString(), + }), + hydrate: ({ data }: { data: { iso: string } }) => ({ + dt: new Date(data.iso), + }), + } + // dehydrate-function overrides router default false and builtin false + expect(shouldDehydrate(option, false, false)).toBe(true) + expect(shouldDehydrate(option, undefined, false)).toBe(true) + }) +}) + +describe('getDehydrateFn', () => { + test('returns undefined for undefined input', () => { + expect(getDehydrateFn(undefined)).toBeUndefined() + }) + + test('returns undefined for function form', () => { + expect(getDehydrateFn(() => 'hello')).toBeUndefined() + }) + + test('returns undefined for object form without dehydrate', () => { + expect(getDehydrateFn({ handler: () => 'hello' })).toBeUndefined() + }) + + test('returns undefined for object form with dehydrate: true', () => { + expect( + getDehydrateFn({ handler: () => 'hello', dehydrate: true }), + ).toBeUndefined() + }) + + test('returns undefined for object form with dehydrate: false', () => { + expect( + getDehydrateFn({ handler: () => 'hello', dehydrate: false }), + ).toBeUndefined() + }) + + test('returns the dehydrate function for object form with dehydrate function', () => { + const dehydrate = ({ data }: { data: number }) => data.toString() + const option = { + handler: () => 1, + dehydrate, + hydrate: ({ data }: { data: string }) => Number(data), + } + expect(getDehydrateFn(option)).toBe(dehydrate) + }) +}) + +describe('getHydrateFn', () => { + test('returns undefined for undefined input', () => { + expect(getHydrateFn(undefined)).toBeUndefined() + }) + + test('returns undefined for function form', () => { + expect(getHydrateFn(() => 'hello')).toBeUndefined() + }) + + test('returns undefined for object form without hydrate', () => { + expect(getHydrateFn({ handler: () => 'hello' })).toBeUndefined() + }) + + test('returns the hydrate function for object form with hydrate function', () => { + const hydrateFn = ({ data }: { data: string }) => new Date(data) + const option = { + handler: () => new Date(), + dehydrate: ({ data }: { data: Date }) => data.toISOString(), + hydrate: hydrateFn, + } + expect(getHydrateFn(option)).toBe(hydrateFn) + }) +}) + +describe('getRevalidateFn', () => { + test('returns undefined for undefined input', () => { + expect(getRevalidateFn(undefined)).toBeUndefined() + }) + + test('returns undefined for function form', () => { + expect(getRevalidateFn(() => 'hello')).toBeUndefined() + }) + + test('returns undefined for object form without revalidate', () => { + expect(getRevalidateFn({ handler: () => 'hello' })).toBeUndefined() + }) + + test('returns undefined for object form with revalidate: true', () => { + expect( + getRevalidateFn({ handler: () => 'hello', revalidate: true }), + ).toBeUndefined() + }) + + test('returns undefined for object form with revalidate: false', () => { + expect( + getRevalidateFn({ handler: () => 'hello', revalidate: false }), + ).toBeUndefined() + }) + + test('returns the revalidate function for object form with revalidate function', () => { + const revalidateFn = (ctx: { prev: number }) => ctx.prev + 1 + const option = { handler: () => 1, revalidate: revalidateFn } + expect(getRevalidateFn(option)).toBe(revalidateFn) + }) +}) diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index 1ea6fca30e8..7d4ca09d236 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -9,17 +9,14 @@ import { } from '../src' import { createTestRouter } from './routerTestUtils' import { loadMatches } from '../src/load-matches' -import type { - AnyRouter, - LoaderStaleReloadMode, - RootRouteOptions, - RouterCore, -} from '../src' +import type { AnyRouter, LoaderStaleReloadMode, RouterCore } from '../src' -type AnyRouteOptions = RootRouteOptions -type BeforeLoad = NonNullable -type Loader = NonNullable -type LoaderEntry = Exclude +// Permissive function types for runtime test helpers — these don't need +// strict return-type checking since the tests only care about call counts +// and runtime behaviour, not the exact type of the returned value. +type BeforeLoad = (...args: Array) => any +type Loader = (...args: Array) => any +type LoaderEntry = { handler: Loader; staleReloadMode?: LoaderStaleReloadMode } describe('redirect resolution', () => { test('resolveRedirect normalizes same-origin Location to path-only', async () => { @@ -236,8 +233,8 @@ describe('beforeLoad skip or exec', () => { }) test('exec if rejected preload (notFound)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { - if (preload) throw notFound() + const beforeLoad = vi.fn(async (ctx: { preload: boolean }) => { + if (ctx.preload) throw notFound() await Promise.resolve() }) const router = setup({ @@ -251,9 +248,9 @@ describe('beforeLoad skip or exec', () => { }) test('exec if pending preload (notFound)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { + const beforeLoad = vi.fn(async (ctx: { preload: boolean }) => { await sleep(100) - if (preload) throw notFound() + if (ctx.preload) throw notFound() }) const router = setup({ beforeLoad, @@ -266,8 +263,8 @@ describe('beforeLoad skip or exec', () => { }) test('exec if rejected preload (redirect)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { - if (preload) throw redirect({ to: '/bar' }) + const beforeLoad = vi.fn(async (ctx: { preload: boolean }) => { + if (ctx.preload) throw redirect({ to: '/bar' }) await Promise.resolve() }) const router = setup({ @@ -288,9 +285,9 @@ describe('beforeLoad skip or exec', () => { }) test('exec if pending preload (redirect)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { + const beforeLoad = vi.fn(async (ctx: { preload: boolean }) => { await sleep(100) - if (preload) throw redirect({ to: '/bar' }) + if (ctx.preload) throw redirect({ to: '/bar' }) }) const router = setup({ beforeLoad, @@ -310,8 +307,8 @@ describe('beforeLoad skip or exec', () => { }) test('exec if rejected preload (error)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { - if (preload) throw new Error('error') + const beforeLoad = vi.fn(async (ctx: { preload: boolean }) => { + if (ctx.preload) throw new Error('error') await Promise.resolve() }) const router = setup({ @@ -360,9 +357,9 @@ describe('beforeLoad skip or exec', () => { }) test('exec if pending preload (error)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { + const beforeLoad = vi.fn(async (ctx: { preload: boolean }) => { await sleep(100) - if (preload) throw new Error('error') + if (ctx.preload) throw new Error('error') }) const router = setup({ beforeLoad, @@ -381,7 +378,7 @@ describe('loader skip or exec', () => { staleTime, defaultStaleReloadMode, }: { - loader?: Loader + loader?: Loader | LoaderEntry staleTime?: number defaultStaleReloadMode?: LoaderStaleReloadMode }) => { @@ -719,7 +716,7 @@ describe('stale loader reload triggers', () => { staleTime, defaultStaleReloadMode, }: { - loader?: Loader + loader?: Loader | LoaderEntry staleTime?: number defaultStaleReloadMode?: LoaderStaleReloadMode }) => { @@ -2176,6 +2173,859 @@ describe('routeId in context options', () => { }) }) +describe('context semantics', () => { + const setup = ({ + contextFn, + loader, + rootContextFn, + }: { + contextFn?: any + loader?: any + rootContextFn?: any + }) => { + const rootRoute = new BaseRootRoute({ + context: rootContextFn, + }) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: contextFn, + loader, + }) + + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + return router + } + + test('context does not run when route is not visited', async () => { + const contextFn = vi.fn() + const router = setup({ contextFn }) + await router.load() + expect(contextFn).toHaveBeenCalledTimes(0) + }) + + test('context runs on first navigation', async () => { + const contextFn = vi.fn(() => ({ hello: 'world' })) + const router = setup({ contextFn }) + await router.navigate({ to: '/foo' }) + expect(contextFn).toHaveBeenCalledTimes(1) + expect(router.state.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '/foo/foo', + }), + ]), + ) + }) + + test('context does not re-run on stay navigation (same route, cached match)', async () => { + const contextFn = vi.fn(() => ({ key: 'value' })) + const loader = vi.fn() + + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo/$fooId', + context: contextFn, + loader, + staleTime: 5000, + gcTime: 5000, + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + defaultStaleTime: 5000, + defaultGcTime: 5000, + }) + + // First nav — context should run + await router.navigate({ + to: '/foo/$fooId', + params: { fooId: '1' }, + }) + expect(contextFn).toHaveBeenCalledTimes(1) + + // Navigate to same route with different params — this is a different matchId, new match + await router.navigate({ + to: '/foo/$fooId', + params: { fooId: '2' }, + }) + expect(contextFn).toHaveBeenCalledTimes(2) + }) + + test('context re-runs when a cached match is garbage collected and recreated', async () => { + const contextFn = vi.fn(() => ({ key: 'value' })) + + // gcTime: 0 so matches are GC'd immediately after leaving + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: contextFn, + gcTime: 0, + }) + + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + // First nav — context runs + await router.navigate({ to: '/foo' }) + expect(contextFn).toHaveBeenCalledTimes(1) + + // Navigate away — the /foo match should be GC'd with gcTime: 0 + await router.navigate({ to: '/bar' }) + + // Wait for GC to happen + await sleep(50) + + // Navigate back — new match created, context runs again + await router.navigate({ to: '/foo' }) + expect(contextFn).toHaveBeenCalledTimes(2) + }) + + test('context is async (returned promise is awaited)', async () => { + const callOrder: Array = [] + const contextFn = vi.fn(async () => { + await sleep(50) + callOrder.push('context') + return { fromContext: true } + }) + const loader = vi.fn(() => { + callOrder.push('loader') + }) + const router = setup({ contextFn, loader }) + await router.navigate({ to: '/foo' }) + + expect(callOrder).toEqual(['context', 'loader']) + expect(contextFn).toHaveBeenCalledTimes(1) + }) + + test('context receives params, context, routeId, cause, preload but NOT search or deps', async () => { + const contextFn = vi.fn() + const rootContextFn = vi.fn(() => ({ rootCtx: 'hello' })) + + const rootRoute = new BaseRootRoute({ + context: rootContextFn, + }) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo/$fooId', + context: contextFn, + validateSearch: () => ({ page: 1 }), + loaderDeps: (deps: any) => ({ page: deps.search.page }), + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + context: { routerCtx: 'test' } as any, + }) + + await router.navigate({ + to: '/foo/$fooId', + params: { fooId: '123' }, + }) + + expect(contextFn).toHaveBeenCalledTimes(1) + const args = contextFn.mock.calls[0]![0] + expect(args.params).toEqual({ fooId: '123' }) + expect(args.context).toEqual({ routerCtx: 'test', rootCtx: 'hello' }) + expect(args.routeId).toBe('/foo/$fooId') + // context receives deps (loaderDeps output), but not search + expect(args.deps).toEqual({ page: 1 }) + expect(args.cause).toBe('enter') + expect(args.preload).toBe(false) + expect(args.search).toBeUndefined() + }) + + test('context return value is stored in match context', async () => { + const contextFn = vi.fn(() => ({ fromContext: 'data' })) + const loader = vi.fn() + const router = setup({ contextFn, loader }) + await router.navigate({ to: '/foo' }) + + // The loader should have access to the context return + expect(loader).toHaveBeenCalledTimes(1) + const loaderArgs = loader.mock.calls[0]![0] + expect(loaderArgs.context).toEqual({ fromContext: 'data' }) + }) + + test('context (without revalidate) does NOT re-run after router.invalidate()', async () => { + const contextFn = vi.fn(() => ({ data: 'fresh' })) + const loader = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: contextFn, + loader, + staleTime: 5000, + gcTime: 5000, + }) + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + defaultStaleTime: 5000, + defaultGcTime: 5000, + }) + + // Navigate to /foo — context runs once + await router.navigate({ to: '/foo' }) + expect(contextFn).toHaveBeenCalledTimes(1) + + // Invalidate and reload — context should NOT re-run (no revalidate flag) + await router.invalidate() + expect(contextFn).toHaveBeenCalledTimes(1) + }) +}) + +describe('context with revalidate semantics', () => { + const setup = ({ + contextFn, + loader, + staleTime, + }: { + contextFn?: any + loader?: any + staleTime?: number + }) => { + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: contextFn, + loader, + staleTime, + gcTime: staleTime, + }) + + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + return router + } + + test('context with revalidate:true does not run when route is not visited', async () => { + const handler = vi.fn() + const router = setup({ contextFn: { handler, revalidate: true } }) + await router.load() + expect(handler).toHaveBeenCalledTimes(0) + }) + + test('context with revalidate:true runs on first navigation', async () => { + const handler = vi.fn(() => ({ loaded: true })) + const router = setup({ contextFn: { handler, revalidate: true } }) + await router.navigate({ to: '/foo' }) + expect(handler).toHaveBeenCalledTimes(1) + }) + + test('context with revalidate:true return value extends context available in loader', async () => { + const handler = vi.fn(() => ({ fromContext: 'loadData' })) + const loader = vi.fn() + const router = setup({ contextFn: { handler, revalidate: true }, loader }) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(1) + const loaderArgs = loader.mock.calls[0]![0] + expect(loaderArgs.context).toEqual({ fromContext: 'loadData' }) + }) + + test('context with revalidate:true is async (returned promise is awaited)', async () => { + const callOrder: Array = [] + const handler = vi.fn(async () => { + await sleep(50) + callOrder.push('context') + return { fromContext: true } + }) + const loader = vi.fn(() => { + callOrder.push('loader') + }) + const router = setup({ contextFn: { handler, revalidate: true }, loader }) + await router.navigate({ to: '/foo' }) + + // context completes before loader starts (serial then parallel) + expect(callOrder).toEqual(['context', 'loader']) + }) + + test('context with revalidate:true does not re-run on cached match (not invalid)', async () => { + const handler = vi.fn(() => ({ data: 'fresh' })) + const loader = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: { handler, revalidate: true }, + loader, + gcTime: 5000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + defaultGcTime: 5000, + }) + + // Navigate to /foo — context runs + await router.navigate({ to: '/foo' }) + expect(handler).toHaveBeenCalledTimes(1) + + // Navigate away to /bar + await router.navigate({ to: '/bar' }) + + // Navigate back to /foo — context should skip + // because needsContext was consumed on first run and match is not invalid + await router.navigate({ to: '/foo' }) + expect(handler).toHaveBeenCalledTimes(1) + }) + + test('context with revalidate:true re-runs after router.invalidate()', async () => { + const handler = vi.fn(() => ({ data: 'fresh' })) + const loader = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: { handler, revalidate: true }, + loader, + staleTime: 5000, + gcTime: 5000, + }) + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + defaultStaleTime: 5000, + defaultGcTime: 5000, + }) + + // Navigate to /foo — context runs once + await router.navigate({ to: '/foo' }) + expect(handler).toHaveBeenCalledTimes(1) + + // Invalidate and reload — context should re-run (revalidate: true) + await router.invalidate() + expect(handler).toHaveBeenCalledTimes(2) + }) + + test('context with revalidate:true re-runs when loaderDeps change (new match)', async () => { + const handler = vi.fn() + + const rootRoute = new BaseRootRoute({ + validateSearch: (search: Record) => ({ + page: Number(search.page) || 1, + }), + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loaderDeps: (deps: any) => ({ page: deps.search.page }), + context: { handler, revalidate: true }, + gcTime: 10_000, + }) + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo', search: { page: 1 } }) + expect(handler).toHaveBeenCalledTimes(1) + + // Navigate with different loaderDeps — new matchId, new match → context runs + await router.navigate({ to: '/foo', search: { page: 2 } }) + expect(handler).toHaveBeenCalledTimes(2) + }) + + test('context with revalidate:true re-runs on stale cached match', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2020-01-01T00:00:00.000Z')) + + try { + const handler = vi.fn(() => ({ loaded: true })) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: { handler, revalidate: true }, + staleTime: 0, + gcTime: 60_000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + expect(handler).toHaveBeenCalledTimes(1) + + await router.navigate({ to: '/bar' }) + + // Advance time — staleness should trigger revalidation when opted in + vi.setSystemTime(new Date('2020-01-01T00:01:00.000Z')) + await router.navigate({ to: '/foo' }) + + // context should re-run — opted in via revalidate and match is stale + expect(handler).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + test('context with revalidate:true re-runs when match is GC-ed and re-created', async () => { + const handler = vi.fn(() => ({ loaded: true })) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: { handler, revalidate: true }, + gcTime: 0, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + expect(handler).toHaveBeenCalledTimes(1) + + // Navigate away — the /foo match should be GC'd with gcTime: 0 + await router.navigate({ to: '/bar' }) + + // Wait for GC to happen + await sleep(50) + + // Navigate back — new match created, context runs again + await router.navigate({ to: '/foo' }) + + // context re-runs because the match was GC-ed and a fresh match was created + expect(handler).toHaveBeenCalledTimes(2) + }) + + test('context does not run during preload when route.preload=false', async () => { + const handler = vi.fn(() => ({ loaded: true })) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: { handler, revalidate: true }, + preload: false, + }) + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.preloadRoute({ to: '/foo' }) + expect(handler).toHaveBeenCalledTimes(0) + }) + + test('context return is inherited by child routes', async () => { + const rootContextFn = vi.fn(() => ({ fromRoot: 'rootData' })) + const childLoader = vi.fn() + + const rootRoute = new BaseRootRoute({ + context: rootContextFn, + }) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: childLoader, + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + expect(rootContextFn).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + const childLoaderArgs = childLoader.mock.calls[0]![0] + expect(childLoaderArgs.context).toEqual({ fromRoot: 'rootData' }) + }) +}) + +describe('per-route interleaved ordering', () => { + test('Parent(context→beforeLoad) → Child(context→beforeLoad) → loaders parallel', async () => { + const callOrder: Array = [] + + const rootRoute = new BaseRootRoute({}) + + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: vi.fn(() => { + callOrder.push('parent:context') + return { parentContext: true } + }), + beforeLoad: vi.fn(() => { + callOrder.push('parent:beforeLoad') + return { parentBeforeLoad: true } + }), + loader: vi.fn(() => { + callOrder.push('parent:loader') + }), + }) + + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: vi.fn(() => { + callOrder.push('child:context') + return { childContext: true } + }), + beforeLoad: vi.fn(() => { + callOrder.push('child:beforeLoad') + return { childBeforeLoad: true } + }), + loader: vi.fn(() => { + callOrder.push('child:loader') + }), + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + + // Serial per-route: parent's context → beforeLoad, then child's context → beforeLoad + // Then loaders run in parallel + expect(callOrder.slice(0, 4)).toEqual([ + 'parent:context', + 'parent:beforeLoad', + 'child:context', + 'child:beforeLoad', + ]) + + // Both loaders should have run (after the serial phase) + expect(callOrder).toContain('parent:loader') + expect(callOrder).toContain('child:loader') + expect(callOrder.indexOf('parent:loader')).toBeGreaterThanOrEqual(4) + expect(callOrder.indexOf('child:loader')).toBeGreaterThanOrEqual(4) + }) + + test('async context and beforeLoad are awaited in order', async () => { + const callOrder: Array = [] + + const rootRoute = new BaseRootRoute({}) + + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: vi.fn(async () => { + await sleep(20) + callOrder.push('parent:context') + return { parentContext: true } + }), + beforeLoad: vi.fn(async () => { + await sleep(20) + callOrder.push('parent:beforeLoad') + return { parentBeforeLoad: true } + }), + }) + + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: vi.fn(async () => { + await sleep(20) + callOrder.push('child:context') + return { childContext: true } + }), + beforeLoad: vi.fn(async () => { + await sleep(20) + callOrder.push('child:beforeLoad') + return { childBeforeLoad: true } + }), + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + + expect(callOrder).toEqual([ + 'parent:context', + 'parent:beforeLoad', + 'child:context', + 'child:beforeLoad', + ]) + }) +}) + +describe('full context accumulation chain', () => { + test('routerContext + parent context + parent beforeLoad + child context + child beforeLoad → available in child loader', async () => { + const childLoader = vi.fn() + + const rootRoute = new BaseRootRoute({}) + + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ parentContext: 'a' }), + beforeLoad: () => ({ parentBeforeLoad: 'b' }), + }) + + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: () => ({ childContext: 'd' }), + beforeLoad: () => ({ childBeforeLoad: 'e' }), + loader: childLoader, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + context: { routerCtx: 'base' } as any, + }) + + await router.navigate({ to: '/parent/child' }) + + expect(childLoader).toHaveBeenCalledTimes(1) + const loaderCtx = childLoader.mock.calls[0]![0].context + expect(loaderCtx).toEqual({ + routerCtx: 'base', + parentContext: 'a', + parentBeforeLoad: 'b', + childContext: 'd', + childBeforeLoad: 'e', + }) + }) + + test('context flows correctly through context → beforeLoad within a single route', async () => { + const beforeLoad = vi.fn() + const loader = vi.fn() + + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: () => ({ fromContext: 'match' }), + beforeLoad: (opts: any) => { + beforeLoad(opts) + return { fromBeforeLoad: 'bl' } + }, + loader, + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + context: { base: 'ctx' } as any, + }) + + await router.navigate({ to: '/foo' }) + + // beforeLoad should see: base + context return + const blCtx = beforeLoad.mock.calls[0]![0].context + expect(blCtx).toEqual({ base: 'ctx', fromContext: 'match' }) + + // loader should see: base + context + beforeLoad + const lCtx = loader.mock.calls[0]![0].context + expect(lCtx).toEqual({ + base: 'ctx', + fromContext: 'match', + fromBeforeLoad: 'bl', + }) + }) + + test('overlapping context keys: later lifecycle overrides earlier', async () => { + const loader = vi.fn() + + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + context: () => ({ shared: 'from-context', contextOnly: true }), + beforeLoad: () => ({ shared: 'from-beforeLoad', beforeLoadOnly: true }), + loader, + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + + const lCtx = loader.mock.calls[0]![0].context + // beforeLoad runs last, so 'shared' should be 'from-beforeLoad' + expect(lCtx.shared).toBe('from-beforeLoad') + expect(lCtx.contextOnly).toBe(true) + expect(lCtx.beforeLoadOnly).toBe(true) + }) + + test('child context sees parent full context (context + beforeLoad)', async () => { + const childContextFn = vi.fn() + + const rootRoute = new BaseRootRoute({}) + + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ pContext: 1 }), + beforeLoad: () => ({ pBeforeLoad: 2 }), + }) + + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: childContextFn, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + + expect(childContextFn).toHaveBeenCalledTimes(1) + const childCtx = childContextFn.mock.calls[0]![0].context + expect(childCtx).toEqual({ + pContext: 1, + pBeforeLoad: 2, + }) + }) + + test('child beforeLoad sees parent full context + child context return', async () => { + const childBeforeLoad = vi.fn() + + const rootRoute = new BaseRootRoute({}) + + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ pContext: 1 }), + beforeLoad: () => ({ pBeforeLoad: 2 }), + }) + + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: () => ({ cContext: 4 }), + beforeLoad: childBeforeLoad, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + + expect(childBeforeLoad).toHaveBeenCalledTimes(1) + const ctx = childBeforeLoad.mock.calls[0]![0].context + expect(ctx).toEqual({ + pContext: 1, + pBeforeLoad: 2, + cContext: 4, + }) + }) +}) + function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index a9e804d5170..46763c557d1 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -127,12 +127,12 @@ function RouteComp({ {}, {}, AnyContext, - AnyContext, {}, undefined, any, FileRouteTypes, unknown, + unknown, undefined >, MakeRouteMatchUnion diff --git a/packages/router-plugin/src/core/code-splitter/compilers.ts b/packages/router-plugin/src/core/code-splitter/compilers.ts index 32eade99af1..1840dc5bdcc 100644 --- a/packages/router-plugin/src/core/code-splitter/compilers.ts +++ b/packages/router-plugin/src/core/code-splitter/compilers.ts @@ -28,6 +28,12 @@ import type { CompileCodeSplitReferenceRouteOptions, ReferenceRouteCompilerPlugin, } from './plugins' +import type { + DeleteNodeCallback, + DeleteNodeCallbackResult, + DeleteNodeReplacement, + DeletableNodes, +} from '../config' import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils' import type { CodeSplitGroupings, SplitRouteIdentNodes } from '../constants' import type { SplitNodeMeta } from './types' @@ -106,6 +112,217 @@ const SPLIT_NODES_CONFIG = new Map([ const KNOWN_SPLIT_ROUTE_IDENTS = [...SPLIT_NODES_CONFIG.keys()] as const +type ObjectPropertyPath = ReadonlyArray +type CompiledDeleteNodes = { + paths: Array + callbacks: Array +} + +function getObjectMemberKeyName( + prop: t.ObjectExpression['properties'][number], +): string | undefined { + if (!t.isObjectProperty(prop) && !t.isObjectMethod(prop)) { + return undefined + } + + if (prop.computed) { + return undefined + } + + if (t.isIdentifier(prop.key)) { + return prop.key.name + } + + if (t.isStringLiteral(prop.key)) { + return prop.key.value + } + + return undefined +} + +function compileObjectExpressionDeleteNodes( + deleteNodes: ReadonlySet | undefined, +): CompiledDeleteNodes { + const compiledDeleteNodes: CompiledDeleteNodes = { + paths: [], + callbacks: [], + } + + if (!deleteNodes?.size) { + return compiledDeleteNodes + } + + deleteNodes.forEach((deleteNode) => { + if (typeof deleteNode === 'function') { + compiledDeleteNodes.callbacks.push(deleteNode) + return + } + + const pathSegments = deleteNode.split('.').filter(Boolean) + + if (pathSegments.length > 0) { + compiledDeleteNodes.paths.push(pathSegments) + } + }) + + return compiledDeleteNodes +} + +function deleteObjectExpressionPropertyPath( + objectExpression: t.ObjectExpression, + pathSegments: ObjectPropertyPath, + segmentIndex = 0, +): boolean { + const segment = pathSegments[segmentIndex] + + if (!segment) { + return false + } + + let modified = false + objectExpression.properties = objectExpression.properties.filter((prop) => { + const key = getObjectMemberKeyName(prop) + + if (key !== segment) { + return true + } + + if (segmentIndex === pathSegments.length - 1) { + modified = true + return false + } + + if (t.isObjectProperty(prop) && t.isObjectExpression(prop.value)) { + modified = + deleteObjectExpressionPropertyPath( + prop.value, + pathSegments, + segmentIndex + 1, + ) || modified + } + + return true + }) + + return modified +} + +function deleteObjectExpressionPaths( + objectExpression: t.ObjectExpression, + paths: ReadonlyArray, +): boolean { + let modified = false + + paths.forEach((pathSegments) => { + modified = + deleteObjectExpressionPropertyPath(objectExpression, pathSegments) || + modified + }) + + return modified +} + +function getDeleteNodeCallbackReplacement( + result: DeleteNodeCallbackResult, +): DeleteNodeReplacement | undefined { + if (!result || result === true) { + return undefined + } + + if ( + typeof result === 'object' && + 'type' in result && + (result.type === 'ObjectProperty' || result.type === 'ObjectMethod') + ) { + return result + } + + if (typeof result === 'object' && result.action === 'replace') { + return result.node + } + + return undefined +} + +function shouldDeleteFromCallbackResult( + result: DeleteNodeCallbackResult, +): boolean { + return ( + result === true || + (typeof result === 'object' && + result !== null && + 'action' in result && + result.action === 'delete') + ) +} + +function applyObjectExpressionDeleteNodeCallbacks( + objectExpression: t.ObjectExpression, + callbacks: ReadonlyArray, + parentPath: ObjectPropertyPath = [], +): boolean { + let modified = false + const nextProperties: t.ObjectExpression['properties'] = [] + + objectExpression.properties.forEach((prop) => { + const key = getObjectMemberKeyName(prop) + + if (!key || (!t.isObjectProperty(prop) && !t.isObjectMethod(prop))) { + nextProperties.push(prop) + return + } + + const path = [...parentPath, key] + let nextProp: t.ObjectProperty | t.ObjectMethod = prop + let shouldDelete = false + + callbacks.forEach((callback) => { + if (shouldDelete) { + return + } + + const result = callback({ + key, + path, + dotPath: path.join('.'), + prop: nextProp, + parent: objectExpression, + }) + + if (shouldDeleteFromCallbackResult(result)) { + modified = true + shouldDelete = true + return + } + + const replacement = getDeleteNodeCallbackReplacement(result) + if (replacement) { + modified = true + nextProp = replacement + } + }) + + if (shouldDelete) { + return + } + + if (t.isObjectProperty(nextProp) && t.isObjectExpression(nextProp.value)) { + modified = + applyObjectExpressionDeleteNodeCallbacks( + nextProp.value, + callbacks, + path, + ) || modified + } + + nextProperties.push(nextProp) + }) + + objectExpression.properties = nextProperties + + return modified +} + function addSplitSearchParamToFilename( filename: string, grouping: Array, @@ -390,6 +607,7 @@ export function compileCodeSplitReferenceRoute( ), ), ] + const deleteNodes = compileObjectExpressionDeleteNodes(opts.deleteNodes) let createRouteFn: string @@ -480,19 +698,19 @@ export function compileCodeSplitReferenceRoute( } }) - if (opts.deleteNodes && opts.deleteNodes.size > 0) { - routeOptions.properties = routeOptions.properties.filter( - (prop) => { - if (t.isObjectProperty(prop)) { - const key = getObjectPropertyKeyName(prop) - if (key && opts.deleteNodes!.has(key as any)) { - modified = true - return false - } - } - return true - }, - ) + if (deleteNodes.paths.length > 0) { + modified = + deleteObjectExpressionPaths( + routeOptions, + deleteNodes.paths, + ) || modified + } + if (deleteNodes.callbacks.length > 0) { + modified = + applyObjectExpressionDeleteNodeCallbacks( + routeOptions, + deleteNodes.callbacks, + ) || modified } if (!splittableCreateRouteFns.includes(createRouteFn)) { opts.compilerPlugins?.forEach((plugin) => { diff --git a/packages/router-plugin/src/core/config.ts b/packages/router-plugin/src/core/config.ts index c0b47b9ef57..b9fa2844291 100644 --- a/packages/router-plugin/src/core/config.ts +++ b/packages/router-plugin/src/core/config.ts @@ -8,6 +8,7 @@ import type { RegisteredRouter, RouteIds, } from '@tanstack/router-core' +import type * as t from '@babel/types' import type { CodeSplitGroupings } from './constants' import type { ReferenceRouteCompilerPlugin } from './code-splitter/plugins' @@ -99,14 +100,42 @@ const codeSplittingOptionsSchema = z.object({ >((value) => typeof value === 'function') .optional(), defaultBehavior: splitGroupingsSchema.optional(), - deleteNodes: z.array(z.string()).optional(), + deleteNodes: z + .array( + z.custom( + (value) => typeof value === 'string' || typeof value === 'function', + ), + ) + .optional(), addHmr: z.boolean().optional().default(true), }) type FileRouteKeys = keyof (Parameters< CreateFileRoute >[0] & {}) -export type DeletableNodes = FileRouteKeys | (string & {}) +export type DeleteNodeReplacement = t.ObjectProperty | t.ObjectMethod +export type DeleteNodeCallbackResult = + | boolean + | void + | DeleteNodeReplacement + | { + action: 'delete' + } + | { + action: 'replace' + node: DeleteNodeReplacement + } +export type DeleteNodeCallbackContext = { + key: string + path: ReadonlyArray + dotPath: string + prop: t.ObjectProperty | t.ObjectMethod + parent: t.ObjectExpression +} +export type DeleteNodeCallback = ( + ctx: DeleteNodeCallbackContext, +) => DeleteNodeCallbackResult +export type DeletableNodes = FileRouteKeys | (string & {}) | DeleteNodeCallback export const configSchema = generatorConfigSchema.extend({ enableRouteGeneration: z.boolean().optional(), diff --git a/packages/router-plugin/src/index.ts b/packages/router-plugin/src/index.ts index e390c046a22..367ce8b59f3 100644 --- a/packages/router-plugin/src/index.ts +++ b/packages/router-plugin/src/index.ts @@ -7,6 +7,10 @@ export type { ConfigInput, ConfigOutput, CodeSplittingOptions, + DeleteNodeCallback, + DeleteNodeCallbackContext, + DeleteNodeCallbackResult, + DeleteNodeReplacement, DeletableNodes, HmrOptions, } from './core/config' diff --git a/packages/router-plugin/tests/delete-nodes.test.ts b/packages/router-plugin/tests/delete-nodes.test.ts index 6bb1d3dc6d6..b381c0db641 100644 --- a/packages/router-plugin/tests/delete-nodes.test.ts +++ b/packages/router-plugin/tests/delete-nodes.test.ts @@ -1,10 +1,11 @@ import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' +import * as t from '@babel/types' import { describe, expect, it } from 'vitest' import { compileCodeSplitReferenceRoute } from '../src/core/code-splitter/compilers' import { frameworks } from './constants' -import type { DeletableNodes } from '../src/core/config' +import type { DeleteNodeCallback, DeletableNodes } from '../src/core/config' function getFrameworkDir(framework: string) { const files = path.resolve( @@ -34,6 +35,181 @@ const testGroups: Array<{ ] describe('code-splitter delete nodes', () => { + it('deletes nested route option properties by dot path', () => { + const code = ` +import { createFileRoute } from '@tanstack/react-router' +import { serverOnly } from './server-only' +import { clientOnly } from './client-only' + +export const Route = createFileRoute('/')({ + context: { + handler: () => ({ value: 'context' }), + dehydrate({ data }) { + return serverOnly(data) + }, + hydrate: ({ data }) => clientOnly(data), + }, + beforeLoad: { + handler: () => ({ value: 'beforeLoad' }), + dehydrate: ({ data }) => serverOnly(data), + hydrate: ({ data }) => clientOnly(data), + }, + loader: { + handler: () => ({ value: 'loader' }), + dehydrate: ({ data }) => serverOnly(data), + hydrate: ({ data }) => clientOnly(data), + }, + component: () =>
hello world
, +}) +` + + const compileResult = compileCodeSplitReferenceRoute({ + code, + filename: 'route-lifecycle-object.tsx', + id: 'route-lifecycle-object.tsx', + addHmr: false, + codeSplitGroupings: [], + deleteNodes: new Set([ + 'context.dehydrate', + 'beforeLoad.dehydrate', + 'loader.dehydrate', + ]), + targetFramework: 'react', + }) + + const output = compileResult?.code || code + + expect(output).not.toMatch(/\bdehydrate\b/) + expect(output).not.toMatch(/\bserverOnly\b/) + expect(output).toMatch(/\bhydrate\b/) + expect(output).toMatch(/\bclientOnly\b/) + }) + + it('can delete the opposite nested route option properties', () => { + const code = ` +import { createFileRoute } from '@tanstack/react-router' +import { serverOnly } from './server-only' +import { clientOnly } from './client-only' + +export const Route = createFileRoute('/')({ + context: { + handler: () => ({ value: 'context' }), + dehydrate: ({ data }) => serverOnly(data), + revalidate({ prev }) { + return clientOnly(prev) + }, + hydrate({ data }) { + return clientOnly(data) + }, + }, + beforeLoad: { + handler: () => ({ value: 'beforeLoad' }), + dehydrate: ({ data }) => serverOnly(data), + hydrate: ({ data }) => clientOnly(data), + }, + loader: { + handler: () => ({ value: 'loader' }), + dehydrate: ({ data }) => serverOnly(data), + hydrate: ({ data }) => clientOnly(data), + }, + component: () =>
hello world
, +}) +` + + const compileResult = compileCodeSplitReferenceRoute({ + code, + filename: 'route-lifecycle-object.tsx', + id: 'route-lifecycle-object.tsx', + addHmr: false, + codeSplitGroupings: [], + deleteNodes: new Set([ + 'context.revalidate', + 'context.hydrate', + 'beforeLoad.hydrate', + 'loader.hydrate', + ]), + targetFramework: 'react', + }) + + const output = compileResult?.code || code + + expect(output).toMatch(/\bdehydrate\b/) + expect(output).toMatch(/\bserverOnly\b/) + expect(output).not.toMatch(/\brevalidate\b/) + expect(output).not.toMatch(/\bhydrate\b/) + expect(output).not.toMatch(/\bclientOnly\b/) + }) + + it('can apply delete node callbacks to route option properties', () => { + const replaceCustomDehydrateWithTrue: DeleteNodeCallback = ({ + dotPath, + prop, + key, + }) => { + if (!dotPath.endsWith('.dehydrate')) { + return + } + + if (t.isObjectProperty(prop) && t.isBooleanLiteral(prop.value)) { + return + } + + return { + action: 'replace', + node: t.objectProperty(t.identifier(key), t.booleanLiteral(true)), + } + } + + const code = ` +import { createFileRoute } from '@tanstack/react-router' +import { serverOnly } from './server-only' +import { clientOnly } from './client-only' + +export const Route = createFileRoute('/')({ + context: { + handler: () => ({ value: 'context' }), + dehydrate: true, + hydrate: ({ data }) => clientOnly(data), + }, + beforeLoad: { + handler: () => ({ value: 'beforeLoad' }), + dehydrate: false, + hydrate: ({ data }) => clientOnly(data), + }, + loader: { + handler: () => ({ value: 'loader' }), + dehydrate({ data }) { + return serverOnly(data) + }, + hydrate: ({ data }) => clientOnly(data), + }, +}) +` + + const compileResult = compileCodeSplitReferenceRoute({ + code, + filename: 'route-lifecycle-object.tsx', + id: 'route-lifecycle-object.tsx', + addHmr: false, + codeSplitGroupings: [], + deleteNodes: new Set([ + replaceCustomDehydrateWithTrue, + 'context.hydrate', + 'beforeLoad.hydrate', + 'loader.hydrate', + ]), + targetFramework: 'react', + }) + + const output = compileResult?.code || code + + expect(output).toMatch(/dehydrate:\s*true/) + expect(output).toMatch(/dehydrate:\s*false/) + expect(output).not.toMatch(/\bserverOnly\b/) + expect(output).not.toMatch(/\bhydrate\b/) + expect(output).not.toMatch(/\bclientOnly\b/) + }) + describe.each(frameworks)('FRAMEWORK=%s', (framework) => { describe.each(testGroups)( 'SPLIT_GROUP=$name', diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index afed9eae9be..1fa20811ca6 100644 --- a/packages/solid-router/src/fileRoute.ts +++ b/packages/solid-router/src/fileRoute.ts @@ -17,6 +17,7 @@ import type { AnyRouter, Constrain, ConstrainLiteral, + DefaultLifecycleDehydrateFn, FileBaseRouteOptions, FileRoutesByPath, LazyRouteOptions, @@ -75,7 +76,7 @@ export class FileRoute< TRegister = Register, TSearchValidator = undefined, TParams = ResolveParams, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -83,6 +84,9 @@ export class FileRoute< TSSR = unknown, const TMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: FileBaseRouteOptions< TRegister, @@ -94,24 +98,27 @@ export class FileRoute< TLoaderDeps, TLoaderFn, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, AnyContext, TSSR, TMiddlewares, - THandlers + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn > & UpdatableRouteOptions< - TParentRoute, - TId, - TFullPath, - TParams, - TSearchValidator, - TLoaderFn, - TLoaderDeps, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, AnyContext, - TRouteContextFn, - TBeforeLoadFn + NoInfer, + NoInfer >, ): Route< TRegister, @@ -123,7 +130,7 @@ export class FileRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -166,7 +173,7 @@ export function FileRouteLoader< TRoute['types']['params'], TRoute['types']['loaderDeps'], TRoute['types']['routerContext'], - TRoute['types']['routeContextFn'], + TRoute['types']['contextFn'], TRoute['types']['beforeLoadFn'] > >, diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 31be74086fd..146d315327f 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -64,7 +64,6 @@ export type { InferAllContext, LooseReturnType, LooseAsyncReturnType, - ContextReturnType, ContextAsyncReturnType, ResolveLoaderData, ResolveRouteContext, @@ -155,7 +154,8 @@ export type { MakeRouteMatchUnion, RouteMatch, AnyRouteMatch, - RouteContextFn, + ContextFn, + ContextFnOptions, RouteContextOptions, BeforeLoadContextOptions, ContextOptions, diff --git a/packages/solid-router/src/route.tsx b/packages/solid-router/src/route.tsx index da7e639a3ae..7ba89c42dc2 100644 --- a/packages/solid-router/src/route.tsx +++ b/packages/solid-router/src/route.tsx @@ -18,6 +18,7 @@ import type { AnyRoute, AnyRouter, ConstrainLiteral, + DefaultLifecycleDehydrateFn, ErrorComponentProps, NotFoundError, NotFoundRouteProps, @@ -48,6 +49,9 @@ import type * as Solid from 'solid-js' import type { UseRouteContextRoute } from './useRouteContext' import type { LinkComponentRoute } from './link' +type NormalizeRouteContext = [T] extends [never] ? AnyContext : T +type NormalizeRouteLoader = [T] extends [never] ? undefined : T + declare module '@tanstack/router-core' { export interface UpdatableRouteOptionsExtensions { component?: RouteComponent @@ -146,7 +150,7 @@ export class RouteApi< ) => { const router = useRouter() const fullPath = router.routesById[this.id as string].fullPath - return + return }) as LinkComponentRoute['fullPath']> } @@ -167,7 +171,7 @@ export class Route< in out TSearchValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TLoaderDeps extends Record = {}, in out TLoaderFn = undefined, @@ -187,7 +191,7 @@ export class Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -208,7 +212,7 @@ export class Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -235,7 +239,7 @@ export class Route< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, TMiddlewares, @@ -283,7 +287,7 @@ export class Route< } Link: LinkComponentRoute = ((props) => { - return + return }) as LinkComponentRoute } @@ -303,13 +307,17 @@ export function createRoute< >, TSearchValidator = undefined, TParams = ResolveParams, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, TSSR = unknown, + const TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options: RouteOptions< TRegister, @@ -323,10 +331,14 @@ export function createRoute< TLoaderDeps, TLoaderFn, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, - THandlers + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ): Route< TRegister, @@ -338,13 +350,14 @@ export function createRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, TChildren, unknown, TSSR, + TServerMiddlewares, THandlers > { return new Route< @@ -357,15 +370,16 @@ export function createRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, TChildren, unknown, TSSR, + TServerMiddlewares, THandlers - >(options) + >(options as any) } export type AnyRootRoute = RootRoute< @@ -378,43 +392,57 @@ export type AnyRootRoute = RootRoute< any, any, any, + any, + any, any > export function createRootRouteWithContext() { return < TRegister = Register, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, + const TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: RootRouteOptions< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, - THandlers + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ) => { return createRootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, - THandlers - >(options) + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn + >(options as any) } } @@ -427,26 +455,28 @@ export class RootRoute< in out TRegister = Register, in out TSearchValidator = undefined, in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TLoaderDeps extends Record = {}, in out TLoaderFn = undefined, in out TChildren = unknown, in out TFileRouteTypes = unknown, in out TSSR = unknown, + in out TServerMiddlewares = unknown, in out THandlers = undefined, > extends BaseRootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TFileRouteTypes, TSSR, + TServerMiddlewares, THandlers > implements @@ -454,13 +484,14 @@ export class RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TFileRouteTypes, TSSR, + TServerMiddlewares, THandlers > { @@ -472,11 +503,12 @@ export class RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, + TServerMiddlewares, THandlers >, ) { @@ -532,7 +564,7 @@ export function createRouteMask< >( opts: { routeTree: TRouteTree - } & ToMaskOptions, TFrom, TTo>, + } & ToMaskOptions, TFrom, TTo>, ): RouteMask { return opts as any } @@ -558,14 +590,14 @@ export class NotFoundRoute< TRegister, TParentRoute extends AnyRootRoute, TRouterContext = AnyContext, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, TSSR = unknown, - THandlers = undefined, + TServerMiddlewares = unknown, > extends Route< TRegister, TParentRoute, @@ -576,13 +608,14 @@ export class NotFoundRoute< TSearchValidator, {}, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, + unknown, TSSR, - THandlers + TServerMiddlewares > { constructor( options: Omit< @@ -598,10 +631,10 @@ export class NotFoundRoute< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, - THandlers + TServerMiddlewares >, | 'caseSensitive' | 'parseParams' @@ -622,48 +655,58 @@ export function createRootRoute< TRegister = Register, TSearchValidator = undefined, TRouterContext = {}, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, + const TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: RootRouteOptions< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, - THandlers + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ): RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, unknown, unknown, TSSR, + TServerMiddlewares, THandlers > { return new RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, unknown, unknown, TSSR, + TServerMiddlewares, THandlers - >(options) + >(options as any) } diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx index 4ee1abf3f1b..32b6c759427 100644 --- a/packages/solid-router/tests/errorComponent.test.tsx +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -160,3 +160,250 @@ describe.each([true, false])( ) }, ) + +describe('errorComponent is rendered when an Error is thrown in lifecycle methods', () => { + test('an Error thrown in `context` renders errorComponent on navigate', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: () => { + throw new Error('context error thrown') + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: context error thrown', + undefined, + { timeout: 1500 }, + ) + await expect(screen.findByText('About route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an Error thrown in `context` with invalidate renders errorComponent on navigate', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: () => { + throw new Error('context invalidate error thrown') + }, + revalidate: true, + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: context invalidate error thrown', + undefined, + { timeout: 1500 }, + ) + await expect(screen.findByText('About route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an Error thrown in `context` renders errorComponent on first load', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + throw new Error('context error thrown') + }, + component: function Home() { + return
Index route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const errorComponent = await screen.findByText( + 'Error: context error thrown', + undefined, + { timeout: 750 }, + ) + await expect(screen.findByText('Index route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an Error thrown in `context` with invalidate renders errorComponent on first load', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + throw new Error('context invalidate error thrown') + }, + revalidate: true, + }, + component: function Home() { + return
Index route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const errorComponent = await screen.findByText( + 'Error: context invalidate error thrown', + undefined, + { timeout: 750 }, + ) + await expect(screen.findByText('Index route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an async Error thrown in `context` renders errorComponent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + throw new Error('async context error') + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: async context error', + undefined, + { timeout: 1500 }, + ) + expect(errorComponent).toBeInTheDocument() + }) + + test('an async Error thrown in `context` with invalidate renders errorComponent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + throw new Error('async context invalidate error') + }, + revalidate: true, + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: async context invalidate error', + undefined, + { timeout: 1500 }, + ) + expect(errorComponent).toBeInTheDocument() + }) +}) diff --git a/packages/solid-router/tests/fileRoute.test-d.tsx b/packages/solid-router/tests/fileRoute.test-d.tsx index 6c32156b2d8..9c37b45ce05 100644 --- a/packages/solid-router/tests/fileRoute.test-d.tsx +++ b/packages/solid-router/tests/fileRoute.test-d.tsx @@ -1,24 +1,32 @@ import { expectTypeOf, test } from 'vitest' import { createFileRoute, createRootRoute } from '../src' -import type { Route } from '@tanstack/router-core' +import type { + AnyContext, + AnyRoute, + DefaultLifecycleDehydrateFn, + Route, +} from '@tanstack/router-core' declare module '@tanstack/router-core' { interface FilebaseRouteOptionsInterface< TRegister, - TParentRoute, - TId, - TPath, - TSearchValidator, - TParams, - TLoaderDeps, - TLoaderFn, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn, - TRemountDepsFn, - TSSR, - TServerMiddlewares, - THandlers, + TParentRoute extends AnyRoute = AnyRoute, + TId extends string = string, + TPath extends string = string, + TSearchValidator = undefined, + TParams = {}, + TLoaderDeps extends Record = {}, + TLoaderFn = undefined, + TRouterContext = {}, + TContextFn = AnyContext, + TBeforeLoadFn = AnyContext, + TRemountDepsFn = AnyContext, + TSSR = unknown, + TServerMiddlewares = unknown, + THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, > { server?: { middleware?: TServerMiddlewares @@ -26,24 +34,24 @@ declare module '@tanstack/router-core' { } interface RouteTypes< - TRegister, - TParentRoute, - TPath, - TFullPath, - TCustomId, - TId, - TSearchValidator, - TParams, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn, - TLoaderDeps, - TLoaderFn, - TChildren, - TFileRouteTypes, - TSSR, - TServerMiddlewares, - THandlers, + in out TRegister, + in out TParentRoute extends AnyRoute, + in out TPath extends string, + in out TFullPath extends string, + in out TCustomId extends string, + in out TId extends string, + in out TSearchValidator, + in out TParams, + in out TRouterContext, + in out TContextFn, + in out TBeforeLoadFn, + in out TLoaderDeps, + in out TLoaderFn, + in out TChildren, + in out TFileRouteTypes, + in out TSSR, + in out TServerMiddlewares, + in out THandlers, > { middleware: TServerMiddlewares } diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index 81bd1f6bc64..022a8e4ddb2 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -230,6 +230,145 @@ describe('redirect', () => { expect(nestedFooLoaderMock).toHaveBeenCalled() }) + test('when `redirect` is thrown in `context`', async () => { + const nestedLoaderMock = vi.fn() + const nestedFooLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/foo' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + nestedLoaderMock('nested') + }, + }) + const fooRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async () => { + await sleep(WAIT_TIME) + nestedFooLoaderMock('foo') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByText('link to about') + + expect(linkToAbout).toBeInTheDocument() + + fireEvent.click(linkToAbout) + + const fooElement = await screen.findByText('Nested Foo page') + + expect(fooElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/foo') + expect(window.location.pathname).toBe('/nested/foo') + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedFooLoaderMock).toHaveBeenCalled() + }) + + test('when `redirect` is thrown in `context` with invalidate', async () => { + const nestedLoaderMock = vi.fn() + const nestedFooLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/foo' }) + }, + revalidate: true, + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + nestedLoaderMock('nested') + }, + }) + const fooRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async () => { + await sleep(WAIT_TIME) + nestedFooLoaderMock('foo') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree }) + + render(() => ) + + const linkToAbout = await screen.findByText('link to about') + + expect(linkToAbout).toBeInTheDocument() + + fireEvent.click(linkToAbout) + + const fooElement = await screen.findByText('Nested Foo page') + + expect(fooElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/foo') + expect(window.location.pathname).toBe('/nested/foo') + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedFooLoaderMock).toHaveBeenCalled() + }) + test('when `redirect` is thrown in `loader` after `router.invalidate()`', async () => { let shouldRedirect = false @@ -354,6 +493,107 @@ describe('redirect', () => { statusCode: 307, }) }) + + test('when `redirect` is thrown in `context`', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + context: () => { + throw redirect({ + to: '/about', + }) + }, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => { + return 'About' + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + isServer: true, + history: createMemoryHistory({ + initialEntries: ['/'], + }), + }) + + await router.load() + + const stateRedirect = router.state.redirect + expect(stateRedirect).toBeDefined() + expect(stateRedirect).toBeInstanceOf(Response) + + expect(stateRedirect!.options).toEqual({ + _fromLocation: expect.objectContaining({ + hash: '', + href: '/', + pathname: '/', + search: {}, + searchStr: '', + }), + to: '/about', + href: '/about', + statusCode: 307, + }) + }) + + test('when `redirect` is thrown in `context` with invalidate', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + context: { + handler: () => { + throw redirect({ + to: '/about', + }) + }, + revalidate: true, + }, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => { + return 'About' + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + isServer: true, + history: createMemoryHistory({ + initialEntries: ['/'], + }), + }) + + await router.load() + + const stateRedirect = router.state.redirect + expect(stateRedirect).toBeDefined() + expect(stateRedirect).toBeInstanceOf(Response) + + expect(stateRedirect!.options).toEqual({ + _fromLocation: expect.objectContaining({ + hash: '', + href: '/', + pathname: '/', + search: {}, + searchStr: '', + }), + to: '/about', + href: '/about', + statusCode: 307, + }) + }) }) test('when `redirect` is thrown in `loader`', async () => { diff --git a/packages/solid-router/tests/route.test-d.tsx b/packages/solid-router/tests/route.test-d.tsx index f2dfcada866..bd091d20e2c 100644 --- a/packages/solid-router/tests/route.test-d.tsx +++ b/packages/solid-router/tests/route.test-d.tsx @@ -30,19 +30,19 @@ test('when creating the root', () => { expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() }) -test('when creating the root with routeContext', () => { +test('when creating the root with context', () => { const rootRoute = createRootRoute({ context: (opts) => { expectTypeOf(opts).toEqualTypeOf<{ abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: {} - deps: {} matches: Array routeId: '__root__' }>() @@ -78,6 +78,33 @@ test('when creating the root with beforeLoad', () => { expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() }) +test('when creating the root with context using object form with revalidate', () => { + const rootRoute = createRootRoute({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation<{}> + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + matches: Array + routeId: '__root__' + }>() + }, + revalidate: true, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() +}) + test('when creating the root with a loader', () => { const rootRoute = createRootRoute({ loader: (opts) => { @@ -101,7 +128,7 @@ test('when creating the root with a loader', () => { expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() }) -test('when creating the root route with context and routeContext', () => { +test('when creating the root route with context and context function', () => { const createRouteResult = createRootRouteWithContext<{ userId: string }>() const rootRoute = createRouteResult({ context: (opts) => { @@ -109,12 +136,12 @@ test('when creating the root route with context and routeContext', () => { abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '__root__' }>() @@ -186,6 +213,42 @@ test('when creating the root route with context and beforeLoad', () => { .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() }) +test('when creating the root route with context and context using object form with revalidate', () => { + const createRouteResult = createRootRouteWithContext<{ userId: string }>() + + const rootRoute = createRouteResult({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation<{}> + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + matches: Array + routeId: '__root__' + }>() + }, + revalidate: true, + }, + }) + + const router = createRouter({ + routeTree: rootRoute, + context: { userId: '123' }, + }) + + expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + userId: string + }> + >() +}) + test('when creating the root route with context and a loader', () => { const createRouteResult = createRootRouteWithContext<{ userId: string }>() @@ -228,7 +291,7 @@ test('when creating the root route with context and a loader', () => { .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() }) -test('when creating the root route with context, routeContext, beforeLoad and a loader', () => { +test('when creating the root route with context, context function, beforeLoad and a loader', () => { const createRouteResult = createRootRouteWithContext<{ userId: string }>() const rootRoute = createRouteResult({ @@ -237,12 +300,12 @@ test('when creating the root route with context, routeContext, beforeLoad and a abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '__root__' }>() @@ -353,7 +416,7 @@ test('when creating a child route from the root route with context', () => { .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() }) -test('when creating a child route with routeContext from the root route with context', () => { +test('when creating a child route with context from the root route with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() createRoute({ @@ -364,12 +427,12 @@ test('when creating a child route with routeContext from the root route with con abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices' }>() @@ -716,7 +779,7 @@ test('when creating a child route with params, search, loader and loaderDeps fro }) }) -test('when creating a child route with params, search with routeContext from the root route with context', () => { +test('when creating a child route with params, search with context from the root route with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() createRoute({ @@ -728,12 +791,12 @@ test('when creating a child route with params, search with routeContext from the abortController: AbortController preload: boolean params: { invoiceId: string } + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices/$invoiceId' }>() @@ -765,7 +828,7 @@ test('when creating a child route with params, search with beforeLoad from the r }) }) -test('when creating a child route with params, search with routeContext, beforeLoad and a loader from the root route with context', () => { +test('when creating a child route with params, search with context, beforeLoad and a loader from the root route with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() createRoute({ @@ -777,12 +840,12 @@ test('when creating a child route with params, search with routeContext, beforeL abortController: AbortController preload: boolean params: { invoiceId: string } + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices/$invoiceId' }>() @@ -896,7 +959,7 @@ test('when creating a child route with search from a parent with search', () => >() }) -test('when creating a child route with routeContext from a parent with routeContext', () => { +test('when creating a child route with context from a parent with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() const invoicesRoute = createRoute({ @@ -907,12 +970,12 @@ test('when creating a child route with routeContext from a parent with routeCont abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices' }>() @@ -929,12 +992,12 @@ test('when creating a child route with routeContext from a parent with routeCont abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string; invoiceId: string } - deps: {} matches: Array routeId: '/invoices/details' }>() @@ -1046,7 +1109,7 @@ test('when creating a child route with beforeLoad from a parent with beforeLoad' >() }) -test('when creating a child route with routeContext, beforeLoad, search, params, loaderDeps and loader', () => { +test('when creating a child route with context, beforeLoad, search, params, loaderDeps and loader', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() const invoicesRoute = createRoute({ @@ -1058,12 +1121,12 @@ test('when creating a child route with routeContext, beforeLoad, search, params, abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array routeId: '/invoices' }>() @@ -1101,6 +1164,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params, abortController: AbortController preload: boolean params: { invoiceId: string } + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn @@ -1110,7 +1174,6 @@ test('when creating a child route with routeContext, beforeLoad, search, params, env: string invoicePermissions: readonly ['view'] } - deps: {} matches: Array routeId: '/invoices/$invoiceId/details' }>() @@ -1151,6 +1214,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params, abortController: AbortController preload: boolean params: { invoiceId: string; detailId: string } + deps: { detailPage: number; invoicePage: number } location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn @@ -1162,7 +1226,6 @@ test('when creating a child route with routeContext, beforeLoad, search, params, detailEnv: string detailsPermissions: readonly ['view'] } - deps: { detailPage: number; invoicePage: number } matches: Array routeId: '/invoices/$invoiceId/details/$detailId' }>() @@ -1607,7 +1670,7 @@ test('when creating a child route with params.parse and params.stringify with me >() }) -test('when routeContext throws', () => { +test('when context throws', () => { const rootRoute = createRootRoute() const invoicesRoute = createRoute({ getParentRoute: () => rootRoute, @@ -1947,3 +2010,749 @@ test('when creating a route with escaped path param', () => { Accessor<{}> >() }) + +// --------------------------------------------------------------------------- +// Object form lifecycle methods — type-level tests +// --------------------------------------------------------------------------- + +test('object form context is accepted on root route', () => { + const rootRoute = createRootRoute({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + matches: Array + routeId: '__root__' + }>() + return { env: 'production' } + }, + dehydrate: false, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('object form beforeLoad is accepted on root route', () => { + const rootRoute = createRootRoute({ + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + search: {} + matches: Array + routeId: '__root__' + }>() + return { perm: 'admin' } + }, + dehydrate: true, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('object form context with revalidate is accepted on root route', () => { + const rootRoute = createRootRoute({ + context: { + handler: (_opts) => { + // Root route context handler compiles — vitest typecheck resolves search + // differently than tsc for the root route, so we only verify + // that the handler accepts and returns the correct types + return { cache: 'initialized' } + }, + revalidate: true, + dehydrate: false, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('object form loader is accepted on child route', () => { + const rootRoute = createRootRoute() + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + loader: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + context: {} + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise> + cause: 'preload' | 'enter' | 'stay' + route: AnyRoute + }>() + return { data: 'loaded' } + }, + dehydrate: true, + }, + }) + + expectTypeOf(childRoute.fullPath).toEqualTypeOf<'/child'>() +}) + +test('object form context context flows into beforeLoad handler context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { + handler: () => ({ env: 'production' }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + // beforeLoad should see context function context + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'admin' } + }, + }, + }) +}) + +test('object form context flows into beforeLoad', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { + handler: () => ({ env: 'production' }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + // beforeLoad should see context's return + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'admin' as const } + }, + dehydrate: true, + }, + }) +}) + +test('object form full context chain: context -> beforeLoad -> loader', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { + handler: () => ({ env: 'prod' }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'view' as const } + }, + dehydrate: true, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + perm: 'view' + }>() + return { items: ['a', 'b'] } + }, + dehydrate: true, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + context: { userId: '123' }, + }) + + expectTypeOf(invoicesRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + userId: string + env: string + perm: 'view' + }> + >() +}) + +test('mixed function and object form on the same route', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + // function form for context + context: () => ({ env: 'staging' }), + // object form for beforeLoad + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'edit' as const } + }, + dehydrate: false, + }, + // object form for loader + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + perm: 'edit' + }>() + return { data: [1, 2, 3] } + }, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + context: { userId: '123' }, + }) + + expectTypeOf(invoicesRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + userId: string + env: string + perm: 'edit' + }> + >() +}) + +test('object form parent-child context propagation', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'parent', + context: { + handler: () => ({ parentEnv: 'env1' }), + dehydrate: true, + }, + beforeLoad: { + handler: () => ({ parentPerm: 'admin' as const }), + dehydrate: false, + }, + }) + + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: 'child', + context: { + handler: (opts) => { + // child's context sees parent's full allContext (context + beforeLoad) + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + }>() + return { childEnv: 'env2' } + }, + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + childEnv: string + }>() + return { childPerm: 'viewer' as const } + }, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + childEnv: string + childPerm: 'viewer' + }>() + return { items: [1, 2] } + }, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + context: { userId: '123' }, + }) + + expectTypeOf(parentRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + userId: string + parentEnv: string + parentPerm: 'admin' + }> + >() + + expectTypeOf(childRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + userId: string + parentEnv: string + parentPerm: 'admin' + childEnv: string + childPerm: 'viewer' + }> + >() +}) + +test('object form without dehydrate: full context chain with useRouteContext and useLoaderData', () => { + const rootRoute = createRootRouteWithContext<{ appId: string }>()() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ appId: string }>() + return { env: 'test' } + }, + // no dehydrate specified + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + appId: string + env: string + }>() + return { perm: 'view' as const } + }, + // no dehydrate specified + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + appId: string + env: string + perm: 'view' + }>() + return { data: [1, 2, 3] } + }, + // no dehydrate specified + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + context: { appId: 'app1' }, + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + appId: string + env: string + perm: 'view' + }> + >() + + expectTypeOf(testRoute.useLoaderData()).toEqualTypeOf< + Accessor<{ + data: Array + }> + >() +}) + +test('object form non-serializable returns flow into context chain', () => { + const rootRoute = createRootRoute() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => ({ cleanup: () => console.log('cleanup') }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + // beforeLoad sees context's non-serializable return in context + expectTypeOf(opts.context).toEqualTypeOf<{ + cleanup: () => void + }>() + return { compute: (x: number) => x * 2 } + }, + dehydrate: false, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + cleanup: () => void + compute: (x: number) => number + }>() + return { items: ['a'] } + }, + dehydrate: false, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + cleanup: () => void + compute: (x: number) => number + }> + >() +}) + +test('object form with params and search', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: () => ({ page: 0 }), + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + matches: Array + routeId: '/invoices' + }>() + return { invoiceEnv: 'prod' } + }, + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; invoiceEnv: string } + search: { page: number } + matches: Array + routeId: '/invoices' + }>() + return { invoicePermissions: ['view'] as const } + }, + }, + }) + + const invoiceRoute = createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + loaderDeps: (deps) => ({ + currentPage: deps.search.page, + }), + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + deps: { currentPage: number } + context: { + userId: string + invoiceEnv: string + invoicePermissions: readonly ['view'] + } + matches: Array + routeId: '/invoices/$invoiceId' + }>() + return { detailEnv: 'staging' } + }, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.params).toEqualTypeOf<{ invoiceId: string }>() + expectTypeOf(opts.deps).toEqualTypeOf<{ currentPage: number }>() + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + invoiceEnv: string + invoicePermissions: readonly ['view'] + detailEnv: string + }>() + return { invoice: { id: 'inv1', amount: 100 } } + }, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + ]), + context: { userId: '123' }, + }) + + expectTypeOf(invoiceRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + userId: string + invoiceEnv: string + invoicePermissions: readonly ['view'] + detailEnv: string + }> + >() + + expectTypeOf(invoiceRoute.useLoaderData()).toEqualTypeOf< + Accessor<{ + invoice: { id: string; amount: number } + }> + >() + + expectTypeOf(invoiceRoute.useParams()).toEqualTypeOf< + Accessor<{ + invoiceId: string + }> + >() +}) + +test('object form useLoaderData with select and structuralSharing', () => { + const rootRoute = createRootRoute() + + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + loader: { + handler: () => + ({ items: ['a', 'b'], count: 2 }) as const satisfies { + items: ReadonlyArray + count: number + }, + }, + }) + + const routeTree = rootRoute.addChildren([childRoute]) + const router = createRouter({ routeTree }) + + expectTypeOf(childRoute.useLoaderData()).toEqualTypeOf< + Accessor<{ + readonly items: readonly ['a', 'b'] + readonly count: 2 + }> + >() + + expectTypeOf(childRoute.useLoaderData) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { + readonly items: readonly ['a', 'b'] + readonly count: 2 + }) => string) + | undefined + >() +}) + +test('object form useRouteContext with select', () => { + const rootRoute = createRootRouteWithContext<{ appId: string }>()() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => ({ env: 'prod' }), + }, + beforeLoad: { + handler: () => ({ perm: 'admin' as const }), + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + context: { appId: 'app1' }, + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + appId: string + env: string + perm: 'admin' + }> + >() + + expectTypeOf(testRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((context: { appId: string; env: string; perm: 'admin' }) => unknown) + | undefined + >() +}) + +test('object form onEnter, onStay, onLeave match types', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + beforeLoad: { handler: () => ({ invoicePermissions: ['view'] as const }) }, + }) + + type TExpectedParams = {} + type TExpectedSearch = { page: number } + type TExpectedContext = { + userId: string + invoicePermissions: readonly ['view'] + } + type TExpectedLoaderData = { totalInvoices: number } + type TExpectedMatch = { + params: TExpectedParams + search: TExpectedSearch + context: TExpectedContext + loaderDeps: {} + beforeLoadPromise?: ControlledPromise + loaderPromise?: ControlledPromise + componentsPromise?: Promise> + loaderData?: TExpectedLoaderData + } + + createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + context: { handler: () => ({ detailPermission: true }) }, + loader: { handler: () => ({ totalInvoices: 42 }) }, + onEnter: (match) => expectTypeOf(match).toMatchTypeOf(), + onStay: (match) => expectTypeOf(match).toMatchTypeOf(), + onLeave: (match) => expectTypeOf(match).toMatchTypeOf(), + }) +}) + +test('object form void-returning context does not add to context', () => { + const rootRoute = createRootRouteWithContext<{ appId: string }>()() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => {}, + }, + beforeLoad: { + handler: () => ({ perm: 'admin' as const }), + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + context: { appId: 'app1' }, + }) + + // void context should not add anything — useRouteContext shows only root + beforeLoad + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf< + Accessor<{ + appId: string + perm: 'admin' + }> + >() +}) + +test('three-level object form context accumulation', () => { + const rootRoute = createRootRouteWithContext<{ rootCtx: string }>()() + + const level1 = createRoute({ + getParentRoute: () => rootRoute, + path: 'l1', + context: { handler: () => ({ l1Match: 'a' }) }, + beforeLoad: { handler: () => ({ l1Before: 'b' }) }, + }) + + const level2 = createRoute({ + getParentRoute: () => level1, + path: 'l2', + context: { handler: () => ({ l2Match: 'd' }) }, + beforeLoad: { handler: () => ({ l2Before: 'e' }) }, + }) + + const level3 = createRoute({ + getParentRoute: () => level2, + path: 'l3', + context: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + rootCtx: string + l1Match: string + l1Before: string + l2Match: string + l2Before: string + }>() + return { l3Match: 'g' } + }, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + rootCtx: string + l1Match: string + l1Before: string + l2Match: string + l2Before: string + l3Match: string + }>() + return { data: 'final' } + }, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + level1.addChildren([level2.addChildren([level3])]), + ]), + context: { rootCtx: 'root' }, + }) + + expectTypeOf(level3.useRouteContext()).toEqualTypeOf< + Accessor<{ + rootCtx: string + l1Match: string + l1Before: string + l2Match: string + l2Before: string + l3Match: string + }> + >() +}) diff --git a/packages/solid-router/tests/routeContext.test.tsx b/packages/solid-router/tests/routeContext.test.tsx index b7e0a902a91..5068bb96c50 100644 --- a/packages/solid-router/tests/routeContext.test.tsx +++ b/packages/solid-router/tests/routeContext.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@solidjs/testing-library' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { z } from 'zod' @@ -144,8 +150,11 @@ describe('context function', () => { }), path: '/', loaderDeps: ({ search }) => ({ foo: search.foo }), - context: ({ deps }) => { - mockContextFn(deps) + context: { + handler: () => { + mockContextFn() + }, + revalidate: true, }, component: () => { const navigate = indexRoute.useNavigate() @@ -202,13 +211,11 @@ describe('context function', () => { await findByText(`search: ${JSON.stringify({})}`) expect(mockContextFn).toHaveBeenCalledOnce() - expect(mockContextFn).toHaveBeenCalledWith({}) mockContextFn.mockClear() await clickButton('foo-1') await findByText(`search: ${JSON.stringify({ foo: 'foo-1' })}`) expect(mockContextFn).toHaveBeenCalledOnce() - expect(mockContextFn).toHaveBeenCalledWith({ foo: 'foo-1' }) mockContextFn.mockClear() await clickButton('foo-1') @@ -225,7 +232,7 @@ describe('context function', () => { await findByText( `search: ${JSON.stringify({ foo: 'foo-2', bar: 'bar-1' })}`, ) - expect(mockContextFn).toHaveBeenCalledWith({ foo: 'foo-2' }) + expect(mockContextFn).toHaveBeenCalledOnce() mockContextFn.mockClear() await clickButton('bar-2') @@ -236,8 +243,9 @@ describe('context function', () => { await clickButton('clear') await findByText(`search: ${JSON.stringify({})}`) - expect(mockContextFn).toHaveBeenCalledOnce() - expect(mockContextFn).toHaveBeenCalledWith({}) + // context with revalidate does NOT re-run: the cached match (from the initial load with + // the same loaderDeps hash) is restored and the context is already consumed. + expect(mockContextFn).not.toHaveBeenCalled() }) }) @@ -3173,3 +3181,1457 @@ describe('useRouteContext in the component', () => { expect(content).toBeInTheDocument() }) }) + +describe('lifecycle method semantics', () => { + describe('execution order guarantees', () => { + test('parent serial phases complete before child serial phases (parent context → beforeLoad → child context → beforeLoad)', async () => { + const executionOrder: Array = [] + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: async () => { + executionOrder.push('parent-context-start') + await sleep(WAIT_TIME) + executionOrder.push('parent-context-end') + return { parentContext: true } + }, + beforeLoad: async () => { + executionOrder.push('parent-beforeLoad-start') + await sleep(WAIT_TIME) + executionOrder.push('parent-beforeLoad-end') + return { parentBeforeLoad: true } + }, + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: async () => { + executionOrder.push('child-context-start') + await sleep(WAIT_TIME) + executionOrder.push('child-context-end') + return { childContext: true } + }, + beforeLoad: async () => { + executionOrder.push('child-beforeLoad-start') + await sleep(WAIT_TIME) + executionOrder.push('child-beforeLoad-end') + return { childBeforeLoad: true } + }, + component: () =>
Child page
, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + render(() => ) + await screen.findByTestId('index-page') + + // Clear any entries from initial load + executionOrder.length = 0 + + fireEvent.click(screen.getByTestId('go-parent-child')) + await screen.findByTestId('child-page') + + expect(executionOrder).toEqual([ + 'parent-context-start', + 'parent-context-end', + 'parent-beforeLoad-start', + 'parent-beforeLoad-end', + 'child-context-start', + 'child-context-end', + 'child-beforeLoad-start', + 'child-beforeLoad-end', + ]) + }) + + test('all serial phases complete before loaders fire', async () => { + const executionOrder: Array = [] + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => { + executionOrder.push('parent-context') + return {} + }, + beforeLoad: () => { + executionOrder.push('parent-beforeLoad') + return {} + }, + loader: () => { + executionOrder.push('parent-loader') + }, + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: () => { + executionOrder.push('child-context') + return {} + }, + beforeLoad: () => { + executionOrder.push('child-beforeLoad') + return {} + }, + loader: () => { + executionOrder.push('child-loader') + }, + component: () =>
Child page
, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + render(() => ) + await screen.findByTestId('index-page') + + // Clear any entries from initial load + executionOrder.length = 0 + + fireEvent.click(screen.getByTestId('go-parent-child')) + await screen.findByTestId('child-page') + + // All serial phases (context, beforeLoad) must come before any loader + const loaderIndices = executionOrder + .map((entry, i) => (entry.includes('loader') ? i : -1)) + .filter((i) => i >= 0) + const serialIndices = executionOrder + .map((entry, i) => (!entry.includes('loader') ? i : -1)) + .filter((i) => i >= 0) + + const lastSerial = Math.max(...serialIndices) + const firstLoader = Math.min(...loaderIndices) + expect(lastSerial).toBeLessThan(firstLoader) + }) + }) + + describe('context edge cases', () => { + test('context on root route fires exactly once and never again on navigation', async () => { + const mockRootContext = vi.fn() + + const rootRoute = createRootRoute({ + context: () => { + mockRootContext() + return { rootMatched: true } + }, + component: () => ( +
+ Root +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+ Other + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('index-page') + expect(mockRootContext).toHaveBeenCalledTimes(1) + mockRootContext.mockClear() + + // Navigate to other + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + expect(mockRootContext).not.toHaveBeenCalled() + + // Navigate back + fireEvent.click(await screen.findByTestId('go-index')) + await screen.findByTestId('index-page') + expect(mockRootContext).not.toHaveBeenCalled() + }) + + test('context returning undefined does not clobber parent context', async () => { + const rootRoute = createRootRoute({ + beforeLoad: () => ({ rootValue: 'from-root' }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return undefined + }, + beforeLoad: ({ context }) => { + return { sawRootValue: context.rootValue } + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + rootValue: 'from-root', + sawRootValue: 'from-root', + }), + ) + }) + + test('context receives cause "enter" on fresh match creation', async () => { + const receivedCause = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + context: ({ cause }) => { + receivedCause(cause) + }, + component: () =>
Other
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('index-page') + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + expect(receivedCause).toHaveBeenCalledTimes(1) + expect(receivedCause).toHaveBeenCalledWith('enter') + }) + + test('context receives correct params', async () => { + const receivedParams = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const userRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/user/$userId', + context: ({ params }) => { + receivedParams(params) + return { userId: params.userId } + }, + component: () => { + const context = userRoute.useRouteContext() + return ( +
{JSON.stringify(context())}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, userRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('index-page') + + fireEvent.click(await screen.findByTestId('go-user')) + const contextEl = await screen.findByTestId('user-context') + const context = JSON.parse(contextEl.textContent) + + expect(receivedParams).toHaveBeenCalledWith({ userId: '42' }) + expect(context).toEqual(expect.objectContaining({ userId: '42' })) + }) + }) + + describe('context with revalidate edge cases', () => { + test('context with revalidate re-runs when loaderDeps change (new matchId)', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: z.object({ + page: z.number().optional(), + }), + loaderDeps: ({ search }) => ({ page: search.page }), + context: { + handler: () => { + mockContext() + return { loadedPage: undefined } + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + const search = indexRoute.useSearch() + return ( +
+ {JSON.stringify(search())} + + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await waitFor(() => { + expect(screen.getByTestId('search')).toBeInTheDocument() + }) + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-page-1')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ page: 1 }), + ) + }) + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-page-2')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ page: 2 }), + ) + }) + expect(mockContext).toHaveBeenCalledTimes(1) + }) + + test('context with revalidate re-runs after GC', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+ Other + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history, defaultGcTime: 0 }) + + render(() => ) + + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + fireEvent.click(await screen.findByTestId('go-index')) + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + }) + + test('context returning undefined does not clobber context from beforeLoad', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { fromContext: 'match-val' } + }, + beforeLoad: () => { + return { fromBeforeLoad: 'bl-val' } + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + fromContext: 'match-val', + fromBeforeLoad: 'bl-val', + }), + ) + }) + + test('context with revalidate receives correct deps (loaderDeps)', async () => { + const receivedDeps = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: z.object({ sort: z.string().optional() }), + loaderDeps: ({ search }) => ({ sort: search.sort }), + context: { + handler: () => { + receivedDeps() + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('index-page') + expect(receivedDeps).toHaveBeenCalledTimes(1) + receivedDeps.mockClear() + + fireEvent.click(await screen.findByTestId('set-sort')) + await waitFor(() => { + expect(receivedDeps).toHaveBeenCalledTimes(1) + }) + }) + + test('context with revalidate receives cause "enter" on fresh navigation', async () => { + const receivedCause = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + context: { + handler: (opts: { cause: string }) => { + const { cause } = opts + receivedCause(cause) + }, + revalidate: true, + }, + component: () =>
Other
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('index-page') + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + expect(receivedCause).toHaveBeenCalledTimes(1) + expect(receivedCause).toHaveBeenCalledWith('enter') + }) + }) + + describe('context visibility per callback', () => { + test('beforeLoad sees context return from same route', async () => { + const beforeLoadContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { fromContext: 'visible' } + }, + beforeLoad: ({ context }) => { + beforeLoadContext(context) + return { fromBeforeLoad: 'bl' } + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('index-page') + + expect(beforeLoadContext).toHaveBeenCalledWith( + expect.objectContaining({ fromContext: 'visible' }), + ) + }) + + test('loader sees context + beforeLoad from same route', async () => { + const loaderContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { fromContext: 'ctx' } + }, + beforeLoad: () => { + return { fromBeforeLoad: 'bl' } + }, + loader: ({ context }) => { + loaderContext(context) + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('index-page') + + expect(loaderContext).toHaveBeenCalledWith( + expect.objectContaining({ + fromContext: 'ctx', + fromBeforeLoad: 'bl', + }), + ) + }) + + test('context does NOT see same-route beforeLoad (only parent full context)', async () => { + const childContextCallback = vi.fn() + + const rootRoute = createRootRoute() + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ parentContext: 'pctx' }), + beforeLoad: () => ({ parentBeforeLoad: 'pbl' }), + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: ({ context }) => { + childContextCallback(context) + return { childContext: 'cctx' } + }, + beforeLoad: () => ({ childBeforeLoad: 'cbl' }), + component: () =>
Child
, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await router.navigate({ to: '/parent/child' }) + + render(() => ) + + await screen.findByTestId('child-page') + + expect(childContextCallback).toHaveBeenCalledWith( + expect.objectContaining({ + parentContext: 'pctx', + parentBeforeLoad: 'pbl', + }), + ) + const calledWith = childContextCallback.mock.calls[0]![0] + expect(calledWith).not.toHaveProperty('childBeforeLoad') + expect(calledWith).not.toHaveProperty('childContext') + }) + }) + + describe('parent-child selective GC', () => { + test('when child match is GC-ed but parent is not, only child context re-runs', async () => { + const parentContext = vi.fn() + const childContext = vi.fn() + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => { + parentContext() + return { parentMatched: true } + }, + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + gcTime: 0, + context: () => { + childContext() + return { childMatched: true } + }, + component: () => { + const navigate = childRoute.useNavigate() + return ( +
+ Child + +
+ ) + }, + }) + const parentIndexRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/', + component: () => { + const navigate = parentIndexRoute.useNavigate() + return ( +
+ Parent Index + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute, parentIndexRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await router.navigate({ to: '/parent/child' }) + + render(() => ) + + await screen.findByTestId('child-page') + expect(parentContext).toHaveBeenCalledTimes(1) + expect(childContext).toHaveBeenCalledTimes(1) + parentContext.mockClear() + childContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-parent-only')) + await screen.findByTestId('parent-index-page') + + expect(parentContext).not.toHaveBeenCalled() + + fireEvent.click(await screen.findByTestId('go-child')) + await screen.findByTestId('child-page') + + expect(parentContext).not.toHaveBeenCalled() + + expect(childContext).toHaveBeenCalledTimes(1) + }) + }) + + describe('context-with-revalidate-only routes (no loader)', () => { + test('route with only context (revalidate) is GC-protected and works correctly', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + return { contextValue: 'from-context' } + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + const context = indexRoute.useRouteContext() + return ( +
+ {JSON.stringify(context())} + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+ Other + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ contextValue: 'from-context' }), + ) + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + fireEvent.click(await screen.findByTestId('go-index')) + const contextEl2 = await screen.findByTestId('context') + expect(JSON.parse(contextEl2.textContent)).toEqual( + expect.objectContaining({ contextValue: 'from-context' }), + ) + expect(mockContext).not.toHaveBeenCalled() + }) + + test('route with only context (revalidate) re-runs on invalidate', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + return { contextRun: mockContext.mock.calls.length } + }, + revalidate: true, + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + await router.invalidate() + + expect(mockContext).toHaveBeenCalledTimes(1) + }) + }) + + describe('context updates on invalidation', () => { + test('context from context with revalidate updates after each invalidation', async () => { + let counter = 0 + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + counter++ + return { counter } + }, + revalidate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await waitFor(() => { + const el = screen.getByTestId('context') + expect(JSON.parse(el.textContent).counter).toBe(1) + }) + + await router.invalidate() + + await waitFor(() => { + const el = screen.getByTestId('context') + expect(JSON.parse(el.textContent).counter).toBe(2) + }) + + await router.invalidate() + + await waitFor(() => { + const el = screen.getByTestId('context') + expect(JSON.parse(el.textContent).counter).toBe(3) + }) + }) + }) + + describe('context-only routes', () => { + test('route with only context provides context to component', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { onlyContext: 'value' } + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ onlyContext: 'value' }), + ) + }) + }) + + describe('context overriding between lifecycle methods', () => { + test('later lifecycle methods can override earlier context keys', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { shared: 'from-context', contextOnly: 'ctx' } + }, + beforeLoad: () => { + return { shared: 'from-beforeLoad', beforeLoadOnly: 'bl' } + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + + expect(context.shared).toBe('from-beforeLoad') + expect(context.contextOnly).toBe('ctx') + expect(context.beforeLoadOnly).toBe('bl') + }) + }) + + describe('object form lifecycle methods', () => { + test('object form beforeLoad handler runs and provides context', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: { + handler: () => ({ blValue: 'from-object-form' }), + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ blValue: 'from-object-form' }), + ) + }) + + test('object form context handler runs and provides context', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => ({ omValue: 'from-object-form' }), + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ omValue: 'from-object-form' }), + ) + }) + + test('object form context with revalidate handler runs and provides context', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => ({ ctxValue: 'from-object-form' }), + revalidate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ ctxValue: 'from-object-form' }), + ) + }) + + test('object form loader handler runs and provides loaderData', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: { + handler: () => ({ ldValue: 'from-object-form' }), + }, + component: () => { + const data = indexRoute.useLoaderData() + return
{JSON.stringify(data())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const dataEl = await screen.findByTestId('loader-data') + expect(JSON.parse(dataEl.textContent)).toEqual({ + ldValue: 'from-object-form', + }) + }) + + test('mixed function and object form on the same route', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => ({ ctxFunc: 'function-form' }), + beforeLoad: { + handler: () => ({ blObj: 'object-form' }), + }, + loader: () => ({ ldFunc: 'function-form' }), + component: () => { + const context = indexRoute.useRouteContext() + const data = indexRoute.useLoaderData() + return ( +
+ {JSON.stringify(context())} + {JSON.stringify(data())} +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + ctxFunc: 'function-form', + blObj: 'object-form', + }), + ) + + const dataEl = screen.getByTestId('loader-data') + expect(JSON.parse(dataEl.textContent)).toEqual({ + ldFunc: 'function-form', + }) + }) + + test('object form with dehydrate flag still runs handler on client navigation', async () => { + const contextHandler = vi.fn(() => ({ ctxVal: 'matched' })) + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: contextHandler, + dehydrate: true, + }, + component: () => { + const context = aboutRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + await screen.findByTestId('index-page') + + fireEvent.click(screen.getByTestId('go-about')) + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ ctxVal: 'matched' }), + ) + expect(contextHandler).toHaveBeenCalledTimes(1) + }) + + test('object form backward compat: function form still works identically', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => ({ ctxFn: 'fn' }), + beforeLoad: () => ({ blFn: 'fn' }), + loader: () => ({ ldFn: 'fn' }), + component: () => { + const context = indexRoute.useRouteContext() + const data = indexRoute.useLoaderData() + return ( +
+ {JSON.stringify(context())} + {JSON.stringify(data())} +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + ctxFn: 'fn', + blFn: 'fn', + }), + ) + + const dataEl = screen.getByTestId('loader-data') + expect(JSON.parse(dataEl.textContent)).toEqual({ ldFn: 'fn' }) + }) + + test('object form context chain flows correctly parent to child', async () => { + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: { + handler: () => ({ parentOM: 'p-om' }), + }, + beforeLoad: { + handler: () => ({ parentBL: 'p-bl' }), + dehydrate: false, + }, + component: () => ( +
+ +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: { + handler: (opts: { context: any }) => { + const { context } = opts + return { + childCtx: 'c-ctx', + sawParentOM: context.parentOM, + sawParentBL: context.parentBL, + } + }, + }, + component: () => { + const context = childRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await router.navigate({ to: '/parent/child' }) + + render(() => ) + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + parentOM: 'p-om', + parentBL: 'p-bl', + childCtx: 'c-ctx', + sawParentOM: 'p-om', + sawParentBL: 'p-bl', + }), + ) + }) + + test('object form context runs only once even with dehydrate flag', async () => { + const contextCount = vi.fn() + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + contextCount() + return { matched: true } + }, + dehydrate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return
{JSON.stringify(context())}
+ }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('context') + expect(contextCount).toHaveBeenCalledTimes(1) + + // Invalidate and verify context doesn't re-run + await router.invalidate() + + expect(contextCount).toHaveBeenCalledTimes(1) + }) + + test('object form context with revalidate re-runs on invalidation', async () => { + const contextCount = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + contextCount() + return { runCount: contextCount.mock.calls.length } + }, + revalidate: true, + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render(() => ) + + await screen.findByTestId('index-page') + expect(contextCount).toHaveBeenCalledTimes(1) + + await router.invalidate() + + // context with revalidate should re-run on invalidation + expect(contextCount).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/packages/solid-router/tests/useRouteContext.test-d.tsx b/packages/solid-router/tests/useRouteContext.test-d.tsx index 93b84b2930c..0054e6845a5 100644 --- a/packages/solid-router/tests/useRouteContext.test-d.tsx +++ b/packages/solid-router/tests/useRouteContext.test-d.tsx @@ -218,6 +218,357 @@ test('when there are multiple contexts', () => { >() }) +test('when context returns context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ invoicePermissions: true }), + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + ]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId: string + invoicePermissions: boolean + }> + >() + + // child inherits parent context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId: string + invoicePermissions: boolean + }> + >() +}) + +test('when context with revalidate returns context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { handler: () => ({ invoiceList: [1, 2, 3] }), revalidate: true }, + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + ]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId: string + invoiceList: Array + }> + >() + + // child inherits parent context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId: string + invoiceList: Array + }> + >() +}) + +test('when context + beforeLoad all return context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ fromContext: 'match-data' }), + beforeLoad: () => ({ fromBeforeLoad: 'before-data' }), + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId: string + fromContext: string + fromBeforeLoad: string + }> + >() +}) + +test('when child route sees parent context + beforeLoad context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'parent', + context: () => ({ parentContext: 'match' }), + beforeLoad: () => ({ parentBeforeLoad: 'before' }), + }) + + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: 'child', + context: () => ({ childContext: 'child-match' }), + beforeLoad: () => ({ childBeforeLoad: 'child-before' }), + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // parent only has its own context + expectTypeOf(useRouteContext).returns.toEqualTypeOf< + Accessor<{ + userId: string + parentContext: string + parentBeforeLoad: string + }> + >() + + // child inherits all parent context plus its own + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId: string + parentContext: string + parentBeforeLoad: string + childContext: string + childBeforeLoad: string + }> + >() +}) + +test('when context uses as const return type', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ status: 'active' }) as const, + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId: string + readonly status: 'active' + }> + >() +}) + +test('when overlapping keys across context and beforeLoad', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ shared: 'from-context' }) as const, + beforeLoad: () => ({ shared: 'from-beforeLoad' }) as const, + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // beforeLoad wins because it's the last Assign in the chain + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId: string + readonly shared: 'from-beforeLoad' + }> + >() +}) + +test('when non-strict mode with context across routes', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ invoiceData: 'data' }), + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + context: { handler: () => ({ postData: 'data' }), revalidate: true }, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + invoicesRoute, + postsRoute, + ]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // non-strict mode unions all possible context shapes + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId?: string + invoiceData?: string + postData?: string + }> + >() +}) + +test('when root route has context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()({ + context: () => ({ rootContext: 'root-match' }), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ invoiceContext: 'inv-match' }), + }) + + const routeTree = rootRoute.addChildren([indexRoute, invoicesRoute]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // index route inherits root context + expectTypeOf(useRouteContext).returns.toEqualTypeOf< + Accessor<{ + userId: string + rootContext: string + }> + >() + + // invoices route has root + its own context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Accessor<{ + userId: string + rootContext: string + invoiceContext: string + }> + >() +}) + test('when there are overlapping contexts', () => { interface Context { userId: string diff --git a/packages/start-client-core/src/client/hydrateStart.ts b/packages/start-client-core/src/client/hydrateStart.ts index 83b9c468afb..8de2537c6bc 100644 --- a/packages/start-client-core/src/client/hydrateStart.ts +++ b/packages/start-client-core/src/client/hydrateStart.ts @@ -32,6 +32,7 @@ async function hydrateStart(): Promise { window.__TSS_START_OPTIONS__ = startOptions as AnyStartInstanceOptions serializationAdapters = startOptions.serializationAdapters router.options.defaultSsr = startOptions.defaultSsr + router.options.defaultDehydrate = startOptions.defaultDehydrate } else { serializationAdapters = [] window.__TSS_START_OPTIONS__ = { diff --git a/packages/start-client-core/src/createMiddleware.ts b/packages/start-client-core/src/createMiddleware.ts index 8f4ff474fc6..6ab2e727116 100644 --- a/packages/start-client-core/src/createMiddleware.ts +++ b/packages/start-client-core/src/createMiddleware.ts @@ -314,7 +314,7 @@ export type GlobalFetchRequestContext = Register extends { : AnyContext export type GlobalServerRequestContext = TRegister extends { - config: StartInstanceOptions + config: StartInstanceOptions } ? AssignAllMiddleware : AnyContext @@ -351,7 +351,7 @@ export type AssignAllServerFnContext< > type GlobalServerFnContext = TRegister extends { - config: StartInstanceOptions + config: StartInstanceOptions } ? AssignAllMiddleware : AnyContext diff --git a/packages/start-client-core/src/createStart.ts b/packages/start-client-core/src/createStart.ts index e59adb452b3..5da2d923414 100644 --- a/packages/start-client-core/src/createStart.ts +++ b/packages/start-client-core/src/createStart.ts @@ -8,6 +8,7 @@ import type { import type { CustomFetch } from './createServerFn' import type { AnySerializationAdapter, + DefaultDehydrateConfig, Register, SSROption, } from '@tanstack/router-core' @@ -15,17 +16,20 @@ import type { export interface StartInstanceOptions< in out TSerializationAdapters, in out TDefaultSsr, + in out TDefaultDehydrate, in out TRequestMiddlewares, in out TFunctionMiddlewares, > { '~types': StartInstanceTypes< TSerializationAdapters, TDefaultSsr, + TDefaultDehydrate, TRequestMiddlewares, TFunctionMiddlewares > serializationAdapters?: TSerializationAdapters defaultSsr?: TDefaultSsr + defaultDehydrate?: TDefaultDehydrate requestMiddleware?: TRequestMiddlewares functionMiddleware?: TFunctionMiddlewares /** @@ -52,6 +56,7 @@ export interface StartInstanceOptions< export interface StartInstance< in out TSerializationAdapters, in out TDefaultSsr, + in out TDefaultDehydrate, in out TRequestMiddlewares, in out TFunctionMiddlewares, > { @@ -60,6 +65,7 @@ export interface StartInstance< StartInstanceOptions< TSerializationAdapters, TDefaultSsr, + TDefaultDehydrate, TRequestMiddlewares, TFunctionMiddlewares > @@ -67,6 +73,7 @@ export interface StartInstance< | StartInstanceOptions< TSerializationAdapters, TDefaultSsr, + TDefaultDehydrate, TRequestMiddlewares, TFunctionMiddlewares > @@ -76,11 +83,13 @@ export interface StartInstance< export interface StartInstanceTypes< in out TSerializationAdapters, in out TDefaultSsr, + in out TDefaultDehydrate, in out TRequestMiddlewares, in out TFunctionMiddlewares, > { serializationAdapters: TSerializationAdapters defaultSsr: TDefaultSsr + defaultDehydrate: TDefaultDehydrate requestMiddleware: TRequestMiddlewares functionMiddleware: TFunctionMiddlewares } @@ -104,6 +113,7 @@ export const createStart = < const TSerializationAdapters extends ReadonlyArray = [], TDefaultSsr extends SSROption = SSROption, + TDefaultDehydrate extends DefaultDehydrateConfig = DefaultDehydrateConfig, const TRequestMiddlewares extends ReadonlyArray = [], const TFunctionMiddlewares extends ReadonlyArray = [], >( @@ -113,6 +123,7 @@ export const createStart = < StartInstanceOptions< TSerializationAdapters, TDefaultSsr, + TDefaultDehydrate, TRequestMiddlewares, TFunctionMiddlewares >, @@ -123,6 +134,7 @@ export const createStart = < StartInstanceOptions< TSerializationAdapters, TDefaultSsr, + TDefaultDehydrate, TRequestMiddlewares, TFunctionMiddlewares >, @@ -131,6 +143,7 @@ export const createStart = < ): StartInstance< TSerializationAdapters, TDefaultSsr, + TDefaultDehydrate, TRequestMiddlewares, TFunctionMiddlewares > => { @@ -151,13 +164,20 @@ export const createStart = < } as StartInstance< TSerializationAdapters, TDefaultSsr, + TDefaultDehydrate, TRequestMiddlewares, TFunctionMiddlewares > } -export type AnyStartInstance = StartInstance -export type AnyStartInstanceOptions = StartInstanceOptions +export type AnyStartInstance = StartInstance +export type AnyStartInstanceOptions = StartInstanceOptions< + any, + any, + any, + any, + any +> declare module '@tanstack/router-core' { interface SerializableExtensions { diff --git a/packages/start-client-core/src/serverRoute.ts b/packages/start-client-core/src/serverRoute.ts index 511412683e8..766da7c8d0e 100644 --- a/packages/start-client-core/src/serverRoute.ts +++ b/packages/start-client-core/src/serverRoute.ts @@ -3,6 +3,7 @@ import type { AnyRoute, Assign, Constrain, + DefaultLifecycleDehydrateFn, Expand, ResolveAllParamsFromParent, UnionToIntersection, @@ -23,12 +24,15 @@ declare module '@tanstack/router-core' { TLoaderDeps extends Record = {}, TLoaderFn = undefined, TRouterContext = {}, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TRemountDepsFn = AnyContext, TSSR = unknown, TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, > { server?: RouteServerOptions< TRegister, @@ -38,7 +42,7 @@ declare module '@tanstack/router-core' { TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TServerMiddlewares, THandlers @@ -55,7 +59,7 @@ declare module '@tanstack/router-core' { in out TSearchValidator, in out TParams, in out TRouterContext, - in out TRouteContextFn, + in out TContextFn, in out TBeforeLoadFn, in out TLoaderDeps, in out TLoaderFn, @@ -79,7 +83,7 @@ declare module '@tanstack/router-core' { in out TSearchValidator, in out TParams, in out TRouterContext, - in out TRouteContextFn, + in out TContextFn, in out TRouteId, in out TServerMiddlewares, in out THandlers, @@ -93,13 +97,13 @@ declare module '@tanstack/router-core' { } interface LoaderFnContext< - in out TRegister, + in out TRegister = unknown, in out TParentRoute extends AnyRoute = AnyRoute, in out TId extends string = string, in out TParams = {}, in out TLoaderDeps = {}, in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TServerMiddlewares = unknown, in out THandlers = undefined, @@ -140,7 +144,7 @@ export interface RouteServerOptions< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TServerMiddlewares, THandlers, diff --git a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts index 438c01bad7f..60e75aee9d1 100644 --- a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -7,6 +7,10 @@ import { import { routesManifestPlugin } from '../start-router-plugin/generator-plugins/routes-manifest-plugin' import { prerenderRoutesPlugin } from '../start-router-plugin/generator-plugins/prerender-routes-plugin' import { buildRouteTreeFileFooterFromConfig } from '../start-router-plugin/route-tree-footer' +import { + getRouteOptionDeleteNodesForClient, + getRouteOptionDeleteNodesForServer, +} from '../start-router-plugin/route-option-delete-nodes' import { RSBUILD_ENVIRONMENT_NAMES } from './planning' import type { RsbuildPluginAPI } from '@rsbuild/core' import type { GetConfigFn, TanStackStartCoreOptions } from '../types' @@ -73,7 +77,13 @@ export function registerRouterPlugins( target: opts.corePluginOpts.framework, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, - deleteNodes: isClient ? ['ssr', 'server', 'headers'] : undefined, + deleteNodes: isClient + ? getRouteOptionDeleteNodesForClient( + routerConfig.codeSplittingOptions?.deleteNodes, + ) + : getRouteOptionDeleteNodesForServer( + routerConfig.codeSplittingOptions?.deleteNodes, + ), addHmr: isClient, }, }, diff --git a/packages/start-plugin-core/src/start-router-plugin/route-option-delete-nodes.ts b/packages/start-plugin-core/src/start-router-plugin/route-option-delete-nodes.ts new file mode 100644 index 00000000000..5e6117a34a2 --- /dev/null +++ b/packages/start-plugin-core/src/start-router-plugin/route-option-delete-nodes.ts @@ -0,0 +1,59 @@ +import * as t from '@babel/types' +import type { + DeleteNodeCallback, + DeletableNodes, +} from '@tanstack/router-plugin' + +const CLIENT_ONLY_ROUTE_OPTION_NODES = [ + 'context.revalidate', + 'context.hydrate', + 'beforeLoad.hydrate', + 'loader.hydrate', +] + +const SERVER_ONLY_ROUTE_OPTION_NODES = ['ssr', 'server', 'headers'] + +const DEHYDRATE_ROUTE_OPTION_NODES = new Set([ + 'context.dehydrate', + 'beforeLoad.dehydrate', + 'loader.dehydrate', +]) + +const replaceCustomDehydrateWithClientMarker: DeleteNodeCallback = ({ + dotPath, + prop, + key, +}) => { + if (!DEHYDRATE_ROUTE_OPTION_NODES.has(dotPath)) { + return + } + + if (t.isObjectProperty(prop) && t.isBooleanLiteral(prop.value)) { + return + } + + return { + action: 'replace', + node: t.objectProperty(t.identifier(key), t.booleanLiteral(true)), + } +} + +export function getRouteOptionDeleteNodesForClient( + userDeleteNodes: Array | undefined, +): Array { + return [ + ...new Set([ + ...(userDeleteNodes ?? []), + ...SERVER_ONLY_ROUTE_OPTION_NODES, + replaceCustomDehydrateWithClientMarker, + ]), + ] +} + +export function getRouteOptionDeleteNodesForServer( + userDeleteNodes: Array | undefined, +): Array { + return [ + ...new Set([...(userDeleteNodes ?? []), ...CLIENT_ONLY_ROUTE_OPTION_NODES]), + ] +} diff --git a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts index e4f06893a7c..5dca69bdc10 100644 --- a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts @@ -11,6 +11,10 @@ import { prerenderRoutesPlugin } from '../../start-router-plugin/generator-plugi import { buildRouteTreeFileFooterFromConfig } from '../../start-router-plugin/route-tree-footer' import { pruneServerOnlySubtrees } from '../../start-router-plugin/pruneServerOnlySubtrees' import { SERVER_PROP } from '../../start-router-plugin/constants' +import { + getRouteOptionDeleteNodesForClient, + getRouteOptionDeleteNodesForServer, +} from '../../start-router-plugin/route-option-delete-nodes' import type { GetConfigFn } from '../../types' import type { TanStackStartVitePluginCoreOptions } from '../types' import type { @@ -163,7 +167,9 @@ export function tanStackStartRouter( ...routerConfig, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, - deleteNodes: ['ssr', 'server', 'headers'], + deleteNodes: getRouteOptionDeleteNodesForClient( + routerConfig.codeSplittingOptions?.deleteNodes, + ), addHmr: true, }, plugin: { @@ -177,6 +183,9 @@ export function tanStackStartRouter( ...routerConfig, codeSplittingOptions: { ...routerConfig.codeSplittingOptions, + deleteNodes: getRouteOptionDeleteNodesForServer( + routerConfig.codeSplittingOptions?.deleteNodes, + ), addHmr: false, }, plugin: { diff --git a/packages/start-plugin-core/tests/route-option-delete-nodes.test.ts b/packages/start-plugin-core/tests/route-option-delete-nodes.test.ts new file mode 100644 index 00000000000..148a7bba174 --- /dev/null +++ b/packages/start-plugin-core/tests/route-option-delete-nodes.test.ts @@ -0,0 +1,68 @@ +import * as t from '@babel/types' +import { describe, expect, it } from 'vitest' +import { getRouteOptionDeleteNodesForClient } from '../src/start-router-plugin/route-option-delete-nodes' +import type { + DeleteNodeCallback, + DeleteNodeCallbackContext, +} from '@tanstack/router-plugin' + +function getClientDeleteNodeCallback(): DeleteNodeCallback { + const callback = getRouteOptionDeleteNodesForClient(undefined).find( + (deleteNode): deleteNode is DeleteNodeCallback => + typeof deleteNode === 'function', + ) + + if (!callback) { + throw new Error('Expected a client delete node callback') + } + + return callback +} + +function createContext( + prop: t.ObjectProperty | t.ObjectMethod, + dotPath: string, +): DeleteNodeCallbackContext { + return { + key: 'dehydrate', + path: dotPath.split('.'), + dotPath, + prop, + parent: t.objectExpression([prop]), + } +} + +describe('Start route option delete nodes', () => { + it('replaces custom dehydrate route options with a client marker', () => { + const callback = getClientDeleteNodeCallback() + const prop = t.objectMethod( + 'method', + t.identifier('dehydrate'), + [t.identifier('ctx')], + t.blockStatement([]), + ) + + const result = callback(createContext(prop, 'loader.dehydrate')) + + expect(result).toMatchObject({ action: 'replace' }) + expect( + result && + typeof result === 'object' && + 'action' in result && + result.action === 'replace' && + t.isObjectProperty(result.node) && + t.isBooleanLiteral(result.node.value) && + result.node.value.value, + ).toBe(true) + }) + + it('preserves boolean dehydrate route options on the client', () => { + const callback = getClientDeleteNodeCallback() + const prop = t.objectProperty( + t.identifier('dehydrate'), + t.booleanLiteral(false), + ) + + expect(callback(createContext(prop, 'loader.dehydrate'))).toBeUndefined() + }) +}) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 378ed50d2d9..074c4cc2409 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -470,6 +470,7 @@ export function createStartHandler( origin: router.options.origin ?? origin, ...{ defaultSsr: requestStartOptions.defaultSsr, + defaultDehydrate: requestStartOptions.defaultDehydrate, serializationAdapters: [ ...requestStartOptions.serializationAdapters, ...(router.options.serializationAdapters || []), diff --git a/packages/vue-router/src/fileRoute.ts b/packages/vue-router/src/fileRoute.ts index 79902b87812..938a91ec78d 100644 --- a/packages/vue-router/src/fileRoute.ts +++ b/packages/vue-router/src/fileRoute.ts @@ -17,6 +17,7 @@ import type { AnyRouter, Constrain, ConstrainLiteral, + DefaultLifecycleDehydrateFn, FileBaseRouteOptions, FileRoutesByPath, LazyRouteOptions, @@ -75,7 +76,7 @@ export class FileRoute< TRegister = Register, TSearchValidator = undefined, TParams = ResolveParams, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, @@ -83,6 +84,9 @@ export class FileRoute< TSSR = unknown, TMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: FileBaseRouteOptions< TRegister, @@ -94,24 +98,27 @@ export class FileRoute< TLoaderDeps, TLoaderFn, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, AnyContext, TSSR, TMiddlewares, - THandlers + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn > & UpdatableRouteOptions< - TParentRoute, - TId, - TFullPath, - TParams, - TSearchValidator, - TLoaderFn, - TLoaderDeps, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, + NoInfer, AnyContext, - TRouteContextFn, - TBeforeLoadFn + NoInfer, + NoInfer >, ): Route< TRegister, @@ -123,7 +130,7 @@ export class FileRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -166,7 +173,7 @@ export function FileRouteLoader< TRoute['types']['params'], TRoute['types']['loaderDeps'], TRoute['types']['routerContext'], - TRoute['types']['routeContextFn'], + TRoute['types']['contextFn'], TRoute['types']['beforeLoadFn'] > >, diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index 2e14e349f06..be68e581a2a 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -65,7 +65,6 @@ export type { InferAllContext, LooseReturnType, LooseAsyncReturnType, - ContextReturnType, ContextAsyncReturnType, ResolveLoaderData, ResolveRouteContext, @@ -184,7 +183,8 @@ export type { MakeRouteMatchUnion, RouteMatch, AnyRouteMatch, - RouteContextFn, + ContextFn, + ContextFnOptions, RouteContextOptions, BeforeLoadContextOptions, ContextOptions, diff --git a/packages/vue-router/src/route.ts b/packages/vue-router/src/route.ts index 9a958f111d0..7a769e6f68a 100644 --- a/packages/vue-router/src/route.ts +++ b/packages/vue-router/src/route.ts @@ -19,6 +19,7 @@ import type { AnyRoute, AnyRouter, ConstrainLiteral, + DefaultLifecycleDehydrateFn, ErrorComponentProps, NotFoundError, NotFoundRouteProps, @@ -57,6 +58,9 @@ type VueSFC = { render?: Function } +type NormalizeRouteContext = [T] extends [never] ? AnyContext : T +type NormalizeRouteLoader = [T] extends [never] ? undefined : T + declare module '@tanstack/router-core' { export interface UpdatableRouteOptionsExtensions { component?: RouteComponent | VueSFC @@ -173,7 +177,7 @@ export class Route< in out TSearchValidator = undefined, in out TParams = ResolveParams, in out TRouterContext = AnyContext, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TLoaderDeps extends Record = {}, in out TLoaderFn = undefined, @@ -193,7 +197,7 @@ export class Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -214,7 +218,7 @@ export class Route< TSearchValidator, TParams, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, @@ -241,7 +245,7 @@ export class Route< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, TMiddlewares, @@ -313,13 +317,17 @@ export function createRoute< >, TSearchValidator = undefined, TParams = ResolveParams, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, TSSR = unknown, + TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options: RouteOptions< TRegister, @@ -333,10 +341,14 @@ export function createRoute< TLoaderDeps, TLoaderFn, AnyContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, - THandlers + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ): Route< TRegister, @@ -348,13 +360,14 @@ export function createRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, TChildren, unknown, TSSR, + TServerMiddlewares, THandlers > { return new Route< @@ -367,15 +380,16 @@ export function createRoute< TSearchValidator, TParams, AnyContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, TChildren, unknown, TSSR, + TServerMiddlewares, THandlers - >(options) + >(options as any) } export type AnyRootRoute = RootRoute< @@ -388,43 +402,57 @@ export type AnyRootRoute = RootRoute< any, any, any, + any, + any, any > export function createRootRouteWithContext() { return < TRegister = Register, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, + TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: RootRouteOptions< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, - THandlers + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ) => { return createRootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, - THandlers - >(options) + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn + >(options as any) } } @@ -437,26 +465,28 @@ export class RootRoute< in out TRegister = Register, in out TSearchValidator = undefined, in out TRouterContext = {}, - in out TRouteContextFn = AnyContext, + in out TContextFn = AnyContext, in out TBeforeLoadFn = AnyContext, in out TLoaderDeps extends Record = {}, in out TLoaderFn = undefined, in out TChildren = unknown, in out TFileRouteTypes = unknown, in out TSSR = unknown, + in out TServerMiddlewares = unknown, in out THandlers = undefined, > extends BaseRootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TFileRouteTypes, TSSR, + TServerMiddlewares, THandlers > implements @@ -464,13 +494,14 @@ export class RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TFileRouteTypes, TSSR, + TServerMiddlewares, THandlers > { @@ -482,11 +513,12 @@ export class RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, + TServerMiddlewares, THandlers >, ) { @@ -569,14 +601,14 @@ export class NotFoundRoute< TRegister, TParentRoute extends AnyRootRoute, TRouterContext = AnyContext, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TSearchValidator = undefined, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TChildren = unknown, TSSR = unknown, - THandlers = undefined, + TServerMiddlewares = unknown, > extends Route< TRegister, TParentRoute, @@ -587,13 +619,14 @@ export class NotFoundRoute< TSearchValidator, {}, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, + unknown, TSSR, - THandlers + TServerMiddlewares > { constructor( options: Omit< @@ -609,10 +642,10 @@ export class NotFoundRoute< TLoaderDeps, TLoaderFn, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TSSR, - THandlers + TServerMiddlewares >, | 'caseSensitive' | 'parseParams' @@ -633,48 +666,58 @@ export function createRootRoute< TRegister = Register, TSearchValidator = undefined, TRouterContext = {}, - TRouteContextFn = AnyContext, + TContextFn = AnyContext, TBeforeLoadFn = AnyContext, TLoaderDeps extends Record = {}, TLoaderFn = undefined, TSSR = unknown, + TServerMiddlewares = unknown, THandlers = undefined, + TContextDehydrateFn = DefaultLifecycleDehydrateFn, + TBeforeLoadDehydrateFn = DefaultLifecycleDehydrateFn, + TLoaderDehydrateFn = DefaultLifecycleDehydrateFn, >( options?: RootRouteOptions< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, + TContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, - THandlers + TServerMiddlewares, + THandlers, + TContextDehydrateFn, + TBeforeLoadDehydrateFn, + TLoaderDehydrateFn >, ): RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, unknown, unknown, TSSR, + TServerMiddlewares, THandlers > { return new RootRoute< TRegister, TSearchValidator, TRouterContext, - TRouteContextFn, - TBeforeLoadFn, + NormalizeRouteContext, + NormalizeRouteContext, TLoaderDeps, - TLoaderFn, + NormalizeRouteLoader, unknown, unknown, TSSR, + TServerMiddlewares, THandlers - >(options) + >(options as any) } diff --git a/packages/vue-router/tests/errorComponent.test.tsx b/packages/vue-router/tests/errorComponent.test.tsx index f606040f173..16d2c6d253f 100644 --- a/packages/vue-router/tests/errorComponent.test.tsx +++ b/packages/vue-router/tests/errorComponent.test.tsx @@ -160,3 +160,250 @@ describe.each([true, false])( ) }, ) + +describe('errorComponent is rendered when an Error is thrown in lifecycle methods', () => { + test('an Error thrown in `context` renders errorComponent on navigate', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: () => { + throw new Error('context error thrown') + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree }) + + render() + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: context error thrown', + undefined, + { timeout: 1500 }, + ) + await expect(screen.findByText('About route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an Error thrown in `context` with invalidate renders errorComponent on navigate', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: () => { + throw new Error('context invalidate error thrown') + }, + revalidate: true, + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree }) + + render() + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + + expect(linkToAbout).toBeInTheDocument() + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: context invalidate error thrown', + undefined, + { timeout: 1500 }, + ) + await expect(screen.findByText('About route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an Error thrown in `context` renders errorComponent on first load', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + throw new Error('context error thrown') + }, + component: function Home() { + return
Index route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + + render() + + const errorComponent = await screen.findByText( + 'Error: context error thrown', + undefined, + { timeout: 750 }, + ) + await expect(screen.findByText('Index route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an Error thrown in `context` with invalidate renders errorComponent on first load', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + throw new Error('context invalidate error thrown') + }, + revalidate: true, + }, + component: function Home() { + return
Index route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + + render() + + const errorComponent = await screen.findByText( + 'Error: context invalidate error thrown', + undefined, + { timeout: 750 }, + ) + await expect(screen.findByText('Index route content')).rejects.toThrow() + expect(errorComponent).toBeInTheDocument() + }) + + test('an async Error thrown in `context` renders errorComponent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + throw new Error('async context error') + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree }) + + render() + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: async context error', + undefined, + { timeout: 1500 }, + ) + expect(errorComponent).toBeInTheDocument() + }) + + test('an async Error thrown in `context` with invalidate renders errorComponent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Home() { + return ( +
+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + throw new Error('async context invalidate error') + }, + revalidate: true, + }, + component: function About() { + return
About route content
+ }, + errorComponent: MyErrorComponent, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree }) + + render() + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + fireEvent.click(linkToAbout) + + const errorComponent = await screen.findByText( + 'Error: async context invalidate error', + undefined, + { timeout: 1500 }, + ) + expect(errorComponent).toBeInTheDocument() + }) +}) diff --git a/packages/vue-router/tests/redirect.test.tsx b/packages/vue-router/tests/redirect.test.tsx index b7ba2e1af3e..f42c79d5151 100644 --- a/packages/vue-router/tests/redirect.test.tsx +++ b/packages/vue-router/tests/redirect.test.tsx @@ -291,6 +291,145 @@ describe('redirect', () => { expect(await screen.findByText('Final')).toBeInTheDocument() expect(window.location.pathname).toBe('/final') }) + + test('when `redirect` is thrown in `context`', async () => { + const nestedLoaderMock = vi.fn() + const nestedFooLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/foo' }) + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + nestedLoaderMock('nested') + }, + }) + const fooRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async () => { + await sleep(WAIT_TIME) + nestedFooLoaderMock('foo') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree }) + + render() + + const linkToAbout = await screen.findByText('link to about') + + expect(linkToAbout).toBeInTheDocument() + + fireEvent.click(linkToAbout) + + const fooElement = await screen.findByText('Nested Foo page') + + expect(fooElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/foo') + expect(window.location.pathname).toBe('/nested/foo') + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedFooLoaderMock).toHaveBeenCalled() + }) + + test('when `redirect` is thrown in `context` with invalidate', async () => { + const nestedLoaderMock = vi.fn() + const nestedFooLoaderMock = vi.fn() + + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: async () => { + await sleep(WAIT_TIME) + throw redirect({ to: '/nested/foo' }) + }, + revalidate: true, + }, + }) + const nestedRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + loader: async () => { + await sleep(WAIT_TIME) + nestedLoaderMock('nested') + }, + }) + const fooRoute = createRoute({ + getParentRoute: () => nestedRoute, + path: '/foo', + loader: async () => { + await sleep(WAIT_TIME) + nestedFooLoaderMock('foo') + }, + component: () =>
Nested Foo page
, + }) + const routeTree = rootRoute.addChildren([ + nestedRoute.addChildren([fooRoute]), + aboutRoute, + indexRoute, + ]) + const router = createRouter({ routeTree }) + + render() + + const linkToAbout = await screen.findByText('link to about') + + expect(linkToAbout).toBeInTheDocument() + + fireEvent.click(linkToAbout) + + const fooElement = await screen.findByText('Nested Foo page') + + expect(fooElement).toBeInTheDocument() + + expect(router.state.location.href).toBe('/nested/foo') + expect(window.location.pathname).toBe('/nested/foo') + + expect(nestedLoaderMock).toHaveBeenCalled() + expect(nestedFooLoaderMock).toHaveBeenCalled() + }) }) describe('SSR', () => { @@ -395,5 +534,106 @@ describe('redirect', () => { statusCode: 307, }) }) + + test('when `redirect` is thrown in `context`', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + context: () => { + throw redirect({ + to: '/about', + }) + }, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => { + return <>About + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + isServer: true, + history: createMemoryHistory({ + initialEntries: ['/'], + }), + }) + + await router.load() + + const stateRedirect = router.state.redirect + expect(stateRedirect).toBeDefined() + expect(stateRedirect).toBeInstanceOf(Response) + + expect(stateRedirect!.options).toEqual({ + _fromLocation: expect.objectContaining({ + hash: '', + href: '/', + pathname: '/', + search: {}, + searchStr: '', + }), + to: '/about', + href: '/about', + statusCode: 307, + }) + }) + + test('when `redirect` is thrown in `context` with invalidate', async () => { + const rootRoute = createRootRoute() + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + context: { + handler: () => { + throw redirect({ + to: '/about', + }) + }, + revalidate: true, + }, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => { + return <>About + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + isServer: true, + history: createMemoryHistory({ + initialEntries: ['/'], + }), + }) + + await router.load() + + const stateRedirect = router.state.redirect + expect(stateRedirect).toBeDefined() + expect(stateRedirect).toBeInstanceOf(Response) + + expect(stateRedirect!.options).toEqual({ + _fromLocation: expect.objectContaining({ + hash: '', + href: '/', + pathname: '/', + search: {}, + searchStr: '', + }), + to: '/about', + href: '/about', + statusCode: 307, + }) + }) }) }) diff --git a/packages/vue-router/tests/route.test-d.tsx b/packages/vue-router/tests/route.test-d.tsx index 5e320174131..969d0c99bce 100644 --- a/packages/vue-router/tests/route.test-d.tsx +++ b/packages/vue-router/tests/route.test-d.tsx @@ -30,20 +30,20 @@ test('when creating the root', () => { expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() }) -test('when creating the root with routeContext', () => { +test('when creating the root with context', () => { const rootRoute = createRootRoute({ context: (opts) => { expectTypeOf(opts).toEqualTypeOf<{ abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn routeId: '__root__' cause: 'preload' | 'enter' | 'stay' context: {} - deps: {} matches: Array }>() }, @@ -78,6 +78,33 @@ test('when creating the root with beforeLoad', () => { expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() }) +test('when creating the root with context using object form with revalidate', () => { + const rootRoute = createRootRoute({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + routeId: '__root__' + cause: 'preload' | 'enter' | 'stay' + context: {} + matches: Array + }>() + }, + revalidate: true, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() + expectTypeOf(rootRoute.id).toEqualTypeOf<'__root__'>() + expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() +}) + test('when creating the root with a loader', () => { const rootRoute = createRootRoute({ loader: (opts) => { @@ -101,7 +128,7 @@ test('when creating the root with a loader', () => { expectTypeOf(rootRoute.path).toEqualTypeOf<'/'>() }) -test('when creating the root route with context and routeContext', () => { +test('when creating the root route with context and context option', () => { const createRouteResult = createRootRouteWithContext<{ userId: string }>() const rootRoute = createRouteResult({ context: (opts) => { @@ -109,13 +136,13 @@ test('when creating the root route with context and routeContext', () => { abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn routeId: '__root__' cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array }>() }, @@ -186,6 +213,42 @@ test('when creating the root route with context and beforeLoad', () => { .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() }) +test('when creating the root route with context and context option using object form with revalidate', () => { + const createRouteResult = createRootRouteWithContext<{ userId: string }>() + + const rootRoute = createRouteResult({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + routeId: '__root__' + cause: 'preload' | 'enter' | 'stay' + context: { userId: string } + matches: Array + }>() + }, + revalidate: true, + }, + }) + + const router = createRouter({ + routeTree: rootRoute, + context: { userId: '123' }, + }) + + expectTypeOf(rootRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + userId: string + }> + >() +}) + test('when creating the root route with context and a loader', () => { const createRouteResult = createRootRouteWithContext<{ userId: string }>() @@ -228,7 +291,7 @@ test('when creating the root route with context and a loader', () => { .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() }) -test('when creating the root route with context, routeContext, beforeLoad and a loader', () => { +test('when creating the root route with context, context option, beforeLoad and a loader', () => { const createRouteResult = createRootRouteWithContext<{ userId: string }>() const rootRoute = createRouteResult({ @@ -237,13 +300,13 @@ test('when creating the root route with context, routeContext, beforeLoad and a abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn routeId: '__root__' cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array }>() @@ -353,7 +416,7 @@ test('when creating a child route from the root route with context', () => { .toEqualTypeOf<((context: { userId: string }) => unknown) | undefined>() }) -test('when creating a child route with routeContext from the root route with context', () => { +test('when creating a child route with context from the root route with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() createRoute({ @@ -364,13 +427,13 @@ test('when creating a child route with routeContext from the root route with con abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn routeId: '/invoices' cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array }>() @@ -710,7 +773,7 @@ test('when creating a child route with params, search, loader and loaderDeps fro }) }) -test('when creating a child route with params, search with routeContext from the root route with context', () => { +test('when creating a child route with params, search with context from the root route with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() createRoute({ @@ -722,13 +785,13 @@ test('when creating a child route with params, search with routeContext from the abortController: AbortController preload: boolean params: { invoiceId: string } + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn routeId: '/invoices/$invoiceId' cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array }>() }, @@ -759,7 +822,7 @@ test('when creating a child route with params, search with beforeLoad from the r }) }) -test('when creating a child route with params, search with routeContext, beforeLoad and a loader from the root route with context', () => { +test('when creating a child route with params, search with context, beforeLoad and a loader from the root route with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() createRoute({ @@ -771,13 +834,13 @@ test('when creating a child route with params, search with routeContext, beforeL abortController: AbortController preload: boolean params: { invoiceId: string } + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn routeId: '/invoices/$invoiceId' cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array }>() return { @@ -890,7 +953,7 @@ test('when creating a child route with search from a parent with search', () => >() }) -test('when creating a child route with routeContext from a parent with routeContext', () => { +test('when creating a child route with context from a parent with context', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() const invoicesRoute = createRoute({ @@ -901,13 +964,13 @@ test('when creating a child route with routeContext from a parent with routeCont abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn routeId: '/invoices' cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array }>() @@ -923,13 +986,13 @@ test('when creating a child route with routeContext from a parent with routeCont abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn routeId: '/invoices/details' cause: 'preload' | 'enter' | 'stay' context: { userId: string; invoiceId: string } - deps: {} matches: Array }>() @@ -1040,7 +1103,7 @@ test('when creating a child route with beforeLoad from a parent with beforeLoad' >() }) -test('when creating a child route with routeContext, beforeLoad, search, params, loaderDeps and loader', () => { +test('when creating a child route with context, beforeLoad, search, params, loaderDeps and loader', () => { const rootRoute = createRootRouteWithContext<{ userId: string }>()() const invoicesRoute = createRoute({ @@ -1052,13 +1115,13 @@ test('when creating a child route with routeContext, beforeLoad, search, params, abortController: AbortController preload: boolean params: {} + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn routeId: '/invoices' cause: 'preload' | 'enter' | 'stay' context: { userId: string } - deps: {} matches: Array }>() return { env: 'env1' } @@ -1095,6 +1158,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params, abortController: AbortController preload: boolean params: { invoiceId: string } + deps: {} location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn @@ -1105,7 +1169,6 @@ test('when creating a child route with routeContext, beforeLoad, search, params, env: string invoicePermissions: readonly ['view'] } - deps: {} matches: Array }>() return { detailEnv: 'detailEnv' } @@ -1145,6 +1208,7 @@ test('when creating a child route with routeContext, beforeLoad, search, params, abortController: AbortController preload: boolean params: { invoiceId: string; detailId: string } + deps: { detailPage: number; invoicePage: number } location: ParsedLocation navigate: NavigateFn buildLocation: BuildLocationFn @@ -1157,7 +1221,6 @@ test('when creating a child route with routeContext, beforeLoad, search, params, detailEnv: string detailsPermissions: readonly ['view'] } - deps: { detailPage: number; invoicePage: number } matches: Array }>() return { detailEnv: 'detailEnv' } @@ -1601,7 +1664,7 @@ test('when creating a child route with params.parse and params.stringify with me >() }) -test('when routeContext throws', () => { +test('when context throws', () => { const rootRoute = createRootRoute() const invoicesRoute = createRoute({ getParentRoute: () => rootRoute, @@ -1779,3 +1842,725 @@ test('when creating a child route with an explicit search input', () => { .parameter(0) .toEqualTypeOf<{ page: string }>() }) + +// --------------------------------------------------------------------------- +// Object form lifecycle methods — type-level tests +// --------------------------------------------------------------------------- + +test('object form context is accepted on root route', () => { + const rootRoute = createRootRoute({ + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + matches: Array + routeId: '__root__' + }>() + return { env: 'production' } + }, + dehydrate: false, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('object form beforeLoad is accepted on root route', () => { + const rootRoute = createRootRoute({ + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: {} + search: {} + matches: Array + routeId: '__root__' + }>() + return { perm: 'admin' } + }, + dehydrate: true, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('object form context with revalidate is accepted on root route', () => { + const rootRoute = createRootRoute({ + context: { + handler: (_opts) => { + // Root route context handler compiles — vue-tsc resolves search + // differently than tsc for the root route, so we only verify + // that the handler accepts and returns the correct types + return { cache: 'initialized' } + }, + revalidate: true, + dehydrate: false, + }, + }) + + expectTypeOf(rootRoute.fullPath).toEqualTypeOf<'/'>() +}) + +test('object form loader is accepted on child route', () => { + const rootRoute = createRootRoute() + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + loader: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + deps: {} + context: {} + location: ParsedLocation + navigate: (opts: NavigateOptions) => Promise | void + parentMatchPromise: Promise> + cause: 'preload' | 'enter' | 'stay' + route: AnyRoute + }>() + return { data: 'loaded' } + }, + dehydrate: true, + }, + }) + + expectTypeOf(childRoute.fullPath).toEqualTypeOf<'/child'>() +}) + +test('object form context flows into beforeLoad handler context', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { + handler: () => ({ env: 'production' }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + // beforeLoad should see context's return + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'admin' } + }, + }, + }) +}) + +test('object form context -> beforeLoad -> loader full context chain', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { + handler: () => ({ env: 'prod' }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'view' as const } + }, + dehydrate: true, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + perm: 'view' + }>() + return { items: ['a', 'b'] } + }, + dehydrate: true, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + context: { userId: '123' }, + }) + + expectTypeOf(invoicesRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + userId: string + env: string + perm: 'view' + }> + >() +}) + +test('mixed function and object form on the same route', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + // function form for context + context: () => ({ env: 'staging' }), + // object form for beforeLoad + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + }>() + return { perm: 'edit' as const } + }, + dehydrate: false, + }, + // object form for loader + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + env: string + perm: 'edit' + }>() + return { data: [1, 2, 3] } + }, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([invoicesRoute]), + context: { userId: '123' }, + }) + + expectTypeOf(invoicesRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + userId: string + env: string + perm: 'edit' + }> + >() +}) + +test('object form parent-child context propagation', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'parent', + context: { + handler: () => ({ parentEnv: 'env1' }), + dehydrate: true, + }, + beforeLoad: { + handler: () => ({ parentPerm: 'admin' as const }), + dehydrate: false, + }, + }) + + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: 'child', + context: { + handler: (opts) => { + // child's context sees parent's full allContext (context + beforeLoad) + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + }>() + return { childEnv: 'env2' } + }, + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + childEnv: string + }>() + return { childPerm: 'viewer' as const } + }, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + parentEnv: string + parentPerm: 'admin' + childEnv: string + childPerm: 'viewer' + }>() + return { items: [1, 2] } + }, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + context: { userId: '123' }, + }) + + expectTypeOf(parentRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + userId: string + parentEnv: string + parentPerm: 'admin' + }> + >() + + expectTypeOf(childRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + userId: string + parentEnv: string + parentPerm: 'admin' + childEnv: string + childPerm: 'viewer' + }> + >() +}) + +test('object form without dehydrate: full context chain with useRouteContext and useLoaderData', () => { + const rootRoute = createRootRouteWithContext<{ appId: string }>()() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ appId: string }>() + return { env: 'test' } + }, + // no dehydrate specified + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + appId: string + env: string + }>() + return { perm: 'view' as const } + }, + // no dehydrate specified + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + appId: string + env: string + perm: 'view' + }>() + return { data: [1, 2, 3] } + }, + // no dehydrate specified + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + context: { appId: 'app1' }, + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + appId: string + env: string + perm: 'view' + }> + >() + + expectTypeOf(testRoute.useLoaderData()).toEqualTypeOf< + Vue.Ref<{ + data: Array + }> + >() +}) + +test('object form non-serializable returns flow into context chain', () => { + const rootRoute = createRootRoute() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => ({ cleanup: () => console.log('cleanup') }), + dehydrate: false, + }, + beforeLoad: { + handler: (opts) => { + // beforeLoad sees context's non-serializable return in context + expectTypeOf(opts.context).toEqualTypeOf<{ + cleanup: () => void + }>() + return { compute: (x: number) => x * 2 } + }, + dehydrate: false, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + cleanup: () => void + compute: (x: number) => number + }>() + return { items: ['a'] } + }, + dehydrate: false, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + cleanup: () => void + compute: (x: number) => number + }> + >() +}) + +test('object form with params and search', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + validateSearch: () => ({ page: 0 }), + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + deps: {} + context: { userId: string } + matches: Array + routeId: '/invoices' + }>() + return { invoiceEnv: 'prod' } + }, + }, + beforeLoad: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: {} + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + context: { userId: string; invoiceEnv: string } + search: { page: number } + matches: Array + routeId: '/invoices' + }>() + return { invoicePermissions: ['view'] as const } + }, + }, + }) + + const invoiceRoute = createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + loaderDeps: (deps) => ({ + currentPage: deps.search.page, + }), + context: { + handler: (opts) => { + expectTypeOf(opts).toEqualTypeOf<{ + abortController: AbortController + preload: boolean + params: { invoiceId: string } + location: ParsedLocation + navigate: NavigateFn + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + deps: { currentPage: number } + context: { + userId: string + invoiceEnv: string + invoicePermissions: readonly ['view'] + } + matches: Array + routeId: '/invoices/$invoiceId' + }>() + return { detailEnv: 'staging' } + }, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.params).toEqualTypeOf<{ invoiceId: string }>() + expectTypeOf(opts.deps).toEqualTypeOf<{ currentPage: number }>() + expectTypeOf(opts.context).toEqualTypeOf<{ + userId: string + invoiceEnv: string + invoicePermissions: readonly ['view'] + detailEnv: string + }>() + return { invoice: { id: 'inv1', amount: 100 } } + }, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + ]), + context: { userId: '123' }, + }) + + expectTypeOf(invoiceRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + userId: string + invoiceEnv: string + invoicePermissions: readonly ['view'] + detailEnv: string + }> + >() + + expectTypeOf(invoiceRoute.useLoaderData()).toEqualTypeOf< + Vue.Ref<{ + invoice: { id: string; amount: number } + }> + >() + + expectTypeOf(invoiceRoute.useParams()).toEqualTypeOf< + Vue.Ref<{ + invoiceId: string + }> + >() +}) + +test('object form useLoaderData with select and structuralSharing', () => { + const rootRoute = createRootRoute() + + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'child', + loader: { + handler: () => + ({ items: ['a', 'b'], count: 2 }) as const satisfies { + items: ReadonlyArray + count: number + }, + }, + }) + + const routeTree = rootRoute.addChildren([childRoute]) + const router = createRouter({ routeTree }) + + expectTypeOf(childRoute.useLoaderData()).toEqualTypeOf< + Vue.Ref<{ + readonly items: readonly ['a', 'b'] + readonly count: 2 + }> + >() + + expectTypeOf(childRoute.useLoaderData) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((search: { + readonly items: readonly ['a', 'b'] + readonly count: 2 + }) => string) + | undefined + >() +}) + +test('object form useRouteContext with select', () => { + const rootRoute = createRootRouteWithContext<{ appId: string }>()() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => ({ env: 'prod' }), + }, + beforeLoad: { + handler: () => ({ perm: 'admin' as const }), + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + context: { appId: 'app1' }, + }) + + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + appId: string + env: string + perm: 'admin' + }> + >() + + expectTypeOf(testRoute.useRouteContext) + .parameter(0) + .exclude() + .toHaveProperty('select') + .toEqualTypeOf< + | ((context: { appId: string; env: string; perm: 'admin' }) => unknown) + | undefined + >() +}) + +test('object form onEnter, onStay, onLeave match types', () => { + const rootRoute = createRootRouteWithContext<{ userId: string }>()() + + const invoicesRoute = createRoute({ + path: 'invoices', + getParentRoute: () => rootRoute, + validateSearch: () => ({ page: 0 }), + beforeLoad: { handler: () => ({ invoicePermissions: ['view'] as const }) }, + }) + + type TExpectedParams = {} + type TExpectedSearch = { page: number } + type TExpectedContext = { + userId: string + invoicePermissions: readonly ['view'] + } + type TExpectedLoaderData = { totalInvoices: number } + type TExpectedMatch = { + params: TExpectedParams + search: TExpectedSearch + context: TExpectedContext + loaderDeps: {} + beforeLoadPromise?: ControlledPromise + loaderPromise?: ControlledPromise + componentsPromise?: Promise> + loaderData?: TExpectedLoaderData + } + + createRoute({ + path: '$invoiceId', + getParentRoute: () => invoicesRoute, + context: { handler: () => ({ detailPermission: true }) }, + loader: { handler: () => ({ totalInvoices: 42 }) }, + onEnter: (match) => expectTypeOf(match).toMatchTypeOf(), + onStay: (match) => expectTypeOf(match).toMatchTypeOf(), + onLeave: (match) => expectTypeOf(match).toMatchTypeOf(), + }) +}) + +test('object form void-returning context does not add to context', () => { + const rootRoute = createRootRouteWithContext<{ appId: string }>()() + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'test', + context: { + handler: () => {}, + }, + beforeLoad: { + handler: () => ({ perm: 'admin' as const }), + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute]), + context: { appId: 'app1' }, + }) + + // void context should not add anything — useRouteContext shows only root + beforeLoad + expectTypeOf(testRoute.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + appId: string + perm: 'admin' + }> + >() +}) + +test('three-level object form context accumulation', () => { + const rootRoute = createRootRouteWithContext<{ rootCtx: string }>()() + + const level1 = createRoute({ + getParentRoute: () => rootRoute, + path: 'l1', + context: { handler: () => ({ l1Ctx: 'a' }) }, + beforeLoad: { handler: () => ({ l1Before: 'b' }) }, + }) + + const level2 = createRoute({ + getParentRoute: () => level1, + path: 'l2', + context: { handler: () => ({ l2Ctx: 'd' }) }, + beforeLoad: { handler: () => ({ l2Before: 'e' }) }, + }) + + const level3 = createRoute({ + getParentRoute: () => level2, + path: 'l3', + context: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + rootCtx: string + l1Ctx: string + l1Before: string + l2Ctx: string + l2Before: string + }>() + return { l3Ctx: 'g' } + }, + }, + loader: { + handler: (opts) => { + expectTypeOf(opts.context).toEqualTypeOf<{ + rootCtx: string + l1Ctx: string + l1Before: string + l2Ctx: string + l2Before: string + l3Ctx: string + }>() + return { data: 'final' } + }, + }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + level1.addChildren([level2.addChildren([level3])]), + ]), + context: { rootCtx: 'root' }, + }) + + expectTypeOf(level3.useRouteContext()).toEqualTypeOf< + Vue.Ref<{ + rootCtx: string + l1Ctx: string + l1Before: string + l2Ctx: string + l2Before: string + l3Ctx: string + }> + >() +}) diff --git a/packages/vue-router/tests/routeContext.test.tsx b/packages/vue-router/tests/routeContext.test.tsx index 53db6aa42f2..6ecb49470ad 100644 --- a/packages/vue-router/tests/routeContext.test.tsx +++ b/packages/vue-router/tests/routeContext.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/vue' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { z } from 'zod' @@ -143,8 +149,11 @@ describe('context function', () => { }), path: '/', loaderDeps: ({ search }) => ({ foo: search.foo }), - context: ({ deps }) => { - mockContextFn(deps) + context: { + handler: () => { + mockContextFn() + }, + revalidate: true, }, component: () => { const navigate = indexRoute.useNavigate() @@ -201,13 +210,11 @@ describe('context function', () => { await findByText(`search: ${JSON.stringify({})}`) expect(mockContextFn).toHaveBeenCalledOnce() - expect(mockContextFn).toHaveBeenCalledWith({}) mockContextFn.mockClear() await clickButton('foo-1') await findByText(`search: ${JSON.stringify({ foo: 'foo-1' })}`) expect(mockContextFn).toHaveBeenCalledOnce() - expect(mockContextFn).toHaveBeenCalledWith({ foo: 'foo-1' }) mockContextFn.mockClear() await clickButton('foo-1') @@ -224,7 +231,7 @@ describe('context function', () => { await findByText( `search: ${JSON.stringify({ foo: 'foo-2', bar: 'bar-1' })}`, ) - expect(mockContextFn).toHaveBeenCalledWith({ foo: 'foo-2' }) + expect(mockContextFn).toHaveBeenCalledOnce() mockContextFn.mockClear() await clickButton('bar-2') @@ -235,8 +242,9 @@ describe('context function', () => { await clickButton('clear') await findByText(`search: ${JSON.stringify({})}`) - expect(mockContextFn).toHaveBeenCalledOnce() - expect(mockContextFn).toHaveBeenCalledWith({}) + // context with invalidate does NOT re-run: the cached match (from the initial load with + // the same loaderDeps hash) is restored and needsContext is already consumed. + expect(mockContextFn).not.toHaveBeenCalled() }) }) @@ -3112,3 +3120,1501 @@ describe('useRouteContext in the component', () => { expect(content).toBeInTheDocument() }) }) + +describe('lifecycle method semantics', () => { + describe('execution order guarantees', () => { + test('parent serial phases complete before child serial phases (parent context → beforeLoad → child context → beforeLoad)', async () => { + const executionOrder: Array = [] + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: async () => { + executionOrder.push('parent-context-start') + await sleep(WAIT_TIME) + executionOrder.push('parent-context-end') + return { parentContext: true } + }, + beforeLoad: async () => { + executionOrder.push('parent-beforeLoad-start') + await sleep(WAIT_TIME) + executionOrder.push('parent-beforeLoad-end') + return { parentBeforeLoad: true } + }, + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: async () => { + executionOrder.push('child-context-start') + await sleep(WAIT_TIME) + executionOrder.push('child-context-end') + return { childContext: true } + }, + beforeLoad: async () => { + executionOrder.push('child-beforeLoad-start') + await sleep(WAIT_TIME) + executionOrder.push('child-beforeLoad-end') + return { childBeforeLoad: true } + }, + component: () =>
Child page
, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + render() + await screen.findByTestId('index-page') + + // Clear any entries from initial load + executionOrder.length = 0 + + fireEvent.click(screen.getByTestId('go-parent-child')) + await screen.findByTestId('child-page') + + expect(executionOrder).toEqual([ + 'parent-context-start', + 'parent-context-end', + 'parent-beforeLoad-start', + 'parent-beforeLoad-end', + 'child-context-start', + 'child-context-end', + 'child-beforeLoad-start', + 'child-beforeLoad-end', + ]) + }) + + test('all serial phases complete before loaders fire', async () => { + const executionOrder: Array = [] + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => { + executionOrder.push('parent-context') + return {} + }, + beforeLoad: () => { + executionOrder.push('parent-beforeLoad') + return {} + }, + loader: () => { + executionOrder.push('parent-loader') + }, + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: () => { + executionOrder.push('child-context') + return {} + }, + beforeLoad: () => { + executionOrder.push('child-beforeLoad') + return {} + }, + loader: () => { + executionOrder.push('child-loader') + }, + component: () =>
Child page
, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + render() + await screen.findByTestId('index-page') + + // Clear any entries from initial load + executionOrder.length = 0 + + fireEvent.click(screen.getByTestId('go-parent-child')) + await screen.findByTestId('child-page') + + // All serial phases (context, beforeLoad) must come before any loader + const loaderIndices = executionOrder + .map((entry, i) => (entry.includes('loader') ? i : -1)) + .filter((i) => i >= 0) + const serialIndices = executionOrder + .map((entry, i) => (!entry.includes('loader') ? i : -1)) + .filter((i) => i >= 0) + + const lastSerial = Math.max(...serialIndices) + const firstLoader = Math.min(...loaderIndices) + expect(lastSerial).toBeLessThan(firstLoader) + }) + }) + + describe('context edge cases', () => { + test('context on root route fires exactly once and never again on navigation', async () => { + const mockRootContext = vi.fn() + + const rootRoute = createRootRoute({ + context: () => { + mockRootContext() + return { rootMatched: true } + }, + component: () => ( +
+ Root +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+ Other + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(mockRootContext).toHaveBeenCalledTimes(1) + mockRootContext.mockClear() + + // Navigate to other + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + expect(mockRootContext).not.toHaveBeenCalled() + + // Navigate back + fireEvent.click(await screen.findByTestId('go-index')) + await screen.findByTestId('index-page') + expect(mockRootContext).not.toHaveBeenCalled() + }) + + test('context returning undefined does not clobber parent context', async () => { + const rootRoute = createRootRoute({ + beforeLoad: () => ({ rootValue: 'from-root' }), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return undefined + }, + beforeLoad: ({ context }) => { + return { sawRootValue: context.rootValue } + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + rootValue: 'from-root', + sawRootValue: 'from-root', + }), + ) + }) + + test('context receives cause "enter" on fresh match creation', async () => { + const receivedCause = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + context: ({ cause }) => { + receivedCause(cause) + }, + component: () =>
Other
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + expect(receivedCause).toHaveBeenCalledTimes(1) + expect(receivedCause).toHaveBeenCalledWith('enter') + }) + + test('context receives correct params', async () => { + const receivedParams = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const userRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/user/$userId', + context: ({ params }) => { + receivedParams(params) + return { userId: params.userId } + }, + component: () => { + const context = userRoute.useRouteContext() + return ( +
+ {JSON.stringify(context.value)} +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, userRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + fireEvent.click(await screen.findByTestId('go-user')) + const contextEl = await screen.findByTestId('user-context') + const context = JSON.parse(contextEl.textContent) + + expect(receivedParams).toHaveBeenCalledWith({ userId: '42' }) + expect(context).toEqual(expect.objectContaining({ userId: '42' })) + }) + }) + + describe('context with invalidate edge cases', () => { + test('context with invalidate re-runs when loaderDeps change (new matchId)', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: z.object({ + page: z.number().optional(), + }), + loaderDeps: ({ search }) => ({ page: search.page }), + context: { + handler: () => { + mockContext() + return { loadedPage: undefined } + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + const search = indexRoute.useSearch() + return ( +
+ {JSON.stringify(search.value)} + + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('search')).toBeInTheDocument() + }) + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-page-1')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ page: 1 }), + ) + }) + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-page-2')) + await waitFor(() => { + expect(screen.getByTestId('search').textContent).toBe( + JSON.stringify({ page: 2 }), + ) + }) + expect(mockContext).toHaveBeenCalledTimes(1) + }) + + test('context with invalidate re-runs after GC', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+ Other + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history, defaultGcTime: 0 }) + + render() + + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + fireEvent.click(await screen.findByTestId('go-index')) + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + }) + + test('context returning undefined does not clobber context from parent and beforeLoad', async () => { + const rootRoute = createRootRoute({ + context: () => { + return { fromParent: 'parent-val' } + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: () => { + return { fromBeforeLoad: 'bl-val' } + }, + context: { + handler: () => { + return undefined + }, + revalidate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + fromParent: 'parent-val', + fromBeforeLoad: 'bl-val', + }), + ) + }) + + test('context with invalidate receives correct deps (loaderDeps)', async () => { + const receivedDeps = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: z.object({ sort: z.string().optional() }), + loaderDeps: ({ search }) => ({ sort: search.sort }), + context: { + handler: () => { + receivedDeps() + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(receivedDeps).toHaveBeenCalledTimes(1) + receivedDeps.mockClear() + + fireEvent.click(await screen.findByTestId('set-sort')) + await waitFor(() => { + expect(receivedDeps).toHaveBeenCalledTimes(1) + }) + }) + + test('context with invalidate receives cause "enter" on fresh navigation', async () => { + const receivedCause = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + context: { + handler: ({ cause }) => { + receivedCause(cause) + }, + revalidate: true, + }, + component: () =>
Other
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + expect(receivedCause).toHaveBeenCalledTimes(1) + expect(receivedCause).toHaveBeenCalledWith('enter') + }) + }) + + describe('context visibility per callback', () => { + test('beforeLoad sees context return from same route', async () => { + const beforeLoadContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { fromContext: 'visible' } + }, + beforeLoad: ({ context }) => { + beforeLoadContext(context) + return { fromBeforeLoad: 'bl' } + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + expect(beforeLoadContext).toHaveBeenCalledWith( + expect.objectContaining({ fromContext: 'visible' }), + ) + }) + + test('loader sees context + beforeLoad from same route', async () => { + const loaderContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { fromContext: 'ctx' } + }, + beforeLoad: () => { + return { fromBeforeLoad: 'bl' } + }, + loader: ({ context }) => { + loaderContext(context) + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + + expect(loaderContext).toHaveBeenCalledWith( + expect.objectContaining({ + fromContext: 'ctx', + fromBeforeLoad: 'bl', + }), + ) + }) + + test('context does NOT see same-route beforeLoad (only parent full context)', async () => { + const childContextFn = vi.fn() + + const rootRoute = createRootRoute() + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ parentContext: 'pctx' }), + beforeLoad: () => ({ parentBeforeLoad: 'pbl' }), + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: ({ context }) => { + childContextFn(context) + return { childContext: 'cctx' } + }, + beforeLoad: () => ({ childBeforeLoad: 'cbl' }), + component: () =>
Child
, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await router.navigate({ to: '/parent/child' }) + + render() + + await screen.findByTestId('child-page') + + // Child's context should see parent's FULL context (context + beforeLoad) + expect(childContextFn).toHaveBeenCalledWith( + expect.objectContaining({ + parentContext: 'pctx', + parentBeforeLoad: 'pbl', + }), + ) + // But NOT child's own beforeLoad or context + const calledWith = childContextFn.mock.calls[0]![0] + expect(calledWith).not.toHaveProperty('childBeforeLoad') + expect(calledWith).not.toHaveProperty('childContext') + }) + }) + + describe('parent-child selective GC', () => { + test('when child match is GC-ed but parent is not, only child context re-runs', async () => { + const parentContext = vi.fn() + const childContext = vi.fn() + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => { + parentContext() + return { parentMatched: true } + }, + component: () => ( +
+ Parent +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + gcTime: 0, + context: () => { + childContext() + return { childMatched: true } + }, + component: () => { + const navigate = childRoute.useNavigate() + return ( +
+ Child + +
+ ) + }, + }) + const parentIndexRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/', + component: () => { + const navigate = parentIndexRoute.useNavigate() + return ( +
+ Parent Index + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute, parentIndexRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await router.navigate({ to: '/parent/child' }) + + render() + + await screen.findByTestId('child-page') + expect(parentContext).toHaveBeenCalledTimes(1) + expect(childContext).toHaveBeenCalledTimes(1) + parentContext.mockClear() + childContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-parent-only')) + await screen.findByTestId('parent-index-page') + + expect(parentContext).not.toHaveBeenCalled() + + fireEvent.click(await screen.findByTestId('go-child')) + await screen.findByTestId('child-page') + + expect(parentContext).not.toHaveBeenCalled() + + expect(childContext).toHaveBeenCalledTimes(1) + }) + }) + + describe('context-with-invalidate-only routes (no loader)', () => { + test('route with only context (invalidate) and no loader works correctly', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + // No loader — only context with invalidate + context: { + handler: () => { + mockContext() + return { contextValue: 'from-context' } + }, + revalidate: true, + }, + component: () => { + const navigate = indexRoute.useNavigate() + const context = indexRoute.useRouteContext() + return ( +
+ {JSON.stringify(context.value)} + +
+ ) + }, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () => { + const navigate = otherRoute.useNavigate() + return ( +
+ Other + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, otherRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ contextValue: 'from-context' }), + ) + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + fireEvent.click(await screen.findByTestId('go-other')) + await screen.findByTestId('other-page') + + fireEvent.click(await screen.findByTestId('go-index')) + const contextEl2 = await screen.findByTestId('context') + expect(JSON.parse(contextEl2.textContent)).toEqual( + expect.objectContaining({ contextValue: 'from-context' }), + ) + expect(mockContext).not.toHaveBeenCalled() + }) + + test('route with only context (invalidate) re-runs on invalidate', async () => { + const mockContext = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + mockContext() + return { contextRun: mockContext.mock.calls.length } + }, + revalidate: true, + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(mockContext).toHaveBeenCalledTimes(1) + mockContext.mockClear() + + await router.invalidate() + + expect(mockContext).toHaveBeenCalledTimes(1) + }) + }) + + describe('context updates on invalidation', () => { + test('context with invalidate updates after each invalidation', async () => { + let counter = 0 + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + counter++ + return { counter } + }, + revalidate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await waitFor(() => { + const el = screen.getByTestId('context') + expect(JSON.parse(el.textContent).counter).toBe(1) + }) + + await router.invalidate() + + await waitFor(() => { + const el = screen.getByTestId('context') + expect(JSON.parse(el.textContent).counter).toBe(2) + }) + + await router.invalidate() + + await waitFor(() => { + const el = screen.getByTestId('context') + expect(JSON.parse(el.textContent).counter).toBe(3) + }) + }) + }) + + describe('context-only routes (no beforeLoad, no loader)', () => { + test('route with only context provides context to component', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { onlyContext: 'value' } + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ onlyContext: 'value' }), + ) + }) + }) + + describe('context overriding between lifecycle methods', () => { + test('later lifecycle methods can override earlier context keys', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => { + return { shared: 'from-context', contextOnly: 'ctx' } + }, + beforeLoad: () => { + return { shared: 'from-beforeLoad', beforeLoadOnly: 'bl' } + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + + // beforeLoad runs after context, so it wins for 'shared' + expect(context.shared).toBe('from-beforeLoad') + expect(context.contextOnly).toBe('ctx') + expect(context.beforeLoadOnly).toBe('bl') + }) + }) + + describe('object form lifecycle methods', () => { + test('object form beforeLoad handler runs and provides context', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: { + handler: () => ({ blValue: 'from-object-form' }), + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ blValue: 'from-object-form' }), + ) + }) + + test('object form context handler runs and provides context', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => ({ ctxValue: 'from-object-form' }), + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ ctxValue: 'from-object-form' }), + ) + }) + + test('object form context with invalidate handler runs and provides context', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => ({ ctxInvValue: 'from-object-form' }), + revalidate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ ctxInvValue: 'from-object-form' }), + ) + }) + + test('object form loader handler runs and provides loaderData', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: { + handler: () => ({ ldValue: 'from-object-form' }), + }, + component: () => { + const data = indexRoute.useLoaderData() + return ( +
{JSON.stringify(data.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const dataEl = await screen.findByTestId('loader-data') + expect(JSON.parse(dataEl.textContent)).toEqual({ + ldValue: 'from-object-form', + }) + }) + + test('mixed function and object form on the same route', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => ({ ctxObj: 'object-form' }), + dehydrate: false, + }, + beforeLoad: { + handler: () => ({ blObj: 'object-form' }), + }, + loader: () => ({ ldFunc: 'function-form' }), + component: () => { + const context = indexRoute.useRouteContext() + const data = indexRoute.useLoaderData() + return ( +
+ {JSON.stringify(context.value)} + + {JSON.stringify(data.value)} + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + ctxObj: 'object-form', + blObj: 'object-form', + }), + ) + + const dataEl = screen.getByTestId('loader-data') + expect(JSON.parse(dataEl.textContent)).toEqual({ + ldFunc: 'function-form', + }) + }) + + test('object form with dehydrate flag still runs handler on client navigation', async () => { + const contextHandler = vi.fn(() => ({ ctxVal: 'matched' })) + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const navigate = indexRoute.useNavigate() + return ( +
+ Index + +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + context: { + handler: contextHandler, + dehydrate: true, // dehydrate flag has no effect on SPA navigation + }, + component: () => { + const context = aboutRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + const router = createRouter({ routeTree, history }) + + render() + await screen.findByTestId('index-page') + + // Navigate to about + fireEvent.click(screen.getByTestId('go-about')) + const contextEl = await screen.findByTestId('context') + expect(JSON.parse(contextEl.textContent)).toEqual( + expect.objectContaining({ ctxVal: 'matched' }), + ) + expect(contextHandler).toHaveBeenCalledTimes(1) + }) + + test('object form backward compat: function form still works identically', async () => { + // This test verifies that routes using function form continue to work + // exactly as before, ensuring backward compatibility + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: () => ({ ctxFn: 'fn' }), + beforeLoad: () => ({ blFn: 'fn' }), + loader: () => ({ ldFn: 'fn' }), + component: () => { + const context = indexRoute.useRouteContext() + const data = indexRoute.useLoaderData() + return ( +
+ {JSON.stringify(context.value)} + + {JSON.stringify(data.value)} + +
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + ctxFn: 'fn', + blFn: 'fn', + }), + ) + + const dataEl = screen.getByTestId('loader-data') + expect(JSON.parse(dataEl.textContent)).toEqual({ ldFn: 'fn' }) + }) + + test('object form context chain flows correctly parent to child', async () => { + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: { + handler: () => ({ parentOM: 'p-om' }), + }, + beforeLoad: { + handler: () => ({ parentBL: 'p-bl' }), + dehydrate: false, + }, + component: () => ( +
+ +
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: { + handler: ({ context }) => ({ + childCtx: 'c-ctx', + sawParentOM: context.parentOM, + sawParentBL: context.parentBL, + }), + revalidate: true, + }, + component: () => { + const context = childRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + await router.navigate({ to: '/parent/child' }) + + render() + + const contextEl = await screen.findByTestId('context') + const context = JSON.parse(contextEl.textContent) + expect(context).toEqual( + expect.objectContaining({ + parentOM: 'p-om', + parentBL: 'p-bl', + childCtx: 'c-ctx', + sawParentOM: 'p-om', + sawParentBL: 'p-bl', + }), + ) + }) + + test('object form context runs only once even with dehydrate flag', async () => { + const contextCount = vi.fn() + + const rootRoute = createRootRoute({ + component: () => ( +
+ +
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + contextCount() + return { matched: true } + }, + dehydrate: true, + }, + component: () => { + const context = indexRoute.useRouteContext() + return ( +
{JSON.stringify(context.value)}
+ ) + }, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('context') + expect(contextCount).toHaveBeenCalledTimes(1) + + // Invalidate and verify context doesn't re-run (no invalidate flag) + await router.invalidate() + + expect(contextCount).toHaveBeenCalledTimes(1) + }) + + test('object form context with revalidate re-runs on invalidation', async () => { + const contextCount = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + context: { + handler: () => { + contextCount() + return { loadCount: contextCount.mock.calls.length } + }, + revalidate: true, + }, + component: () =>
Index
, + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await screen.findByTestId('index-page') + expect(contextCount).toHaveBeenCalledTimes(1) + + await router.invalidate() + + // context with revalidate should re-run on invalidation + expect(contextCount).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/packages/vue-router/tests/useRouteContext.test-d.tsx b/packages/vue-router/tests/useRouteContext.test-d.tsx index 134444f30a0..949a21a04ea 100644 --- a/packages/vue-router/tests/useRouteContext.test-d.tsx +++ b/packages/vue-router/tests/useRouteContext.test-d.tsx @@ -218,6 +218,357 @@ test('when there are multiple contexts', () => { >() }) +test('when context returns context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ invoicePermissions: true }), + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + ]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + invoicePermissions: boolean + }> + >() + + // child inherits parent context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + invoicePermissions: boolean + }> + >() +}) + +test('when context with revalidate returns context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: { handler: () => ({ invoiceList: [1, 2, 3] }), revalidate: true }, + }) + + const invoiceRoute = createRoute({ + getParentRoute: () => invoicesRoute, + path: '$invoiceId', + }) + + const routeTree = rootRoute.addChildren([ + invoicesRoute.addChildren([invoiceRoute]), + ]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + invoiceList: Array + }> + >() + + // child inherits parent context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + invoiceList: Array + }> + >() +}) + +test('when context + beforeLoad all return context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ fromContext: 'match-data' }), + beforeLoad: () => ({ fromBeforeLoad: 'before-data' }), + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + fromContext: string + fromBeforeLoad: string + }> + >() +}) + +test('when child route sees parent context + beforeLoad context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'parent', + context: () => ({ parentContext: 'match' }), + beforeLoad: () => ({ parentBeforeLoad: 'before' }), + }) + + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: 'child', + context: () => ({ childContext: 'child-match' }), + beforeLoad: () => ({ childBeforeLoad: 'child-before' }), + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // parent only has its own context + expectTypeOf(useRouteContext).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + parentContext: string + parentBeforeLoad: string + }> + >() + + // child inherits all parent context plus its own + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + parentContext: string + parentBeforeLoad: string + childContext: string + childBeforeLoad: string + }> + >() +}) + +test('when context uses as const return type', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ status: 'active' }) as const, + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + readonly status: 'active' + }> + >() +}) + +test('when overlapping keys across context and beforeLoad', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ shared: 'from-context' }) as const, + beforeLoad: () => ({ shared: 'from-beforeLoad' }) as const, + }) + + const routeTree = rootRoute.addChildren([invoicesRoute]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // beforeLoad wins because it's the last Assign in the chain + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + readonly shared: 'from-beforeLoad' + }> + >() +}) + +test('when non-strict mode with context across routes', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()() + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ invoiceData: 'data' }), + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + context: { handler: () => ({ postData: 'data' }), revalidate: true }, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + invoicesRoute, + postsRoute, + ]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // non-strict mode unions all possible context shapes + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId?: string + invoiceData?: string + postData?: string + }> + >() +}) + +test('when root route has context', () => { + interface Context { + userId: string + } + + const rootRoute = createRootRouteWithContext()({ + context: () => ({ rootContext: 'root-match' }), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const invoicesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'invoices', + context: () => ({ invoiceContext: 'inv-match' }), + }) + + const routeTree = rootRoute.addChildren([indexRoute, invoicesRoute]) + + const defaultRouter = createRouter({ + routeTree, + context: { userId: 'userId' }, + }) + + type DefaultRouter = typeof defaultRouter + + // index route inherits root context + expectTypeOf(useRouteContext).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + rootContext: string + }> + >() + + // invoices route has root + its own context + expectTypeOf( + useRouteContext, + ).returns.toEqualTypeOf< + Vue.Ref<{ + userId: string + rootContext: string + invoiceContext: string + }> + >() +}) + test('when there are overlapping contexts', () => { interface Context { userId: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05051f53f91..8e85081c27e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2454,6 +2454,52 @@ 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.8.1) + e2e/react-start/router-lifecycle-methods: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + 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.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(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.8.1)) + devDependencies: + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@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: ^4.3.4 + version: 4.7.0(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.8.1)) + srvx: + specifier: ^0.11.2 + version: 0.11.15 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + e2e/react-start/rsc: dependencies: '@tanstack/react-router': @@ -26202,6 +26248,14 @@ packages: '@testing-library/jest-dom': optional: true + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: ^8.0.14 + peerDependenciesMeta: + vite: + optional: true + vite-tsconfig-paths@6.1.1: resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: @@ -40303,6 +40357,10 @@ snapshots: ts-pattern@5.6.2: {} + tsconfck@3.1.4(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + tsconfck@3.1.4(typescript@6.0.2): optionalDependencies: typescript: 6.0.2 @@ -40713,6 +40771,17 @@ snapshots: transitivePeerDependencies: - supports-color + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(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.8.1)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.4(typescript@5.9.3) + optionalDependencies: + 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.8.1) + transitivePeerDependencies: + - supports-color + - typescript + vite-tsconfig-paths@6.1.1(typescript@6.0.2)(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.8.1)): dependencies: debug: 4.4.3