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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
9 changes: 9 additions & 0 deletions .changeset/eslint-lifecycle-object-form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/eslint-plugin-router': minor
---

Lint the lifecycle object form of `context`, `beforeLoad` and `loader`.

- `create-route-property-order` now also checks and fixes the key order inside the object form: `handler`, `dehydrate`, `hydrate`, then `revalidate` / `staleReloadMode`. Quoted keys (`'loader'`) are now checked and fixed like unquoted ones. Its fixer keeps the members it does not order (unknown and computed keys) instead of dropping them, and no longer throws on `...fn()` spreads. The fixer never moves a key across a spread or a computed key, since that can change which value wins, and a spread is never taken for the key its variable is named after. When the order can only be fixed by such a move, or when a comment belongs to a key in the range the fix reorders, the rule reports without fixing.
- All rules recognize `createRoute`, `createFileRoute` and the other factories through their import from a TanStack Router package, also under an alias or through a namespace import. A local binding with the same name, or a `createRoute` imported from another package, is not checked.
- New rule `no-lifecycle-codec-param-annotations`, enabled in the recommended configs: the parameters of `dehydrate`, `hydrate` and `revalidate` are inferred and must not be annotated.
7 changes: 7 additions & 0 deletions .changeset/lifecycle-client-follow-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/router-core': patch
---

- A route whose `context` fails keeps the context it inherited (the parent context, router context included), as a failed `beforeLoad` does: on its error match after a client navigation, and on its pending match while hydration recovers. Before, that match had an empty context, so `head`, the error component and the pending component lost the router context.
- In development, hydration warns when it hands a failed route to the client load, naming the route, and when it ignores a failure on a route the server rendered as an error or not-found (a failing `context`, or a throwing `context`, `beforeLoad` or `loader` `hydrate`), naming the route and the lifecycle. The second warning is not printed for a route the client load runs again.
- In development, calling a lazily imported object-form loader fails with an error that says to write it in the route file.
27 changes: 27 additions & 0 deletions .changeset/lifecycle-object-form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@tanstack/router-core': minor
'@tanstack/react-router': minor
'@tanstack/solid-router': minor
'@tanstack/vue-router': minor
---

`context`, `beforeLoad` and `loader` accept an object form `{ handler, dehydrate, hydrate }` that controls what crosses the wire during SSR. The route `context` option and the `dehydrate` and `hydrate` keys are experimental: they may still change in a minor release before they are declared stable.

- `dehydrate: true` sends the result to the client, `dehydrate: false` keeps it on the server (the client re-runs the lifecycle during hydration), a `dehydrate` function sends a custom wire value and `hydrate` rebuilds the value on the client from it and the route's context. Both may be async; the awaited values are used in the types. Defaults: `context` is not sent, `beforeLoad` and `loader` are.
- During SSR hydration the client restores what was sent and re-runs what was not: `context` and `beforeLoad` route by route, then the loaders in parallel. A failure there (an error, a thrown `redirect()` or `notFound()` or one returned from `beforeLoad` or `loader`, a throwing `hydrate`) is not handled by hydration: the route is shown as pending and the normal client load after hydration runs the lifecycle's handler again and handles the outcome as a client navigation does (data, error, redirect or not-found). A route the server already rendered as an error or not-found, and the root route when it shows the not-found page, keep that result instead: during hydration only their `context` and the `hydrate` of sent results run there. A `beforeLoad` or `loader` result that was not sent is missing there in the browser, and a failure there makes nothing run again and leaves the result that failed missing. A lifecycle that the client re-runs during hydration and that fails there therefore runs twice on the client, side effects included. `hydrate` never shows an `errorComponent` or calls `onError` by itself. `dehydrate` and `hydrate` are transforms: a `redirect()` or `notFound()` thrown from `dehydrate` is an ordinary error of that match.
- `hydrate` requires `dehydrate` (`true` or a function); a `dehydrate` function requires `hydrate`. Key order is `handler`, `dehydrate`, `hydrate`, then `revalidate` or `staleReloadMode`; the parameters of the codec halves are inferred and must not be annotated. The pairing rules are type errors; the order and annotation rules are enforced by `@tanstack/eslint-plugin-router`.
- `hydrate` may return a different type than `handler`. The lifecycle's data type is then the union of both and consumers narrow on a literal discriminant written with `as const` on both sides (`hydrated: false` in `handler`, `hydrated: true` in `hydrate`). A `hydrate` result that is assignable to the handler data reduces to it, so a same-shape `hydrate` changes nothing and a property that only `hydrate` adds is not visible.
- `context` is a cached lifecycle: it may be async, it runs once per match, serially and interleaved with `beforeLoad` (parent `context`, parent `beforeLoad`, child `context`, ...). It receives `deps` and not `search`. `context: { handler, revalidate }` re-runs it on `router.invalidate()`, as `revalidate: true` or as a function that receives `prev`.
- `context` receives TanStack Start's `serverContext` on the server, like `beforeLoad` and `loader`.
- On the server, a route with `ssr: false` (and every child route of a shell render) no longer runs `context`. Its server-side `head`, `scripts` and `headers` see only the inherited context, and an error thrown by that `context` now shows up on the client, which runs it.
- A server `context` that fails now leaves the match with the inherited context, as a failed `beforeLoad` does.
- `router.invalidate()` does not re-run a route's `context` unless `revalidate` is set. This matches the previous runtime behavior and corrects the docs, which said the context was recomputed.
- Loader results are serializability-checked again in Start apps with SSR, like `beforeLoad` results. Existing loaders returning `unknown`, `Record<string, unknown>` or class instances with methods surface type errors: return serializable data, set `dehydrate: false`, or register a serialization adapter. A `dehydrate` whose return type is `unknown` is rejected the same way. A `redirect()` or `notFound()` returned from `beforeLoad` or `loader` is not data and is not checked; before, a returned `redirect()` failed the `beforeLoad` check.
- Code-based `createRoute` routes get this check for the first time in Start apps, for `beforeLoad`, `loader` and a sent `context` alike. Before, they were never checked.
- `Route.options.beforeLoad` and `Route.options.context` may now be objects, so calling them directly (`Route.options.beforeLoad?.(ctx)`, common in unit tests) is a type error. Narrow with `typeof option === 'function'`, or call `option.handler`.
- A bare `vi.fn()` written inline as `context`, `beforeLoad` or `loader` is a type error now. Give it an implementation or a type argument (`vi.fn(() => {})`, `vi.fn<() => void>()`).
- With `dehydrate: false` or a custom `dehydrate`, the server handles rejections of promises in the top-level fields of a plain-object loader result, since the stream never sees them, and aborts the loader's pending work when the response is done (right away when `dehydrate` throws). Promises in arrays, class instances or nested objects get no such handler.
- A reused server router no longer renders loader data from its previous load.
- `router.invalidate()` and `onLeave` no longer throw for a match whose route `router.update` removed from the route tree.
- `DehydratedMatch.l` is typed `unknown`: a custom `dehydrate` may send any serializable value.
- The published types use TypeScript 5.4's `NoInfer`, so TypeScript 5.4 or newer is required (the docs said 5.3).
5 changes: 5 additions & 0 deletions .changeset/router-core-reused-server-router.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

A server router that runs `load()` more than once starts each load from fresh match state. Before, a match that turned `ssr: false` was sent with the previous load's status and error, a route's `ssr` function saw the previous load's `ssr` results of child matches, and a failing `head` kept the previous load's head tags and response headers.
5 changes: 5 additions & 0 deletions .changeset/router-plugin-nested-delete-nodes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-plugin': minor
---

`codeSplittingOptions.deleteNodes` accepts nested paths such as `loader.dehydrate`, which remove one key from a lifecycle object without touching the rest of it.
11 changes: 11 additions & 0 deletions .changeset/router-plugin-object-form-loader-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/router-plugin': patch
---

Loader code splitting works with the object form of `loader` (`{ handler, dehydrate, hydrate, staleReloadMode }`). Only `handler` moves to the loader chunk; the other keys stay in the route file, where the router reads them without loading the chunk. Before, an object-form loader in a `loader` split group failed the build with "Unexpected splitNode type: ObjectExpression".

Also in the loader split: a `handler` written as a getter or setter leaves the loader unsplit; an imported handler keeps its import when another key of the route still uses it (`hydrate: ({ data }) => fetchPosts.parse(data)`), and the import is removed only when nothing else references it; `satisfies`, `!` and `<T>` around the loader function no longer break the loader chunk, and the handler may sit behind these wrappers, `as` or parentheses.

An object-form loader held in a `const` of the same file (`const postsLoader = { handler, dehydrate: true }` and `loader: postsLoader`) is split by its `handler` too; before, the chunk exported the whole object and calling the loader threw a TypeError. The loader is left unsplit when that variable is exported, reassigned or read anywhere besides `loader`, types aside (another option such as `head` calling `postsLoader.handler(ctx)` gets the function it wrote, not the lazy wrapper); when a split option such as `component` reads it, the object moves to the shared module together with what its handler uses, so the route and the chunk read one object and one copy of its state instead of each initializing their own. A `handler` method that uses `super` stays in its object. `deleteNodes` strips such a `const` as it strips an inline literal, in every module that keeps it: the route file, the shared module, and a chunk that keeps a copy for top-level code reading it. A binding exported with a specifier (`export { loadPosts }`) counts as exported: the option is left unsplit with the usual warning, for the function form, the object form and components alike; before, the specifier was overlooked and the reference module lost the declaration. A loader or component that is a fallback literal under a TypeScript wrapper (`loader: undefined as any`, `component: null!`) is kept in place like the bare literal. An imported component keeps its import in the route file while another option still reads it (`head: () => ({ meta: [{ title: PostsComponent.title }] })`); before, the import was removed and the option threw a ReferenceError. A side-effect import of the component's module (`import './posts-component'`) is no longer removed from the route file either.

The keys `deleteNodes` removes no longer count when bindings are shared between the route file and its chunks: a helper only a removed key used (`dehydrate: ({ data }) => toWire(data)` on the client) leaves the shared module with its import, and a helper the handler shares only with a removed key goes to the loader chunk instead of a shared module. Route options passed as `{ ... } satisfies RouteOptions` or `as`, inline or through a variable, are split and stripped like a bare literal; before, they were left untouched. A split component behind `satisfies` or `!`, and a handler or loader that is a member or other expression (`handler: api.list`), no longer fail the build with "Unexpected splitNode type". An exported component behind `as`, `satisfies` or `!` stays in the route file like the bare identifier, with the same "will not be code-split" warning; before, `as` split it. A split component that top-level code also reads (`PostsComponent.displayName = 'Posts'`) keeps its declaration in the route file; before, the declaration was removed and loading the route threw a ReferenceError. An exported loader or component that an option left in the route file also reads is no longer moved to the shared module, since the route file keeps it anyway. A split loader, handler or component that is a call with type arguments or annotated parameters (`makeLoader<Post>('posts')`, `wrap((name: string) => name)`) keeps them in the chunk; before, `f<T>(x)` became a comparison and an annotated parameter failed the build.
7 changes: 7 additions & 0 deletions .changeset/start-plugin-lifecycle-stripping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/start-plugin-core': minor
---

Strip the lifecycle object-form keys each bundle never reads from the route options in the route file (written inline or held in a variable of the same file; the code-splitting guide lists the exact scope): the client build drops `context.dehydrate`, `beforeLoad.dehydrate` and `loader.dehydrate`; the server build drops `context.hydrate`, `beforeLoad.hydrate`, `loader.hydrate` and `context.revalidate`. Imports used only inside a removed key are removed with it.

A `codeSplittingOptions.deleteNodes` list in your Start router config now applies to the client and the server build of both Vite and Rsbuild, with the built-in entries added after it. Before, Vite's client build replaced it with `['ssr', 'server', 'headers']` and Rsbuild ignored it in both builds, so keys you list there are now removed from builds that kept them.
5 changes: 5 additions & 0 deletions .changeset/start-route-context-server-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/start-client-core': minor
---

The route `context` option types `serverContext` like `beforeLoad` and `loader` do: in the function form and in the object form's `handler`, `dehydrate`, `hydrate` and `revalidate`, typed from the route's server middleware (`undefined` on the client).
4 changes: 4 additions & 0 deletions benchmarks/client-nav/scenarios/hydration/react/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export function setup({ countRenders = false } = {}) {
ReadableStream,
WritableStream,
TransformStream,
// Browsers have Response; jsdom does not. The Router detects redirects
// with `instanceof Response` on beforeLoad and loader results, including
// context restored during hydration.
Response,
// Let React's production scheduler use the same task-turn primitive as
// the existing Node client benchmarks, without a real-delay timer.
setImmediate(
Expand Down
4 changes: 4 additions & 0 deletions benchmarks/client-nav/scenarios/hydration/solid/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export function setup({ countUpdates = false } = {}) {
ReadableStream,
WritableStream,
TransformStream,
// Browsers have Response; jsdom does not. The Router detects redirects
// with `instanceof Response` on beforeLoad and loader results, including
// context restored during hydration.
Response,
setImmediate(
callback: (...args: Array<unknown>) => void,
...args: Array<unknown>
Expand Down
Loading
Loading