diff --git a/.changeset/eslint-lifecycle-object-form.md b/.changeset/eslint-lifecycle-object-form.md new file mode 100644 index 00000000000..49e04f22bb0 --- /dev/null +++ b/.changeset/eslint-lifecycle-object-form.md @@ -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. diff --git a/.changeset/lifecycle-client-follow-up.md b/.changeset/lifecycle-client-follow-up.md new file mode 100644 index 00000000000..f3d957b657f --- /dev/null +++ b/.changeset/lifecycle-client-follow-up.md @@ -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. diff --git a/.changeset/lifecycle-object-form.md b/.changeset/lifecycle-object-form.md new file mode 100644 index 00000000000..8db1b730e40 --- /dev/null +++ b/.changeset/lifecycle-object-form.md @@ -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` 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). diff --git a/.changeset/router-core-reused-server-router.md b/.changeset/router-core-reused-server-router.md new file mode 100644 index 00000000000..40000b18b3f --- /dev/null +++ b/.changeset/router-core-reused-server-router.md @@ -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. diff --git a/.changeset/router-plugin-nested-delete-nodes.md b/.changeset/router-plugin-nested-delete-nodes.md new file mode 100644 index 00000000000..83f0bf6028f --- /dev/null +++ b/.changeset/router-plugin-nested-delete-nodes.md @@ -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. diff --git a/.changeset/router-plugin-object-form-loader-split.md b/.changeset/router-plugin-object-form-loader-split.md new file mode 100644 index 00000000000..41b38304d69 --- /dev/null +++ b/.changeset/router-plugin-object-form-loader-split.md @@ -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 `` 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('posts')`, `wrap((name: string) => name)`) keeps them in the chunk; before, `f(x)` became a comparison and an annotated parameter failed the build. diff --git a/.changeset/start-plugin-lifecycle-stripping.md b/.changeset/start-plugin-lifecycle-stripping.md new file mode 100644 index 00000000000..34f86a95641 --- /dev/null +++ b/.changeset/start-plugin-lifecycle-stripping.md @@ -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. diff --git a/.changeset/start-route-context-server-context.md b/.changeset/start-route-context-server-context.md new file mode 100644 index 00000000000..793489b462c --- /dev/null +++ b/.changeset/start-route-context-server-context.md @@ -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). diff --git a/benchmarks/client-nav/scenarios/hydration/react/setup.ts b/benchmarks/client-nav/scenarios/hydration/react/setup.ts index 7c588af3fd4..c89cb036ba0 100644 --- a/benchmarks/client-nav/scenarios/hydration/react/setup.ts +++ b/benchmarks/client-nav/scenarios/hydration/react/setup.ts @@ -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( diff --git a/benchmarks/client-nav/scenarios/hydration/solid/setup.ts b/benchmarks/client-nav/scenarios/hydration/solid/setup.ts index ebdbc8e9b2c..a39235e88ee 100644 --- a/benchmarks/client-nav/scenarios/hydration/solid/setup.ts +++ b/benchmarks/client-nav/scenarios/hydration/solid/setup.ts @@ -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) => void, ...args: Array diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index 2adfc6e5c68..ed2ce2ab124 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -100,12 +100,114 @@ The `RouteOptions` type accepts an object with the following properties: - Type: `(params: TParams) => Record` - A function that will be called when this route's parsed params are being used to build a location. This function should return a valid object of `Record` mapping. +### Lifecycle object form + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +`context`, `beforeLoad` and `loader` each accept either a function or an object with a `handler` function and a few keys next to it: + +```tsx +type context = + | contextFn + | { + handler: contextFn + dehydrate?: boolean | DehydrateFn + hydrate?: HydrateFn + revalidate?: boolean | RevalidateFn + } + +type beforeLoad = + | beforeLoadFn + | { + handler: beforeLoadFn + dehydrate?: boolean | DehydrateFn + hydrate?: HydrateFn + } + +type loader = + | loaderFn + | { + handler: loaderFn + dehydrate?: boolean | DehydrateFn + hydrate?: HydrateFn + staleReloadMode?: 'background' | 'blocking' + } + +type DehydrateFn = (opts: HandlerOpts & { data: TData }) => TWire // server; TWire must be serializable +type HydrateFn = (opts: HandlerOpts & { data: TWire | TData }) => TData // client; data is TWire when dehydrate is a function +type RevalidateFn = ( + opts: HandlerOpts & { prev: TData | undefined }, +) => Promise | TData +``` + +- `handler` is the lifecycle function. It is required in the object form and receives the same options as the function form. +- `dehydrate` and `hydrate` control what the server sends to the client during SSR. `dehydrate: true` sends the handler's result; `dehydrate: false` keeps it on the server, and the client runs the handler again during hydration; a `dehydrate` function receives the handler's options plus `data` (the awaited result) and returns the wire value; `hydrate` receives the options plus `data` (the wire value when `dehydrate` is a function, otherwise the full result) and returns the value the app uses on the client. Defaults: `context` is not sent, `beforeLoad` and `loader` are. A sent value must be serializable. TanStack Start type-checks this, and so does router-only SSR when `Register` has `ssr: true` (see [SSR](../../guide/ssr.md#what-crosses-the-wire)). Routes with `ssr: false` are not checked. +- `revalidate` exists on `context` only; `staleReloadMode` on `loader` only. +- A `dehydrate` function requires `hydrate`. `hydrate` requires `dehydrate` (`true` or a function); without a `dehydrate` key, or with `dehydrate: false`, `hydrate` is rejected. +- Write the keys in this order: `handler`, `dehydrate`, `hydrate`, then `revalidate` or `staleReloadMode`. The parameters of the later keys are inferred from the earlier ones. The [`create-route-property-order`](../../eslint/create-route-property-order.md) lint rule checks the order and can fix it. TypeScript does not check the order. A wrong order shows up as a type error only when it breaks that inference, and the error rarely says so: + - A `handler` that reads its options, written after a function that reads `data` or `prev`, gets an error that says to write `handler` first. This is the error when the object has a `dehydrate` function or `dehydrate: false`. + - With `dehydrate: true`, or without a `dehydrate` key, the same `handler` gets an error that its result is not assignable to `never`. That error needs the serializability check from the item above. Without the check, the order compiles next to `dehydrate: true`, and without a `dehydrate` key the error says that `dehydrate` is missing. + - A `hydrate` that reads `data`, written before a `dehydrate` function, gets an error that the `dehydrate` function is not assignable to `true`. + - A `revalidate` that reads `prev`, written before a `hydrate` that returns a different shape, gets an error about the type `hydrate` returns. + + Other wrong orders compile. When one of these errors appears, fix the key order. Do not add `dehydrate: true` or switch to it to make the error go away: that sends the handler's full result to the client. + +- Never annotate the parameters of `dehydrate`, `hydrate` or `revalidate`. They are inferred. Next to `dehydrate: true`, write `hydrate` inline: a `hydrate` passed by reference or typed through `satisfies` or `as` decides the type of `data` itself, and neither TypeScript nor the lint rule checks it against `handler`. +- `dehydrate` and `hydrate` may be async; the awaited value is used. They only transform a value; throw `redirect()` or `notFound()` from `handler`. A throw from `dehydrate`, a `redirect()` or `notFound()` included, is an ordinary error of that match. A throw from `hydrate`, or a `redirect()` or `notFound()` returned from the `hydrate` of `beforeLoad` or `loader`, is a failure during hydration (next item): the client load runs `handler` instead, and `hydrate` itself never shows the `errorComponent` or calls `onError`. +- A lifecycle that fails while the client runs it during hydration (an error, a thrown `redirect()` or `notFound()` or one returned from `beforeLoad` or `loader`, or a throwing `hydrate`) is not handled by hydration. The route is shown as pending and the client load that follows hydration runs it again; that run's outcome (data, error, redirect or not-found) is handled as on a client navigation. A lifecycle that the client re-ran during hydration and that failed there therefore runs twice on the client, and so do its side effects; some lifecycles that succeeded during hydration run again as well. 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: 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. A failure there makes nothing run again, and the result that failed is missing too. See [When a lifecycle fails during hydration](/start/latest/docs/framework/react/guide/ssr-data-transport#when-a-lifecycle-fails-during-hydration). +- `hydrate` may return a different type than `handler`. The lifecycle's data type is then the union of both; a `hydrate` result that is assignable to the handler's data reduces to it, so a property that only `hydrate` adds to a matching shape is not visible. Narrow on a literal written with `as const` on both sides that says which function produced the value (`hydrated: false` in `handler`, `hydrated: true` in `hydrate`), not on server versus client: `handler` also runs in the browser on client navigations. See [Narrow with a discriminant](/start/latest/docs/framework/react/guide/ssr-data-transport#narrow-with-a-discriminant). +- On a route with `ssr: true`, a lifecycle with `dehydrate: false` must produce the same value on the client as on the server, and a `hydrate` result must render the same as the server's value; otherwise the first client render differs from the server HTML. Use `ssr: 'data-only'` when they cannot be the same. +- Removing `dehydrate` from the client bundle and `hydrate`/`revalidate` from the server bundle is done by the TanStack Start build; see [SSR Data Transport](/start/latest/docs/framework/react/guide/ssr-data-transport). In a router-only setup both halves stay in both bundles unless each production build gets its own `deleteNodes` list; see [Removing route options from a build](../../guide/automatic-code-splitting.md#removing-route-options-from-a-build-deletenodes). + +### `context` method + +> **Experimental:** the route `context` option is experimental and subject to change. + +- Type: + +```tsx +type contextFn = (opts: { + abortController: AbortController + preload: boolean + params: TAllParams + deps: TLoaderDeps + context: TParentContext + location: ParsedLocation + matches: Array + routeId: TRouteId + navigate: NavigateFn // @deprecated + buildLocation: BuildLocationFn + cause: 'preload' | 'enter' | 'stay' + serverContext?: TServerContext // TanStack Start only, undefined on the client +}) => Promise | TRouteContext | void + +type context = + | contextFn + | { + handler: contextFn + dehydrate?: boolean | DehydrateFn + hydrate?: HydrateFn + revalidate?: boolean | RevalidateFn + } +``` + +- Optional +- [`ParsedLocation`](./ParsedLocationType.md) +- Runs before this route's `beforeLoad`. The lifecycles run serially, one route after the other, parent first: `context` then `beforeLoad` of the first match, then `context` then `beforeLoad` of the second match, and so on. Loaders run after that. +- `opts.context` is the parent's effective context: the router context plus every ancestor's `context` result and `beforeLoad` result. The returned object is merged into the context that this route's `beforeLoad`, `loader`, components and child routes see. +- May return a promise. The route is pending while it resolves and the awaited value is merged. +- Receives `deps`, the result of `loaderDeps`, and not `search`. The result is cached per match, and a match is keyed by its params and `loaderDeps`, so a `context` must not depend on search params outside `loaderDeps`. +- Computed once per match and reused on later loads. `router.invalidate()` does not re-run it unless `revalidate` is set. A navigation that starts while a preload's async `context` for the same match is still running runs it again. +- `revalidate: true` runs `handler` again on `router.invalidate()`. `revalidate: fn` runs that function instead; it receives `prev`, the current context data (the last successful `handler`, `hydrate` or `revalidate` result, or `undefined` if none has succeeded yet), and must return the handler's shape. There is no time-based revalidation of context. +- If it throws, this route's `beforeLoad`, its loader and its descendants do not run, and the error becomes the match's error state. +- During SSR the result is not sent to the client by default; the client computes it again during hydration. `dehydrate: true` sends it. See [Lifecycle object form](#lifecycle-object-form). + ### `beforeLoad` method - Type: ```tsx -type beforeLoad = ( +type beforeLoadFn = ( opts: RouteMatch & { search: TFullSearchSchema abortController: AbortController @@ -118,14 +220,24 @@ type beforeLoad = ( cause: 'preload' | 'enter' | 'stay' }, ) => Promise | TRouteContext | void + +type beforeLoad = + | beforeLoadFn + | { + handler: beforeLoadFn + dehydrate?: boolean | DehydrateFn + hydrate?: HydrateFn + } ``` - Optional - [`ParsedLocation`](./ParsedLocationType.md) -- This async function is called before a route is loaded. If it fails, the route's loader and its descendants will not run. During navigation, ordinary errors become the match's error state and are passed to the `onError` function. During a preload, ordinary errors and not-found results are represented in the returned speculative match lane instead of rejecting the `preloadRoute` promise. +- This async function is called before a route is loaded, after the route's `context`. It runs on every load; it is not cached. If it fails, the route's loader and its descendants will not run. During navigation, ordinary errors become the match's error state and are passed to the `onError` function. During a preload, ordinary errors and not-found results are represented in the returned speculative match lane instead of rejecting the `preloadRoute` promise. - If this function returns a promise, the route will be put into a pending state and cause rendering to suspend until the promise resolves. If this route's pendingMs threshold is reached, the `pendingComponent` will be shown until it resolves. If the promise rejects, the route will be put into an error state and the error will be thrown during render. - If this function returns a `TRouteContext` object, that object will be merged into the route's context and be made available in the `loader` and other related route components/methods. +- `opts.context` is the parent context plus this route's own `context` result. - It's common to use this function to check if a user is authenticated and redirect them to a login page if they are not. To do this, you can either return or throw a `redirect` object from this function. +- During SSR the result is sent to the client by default. The object form adds `dehydrate` and `hydrate` (experimental); see [Lifecycle object form](#lifecycle-object-form). > 🚧 `opts.navigate` has been deprecated and will be removed in the next major release. Use `throw redirect({ to: '/somewhere' })` instead. Read more about the `redirect` function [here](./redirectFunction.md). @@ -153,6 +265,8 @@ type loader = | loaderFn | { handler: loaderFn + dehydrate?: boolean | DehydrateFn + hydrate?: HydrateFn staleReloadMode?: 'background' | 'blocking' } ``` @@ -163,9 +277,11 @@ type loader = - If this function returns a promise, the route will be put into a pending state and cause rendering to suspend until the promise resolves. If this route's pendingMs threshold is reached, the `pendingComponent` will be shown until it resolves. If the promise rejects, the route will be put into an error state and the error will be thrown during render. - If this function returns a `TLoaderData` object, that object will be stored on the route match and can remain available in the in-memory cache after the match becomes inactive. Navigation-owned data uses `gcTime` for retention, while preload-owned data uses `preloadGcTime`. It can be accessed using the `useLoaderData` hook in any component that is a child of the route match before another `` is rendered. - Deps must be returned by your `loaderDeps` function in order to appear. -- Use the object form to configure loader-specific behavior like `staleReloadMode`. +- `opts.context` is the parent context plus this route's own `context` and `beforeLoad` results. +- Use the object form to configure loader-specific behavior like `staleReloadMode`, `dehydrate` and `hydrate` (`dehydrate` and `hydrate` are experimental); see [Lifecycle object form](#lifecycle-object-form). - `staleReloadMode: 'background'` preserves stale-while-revalidate behavior for stale successful matches. - `staleReloadMode: 'blocking'` waits for the stale loader reload to complete before continuing. +- During SSR the loader data is sent to the client by default. In TanStack Start apps its type is checked for serializability; `dehydrate: false` or a serialization adapter lifts that requirement. > 🚧 `opts.navigate` has been deprecated and will be removed in the next major release. Use `throw redirect({ to: '/somewhere' })` instead. Read more about the `redirect` function [here](./redirectFunction.md). diff --git a/docs/router/api/router/RouterType.md b/docs/router/api/router/RouterType.md index 3fde90e32e5..0e3a54a2cd2 100644 --- a/docs/router/api/router/RouterType.md +++ b/docs/router/api/router/RouterType.md @@ -129,15 +129,15 @@ Navigates to a new location. ### `.invalidate` method -Invalidates selected route-match generations, reruns their loading lifecycle, -reruns `beforeLoad`, and reloads their loaders through the normal loading -protocol. +Invalidates selected route-match generations, reruns `beforeLoad`, and reloads +their loaders through the normal loading protocol. A route `context` runs again +only when it sets `revalidate`. - Type: `(opts?: {filter?: (d: MakeRouteMatchUnion) => boolean, sync?: boolean, forcePending?: boolean }) => Promise` - This is useful any time your loader data might be out of date or stale. For example, if you have a route that displays a list of posts, and you have a loader function that fetches the list of posts from an API, you might want to invalidate the route matches for that route any time a new post is created so that the list of posts is always up-to-date. - If `filter` is not supplied, all committed, cached, and in-flight match generations are invalidated. - If `filter` is supplied, it is evaluated against committed, cached, and in-flight matches. Selecting one generation invalidates every committed, cached, or in-flight generation with the same match ID. -- Invalidation reruns `beforeLoad`; reusable loader data is marked stale and reloads through the normal loading protocol. Route-level `context` remains reusable while the match ID is unchanged. +- Invalidation reruns `beforeLoad`; reusable loader data is marked stale and reloads through the normal loading protocol. Route-level `context` remains reusable while the match ID is unchanged, unless the route sets `revalidate` in the object form of `context` (experimental): then `revalidate: true` runs the context `handler` again and `revalidate: fn` runs that function. See [`revalidate` and `router.invalidate()`](../../guide/router-context.md#revalidate-and-routerinvalidate). - If `sync` is `true`, stale loader work is blocking and the returned promise resolves after it finishes instead of leaving a background refresh detached. - If `forcePending` is `true`, selected routes that need loading enter the normal pending protocol even when successful data was already available. - You might also want to invalidate the Router if you imperatively `reset` the router's `CatchBoundary` to trigger loaders again. diff --git a/docs/router/config.json b/docs/router/config.json index 1258ddd8175..4724d5d85b2 100644 --- a/docs/router/config.json +++ b/docs/router/config.json @@ -360,6 +360,10 @@ { "label": "Create Route Property Order", "to": "eslint/create-route-property-order" + }, + { + "label": "No Lifecycle Codec Param Annotations", + "to": "eslint/no-lifecycle-codec-param-annotations" } ] }, diff --git a/docs/router/eslint/create-route-property-order.md b/docs/router/eslint/create-route-property-order.md index 453fd64accb..d77eeb270af 100644 --- a/docs/router/eslint/create-route-property-order.md +++ b/docs/router/eslint/create-route-property-order.md @@ -10,6 +10,8 @@ For the following functions, the property order of the passed in object matters - `createRootRoute` - `createRootRouteWithContext` +The rule checks these functions when they are imported from a TanStack Router package (`@tanstack/react-router`, `@tanstack/solid-router`, `@tanstack/vue-router`), also under an alias (`import { createFileRoute as cfr }`) or through a namespace import (`Router.createFileRoute`). A local variable or parameter with the same name is not checked. + The correct property order is as follows - `params`, `validateSearch` @@ -21,6 +23,30 @@ The correct property order is as follows All other properties are insensitive to the order as they do not depend on type inference. +The options object may be wrapped in `satisfies`, `as`, a `` type assertion or a non-null `!`; the rule looks through the wrapper and the fix leaves it in place. + +## Lifecycle object form + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +`context`, `beforeLoad` and `loader` also accept an object form, `{ handler, dehydrate, hydrate, ... }`. Inside that object the order matters as well: the parameters of `dehydrate`, `hydrate` and `revalidate` are inferred from `handler`, and the `data` parameter of `hydrate` is inferred from `dehydrate`. The rule checks and fixes this nested order too: + +- `handler` +- `dehydrate` +- `hydrate` +- `revalidate` (`context` only), `staleReloadMode` (`loader` only) + +A key a lifecycle does not accept (`revalidate` on `loader`, `staleReloadMode` on `context`) is not moved by this rule; the types reject it. + +The lifecycle object may be wrapped in `satisfies`, `as`, a `` type assertion or a non-null `!` as well; the rule looks through that wrapper too and the fix leaves it in place. + +## When the order is reported but not fixed + +The fix only reorders keys. In two cases it would change more than the order, so the rule reports the order and leaves the fix to you: + +- A key would have to move across a spread (`...shared`) or a computed key (`[key]: value`). Both can set the same key, and in an object literal the later value wins: in `{ loader, ...shared }` a `loader` inside `shared` replaces yours, in `{ ...shared, loader }` yours replaces it. Moving the key past the spread would change which `loader` runs. A spread is never treated as a key, whatever its variable is called: `...loader` is a spread, not the `loader` key. +- A comment belongs to a key in the range the fix reorders, from the first to the last key whose position changes. A comment belongs to the key below it, also when it starts on the line where the key above ends; such a comment belongs to the key above as well. The fix moves the keys and leaves the text between them in place, so the comment would end up on a different key: a `// eslint-disable-next-line` or `// @ts-expect-error` comment would silence another line, and a `/* eslint-disable */` comment would cover other keys. Comments on keys before or after that range stay on their key, and comments inside a value, such as a function body, move with their key. + ## Rule Details Examples of **incorrect** code for this rule: @@ -119,6 +145,94 @@ export const Route = createFileRoute('/path')({ +Examples of **incorrect** code for the lifecycle object form: + + + +# React + + + +```tsx title="src/routes/posts.$postId.tsx" +/* eslint "@tanstack/router/create-route-property-order": "warn" */ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + loader: { + hydrate: ({ data }) => postCache.get(data.id), + dehydrate: ({ data }) => ({ id: data.id }), + handler: async ({ params }) => fetchPost(params.postId), + }, +}) +``` + + + +# Solid + + + +```tsx title="src/routes/posts.$postId.tsx" +/* eslint "@tanstack/router/create-route-property-order": "warn" */ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/posts/$postId')({ + loader: { + hydrate: ({ data }) => postCache.get(data.id), + dehydrate: ({ data }) => ({ id: data.id }), + handler: async ({ params }) => fetchPost(params.postId), + }, +}) +``` + + + + + +Examples of **correct** code for the lifecycle object form: + + + +# React + + + +```tsx title="src/routes/posts.$postId.tsx" +/* eslint "@tanstack/router/create-route-property-order": "warn" */ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + loader: { + handler: async ({ params }) => fetchPost(params.postId), + dehydrate: ({ data }) => ({ id: data.id }), + hydrate: ({ data }) => postCache.get(data.id), + }, +}) +``` + + + +# Solid + + + +```tsx title="src/routes/posts.$postId.tsx" +/* eslint "@tanstack/router/create-route-property-order": "warn" */ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/posts/$postId')({ + loader: { + handler: async ({ params }) => fetchPost(params.postId), + dehydrate: ({ data }) => ({ id: data.id }), + hydrate: ({ data }) => postCache.get(data.id), + }, +}) +``` + + + + + ## Attributes - [x] ✅ Recommended diff --git a/docs/router/eslint/eslint-plugin-router.md b/docs/router/eslint/eslint-plugin-router.md index 6af61030313..16e910333be 100644 --- a/docs/router/eslint/eslint-plugin-router.md +++ b/docs/router/eslint/eslint-plugin-router.md @@ -54,6 +54,7 @@ export default [ }, rules: { '@tanstack/router/create-route-property-order': 'error', + '@tanstack/router/no-lifecycle-codec-param-annotations': 'error', }, }, // Any other config... @@ -84,7 +85,8 @@ Alternatively, add `@tanstack/eslint-plugin-router` to the plugins section, and { "plugins": ["@tanstack/eslint-plugin-router"], "rules": { - "@tanstack/router/create-route-property-order": "error" + "@tanstack/router/create-route-property-order": "error", + "@tanstack/router/no-lifecycle-codec-param-annotations": "error" } } ``` @@ -94,6 +96,7 @@ Alternatively, add `@tanstack/eslint-plugin-router` to the plugins section, and The following rules are available in the TanStack Router ESLint Plugin: - [@tanstack/router/create-route-property-order](./create-route-property-order.md) +- [@tanstack/router/no-lifecycle-codec-param-annotations](./no-lifecycle-codec-param-annotations.md) ## Conflicts with other ESLint plugins diff --git a/docs/router/eslint/no-lifecycle-codec-param-annotations.md b/docs/router/eslint/no-lifecycle-codec-param-annotations.md new file mode 100644 index 00000000000..3904071ef14 --- /dev/null +++ b/docs/router/eslint/no-lifecycle-codec-param-annotations.md @@ -0,0 +1,123 @@ +--- +id: no-lifecycle-codec-param-annotations +title: Disallow type annotations on the parameters of dehydrate, hydrate and revalidate +--- + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +`context`, `beforeLoad` and `loader` accept an object form next to the plain function: + +```tsx +loader: { + handler: async ({ params }) => fetchPost(params.postId), + dehydrate: ({ data }) => ({ id: data.id }), + hydrate: ({ data, context }) => context.cache.get(data.id), +} +``` + +The parameters of `dehydrate`, `hydrate` and `revalidate` are inferred from the route: + +- `dehydrate` receives the lifecycle context plus `data`, the awaited result of `handler`. +- `hydrate` receives the lifecycle context plus `data`: the awaited result of `dehydrate` when `dehydrate` is a function, otherwise the awaited result of `handler`. +- `revalidate` (on `context` only) receives the lifecycle context plus `prev`, the previous `context` result or `undefined`. + +A type annotation written on one of these parameters is not checked against what the route produces. TypeScript trusts the annotation, so a wrong one hides a type error instead of reporting it: a `hydrate` annotated with a shape the server never sends compiles and fails at runtime. Leave these parameters unannotated. When inference reports an error, the fix is in `handler` or `dehydrate`, or in their order (`handler` before `dehydrate` before `hydrate`, see [create-route-property-order](./create-route-property-order.md)). + +The rule inspects functions written inline as the `dehydrate`, `hydrate` or `revalidate` member of a lifecycle object in the options of `createRoute`, `createFileRoute`, `createRootRoute` and `createRootRouteWithContext`. These functions are recognized when they are imported from a TanStack Router package (`@tanstack/react-router`, `@tanstack/solid-router`, `@tanstack/vue-router`), also under an alias (`import { createFileRoute as cfr }`) or through a namespace import (`Router.createFileRoute`); a local variable or parameter with the same name is not checked. The rule does not report `handler` or return type annotations. The options object, the lifecycle object and the function may be wrapped in `satisfies`, `as`, a `` type assertion or a non-null `!`, and a `this` pseudo-parameter (`function (this: void, ctx) {}`) is ignored: the parameter after it is the one checked. The rule has no autofix: remove the annotation by hand. + +The rule only sees the parameters of functions written inline. It does not check a function passed by reference (`hydrate: restorePost`, with `restorePost` declared elsewhere) or one whose type comes from `satisfies` or `as` (`hydrate: (({ data }) => data) satisfies RestorePost`). TypeScript still checks such a `dehydrate`, a `revalidate`, and a `hydrate` next to a `dehydrate` function against the route. The exception is a `hydrate` next to `dehydrate: true`: the type that function declares for `data` is taken as is and not compared with what `handler` returns, so a wrong type compiles and fails at runtime, and the rule does not report it. Write `hydrate` inline and unannotated when `dehydrate` is `true`. + +## Rule Details + +Examples of **incorrect** code for this rule: + + + +# React + + + +```tsx title="src/routes/posts.$postId.tsx" +/* eslint "@tanstack/router/no-lifecycle-codec-param-annotations": "error" */ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + loader: { + handler: async ({ params }) => fetchPost(params.postId), + dehydrate: ({ data }: { data: Post }) => ({ id: data.id }), + hydrate: ({ data }: { data: { id: string } }) => postCache.get(data.id), + }, +}) +``` + + + +# Solid + + + +```tsx title="src/routes/posts.$postId.tsx" +/* eslint "@tanstack/router/no-lifecycle-codec-param-annotations": "error" */ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/posts/$postId')({ + loader: { + handler: async ({ params }) => fetchPost(params.postId), + dehydrate: ({ data }: { data: Post }) => ({ id: data.id }), + hydrate: ({ data }: { data: { id: string } }) => postCache.get(data.id), + }, +}) +``` + + + + + +Examples of **correct** code for this rule: + + + +# React + + + +```tsx title="src/routes/posts.$postId.tsx" +/* eslint "@tanstack/router/no-lifecycle-codec-param-annotations": "error" */ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$postId')({ + loader: { + handler: async ({ params }) => fetchPost(params.postId), + dehydrate: ({ data }) => ({ id: data.id }), + hydrate: ({ data }) => postCache.get(data.id), + }, +}) +``` + + + +# Solid + + + +```tsx title="src/routes/posts.$postId.tsx" +/* eslint "@tanstack/router/no-lifecycle-codec-param-annotations": "error" */ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/posts/$postId')({ + loader: { + handler: async ({ params }) => fetchPost(params.postId), + dehydrate: ({ data }) => ({ id: data.id }), + hydrate: ({ data }) => postCache.get(data.id), + }, +}) +``` + + + + + +## Attributes + +- [x] ✅ Recommended +- [ ] 🔧 Fixable diff --git a/docs/router/guide/automatic-code-splitting.md b/docs/router/guide/automatic-code-splitting.md index be599845bc8..67d98db284d 100644 --- a/docs/router/guide/automatic-code-splitting.md +++ b/docs/router/guide/automatic-code-splitting.md @@ -224,3 +224,85 @@ export default defineConfig({ ``` We highly discourage splitting the `loader` unless you have a specific use case that requires it. In most cases, not splitting off the `loader` and keep it in the main bundle is the best choice for performance. + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +A loader written in the object form, `loader: { handler, dehydrate, hydrate, staleReloadMode }`, is split by its `handler` only: the chunk exports the handler, while `dehydrate`, `hydrate` and `staleReloadMode` stay in the reference file, where the router reads them without loading the chunk. The same applies to an object declared as a `const` in the same file and passed by name (`const postsLoader = { handler, dehydrate: true }` and `loader: postsLoader`) when `loader` is the only place that reads it: the object stays in the reference file with its `handler` replaced. A type that names the object, such as `Awaited>`, does not count as a reader. A `handler` written as a getter (`get handler() { ... }`) or as a method that uses `super` is not split. Neither is an object held in a variable that is exported, reassigned, or read anywhere else in the file (another option such as `head` calling `postsLoader.handler(ctx)`, top-level code, or the handler itself): replacing the handler in the object would hand every reader the lazy wrapper instead of your function. When a split option such as `component` or `errorComponent` reads that variable, the object moves to the shared module, so the route and the chunk read one object. An object-form loader imported from another module or returned by a function cannot be told apart from a function: do not put `loader` in a split group for such a route, or the chunk exports the object and calling the loader fails. + +### Removing route options from a build (`deleteNodes`) + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +`deleteNodes` lists route options that the plugin removes from the compiled route file. An entry is a key of the route options object (`'ssr'`) or a dot path into an option written in the object form (`'loader.dehydrate'`, `'context.hydrate'`). The removed code and any import that only this code used disappear from the output; the rest of the file is unchanged. + +The list applies to every module the plugin instance compiles, for the browser and for the server alike. A router-only setup that renders on the server compiles both builds with one `tanstackRouter()`, and in development the dev server compiles the modules it renders on the server through that same plugin. The two builds need different keys: only the server reads `ssr` and `dehydrate`, and only the client reads `hydrate`. So give each production build its own list, and none in development: + +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig(({ command, isSsrBuild }) => ({ + plugins: [ + tanstackRouter({ + autoCodeSplitting: true, + codeSplittingOptions: { + // `vite build` is the client build, `vite build --ssr` the server + // build; the dev server compiles both, so it removes nothing + deleteNodes: + command !== 'build' + ? undefined + : isSsrBuild + ? ['loader.hydrate'] + : ['ssr', 'loader.dehydrate'], + }, + }), + ], +})) +``` + +With this configuration, the route file + +```tsx +import { fetchPosts } from './posts' +import { toWire } from './server-only' +import { fromWire } from './client-only' + +export const Route = createFileRoute('/posts')({ + ssr: 'data-only', + loader: { + handler: ({ params }) => fetchPosts(params), + dehydrate: ({ data }) => toWire(data), + hydrate: ({ data }) => fromWire(data), + }, +}) +``` + +compiles to this in the client build: + +```tsx +import { fetchPosts } from './posts' +import { fromWire } from './client-only' + +export const Route = createFileRoute('/posts')({ + loader: { + handler: ({ params }) => fetchPosts(params), + hydrate: ({ data }) => fromWire(data), + }, +}) +``` + +The server build keeps `ssr` and `dehydrate` and drops `hydrate` together with the `./client-only` import. + +The plugin visits: + +- The options object literal passed to `createFileRoute`, `createRootRoute` or `createRootRouteWithContext`, written inline or held in a variable of the same file and passed by name, also behind `satisfies` or `as`. +- Object-form values written inline, or held in a variable of the same file that is never reassigned and passed by name (`loader: postsLoader`), also behind a TypeScript wrapper such as `satisfies`, `as`, `!` or parentheses. Such a variable is stripped in every module that keeps it: the route file, the shared module when a split option reads it, and a chunk that keeps a copy for top-level code reading it. If it is exported, the modules that import it get it without the removed keys too. An object-form value held in a variable that is reassigned is left as written. + +It does not visit spread members (`...sharedOptions`), computed keys, or option objects that are imported from another module or returned by a function. Those are left as they are, without a build error. + +A function or variable that only the removed code used is removed with it, and so are its imports. A class declaration is not: it stays, and keeps its imports. + +Removing an option is a bundle-size optimization, not a way to hide code. A value that must not reach the client has to live in a module the client never imports. + +TanStack Start configures these lists itself: its client build removes `ssr`, `server`, `headers` and the `dehydrate` key of `context`, `beforeLoad` and `loader`; its server build removes the `hydrate` key of the three and `context.revalidate`. A `deleteNodes` list in your own Start router configuration applies to both the client and the server build, with the built-in entries added after it, so it should only name options that neither build reads. diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index 6b9fd427779..c01bdb0708c 100644 --- a/docs/router/guide/data-loading.md +++ b/docs/router/guide/data-loading.md @@ -17,6 +17,7 @@ Every time a URL/history update is detected, the router executes the following s - `route.params.parse` - `route.validateSearch` - Route Pre-Loading (Serial) + - `route.context` - `route.beforeLoad` - `route.onError` - `route.errorComponent` / `parentRoute.errorComponent` / `router.defaultErrorComponent` @@ -28,6 +29,8 @@ Every time a URL/history update is detected, the router executes the following s - `route.onError` - `route.errorComponent` / `parentRoute.errorComponent` / `router.defaultErrorComponent` +The serial phase goes route by route, parent first: the first match runs `context` and then `beforeLoad`, then the second match runs its `context` and `beforeLoad`, and so on. Each `context` sees the parent's full context, including the parent's `beforeLoad` result. `context` runs once per match and is cached; `beforeLoad` runs on every load. Loaders start when the serial phase is done. The `context` option is experimental and subject to change; it is described in the [Router Context](./router-context.md#the-route-context-option) guide. + ## To Router Cache or not to Router Cache? There is a high possibility that TanStack's router cache will be a good fit for most smaller to medium size applications, but it's important to understand the tradeoffs of using it vs a more robust caching solution like TanStack Query: @@ -75,7 +78,11 @@ export const Route = createFileRoute('/posts')({ }) ``` -Use the object form when you want to configure loader-specific behavior such as `staleReloadMode`. +Use the object form to configure loader-specific behavior: + +- `handler` is the loader function. It receives the same parameters as the function form. +- `staleReloadMode` chooses between `'background'` and `'blocking'` reloads of stale data; see [Choosing background vs blocking stale reloads](#choosing-background-vs-blocking-stale-reloads). +- `dehydrate` and `hydrate` (experimental) control what the server sends to the client during SSR. They are described in [What crosses the wire](./ssr.md#what-crosses-the-wire) and, for TanStack Start, in [SSR Data Transport](/start/latest/docs/framework/react/guide/ssr-data-transport). ## `loader` Parameters @@ -88,8 +95,9 @@ 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 `context` option - This route's context as provided by the `beforeLoad` option -- `deps` - The object value returned from the `Route.loaderDeps` function. If `Route.loaderDeps` is not defined, an empty object will be provided instead. +- `deps` - The object value returned from the `Route.loaderDeps` function. If `Route.loaderDeps` is not defined, `deps` is an empty string (`''`). - `location` - The current location - `params` - The route's path params - `parentMatchPromise` - `Promise` (`undefined` for the root route) @@ -346,9 +354,10 @@ a loader call. 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 `context` option - This route's context as provided by the `beforeLoad` option -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. +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 `context` or `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. In this example, we'll create a function in our route context to fetch posts, then use it in our `loader` function. @@ -432,7 +441,11 @@ export const Route = createFileRoute('/posts/$postId')({ ## Using Route Context -Passing down global context to your router is great, but what if you want to provide context that is specific to a route? This is where the `beforeLoad` option comes in. The `beforeLoad` option is a function that runs right before attempting to load a route and receives the same parameters as `loader`. Beyond its ability to redirect potential matches, block loader requests, etc, it can also return an object that will be merged into the route's context. Let's take a look at an example where we inject some data into our route context via the `beforeLoad` option: +Passing down global context to your router is great, but what if you want to provide context that is specific to a route? This is where the `beforeLoad` option comes in. The `beforeLoad` option is a function that runs right before attempting to load a route and receives the same parameters as `loader`. Beyond its ability to redirect potential matches, block loader requests, etc, it can also return an object that will be merged into the route's context. + +A route can also contribute context with the `context` option. It runs before `beforeLoad`, is computed once per match and cached, and receives `deps` instead of `search`. Use it for values that should not be rebuilt on every navigation, such as query options derived from `params`. The [Router Context](./router-context.md#the-route-context-option) guide covers the difference. + +Let's take a look at an example where we inject some data into our route context via the `beforeLoad` option: ```tsx // src/routes/posts.tsx diff --git a/docs/router/guide/deferred-data-loading.md b/docs/router/guide/deferred-data-loading.md index f378d7adbf9..a1b5cb6b704 100644 --- a/docs/router/guide/deferred-data-loading.md +++ b/docs/router/guide/deferred-data-loading.md @@ -199,6 +199,12 @@ function SlowDataComponent() { Streamed promises follow the same lifecycle as the loader data they are associated with. They can even be preloaded! +## Deferred Data That Is Not Sent + +> **Experimental:** `dehydrate` and `hydrate` are experimental and subject to change. + +A loader with `dehydrate: false` or a custom `dehydrate` (see [What crosses the wire](./ssr.md#what-crosses-the-wire)) does not send its data as is, so the SSR stream never sees its promises. On the server, return such promises as top-level fields of a plain object. The router then handles their rejections, so a rejected promise that nothing awaits does not become an unhandled rejection. Promises inside arrays, class instances or nested objects get no such handler. When the response is done, the router aborts the loader's `abortController`, so work tied to its signal stops. + # React diff --git a/docs/router/guide/not-found-errors.md b/docs/router/guide/not-found-errors.md index 88b8767a00f..bed28c1d81b 100644 --- a/docs/router/guide/not-found-errors.md +++ b/docs/router/guide/not-found-errors.md @@ -233,6 +233,8 @@ TanStack Router exposes a `CatchNotFound` component similar to `CatchBoundary` t `notFoundComponent` is a special case when it comes to data loading. **`SomeRoute.useLoaderData` may not be defined depending on which route you are trying to access and where the not-found error gets thrown**. However, `Route.useParams`, `Route.useSearch`, `Route.useRouteContext`, etc. will return a defined value. +With SSR, a `beforeLoad` or `loader` result that is not sent to the client (`dehydrate: false`, experimental) is missing in the browser on a route the server rendered as not-found, and on the root route when it shows the not-found page. The browser does not run `beforeLoad` or `loader` there during hydration. The root layout renders around the not-found page, so if it shows such a result, keep the result sent (the default). Otherwise the browser renders the root layout without it on every unknown URL, which can show up as a hydration mismatch. + **If you need to pass incomplete loader data to `notFoundComponent`,** pass the data via the `data` option in the `notFound` function and validate it in `notFoundComponent`. ```tsx diff --git a/docs/router/guide/router-context.md b/docs/router/guide/router-context.md index 347082106f7..cfd30399a64 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` or `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: @@ -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` or `beforeLoad` will be inferred. ## Passing the initial Router Context @@ -123,9 +123,9 @@ const router = createRouter({ -### Invalidating the Router Context +### Reloading after the context changed -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. +When the state behind the router context changes, call `router.invalidate()`. The router reloads the matched routes: every `beforeLoad` and every loader runs again and sees the current context. A route's own `context` option is cached per match and is not re-run by `invalidate()` unless the route sets `revalidate`; see [The route `context` option](#the-route-context-option) below. @@ -380,7 +380,7 @@ 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 modify the context at each route and the modifications will be available to all child routes. Two route options contribute to it: `context`, which runs first and is cached per match, and `beforeLoad`, which runs on every load. This example uses `beforeLoad`; the `context` option is described in [The route `context` option](#the-route-context-option). Here's an example: @@ -498,6 +498,113 @@ export const Route = createFileRoute('/todos')({ +## The route `context` option + +> **Experimental:** the route `context` option is experimental and subject to change. + +A route can contribute to the context with the `context` option. It runs before the route's `beforeLoad`, its result is merged into the context that `beforeLoad`, `loader`, the components and every child route see, and it is computed once per match. + +```tsx +// src/routes/posts.$postId.tsx +export const Route = createFileRoute('/posts/$postId')({ + context: ({ params }) => ({ + postQueryOptions: postQueryOptions(params.postId), + }), + loader: ({ context }) => + context.queryClient.ensureQueryData(context.postQueryOptions), +}) +``` + +This is the common case: the context holds functions (a `queryFn` inside the options), so it is never sent to the client during SSR, and it is recomputed in the browser during hydration from `params`. Because it is cached per match, the options object stays the same across renders and across `router.invalidate()` calls. + +### Execution order + +The router runs `context` and `beforeLoad` serially, one route after the other, parent first: for the first match it runs `context`, then `beforeLoad`; then for the second match `context`, then `beforeLoad`; and so on down to the leaf. Loaders start after that serial phase, with the accumulated context. + +That order determines what each function sees: + +- A route's `context` sees the parent's complete context: the router context plus every ancestor's `context` result and `beforeLoad` result. +- A route's `beforeLoad` sees the parent context plus the route's own `context` result. +- A route's `loader` sees all of that plus the route's own `beforeLoad` result. + +### What `context` receives + +`context` receives one object with these properties: + +- `context` - The parent's effective context as described above. +- `params` - The route's path params. +- `deps` - The result of this route's `loaderDeps`. A route without `loaderDeps` gets an empty string (`''`). +- `location` - The current location. +- `cause` - `'enter'`, `'stay'` or `'preload'`. +- `preload` - `true` while the route is being preloaded. +- `abortController` - The controller of this load. +- `matches` - The matches of this load. +- `routeId` - This route's id. +- `serverContext` - In a TanStack Start app, the request's server context from the route's server middleware, as `beforeLoad` and `loader` receive it; `undefined` on the client. +- `buildLocation`, and the deprecated `navigate`. + +`search` is not among them, on purpose: `context` is memoized per match, and a match is keyed by its params and its `loaderDeps`, so a context that read search params outside `loaderDeps` would keep a stale value when those params change. Return the search params you need from `loaderDeps` and read `deps`. + +`context` may be async. The route is pending while it resolves and the awaited value is merged. + +### Cached per match + +`context` runs once per match and the result is reused on every later load of that match. `beforeLoad` runs on every load. A different match (other `params` or other `loaderDeps`) computes its own context. A preload shares its context once it has resolved: when a navigation to the match starts while the preload's async `context` is still running, the navigation runs `context` again. + +### `revalidate` and `router.invalidate()` + +By default `router.invalidate()` reloads the loaders and re-runs `beforeLoad`, but leaves a cached `context` alone. The reason is identity: `useRouteContext` hands components the match's context object itself. If every `invalidate()` rebuilt the context, consumers would get new objects (new `queryOptions`, for example), every component reading the context would re-render, and effect and memo dependencies keyed on those objects would fire again. + +A route opts in with `revalidate` in the object form: + +- `revalidate: true` runs `handler` again on `router.invalidate()`. +- `revalidate: fn` runs that function instead. It receives the same options as `handler` plus `prev`, the current context data (the last successful `handler`, `hydrate` or `revalidate` result, or `undefined` if none has succeeded for this match yet), and must return the handler's shape. Use it to refresh part of the context and keep the rest stable. + +There is no time-based revalidation of context. Freshness of data belongs to the data layer (a query's own `staleTime`); the router owns structural caching and explicit invalidation. + +An auth lookup that must not run on every client navigation: + +```tsx +// src/routes/_authed.tsx +export const Route = createFileRoute('/_authed')({ + context: { + handler: async ({ context }) => ({ + session: await context.auth.getSession(), + }), + dehydrate: true, // SSR: send the result to the client instead of recomputing it there + revalidate: true, // re-run on router.invalidate() (login/logout) + }, + beforeLoad: ({ context }) => { + if (!context.session) throw redirect({ to: '/login' }) + }, +}) +``` + +Call `router.invalidate()` after logout, and after login too. A layout that also has a `loader` keeps its revalidated context even when that load redirects away (here to `/login`). Without `router.invalidate()` after login, the next visit would reuse the logged-out context. + +Refreshing one part of the context and keeping the rest stable: + +```tsx +// src/routes/account.tsx +export const Route = createFileRoute('/account')({ + context: { + handler: async () => ({ + accountQueryOptions: accountQueryOptions(), + authStatus: await getAuthStatus(), + }), + dehydrate: false, // SSR: holds query options, so the client runs it again + revalidate: async ({ prev }) => ({ + accountQueryOptions: prev?.accountQueryOptions ?? accountQueryOptions(), + authStatus: await getAuthStatus(), + }), + }, +}) +``` + +### Sending context to the client + +During SSR a route's `context` result is not sent to the client by default; the client computes it again during hydration. `dehydrate: true` in the object form sends it, as in the auth example above, and a `dehydrate`/`hydrate` pair sends a custom wire value. These keys are described in [What crosses the wire](./ssr.md#what-crosses-the-wire) and, for TanStack Start, in [SSR Data Transport](/start/latest/docs/framework/react/guide/ssr-data-transport). The full option reference is in [RouteOptions](../api/router/RouteOptionsType.md#context-method). + ## 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/router/guide/ssr.md b/docs/router/guide/ssr.md index 46a0941e7c6..1406ef35832 100644 --- a/docs/router/guide/ssr.md +++ b/docs/router/guide/ssr.md @@ -72,6 +72,51 @@ Resolved loader data fetched by routes is automatically dehydrated and rehydrate For more information on how to utilize data loading, see the [Data Loading](./data-loading.md) guide. +### What crosses the wire + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +The server runs each matched route's `context`, `beforeLoad` and `loader`. Which of their results are sent to the client is decided per lifecycle: + +| Lifecycle | Sent to the client by default | +| ------------ | ----------------------------- | +| `context` | no | +| `beforeLoad` | yes | +| `loader` | yes | + +A result that is sent is restored from the payload during hydration; the lifecycle does not run again on the client. A result that is not sent is recomputed on the client during hydration, in the same order the server used. A recomputed value must come out the same on the client as on the server, otherwise the first client render does not match the HTML. + +To change the default, use the object form of the lifecycle. `dehydrate: false` keeps a `beforeLoad` or `loader` result on the server, `dehydrate: true` sends a `context` result, and a `dehydrate` function paired with a `hydrate` function sends a custom wire value that `hydrate` turns back into the value the app uses on the client: + +```tsx +export const Route = createFileRoute('/_authed')({ + ssr: 'data-only', // lifecycles run on the server, the component renders on the client only + context: { + handler: async ({ context }) => ({ + session: await context.auth.getSession(), + }), + dehydrate: true, // context is not sent by default; send this one + }, + beforeLoad: { + handler: () => ({ editorSession: createEditorSession() }), + dehydrate: false, // beforeLoad is sent by default; keep this one on the server + }, +}) +``` + +`createEditorSession()` produces a different value on every run, so the client's recomputed `editorSession` cannot match the server's. `ssr: 'data-only'` (see [Selective SSR](/start/latest/docs/framework/react/guide/selective-ssr)) still runs the lifecycles on the server but renders that route's component on the client only, so the server's value never ends up in the HTML. Without it, `dehydrate: false` is only safe for values that come out the same on both sides. + +If a recomputed lifecycle or a `hydrate` fails during hydration, hydration does not handle it. A failure is a thrown error, a thrown `redirect()` or `notFound()`, or a `redirect()` or `notFound()` returned from `beforeLoad` or `loader`. The route is shown as pending and the client load that follows hydration runs it again (after a throwing `hydrate`, it runs `handler`); that run's outcome (data, error, redirect or not-found) is handled as on a client navigation. A recomputed lifecycle that fails therefore runs twice on the client, and so do its side effects. A route the server rendered as an error or not-found, and the root route when it shows the not-found page, follow different rules; see [When a lifecycle fails during hydration](/start/latest/docs/framework/react/guide/ssr-data-transport#when-a-lifecycle-fails-during-hydration). + +The full description, including custom `dehydrate`/`hydrate` pairs, async transforms and the rules the type checker enforces, is on the TanStack Start page [SSR Data Transport](/start/latest/docs/framework/react/guide/ssr-data-transport). That page applies to a router-only SSR setup as well, with these differences: + +- The type check that a sent result is serializable runs only when the `Register` interface has `ssr: true`. TanStack Start's generated route tree adds it. In a router-only setup, add `ssr: true` next to `router` in your `Register` declaration (see [Router Creation](#router-creation)). Routes with `ssr: false` are not checked. Without the check, some wrong key orders give a different type error than that page lists, or none; [RouteOptions](../api/router/RouteOptionsType.md#lifecycle-object-form) lists both cases. +- Serialization adapters are registered on TanStack Start's `createStart`. `createRouter` has no option for them. +- `createIsomorphicFn`, used in some examples there, is a TanStack Start API. +- Removing `dehydrate` from the client bundle, and `hydrate` and `context.revalidate` from the server bundle, is done by the TanStack Start build. With a plain router setup they stay in both bundles unless you give each production build its own `deleteNodes` list, as shown in [Removing route options from a build](./automatic-code-splitting.md#removing-route-options-from-a-build-deletenodes). + +The option reference is in [RouteOptions](../api/router/RouteOptionsType.md#lifecycle-object-form). + ### Router Creation Since your router will exist both on the server and the client, it's important that you create your router in a way that is consistent between both of these environments. The easiest way to do this is to expose a `createRouter` function in a shared file that can be imported and called by both your server and client entry files. @@ -374,4 +419,4 @@ Out of the box, the following types are supported: If you feel that there are other types that should be supported by default, please open an issue on the TanStack Router repository. -If you are using more complex data types like `Map`, `Set`, `BigInt`, etc, you may need to use a custom serializer to ensure that your type-definitions are accurate and your data is correctly serialized and deserialized. We are currently working on both a more robust serializer and a way to customize the serializer for your application. Open an issue if you are interested in helping out! +For a type the serializer does not know, such as a class instance, create a serialization adapter with `createSerializationAdapter` (exported from `@tanstack/react-router` and `@tanstack/solid-router`) and register it on TanStack Start's `createStart`; `createRouter` has no option for adapters. The adapter API is described on the Start page [Serialization Adapters](/start/latest/docs/framework/react/guide/serialization-adapters). diff --git a/docs/router/how-to/install.md b/docs/router/how-to/install.md index f325e0b505a..c39c3894368 100644 --- a/docs/router/how-to/install.md +++ b/docs/router/how-to/install.md @@ -6,7 +6,7 @@ title: How to Install TanStack Router - React 18.x.x or 19.x.x - ReactDOM 18.x.x or 19.x.x with `createRoot` support -- TypeScript 5.3.x or higher (recommended) +- TypeScript 5.4.x or higher (recommended) ## Installation Steps diff --git a/docs/router/how-to/setup-ssr.md b/docs/router/how-to/setup-ssr.md index 08c1e4ca4bb..98f01793e64 100644 --- a/docs/router/how-to/setup-ssr.md +++ b/docs/router/how-to/setup-ssr.md @@ -422,6 +422,20 @@ export default defineConfig(({ isSsrBuild }) => ({ })) ``` +## What Crosses the Wire + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +The server runs each matched route's `context`, `beforeLoad` and `loader`. By default the `beforeLoad` and `loader` results are sent to the client in the HTML payload and restored during hydration; the `context` result is not sent and the client computes it again. + +| Lifecycle | Sent to the client by default | Change it with | +| ------------ | ----------------------------- | ---------------------------------------------- | +| `context` | no | `dehydrate: true`, or `dehydrate` + `hydrate` | +| `beforeLoad` | yes | `dehydrate: false`, or `dehydrate` + `hydrate` | +| `loader` | yes | `dehydrate: false`, or `dehydrate` + `hydrate` | + +These keys live in the object form of the lifecycle, `{ handler, dehydrate, hydrate }`. A result that is not sent is recomputed on the client during hydration and must come out the same as on the server. See [What crosses the wire](../guide/ssr.md#what-crosses-the-wire) in the SSR guide and the TanStack Start page [SSR Data Transport](/start/latest/docs/framework/react/guide/ssr-data-transport) for the details. Note that stripping `dehydrate` from the client bundle, and `hydrate` and `context.revalidate` from the server bundle, is done by the TanStack Start build; in a manual setup they stay in both bundles unless you give each production build its own `deleteNodes` list, as shown in [Removing route options from a build](../guide/automatic-code-splitting.md#removing-route-options-from-a-build-deletenodes). + ## Common Problems > [!TIP] diff --git a/docs/router/quick-start.md b/docs/router/quick-start.md index a3571756df2..3c7922f2c8f 100644 --- a/docs/router/quick-start.md +++ b/docs/router/quick-start.md @@ -91,7 +91,7 @@ Before installing TanStack Router, please ensure your project meets the followin > [!NOTE] -> Using TypeScript (`v5.3.x or higher`) is recommended for the best development experience, though not strictly required. We aim to support the last 5 minor versions of TypeScript, but using the latest version will help avoid potential issues. +> Using TypeScript (`v5.4.x or higher`) is recommended for the best development experience, though not strictly required. We aim to support the last 5 minor versions of TypeScript, but using the latest version will help avoid potential issues. TanStack Router is currently only compatible with React (with ReactDOM) and Solid. If you're interested in contributing to support other frameworks, such as React Native, Angular, or Vue, please reach out to us on [Discord](https://tlinz.com/discord). diff --git a/docs/start/config.json b/docs/start/config.json index 36af0e6e0d5..c929937caf7 100644 --- a/docs/start/config.json +++ b/docs/start/config.json @@ -207,6 +207,14 @@ "label": "Selective SSR", "to": "framework/react/guide/selective-ssr" }, + { + "label": "SSR Data Transport", + "to": "framework/react/guide/ssr-data-transport" + }, + { + "label": "Serialization Adapters", + "to": "framework/react/guide/serialization-adapters" + }, { "label": "SPA Mode", "to": "framework/react/guide/spa-mode" @@ -252,6 +260,14 @@ "label": "Selective SSR", "to": "framework/solid/guide/selective-ssr" }, + { + "label": "SSR Data Transport", + "to": "framework/solid/guide/ssr-data-transport" + }, + { + "label": "Serialization Adapters", + "to": "framework/solid/guide/serialization-adapters" + }, { "label": "SPA Mode", "to": "framework/solid/guide/spa-mode" diff --git a/docs/start/framework/react/guide/selective-ssr.md b/docs/start/framework/react/guide/selective-ssr.md index 63671f173a2..ac5edf956b5 100644 --- a/docs/start/framework/react/guide/selective-ssr.md +++ b/docs/start/framework/react/guide/selective-ssr.md @@ -5,7 +5,7 @@ title: Selective Server-Side Rendering (SSR) ## What is Selective SSR? -In TanStack Start, routes matching the initial request are rendered on the server by default. This means `beforeLoad` and `loader` are executed on the server, followed by rendering the route components. The resulting HTML is sent to the client, which hydrates the markup into a fully interactive application. +In TanStack Start, routes matching the initial request are rendered on the server by default. This means `context`, `beforeLoad` and `loader` are executed on the server, followed by rendering the route components. The resulting HTML is sent to the client, which hydrates the markup into a fully interactive application. However, there are cases where you might want to disable SSR for certain routes or all routes, such as: @@ -14,7 +14,7 @@ However, there are cases where you might want to disable SSR for certain routes TanStack Start's Selective SSR feature lets you configure: -- Which routes should execute `beforeLoad` or `loader` on the server. +- Which routes should execute `context`, `beforeLoad` or `loader` on the server. - Which route components should be rendered on the server. ## How does this compare to SPA mode? @@ -39,6 +39,7 @@ export const startInstance = createStart(() => ({ This is the default behavior unless otherwise configured. On the initial request, it will: +- Run `context` on the server. Its result stays on the server unless the route sets `dehydrate: true`; the client runs `context` again during hydration. - Run `beforeLoad` on the server and send the resulting context to the client. - Run `loader` on the server and send the loader data to the client. - Render the component on the server and send the HTML markup to the client. @@ -63,7 +64,7 @@ export const Route = createFileRoute('/posts/$postId')({ This disables server-side: -- Execution of the route's `beforeLoad` and `loader`. +- Execution of the route's `context`, `beforeLoad` and `loader`. - Rendering of the route component. ```tsx @@ -84,6 +85,7 @@ export const Route = createFileRoute('/posts/$postId')({ This hybrid option will: +- Run `context` on the server. Its result stays on the server unless the route sets `dehydrate: true`; the client runs `context` again during hydration. - Run `beforeLoad` on the server and send the resulting context to the client. - Run `loader` on the server and send the loader data to the client. - Disable server-side rendering of the route component. @@ -156,7 +158,7 @@ root { ssr: undefined } ``` - `root` defaults to `ssr: true`. -- `posts` explicitly sets `ssr: false`, so neither `beforeLoad` nor `loader` will run on the server, and the route component won't be rendered on the server. +- `posts` explicitly sets `ssr: false`, so neither `context`, `beforeLoad` nor `loader` will run on the server, and the route component won't be rendered on the server. - `$postId` sets `ssr: true`, but inherits `ssr: false` from its parent. Because the inherited value can only be changed to be more restrictive, `ssr: true` has no effect and the inherited `ssr: false` will remain. Another example: @@ -169,9 +171,20 @@ root { ssr: undefined } ``` - `root` defaults to `ssr: true`. -- `posts` sets `ssr: 'data-only'`, so `beforeLoad` and `loader` run on the server, but the route component isn't rendered on the server. +- `posts` sets `ssr: 'data-only'`, so `context`, `beforeLoad` and `loader` run on the server, but the route component isn't rendered on the server. - `$postId` sets `ssr: true`, but inherits `ssr: 'data-only'` from its parent. -- `details` sets `ssr: false`, so neither `beforeLoad` nor `loader` will run on the server, and the route component won't be rendered on the server. Here the inherited value is changed to be more restrictive, and therefore, the `ssr: false` will override the inherited value. +- `details` sets `ssr: false`, so neither `context`, `beforeLoad` nor `loader` will run on the server, and the route component won't be rendered on the server. Here the inherited value is changed to be more restrictive, and therefore, the `ssr: false` will override the inherited value. + +## Selective SSR and data transport + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +`ssr` decides what runs on the server. `dehydrate` on a lifecycle decides whether its result is sent to the client; see [SSR Data Transport](./ssr-data-transport.md). The two combine like this: + +- `ssr: false`: nothing runs on the server, `context` included, so there is nothing to send and `dehydrate`/`hydrate` have no effect. The client runs these lifecycles after hydration, as on a client navigation. +- `ssr: 'data-only'`: the lifecycles run on the server and their results are sent as configured; only rendering moves to the client. Pair it with `dehydrate: false` when the client's value differs from the server's. +- `ssr: true` with `dehydrate: false`: the server renders with its value and the client recomputes the value during hydration. The two must be equal, otherwise the first client render differs from the server HTML. +- `ssr: true` with a `hydrate` transform: the first client render uses what `hydrate` returns, so it must render the same as the server's value. When `hydrate` adds client-only data or returns another shape, use `ssr: 'data-only'`. ## Fallback Rendering diff --git a/docs/start/framework/react/guide/serialization-adapters.md b/docs/start/framework/react/guide/serialization-adapters.md new file mode 100644 index 00000000000..b472f7a815b --- /dev/null +++ b/docs/start/framework/react/guide/serialization-adapters.md @@ -0,0 +1,101 @@ +--- +id: serialization-adapters +title: Serialization Adapters +--- + +Values that cross from the server to the client are serialized: transported lifecycle results (see [SSR Data Transport](./ssr-data-transport.md)) and the arguments and results of server functions. The serializer handles the common types out of the box. For a type it does not know, such as a class instance, you register a serialization adapter once. After that the type can appear anywhere in transported data, nested as deep as you like, and TypeScript accepts it in the serializability check. + +## What is serializable without an adapter + +At the type level, these pass the serializability check: + +- `string`, `number`, `boolean`, `bigint`, `null`, `undefined` +- `Date`, `Uint8Array` +- plain objects and arrays of the above, including tuples and readonly arrays +- `Set`, `Map`, `Promise`, `ReadableStream` and `AsyncGenerator` whose contents pass the check +- the input type of every registered adapter + +Functions do not pass, which also rules out class instances with methods. + +## Create an adapter + +`createSerializationAdapter` is exported from `@tanstack/react-router`. An adapter has four parts: + +- `key`: a non-empty string that identifies the adapter in the payload. +- `test`: a type guard that decides whether a value belongs to this adapter. +- `toSerializable`: turns the value into something the serializer already handles. +- `fromSerializable`: turns that back into the original type on the receiving side. + +```tsx +// src/decimal-adapter.ts +import { createSerializationAdapter } from '@tanstack/react-router' +import { Decimal } from 'decimal.js' + +export const decimalAdapter = createSerializationAdapter({ + key: 'decimal', + test: (v): v is Decimal => v instanceof Decimal, + toSerializable: (v) => v.toString(), + fromSerializable: (s) => new Decimal(s), +}) +``` + +The return type of `toSerializable` is itself checked for serializability. It can use the built-in types and the input types of adapters listed in `extends`: + +```tsx +const innerAdapter = createSerializationAdapter({ + key: 'inner', + test: (v): v is Inner => v instanceof Inner, + toSerializable: (inner) => inner.value, + fromSerializable: (value) => new Inner(value), +}) + +const outerAdapter = createSerializationAdapter({ + key: 'outer', + extends: [innerAdapter], + test: (v): v is Outer => v instanceof Outer, + toSerializable: (outer) => outer.inner, // an Inner, handled by innerAdapter + fromSerializable: (inner) => new Outer(inner), +}) +``` + +An adapter listed in `extends` is registered together with the adapter that extends it. + +## Register adapters + +Pass the adapters to `createStart`. Start hands them to the server when it serializes the payload and to the client when it reads it. + +```tsx +// src/start.ts +import { createStart } from '@tanstack/react-start' +import { decimalAdapter } from './decimal-adapter' + +export const startInstance = createStart(() => ({ + serializationAdapters: [decimalAdapter], +})) +``` + +## The type-level effect + +Once `decimalAdapter` is registered, a transported loader result containing a `Decimal` type-checks: + +```tsx +export const Route = createFileRoute('/invoice/$id')({ + loader: async ({ params }) => ({ + id: params.id, + total: await fetchInvoiceTotal(params.id), // a Decimal: accepted, decimalAdapter is registered + }), +}) +``` + +Without the adapter the same loader reports a serializability error that names `total`. + +## Adapter or `dehydrate`/`hydrate`? + +> **Experimental:** `dehydrate` and `hydrate` are experimental and subject to change. + +Both let you change what goes over the wire, so pick by where the problem is: + +- The problem is a type. A value that does not serialize shows up in several routes or server functions, and the way to serialize it does not depend on the route. Register an adapter once and return the value as you would any other. +- The problem is one route's data. The route wants a smaller wire payload, or the client needs to rebuild something from route context (query options, a client-side cache entry) that must never be serialized. Use the object form with `dehydrate` and `hydrate` on that lifecycle; see [SSR Data Transport](./ssr-data-transport.md). + +An adapter is not a lifecycle option and knows nothing about routes. `dehydrate` and `hydrate` are per lifecycle and receive the route's options. The two compose: a `dehydrate` function may return a value that relies on a registered adapter. diff --git a/docs/start/framework/react/guide/ssr-data-transport.md b/docs/start/framework/react/guide/ssr-data-transport.md new file mode 100644 index 00000000000..b09c2b3b3ab --- /dev/null +++ b/docs/start/framework/react/guide/ssr-data-transport.md @@ -0,0 +1,299 @@ +--- +id: ssr-data-transport +title: SSR Data Transport +--- + +During the initial request the server runs the route lifecycles (`context`, `beforeLoad`, `loader`) and renders the page. Some of their results travel to the browser inside the HTML payload so the client can restore them during hydration instead of running the lifecycle again. This page describes what is sent by default, how to change it per lifecycle, and what the build removes from each bundle. + +The lifecycles themselves (order, caching, `revalidate`) are documented in the Router docs: [Router Context](/router/latest/docs/framework/react/guide/router-context) and [Data Loading](/router/latest/docs/framework/react/guide/data-loading). Which lifecycles run on the server at all is controlled by [Selective SSR](./selective-ssr.md). + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +## What crosses the wire + +| Lifecycle | Runs on the server | Result sent to the client by default | Change it with | +| ------------ | ------------------ | ------------------------------------ | ---------------------------------------------- | +| `context` | yes | no | `dehydrate: true`, or `dehydrate` + `hydrate` | +| `beforeLoad` | yes | yes | `dehydrate: false`, or `dehydrate` + `hydrate` | +| `loader` | yes | yes | `dehydrate: false`, or `dehydrate` + `hydrate` | + +`context` is not sent because it usually holds values that cannot be serialized, such as query options or a client instance. The client computes it again during hydration. + +A result that is sent is restored from the payload on the client; the handler does not run again. A result that is not sent is recomputed on the client during hydration: `context` and `beforeLoad` run route by route, parent first, in the same order the server used, and then the loaders start in parallel. What happens when one of these runs fails is described in [When a lifecycle fails during hydration](#when-a-lifecycle-fails-during-hydration). + +All of this is configured in the object form of the lifecycle: + +```tsx +loader: { + handler: () => fetchPosts(), + dehydrate: false, +} +``` + +A sent result has to be serializable. In a Start app TypeScript checks the return type of every transported lifecycle and reports an error when it is not. There are three ways to fix such an error: return serializable data, set `dehydrate: false`, or register a [serialization adapter](./serialization-adapters.md) for the type. + +## `dehydrate: false` + +The server still runs the lifecycle (it needs the value to render and to feed child routes), but the result stays on the server. The client runs the lifecycle again during hydration. + +```tsx +// src/routes/editor.tsx +export const Route = createFileRoute('/editor')({ + ssr: 'data-only', + beforeLoad: { + handler: () => ({ editorSession: createEditorSession() }), + dehydrate: false, + }, +}) +``` + +On a route with `ssr: true` the client's run must produce the same value the server used. The server rendered HTML with its value; if the client comes up with a different one, the first client render differs from the HTML and you get a [hydration mismatch](./hydration-errors.md). When the value cannot be the same on both sides, pair `dehydrate: false` with `ssr: 'data-only'` as in the example: the lifecycle still runs on the server, but the component is not rendered there. + +`dehydrate: false` is the default for `context`, so a plain `context` function is already in this situation; for an async one, writing it out also silences a development warning (see the end of the next section). A context derived from `params` or `deps` produces the same value on both sides and needs nothing extra. + +## Sending a context: `dehydrate: true` + +A `context` whose result is serializable can be sent. The typical case is an auth lookup that should run once per match and not again on every client navigation: + +```tsx +// src/routes/_authed.tsx +export const Route = createFileRoute('/_authed')({ + context: { + handler: async ({ context }) => ({ + session: await context.auth.getSession(), + }), + dehydrate: true, // send it; the result is serializability-checked + revalidate: true, // re-run on router.invalidate() (login/logout) + }, + beforeLoad: ({ context }) => { + if (!context.session) throw redirect({ to: '/login' }) + }, +}) +``` + +The server runs the handler, the client restores `session` from the payload and keeps it for the lifetime of the match. Without `revalidate` a context never re-runs on `router.invalidate()`; with `revalidate: true` the handler runs again on the client when you call it. See [Router Context](/router/latest/docs/framework/react/guide/router-context) for the caching rules. + +If a `context` handler is async and its result is not sent, the client awaits it again during hydration. In development the server warns about this once per route. Set `dehydrate: true` when the result is serializable; when it is not, write `dehydrate: false`, which keeps the behavior and silences the warning. + +## Custom `dehydrate` and `hydrate` + +When the value the app needs is not the value you want on the wire, write the two halves yourself: + +- `dehydrate` runs on the server after the handler settles. It receives the same options the handler received plus `data`, the handler's awaited result, and returns the wire value. The wire value is what has to be serializable. A `dehydrate` whose return type is `unknown` is rejected in a Start app; give it a concrete return type. +- `hydrate` runs on the client during hydration. It receives the same options plus `data`, the wire value, and returns the value the app uses. + +The first client render uses what `hydrate` returns. On a route with `ssr: true` that value must render the same HTML as the server's value, otherwise you get a [hydration mismatch](./hydration-errors.md). When it cannot, because `hydrate` adds a client-only field or returns another shape, add `ssr: 'data-only'` to the route, as the examples with client-only data below do. + +Because `hydrate` sees the route's `context` and `params`, it can rebuild values that must never be serialized, such as query options, from a small wire payload: + +```tsx +// src/routes/posts.$postId.tsx +export const Route = createFileRoute('/posts/$postId')({ + context: { + handler: async ({ params }) => ({ + postId: params.postId, + postQueryOptions: postQueryOptions(params.postId), + authStatus: await getAuthStatus(), + }), + dehydrate: ({ data }) => ({ + postId: data.postId, + authStatus: data.authStatus, + }), + hydrate: ({ data, context }) => ({ + postId: data.postId, + postQueryOptions: postQueryOptions(data.postId), // may also use context.queryClient + authStatus: data.authStatus, + }), + }, +}) +``` + +The server sends `{ postId, authStatus }`; the client rebuilds `postQueryOptions` and ends up with the same shape the handler returns. On client navigations the handler itself runs in the browser and neither half is involved. + +### Reconstruct from the full value + +If the default wire shape is fine and you only need a client-side step, keep `dehydrate: true` and add `hydrate`. `data` is then the handler's full result. A `hydrate` result that is assignable to the handler's data type is typed as the handler's data type, so a field that only exists on the client is declared by the handler as `undefined` and filled in by `hydrate`: + +```tsx +// src/routes/report.tsx +export const Route = createFileRoute('/report')({ + ssr: 'data-only', // the client's timeZone differs from the server's + loader: { + handler: async () => ({ + report: await fetchReport(), + timeZone: undefined as string | undefined, // client-only, set by hydrate + }), + dehydrate: true, + hydrate: ({ data }) => ({ + ...data, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), + }, +}) +// Route.useLoaderData(): { report: Report; timeZone: string | undefined } +``` + +A property that `hydrate` adds to an otherwise matching shape does not show up in `Route.useLoaderData()`; a `hydrate` result with a different shape is covered under [Narrow with a discriminant](#narrow-with-a-discriminant). The field is part of the handler's result, so with `dehydrate: true` it goes through the same serializability check as the rest of the value. On a client navigation the handler runs in the browser and `timeZone` stays `undefined`; the next section fills the field on that path too. + +`dehydrate: true` is required next to `hydrate` so the transport is visible in the route file. + +### Combine server data with client-only data + +A loader handler is isomorphic: during SSR it runs on the server, during a client navigation it runs in the browser. Data that only exists in the browser (a preference in `localStorage`, for example) is added in two places: in `hydrate`, which runs at hydration, and in the handler's client branch, which runs on navigation. One `Data` type covers both; the client-only field is optional because it is `undefined` while the server renders. + +```tsx +// src/routes/dashboard.tsx +import { createIsomorphicFn } from '@tanstack/react-start' + +const getLocalPrefs = createIsomorphicFn() + .server(() => undefined) + .client(() => readPrefsFromLocalStorage()) + +export const Route = createFileRoute('/dashboard')({ + ssr: 'data-only', // prefs exist on the client only + loader: { + handler: async () => ({ + stats: await fetchStats(), // server fn: runs on the server during SSR, via fetch on client navigation + prefs: getLocalPrefs(), // undefined on the server, filled on client navigations + }), + dehydrate: true, + hydrate: ({ data }) => ({ ...data, prefs: readPrefsFromLocalStorage() }), // filled at hydration + }, +}) +// Route.useLoaderData(): { stats: Stats; prefs: Prefs | undefined } +``` + +The server renders without `prefs` and the first client render has them, so the route needs `ssr: 'data-only'`. `createIsomorphicFn` is described in [Environment Functions](./environment-functions.md). + +### Narrow with a discriminant + +`hydrate` may also return a different shape than the handler. The loader's data type is then the union of the handler's data and the `hydrate` data, and the component narrows on a discriminant. One rule decides whether that union exists: a `hydrate` result that is assignable to the handler's data reduces to the handler's data. Only a result with a shape of its own produces the union. + +That rules out server versus client as the discriminant. A handler that returns `env: isServer ? 'server' : 'client'` types `env` as `string` (the two literals widen), and with `as const` on both branches as `'server' | 'client'`. Either way a `hydrate` result `{ env: 'client', stats, prefs }` is a subtype of the handler's data. It reduces, the union disappears, and `prefs` is not visible anywhere. + +The discriminant has to say which function produced the value: the handler returns `hydrated: false as const`, `hydrate` returns `hydrated: true as const` plus the client-only fields. Write `as const` on both sides. Without it in the handler, `hydrated` widens to `boolean` and the `hydrate` result is a subtype again. Without it in `hydrate`, `true` widens to `boolean` and the `hydrated: false` branch no longer rules out the `hydrate` result. + +```tsx +// src/routes/dashboard.tsx +export const Route = createFileRoute('/dashboard')({ + ssr: 'data-only', // the server renders one branch, the first client render the other + loader: { + handler: async () => ({ + hydrated: false as const, + stats: await fetchStats(), + }), + dehydrate: ({ data }) => ({ stats: data.stats }), + hydrate: async ({ data }) => ({ + hydrated: true as const, + stats: data.stats, + prefs: await readPrefs(), + }), + }, +}) +// Route.useLoaderData(): +// | { hydrated: false; stats: Stats } +// | { hydrated: true; stats: Stats; prefs: Prefs } +// +// In the component, `if (data.hydrated)` narrows to the second member and +// `data.prefs` is a `Prefs`; in the other branch `prefs` does not exist. +``` + +On a client navigation the handler runs in the browser, so the component sees the `hydrated: false` branch. That is the truth: `hydrate` did not run and `prefs` was not read. This form fits data that only exists after hydration. When the field should also be there after a client navigation, declare it in the handler as in [Combine server data with client-only data](#combine-server-data-with-client-only-data): a client-only field in a same-shape result is declared optional (`undefined`) in the handler and must itself be serializable, because the handler's result is what `dehydrate: true` checks. + +## Async `dehydrate` and `hydrate` + +Both halves may be async. The server awaits `dehydrate` before the match is marked successful, and the client awaits `hydrate` during hydration; the awaited value is used everywhere, including in the inferred types. + +```tsx +loader: { + handler: () => fetchDocument(), + dehydrate: async ({ data }) => ({ id: data.id, body: await compress(data.body) }), + hydrate: async ({ data }) => ({ id: data.id, body: await decompress(data.body) }), +} +``` + +## Errors thrown by `dehydrate` or `hydrate` + +`dehydrate` and `hydrate` only transform a value. They are not lifecycles, so `redirect()` and `notFound()` are not control flow there; throw them from `handler`. + +- A throw inside `dehydrate` is handled like a throw inside the handler: the match becomes an error match with its `errorComponent` and `onError`. A `redirect()` or `notFound()` thrown from `dehydrate` is such an ordinary error. +- A throw inside `hydrate`, whatever is thrown, is a failure during hydration like any other (next section): the client load that follows hydration runs the lifecycle's `handler` in the browser. `hydrate` itself never shows an `errorComponent` or calls `onError`. If the handler succeeds, the page continues with its result; if it fails, the client load handles the error, redirect or not-found as on a client navigation. +- A `redirect()` or `notFound()` returned from the `hydrate` of `beforeLoad` or `loader` is such a failure too, as when their `handler` returns one. + +## When a lifecycle fails during hydration + +During hydration the client runs some lifecycles itself: every lifecycle the server ran without sending its result, and every `hydrate`. On a route the server rendered as an error or a not-found, it runs only `context` and `hydrate` (see the end of this section). A run fails when the handler throws an error, when it throws a `redirect()` or `notFound()` (or, in `beforeLoad` and `loader`, returns one), or when `hydrate` throws. + +Hydration does not handle the failure itself and does not call `onError` for it. It shows that route as pending and hands it, together with the routes below it, to the normal client load that follows hydration. That load runs the route again like a client navigation. If the run succeeds, the route renders with its result; otherwise the load renders the `errorComponent` and calls `onError`, follows the redirect, or renders the `notFoundComponent`. On a route with `ssr: true` the route's server HTML is replaced by its pending state in the meantime, which can show up as a [hydration mismatch](./hydration-errors.md). + +A lifecycle that the client re-runs during hydration and that fails there therefore runs twice on the client, once during hydration and once in the client load, so its side effects (a request, a log entry, an analytics event) happen twice; this only happens when a run fails. + +Some lifecycles that succeeded during hydration also run again in the client load, so their side effects happen twice as well: + +- `beforeLoad` with `dehydrate: false` on the failing route and the routes below it, because every load runs `beforeLoad`. +- `loader` with `dehydrate: false` on the routes below the failing route. Loader results the browser computed below a failed route are not kept, because they may have read the failed route through `parentMatchPromise`. The client load can reuse loader data the server sent for those routes. +- A `context` that is not sent (the default), when the failing route is the root route. The client load then starts from scratch. Below the root route, a `context` that finished during hydration is kept. + +None of this applies to a route that the server already rendered as an error or a not-found, or to the root route when it shows the not-found page. Such a route keeps the server's result. During hydration the browser runs only its `context` and the `hydrate` of each result that was sent. A `beforeLoad` or `loader` whose result was not sent does not run there, so that result is missing on that route in the browser. A failure there during hydration, a throwing `hydrate` included, does not make anything run again: the route keeps the server's state, and the result that failed is missing in the browser too. A layout below the root that shows the not-found page for an unknown URL in its `Outlet` hydrates like any other route. + +The root layout renders around the not-found page. If it shows a `beforeLoad` or `loader` result, keep that result sent (the default). With `dehydrate: false` the browser renders the root layout without it on every unknown URL, which can show up as a [hydration mismatch](./hydration-errors.md). + +## What the build removes + +Each bundle only keeps the half it runs. The Start build removes these keys from route options: + +| Bundle | Removed keys | +| ------ | ------------------------------------------------------------------------------- | +| client | `context.dehydrate`, `beforeLoad.dehydrate`, `loader.dehydrate` | +| server | `context.hydrate`, `beforeLoad.hydrate`, `loader.hydrate`, `context.revalidate` | + +Source: + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + context: { + handler: async ({ params, context }) => ({ + postId: params.postId, + session: await getSession(context), + }), + dehydrate: ({ data }) => ({ + postId: data.postId, + userId: data.session.userId, + }), + hydrate: ({ data, context }) => ({ + postId: data.postId, + session: context.sessions.get(data.userId)!, + }), + revalidate: true, + }, +}) +``` + +Client bundle: `context: { handler, hydrate, revalidate: true }`. Server bundle: `context: { handler, dehydrate }`. + +The client never reads `dehydrate` at runtime. It restores a result when the payload contains one and runs the lifecycle otherwise, so removing the key changes nothing in behavior. The removal is a size optimization. An import used only inside a removed key is removed with it, so a server-only module imported only for `dehydrate` does not end up in the client bundle. This holds in every module the build emits for the route, split chunks included. A function or variable used only inside the removed key is removed too, with its imports; a class declaration is not and keeps its imports. This is not what protects server-only code: [import protection](./import-protection.md) remains the guard. + +Scope: the compiler processes the options object passed to `createFileRoute`, `createRootRoute` and `createRootRouteWithContext`, written inline or held in a variable of the same file, also behind `satisfies` or `as`. An object-form lifecycle (`loader: postsLoader`) is processed when it is written inline or held in a variable of the same file that is never reassigned. A held object is changed where it is declared, so if it is exported, the modules that import it get it without the removed keys too. Options passed through a spread, imported from another module or produced by a factory are not processed, and the build does not report that. Code-based `createRoute` calls are not visited. See [Removing route options from a build](/router/latest/docs/framework/react/guide/automatic-code-splitting#removing-route-options-from-a-build-deletenodes) for the full list. + +## Rules + +The object form follows a few rules. TypeScript enforces most of them; the key order and the annotation rule are checked by lint rules. + +- The object form requires `handler`. +- `revalidate` exists on `context` only; `staleReloadMode` on `loader` only. +- A `dehydrate` function requires `hydrate`. +- `hydrate` requires `dehydrate`: with a `dehydrate` function, `data` is the wire value; with `dehydrate: true`, `data` is the handler's full value. Without a `dehydrate` key, or with `dehydrate: false`, `hydrate` is rejected. +- Write the keys in this order: `handler`, `dehydrate`, `hydrate`, then `revalidate` or `staleReloadMode`. The parameters of `dehydrate`, `hydrate` and `revalidate` are inferred from the keys before them. The [`create-route-property-order`](/router/latest/docs/eslint/create-route-property-order) lint rule checks the order and can fix it. TypeScript does not check the order. A wrong order shows up as a type error only when it breaks that inference, and the error rarely says so: + - A `handler` that reads its options (`({ params }) => ...`), written after a function that reads `data` or `prev`, gets an error that says to write `handler` first. This is the error when the object has a `dehydrate` function or `dehydrate: false`. + - With `dehydrate: true`, or without a `dehydrate` key, the same `handler` gets an error that its result is not assignable to `never`. On a route with `ssr: false` the result is not checked for serializability, so this error changes: next to `dehydrate: true` the order compiles, and without a `dehydrate` key the error says that `dehydrate` is missing. + - A `hydrate` that reads `data`, written before a `dehydrate` function, gets an error that the `dehydrate` function is not assignable to `true`. Move `dehydrate` above `hydrate`. Do not switch to `dehydrate: true`, which sends the handler's full result. + - A `revalidate` that reads `prev`, written before a `hydrate` that returns a different shape, gets an error about the type `hydrate` returns. + + Other wrong orders compile. When one of these errors appears, fix the key order. Do not add `dehydrate: true` or switch to it to make the error go away. + +- Never annotate the parameters of `dehydrate`, `hydrate` or `revalidate`. They are inferred; an annotation is trusted instead of checked and can hide a mismatch between what the server sends and what the client expects. The [`no-lifecycle-codec-param-annotations`](/router/latest/docs/eslint/no-lifecycle-codec-param-annotations) lint rule reports annotations on functions written inline. Next to `dehydrate: true`, a `hydrate` passed by reference or typed through `satisfies` or `as` is checked by neither TypeScript nor the rule: the type it declares for `data` is taken as is. Write `hydrate` inline when `dehydrate` is `true`. +- `dehydrate` and `hydrate` may be async. They only transform a value: a `redirect()` or `notFound()` thrown from `dehydrate` is an ordinary error, and any throw from `hydrate` makes the client load run `handler` instead. +- `hydrate` may return a different shape than `handler`. The data type is then the union of both; a result assignable to the handler's data reduces to it. Narrow on a literal written with `as const` on both sides that says which function produced the value (`hydrated: false` in `handler`, `hydrated: true` in `hydrate`), not on server versus client. +- A transported result must be serializable. A custom `dehydrate` moves that check to its return value. + +If your problem is a type that does not serialize, such as a class instance, you do not need `dehydrate` and `hydrate`. Register a [serialization adapter](./serialization-adapters.md) once and return the value as is. diff --git a/docs/start/framework/react/guide/tanstack-query.md b/docs/start/framework/react/guide/tanstack-query.md index 7797ae306be..dc953079c85 100644 --- a/docs/start/framework/react/guide/tanstack-query.md +++ b/docs/start/framework/react/guide/tanstack-query.md @@ -85,6 +85,60 @@ Both calls use the same query key and options. The loader populates the request' For a parameterized resource, include its identity in the key, such as `['post', postId]`. If search values affect the result, validate them and include them in both `loaderDeps` and the query key. Keep secrets out of keys and serialized query data. +## Put query options in route context + +> **Experimental:** the route `context` option is experimental and subject to change. + +When the loader and the component need the same parameterized options, the route's `context` option can build them once per match and hand them to both: + +```tsx title="src/routes/posts.$postId.tsx" +import { useSuspenseQuery } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { postQueryOptions } from '../utils/posts' + +export const Route = createFileRoute('/posts/$postId')({ + context: ({ params }) => ({ + postQueryOptions: postQueryOptions(params.postId), + }), + loader: ({ context }) => + context.queryClient.ensureQueryData(context.postQueryOptions), + component: Post, +}) + +function Post() { + const { postQueryOptions } = Route.useRouteContext() + const { data: post } = useSuspenseQuery(postQueryOptions) + return

{post.title}

+} +``` + +This works without further configuration because of how `context` behaves. It is not sent to the client (the options hold a `queryFn`); the browser computes it again from `params` during hydration. It is cached per match, so `Route.useRouteContext()` returns the same options object across re-renders and across `router.invalidate()` calls, and the query identity stays stable. The caching rules are in the Router docs: [Router Context](/router/latest/docs/framework/react/guide/router-context). + +## Cache an auth check in route context + +> **Experimental:** the route `context` option is experimental and subject to change. + +An auth lookup that should run once per match, not on every client navigation, also belongs in `context`. Its result is plain data, so it can be sent to the client with `dehydrate: true`, and `revalidate: true` makes `router.invalidate()` run it again after a login or logout: + +```tsx title="src/routes/_authed.tsx" +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authed')({ + context: { + handler: async ({ context }) => ({ + session: await context.auth.getSession(), + }), + dehydrate: true, // send it; the result is serializability-checked + revalidate: true, // re-run on router.invalidate() (login/logout) + }, + beforeLoad: ({ context }) => { + if (!context.session) throw redirect({ to: '/login' }) + }, +}) +``` + +Without `revalidate`, `router.invalidate()` leaves the cached session alone. After a mutation that changes the session, call `router.invalidate()` for the context and `queryClient.invalidateQueries` for the private queries that depend on it. What is sent and what is recomputed is described in [SSR Data Transport](./ssr-data-transport.md). + ## Await critical data and stream secondary data Await queries that determine the page's main content, title, authorization, redirects, or whether it exists. Return title and description data from the loader when a route's `head` needs them, as the example's post route does. @@ -109,7 +163,7 @@ const mutation = useMutation({ Returning the invalidation promise keeps the mutation pending until its active query's refetch finishes. The form can use `mutation.isPending` and `mutation.isError` for feedback. The example also disables its JavaScript-dependent form until hydration and uses a POST method so a native submission cannot put form values in the URL. -Invalidate the data's actual owner. `queryClient.invalidateQueries` refreshes Query data; `router.invalidate()` reloads Router-owned loader data and route context. Use both only when the mutation changes both, such as a login that changes root authentication context and private queries. Neither call purges your CDN or invalidates a database cache. +Invalidate the data's actual owner. `queryClient.invalidateQueries` refreshes Query data; `router.invalidate()` reloads Router-owned loader data and re-runs `beforeLoad`. A route's `context` re-runs only when it sets `revalidate` (see [Cache an auth check in route context](#cache-an-auth-check-in-route-context)). Use both only when the mutation changes both, such as a login that changes the authentication context and private queries. Neither call purges your CDN or invalidates a database cache. If a mutation returns the complete updated resource, `setQueryData` can update that cache entry directly instead. Optimistic updates need cancellation, rollback on failure, and reconciliation with the server result. This example uses a confirmed write followed by invalidation, not an optimistic update. diff --git a/docs/start/framework/solid/guide/selective-ssr.md b/docs/start/framework/solid/guide/selective-ssr.md index a7b454dbae2..09975899a91 100644 --- a/docs/start/framework/solid/guide/selective-ssr.md +++ b/docs/start/framework/solid/guide/selective-ssr.md @@ -5,7 +5,7 @@ title: Selective Server-Side Rendering (SSR) ## What is Selective SSR? -In TanStack Start, routes matching the initial request are rendered on the server by default. This means `beforeLoad` and `loader` are executed on the server, followed by rendering the route components. The resulting HTML is sent to the client, which hydrates the markup into a fully interactive application. +In TanStack Start, routes matching the initial request are rendered on the server by default. This means `context`, `beforeLoad` and `loader` are executed on the server, followed by rendering the route components. The resulting HTML is sent to the client, which hydrates the markup into a fully interactive application. However, there are cases where you might want to disable SSR for certain routes or all routes, such as: @@ -14,7 +14,7 @@ However, there are cases where you might want to disable SSR for certain routes TanStack Start's Selective SSR feature lets you configure: -- Which routes should execute `beforeLoad` or `loader` on the server. +- Which routes should execute `context`, `beforeLoad` or `loader` on the server. - Which route components should be rendered on the server. ## How does this compare to SPA mode? @@ -39,6 +39,7 @@ export const startInstance = createStart(() => ({ This is the default behavior unless otherwise configured. On the initial request, it will: +- Run `context` on the server. Its result stays on the server unless the route sets `dehydrate: true`; the client runs `context` again during hydration. - Run `beforeLoad` on the server and send the resulting context to the client. - Run `loader` on the server and send the loader data to the client. - Render the component on the server and send the HTML markup to the client. @@ -63,7 +64,7 @@ export const Route = createFileRoute('/posts/$postId')({ This disables server-side: -- Execution of the route's `beforeLoad` and `loader`. +- Execution of the route's `context`, `beforeLoad` and `loader`. - Rendering of the route component. ```tsx @@ -84,6 +85,7 @@ export const Route = createFileRoute('/posts/$postId')({ This hybrid option will: +- Run `context` on the server. Its result stays on the server unless the route sets `dehydrate: true`; the client runs `context` again during hydration. - Run `beforeLoad` on the server and send the resulting context to the client. - Run `loader` on the server and send the loader data to the client. - Disable server-side rendering of the route component. @@ -156,7 +158,7 @@ root { ssr: undefined } ``` - `root` defaults to `ssr: true`. -- `posts` explicitly sets `ssr: false`, so neither `beforeLoad` nor `loader` will run on the server, and the route component won't be rendered on the server. +- `posts` explicitly sets `ssr: false`, so neither `context`, `beforeLoad` nor `loader` will run on the server, and the route component won't be rendered on the server. - `$postId` sets `ssr: true`, but inherits `ssr: false` from its parent. Because the inherited value can only be changed to be more restrictive, `ssr: true` has no effect and the inherited `ssr: false` will remain. Another example: @@ -169,9 +171,20 @@ root { ssr: undefined } ``` - `root` defaults to `ssr: true`. -- `posts` sets `ssr: 'data-only'`, so `beforeLoad` and `loader` run on the server, but the route component isn't rendered on the server. +- `posts` sets `ssr: 'data-only'`, so `context`, `beforeLoad` and `loader` run on the server, but the route component isn't rendered on the server. - `$postId` sets `ssr: true`, but inherits `ssr: 'data-only'` from its parent. -- `details` sets `ssr: false`, so neither `beforeLoad` nor `loader` will run on the server, and the route component won't be rendered on the server. Here the inherited value is changed to be more restrictive, and therefore, the `ssr: false` will override the inherited value. +- `details` sets `ssr: false`, so neither `context`, `beforeLoad` nor `loader` will run on the server, and the route component won't be rendered on the server. Here the inherited value is changed to be more restrictive, and therefore, the `ssr: false` will override the inherited value. + +## Selective SSR and data transport + +> **Experimental:** the route `context` option and the `dehydrate` and `hydrate` keys of `beforeLoad` and `loader` are experimental and subject to change. + +`ssr` decides what runs on the server. `dehydrate` on a lifecycle decides whether its result is sent to the client; see [SSR Data Transport](./ssr-data-transport.md). The two combine like this: + +- `ssr: false`: nothing runs on the server, `context` included, so there is nothing to send and `dehydrate`/`hydrate` have no effect. The client runs these lifecycles after hydration, as on a client navigation. +- `ssr: 'data-only'`: the lifecycles run on the server and their results are sent as configured; only rendering moves to the client. Pair it with `dehydrate: false` when the client's value differs from the server's. +- `ssr: true` with `dehydrate: false`: the server renders with its value and the client recomputes the value during hydration. The two must be equal, otherwise the first client render differs from the server HTML. +- `ssr: true` with a `hydrate` transform: the first client render uses what `hydrate` returns, so it must render the same as the server's value. When `hydrate` adds client-only data or returns another shape, use `ssr: 'data-only'`. ## Fallback Rendering diff --git a/docs/start/framework/solid/guide/serialization-adapters.md b/docs/start/framework/solid/guide/serialization-adapters.md new file mode 100644 index 00000000000..a5accb5d5c6 --- /dev/null +++ b/docs/start/framework/solid/guide/serialization-adapters.md @@ -0,0 +1,9 @@ +--- +ref: docs/start/framework/react/guide/serialization-adapters.md +replace: + { + '@tanstack/react-start': '@tanstack/solid-start', + '@tanstack/react-router': '@tanstack/solid-router', + '/framework/react/': '/framework/solid/', + } +--- diff --git a/docs/start/framework/solid/guide/ssr-data-transport.md b/docs/start/framework/solid/guide/ssr-data-transport.md new file mode 100644 index 00000000000..d23a0053f6a --- /dev/null +++ b/docs/start/framework/solid/guide/ssr-data-transport.md @@ -0,0 +1,12 @@ +--- +ref: docs/start/framework/react/guide/ssr-data-transport.md +replace: + { + '@tanstack/react-start': '@tanstack/solid-start', + '@tanstack/react-router': '@tanstack/solid-router', + '/framework/react/': '/framework/solid/', + 'Route.useLoaderData():': 'Route.useLoaderData()():', + '// In the component, `if (data.hydrated)` narrows to the second member and': '// In the component, read the accessor once (`const d = data()`): `if (d.hydrated)` narrows to the second member and', + '// `data.prefs` is a `Prefs`; in the other branch `prefs` does not exist.': '// `d.prefs` is a `Prefs`; in the other branch `prefs` does not exist. Two separate `data()` calls do not narrow each other.', + } +--- diff --git a/e2e/react-start/router-lifecycle-methods/.devcontainer/devcontainer.json b/e2e/react-start/router-lifecycle-methods/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..e5dafce1881 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/.devcontainer/devcontainer.json @@ -0,0 +1,3 @@ +{ + "image": "mcr.microsoft.com/devcontainers/typescript-node:24" +} 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/.prettierignore b/e2e/react-start/router-lifecycle-methods/.prettierignore new file mode 100644 index 00000000000..2be5eaa6ece --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file 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..d061bbcc479 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/package.json @@ -0,0 +1,32 @@ +{ + "name": "tanstack-react-start-e2e-router-lifecycle-methods", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "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": "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" + }, + "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": "^6.0.1", + "srvx": "^1.0.0", + "@typescript/native": "npm:typescript@^7.0.2", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vite": "^8.0.14" + } +} 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..630989e41f6 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test' +import { appServerReady } from '@tanstack/router-e2e-utils' + +const PORT = Number(process.env.E2E_APP_PORT ?? 0) +const baseURL = `http://localhost:${PORT}` +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: `VITE_SERVER_PORT=${PORT} pnpm build && NODE_ENV=production PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + wait: appServerReady, + reuseExistingServer: false, + 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..9d89e0916a7 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routeTree.gen.ts @@ -0,0 +1,711 @@ +/* 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 IndexRouteImport } from './routes/index' +import { Route as AsyncCodecRouteImport } from './routes/async-codec' +import { Route as CodecErrorDehydrateRedirectRouteImport } from './routes/codec-error-dehydrate-redirect' +import { Route as CodecErrorDehydrateThrowsRouteImport } from './routes/codec-error-dehydrate-throws' +import { Route as CodecErrorHydrateThrowsRouteImport } from './routes/codec-error-hydrate-throws' +import { Route as CodecErrorHydrateThrowsSsrRouteImport } from './routes/codec-error-hydrate-throws-ssr' +import { Route as DehydrateAllFalseRouteImport } from './routes/dehydrate-all-false' +import { Route as DehydrateAllTrueRouteImport } from './routes/dehydrate-all-true' +import { Route as DehydrateBeforeloadFalseRouteImport } from './routes/dehydrate-beforeload-false' +import { Route as DehydrateContextTrueRouteImport } from './routes/dehydrate-context-true' +import { Route as DehydrateDefaultsRouteImport } from './routes/dehydrate-defaults' +import { Route as DehydrateLoaderFalseRouteImport } from './routes/dehydrate-loader-false' +import { Route as DehydrateMixedRouteImport } from './routes/dehydrate-mixed' +import { Route as FullValueRouteImport } from './routes/full-value' +import { Route as HydratedNarrowingRouteImport } from './routes/hydrated-narrowing' +import { Route as InPlaceBeforeloadRouteImport } from './routes/in-place-beforeload' +import { Route as InPlaceLoaderRouteImport } from './routes/in-place-loader' +import { Route as InPlaceRedirectRouteImport } from './routes/in-place-redirect' +import { Route as PostsRouteImport } from './routes/posts' +import { Route as RevalidateFnRouteImport } from './routes/revalidate-fn' +import { Route as SsrFalseContextRouteImport } from './routes/ssr-false-context' +import { Route as StrippingMarkerRouteImport } from './routes/stripping-marker' +import { Route as VoidGuardRouteImport } from './routes/void-guard' +import { Route as CachedAuthUserRouteImport } from './routes/cached-auth.$user' +import { Route as DehydratePartialLocaleRouteImport } from './routes/dehydrate-partial.$locale' +import { Route as InPlaceRedirectChildRouteImport } from './routes/in-place-redirect.child' +import { Route as PostsIndexRouteImport } from './routes/posts.index' +import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' +import { Route as QueryContextPostIdRouteImport } from './routes/query-context.$postId' +import { Route as PostsPostIdCommentsRouteImport } from './routes/posts.$postId.comments' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const AsyncCodecRoute = AsyncCodecRouteImport.update({ + id: '/async-codec', + path: '/async-codec', + getParentRoute: () => rootRouteImport, +} as any) +const CodecErrorDehydrateRedirectRoute = + CodecErrorDehydrateRedirectRouteImport.update({ + id: '/codec-error-dehydrate-redirect', + path: '/codec-error-dehydrate-redirect', + getParentRoute: () => rootRouteImport, + } as any) +const CodecErrorDehydrateThrowsRoute = + CodecErrorDehydrateThrowsRouteImport.update({ + id: '/codec-error-dehydrate-throws', + path: '/codec-error-dehydrate-throws', + getParentRoute: () => rootRouteImport, + } as any) +const CodecErrorHydrateThrowsRoute = CodecErrorHydrateThrowsRouteImport.update({ + id: '/codec-error-hydrate-throws', + path: '/codec-error-hydrate-throws', + getParentRoute: () => rootRouteImport, +} as any) +const CodecErrorHydrateThrowsSsrRoute = + CodecErrorHydrateThrowsSsrRouteImport.update({ + id: '/codec-error-hydrate-throws-ssr', + path: '/codec-error-hydrate-throws-ssr', + getParentRoute: () => rootRouteImport, + } as any) +const DehydrateAllFalseRoute = DehydrateAllFalseRouteImport.update({ + id: '/dehydrate-all-false', + path: '/dehydrate-all-false', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateAllTrueRoute = DehydrateAllTrueRouteImport.update({ + id: '/dehydrate-all-true', + path: '/dehydrate-all-true', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateBeforeloadFalseRoute = + DehydrateBeforeloadFalseRouteImport.update({ + id: '/dehydrate-beforeload-false', + path: '/dehydrate-beforeload-false', + getParentRoute: () => rootRouteImport, + } as any) +const DehydrateContextTrueRoute = DehydrateContextTrueRouteImport.update({ + id: '/dehydrate-context-true', + path: '/dehydrate-context-true', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateDefaultsRoute = DehydrateDefaultsRouteImport.update({ + id: '/dehydrate-defaults', + path: '/dehydrate-defaults', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateLoaderFalseRoute = DehydrateLoaderFalseRouteImport.update({ + id: '/dehydrate-loader-false', + path: '/dehydrate-loader-false', + getParentRoute: () => rootRouteImport, +} as any) +const DehydrateMixedRoute = DehydrateMixedRouteImport.update({ + id: '/dehydrate-mixed', + path: '/dehydrate-mixed', + getParentRoute: () => rootRouteImport, +} as any) +const FullValueRoute = FullValueRouteImport.update({ + id: '/full-value', + path: '/full-value', + getParentRoute: () => rootRouteImport, +} as any) +const HydratedNarrowingRoute = HydratedNarrowingRouteImport.update({ + id: '/hydrated-narrowing', + path: '/hydrated-narrowing', + getParentRoute: () => rootRouteImport, +} as any) +const InPlaceBeforeloadRoute = InPlaceBeforeloadRouteImport.update({ + id: '/in-place-beforeload', + path: '/in-place-beforeload', + getParentRoute: () => rootRouteImport, +} as any) +const InPlaceLoaderRoute = InPlaceLoaderRouteImport.update({ + id: '/in-place-loader', + path: '/in-place-loader', + getParentRoute: () => rootRouteImport, +} as any) +const InPlaceRedirectRoute = InPlaceRedirectRouteImport.update({ + id: '/in-place-redirect', + path: '/in-place-redirect', + getParentRoute: () => rootRouteImport, +} as any) +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const RevalidateFnRoute = RevalidateFnRouteImport.update({ + id: '/revalidate-fn', + path: '/revalidate-fn', + getParentRoute: () => rootRouteImport, +} as any) +const SsrFalseContextRoute = SsrFalseContextRouteImport.update({ + id: '/ssr-false-context', + path: '/ssr-false-context', + getParentRoute: () => rootRouteImport, +} as any) +const StrippingMarkerRoute = StrippingMarkerRouteImport.update({ + id: '/stripping-marker', + path: '/stripping-marker', + getParentRoute: () => rootRouteImport, +} as any) +const VoidGuardRoute = VoidGuardRouteImport.update({ + id: '/void-guard', + path: '/void-guard', + getParentRoute: () => rootRouteImport, +} as any) +const CachedAuthUserRoute = CachedAuthUserRouteImport.update({ + id: '/cached-auth/$user', + path: '/cached-auth/$user', + getParentRoute: () => rootRouteImport, +} as any) +const DehydratePartialLocaleRoute = DehydratePartialLocaleRouteImport.update({ + id: '/dehydrate-partial/$locale', + path: '/dehydrate-partial/$locale', + getParentRoute: () => rootRouteImport, +} as any) +const InPlaceRedirectChildRoute = InPlaceRedirectChildRouteImport.update({ + id: '/child', + path: '/child', + getParentRoute: () => InPlaceRedirectRoute, +} 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 QueryContextPostIdRoute = QueryContextPostIdRouteImport.update({ + id: '/query-context/$postId', + path: '/query-context/$postId', + getParentRoute: () => rootRouteImport, +} as any) +const PostsPostIdCommentsRoute = PostsPostIdCommentsRouteImport.update({ + id: '/comments', + path: '/comments', + getParentRoute: () => PostsPostIdRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/async-codec': typeof AsyncCodecRoute + '/codec-error-dehydrate-redirect': typeof CodecErrorDehydrateRedirectRoute + '/codec-error-dehydrate-throws': typeof CodecErrorDehydrateThrowsRoute + '/codec-error-hydrate-throws': typeof CodecErrorHydrateThrowsRoute + '/codec-error-hydrate-throws-ssr': typeof CodecErrorHydrateThrowsSsrRoute + '/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-loader-false': typeof DehydrateLoaderFalseRoute + '/dehydrate-mixed': typeof DehydrateMixedRoute + '/full-value': typeof FullValueRoute + '/hydrated-narrowing': typeof HydratedNarrowingRoute + '/in-place-beforeload': typeof InPlaceBeforeloadRoute + '/in-place-loader': typeof InPlaceLoaderRoute + '/in-place-redirect': typeof InPlaceRedirectRouteWithChildren + '/posts': typeof PostsRouteWithChildren + '/revalidate-fn': typeof RevalidateFnRoute + '/ssr-false-context': typeof SsrFalseContextRoute + '/stripping-marker': typeof StrippingMarkerRoute + '/void-guard': typeof VoidGuardRoute + '/cached-auth/$user': typeof CachedAuthUserRoute + '/dehydrate-partial/$locale': typeof DehydratePartialLocaleRoute + '/in-place-redirect/child': typeof InPlaceRedirectChildRoute + '/posts/$postId': typeof PostsPostIdRouteWithChildren + '/query-context/$postId': typeof QueryContextPostIdRoute + '/posts/': typeof PostsIndexRoute + '/posts/$postId/comments': typeof PostsPostIdCommentsRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/async-codec': typeof AsyncCodecRoute + '/codec-error-dehydrate-redirect': typeof CodecErrorDehydrateRedirectRoute + '/codec-error-dehydrate-throws': typeof CodecErrorDehydrateThrowsRoute + '/codec-error-hydrate-throws': typeof CodecErrorHydrateThrowsRoute + '/codec-error-hydrate-throws-ssr': typeof CodecErrorHydrateThrowsSsrRoute + '/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-loader-false': typeof DehydrateLoaderFalseRoute + '/dehydrate-mixed': typeof DehydrateMixedRoute + '/full-value': typeof FullValueRoute + '/hydrated-narrowing': typeof HydratedNarrowingRoute + '/in-place-beforeload': typeof InPlaceBeforeloadRoute + '/in-place-loader': typeof InPlaceLoaderRoute + '/in-place-redirect': typeof InPlaceRedirectRouteWithChildren + '/revalidate-fn': typeof RevalidateFnRoute + '/ssr-false-context': typeof SsrFalseContextRoute + '/stripping-marker': typeof StrippingMarkerRoute + '/void-guard': typeof VoidGuardRoute + '/cached-auth/$user': typeof CachedAuthUserRoute + '/dehydrate-partial/$locale': typeof DehydratePartialLocaleRoute + '/in-place-redirect/child': typeof InPlaceRedirectChildRoute + '/posts/$postId': typeof PostsPostIdRouteWithChildren + '/query-context/$postId': typeof QueryContextPostIdRoute + '/posts': typeof PostsIndexRoute + '/posts/$postId/comments': typeof PostsPostIdCommentsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/async-codec': typeof AsyncCodecRoute + '/codec-error-dehydrate-redirect': typeof CodecErrorDehydrateRedirectRoute + '/codec-error-dehydrate-throws': typeof CodecErrorDehydrateThrowsRoute + '/codec-error-hydrate-throws': typeof CodecErrorHydrateThrowsRoute + '/codec-error-hydrate-throws-ssr': typeof CodecErrorHydrateThrowsSsrRoute + '/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-loader-false': typeof DehydrateLoaderFalseRoute + '/dehydrate-mixed': typeof DehydrateMixedRoute + '/full-value': typeof FullValueRoute + '/hydrated-narrowing': typeof HydratedNarrowingRoute + '/in-place-beforeload': typeof InPlaceBeforeloadRoute + '/in-place-loader': typeof InPlaceLoaderRoute + '/in-place-redirect': typeof InPlaceRedirectRouteWithChildren + '/posts': typeof PostsRouteWithChildren + '/revalidate-fn': typeof RevalidateFnRoute + '/ssr-false-context': typeof SsrFalseContextRoute + '/stripping-marker': typeof StrippingMarkerRoute + '/void-guard': typeof VoidGuardRoute + '/cached-auth/$user': typeof CachedAuthUserRoute + '/dehydrate-partial/$locale': typeof DehydratePartialLocaleRoute + '/in-place-redirect/child': typeof InPlaceRedirectChildRoute + '/posts/$postId': typeof PostsPostIdRouteWithChildren + '/query-context/$postId': typeof QueryContextPostIdRoute + '/posts/': typeof PostsIndexRoute + '/posts/$postId/comments': typeof PostsPostIdCommentsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/async-codec' + | '/codec-error-dehydrate-redirect' + | '/codec-error-dehydrate-throws' + | '/codec-error-hydrate-throws' + | '/codec-error-hydrate-throws-ssr' + | '/dehydrate-all-false' + | '/dehydrate-all-true' + | '/dehydrate-beforeload-false' + | '/dehydrate-context-true' + | '/dehydrate-defaults' + | '/dehydrate-loader-false' + | '/dehydrate-mixed' + | '/full-value' + | '/hydrated-narrowing' + | '/in-place-beforeload' + | '/in-place-loader' + | '/in-place-redirect' + | '/posts' + | '/revalidate-fn' + | '/ssr-false-context' + | '/stripping-marker' + | '/void-guard' + | '/cached-auth/$user' + | '/dehydrate-partial/$locale' + | '/in-place-redirect/child' + | '/posts/$postId' + | '/query-context/$postId' + | '/posts/' + | '/posts/$postId/comments' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/async-codec' + | '/codec-error-dehydrate-redirect' + | '/codec-error-dehydrate-throws' + | '/codec-error-hydrate-throws' + | '/codec-error-hydrate-throws-ssr' + | '/dehydrate-all-false' + | '/dehydrate-all-true' + | '/dehydrate-beforeload-false' + | '/dehydrate-context-true' + | '/dehydrate-defaults' + | '/dehydrate-loader-false' + | '/dehydrate-mixed' + | '/full-value' + | '/hydrated-narrowing' + | '/in-place-beforeload' + | '/in-place-loader' + | '/in-place-redirect' + | '/revalidate-fn' + | '/ssr-false-context' + | '/stripping-marker' + | '/void-guard' + | '/cached-auth/$user' + | '/dehydrate-partial/$locale' + | '/in-place-redirect/child' + | '/posts/$postId' + | '/query-context/$postId' + | '/posts' + | '/posts/$postId/comments' + id: + | '__root__' + | '/' + | '/async-codec' + | '/codec-error-dehydrate-redirect' + | '/codec-error-dehydrate-throws' + | '/codec-error-hydrate-throws' + | '/codec-error-hydrate-throws-ssr' + | '/dehydrate-all-false' + | '/dehydrate-all-true' + | '/dehydrate-beforeload-false' + | '/dehydrate-context-true' + | '/dehydrate-defaults' + | '/dehydrate-loader-false' + | '/dehydrate-mixed' + | '/full-value' + | '/hydrated-narrowing' + | '/in-place-beforeload' + | '/in-place-loader' + | '/in-place-redirect' + | '/posts' + | '/revalidate-fn' + | '/ssr-false-context' + | '/stripping-marker' + | '/void-guard' + | '/cached-auth/$user' + | '/dehydrate-partial/$locale' + | '/in-place-redirect/child' + | '/posts/$postId' + | '/query-context/$postId' + | '/posts/' + | '/posts/$postId/comments' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AsyncCodecRoute: typeof AsyncCodecRoute + CodecErrorDehydrateRedirectRoute: typeof CodecErrorDehydrateRedirectRoute + CodecErrorDehydrateThrowsRoute: typeof CodecErrorDehydrateThrowsRoute + CodecErrorHydrateThrowsRoute: typeof CodecErrorHydrateThrowsRoute + CodecErrorHydrateThrowsSsrRoute: typeof CodecErrorHydrateThrowsSsrRoute + DehydrateAllFalseRoute: typeof DehydrateAllFalseRoute + DehydrateAllTrueRoute: typeof DehydrateAllTrueRoute + DehydrateBeforeloadFalseRoute: typeof DehydrateBeforeloadFalseRoute + DehydrateContextTrueRoute: typeof DehydrateContextTrueRoute + DehydrateDefaultsRoute: typeof DehydrateDefaultsRoute + DehydrateLoaderFalseRoute: typeof DehydrateLoaderFalseRoute + DehydrateMixedRoute: typeof DehydrateMixedRoute + FullValueRoute: typeof FullValueRoute + HydratedNarrowingRoute: typeof HydratedNarrowingRoute + InPlaceBeforeloadRoute: typeof InPlaceBeforeloadRoute + InPlaceLoaderRoute: typeof InPlaceLoaderRoute + InPlaceRedirectRoute: typeof InPlaceRedirectRouteWithChildren + PostsRoute: typeof PostsRouteWithChildren + RevalidateFnRoute: typeof RevalidateFnRoute + SsrFalseContextRoute: typeof SsrFalseContextRoute + StrippingMarkerRoute: typeof StrippingMarkerRoute + VoidGuardRoute: typeof VoidGuardRoute + CachedAuthUserRoute: typeof CachedAuthUserRoute + DehydratePartialLocaleRoute: typeof DehydratePartialLocaleRoute + QueryContextPostIdRoute: typeof QueryContextPostIdRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/async-codec': { + id: '/async-codec' + path: '/async-codec' + fullPath: '/async-codec' + preLoaderRoute: typeof AsyncCodecRouteImport + parentRoute: typeof rootRouteImport + } + '/codec-error-dehydrate-redirect': { + id: '/codec-error-dehydrate-redirect' + path: '/codec-error-dehydrate-redirect' + fullPath: '/codec-error-dehydrate-redirect' + preLoaderRoute: typeof CodecErrorDehydrateRedirectRouteImport + parentRoute: typeof rootRouteImport + } + '/codec-error-dehydrate-throws': { + id: '/codec-error-dehydrate-throws' + path: '/codec-error-dehydrate-throws' + fullPath: '/codec-error-dehydrate-throws' + preLoaderRoute: typeof CodecErrorDehydrateThrowsRouteImport + parentRoute: typeof rootRouteImport + } + '/codec-error-hydrate-throws': { + id: '/codec-error-hydrate-throws' + path: '/codec-error-hydrate-throws' + fullPath: '/codec-error-hydrate-throws' + preLoaderRoute: typeof CodecErrorHydrateThrowsRouteImport + parentRoute: typeof rootRouteImport + } + '/codec-error-hydrate-throws-ssr': { + id: '/codec-error-hydrate-throws-ssr' + path: '/codec-error-hydrate-throws-ssr' + fullPath: '/codec-error-hydrate-throws-ssr' + preLoaderRoute: typeof CodecErrorHydrateThrowsSsrRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-all-false': { + id: '/dehydrate-all-false' + path: '/dehydrate-all-false' + fullPath: '/dehydrate-all-false' + preLoaderRoute: typeof DehydrateAllFalseRouteImport + 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-beforeload-false': { + id: '/dehydrate-beforeload-false' + path: '/dehydrate-beforeload-false' + fullPath: '/dehydrate-beforeload-false' + preLoaderRoute: typeof DehydrateBeforeloadFalseRouteImport + 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-defaults': { + id: '/dehydrate-defaults' + path: '/dehydrate-defaults' + fullPath: '/dehydrate-defaults' + preLoaderRoute: typeof DehydrateDefaultsRouteImport + 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-mixed': { + id: '/dehydrate-mixed' + path: '/dehydrate-mixed' + fullPath: '/dehydrate-mixed' + preLoaderRoute: typeof DehydrateMixedRouteImport + parentRoute: typeof rootRouteImport + } + '/full-value': { + id: '/full-value' + path: '/full-value' + fullPath: '/full-value' + preLoaderRoute: typeof FullValueRouteImport + parentRoute: typeof rootRouteImport + } + '/hydrated-narrowing': { + id: '/hydrated-narrowing' + path: '/hydrated-narrowing' + fullPath: '/hydrated-narrowing' + preLoaderRoute: typeof HydratedNarrowingRouteImport + parentRoute: typeof rootRouteImport + } + '/in-place-beforeload': { + id: '/in-place-beforeload' + path: '/in-place-beforeload' + fullPath: '/in-place-beforeload' + preLoaderRoute: typeof InPlaceBeforeloadRouteImport + parentRoute: typeof rootRouteImport + } + '/in-place-loader': { + id: '/in-place-loader' + path: '/in-place-loader' + fullPath: '/in-place-loader' + preLoaderRoute: typeof InPlaceLoaderRouteImport + parentRoute: typeof rootRouteImport + } + '/in-place-redirect': { + id: '/in-place-redirect' + path: '/in-place-redirect' + fullPath: '/in-place-redirect' + preLoaderRoute: typeof InPlaceRedirectRouteImport + parentRoute: typeof rootRouteImport + } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/revalidate-fn': { + id: '/revalidate-fn' + path: '/revalidate-fn' + fullPath: '/revalidate-fn' + preLoaderRoute: typeof RevalidateFnRouteImport + parentRoute: typeof rootRouteImport + } + '/ssr-false-context': { + id: '/ssr-false-context' + path: '/ssr-false-context' + fullPath: '/ssr-false-context' + preLoaderRoute: typeof SsrFalseContextRouteImport + parentRoute: typeof rootRouteImport + } + '/stripping-marker': { + id: '/stripping-marker' + path: '/stripping-marker' + fullPath: '/stripping-marker' + preLoaderRoute: typeof StrippingMarkerRouteImport + parentRoute: typeof rootRouteImport + } + '/void-guard': { + id: '/void-guard' + path: '/void-guard' + fullPath: '/void-guard' + preLoaderRoute: typeof VoidGuardRouteImport + parentRoute: typeof rootRouteImport + } + '/cached-auth/$user': { + id: '/cached-auth/$user' + path: '/cached-auth/$user' + fullPath: '/cached-auth/$user' + preLoaderRoute: typeof CachedAuthUserRouteImport + parentRoute: typeof rootRouteImport + } + '/dehydrate-partial/$locale': { + id: '/dehydrate-partial/$locale' + path: '/dehydrate-partial/$locale' + fullPath: '/dehydrate-partial/$locale' + preLoaderRoute: typeof DehydratePartialLocaleRouteImport + parentRoute: typeof rootRouteImport + } + '/in-place-redirect/child': { + id: '/in-place-redirect/child' + path: '/child' + fullPath: '/in-place-redirect/child' + preLoaderRoute: typeof InPlaceRedirectChildRouteImport + parentRoute: typeof InPlaceRedirectRoute + } + '/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 + } + '/query-context/$postId': { + id: '/query-context/$postId' + path: '/query-context/$postId' + fullPath: '/query-context/$postId' + preLoaderRoute: typeof QueryContextPostIdRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/$postId/comments': { + id: '/posts/$postId/comments' + path: '/comments' + fullPath: '/posts/$postId/comments' + preLoaderRoute: typeof PostsPostIdCommentsRouteImport + parentRoute: typeof PostsPostIdRoute + } + } +} + +interface InPlaceRedirectRouteChildren { + InPlaceRedirectChildRoute: typeof InPlaceRedirectChildRoute +} + +const InPlaceRedirectRouteChildren: InPlaceRedirectRouteChildren = { + InPlaceRedirectChildRoute: InPlaceRedirectChildRoute, +} + +const InPlaceRedirectRouteWithChildren = InPlaceRedirectRoute._addFileChildren( + InPlaceRedirectRouteChildren, +) + +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, + AsyncCodecRoute: AsyncCodecRoute, + CodecErrorDehydrateRedirectRoute: CodecErrorDehydrateRedirectRoute, + CodecErrorDehydrateThrowsRoute: CodecErrorDehydrateThrowsRoute, + CodecErrorHydrateThrowsRoute: CodecErrorHydrateThrowsRoute, + CodecErrorHydrateThrowsSsrRoute: CodecErrorHydrateThrowsSsrRoute, + DehydrateAllFalseRoute: DehydrateAllFalseRoute, + DehydrateAllTrueRoute: DehydrateAllTrueRoute, + DehydrateBeforeloadFalseRoute: DehydrateBeforeloadFalseRoute, + DehydrateContextTrueRoute: DehydrateContextTrueRoute, + DehydrateDefaultsRoute: DehydrateDefaultsRoute, + DehydrateLoaderFalseRoute: DehydrateLoaderFalseRoute, + DehydrateMixedRoute: DehydrateMixedRoute, + FullValueRoute: FullValueRoute, + HydratedNarrowingRoute: HydratedNarrowingRoute, + InPlaceBeforeloadRoute: InPlaceBeforeloadRoute, + InPlaceLoaderRoute: InPlaceLoaderRoute, + InPlaceRedirectRoute: InPlaceRedirectRouteWithChildren, + PostsRoute: PostsRouteWithChildren, + RevalidateFnRoute: RevalidateFnRoute, + SsrFalseContextRoute: SsrFalseContextRoute, + StrippingMarkerRoute: StrippingMarkerRoute, + VoidGuardRoute: VoidGuardRoute, + CachedAuthUserRoute: CachedAuthUserRoute, + DehydratePartialLocaleRoute: DehydratePartialLocaleRoute, + QueryContextPostIdRoute: QueryContextPostIdRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: 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..3e84fc54709 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/__root.tsx @@ -0,0 +1,232 @@ +/// +import * as React from 'react' +import { + ClientOnly, + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, + useRouterState, +} 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}
+ +
+ ) +} + +const links: Array<{ + to: string + params?: Record + id: string + label: string +}> = [ + { to: '/', id: 'home', label: 'Home' }, + { to: '/posts', id: 'posts', label: 'Posts' }, + { + to: '/posts/$postId', + params: { postId: '1' }, + id: 'post-1', + label: 'Post 1', + }, + { + to: '/posts/$postId', + params: { postId: '2' }, + id: 'post-2', + label: 'Post 2', + }, + { + to: '/posts/$postId/comments', + params: { postId: '1' }, + id: 'post-1-comments', + label: 'Post 1 Comments', + }, + { + to: '/cached-auth/$user', + params: { user: 'alice' }, + id: 'cached-auth-alice', + label: 'Cached auth (alice)', + }, + { + to: '/cached-auth/$user', + params: { user: 'nobody' }, + id: 'cached-auth-nobody', + label: 'Cached auth (nobody)', + }, + { + to: '/query-context/$postId', + params: { postId: '1' }, + id: 'query-context', + label: 'Query context', + }, + { + to: '/revalidate-fn', + id: 'revalidate-fn', + label: 'Revalidate fn', + }, + { + to: '/dehydrate-defaults', + id: 'dehydrate-defaults', + label: 'Dehydrate Defaults', + }, + { + to: '/dehydrate-all-true', + id: 'dehydrate-all-true', + label: 'Dehydrate All True', + }, + { + to: '/dehydrate-all-false', + id: 'dehydrate-all-false', + label: 'Dehydrate All False', + }, + { to: '/dehydrate-mixed', id: 'dehydrate-mixed', label: 'Dehydrate Mixed' }, + { + to: '/dehydrate-beforeload-false', + id: 'dehydrate-beforeload-false', + label: 'Dehydrate BeforeLoad False', + }, + { + to: '/dehydrate-loader-false', + id: 'dehydrate-loader-false', + label: 'Dehydrate Loader False', + }, + { + to: '/dehydrate-context-true', + id: 'dehydrate-context-true', + label: 'Dehydrate Context True', + }, + { + to: '/dehydrate-partial/$locale', + params: { locale: 'de-DE' }, + id: 'dehydrate-partial', + label: 'Dehydrate Partial', + }, + { + to: '/hydrated-narrowing', + id: 'hydrated-narrowing', + label: 'Hydrated narrowing', + }, + { to: '/full-value', id: 'full-value', label: 'Full value' }, + { to: '/void-guard', id: 'void-guard', label: 'Void guard' }, + { + to: '/ssr-false-context', + id: 'ssr-false-context', + label: 'SSR false context', + }, + { to: '/async-codec', id: 'async-codec', label: 'Async codec' }, + { + to: '/codec-error-dehydrate-throws', + id: 'codec-error-dehydrate-throws', + label: 'Codec error: dehydrate throws', + }, + { + to: '/codec-error-hydrate-throws', + id: 'codec-error-hydrate-throws', + label: 'Codec error: hydrate throws', + }, + { + to: '/codec-error-hydrate-throws-ssr', + id: 'codec-error-hydrate-throws-ssr', + label: 'Codec error: hydrate throws (ssr: true)', + }, + { + to: '/codec-error-dehydrate-redirect', + id: 'codec-error-dehydrate-redirect', + label: 'Codec error: redirect from dehydrate', + }, + { + to: '/in-place-beforeload', + id: 'in-place-beforeload', + label: 'In-place beforeLoad', + }, + { to: '/in-place-loader', id: 'in-place-loader', label: 'In-place loader' }, + { + to: '/in-place-redirect/child', + id: 'in-place-redirect-child', + label: 'In-place redirect (child)', + }, + { + to: '/stripping-marker', + id: 'stripping-marker', + label: 'Stripping marker', + }, +] + +function RouterStatus() { + const { isLoading, status } = useRouterState({ + select: (state) => ({ isLoading: state.isLoading, status: state.status }), + structuralSharing: true, + }) + return ( +
+
hydrated
+
+ router isLoading:{' '} + {isLoading ? 'true' : 'false'} +
+
+ router status: {status} +
+
+ ) +} + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + +
+ + + +
+ {children} + + + + + ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/async-codec.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/async-codec.tsx new file mode 100644 index 00000000000..82cf0174660 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/async-codec.tsx @@ -0,0 +1,92 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +// Async codecs: `dehydrate` and `hydrate` may return promises; the +// server awaits `dehydrate` before the match settles and the client awaits +// `hydrate` in the hydration lane. The types unwrap the promise on both +// sides, so `hydrate` receives the awaited wire shape. Exercised on +// `context` and `loader`. + +const getContextLabel = createIsomorphicFn() + .server(() => 'server-ac-context') + .client(() => 'client-ac-context') + +const getLoaderTitle = createIsomorphicFn() + .server(() => 'server-ac-loader') + .client(() => 'client-ac-loader') + +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)) + +type ContextData = { label: string; stamp: number; via: string } +type ContextWire = { label: string; packed: string } + +async function encodeContext(data: ContextData): Promise { + await tick() + return { label: data.label, packed: `${data.stamp}` } +} + +async function decodeContext(wire: ContextWire): Promise { + await tick() + return { label: wire.label, stamp: Number(wire.packed), via: 'async-hydrate' } +} + +type LoaderData = { title: string; scores: Array; via: string } +type LoaderWire = { title: string; csv: string } + +async function encodeLoader(data: LoaderData): Promise { + await tick() + return { title: data.title, csv: data.scores.join(',') } +} + +async function decodeLoader(wire: LoaderWire): Promise { + await tick() + return { + title: wire.title, + scores: wire.csv.split(',').map(Number), + via: 'async-hydrate', + } +} + +export const Route = createFileRoute('/async-codec')({ + ssr: 'data-only', + context: { + handler: (): ContextData => ({ + label: getContextLabel(), + stamp: 7, + via: 'handler', + }), + dehydrate: ({ data }) => encodeContext(data), + hydrate: ({ data }) => decodeContext(data), + }, + loader: { + handler: (): LoaderData => ({ + title: getLoaderTitle(), + scores: [10, 20, 30], + via: 'handler', + }), + dehydrate: ({ data }) => encodeLoader(data), + hydrate: ({ data }) => decodeLoader(data), + }, + component: AsyncCodecComponent, +}) + +function AsyncCodecComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Async codec

+
{context.label}
+
{String(context.stamp)}
+
{typeof context.stamp}
+
{context.via}
+
{loaderData.title}
+
{loaderData.scores.join('|')}
+
+ {Array.isArray(loaderData.scores) ? 'array' : typeof loaderData.scores} +
+
{loaderData.via}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/cached-auth.$user.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/cached-auth.$user.tsx new file mode 100644 index 00000000000..8265d8f5875 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/cached-auth.$user.tsx @@ -0,0 +1,63 @@ +import { createFileRoute, redirect, useRouter } from '@tanstack/react-router' +import { countClientRun, getOrigin } from '~/utils/origin' + +// Cached auth context. +// The session lookup runs once per match on the server, is transported +// (`dehydrate: true`), is restored at hydration without running again, and is +// refreshed only by `router.invalidate()` (`revalidate: true`). The beforeLoad +// guard redirects when there is no session. + +async function getSession(user: string) { + await new Promise((resolve) => setTimeout(resolve, 5)) + if (user === 'nobody') return null + return { user, origin: getOrigin() } +} + +export const Route = createFileRoute('/cached-auth/$user')({ + context: { + handler: async ({ params }) => ({ + session: await getSession(params.user), + // 0 when the value was produced on the server; n after the n-th client run. + clientRuns: countClientRun('auth-context'), + }), + dehydrate: true, + revalidate: true, + }, + beforeLoad: ({ context }) => { + if (!context.session) throw redirect({ to: '/' }) + return { guard: `guarded-${context.session.user}` } + }, + loader: ({ context }) => ({ + loaderOrigin: getOrigin(), + loaderUser: context.session?.user ?? 'none', + }), + component: CachedAuthComponent, +}) + +function CachedAuthComponent() { + const router = useRouter() + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Cached auth context

+
+ {context.session?.user ?? 'none'} +
+
+ {context.session?.origin ?? 'none'} +
+
{String(context.clientRuns)}
+
{context.guard}
+
{loaderData.loaderOrigin}
+
{loaderData.loaderUser}
+ +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-dehydrate-redirect.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-dehydrate-redirect.tsx new file mode 100644 index 00000000000..5518e703c90 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-dehydrate-redirect.tsx @@ -0,0 +1,27 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +// Codecs are transforms, not lifecycles. A `redirect()` thrown from +// `dehydrate` is not control flow; it is treated as an ordinary error, so the +// response is an error page at the same URL, never a redirect. + +export const Route = createFileRoute('/codec-error-dehydrate-redirect')({ + loader: { + handler: () => ({ value: 'loader-value' }), + dehydrate: ({ data }) => { + if (data.value) throw redirect({ to: '/' }) + return { value: data.value } + }, + hydrate: ({ data }) => ({ value: data.value }), + }, + component: () => ( +
+

redirect from dehydrate

+
{Route.useLoaderData().value}
+
+ ), + errorComponent: ({ error }) => ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ), +}) diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-dehydrate-throws.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-dehydrate-throws.tsx new file mode 100644 index 00000000000..475a2ddbeb0 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-dehydrate-throws.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from '@tanstack/react-router' + +// A throw inside `dehydrate` is handled like a throw inside the handler. +// The match becomes an error match on the server, the route's errorComponent +// is part of the SSR HTML and the response carries the error status. + +export const Route = createFileRoute('/codec-error-dehydrate-throws')({ + loader: { + handler: () => ({ value: 'loader-value' }), + dehydrate: ({ data }) => { + if (data.value) throw new Error('TSR_E2E_DEHYDRATE_THROW') + return { value: data.value } + }, + hydrate: ({ data }) => ({ value: data.value }), + }, + component: () => ( +
+

dehydrate throws

+
{Route.useLoaderData().value}
+
+ ), + errorComponent: ({ error }) => ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ), +}) diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-hydrate-throws-ssr.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-hydrate-throws-ssr.tsx new file mode 100644 index 00000000000..0d4c073af9a --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-hydrate-throws-ssr.tsx @@ -0,0 +1,33 @@ +import { createFileRoute } from '@tanstack/react-router' +import { countClientRun } from '~/utils/origin' + +// A throwing `hydrate` on an `ssr: true` route: the server rendered the +// component from the handler's value and transported it; `hydrate` throws +// during hydration, and the client load that follows re-runs the handler. The +// handler returns the server's value, so `countClientRun` tells its client +// run apart from a presented wire value. + +export const Route = createFileRoute('/codec-error-hydrate-throws-ssr')({ + loader: { + handler: () => { + countClientRun('cehts-loader') + return { value: 'ssr-loader-value' } + }, + dehydrate: true, + hydrate: ({ data }) => { + if (data.value) throw new Error('TSR_E2E_HYDRATE_THROW_SSR') + return data + }, + }, + component: () => ( +
+

hydrate throws (ssr: true)

+
{Route.useLoaderData().value}
+
+ ), + errorComponent: ({ error }) => ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ), +}) diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-hydrate-throws.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-hydrate-throws.tsx new file mode 100644 index 00000000000..0d8ac50931e --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/codec-error-hydrate-throws.tsx @@ -0,0 +1,33 @@ +import { createFileRoute } from '@tanstack/react-router' +import { countClientRun } from '~/utils/origin' + +// A throwing `hydrate` makes that match the pending boundary +// of the client load that follows hydration, which re-runs the handler. The +// handler returns the transported value, so `countClientRun` tells its +// client run apart from a presented wire value. + +export const Route = createFileRoute('/codec-error-hydrate-throws')({ + ssr: 'data-only', + beforeLoad: { + handler: () => { + countClientRun('ceht-beforeLoad') + return { guard: 'ok' } + }, + dehydrate: true, + hydrate: ({ data }) => { + if (data.guard) throw new Error('TSR_E2E_HYDRATE_THROW') + return data + }, + }, + component: () => ( +
+

hydrate throws

+
{Route.useRouteContext().guard}
+
+ ), + errorComponent: ({ error }) => ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ), +}) 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..8a1591c483e --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-false.tsx @@ -0,0 +1,49 @@ +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 transported, everything re-run on the client during hydration. + context: { + handler: () => ({ dafContextCtx: getContext() }), + dehydrate: false, + }, + beforeLoad: { + handler: () => ({ dafBeforeLoadCtx: getBeforeLoad() }), + dehydrate: false, + }, + loader: { + handler: () => ({ dafLoaderData: getLoader() }), + dehydrate: false, + }, + // data-only SSR: a lifecycle that is not transported re-runs on the client + // and produces a different value, which an SSR-rendered component would + // mismatch on. + ssr: 'data-only', + component: RouteComponent, +}) + +function RouteComponent() { + 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..3cc121b75f5 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-all-true.tsx @@ -0,0 +1,49 @@ +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 restored from the payload. + context: { + handler: () => ({ datContextCtx: getContext() }), + dehydrate: true, + }, + beforeLoad: { + handler: () => ({ datBeforeLoadCtx: getBeforeLoad() }), + dehydrate: true, + }, + loader: { + handler: () => ({ datLoaderData: getLoader() }), + dehydrate: true, + }, + // data-only SSR: a lifecycle that is not transported re-runs on the client + // and produces a different value, which an SSR-rendered component would + // mismatch on. + ssr: 'data-only', + component: RouteComponent, +}) + +function RouteComponent() { + 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..038cc247e81 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-beforeload-false.tsx @@ -0,0 +1,43 @@ +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 opts out of transport; context and loader keep their defaults. + context: () => ({ dbfContextCtx: getContext() }), + beforeLoad: { + handler: () => ({ dbfBeforeLoadCtx: getBeforeLoad() }), + dehydrate: false, + }, + loader: () => ({ dbfLoaderData: getLoader() }), + // data-only SSR: a lifecycle that is not transported re-runs on the client + // and produces a different value, which an SSR-rendered component would + // mismatch on. + ssr: 'data-only', + component: RouteComponent, +}) + +function RouteComponent() { + 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..3b3d9fafa7f --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-context-true.tsx @@ -0,0 +1,43 @@ +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 opts into transport; beforeLoad and loader keep their defaults. + context: { + handler: () => ({ dctContextCtx: getContext() }), + dehydrate: true, + }, + beforeLoad: () => ({ dctBeforeLoadCtx: getBeforeLoad() }), + loader: () => ({ dctLoaderData: getLoader() }), + // data-only SSR: a lifecycle that is not transported re-runs on the client + // and produces a different value, which an SSR-rendered component would + // mismatch on. + ssr: 'data-only', + component: RouteComponent, +}) + +function RouteComponent() { + 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..43f700bb632 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-defaults.tsx @@ -0,0 +1,40 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +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: builtin transport defaults (context: false, beforeLoad: true, loader: true). + context: () => ({ ddContextCtx: getContext() }), + beforeLoad: () => ({ ddBeforeLoadCtx: getBeforeLoad() }), + loader: () => ({ ddLoaderData: getLoader() }), + // data-only SSR: a lifecycle that is not transported re-runs on the client + // and produces a different value, which an SSR-rendered component would + // mismatch on. + ssr: 'data-only', + component: RouteComponent, +}) + +function RouteComponent() { + 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-loader-false.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-loader-false.tsx new file mode 100644 index 00000000000..bcc25cac389 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-loader-false.tsx @@ -0,0 +1,43 @@ +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 opts out of transport; context and beforeLoad keep their defaults. + context: () => ({ dlfContextCtx: getContext() }), + beforeLoad: () => ({ dlfBeforeLoadCtx: getBeforeLoad() }), + loader: { + handler: () => ({ dlfLoaderData: getLoader() }), + dehydrate: false, + }, + // data-only SSR: a lifecycle that is not transported re-runs on the client + // and produces a different value, which an SSR-rendered component would + // mismatch on. + ssr: 'data-only', + component: RouteComponent, +}) + +function RouteComponent() { + 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..82621f7b85b --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-mixed.tsx @@ -0,0 +1,49 @@ +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: context true (default is false), beforeLoad false (default is true), loader true. + context: { + handler: () => ({ dmContextCtx: getContext() }), + dehydrate: true, + }, + beforeLoad: { + handler: () => ({ dmBeforeLoadCtx: getBeforeLoad() }), + dehydrate: false, + }, + loader: { + handler: () => ({ dmLoaderData: getLoader() }), + dehydrate: true, + }, + // data-only SSR: a lifecycle that is not transported re-runs on the client + // and produces a different value, which an SSR-rendered component would + // mismatch on. + ssr: 'data-only', + component: RouteComponent, +}) + +function RouteComponent() { + 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.$locale.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-partial.$locale.tsx new file mode 100644 index 00000000000..26d7f8cb43c --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/dehydrate-partial.$locale.tsx @@ -0,0 +1,170 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +// Client-side reconstruction from shipped data. +// +// Each lifecycle returns a mixed object with serializable fields (string, +// number, array) and non-serializable ones (Date, RegExp, Intl formatter, +// functions). `dehydrate` keeps only the wire-safe subset; `hydrate` rebuilds +// the full shape and uses its ctx for that: `params.locale` for the formatter, +// the parent `context` for the label functions. +// +// Server and client use different prefixes so tests can tell which side +// produced each value. + +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') + +function currencyFormatter(locale: string) { + return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }) +} + +export const Route = createFileRoute('/dehydrate-partial/$locale')({ + ssr: 'data-only', + context: { + handler: ({ params }) => { + const label = getContextLabel() + return { + label, + createdAt: new Date('2024-03-15T12:00:00.000Z'), + currency: currencyFormatter(params.locale), + format: (v: string) => `[${label}] ${v}`, + } + }, + dehydrate: ({ data }) => ({ + label: data.label, + createdAtISO: data.createdAt.toISOString(), + }), + hydrate: ({ data, params, context }) => ({ + label: data.label, + createdAt: new Date(data.createdAtISO), + // rebuilt from the route params, not from the wire + currency: currencyFormatter(params.locale), + // uses the parent context (root's function-form context, re-run first) + format: (v: string) => `[${data.label}|${context.rootContextCtx}] ${v}`, + }), + }, + + beforeLoad: { + handler: ({ context }) => { + const tag = getBeforeLoadTag() + return { + tag, + count: 42, + pattern: /^hello-\d+$/i, + describe: () => `${context.label}:${tag}`, + } + }, + dehydrate: ({ data }) => ({ + tag: data.tag, + count: data.count, + patternSource: data.pattern.source, + patternFlags: data.pattern.flags, + }), + hydrate: ({ data, context }) => ({ + tag: data.tag, + count: data.count, + pattern: new RegExp(data.patternSource, data.patternFlags), + // uses this route's own (already hydrated) context contribution + describe: () => `${context.label}:${data.tag}:hydrated`, + }), + }, + + loader: { + handler: ({ context }) => { + const scores = [10, 20, 30] + return { + title: getLoaderTitle(), + scores, + computeAvg: () => scores.reduce((a, b) => a + b, 0) / scores.length, + summary: () => `${context.tag}/${scores.length}`, + } + }, + dehydrate: ({ data }) => ({ + title: data.title, + scores: data.scores, + }), + hydrate: ({ data, context }) => ({ + title: data.title, + scores: data.scores, + computeAvg: () => + data.scores.reduce((a, b) => a + b, 0) / data.scores.length, + // uses the beforeLoad contribution, visible in the loader ctx + summary: () => `${context.tag}/${data.scores.length}:hydrated`, + }), + }, + + component: DehydratePartialComponent, +}) + +function DehydratePartialComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + const contextDateOk = context.createdAt instanceof Date + const contextFormatOk = typeof context.format === 'function' + const contextCurrencyOk = context.currency instanceof Intl.NumberFormat + const blPatternOk = context.pattern instanceof RegExp + const loaderComputeOk = typeof loaderData.computeAvg === 'function' + + return ( +
+

Dehydrate Partial

+ +
{context.label}
+
{context.tag}
+
{loaderData.title}
+
{String(context.count)}
+ +
+ {contextDateOk ? context.createdAt.toISOString() : 'NOT_DATE'} +
+
+ {contextDateOk ? 'Date' : 'other'} +
+ +
+ {contextFormatOk ? context.format('test') : 'NOT_FN'} +
+
+ {contextFormatOk ? 'function' : 'other'} +
+ +
+ {contextCurrencyOk ? context.currency.format(1234.5) : 'NOT_FORMATTER'} +
+
+ {contextCurrencyOk ? context.currency.resolvedOptions().locale : 'n/a'} +
+ +
+ {blPatternOk ? context.pattern.source : 'NOT_REGEXP'} +
+
+ {blPatternOk ? 'RegExp' : 'other'} +
+
+ {blPatternOk ? String(context.pattern.test('hello-123')) : 'N/A'} +
+
{context.describe()}
+ +
{loaderData.scores.join(',')}
+
+ {loaderComputeOk ? String(loaderData.computeAvg()) : 'NOT_FN'} +
+
+ {loaderComputeOk ? 'function' : 'other'} +
+
{loaderData.summary()}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/full-value.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/full-value.tsx new file mode 100644 index 00000000000..e5794fb6133 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/full-value.tsx @@ -0,0 +1,53 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +// Reconstruct from the full value. Default transport (`dehydrate: true`, +// required next to `hydrate`) plus a client-side step that fills a field the +// server cannot know. The client-only field is declared in the handler's +// return type (`undefined` on the server) and is itself serializable: the +// handler result is what the serializability check sees, and the `hydrate` +// result, assignable to the handler data, reduces to it, so the loader data +// stays `{ report; timeZone: string | undefined }`. +// On a client navigation the handler runs in the browser and leaves +// `timeZone` undefined; only `hydrate` fills it. + +const getReportSource = createIsomorphicFn() + .server(() => 'server-full-value-report') + .client(() => 'client-full-value-report') + +async function fetchReport() { + await new Promise((resolve) => setTimeout(resolve, 5)) + return { title: 'Quarterly report', source: getReportSource() } +} + +const resolvedTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone + +export const Route = createFileRoute('/full-value')({ + // The hydrated value differs from the server value (timeZone), so the + // component must not be server-rendered: a server-rendered component needs + // the same value on both sides. + ssr: 'data-only', + loader: { + handler: async () => ({ + report: await fetchReport(), + timeZone: undefined as string | undefined, // filled on the client + }), + dehydrate: true, + hydrate: ({ data }) => ({ ...data, timeZone: resolvedTimeZone() }), + }, + component: FullValueComponent, +}) + +function FullValueComponent() { + const data = Route.useLoaderData() + + return ( +
+

Full value

+
{data.report.title}
+
{data.report.source}
+
{String(data.timeZone)}
+
{typeof data.timeZone}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/hydrated-narrowing.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/hydrated-narrowing.tsx new file mode 100644 index 00000000000..3c0f8ddb6d7 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/hydrated-narrowing.tsx @@ -0,0 +1,68 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' +import { countClientRun } from '~/utils/origin' + +// Combine server data with client-only data and narrow on a discriminant. +// +// `hydrate` returns a different shape than the handler, so the loader data is +// the union of both. The discriminant says which function produced the value +// and is a literal written on both sides (`as const`): the handler returns +// `hydrated: false`, `hydrate` returns `hydrated: true` plus the client-only +// `prefs`. It cannot be server versus client: the handler also runs in the +// browser on client navigations, and a runtime `env` would make the hydrate +// shape a structural subtype, which the types reduce to the handler data +// (`prefs` would be invisible). After a client navigation the component sees +// the `hydrated: false` branch, which is truthful: nothing went through +// `hydrate`. + +type Prefs = { theme: string } + +// Runs only inside `hydrate`, which the server build deletes. +function readPrefs(): Prefs { + return { theme: window.localStorage.getItem('tsr-e2e-theme') ?? 'light' } +} + +const fetchStats = createIsomorphicFn() + .server(() => ({ visits: 42, origin: 'server' })) + .client(() => ({ + visits: 42 + countClientRun('narrowing-stats'), + origin: 'client', + })) + +export const Route = createFileRoute('/hydrated-narrowing')({ + // The hydrated value differs from the server value (prefs), so the + // component must not be server-rendered: a server-rendered component needs + // the same value on both sides. + ssr: 'data-only', + loader: { + handler: () => ({ hydrated: false as const, stats: fetchStats() }), + dehydrate: ({ data }) => ({ stats: data.stats }), + hydrate: ({ data }) => ({ + hydrated: true as const, + stats: data.stats, + prefs: readPrefs(), + }), + }, + component: HydratedNarrowingComponent, +}) + +function HydratedNarrowingComponent() { + const data = Route.useLoaderData() + + return ( +
+

Hydrated narrowing

+
{String(data.hydrated)}
+
{data.stats.origin}
+
+ {String(data.stats.visits)} +
+ {data.hydrated ? ( + // narrowed: `prefs` exists only on the hydrated branch +
{data.prefs.theme}
+ ) : ( +
no-prefs
+ )} +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/in-place-beforeload.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/in-place-beforeload.tsx new file mode 100644 index 00000000000..7bb16b536f2 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/in-place-beforeload.tsx @@ -0,0 +1,57 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' +import { countClientRun } from '~/utils/origin' + +// In-place re-run on an `ssr: true` route: the server runs this beforeLoad +// but does not transport it (`dehydrate: false`); the client re-runs it in +// place during hydration while the match stays committed. The value is +// deterministic (same on both sides), so there is no hydration error and +// no pending flash: the component mounts once and the pendingComponent never +// renders. + +const notePendingRender = createIsomorphicFn() + .server(() => {}) + .client(() => { + countClientRun('ipb-pending') + }) + +const noteMount = createIsomorphicFn() + .server(() => {}) + .client(() => { + countClientRun('ipb-mount') + }) + +export const Route = createFileRoute('/in-place-beforeload')({ + beforeLoad: { + handler: () => { + countClientRun('ipb-beforeLoad') + return { ipbValue: 'deterministic-42' } + }, + dehydrate: false, + }, + loader: ({ context }) => ({ ipbLoader: `loader-${context.ipbValue}` }), + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: () => { + notePendingRender() + return
pending
+ }, + component: InPlaceBeforeLoadComponent, +}) + +function InPlaceBeforeLoadComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + React.useEffect(() => { + noteMount() + }, []) + + return ( +
+

In-place beforeLoad

+
{context.ipbValue}
+
{loaderData.ipbLoader}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/in-place-loader.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/in-place-loader.tsx new file mode 100644 index 00000000000..fc52252ccde --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/in-place-loader.tsx @@ -0,0 +1,37 @@ +import { createFileRoute } from '@tanstack/react-router' +import { countClientRun, getOrigin } from '~/utils/origin' + +// In-place re-run of a non-transported loader on a data-only route: the +// server runs the loader, sends nothing for it, and the client runs it exactly +// once during hydration (no second client load afterwards). + +export const Route = createFileRoute('/in-place-loader')({ + ssr: 'data-only', + context: () => ({ iplContextCtx: 'ipl-context' }), + beforeLoad: () => ({ iplBeforeLoadCtx: 'ipl-beforeLoad' }), + loader: { + handler: () => ({ + origin: getOrigin(), + clientRuns: countClientRun('ipl-loader'), + }), + dehydrate: false, + }, + component: InPlaceLoaderComponent, +}) + +function InPlaceLoaderComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

In-place loader

+
{context.iplContextCtx}
+
{context.iplBeforeLoadCtx}
+
{loaderData.origin}
+
+ {String(loaderData.clientRuns)} +
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/in-place-redirect.child.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/in-place-redirect.child.tsx new file mode 100644 index 00000000000..6054409a557 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/in-place-redirect.child.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { countClientRun } from '~/utils/origin' + +// Child of /in-place-redirect: a codec loader whose wire value carries a +// marker. The parent redirects during hydration, so neither the raw wire value +// nor the hydrated value may ever render (the component must not mount). + +const WIRE_PREFIX = 'TSR_E2E_IPR_WIRE:' + +export const Route = createFileRoute('/in-place-redirect/child')({ + loader: { + handler: () => ({ secret: 'child-plain-value' }), + dehydrate: ({ data }) => ({ wire: `${WIRE_PREFIX}${data.secret}` }), + hydrate: ({ data }) => { + countClientRun('ipr-child-hydrate') + return { secret: data.wire.slice(WIRE_PREFIX.length) } + }, + }, + component: InPlaceRedirectChildComponent, +}) + +function InPlaceRedirectChildComponent() { + const loaderData = Route.useLoaderData() + React.useEffect(() => { + countClientRun('ipr-child-mount') + }, []) + + return ( +
+
{JSON.stringify(loaderData)}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/in-place-redirect.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/in-place-redirect.tsx new file mode 100644 index 00000000000..b26632df75c --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/in-place-redirect.tsx @@ -0,0 +1,33 @@ +import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' +import { countClientRun } from '~/utils/origin' + +// A redirect thrown by an in-place re-run during hydration. This +// beforeLoad is not transported (`dehydrate: false`); its server run passes, +// its client runs (in place during hydration, then in the client load that +// follows) hit a client-only condition and redirect. The child route carries +// a codec loader whose transported wire value must never reach the DOM. The +// two runs produce different outcomes, so the subtree is not server-rendered +// (a server-rendered component needs the same value on both sides). + +const mustRedirect = createIsomorphicFn() + .server(() => false) + .client(() => true) + +export const Route = createFileRoute('/in-place-redirect')({ + ssr: 'data-only', + beforeLoad: { + handler: () => { + countClientRun('ipr-beforeLoad') + if (mustRedirect()) throw redirect({ to: '/' }) + return { iprGuard: 'passed' } + }, + dehydrate: false, + }, + component: () => ( +
+

In-place redirect

+ +
+ ), +}) 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..45f69468c67 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/posts.$postId.tsx @@ -0,0 +1,34 @@ +import { Outlet, createFileRoute } 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..3f105ebe270 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/posts.tsx @@ -0,0 +1,42 @@ +import { Link, Outlet, createFileRoute } 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/query-context.$postId.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/query-context.$postId.tsx new file mode 100644 index 00000000000..cbf134a2c94 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/query-context.$postId.tsx @@ -0,0 +1,70 @@ +import { ClientOnly, createFileRoute, useRouter } from '@tanstack/react-router' +import { countClientRun, getOrigin } from '~/utils/origin' +import { getPost } from '~/utils/posts' + +// Query options in context, the default case. +// Function form: not transported (context default is `dehydrate: false`), so +// the client recomputes it during hydration. It is memoized per match, so +// `router.invalidate()` does not re-run it and consumers keep identities. + +function postQueryOptions(postId: string) { + return { + queryKey: ['post', postId] as const, + queryFn: () => getPost(Number(postId)), + } +} + +export const Route = createFileRoute('/query-context/$postId')({ + context: ({ params }) => ({ + postQueryOptions: postQueryOptions(params.postId), + contextOrigin: getOrigin(), + // Identity witness: increments on every client run of this handler. + contextInstance: countClientRun('query-context'), + }), + loader: ({ context }) => ({ + post: context.postQueryOptions.queryFn(), + loaderOrigin: getOrigin(), + loaderInstance: countClientRun('query-loader'), + }), + component: QueryContextComponent, +}) + +function QueryContextComponent() { + const router = useRouter() + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Query context

+
+ {loaderData.post?.title ?? 'none'} +
+
{loaderData.loaderOrigin}
+
+ {String(loaderData.loaderInstance)} +
+ {/* The context is recomputed on the client, so origin-dependent fields + are rendered client-only on this ssr: true route: a server-rendered + component needs the same value on both sides. */} + +
{context.contextOrigin}
+
+ {String(context.contextInstance)} +
+
+ {typeof context.postQueryOptions.queryFn} +
+
+ {context.postQueryOptions.queryKey.join('/')} +
+
+ +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/revalidate-fn.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/revalidate-fn.tsx new file mode 100644 index 00000000000..c721bc70304 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/revalidate-fn.tsx @@ -0,0 +1,65 @@ +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' +import { countClientRun, getOrigin } from '~/utils/origin' + +// Refresh part of a context, keep the rest stable. +// `revalidate` receives `prev` and keeps `stableToken` while refreshing +// `authStatus`. The context is transported (`dehydrate: true`), so after SSR +// the first `prev` is the value restored from the payload. + +let clientStableCounter = 0 +const nextStableToken = createIsomorphicFn() + .server(() => 'stable-server') + .client(() => `stable-client-${++clientStableCounter}`) + +export const Route = createFileRoute('/revalidate-fn')({ + context: { + handler: () => ({ + origin: getOrigin(), + stableToken: nextStableToken(), + authStatus: 1, + revalidateRuns: 0, + }), + dehydrate: true, + revalidate: ({ prev }) => ({ + origin: getOrigin(), + stableToken: prev?.stableToken ?? nextStableToken(), + authStatus: (prev?.authStatus ?? 0) + 1, + revalidateRuns: countClientRun('revalidate'), + }), + }, + beforeLoad: () => ({ revalidateBeforeLoadCtx: 'revalidate-beforeLoad' }), + loader: () => ({ revalidateLoaderData: 'revalidate-loader' }), + ssr: 'data-only', + component: RevalidateFnComponent, +}) + +function RevalidateFnComponent() { + const router = useRouter() + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Revalidate fn

+
{context.origin}
+
{context.stableToken}
+
+ {String(context.authStatus)} +
+
{String(context.revalidateRuns)}
+
+ {context.revalidateBeforeLoadCtx} +
+
+ {loaderData.revalidateLoaderData} +
+ +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/ssr-false-context.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/ssr-false-context.tsx new file mode 100644 index 00000000000..fe8e5965493 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/ssr-false-context.tsx @@ -0,0 +1,53 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' +import { countClientRun } from '~/utils/origin' + +// `ssr: false` runs nothing on the server, `context` included (it is a +// lifecycle like the other two; earlier versions still ran it). Each lifecycle +// records where it ran with a marker only its server branch produces: the SSR +// HTML and its hydration payload must contain none of them, and the client +// runs the context exactly once at hydration (memoized per match afterwards). + +const contextOrigin = createIsomorphicFn() + .server(() => 'TSR_E2E_SFC_CONTEXT_SERVER') + .client(() => 'sfc-context-client') + +const beforeLoadOrigin = createIsomorphicFn() + .server(() => 'TSR_E2E_SFC_BEFORELOAD_SERVER') + .client(() => 'sfc-beforeLoad-client') + +const loaderOrigin = createIsomorphicFn() + .server(() => 'TSR_E2E_SFC_LOADER_SERVER') + .client(() => 'sfc-loader-client') + +export const Route = createFileRoute('/ssr-false-context')({ + ssr: false, + context: { + handler: () => ({ + sfcContextOrigin: contextOrigin(), + sfcContextRuns: countClientRun('sfc-context'), + }), + // transport is asked for, but an `ssr: false` match has nothing to send + dehydrate: true, + }, + beforeLoad: () => ({ sfcBeforeLoadOrigin: beforeLoadOrigin() }), + loader: () => ({ sfcLoaderOrigin: loaderOrigin() }), + component: SsrFalseContextComponent, +}) + +function SsrFalseContextComponent() { + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

SSR false context

+
{context.sfcContextOrigin}
+
{String(context.sfcContextRuns)}
+
+ {context.sfcBeforeLoadOrigin} +
+
{loaderData.sfcLoaderOrigin}
+
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/stripping-marker.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/stripping-marker.tsx new file mode 100644 index 00000000000..ef789cfa4d2 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/stripping-marker.tsx @@ -0,0 +1,73 @@ +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { digestMarker } from '~/utils/marker-digest' + +// Compiler stripping. Unique string literals live only inside the bodies +// the compiler deletes per environment: +// client build drops context/beforeLoad/loader.dehydrate -> *_DEHYDRATE_MARKER_* +// server build drops context/beforeLoad/loader.hydrate -> *_HYDRATE_MARKER_* +// and context.revalidate -> *_REVALIDATE_MARKER +// `digestMarker` comes from a module that imports `node:crypto` and is used +// only inside `dehydrate`, so the client build must not reference it. +// tests/bundle.spec.ts asserts this against the built assets. + +export const Route = createFileRoute('/stripping-marker')({ + ssr: 'data-only', + context: { + handler: () => ({ token: 'token-from-handler', via: 'handler' as string }), + dehydrate: ({ data }) => ({ + token: data.token, + digest: digestMarker('TSR_E2E_DEHYDRATE_MARKER_CONTEXT'), + }), + hydrate: ({ data }) => ({ + token: `${data.token}:${data.digest.length}`, + via: 'TSR_E2E_HYDRATE_MARKER_CONTEXT', + }), + revalidate: ({ prev }) => ({ + token: prev?.token ?? 'no-prev', + via: 'TSR_E2E_REVALIDATE_MARKER', + }), + }, + beforeLoad: { + handler: () => ({ blVia: 'handler' as string }), + dehydrate: ({ data }) => ({ + blVia: data.blVia, + mark: 'TSR_E2E_DEHYDRATE_MARKER_BEFORELOAD', + }), + hydrate: ({ data }) => ({ + blVia: `TSR_E2E_HYDRATE_MARKER_BEFORELOAD:${data.mark.length}`, + }), + }, + loader: { + handler: () => ({ ldVia: 'handler' as string }), + dehydrate: ({ data }) => ({ + ldVia: data.ldVia, + mark: 'TSR_E2E_DEHYDRATE_MARKER_LOADER', + }), + hydrate: ({ data }) => ({ + ldVia: `TSR_E2E_HYDRATE_MARKER_LOADER:${data.mark.length}`, + }), + }, + component: StrippingMarkerComponent, +}) + +function StrippingMarkerComponent() { + const router = useRouter() + const context = Route.useRouteContext() + const loaderData = Route.useLoaderData() + + return ( +
+

Stripping marker

+
{context.token}
+
{context.via}
+
{context.blVia}
+
{loaderData.ldVia}
+ +
+ ) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/routes/void-guard.tsx b/e2e/react-start/router-lifecycle-methods/src/routes/void-guard.tsx new file mode 100644 index 00000000000..0ade50370f1 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/routes/void-guard.tsx @@ -0,0 +1,28 @@ +import { createFileRoute } from '@tanstack/react-router' +import { countClientRun } from '~/utils/origin' + +// Guard-only beforeLoad under the presence protocol: the handler +// returns nothing, the server still transports the (undefined) result as a +// `b: void 0` key, and the client restores it by presence instead of +// re-running the guard during hydration. On client navigations the guard runs +// per load as always. `countClientRun` counts the browser runs. + +export const Route = createFileRoute('/void-guard')({ + beforeLoad: () => { + countClientRun('vg-beforeLoad') + // guard-only: nothing to return + }, + loader: () => ({ vgLoader: 'vg-loader' }), + component: VoidGuardComponent, +}) + +function VoidGuardComponent() { + const loaderData = Route.useLoaderData() + + return ( +
+

Void guard

+
{loaderData.vgLoader}
+
+ ) +} 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..9af57dd2d5d --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/styles/app.css @@ -0,0 +1,23 @@ +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; + flex-wrap: wrap; + gap: 12px; +} diff --git a/e2e/react-start/router-lifecycle-methods/src/utils/marker-digest.ts b/e2e/react-start/router-lifecycle-methods/src/utils/marker-digest.ts new file mode 100644 index 00000000000..12732dc97b8 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/utils/marker-digest.ts @@ -0,0 +1,13 @@ +// Server-only module: it imports a Node built-in. The stripping-marker route +// uses it only inside `dehydrate`, which the client build deletes, so +// this module and its literal must not appear in any client JS asset. +import { createHash } from 'node:crypto' + +export const SERVER_ONLY_MODULE_ID = 'TSR_E2E_SERVER_ONLY_MODULE' + +export function digestMarker(value: string): string { + return createHash('sha256') + .update(`${SERVER_ONLY_MODULE_ID}:${value}`) + .digest('hex') + .slice(0, 16) +} diff --git a/e2e/react-start/router-lifecycle-methods/src/utils/origin.ts b/e2e/react-start/router-lifecycle-methods/src/utils/origin.ts new file mode 100644 index 00000000000..b74271a25fc --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/src/utils/origin.ts @@ -0,0 +1,23 @@ +import { createIsomorphicFn } from '@tanstack/react-start' + +/** Which bundle ran the calling lifecycle: 'server' during SSR, 'client' in the browser. */ +export const getOrigin = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') + +/** + * Per-page client-side run counter kept on `globalThis` so tests can read it + * both from the DOM (when a lifecycle puts it in its result) and through + * `page.evaluate`. The server branch returns 0 so a transported value can be + * told apart from a client re-run by the count alone. + */ +export const countClientRun = createIsomorphicFn() + .server((_key: string) => 0) + .client((key: string) => { + const counters = ((globalThis as any).__tsrE2eRuns ??= {}) as Record< + string, + number + > + counters[key] = (counters[key] ?? 0) + 1 + return counters[key] + }) 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..665a0fb2b93 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/app.spec.ts @@ -0,0 +1,186 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { awaitHydrated } from './utils' + +test.describe('lifecycle methods - SSR', () => { + test('home page renders root and index lifecycle context', async ({ + page, + }) => { + const response = await page.goto('/') + const html = await response!.text() + // function-form context and beforeLoad run on the server and render + expect(html).toContain('root-context') + expect(html).toContain('index-beforeLoad') + expect(html).toContain('index-loader') + + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('root-beforeLoad')).toHaveText( + 'root-beforeLoad', + ) + 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') + await awaitHydrated(page) + // the same values after hydration (context re-run in place, loader restored) + await expect(page.getByTestId('index-context')).toHaveText('index-context') + 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') + + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('root-beforeLoad')).toHaveText( + 'root-beforeLoad', + ) + 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', + ) + 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') + + 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('post-beforeLoad')).toHaveText( + 'postId-beforeLoad-1', + ) + 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', + ) + 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-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 awaitHydrated(page) + + 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', + ) + 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 awaitHydrated(page) + 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 awaitHydrated(page) + + 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 awaitHydrated(page) + + await page.getByTestId('link-post-1-comments').click() + await expect(page.getByTestId('comments-heading')).toHaveText('Comments') + 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('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-index-text')).toHaveText( + 'Select a post from the list above.', + ) + await expect(page.getByTestId('post-context')).not.toBeVisible() + }) +}) diff --git a/e2e/react-start/router-lifecycle-methods/tests/bundle.spec.ts b/e2e/react-start/router-lifecycle-methods/tests/bundle.spec.ts new file mode 100644 index 00000000000..96985d94450 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/bundle.spec.ts @@ -0,0 +1,146 @@ +import fs from 'node:fs' +import path from 'node:path' +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { awaitHydrated } from './utils' + +// Compiler stripping is a requirement. The client bundle must not carry +// `dehydrate` bodies (nor the server-only module they alone referenced); the +// server bundle must not carry `hydrate` or `context.revalidate` bodies. The +// markers live only inside those bodies in src/routes/stripping-marker.tsx. + +const distDir = path.resolve( + import.meta.dirname, + '..', + process.env.E2E_DIST_DIR ?? 'dist', +) + +function readJsSources(dir: string): Array<{ file: string; code: string }> { + const out: Array<{ file: string; code: string }> = [] + const walk = (current: string) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (/\.(m|c)?js$/.test(entry.name)) { + out.push({ + file: path.relative(dir, full), + code: fs.readFileSync(full, 'utf8'), + }) + } + } + } + walk(dir) + return out +} + +function filesContaining( + sources: Array<{ file: string; code: string }>, + needle: string, +) { + return sources.filter((s) => s.code.includes(needle)).map((s) => s.file) +} + +test.describe('bundle content - compiler stripping', () => { + test('client bundle: no dehydrate bodies, no server-only module; hydrate and revalidate present', () => { + const client = readJsSources(path.join(distDir, 'client')) + expect(client.length).toBeGreaterThan(0) + + expect(filesContaining(client, 'TSR_E2E_DEHYDRATE_MARKER')).toEqual([]) + expect(filesContaining(client, 'TSR_E2E_DEHYDRATE_MARKER_CONTEXT')).toEqual( + [], + ) + expect( + filesContaining(client, 'TSR_E2E_DEHYDRATE_MARKER_BEFORELOAD'), + ).toEqual([]) + expect(filesContaining(client, 'TSR_E2E_DEHYDRATE_MARKER_LOADER')).toEqual( + [], + ) + + // the module that is only used inside `dehydrate` + expect(filesContaining(client, 'TSR_E2E_SERVER_ONLY_MODULE')).toEqual([]) + expect(filesContaining(client, 'node:crypto')).toEqual([]) + + expect( + filesContaining(client, 'TSR_E2E_HYDRATE_MARKER_CONTEXT'), + ).not.toEqual([]) + expect( + filesContaining(client, 'TSR_E2E_HYDRATE_MARKER_BEFORELOAD'), + ).not.toEqual([]) + expect( + filesContaining(client, 'TSR_E2E_HYDRATE_MARKER_LOADER'), + ).not.toEqual([]) + expect(filesContaining(client, 'TSR_E2E_REVALIDATE_MARKER')).not.toEqual([]) + }) + + test('server bundle: no hydrate or context.revalidate bodies; dehydrate and the server-only module present', () => { + const server = readJsSources(path.join(distDir, 'server')) + expect(server.length).toBeGreaterThan(0) + + expect(filesContaining(server, 'TSR_E2E_HYDRATE_MARKER')).toEqual([]) + expect(filesContaining(server, 'TSR_E2E_HYDRATE_MARKER_CONTEXT')).toEqual( + [], + ) + expect( + filesContaining(server, 'TSR_E2E_HYDRATE_MARKER_BEFORELOAD'), + ).toEqual([]) + expect(filesContaining(server, 'TSR_E2E_HYDRATE_MARKER_LOADER')).toEqual([]) + expect(filesContaining(server, 'TSR_E2E_REVALIDATE_MARKER')).toEqual([]) + + expect( + filesContaining(server, 'TSR_E2E_DEHYDRATE_MARKER_CONTEXT'), + ).not.toEqual([]) + expect( + filesContaining(server, 'TSR_E2E_DEHYDRATE_MARKER_BEFORELOAD'), + ).not.toEqual([]) + expect( + filesContaining(server, 'TSR_E2E_DEHYDRATE_MARKER_LOADER'), + ).not.toEqual([]) + expect(filesContaining(server, 'TSR_E2E_SERVER_ONLY_MODULE')).not.toEqual( + [], + ) + }) +}) + +test.describe('stripping marker route - runtime', () => { + test('SSR: dehydrate ran on the server, hydrate on the client, revalidate on invalidate', async ({ + page, + }) => { + await page.goto('/stripping-marker') + await awaitHydrated(page) + await expect(page.getByTestId('sm-heading')).toHaveText('Stripping marker') + // the wire carried the 16-char digest computed by the server-only module + await expect(page.getByTestId('sm-token')).toHaveText( + 'token-from-handler:16', + ) + await expect(page.getByTestId('sm-via')).toHaveText( + 'TSR_E2E_HYDRATE_MARKER_CONTEXT', + ) + await expect(page.getByTestId('sm-bl-via')).toHaveText( + `TSR_E2E_HYDRATE_MARKER_BEFORELOAD:${'TSR_E2E_DEHYDRATE_MARKER_BEFORELOAD'.length}`, + ) + await expect(page.getByTestId('sm-ld-via')).toHaveText( + `TSR_E2E_HYDRATE_MARKER_LOADER:${'TSR_E2E_DEHYDRATE_MARKER_LOADER'.length}`, + ) + + await page.getByTestId('sm-invalidate-btn').click() + await expect(page.getByTestId('sm-via')).toHaveText( + 'TSR_E2E_REVALIDATE_MARKER', + ) + // prev was the hydrated value + await expect(page.getByTestId('sm-token')).toHaveText( + 'token-from-handler:16', + ) + }) + + test('client nav: handlers only', async ({ page }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-stripping-marker').click() + await expect(page.getByTestId('sm-heading')).toHaveText('Stripping marker') + await expect(page.getByTestId('sm-token')).toHaveText('token-from-handler') + await expect(page.getByTestId('sm-via')).toHaveText('handler') + await expect(page.getByTestId('sm-bl-via')).toHaveText('handler') + await expect(page.getByTestId('sm-ld-via')).toHaveText('handler') + }) +}) diff --git a/e2e/react-start/router-lifecycle-methods/tests/codec-errors.spec.ts b/e2e/react-start/router-lifecycle-methods/tests/codec-errors.spec.ts new file mode 100644 index 00000000000..fb4df7a477a --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/codec-errors.spec.ts @@ -0,0 +1,144 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { awaitHydrated, hydrationErrorPattern, readClientRuns } from './utils' + +// Codecs are transforms, not lifecycles. A throw inside `dehydrate` fails +// the match like a throw inside the handler, and a `redirect()` thrown from it +// is an ordinary error, never a redirect. A throw inside `hydrate` hands the +// match to the client load that follows hydration, which re-runs the handler. +// +// Only the server-side `dehydrate` failures reach the browser console. Keep +// every entry a string: the shared fixture receives this array through +// `test.use`, and Playwright reads an array whose second element is an object +// (a RegExp included) as a fixture tuple, which silently drops the whitelist. +test.use({ + whitelistErrors: [ + 'TSR_E2E_DEHYDRATE_THROW', + 'Invalid throw from dehydrate', + 'Failed to load resource: the server responded with a status of 500', + ], +}) + +test.describe('codec error - dehydrate throws on the server', () => { + test('SSR: the route errorComponent is in the HTML with status 500', async ({ + page, + }) => { + const response = await page.goto('/codec-error-dehydrate-throws') + expect(response?.status()).toBe(500) + const html = await response!.text() + expect(html).toContain('data-testid="cedt-error-component"') + expect(html).not.toContain('data-testid="cedt-component"') + + await awaitHydrated(page) + await expect(page.getByTestId('cedt-error-component')).toBeVisible() + await expect(page.getByTestId('cedt-error-component')).toContainText( + 'TSR_E2E_DEHYDRATE_THROW', + ) + await expect(page.getByTestId('cedt-component')).not.toBeVisible() + }) + + test('client nav: dehydrate does not run in the browser, the route renders', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-codec-error-dehydrate-throws').click() + await expect(page.getByTestId('cedt-value')).toHaveText('loader-value') + }) +}) + +test.describe('codec error - hydrate throws on the client', () => { + test('SSR: the client load re-runs the handler and the route renders its value', async ({ + page, + }) => { + const hydrationErrors: Array = [] + page.on('console', (message) => { + if ( + message.type() === 'error' && + hydrationErrorPattern.test(message.text()) + ) { + hydrationErrors.push(message.text()) + } + }) + const response = await page.goto('/codec-error-hydrate-throws') + // the server succeeded; only the client-side transform fails + expect(response?.status()).toBe(200) + + await awaitHydrated(page) + await expect(page.getByTestId('ceht-guard')).toHaveText('ok') + await expect(page.getByTestId('ceht-error-component')).toHaveCount(0) + // The rendered value comes from the handler's client run. + expect((await readClientRuns(page))['ceht-beforeLoad']).toBe(1) + // data-only route: nothing of it was server-rendered, so there is no + // hydration mismatch either + expect(hydrationErrors).toEqual([]) + }) + + test('client nav: hydrate is not involved, the route renders', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-codec-error-hydrate-throws').click() + await expect(page.getByTestId('ceht-guard')).toHaveText('ok') + }) +}) + +test.describe('codec error - redirect thrown from dehydrate', () => { + test('SSR: it is an error at the same URL, not a redirect', async ({ + page, + }) => { + const response = await page.goto('/codec-error-dehydrate-redirect') + // no redirect happened: same URL, error status, error component + expect(response?.status()).toBe(500) + expect(new URL(response!.url()).pathname).toBe( + '/codec-error-dehydrate-redirect', + ) + expect(response?.request().redirectedFrom()).toBeNull() + await expect(page).toHaveURL(/\/codec-error-dehydrate-redirect$/) + const html = await response!.text() + expect(html).toContain('data-testid="cedr-error-component"') + + await awaitHydrated(page) + await expect(page).toHaveURL(/\/codec-error-dehydrate-redirect$/) + await expect(page.getByTestId('cedr-error-component')).toBeVisible() + await expect(page.getByTestId('cedr-error-component')).toContainText( + 'dehydrate', + ) + await expect(page.getByTestId('index-heading')).not.toBeVisible() + }) +}) + +test.describe('codec error - hydrate throws on the client, ssr: true route', () => { + // The server rendered the component; the client load re-runs the loader + // handler, which produces the same value, so the component stays. + test.use({ whitelistErrors: [] }) + + test('SSR: the HTML has the component, the client load re-runs the handler and keeps it', async ({ + page, + }) => { + const response = await page.goto('/codec-error-hydrate-throws-ssr') + // the server succeeded and rendered the handler's value + expect(response?.status()).toBe(200) + const html = await response!.text() + expect(html).toContain('data-testid="cehts-component"') + expect(html).toContain('ssr-loader-value') + expect(html).not.toContain('data-testid="cehts-error-component"') + + await awaitHydrated(page) + await expect(page.getByTestId('cehts-value')).toHaveText('ssr-loader-value') + await expect(page.getByTestId('cehts-error-component')).toHaveCount(0) + // The rendered value comes from the handler's client run. + expect((await readClientRuns(page))['cehts-loader']).toBe(1) + }) + + test('client nav: hydrate is not involved, the route renders', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-codec-error-hydrate-throws-ssr').click() + await expect(page.getByTestId('cehts-value')).toHaveText('ssr-loader-value') + await expect(page.getByTestId('cehts-error-component')).toHaveCount(0) + }) +}) diff --git a/e2e/react-start/router-lifecycle-methods/tests/codecs.spec.ts b/e2e/react-start/router-lifecycle-methods/tests/codecs.spec.ts new file mode 100644 index 00000000000..597055e525d --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/codecs.spec.ts @@ -0,0 +1,81 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { awaitHydrated, collectErrors, hydrationErrorPattern } from './utils' + +// ============================================================================ +// Async dehydrate/hydrate on context and loader +// ============================================================================ +test.describe('async codec', () => { + test('SSR: async dehydrate on the server, async hydrate on the client, hydration completes', async ({ + page, + }) => { + await page.goto('/async-codec') + await awaitHydrated(page) + await expect(page.getByTestId('ac-heading')).toHaveText('Async codec') + + await expect(page.getByTestId('ac-context-label')).toHaveText( + 'server-ac-context', + ) + await expect(page.getByTestId('ac-context-stamp')).toHaveText('7') + await expect(page.getByTestId('ac-context-stamp-type')).toHaveText('number') + await expect(page.getByTestId('ac-context-via')).toHaveText('async-hydrate') + + await expect(page.getByTestId('ac-loader-title')).toHaveText( + 'server-ac-loader', + ) + await expect(page.getByTestId('ac-loader-scores')).toHaveText('10|20|30') + await expect(page.getByTestId('ac-loader-scores-type')).toHaveText('array') + await expect(page.getByTestId('ac-loader-via')).toHaveText('async-hydrate') + }) + + test('client nav: handlers run locally, codecs are not involved', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-async-codec').click() + await expect(page.getByTestId('ac-heading')).toHaveText('Async codec') + await expect(page.getByTestId('ac-context-label')).toHaveText( + 'client-ac-context', + ) + await expect(page.getByTestId('ac-context-via')).toHaveText('handler') + await expect(page.getByTestId('ac-loader-title')).toHaveText( + 'client-ac-loader', + ) + await expect(page.getByTestId('ac-loader-via')).toHaveText('handler') + }) + + test('round trip: hydrated context is kept, loader re-runs locally', async ({ + page, + }) => { + await page.goto('/async-codec') + await awaitHydrated(page) + await expect(page.getByTestId('ac-context-via')).toHaveText('async-hydrate') + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await page.getByTestId('link-async-codec').click() + await expect(page.getByTestId('ac-heading')).toHaveText('Async codec') + await expect(page.getByTestId('ac-context-via')).toHaveText('async-hydrate') + await expect(page.getByTestId('ac-context-label')).toHaveText( + 'server-ac-context', + ) + await expect(page.getByTestId('ac-loader-via')).toHaveText('handler') + await expect(page.getByTestId('ac-loader-title')).toHaveText( + 'client-ac-loader', + ) + }) +}) + +test.describe('codec paths - console', () => { + test('async codec and dehydrate-partial hydrate without hydration errors', async ({ + page, + }) => { + const errors = collectErrors(page) + await page.goto('/async-codec') + await awaitHydrated(page) + await page.goto('/dehydrate-partial/de-DE') + await awaitHydrated(page) + expect(errors.filter((e) => hydrationErrorPattern.test(e))).toEqual([]) + }) +}) diff --git a/e2e/react-start/router-lifecycle-methods/tests/dehydrate-flags.spec.ts b/e2e/react-start/router-lifecycle-methods/tests/dehydrate-flags.spec.ts new file mode 100644 index 00000000000..a2793b36fef --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/dehydrate-flags.spec.ts @@ -0,0 +1,233 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { awaitHydrated, hydrationPayload } from './utils' + +// ============================================================================ +// Transport flags, black-box through createIsomorphicFn. +// +// Each route's lifecycles return 'server-{prefix}-{method}' on the server and +// 'client-{prefix}-{method}' in the browser. +// +// Transported lifecycle -> the server value is restored from the payload at +// hydration -> DOM shows 'server-*' +// Not transported -> the client re-runs the lifecycle in place at +// hydration -> DOM shows 'client-*' +// +// Builtin defaults: context false, beforeLoad true, loader +// true. There is no app-wide override; each route states its flags. +// ============================================================================ + +interface RouteTransport { + path: string + prefix: string + heading: string + /** effective transport per lifecycle for this route */ + context: boolean + beforeLoad: boolean + loader: boolean +} + +const routes: Array = [ + { + path: '/dehydrate-defaults', + prefix: 'dd', + heading: 'Dehydrate Defaults', + context: false, + beforeLoad: true, + loader: true, + }, + { + path: '/dehydrate-all-true', + prefix: 'dat', + heading: 'Dehydrate All True', + context: true, + beforeLoad: true, + loader: true, + }, + { + path: '/dehydrate-all-false', + prefix: 'daf', + heading: 'Dehydrate All False', + context: false, + beforeLoad: false, + loader: false, + }, + { + path: '/dehydrate-mixed', + prefix: 'dm', + heading: 'Dehydrate Mixed', + context: true, + beforeLoad: false, + loader: true, + }, + { + path: '/dehydrate-beforeload-false', + prefix: 'dbf', + heading: 'Dehydrate BeforeLoad False', + context: false, + beforeLoad: false, + loader: true, + }, + { + path: '/dehydrate-loader-false', + prefix: 'dlf', + heading: 'Dehydrate Loader False', + context: false, + beforeLoad: true, + loader: false, + }, + { + path: '/dehydrate-context-true', + prefix: 'dct', + heading: 'Dehydrate Context True', + context: true, + beforeLoad: true, + loader: true, + }, +] + +function value(prefix: string, method: string, fromServer: boolean) { + return `${fromServer ? 'server' : 'client'}-${prefix}-${method}` +} + +/** + * 'ssr' direct visit: transported values are server values, the rest + * is re-run on the client during hydration. + * 'clientNav' link click after hydration: everything runs in the browser. + * 'roundTrip' direct visit, navigate away, navigate back: context is memoized + * per match (keeps its hydrated value), beforeLoad re-runs on every + * load, the loader is stale (staleTime 0) and re-runs. + */ +function expected( + route: RouteTransport, + mode: 'ssr' | 'clientNav' | 'roundTrip', +) { + const { prefix } = route + if (mode === 'clientNav') { + return { + context: value(prefix, 'context', false), + beforeLoad: value(prefix, 'beforeLoad', false), + loader: value(prefix, 'loader', false), + } + } + if (mode === 'ssr') { + return { + context: value(prefix, 'context', route.context), + beforeLoad: value(prefix, 'beforeLoad', route.beforeLoad), + loader: value(prefix, 'loader', route.loader), + } + } + return { + context: value(prefix, 'context', route.context), + beforeLoad: value(prefix, 'beforeLoad', false), + loader: value(prefix, 'loader', false), + } +} + +async function expectValues( + page: import('@playwright/test').Page, + route: RouteTransport, + values: ReturnType, +) { + await expect(page.getByTestId(`${route.prefix}-context`)).toHaveText( + values.context, + ) + await expect(page.getByTestId(`${route.prefix}-beforeLoad`)).toHaveText( + values.beforeLoad, + ) + await expect(page.getByTestId(`${route.prefix}-loader`)).toHaveText( + values.loader, + ) +} + +const methods = ['context', 'beforeLoad', 'loader'] as const + +test.describe('transport flags - SSR and hydration', () => { + for (const route of routes) { + test(`${route.path}: the wire carries only the transported values, the DOM shows them after hydration`, async ({ + page, + }) => { + const response = await page.goto(route.path) + expect(response?.status()).toBe(200) + const html = await response!.text() + const payload = hydrationPayload(html) + // data-only: the component is not server-rendered, so a server value can + // reach the HTML only through the hydration payload (presence protocol) + expect(html).not.toContain(`data-testid="${route.prefix}-component"`) + for (const method of methods) { + const serverValue = value(route.prefix, method, true) + if (route[method]) { + expect(payload, `${method} is transported`).toContain(serverValue) + } else { + expect(html, `${method} is not transported`).not.toContain( + serverValue, + ) + } + // a client value never comes out of the server + expect(html).not.toContain(value(route.prefix, method, false)) + } + + await awaitHydrated(page) + await expect(page.getByTestId(`${route.prefix}-heading`)).toHaveText( + route.heading, + ) + await expectValues(page, route, expected(route, 'ssr')) + }) + } +}) + +test.describe('transport flags - client navigation', () => { + for (const route of routes) { + test(`client nav to ${route.path}: everything runs in the browser`, async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId(`link-${route.path.slice(1)}`).click() + await expect(page.getByTestId(`${route.prefix}-heading`)).toHaveText( + route.heading, + ) + await expectValues(page, route, expected(route, 'clientNav')) + }) + } + + test('navigating between flag routes preserves root context', async ({ + page, + }) => { + await page.goto('/dehydrate-defaults') + await awaitHydrated(page) + await expect(page.getByTestId('root-context')).toHaveText('root-context') + + await page.getByTestId('link-dehydrate-all-true').click() + await expect(page.getByTestId('dat-heading')).toHaveText( + 'Dehydrate All True', + ) + await expect(page.getByTestId('root-context')).toHaveText('root-context') + await expect(page.getByTestId('root-beforeLoad')).toHaveText( + 'root-beforeLoad', + ) + }) +}) + +test.describe('transport flags - round trip', () => { + for (const route of routes) { + test(`${route.path}: context memoized, beforeLoad and loader re-run`, async ({ + page, + }) => { + await page.goto(route.path) + await awaitHydrated(page) + await expectValues(page, route, expected(route, 'ssr')) + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + + await page.getByTestId(`link-${route.path.slice(1)}`).click() + await expect(page.getByTestId(`${route.prefix}-heading`)).toHaveText( + route.heading, + ) + await expectValues(page, route, expected(route, 'roundTrip')) + }) + } +}) diff --git a/e2e/react-start/router-lifecycle-methods/tests/in-place.spec.ts b/e2e/react-start/router-lifecycle-methods/tests/in-place.spec.ts new file mode 100644 index 00000000000..7b5f8acdc31 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/in-place.spec.ts @@ -0,0 +1,188 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { + awaitHydrated, + collectErrors, + hydrationErrorPattern, + hydrationPayload, + readClientRuns, +} from './utils' + +// Lifecycles the server ran but did not transport re-run in place during +// hydration while the match stays committed. + +test.describe('in-place re-run - non-transported beforeLoad on an ssr: true route', () => { + test('SSR HTML has the value, hydration re-runs beforeLoad once, no hydration error, no pending flash', async ({ + page, + }) => { + const errors = collectErrors(page) + const response = await page.goto('/in-place-beforeload') + expect(response?.status()).toBe(200) + const html = await response!.text() + expect(html).toContain('deterministic-42') + expect(html).toContain('loader-deterministic-42') + expect(html).not.toContain('data-testid="ipb-pending"') + + await awaitHydrated(page) + await expect(page.getByTestId('ipb-value')).toHaveText('deterministic-42') + await expect(page.getByTestId('ipb-loader')).toHaveText( + 'loader-deterministic-42', + ) + await expect(page.getByTestId('ipb-pending')).toHaveCount(0) + + const runs = await readClientRuns(page) + // re-run in place exactly once + expect(runs['ipb-beforeLoad']).toBe(1) + // the component mounted once; the pending component never rendered + expect(runs['ipb-mount']).toBe(1) + expect(runs['ipb-pending']).toBeUndefined() + + expect(errors.filter((e) => hydrationErrorPattern.test(e))).toEqual([]) + }) + + test('client nav: beforeLoad runs per load', async ({ page }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-in-place-beforeload').click() + await expect(page.getByTestId('ipb-value')).toHaveText('deterministic-42') + expect((await readClientRuns(page))['ipb-beforeLoad']).toBe(1) + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await page.getByTestId('link-in-place-beforeload').click() + await expect(page.getByTestId('ipb-value')).toHaveText('deterministic-42') + expect((await readClientRuns(page))['ipb-beforeLoad']).toBe(2) + }) +}) + +test.describe('in-place re-run - non-transported loader on a data-only route', () => { + test('SSR: the client runs the loader exactly once during hydration', async ({ + page, + }) => { + const errors = collectErrors(page) + const response = await page.goto('/in-place-loader') + const html = await response!.text() + // data-only: the route component is not server-rendered + expect(html).not.toContain('data-testid="ipl-component"') + + await awaitHydrated(page) + await expect(page.getByTestId('ipl-context')).toHaveText('ipl-context') + await expect(page.getByTestId('ipl-beforeLoad')).toHaveText( + 'ipl-beforeLoad', + ) + await expect(page.getByTestId('ipl-loader-origin')).toHaveText('client') + await expect(page.getByTestId('ipl-loader-client-runs')).toHaveText('1') + expect((await readClientRuns(page))['ipl-loader']).toBe(1) + expect(errors.filter((e) => hydrationErrorPattern.test(e))).toEqual([]) + }) + + test('client nav: loader runs in the browser', async ({ page }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-in-place-loader').click() + await expect(page.getByTestId('ipl-loader-origin')).toHaveText('client') + await expect(page.getByTestId('ipl-loader-client-runs')).toHaveText('1') + }) +}) + +// A guard-only beforeLoad returns nothing; the server still emits its +// `b` key with `void 0` and the client restores by presence instead of +// re-running the guard during hydration. + +test.describe('void guard - presence protocol for an undefined beforeLoad result', () => { + test('SSR: the payload carries `b: void 0`, hydration does not re-run the guard', async ({ + page, + }) => { + const response = await page.goto('/void-guard') + expect(response?.status()).toBe(200) + const html = await response!.text() + const payload = hydrationPayload(html) + // the void-guard match is the only one whose beforeLoad returned nothing + expect(payload).toContain('b:void 0') + expect(payload).toContain('vg-loader') + + await awaitHydrated(page) + await expect(page.getByTestId('vg-heading')).toHaveText('Void guard') + await expect(page.getByTestId('vg-loader')).toHaveText('vg-loader') + // restored from the payload: no client run + expect((await readClientRuns(page))['vg-beforeLoad']).toBeUndefined() + + // a client navigation runs the guard once + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await page.getByTestId('link-void-guard').click() + await expect(page.getByTestId('vg-heading')).toHaveText('Void guard') + expect((await readClientRuns(page))['vg-beforeLoad']).toBe(1) + }) + + test('client nav: the guard runs per load', async ({ page }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-void-guard').click() + await expect(page.getByTestId('vg-heading')).toHaveText('Void guard') + expect((await readClientRuns(page))['vg-beforeLoad']).toBe(1) + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await page.getByTestId('link-void-guard').click() + await expect(page.getByTestId('vg-heading')).toHaveText('Void guard') + expect((await readClientRuns(page))['vg-beforeLoad']).toBe(2) + }) +}) + +// A redirect thrown by an in-place re-run during hydration makes that +// match the pending boundary; the client load that follows re-runs it and +// follows the redirect. The child's transported wire value never reaches the +// DOM. + +test.describe('in-place redirect - non-transported beforeLoad redirects during hydration', () => { + test('SSR: the server passes and ships the child wire value, the client re-run redirects home', async ({ + page, + }) => { + const errors = collectErrors(page) + const response = await page.goto('/in-place-redirect/child') + // the server run of the guard passed: no redirect on the wire + expect(response?.status()).toBe(200) + expect(response?.request().redirectedFrom()).toBeNull() + const html = await response!.text() + // the child's loader ran on the server and its codec wire value was sent + expect(hydrationPayload(html)).toContain('TSR_E2E_IPR_WIRE:') + // data-only: nothing of the subtree is server-rendered + expect(html).not.toContain('data-testid="ipr-parent"') + expect(html).not.toContain('data-testid="ipr-child"') + + // the in-place re-run redirected + await expect(page).toHaveURL(/\/$/) + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await awaitHydrated(page) + await expect(page.getByTestId('index-loader')).toHaveText('index-loader') + + const runs = await readClientRuns(page) + // once in place during hydration, once in the client load that follows + expect(runs['ipr-beforeLoad']).toBe(2) + // the child was neither reconstructed (its hydrate did not run) nor mounted + expect(runs['ipr-child-hydrate']).toBeUndefined() + expect(runs['ipr-child-mount']).toBeUndefined() + await expect(page.getByTestId('ipr-parent')).toHaveCount(0) + await expect(page.getByTestId('ipr-child')).toHaveCount(0) + // neither the raw wire value nor the hydrated value reached the DOM + await expect(page.locator('body')).not.toContainText('TSR_E2E_IPR_WIRE') + await expect(page.locator('body')).not.toContainText('child-plain-value') + + expect(errors).toEqual([]) + }) + + test('client nav: the client-only condition redirects on every browser run', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-in-place-redirect-child').click() + await expect(page).toHaveURL(/\/$/) + await expect(page.getByTestId('index-heading')).toHaveText('Home') + const runs = await readClientRuns(page) + expect(runs['ipr-beforeLoad']).toBe(1) + expect(runs['ipr-child-mount']).toBeUndefined() + await expect(page.locator('body')).not.toContainText('TSR_E2E_IPR_WIRE') + }) +}) diff --git a/e2e/react-start/router-lifecycle-methods/tests/selective-ssr.spec.ts b/e2e/react-start/router-lifecycle-methods/tests/selective-ssr.spec.ts new file mode 100644 index 00000000000..a888c2027aa --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/selective-ssr.spec.ts @@ -0,0 +1,81 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { awaitHydrated, hydrationPayload, readClientRuns } from './utils' + +// `ssr: false` runs nothing on the server, `context` included. The +// markers below exist only in the server branches of the route's lifecycles. +const serverMarkers = [ + 'TSR_E2E_SFC_CONTEXT_SERVER', + 'TSR_E2E_SFC_BEFORELOAD_SERVER', + 'TSR_E2E_SFC_LOADER_SERVER', +] + +test.describe('ssr: false - context runs on the client only', () => { + test('SSR: no lifecycle ran on the server, the client runs the context once at hydration', async ({ + page, + }) => { + const response = await page.goto('/ssr-false-context') + expect(response?.status()).toBe(200) + const html = await response!.text() + const payload = hydrationPayload(html) + for (const marker of serverMarkers) { + expect(html, marker).not.toContain(marker) + expect(payload, marker).not.toContain(marker) + } + // nothing of the route is server-rendered + expect(html).not.toContain('data-testid="sfc-component"') + + await awaitHydrated(page) + await expect(page.getByTestId('sfc-heading')).toHaveText( + 'SSR false context', + ) + await expect(page.getByTestId('sfc-context-origin')).toHaveText( + 'sfc-context-client', + ) + await expect(page.getByTestId('sfc-context-runs')).toHaveText('1') + await expect(page.getByTestId('sfc-beforeLoad-origin')).toHaveText( + 'sfc-beforeLoad-client', + ) + await expect(page.getByTestId('sfc-loader-origin')).toHaveText( + 'sfc-loader-client', + ) + expect((await readClientRuns(page))['sfc-context']).toBe(1) + // the root (ssr: true) still transported its own lifecycles + expect(payload).toContain('root-beforeLoad') + }) + + test('round trip after SSR: the context is memoized per match', async ({ + page, + }) => { + await page.goto('/ssr-false-context') + await awaitHydrated(page) + await expect(page.getByTestId('sfc-context-runs')).toHaveText('1') + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await page.getByTestId('link-ssr-false-context').click() + await expect(page.getByTestId('sfc-heading')).toHaveText( + 'SSR false context', + ) + await expect(page.getByTestId('sfc-context-runs')).toHaveText('1') + expect((await readClientRuns(page))['sfc-context']).toBe(1) + }) + + test('client nav: everything runs in the browser, the context once', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-ssr-false-context').click() + await expect(page.getByTestId('sfc-heading')).toHaveText( + 'SSR false context', + ) + await expect(page.getByTestId('sfc-context-origin')).toHaveText( + 'sfc-context-client', + ) + await expect(page.getByTestId('sfc-context-runs')).toHaveText('1') + await expect(page.getByTestId('sfc-loader-origin')).toHaveText( + 'sfc-loader-client', + ) + }) +}) diff --git a/e2e/react-start/router-lifecycle-methods/tests/use-cases.spec.ts b/e2e/react-start/router-lifecycle-methods/tests/use-cases.spec.ts new file mode 100644 index 00000000000..5d7e1b6464a --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/use-cases.spec.ts @@ -0,0 +1,460 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { awaitHydrated, hydrationPayload, readClientRuns } from './utils' + +// ============================================================================ +// Cached auth context (dehydrate: true, revalidate: true, beforeLoad guard) +// ============================================================================ +test.describe('use case - cached auth context', () => { + test('SSR: restored from the payload, not re-run at hydration, re-run on invalidate', async ({ + page, + }) => { + const response = await page.goto('/cached-auth/alice') + expect(response?.status()).toBe(200) + const html = await response!.text() + expect(html).toContain('guarded-alice') + + await awaitHydrated(page) + await expect(page.getByTestId('auth-session-user')).toHaveText('alice') + // the session came from the server and was restored from the payload + await expect(page.getByTestId('auth-session-origin')).toHaveText('server') + // the handler did not run a second time during hydration + await expect(page.getByTestId('auth-client-runs')).toHaveText('0') + expect((await readClientRuns(page))['auth-context']).toBeUndefined() + await expect(page.getByTestId('auth-guard')).toHaveText('guarded-alice') + await expect(page.getByTestId('auth-loader-origin')).toHaveText('server') + + // router.invalidate() re-runs the context handler (revalidate: true) + await page.getByTestId('auth-invalidate-btn').click() + await expect(page.getByTestId('auth-session-origin')).toHaveText('client') + await expect(page.getByTestId('auth-client-runs')).toHaveText('1') + await expect(page.getByTestId('auth-loader-origin')).toHaveText('client') + await expect(page.getByTestId('auth-guard')).toHaveText('guarded-alice') + expect((await readClientRuns(page))['auth-context']).toBe(1) + + // a second invalidate runs it again + await page.getByTestId('auth-invalidate-btn').click() + await expect(page.getByTestId('auth-client-runs')).toHaveText('2') + }) + + test('SSR: the beforeLoad guard redirects when there is no session', async ({ + page, + }) => { + await page.goto('/cached-auth/nobody') + await expect(page).toHaveURL(/\/$/) + await expect(page.getByTestId('index-heading')).toHaveText('Home') + }) + + test('client nav: runs once in the browser, is memoized across navigations', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + + await page.getByTestId('link-cached-auth-alice').click() + await expect(page.getByTestId('auth-heading')).toHaveText( + 'Cached auth context', + ) + await expect(page.getByTestId('auth-session-origin')).toHaveText('client') + await expect(page.getByTestId('auth-client-runs')).toHaveText('1') + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await page.getByTestId('link-cached-auth-alice').click() + await expect(page.getByTestId('auth-heading')).toHaveText( + 'Cached auth context', + ) + // context is cached per match: no re-run without invalidate + await expect(page.getByTestId('auth-client-runs')).toHaveText('1') + + await page.getByTestId('auth-invalidate-btn').click() + await expect(page.getByTestId('auth-client-runs')).toHaveText('2') + }) + + test('client nav: the guard redirects when there is no session', async ({ + page, + }) => { + await page.goto('/posts') + await awaitHydrated(page) + await page.getByTestId('link-cached-auth-nobody').click() + await expect(page).toHaveURL(/\/$/) + await expect(page.getByTestId('index-heading')).toHaveText('Home') + }) +}) + +// ============================================================================ +// Query options in context (function form, not transported, memoized) +// ============================================================================ +test.describe('use case - query options in context', () => { + test('SSR: recomputed on the client at hydration, stable across invalidate', async ({ + page, + }) => { + const response = await page.goto('/query-context/1') + const html = await response!.text() + expect(html).toContain('First Post') + + await awaitHydrated(page) + await expect(page.getByTestId('query-post-title')).toHaveText('First Post') + // the loader was transported + await expect(page.getByTestId('query-loader-origin')).toHaveText('server') + await expect(page.getByTestId('query-loader-instance')).toHaveText('0') + // the context was not: the client computed it once during hydration + await expect(page.getByTestId('query-context-origin')).toHaveText('client') + await expect(page.getByTestId('query-context-instance')).toHaveText('1') + await expect(page.getByTestId('query-fn-type')).toHaveText('function') + await expect(page.getByTestId('query-key')).toHaveText('post/1') + + // invalidate re-runs the loader but keeps the context contribution + await page.getByTestId('query-invalidate-btn').click() + await expect(page.getByTestId('query-loader-origin')).toHaveText('client') + await expect(page.getByTestId('query-loader-instance')).toHaveText('1') + await expect(page.getByTestId('query-context-instance')).toHaveText('1') + expect((await readClientRuns(page))['query-context']).toBe(1) + }) + + test('client nav: computed once in the browser', async ({ page }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-query-context').click() + await expect(page.getByTestId('query-post-title')).toHaveText('First Post') + await expect(page.getByTestId('query-context-origin')).toHaveText('client') + await expect(page.getByTestId('query-context-instance')).toHaveText('1') + await expect(page.getByTestId('query-loader-origin')).toHaveText('client') + + await page.getByTestId('query-invalidate-btn').click() + await expect(page.getByTestId('query-loader-instance')).toHaveText('2') + await expect(page.getByTestId('query-context-instance')).toHaveText('1') + }) +}) + +// ============================================================================ +// A revalidate fn with prev keeps one field stable, refreshes another +// ============================================================================ +test.describe('use case - revalidate with prev', () => { + test('SSR: first prev is the restored server value', async ({ page }) => { + await page.goto('/revalidate-fn') + await awaitHydrated(page) + await expect(page.getByTestId('revalidate-origin')).toHaveText('server') + await expect(page.getByTestId('revalidate-stable-token')).toHaveText( + 'stable-server', + ) + await expect(page.getByTestId('revalidate-auth-status')).toHaveText('1') + await expect(page.getByTestId('revalidate-runs')).toHaveText('0') + await expect(page.getByTestId('revalidate-beforeLoad')).toHaveText( + 'revalidate-beforeLoad', + ) + await expect(page.getByTestId('revalidate-loader')).toHaveText( + 'revalidate-loader', + ) + + await page.getByTestId('revalidate-invalidate-btn').click() + await expect(page.getByTestId('revalidate-runs')).toHaveText('1') + await expect(page.getByTestId('revalidate-origin')).toHaveText('client') + // kept from prev, which was the value restored from the payload + await expect(page.getByTestId('revalidate-stable-token')).toHaveText( + 'stable-server', + ) + await expect(page.getByTestId('revalidate-auth-status')).toHaveText('2') + + await page.getByTestId('revalidate-invalidate-btn').click() + await expect(page.getByTestId('revalidate-runs')).toHaveText('2') + await expect(page.getByTestId('revalidate-stable-token')).toHaveText( + 'stable-server', + ) + await expect(page.getByTestId('revalidate-auth-status')).toHaveText('3') + const runs = await readClientRuns(page) + expect(runs['revalidate']).toBe(2) + }) + + test('client nav: handler first, then revalidate with prev', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-revalidate-fn').click() + await expect(page.getByTestId('revalidate-heading')).toHaveText( + 'Revalidate fn', + ) + await expect(page.getByTestId('revalidate-origin')).toHaveText('client') + await expect(page.getByTestId('revalidate-stable-token')).toHaveText( + 'stable-client-1', + ) + await expect(page.getByTestId('revalidate-auth-status')).toHaveText('1') + await expect(page.getByTestId('revalidate-runs')).toHaveText('0') + + await page.getByTestId('revalidate-invalidate-btn').click() + await expect(page.getByTestId('revalidate-runs')).toHaveText('1') + await expect(page.getByTestId('revalidate-stable-token')).toHaveText( + 'stable-client-1', + ) + await expect(page.getByTestId('revalidate-auth-status')).toHaveText('2') + }) +}) + +// ============================================================================ +// Dehydrate-partial: hydrate reconstructs with ctx (params, context) +// ============================================================================ +test.describe('use case - dehydrate-partial reconstruction with ctx', () => { + test('SSR: serializable fields from the server, the rest rebuilt by hydrate using ctx', async ({ + page, + }) => { + await page.goto('/dehydrate-partial/de-DE') + await awaitHydrated(page) + await expect(page.getByTestId('dp-heading')).toHaveText('Dehydrate Partial') + + 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') + + await expect(page.getByTestId('dp-context-date')).toHaveText( + '2024-03-15T12:00:00.000Z', + ) + await expect(page.getByTestId('dp-context-date-type')).toHaveText('Date') + + // hydrate used the parent context (root-context) and the wire label + await expect(page.getByTestId('dp-context-format')).toHaveText( + '[server-ctx|root-context] test', + ) + await expect(page.getByTestId('dp-context-format-type')).toHaveText( + 'function', + ) + // hydrate rebuilt the formatter from params.locale + await expect(page.getByTestId('dp-context-currency-locale')).toHaveText( + 'de-DE', + ) + await expect(page.getByTestId('dp-context-currency')).toContainText( + '1.234,50', + ) + + 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', + ) + // beforeLoad.hydrate saw the route's own hydrated context contribution + await expect(page.getByTestId('dp-beforeLoad-describe')).toHaveText( + 'server-ctx:server-bl:hydrated', + ) + + 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') + // loader.hydrate saw the hydrated beforeLoad contribution + await expect(page.getByTestId('dp-loader-summary')).toHaveText( + 'server-bl/3:hydrated', + ) + }) + + test('client nav: handlers run locally, nothing goes through hydrate', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-dehydrate-partial').click() + await expect(page.getByTestId('dp-heading')).toHaveText('Dehydrate Partial') + + 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-context-format')).toHaveText( + '[client-ctx] test', + ) + await expect(page.getByTestId('dp-context-currency-locale')).toHaveText( + 'de-DE', + ) + await expect(page.getByTestId('dp-beforeLoad-describe')).toHaveText( + 'client-ctx:client-bl', + ) + await expect(page.getByTestId('dp-loader-summary')).toHaveText( + 'client-bl/3', + ) + await expect(page.getByTestId('dp-context-date-type')).toHaveText('Date') + await expect(page.getByTestId('dp-beforeLoad-pattern-test')).toHaveText( + 'true', + ) + await expect(page.getByTestId('dp-loader-avg')).toHaveText('20') + }) + + test('round trip: context keeps its hydrated value, beforeLoad and loader re-run', async ({ + page, + }) => { + await page.goto('/dehydrate-partial/de-DE') + await awaitHydrated(page) + await expect(page.getByTestId('dp-context-format')).toHaveText( + '[server-ctx|root-context] test', + ) + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await page.getByTestId('link-dehydrate-partial').click() + await expect(page.getByTestId('dp-heading')).toHaveText('Dehydrate Partial') + + await expect(page.getByTestId('dp-context-label')).toHaveText('server-ctx') + await expect(page.getByTestId('dp-context-format')).toHaveText( + '[server-ctx|root-context] test', + ) + await expect(page.getByTestId('dp-beforeLoad-tag')).toHaveText('client-bl') + await expect(page.getByTestId('dp-beforeLoad-describe')).toHaveText( + 'server-ctx:client-bl', + ) + await expect(page.getByTestId('dp-loader-title')).toHaveText('client-ldr') + await expect(page.getByTestId('dp-loader-summary')).toHaveText( + 'client-bl/3', + ) + }) +}) + +// ============================================================================ +// dehydrate: true + hydrate on the full value, client-only field declared +// in the handler as `undefined` +// ============================================================================ +test.describe('use case - reconstruct from the full value', () => { + test('SSR: the report is transported, hydrate fills the time zone in the browser', async ({ + page, + }) => { + const response = await page.goto('/full-value') + expect(response?.status()).toBe(200) + const html = await response!.text() + // data-only: the component is not in the HTML, the value is in the payload + expect(html).not.toContain('data-testid="full-value-component"') + expect(hydrationPayload(html)).toContain('server-full-value-report') + expect(html).not.toContain('client-full-value-report') + + await awaitHydrated(page) + await expect(page.getByTestId('full-value-heading')).toHaveText( + 'Full value', + ) + await expect(page.getByTestId('full-value-report-title')).toHaveText( + 'Quarterly report', + ) + await expect(page.getByTestId('full-value-report-source')).toHaveText( + 'server-full-value-report', + ) + const browserTimeZone = await page.evaluate( + () => Intl.DateTimeFormat().resolvedOptions().timeZone, + ) + expect(browserTimeZone).not.toBe('') + await expect(page.getByTestId('full-value-time-zone')).toHaveText( + browserTimeZone, + ) + await expect(page.getByTestId('full-value-time-zone-type')).toHaveText( + 'string', + ) + }) + + test('client nav: the handler runs in the browser, the time zone stays undefined', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-full-value').click() + await expect(page.getByTestId('full-value-heading')).toHaveText( + 'Full value', + ) + await expect(page.getByTestId('full-value-report-source')).toHaveText( + 'client-full-value-report', + ) + await expect(page.getByTestId('full-value-time-zone')).toHaveText( + 'undefined', + ) + await expect(page.getByTestId('full-value-time-zone-type')).toHaveText( + 'undefined', + ) + }) + + test('round trip after SSR: the loader re-runs locally and loses the hydrated time zone', async ({ + page, + }) => { + await page.goto('/full-value') + await awaitHydrated(page) + await expect(page.getByTestId('full-value-time-zone-type')).toHaveText( + 'string', + ) + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await page.getByTestId('link-full-value').click() + await expect(page.getByTestId('full-value-heading')).toHaveText( + 'Full value', + ) + await expect(page.getByTestId('full-value-report-source')).toHaveText( + 'client-full-value-report', + ) + await expect(page.getByTestId('full-value-time-zone')).toHaveText( + 'undefined', + ) + }) +}) + +// ============================================================================ +// `hydrate` returns a different shape, the component narrows on a literal +// discriminant written on both sides +// ============================================================================ +test.describe('use case - hydrated narrowing', () => { + test('SSR: hydrate adds client-only prefs to the transported stats', async ({ + page, + }) => { + const response = await page.goto('/hydrated-narrowing') + const html = await response!.text() + // the wire carries only what dehydrate kept + expect(hydrationPayload(html)).toContain('visits:42') + expect(hydrationPayload(html)).not.toContain('prefs') + + await awaitHydrated(page) + await expect(page.getByTestId('narrowing-heading')).toHaveText( + 'Hydrated narrowing', + ) + await expect(page.getByTestId('narrowing-hydrated')).toHaveText('true') + // stats were shipped from the server + await expect(page.getByTestId('narrowing-stats-origin')).toHaveText( + 'server', + ) + await expect(page.getByTestId('narrowing-stats-visits')).toHaveText('42') + // prefs were read in the browser by hydrate + await expect(page.getByTestId('narrowing-prefs')).toHaveText('light') + }) + + test('client nav: the handler runs in the browser, the component sees the hydrated: false branch', async ({ + page, + }) => { + await page.goto('/') + await awaitHydrated(page) + await page.getByTestId('link-hydrated-narrowing').click() + await expect(page.getByTestId('narrowing-heading')).toHaveText( + 'Hydrated narrowing', + ) + await expect(page.getByTestId('narrowing-hydrated')).toHaveText('false') + await expect(page.getByTestId('narrowing-stats-origin')).toHaveText( + 'client', + ) + await expect(page.getByTestId('narrowing-stats-visits')).toHaveText('43') + // the handler does not produce prefs; only hydrate does + await expect(page.getByTestId('narrowing-prefs')).toHaveText('no-prefs') + }) + + test('round trip after SSR: hydrated data is replaced by a client loader run', async ({ + page, + }) => { + await page.goto('/hydrated-narrowing') + await awaitHydrated(page) + await expect(page.getByTestId('narrowing-hydrated')).toHaveText('true') + await expect(page.getByTestId('narrowing-prefs')).toHaveText('light') + + await page.getByTestId('link-home').click() + await expect(page.getByTestId('index-heading')).toHaveText('Home') + await page.getByTestId('link-hydrated-narrowing').click() + await expect(page.getByTestId('narrowing-heading')).toHaveText( + 'Hydrated narrowing', + ) + await expect(page.getByTestId('narrowing-hydrated')).toHaveText('false') + await expect(page.getByTestId('narrowing-stats-origin')).toHaveText( + 'client', + ) + await expect(page.getByTestId('narrowing-prefs')).toHaveText('no-prefs') + }) +}) diff --git a/e2e/react-start/router-lifecycle-methods/tests/utils.ts b/e2e/react-start/router-lifecycle-methods/tests/utils.ts new file mode 100644 index 00000000000..67005bd9b95 --- /dev/null +++ b/e2e/react-start/router-lifecycle-methods/tests/utils.ts @@ -0,0 +1,57 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' + +/** Hydration is complete once the ClientOnly status block rendered and the router is idle. */ +export async function awaitHydrated(page: Page) { + await expect(page.getByTestId('hydrated')).toHaveText('hydrated') + await expect(page.getByTestId('router-status')).toHaveText('idle') + await expect(page.getByTestId('router-isLoading')).toHaveText('false') +} + +/** + * React reports hydration problems through these messages (dev and minified: + * #418 mismatch, #423 error while hydrating, #425 text mismatch). The pattern + * is deliberately narrower than /hydrat/ so a stack frame such as + * "at hydrate (...)" of an expected codec error does not count. + */ +export const hydrationErrorPattern = + /Hydration failed|while hydrating|did not match|server rendered HTML|Minified React error #(418|423|425)\b/i + +/** Collect console errors and page errors on both channels React may use. */ +export function collectErrors(page: Page): Array { + const errors: Array = [] + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + page.on('pageerror', (error) => { + errors.push(error.message) + }) + return errors +} + +/** Client-side run counters kept by src/utils/origin.ts. */ +export function readClientRuns(page: Page) { + return page.evaluate( + () => ((globalThis as any).__tsrE2eRuns ?? {}) as Record, + ) +} + +/** + * The hydration payload of an SSR response: the bodies of every inline + * `