diff --git a/.changeset/deferred-hydration-start.md b/.changeset/deferred-hydration-start.md
new file mode 100644
index 0000000000..90d7680399
--- /dev/null
+++ b/.changeset/deferred-hydration-start.md
@@ -0,0 +1,16 @@
+---
+'@tanstack/react-start-client': minor
+'@tanstack/solid-start-client': minor
+'@tanstack/start-client-core': minor
+'@tanstack/start-plugin-core': minor
+'@tanstack/start-server-core': minor
+'@tanstack/router-core': patch
+'@tanstack/router-plugin': patch
+'@tanstack/router-utils': patch
+---
+
+Add deferred Hydrate boundary support for TanStack Start.
+
+Hydrate boundaries can now be code-split by the Start compiler, preload their generated client chunks, preserve server-rendered fallback HTML, and replay interaction-triggered events after hydration. The compiler integration now uses a Start-owned compiler plugin for Hydrate virtual modules across Vite and Rsbuild, with dev invalidation for generated virtual modules.
+
+Shared AST utilities used by the router code-splitter and Hydrate virtual modules were moved into `@tanstack/router-utils` so both pipelines can retain referenced top-level declarations, unwrap local exports, and let dead-code elimination remove unused route module code.
diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md
index ba3095f0ae..f222b8088b 100644
--- a/benchmarks/bundle-size/README.md
+++ b/benchmarks/bundle-size/README.md
@@ -13,6 +13,7 @@ Each package has `minimal` and `full` scenarios:
- `minimal`: Small route app with `__root` + index route that renders `hello world`
- `full`: Same route shape plus a broad root-level harness that imports/uses the full hooks/components surface
- Start `full` scenarios also exercise `createServerFn`, `createMiddleware`, and `useServerFn`
+- Start `deferred-hydration` scenarios match the minimal route shape and wrap the index route content in `Hydrate`
## Design Notes
diff --git a/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/router.tsx b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/router.tsx
new file mode 100644
index 0000000000..9d87d8748b
--- /dev/null
+++ b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/router.tsx
@@ -0,0 +1,9 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ scrollRestoration: true,
+ })
+}
diff --git a/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/__root.tsx b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/__root.tsx
new file mode 100644
index 0000000000..ff1da4c304
--- /dev/null
+++ b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/__root.tsx
@@ -0,0 +1,24 @@
+import {
+ HeadContent,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/react-router'
+
+export const Route = createRootRoute({
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/index.tsx b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/index.tsx
new file mode 100644
index 0000000000..a462508abd
--- /dev/null
+++ b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/index.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { Hydrate } from '@tanstack/react-start'
+import { visible } from '@tanstack/react-start/hydration'
+
+export const Route = createFileRoute('/')({
+ component: IndexComponent,
+})
+
+function IndexComponent() {
+ return (
+
+ hello world
+
+ )
+}
diff --git a/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/vite.config.ts b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/vite.config.ts
new file mode 100644
index 0000000000..d4e4cd980d
--- /dev/null
+++ b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import viteReact from '@vitejs/plugin-react'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+
+export default defineConfig({
+ plugins: [tanstackStart(), viteReact()],
+})
diff --git a/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/router.tsx b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/router.tsx
new file mode 100644
index 0000000000..aa7ead6752
--- /dev/null
+++ b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/router.tsx
@@ -0,0 +1,9 @@
+import { createRouter } from '@tanstack/solid-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ scrollRestoration: true,
+ })
+}
diff --git a/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/__root.tsx b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/__root.tsx
new file mode 100644
index 0000000000..e59de72236
--- /dev/null
+++ b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/__root.tsx
@@ -0,0 +1,24 @@
+import {
+ HeadContent,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/solid-router'
+
+export const Route = createRootRoute({
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/index.tsx b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/index.tsx
new file mode 100644
index 0000000000..80362386b2
--- /dev/null
+++ b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/index.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { Hydrate } from '@tanstack/solid-start'
+import { visible } from '@tanstack/solid-start/hydration'
+
+export const Route = createFileRoute('/')({
+ component: IndexComponent,
+})
+
+function IndexComponent() {
+ return (
+
+ hello world
+
+ )
+}
diff --git a/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/vite.config.ts b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/vite.config.ts
new file mode 100644
index 0000000000..0bd21e64f4
--- /dev/null
+++ b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import solid from 'vite-plugin-solid'
+import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
+
+export default defineConfig({
+ plugins: [tanstackStart(), solid({ ssr: true })],
+})
diff --git a/docs/start/config.json b/docs/start/config.json
index 8872178ce9..b5b0c8753f 100644
--- a/docs/start/config.json
+++ b/docs/start/config.json
@@ -117,6 +117,10 @@
"label": "Hydration Errors",
"to": "framework/react/guide/hydration-errors"
},
+ {
+ "label": "Deferred Hydration",
+ "to": "framework/react/guide/deferred-hydration"
+ },
{
"label": "Selective SSR",
"to": "framework/react/guide/selective-ssr"
@@ -246,6 +250,10 @@
"label": "Hydration Errors",
"to": "framework/solid/guide/hydration-errors"
},
+ {
+ "label": "Deferred Hydration",
+ "to": "framework/solid/guide/deferred-hydration"
+ },
{
"label": "Selective SSR",
"to": "framework/solid/guide/selective-ssr"
diff --git a/docs/start/framework/react/guide/deferred-hydration.md b/docs/start/framework/react/guide/deferred-hydration.md
new file mode 100644
index 0000000000..a0420f0943
--- /dev/null
+++ b/docs/start/framework/react/guide/deferred-hydration.md
@@ -0,0 +1,701 @@
+---
+id: deferred-hydration
+title: Deferred Hydration
+---
+
+> Deferred hydration is experimental
+
+On an initial page load, TanStack Start server-renders your page so the browser
+can show useful HTML quickly. Hydration is the client-side work that turns that
+initial HTML document into an interactive app. It loads and executes JavaScript,
+runs components, attaches event handlers, and reconnects the existing DOM to
+React.
+
+Deferred hydration applies to this initial document hydration work. After the
+app is already running, subsequent client-side navigations render through the
+client app; there is no initial server HTML for TanStack Start to preserve.
+
+By default, TanStack Start hydrates the full document. That is usually the
+simplest and safest behavior, but large pages can spend meaningful startup time
+loading JavaScript and hydrating parts of the page that the user may not need
+right away.
+
+Deferred hydration lets you mark selected parts of a page as "not interactive
+yet". The server HTML remains in the document, but TanStack Start waits to
+hydrate that boundary until a strategy says it is time. By default, the compiler
+also moves the boundary children into a separate JavaScript chunk so the browser
+can delay loading that code too.
+
+Use deferred hydration when a part of the page should be visible, styled, and
+indexable immediately, but does not need to be interactive immediately.
+
+## Add A Deferred Boundary
+
+Use `Hydrate` with a strategy from `@tanstack/react-start/hydration`:
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { visible } from '@tanstack/react-start/hydration'
+
+export function ProductPage() {
+ return (
+
+
+
+ )
+}
+```
+
+On the initial server response, `Reviews` is still rendered to HTML. During the
+initial client hydration pass, that HTML is preserved but the `Reviews` React
+tree does not hydrate yet. When the boundary comes within `400px` of the
+viewport, TanStack Start loads the deferred child chunk and hydrates the
+boundary.
+
+`Hydrate` only preserves server HTML that exists in the initial document. If the
+same boundary first mounts later, for example after client-side navigation,
+there is no server HTML to preserve, so it renders normally on the client.
+
+## Choose What To Defer
+
+The right boundary depends on your page, your product priorities, and real user
+behavior. TanStack Start cannot know which parts of your page are safe to delay.
+
+Good candidates are usually SSR content that is not needed for immediate
+interaction:
+
+- Below-the-fold reviews, comments, product details, related content, or long
+ marketing sections.
+- Rich widgets such as maps, charts, carousels, video players, editors, or
+ embeds.
+- Panels that are activated by intent, such as filters, preview panes, or
+ contextual tools.
+- UI that only matters for a matching media query.
+- Static server-rendered content that should not hydrate on the initial
+ document.
+
+Poor candidates are parts of the page users may need immediately:
+
+- Primary navigation, route chrome, search boxes, and account controls.
+- Above-the-fold forms, add-to-cart buttons, checkout actions, or consent
+ controls.
+- The interactive part of the LCP or hero area when users may click it
+ immediately.
+- Accessibility-critical controls that must be keyboard-ready as soon as the
+ page appears.
+- Components whose props, context, or shared state are expected to update
+ immediately after app startup.
+
+Measure each boundary. A useful boundary reduces startup JavaScript or hydration
+work without making expected interactions feel late.
+
+## The Three Decisions
+
+Each `Hydrate` boundary has three performance decisions:
+
+| Decision | Option | What it controls |
+| ----------- | ---------- | ------------------------------------------------------------------ |
+| Hydration | `when` | When the preserved server HTML becomes interactive. |
+| Code split | `split` | Whether the children move into a generated deferred child chunk. |
+| Preparation | `prefetch` | Whether work starts before the `when` strategy hydrates the child. |
+
+### `when`: decide when the boundary hydrates
+
+`when` is required. Pass a strategy object for the common case:
+
+```tsx
+
+
+
+```
+
+Pass a function when the decision needs browser-only information:
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { interaction, visible } from '@tanstack/react-start/hydration'
+
+export function RecommendationsBoundary() {
+ return (
+
+ navigator.connection?.saveData
+ ? interaction({ events: 'click' })
+ : visible()
+ }
+ >
+
+
+ )
+}
+```
+
+The function form is evaluated only on the client and must synchronously return
+a strategy. Use `never()` when you intentionally want the initial server HTML to
+stay static.
+
+### `split`: decide whether to create a separate child chunk
+
+By default, `Hydrate` splits the children into a generated child chunk:
+
+```tsx
+
+
+
+```
+
+This delays both hydration work and child JavaScript loading.
+
+Set `split={false}` when the child code is small or already needed elsewhere,
+and you only want to delay hydration work:
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { idle } from '@tanstack/react-start/hydration'
+
+export function SmallWidgetBoundary() {
+ return (
+
+
+
+ )
+}
+```
+
+### `prefetch`: decide whether to start loading before hydration
+
+`prefetch` starts loading before the boundary hydrates. It has two forms:
+
+| Form | Example | Use it for |
+| ------------------- | ----------------------------------- | -------------------------------------------------------------- |
+| Prefetch strategy | `prefetch={idle()}` | Preloading the generated child chunk before hydration. |
+| Procedural prefetch | `prefetch={async (ctx) => { ... }}` | Preloading the child chunk plus data or other async resources. |
+
+Both forms start work early, but they do not change when the boundary becomes
+interactive. That is still controlled by `when`.
+
+A prefetch strategy is the small, declarative form:
+
+```tsx
+import { idle, interaction, visible } from '@tanstack/react-start/hydration'
+
+
+
+
+
+
+
+
+```
+
+Strategy-form `prefetch` downloads the generated child chunk before the boundary
+hydrates. This can make the later hydration trigger feel faster, because the
+browser may already have the chunk by the time `when` resolves. Generated child
+chunks only exist when `split` is enabled, so TypeScript rejects strategy-form
+`prefetch` when `split={false}`.
+
+Use procedural prefetch when you need custom work:
+
+```tsx
+import { useQueryClient } from '@tanstack/react-query'
+import { Hydrate } from '@tanstack/react-start'
+import { visible } from '@tanstack/react-start/hydration'
+
+function DeferredReviews() {
+ const queryClient = useQueryClient()
+
+ return (
+ {
+ await preload()
+ await queryClient.prefetchQuery(reviewsQueryOptions)
+ }}
+ >
+
+
+ )
+}
+```
+
+Procedural prefetch also works with `split={false}`. In that case, `preload()`
+is a resolved no-op, but the function can still prepare data or other
+resources.
+
+## Common Recipes
+
+### Hydrate below-the-fold SSR content
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { visible } from '@tanstack/react-start/hydration'
+
+export function ProductPage() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+}
+```
+
+Use a positive `rootMargin` when the boundary should hydrate before it actually
+enters the viewport.
+
+### Download the child chunk before it is needed
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { idle, visible } from '@tanstack/react-start/hydration'
+
+export function ReviewsBoundary() {
+ return (
+
+
+
+ )
+}
+```
+
+This keeps the boundary non-interactive until it is close to the viewport, but
+starts loading the child chunk during idle time.
+
+### Keep a widget cold until user intent
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { interaction, visible } from '@tanstack/react-start/hydration'
+
+export function RecommendationsBoundary() {
+ return (
+
+
+
+ )
+}
+```
+
+This is useful for expensive controls that are visible or nearby, but only
+matter when the user reaches for them.
+
+### Delay hydration without code splitting
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { idle } from '@tanstack/react-start/hydration'
+
+export function BadgeBoundary() {
+ return (
+
+
+
+ )
+}
+```
+
+Use this when the JavaScript is already part of the startup bundle or when a
+separate child chunk would not be worth it.
+
+### Keep initial SSR HTML static
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { never } from '@tanstack/react-start/hydration'
+
+export function MarketingPage() {
+ return (
+
+
+
+ )
+}
+```
+
+`never()` preserves the existing server HTML and does not hydrate the boundary
+during initial document hydration. If the same boundary mounts later during
+client-side navigation, it renders normally because there is no initial server
+HTML to preserve. `never()` cannot be used as a prefetch strategy.
+
+### Reuse Hydrate props
+
+Use `HydrateOptions` for reusable objects that you spread into `Hydrate`:
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import type { HydrateOptions } from '@tanstack/react-start'
+import { visible } from '@tanstack/react-start/hydration'
+
+const belowFoldProps = {
+ when: () => visible({ rootMargin: '800px' }),
+} satisfies HydrateOptions
+
+export function Page() {
+ return (
+ {
+ await preload()
+ }}
+ >
+
+
+ )
+}
+```
+
+Inline `when` and `prefetch` functions are supported. You do not need to wrap
+them in `useCallback`; TanStack Start keeps the latest callback internally and
+does not re-register hydration listeners just because a function identity
+changed. If the meaning of a boundary changes, use a normal React `key` to
+create a new boundary.
+
+## Hydrate Props Reference
+
+`Hydrate` accepts these props:
+
+| Prop | Type | Notes |
+| ------------ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `when` | `HydrationStrategy \| () => HydrationStrategy` | Required. Controls when the boundary hydrates. Function form is client-only and synchronous. |
+| `prefetch` | `HydrationPrefetchStrategy \| HydrationPrefetchFunction` | Optional. Strategy form preloads the split child chunk. Function form can preload chunks, data, or other resources, and can be used with `split={false}`. |
+| `split` | `boolean` | Defaults to `true`. Set literal `false` to disable compiler extraction and only defer hydration work. |
+| `fallback` | `ReactNode` | Client-only loading UI for boundaries that mount after the app has already hydrated and then suspend on the child chunk or child `Suspense`. |
+| `onHydrated` | `() => void` | Fires once after the boundary has hydrated on the client. |
+
+## Strategy Reference
+
+Import strategies from `@tanstack/react-start/hydration`.
+
+| Strategy | Behavior |
+| --------------- | ------------------------------------------------------------------------------------------ |
+| `load()` | Hydrates as soon as the app hydrates. |
+| `idle()` | Hydrates in `requestIdleCallback`, or after `timeout` when idle callbacks are unavailable. |
+| `visible()` | Hydrates when the boundary marker enters the viewport. |
+| `media()` | Hydrates when the media query matches. |
+| `interaction()` | Hydrates on configured interaction intent events. |
+| `condition()` | Hydrates once the condition is truthy. |
+| `never()` | Never hydrates the initial server-rendered boundary. |
+
+Strategy options:
+
+| Strategy | Options |
+| ------------- | --------------------------------------------------------------------------------------- |
+| `idle` | `{ timeout?: number }`, defaults to `2000`. |
+| `visible` | `{ rootMargin?: string; threshold?: number \| Array }`, default margin `600px`. |
+| `media` | Query string, for example `media('(min-width: 800px)')`. |
+| `interaction` | `{ events?: supported event or readonly array of supported events }`. |
+| `condition` | Boolean or boolean-returning function. |
+
+Supported interaction events are `auxclick`, `click`, `contextmenu`,
+`dblclick`, `focusin`, `keydown`, `keyup`, `mousedown`, `mouseenter`,
+`mouseover`, `mouseup`, `pointerdown`, `pointerenter`, `pointerover`, and
+`pointerup`.
+
+The default `interaction()` event list is `pointerenter`, `focusin`,
+`pointerdown`, and `click`. Use `events` when a boundary should listen to a
+different event or a smaller set:
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { interaction } from '@tanstack/react-start/hydration'
+
+
+
+
+
+
+
+
+```
+
+After a `condition()` boundary hydrates, it stays hydrated even if the condition
+later becomes false:
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { condition } from '@tanstack/react-start/hydration'
+
+export function CartRecommendationsBoundary() {
+ return (
+
+
+
+ )
+}
+```
+
+## Prefetch Reference
+
+Procedural prefetch receives a context object:
+
+| Property | Meaning |
+| ------------------- | --------------------------------------------------------------------------------------- |
+| `preload()` | Loads the compiler-generated child chunk. It resolves immediately when `split={false}`. |
+| `waitFor(strategy)` | Waits for a prefetch strategy, the hydration trigger, or abort. |
+| `signal` | `AbortSignal` for cancelable async work such as `fetch`. |
+| `element` | Boundary marker element for custom observers or DOM measurements. |
+
+`waitFor(strategy)` resolves with:
+
+| Result | Meaning |
+| ------------ | ------------------------------------------------------------------- |
+| `'prefetch'` | The supplied prefetch strategy resolved normally. |
+| `'hydrate'` | The boundary's hydration trigger fired first. Do required work now. |
+| `'abort'` | The boundary unmounted or the prefetch lifecycle was abandoned. |
+
+The promise returned from procedural prefetch is meaningful. Awaited work blocks
+hydration if the `when` strategy resolves before the prefetch function
+finishes:
+
+```tsx
+ {
+ await preload()
+ }}
+>
+
+
+```
+
+Fire-and-forget work does not block hydration:
+
+```tsx
+ {
+ void preload()
+ }}
+>
+
+
+```
+
+Use this distinction deliberately. Await when the resource is required for the
+first hydrated render. Fire and forget when the resource is only a helpful
+head start.
+
+## Fallbacks
+
+`fallback` is not the placeholder for the initial server-rendered HTML. On the
+initial page load, TanStack Start keeps the existing server HTML in place until
+the boundary hydrates:
+
+```tsx
+ }>
+
+
+```
+
+In that example, if `Reviews` was present in the initial HTML document, users
+see the server-rendered reviews. They do not see `ReviewsSkeleton` while the
+boundary is waiting for `visible()`.
+
+`fallback` is used when the boundary first appears after the app is already
+running and there is no existing server HTML for that boundary. Common examples
+include client-side navigation, conditionally showing a panel, or opening a tab
+whose contents were not in the initial document. In those cases, the boundary
+renders on the client, and `fallback` can show while the generated child chunk
+or a child `Suspense` is still loading.
+
+With `never()`, initial server HTML remains static and `fallback` is not used.
+
+The compiler removes statically visible `fallback` props from the server bundle.
+Prefer passing `fallback` directly, in an inline object spread, or through a
+single-use `const` object spread so server builds can strip that UI.
+
+## Correctness And Updates
+
+Deferred hydration is a performance hint for React's initial hydration work.
+React may hydrate a deferred boundary earlier than its strategy would normally
+allow if state, props, context, or store updates outside the boundary require
+React to reconcile inside it before the gate opens. This preserves correctness
+and avoids showing stale server HTML after the surrounding app has changed.
+
+`never()` is the exception for initial document hydration. Treat it as
+intentionally static SSR HTML. Do not rely on parent updates to make a `never()`
+boundary interactive. If the same boundary mounts later during client-side
+navigation, it renders normally.
+
+## Nested Boundaries
+
+Nested boundaries hydrate parent-first. A child boundary can only hydrate after
+its ancestor boundaries have hydrated. That means non-interaction child
+strategies such as `visible`, `media`, `idle`, or `condition` cannot run while
+their parent boundary is still dehydrated.
+
+For example, a product page might defer the whole reviews section until it is
+near the viewport, while keeping heavier review tools cold until the user
+interacts with them:
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+import { interaction, visible } from '@tanstack/react-start/hydration'
+
+export function ProductPage() {
+ return (
+ <>
+
+
+
+
+
+ Reviews
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+```
+
+In this example, scrolling near the reviews hydrates the parent first. Only
+after that can the nested interaction boundaries hydrate from focus or click.
+
+Interaction intent can also resolve an unresolved ancestor chain when the
+ancestor is itself waiting for interaction:
+
+```tsx
+
+
+
+```
+
+If the first meaningful intent is a click inside `WriteReviewForm`, TanStack
+Start hydrates the unresolved parent chain and then redispatches a same-type
+event for the target boundary. Native listener payload details such as pointer
+coordinates are not guaranteed to be preserved. A `never()` ancestor still wins
+during initial hydration, so descendants under it remain non-interactive.
+
+## Preloading And CSS
+
+Transformed `Hydrate` JavaScript chunks are not modulepreloaded with the route.
+Without `prefetch`, the child chunk loads when the split boundary is ready to
+render. If that import suspends during client-side navigation or another
+client-only mount, the boundary's `fallback` is shown.
+
+CSS used by split, deferred, and `never()` boundaries is linked in the SSR HTML
+for the matched route. It is not deferred with the generated child JavaScript
+chunk, because the server-rendered HTML may need those styles before any
+JavaScript runs. This is route-level asset linking: if a route module contains a
+deferred boundary that imports CSS, that stylesheet can be linked for the route
+even when that boundary is hidden behind conditional rendering and does not
+appear in a particular response.
+
+## Extraction Limits
+
+Compiler-backed `Hydrate` splitting works by moving the boundary's children into
+a generated virtual module and rendering them through a lazy component. That
+gives TanStack Start a separate child chunk to load later, but it also means the
+compiler must be able to move the JSX safely.
+
+Keep the component you want to split directly inside `Hydrate`. If you hide it
+behind opaque `children` props, the compiler cannot statically extract those
+children into a generated child chunk at the usage site.
+
+The split boundary must use a statically imported `Hydrate` component from
+`@tanstack/react-start`. Renaming that import is supported:
+
+```tsx
+import { Hydrate as Deferred } from '@tanstack/react-start'
+
+export function ProductPage() {
+ return (
+
+
+
+ )
+}
+```
+
+Assigning `Hydrate` to another component variable is not analyzed for splitting:
+
+```tsx
+import { Hydrate } from '@tanstack/react-start'
+
+const Deferred = Hydrate
+
+
+
+
+```
+
+Render the imported `Hydrate` tag directly, use an import rename, or set
+`split={false}` when you need component indirection.
+
+Use the literal prop `split={false}` to opt out of extraction. Dynamic values
+such as `split={shouldSplit}` cannot be used to opt out at compile time.
+
+These patterns cannot be split:
+
+| Pattern | Why it is rejected | What to do instead |
+| ---------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
+| Function-as-children | The compiler cannot move a render function and preserve the expected call pattern. | Use `split={false}` or move the rendered UI into a child component. |
+| Hook calls directly inside extracted JSX | Moving that JSX would move where the hook executes. | Move the hook call into a component inside the boundary, then render that component. |
+| `this` captures | Extracted function components cannot safely preserve class instance context. | Wrap the UI in a function component or use `split={false}`. |
+| `super` captures | Extracted function components cannot preserve superclass access. | Wrap the UI in a function component or use `split={false}`. |
+
+This fails because `useThing()` would be moved into the generated component:
+
+```tsx
+
+ {useThing()}
+
+```
+
+Move the hook into a component instead:
+
+```tsx
+function ThingText() {
+ const thing = useThing()
+ return {thing}
+}
+
+export function ProductPage() {
+ return (
+
+
+
+ )
+}
+```
+
+Values captured from the surrounding component can be passed into the generated
+child component, but keep the boundary simple. If extraction starts forcing
+complicated data flow, prefer a named child component and put the logic there.
+
+`fallback` stripping is intentionally conservative. The server build can strip
+directly passed fallback UI, inline object-spread fallback UI, and single-use
+`const` object-spread fallback UI. If fallback props are hidden behind dynamic
+spreads or shared objects, the compiler may keep them.
+
+You can extract reusable `when` and `prefetch` helpers today, but avoid hiding
+split boundaries behind plain wrapper components if you need child code
+splitting. A wrapper can defer hydration at runtime, but the compiler cannot
+reliably move call-site children into a separate chunk through arbitrary
+component indirection.
diff --git a/docs/start/framework/solid/guide/deferred-hydration.md b/docs/start/framework/solid/guide/deferred-hydration.md
new file mode 100644
index 0000000000..99ffc57a86
--- /dev/null
+++ b/docs/start/framework/solid/guide/deferred-hydration.md
@@ -0,0 +1,12 @@
+---
+ref: docs/start/framework/react/guide/deferred-hydration.md
+replace:
+ '@tanstack/react-start': '@tanstack/solid-start'
+ 'React handlers': 'Solid handlers'
+ 'ReactNode': 'JSX.Element'
+ "Deferred hydration is a performance hint for React's initial hydration work. React may hydrate a deferred boundary earlier than its strategy would normally allow if state, props, context, or store updates outside the boundary require React to reconcile inside it before the gate opens. This preserves correctness and avoids showing stale server HTML after the surrounding app has changed.": "Deferred hydration is a performance hint for Solid's initial hydration work. Once a boundary gate opens, TanStack Start clears the preserved server DOM inside the marker and mounts the live Solid subtree in its place."
+ 'Hook calls directly inside extracted JSX': 'Render-time `use*` calls directly inside extracted JSX'
+ 'Moving that JSX would move where the hook executes.': 'Moving that JSX would move where the call executes.'
+ 'Move the hook call into a component inside the boundary, then render that component.': 'Move the call into a component inside the boundary, then render that component.'
+ 'Move the hook into a component instead:': 'Move the call into a component instead:'
+---
diff --git a/e2e/e2e-utils/src/hmrFileEditor.ts b/e2e/e2e-utils/src/hmrFileEditor.ts
new file mode 100644
index 0000000000..309940fbfe
--- /dev/null
+++ b/e2e/e2e-utils/src/hmrFileEditor.ts
@@ -0,0 +1,127 @@
+import { readFile, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+
+export function replaceAll(source: string, from: string, to: string) {
+ return source.split(from).join(to)
+}
+
+export type HmrFileEditorOptions = {
+ rootDir?: string
+ files: Record
+ normalizeSource?: (fileKey: TFileKey, source: string) => string
+}
+
+export function createHmrFileEditor(
+ options: HmrFileEditorOptions,
+) {
+ const files = Object.fromEntries(
+ Object.entries(options.files).map(([key, filePath]) => [
+ key,
+ options.rootDir && !path.isAbsolute(filePath as string)
+ ? path.join(options.rootDir, filePath as string)
+ : (filePath as string),
+ ]),
+ ) as Record
+ const originalContents: Partial> = {}
+ const pendingRestoreKeys = new Set()
+ const normalizeSource =
+ options.normalizeSource ?? ((_fileKey: TFileKey, source: string) => source)
+
+ async function captureOriginals() {
+ for (const [key, filePath] of Object.entries(files) as Array<
+ [TFileKey, string]
+ >) {
+ const current = await readFile(filePath, 'utf8')
+ const normalized = normalizeSource(key, current)
+
+ if (normalized !== current) {
+ await writeFile(filePath, normalized)
+ pendingRestoreKeys.add(key)
+ }
+
+ originalContents[key] = normalized
+ }
+ }
+
+ const capturePromise = captureOriginals()
+
+ async function restoreFiles(forceFileKeys: Iterable = []) {
+ const forceRestoreKeys = new Set(forceFileKeys)
+ const restoredFileKeys: Array = []
+
+ for (const [key, filePath] of Object.entries(files) as Array<
+ [TFileKey, string]
+ >) {
+ const content = originalContents[key]
+ if (content === undefined) continue
+
+ const current = await readFile(filePath, 'utf8')
+
+ if (current !== content || forceRestoreKeys.has(key)) {
+ await writeFile(filePath, content)
+ restoredFileKeys.push(key)
+ }
+ }
+
+ return restoredFileKeys
+ }
+
+ async function replaceText(fileKey: TFileKey, from: string, to: string) {
+ const filePath = files[fileKey]
+ const source = await readFile(filePath, 'utf8')
+
+ if (!source.includes(from)) {
+ throw new Error(`Expected file to include ${JSON.stringify(from)}`)
+ }
+
+ await writeFile(filePath, source.replace(from, to))
+ }
+
+ async function rewriteFile(
+ fileKey: TFileKey,
+ updater: (source: string) => string,
+ options: { allowNoop?: boolean } = {},
+ ) {
+ const filePath = files[fileKey]
+ const source = await readFile(filePath, 'utf8')
+ const updated = updater(source)
+
+ if (updated === source && !options.allowNoop) {
+ throw new Error(`Expected ${filePath} to change during rewrite`)
+ }
+
+ await writeFile(filePath, updated)
+ }
+
+ async function replaceTextAndWait(
+ fileKey: TFileKey,
+ from: string,
+ to: string,
+ assertion: () => Promise,
+ ) {
+ await replaceText(fileKey, from, to)
+ await assertion()
+ }
+
+ async function rewriteFileAndWait(
+ fileKey: TFileKey,
+ updater: (source: string) => string,
+ assertion: () => Promise,
+ options: { allowNoop?: boolean } = {},
+ ) {
+ await rewriteFile(fileKey, updater, options)
+ await assertion()
+ }
+
+ return {
+ files,
+ pendingRestoreKeys,
+ capturePromise,
+ captureOriginals,
+ restoreFiles,
+ replaceText,
+ replaceTextAndWait,
+ rewriteFile,
+ rewriteFileAndWait,
+ }
+}
diff --git a/e2e/e2e-utils/src/index.ts b/e2e/e2e-utils/src/index.ts
index ec6affdb56..17e58f82e5 100644
--- a/e2e/e2e-utils/src/index.ts
+++ b/e2e/e2e-utils/src/index.ts
@@ -4,5 +4,7 @@ export { toRuntimePath } from './to-runtime-path'
export { resolveRuntimeSuffix } from './resolve-runtime-suffix'
export { e2eStartDummyServer, e2eStopDummyServer } from './e2eSetupTeardown'
export { preOptimizeDevServer, waitForServer } from './devServerWarmup'
+export { createHmrFileEditor, replaceAll } from './hmrFileEditor'
+export type { HmrFileEditorOptions } from './hmrFileEditor'
export type { Post } from './posts'
export { collectBrowserErrors, test } from './fixture'
diff --git a/e2e/react-start/deferred-hydration/.gitignore b/e2e/react-start/deferred-hydration/.gitignore
new file mode 100644
index 0000000000..1b3a07ede1
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/.gitignore
@@ -0,0 +1,15 @@
+node_modules
+package-lock.json
+yarn.lock
+.DS_Store
+.cache
+.env
+.vercel
+.output
+/build/
+/api/
+/server/build
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/e2e/react-start/deferred-hydration/package.json b/e2e/react-start/deferred-hydration/package.json
new file mode 100644
index 0000000000..db76b2fecf
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "tanstack-react-start-e2e-deferred-hydration",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev": "pnpm dev:vite --port 3000",
+ "dev:e2e": "pnpm dev:vite",
+ "dev:vite": "vite dev",
+ "dev:rsbuild": "rsbuild dev",
+ "build": "pnpm build:vite",
+ "build:vite": "vite build && tsc --noEmit",
+ "build:rsbuild": "rsbuild build && tsc --noEmit",
+ "preview": "vite preview",
+ "start": "pnpm start:vite",
+ "start:vite": "srvx --prod --dir=. -s dist-vite-ssr/client --entry dist-vite-ssr/server/server.js",
+ "start:rsbuild": "srvx --prod --dir=. -s dist-rsbuild-ssr/client --entry dist-rsbuild-ssr/server/index.js",
+ "test:e2e": "pnpm test:e2e:dev && pnpm test:e2e:prod",
+ "test:e2e:dev": "MODE=dev playwright test --project=chromium",
+ "test:e2e:prod": "MODE=prod playwright test --project=chromium"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:^",
+ "@tanstack/react-start": "workspace:^",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "vite": "^8.0.0"
+ },
+ "devDependencies": {
+ "@rsbuild/core": "^2.0.1",
+ "@rsbuild/plugin-react": "^2.0.0",
+ "@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",
+ "rolldown": "1.0.0-rc.18",
+ "srvx": "^0.11.9",
+ "typescript": "^6.0.2"
+ },
+ "nx": {
+ "targets": {
+ "test:e2e": {
+ "parallelism": false
+ }
+ },
+ "metadata": {
+ "playwrightModes": [
+ {
+ "toolchain": "vite",
+ "mode": "ssr"
+ },
+ {
+ "toolchain": "rsbuild",
+ "mode": "ssr"
+ }
+ ]
+ }
+ }
+}
diff --git a/e2e/react-start/deferred-hydration/playwright.config.ts b/e2e/react-start/deferred-hydration/playwright.config.ts
new file mode 100644
index 0000000000..0125792d4b
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/playwright.config.ts
@@ -0,0 +1,52 @@
+import fs from 'node:fs'
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite'
+const distDir = process.env.E2E_DIST_DIR ?? `dist-${toolchain}-ssr`
+const e2ePortKey =
+ process.env.E2E_PORT_KEY ?? `${packageJson.name}-${toolchain}`
+const mode = process.env.MODE ?? 'prod'
+const isDev = mode === 'dev'
+const serverEntryFile = toolchain === 'rsbuild' ? 'index.js' : 'server.js'
+const startCommand = `pnpm exec srvx --prod --dir=. -s ${distDir}/client --entry ${distDir}/server/${serverEntryFile}`
+const devCommand =
+ toolchain === 'rsbuild' ? 'pnpm dev:rsbuild' : 'pnpm dev:vite'
+
+if (process.env.TEST_WORKER_INDEX === undefined) {
+ fs.rmSync(`port-${e2ePortKey}.txt`, { force: true })
+}
+
+const PORT = await getTestServerPort(e2ePortKey)
+const baseURL = `http://localhost:${PORT}`
+
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+ reporter: [['line']],
+ globalSetup: './tests/setup/global.setup.ts',
+ globalTeardown: './tests/setup/global.teardown.ts',
+ use: { baseURL },
+ webServer: {
+ command: isDev ? `${devCommand} --port ${PORT}` : startCommand,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ env: {
+ E2E_DIST_DIR: distDir,
+ NODE_ENV: isDev ? 'development' : 'production',
+ VITE_NODE_ENV: 'test',
+ PORT: String(PORT),
+ VITE_SERVER_PORT: String(PORT),
+ E2E_TOOLCHAIN: toolchain,
+ E2E_PORT_KEY: e2ePortKey,
+ },
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/react-start/deferred-hydration/rsbuild.config.ts b/e2e/react-start/deferred-hydration/rsbuild.config.ts
new file mode 100644
index 0000000000..6279705adb
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/rsbuild.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from '@rsbuild/core'
+import { pluginReact } from '@rsbuild/plugin-react'
+import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild'
+
+const outDir = process.env.E2E_DIST_DIR ?? 'dist-rsbuild-ssr'
+
+export default defineConfig({
+ plugins: [pluginReact({ splitChunks: false }), tanstackStart()],
+ output: {
+ distPath: {
+ root: outDir,
+ },
+ },
+})
diff --git a/e2e/react-start/deferred-hydration/src/routeTree.gen.ts b/e2e/react-start/deferred-hydration/src/routeTree.gen.ts
new file mode 100644
index 0000000000..a5b35a698f
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routeTree.gen.ts
@@ -0,0 +1,177 @@
+/* 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 ScrollRestorationRouteImport } from './routes/scroll-restoration'
+import { Route as ImportedRouteImport } from './routes/imported'
+import { Route as EnhancedRouteImport } from './routes/enhanced'
+import { Route as CssRouteImport } from './routes/css'
+import { Route as ComponentsRouteImport } from './routes/components'
+import { Route as IndexRouteImport } from './routes/index'
+
+const ScrollRestorationRoute = ScrollRestorationRouteImport.update({
+ id: '/scroll-restoration',
+ path: '/scroll-restoration',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ImportedRoute = ImportedRouteImport.update({
+ id: '/imported',
+ path: '/imported',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const EnhancedRoute = EnhancedRouteImport.update({
+ id: '/enhanced',
+ path: '/enhanced',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const CssRoute = CssRouteImport.update({
+ id: '/css',
+ path: '/css',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ComponentsRoute = ComponentsRouteImport.update({
+ id: '/components',
+ path: '/components',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/components': typeof ComponentsRoute
+ '/css': typeof CssRoute
+ '/enhanced': typeof EnhancedRoute
+ '/imported': typeof ImportedRoute
+ '/scroll-restoration': typeof ScrollRestorationRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/components': typeof ComponentsRoute
+ '/css': typeof CssRoute
+ '/enhanced': typeof EnhancedRoute
+ '/imported': typeof ImportedRoute
+ '/scroll-restoration': typeof ScrollRestorationRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/components': typeof ComponentsRoute
+ '/css': typeof CssRoute
+ '/enhanced': typeof EnhancedRoute
+ '/imported': typeof ImportedRoute
+ '/scroll-restoration': typeof ScrollRestorationRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | '/'
+ | '/components'
+ | '/css'
+ | '/enhanced'
+ | '/imported'
+ | '/scroll-restoration'
+ fileRoutesByTo: FileRoutesByTo
+ to:
+ | '/'
+ | '/components'
+ | '/css'
+ | '/enhanced'
+ | '/imported'
+ | '/scroll-restoration'
+ id:
+ | '__root__'
+ | '/'
+ | '/components'
+ | '/css'
+ | '/enhanced'
+ | '/imported'
+ | '/scroll-restoration'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ ComponentsRoute: typeof ComponentsRoute
+ CssRoute: typeof CssRoute
+ EnhancedRoute: typeof EnhancedRoute
+ ImportedRoute: typeof ImportedRoute
+ ScrollRestorationRoute: typeof ScrollRestorationRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/scroll-restoration': {
+ id: '/scroll-restoration'
+ path: '/scroll-restoration'
+ fullPath: '/scroll-restoration'
+ preLoaderRoute: typeof ScrollRestorationRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/imported': {
+ id: '/imported'
+ path: '/imported'
+ fullPath: '/imported'
+ preLoaderRoute: typeof ImportedRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/enhanced': {
+ id: '/enhanced'
+ path: '/enhanced'
+ fullPath: '/enhanced'
+ preLoaderRoute: typeof EnhancedRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/css': {
+ id: '/css'
+ path: '/css'
+ fullPath: '/css'
+ preLoaderRoute: typeof CssRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/components': {
+ id: '/components'
+ path: '/components'
+ fullPath: '/components'
+ preLoaderRoute: typeof ComponentsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ ComponentsRoute: ComponentsRoute,
+ CssRoute: CssRoute,
+ EnhancedRoute: EnhancedRoute,
+ ImportedRoute: ImportedRoute,
+ ScrollRestorationRoute: ScrollRestorationRoute,
+}
+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/deferred-hydration/src/router.tsx b/e2e/react-start/deferred-hydration/src/router.tsx
new file mode 100644
index 0000000000..9d87d8748b
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/router.tsx
@@ -0,0 +1,9 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ scrollRestoration: true,
+ })
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/__root.tsx b/e2e/react-start/deferred-hydration/src/routes/__root.tsx
new file mode 100644
index 0000000000..bfdc646fc5
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/__root.tsx
@@ -0,0 +1,180 @@
+///
+import * as React from 'react'
+import {
+ HeadContent,
+ Link,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/react-router'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ { charSet: 'utf-8' },
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ { title: 'Deferred Hydration E2E' },
+ ],
+ }),
+ shellComponent: RootDocument,
+ component: () => (
+
+
+
+ ),
+})
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+ Home Component{' '}
+ CSS{' '}
+
+ Imported
+
+
+ Enhanced
+
+ Scroll
+
+ {children}
+
+
+
+ )
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/components.tsx b/e2e/react-start/deferred-hydration/src/routes/components.tsx
new file mode 100644
index 0000000000..95fa7531a4
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/components.tsx
@@ -0,0 +1,179 @@
+import { createFileRoute } from '@tanstack/react-router'
+import * as React from 'react'
+import { Hydrate } from '@tanstack/react-start'
+import {
+ condition,
+ idle,
+ interaction,
+ load,
+ media,
+ never,
+ visible,
+} from '@tanstack/react-start/hydration'
+
+export const Route = createFileRoute('/components')({
+ component: ComponentHydrationPage,
+})
+
+function InteractiveBox(props: { id: string; label: string }) {
+ const [count, setCount] = React.useState(0)
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ {props.label}: {count}
+
+ )
+}
+
+type HydrationFallbackWindow = Window & {
+ __componentFallbackReady?: boolean
+ __componentFallbackPromise?: Promise
+}
+
+function DelayedFallbackBox() {
+ if (typeof window !== 'undefined') {
+ const win = window as HydrationFallbackWindow
+
+ if (!win.__componentFallbackReady) {
+ win.__componentFallbackPromise ??= new Promise((resolve) => {
+ win.setTimeout(() => {
+ win.__componentFallbackReady = true
+ resolve()
+ }, 1000)
+ })
+
+ throw win.__componentFallbackPromise
+ }
+ }
+
+ return fallback child
+}
+
+function ComponentHydrationPage() {
+ const [hydratedCallbacks, setHydratedCallbacks] = React.useState(0)
+ const [conditionReady, setConditionReady] = React.useState(false)
+ const [showClientFallbackBoundary, setShowClientFallbackBoundary] =
+ React.useState(false)
+
+ return (
+
+ Component Deferred Hydration
+
+ Manual test guide
+
+ Pink buttons are server HTML that has not hydrated yet. Green buttons
+ have hydrated and should increment when clicked. Follow the notes
+ below to trigger each strategy intentionally.
+
+
+ {hydratedCallbacks}
+
+ load and idle should become green
+ without interaction shortly after the page loads.
+
+
+
+
+
+
+
+
+ Scroll down to reveal the visible boundary
+
+
+ visible hydrates only after this button enters the
+ viewport.
+
+
+
+
+
+ media hydrates when (min-width: 1px)
+ matches. interaction hydrates on hover, focus, pointer
+ down, or click intent.
+
+
+
+
+
+
+
+
+ Custom interaction boundaries below hydrate only for their configured
+ events: double-click for the single-event example, and right-click or
+ double-click for the multi-event example. The prefetch example should
+ download code on hover but hydrate on click.
+
+ setHydratedCallbacks((count) => count + 1)}
+ >
+
+
+
+
+
+ setConditionReady(true)}
+ >
+ enable condition
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ never stays as server HTML forever on the initial page,
+ so clicking should not increment it.
+
+ setShowClientFallbackBoundary(true)}
+ >
+ show client fallback
+
+ {showClientFallbackBoundary ? (
+ client fallback
+ }
+ >
+
+
+ ) : null}
+
+ )
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/css.tsx b/e2e/react-start/deferred-hydration/src/routes/css.tsx
new file mode 100644
index 0000000000..2c01aa9e57
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/css.tsx
@@ -0,0 +1,57 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { Hydrate } from '@tanstack/react-start'
+import { media, never, visible } from '@tanstack/react-start/hydration'
+import outerStyles from './css/outer.module.css'
+import deferredStyles from './css/deferred-only.module.css'
+import sharedStyles from './css/shared.module.css'
+
+export const Route = createFileRoute('/css')({
+ component: CssHydrationPage,
+})
+
+function CssHydrationPage() {
+ return (
+
+
+
+ CSS Deferred Hydration
+
+
+ CSS from deferred, never, shared, and nested Hydrate boundaries should
+ be available even before the client JavaScript hydrates those islands.
+
+
+
+
+ Outer CSS
+
+
+ Shared outer CSS
+
+
+
+
+ Deferred CSS
+
+
+
+
+ Never CSS
+
+
+
+
+
+ Nested CSS
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/css/deferred-only.module.css b/e2e/react-start/deferred-hydration/src/routes/css/deferred-only.module.css
new file mode 100644
index 0000000000..05eafda03c
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/css/deferred-only.module.css
@@ -0,0 +1,15 @@
+.deferredBox {
+ background-color: rgb(23, 45, 67);
+ color: rgb(255, 255, 255);
+ padding: 12px;
+}
+
+.neverBox {
+ color: rgb(45, 67, 89);
+ padding: 12px;
+}
+
+.nestedBox {
+ border-left: 5px solid rgb(67, 89, 123);
+ padding-left: 12px;
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/css/outer.module.css b/e2e/react-start/deferred-hydration/src/routes/css/outer.module.css
new file mode 100644
index 0000000000..98ca5e0934
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/css/outer.module.css
@@ -0,0 +1,9 @@
+.heading {
+ color: rgb(11, 31, 53);
+}
+
+.outerBox {
+ background-color: rgb(242, 250, 255);
+ color: rgb(12, 34, 56);
+ padding: 12px;
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/css/shared.module.css b/e2e/react-start/deferred-hydration/src/routes/css/shared.module.css
new file mode 100644
index 0000000000..020da5d7ad
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/css/shared.module.css
@@ -0,0 +1,4 @@
+.sharedBox {
+ border-top: 4px solid rgb(98, 76, 54);
+ margin-top: 8px;
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/enhanced.tsx b/e2e/react-start/deferred-hydration/src/routes/enhanced.tsx
new file mode 100644
index 0000000000..628a5c4ae3
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/enhanced.tsx
@@ -0,0 +1,339 @@
+import { createFileRoute } from '@tanstack/react-router'
+import * as React from 'react'
+import { Hydrate } from '@tanstack/react-start'
+import { interaction, media } from '@tanstack/react-start/hydration'
+import { EnhancedNestedWidget } from '../shared/EnhancedNestedWidget'
+
+type EnhancedSearch = {
+ dynamic?: 'interaction'
+}
+
+export const Route = createFileRoute('/enhanced')({
+ validateSearch: (search: Record): EnhancedSearch => ({
+ dynamic: search.dynamic === 'interaction' ? 'interaction' : undefined,
+ }),
+ component: EnhancedHydrationPage,
+})
+
+type DeferredGate = {
+ promise: Promise
+ resolve: () => void
+}
+
+const clickIntent = interaction({ events: 'click' })
+const pointerOverIntent = interaction({ events: 'pointerover' })
+const doubleClickIntent = interaction({ events: 'dblclick' })
+
+function createDeferredGate(): DeferredGate {
+ let resolve!: () => void
+ const promise = new Promise((next) => {
+ resolve = next
+ })
+ return { promise, resolve }
+}
+
+function useDeferredGate() {
+ const ref = React.useRef(undefined)
+ ref.current ??= createDeferredGate()
+ return ref.current
+}
+
+function mergeStatus>(
+ setStatus: React.Dispatch>,
+ patch: Partial,
+) {
+ setStatus((current) => ({ ...current, ...patch }))
+}
+
+function InteractiveBox(props: { id: string; label: string }) {
+ const [count, setCount] = React.useState(0)
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ {props.label}: {count}
+
+ )
+}
+
+function DynamicWhenExamples() {
+ const search = Route.useSearch()
+ const searchDrivenHydration = React.useCallback(
+ () => (search.dynamic === 'interaction' ? clickIntent : doubleClickIntent),
+ [search.dynamic],
+ )
+
+ return (
+ <>
+
+ Dynamic callbacks are client-only. The first boundary always hydrates on
+ click; the second reads typed router search state before choosing its
+ interaction event.
+
+ clickIntent}>
+
+
+
+
+
+ >
+ )
+}
+
+function SplitPrefetchExample() {
+ const gate = useDeferredGate()
+ const [status, setStatus] = React.useState({
+ wait: 'idle',
+ preload: 'idle',
+ query: 'idle',
+ })
+
+ return (
+ <>
+ {status.wait}
+ {status.preload}
+ {status.query}
+ gate.resolve()}
+ >
+ release split prefetch
+
+ {
+ mergeStatus(setStatus, {
+ query: element ? 'element' : 'missing-element',
+ wait: 'waiting',
+ })
+
+ const reason = await waitFor(pointerOverIntent)
+ mergeStatus(setStatus, { wait: reason })
+ if (reason === 'abort' || signal.aborted) return
+
+ await preload()
+ mergeStatus(setStatus, { preload: 'done' })
+ await gate.promise
+ mergeStatus(setStatus, { query: 'done' })
+ }}
+ >
+
+
+ >
+ )
+}
+
+function FireAndForgetPrefetchExample() {
+ const gate = useDeferredGate()
+ const [status, setStatus] = React.useState({
+ wait: 'idle',
+ work: 'idle',
+ query: 'idle',
+ })
+
+ return (
+ <>
+ {status.work}
+ {status.wait}
+ {status.query}
+ gate.resolve()}
+ >
+ release fire-and-forget prefetch
+
+ {
+ mergeStatus(setStatus, { wait: 'waiting' })
+ void waitFor(pointerOverIntent).then((reason) => {
+ mergeStatus(setStatus, { wait: reason })
+ if (reason === 'abort') return
+
+ mergeStatus(setStatus, { work: 'started' })
+ void gate.promise.then(() => {
+ mergeStatus(setStatus, { query: 'done' })
+ })
+ })
+ }}
+ >
+
+
+ >
+ )
+}
+
+function HydrateFirstPrefetchExample() {
+ const [reason, setReason] = React.useState('idle')
+
+ return (
+ <>
+ {reason}
+ {
+ setReason(await waitFor(doubleClickIntent))
+ }}
+ >
+
+
+ >
+ )
+}
+
+function RuntimeOnlyPrefetchExample() {
+ const gate = useDeferredGate()
+ const [status, setStatus] = React.useState({
+ wait: 'idle',
+ ready: 'idle',
+ })
+
+ return (
+ <>
+ {status.wait}
+ {status.ready}
+ gate.resolve()}
+ >
+ release runtime-only prefetch
+
+ {
+ mergeStatus(setStatus, { wait: 'waiting' })
+ const reason = await waitFor(pointerOverIntent)
+ mergeStatus(setStatus, { wait: reason })
+ if (reason === 'abort') return
+
+ await gate.promise
+ await preload()
+ mergeStatus(setStatus, { ready: 'ready' })
+ }}
+ >
+
+
+ >
+ )
+}
+
+function WaitForAbortExample() {
+ const [showBoundary, setShowBoundary] = React.useState(true)
+ const [reason, setReason] = React.useState('idle')
+
+ return (
+ <>
+ {reason}
+ setShowBoundary(false)}
+ >
+ hide waitFor abort boundary
+
+ {showBoundary ? (
+ {
+ setReason('waiting')
+ setReason(await waitFor(pointerOverIntent))
+ }}
+ >
+
+
+ ) : null}
+ >
+ )
+}
+
+function SignalAbortExample() {
+ const [showBoundary, setShowBoundary] = React.useState(true)
+ const [status, setStatus] = React.useState('idle')
+
+ return (
+ <>
+ {status}
+ setShowBoundary(false)}
+ >
+ hide abort boundary
+
+ {showBoundary ? (
+ {
+ setStatus('listening')
+ await new Promise((resolve) => {
+ const onAbort = () => {
+ setStatus('aborted')
+ resolve()
+ }
+
+ if (signal.aborted) {
+ onAbort()
+ return
+ }
+
+ signal.addEventListener('abort', onAbort, { once: true })
+ })
+ }}
+ >
+
+
+ ) : null}
+ >
+ )
+}
+
+function NestedDynamicExamples() {
+ return (
+ <>
+
+ clickIntent}>
+
+
+
+
+ >
+ )
+}
+
+function EnhancedHydrationPage() {
+ return (
+
+ Enhanced Hydrate APIs
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/imported.tsx b/e2e/react-start/deferred-hydration/src/routes/imported.tsx
new file mode 100644
index 0000000000..bf0f8b74bd
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/imported.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { ImportedHydrateWidget } from '../shared/ImportedHydrateWidget'
+
+export const Route = createFileRoute('/imported')({
+ component: ImportedHydrationPage,
+})
+
+function ImportedHydrationPage() {
+ return (
+
+ )
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/index.tsx b/e2e/react-start/deferred-hydration/src/routes/index.tsx
new file mode 100644
index 0000000000..0585f4bc87
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/index.tsx
@@ -0,0 +1,23 @@
+import { Link, createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+ component: Home,
+})
+
+function Home() {
+ return (
+
+ Deferred Hydration
+ Component strategies
+ component strategies
+ CSS
+ CSS deferred hydration
+ Imported component
+
+ imported Hydrate
+
+ Enhanced APIs
+ enhanced Hydrate APIs
+
+ )
+}
diff --git a/e2e/react-start/deferred-hydration/src/routes/scroll-restoration.tsx b/e2e/react-start/deferred-hydration/src/routes/scroll-restoration.tsx
new file mode 100644
index 0000000000..7b52cf25fe
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/routes/scroll-restoration.tsx
@@ -0,0 +1,93 @@
+import { ClientOnly, createFileRoute } from '@tanstack/react-router'
+import { Hydrate } from '@tanstack/react-start'
+import { visible } from '@tanstack/react-start/hydration'
+import * as React from 'react'
+
+export const Route = createFileRoute('/scroll-restoration')({
+ component: ScrollRestorationRoute,
+})
+
+function BottomWidget() {
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+
+ Bottom widget
+
+ This visible deferred boundary is intentionally near the bottom of the
+ document.
+
+
+ The hydrated widget is taller than the server skeleton so the test can
+ catch scroll restoration that happens before late hydration settles.
+
+
+ )
+}
+
+function BottomWidgetSkeleton() {
+ return (
+
+ Bottom widget skeleton
+
+ The server-rendered placeholder reserves the same vertical space as the
+ hydrated widget.
+
+
+ )
+}
+
+function ScrollRestorationRoute() {
+ return (
+
+
+ Scroll restoration deferred hydration
+
+
+ This route keeps the reproduction small: a tall page, a visible Hydrate
+ boundary near the bottom, and normal router scroll restoration.
+
+
+ Scroll to the bottom widget
+
+
+ }>
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx b/e2e/react-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx
new file mode 100644
index 0000000000..fb2f094e51
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx
@@ -0,0 +1,33 @@
+import * as React from 'react'
+import { Hydrate } from '@tanstack/react-start'
+import { interaction, media } from '@tanstack/react-start/hydration'
+
+function CrossFileNestedButton() {
+ const [count, setCount] = React.useState(0)
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ cross-file nested child:{' '}
+ {count}
+
+ )
+}
+
+export function EnhancedNestedWidget() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx b/e2e/react-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx
new file mode 100644
index 0000000000..f2be89ec1c
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx
@@ -0,0 +1,39 @@
+import * as React from 'react'
+import { Hydrate } from '@tanstack/react-start'
+import { interaction } from '@tanstack/react-start/hydration'
+
+function ImportedHydrateChild() {
+ const [count, setCount] = React.useState(0)
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ Imported Hydrate Child:{' '}
+ {count}
+
+ )
+}
+
+export function ImportedHydrateWidget() {
+ return (
+
+ imported hydrate fallback
+
+ }
+ >
+
+
+ )
+}
diff --git a/e2e/react-start/deferred-hydration/tests/hydration.spec.ts b/e2e/react-start/deferred-hydration/tests/hydration.spec.ts
new file mode 100644
index 0000000000..0a2c332271
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/tests/hydration.spec.ts
@@ -0,0 +1,1028 @@
+import { expect } from '@playwright/test'
+import { createHmrFileEditor, test } from '@tanstack/router-e2e-utils'
+import crypto from 'node:crypto'
+import path from 'node:path'
+import type { APIRequestContext, Page } from '@playwright/test'
+
+const isDev = process.env.MODE === 'dev'
+const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite'
+const isVite = toolchain === 'vite'
+const hmrExpect = expect.configure({ timeout: 20_000 })
+const componentsRouteFile = path.join(
+ process.cwd(),
+ 'src/routes/components.tsx',
+)
+const interactiveBoxLabelSource =
+ ' {props.label}: {count} '
+const interactiveBoxHmrLabelSource =
+ ' hmr {props.label}: {count} '
+
+function normalizeComponentsRouteSource(source: string) {
+ return source
+ .split(interactiveBoxHmrLabelSource)
+ .join(interactiveBoxLabelSource)
+}
+
+const componentsRouteEditor = createHmrFileEditor({
+ files: {
+ componentsRoute: componentsRouteFile,
+ },
+ normalizeSource: (_fileKey, source) => normalizeComponentsRouteSource(source),
+})
+
+function getVisibleHydrateVirtualPath() {
+ const normalizedSourcePath = path
+ .relative(process.cwd(), componentsRouteFile)
+ .replaceAll('\\', '/')
+ const sourceHash = crypto
+ .createHash('sha1')
+ .update(normalizedSourcePath)
+ .digest('hex')
+ .slice(0, 10)
+ const params = new URLSearchParams()
+ params.set('tss-hydrate', `0_${sourceHash}`)
+
+ return `${componentsRouteFile}?${params.toString()}`
+}
+
+async function waitForVisibleHydrateVirtualModule(page: Page, marker: string) {
+ const virtualPath = getVisibleHydrateVirtualPath()
+
+ await expect
+ .poll(
+ async () => {
+ try {
+ const response = await page.request.get(virtualPath)
+ const text = await response.text()
+
+ if (response.ok() && text.includes(marker)) {
+ return 'ready'
+ }
+
+ return `${response.status()} ${text.slice(0, 240)}`
+ } catch (error) {
+ return String(error)
+ }
+ },
+ { timeout: 20_000 },
+ )
+ .toBe('ready')
+}
+
+async function clickAndExpectCount(
+ page: Page,
+ buttonTestId: string,
+ countTestId: string,
+ count: string,
+) {
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await page.getByTestId(buttonTestId).click()
+ await expect(page.getByTestId(countTestId)).toHaveText(count)
+}
+
+async function clickIntentAndExpectReplayedCount(
+ page: Page,
+ buttonTestId: string,
+ countTestId: string,
+ count: string,
+) {
+ await expectRouteToStayUnhydrated(page, buttonTestId)
+ await page.getByTestId(buttonTestId).click()
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await expect(page.getByTestId(countTestId)).toHaveText(count)
+}
+
+async function clickToHydrateThenClickAndExpectIncrement(
+ page: Page,
+ buttonTestId: string,
+ countTestId: string,
+) {
+ await expectRouteToStayUnhydrated(page, buttonTestId)
+ await page.getByTestId(buttonTestId).click()
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ const previousCount = Number(
+ await page.getByTestId(countTestId).textContent(),
+ )
+ await page.getByTestId(buttonTestId).click()
+ await expect
+ .poll(async () => Number(await page.getByTestId(countTestId).textContent()))
+ .toBe(previousCount + 1)
+}
+
+async function hoverIntentAndExpectCount(
+ page: Page,
+ buttonTestId: string,
+ countTestId: string,
+ count: string,
+) {
+ await expectRouteToStayUnhydrated(page, buttonTestId)
+ await page.mouse.move(0, 0)
+ await page.getByTestId(buttonTestId).hover()
+ await clickAndExpectCount(page, buttonTestId, countTestId, count)
+}
+
+async function dispatchHydrationIntent(
+ page: Page,
+ buttonTestId: string,
+ eventName: string,
+) {
+ await page.getByTestId(buttonTestId).evaluate((element, eventName) => {
+ const marker = element.closest('[data-ts-hydrate-id]')
+
+ if (!marker) {
+ throw new Error('Expected Hydrate marker to exist')
+ }
+
+ marker.dispatchEvent(
+ new Event(eventName, { bubbles: true, cancelable: true }),
+ )
+ }, eventName)
+}
+
+async function expectRouteToStayUnhydrated(
+ page: Page,
+ buttonTestId: string,
+ duration = 250,
+) {
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'false',
+ )
+ await page.waitForTimeout(duration)
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'false',
+ )
+}
+
+async function scrollToBoundary(page: Page, buttonTestId: string) {
+ const button = page.getByTestId(buttonTestId)
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await button.evaluate((element) => {
+ element.scrollIntoView({ block: 'center', inline: 'nearest' })
+ })
+
+ await page.waitForTimeout(100)
+ const isVisible = await button.evaluate((element) => {
+ const rect = element.getBoundingClientRect()
+ return rect.bottom > 0 && rect.top < window.innerHeight
+ })
+
+ if (isVisible) return
+ }
+
+ await expect(button).toBeInViewport()
+}
+
+async function expectCssProperty(
+ page: Page,
+ testId: string,
+ property: string,
+ value: string,
+) {
+ await expect
+ .poll(() =>
+ page.getByTestId(testId).evaluate((element, propertyName) => {
+ return getComputedStyle(element).getPropertyValue(propertyName)
+ }, property),
+ )
+ .toBe(value)
+}
+
+function htmlContainsText(html: string, text: string) {
+ const pattern = text.split(' ').join('(?:\\s|)+')
+ expect(html).toMatch(new RegExp(pattern))
+}
+
+async function waitForComponentsServerHtmlText(page: Page, text: string) {
+ await expect
+ .poll(
+ async () => {
+ const response = await page.request.get('/components')
+ const html = await response.text()
+
+ if (!response.ok()) {
+ return `${response.status()} ${html.slice(0, 240)}`
+ }
+
+ try {
+ htmlContainsText(html, text)
+ return 'ready'
+ } catch {
+ return html.slice(0, 240)
+ }
+ },
+ { timeout: 20_000 },
+ )
+ .toBe('ready')
+}
+
+function getModulePreloadHrefs(html: string) {
+ return Array.from(html.matchAll(/ ]*>/g), (match) => match[0])
+ .filter((tag) => /\brel="modulepreload"/.test(tag))
+ .map((tag) => tag.match(/\bhref="([^"]+)"/)?.[1])
+ .filter((href): href is string => !!href)
+}
+
+async function modulePreloadContentsContain(
+ request: APIRequestContext,
+ hrefs: Array,
+ marker: string,
+) {
+ for (const href of hrefs) {
+ const response = await request.get(href)
+ if (!response.ok()) continue
+
+ const text = await response.text()
+ if (text.includes(marker)) return true
+ }
+
+ return false
+}
+
+async function resourceContentsContain(
+ page: Page,
+ request: APIRequestContext,
+ marker: string,
+ filter: (url: string) => boolean,
+) {
+ const resourceUrls = await page.evaluate(() =>
+ performance.getEntriesByType('resource').map((entry) => entry.name),
+ )
+
+ return modulePreloadContentsContain(
+ request,
+ resourceUrls.filter(filter),
+ marker,
+ )
+}
+
+async function documentModulePreloadHrefs(page: Page) {
+ return page.evaluate(() =>
+ Array.from(
+ document.querySelectorAll('link[rel~="modulepreload"]'),
+ (link) => link.href,
+ ),
+ )
+}
+
+function isHydrateBoundaryResource(url: string) {
+ return (
+ url.includes('/assets/components-') || url.includes('/static/js/async/')
+ )
+}
+
+function isClientJavaScriptResource(url: string) {
+ return (
+ url.includes('/assets/') ||
+ url.includes('/static/js/') ||
+ url.includes('/static/js/async/')
+ )
+}
+
+async function expectClientRouterReady(page: Page) {
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ Boolean(
+ (
+ globalThis as typeof globalThis & {
+ __TSR_ROUTER__?: unknown
+ }
+ ).__TSR_ROUTER__,
+ ),
+ ),
+ )
+ .toBe(true)
+}
+
+async function gotoEnhanced(page: Page, search = '') {
+ await page.goto(`/enhanced${search}`)
+ await expectClientRouterReady(page)
+}
+
+test.describe('Hydrate HMR', () => {
+ test.skip(!isDev, 'HMR regression coverage runs against the dev server only')
+
+ test.beforeAll(async () => {
+ await componentsRouteEditor.capturePromise
+ })
+
+ test.afterEach(async () => {
+ await componentsRouteEditor.capturePromise
+ await componentsRouteEditor.restoreFiles()
+ })
+
+ test.afterAll(async () => {
+ await componentsRouteEditor.capturePromise
+ await componentsRouteEditor.restoreFiles()
+ })
+
+ test('updates deferred child chunks after the parent route is edited', async ({
+ page,
+ }) => {
+ const pageErrors: Array = []
+ page.on('pageerror', (error) => {
+ pageErrors.push(error.message)
+ })
+
+ await page.goto('/components')
+ await expectClientRouterReady(page)
+ await expectRouteToStayUnhydrated(page, 'component-visible-button')
+
+ await componentsRouteEditor.replaceText(
+ 'componentsRoute',
+ interactiveBoxLabelSource,
+ interactiveBoxHmrLabelSource,
+ )
+
+ await waitForComponentsServerHtmlText(page, 'hmr visible')
+ if (isVite) {
+ await waitForVisibleHydrateVirtualModule(page, 'hmr ')
+ }
+
+ await page.goto('/components')
+ await expectClientRouterReady(page)
+ await expectRouteToStayUnhydrated(page, 'component-visible-button')
+ await scrollToBoundary(page, 'component-visible-button')
+ await hmrExpect(page.getByTestId('component-visible-button')).toContainText(
+ 'hmr visible',
+ )
+ await clickAndExpectCount(
+ page,
+ 'component-visible-button',
+ 'component-visible-count',
+ '1',
+ )
+ expect(pageErrors).toEqual([])
+ })
+})
+
+test.describe('component-level Hydrate runtime strategies', () => {
+ test.skip(
+ isDev,
+ 'production hydration coverage runs against the preview server',
+ )
+
+ test('renders SSR HTML and hydrates each runtime when appropriately', async ({
+ page,
+ request,
+ }) => {
+ await page.goto('/components')
+
+ await expect(page.getByTestId('component-heading')).toHaveText(
+ 'Component Deferred Hydration',
+ )
+
+ await clickAndExpectCount(
+ page,
+ 'component-load-button',
+ 'component-load-count',
+ '1',
+ )
+ await clickAndExpectCount(
+ page,
+ 'component-idle-button',
+ 'component-idle-count',
+ '1',
+ )
+ await expect(
+ resourceContentsContain(page, request, 'component-visible', (url) =>
+ isHydrateBoundaryResource(url),
+ ),
+ ).resolves.toBe(false)
+ await expectRouteToStayUnhydrated(page, 'component-visible-button')
+ await scrollToBoundary(page, 'component-visible-button')
+ await clickAndExpectCount(
+ page,
+ 'component-visible-button',
+ 'component-visible-count',
+ '1',
+ )
+ await expect
+ .poll(() =>
+ resourceContentsContain(page, request, 'component-visible', (url) =>
+ isHydrateBoundaryResource(url),
+ ),
+ )
+ .toBe(true)
+ await clickAndExpectCount(
+ page,
+ 'component-media-button',
+ 'component-media-count',
+ '1',
+ )
+ await hoverIntentAndExpectCount(
+ page,
+ 'component-interaction-button',
+ 'component-interaction-count',
+ '1',
+ )
+ await expect(page.getByTestId('component-on-hydrated-count')).toHaveText(
+ '0',
+ )
+ await expectRouteToStayUnhydrated(page, 'component-custom-single-button')
+ await page.getByTestId('component-custom-single-button').hover()
+ await expectRouteToStayUnhydrated(page, 'component-custom-single-button')
+ await page.getByTestId('component-custom-single-button').click()
+ await expectRouteToStayUnhydrated(page, 'component-custom-single-button')
+ await dispatchHydrationIntent(
+ page,
+ 'component-custom-single-button',
+ 'dblclick',
+ )
+ await expect(
+ page.getByTestId('component-custom-single-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expect(page.getByTestId('component-on-hydrated-count')).toHaveText(
+ '1',
+ )
+ await clickAndExpectCount(
+ page,
+ 'component-custom-single-button',
+ 'component-custom-single-count',
+ '1',
+ )
+ await expect(page.getByTestId('component-on-hydrated-count')).toHaveText(
+ '1',
+ )
+ await expectRouteToStayUnhydrated(page, 'component-custom-multi-button')
+ await dispatchHydrationIntent(
+ page,
+ 'component-custom-multi-button',
+ 'contextmenu',
+ )
+ await clickAndExpectCount(
+ page,
+ 'component-custom-multi-button',
+ 'component-custom-multi-count',
+ '1',
+ )
+ await expectRouteToStayUnhydrated(page, 'component-condition-button')
+ await page.getByTestId('component-enable-condition').click()
+ await clickAndExpectCount(
+ page,
+ 'component-condition-button',
+ 'component-condition-count',
+ '1',
+ )
+ await expectRouteToStayUnhydrated(page, 'component-click-replay-button')
+ await page.getByTestId('component-click-replay-button').click()
+ await expect(
+ page.getByTestId('component-click-replay-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expect(page.getByTestId('component-click-replay-count')).toHaveText(
+ '1',
+ )
+ await expectRouteToStayUnhydrated(page, 'component-prefetch-button')
+ await expect(
+ resourceContentsContain(page, request, 'component-prefetch', (url) =>
+ isHydrateBoundaryResource(url),
+ ),
+ ).resolves.toBe(false)
+ await page.mouse.move(0, 0)
+ await page.getByTestId('component-prefetch-button').hover()
+ await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute(
+ 'data-hydrated',
+ 'false',
+ )
+ await expect
+ .poll(() =>
+ resourceContentsContain(page, request, 'component-prefetch', (url) =>
+ isHydrateBoundaryResource(url),
+ ),
+ )
+ .toBe(true)
+ await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute(
+ 'data-hydrated',
+ 'false',
+ )
+ await page.getByTestId('component-prefetch-button').click()
+ await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await expect(page.getByTestId('component-prefetch-count')).toHaveText('1')
+ await hoverIntentAndExpectCount(
+ page,
+ 'component-nested-child-button',
+ 'component-nested-child-count',
+ '1',
+ )
+
+ await page.getByTestId('component-never-button').click()
+ await expect(page.getByTestId('component-never-count')).toHaveText('0')
+ })
+
+ test('replays click after another interaction boundary hydrates first', async ({
+ page,
+ }) => {
+ await page.goto('/components')
+ await expectClientRouterReady(page)
+
+ await scrollToBoundary(page, 'component-custom-multi-button')
+ await expectRouteToStayUnhydrated(page, 'component-custom-multi-button')
+ await page.getByTestId('component-custom-multi-button').click({
+ button: 'right',
+ })
+ await expect(
+ page.getByTestId('component-custom-multi-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+
+ await scrollToBoundary(page, 'component-click-replay-button')
+ await expectRouteToStayUnhydrated(page, 'component-click-replay-button')
+ await page.getByTestId('component-click-replay-button').click()
+ await expect(
+ page.getByTestId('component-click-replay-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expect(page.getByTestId('component-click-replay-count')).toHaveText(
+ '1',
+ )
+ })
+
+ test('shows fallback during a client-only mount while the child suspends', async ({
+ page,
+ }) => {
+ await page.goto('/components')
+ await expect(page.getByTestId('component-load-button')).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await page.getByTestId('component-show-client-fallback').click()
+
+ await expect(page.getByTestId('component-client-fallback')).toHaveText(
+ 'client fallback',
+ )
+ await expect(page.getByTestId('component-fallback-child')).toHaveText(
+ 'fallback child',
+ )
+ await expect(page.getByTestId('component-client-fallback')).toHaveCount(0)
+ })
+
+ test('preserves scroll position after a force reload on a visible boundary', async ({
+ page,
+ }) => {
+ await page.goto('/scroll-restoration')
+ await expectClientRouterReady(page)
+ await page
+ .getByTestId('scroll-restoration-skeleton')
+ .scrollIntoViewIfNeeded()
+ await expect(page.getByTestId('scroll-restoration-widget')).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await page.evaluate(() => {
+ window.scrollTo(0, document.documentElement.scrollHeight)
+ })
+
+ const beforeReloadDistanceFromBottom = await page.evaluate(
+ () =>
+ document.documentElement.scrollHeight -
+ window.innerHeight -
+ window.scrollY,
+ )
+ expect(beforeReloadDistanceFromBottom).toBeLessThan(5)
+
+ const client = await page.context().newCDPSession(page)
+ const reloaded = page.waitForEvent('load')
+ await client.send('Page.reload', { ignoreCache: true })
+ await reloaded
+
+ await expectClientRouterReady(page)
+ await expect(page.getByTestId('scroll-restoration-widget')).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ document.documentElement.scrollHeight -
+ window.innerHeight -
+ window.scrollY,
+ ),
+ )
+ .toBeLessThan(20)
+ })
+})
+
+test.describe('enhanced Hydrate API combinations', () => {
+ test.skip(
+ isDev,
+ 'production hydration coverage runs against the preview server',
+ )
+
+ test('server renders dynamic markers without evaluating client-only callbacks or prefetch functions', async ({
+ request,
+ }) => {
+ const response = await request.get('/enhanced?dynamic=interaction')
+ const html = await response.text()
+
+ expect(response.ok()).toBe(true)
+ htmlContainsText(html, 'Enhanced Hydrate APIs')
+ htmlContainsText(html, 'conditional dynamic')
+ expect(html).toContain('data-ts-hydrate-when="dynamic"')
+ expect(html).not.toContain('missing-element')
+ })
+
+ test('dynamic when functions hydrate and replay interaction events', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page, '?dynamic=interaction')
+ await expect(page.getByTestId('enhanced-heading')).toHaveText(
+ 'Enhanced Hydrate APIs',
+ )
+ await clickIntentAndExpectReplayedCount(
+ page,
+ 'enhanced-dynamic-interaction-button',
+ 'enhanced-dynamic-interaction-count',
+ '1',
+ )
+
+ await expectRouteToStayUnhydrated(
+ page,
+ 'enhanced-dynamic-conditional-button',
+ )
+ await page.getByTestId('enhanced-dynamic-conditional-button').hover()
+ await expectRouteToStayUnhydrated(
+ page,
+ 'enhanced-dynamic-conditional-button',
+ )
+ await clickIntentAndExpectReplayedCount(
+ page,
+ 'enhanced-dynamic-conditional-button',
+ 'enhanced-dynamic-conditional-count',
+ '1',
+ )
+ })
+
+ test('procedural prefetch can block hydration, preload the split chunk, and prepare query-like work', async ({
+ page,
+ request,
+ }) => {
+ await gotoEnhanced(page)
+ await expect(
+ resourceContentsContain(
+ page,
+ request,
+ 'enhanced-procedural-split-child',
+ isClientJavaScriptResource,
+ ),
+ ).resolves.toBe(false)
+
+ await expectRouteToStayUnhydrated(page, 'enhanced-procedural-split-button')
+ await expect(page.getByTestId('enhanced-split-wait-reason')).toHaveText(
+ 'waiting',
+ )
+ await dispatchHydrationIntent(
+ page,
+ 'enhanced-procedural-split-button',
+ 'pointerover',
+ )
+ await expect(page.getByTestId('enhanced-split-query')).toHaveText('element')
+ await expect(page.getByTestId('enhanced-split-wait-reason')).toHaveText(
+ 'prefetch',
+ )
+ await expect(page.getByTestId('enhanced-split-preload')).toHaveText('done')
+ await expect
+ .poll(() =>
+ resourceContentsContain(
+ page,
+ request,
+ 'enhanced-procedural-split-child',
+ isClientJavaScriptResource,
+ ),
+ )
+ .toBe(true)
+
+ await page.getByTestId('enhanced-procedural-split-button').click()
+ await expect(
+ page.getByTestId('enhanced-procedural-split-button'),
+ ).toHaveAttribute('data-hydrated', 'false')
+ await expect(
+ page.getByTestId('enhanced-procedural-split-count'),
+ ).toHaveText('0')
+ await page.getByTestId('enhanced-release-split-prefetch').click()
+ await expect(
+ page.getByTestId('enhanced-procedural-split-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expect(
+ page.getByTestId('enhanced-procedural-split-count'),
+ ).toHaveText('1')
+ await expect(page.getByTestId('enhanced-split-query')).toHaveText('done')
+ })
+
+ test('function prefetch supports fire-and-forget work and waitFor hydrate-first resolution', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page)
+
+ await expect(page.getByTestId('enhanced-fire-wait-reason')).toHaveText(
+ 'waiting',
+ )
+ await dispatchHydrationIntent(
+ page,
+ 'enhanced-fire-and-forget-button',
+ 'pointerover',
+ )
+ await expect(page.getByTestId('enhanced-fire-wait-reason')).toHaveText(
+ 'prefetch',
+ )
+ await expect(page.getByTestId('enhanced-fire-status')).toHaveText('started')
+ await clickIntentAndExpectReplayedCount(
+ page,
+ 'enhanced-fire-and-forget-button',
+ 'enhanced-fire-and-forget-count',
+ '1',
+ )
+ await expect(page.getByTestId('enhanced-fire-query')).toHaveText('idle')
+ await page.getByTestId('enhanced-release-fire-prefetch').click()
+ await expect(page.getByTestId('enhanced-fire-query')).toHaveText('done')
+
+ await clickIntentAndExpectReplayedCount(
+ page,
+ 'enhanced-hydrate-first-button',
+ 'enhanced-hydrate-first-count',
+ '1',
+ )
+ await expect(page.getByTestId('enhanced-hydrate-first-reason')).toHaveText(
+ 'hydrate',
+ )
+ })
+
+ test('split=false procedural prefetch blocks hydration without requiring a child preload chunk', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page)
+
+ await expectRouteToStayUnhydrated(page, 'enhanced-runtime-only-button')
+ await expect(page.getByTestId('enhanced-runtime-wait-reason')).toHaveText(
+ 'waiting',
+ )
+ await dispatchHydrationIntent(
+ page,
+ 'enhanced-runtime-only-button',
+ 'pointerover',
+ )
+ await expect(page.getByTestId('enhanced-runtime-wait-reason')).toHaveText(
+ 'prefetch',
+ )
+ await page.getByTestId('enhanced-runtime-only-button').click()
+ await expect(
+ page.getByTestId('enhanced-runtime-only-button'),
+ ).toHaveAttribute('data-hydrated', 'false')
+ await expect(page.getByTestId('enhanced-runtime-only-count')).toHaveText(
+ '0',
+ )
+ await page.getByTestId('enhanced-release-runtime-prefetch').click()
+ await expect(page.getByTestId('enhanced-runtime-status')).toHaveText(
+ 'ready',
+ )
+ await expect(
+ page.getByTestId('enhanced-runtime-only-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expect(page.getByTestId('enhanced-runtime-only-count')).toHaveText(
+ '1',
+ )
+ })
+
+ test('procedural prefetch aborts waiters and signals when boundaries unmount', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page)
+
+ await expectRouteToStayUnhydrated(page, 'enhanced-wait-abort-button')
+ await expect(page.getByTestId('enhanced-wait-abort-reason')).toHaveText(
+ 'waiting',
+ )
+ await page.getByTestId('enhanced-hide-wait-abort').click()
+ await expect(page.getByTestId('enhanced-wait-abort-reason')).toHaveText(
+ 'abort',
+ )
+
+ await expect(page.getByTestId('enhanced-abort-status')).toHaveText(
+ 'listening',
+ )
+ await page.getByTestId('enhanced-hide-abort').click()
+ await expect(page.getByTestId('enhanced-abort-status')).toHaveText(
+ 'aborted',
+ )
+ })
+
+ test('nested dynamic interaction boundaries delegate through outer boundaries', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page)
+
+ await clickToHydrateThenClickAndExpectIncrement(
+ page,
+ 'enhanced-dynamic-nested-button',
+ 'enhanced-dynamic-nested-count',
+ )
+ await clickToHydrateThenClickAndExpectIncrement(
+ page,
+ 'enhanced-cross-file-nested-button',
+ 'enhanced-cross-file-nested-count',
+ )
+ })
+})
+
+test.describe('Hydrate CSS delivery', () => {
+ test.skip(
+ isDev,
+ 'production hydration coverage runs against the preview server',
+ )
+
+ test('ships CSS for deferred, never, shared, and nested boundaries without JavaScript', async ({
+ browser,
+ request,
+ }) => {
+ const response = await request.get('/css')
+ const html = await response.text()
+
+ htmlContainsText(html, 'CSS Deferred Hydration')
+ htmlContainsText(html, 'Outer CSS')
+ htmlContainsText(html, 'Deferred CSS')
+ htmlContainsText(html, 'Never CSS')
+ htmlContainsText(html, 'Nested CSS')
+
+ const context = await browser.newContext({ javaScriptEnabled: false })
+ const page = await context.newPage()
+
+ try {
+ await page.goto('/css')
+
+ await expect(page.getByTestId('css-heading')).toHaveText(
+ 'CSS Deferred Hydration',
+ )
+ await expect(page.getByTestId('css-deferred')).toHaveText('Deferred CSS')
+ await expect(page.getByTestId('css-never')).toHaveText('Never CSS')
+ await expect(page.getByTestId('css-nested')).toHaveText('Nested CSS')
+
+ await expectCssProperty(page, 'css-outer', 'color', 'rgb(12, 34, 56)')
+ await expectCssProperty(
+ page,
+ 'css-deferred',
+ 'background-color',
+ 'rgb(23, 45, 67)',
+ )
+ await expectCssProperty(page, 'css-never', 'color', 'rgb(45, 67, 89)')
+ await expectCssProperty(
+ page,
+ 'css-shared-outer',
+ 'border-top-color',
+ 'rgb(98, 76, 54)',
+ )
+ await expectCssProperty(
+ page,
+ 'css-deferred',
+ 'border-top-color',
+ 'rgb(98, 76, 54)',
+ )
+ await expectCssProperty(
+ page,
+ 'css-nested',
+ 'border-left-color',
+ 'rgb(67, 89, 123)',
+ )
+ await expectCssProperty(page, 'css-nested', 'border-left-width', '5px')
+ } finally {
+ await context.close()
+ }
+ })
+
+ test('renders deferred content and omits never content after client-side navigation', async ({
+ page,
+ }) => {
+ await page.goto('/')
+ await expectClientRouterReady(page)
+ await page.getByRole('link', { name: 'CSS', exact: true }).click()
+ await expect(page).toHaveURL(/\/css$/)
+
+ await expect(page.getByTestId('css-heading')).toHaveText(
+ 'CSS Deferred Hydration',
+ )
+ await expect(page.getByTestId('css-deferred')).toHaveText('Deferred CSS')
+ await expect(page.getByTestId('css-never')).toHaveCount(0)
+ await expect(page.getByTestId('css-nested')).toHaveCount(0)
+
+ await expectCssProperty(
+ page,
+ 'css-deferred',
+ 'background-color',
+ 'rgb(23, 45, 67)',
+ )
+ })
+})
+
+test.describe('imported Hydrate boundaries', () => {
+ test.skip(
+ isDev,
+ 'production hydration coverage runs against the preview server',
+ )
+
+ test('does not emit filtered shared Hydrate child JS on the initial document', async ({
+ request,
+ }) => {
+ const response = await request.get('/imported')
+ const html = await response.text()
+
+ htmlContainsText(html, 'Imported Hydrate')
+ htmlContainsText(html, 'Imported Hydrate Child')
+
+ await expect(
+ modulePreloadContentsContain(
+ request,
+ getModulePreloadHrefs(html),
+ 'imported-hydrate-child',
+ ),
+ ).resolves.toBe(false)
+ })
+
+ test('does not preload Hydrate child chunks before client navigation', async ({
+ page,
+ request,
+ }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('home-heading')).toHaveText(
+ 'Deferred Hydration',
+ )
+ await expectClientRouterReady(page)
+
+ const link = page.getByRole('link', { name: 'imported Hydrate' })
+ await page.mouse.move(0, 0)
+ await link.hover()
+ await link.focus()
+
+ await expect(
+ modulePreloadContentsContain(
+ request,
+ await documentModulePreloadHrefs(page),
+ 'imported-hydrate-child',
+ ),
+ ).resolves.toBe(false)
+ await expect(
+ resourceContentsContain(page, request, 'imported-hydrate-child', (url) =>
+ isClientJavaScriptResource(url),
+ ),
+ ).resolves.toBe(false)
+
+ await page.getByRole('link', { name: 'imported Hydrate' }).click()
+ await expect(page).toHaveURL(/\/imported$/)
+ await expect(page.getByTestId('imported-hydrate-fallback')).toHaveCount(0)
+ await expect(page.getByTestId('imported-hydrate-child')).toContainText(
+ 'Imported Hydrate Child',
+ )
+ await clickAndExpectCount(
+ page,
+ 'imported-hydrate-child',
+ 'imported-hydrate-count',
+ '1',
+ )
+ })
+
+ test('hydrates imported child boundaries from the initial document on interaction', async ({
+ page,
+ request,
+ }) => {
+ await page.goto('/imported')
+ await expect(page.getByTestId('imported-heading')).toHaveText(
+ 'Imported Hydrate',
+ )
+ await expectClientRouterReady(page)
+ await expect(page.getByTestId('imported-hydrate-fallback')).toHaveCount(0)
+ await expectRouteToStayUnhydrated(page, 'imported-hydrate-child')
+ await expect(
+ resourceContentsContain(page, request, 'imported-hydrate-child', (url) =>
+ isClientJavaScriptResource(url),
+ ),
+ ).resolves.toBe(false)
+
+ await page.getByTestId('imported-hydrate-child').click()
+ await expect(page.getByTestId('imported-hydrate-child')).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await expect(page.getByTestId('imported-hydrate-count')).toHaveText('1')
+ await expect
+ .poll(() =>
+ resourceContentsContain(
+ page,
+ request,
+ 'imported-hydrate-child',
+ isClientJavaScriptResource,
+ ),
+ )
+ .toBe(true)
+ })
+})
diff --git a/e2e/react-start/deferred-hydration/tests/setup/global.setup.ts b/e2e/react-start/deferred-hydration/tests/setup/global.setup.ts
new file mode 100644
index 0000000000..1117664a14
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/tests/setup/global.setup.ts
@@ -0,0 +1,38 @@
+import {
+ e2eStartDummyServer,
+ getTestServerPort,
+ preOptimizeDevServer,
+ waitForServer,
+} from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+function getE2EPortKey() {
+ const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite'
+ return process.env.E2E_PORT_KEY ?? `${packageJson.name}-${toolchain}`
+}
+
+export default async function setup() {
+ if (process.env.MODE !== 'dev') return
+
+ const e2ePortKey = getE2EPortKey()
+
+ await e2eStartDummyServer(e2ePortKey)
+
+ const port = await getTestServerPort(e2ePortKey)
+ const baseURL = `http://localhost:${port}`
+
+ await waitForServer(baseURL)
+ await preOptimizeDevServer({
+ baseURL,
+ readyTestId: 'home-heading',
+ warmup: async (page) => {
+ await page.goto(`${baseURL}/components`, {
+ waitUntil: 'domcontentloaded',
+ })
+ await page.getByTestId('component-heading').waitFor({
+ state: 'visible',
+ })
+ await page.waitForLoadState('networkidle')
+ },
+ })
+}
diff --git a/e2e/react-start/deferred-hydration/tests/setup/global.teardown.ts b/e2e/react-start/deferred-hydration/tests/setup/global.teardown.ts
new file mode 100644
index 0000000000..df79f50e82
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/tests/setup/global.teardown.ts
@@ -0,0 +1,13 @@
+import { e2eStopDummyServer } from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+function getE2EPortKey() {
+ const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite'
+ return process.env.E2E_PORT_KEY ?? `${packageJson.name}-${toolchain}`
+}
+
+export default async function teardown() {
+ if (process.env.MODE !== 'dev') return
+
+ await e2eStopDummyServer(getE2EPortKey())
+}
diff --git a/e2e/react-start/deferred-hydration/tsconfig.json b/e2e/react-start/deferred-hydration/tsconfig.json
new file mode 100644
index 0000000000..cef9369516
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "target": "ES2022",
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+ "noEmit": true
+ }
+}
diff --git a/e2e/react-start/deferred-hydration/vite.config.ts b/e2e/react-start/deferred-hydration/vite.config.ts
new file mode 100644
index 0000000000..1289bc0be7
--- /dev/null
+++ b/e2e/react-start/deferred-hydration/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import viteReact from '@vitejs/plugin-react'
+
+const outDir = process.env.E2E_DIST_DIR ?? 'dist-vite-ssr'
+
+export default defineConfig({
+ resolve: { tsconfigPaths: true },
+ build: {
+ outDir,
+ },
+ server: { port: 3000 },
+ plugins: [tanstackStart(), viteReact()],
+})
diff --git a/e2e/react-start/hmr/tests/app.spec.ts b/e2e/react-start/hmr/tests/app.spec.ts
index b3ac8b6a1a..95914f6270 100644
--- a/e2e/react-start/hmr/tests/app.spec.ts
+++ b/e2e/react-start/hmr/tests/app.spec.ts
@@ -1,7 +1,10 @@
-import { readFile, writeFile } from 'node:fs/promises'
-import path from 'node:path'
import { expect } from '@playwright/test'
-import { test } from '@tanstack/router-e2e-utils'
+import {
+ createHmrFileEditor,
+ replaceAll,
+ test,
+} from '@tanstack/router-e2e-utils'
+import path from 'node:path'
import type { Page } from '@playwright/test'
@@ -115,14 +118,6 @@ const routeFileRestoreChecks: Partial<
},
}
-// Capture original file contents once so beforeEach can restore them
-const originalContents: Partial> = {}
-const routeKeysPendingRestoreCheck = new Set()
-
-function replaceAll(source: string, from: string, to: string) {
- return source.split(from).join(to)
-}
-
function normalizeRouteSource(routeFileKey: RouteFileKey, source: string) {
let next = source
@@ -254,59 +249,14 @@ function normalizeRouteSource(routeFileKey: RouteFileKey, source: string) {
return next
}
-async function captureOriginals() {
- for (const [key, filePath] of Object.entries(routeFiles) as Array<
- [RouteFileKey, string]
- >) {
- const current = await readFile(filePath, 'utf8')
- const normalized = normalizeRouteSource(key, current)
- if (normalized !== current) {
- await writeFile(filePath, normalized)
- routeKeysPendingRestoreCheck.add(key)
- }
- originalContents[key] = normalized
- }
-}
-
-const capturePromise = captureOriginals()
-
-async function restoreRouteFiles(
- forceRouteFileKeys: Iterable = [],
-) {
- const forceRestoreKeys = new Set(forceRouteFileKeys)
- const restoredRouteKeys: Array = []
-
- for (const [key, filePath] of Object.entries(routeFiles) as Array<
- [RouteFileKey, string]
- >) {
- const content = originalContents[key]
- if (content === undefined) continue
- const current = await readFile(filePath, 'utf8')
- // Re-emit pending restores in case the watcher coalesced the previous
- // restore write and the dev server is still serving stale route options.
- if (current !== content || forceRestoreKeys.has(key)) {
- await writeFile(filePath, content)
- restoredRouteKeys.push(key)
- }
- }
-
- return restoredRouteKeys
-}
-
-async function replaceRouteText(
- routeFileKey: RouteFileKey,
- from: string,
- to: string,
-) {
- const filePath = routeFiles[routeFileKey]
- const source = await readFile(filePath, 'utf8')
-
- if (!source.includes(from)) {
- throw new Error(`Expected route file to include ${JSON.stringify(from)}`)
- }
-
- await writeFile(filePath, source.replace(from, to))
-}
+const routeFileEditor = createHmrFileEditor({
+ files: routeFiles,
+ normalizeSource: normalizeRouteSource,
+})
+const capturePromise = routeFileEditor.capturePromise
+const routeKeysPendingRestoreCheck = routeFileEditor.pendingRestoreKeys
+const restoreRouteFiles = routeFileEditor.restoreFiles
+const replaceRouteText = routeFileEditor.replaceText
async function replaceRouteTextAndWait(
page: Page,
@@ -315,7 +265,7 @@ async function replaceRouteTextAndWait(
to: string,
assertion: () => Promise,
) {
- await replaceRouteText(routeFileKey, from, to)
+ await routeFileEditor.replaceText(routeFileKey, from, to)
await assertion()
}
@@ -326,17 +276,7 @@ async function rewriteRouteFile(
assertion: () => Promise,
options: { allowNoop?: boolean } = {},
) {
- const filePath = routeFiles[routeFileKey]
- const source = await readFile(filePath, 'utf8')
- const updated = updater(source)
-
- if (updated === source && !options.allowNoop) {
- throw new Error(`Expected ${filePath} to change during rewrite`)
- }
-
- // Even a no-op write is useful for tests that need to force the dev server
- // to reconcile a stale in-memory module with the current file contents.
- await writeFile(filePath, updated)
+ await routeFileEditor.rewriteFile(routeFileKey, updater, options)
await assertion()
}
diff --git a/e2e/react-start/rsc-deferred-hydration/.gitignore b/e2e/react-start/rsc-deferred-hydration/.gitignore
new file mode 100644
index 0000000000..cc99170fbb
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/.gitignore
@@ -0,0 +1,5 @@
+dist
+node_modules
+port*.txt
+test-results
+playwright-report
diff --git a/e2e/react-start/rsc-deferred-hydration/package.json b/e2e/react-start/rsc-deferred-hydration/package.json
new file mode 100644
index 0000000000..7944b81552
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "tanstack-react-start-e2e-rsc-deferred-hydration",
+ "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": "node server.js",
+ "test:e2e": "pnpm test:e2e:dev && pnpm test:e2e:prod",
+ "test:e2e:dev": "MODE=dev playwright test --project=chromium",
+ "test:e2e:prod": "MODE=prod playwright test --project=chromium"
+ },
+ "nx": {
+ "metadata": {
+ "playwrightModes": [
+ {
+ "toolchain": "vite",
+ "mode": "ssr",
+ "shards": 1
+ }
+ ]
+ }
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:^",
+ "@tanstack/react-start": "workspace:^",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "vite": "^8.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@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",
+ "@vitejs/plugin-rsc": "^0.5.20",
+ "srvx": "^0.11.9",
+ "typescript": "^6.0.2"
+ }
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/playwright.config.ts b/e2e/react-start/rsc-deferred-hydration/playwright.config.ts
new file mode 100644
index 0000000000..10f41cd911
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/playwright.config.ts
@@ -0,0 +1,35 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+const mode = process.env.MODE ?? 'prod'
+const isDev = mode === 'dev'
+
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+ reporter: [['line']],
+ globalSetup: './tests/setup/global.setup.ts',
+ globalTeardown: './tests/setup/global.teardown.ts',
+ use: { baseURL },
+ webServer: {
+ command: isDev ? 'pnpm dev:e2e' : 'pnpm build && pnpm start',
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ env: {
+ VITE_NODE_ENV: 'test',
+ NODE_ENV: isDev ? 'development' : 'production',
+ PORT: String(PORT),
+ VITE_SERVER_PORT: String(PORT),
+ },
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/react-start/rsc-deferred-hydration/server.js b/e2e/react-start/rsc-deferred-hydration/server.js
new file mode 100644
index 0000000000..6365c568d4
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/server.js
@@ -0,0 +1,47 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { spawn } from 'node:child_process'
+import { pathToFileURL } from 'node:url'
+
+const distDir = process.env.E2E_DIST_DIR || 'dist'
+
+function resolveDistClientDir() {
+ return path.resolve(distDir, 'client')
+}
+
+function resolveDistServerEntryPath() {
+ const serverJsPath = path.resolve(distDir, 'server', 'server.js')
+ if (fs.existsSync(serverJsPath)) return serverJsPath
+
+ const indexJsPath = path.resolve(distDir, 'server', 'index.js')
+ if (fs.existsSync(indexJsPath)) return indexJsPath
+
+ return serverJsPath
+}
+
+export function start() {
+ const child = spawn(
+ 'srvx',
+ ['--prod', '-s', resolveDistClientDir(), resolveDistServerEntryPath()],
+ {
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+ },
+ )
+
+ child.on('exit', (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal)
+ return
+ }
+
+ process.exit(code ?? 0)
+ })
+}
+
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(process.argv[1]).href
+) {
+ start()
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.module.css b/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.module.css
new file mode 100644
index 0000000000..d80e17aebe
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.module.css
@@ -0,0 +1,42 @@
+.cssIsland {
+ border-color: rgba(16, 185, 129, 0.38);
+ background:
+ linear-gradient(
+ 135deg,
+ rgba(236, 253, 245, 0.96),
+ rgba(255, 255, 255, 0.9)
+ ),
+ repeating-linear-gradient(
+ 45deg,
+ rgba(16, 185, 129, 0.12) 0 8px,
+ transparent 8px 16px
+ );
+ color: rgb(6, 78, 59);
+}
+
+.cssIsland h2 {
+ color: rgb(6, 95, 70);
+}
+
+.cssMarker {
+ width: fit-content;
+ padding: 0.45rem 0.7rem;
+ border-radius: 999px;
+ font-weight: 900;
+ transition:
+ background 180ms ease,
+ color 180ms ease,
+ box-shadow 180ms ease;
+}
+
+.cssMarkerPending {
+ background: rgb(252, 231, 243);
+ color: rgb(157, 23, 77);
+ box-shadow: 0 8px 26px rgba(219, 39, 119, 0.18);
+}
+
+.cssMarkerHydrated {
+ background: rgb(209, 250, 229);
+ color: rgb(6, 95, 70);
+ box-shadow: 0 8px 26px rgba(5, 150, 105, 0.18);
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.tsx b/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.tsx
new file mode 100644
index 0000000000..7f023bd10c
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+import * as React from 'react'
+import { Hydrate } from '@tanstack/react-start'
+import { media } from '@tanstack/react-start/hydration'
+import { DeferredHydrateIsland } from './DeferredHydrateIsland'
+import styles from './CssHydrateIsland.module.css'
+
+function CssHydratePanel() {
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+
+
CSS module Hydrate island
+
CSS modules survive the RSC to client boundary
+
+ {hydrated
+ ? 'Hydrated module-styled client content'
+ : 'Pending module-styled client content'}
+
+
+
+ )
+}
+
+export function CssHydrateIsland() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/components/DeferredHydrateIsland.tsx b/e2e/react-start/rsc-deferred-hydration/src/components/DeferredHydrateIsland.tsx
new file mode 100644
index 0000000000..0a716fe39c
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/components/DeferredHydrateIsland.tsx
@@ -0,0 +1,67 @@
+'use client'
+
+import * as React from 'react'
+import { Hydrate } from '@tanstack/react-start'
+import { interaction, media, visible } from '@tanstack/react-start/hydration'
+
+type Strategy = 'interaction' | 'visible' | 'media'
+
+const strategyCopy: Record = {
+ interaction: 'Hydrates after pointer or focus intent reaches this island.',
+ visible: 'Hydrates only after the island scrolls into the viewport.',
+ media: 'Hydrates immediately when the matching media query is true.',
+}
+
+function getStrategy(strategy: Strategy) {
+ if (strategy === 'interaction') return interaction()
+ if (strategy === 'visible') return visible({ rootMargin: '0px' })
+ return media('(min-width: 1px)')
+}
+
+export function CounterButton(props: { id: string; label: string }) {
+ const [count, setCount] = React.useState(0)
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ {hydrated ? 'Hydrated' : 'Waiting'} {props.label}:{' '}
+ {count}
+
+ )
+}
+
+export function DeferredHydrateIsland(props: {
+ id: string
+ title: string
+ strategy: Strategy
+ className?: string
+}) {
+ return (
+
+
Client Hydrate island
+
{props.title}
+
{strategyCopy[props.strategy]}
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/routeTree.gen.ts b/e2e/react-start/rsc-deferred-hydration/src/routeTree.gen.ts
new file mode 100644
index 0000000000..246737b515
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/routeTree.gen.ts
@@ -0,0 +1,122 @@
+/* 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 ServerClientRouteImport } from './routes/server-client'
+import { Route as CssRouteImport } from './routes/css'
+import { Route as CompositeRouteImport } from './routes/composite'
+import { Route as IndexRouteImport } from './routes/index'
+
+const ServerClientRoute = ServerClientRouteImport.update({
+ id: '/server-client',
+ path: '/server-client',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const CssRoute = CssRouteImport.update({
+ id: '/css',
+ path: '/css',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const CompositeRoute = CompositeRouteImport.update({
+ id: '/composite',
+ path: '/composite',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/composite': typeof CompositeRoute
+ '/css': typeof CssRoute
+ '/server-client': typeof ServerClientRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/composite': typeof CompositeRoute
+ '/css': typeof CssRoute
+ '/server-client': typeof ServerClientRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/composite': typeof CompositeRoute
+ '/css': typeof CssRoute
+ '/server-client': typeof ServerClientRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/composite' | '/css' | '/server-client'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/composite' | '/css' | '/server-client'
+ id: '__root__' | '/' | '/composite' | '/css' | '/server-client'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ CompositeRoute: typeof CompositeRoute
+ CssRoute: typeof CssRoute
+ ServerClientRoute: typeof ServerClientRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/server-client': {
+ id: '/server-client'
+ path: '/server-client'
+ fullPath: '/server-client'
+ preLoaderRoute: typeof ServerClientRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/css': {
+ id: '/css'
+ path: '/css'
+ fullPath: '/css'
+ preLoaderRoute: typeof CssRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/composite': {
+ id: '/composite'
+ path: '/composite'
+ fullPath: '/composite'
+ preLoaderRoute: typeof CompositeRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ CompositeRoute: CompositeRoute,
+ CssRoute: CssRoute,
+ ServerClientRoute: ServerClientRoute,
+}
+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/rsc-deferred-hydration/src/router.tsx b/e2e/react-start/rsc-deferred-hydration/src/router.tsx
new file mode 100644
index 0000000000..9d87d8748b
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/router.tsx
@@ -0,0 +1,9 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ scrollRestoration: true,
+ })
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/__root.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/__root.tsx
new file mode 100644
index 0000000000..7ce2e7bdb1
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/routes/__root.tsx
@@ -0,0 +1,122 @@
+///
+import * as React from 'react'
+import {
+ HeadContent,
+ Link,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/react-router'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ { charSet: 'utf-8' },
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ { title: 'RSC Deferred Hydration E2E' },
+ ],
+ }),
+ shellComponent: RootDocument,
+ component: () => (
+
+
+
+ ),
+})
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+ RSC Deferred Hydration
+ Home
+ Server Client
+ Composite
+ CSS Modules
+
+ {children}
+
+
+
+ )
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/composite.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/composite.tsx
new file mode 100644
index 0000000000..3241ee487d
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/routes/composite.tsx
@@ -0,0 +1,24 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { CompositeComponent } from '@tanstack/react-start/rsc'
+import { getCompositeHydrate } from '~/server/serverHydrateComponents'
+import { DeferredHydrateIsland } from '~/components/DeferredHydrateIsland'
+
+export const Route = createFileRoute('/composite')({
+ loader: async () => ({
+ Composite: await getCompositeHydrate(),
+ }),
+ component: CompositeRoute,
+})
+
+function CompositeRoute() {
+ const { Composite } = Route.useLoaderData()
+ return (
+
+
+
+ )
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/css.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/css.tsx
new file mode 100644
index 0000000000..ddb22f318a
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/routes/css.tsx
@@ -0,0 +1,14 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { getCssModuleHydrate } from '~/server/serverHydrateComponents'
+
+export const Route = createFileRoute('/css')({
+ loader: async () => ({
+ Server: await getCssModuleHydrate(),
+ }),
+ component: CssRoute,
+})
+
+function CssRoute() {
+ const { Server } = Route.useLoaderData()
+ return Server
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/index.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/index.tsx
new file mode 100644
index 0000000000..ebe57e7028
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/routes/index.tsx
@@ -0,0 +1,35 @@
+import { createFileRoute, Link } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+ component: Home,
+})
+
+function Home() {
+ return (
+
+ RSC meets Deferred Hydration
+
+ These routes render React Server Components that cross into client
+ components using Hydrate. Each card explains when its
+ client island should hydrate and keeps server-rendered HTML visible
+ first.
+
+
+
+ Server component to client Hydrate
+ The RSC renders a separate "use client" component that
+ defers hydration until interaction.
+
+
+ Composite server shell A server component owns the
+ visual frame while a client child hydrates only after it becomes
+ visible.
+
+
+ CSS module client island A server component renders a
+ client Hydrate boundary whose child uses CSS modules.
+
+
+
+ )
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/server-client.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/server-client.tsx
new file mode 100644
index 0000000000..f39b635241
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/routes/server-client.tsx
@@ -0,0 +1,14 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { getServerClientHydrate } from '~/server/serverHydrateComponents'
+
+export const Route = createFileRoute('/server-client')({
+ loader: async () => ({
+ Server: await getServerClientHydrate(),
+ }),
+ component: ServerClientRoute,
+})
+
+function ServerClientRoute() {
+ const { Server } = Route.useLoaderData()
+ return Server
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateComponents.tsx b/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateComponents.tsx
new file mode 100644
index 0000000000..d4e431061a
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateComponents.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react'
+import { createServerFn } from '@tanstack/react-start'
+import {
+ createCompositeComponent,
+ renderServerComponent,
+} from '@tanstack/react-start/rsc'
+import {
+ CompositeHydrateContent,
+ CssModuleHydrateContent,
+ ServerClientHydrateContent,
+} from './serverHydrateContent'
+
+export const getServerClientHydrate = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ const renderedAt = new Date().toISOString()
+
+ return renderServerComponent(
+ ,
+ )
+ },
+)
+
+export const getCompositeHydrate = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ return createCompositeComponent((props: { children?: React.ReactNode }) => (
+ {props.children}
+ ))
+ },
+)
+
+export const getCssModuleHydrate = createServerFn({ method: 'GET' }).handler(
+ async () => {
+ return renderServerComponent( )
+ },
+)
diff --git a/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateContent.tsx b/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateContent.tsx
new file mode 100644
index 0000000000..dc785a16ce
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateContent.tsx
@@ -0,0 +1,63 @@
+import * as React from 'react'
+import { DeferredHydrateIsland } from '../components/DeferredHydrateIsland'
+import { CssHydrateIsland } from '../components/CssHydrateIsland'
+
+export function ServerClientHydrateContent({
+ renderedAt,
+}: {
+ renderedAt: string
+}) {
+ return (
+
+ React Server Component
+ Server component renders a deferred client island
+
+ Server rendered at {renderedAt} . The button below is
+ present in HTML but stays unhydrated until interaction.
+
+
+
+ )
+}
+
+export function CompositeHydrateContent({
+ children,
+}: {
+ children?: React.ReactNode
+}) {
+ return (
+
+ Composite Server Component
+ Server shell, client Hydrate slot
+
+ The server owns this descriptive shell. The client slot below remains
+ server HTML until an interaction reaches it.
+
+
+ {children}
+
+ )
+}
+
+export function CssModuleHydrateContent() {
+ return (
+
+
+ Server Component plus CSS module client island
+
+ CSS module Hydrate boundary
+
+ This server component renders a separate client component that uses
+ Hydrate and CSS modules.
+
+
+
+ )
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts b/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts
new file mode 100644
index 0000000000..2d543c5406
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts
@@ -0,0 +1,82 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+import type { Page } from '@playwright/test'
+
+async function expectUnhydrated(page: Page, id: string) {
+ await expect(page.getByTestId(`${id}-button`)).toHaveAttribute(
+ 'data-hydrated',
+ 'false',
+ )
+}
+
+async function clickAndExpectCount(page: Page, id: string, count: string) {
+ await expect(page.getByTestId(`${id}-button`)).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await page.getByTestId(`${id}-button`).click()
+ await expect(page.getByTestId(`${id}-count`)).toHaveText(count)
+}
+
+async function waitForHydrateMarkerToMount(page: Page, id: string) {
+ await page.waitForFunction((testId) => {
+ const button = document.querySelector(`[data-testid="${testId}-button"]`)
+ const marker = button?.closest('[data-ts-hydrate-id]')
+ return Object.keys(marker ?? {}).some((key) => key.startsWith('__react'))
+ }, id)
+}
+
+test.describe('RSC deferred hydration', () => {
+ test('server component renders a client Hydrate island that hydrates on interaction', async ({
+ page,
+ }) => {
+ await page.goto('/server-client')
+
+ await expect(page.getByTestId('server-client-rsc')).toContainText(
+ 'Server component renders a deferred client island',
+ )
+ await expect(page.getByTestId('server-client-island')).toContainText(
+ 'Interaction strategy inside RSC output',
+ )
+ await expectUnhydrated(page, 'server-client')
+
+ await page.getByTestId('server-client-button').hover()
+ await clickAndExpectCount(page, 'server-client', '1')
+ })
+
+ test('composite server component can wrap an interaction Hydrate client island', async ({
+ page,
+ }) => {
+ await page.goto('/composite')
+
+ await expect(page.getByTestId('composite-rsc')).toContainText(
+ 'Server shell, client Hydrate slot',
+ )
+ await expect(
+ page.getByTestId('composite-interaction-island'),
+ ).toContainText('Interaction strategy inside a composite server component')
+ await expectUnhydrated(page, 'composite-interaction')
+
+ await waitForHydrateMarkerToMount(page, 'composite-interaction')
+ await page.getByTestId('composite-interaction-button').hover()
+ await clickAndExpectCount(page, 'composite-interaction', '1')
+ })
+
+ test('server component can render a CSS module Hydrate client island', async ({
+ page,
+ }) => {
+ await page.goto('/css')
+
+ await expect(page.getByTestId('css-rsc')).toContainText(
+ 'CSS module Hydrate boundary',
+ )
+ await expect(page.getByTestId('css-module-marker')).toHaveCSS(
+ 'font-weight',
+ '900',
+ )
+ await expectUnhydrated(page, 'css-nested')
+ await waitForHydrateMarkerToMount(page, 'css-nested')
+ await page.getByTestId('css-nested-button').hover()
+ await clickAndExpectCount(page, 'css-nested', '1')
+ })
+})
diff --git a/e2e/react-start/rsc-deferred-hydration/tests/setup/global.setup.ts b/e2e/react-start/rsc-deferred-hydration/tests/setup/global.setup.ts
new file mode 100644
index 0000000000..3d1579ee87
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/tests/setup/global.setup.ts
@@ -0,0 +1,28 @@
+import {
+ e2eStartDummyServer,
+ getTestServerPort,
+ preOptimizeDevServer,
+ waitForServer,
+} from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+export default async function setup() {
+ await e2eStartDummyServer(packageJson.name)
+
+ if (process.env.MODE !== 'dev') return
+
+ const port = await getTestServerPort(packageJson.name)
+ const baseURL = `http://localhost:${port}`
+
+ await waitForServer(baseURL)
+ await preOptimizeDevServer({
+ baseURL,
+ readyTestId: 'home-heading',
+ warmup: async (page) => {
+ for (const route of ['/server-client', '/composite', '/css']) {
+ await page.goto(`${baseURL}${route}`, { waitUntil: 'domcontentloaded' })
+ await page.waitForLoadState('networkidle')
+ }
+ },
+ })
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/tests/setup/global.teardown.ts b/e2e/react-start/rsc-deferred-hydration/tests/setup/global.teardown.ts
new file mode 100644
index 0000000000..62fd79911c
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/tests/setup/global.teardown.ts
@@ -0,0 +1,6 @@
+import { e2eStopDummyServer } from '@tanstack/router-e2e-utils'
+import packageJson from '../../package.json' with { type: 'json' }
+
+export default async function teardown() {
+ await e2eStopDummyServer(packageJson.name)
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/tsconfig.json b/e2e/react-start/rsc-deferred-hydration/tsconfig.json
new file mode 100644
index 0000000000..cef9369516
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "target": "ES2022",
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+ "noEmit": true
+ }
+}
diff --git a/e2e/react-start/rsc-deferred-hydration/vite.config.ts b/e2e/react-start/rsc-deferred-hydration/vite.config.ts
new file mode 100644
index 0000000000..1919761bc3
--- /dev/null
+++ b/e2e/react-start/rsc-deferred-hydration/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import viteReact from '@vitejs/plugin-react'
+import rsc from '@vitejs/plugin-rsc'
+
+const outDir = process.env.E2E_DIST_DIR ?? 'dist'
+
+export default defineConfig({
+ resolve: { tsconfigPaths: true },
+ build: {
+ outDir,
+ },
+ server: {
+ port: Number(process.env.VITE_SERVER_PORT ?? 3000),
+ },
+ plugins: [
+ tanstackStart({
+ rsc: {
+ enabled: true,
+ },
+ }),
+ rsc(),
+ viteReact(),
+ ],
+})
diff --git a/e2e/solid-start/deferred-hydration/.gitignore b/e2e/solid-start/deferred-hydration/.gitignore
new file mode 100644
index 0000000000..1b3a07ede1
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/.gitignore
@@ -0,0 +1,15 @@
+node_modules
+package-lock.json
+yarn.lock
+.DS_Store
+.cache
+.env
+.vercel
+.output
+/build/
+/api/
+/server/build
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/e2e/solid-start/deferred-hydration/package.json b/e2e/solid-start/deferred-hydration/package.json
new file mode 100644
index 0000000000..77f55458fe
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "tanstack-solid-start-e2e-deferred-hydration",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "dev": "pnpm dev:vite --port 3000",
+ "dev:e2e": "pnpm dev:vite",
+ "dev:vite": "vite dev",
+ "dev:rsbuild": "rsbuild dev",
+ "build": "pnpm build:vite",
+ "build:vite": "vite build && tsc --noEmit",
+ "build:rsbuild": "rsbuild build && tsc --noEmit",
+ "preview": "vite preview",
+ "start": "pnpm start:vite",
+ "start:vite": "srvx --prod --dir=. -s dist-vite-ssr/client --entry dist-vite-ssr/server/server.js",
+ "start:rsbuild": "srvx --prod --dir=. -s dist-rsbuild-ssr/client --entry dist-rsbuild-ssr/server/index.js",
+ "test:e2e": "playwright test --project=chromium"
+ },
+ "dependencies": {
+ "@tanstack/solid-router": "workspace:^",
+ "@tanstack/solid-start": "workspace:^",
+ "solid-js": "^1.9.10",
+ "vite": "^8.0.0"
+ },
+ "devDependencies": {
+ "@rsbuild/core": "^2.0.1",
+ "@rsbuild/plugin-babel": "^1.1.2",
+ "@rsbuild/plugin-solid": "^1.1.1",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@types/node": "^22.10.2",
+ "rolldown": "1.0.0-rc.18",
+ "srvx": "^0.11.9",
+ "typescript": "^6.0.2",
+ "vite-plugin-solid": "^2.11.11"
+ },
+ "nx": {
+ "targets": {
+ "test:e2e": {
+ "parallelism": false
+ }
+ },
+ "metadata": {
+ "playwrightModes": [
+ {
+ "toolchain": "vite",
+ "mode": "ssr"
+ },
+ {
+ "toolchain": "rsbuild",
+ "mode": "ssr"
+ }
+ ]
+ }
+ }
+}
diff --git a/e2e/solid-start/deferred-hydration/playwright.config.ts b/e2e/solid-start/deferred-hydration/playwright.config.ts
new file mode 100644
index 0000000000..ea9e73a856
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/playwright.config.ts
@@ -0,0 +1,43 @@
+import fs from 'node:fs'
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite'
+const distDir = process.env.E2E_DIST_DIR ?? `dist-${toolchain}-ssr`
+const e2ePortKey =
+ process.env.E2E_PORT_KEY ?? `${packageJson.name}-${toolchain}`
+const serverEntryFile = toolchain === 'rsbuild' ? 'index.js' : 'server.js'
+const startCommand = `pnpm exec srvx --prod --dir=. -s ${distDir}/client --entry ${distDir}/server/${serverEntryFile}`
+
+if (process.env.TEST_WORKER_INDEX === undefined) {
+ fs.rmSync(`port-${e2ePortKey}.txt`, { force: true })
+}
+
+const PORT = await getTestServerPort(e2ePortKey)
+const baseURL = `http://localhost:${PORT}`
+
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+ reporter: [['line']],
+ use: { baseURL },
+ webServer: {
+ command: startCommand,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ env: {
+ E2E_DIST_DIR: distDir,
+ NODE_ENV: 'production',
+ PORT: String(PORT),
+ VITE_SERVER_PORT: String(PORT),
+ },
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/solid-start/deferred-hydration/rsbuild.config.ts b/e2e/solid-start/deferred-hydration/rsbuild.config.ts
new file mode 100644
index 0000000000..7ab152bb58
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/rsbuild.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from '@rsbuild/core'
+import { pluginBabel } from '@rsbuild/plugin-babel'
+import { pluginSolid } from '@rsbuild/plugin-solid'
+import { tanstackStart } from '@tanstack/solid-start/plugin/rsbuild'
+
+const outDir = process.env.E2E_DIST_DIR ?? 'dist-rsbuild-ssr'
+
+export default defineConfig({
+ plugins: [
+ pluginBabel({
+ include: /\.(?:jsx|tsx)$/,
+ }),
+ pluginSolid(),
+ tanstackStart(),
+ ],
+ output: {
+ distPath: {
+ root: outDir,
+ },
+ },
+})
diff --git a/e2e/solid-start/deferred-hydration/src/routeTree.gen.ts b/e2e/solid-start/deferred-hydration/src/routeTree.gen.ts
new file mode 100644
index 0000000000..e75225b500
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routeTree.gen.ts
@@ -0,0 +1,140 @@
+/* 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 ImportedRouteImport } from './routes/imported'
+import { Route as EnhancedRouteImport } from './routes/enhanced'
+import { Route as CssRouteImport } from './routes/css'
+import { Route as ComponentsRouteImport } from './routes/components'
+import { Route as IndexRouteImport } from './routes/index'
+
+const ImportedRoute = ImportedRouteImport.update({
+ id: '/imported',
+ path: '/imported',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const EnhancedRoute = EnhancedRouteImport.update({
+ id: '/enhanced',
+ path: '/enhanced',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const CssRoute = CssRouteImport.update({
+ id: '/css',
+ path: '/css',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ComponentsRoute = ComponentsRouteImport.update({
+ id: '/components',
+ path: '/components',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/components': typeof ComponentsRoute
+ '/css': typeof CssRoute
+ '/enhanced': typeof EnhancedRoute
+ '/imported': typeof ImportedRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/components': typeof ComponentsRoute
+ '/css': typeof CssRoute
+ '/enhanced': typeof EnhancedRoute
+ '/imported': typeof ImportedRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/components': typeof ComponentsRoute
+ '/css': typeof CssRoute
+ '/enhanced': typeof EnhancedRoute
+ '/imported': typeof ImportedRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/components' | '/css' | '/enhanced' | '/imported'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/components' | '/css' | '/enhanced' | '/imported'
+ id: '__root__' | '/' | '/components' | '/css' | '/enhanced' | '/imported'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ ComponentsRoute: typeof ComponentsRoute
+ CssRoute: typeof CssRoute
+ EnhancedRoute: typeof EnhancedRoute
+ ImportedRoute: typeof ImportedRoute
+}
+
+declare module '@tanstack/solid-router' {
+ interface FileRoutesByPath {
+ '/imported': {
+ id: '/imported'
+ path: '/imported'
+ fullPath: '/imported'
+ preLoaderRoute: typeof ImportedRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/enhanced': {
+ id: '/enhanced'
+ path: '/enhanced'
+ fullPath: '/enhanced'
+ preLoaderRoute: typeof EnhancedRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/css': {
+ id: '/css'
+ path: '/css'
+ fullPath: '/css'
+ preLoaderRoute: typeof CssRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/components': {
+ id: '/components'
+ path: '/components'
+ fullPath: '/components'
+ preLoaderRoute: typeof ComponentsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ ComponentsRoute: ComponentsRoute,
+ CssRoute: CssRoute,
+ EnhancedRoute: EnhancedRoute,
+ ImportedRoute: ImportedRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/solid-start'
+declare module '@tanstack/solid-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/e2e/solid-start/deferred-hydration/src/router.tsx b/e2e/solid-start/deferred-hydration/src/router.tsx
new file mode 100644
index 0000000000..aa7ead6752
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/router.tsx
@@ -0,0 +1,9 @@
+import { createRouter } from '@tanstack/solid-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ scrollRestoration: true,
+ })
+}
diff --git a/e2e/solid-start/deferred-hydration/src/routes/__root.tsx b/e2e/solid-start/deferred-hydration/src/routes/__root.tsx
new file mode 100644
index 0000000000..b8c8189c6b
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routes/__root.tsx
@@ -0,0 +1,98 @@
+///
+import {
+ HeadContent,
+ Link,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/solid-router'
+import { HydrationScript } from 'solid-js/web'
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ { charSet: 'utf-8' },
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ { title: 'Deferred Hydration E2E' },
+ ],
+ }),
+ component: RootDocument,
+})
+
+function RootDocument() {
+ return (
+
+
+
+
+
+
+
+
+ Home Component{' '}
+ CSS{' '}
+
+ Imported
+
+
+ Enhanced
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/deferred-hydration/src/routes/components.tsx b/e2e/solid-start/deferred-hydration/src/routes/components.tsx
new file mode 100644
index 0000000000..9dd6387f09
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routes/components.tsx
@@ -0,0 +1,171 @@
+import * as Solid from 'solid-js'
+
+import { createFileRoute } from '@tanstack/solid-router'
+import { Hydrate } from '@tanstack/solid-start'
+import {
+ condition,
+ idle,
+ interaction,
+ load,
+ media,
+ never,
+ visible,
+} from '@tanstack/solid-start/hydration'
+
+export const Route = createFileRoute('/components')({
+ component: ComponentHydrationPage,
+})
+
+function InteractiveBox(props: { id: string; label: string }) {
+ const [count, setCount] = Solid.createSignal(0)
+ const [hydrated, setHydrated] = Solid.createSignal(false)
+
+ Solid.onMount(() => {
+ setHydrated(true)
+ })
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ {props.label}: {count()}
+
+ )
+}
+
+function DelayedFallbackBox() {
+ if (typeof window !== 'undefined') {
+ const [ready] = Solid.createResource(async () => {
+ await new Promise((resolve) => window.setTimeout(resolve, 1000))
+ return true
+ })
+
+ return (
+
+ fallback child
+
+ )
+ }
+
+ return fallback child
+}
+
+function ComponentHydrationPage() {
+ const [hydratedCallbacks, setHydratedCallbacks] = Solid.createSignal(0)
+ const [conditionReady, setConditionReady] = Solid.createSignal(false)
+ const [showClientFallbackBoundary, setShowClientFallbackBoundary] =
+ Solid.createSignal(false)
+
+ return (
+
+ Component Deferred Hydration
+
+ Manual test guide
+
+ Pink buttons are server HTML that has not hydrated yet. Green buttons
+ have hydrated and should increment when clicked. Follow the notes
+ below to trigger each strategy intentionally.
+
+
+ {hydratedCallbacks()}
+
+ load and idle should become green
+ without interaction shortly after the page loads.
+
+
+
+
+
+
+
+ Scroll down to reveal the visible boundary
+
+ visible hydrates only after this button enters the
+ viewport.
+
+
+
+
+
+ media hydrates when (min-width: 1px)
+ matches. interaction hydrates on hover, focus, pointer
+ down, or click intent.
+
+
+
+
+
+
+
+
+ Custom interaction boundaries below hydrate only for their configured
+ events: double-click for the single-event example, and right-click or
+ double-click for the multi-event example. The prefetch example should
+ download code on hover but hydrate on click.
+
+ setHydratedCallbacks((count) => count + 1)}
+ >
+
+
+
+
+
+ setConditionReady(true)}
+ >
+ enable condition
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ never stays as server HTML forever on the initial page,
+ so clicking should not increment it.
+
+ setShowClientFallbackBoundary(true)}
+ >
+ show client fallback
+
+
+ client fallback
+ }
+ >
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/deferred-hydration/src/routes/css.tsx b/e2e/solid-start/deferred-hydration/src/routes/css.tsx
new file mode 100644
index 0000000000..c1a80d492b
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routes/css.tsx
@@ -0,0 +1,57 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { Hydrate } from '@tanstack/solid-start'
+import { media, never, visible } from '@tanstack/solid-start/hydration'
+import outerStyles from './css/outer.module.css'
+import deferredStyles from './css/deferred-only.module.css'
+import sharedStyles from './css/shared.module.css'
+
+export const Route = createFileRoute('/css')({
+ component: CssHydrationPage,
+})
+
+function CssHydrationPage() {
+ return (
+
+
+
+ CSS Deferred Hydration
+
+
+ CSS from deferred, never, shared, and nested Hydrate boundaries should
+ be available even before the client JavaScript hydrates those islands.
+
+
+
+
+ Outer CSS
+
+
+ Shared outer CSS
+
+
+
+
+ Deferred CSS
+
+
+
+
+ Never CSS
+
+
+
+
+
+ Nested CSS
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/deferred-hydration/src/routes/css/deferred-only.module.css b/e2e/solid-start/deferred-hydration/src/routes/css/deferred-only.module.css
new file mode 100644
index 0000000000..05eafda03c
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routes/css/deferred-only.module.css
@@ -0,0 +1,15 @@
+.deferredBox {
+ background-color: rgb(23, 45, 67);
+ color: rgb(255, 255, 255);
+ padding: 12px;
+}
+
+.neverBox {
+ color: rgb(45, 67, 89);
+ padding: 12px;
+}
+
+.nestedBox {
+ border-left: 5px solid rgb(67, 89, 123);
+ padding-left: 12px;
+}
diff --git a/e2e/solid-start/deferred-hydration/src/routes/css/outer.module.css b/e2e/solid-start/deferred-hydration/src/routes/css/outer.module.css
new file mode 100644
index 0000000000..98ca5e0934
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routes/css/outer.module.css
@@ -0,0 +1,9 @@
+.heading {
+ color: rgb(11, 31, 53);
+}
+
+.outerBox {
+ background-color: rgb(242, 250, 255);
+ color: rgb(12, 34, 56);
+ padding: 12px;
+}
diff --git a/e2e/solid-start/deferred-hydration/src/routes/css/shared.module.css b/e2e/solid-start/deferred-hydration/src/routes/css/shared.module.css
new file mode 100644
index 0000000000..020da5d7ad
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routes/css/shared.module.css
@@ -0,0 +1,4 @@
+.sharedBox {
+ border-top: 4px solid rgb(98, 76, 54);
+ margin-top: 8px;
+}
diff --git a/e2e/solid-start/deferred-hydration/src/routes/enhanced.tsx b/e2e/solid-start/deferred-hydration/src/routes/enhanced.tsx
new file mode 100644
index 0000000000..d699a644f1
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routes/enhanced.tsx
@@ -0,0 +1,316 @@
+import { Show, createSignal, onMount, type JSX } from 'solid-js'
+import { createFileRoute } from '@tanstack/solid-router'
+import { Hydrate } from '@tanstack/solid-start'
+import { interaction, media } from '@tanstack/solid-start/hydration'
+import { EnhancedNestedWidget } from '../shared/EnhancedNestedWidget'
+
+type EnhancedSearch = {
+ dynamic?: 'interaction'
+}
+
+export const Route = createFileRoute('/enhanced')({
+ validateSearch: (search: Record): EnhancedSearch => ({
+ dynamic: search.dynamic === 'interaction' ? 'interaction' : undefined,
+ }),
+ component: EnhancedHydrationPage,
+})
+
+type DeferredGate = {
+ promise: Promise
+ resolve: () => void
+}
+
+const clickIntent = interaction({ events: 'click' })
+const pointerOverIntent = interaction({ events: 'pointerover' })
+const doubleClickIntent = interaction({ events: 'dblclick' })
+
+function createDeferredGate(): DeferredGate {
+ let resolve!: () => void
+ const promise = new Promise((next) => {
+ resolve = next
+ })
+ return { promise, resolve }
+}
+
+function InteractiveBox(props: { id: string; label: string }): JSX.Element {
+ const [count, setCount] = createSignal(0)
+ const [hydrated, setHydrated] = createSignal(false)
+
+ onMount(() => {
+ setHydrated(true)
+ })
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ {props.label}: {count()}
+
+ )
+}
+
+function DynamicWhenExamples(): JSX.Element {
+ const search = Route.useSearch()
+ const searchDrivenHydration = () =>
+ search().dynamic === 'interaction' ? clickIntent : doubleClickIntent
+
+ return (
+ <>
+
+ Dynamic callbacks are client-only. The first boundary always hydrates on
+ click; the second reads typed router search state before choosing its
+ interaction event.
+
+ clickIntent}>
+
+
+
+
+
+ >
+ )
+}
+
+function SplitPrefetchExample(): JSX.Element {
+ const gate = createDeferredGate()
+ const [waitReason, setWaitReason] = createSignal('idle')
+ const [preloadStatus, setPreloadStatus] = createSignal('idle')
+ const [queryStatus, setQueryStatus] = createSignal('idle')
+
+ return (
+ <>
+ {waitReason()}
+ {preloadStatus()}
+ {queryStatus()}
+ gate.resolve()}
+ >
+ release split prefetch
+
+ {
+ setQueryStatus(element ? 'element' : 'missing-element')
+ setWaitReason('waiting')
+
+ const reason = await waitFor(pointerOverIntent)
+ setWaitReason(reason)
+ if (reason === 'abort' || signal.aborted) return
+
+ await preload()
+ setPreloadStatus('done')
+ await gate.promise
+ setQueryStatus('done')
+ }}
+ >
+
+
+ >
+ )
+}
+
+function FireAndForgetPrefetchExample(): JSX.Element {
+ const gate = createDeferredGate()
+ const [waitReason, setWaitReason] = createSignal('idle')
+ const [workStatus, setWorkStatus] = createSignal('idle')
+ const [queryStatus, setQueryStatus] = createSignal('idle')
+
+ return (
+ <>
+ {workStatus()}
+ {waitReason()}
+ {queryStatus()}
+ gate.resolve()}
+ >
+ release fire-and-forget prefetch
+
+ {
+ setWaitReason('waiting')
+ void waitFor(pointerOverIntent).then((reason) => {
+ setWaitReason(reason)
+ if (reason === 'abort') return
+
+ setWorkStatus('started')
+ void gate.promise.then(() => {
+ setQueryStatus('done')
+ })
+ })
+ }}
+ >
+
+
+ >
+ )
+}
+
+function HydrateFirstPrefetchExample(): JSX.Element {
+ const [reason, setReason] = createSignal('idle')
+
+ return (
+ <>
+ {reason()}
+ {
+ setReason(await waitFor(doubleClickIntent))
+ }}
+ >
+
+
+ >
+ )
+}
+
+function RuntimeOnlyPrefetchExample(): JSX.Element {
+ const gate = createDeferredGate()
+ const [waitReason, setWaitReason] = createSignal('idle')
+ const [runtimeStatus, setRuntimeStatus] = createSignal('idle')
+
+ return (
+ <>
+ {waitReason()}
+ {runtimeStatus()}
+ gate.resolve()}
+ >
+ release runtime-only prefetch
+
+ {
+ setWaitReason('waiting')
+ const reason = await waitFor(pointerOverIntent)
+ setWaitReason(reason)
+ if (reason === 'abort') return
+
+ await gate.promise
+ await preload()
+ setRuntimeStatus('ready')
+ }}
+ >
+
+
+ >
+ )
+}
+
+function WaitForAbortExample(): JSX.Element {
+ const [showBoundary, setShowBoundary] = createSignal(true)
+ const [reason, setReason] = createSignal('idle')
+
+ return (
+ <>
+ {reason()}
+ setShowBoundary(false)}
+ >
+ hide waitFor abort boundary
+
+
+ {
+ setReason('waiting')
+ setReason(await waitFor(pointerOverIntent))
+ }}
+ >
+
+
+
+ >
+ )
+}
+
+function SignalAbortExample(): JSX.Element {
+ const [showBoundary, setShowBoundary] = createSignal(true)
+ const [status, setStatus] = createSignal('idle')
+
+ return (
+ <>
+ {status()}
+ setShowBoundary(false)}
+ >
+ hide abort boundary
+
+
+ {
+ setStatus('listening')
+ await new Promise((resolve) => {
+ const onAbort = () => {
+ setStatus('aborted')
+ resolve()
+ }
+
+ if (signal.aborted) {
+ onAbort()
+ return
+ }
+
+ signal.addEventListener('abort', onAbort, { once: true })
+ })
+ }}
+ >
+
+
+
+ >
+ )
+}
+
+function NestedDynamicExamples(): JSX.Element {
+ return (
+ <>
+
+ clickIntent}>
+
+
+
+
+ >
+ )
+}
+
+function EnhancedHydrationPage(): JSX.Element {
+ return (
+
+ Enhanced Hydrate APIs
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/deferred-hydration/src/routes/imported.tsx b/e2e/solid-start/deferred-hydration/src/routes/imported.tsx
new file mode 100644
index 0000000000..abfb827c91
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routes/imported.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { ImportedHydrateWidget } from '../shared/ImportedHydrateWidget'
+
+export const Route = createFileRoute('/imported')({
+ component: ImportedHydrationPage,
+})
+
+function ImportedHydrationPage() {
+ return (
+
+ )
+}
diff --git a/e2e/solid-start/deferred-hydration/src/routes/index.tsx b/e2e/solid-start/deferred-hydration/src/routes/index.tsx
new file mode 100644
index 0000000000..61fbf7f6dc
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/routes/index.tsx
@@ -0,0 +1,23 @@
+import { Link, createFileRoute } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/')({
+ component: Home,
+})
+
+function Home() {
+ return (
+
+ Deferred Hydration
+ Component strategies
+ component strategies
+ CSS
+ CSS deferred hydration
+ Imported component
+
+ imported Hydrate
+
+ Enhanced APIs
+ enhanced Hydrate APIs
+
+ )
+}
diff --git a/e2e/solid-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx b/e2e/solid-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx
new file mode 100644
index 0000000000..e595e38da8
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx
@@ -0,0 +1,33 @@
+import { createSignal, onMount } from 'solid-js'
+import { Hydrate } from '@tanstack/solid-start'
+import { interaction, media } from '@tanstack/solid-start/hydration'
+
+function CrossFileNestedButton() {
+ const [count, setCount] = createSignal(0)
+ const [hydrated, setHydrated] = createSignal(false)
+
+ onMount(() => {
+ setHydrated(true)
+ })
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ cross-file nested child:{' '}
+ {count()}
+
+ )
+}
+
+export function EnhancedNestedWidget() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx b/e2e/solid-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx
new file mode 100644
index 0000000000..859b741755
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx
@@ -0,0 +1,34 @@
+import { createSignal } from 'solid-js'
+
+import { Hydrate } from '@tanstack/solid-start'
+import { media } from '@tanstack/solid-start/hydration'
+
+function ImportedHydrateChild() {
+ const [count, setCount] = createSignal(0)
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ Imported Hydrate Child:{' '}
+ {count()}
+
+ )
+}
+
+export function ImportedHydrateWidget() {
+ return (
+
+ imported hydrate fallback
+
+ }
+ >
+
+
+ )
+}
diff --git a/e2e/solid-start/deferred-hydration/tests/hydration.spec.ts b/e2e/solid-start/deferred-hydration/tests/hydration.spec.ts
new file mode 100644
index 0000000000..dff2357837
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/tests/hydration.spec.ts
@@ -0,0 +1,909 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+import type { APIRequestContext, Page } from '@playwright/test'
+
+async function clickAndExpectCount(
+ page: Page,
+ buttonTestId: string,
+ countTestId: string,
+ count: string,
+) {
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await page.getByTestId(buttonTestId).click()
+ await expect(page.getByTestId(countTestId)).toHaveText(count)
+}
+
+async function clickIntentAndExpectReplayedCount(
+ page: Page,
+ buttonTestId: string,
+ countTestId: string,
+ count: string,
+) {
+ await expectRouteToStayUnhydrated(page, buttonTestId)
+ await page.getByTestId(buttonTestId).click()
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await expect(page.getByTestId(countTestId)).toHaveText(count)
+}
+
+async function clickToHydrateThenClickAndExpectIncrement(
+ page: Page,
+ buttonTestId: string,
+ countTestId: string,
+) {
+ await expectRouteToStayUnhydrated(page, buttonTestId)
+ await page.getByTestId(buttonTestId).click()
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ const previousCount = Number(
+ await page.getByTestId(countTestId).textContent(),
+ )
+ await page.getByTestId(buttonTestId).click()
+ await expect
+ .poll(async () => Number(await page.getByTestId(countTestId).textContent()))
+ .toBe(previousCount + 1)
+}
+
+async function hoverIntentAndExpectCount(
+ page: Page,
+ buttonTestId: string,
+ countTestId: string,
+ count: string,
+) {
+ await expectRouteToStayUnhydrated(page, buttonTestId)
+ await page.mouse.move(0, 0)
+ await page.getByTestId(buttonTestId).hover()
+ await clickAndExpectCount(page, buttonTestId, countTestId, count)
+}
+
+async function dispatchHydrationIntent(
+ page: Page,
+ buttonTestId: string,
+ eventName: string,
+) {
+ await page.getByTestId(buttonTestId).evaluate((element, eventName) => {
+ const marker = element.closest('[data-ts-hydrate-id]')
+
+ if (!marker) {
+ throw new Error('Expected Hydrate marker to exist')
+ }
+
+ marker.dispatchEvent(
+ new Event(eventName, { bubbles: true, cancelable: true }),
+ )
+ }, eventName)
+}
+
+async function trackBoundaryStability(page: Page, buttonTestId: string) {
+ await page.getByTestId(buttonTestId).evaluate((element, testId) => {
+ const marker = element.closest('[data-ts-hydrate-id]')
+
+ if (!(marker instanceof HTMLElement)) {
+ throw new Error('Expected Hydrate marker to exist')
+ }
+
+ const win = window as typeof window & {
+ __hydrateBoundaryVisualGapCount?: number
+ __hydrateBoundaryMaxScrollDelta?: number
+ __hydrateBoundaryStabilityDone?: boolean
+ }
+ const hasBoundaryButton = () =>
+ Array.from(marker.querySelectorAll('[data-testid]')).some(
+ (child) => child.getAttribute('data-testid') === testId,
+ )
+ const hasHydratedBoundaryButton = () =>
+ Array.from(marker.querySelectorAll('[data-testid]')).some(
+ (child) =>
+ child.getAttribute('data-testid') === testId &&
+ child.getAttribute('data-hydrated') === 'true',
+ )
+ const initialScrollY = window.scrollY
+
+ win.__hydrateBoundaryVisualGapCount = 0
+ win.__hydrateBoundaryMaxScrollDelta = 0
+ win.__hydrateBoundaryStabilityDone = false
+
+ let frameCount = 0
+ let hydratedFrameCount = 0
+ let observer: MutationObserver | undefined
+ const recordScroll = () => {
+ win.__hydrateBoundaryMaxScrollDelta = Math.max(
+ win.__hydrateBoundaryMaxScrollDelta ?? 0,
+ Math.abs(window.scrollY - initialScrollY),
+ )
+
+ if (hasHydratedBoundaryButton()) hydratedFrameCount++
+
+ frameCount++
+ if (hydratedFrameCount >= 10 || frameCount >= 300) {
+ observer?.disconnect()
+ win.__hydrateBoundaryStabilityDone = true
+ return
+ }
+
+ requestAnimationFrame(recordScroll)
+ }
+
+ observer = new MutationObserver(() => {
+ if (!hasBoundaryButton()) {
+ win.__hydrateBoundaryVisualGapCount!++
+ }
+ })
+ observer.observe(marker, { childList: true })
+
+ requestAnimationFrame(recordScroll)
+ }, buttonTestId)
+}
+
+async function expectBoundaryStable(page: Page) {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as typeof window & {
+ __hydrateBoundaryStabilityDone?: boolean
+ }
+ ).__hydrateBoundaryStabilityDone ?? false,
+ ),
+ )
+ .toBe(true)
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as typeof window & {
+ __hydrateBoundaryVisualGapCount?: number
+ }
+ ).__hydrateBoundaryVisualGapCount ?? 0,
+ ),
+ )
+ .toBe(0)
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as typeof window & {
+ __hydrateBoundaryMaxScrollDelta?: number
+ }
+ ).__hydrateBoundaryMaxScrollDelta ?? 0,
+ ),
+ )
+ .toBeLessThanOrEqual(1)
+}
+
+async function expectRouteToStayUnhydrated(
+ page: Page,
+ buttonTestId: string,
+ duration = 250,
+) {
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'false',
+ )
+ await page.waitForTimeout(duration)
+ await expect(page.getByTestId(buttonTestId)).toHaveAttribute(
+ 'data-hydrated',
+ 'false',
+ )
+}
+
+async function scrollToBoundary(page: Page, buttonTestId: string) {
+ const button = page.getByTestId(buttonTestId)
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await button.evaluate((element) => {
+ element.scrollIntoView({ block: 'center', inline: 'nearest' })
+ })
+
+ await page.waitForTimeout(100)
+ const isVisible = await button.evaluate((element) => {
+ const rect = element.getBoundingClientRect()
+ return rect.bottom > 0 && rect.top < window.innerHeight
+ })
+
+ if (isVisible) return
+ }
+
+ await expect(button).toBeInViewport()
+}
+
+async function expectCssProperty(
+ page: Page,
+ testId: string,
+ property: string,
+ value: string,
+) {
+ await expect
+ .poll(() =>
+ page.getByTestId(testId).evaluate((element, propertyName) => {
+ return getComputedStyle(element).getPropertyValue(propertyName)
+ }, property),
+ )
+ .toBe(value)
+}
+
+function htmlContainsText(html: string, text: string) {
+ const pattern = text.split(' ').join('(?:\\s|)+')
+ expect(html).toMatch(new RegExp(pattern))
+}
+
+function getModulePreloadHrefs(html: string) {
+ return Array.from(html.matchAll(/ ]*>/g), (match) => match[0])
+ .filter((tag) => /\brel="modulepreload"/.test(tag))
+ .map((tag) => tag.match(/\bhref="([^"]+)"/)?.[1])
+ .filter((href): href is string => !!href)
+}
+
+async function modulePreloadContentsContain(
+ request: APIRequestContext,
+ hrefs: Array,
+ marker: string,
+) {
+ for (const href of hrefs) {
+ const response = await request.get(href)
+ if (!response.ok()) continue
+
+ const text = await response.text()
+ if (text.includes(marker)) return true
+ }
+
+ return false
+}
+
+async function resourceContentsContain(
+ page: Page,
+ request: APIRequestContext,
+ marker: string,
+ filter: (url: string) => boolean,
+) {
+ const resourceUrls = await page.evaluate(() =>
+ performance.getEntriesByType('resource').map((entry) => entry.name),
+ )
+
+ return modulePreloadContentsContain(
+ request,
+ resourceUrls.filter(filter),
+ marker,
+ )
+}
+
+async function documentModulePreloadHrefs(page: Page) {
+ return page.evaluate(() =>
+ Array.from(
+ document.querySelectorAll('link[rel~="modulepreload"]'),
+ (link) => link.href,
+ ),
+ )
+}
+
+function isHydrateBoundaryResource(url: string) {
+ return (
+ url.includes('/assets/components-') || url.includes('/static/js/async/')
+ )
+}
+
+function isClientJavaScriptResource(url: string) {
+ return (
+ url.includes('/assets/') ||
+ url.includes('/static/js/') ||
+ url.includes('/static/js/async/')
+ )
+}
+
+async function expectClientRouterReady(page: Page) {
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ Boolean(
+ (
+ globalThis as typeof globalThis & {
+ __TSR_ROUTER__?: unknown
+ }
+ ).__TSR_ROUTER__,
+ ),
+ ),
+ )
+ .toBe(true)
+}
+
+async function gotoEnhanced(page: Page, search = '') {
+ await page.goto(`/enhanced${search}`)
+ await expectClientRouterReady(page)
+}
+
+test.describe('component-level Hydrate runtime strategies', () => {
+ test('renders SSR HTML and hydrates each runtime when appropriately', async ({
+ page,
+ request,
+ }) => {
+ await page.goto('/components')
+
+ await expect(page.getByTestId('component-heading')).toHaveText(
+ 'Component Deferred Hydration',
+ )
+
+ await clickAndExpectCount(
+ page,
+ 'component-load-button',
+ 'component-load-count',
+ '1',
+ )
+ await clickAndExpectCount(
+ page,
+ 'component-idle-button',
+ 'component-idle-count',
+ '1',
+ )
+ await expect(
+ resourceContentsContain(page, request, 'component-visible', (url) =>
+ isHydrateBoundaryResource(url),
+ ),
+ ).resolves.toBe(false)
+ await expectRouteToStayUnhydrated(page, 'component-visible-button')
+ await scrollToBoundary(page, 'component-visible-button')
+ await clickAndExpectCount(
+ page,
+ 'component-visible-button',
+ 'component-visible-count',
+ '1',
+ )
+ await expect
+ .poll(() =>
+ resourceContentsContain(page, request, 'component-visible', (url) =>
+ isHydrateBoundaryResource(url),
+ ),
+ )
+ .toBe(true)
+ await clickAndExpectCount(
+ page,
+ 'component-media-button',
+ 'component-media-count',
+ '1',
+ )
+ await hoverIntentAndExpectCount(
+ page,
+ 'component-interaction-button',
+ 'component-interaction-count',
+ '1',
+ )
+ await expect(page.getByTestId('component-on-hydrated-count')).toHaveText(
+ '0',
+ )
+ await expectRouteToStayUnhydrated(page, 'component-custom-single-button')
+ await page.getByTestId('component-custom-single-button').hover()
+ await expectRouteToStayUnhydrated(page, 'component-custom-single-button')
+ await page.getByTestId('component-custom-single-button').click()
+ await expectRouteToStayUnhydrated(page, 'component-custom-single-button')
+ await dispatchHydrationIntent(
+ page,
+ 'component-custom-single-button',
+ 'dblclick',
+ )
+ await expect(
+ page.getByTestId('component-custom-single-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expect(page.getByTestId('component-on-hydrated-count')).toHaveText(
+ '1',
+ )
+ await clickAndExpectCount(
+ page,
+ 'component-custom-single-button',
+ 'component-custom-single-count',
+ '1',
+ )
+ await expect(page.getByTestId('component-on-hydrated-count')).toHaveText(
+ '1',
+ )
+ await expectRouteToStayUnhydrated(page, 'component-custom-multi-button')
+ await dispatchHydrationIntent(
+ page,
+ 'component-custom-multi-button',
+ 'contextmenu',
+ )
+ await clickAndExpectCount(
+ page,
+ 'component-custom-multi-button',
+ 'component-custom-multi-count',
+ '1',
+ )
+ await expectRouteToStayUnhydrated(page, 'component-condition-button')
+ await page.getByTestId('component-enable-condition').click()
+ await clickAndExpectCount(
+ page,
+ 'component-condition-button',
+ 'component-condition-count',
+ '1',
+ )
+ await scrollToBoundary(page, 'component-click-replay-button')
+ await expectRouteToStayUnhydrated(page, 'component-click-replay-button')
+ await trackBoundaryStability(page, 'component-click-replay-button')
+ await page.getByTestId('component-click-replay-button').click()
+ await expect(
+ page.getByTestId('component-click-replay-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expectBoundaryStable(page)
+ await expect(page.getByTestId('component-click-replay-count')).toHaveText(
+ '1',
+ )
+ await expectRouteToStayUnhydrated(page, 'component-prefetch-button')
+ await expect(
+ resourceContentsContain(page, request, 'component-prefetch', (url) =>
+ isHydrateBoundaryResource(url),
+ ),
+ ).resolves.toBe(false)
+ await page.mouse.move(0, 0)
+ await page.getByTestId('component-prefetch-button').hover()
+ await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute(
+ 'data-hydrated',
+ 'false',
+ )
+ await expect
+ .poll(() =>
+ resourceContentsContain(page, request, 'component-prefetch', (url) =>
+ isHydrateBoundaryResource(url),
+ ),
+ )
+ .toBe(true)
+ await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute(
+ 'data-hydrated',
+ 'false',
+ )
+ await page.getByTestId('component-prefetch-button').click()
+ await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await expect(page.getByTestId('component-prefetch-count')).toHaveText('1')
+ await hoverIntentAndExpectCount(
+ page,
+ 'component-nested-child-button',
+ 'component-nested-child-count',
+ '1',
+ )
+
+ await page.getByTestId('component-never-button').click()
+ await expect(page.getByTestId('component-never-count')).toHaveText('0')
+ })
+
+ test('replays click after another interaction boundary hydrates first', async ({
+ page,
+ }) => {
+ await page.goto('/components')
+ await expectClientRouterReady(page)
+
+ await scrollToBoundary(page, 'component-custom-multi-button')
+ await expectRouteToStayUnhydrated(page, 'component-custom-multi-button')
+ await page.getByTestId('component-custom-multi-button').click({
+ button: 'right',
+ })
+ await expect(
+ page.getByTestId('component-custom-multi-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+
+ await scrollToBoundary(page, 'component-click-replay-button')
+ await expectRouteToStayUnhydrated(page, 'component-click-replay-button')
+ await trackBoundaryStability(page, 'component-click-replay-button')
+ await page.getByTestId('component-click-replay-button').click()
+ await expect(
+ page.getByTestId('component-click-replay-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expectBoundaryStable(page)
+ await expect(page.getByTestId('component-click-replay-count')).toHaveText(
+ '1',
+ )
+ })
+
+ test('keeps bottom scroll stable when a condition boundary hydrates', async ({
+ page,
+ }) => {
+ await page.goto('/components')
+ await expectClientRouterReady(page)
+ await expectRouteToStayUnhydrated(page, 'component-condition-button')
+
+ await page.evaluate(() => {
+ window.scrollTo(0, document.documentElement.scrollHeight)
+ })
+ await page.waitForTimeout(100)
+ await expect(
+ page.getByTestId('component-enable-condition'),
+ ).toBeInViewport()
+
+ await trackBoundaryStability(page, 'component-condition-button')
+ await page.getByTestId('component-enable-condition').click()
+ await expect(
+ page.getByTestId('component-condition-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expectBoundaryStable(page)
+ await clickAndExpectCount(
+ page,
+ 'component-condition-button',
+ 'component-condition-count',
+ '1',
+ )
+ })
+
+ test('shows fallback during a client-only mount while the child suspends', async ({
+ page,
+ }) => {
+ await page.goto('/components')
+ await expect(page.getByTestId('component-load-button')).toHaveAttribute(
+ 'data-hydrated',
+ 'true',
+ )
+ await page.getByTestId('component-show-client-fallback').click()
+
+ await expect(page.getByTestId('component-client-fallback')).toHaveText(
+ 'client fallback',
+ )
+ await expect(page.getByTestId('component-fallback-child')).toHaveText(
+ 'fallback child',
+ )
+ await expect(page.getByTestId('component-client-fallback')).toHaveCount(0)
+ })
+})
+
+test.describe('enhanced Hydrate API combinations', () => {
+ test('server renders dynamic markers without evaluating client-only callbacks or prefetch functions', async ({
+ request,
+ }) => {
+ const response = await request.get('/enhanced?dynamic=interaction')
+ const html = await response.text()
+
+ expect(response.ok()).toBe(true)
+ htmlContainsText(html, 'Enhanced Hydrate APIs')
+ htmlContainsText(html, 'conditional dynamic')
+ expect(html).toContain('data-ts-hydrate-when="dynamic"')
+ expect(html).not.toContain('missing-element')
+ })
+
+ test('dynamic when functions hydrate and replay interaction events', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page, '?dynamic=interaction')
+ await expect(page.getByTestId('enhanced-heading')).toHaveText(
+ 'Enhanced Hydrate APIs',
+ )
+ await clickIntentAndExpectReplayedCount(
+ page,
+ 'enhanced-dynamic-interaction-button',
+ 'enhanced-dynamic-interaction-count',
+ '1',
+ )
+
+ await expectRouteToStayUnhydrated(
+ page,
+ 'enhanced-dynamic-conditional-button',
+ )
+ await page.getByTestId('enhanced-dynamic-conditional-button').hover()
+ await expectRouteToStayUnhydrated(
+ page,
+ 'enhanced-dynamic-conditional-button',
+ )
+ await clickIntentAndExpectReplayedCount(
+ page,
+ 'enhanced-dynamic-conditional-button',
+ 'enhanced-dynamic-conditional-count',
+ '1',
+ )
+ })
+
+ test('procedural prefetch can block hydration, preload the split chunk, and prepare query-like work', async ({
+ page,
+ request,
+ }) => {
+ await gotoEnhanced(page)
+ await expect(
+ resourceContentsContain(
+ page,
+ request,
+ 'enhanced-procedural-split-child',
+ isClientJavaScriptResource,
+ ),
+ ).resolves.toBe(false)
+
+ await expectRouteToStayUnhydrated(page, 'enhanced-procedural-split-button')
+ await expect(page.getByTestId('enhanced-split-wait-reason')).toHaveText(
+ 'waiting',
+ )
+ await dispatchHydrationIntent(
+ page,
+ 'enhanced-procedural-split-button',
+ 'pointerover',
+ )
+ await expect(page.getByTestId('enhanced-split-query')).toHaveText('element')
+ await expect(page.getByTestId('enhanced-split-wait-reason')).toHaveText(
+ 'prefetch',
+ )
+ await expect(page.getByTestId('enhanced-split-preload')).toHaveText('done')
+ await expect
+ .poll(() =>
+ resourceContentsContain(
+ page,
+ request,
+ 'enhanced-procedural-split-child',
+ isClientJavaScriptResource,
+ ),
+ )
+ .toBe(true)
+
+ await page.getByTestId('enhanced-procedural-split-button').click()
+ await expect(
+ page.getByTestId('enhanced-procedural-split-button'),
+ ).toHaveAttribute('data-hydrated', 'false')
+ await expect(
+ page.getByTestId('enhanced-procedural-split-count'),
+ ).toHaveText('0')
+ await page.getByTestId('enhanced-release-split-prefetch').click()
+ await expect(
+ page.getByTestId('enhanced-procedural-split-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expect(
+ page.getByTestId('enhanced-procedural-split-count'),
+ ).toHaveText('1')
+ await expect(page.getByTestId('enhanced-split-query')).toHaveText('done')
+ })
+
+ test('function prefetch supports fire-and-forget work and waitFor hydrate-first resolution', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page)
+
+ await expect(page.getByTestId('enhanced-fire-wait-reason')).toHaveText(
+ 'waiting',
+ )
+ await dispatchHydrationIntent(
+ page,
+ 'enhanced-fire-and-forget-button',
+ 'pointerover',
+ )
+ await expect(page.getByTestId('enhanced-fire-wait-reason')).toHaveText(
+ 'prefetch',
+ )
+ await expect(page.getByTestId('enhanced-fire-status')).toHaveText('started')
+ await clickIntentAndExpectReplayedCount(
+ page,
+ 'enhanced-fire-and-forget-button',
+ 'enhanced-fire-and-forget-count',
+ '1',
+ )
+ await expect(page.getByTestId('enhanced-fire-query')).toHaveText('idle')
+ await page.getByTestId('enhanced-release-fire-prefetch').click()
+ await expect(page.getByTestId('enhanced-fire-query')).toHaveText('done')
+
+ await clickIntentAndExpectReplayedCount(
+ page,
+ 'enhanced-hydrate-first-button',
+ 'enhanced-hydrate-first-count',
+ '1',
+ )
+ await expect(page.getByTestId('enhanced-hydrate-first-reason')).toHaveText(
+ 'hydrate',
+ )
+ })
+
+ test('split=false procedural prefetch blocks hydration without requiring a child preload chunk', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page)
+
+ await expectRouteToStayUnhydrated(page, 'enhanced-runtime-only-button')
+ await expect(page.getByTestId('enhanced-runtime-wait-reason')).toHaveText(
+ 'waiting',
+ )
+ await dispatchHydrationIntent(
+ page,
+ 'enhanced-runtime-only-button',
+ 'pointerover',
+ )
+ await expect(page.getByTestId('enhanced-runtime-wait-reason')).toHaveText(
+ 'prefetch',
+ )
+ await page.getByTestId('enhanced-runtime-only-button').click()
+ await expect(
+ page.getByTestId('enhanced-runtime-only-button'),
+ ).toHaveAttribute('data-hydrated', 'false')
+ await expect(page.getByTestId('enhanced-runtime-only-count')).toHaveText(
+ '0',
+ )
+ await page.getByTestId('enhanced-release-runtime-prefetch').click()
+ await expect(page.getByTestId('enhanced-runtime-status')).toHaveText(
+ 'ready',
+ )
+ await expect(
+ page.getByTestId('enhanced-runtime-only-button'),
+ ).toHaveAttribute('data-hydrated', 'true')
+ await expect(page.getByTestId('enhanced-runtime-only-count')).toHaveText(
+ '1',
+ )
+ })
+
+ test('procedural prefetch aborts waiters and signals when boundaries unmount', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page)
+
+ await expectRouteToStayUnhydrated(page, 'enhanced-wait-abort-button')
+ await expect(page.getByTestId('enhanced-wait-abort-reason')).toHaveText(
+ 'waiting',
+ )
+ await page.getByTestId('enhanced-hide-wait-abort').click()
+ await expect(page.getByTestId('enhanced-wait-abort-reason')).toHaveText(
+ 'abort',
+ )
+
+ await expect(page.getByTestId('enhanced-abort-status')).toHaveText(
+ 'listening',
+ )
+ await page.getByTestId('enhanced-hide-abort').click()
+ await expect(page.getByTestId('enhanced-abort-status')).toHaveText(
+ 'aborted',
+ )
+ })
+
+ test('nested dynamic interaction boundaries delegate through outer boundaries', async ({
+ page,
+ }) => {
+ await gotoEnhanced(page)
+
+ await clickToHydrateThenClickAndExpectIncrement(
+ page,
+ 'enhanced-dynamic-nested-button',
+ 'enhanced-dynamic-nested-count',
+ )
+ await clickToHydrateThenClickAndExpectIncrement(
+ page,
+ 'enhanced-cross-file-nested-button',
+ 'enhanced-cross-file-nested-count',
+ )
+ })
+})
+
+test.describe('Hydrate CSS delivery', () => {
+ test('ships CSS for deferred, never, shared, and nested boundaries without JavaScript', async ({
+ browser,
+ request,
+ }) => {
+ const response = await request.get('/css')
+ const html = await response.text()
+
+ htmlContainsText(html, 'CSS Deferred Hydration')
+ htmlContainsText(html, 'Outer CSS')
+ htmlContainsText(html, 'Deferred CSS')
+ htmlContainsText(html, 'Never CSS')
+ htmlContainsText(html, 'Nested CSS')
+
+ const context = await browser.newContext({ javaScriptEnabled: false })
+ const page = await context.newPage()
+
+ try {
+ await page.goto('/css')
+
+ await expect(page.getByTestId('css-heading')).toHaveText(
+ 'CSS Deferred Hydration',
+ )
+ await expect(page.getByTestId('css-deferred')).toHaveText('Deferred CSS')
+ await expect(page.getByTestId('css-never')).toHaveText('Never CSS')
+ await expect(page.getByTestId('css-nested')).toHaveText('Nested CSS')
+
+ await expectCssProperty(page, 'css-outer', 'color', 'rgb(12, 34, 56)')
+ await expectCssProperty(
+ page,
+ 'css-deferred',
+ 'background-color',
+ 'rgb(23, 45, 67)',
+ )
+ await expectCssProperty(page, 'css-never', 'color', 'rgb(45, 67, 89)')
+ await expectCssProperty(
+ page,
+ 'css-shared-outer',
+ 'border-top-color',
+ 'rgb(98, 76, 54)',
+ )
+ await expectCssProperty(
+ page,
+ 'css-deferred',
+ 'border-top-color',
+ 'rgb(98, 76, 54)',
+ )
+ await expectCssProperty(
+ page,
+ 'css-nested',
+ 'border-left-color',
+ 'rgb(67, 89, 123)',
+ )
+ await expectCssProperty(page, 'css-nested', 'border-left-width', '5px')
+ } finally {
+ await context.close()
+ }
+ })
+
+ test('renders deferred content and omits never content after client-side navigation', async ({
+ page,
+ }) => {
+ await page.goto('/')
+ await expectClientRouterReady(page)
+ await page.getByRole('link', { name: 'CSS', exact: true }).click()
+ await expect(page).toHaveURL(/\/css$/)
+
+ await expect(page.getByTestId('css-heading')).toHaveText(
+ 'CSS Deferred Hydration',
+ )
+ await expect(page.getByTestId('css-deferred')).toHaveText('Deferred CSS')
+ await expect(page.getByTestId('css-never')).toHaveCount(0)
+ await expect(page.getByTestId('css-nested')).toHaveCount(0)
+
+ await expectCssProperty(
+ page,
+ 'css-deferred',
+ 'background-color',
+ 'rgb(23, 45, 67)',
+ )
+ })
+})
+
+test.describe('imported Hydrate boundaries', () => {
+ test('does not emit filtered shared Hydrate child JS on the initial document', async ({
+ request,
+ }) => {
+ const response = await request.get('/imported')
+ const html = await response.text()
+
+ htmlContainsText(html, 'Imported Hydrate')
+ htmlContainsText(html, 'Imported Hydrate Child')
+
+ await expect(
+ modulePreloadContentsContain(
+ request,
+ getModulePreloadHrefs(html),
+ 'imported-hydrate-child',
+ ),
+ ).resolves.toBe(false)
+ })
+
+ test('does not preload Hydrate child chunks before client navigation', async ({
+ page,
+ request,
+ }) => {
+ await page.goto('/')
+ await expect(page.getByTestId('home-heading')).toHaveText(
+ 'Deferred Hydration',
+ )
+ await expectClientRouterReady(page)
+
+ const link = page.getByRole('link', { name: 'imported Hydrate' })
+ await page.mouse.move(0, 0)
+ await link.hover()
+ await link.focus()
+
+ await expect(
+ modulePreloadContentsContain(
+ request,
+ await documentModulePreloadHrefs(page),
+ 'imported-hydrate-child',
+ ),
+ ).resolves.toBe(false)
+ await expect(
+ resourceContentsContain(page, request, 'imported-hydrate-child', (url) =>
+ isClientJavaScriptResource(url),
+ ),
+ ).resolves.toBe(false)
+
+ await page.getByRole('link', { name: 'imported Hydrate' }).click()
+ await expect(page).toHaveURL(/\/imported$/)
+ await expect(page.getByTestId('imported-hydrate-fallback')).toHaveCount(0)
+ await expect(page.getByTestId('imported-hydrate-child')).toContainText(
+ 'Imported Hydrate Child',
+ )
+ await page.getByTestId('imported-hydrate-child').click()
+ await expect(page.getByTestId('imported-hydrate-count')).toHaveText('1')
+ })
+})
diff --git a/e2e/solid-start/deferred-hydration/tsconfig.json b/e2e/solid-start/deferred-hydration/tsconfig.json
new file mode 100644
index 0000000000..76cf3401fa
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "target": "ES2024",
+ "allowJs": true,
+ "forceConsistentCasingInFileNames": true,
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+ "noEmit": true,
+ "types": ["vite/client"]
+ }
+}
diff --git a/e2e/solid-start/deferred-hydration/vite.config.ts b/e2e/solid-start/deferred-hydration/vite.config.ts
new file mode 100644
index 0000000000..70f388638a
--- /dev/null
+++ b/e2e/solid-start/deferred-hydration/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import { tanstackStart } from '@tanstack/solid-start/plugin/vite'
+import viteSolid from 'vite-plugin-solid'
+
+const outDir = process.env.E2E_DIST_DIR ?? 'dist-vite-ssr'
+
+export default defineConfig({
+ resolve: { tsconfigPaths: true },
+ build: {
+ outDir,
+ },
+ server: { port: 3000 },
+ plugins: [tanstackStart(), viteSolid({ ssr: true })],
+})
diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx
index 48de353b2e..6637c7542b 100644
--- a/packages/react-router/src/index.tsx
+++ b/packages/react-router/src/index.tsx
@@ -129,6 +129,7 @@ export type { AwaitOptions } from './awaited'
export { CatchBoundary, ErrorComponent } from './CatchBoundary'
export { ClientOnly, useHydrated } from './ClientOnly'
+export { reactUse, useLayoutEffect } from './utils'
export {
FileRoute,
diff --git a/packages/react-start-client/package.json b/packages/react-start-client/package.json
index a9f8bcf0a0..0b0c192d8d 100644
--- a/packages/react-start-client/package.json
+++ b/packages/react-start-client/package.json
@@ -48,6 +48,12 @@
"default": "./dist/esm/index.js"
}
},
+ "./hydration": {
+ "import": {
+ "types": "./dist/esm/hydration.d.ts",
+ "default": "./dist/esm/hydration.js"
+ }
+ },
"./package.json": "./package.json"
},
"sideEffects": false,
diff --git a/packages/react-start-client/src/GenericHydrate.tsx b/packages/react-start-client/src/GenericHydrate.tsx
new file mode 100644
index 0000000000..e92042a0d7
--- /dev/null
+++ b/packages/react-start-client/src/GenericHydrate.tsx
@@ -0,0 +1,436 @@
+'use client'
+
+import * as React from 'react'
+
+import { reactUse, useHydrated, useLayoutEffect } from '@tanstack/react-router'
+import { isServer } from '@tanstack/router-core/isServer'
+import {
+ hydrateIdAttribute,
+ hydrateWhenAttribute,
+} from '@tanstack/start-client-core/hydration/constants'
+import {
+ createResolvedGate,
+ getFallbackHtml,
+ getOrCreateGate,
+ onGateResolve,
+ releaseGate,
+ runHydrationStrategyCleanup,
+ saveFallbackHtml,
+ waitForHydrationPrefetchStrategy,
+} from '@tanstack/start-client-core/hydration/runtime'
+import { listenForDelegatedHydrationIntent } from '@tanstack/start-client-core/hydration'
+import type {
+ HydrationRuntimeContext,
+ HydrationStrategy,
+ HydrationWhen,
+} from '@tanstack/start-client-core/hydration'
+import type { HydrationGateRecord } from '@tanstack/start-client-core/hydration/runtime'
+import type { HydrateProps, InternalHydrateProps } from './Hydrate'
+
+type Gate = HydrationGateRecord & { promise: Promise }
+type PrefetchController = {
+ abortController: AbortController
+ hydrationRequested: boolean
+ hydrationListeners: Set<() => void>
+ hydrationResolvePending: boolean
+ started: boolean
+ promise?: Promise
+ cleanup?: () => void
+}
+
+const dynamicType = 'dynamic'
+const dynamicHydrateStrategy = {
+ _t: dynamicType,
+ _d: () => true,
+} satisfies HydrationStrategy
+
+function shouldDeferHydration(strategy: HydrationStrategy) {
+ return strategy._d ? strategy._d() : strategy._t !== 'load'
+}
+
+function useLatest(value: T) {
+ const ref = React.useRef(value)
+ ref.current = value
+ return ref
+}
+
+function useHydrationGate(props: InternalHydrateProps) {
+ const hydrated = useHydrated()
+ const reactId = React.useId()
+ const id = props.h ? `${props.h}${reactId}` : reactId
+ const when = props.when
+ const isDynamicHydrate = typeof when === 'function'
+ const dynamicHydrateStrategyRef = React.useRef(
+ undefined,
+ )
+ if (isDynamicHydrate) {
+ dynamicHydrateStrategyRef.current ??=
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined')
+ ? dynamicHydrateStrategy
+ : when()
+ }
+ const hydrateStrategy = isDynamicHydrate
+ ? dynamicHydrateStrategyRef.current!
+ : when
+ const markerHydrateType: HydrationWhen = isDynamicHydrate
+ ? dynamicType
+ : hydrateStrategy._t!
+ const [prefetchError, setPrefetchError] = React.useState()
+ const latestRef = useLatest({
+ prefetch: props.prefetch,
+ preload: props.p,
+ })
+ const gateRef = React.useRef(undefined)
+ const markerElementRef = React.useRef(null)
+ const shouldPreserveServerHTMLRef = React.useRef(
+ undefined,
+ )
+ const shouldDeferInitialHydrationRef = React.useRef(
+ undefined,
+ )
+ const didPrefetchRef = React.useRef(false)
+ const prefetchControllerRef = React.useRef(
+ undefined,
+ )
+
+ prefetchControllerRef.current ??= {
+ abortController: new AbortController(),
+ hydrationRequested: false,
+ hydrationListeners: new Set<() => void>(),
+ hydrationResolvePending: false,
+ started: false,
+ }
+
+ shouldPreserveServerHTMLRef.current ??=
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined') || !hydrated
+ shouldDeferInitialHydrationRef.current ??=
+ !hydrated && shouldDeferHydration(hydrateStrategy)
+
+ if (!gateRef.current) {
+ gateRef.current =
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined')
+ ? createResolvedGate(id, hydrateStrategy._t!)
+ : getOrCreateGate(id, hydrateStrategy._t!)
+ }
+
+ gateRef.current.when = hydrateStrategy._t!
+
+ if (
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ !(isServer ?? typeof window === 'undefined') &&
+ hydrateStrategy._t !== 'never' &&
+ (!shouldDeferInitialHydrationRef.current ||
+ !shouldDeferHydration(hydrateStrategy))
+ ) {
+ gateRef.current.resolve()
+ }
+
+ const markerRef = React.useCallback(
+ (element: HTMLDivElement | null) => {
+ markerElementRef.current = element
+ if (element) {
+ if (
+ hydrateStrategy._t === 'never' &&
+ !shouldPreserveServerHTMLRef.current
+ ) {
+ element.replaceChildren()
+ }
+ saveFallbackHtml(id, element)
+ }
+ },
+ [hydrateStrategy._t, id],
+ )
+
+ React.useEffect(() => {
+ const gate = gateRef.current!
+ return () => {
+ const controller = prefetchControllerRef.current
+ controller?.abortController.abort()
+ controller?.cleanup?.()
+ controller?.hydrationListeners.clear()
+ releaseGate(gate)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ if (
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined') ||
+ !latestRef.current.prefetch
+ ) {
+ return
+ }
+
+ const controller = prefetchControllerRef.current!
+ if (controller.started) return
+ controller.started = true
+
+ const onHydrate = (listener: () => void) => {
+ if (controller.hydrationRequested) {
+ listener()
+ return () => {}
+ }
+
+ controller.hydrationListeners.add(listener)
+ return () => {
+ controller.hydrationListeners.delete(listener)
+ }
+ }
+
+ const preload = () => latestRef.current.preload?.() ?? Promise.resolve()
+ const prefetchInput = latestRef.current.prefetch
+
+ if (typeof prefetchInput === 'function') {
+ const promise = Promise.resolve()
+ .then(() =>
+ prefetchInput({
+ element: markerElementRef.current,
+ signal: controller.abortController.signal,
+ preload,
+ waitFor: (strategy) =>
+ waitForHydrationPrefetchStrategy(strategy, {
+ element: markerElementRef.current,
+ signal: controller.abortController.signal,
+ onHydrate,
+ }),
+ }),
+ )
+ .then(() => undefined)
+
+ controller.promise = promise
+ promise.catch((error) => {
+ if (!controller.abortController.signal.aborted) {
+ setPrefetchError(error)
+ }
+ })
+ return
+ }
+
+ if (!latestRef.current.preload) return
+
+ const prefetch = () => {
+ if (didPrefetchRef.current) return
+ didPrefetchRef.current = true
+ void preload()
+ }
+
+ controller.cleanup = runHydrationStrategyCleanup(
+ prefetchInput._s?.({
+ element: markerElementRef.current,
+ prefetch,
+ }),
+ )
+ }, [hydrateStrategy, latestRef])
+
+ useLayoutEffect(() => {
+ const gate = gateRef.current!
+ if (
+ !shouldDeferInitialHydrationRef.current ||
+ hydrateStrategy._t === 'never'
+ ) {
+ return
+ }
+
+ if (gate.resolved) {
+ return
+ }
+
+ const cleanups: Array<() => void> = []
+ let removeResolveListener = () => {}
+ let disposed = false
+ const resolveGate = gate.resolve
+
+ const cleanup = () => {
+ if (disposed) return
+ disposed = true
+ if (gate.resolve === requestHydration) {
+ gate.resolve = resolveGate
+ }
+ removeResolveListener()
+ cleanups.forEach((fn) => fn())
+ }
+
+ const addCleanup = (fn: void | (() => void)) => {
+ if (!fn) return
+ if (disposed || gate.resolved) {
+ fn()
+ return
+ }
+ cleanups.push(fn)
+ }
+
+ const requestHydration = () => {
+ const controller = prefetchControllerRef.current!
+ if (!controller.hydrationRequested) {
+ controller.hydrationRequested = true
+ controller.hydrationListeners.forEach((listener) => listener())
+ controller.hydrationListeners.clear()
+ }
+
+ if (!controller.promise) {
+ resolveGate()
+ return
+ }
+ if (controller.hydrationResolvePending) return
+ controller.hydrationResolvePending = true
+
+ controller.promise.then(
+ () => resolveGate(),
+ (error) => {
+ if (!controller.abortController.signal.aborted) {
+ setPrefetchError(error)
+ }
+ },
+ )
+ }
+
+ gate.resolve = requestHydration
+ removeResolveListener = onGateResolve(gate, cleanup)
+
+ const context: HydrationRuntimeContext = {
+ element: markerElementRef.current,
+ gate,
+ }
+ addCleanup(runHydrationStrategyCleanup(hydrateStrategy._s?.(context)))
+
+ if (hydrateStrategy._t !== 'interaction') {
+ addCleanup(
+ runHydrationStrategyCleanup(
+ markerElementRef.current
+ ? listenForDelegatedHydrationIntent(
+ markerElementRef.current,
+ context,
+ )
+ : undefined,
+ ),
+ )
+ }
+
+ return cleanup
+ }, [hydrateStrategy, latestRef])
+
+ return {
+ gate: gateRef.current,
+ markerRef,
+ markerElementRef,
+ hydrateStrategy,
+ markerHydrateType,
+ prefetchError,
+ shouldPreserveServerHTML: shouldPreserveServerHTMLRef.current,
+ }
+}
+
+function HydrationGate(props: { gate: Gate; children: React.ReactNode }) {
+ if (
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ isServer ??
+ typeof window === 'undefined'
+ ) {
+ return props.children as React.JSX.Element
+ }
+
+ if (props.gate.resolved) {
+ return props.children as React.JSX.Element
+ }
+
+ if (!reactUse) {
+ throw props.gate.promise
+ }
+
+ reactUse(props.gate.promise)
+
+ return props.children as React.JSX.Element
+}
+
+function HydratedBoundary(props: {
+ id: string
+ onHydrated?: () => void
+ onStrategyHydrated?: (id: string) => void
+ children: React.ReactNode
+}) {
+ const { id, onHydrated, onStrategyHydrated } = props
+ const didHydrateRef = React.useRef(false)
+
+ React.useEffect(() => {
+ if (didHydrateRef.current) return
+ didHydrateRef.current = true
+ onHydrated?.()
+ onStrategyHydrated?.(id)
+ }, [id, onHydrated, onStrategyHydrated])
+
+ return props.children as React.JSX.Element
+}
+
+export function GenericHydrate(props: HydrateProps): React.JSX.Element {
+ const internalProps = props as InternalHydrateProps
+ const {
+ gate,
+ hydrateStrategy,
+ markerHydrateType,
+ markerElementRef,
+ markerRef,
+ prefetchError,
+ shouldPreserveServerHTML,
+ } = useHydrationGate(internalProps)
+ if (prefetchError) throw prefetchError
+
+ const fallback = shouldPreserveServerHTML
+ ? (() => {
+ const html = getFallbackHtml(gate.id)
+ return html ? (
+
+ ) : null
+ })()
+ : (props.fallback ?? null)
+ const markerAttributes =
+ markerHydrateType === dynamicType ? undefined : hydrateStrategy._a?.()
+
+ const hydrateType = hydrateStrategy._t!
+
+ if (hydrateType === 'never' && !shouldPreserveServerHTML) {
+ return (
+
+ {props.fallback ?? null}
+
+ )
+ }
+
+ return (
+
+
+
+ {
+ markerElementRef.current?.removeAttribute(hydrateWhenAttribute)
+ hydrateStrategy._o?.(id)
+ }}
+ >
+ {props.children}
+
+
+
+
+ )
+}
diff --git a/packages/react-start-client/src/Hydrate.tsx b/packages/react-start-client/src/Hydrate.tsx
new file mode 100644
index 0000000000..019730f3be
--- /dev/null
+++ b/packages/react-start-client/src/Hydrate.tsx
@@ -0,0 +1,107 @@
+'use client'
+
+import * as React from 'react'
+
+import { isServer } from '@tanstack/router-core/isServer'
+import type {
+ HydrationStrategy as CoreHydrationStrategy,
+ HydrationPrefetchFunction,
+ HydrationPrefetchStrategy,
+ HydrationWhen,
+} from '@tanstack/start-client-core/hydration'
+
+export type {
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ HydrationPrefetchContext,
+ HydrationPrefetchFunction,
+ HydrationPrefetchStrategy,
+ HydrationPrefetchWaitReason,
+ HydrationWhen,
+} from '@tanstack/start-client-core/hydration'
+
+export type ReactHydrationStrategy<
+ TWhen extends HydrationWhen = HydrationWhen,
+ TCanPrefetch extends boolean = boolean,
+> = CoreHydrationStrategy & {
+ _h: (this: ReactHydrationStrategy, props: HydrateProps) => React.JSX.Element
+}
+
+export type HydrationStrategy<
+ TWhen extends HydrationWhen = HydrationWhen,
+ TCanPrefetch extends boolean = boolean,
+> = ReactHydrationStrategy
+
+export type HydrateWhen =
+ | ReactHydrationStrategy
+ | (() => ReactHydrationStrategy)
+
+type HydrateCommonOptions = {
+ when: HydrateWhen
+ fallback?: React.ReactNode
+ onHydrated?: () => void
+}
+
+export type HydrateOptions =
+ | (HydrateCommonOptions & {
+ prefetch?: never
+ split?: boolean
+ })
+ | (HydrateCommonOptions & {
+ prefetch: HydrationPrefetchStrategy
+ split?: true
+ })
+ | (HydrateCommonOptions & {
+ prefetch: HydrationPrefetchFunction
+ split?: boolean
+ })
+
+export type HydrateProps = HydrateOptions & {
+ children: React.ReactNode
+}
+
+export type InternalHydrateProps = HydrateProps & {
+ h?: string
+ p?: () => Promise
+}
+
+const dynamicType = 'dynamic'
+const hydrateIdAttribute = 'data-ts-hydrate-id'
+const hydrateWhenAttribute = 'data-ts-hydrate-when'
+
+/* @__NO_SIDE_EFFECTS__ */
+function ServerDynamicHydrate(props: HydrateProps): React.JSX.Element {
+ const internalProps = props as InternalHydrateProps
+ const reactId = React.useId()
+ const id = internalProps.h ? `${internalProps.h}${reactId}` : reactId
+
+ return (
+
+
+ {props.children}
+
+
+ )
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function Hydrate(props: HydrateProps): React.JSX.Element {
+ if (typeof props.when === 'function') {
+ if (
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ isServer ??
+ typeof window === 'undefined'
+ ) {
+ return
+ }
+
+ return props.when()._h(props)
+ }
+
+ return props.when._h(props)
+}
diff --git a/packages/react-start-client/src/hydration.ts b/packages/react-start-client/src/hydration.ts
new file mode 100644
index 0000000000..fad458175c
--- /dev/null
+++ b/packages/react-start-client/src/hydration.ts
@@ -0,0 +1,22 @@
+'use client'
+
+export { condition, interaction, media } from './hydration/generic'
+export { idle } from './hydration/idle'
+export { load } from './hydration/load'
+export { never } from './hydration/never'
+export { visible } from './hydration/visible'
+export type {
+ HydrationCondition,
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ IdleHydrationOptions,
+ HydrationPrefetchContext,
+ HydrationPrefetchFunction,
+ HydrationPrefetchWhen,
+ HydrationPrefetchStrategy,
+ HydrationPrefetchWaitReason,
+ HydrationStrategyTypes,
+ HydrationWhen,
+ VisibleHydrationOptions,
+} from '@tanstack/start-client-core/hydration'
+export type { HydrationStrategy, ReactHydrationStrategy } from './Hydrate'
diff --git a/packages/react-start-client/src/hydration/generic.ts b/packages/react-start-client/src/hydration/generic.ts
new file mode 100644
index 0000000000..5ee6d3c3d6
--- /dev/null
+++ b/packages/react-start-client/src/hydration/generic.ts
@@ -0,0 +1,43 @@
+'use client'
+
+import {
+ condition as coreCondition,
+ interaction as coreInteraction,
+ media as coreMedia,
+ withHydrationRenderer,
+} from '@tanstack/start-client-core/hydration'
+import { GenericHydrate } from '../GenericHydrate'
+import type {
+ HydrationCondition,
+ HydrationInteractionEvents,
+ HydrationPrefetchStrategy,
+} from '@tanstack/start-client-core/hydration'
+import type { ReactHydrationStrategy } from '../Hydrate'
+
+/* @__NO_SIDE_EFFECTS__ */
+export function media(
+ query: string,
+): ReactHydrationStrategy<'media', true> & HydrationPrefetchStrategy<'media'> {
+ return /* @__PURE__ */ withHydrationRenderer(coreMedia(query), GenericHydrate)
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function condition(
+ condition: HydrationCondition,
+): ReactHydrationStrategy<'condition', false> {
+ return /* @__PURE__ */ withHydrationRenderer(
+ coreCondition(condition),
+ GenericHydrate,
+ )
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function interaction(options?: {
+ events?: HydrationInteractionEvents
+}): ReactHydrationStrategy<'interaction', true> &
+ HydrationPrefetchStrategy<'interaction'> {
+ return /* @__PURE__ */ withHydrationRenderer(
+ coreInteraction(options),
+ GenericHydrate,
+ )
+}
diff --git a/packages/react-start-client/src/hydration/idle.ts b/packages/react-start-client/src/hydration/idle.ts
new file mode 100644
index 0000000000..58c7f265eb
--- /dev/null
+++ b/packages/react-start-client/src/hydration/idle.ts
@@ -0,0 +1,22 @@
+'use client'
+
+import {
+ idle as coreIdle,
+ withHydrationRenderer,
+} from '@tanstack/start-client-core/hydration'
+import { GenericHydrate } from '../GenericHydrate'
+import type {
+ HydrationPrefetchStrategy,
+ IdleHydrationOptions,
+} from '@tanstack/start-client-core/hydration'
+import type { ReactHydrationStrategy } from '../Hydrate'
+
+/* @__NO_SIDE_EFFECTS__ */
+export function idle(
+ options: IdleHydrationOptions = {},
+): ReactHydrationStrategy<'idle', true> & HydrationPrefetchStrategy<'idle'> {
+ return /* @__PURE__ */ withHydrationRenderer(
+ coreIdle(options),
+ GenericHydrate,
+ )
+}
diff --git a/packages/react-start-client/src/hydration/load.tsx b/packages/react-start-client/src/hydration/load.tsx
new file mode 100644
index 0000000000..006f4e6952
--- /dev/null
+++ b/packages/react-start-client/src/hydration/load.tsx
@@ -0,0 +1,49 @@
+'use client'
+
+import * as React from 'react'
+
+import {
+ load as coreLoad,
+ withHydrationRenderer,
+} from '@tanstack/start-client-core/hydration'
+import type { HydrationPrefetchStrategy } from '@tanstack/start-client-core/hydration'
+import type { HydrateProps, ReactHydrationStrategy } from '../Hydrate'
+
+function HydratedBoundary(props: {
+ onHydrated?: () => void
+ children: React.ReactNode
+}) {
+ const { onHydrated, children } = props
+ const didHydrateRef = React.useRef(false)
+
+ React.useEffect(() => {
+ if (didHydrateRef.current) return
+ didHydrateRef.current = true
+ onHydrated?.()
+ }, [onHydrated])
+
+ return children as React.JSX.Element
+}
+
+export function LoadHydrate(props: HydrateProps): React.JSX.Element {
+ return (
+
+
+
+ {props.children}
+
+
+
+ )
+}
+
+const loadStrategy = /* @__PURE__ */ withHydrationRenderer(
+ coreLoad(),
+ LoadHydrate,
+) as ReactHydrationStrategy<'load', true> & HydrationPrefetchStrategy<'load'>
+
+/* @__NO_SIDE_EFFECTS__ */
+export function load(): ReactHydrationStrategy<'load', true> &
+ HydrationPrefetchStrategy<'load'> {
+ return loadStrategy
+}
diff --git a/packages/react-start-client/src/hydration/never.tsx b/packages/react-start-client/src/hydration/never.tsx
new file mode 100644
index 0000000000..250a8ed1ae
--- /dev/null
+++ b/packages/react-start-client/src/hydration/never.tsx
@@ -0,0 +1,97 @@
+'use client'
+
+import * as React from 'react'
+
+import { reactUse, useHydrated } from '@tanstack/react-router'
+import { isServer } from '@tanstack/router-core/isServer'
+import {
+ never as coreNever,
+ withHydrationRenderer,
+} from '@tanstack/start-client-core/hydration'
+import {
+ hydrateIdAttribute,
+ hydrateWhenAttribute,
+} from '@tanstack/start-client-core/hydration/constants'
+import {
+ getFallbackHtml,
+ saveFallbackHtml,
+} from '@tanstack/start-client-core/hydration/runtime'
+import type {
+ HydrateProps,
+ InternalHydrateProps,
+ ReactHydrationStrategy,
+} from '../Hydrate'
+
+const neverType = 'never'
+const neverPromise = new Promise(() => {})
+
+function NeverGate(props: { children: React.ReactNode }) {
+ if (
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ isServer ??
+ typeof window === 'undefined'
+ ) {
+ return props.children as React.JSX.Element
+ }
+
+ if (!reactUse) {
+ throw neverPromise
+ }
+
+ reactUse(neverPromise)
+
+ return props.children as React.JSX.Element
+}
+
+export function NeverHydrate(props: HydrateProps): React.JSX.Element {
+ const internalProps = props as InternalHydrateProps
+ const hydrated = useHydrated()
+ const reactId = React.useId()
+ const id = internalProps.h ? `${internalProps.h}${reactId}` : reactId
+ const shouldPreserveServerHTMLRef = React.useRef(
+ undefined,
+ )
+ shouldPreserveServerHTMLRef.current ??=
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined') || !hydrated
+ const markerRef = React.useCallback(
+ (element: HTMLDivElement | null) => {
+ if (!element) return
+ if (!shouldPreserveServerHTMLRef.current) {
+ element.replaceChildren()
+ } else {
+ saveFallbackHtml(id, element)
+ }
+ },
+ [id],
+ )
+ const markerProps = {
+ ref: markerRef,
+ [hydrateIdAttribute]: id,
+ [hydrateWhenAttribute]: neverType,
+ }
+ const fallback = (() => {
+ const html = getFallbackHtml(id)
+ return html ? (
+
+ ) : (
+ (props.fallback ?? null)
+ )
+ })()
+
+ return (
+
+
+ {props.children}
+
+
+ )
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function never(): ReactHydrationStrategy<'never', false> {
+ return /* @__PURE__ */ withHydrationRenderer(coreNever(), NeverHydrate)
+}
diff --git a/packages/react-start-client/src/hydration/visible.tsx b/packages/react-start-client/src/hydration/visible.tsx
new file mode 100644
index 0000000000..e274285cb8
--- /dev/null
+++ b/packages/react-start-client/src/hydration/visible.tsx
@@ -0,0 +1,139 @@
+'use client'
+
+import * as React from 'react'
+
+import { reactUse } from '@tanstack/react-router'
+import { isServer } from '@tanstack/router-core/isServer'
+import type {
+ HydrationPrefetchStrategy,
+ VisibleHydrationOptions,
+} from '@tanstack/start-client-core/hydration'
+import type {
+ HydrateProps,
+ InternalHydrateProps,
+ ReactHydrationStrategy,
+} from '../Hydrate'
+
+type VisibleGate = {
+ p: Promise
+ r: boolean
+ s: () => void
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+function HydrationBoundary(props: {
+ g: VisibleGate
+ o?: () => void
+ children?: React.ReactNode
+}) {
+ const { g, o } = props
+
+ if (!g.r) {
+ if (!reactUse) {
+ throw g.p
+ }
+
+ reactUse(g.p)
+ }
+
+ React.useEffect(() => {
+ o?.()
+ }, [o])
+
+ return props.children as React.JSX.Element
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function VisibleHydrate(
+ this: ReactHydrationStrategy,
+ props: HydrateProps,
+): React.JSX.Element {
+ const strategy = this as ReactHydrationStrategy<'visible', true>
+ const prefetchStrategy = props.prefetch
+ const preload = (props as InternalHydrateProps).p
+ const markerRef = React.useRef(null)
+ const [gate] = React.useState(() => {
+ let resolvePromise!: () => void
+ const nextGate: VisibleGate = {
+ p: new Promise((resolve) => {
+ resolvePromise = resolve
+ }),
+ r: false,
+ s: () => {
+ nextGate.r = true
+ resolvePromise()
+ },
+ }
+ if (
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ isServer ??
+ typeof window === 'undefined'
+ ) {
+ nextGate.s()
+ }
+
+ return nextGate
+ })
+
+ React.useEffect(() => {
+ if (!preload || typeof prefetchStrategy === 'function') {
+ return
+ }
+
+ return prefetchStrategy?._s?.({
+ element: markerRef.current,
+ prefetch: preload,
+ })
+ }, [prefetchStrategy, preload])
+
+ React.useEffect(() => {
+ if (gate.r) return
+
+ return strategy._s?.({
+ element: markerRef.current,
+ gate: gate as never,
+ })
+ }, [gate, strategy])
+
+ return (
+
+
+
+ {props.children}
+
+
+
+ )
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function visible(
+ options?: VisibleHydrationOptions,
+): ReactHydrationStrategy<'visible', true> &
+ HydrationPrefetchStrategy<'visible'> {
+ const rootMargin = options?.rootMargin ?? '600px'
+ const threshold = options?.threshold ?? 0
+
+ return {
+ _s: ({ element, gate, prefetch }) => {
+ const callback = prefetch || (gate as never as VisibleGate).s
+
+ if (!element) {
+ callback()
+ return
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (!entries[0]!.isIntersecting) return
+ observer.disconnect()
+ callback()
+ },
+ { rootMargin, threshold },
+ )
+ observer.observe(element)
+ return () => observer.disconnect()
+ },
+ _h: VisibleHydrate,
+ }
+}
diff --git a/packages/react-start-client/src/index.tsx b/packages/react-start-client/src/index.tsx
index aa73990a57..669b1e0e3f 100644
--- a/packages/react-start-client/src/index.tsx
+++ b/packages/react-start-client/src/index.tsx
@@ -1,2 +1,18 @@
+'use client'
+
export { StartClient } from './StartClient'
export { hydrateStart } from './hydrateStart'
+export { Hydrate } from './Hydrate'
+export type {
+ HydrateOptions,
+ HydrateProps,
+ HydrateWhen,
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ HydrationPrefetchContext,
+ HydrationPrefetchFunction,
+ HydrationPrefetchStrategy,
+ HydrationPrefetchWaitReason,
+ HydrationStrategy,
+ HydrationWhen,
+} from './Hydrate'
diff --git a/packages/react-start-client/src/tests/Hydrate.test-d.tsx b/packages/react-start-client/src/tests/Hydrate.test-d.tsx
new file mode 100644
index 0000000000..2e3726edfa
--- /dev/null
+++ b/packages/react-start-client/src/tests/Hydrate.test-d.tsx
@@ -0,0 +1,147 @@
+import { expectTypeOf, test } from 'vitest'
+import { visible } from '../hydration'
+import { Hydrate } from '../Hydrate'
+import type {
+ HydrateOptions,
+ HydrateProps,
+ HydrationPrefetchFunction,
+ HydrationPrefetchStrategy,
+ HydrationStrategy,
+} from '../Hydrate'
+import type { HydrationStrategy as CoreHydrationStrategy } from '@tanstack/start-client-core/hydration'
+import type { ReactNode } from 'react'
+
+type CommonHydrateProps = {
+ fallback?: ReactNode
+ onHydrated?: () => void
+ children: ReactNode
+}
+
+type SplitHydrateProps = CommonHydrateProps & {
+ when: HydrationStrategy | (() => HydrationStrategy)
+ prefetch?: never
+ split?: boolean
+}
+
+type PrefetchHydrateProps = CommonHydrateProps & {
+ when: HydrationStrategy | (() => HydrationStrategy)
+ prefetch: HydrationPrefetchStrategy
+ split?: true
+}
+
+type FunctionPrefetchHydrateProps = CommonHydrateProps & {
+ when: HydrationStrategy | (() => HydrationStrategy)
+ prefetch: HydrationPrefetchFunction
+ split?: boolean
+}
+
+test('Hydrate component accepts the public HydrateProps type', () => {
+ expectTypeOf(Hydrate).toBeFunction()
+ expectTypeOf(Hydrate).parameter(0).branded.toEqualTypeOf()
+})
+
+test('HydrateOptions supports reusable spread props', () => {
+ const belowFoldProps = {
+ when: () => visible({ rootMargin: '800px' }),
+ } satisfies HydrateOptions
+
+ expectTypeOf(belowFoldProps).toMatchTypeOf()
+
+ const withFunctionPrefetch = {
+ when: visible(),
+ split: false,
+ prefetch: (ctx) => {
+ expectTypeOf(ctx.element).toEqualTypeOf()
+ expectTypeOf(ctx.signal).toEqualTypeOf()
+ expectTypeOf(ctx.preload).returns.toEqualTypeOf>()
+ expectTypeOf(ctx.waitFor).returns.toEqualTypeOf<
+ Promise<'prefetch' | 'hydrate' | 'abort'>
+ >()
+ },
+ } satisfies HydrateOptions
+
+ expectTypeOf(withFunctionPrefetch).toMatchTypeOf()
+})
+
+test('Hydrate props are exact for strategy and prefetch forms', () => {
+ expectTypeOf<
+ Extract
+ >().branded.toEqualTypeOf()
+ expectTypeOf<
+ Extract
+ >().branded.toEqualTypeOf()
+ expectTypeOf<
+ Extract
+ >().branded.toEqualTypeOf()
+})
+
+test('Hydrate requires a strategy', () => {
+ expectTypeOf<{
+ when: HydrationStrategy
+ children: ReactNode
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: () => HydrationStrategy
+ children: ReactNode
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ children: ReactNode
+ }>().not.toMatchTypeOf()
+
+ expectTypeOf<{
+ when: () => true
+ children: ReactNode
+ }>().not.toMatchTypeOf()
+
+ expectTypeOf<{
+ when: false
+ children: ReactNode
+ }>().not.toMatchTypeOf()
+})
+
+test('Hydrate requires a framework-renderable strategy', () => {
+ expectTypeOf().not.toMatchTypeOf()
+ expectTypeOf>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: CoreHydrationStrategy
+ children: ReactNode
+ }>().not.toMatchTypeOf()
+})
+
+test('Hydrate enforces prefetch only with split boundaries', () => {
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchStrategy
+ children: ReactNode
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchStrategy
+ split: true
+ children: ReactNode
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchStrategy
+ split: false
+ children: ReactNode
+ }>().not.toMatchTypeOf()
+
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchFunction
+ split: false
+ children: ReactNode
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchFunction
+ children: ReactNode
+ }>().toMatchTypeOf()
+})
diff --git a/packages/react-start-client/src/tests/Hydrate.test.tsx b/packages/react-start-client/src/tests/Hydrate.test.tsx
new file mode 100644
index 0000000000..0d0ce1faad
--- /dev/null
+++ b/packages/react-start-client/src/tests/Hydrate.test.tsx
@@ -0,0 +1,676 @@
+import * as React from 'react'
+import { renderToString } from 'react-dom/server'
+import { hydrateRoot } from 'react-dom/client'
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { hydrateIdAttribute } from '@tanstack/start-client-core/hydration/constants'
+import { Hydrate } from '../Hydrate'
+import { condition, idle, interaction, load, never } from '../hydration'
+import type { HydrateProps, HydrationPrefetchStrategy } from '../Hydrate'
+
+const InternalHydrate = Hydrate as React.ComponentType<
+ HydrateProps & { p?: () => Promise; h?: string }
+>
+
+const hydrateIdSelector = `[${hydrateIdAttribute}]`
+
+function getMarker() {
+ const marker = document.querySelector(hydrateIdSelector)
+
+ if (!marker) {
+ throw new Error('Expected Hydrate marker to exist')
+ }
+
+ return marker
+}
+
+function InteractiveChild() {
+ const [count, setCount] = React.useState(0)
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+ setCount((prev) => prev + 1)}
+ >
+ {count}
+
+ )
+}
+
+function NamedInteractiveChild(props: { id: string }) {
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return (
+
+ {props.id}
+
+ )
+}
+
+function createSuspendingChild() {
+ let resolve!: () => void
+ let resolved = false
+ const promise = new Promise((resolvePromise) => {
+ resolve = () => {
+ resolved = true
+ resolvePromise()
+ }
+ })
+
+ function SuspendingChild() {
+ if (!resolved) {
+ throw promise
+ }
+
+ return child
+ }
+
+ return { resolve, SuspendingChild }
+}
+
+async function expectNoHydrationAfterDefaultIntentEvents() {
+ const marker = getMarker()
+
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'false',
+ )
+
+ await act(async () => {
+ fireEvent.pointerEnter(marker)
+ fireEvent.focusIn(marker)
+ fireEvent.pointerDown(marker)
+ fireEvent.click(marker)
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ })
+
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'false',
+ )
+}
+
+async function fireIntent(event: () => void) {
+ await act(async () => {
+ event()
+ await Promise.resolve()
+ })
+}
+
+async function renderAsync(ui: React.ReactElement) {
+ await act(async () => {
+ render(ui)
+ await Promise.resolve()
+ })
+}
+
+async function hydrateFromServer(ui: React.ReactElement) {
+ vi.stubGlobal('window', undefined)
+ const html = renderToString(ui)
+ vi.unstubAllGlobals()
+
+ const container = document.createElement('div')
+ document.body.append(container)
+ container.innerHTML = html
+
+ let root!: ReturnType
+ await act(async () => {
+ root = hydrateRoot(container, ui)
+ await Promise.resolve()
+ })
+
+ return { container, html, root }
+}
+
+async function unmountHydratedRoot(
+ root: ReturnType,
+ container: Element,
+) {
+ await act(async () => {
+ root.unmount()
+ })
+ container.remove()
+}
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+describe('Hydrate', () => {
+ it('uses a single custom interaction event instead of the default intent events', async () => {
+ const { container, html, root } = await hydrateFromServer(
+ fallback}
+ >
+
+ ,
+ )
+
+ try {
+ expect(html).toContain('data-testid="child"')
+ expect(html).not.toContain('data-testid="fallback"')
+ expect(screen.queryByTestId('fallback')).toBeNull()
+ await expectNoHydrationAfterDefaultIntentEvents()
+
+ await fireIntent(() =>
+ getMarker().dispatchEvent(
+ new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
+ ),
+ )
+
+ await waitFor(() =>
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'true',
+ ),
+ )
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('uses every event in a custom interaction event list', async () => {
+ const { container, root } = await hydrateFromServer(
+ fallback}
+ >
+
+ ,
+ )
+
+ try {
+ expect(screen.queryByTestId('fallback')).toBeNull()
+ await expectNoHydrationAfterDefaultIntentEvents()
+
+ await fireIntent(() =>
+ getMarker().dispatchEvent(
+ new MouseEvent('contextmenu', { bubbles: true, cancelable: true }),
+ ),
+ )
+
+ await waitFor(() =>
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'true',
+ ),
+ )
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('omits never content when mounted after the app is already hydrated', async () => {
+ await renderAsync(
+
+
+ ,
+ )
+
+ expect(screen.queryByTestId('child')).toBeNull()
+ })
+
+ it('shows fallback for a client-only mount while children suspend', async () => {
+ const { resolve, SuspendingChild } = createSuspendingChild()
+
+ await renderAsync(
+ fallback}
+ >
+
+ ,
+ )
+
+ expect(screen.getByTestId('fallback').textContent).toBe('fallback')
+ expect(screen.queryByTestId('child')).toBeNull()
+
+ await act(async () => {
+ resolve()
+ await Promise.resolve()
+ })
+
+ await screen.findByTestId('child')
+ expect(screen.queryByTestId('fallback')).toBeNull()
+ })
+
+ it('does not use fallback for an initial never boundary', async () => {
+ const { container, html, root } = await hydrateFromServer(
+ fallback}
+ >
+
+ ,
+ )
+
+ try {
+ expect(html).toContain('data-testid="child"')
+ expect(html).not.toContain('data-testid="fallback"')
+ expect(screen.queryByTestId('fallback')).toBeNull()
+
+ fireEvent.click(screen.getByTestId('child'))
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'false',
+ )
+ expect(screen.getByTestId('child').textContent).toBe('0')
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('keeps repeated split boundaries independently gated', async () => {
+ const { container, root } = await hydrateFromServer(
+ <>
+
+
+
+
+
+
+ >,
+ )
+
+ try {
+ const markers = container.querySelectorAll(hydrateIdSelector)
+
+ expect(markers).toHaveLength(2)
+ expect(markers[0]!.getAttribute(hydrateIdAttribute)).not.toBe(
+ markers[1]!.getAttribute(hydrateIdAttribute),
+ )
+ expect(
+ screen.getByTestId('child-one').getAttribute('data-hydrated'),
+ ).toBe('false')
+ expect(
+ screen.getByTestId('child-two').getAttribute('data-hydrated'),
+ ).toBe('false')
+
+ await fireIntent(() =>
+ markers[0]!.dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
+ ),
+ )
+
+ await waitFor(() =>
+ expect(
+ screen.getByTestId('child-one').getAttribute('data-hydrated'),
+ ).toBe('true'),
+ )
+ expect(
+ screen.getByTestId('child-two').getAttribute('data-hydrated'),
+ ).toBe('false')
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('fires onHydrated once after the client hydration commit', async () => {
+ const onHydrated = vi.fn()
+ const app = (
+
+ child
+
+ )
+
+ vi.stubGlobal('window', undefined)
+ const html = renderToString(app)
+ expect(html).toContain('child')
+ expect(onHydrated).not.toHaveBeenCalled()
+ vi.unstubAllGlobals()
+
+ const container = document.createElement('div')
+ document.body.append(container)
+ container.innerHTML = html
+
+ let root!: ReturnType
+ await act(async () => {
+ root = hydrateRoot(container, app)
+ })
+
+ await waitFor(() => expect(onHydrated).toHaveBeenCalledTimes(1))
+
+ fireEvent.click(screen.getByTestId('child'))
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ expect(onHydrated).toHaveBeenCalledTimes(1)
+
+ await act(async () => {
+ root.unmount()
+ })
+ container.remove()
+ })
+
+ it('prefetches split children without hydrating the boundary', async () => {
+ const preload = vi.fn(() => Promise.resolve())
+
+ const { container, root } = await hydrateFromServer(
+
+
+ ,
+ )
+
+ try {
+ await waitFor(() => expect(preload).toHaveBeenCalledTimes(1))
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'false',
+ )
+
+ await fireIntent(() =>
+ getMarker().dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
+ ),
+ )
+
+ await waitFor(() =>
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'true',
+ ),
+ )
+ expect(preload).toHaveBeenCalledTimes(1)
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('does not evaluate dynamic when callbacks on the server', async () => {
+ const when = vi.fn(() => interaction({ events: 'dblclick' }))
+
+ vi.stubGlobal('window', undefined)
+ const html = renderToString(
+
+
+ ,
+ )
+ vi.unstubAllGlobals()
+
+ expect(when).not.toHaveBeenCalled()
+ expect(html).toContain('data-ts-hydrate-when="dynamic"')
+
+ const container = document.createElement('div')
+ document.body.append(container)
+ container.innerHTML = html
+
+ let root!: ReturnType
+ try {
+ await act(async () => {
+ root = hydrateRoot(
+ container,
+
+
+ ,
+ )
+ await Promise.resolve()
+ })
+
+ expect(when).toHaveBeenCalled()
+ await expectNoHydrationAfterDefaultIntentEvents()
+
+ await fireIntent(() =>
+ getMarker().dispatchEvent(
+ new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
+ ),
+ )
+
+ await waitFor(() =>
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'true',
+ ),
+ )
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('replays an interaction captured before the Hydrate component hydrates', async () => {
+ const when = () => interaction({ events: 'click' })
+
+ vi.stubGlobal('window', undefined)
+ const html = renderToString(
+
+
+ ,
+ )
+ vi.unstubAllGlobals()
+
+ const container = document.createElement('div')
+ document.body.append(container)
+ container.innerHTML = html
+
+ const button = container.querySelector('[data-testid="child"]')
+ if (!button) {
+ throw new Error('Expected server-rendered child button')
+ }
+
+ button.dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
+ )
+
+ let root!: ReturnType
+ try {
+ await act(async () => {
+ root = hydrateRoot(
+ container,
+
+
+ ,
+ )
+ await Promise.resolve()
+ })
+
+ await waitFor(() =>
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'true',
+ ),
+ )
+ await waitFor(() =>
+ expect(screen.getByTestId('child').textContent).toBe('1'),
+ )
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('blocks hydration on awaited procedural prefetch work', async () => {
+ const preload = vi.fn(() => Promise.resolve())
+ let resolvePrefetch!: () => void
+ const prefetchBlocker = new Promise((resolve) => {
+ resolvePrefetch = resolve
+ })
+ const waitReasons: Array = []
+ const neverPrefetches = {
+ _t: 'idle',
+ _s: () => () => {},
+ } as HydrationPrefetchStrategy<'idle'>
+
+ const { container, root } = await hydrateFromServer(
+ {
+ waitReasons.push(await waitFor(neverPrefetches))
+ await preload()
+ await prefetchBlocker
+ }}
+ p={preload}
+ >
+
+ ,
+ )
+
+ try {
+ await fireIntent(() =>
+ getMarker().dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
+ ),
+ )
+
+ await waitFor(() => expect(waitReasons).toEqual(['hydrate']))
+ expect(preload).toHaveBeenCalledTimes(1)
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'false',
+ )
+
+ await act(async () => {
+ resolvePrefetch()
+ await prefetchBlocker
+ await Promise.resolve()
+ })
+
+ await waitFor(() =>
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'true',
+ ),
+ )
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('hydrates when a condition strategy changes after the initial render', async () => {
+ function ConditionHarness() {
+ const [ready, setReady] = React.useState(false)
+
+ return (
+ <>
+ setReady(true)}>
+ ready
+
+
+
+
+ >
+ )
+ }
+
+ const { container, root } = await hydrateFromServer( )
+
+ try {
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'false',
+ )
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('ready'))
+ await Promise.resolve()
+ })
+
+ await waitFor(() =>
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'true',
+ ),
+ )
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('does not block hydration on fire-and-forget procedural prefetch work', async () => {
+ let resolvePrefetch!: () => void
+ const prefetchBlocker = new Promise((resolve) => {
+ resolvePrefetch = resolve
+ })
+
+ const { container, root } = await hydrateFromServer(
+ {
+ void prefetchBlocker
+ }}
+ >
+
+ ,
+ )
+
+ try {
+ await fireIntent(() =>
+ getMarker().dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
+ ),
+ )
+
+ await waitFor(() =>
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'true',
+ ),
+ )
+
+ await act(async () => {
+ resolvePrefetch()
+ await prefetchBlocker
+ })
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+
+ it('aborts procedural prefetch when the boundary unmounts', async () => {
+ const signals: Array = []
+
+ const { container, root } = await hydrateFromServer(
+ {
+ signals.push(signal)
+ return new Promise(() => {})
+ }}
+ >
+
+ ,
+ )
+
+ expect(signals).toHaveLength(1)
+ expect(signals[0]!.aborted).toBe(false)
+
+ await unmountHydratedRoot(root, container)
+ expect(signals[0]!.aborted).toBe(true)
+ })
+
+ it('delegates nested interaction boundaries at runtime', async () => {
+ const { container, root } = await hydrateFromServer(
+
+
+
+
+ ,
+ )
+
+ try {
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'false',
+ )
+
+ await fireIntent(() => {
+ fireEvent.click(screen.getByTestId('child'))
+ })
+
+ await waitFor(() =>
+ expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe(
+ 'true',
+ ),
+ )
+ } finally {
+ await unmountHydratedRoot(root, container)
+ }
+ })
+})
diff --git a/packages/react-start-client/vite.config.ts b/packages/react-start-client/vite.config.ts
index 32119eee3a..40e07174ac 100644
--- a/packages/react-start-client/vite.config.ts
+++ b/packages/react-start-client/vite.config.ts
@@ -20,7 +20,7 @@ export default mergeConfig(
tanstackViteConfig({
tsconfigPath: './tsconfig.build.json',
srcDir: './src',
- entry: './src/index.tsx',
+ entry: ['./src/index.tsx', './src/hydration.ts'],
cjs: false,
}),
)
diff --git a/packages/react-start/package.json b/packages/react-start/package.json
index fea88eb30c..907392e1ac 100644
--- a/packages/react-start/package.json
+++ b/packages/react-start/package.json
@@ -48,6 +48,12 @@
"default": "./dist/esm/client.js"
}
},
+ "./hydration": {
+ "import": {
+ "types": "./dist/esm/hydration.d.ts",
+ "default": "./dist/esm/hydration.js"
+ }
+ },
"./client-rpc": {
"import": {
"types": "./dist/esm/client-rpc.d.ts",
diff --git a/packages/react-start/src/hydration.ts b/packages/react-start/src/hydration.ts
new file mode 100644
index 0000000000..e86169e71d
--- /dev/null
+++ b/packages/react-start/src/hydration.ts
@@ -0,0 +1,18 @@
+export {
+ condition,
+ idle,
+ interaction,
+ load,
+ media,
+ never,
+ visible,
+} from '@tanstack/react-start-client/hydration'
+export type {
+ HydrationCondition,
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ HydrationPrefetchStrategy,
+ HydrationStrategy,
+ HydrationWhen,
+ VisibleHydrationOptions,
+} from '@tanstack/react-start-client/hydration'
diff --git a/packages/react-start/src/index.ts b/packages/react-start/src/index.ts
index 8b51b6c783..65b116ec2e 100644
--- a/packages/react-start/src/index.ts
+++ b/packages/react-start/src/index.ts
@@ -1,2 +1,13 @@
export { useServerFn } from './useServerFn'
export * from '@tanstack/start-client-core'
+export { createServerFn } from '@tanstack/start-client-core'
+export { Hydrate } from '@tanstack/react-start-client'
+export type {
+ HydrateOptions,
+ HydrateProps,
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ HydrationPrefetchStrategy,
+ HydrationStrategy,
+ HydrationWhen,
+} from '@tanstack/react-start-client'
diff --git a/packages/react-start/vite.config.ts b/packages/react-start/vite.config.ts
index 0995b1f9b4..652e4ef842 100644
--- a/packages/react-start/vite.config.ts
+++ b/packages/react-start/vite.config.ts
@@ -27,6 +27,7 @@ export default mergeConfig(
entry: [
'./src/index.ts',
'./src/client.tsx',
+ './src/hydration.ts',
'./src/client-rpc.ts',
'./src/server.tsx',
'./src/server.rsc.ts',
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index ded15fff6d..e737fa50f8 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -185,6 +185,7 @@ export type {
RouteContextFn,
ContextOptions,
RouteContextOptions,
+ SsrContextOptions,
BeforeLoadContextOptions,
RootRouteOptions,
RootRouteOptionsExtensions,
diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts
index 2bca7009d9..c1e467f2fb 100644
--- a/packages/router-core/src/ssr/ssr-server.ts
+++ b/packages/router-core/src/ssr/ssr-server.ts
@@ -292,23 +292,27 @@ export function attachRouterServerSsrUtils({
}) {
router.ssr = {
get manifest() {
+ if (!manifest) return manifest
+
const requestAssets = getRequestAssets?.()
- const inlineCssAsset = getInlineCssAssetForMatches(
- manifest,
- router.stores.matches.get(),
- )
- if (!requestAssets?.length && !inlineCssAsset) return manifest
+ const matches = router.stores.matches.get()
+ const inlineCssAsset = getInlineCssAssetForMatches(manifest, matches)
+
+ if (!requestAssets?.length && !inlineCssAsset) {
+ return manifest
+ }
+
// Merge request-scoped assets into root route without mutating cached manifest
return {
...manifest,
routes: {
- ...manifest?.routes,
+ ...manifest.routes,
[rootRouteId]: {
- ...manifest?.routes?.[rootRouteId],
+ ...manifest.routes[rootRouteId],
assets: [
...(requestAssets ?? []),
...(inlineCssAsset ? [inlineCssAsset] : []),
- ...(manifest?.routes?.[rootRouteId]?.assets ?? []),
+ ...(manifest.routes[rootRouteId]?.assets ?? []),
],
},
},
diff --git a/packages/router-plugin/src/core/code-splitter/compilers.ts b/packages/router-plugin/src/core/code-splitter/compilers.ts
index d59b4fa9af..32eade99af 100644
--- a/packages/router-plugin/src/core/code-splitter/compilers.ts
+++ b/packages/router-plugin/src/core/code-splitter/compilers.ts
@@ -2,15 +2,27 @@ import * as t from '@babel/types'
import * as babel from '@babel/core'
import * as template from '@babel/template'
import {
+ buildDeclarationMap,
+ buildDependencyGraph,
+ collectIdentifiersFromPattern,
+ collectLocalBindingsFromStatement,
+ collectModuleLevelRefsFromNode,
+ createIdentifier,
deadCodeElimination,
+ expandDestructuredDeclarations,
+ expandSharedDestructuredDeclarators,
+ expandTransitively,
findReferencedIdentifiers,
generateFromAst,
parseAst,
+ removeBindingsTransitivelyDependingOn,
+ retainModuleLevelDeclarations,
+ stripUnreferencedTopLevelExpressionStatements,
+ unwrapExportedDeclarations,
} from '@tanstack/router-utils'
import { tsrShared, tsrSplit } from '../constants'
import { createRouteHmrStatement } from '../hmr'
import { getObjectPropertyKeyName } from '../utils'
-import { createIdentifier } from './path-ids'
import { getFrameworkOptions } from './framework-options'
import type {
CompileCodeSplitReferenceRouteOptions,
@@ -20,6 +32,25 @@ import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils'
import type { CodeSplitGroupings, SplitRouteIdentNodes } from '../constants'
import type { SplitNodeMeta } from './types'
+export {
+ buildDeclarationMap,
+ buildDependencyGraph,
+ collectIdentifiersFromNode,
+ collectLocalBindingsFromStatement,
+ collectModuleLevelRefsFromNode,
+ expandDestructuredDeclarations,
+ expandSharedDestructuredDeclarators,
+ expandTransitively,
+ removeBindingsTransitivelyDependingOn,
+} from '@tanstack/router-utils'
+
+export function removeBindingsDependingOnRoute(
+ bindings: Set,
+ dependencyGraph: Map>,
+) {
+ removeBindingsTransitivelyDependingOn(bindings, dependencyGraph, ['Route'])
+}
+
const SPLIT_NODES_CONFIG = new Map([
[
'loader',
@@ -108,124 +139,6 @@ const allCreateRouteFns = [
...unsplittableCreateRouteFns,
]
-/**
- * Recursively walk an AST node and collect referenced identifier-like names.
- * Much cheaper than babel.traverse — no path/scope overhead.
- *
- * Notes:
- * - Uses @babel/types `isReferenced` to avoid collecting non-references like
- * object keys, member expression properties, or binding identifiers.
- * - Also handles JSX identifiers for component references.
- */
-export function collectIdentifiersFromNode(node: t.Node): Set {
- const ids = new Set()
-
- ;(function walk(
- n: t.Node | null | undefined,
- parent?: t.Node,
- grandparent?: t.Node,
- parentKey?: string,
- ) {
- if (!n) return
-
- if (t.isIdentifier(n)) {
- // When we don't have parent info (node passed in isolation), treat as referenced.
- if (!parent || t.isReferenced(n, parent, grandparent)) {
- ids.add(n.name)
- }
- return
- }
-
- if (t.isJSXIdentifier(n)) {
- // Skip attribute names:
- if (parent && t.isJSXAttribute(parent) && parentKey === 'name') {
- return
- }
-
- // Skip member properties: should count Foo, not Bar
- if (
- parent &&
- t.isJSXMemberExpression(parent) &&
- parentKey === 'property'
- ) {
- return
- }
-
- // Intrinsic elements (lowercase) are not identifiers
- const first = n.name[0]
- if (first && first === first.toLowerCase()) {
- return
- }
-
- ids.add(n.name)
- return
- }
-
- for (const key of t.VISITOR_KEYS[n.type] || []) {
- const child = (n as any)[key]
- if (Array.isArray(child)) {
- for (const c of child) {
- if (c && typeof c.type === 'string') {
- walk(c, n, parent, key)
- }
- }
- } else if (child && typeof child.type === 'string') {
- walk(child, n, parent, key)
- }
- }
- })(node)
-
- return ids
-}
-
-/**
- * Build a map from binding name → declaration AST node for all
- * locally-declared module-level bindings. Built once, O(1) lookup.
- */
-export function buildDeclarationMap(ast: t.File): Map {
- const map = new Map()
- for (const stmt of ast.program.body) {
- const decl =
- t.isExportNamedDeclaration(stmt) && stmt.declaration
- ? stmt.declaration
- : stmt
-
- if (t.isVariableDeclaration(decl)) {
- for (const declarator of decl.declarations) {
- for (const name of collectIdentifiersFromPattern(declarator.id)) {
- map.set(name, declarator)
- }
- }
- } else if (t.isFunctionDeclaration(decl) && decl.id) {
- map.set(decl.id.name, decl)
- } else if (t.isClassDeclaration(decl) && decl.id) {
- map.set(decl.id.name, decl)
- }
- }
- return map
-}
-
-/**
- * Build a dependency graph: for each local binding, the set of other local
- * bindings its declaration references. Built once via simple node walking.
- */
-export function buildDependencyGraph(
- declMap: Map,
- localBindings: Set,
-): Map> {
- const graph = new Map>()
- for (const [name, declNode] of declMap) {
- if (!localBindings.has(name)) continue
- const allIds = collectIdentifiersFromNode(declNode)
- const deps = new Set()
- for (const id of allIds) {
- if (id !== name && localBindings.has(id)) deps.add(id)
- }
- graph.set(name, deps)
- }
- return graph
-}
-
/**
* Computes module-level bindings that are shared between split and non-split
* route properties. These bindings need to be extracted into a shared virtual
@@ -381,199 +294,11 @@ export function computeSharedBindings(opts: {
// Remove shared bindings that transitively depend on `Route`.
// The Route singleton must stay in the reference file; extracting a
// binding that references it would duplicate Route in the shared module.
- removeBindingsDependingOnRoute(shared, fullDepGraph)
+ removeBindingsTransitivelyDependingOn(shared, fullDepGraph, ['Route'])
return shared
}
-/**
- * If bindings from the same destructured declarator are referenced by
- * different groups, mark all bindings from that declarator as shared.
- */
-export function expandSharedDestructuredDeclarators(
- ast: t.File,
- refsByGroup: Map>,
- shared: Set,
-) {
- for (const stmt of ast.program.body) {
- const decl =
- t.isExportNamedDeclaration(stmt) && stmt.declaration
- ? stmt.declaration
- : stmt
-
- if (!t.isVariableDeclaration(decl)) continue
-
- for (const declarator of decl.declarations) {
- if (!t.isObjectPattern(declarator.id) && !t.isArrayPattern(declarator.id))
- continue
-
- const names = collectIdentifiersFromPattern(declarator.id)
-
- const usedGroups = new Set()
- for (const name of names) {
- const groups = refsByGroup.get(name)
- if (!groups) continue
- for (const g of groups) usedGroups.add(g)
- }
-
- if (usedGroups.size >= 2) {
- for (const name of names) {
- shared.add(name)
- }
- }
- }
- }
-}
-
-/**
- * Collect locally-declared module-level binding names from a statement.
- * Pure node inspection, no traversal.
- */
-export function collectLocalBindingsFromStatement(
- node: t.Statement | t.ModuleDeclaration,
- bindings: Set,
-) {
- const decl =
- t.isExportNamedDeclaration(node) && node.declaration
- ? node.declaration
- : node
-
- if (t.isVariableDeclaration(decl)) {
- for (const declarator of decl.declarations) {
- for (const name of collectIdentifiersFromPattern(declarator.id)) {
- bindings.add(name)
- }
- }
- } else if (t.isFunctionDeclaration(decl) && decl.id) {
- bindings.add(decl.id.name)
- } else if (t.isClassDeclaration(decl) && decl.id) {
- bindings.add(decl.id.name)
- }
-}
-
-/**
- * Collect direct module-level binding names referenced from a given AST node.
- * Uses a simple recursive walk instead of babel.traverse.
- */
-export function collectModuleLevelRefsFromNode(
- node: t.Node,
- localModuleLevelBindings: Set,
-): Set {
- const allIds = collectIdentifiersFromNode(node)
- const refs = new Set()
- for (const name of allIds) {
- if (localModuleLevelBindings.has(name)) refs.add(name)
- }
- return refs
-}
-
-/**
- * Expand the shared set transitively using a prebuilt dependency graph.
- * No AST traversals — pure graph BFS.
- */
-export function expandTransitively(
- shared: Set,
- depGraph: Map>,
-) {
- const queue = [...shared]
- const visited = new Set()
-
- while (queue.length > 0) {
- const name = queue.pop()!
- if (visited.has(name)) continue
- visited.add(name)
-
- const deps = depGraph.get(name)
- if (!deps) continue
-
- for (const dep of deps) {
- if (!shared.has(dep)) {
- shared.add(dep)
- queue.push(dep)
- }
- }
- }
-}
-
-/**
- * Remove any bindings from `shared` that transitively depend on `Route`.
- * The Route singleton must remain in the reference file; if a shared binding
- * references it (directly or transitively), extracting that binding would
- * duplicate Route in the shared module.
- *
- * Uses `depGraph` which must include `Route` as a node so the dependency
- * chain is visible.
- */
-export function removeBindingsDependingOnRoute(
- shared: Set,
- depGraph: Map>,
-) {
- const reverseGraph = new Map>()
- for (const [name, deps] of depGraph) {
- for (const dep of deps) {
- let parents = reverseGraph.get(dep)
- if (!parents) {
- parents = new Set()
- reverseGraph.set(dep, parents)
- }
- parents.add(name)
- }
- }
-
- // Walk backwards from Route to find all bindings that can reach it.
- const visited = new Set()
- const queue = ['Route']
- while (queue.length > 0) {
- const cur = queue.pop()!
- if (visited.has(cur)) continue
- visited.add(cur)
-
- const parents = reverseGraph.get(cur)
- if (!parents) continue
- for (const parent of parents) {
- if (!visited.has(parent)) queue.push(parent)
- }
- }
-
- for (const name of [...shared]) {
- if (visited.has(name)) {
- shared.delete(name)
- }
- }
-}
-
-/**
- * If any binding from a destructured declaration is shared,
- * ensure all bindings from that same declaration are also shared.
- * Pure node inspection of program.body, no traversal.
- */
-export function expandDestructuredDeclarations(
- ast: t.File,
- shared: Set,
-) {
- for (const stmt of ast.program.body) {
- const decl =
- t.isExportNamedDeclaration(stmt) && stmt.declaration
- ? stmt.declaration
- : stmt
-
- if (!t.isVariableDeclaration(decl)) continue
-
- for (const declarator of decl.declarations) {
- if (!t.isObjectPattern(declarator.id) && !t.isArrayPattern(declarator.id))
- continue
-
- const names = collectIdentifiersFromPattern(declarator.id)
- const hasShared = names.some((n) => shared.has(n))
- if (hasShared) {
- for (const n of names) {
- shared.add(n)
- }
- }
- }
- }
-}
-
/**
* Find which shared bindings are user-exported in the original source.
* These need to be re-exported from the shared module.
@@ -740,6 +465,21 @@ export function compileCodeSplitReferenceRoute(
if (t.isObjectExpression(routeOptions)) {
const insertionPath = path.getStatementParent() ?? path
+ opts.compilerPlugins?.forEach((plugin) => {
+ const pluginResult = plugin.onRouteOptions?.({
+ programPath,
+ callExpressionPath: path,
+ insertionPath,
+ routeOptions,
+ createRouteFn,
+ opts: opts as CompileCodeSplitReferenceRouteOptions,
+ })
+
+ if (pluginResult?.modified) {
+ modified = true
+ }
+ })
+
if (opts.deleteNodes && opts.deleteNodes.size > 0) {
routeOptions.properties = routeOptions.properties.filter(
(prop) => {
@@ -1525,22 +1265,7 @@ export function compileCodeSplitVirtualRoute(
})
deadCodeElimination(ast, refIdents)
-
- // Strip top-level expression statements that reference no locally-bound names.
- // DCE only removes unused declarations; bare side-effect statements like
- // `console.log(...)` survive even when the virtual file has no exports.
- {
- const locallyBound = new Set()
- for (const stmt of ast.program.body) {
- collectLocalBindingsFromStatement(stmt, locallyBound)
- }
- ast.program.body = ast.program.body.filter((stmt) => {
- if (!t.isExpressionStatement(stmt)) return true
- const refs = collectIdentifiersFromNode(stmt)
- // Keep if it references at least one locally-bound identifier
- return [...refs].some((name) => locallyBound.has(name))
- })
- }
+ stripUnreferencedTopLevelExpressionStatements(ast)
// If the body is empty after DCE, strip directive prologues too.
// A file containing only `'use client'` with no real code is useless.
@@ -1595,49 +1320,8 @@ export function compileCodeSplitSharedRoute(
keepBindings.delete('Route')
expandTransitively(keepBindings, depGraph)
- // Remove all statements except:
- // - Import declarations (needed for deps; DCE will clean unused ones)
- // - Declarations of bindings in keepBindings
- ast.program.body = ast.program.body.filter((stmt) => {
- // Always keep imports — DCE will remove unused ones
- if (t.isImportDeclaration(stmt)) return true
-
- const decl =
- t.isExportNamedDeclaration(stmt) && stmt.declaration
- ? stmt.declaration
- : stmt
-
- if (t.isVariableDeclaration(decl)) {
- // Keep declarators where at least one binding is in keepBindings
- decl.declarations = decl.declarations.filter((declarator) => {
- const names = collectIdentifiersFromPattern(declarator.id)
- return names.some((n) => keepBindings.has(n))
- })
- if (decl.declarations.length === 0) return false
-
- // Strip the `export` wrapper — shared module controls its own exports
- if (t.isExportNamedDeclaration(stmt) && stmt.declaration) {
- return true // keep for now, we'll convert below
- }
- return true
- } else if (t.isFunctionDeclaration(decl) && decl.id) {
- return keepBindings.has(decl.id.name)
- } else if (t.isClassDeclaration(decl) && decl.id) {
- return keepBindings.has(decl.id.name)
- }
-
- // Remove everything else (expression statements, other exports, etc.)
- return false
- })
-
- // Convert `export const/function/class` to plain declarations
- // (we'll add our own export statement at the end)
- ast.program.body = ast.program.body.map((stmt) => {
- if (t.isExportNamedDeclaration(stmt) && stmt.declaration) {
- return stmt.declaration
- }
- return stmt
- })
+ retainModuleLevelDeclarations(ast, keepBindings)
+ unwrapExportedDeclarations(ast)
// Export all shared bindings (sorted for deterministic output)
const exportNames = [...opts.sharedBindings].sort((a, b) =>
@@ -1837,50 +1521,6 @@ function getImportSpecifierAndPathFromLocalName(
return { specifier, path }
}
-/**
- * Recursively collects all identifier names from a destructuring pattern
- * (ObjectPattern, ArrayPattern, AssignmentPattern, RestElement).
- */
-function collectIdentifiersFromPattern(
- node: t.LVal | t.Node | null | undefined,
-): Array {
- if (!node) {
- return []
- }
-
- if (t.isIdentifier(node)) {
- return [node.name]
- }
-
- if (t.isAssignmentPattern(node)) {
- return collectIdentifiersFromPattern(node.left)
- }
-
- if (t.isRestElement(node)) {
- return collectIdentifiersFromPattern(node.argument)
- }
-
- if (t.isObjectPattern(node)) {
- return node.properties.flatMap((prop) => {
- if (t.isObjectProperty(prop)) {
- return collectIdentifiersFromPattern(prop.value as t.LVal)
- }
- if (t.isRestElement(prop)) {
- return collectIdentifiersFromPattern(prop.argument)
- }
- return []
- })
- }
-
- if (t.isArrayPattern(node)) {
- return node.elements.flatMap((element) =>
- collectIdentifiersFromPattern(element),
- )
- }
-
- return []
-}
-
// Reusable function to get literal value or resolve variable to literal
function resolveIdentifier(path: any, node: any): t.Node | undefined {
if (t.isIdentifier(node)) {
diff --git a/packages/router-plugin/src/core/code-splitter/plugins.ts b/packages/router-plugin/src/core/code-splitter/plugins.ts
index 4b2363e361..ebf1f4aa30 100644
--- a/packages/router-plugin/src/core/code-splitter/plugins.ts
+++ b/packages/router-plugin/src/core/code-splitter/plugins.ts
@@ -43,6 +43,9 @@ export type ReferenceRouteCompilerPluginResult = {
export type ReferenceRouteCompilerPlugin = {
name: string
getStableRouteOptionKeys?: () => Array
+ onRouteOptions?: (
+ ctx: ReferenceRouteCompilerPluginContext,
+ ) => void | ReferenceRouteCompilerPluginResult
onAddHmr?: (
ctx: ReferenceRouteCompilerPluginContext,
) => void | ReferenceRouteCompilerPluginResult
diff --git a/packages/router-plugin/src/core/config.ts b/packages/router-plugin/src/core/config.ts
index f764bfe8bb..d1d36bbe37 100644
--- a/packages/router-plugin/src/core/config.ts
+++ b/packages/router-plugin/src/core/config.ts
@@ -9,6 +9,7 @@ import type {
RouteIds,
} from '@tanstack/router-core'
import type { CodeSplitGroupings } from './constants'
+import type { ReferenceRouteCompilerPlugin } from './code-splitter/plugins'
export const splitGroupingsSchema = z
.array(
@@ -70,6 +71,12 @@ export type CodeSplittingOptions = {
* @default true
*/
addHmr?: boolean
+
+ /**
+ * Internal compiler plugins used by framework integrations.
+ * @internal
+ */
+ compilerPlugins?: Array
}
export type HmrStyle = 'vite' | 'webpack'
diff --git a/packages/router-plugin/src/core/router-code-splitter-plugin.ts b/packages/router-plugin/src/core/router-code-splitter-plugin.ts
index e291b05fe9..f197f4890a 100644
--- a/packages/router-plugin/src/core/router-code-splitter-plugin.ts
+++ b/packages/router-plugin/src/core/router-code-splitter-plugin.ts
@@ -4,7 +4,7 @@
*/
import { fileURLToPath, pathToFileURL } from 'node:url'
-import { logDiff } from '@tanstack/router-utils'
+import { decodeIdentifier, logDiff } from '@tanstack/router-utils'
import { getConfig, splitGroupingsSchema } from './config'
import {
compileCodeSplitReferenceRoute,
@@ -20,7 +20,6 @@ import {
tsrShared,
tsrSplit,
} from './constants'
-import { decodeIdentifier } from './code-splitter/path-ids'
import { debug, normalizePath } from './utils'
import { createRouterPluginContext } from './router-plugin-context'
import type { CodeSplitGroupings, SplitRouteIdentNodes } from './constants'
@@ -177,11 +176,14 @@ export function createRouterCodeSplitterPlugin(
hmrStyle,
hmrRouteId: generatorNodeInfo.routeId,
sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined,
- compilerPlugins: getReferenceRouteCompilerPlugins({
- targetFramework: userConfig.target,
- addHmr,
- hmrStyle,
- }),
+ compilerPlugins: [
+ ...(getReferenceRouteCompilerPlugins({
+ targetFramework: userConfig.target,
+ addHmr,
+ hmrStyle,
+ }) ?? []),
+ ...(userConfig.codeSplittingOptions?.compilerPlugins ?? []),
+ ],
})
if (compiledReferenceRoute === null) {
diff --git a/packages/router-plugin/src/index.ts b/packages/router-plugin/src/index.ts
index a56e097d3a..e390c046a2 100644
--- a/packages/router-plugin/src/index.ts
+++ b/packages/router-plugin/src/index.ts
@@ -11,6 +11,11 @@ export type {
HmrOptions,
} from './core/config'
export type { RouterPluginContext } from './core/router-plugin-context'
+export { getObjectPropertyKeyName } from './core/utils'
+export type {
+ ReferenceRouteCompilerPlugin,
+ ReferenceRouteCompilerPluginContext,
+} from './core/code-splitter/plugins'
export {
tsrSplit,
splitRouteIdentNodes,
diff --git a/packages/router-plugin/tests/code-splitter.test.ts b/packages/router-plugin/tests/code-splitter.test.ts
index 5a68f9453f..ae64e2778b 100644
--- a/packages/router-plugin/tests/code-splitter.test.ts
+++ b/packages/router-plugin/tests/code-splitter.test.ts
@@ -18,7 +18,7 @@ import {
expandTransitively,
removeBindingsDependingOnRoute,
} from '../src/core/code-splitter/compilers'
-import { createIdentifier } from '../src/core/code-splitter/path-ids'
+import { createIdentifier } from '@tanstack/router-utils'
import { defaultCodeSplitGroupings } from '../src/core/constants'
import { frameworks } from './constants'
import type { CodeSplitGroupings } from '../src/core/constants'
diff --git a/packages/router-utils/src/compiler-helpers.ts b/packages/router-utils/src/compiler-helpers.ts
new file mode 100644
index 0000000000..78d6cd9b86
--- /dev/null
+++ b/packages/router-utils/src/compiler-helpers.ts
@@ -0,0 +1,843 @@
+import * as t from '@babel/types'
+
+type IdentifierScopeFrame = {
+ kind: 'program' | 'function' | 'block'
+ bindings: Set
+}
+type IdentifierScopeStack = Array
+
+export type ModuleInfoBinding =
+ | {
+ type: 'import'
+ source: string
+ importedName: string
+ }
+ | {
+ type: 'var'
+ init: t.Expression | null
+ }
+
+export interface ExtractedModuleInfo {
+ bindings: Map
+ exports: Map
+ reExportAllSources: Array
+}
+
+function getModuleExportName(node: t.Identifier | t.StringLiteral) {
+ return t.isIdentifier(node) ? node.name : node.value
+}
+
+function addVariableDeclarationModuleInfo(
+ declaration: t.VariableDeclaration,
+ bindings: Map,
+ exportMap?: Map,
+) {
+ for (const declarator of declaration.declarations) {
+ for (const name of collectIdentifiersFromPattern(declarator.id)) {
+ bindings.set(name, {
+ type: 'var',
+ init: declarator.init ?? null,
+ })
+ exportMap?.set(name, name)
+ }
+ }
+}
+
+function addDeclarationModuleInfo(
+ declaration: t.Declaration,
+ bindings: Map,
+ exportMap?: Map,
+) {
+ if (t.isVariableDeclaration(declaration)) {
+ addVariableDeclarationModuleInfo(declaration, bindings, exportMap)
+ return
+ }
+
+ if (
+ (t.isFunctionDeclaration(declaration) ||
+ t.isClassDeclaration(declaration)) &&
+ declaration.id
+ ) {
+ bindings.set(declaration.id.name, {
+ type: 'var',
+ init: null,
+ })
+ exportMap?.set(declaration.id.name, declaration.id.name)
+ }
+}
+
+function hasIdentifierBinding(scopes: IdentifierScopeStack, name: string) {
+ for (let i = scopes.length - 1; i >= 0; i--) {
+ if (scopes[i]!.bindings.has(name)) {
+ return true
+ }
+ }
+ return false
+}
+
+function currentIdentifierScope(scopes: IdentifierScopeStack) {
+ return scopes[scopes.length - 1]!
+}
+
+function nearestFunctionIdentifierScope(scopes: IdentifierScopeStack) {
+ for (let i = scopes.length - 1; i >= 0; i--) {
+ const scope = scopes[i]!
+ if (scope.kind === 'function' || scope.kind === 'program') {
+ return scope
+ }
+ }
+ return currentIdentifierScope(scopes)
+}
+
+function addIdentifierPatternBindings(
+ pattern: t.LVal | t.Node | null | undefined,
+ scope: IdentifierScopeFrame,
+) {
+ for (const name of collectIdentifiersFromPattern(pattern)) {
+ scope.bindings.add(name)
+ }
+}
+
+function addIdentifierDeclarationBindings(
+ declaration: t.Node,
+ scopes: IdentifierScopeStack,
+) {
+ if (t.isVariableDeclaration(declaration)) {
+ const scope =
+ declaration.kind === 'var'
+ ? nearestFunctionIdentifierScope(scopes)
+ : currentIdentifierScope(scopes)
+ for (const declarator of declaration.declarations) {
+ addIdentifierPatternBindings(declarator.id, scope)
+ }
+ return
+ }
+
+ if (
+ (t.isFunctionDeclaration(declaration) ||
+ t.isClassDeclaration(declaration) ||
+ t.isTSTypeAliasDeclaration(declaration) ||
+ t.isTSInterfaceDeclaration(declaration) ||
+ t.isTSEnumDeclaration(declaration)) &&
+ declaration.id
+ ) {
+ currentIdentifierScope(scopes).bindings.add(declaration.id.name)
+ }
+}
+
+function addIdentifierImportBindings(
+ node: t.ImportDeclaration,
+ scope: IdentifierScopeFrame,
+) {
+ for (const specifier of node.specifiers) {
+ scope.bindings.add(specifier.local.name)
+ }
+}
+
+function createNestedIdentifierScope(
+ kind: IdentifierScopeFrame['kind'],
+ scopes: IdentifierScopeStack,
+): IdentifierScopeStack {
+ return [...scopes, { kind, bindings: new Set() }]
+}
+
+function addIdentifierBlockBindings(
+ body: Array,
+ scopes: IdentifierScopeStack,
+) {
+ for (const statement of body) {
+ if (t.isImportDeclaration(statement)) {
+ addIdentifierImportBindings(statement, currentIdentifierScope(scopes))
+ } else if (t.isExportNamedDeclaration(statement) && statement.declaration) {
+ addIdentifierDeclarationBindings(statement.declaration, scopes)
+ } else {
+ addIdentifierDeclarationBindings(statement, scopes)
+ }
+ }
+}
+
+function walkIdentifierChildren(
+ current: t.Node,
+ parent: t.Node | undefined,
+ scopes: IdentifierScopeStack,
+ ids: Set,
+) {
+ for (const key of t.VISITOR_KEYS[current.type] ?? []) {
+ const child = (current as any)[key]
+ if (Array.isArray(child)) {
+ for (const item of child) {
+ if (item && typeof item.type === 'string') {
+ walkIdentifierNode(item, current, parent, key, scopes, ids)
+ }
+ }
+ } else if (child && typeof child.type === 'string') {
+ walkIdentifierNode(child, current, parent, key, scopes, ids)
+ }
+ }
+}
+
+function walkIdentifierNode(
+ current: t.Node | null | undefined,
+ parent: t.Node | undefined,
+ grandparent: t.Node | undefined,
+ parentKey: string | undefined,
+ scopes: IdentifierScopeStack,
+ ids: Set,
+) {
+ if (!current) return
+
+ if (t.isIdentifier(current)) {
+ if (
+ (!parent || t.isReferenced(current, parent, grandparent)) &&
+ !hasIdentifierBinding(scopes, current.name)
+ ) {
+ ids.add(current.name)
+ }
+ return
+ }
+
+ if (t.isJSXIdentifier(current)) {
+ if (parent && t.isJSXAttribute(parent) && parentKey === 'name') {
+ return
+ }
+
+ if (parent && t.isJSXMemberExpression(parent) && parentKey === 'property') {
+ return
+ }
+
+ const first = current.name[0]
+ if (first && first === first.toLowerCase()) {
+ return
+ }
+
+ if (!hasIdentifierBinding(scopes, current.name)) {
+ ids.add(current.name)
+ }
+ return
+ }
+
+ if (t.isProgram(current)) {
+ const nestedScopes = createNestedIdentifierScope('program', scopes)
+ addIdentifierBlockBindings(current.body, nestedScopes)
+ for (const child of current.body) {
+ walkIdentifierNode(child, current, parent, 'body', nestedScopes, ids)
+ }
+ return
+ }
+
+ if (t.isBlockStatement(current)) {
+ const nestedScopes = createNestedIdentifierScope('block', scopes)
+ addIdentifierBlockBindings(current.body, nestedScopes)
+ for (const child of current.body) {
+ walkIdentifierNode(child, current, parent, 'body', nestedScopes, ids)
+ }
+ return
+ }
+
+ if (
+ t.isFunctionDeclaration(current) ||
+ t.isFunctionExpression(current) ||
+ t.isArrowFunctionExpression(current) ||
+ t.isObjectMethod(current) ||
+ t.isClassMethod(current) ||
+ t.isClassPrivateMethod(current)
+ ) {
+ if (t.isFunctionDeclaration(current) && current.id) {
+ currentIdentifierScope(scopes).bindings.add(current.id.name)
+ }
+
+ const nestedScopes = createNestedIdentifierScope('function', scopes)
+ if (
+ (t.isFunctionDeclaration(current) || t.isFunctionExpression(current)) &&
+ current.id
+ ) {
+ currentIdentifierScope(nestedScopes).bindings.add(current.id.name)
+ }
+ for (const param of current.params) {
+ addIdentifierPatternBindings(param, currentIdentifierScope(nestedScopes))
+ }
+
+ walkIdentifierChildren(current, parent, nestedScopes, ids)
+ return
+ }
+
+ if (t.isCatchClause(current)) {
+ const nestedScopes = createNestedIdentifierScope('block', scopes)
+ addIdentifierPatternBindings(
+ current.param,
+ currentIdentifierScope(nestedScopes),
+ )
+ walkIdentifierNode(
+ current.param,
+ current,
+ parent,
+ 'param',
+ nestedScopes,
+ ids,
+ )
+ walkIdentifierNode(current.body, current, parent, 'body', nestedScopes, ids)
+ return
+ }
+
+ if (t.isImportDeclaration(current)) {
+ addIdentifierImportBindings(current, currentIdentifierScope(scopes))
+ return
+ }
+
+ if (t.isClassDeclaration(current) || t.isClassExpression(current)) {
+ if (t.isClassDeclaration(current) && current.id) {
+ currentIdentifierScope(scopes).bindings.add(current.id.name)
+ }
+
+ const nestedScopes = current.id
+ ? createNestedIdentifierScope('block', scopes)
+ : scopes
+ if (current.id) {
+ currentIdentifierScope(nestedScopes).bindings.add(current.id.name)
+ }
+
+ walkIdentifierChildren(current, parent, nestedScopes, ids)
+ return
+ }
+
+ if (t.isVariableDeclaration(current)) {
+ addIdentifierDeclarationBindings(current, scopes)
+ } else if (t.isVariableDeclarator(current)) {
+ const scope =
+ parent && t.isVariableDeclaration(parent) && parent.kind === 'var'
+ ? nearestFunctionIdentifierScope(scopes)
+ : currentIdentifierScope(scopes)
+ addIdentifierPatternBindings(current.id, scope)
+ } else if (
+ t.isTSTypeAliasDeclaration(current) ||
+ t.isTSInterfaceDeclaration(current) ||
+ t.isTSEnumDeclaration(current)
+ ) {
+ currentIdentifierScope(scopes).bindings.add(current.id.name)
+ }
+
+ walkIdentifierChildren(current, parent, scopes, ids)
+}
+
+/**
+ * Recursively walk an AST node and collect referenced identifier-like names.
+ * This avoids Babel path/scope allocation for module-level dependency scans.
+ */
+export function collectIdentifiersFromNode(node: t.Node): Set {
+ const ids = new Set()
+ walkIdentifierNode(
+ node,
+ undefined,
+ undefined,
+ undefined,
+ [{ kind: 'program', bindings: new Set() }],
+ ids,
+ )
+ return ids
+}
+
+export function collectIdentifiersFromPattern(
+ node: t.LVal | t.Node | null | undefined,
+): Array {
+ if (!node) {
+ return []
+ }
+
+ if (t.isIdentifier(node)) {
+ return [node.name]
+ }
+
+ if (t.isAssignmentPattern(node)) {
+ return collectIdentifiersFromPattern(node.left)
+ }
+
+ if (t.isRestElement(node)) {
+ return collectIdentifiersFromPattern(node.argument)
+ }
+
+ if (t.isObjectPattern(node)) {
+ return node.properties.flatMap((prop) => {
+ if (t.isObjectProperty(prop)) {
+ return collectIdentifiersFromPattern(prop.value as t.LVal)
+ }
+ if (t.isRestElement(prop)) {
+ return collectIdentifiersFromPattern(prop.argument)
+ }
+ return []
+ })
+ }
+
+ if (t.isArrayPattern(node)) {
+ return node.elements.flatMap((element) =>
+ collectIdentifiersFromPattern(element),
+ )
+ }
+
+ return []
+}
+
+export function collectLocalBindingsFromStatement(
+ node: t.Statement | t.ModuleDeclaration,
+ bindings: Set,
+) {
+ const declaration =
+ t.isExportNamedDeclaration(node) && node.declaration
+ ? node.declaration
+ : t.isExportDefaultDeclaration(node)
+ ? node.declaration
+ : node
+
+ if (t.isVariableDeclaration(declaration)) {
+ for (const declarator of declaration.declarations) {
+ for (const name of collectIdentifiersFromPattern(declarator.id)) {
+ bindings.add(name)
+ }
+ }
+ } else if (t.isFunctionDeclaration(declaration) && declaration.id) {
+ bindings.add(declaration.id.name)
+ } else if (t.isClassDeclaration(declaration) && declaration.id) {
+ bindings.add(declaration.id.name)
+ }
+}
+
+export function extractModuleInfoFromAst(ast: t.File): ExtractedModuleInfo {
+ const bindings = new Map()
+ const exportMap = new Map()
+ const reExportAllSources: Array = []
+
+ for (const node of ast.program.body) {
+ if (t.isImportDeclaration(node)) {
+ const source = node.source.value
+ for (const specifier of node.specifiers) {
+ if (t.isImportSpecifier(specifier)) {
+ bindings.set(specifier.local.name, {
+ type: 'import',
+ source,
+ importedName: getModuleExportName(specifier.imported),
+ })
+ } else if (t.isImportDefaultSpecifier(specifier)) {
+ bindings.set(specifier.local.name, {
+ type: 'import',
+ source,
+ importedName: 'default',
+ })
+ } else if (t.isImportNamespaceSpecifier(specifier)) {
+ bindings.set(specifier.local.name, {
+ type: 'import',
+ source,
+ importedName: '*',
+ })
+ }
+ }
+ continue
+ }
+
+ if (t.isVariableDeclaration(node)) {
+ addVariableDeclarationModuleInfo(node, bindings)
+ continue
+ }
+
+ if (t.isFunctionDeclaration(node) || t.isClassDeclaration(node)) {
+ addDeclarationModuleInfo(node, bindings)
+ continue
+ }
+
+ if (t.isExportNamedDeclaration(node)) {
+ if (node.declaration) {
+ addDeclarationModuleInfo(node.declaration, bindings, exportMap)
+ }
+
+ for (const specifier of node.specifiers) {
+ if (t.isExportNamespaceSpecifier(specifier)) {
+ const exported = getModuleExportName(specifier.exported)
+ exportMap.set(exported, exported)
+ if (node.source) {
+ bindings.set(exported, {
+ type: 'import',
+ source: node.source.value,
+ importedName: '*',
+ })
+ }
+ } else if (t.isExportSpecifier(specifier)) {
+ const local = getModuleExportName(specifier.local)
+ const exported = getModuleExportName(specifier.exported)
+ exportMap.set(exported, local)
+
+ if (node.source) {
+ bindings.set(local, {
+ type: 'import',
+ source: node.source.value,
+ importedName: local,
+ })
+ }
+ }
+ }
+ continue
+ }
+
+ if (t.isExportDefaultDeclaration(node)) {
+ const declaration = node.declaration
+ if (t.isIdentifier(declaration)) {
+ exportMap.set('default', declaration.name)
+ } else if (
+ (t.isFunctionDeclaration(declaration) ||
+ t.isClassDeclaration(declaration)) &&
+ declaration.id
+ ) {
+ bindings.set(declaration.id.name, {
+ type: 'var',
+ init: null,
+ })
+ exportMap.set('default', declaration.id.name)
+ } else {
+ const synth = '__default_export__'
+ bindings.set(synth, {
+ type: 'var',
+ init: t.isExpression(declaration) ? declaration : null,
+ })
+ exportMap.set('default', synth)
+ }
+ continue
+ }
+
+ if (t.isExportAllDeclaration(node)) {
+ reExportAllSources.push(node.source.value)
+ }
+ }
+
+ return {
+ bindings,
+ exports: exportMap,
+ reExportAllSources,
+ }
+}
+
+export function buildDeclarationMap(ast: t.File): Map {
+ const map = new Map()
+
+ for (const statement of ast.program.body) {
+ const declaration =
+ t.isExportNamedDeclaration(statement) && statement.declaration
+ ? statement.declaration
+ : t.isExportDefaultDeclaration(statement)
+ ? statement.declaration
+ : statement
+
+ if (t.isVariableDeclaration(declaration)) {
+ for (const declarator of declaration.declarations) {
+ for (const name of collectIdentifiersFromPattern(declarator.id)) {
+ map.set(name, declarator)
+ }
+ }
+ } else if (t.isFunctionDeclaration(declaration) && declaration.id) {
+ map.set(declaration.id.name, declaration)
+ } else if (t.isClassDeclaration(declaration) && declaration.id) {
+ map.set(declaration.id.name, declaration)
+ }
+ }
+
+ return map
+}
+
+export function buildDependencyGraph(
+ declarationMap: Map,
+ localBindings: Set,
+): Map> {
+ const graph = new Map>()
+
+ for (const [name, declarationNode] of declarationMap) {
+ if (!localBindings.has(name)) continue
+
+ const dependencies = new Set()
+ for (const id of collectIdentifiersFromNode(declarationNode)) {
+ if (id !== name && localBindings.has(id)) {
+ dependencies.add(id)
+ }
+ }
+ graph.set(name, dependencies)
+ }
+
+ return graph
+}
+
+export function collectModuleLevelRefsFromNode(
+ node: t.Node,
+ localModuleLevelBindings: Set,
+): Set {
+ const refs = new Set()
+
+ for (const name of collectIdentifiersFromNode(node)) {
+ if (localModuleLevelBindings.has(name)) {
+ refs.add(name)
+ }
+ }
+
+ return refs
+}
+
+export function expandTransitively(
+ bindings: Set,
+ dependencyGraph: Map>,
+) {
+ const queue = [...bindings]
+ const visited = new Set()
+
+ while (queue.length > 0) {
+ const name = queue.pop()!
+ if (visited.has(name)) continue
+ visited.add(name)
+
+ const dependencies = dependencyGraph.get(name)
+ if (!dependencies) continue
+
+ for (const dependency of dependencies) {
+ if (!bindings.has(dependency)) {
+ bindings.add(dependency)
+ queue.push(dependency)
+ }
+ }
+ }
+}
+
+export function expandSharedDestructuredDeclarators(
+ ast: t.File,
+ refsByGroup: Map>,
+ sharedBindings: Set,
+) {
+ for (const statement of ast.program.body) {
+ const declaration =
+ t.isExportNamedDeclaration(statement) && statement.declaration
+ ? statement.declaration
+ : statement
+
+ if (!t.isVariableDeclaration(declaration)) continue
+
+ for (const declarator of declaration.declarations) {
+ if (
+ !t.isObjectPattern(declarator.id) &&
+ !t.isArrayPattern(declarator.id)
+ ) {
+ continue
+ }
+
+ const names = collectIdentifiersFromPattern(declarator.id)
+ const usedGroups = new Set()
+
+ for (const name of names) {
+ const groups = refsByGroup.get(name)
+ if (!groups) continue
+ for (const group of groups) {
+ usedGroups.add(group)
+ }
+ }
+
+ if (usedGroups.size >= 2) {
+ for (const name of names) {
+ sharedBindings.add(name)
+ }
+ }
+ }
+ }
+}
+
+export function expandDestructuredDeclarations(
+ ast: t.File,
+ bindings: Set,
+) {
+ for (const statement of ast.program.body) {
+ const declaration =
+ t.isExportNamedDeclaration(statement) && statement.declaration
+ ? statement.declaration
+ : statement
+
+ if (!t.isVariableDeclaration(declaration)) continue
+
+ for (const declarator of declaration.declarations) {
+ if (
+ !t.isObjectPattern(declarator.id) &&
+ !t.isArrayPattern(declarator.id)
+ ) {
+ continue
+ }
+
+ const names = collectIdentifiersFromPattern(declarator.id)
+ if (names.some((name) => bindings.has(name))) {
+ for (const name of names) {
+ bindings.add(name)
+ }
+ }
+ }
+ }
+}
+
+export function removeBindingsTransitivelyDependingOn(
+ bindings: Set,
+ dependencyGraph: Map>,
+ roots: Iterable,
+) {
+ const reverseGraph = new Map>()
+
+ for (const [name, dependencies] of dependencyGraph) {
+ for (const dependency of dependencies) {
+ let parents = reverseGraph.get(dependency)
+ if (!parents) {
+ parents = new Set()
+ reverseGraph.set(dependency, parents)
+ }
+ parents.add(name)
+ }
+ }
+
+ const visited = new Set()
+ const queue = [...roots]
+
+ while (queue.length > 0) {
+ const current = queue.pop()!
+ if (visited.has(current)) continue
+ visited.add(current)
+
+ const parents = reverseGraph.get(current)
+ if (!parents) continue
+
+ for (const parent of parents) {
+ if (!visited.has(parent)) {
+ queue.push(parent)
+ }
+ }
+ }
+
+ for (const name of [...bindings]) {
+ if (visited.has(name)) {
+ bindings.delete(name)
+ }
+ }
+}
+
+export function removeModuleLevelBindings(
+ ast: t.File,
+ namesToRemove: Set,
+) {
+ ast.program.body = ast.program.body.filter((statement) => {
+ const declaration =
+ t.isExportNamedDeclaration(statement) && statement.declaration
+ ? statement.declaration
+ : statement
+
+ if (t.isVariableDeclaration(declaration)) {
+ declaration.declarations = declaration.declarations.filter(
+ (declarator) =>
+ !collectIdentifiersFromPattern(declarator.id).some((name) =>
+ namesToRemove.has(name),
+ ),
+ )
+ return declaration.declarations.length > 0
+ }
+
+ if (t.isFunctionDeclaration(declaration) && declaration.id) {
+ return !namesToRemove.has(declaration.id.name)
+ }
+
+ if (t.isClassDeclaration(declaration) && declaration.id) {
+ return !namesToRemove.has(declaration.id.name)
+ }
+
+ if (t.isExportDefaultDeclaration(statement)) {
+ const defaultDeclaration = statement.declaration
+ if (
+ (t.isFunctionDeclaration(defaultDeclaration) ||
+ t.isClassDeclaration(defaultDeclaration)) &&
+ defaultDeclaration.id
+ ) {
+ return !namesToRemove.has(defaultDeclaration.id.name)
+ }
+ }
+
+ return true
+ })
+}
+
+export function retainModuleLevelDeclarations(
+ ast: t.File,
+ bindingsToKeep: Set,
+) {
+ ast.program.body = ast.program.body.filter((statement) => {
+ if (t.isImportDeclaration(statement)) return true
+
+ const declaration =
+ t.isExportNamedDeclaration(statement) && statement.declaration
+ ? statement.declaration
+ : statement
+
+ if (t.isVariableDeclaration(declaration)) {
+ declaration.declarations = declaration.declarations.filter((declarator) =>
+ collectIdentifiersFromPattern(declarator.id).some((name) =>
+ bindingsToKeep.has(name),
+ ),
+ )
+ return declaration.declarations.length > 0
+ }
+
+ if (t.isFunctionDeclaration(declaration) && declaration.id) {
+ return bindingsToKeep.has(declaration.id.name)
+ }
+
+ if (t.isClassDeclaration(declaration) && declaration.id) {
+ return bindingsToKeep.has(declaration.id.name)
+ }
+
+ return false
+ })
+}
+
+export function unwrapExportedDeclarations(ast: t.File) {
+ const body: Array = []
+
+ for (const statement of ast.program.body) {
+ if (t.isExportNamedDeclaration(statement)) {
+ if (statement.declaration) {
+ body.push(statement.declaration)
+ }
+ continue
+ }
+
+ if (t.isExportDefaultDeclaration(statement)) {
+ const declaration = statement.declaration
+ if (
+ (t.isFunctionDeclaration(declaration) ||
+ t.isClassDeclaration(declaration)) &&
+ declaration.id
+ ) {
+ body.push(declaration)
+ }
+ continue
+ }
+
+ if (t.isExportAllDeclaration(statement)) {
+ continue
+ }
+
+ body.push(statement)
+ }
+
+ ast.program.body = body
+}
+
+export function stripUnreferencedTopLevelExpressionStatements(ast: t.File) {
+ const locallyBound = new Set()
+
+ for (const statement of ast.program.body) {
+ collectLocalBindingsFromStatement(statement, locallyBound)
+ }
+
+ ast.program.body = ast.program.body.filter((statement) => {
+ if (!t.isExpressionStatement(statement)) return true
+
+ for (const name of collectIdentifiersFromNode(statement)) {
+ if (locallyBound.has(name)) {
+ return true
+ }
+ }
+
+ return false
+ })
+}
diff --git a/packages/router-utils/src/index.ts b/packages/router-utils/src/index.ts
index 3b072ae419..ce0de90824 100644
--- a/packages/router-utils/src/index.ts
+++ b/packages/router-utils/src/index.ts
@@ -9,3 +9,24 @@ export type { ParseAstOptions, ParseAstResult, GeneratorResult } from './ast'
export { logDiff } from './logger'
export { copyFilesPlugin } from './copy-files-plugin'
+
+export { createIdentifier, decodeIdentifier } from './path-ids'
+
+export {
+ buildDeclarationMap,
+ buildDependencyGraph,
+ collectIdentifiersFromNode,
+ collectIdentifiersFromPattern,
+ collectLocalBindingsFromStatement,
+ collectModuleLevelRefsFromNode,
+ expandDestructuredDeclarations,
+ expandSharedDestructuredDeclarators,
+ expandTransitively,
+ extractModuleInfoFromAst,
+ removeBindingsTransitivelyDependingOn,
+ removeModuleLevelBindings,
+ retainModuleLevelDeclarations,
+ stripUnreferencedTopLevelExpressionStatements,
+ unwrapExportedDeclarations,
+} from './compiler-helpers'
+export type { ExtractedModuleInfo, ModuleInfoBinding } from './compiler-helpers'
diff --git a/packages/router-plugin/src/core/code-splitter/path-ids.ts b/packages/router-utils/src/path-ids.ts
similarity index 100%
rename from packages/router-plugin/src/core/code-splitter/path-ids.ts
rename to packages/router-utils/src/path-ids.ts
diff --git a/packages/router-utils/tests/compiler-helpers.test.ts b/packages/router-utils/tests/compiler-helpers.test.ts
new file mode 100644
index 0000000000..00b75d67e5
--- /dev/null
+++ b/packages/router-utils/tests/compiler-helpers.test.ts
@@ -0,0 +1,249 @@
+import * as t from '@babel/types'
+import { describe, expect, test } from 'vitest'
+import {
+ buildDeclarationMap,
+ collectIdentifiersFromNode,
+ collectLocalBindingsFromStatement,
+ extractModuleInfoFromAst,
+} from '../src/compiler-helpers'
+import { parseAst } from '../src/ast'
+
+function getVariableInit(code: string) {
+ const ast = parseAst({ code, filename: 'test.tsx' })
+ const declaration = ast.program.body.find((node) =>
+ t.isVariableDeclaration(node),
+ ) as t.VariableDeclaration
+ return declaration.declarations[0]!.init!
+}
+
+function collectSortedIdentifiers(node: t.Node) {
+ return [...collectIdentifiersFromNode(node)].sort()
+}
+
+function collectSortedStatementBindings(code: string) {
+ const ast = parseAst({ code, filename: 'test.tsx' })
+ const bindings = new Set()
+ for (const statement of ast.program.body) {
+ collectLocalBindingsFromStatement(statement, bindings)
+ }
+ return [...bindings].sort()
+}
+
+function collectSortedDeclarationMapEntries(code: string) {
+ const ast = parseAst({ code, filename: 'test.tsx' })
+ return [...buildDeclarationMap(ast)]
+ .map(([name, node]): [string, string] => [name, node.type])
+ .sort((left, right) => (left[0] < right[0] ? -1 : 1))
+}
+
+function collectModuleInfoSnapshot(code: string) {
+ const ast = parseAst({ code, filename: 'test.tsx' })
+ const info = extractModuleInfoFromAst(ast)
+ return {
+ bindings: [...info.bindings]
+ .map(([name, binding]) => [
+ name,
+ binding.type === 'import'
+ ? `${binding.source}:${binding.importedName}`
+ : (binding.init?.type ?? null),
+ ])
+ .sort((left, right) => (left[0]! < right[0]! ? -1 : 1)),
+ exports: [...info.exports].sort((left, right) =>
+ left[0] < right[0] ? -1 : 1,
+ ),
+ reExportAllSources: info.reExportAllSources,
+ }
+}
+
+describe('collectIdentifiersFromNode', () => {
+ test('collects free identifiers without reporting nested local bindings', () => {
+ const init = getVariableInit(`
+const value = outer + (() => {
+ const local = dep
+
+ function nested(param = fallback) {
+ const Inner = () =>
+ const LocalExpr = class NamedLocal {
+ method() {
+ return NamedLocal + local + param
+ }
+ }
+
+ class LocalComponent {}
+
+ return local + param + imported + Inner + LocalExpr
+ }
+
+ return nested()
+})()
+`)
+
+ expect(collectSortedIdentifiers(init)).toMatchInlineSnapshot(`
+ [
+ "dep",
+ "fallback",
+ "imported",
+ "outer",
+ ]
+ `)
+ })
+
+ test('respects nested variable, parameter, catch, and import shadowing', () => {
+ const ast = parseAst({
+ code: `
+import { external as localImport } from 'pkg'
+
+const value = (localParam = defaultValue) => {
+ const localShadow = factory(localImport)
+
+ try {
+ throw thrown
+ } catch (thrown) {
+ const factory = () => localShadow + localParam + thrown
+ return factory()
+ }
+}
+`,
+ filename: 'test.ts',
+ })
+
+ expect(collectSortedIdentifiers(ast.program)).toMatchInlineSnapshot(`
+ [
+ "defaultValue",
+ "factory",
+ "thrown",
+ ]
+ `)
+ })
+})
+
+describe('collectLocalBindingsFromStatement', () => {
+ test('collects ids from named default function and class declarations', () => {
+ expect({
+ class: collectSortedStatementBindings(
+ `export default class DefaultComponent {}`,
+ ),
+ function: collectSortedStatementBindings(
+ `export default function DefaultRoute() {}`,
+ ),
+ anonymous: collectSortedStatementBindings(
+ `export default function () {}`,
+ ),
+ }).toMatchInlineSnapshot(`
+ {
+ "anonymous": [],
+ "class": [
+ "DefaultComponent",
+ ],
+ "function": [
+ "DefaultRoute",
+ ],
+ }
+ `)
+ })
+})
+
+describe('buildDeclarationMap', () => {
+ test('maps named default function and class declarations', () => {
+ expect({
+ class: collectSortedDeclarationMapEntries(
+ `export default class DefaultComponent {}`,
+ ),
+ function: collectSortedDeclarationMapEntries(
+ `export default function DefaultRoute() {}`,
+ ),
+ anonymous: collectSortedDeclarationMapEntries(`export default class {}`),
+ }).toMatchInlineSnapshot(`
+ {
+ "anonymous": [],
+ "class": [
+ [
+ "DefaultComponent",
+ "ClassDeclaration",
+ ],
+ ],
+ "function": [
+ [
+ "DefaultRoute",
+ "FunctionDeclaration",
+ ],
+ ],
+ }
+ `)
+ })
+})
+
+describe('extractModuleInfoFromAst', () => {
+ test('extracts imports, local exports, default exports, and re-export sources', () => {
+ expect(
+ collectModuleInfoSnapshot(`
+ import defaultImport, { named as localNamed } from 'pkg'
+ import * as ns from 'pkg-ns'
+ const local = localNamed
+ export const exported = local
+ export function loader() {}
+ export default class DefaultRoute {}
+ export { remote as renamed } from './remote'
+ export * from './all'
+ `),
+ ).toMatchInlineSnapshot(`
+ {
+ "bindings": [
+ [
+ "DefaultRoute",
+ null,
+ ],
+ [
+ "defaultImport",
+ "pkg:default",
+ ],
+ [
+ "exported",
+ "Identifier",
+ ],
+ [
+ "loader",
+ null,
+ ],
+ [
+ "local",
+ "Identifier",
+ ],
+ [
+ "localNamed",
+ "pkg:named",
+ ],
+ [
+ "ns",
+ "pkg-ns:*",
+ ],
+ [
+ "remote",
+ "./remote:remote",
+ ],
+ ],
+ "exports": [
+ [
+ "default",
+ "DefaultRoute",
+ ],
+ [
+ "exported",
+ "exported",
+ ],
+ [
+ "loader",
+ "loader",
+ ],
+ [
+ "renamed",
+ "remote",
+ ],
+ ],
+ "reExportAllSources": [
+ "./all",
+ ],
+ }
+ `)
+ })
+})
diff --git a/packages/router-utils/tests/stripTypeExports.test.ts b/packages/router-utils/tests/stripTypeExports.test.ts
index d0c6af3ad7..32d25f1cbe 100644
--- a/packages/router-utils/tests/stripTypeExports.test.ts
+++ b/packages/router-utils/tests/stripTypeExports.test.ts
@@ -123,7 +123,7 @@ type TypeOnly = string;
export { value, type TypeOnly };`
const result = transform(code)
expect(result).toContain('export { value }')
- expect(result).not.toContain('TypeOnly')
+ expect(result).toContain('type TypeOnly = string')
})
test('removes entire export if all specifiers are type-only', () => {
@@ -133,8 +133,8 @@ export { type Foo, type Bar };
export const value = 1;`
const result = transform(code)
expect(result).not.toContain('export { type Foo')
- expect(result).not.toContain('Foo')
- expect(result).not.toContain('Bar')
+ expect(result).toContain('type Foo = string')
+ expect(result).toContain('type Bar = number')
expect(result).toContain('export const value = 1')
})
})
@@ -233,9 +233,11 @@ export { helper, type HelperType };`
expect(result).not.toContain('./types')
expect(result).not.toContain('HelperType')
- // Should remove type declarations
- expect(result).not.toContain('type LocalType')
- expect(result).not.toContain('interface LocalInterface')
+ // Should preserve local type declarations
+ expect(result).toContain('type LocalType = string')
+ expect(result).toContain('interface LocalInterface')
+
+ // Should remove exported type declarations
expect(result).not.toContain('type ExportedType')
expect(result).not.toContain('interface ExportedInterface')
diff --git a/packages/solid-router/src/ClientOnly.tsx b/packages/solid-router/src/ClientOnly.tsx
index 6ed10d267b..a7c4b71819 100644
--- a/packages/solid-router/src/ClientOnly.tsx
+++ b/packages/solid-router/src/ClientOnly.tsx
@@ -56,10 +56,15 @@ export function ClientOnly(props: ClientOnlyProps) {
* ```
* @returns True if the JS has been hydrated already, false otherwise.
*/
+let globalHydrated = false
+
export function useHydrated(): Solid.Accessor {
- const [hydrated, setHydrated] = Solid.createSignal(false)
+ const [hydrated, setHydrated] = Solid.createSignal(globalHydrated)
+
Solid.onMount(() => {
+ globalHydrated = true
setHydrated(true)
})
+
return hydrated
}
diff --git a/packages/solid-start-client/package.json b/packages/solid-start-client/package.json
index 4537f11800..5e0c978f81 100644
--- a/packages/solid-start-client/package.json
+++ b/packages/solid-start-client/package.json
@@ -48,6 +48,12 @@
"default": "./dist/esm/index.js"
}
},
+ "./hydration": {
+ "import": {
+ "types": "./dist/esm/hydration.d.ts",
+ "default": "./dist/esm/hydration.js"
+ }
+ },
"./package.json": "./package.json"
},
"sideEffects": false,
diff --git a/packages/solid-start-client/src/GenericHydrate.tsx b/packages/solid-start-client/src/GenericHydrate.tsx
new file mode 100644
index 0000000000..e23c057f34
--- /dev/null
+++ b/packages/solid-start-client/src/GenericHydrate.tsx
@@ -0,0 +1,376 @@
+import * as Solid from 'solid-js'
+import { Dynamic } from 'solid-js/web'
+
+import { useHydrated } from '@tanstack/solid-router'
+import { isServer } from '@tanstack/router-core/isServer'
+import {
+ hydrateIdAttribute,
+ hydrateWhenAttribute,
+} from '@tanstack/start-client-core/hydration/constants'
+import {
+ createResolvedGate,
+ getFallbackHtml,
+ getOrCreateGate,
+ onGateResolve,
+ releaseGate,
+ runHydrationStrategyCleanup,
+ saveFallbackHtml,
+ waitForHydrationPrefetchStrategy,
+} from '@tanstack/start-client-core/hydration/runtime'
+import { listenForDelegatedHydrationIntent } from '@tanstack/start-client-core/hydration'
+import type {
+ HydrationRuntimeContext,
+ HydrationStrategy,
+ HydrationWhen,
+} from '@tanstack/start-client-core/hydration'
+import type { HydrationGateRecord } from '@tanstack/start-client-core/hydration/runtime'
+import type { InternalHydrateProps } from './Hydrate'
+import type { DynamicProps } from 'solid-js/web'
+
+type HydrationFallbackDynamicProps = DynamicProps<'div'>
+type HydrationMarkerDynamicProps = DynamicProps<'div'> & {
+ [hydrateIdAttribute]: string
+ [hydrateWhenAttribute]: HydrationWhen
+ [key: `data-${string}`]: string | undefined
+}
+type PrefetchController = {
+ abortController: AbortController
+ hydrationRequested: boolean
+ hydrationListeners: Set<() => void>
+ hydrationResolvePending: boolean
+ started: boolean
+ promise?: Promise
+}
+
+const hydrateIdSelector = `[${hydrateIdAttribute}]`
+const dynamicType = 'dynamic'
+const dynamicHydrateStrategy = {
+ _t: dynamicType,
+ _d: () => true,
+} satisfies HydrationStrategy
+
+function shouldDeferHydration(strategy: HydrationStrategy) {
+ return strategy._d ? strategy._d() : strategy._t !== 'load'
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+function HydratedBoundary(props: {
+ id: string
+ onHydrated?: () => void
+ onStrategyHydrated?: (id: string) => void
+ children: Solid.JSX.Element
+}) {
+ let didHydrate = false
+
+ Solid.onMount(() => {
+ if (didHydrate) return
+ didHydrate = true
+ props.onHydrated?.()
+ props.onStrategyHydrated?.(props.id)
+ })
+
+ return props.children
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function GenericHydrate(props: InternalHydrateProps) {
+ const when = props.when
+ const dynamicHydrate = typeof when === 'function'
+ const initialHydrateStrategy: HydrationStrategy = dynamicHydrate
+ ? // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined')
+ ? dynamicHydrateStrategy
+ : when()
+ : when
+ const markerHydrateType: HydrationWhen = dynamicHydrate
+ ? dynamicType
+ : initialHydrateStrategy._t!
+ const prefetchStrategy = () => props.prefetch
+ const hydrated = useHydrated()
+ const uniqueId = Solid.createUniqueId()
+ const id = props.h ? `${props.h}${uniqueId}` : uniqueId
+ const initialHydrateType = initialHydrateStrategy._t!
+ const shouldPreserveServerHTML =
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined') || !hydrated()
+ const shouldDeferInitialHydration =
+ !hydrated() && shouldDeferHydration(initialHydrateStrategy)
+ const gate: HydrationGateRecord =
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined')
+ ? createResolvedGate(id, initialHydrateType)
+ : getOrCreateGate(id, initialHydrateType)
+ const [ready, setReady] = Solid.createSignal(
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined') ||
+ (!shouldDeferInitialHydration && initialHydrateType !== 'never'),
+ )
+ const [prefetchError, setPrefetchError] = Solid.createSignal()
+ const controller: PrefetchController = {
+ abortController: new AbortController(),
+ hydrationRequested: false,
+ hydrationListeners: new Set<() => void>(),
+ hydrationResolvePending: false,
+ started: false,
+ }
+ let didPrefetch = false
+ let markerElement: HTMLDivElement | undefined
+
+ if (
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ !(isServer ?? typeof window === 'undefined') &&
+ initialHydrateType !== 'never' &&
+ (!shouldDeferInitialHydration ||
+ !shouldDeferHydration(initialHydrateStrategy))
+ ) {
+ gate.resolve()
+ }
+
+ const onHydrate = (listener: () => void) => {
+ if (controller.hydrationRequested) {
+ listener()
+ return () => {}
+ }
+
+ controller.hydrationListeners.add(listener)
+ return () => {
+ controller.hydrationListeners.delete(listener)
+ }
+ }
+
+ const requestHydration = () => {
+ if (!controller.hydrationRequested) {
+ controller.hydrationRequested = true
+ controller.hydrationListeners.forEach((listener) => listener())
+ controller.hydrationListeners.clear()
+ }
+
+ if (!controller.promise) {
+ resolveGate()
+ return
+ }
+ if (controller.hydrationResolvePending) return
+ controller.hydrationResolvePending = true
+
+ controller.promise.then(
+ () => resolveGate(),
+ (error) => {
+ if (!controller.abortController.signal.aborted) {
+ setPrefetchError(() => error)
+ }
+ },
+ )
+ }
+ const resolveGate = gate.resolve
+
+ Solid.onMount(() => {
+ const currentHydrateStrategy = initialHydrateStrategy
+ const currentPrefetchStrategy = prefetchStrategy()
+ const currentHydrateType = currentHydrateStrategy._t!
+ gate.when = currentHydrateType
+ for (const element of document.querySelectorAll(
+ hydrateIdSelector,
+ )) {
+ if (element.getAttribute(hydrateIdAttribute) === id) {
+ markerElement = element
+ saveFallbackHtml(id, element)
+ break
+ }
+ }
+
+ if (
+ currentHydrateType === 'never' &&
+ !shouldPreserveServerHTML &&
+ markerElement
+ ) {
+ markerElement.replaceChildren()
+ }
+
+ if (currentPrefetchStrategy && !controller.started) {
+ controller.started = true
+ const preload = () => props.p?.() ?? Promise.resolve()
+
+ if (typeof currentPrefetchStrategy === 'function') {
+ const promise = Promise.resolve()
+ .then(() =>
+ currentPrefetchStrategy({
+ element: markerElement ?? null,
+ signal: controller.abortController.signal,
+ preload,
+ waitFor: (strategy) =>
+ waitForHydrationPrefetchStrategy(strategy, {
+ element: markerElement ?? null,
+ signal: controller.abortController.signal,
+ onHydrate,
+ }),
+ }),
+ )
+ .then(() => undefined)
+
+ controller.promise = promise
+ promise.catch((error) => {
+ if (!controller.abortController.signal.aborted) {
+ setPrefetchError(() => error)
+ }
+ })
+ } else if (props.p) {
+ const currentStrategy = currentPrefetchStrategy
+ const prefetch = () => {
+ if (didPrefetch) return
+ didPrefetch = true
+ void preload()
+ }
+ const cleanupPrefetch = runHydrationStrategyCleanup(
+ currentStrategy._s?.({
+ element: markerElement ?? null,
+ prefetch,
+ }),
+ )
+ if (cleanupPrefetch) Solid.onCleanup(cleanupPrefetch)
+ }
+ }
+
+ if (
+ currentHydrateType !== 'never' &&
+ (!shouldDeferInitialHydration ||
+ !shouldDeferHydration(currentHydrateStrategy))
+ ) {
+ gate.resolve()
+ setReady(true)
+ }
+
+ const cleanups: Array<() => void> = []
+ let removeResolveListener = () => {}
+ let disposed = false
+
+ const resolveBoundary = () => {
+ setReady(true)
+ }
+
+ const cleanup = () => {
+ if (disposed) return
+ disposed = true
+ if (gate.resolve === requestHydration) {
+ gate.resolve = resolveGate
+ }
+ removeResolveListener()
+ cleanups.forEach((fn) => fn())
+ }
+
+ const addCleanup = (fn: void | (() => void)) => {
+ if (!fn) return
+ if (disposed || gate.resolved) {
+ fn()
+ return
+ }
+ cleanups.push(fn)
+ }
+
+ Solid.onCleanup(() => {
+ controller.abortController.abort()
+ controller.hydrationListeners.clear()
+ cleanup()
+ releaseGate(gate)
+ })
+
+ removeResolveListener = onGateResolve(gate, () => {
+ cleanup()
+ resolveBoundary()
+ })
+
+ if (
+ gate.resolved ||
+ !shouldDeferInitialHydration ||
+ currentHydrateType === 'never'
+ ) {
+ if (gate.resolved) resolveBoundary()
+ return
+ }
+
+ gate.resolve = requestHydration
+ const context: HydrationRuntimeContext = {
+ element: markerElement ?? null,
+ gate,
+ }
+ addCleanup(
+ runHydrationStrategyCleanup(currentHydrateStrategy._s?.(context)),
+ )
+
+ if (currentHydrateStrategy._t !== 'interaction') {
+ addCleanup(
+ runHydrationStrategyCleanup(
+ markerElement
+ ? listenForDelegatedHydrationIntent(markerElement, context)
+ : undefined,
+ ),
+ )
+ }
+ })
+
+ Solid.createRenderEffect(() => {
+ if (
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ (isServer ?? typeof window === 'undefined') ||
+ gate.resolved ||
+ initialHydrateStrategy._t === 'never' ||
+ shouldDeferHydration(initialHydrateStrategy)
+ ) {
+ return
+ }
+
+ gate.resolve()
+ })
+
+ const markerAttributes =
+ markerHydrateType === dynamicType
+ ? undefined
+ : initialHydrateStrategy._a?.()
+ const markerProps: HydrationMarkerDynamicProps = {
+ component: 'div',
+ [hydrateIdAttribute]: id,
+ [hydrateWhenAttribute]: markerHydrateType,
+ ...markerAttributes,
+ }
+ const fallback = () => {
+ if (!shouldPreserveServerHTML) return props.fallback ?? null
+
+ const html = getFallbackHtml(id)
+ if (!html) return null
+
+ const fallbackProps: HydrationFallbackDynamicProps = {
+ component: 'div',
+ style: { display: 'contents' },
+ innerHTML: html,
+ }
+
+ return
+ }
+
+ return (
+
+ {(() => {
+ const error = prefetchError()
+ if (error) throw error
+ return null
+ })()}
+ {initialHydrateType === 'never' && !shouldPreserveServerHTML ? (
+ (props.fallback ?? null)
+ ) : (
+
+
+ {
+ markerElement?.removeAttribute(hydrateWhenAttribute)
+ initialHydrateStrategy._o?.(id)
+ }}
+ >
+ {props.children}
+
+
+
+ )}
+
+ )
+}
diff --git a/packages/solid-start-client/src/Hydrate.tsx b/packages/solid-start-client/src/Hydrate.tsx
new file mode 100644
index 0000000000..1b52e643f2
--- /dev/null
+++ b/packages/solid-start-client/src/Hydrate.tsx
@@ -0,0 +1,75 @@
+import { GenericHydrate } from './GenericHydrate'
+import type {
+ HydrationStrategy as CoreHydrationStrategy,
+ HydrationPrefetchFunction,
+ HydrationPrefetchStrategy,
+ HydrationWhen,
+} from '@tanstack/start-client-core/hydration'
+import type * as Solid from 'solid-js'
+
+export type {
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ HydrationPrefetchContext,
+ HydrationPrefetchFunction,
+ HydrationPrefetchStrategy,
+ HydrationPrefetchWaitReason,
+ HydrationWhen,
+} from '@tanstack/start-client-core/hydration'
+
+export type SolidHydrationStrategy<
+ TWhen extends HydrationWhen = HydrationWhen,
+ TCanPrefetch extends boolean = boolean,
+> = CoreHydrationStrategy & {
+ _h: (props: HydrateProps) => Solid.JSX.Element
+}
+
+export type HydrationStrategy<
+ TWhen extends HydrationWhen = HydrationWhen,
+ TCanPrefetch extends boolean = boolean,
+> = SolidHydrationStrategy
+
+export type HydrateWhen =
+ | SolidHydrationStrategy
+ | (() => SolidHydrationStrategy)
+
+type HydrateCommonOptions = {
+ when: HydrateWhen
+ fallback?: Solid.JSX.Element
+ onHydrated?: () => void
+}
+
+export type HydrateOptions =
+ | (HydrateCommonOptions & {
+ prefetch?: never
+ split?: boolean
+ })
+ | (HydrateCommonOptions & {
+ prefetch: HydrationPrefetchStrategy
+ split?: true
+ })
+ | (HydrateCommonOptions & {
+ prefetch: HydrationPrefetchFunction
+ split?: boolean
+ })
+
+export type HydrateProps = HydrateOptions & {
+ children: Solid.JSX.Element
+}
+
+export type InternalHydrateProps = HydrateProps & {
+ h?: string
+ p?: () => Promise
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function Hydrate(props: HydrateProps) {
+ if (
+ typeof props.when === 'function' ||
+ typeof props.prefetch === 'function'
+ ) {
+ return
+ }
+
+ return props.when._h(props)
+}
diff --git a/packages/solid-start-client/src/hydration.ts b/packages/solid-start-client/src/hydration.ts
new file mode 100644
index 0000000000..bd875dcb0a
--- /dev/null
+++ b/packages/solid-start-client/src/hydration.ts
@@ -0,0 +1,20 @@
+export { condition, interaction, media } from './hydration/generic'
+export { idle } from './hydration/idle'
+export { load } from './hydration/load'
+export { never } from './hydration/never'
+export { visible } from './hydration/visible'
+export type {
+ HydrationCondition,
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ IdleHydrationOptions,
+ HydrationPrefetchContext,
+ HydrationPrefetchFunction,
+ HydrationPrefetchWhen,
+ HydrationPrefetchStrategy,
+ HydrationPrefetchWaitReason,
+ HydrationStrategyTypes,
+ HydrationWhen,
+ VisibleHydrationOptions,
+} from '@tanstack/start-client-core/hydration'
+export type { HydrationStrategy, SolidHydrationStrategy } from './Hydrate'
diff --git a/packages/solid-start-client/src/hydration/generic.ts b/packages/solid-start-client/src/hydration/generic.ts
new file mode 100644
index 0000000000..bfc1e8db9f
--- /dev/null
+++ b/packages/solid-start-client/src/hydration/generic.ts
@@ -0,0 +1,41 @@
+import {
+ condition as coreCondition,
+ interaction as coreInteraction,
+ media as coreMedia,
+ withHydrationRenderer,
+} from '@tanstack/start-client-core/hydration'
+import { GenericHydrate } from '../GenericHydrate'
+import type {
+ HydrationCondition,
+ HydrationInteractionEvents,
+ HydrationPrefetchStrategy,
+} from '@tanstack/start-client-core/hydration'
+import type { SolidHydrationStrategy } from '../Hydrate'
+
+/* @__NO_SIDE_EFFECTS__ */
+export function media(
+ query: string,
+): SolidHydrationStrategy<'media', true> & HydrationPrefetchStrategy<'media'> {
+ return /* @__PURE__ */ withHydrationRenderer(coreMedia(query), GenericHydrate)
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function condition(
+ condition: HydrationCondition,
+): SolidHydrationStrategy<'condition', false> {
+ return /* @__PURE__ */ withHydrationRenderer(
+ coreCondition(condition),
+ GenericHydrate,
+ )
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function interaction(options?: {
+ events?: HydrationInteractionEvents
+}): SolidHydrationStrategy<'interaction', true> &
+ HydrationPrefetchStrategy<'interaction'> {
+ return /* @__PURE__ */ withHydrationRenderer(
+ coreInteraction(options),
+ GenericHydrate,
+ )
+}
diff --git a/packages/solid-start-client/src/hydration/idle.ts b/packages/solid-start-client/src/hydration/idle.ts
new file mode 100644
index 0000000000..89282b0a95
--- /dev/null
+++ b/packages/solid-start-client/src/hydration/idle.ts
@@ -0,0 +1,20 @@
+import {
+ idle as coreIdle,
+ withHydrationRenderer,
+} from '@tanstack/start-client-core/hydration'
+import { GenericHydrate } from '../GenericHydrate'
+import type {
+ HydrationPrefetchStrategy,
+ IdleHydrationOptions,
+} from '@tanstack/start-client-core/hydration'
+import type { SolidHydrationStrategy } from '../Hydrate'
+
+/* @__NO_SIDE_EFFECTS__ */
+export function idle(
+ options: IdleHydrationOptions = {},
+): SolidHydrationStrategy<'idle', true> & HydrationPrefetchStrategy<'idle'> {
+ return /* @__PURE__ */ withHydrationRenderer(
+ coreIdle(options),
+ GenericHydrate,
+ )
+}
diff --git a/packages/solid-start-client/src/hydration/load.tsx b/packages/solid-start-client/src/hydration/load.tsx
new file mode 100644
index 0000000000..a2836231e8
--- /dev/null
+++ b/packages/solid-start-client/src/hydration/load.tsx
@@ -0,0 +1,13 @@
+import {
+ load as coreLoad,
+ withHydrationRenderer,
+} from '@tanstack/start-client-core/hydration'
+import { GenericHydrate } from '../GenericHydrate'
+import type { HydrationPrefetchStrategy } from '@tanstack/start-client-core/hydration'
+import type { SolidHydrationStrategy } from '../Hydrate'
+
+/* @__NO_SIDE_EFFECTS__ */
+export function load(): SolidHydrationStrategy<'load', true> &
+ HydrationPrefetchStrategy<'load'> {
+ return /* @__PURE__ */ withHydrationRenderer(coreLoad(), GenericHydrate)
+}
diff --git a/packages/solid-start-client/src/hydration/never.ts b/packages/solid-start-client/src/hydration/never.ts
new file mode 100644
index 0000000000..36513b95d4
--- /dev/null
+++ b/packages/solid-start-client/src/hydration/never.ts
@@ -0,0 +1,11 @@
+import {
+ never as coreNever,
+ withHydrationRenderer,
+} from '@tanstack/start-client-core/hydration'
+import { GenericHydrate } from '../GenericHydrate'
+import type { SolidHydrationStrategy } from '../Hydrate'
+
+/* @__NO_SIDE_EFFECTS__ */
+export function never(): SolidHydrationStrategy<'never', false> {
+ return /* @__PURE__ */ withHydrationRenderer(coreNever(), GenericHydrate)
+}
diff --git a/packages/solid-start-client/src/hydration/visible.tsx b/packages/solid-start-client/src/hydration/visible.tsx
new file mode 100644
index 0000000000..c747c236ec
--- /dev/null
+++ b/packages/solid-start-client/src/hydration/visible.tsx
@@ -0,0 +1,21 @@
+import {
+ visible as coreVisible,
+ withHydrationRenderer,
+} from '@tanstack/start-client-core/hydration'
+import { GenericHydrate } from '../GenericHydrate'
+import type {
+ HydrationPrefetchStrategy,
+ VisibleHydrationOptions,
+} from '@tanstack/start-client-core/hydration'
+import type { SolidHydrationStrategy } from '../Hydrate'
+
+/* @__NO_SIDE_EFFECTS__ */
+export function visible(
+ options?: VisibleHydrationOptions,
+): SolidHydrationStrategy<'visible', true> &
+ HydrationPrefetchStrategy<'visible'> {
+ return /* @__PURE__ */ withHydrationRenderer(
+ coreVisible(options),
+ GenericHydrate,
+ )
+}
diff --git a/packages/solid-start-client/src/index.tsx b/packages/solid-start-client/src/index.tsx
index aa73990a57..2128ebcb01 100644
--- a/packages/solid-start-client/src/index.tsx
+++ b/packages/solid-start-client/src/index.tsx
@@ -1,2 +1,16 @@
export { StartClient } from './StartClient'
export { hydrateStart } from './hydrateStart'
+export { Hydrate } from './Hydrate'
+export type {
+ HydrateOptions,
+ HydrateProps,
+ HydrateWhen,
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ HydrationPrefetchContext,
+ HydrationPrefetchFunction,
+ HydrationPrefetchStrategy,
+ HydrationPrefetchWaitReason,
+ HydrationStrategy,
+ HydrationWhen,
+} from './Hydrate'
diff --git a/packages/solid-start-client/src/tests/Hydrate.test-d.tsx b/packages/solid-start-client/src/tests/Hydrate.test-d.tsx
new file mode 100644
index 0000000000..e5e13334f3
--- /dev/null
+++ b/packages/solid-start-client/src/tests/Hydrate.test-d.tsx
@@ -0,0 +1,147 @@
+import { expectTypeOf, test } from 'vitest'
+import { visible } from '../hydration'
+import { Hydrate } from '../Hydrate'
+import type {
+ HydrateOptions,
+ HydrateProps,
+ HydrationPrefetchFunction,
+ HydrationPrefetchStrategy,
+ HydrationStrategy,
+} from '../Hydrate'
+import type { HydrationStrategy as CoreHydrationStrategy } from '@tanstack/start-client-core/hydration'
+import type { JSX } from 'solid-js'
+
+type CommonHydrateProps = {
+ fallback?: JSX.Element
+ onHydrated?: () => void
+ children: JSX.Element
+}
+
+type SplitHydrateProps = CommonHydrateProps & {
+ when: HydrationStrategy | (() => HydrationStrategy)
+ prefetch?: never
+ split?: boolean
+}
+
+type PrefetchHydrateProps = CommonHydrateProps & {
+ when: HydrationStrategy | (() => HydrationStrategy)
+ prefetch: HydrationPrefetchStrategy
+ split?: true
+}
+
+type FunctionPrefetchHydrateProps = CommonHydrateProps & {
+ when: HydrationStrategy | (() => HydrationStrategy)
+ prefetch: HydrationPrefetchFunction
+ split?: boolean
+}
+
+test('Hydrate component accepts the public HydrateProps type', () => {
+ expectTypeOf(Hydrate).toBeFunction()
+ expectTypeOf(Hydrate).parameter(0).branded.toEqualTypeOf()
+})
+
+test('HydrateOptions supports reusable spread props', () => {
+ const belowFoldProps = {
+ when: () => visible({ rootMargin: '800px' }),
+ } satisfies HydrateOptions
+
+ expectTypeOf(belowFoldProps).toMatchTypeOf()
+
+ const withFunctionPrefetch = {
+ when: visible(),
+ split: false,
+ prefetch: (ctx) => {
+ expectTypeOf(ctx.element).toEqualTypeOf()
+ expectTypeOf(ctx.signal).toEqualTypeOf()
+ expectTypeOf(ctx.preload).returns.toEqualTypeOf>()
+ expectTypeOf(ctx.waitFor).returns.toEqualTypeOf<
+ Promise<'prefetch' | 'hydrate' | 'abort'>
+ >()
+ },
+ } satisfies HydrateOptions
+
+ expectTypeOf(withFunctionPrefetch).toMatchTypeOf()
+})
+
+test('Hydrate props are exact for strategy and prefetch forms', () => {
+ expectTypeOf<
+ Extract
+ >().branded.toEqualTypeOf()
+ expectTypeOf<
+ Extract
+ >().branded.toEqualTypeOf()
+ expectTypeOf<
+ Extract
+ >().branded.toEqualTypeOf()
+})
+
+test('Hydrate requires a strategy', () => {
+ expectTypeOf<{
+ when: HydrationStrategy
+ children: JSX.Element
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: () => HydrationStrategy
+ children: JSX.Element
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ children: JSX.Element
+ }>().not.toMatchTypeOf()
+
+ expectTypeOf<{
+ when: () => true
+ children: JSX.Element
+ }>().not.toMatchTypeOf()
+
+ expectTypeOf<{
+ when: false
+ children: JSX.Element
+ }>().not.toMatchTypeOf()
+})
+
+test('Hydrate requires a framework-renderable strategy', () => {
+ expectTypeOf().not.toMatchTypeOf()
+ expectTypeOf>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: CoreHydrationStrategy
+ children: JSX.Element
+ }>().not.toMatchTypeOf()
+})
+
+test('Hydrate enforces prefetch only with split boundaries', () => {
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchStrategy
+ children: JSX.Element
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchStrategy
+ split: true
+ children: JSX.Element
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchStrategy
+ split: false
+ children: JSX.Element
+ }>().not.toMatchTypeOf()
+
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchFunction
+ split: false
+ children: JSX.Element
+ }>().toMatchTypeOf()
+
+ expectTypeOf<{
+ when: HydrationStrategy
+ prefetch: HydrationPrefetchFunction
+ children: JSX.Element
+ }>().toMatchTypeOf()
+})
diff --git a/packages/solid-start-client/vite.config.ts b/packages/solid-start-client/vite.config.ts
index be71bab518..dbfb60ea03 100644
--- a/packages/solid-start-client/vite.config.ts
+++ b/packages/solid-start-client/vite.config.ts
@@ -20,7 +20,7 @@ export default mergeConfig(
tanstackViteConfig({
tsconfigPath: './tsconfig.build.json',
srcDir: './src',
- entry: './src/index.tsx',
+ entry: ['./src/index.tsx', './src/Hydrate.tsx', './src/hydration.ts'],
cjs: false,
}),
)
diff --git a/packages/solid-start/package.json b/packages/solid-start/package.json
index a6999021e4..4ab9a7ff8e 100644
--- a/packages/solid-start/package.json
+++ b/packages/solid-start/package.json
@@ -44,6 +44,12 @@
"default": "./dist/esm/client.js"
}
},
+ "./hydration": {
+ "import": {
+ "types": "./dist/esm/hydration.d.ts",
+ "default": "./dist/esm/hydration.js"
+ }
+ },
"./client-rpc": {
"import": {
"types": "./dist/esm/client-rpc.d.ts",
diff --git a/packages/solid-start/src/hydration.ts b/packages/solid-start/src/hydration.ts
new file mode 100644
index 0000000000..fc660e9dc5
--- /dev/null
+++ b/packages/solid-start/src/hydration.ts
@@ -0,0 +1,18 @@
+export {
+ condition,
+ idle,
+ interaction,
+ load,
+ media,
+ never,
+ visible,
+} from '@tanstack/solid-start-client/hydration'
+export type {
+ HydrationCondition,
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ HydrationPrefetchStrategy,
+ HydrationStrategy,
+ HydrationWhen,
+ VisibleHydrationOptions,
+} from '@tanstack/solid-start-client/hydration'
diff --git a/packages/solid-start/src/index.ts b/packages/solid-start/src/index.ts
index 8b51b6c783..0cdac87c81 100644
--- a/packages/solid-start/src/index.ts
+++ b/packages/solid-start/src/index.ts
@@ -1,2 +1,12 @@
export { useServerFn } from './useServerFn'
export * from '@tanstack/start-client-core'
+export { Hydrate } from '@tanstack/solid-start-client'
+export type {
+ HydrateOptions,
+ HydrateProps,
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ HydrationPrefetchStrategy,
+ HydrationStrategy,
+ HydrationWhen,
+} from '@tanstack/solid-start-client'
diff --git a/packages/solid-start/vite.config.ts b/packages/solid-start/vite.config.ts
index 262208de09..2c1a42d0e4 100644
--- a/packages/solid-start/vite.config.ts
+++ b/packages/solid-start/vite.config.ts
@@ -27,6 +27,7 @@ export default mergeConfig(
entry: [
'./src/index.ts',
'./src/client.tsx',
+ './src/hydration.ts',
'./src/client-rpc.ts',
'./src/ssr-rpc.ts',
'./src/server-rpc.ts',
diff --git a/packages/start-client-core/package.json b/packages/start-client-core/package.json
index 76df615958..e0443bae20 100644
--- a/packages/start-client-core/package.json
+++ b/packages/start-client-core/package.json
@@ -60,16 +60,37 @@
"default": "./dist/esm/client-rpc/index.js"
}
},
+ "./hydration": {
+ "import": {
+ "types": "./dist/esm/hydration.d.ts",
+ "default": "./dist/esm/hydration.js"
+ }
+ },
+ "./hydration/constants": {
+ "import": {
+ "types": "./dist/esm/hydration/constants.d.ts",
+ "default": "./dist/esm/hydration/constants.js"
+ }
+ },
+ "./hydration/runtime": {
+ "import": {
+ "types": "./dist/esm/hydration/runtime.d.ts",
+ "default": "./dist/esm/hydration/runtime.js"
+ }
+ },
"./package.json": "./package.json"
},
"imports": {
"#tanstack-start-entry": {
+ "types": "./src/start-entry.d.ts",
"default": "./dist/esm/fake-entries/start.js"
},
"#tanstack-router-entry": {
+ "types": "./src/start-entry.d.ts",
"default": "./dist/esm/fake-entries/router.js"
},
"#tanstack-start-plugin-adapters": {
+ "types": "./src/start-entry.d.ts",
"default": "./dist/esm/fake-entries/plugin-adapters.js"
}
},
diff --git a/packages/start-client-core/src/client/hydrateStart.ts b/packages/start-client-core/src/client/hydrateStart.ts
index 206b70505c..48b58158bc 100644
--- a/packages/start-client-core/src/client/hydrateStart.ts
+++ b/packages/start-client-core/src/client/hydrateStart.ts
@@ -10,7 +10,19 @@ import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializatio
import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core'
import type { AnyStartInstanceOptions } from '../createStart'
-export async function hydrateStart(): Promise {
+type HotContext = {
+ data?: Record
+ dispose?: (cb: (data: Record) => void) => void
+}
+
+declare global {
+ interface ImportMeta {
+ hot?: HotContext
+ webpackHot?: HotContext
+ }
+}
+
+async function hydrateStart(): Promise {
const router = await getRouter()
let serializationAdapters: Array
@@ -47,3 +59,38 @@ export async function hydrateStart(): Promise {
return router
}
+
+function hydrateStartWithHmr(): Promise {
+ const hot = import.meta.hot ?? import.meta.webpackHot
+
+ if (!hot) {
+ return hydrateStart()
+ }
+
+ const key = 'tss-hydrate-start-promise'
+ const hotData = (hot.data ??= {})
+ let hydrationPromise = hotData[key] as Promise | undefined
+
+ if (!hydrationPromise) {
+ hydrationPromise = hydrateStart().catch((error) => {
+ if (hotData[key] === hydrationPromise) {
+ hotData[key] = undefined
+ }
+
+ throw error
+ })
+
+ hotData[key] = hydrationPromise
+ }
+
+ hot.dispose?.((data) => {
+ data[key] = hotData[key]
+ })
+
+ return hydrationPromise
+}
+
+const exportedHydrateStart =
+ process.env.NODE_ENV !== 'production' ? hydrateStartWithHmr : hydrateStart
+
+export { exportedHydrateStart as hydrateStart }
diff --git a/packages/start-client-core/src/hydration.ts b/packages/start-client-core/src/hydration.ts
new file mode 100644
index 0000000000..7a2665e998
--- /dev/null
+++ b/packages/start-client-core/src/hydration.ts
@@ -0,0 +1,50 @@
+import { hydrateIdAttribute } from './hydration/constants'
+
+export { condition } from './hydration/condition'
+export type { HydrationCondition } from './hydration/condition'
+export {
+ hydrateIdAttribute,
+ hydrateInteractionEventsAttribute,
+ hydrateWhenAttribute,
+} from './hydration/constants'
+export const hydrateIdSelector = `[${hydrateIdAttribute}]`
+export { idle, scheduleIdle } from './hydration/idle'
+export type { IdleHydrationOptions } from './hydration/idle'
+export { interaction } from './hydration/interaction'
+export { load } from './hydration/load'
+export { media } from './hydration/media'
+export { never } from './hydration/never'
+export {
+ clearResolvedGateIdsInMarker,
+ createResolvedGate,
+ getFallbackHtml,
+ getMarkerGate,
+ getOrCreateGate,
+ onGateResolve,
+ releaseGate,
+ resolveHydrationMarker,
+ runHydrationStrategyCleanup,
+ saveFallbackHtml,
+ waitForHydrationPrefetchStrategy,
+} from './hydration/runtime'
+export { withHydrationRenderer } from './hydration/renderer'
+export { visible } from './hydration/visible'
+export { listenForDelegatedHydrationIntent } from './hydration/interaction'
+export type { VisibleHydrationOptions } from './hydration/visible'
+export type { HydrationGateRecord } from './hydration/runtime'
+export type { HydrationStrategyWithRenderer } from './hydration/renderer'
+export type {
+ HydrationInteractionEvent,
+ HydrationInteractionEvents,
+ HydrationMarkerAttributes,
+ HydrationPrefetchContext,
+ HydrationPrefetchFunction,
+ HydrationPrefetchWhen,
+ HydrationPrefetchStrategy,
+ HydrationPrefetchWaitReason,
+ HydrationRuntimeContext,
+ HydrationRuntimeGate,
+ HydrationStrategy,
+ HydrationStrategyTypes,
+ HydrationWhen,
+} from './hydration/types'
diff --git a/packages/start-client-core/src/hydration/condition.ts b/packages/start-client-core/src/hydration/condition.ts
new file mode 100644
index 0000000000..6e640cc3ae
--- /dev/null
+++ b/packages/start-client-core/src/hydration/condition.ts
@@ -0,0 +1,20 @@
+import type { HydrationStrategy } from './types'
+
+const conditionType = 'condition'
+
+export type HydrationCondition = boolean | (() => boolean)
+
+/* @__NO_SIDE_EFFECTS__ */
+export function condition(
+ condition: HydrationCondition,
+): HydrationStrategy {
+ return {
+ _t: conditionType,
+ _d: () => !(typeof condition === 'function' ? condition() : condition),
+ _s: ({ gate }) => {
+ if (typeof condition === 'function' ? condition() : condition) {
+ gate!.resolve()
+ }
+ },
+ }
+}
diff --git a/packages/start-client-core/src/hydration/constants.ts b/packages/start-client-core/src/hydration/constants.ts
new file mode 100644
index 0000000000..4b7a29313d
--- /dev/null
+++ b/packages/start-client-core/src/hydration/constants.ts
@@ -0,0 +1,4 @@
+export const hydrateIdAttribute = 'data-ts-hydrate-id'
+export const hydrateWhenAttribute = 'data-ts-hydrate-when'
+export const hydrateInteractionEventsAttribute =
+ 'data-ts-hydrate-interaction-events'
diff --git a/packages/start-client-core/src/hydration/idle.ts b/packages/start-client-core/src/hydration/idle.ts
new file mode 100644
index 0000000000..40bcf6e59b
--- /dev/null
+++ b/packages/start-client-core/src/hydration/idle.ts
@@ -0,0 +1,39 @@
+import type { HydrationPrefetchStrategy } from './types'
+
+const idleType = 'idle'
+
+export type IdleHydrationOptions = {
+ timeout?: number
+}
+
+type IdleScheduler = {
+ requestIdleCallback?: (
+ callback: IdleRequestCallback,
+ options?: IdleRequestOptions,
+ ) => number
+ cancelIdleCallback?: (handle: number) => void
+}
+
+export function scheduleIdle(callback: () => void, timeout: number) {
+ const schedule = globalThis as unknown as IdleScheduler
+ if (schedule.requestIdleCallback) {
+ const handle = schedule.requestIdleCallback(callback, { timeout })
+ return () => schedule.cancelIdleCallback?.(handle)
+ }
+
+ const timeoutId = globalThis.setTimeout(callback, timeout)
+ return () => globalThis.clearTimeout(timeoutId)
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function idle(
+ options: IdleHydrationOptions = {},
+): HydrationPrefetchStrategy {
+ const timeout = options.timeout ?? 2000
+
+ return {
+ _t: idleType,
+ _s: ({ gate, prefetch }) =>
+ scheduleIdle(prefetch ?? gate!.resolve, timeout),
+ }
+}
diff --git a/packages/start-client-core/src/hydration/interaction.ts b/packages/start-client-core/src/hydration/interaction.ts
new file mode 100644
index 0000000000..d5d6886d36
--- /dev/null
+++ b/packages/start-client-core/src/hydration/interaction.ts
@@ -0,0 +1,342 @@
+import {
+ hydrateIdAttribute,
+ hydrateInteractionEventsAttribute,
+ hydrateWhenAttribute,
+} from './constants'
+import {
+ clearResolvedGateIdsInMarker,
+ getMarkerGate,
+ resolveHydrationMarker,
+} from './runtime'
+import type {
+ HydrationInteractionEvents,
+ HydrationPrefetchStrategy,
+ HydrationRuntimeContext,
+} from './types'
+
+export type InteractionHydrationOptions = {
+ events?: HydrationInteractionEvents
+}
+
+const hydrateIdSelector = `[${hydrateIdAttribute}]`
+
+type PendingReplayEvent = {
+ marker: Element
+ targetPath: Array
+ type: string
+ event: Event
+}
+
+const defaultInteractionEvents = [
+ 'pointerenter',
+ 'focusin',
+ 'pointerdown',
+ 'click',
+] as const
+const supportedInteractionEvents = [
+ 'auxclick',
+ 'click',
+ 'contextmenu',
+ 'dblclick',
+ 'focusin',
+ 'keydown',
+ 'keyup',
+ 'mousedown',
+ 'mouseenter',
+ 'mouseover',
+ 'mouseup',
+ 'pointerdown',
+ 'pointerenter',
+ 'pointerover',
+ 'pointerup',
+] as const
+const interactionType = 'interaction'
+const dynamicType = 'dynamic'
+const interactionHydrateSelector = `[${hydrateWhenAttribute}="${interactionType}"]`
+const delegatedHydrateSelector = `${interactionHydrateSelector},[${hydrateWhenAttribute}="${dynamicType}"]`
+const replayEventsByGateId = /* @__PURE__ */ new Map<
+ string,
+ Array
+>()
+
+function getIntentListenerEvents(
+ marker: Element,
+ events: ReadonlyArray,
+) {
+ const listenerEvents = new Set(events)
+
+ marker.querySelectorAll(delegatedHydrateSelector).forEach((childMarker) => {
+ if (childMarker.getAttribute(hydrateWhenAttribute) === dynamicType) {
+ supportedInteractionEvents.forEach((eventName) => {
+ listenerEvents.add(eventName)
+ })
+ return
+ }
+
+ const attr = childMarker.getAttribute(hydrateInteractionEventsAttribute)
+ for (const eventName of attr === null
+ ? defaultInteractionEvents
+ : attr.split(/\s+/).filter(Boolean)) {
+ listenerEvents.add(eventName)
+ }
+ })
+
+ return [...listenerEvents]
+}
+
+function queueHydrationReplayEvent(marker: Element, event: Event) {
+ if (!event.bubbles) return
+
+ const id = marker.getAttribute(hydrateIdAttribute)
+ const when = marker.getAttribute(hydrateWhenAttribute)
+ if (!id || !when || when === 'never') return
+
+ const target = event.target
+ if (!target) return
+
+ const gate = getMarkerGate(marker)
+ if (gate?.resolved) return
+
+ event.preventDefault()
+ event.stopPropagation()
+ event.stopImmediatePropagation()
+
+ let targetPath: Array = []
+ if (target instanceof Node && marker.contains(target)) {
+ let node: Element | null =
+ target instanceof Element ? target : target.parentElement
+
+ while (node && node !== marker) {
+ const parent = node.parentElement
+ if (!parent) {
+ targetPath = []
+ break
+ }
+ targetPath.push(Array.prototype.indexOf.call(parent.children, node))
+ node = parent
+ }
+ targetPath.reverse()
+ }
+
+ const pendingEvents = replayEventsByGateId.get(id) ?? []
+ pendingEvents.push({
+ marker,
+ targetPath,
+ type: event.type,
+ event,
+ })
+ replayEventsByGateId.set(id, pendingEvents)
+}
+
+if (typeof document !== 'undefined') {
+ const onIntent = (event: Event) => {
+ const target = event.target
+ if (!(target instanceof Element)) return
+
+ let marker: Element | null = target.closest(hydrateIdSelector)
+ const markers: Array = []
+ let shouldHandle = false
+
+ while (marker) {
+ markers.push(marker)
+
+ const when = marker.getAttribute(hydrateWhenAttribute)
+ if (when === dynamicType) {
+ shouldHandle ||= event.type === 'click'
+ } else if (when === interactionType) {
+ const attr = marker.getAttribute(hydrateInteractionEventsAttribute)
+ const events: ReadonlyArray =
+ attr === null
+ ? defaultInteractionEvents
+ : attr.split(/\s+/).filter(Boolean)
+ shouldHandle ||= events.includes(event.type)
+ }
+
+ marker = marker.parentElement?.closest(hydrateIdSelector) ?? null
+ }
+
+ if (!shouldHandle) return
+
+ markers.reverse()
+ if (markers.every((marker) => getMarkerGate(marker))) return
+
+ markers.forEach((marker) => {
+ queueHydrationReplayEvent(marker, event)
+ resolveHydrationMarker(marker)
+ })
+ }
+
+ supportedInteractionEvents.forEach((eventName) => {
+ document.addEventListener(eventName, onIntent, true)
+ })
+}
+
+function listenForIntent(
+ element: Element,
+ events: ReadonlyArray,
+ context: HydrationRuntimeContext,
+) {
+ const onIntent = (event: Event) => {
+ const target = event.target
+ let marker: Element | null
+ if (target instanceof Element) {
+ const closestMarker = target.closest(hydrateIdSelector)
+ marker =
+ closestMarker && element.contains(closestMarker)
+ ? closestMarker
+ : element
+ } else {
+ marker = element
+ }
+
+ const markers: Array = []
+ while (marker) {
+ if (marker.hasAttribute(hydrateIdAttribute)) {
+ markers.push(marker)
+ }
+ if (marker === element) break
+ marker = marker.parentElement
+ }
+
+ if (!markers.includes(element)) {
+ markers.push(element)
+ }
+
+ markers.reverse()
+
+ if (
+ context.delegated &&
+ !markers.some(
+ (marker) =>
+ marker.getAttribute(hydrateWhenAttribute) === interactionType ||
+ marker.getAttribute(hydrateWhenAttribute) === dynamicType,
+ )
+ ) {
+ return
+ }
+
+ markers.forEach((marker) => {
+ queueHydrationReplayEvent(marker, event)
+ resolveHydrationMarker(marker)
+ })
+ }
+ let disposed = false
+
+ events.forEach((eventName) => {
+ element.addEventListener(eventName, onIntent, true)
+ })
+
+ return () => {
+ if (disposed) return
+ disposed = true
+ events.forEach((eventName) => {
+ element.removeEventListener(eventName, onIntent, true)
+ })
+ }
+}
+
+export function listenForDelegatedHydrationIntent(
+ element: Element,
+ context: HydrationRuntimeContext,
+) {
+ const listenerEvents = getIntentListenerEvents(element, [])
+ if (!listenerEvents.length) return
+
+ const cleanupIntent = listenForIntent(element, listenerEvents, {
+ ...context,
+ delegated: true,
+ })
+ return () => {
+ cleanupIntent()
+ clearResolvedGateIdsInMarker(element)
+ }
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function interaction(
+ options: InteractionHydrationOptions = {},
+): HydrationPrefetchStrategy {
+ let events: ReadonlyArray = defaultInteractionEvents
+ if (options.events !== undefined) {
+ const eventList: ReadonlyArray =
+ typeof options.events === 'string' ? [options.events] : options.events
+ const normalizedEvents: Array = []
+ const seen = new Set()
+
+ for (const eventName of eventList) {
+ if (!eventName || seen.has(eventName)) continue
+ seen.add(eventName)
+ normalizedEvents.push(eventName)
+ }
+
+ events = normalizedEvents
+ }
+
+ const eventKey = events.join(' ')
+
+ return {
+ _t: interactionType,
+ _s: (context) => {
+ const element = context.element
+ if (!element) return
+ const prefetch = context.prefetch
+ if (prefetch) {
+ if (!events.length) return
+ let disposed = false
+
+ events.forEach((eventName) => {
+ element.addEventListener(eventName, prefetch, true)
+ })
+
+ return () => {
+ if (disposed) return
+ disposed = true
+ events.forEach((eventName) => {
+ element.removeEventListener(eventName, prefetch, true)
+ })
+ }
+ }
+
+ const listenerEvents = getIntentListenerEvents(element, events)
+ const cleanupIntent = listenerEvents.length
+ ? listenForIntent(element, listenerEvents, context)
+ : undefined
+ return () => {
+ cleanupIntent?.()
+ clearResolvedGateIdsInMarker(element)
+ }
+ },
+ _o: (id) => {
+ globalThis.requestAnimationFrame(() => {
+ const pendingEvents = replayEventsByGateId.get(id)
+ if (!pendingEvents?.length) return
+
+ replayEventsByGateId.delete(id)
+
+ for (const pendingEvent of pendingEvents) {
+ let replayTarget: Element | null = pendingEvent.marker
+ for (const index of pendingEvent.targetPath) {
+ replayTarget = replayTarget.children[index] ?? null
+ if (!replayTarget) break
+ }
+
+ const event = pendingEvent.event
+ replayTarget ??= pendingEvent.marker
+ replayTarget.dispatchEvent(
+ event instanceof MouseEvent
+ ? new MouseEvent(event.type, event)
+ : event instanceof FocusEvent
+ ? new FocusEvent(event.type, event)
+ : new Event(event.type, event),
+ )
+ }
+ })
+ },
+ _a: () =>
+ options.events === undefined
+ ? undefined
+ : {
+ [hydrateInteractionEventsAttribute]: eventKey,
+ },
+ }
+}
diff --git a/packages/start-client-core/src/hydration/load.ts b/packages/start-client-core/src/hydration/load.ts
new file mode 100644
index 0000000000..b78d6d6a42
--- /dev/null
+++ b/packages/start-client-core/src/hydration/load.ts
@@ -0,0 +1,16 @@
+import type { HydrationPrefetchStrategy } from './types'
+
+const loadType = 'load'
+
+const loadStrategy: HydrationPrefetchStrategy = {
+ _t: loadType,
+ _d: () => false,
+ _s: ({ gate, prefetch }) => {
+ ;(prefetch ?? gate!.resolve)()
+ },
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function load(): HydrationPrefetchStrategy {
+ return loadStrategy
+}
diff --git a/packages/start-client-core/src/hydration/media.ts b/packages/start-client-core/src/hydration/media.ts
new file mode 100644
index 0000000000..c2335b7e2f
--- /dev/null
+++ b/packages/start-client-core/src/hydration/media.ts
@@ -0,0 +1,25 @@
+import type { HydrationPrefetchStrategy } from './types'
+
+const mediaType = 'media'
+
+/* @__NO_SIDE_EFFECTS__ */
+export function media(
+ query: string,
+): HydrationPrefetchStrategy {
+ return {
+ _t: mediaType,
+ _s: ({ gate, prefetch }) => {
+ if (!query) return
+
+ const callback = prefetch ?? gate!.resolve
+ const mediaQuery = window.matchMedia(query)
+ const onChange = () => {
+ if (mediaQuery.matches) callback()
+ }
+ mediaQuery.addEventListener('change', onChange)
+ onChange()
+
+ return () => mediaQuery.removeEventListener('change', onChange)
+ },
+ }
+}
diff --git a/packages/start-client-core/src/hydration/never.ts b/packages/start-client-core/src/hydration/never.ts
new file mode 100644
index 0000000000..8ace129b5e
--- /dev/null
+++ b/packages/start-client-core/src/hydration/never.ts
@@ -0,0 +1,13 @@
+import type { HydrationStrategy } from './types'
+
+const neverType = 'never'
+
+const neverStrategy: HydrationStrategy = {
+ _t: neverType,
+ _d: () => true,
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function never(): HydrationStrategy {
+ return neverStrategy
+}
diff --git a/packages/start-client-core/src/hydration/renderer.ts b/packages/start-client-core/src/hydration/renderer.ts
new file mode 100644
index 0000000000..1a14ef9b46
--- /dev/null
+++ b/packages/start-client-core/src/hydration/renderer.ts
@@ -0,0 +1,21 @@
+import type { HydrationStrategy } from './types'
+
+export type HydrationStrategyWithRenderer<
+ TStrategy extends HydrationStrategy,
+ TRenderer,
+> = TStrategy & {
+ _h: TRenderer
+}
+
+/* @__NO_SIDE_EFFECTS__ */
+export function withHydrationRenderer<
+ TStrategy extends HydrationStrategy,
+ TRenderer,
+>(
+ strategy: TStrategy,
+ renderer: TRenderer,
+): HydrationStrategyWithRenderer {
+ return /* @__PURE__ */ Object.assign(strategy, {
+ _h: renderer,
+ })
+}
diff --git a/packages/start-client-core/src/hydration/runtime.ts b/packages/start-client-core/src/hydration/runtime.ts
new file mode 100644
index 0000000000..b1da55195d
--- /dev/null
+++ b/packages/start-client-core/src/hydration/runtime.ts
@@ -0,0 +1,191 @@
+import { hydrateIdAttribute, hydrateWhenAttribute } from './constants'
+import type {
+ HydrationPrefetchStrategy,
+ HydrationPrefetchWaitReason,
+ HydrationRuntimeGate,
+ HydrationWhen,
+} from './types'
+
+const hydrateIdSelector = `[${hydrateIdAttribute}]`
+
+export type HydrationGateRecord = HydrationRuntimeGate & {
+ id: string
+ when: HydrationWhen
+ promise: Promise
+ consumers: number
+ resolveListeners: Set<() => void>
+}
+
+const gateRegistry = /* @__PURE__ */ new Map()
+const resolvedGateIds = /* @__PURE__ */ new Set()
+const fallbackHtmlByGateId = /* @__PURE__ */ new Map()
+
+export function createResolvedGate(
+ id: string,
+ when: HydrationWhen,
+): HydrationGateRecord {
+ return {
+ id,
+ when,
+ promise: Promise.resolve(),
+ resolve: () => {},
+ resolved: true,
+ consumers: 0,
+ resolveListeners: new Set<() => void>(),
+ }
+}
+
+export function getOrCreateGate(
+ id: string,
+ when: HydrationWhen,
+): HydrationGateRecord {
+ const existing = gateRegistry.get(id)
+ if (existing?.when === when) {
+ existing.consumers++
+ return existing
+ }
+
+ let resolvePromise!: () => void
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+
+ const gate: HydrationGateRecord = {
+ id,
+ promise,
+ resolved: false,
+ consumers: 1,
+ when,
+ resolveListeners: new Set(),
+ resolve: () => {
+ if (gate.resolved) return
+ gate.resolved = true
+ resolvePromise()
+ gate.resolveListeners.forEach((listener) => listener())
+ gate.resolveListeners.clear()
+ },
+ }
+
+ gateRegistry.set(id, gate)
+ if (when !== 'never' && resolvedGateIds.has(id)) {
+ resolvedGateIds.delete(id)
+ gate.resolve()
+ }
+ return gate
+}
+
+export function releaseGate(gate: HydrationGateRecord) {
+ resolvedGateIds.delete(gate.id)
+ gate.consumers--
+ if (gate.consumers > 0) return
+ if (gateRegistry.get(gate.id) === gate) {
+ gateRegistry.delete(gate.id)
+ fallbackHtmlByGateId.delete(gate.id)
+ gate.resolveListeners.clear()
+ }
+}
+
+export function onGateResolve(gate: HydrationGateRecord, listener: () => void) {
+ if (gate.resolved) {
+ listener()
+ return () => {}
+ }
+
+ gate.resolveListeners.add(listener)
+ return () => {
+ gate.resolveListeners.delete(listener)
+ }
+}
+
+export function runHydrationStrategyCleanup(cleanup: void | (() => void)) {
+ if (typeof cleanup === 'function') return cleanup
+ return undefined
+}
+
+export function waitForHydrationPrefetchStrategy(
+ strategy: HydrationPrefetchStrategy,
+ options: {
+ element: Element | null
+ signal: AbortSignal
+ onHydrate: (listener: () => void) => () => void
+ },
+): Promise {
+ if (options.signal.aborted) {
+ return Promise.resolve('abort')
+ }
+
+ return new Promise((resolve) => {
+ const state = { disposed: false }
+ const cleanupStrategyRef: { current: void | (() => void) } = {
+ current: undefined,
+ }
+ let cleanupHydrate = () => {}
+
+ const finish = (reason: HydrationPrefetchWaitReason) => {
+ if (state.disposed) return
+ state.disposed = true
+ options.signal.removeEventListener('abort', onAbort)
+ cleanupHydrate()
+ runHydrationStrategyCleanup(cleanupStrategyRef.current)?.()
+ resolve(reason)
+ }
+
+ const onAbort = () => finish('abort')
+
+ options.signal.addEventListener('abort', onAbort, { once: true })
+ cleanupHydrate = options.onHydrate(() => finish('hydrate'))
+ const cleanupStrategy = strategy._s?.({
+ element: options.element,
+ prefetch: () => finish('prefetch'),
+ })
+ cleanupStrategyRef.current = cleanupStrategy
+ if (state.disposed) {
+ runHydrationStrategyCleanup(cleanupStrategy)?.()
+ }
+ })
+}
+
+export function getMarkerGate(marker: Element) {
+ const id = marker.getAttribute(hydrateIdAttribute)
+ return id ? gateRegistry.get(id) : undefined
+}
+
+export function resolveHydrationMarker(marker: Element) {
+ const id = marker.getAttribute(hydrateIdAttribute)
+ const when = marker.getAttribute(hydrateWhenAttribute)
+ if (!id || !when || when === 'never') {
+ return
+ }
+
+ const gate = gateRegistry.get(id)
+ if (gate) {
+ if (gate.when !== 'never') gate.resolve()
+ return
+ }
+
+ resolvedGateIds.add(id)
+}
+
+export function clearResolvedGateIdsInMarker(marker: Element) {
+ const ownId = marker.getAttribute(hydrateIdAttribute)
+ if (ownId) {
+ resolvedGateIds.delete(ownId)
+ }
+
+ marker.querySelectorAll(hydrateIdSelector).forEach((childMarker) => {
+ const childId = childMarker.getAttribute(hydrateIdAttribute)
+ if (childId) {
+ resolvedGateIds.delete(childId)
+ }
+ })
+}
+
+export function saveFallbackHtml(id: string, element: Element) {
+ if (!fallbackHtmlByGateId.has(id)) {
+ fallbackHtmlByGateId.set(id, element.innerHTML)
+ }
+}
+
+export function getFallbackHtml(id: string) {
+ return fallbackHtmlByGateId.get(id)
+}
diff --git a/packages/start-client-core/src/hydration/types.ts b/packages/start-client-core/src/hydration/types.ts
new file mode 100644
index 0000000000..f4250067d6
--- /dev/null
+++ b/packages/start-client-core/src/hydration/types.ts
@@ -0,0 +1,90 @@
+export type HydrationWhen =
+ | 'load'
+ | 'idle'
+ | 'visible'
+ | 'media'
+ | 'interaction'
+ | 'condition'
+ | 'never'
+ | 'dynamic'
+
+export type HydrationInteractionEvent =
+ | 'auxclick'
+ | 'click'
+ | 'contextmenu'
+ | 'dblclick'
+ | 'focusin'
+ | 'keydown'
+ | 'keyup'
+ | 'mousedown'
+ | 'mouseenter'
+ | 'mouseover'
+ | 'mouseup'
+ | 'pointerdown'
+ | 'pointerenter'
+ | 'pointerover'
+ | 'pointerup'
+
+export type HydrationInteractionEvents =
+ | HydrationInteractionEvent
+ | ReadonlyArray
+
+export type HydrationMarkerAttributes = Record
+
+export type HydrationRuntimeGate = {
+ id?: string
+ when?: HydrationWhen
+ resolved: boolean
+ resolve: () => void
+}
+
+export type HydrationRuntimeContext = {
+ element: Element | null
+ gate?: HydrationRuntimeGate
+ prefetch?: () => void
+ delegated?: boolean
+}
+
+export type HydrationStrategyTypes<
+ TWhen extends HydrationWhen = HydrationWhen,
+ TCanPrefetch extends boolean = boolean,
+> = {
+ when: TWhen
+ canPrefetch: TCanPrefetch
+}
+
+export type HydrationStrategy<
+ TWhen extends HydrationWhen = HydrationWhen,
+ TCanPrefetch extends boolean = boolean,
+> = {
+ _t?: TWhen
+ readonly '~types'?: HydrationStrategyTypes
+ _d?: () => boolean
+ _s?: (context: HydrationRuntimeContext) => void | (() => void)
+ _o?: (id: string) => void
+ _a?: () => HydrationMarkerAttributes | undefined
+}
+
+export type HydrationPrefetchWhen = Exclude<
+ HydrationWhen,
+ 'condition' | 'never' | 'dynamic'
+>
+
+export type HydrationPrefetchStrategy<
+ TWhen extends HydrationPrefetchWhen = HydrationPrefetchWhen,
+> = HydrationStrategy
+
+export type HydrationPrefetchWaitReason = 'prefetch' | 'hydrate' | 'abort'
+
+export type HydrationPrefetchContext = {
+ element: Element | null
+ signal: AbortSignal
+ preload: () => Promise
+ waitFor: (
+ strategy: HydrationPrefetchStrategy,
+ ) => Promise