diff --git a/docs/start/config.json b/docs/start/config.json index 180d38ed44d..7b72afa3cbb 100644 --- a/docs/start/config.json +++ b/docs/start/config.json @@ -125,6 +125,10 @@ "label": "Selective SSR", "to": "framework/react/guide/selective-ssr" }, + { + "label": "Selective Hydration", + "to": "framework/react/guide/selective-hydration" + }, { "label": "SPA Mode", "to": "framework/react/guide/spa-mode" diff --git a/docs/start/framework/react/guide/selective-hydration.md b/docs/start/framework/react/guide/selective-hydration.md new file mode 100644 index 00000000000..63def8c677c --- /dev/null +++ b/docs/start/framework/react/guide/selective-hydration.md @@ -0,0 +1,491 @@ +--- +id: selective-hydration +title: Selective Client-Side Hydration +--- + +## What is Selective Hydration? + +In TanStack Start, routes are server-side rendered by default and then "hydrated" on the client - meaning React attaches event handlers and makes the page interactive. The `hydrate` option gives you **page-level** control over which routes should include the React hydration bundle and become interactive on the client. + +**Note:** This is **page-level** selective hydration, meaning the entire page either hydrates or doesn't. For **component-level** selective hydration (Server Components), where individual components can opt in or out of hydration, stay tuned for upcoming releases from TanStack Router. + +When you set `hydrate: false` on a route: + +- ✅ The page is still server-side rendered (SSR) and SEO-friendly +- ✅ All content loads instantly with no JavaScript required +- ✅ External scripts from the `head()` option still work +- ❌ React is not loaded or hydrated (no interactivity) +- ❌ No `useState`, `useEffect`, or event handlers +- ❌ Navigation becomes traditional full-page reloads + +**Important:** `hydrate: false` should only be used when you want a truly static site with absolutely no React on the client. Most applications should keep the default `hydrate: true` behavior, even for primarily static content, as you typically need at least some client-side interactivity for navigation, analytics, or other features. + +## How does this compare to `ssr: false`? + +The `ssr` and `hydrate` options serve different purposes: + +| Option | Controls | Use Case | +| ------------- | -------------------------------------- | ------------------------------------------------------------------------------- | +| **`ssr`** | Server-side rendering and data loading | Control when `beforeLoad`/`loader` run and when components render on the server | +| **`hydrate`** | Client-side React hydration | Control whether the page becomes interactive after being server-rendered | + +**Common Patterns:** + +```tsx +// Full SSR + Hydration (default) +ssr: true, hydrate: true +// ✅ Renders on server ✅ Data loads on server ✅ Interactive on client + +// Static server-rendered page (no JavaScript) +ssr: true, hydrate: false +// ✅ Renders on server ✅ Data loads on server ❌ NOT interactive + +// Client-only page +ssr: false, hydrate: true +// ❌ Renders on client ❌ Data loads on client ✅ Interactive on client + +// This combination doesn't make sense +ssr: false, hydrate: false +// ❌ Nothing renders or works (avoid this) +``` + +**When to use `hydrate: false`:** + +- Truly static sites where you want zero React on the client +- Pages where you're willing to give up client-side navigation and all interactivity +- Print-only views or embedded content +- **Note:** This is a very rare use case - most sites should use `hydrate: true` (default) + +**When to use `ssr: false`:** + +- Pages using browser-only APIs (localStorage, canvas) +- Client-only routes (user dashboards, admin panels) +- Pages with heavy client-side state + +## Configuration + +You can control whether a route includes the React hydration bundle using the `hydrate` property. This is an **opt-in/opt-out mechanism**: + +- **Not set (undefined)**: The default behavior is to hydrate +- **`hydrate: true`**: Explicitly ensures hydration (useful to override a parent's `hydrate: false`) +- **`hydrate: false`**: Explicitly disables hydration + +You can change the default behavior using the `defaultHydrate` option in `createStart`: + +```tsx +// src/start.ts +import { createStart } from '@tanstack/react-start' + +export const startInstance = createStart(() => ({ + // Disable hydration by default + defaultHydrate: false, +})) +``` + +### Omitting `hydrate` (default behavior) + +When you don't specify the `hydrate` option, the default behavior is to hydrate. The page is server-rendered and React hydrates it on the client, making it fully interactive: + +```tsx +// src/routes/posts/$postId.tsx +export const Route = createFileRoute('/posts/$postId')({ + // hydrate not specified - will use default behavior (hydrate) + loader: async ({ params }) => { + return { post: await fetchPost(params.postId) } + }, + component: PostPage, +}) + +function PostPage() { + const { post } = Route.useLoaderData() + const [likes, setLikes] = useState(0) + + return ( +
+

{post.title}

+

{post.content}

+ +
+ ) +} +``` + +**Result:** + +- ✅ Server renders the HTML +- ✅ Loader data is sent to the client +- ✅ React hydrates and attaches event handlers +- ✅ The "Like" button works + +### Explicitly setting `hydrate: true` + +You can explicitly set `hydrate: true` to **ensure** a route is always hydrated, even if a parent or nested route has `hydrate: false`. This is useful for resolving conflicts in the route tree: + +```tsx +// Parent route disables hydration +export const Route = createFileRoute('/blog')({ + hydrate: false, + component: BlogLayout, +}) + +// Child route explicitly ensures hydration +export const Route = createFileRoute('/blog/interactive')({ + hydrate: true, // Explicitly opt-in to ensure hydration + component: InteractiveBlogPost, +}) +``` + +**When this creates a conflict:** + +- If a route has `hydrate: false` and a child has explicit `hydrate: true`, this creates a conflict +- TanStack Router will **not hydrate** the page (safer default) and log a warning +- You should resolve the conflict by making the hydration settings consistent + +**When to explicitly use `hydrate: true`:** + +- To document intent that a route must be hydrated +- To override a parent's `hydrate: false` (though this creates a conflict that needs resolution) +- To ensure hydration when `defaultHydrate: false` is set globally + +### `hydrate: false` + +This disables client-side hydration. The page is server-rendered but React is not loaded: + +```tsx +// src/routes/legal/privacy.tsx +export const Route = createFileRoute('/legal/privacy')({ + hydrate: false, + loader: async () => { + return { lastUpdated: '2024-01-15' } + }, + head: () => ({ + meta: [ + { title: 'Privacy Policy' }, + { name: 'description', content: 'Our privacy policy' }, + ], + // External scripts still work + scripts: [{ src: 'https://analytics.example.com/script.js' }], + }), + component: PrivacyPage, +}) + +function PrivacyPage() { + const { lastUpdated } = Route.useLoaderData() + + return ( +
+

Privacy Policy

+

Last updated: {lastUpdated}

+

This is a static page with no JavaScript...

+ {/* This button won't work (no event handlers attached) */} + +
+ ) +} +``` + +**Result:** + +- ✅ Server renders the HTML with all content +- ✅ Loader data is used during SSR +- ✅ Meta tags and external scripts are included +- ❌ React is NOT loaded on the client +- ❌ No JavaScript bundle downloaded +- ❌ Event handlers don't work +- ❌ `useState`, `useEffect`, etc. don't run + +**What gets excluded when `hydrate: false`:** + +- React runtime bundle +- React DOM bundle +- TanStack Router client bundle +- Your application code +- Hydration data script (`window.$_TSR`) +- Modulepreload links for JavaScript + +**What still works:** + +- Server-side rendering +- Loader data (during SSR only) +- Meta tags from `head()` +- External scripts from `head()` +- CSS and stylesheets +- Images and static assets + +## Inheritance + +A child route inherits the `hydrate` configuration of its parent. If **any route** in the match has `hydrate: false`, the entire page will not be hydrated: + +```tsx +root { hydrate: true } + blog { hydrate: false } + $postId { hydrate: true } +``` + +**Result:** + +- The `blog` route sets `hydrate: false` +- Even though `$postId` sets `hydrate: true`, it inherits `false` from its parent +- The entire page will NOT be hydrated + +This differs from the `ssr` option, which allows child routes to be "more restrictive" than their parents. With `hydrate`, if any route in the tree has `hydrate: false`, the entire match is treated as non-hydrated. + +**Why this design?** + +Hydration is an all-or-nothing operation for the entire page. You can't hydrate part of a React tree without hydrating its ancestors. This ensures: + +- ✅ Predictable behavior +- ✅ No partial hydration issues +- ✅ Clear mental model + +## Combining with `ssr` Options + +You can combine `ssr` and `hydrate` options for different behaviors: + +### Static Content Page (Server-Rendered, No JavaScript) + +Perfect for SEO-focused content that doesn't need interactivity: + +```tsx +export const Route = createFileRoute('/blog/$slug')({ + ssr: true, // Render on server + hydrate: false, // Don't load React on client + loader: async ({ params }) => { + return { post: await fetchPost(params.slug) } + }, + head: ({ loaderData }) => ({ + meta: [ + { title: loaderData.post.title }, + { name: 'description', content: loaderData.post.excerpt }, + ], + }), + component: BlogPost, +}) +``` + +**Benefits:** + +- ⚡ Fastest possible page load (no JavaScript) +- 🔍 Perfect SEO (fully rendered HTML) +- 📦 Smallest possible bundle size + +### Client-Only Interactive Page + +For pages that need browser APIs: + +```tsx +export const Route = createFileRoute('/dashboard')({ + ssr: false, // Don't render on server (needs browser APIs) + hydrate: true, // Load React and make interactive + loader: () => { + // Runs only on client + return { user: getUserFromLocalStorage() } + }, + component: Dashboard, +}) +``` + +### Hybrid: Server Data, Client Rendering + +Load data on server but render on client (useful for heavy visualizations): + +```tsx +export const Route = createFileRoute('/reports/$id')({ + ssr: 'data-only', // Load data on server, but don't render + hydrate: true, // Hydrate and render on client + loader: async ({ params }) => { + // Runs on server during SSR + return { report: await fetchReport(params.id) } + }, + component: ReportVisualization, // Renders only on client +}) +``` + +## Conflict Detection + +The `hydrate` option is an **opt-in/opt-out mechanism**. Conflicts occur when: + +- Some routes explicitly set `hydrate: false` (opt-out) +- Other routes explicitly set `hydrate: true` (opt-in to ensure hydration) + +**Note:** Routes that don't specify `hydrate` (using the default behavior) do not create conflicts. + +When TanStack Start detects conflicting explicit settings: + +1. **Does not hydrate the page** (safer default - respects the `false` setting) +2. **Logs a warning** to help you debug: + +``` +⚠️ [TanStack Router] Conflicting hydrate options detected in route matches. +Some routes have hydrate: false while others have hydrate: true. +The page will NOT be hydrated, but this may not be the intended behavior. +Please ensure all routes in the match have consistent hydrate settings. +``` + +**How to resolve conflicts:** + +- **Option 1:** Remove explicit `hydrate: true` from child routes (let them use default behavior or inherit from parent) +- **Option 2:** Remove `hydrate: false` from parent routes if child routes need hydration +- **Option 3:** Restructure your routes so interactive and static pages are in separate branches + +## Use Cases + +### 📄 When to use `hydrate: false`: + +**Important:** This is a very rare use case. Most applications should keep the default hydration behavior. + +Use `hydrate: false` only when: + +- You want a **truly static site** with zero React on the client +- You're willing to give up **all client-side navigation** and interactivity +- You want to avoid the overhead of loading React entirely +- Examples: Print-only views, embedded content, purely informational pages + +### ⚡ When to explicitly use `hydrate: true`: + +You typically don't need to explicitly set `hydrate: true` since it's the default behavior. However, explicitly setting it is useful when: + +- **Documenting intent**: Making it clear that a route requires hydration +- **Overriding `defaultHydrate: false`**: When you've set a global default of `false` but need specific routes to hydrate +- **Attempting to override a parent**: Though this creates a conflict (see Conflict Detection above), you might use `hydrate: true` to signal that a child route needs hydration even if a parent has `hydrate: false` + +For general interactive features (forms, dashboards, real-time updates, user interactions), simply omit the `hydrate` option and use the default behavior. + +## Performance Impact + +When you use `hydrate: false`: + +**Bundle Size Savings:** + +- React Runtime: ~130KB (gzipped: ~45KB) +- React DOM: ~130KB (gzipped: ~45KB) +- TanStack Router Client: ~40KB (gzipped: ~12KB) +- Your App Code: Varies + +**Total Savings:** ~300KB+ (gzipped: ~100KB+) per page + +**Load Time Improvements:** + +- No JavaScript parsing/execution +- No hydration time +- Instant interactivity (no loading state) + +## Example: Mixed Application + +A typical application might use both options: + +```tsx +// Root route - enable hydration by default +export const Route = createRootRoute({ + component: RootComponent, +}) + +// Marketing pages - no hydration needed +export const Route = createFileRoute('/about')({ + hydrate: false, + component: AboutPage, +}) + +export const Route = createFileRoute('/blog/$slug')({ + hydrate: false, + loader: fetchBlogPost, + component: BlogPost, +}) + +// Legal pages - no hydration needed +export const Route = createFileRoute('/legal/privacy')({ + hydrate: false, + component: PrivacyPolicy, +}) + +// App pages - need hydration for interactivity +export const Route = createFileRoute('/dashboard')({ + hydrate: true, // explicit for clarity + loader: fetchDashboardData, + component: Dashboard, +}) + +export const Route = createFileRoute('/settings')({ + hydrate: true, + component: SettingsPage, +}) +``` + +## Development Mode + +In development mode, React Refresh (HMR) is kept even when `hydrate: false` is set. This allows you to: + +- ✅ See changes instantly during development +- ✅ Test the no-JavaScript experience in production builds + +To test the true `hydrate: false` experience: + +```bash +# Build for production +pnpm build + +# Preview the production build +pnpm preview +``` + +## Troubleshooting + +### My page has `hydrate: false` but JavaScript is still loading + +**Check:** + +1. Are any parent routes setting `hydrate: true`? +2. Are you in development mode? (React Refresh is kept for HMR) +3. Did you rebuild after changing the option? + +```bash +pnpm build +``` + +### My interactive features stopped working + +If you set `hydrate: false`, all React features will stop working: + +- Event handlers (`onClick`, `onChange`) +- Hooks (`useState`, `useEffect`, `useQuery`) +- Context providers +- Client-side routing + +**Solution:** Explicitly set `hydrate: true` or remove the option (which defaults to hydrating). + +### I'm seeing hydration errors + +Hydration errors occur when server-rendered HTML doesn't match the client. If you have these errors: + +1. Consider `ssr: 'data-only'` (skip server rendering, only load data) +2. Or use `hydrate: false` if the page doesn't need interactivity + +See the [Hydration Errors guide](./hydration-errors) for more details. + +## Summary + +The `hydrate` option gives you precise **page-level** control over client-side React hydration: + +- **Default (omitted)**: Pages hydrate by default - Full SSR + Hydration = Interactive pages +- **`hydrate: true`**: Explicitly ensures a page is hydrated (useful for conflict resolution or documenting intent) +- **`hydrate: false`**: Static server-rendered pages with no JavaScript +- **Opt-in/opt-out mechanism**: Conflicts occur only when explicit `true` and `false` values are both present +- **Inheritance**: If any route has `hydrate: false`, the page won't hydrate + +**Note:** This is **page-level** selective hydration. For **component-level** selective hydration (Server Components), stay tuned for upcoming releases from TanStack Router. + +Use `hydrate: false` for truly static pages to: + +- ⚡ Reduce bundle size +- 🚀 Improve load times +- 📉 Minimize JavaScript overhead +- 🔍 Maintain perfect SEO + +For interactive pages, simply omit the `hydrate` option to use the default behavior: + +- 🎯 User interactions +- 💾 Client-side state +- 🔄 Real-time updates +- ⚡ Dynamic behavior diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index 6efad91f8d7..7ac2b783bf6 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -17,6 +17,8 @@ import { Route as RawStreamRouteImport } from './routes/raw-stream' import { Route as PostsRouteImport } from './routes/posts' import { Route as LinksRouteImport } from './routes/links' import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' +import { Route as HydrateTrueRouteImport } from './routes/hydrate-true' +import { Route as HydrateFalseRouteImport } from './routes/hydrate-false' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as ClientOnlyRouteImport } from './routes/client-only' import { Route as AsyncScriptsRouteImport } from './routes/async-scripts' @@ -109,6 +111,16 @@ const InlineScriptsRoute = InlineScriptsRouteImport.update({ path: '/inline-scripts', getParentRoute: () => rootRouteImport, } as any) +const HydrateTrueRoute = HydrateTrueRouteImport.update({ + id: '/hydrate-true', + path: '/hydrate-true', + getParentRoute: () => rootRouteImport, +} as any) +const HydrateFalseRoute = HydrateFalseRouteImport.update({ + id: '/hydrate-false', + path: '/hydrate-false', + getParentRoute: () => rootRouteImport, +} as any) const DeferredRoute = DeferredRouteImport.update({ id: '/deferred', path: '/deferred', @@ -383,6 +395,8 @@ export interface FileRoutesByFullPath { '/async-scripts': typeof AsyncScriptsRoute '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute + '/hydrate-false': typeof HydrateFalseRoute + '/hydrate-true': typeof HydrateTrueRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren @@ -440,6 +454,8 @@ export interface FileRoutesByTo { '/async-scripts': typeof AsyncScriptsRoute '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute + '/hydrate-false': typeof HydrateFalseRoute + '/hydrate-true': typeof HydrateTrueRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute @@ -496,6 +512,8 @@ export interface FileRoutesById { '/async-scripts': typeof AsyncScriptsRoute '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute + '/hydrate-false': typeof HydrateFalseRoute + '/hydrate-true': typeof HydrateTrueRoute '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren @@ -558,6 +576,8 @@ export interface FileRouteTypes { | '/async-scripts' | '/client-only' | '/deferred' + | '/hydrate-false' + | '/hydrate-true' | '/inline-scripts' | '/links' | '/posts' @@ -615,6 +635,8 @@ export interface FileRouteTypes { | '/async-scripts' | '/client-only' | '/deferred' + | '/hydrate-false' + | '/hydrate-true' | '/inline-scripts' | '/links' | '/scripts' @@ -670,6 +692,8 @@ export interface FileRouteTypes { | '/async-scripts' | '/client-only' | '/deferred' + | '/hydrate-false' + | '/hydrate-true' | '/inline-scripts' | '/links' | '/posts' @@ -732,6 +756,8 @@ export interface RootRouteChildren { AsyncScriptsRoute: typeof AsyncScriptsRoute ClientOnlyRoute: typeof ClientOnlyRoute DeferredRoute: typeof DeferredRoute + HydrateFalseRoute: typeof HydrateFalseRoute + HydrateTrueRoute: typeof HydrateTrueRoute InlineScriptsRoute: typeof InlineScriptsRoute LinksRoute: typeof LinksRoute PostsRoute: typeof PostsRouteWithChildren @@ -807,6 +833,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InlineScriptsRouteImport parentRoute: typeof rootRouteImport } + '/hydrate-true': { + id: '/hydrate-true' + path: '/hydrate-true' + fullPath: '/hydrate-true' + preLoaderRoute: typeof HydrateTrueRouteImport + parentRoute: typeof rootRouteImport + } + '/hydrate-false': { + id: '/hydrate-false' + path: '/hydrate-false' + fullPath: '/hydrate-false' + preLoaderRoute: typeof HydrateFalseRouteImport + parentRoute: typeof rootRouteImport + } '/deferred': { id: '/deferred' path: '/deferred' @@ -1366,6 +1406,8 @@ const rootRouteChildren: RootRouteChildren = { AsyncScriptsRoute: AsyncScriptsRoute, ClientOnlyRoute: ClientOnlyRoute, DeferredRoute: DeferredRoute, + HydrateFalseRoute: HydrateFalseRoute, + HydrateTrueRoute: HydrateTrueRoute, InlineScriptsRoute: InlineScriptsRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, diff --git a/e2e/react-start/basic/src/routes/hydrate-false.tsx b/e2e/react-start/basic/src/routes/hydrate-false.tsx new file mode 100644 index 00000000000..014601e83a0 --- /dev/null +++ b/e2e/react-start/basic/src/routes/hydrate-false.tsx @@ -0,0 +1,41 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/hydrate-false')({ + hydrate: false, + loader: () => ({ + message: 'hydrate false route rendered on server', + serverTime: new Date().toISOString(), + }), + head: () => ({ + meta: [ + { + title: 'Hydrate False Route', + }, + { + name: 'description', + content: 'hydrate false e2e route', + }, + ], + scripts: [ + { + children: 'window.HYDRATE_FALSE_INLINE_SCRIPT = true', + }, + ], + }), + component: HydrateFalseComponent, +}) + +function HydrateFalseComponent() { + const data = Route.useLoaderData() + + return ( +
+

Hydrate false route

+

{data.message}

+

{data.serverTime}

+ +
+ ) +} diff --git a/e2e/react-start/basic/src/routes/hydrate-true.tsx b/e2e/react-start/basic/src/routes/hydrate-true.tsx new file mode 100644 index 00000000000..1e648f027e9 --- /dev/null +++ b/e2e/react-start/basic/src/routes/hydrate-true.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/hydrate-true')({ + loader: () => ({ + message: 'hydrate true route rendered', + }), + component: HydrateTrueComponent, +}) + +function HydrateTrueComponent() { + const data = Route.useLoaderData() + const [count, setCount] = React.useState(0) + + return ( +
+

Hydrate true route

+

{data.message}

+

{count}

+ +
+ ) +} diff --git a/e2e/react-start/basic/tests/hydrate-false.spec.ts b/e2e/react-start/basic/tests/hydrate-false.spec.ts new file mode 100644 index 00000000000..7d9b9f2819e --- /dev/null +++ b/e2e/react-start/basic/tests/hydrate-false.spec.ts @@ -0,0 +1,42 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isSpaMode } from './utils/isSpaMode' + +test.describe('hydrate false route behavior', () => { + test.skip(isSpaMode, 'hydrate false is SSR-only behavior') + + test('excludes hydration payload and client entry assets', async ({ + page, + }) => { + await page.goto('/hydrate-false') + + await expect(page.getByTestId('hydrate-false-heading')).toBeVisible() + + const html = await page.content() + + expect(html).not.toContain('window.$_TSR') + expect(html).not.toContain('data-tsr-client-entry') + expect(html).not.toContain('virtual:tanstack-start-client-entry') + expect(html).not.toContain('rel="modulepreload"') + expect(html).toContain('hydrate false route rendered on server') + + await expect(page).toHaveTitle('Hydrate False Route') + + const description = await page + .locator('meta[name="description"]') + .getAttribute('content') + expect(description).toBe('hydrate false e2e route') + + expect(await page.evaluate('window.HYDRATE_FALSE_INLINE_SCRIPT')).toBe(true) + }) + + test('keeps hydrated control route interactive', async ({ page }) => { + await page.goto('/hydrate-true') + + await expect(page.getByTestId('hydrate-true-heading')).toBeVisible() + await expect(page.getByTestId('hydrate-true-count')).toHaveText('0') + + await page.getByTestId('hydrate-true-increment').click() + await expect(page.getByTestId('hydrate-true-count')).toHaveText('1') + }) +}) diff --git a/examples/react/start-basic/src/routes/__root.tsx b/examples/react/start-basic/src/routes/__root.tsx index 346409e9d91..8e9d52658e6 100644 --- a/examples/react/start-basic/src/routes/__root.tsx +++ b/examples/react/start-basic/src/routes/__root.tsx @@ -13,6 +13,7 @@ import appCss from '~/styles/app.css?url' import { seo } from '~/utils/seo' export const Route = createRootRoute({ + hydrate: false, head: () => ({ meta: [ { diff --git a/packages/react-router/src/Scripts.tsx b/packages/react-router/src/Scripts.tsx index e7fced4d72b..afe3a632ba7 100644 --- a/packages/react-router/src/Scripts.tsx +++ b/packages/react-router/src/Scripts.tsx @@ -1,8 +1,14 @@ +import * as React from 'react' import { Asset } from './Asset' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' +import { getHydrateStatus } from './hydrate-status' import type { RouterManagedTag } from '@tanstack/router-core' +const CLIENT_ENTRY_MARKER_ATTR = 'data-tsr-client-entry' +const LEGACY_CLIENT_ENTRY_ID = 'virtual:tanstack-start-client-entry' +const TRAILING_IMPORT_RE = /(?:^|[;\n])\s*import\((['"]).*?\1\)\s*;?\s*$/s + /** * Render body script tags collected from route matches and SSR manifests. * Should be placed near the end of the document body. @@ -10,8 +16,14 @@ import type { RouterManagedTag } from '@tanstack/router-core' export const Scripts = () => { const router = useRouter() const nonce = router.options.ssr?.nonce + + const hydrateStatus = useRouterState({ + select: (state) => getHydrateStatus(state.matches, router), + }) + const assetScripts = useRouterState({ select: (state) => { + const { shouldHydrate } = getHydrateStatus(state.matches, router) const assetScripts: Array = [] const manifest = router.ssr?.manifest @@ -25,11 +37,21 @@ export const Scripts = () => { manifest.routes[route.id]?.assets ?.filter((d) => d.tag === 'script') .forEach((asset) => { - assetScripts.push({ + const withNonce = { tag: 'script', attrs: { ...asset.attrs, nonce }, children: asset.children, - } as any) + } as RouterManagedTag + + if (!shouldHydrate) { + const normalized = stripClientEntryImport(withNonce) + if (normalized) { + assetScripts.push(normalized) + } + return + } + + assetScripts.push(withNonce) }), ) @@ -38,9 +60,11 @@ export const Scripts = () => { structuralSharing: true as any, }) - const { scripts } = useRouterState({ - select: (state) => ({ - scripts: ( + const scripts = useRouterState({ + select: (state) => { + const { shouldHydrate } = getHydrateStatus(state.matches, router) + + const allScripts = ( state.matches .map((match) => match.scripts!) .flat(1) @@ -53,14 +77,37 @@ export const Scripts = () => { nonce, }, children, - })), - }), + })) as Array + + // If hydrate is false, remove client entry imports but keep React Refresh for HMR + if (!shouldHydrate) { + return allScripts + .map(stripClientEntryImport) + .filter(Boolean) as Array + } + + return allScripts + }, structuralSharing: true as any, }) + React.useEffect(() => { + if (!hydrateStatus.hasConflict) { + return + } + + console.warn( + '⚠️ [TanStack Router] Conflicting hydrate options detected in route matches.\n' + + 'Some routes have hydrate: false while others have hydrate: true.\n' + + 'The page will NOT be hydrated, but this may not be the intended behavior.\n' + + 'Please ensure all routes in the match have consistent hydrate settings.', + ) + }, [hydrateStatus.hasConflict]) + let serverBufferedScript: RouterManagedTag | undefined = undefined - if (router.serverSsr) { + // Only include server buffered script if we're hydrating + if (router.serverSsr && hydrateStatus.shouldHydrate) { serverBufferedScript = router.serverSsr.takeBufferedScripts() } @@ -78,3 +125,58 @@ export const Scripts = () => { ) } + +function stripClientEntryImport( + script: RouterManagedTag, +): RouterManagedTag | null { + if (!isClientEntryScript(script)) { + return script + } + + if (typeof script.children !== 'string') { + return null + } + + const withoutImport = script.children.replace(TRAILING_IMPORT_RE, '').trim() + + if (withoutImport.length > 0) { + return { + ...script, + children: withoutImport, + } + } + + if (script.children.includes(LEGACY_CLIENT_ENTRY_ID)) { + const withoutLegacyImport = script.children + .split('\n') + .filter((line) => !line.includes(LEGACY_CLIENT_ENTRY_ID)) + .join('\n') + .trim() + + if (withoutLegacyImport.length > 0) { + return { + ...script, + children: withoutLegacyImport, + } + } + } + + return null +} + +function isClientEntryScript(script: RouterManagedTag): boolean { + if (script.tag !== 'script') { + return false + } + + const marker = script.attrs?.[CLIENT_ENTRY_MARKER_ATTR] + if (marker === true || marker === 'true') { + return true + } + + if (typeof script.children !== 'string') { + return false + } + + return script.children.includes(LEGACY_CLIENT_ENTRY_ID) +} diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index 1345eebb22d..5b1542ffd88 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { escapeHtml } from '@tanstack/router-core' import { useRouter } from './useRouter' import { useRouterState } from './useRouterState' +import { getHydrateStatus } from './hydrate-status' import type { RouterManagedTag } from '@tanstack/router-core' /** @@ -130,6 +131,12 @@ export const useTags = () => { const preloadLinks = useRouterState({ select: (state) => { + const { shouldHydrate } = getHydrateStatus(state.matches, router) + + if (!shouldHydrate) { + return [] + } + const preloadLinks: Array = [] state.matches diff --git a/packages/react-router/src/hydrate-status.ts b/packages/react-router/src/hydrate-status.ts new file mode 100644 index 00000000000..3c5f9d952c4 --- /dev/null +++ b/packages/react-router/src/hydrate-status.ts @@ -0,0 +1,41 @@ +import type { AnyRouter } from '@tanstack/router-core' + +export function getHydrateStatus( + matches: ReadonlyArray<{ routeId: string }>, + router: AnyRouter, +): { + shouldHydrate: boolean + hasConflict: boolean +} { + let hasExplicitFalse = false + let hasExplicitTrue = false + + const defaultHydrateOption = (router.options as { defaultHydrate?: unknown }) + .defaultHydrate + const defaultHydrate = + typeof defaultHydrateOption === 'boolean' ? defaultHydrateOption : true + + matches.forEach((match) => { + const route = router.looseRoutesById[match.routeId] as + | { + options?: { + hydrate?: boolean + } + } + | undefined + + const routeHydrate = route?.options?.hydrate + const hydrateOption = routeHydrate ?? defaultHydrate + + if (hydrateOption === false) { + hasExplicitFalse = true + } else if (hydrateOption === true && routeHydrate !== undefined) { + hasExplicitTrue = true + } + }) + + const hasConflict = hasExplicitFalse && hasExplicitTrue + const shouldHydrate = !hasExplicitFalse + + return { shouldHydrate, hasConflict } +} diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index a3f31a3a8af..be0ed3a8e59 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -680,3 +680,113 @@ describe('data script rendering', () => { expect(scriptEl!.textContent).toBe('console.log("empty type")') }) }) + +describe('selective hydration scripts', () => { + test('hydrate: false strips client entry import but keeps injected prelude', async () => { + const rootRoute = createRootRoute({ + scripts: () => [ + { + type: 'module', + children: + 'window.__tsr_refresh_setup = true;\nimport("virtual:tanstack-start-client-entry")', + }, + ], + component: () => { + return ( +
+ + +
+ ) + }, + }) + ;(rootRoute.options as any).hydrate = false + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => { + return
index
+ }, + }) + + document.head.innerHTML = '' + document.querySelectorAll('body script').forEach((s) => s.remove()) + + const router = createRouter({ + history: createMemoryHistory({ + initialEntries: ['/'], + }), + routeTree: rootRoute.addChildren([indexRoute]), + isServer: false, + }) + + await router.load() + await act(() => render()) + + const headScripts = document.head.textContent || '' + + expect(headScripts).toContain('window.__tsr_refresh_setup = true') + expect(headScripts).not.toContain('virtual:tanstack-start-client-entry') + }) + + test('hydrate: false strips marked client entry scripts from manifest assets', async () => { + const rootRoute = createRootRoute({ + component: () => { + return ( +
+ + +
+ ) + }, + }) + ;(rootRoute.options as any).hydrate = false + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => { + return
index
+ }, + }) + + const router = createRouter({ + history: createMemoryHistory({ + initialEntries: ['/'], + }), + routeTree: rootRoute.addChildren([indexRoute]), + isServer: true, + }) + + ;(router as any).ssr = { + manifest: { + routes: { + [rootRoute.id]: { + assets: [ + { + tag: 'script', + attrs: { + type: 'module', + async: true, + 'data-tsr-client-entry': 'true', + }, + children: + 'window.__tsr_refresh_setup = true;import("/assets/client.js")', + }, + ], + }, + }, + }, + } + + await router.load() + + const html = ReactDOMServer.renderToString( + , + ) + + expect(html).toContain('window.__tsr_refresh_setup = true') + expect(html).not.toContain('import("/assets/client.js")') + }) +}) diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 852d186b67d..9ba0d4771d7 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -8,7 +8,12 @@ import type { RouteById, RouteIds, } from './routeInfo' -import type { AnyRouter, RegisteredRouter, SSROption } from './router' +import type { + AnyRouter, + HydrateOption, + RegisteredRouter, + SSROption, +} from './router' import type { Constrain, ControlledPromise } from './utils' export type AnyMatchAndValue = { match: any; value: any } @@ -170,6 +175,8 @@ export interface RouteMatch< staticData: StaticDataRouteOption /** This attribute is not reactive */ ssr?: SSROption + /** This attribute is not reactive */ + hydrate?: HydrateOption _forcePending?: boolean _displayPending?: boolean } @@ -193,6 +200,7 @@ export interface PreValidationErrorHandlingRouteMatch< | { status: 'error'; error: unknown } staticData: StaticDataRouteOption ssr?: boolean | 'data-only' + hydrate?: boolean } export type MakePreValidationErrorHandlingRouteMatchUnion< diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 243e93c9893..17a72576d68 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -17,7 +17,13 @@ import type { } from './Matches' import type { RootRouteId } from './root' import type { ParseRoute, RouteById, RouteIds, RoutePaths } from './routeInfo' -import type { AnyRouter, Register, RegisteredRouter, SSROption } from './router' +import type { + AnyRouter, + HydrateOption, + Register, + RegisteredRouter, + SSROption, +} from './router' import type { BuildLocationFn, NavigateFn } from './RouterProvider' import type { Assign, @@ -966,6 +972,8 @@ export interface FilebaseRouteOptionsInterface< ) => Awaitable) > + hydrate?: undefined | HydrateOption + // This async function is called before a route is loaded. // If an error is thrown here, the route's loader will not be called. // If thrown during a navigation, the navigation will be cancelled and the error will be passed to the `onError` function. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e59c87bb409..6aaddd9d50a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -152,6 +152,8 @@ export interface RouterOptionsExtensions extends DefaultRouterOptionsExtensions export type SSROption = boolean | 'data-only' +export type HydrateOption = boolean + export interface RouterOptions< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, @@ -403,6 +405,13 @@ export interface RouterOptions< */ defaultSsr?: SSROption + /** + * The default `hydrate` a route should use if no `hydrate` is provided. + * + * @default true + */ + defaultHydrate?: HydrateOption + search?: { /** * Configures how unknown search params (= not returned by any `validateSearch`) are treated. @@ -635,7 +644,7 @@ export type RouterConstructorOptions< TRouterHistory, TDehydrated >, - 'context' | 'serializationAdapters' | 'defaultSsr' + 'context' | 'serializationAdapters' | 'defaultSsr' | 'defaultHydrate' > & RouterContextOptions diff --git a/packages/start-server-core/src/transformAssetUrls.ts b/packages/start-server-core/src/transformAssetUrls.ts index 50ab5f4dc9b..58b9203546d 100644 --- a/packages/start-server-core/src/transformAssetUrls.ts +++ b/packages/start-server-core/src/transformAssetUrls.ts @@ -181,6 +181,7 @@ export function buildClientEntryScriptTag( attrs: { type: 'module', async: true, + 'data-tsr-client-entry': 'true', }, children: script, }