Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -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<TValue, TWire> =
| undefined
| false
| true
| ((ctx: { data: TValue }) => TWire)

type HydrateOption<TValue, TWire> = (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.
7 changes: 4 additions & 3 deletions docs/router/guide/data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
31 changes: 22 additions & 9 deletions docs/router/guide/router-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<YourContextTypeHere>()(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<YourContextTypeHere>()(routeOptions)` function to create a new router context instead of the `createRootRoute()` function to create your root route. Here's an example:

<!-- ::start:framework -->

Expand Down Expand Up @@ -76,7 +76,7 @@ const router = createRouter({
<!-- ::end:framework -->

> [!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

Expand Down Expand Up @@ -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 `<RouterProvider />` 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`.

<!-- ::start:framework -->

Expand Down Expand Up @@ -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.

<!-- ::start:framework -->

Expand Down Expand Up @@ -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
},
})
```
Expand Down Expand Up @@ -497,6 +508,8 @@ export const Route = createFileRoute('/todos')({

<!-- ::end:framework -->

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:
Expand Down
8 changes: 8 additions & 0 deletions docs/start/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading