Skip to content

feat: Add page-level selective hydration with hydrate route option#6092

Open
tannerlinsley wants to merge 2 commits intomainfrom
feat/selective-hydration
Open

feat: Add page-level selective hydration with hydrate route option#6092
tannerlinsley wants to merge 2 commits intomainfrom
feat/selective-hydration

Conversation

@tannerlinsley
Copy link
Member

@tannerlinsley tannerlinsley commented Dec 13, 2025

Summary

  • Add page-level selective hydration for TanStack Start via a new route hydrate option and router defaultHydrate option.
  • When hydration is disabled for the matched route tree, skip hydration-specific client assets while keeping SSR HTML/head output intact.
  • Consolidate coverage into the existing e2e/react-start/basic fixture and remove the duplicated basic-hydrate-false fixture.

API

// Route-level opt out
export const Route = createFileRoute("/legal/privacy")({
  hydrate: false,
  component: PrivacyPage,
})

// Router-level default
export const router = createRouter({
  routeTree,
  defaultHydrate: false,
})

Behavior

  • hydrate defaults to true unless overridden by defaultHydrate.
  • If any matched route explicitly sets hydrate: false, the page does not hydrate.
  • Conflicting explicit route settings are detected and warned.
  • With hydrate: false, hydration-specific scripts/preloads are excluded:
    • client entry script import/assets
    • modulepreload links
    • hydration payload bootstrap
  • SSR output that should remain still renders:
    • route HTML
    • head metadata
    • non-hydration scripts/styles/assets

Implementation Highlights

  • Router core: add typed hydrate/defaultHydrate options and match support.
  • React router:
    • add getHydrateStatus utility
    • gate <Scripts /> client-entry output
    • gate head modulepreload generation
  • Start server core: mark generated client-entry script tags with data-tsr-client-entry to allow robust filtering.

Tests

  • Unit coverage in packages/react-router/tests/Scripts.test.tsx for client-entry stripping behavior.
  • Focused e2e coverage added to existing fixture:
    • e2e/react-start/basic/src/routes/hydrate-false.tsx
    • e2e/react-start/basic/src/routes/hydrate-true.tsx
    • e2e/react-start/basic/tests/hydrate-false.spec.ts
  • Removed duplicated fixture: e2e/react-start/basic-hydrate-false.

Docs

  • Add guide: docs/start/framework/react/guide/selective-hydration.md
  • Add docs config entry in docs/start/config.json

Compatibility

  • No breaking changes; this is opt-in and existing behavior remains hydrated by default.

Summary by CodeRabbit

  • New Features

    • Added selective hydration support, enabling routes to disable client-side hydration via new hydrate and defaultHydrate configuration options for improved performance on server-rendered pages.
  • Documentation

    • Added comprehensive guide for selective hydration covering configuration, patterns, and usage examples.
  • Tests

    • Added end-to-end tests validating selective hydration behavior and asset exclusion.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 13, 2025

📝 Walkthrough

Walkthrough

This PR introduces page-level selective hydration for TanStack Start's React framework. It adds route-level configuration options (hydrate and defaultHydrate) to control whether routes are hydrated, includes conflict detection for mixed hydration settings, and implements script filtering to exclude client-entry imports when hydration is disabled. Documentation, utilities, tests, and example routes are provided.

Changes

Cohort / File(s) Summary
Documentation
docs/start/config.json, docs/start/framework/react/guide/selective-hydration.md
Added guide entry for selective hydration and comprehensive documentation covering hydration behavior, inheritance model, conflict detection, and usage patterns.
Router Core Types
packages/router-core/src/router.ts, packages/router-core/src/route.ts, packages/router-core/src/Matches.ts
Added public HydrateOption type alias, defaultHydrate router option, hydrate field to route options and match interfaces.
Hydration Status Utility
packages/react-router/src/hydrate-status.ts
New utility function getHydrateStatus() that determines hydration requirement and detects conflicts across matched routes.
Script & Head Content Handling
packages/react-router/src/Scripts.tsx, packages/react-router/src/headContentUtils.tsx
Added hydration-aware logic to filter client-entry imports from scripts, conditionally include serverBufferedScript, detect conflicts, and skip preload links when hydration is disabled.
Asset Transformation
packages/start-server-core/src/transformAssetUrls.ts
Added data-tsr-client-entry="true" attribute to client entry script tags for identification during filtering.
Tests
packages/react-router/tests/Scripts.test.tsx, e2e/react-start/basic/tests/hydrate-false.spec.ts
Added test suites for selective hydration script handling and end-to-end behavioral tests for hydrate-true and hydrate-false routes.
Example & E2E Routes
examples/react/start-basic/src/routes/__root.tsx, e2e/react-start/basic/src/routes/hydrate-*.tsx
Added example root route with hydrate: false and two new E2E test routes demonstrating server-rendered and interactive hydration scenarios.
Generated Route Types
e2e/react-start/basic/src/routeTree.gen.ts
Extended route type definitions and file routes map to include new hydrate-true and hydrate-false routes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel

Poem

🐰 Hopping through routes with hydration grace,
Some skip the client dance, some embrace,
Scripts filter clean when hydrate's set false,
No conflicts here, just truth, not false,
Selective choices bloom in TanStack's place! 🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main feature: adding page-level selective hydration via a hydrate route option. It directly reflects the primary changeset across router-core, react-router, and documentation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/selective-hydration

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link

nx-cloud bot commented Dec 13, 2025

View your CI Pipeline Execution ↗ for commit 5a864b0

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 9m 55s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 44s View ↗

☁️ Nx Cloud last updated this comment at 2026-02-18 08:26:04 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 13, 2025

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/arktype-adapter@6092

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/eslint-plugin-router@6092

@tanstack/history

npm i https://pkg.pr.new/TanStack/router/@tanstack/history@6092

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/nitro-v2-vite-plugin@6092

@tanstack/react-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router@6092

@tanstack/react-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router-devtools@6092

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-router-ssr-query@6092

@tanstack/react-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start@6092

@tanstack/react-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-client@6092

@tanstack/react-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/react-start-server@6092

@tanstack/router-cli

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-cli@6092

@tanstack/router-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-core@6092

@tanstack/router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-devtools@6092

@tanstack/router-devtools-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-devtools-core@6092

@tanstack/router-generator

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-generator@6092

@tanstack/router-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-plugin@6092

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-ssr-query-core@6092

@tanstack/router-utils

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-utils@6092

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/router-vite-plugin@6092

@tanstack/solid-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router@6092

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router-devtools@6092

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-router-ssr-query@6092

@tanstack/solid-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start@6092

@tanstack/solid-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-client@6092

@tanstack/solid-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/solid-start-server@6092

@tanstack/start-client-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-client-core@6092

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-fn-stubs@6092

@tanstack/start-plugin-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-plugin-core@6092

@tanstack/start-server-core

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-server-core@6092

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-static-server-functions@6092

@tanstack/start-storage-context

npm i https://pkg.pr.new/TanStack/router/@tanstack/start-storage-context@6092

@tanstack/valibot-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/valibot-adapter@6092

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/TanStack/router/@tanstack/virtual-file-routes@6092

@tanstack/vue-router

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-router@6092

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-router-devtools@6092

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-router-ssr-query@6092

@tanstack/vue-start

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-start@6092

@tanstack/vue-start-client

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-start-client@6092

@tanstack/vue-start-server

npm i https://pkg.pr.new/TanStack/router/@tanstack/vue-start-server@6092

@tanstack/zod-adapter

npm i https://pkg.pr.new/TanStack/router/@tanstack/zod-adapter@6092

commit: 5a864b0

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

🧹 Nitpick comments (35)
e2e/react-start/basic-hydrate-false/src/routes/_layout/_layout-2/layout-b.tsx (1)

7-9: Consider adding explicit return type for strict TypeScript mode.

The component is simple and works correctly. For full TypeScript strict mode compliance (per coding guidelines), you could optionally add an explicit return type, though it's not critical for this e2e test file.

-function LayoutBComponent() {
+function LayoutBComponent(): JSX.Element {
   return <div>I'm layout B!</div>
 }
e2e/react-start/basic-hydrate-false/src/utils/users.tsx (1)

7-9: Ensure consistent types for strict TypeScript.

The PORT constant has inconsistent types: process.env.VITE_SERVER_PORT is string | undefined, while the fallback is number. This results in PORT being string | number, which violates strict type safety guidelines.

Apply this diff to maintain type consistency by parsing the environment variable to a number:

-const PORT = process.env.VITE_SERVER_PORT || 3000
+const PORT = Number(process.env.VITE_SERVER_PORT) || 3000

Alternatively, keep it as a string consistently:

-const PORT = process.env.VITE_SERVER_PORT || 3000
+const PORT = process.env.VITE_SERVER_PORT || '3000'

As per coding guidelines, use TypeScript strict mode with extensive type safety for all code.

e2e/react-start/basic-hydrate-false/src/routes/search-params/default.tsx (1)

8-17: Consider extracting duplicate validation logic.

Both beforeLoad and loader hooks contain identical validation logic checking context.hello !== 'world'. While this duplication may be intentional for e2e testing to verify both hooks independently, consider extracting to a shared helper for better maintainability.

Apply this diff to extract the validation:

+const validateContext = (context: { hello?: string }) => {
+  if (context.hello !== 'world') {
+    throw new Error('Context hello is not "world"')
+  }
+}
+
 export const Route = createFileRoute('/search-params/default')({
   validateSearch: z.object({
     default: z.string().default('d1'),
   }),
   beforeLoad: ({ context }) => {
-    if (context.hello !== 'world') {
-      throw new Error('Context hello is not "world"')
-    }
+    validateContext(context)
   },
   loader: ({ context }) => {
-    if (context.hello !== 'world') {
-      throw new Error('Context hello is not "world"')
-    }
+    validateContext(context)
   },
e2e/react-start/basic-hydrate-false/src/routes/search-params/index.tsx (1)

3-5: Consider explicit hydrate option for test clarity.

Since this is an e2e test for the selective hydration feature, explicitly setting the hydrate option (even if undefined to test default behavior) would make the test's intent clearer to future readers. If this route is intentionally testing inheritance from a parent route or defaultHydrate, consider adding a comment explaining the test scenario.

Example:

 export const Route = createFileRoute('/search-params/')({
   component: RouteComponent,
+  // Testing hydrate inheritance from parent route
+  // hydrate: undefined, 
 })
e2e/react-start/basic-hydrate-false/src/routes/index.tsx (1)

8-64: Consider adding explicit return type for strict TypeScript compliance.

The Home component implementation is clean and correct. For consistency with TypeScript strict mode guidelines, consider adding an explicit return type annotation.

Apply this diff:

-function Home() {
+function Home(): React.JSX.Element {
   return (
e2e/react-start/basic-hydrate-false/tsconfig.json (1)

2-15: allowJs: true + including public/script*.js may create noisy/unintended typecheck surface
If the goal is “don’t fail on JS”, consider explicitly setting checkJs: false (or excluding that folder) to avoid accidental TS diagnostics from public/.

packages/start-server-core/src/router-manifest.ts (1)

14-18: Make asset injection idempotent (avoid duplicate pushes across repeated calls/requests).

getStartManifest() mutates startManifest.routes[rootRouteId].assets and unconditionally pushes dev + client-entry scripts. If tsrStartManifest() (or the root route object) is cached/reused, assets will accumulate and duplicate.

Suggested direction: before pushing, detect existing entries (e.g., data-tsr-client-entry, or a dedicated marker for React Refresh) and skip if present.

Also applies to: 19-36, 38-46

e2e/react-start/basic-hydrate-false/src/routes/links.tsx (2)

33-43: Add type="button" to prevent accidental form submission.

These <button> elements will default to type="submit" if rendered inside a form in the future.

-<button onClick={() => navigate({ to: '/posts' })}>
+<button type="button" onClick={() => navigate({ to: '/posts' })}>
   navigate to /posts
 </button>

 <button
+  type="button"
   onClick={() => navigate({ to: '/posts', reloadDocument: true })}
 >
   navigate to /posts (reloadDocument=true)
 </button>

3-47: Use the recommended type-safe pattern for useNavigate.

Route.useNavigate() (Line 5) works at runtime, but TanStack React Router's documented best practice is to use useNavigate({ from: Route.fullPath }) to ensure type safety for params and search. This gives better IDE support and catches routing errors earlier.

Update the import and adjust the hook call:

-import { Link, createFileRoute } from '@tanstack/react-router'
+import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'

 export const Route = createFileRoute('/links')({
   component: () => {
-    const navigate = Route.useNavigate()
+    const navigate = useNavigate({ from: Route.fullPath })
     return (
e2e/react-start/basic-hydrate-false/src/routes/multi-cookie-redirect/target.tsx (2)

12-18: Consider reading cookies in a loader to avoid hydration mismatch.

The current implementation reads cookies client-side in useEffect, which creates a hydration mismatch: the server renders empty strings while the client renders actual cookie values. This violates React's hydration contract and may cause warnings.

Apply this pattern to read cookies server-side:

+import { createServerFn } from '@tanstack/start'
+
+const getCookies = createServerFn('GET', async (_, { request }) => {
+  const cookieHeader = request.headers.get('Cookie') || ''
+  const session = cookieHeader.match(/session=([^;]+)/)?.[1] || ''
+  const csrf = cookieHeader.match(/csrf=([^;]+)/)?.[1] || ''
+  const theme = cookieHeader.match(/theme=([^;]+)/)?.[1] || ''
+  return { session, csrf, theme }
+})
+
 export const Route = createFileRoute('/multi-cookie-redirect/target')({
+  loader: async () => getCookies(),
   component: RouteComponent,
 })
 
 function RouteComponent() {
-  const [cookies, setCookies] = React.useState<Record<string, string>>({})
-
-  useEffect(() => {
-    setCookies({
-      session: Cookies.get('session') || '',
-      csrf: Cookies.get('csrf') || '',
-      theme: Cookies.get('theme') || '',
-    })
-  }, [])
+  const cookies = Route.useLoaderData()

This ensures the server and client render identical content, eliminating the hydration mismatch.


14-16: Optional: Add error handling for cookie access.

While js-cookie is generally safe, wrapping the cookie reads in a try-catch would add robustness against unexpected errors.

   useEffect(() => {
+    try {
       setCookies({
         session: Cookies.get('session') || '',
         csrf: Cookies.get('csrf') || '',
         theme: Cookies.get('theme') || '',
       })
+    } catch (error) {
+      console.error('Failed to read cookies:', error)
+      setCookies({ session: '', csrf: '', theme: '' })
+    }
   }, [])
e2e/react-start/basic-hydrate-false/src/routes/api/users.$id.ts (1)

18-18: Inconsistent string concatenation style.

Mixing template literal syntax with concatenation is inconsistent. Use a full template literal for clarity.

Apply this diff:

-          const res = await axios.get<User>(`${queryURL}/users/` + params.id)
+          const res = await axios.get<User>(`${queryURL}/users/${params.id}`)
packages/router-core/src/Matches.ts (1)

203-203: Consider using HydrateOption for consistency.

The PreValidationErrorHandlingRouteMatch interface uses boolean directly, while RouteMatch uses HydrateOption (which is a type alias for boolean). While functionally equivalent, using HydrateOption consistently would better express the semantic intent.

Apply this diff for consistency:

-  hydrate?: boolean
+  hydrate?: HydrateOption

Note: This requires importing HydrateOption in the type parameters section if not already available at that scope.

docs/start/framework/react/guide/selective-hydration.md (2)

313-318: Add language specifier to fenced code block.

The warning message example lacks a language identifier. Consider adding text or plaintext to satisfy markdown linting rules.

-```
+```text
 ⚠️ [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.

---

`217-228`: **Clarify the inheritance vs. conflict distinction.**

The example shows `$postId` with explicit `hydrate: true` while parent `blog` has `hydrate: false`. According to the conflict detection section (lines 302-318), this creates a conflict (explicit `true` vs explicit `false`), not pure inheritance. The explanation "inherits `false` from its parent" may confuse readers since it's actually a conflict scenario where the page won't hydrate. Consider either:
1. Changing `$postId { hydrate: true }` to `$postId { /* omitted */ }` to show true inheritance, or
2. Referencing the conflict detection section here.

</blockquote></details>
<details>
<summary>e2e/react-start/basic-hydrate-false/package.json (1)</summary><blockquote>

`6-22`: **POSIX-only e2e scripts (`MODE=...`, `rm -rf`)—confirm CI/OS expectations.** 

If these scripts ever need to run on Windows runners, consider `cross-env` for `MODE=...` and a cross-platform cleanup (or do cleanup inside the Playwright setup).

</blockquote></details>
<details>
<summary>e2e/react-start/basic-hydrate-false/server.js (1)</summary><blockquote>

`6-9`: **Consider normalizing `PORT` / `START_PORT` types.** 

`process.env.* || 3000` yields `string | number`; parsing to integers avoids surprises and keeps logging consistent.

</blockquote></details>
<details>
<summary>e2e/react-start/basic-hydrate-false/src/routes/__root.tsx (1)</summary><blockquote>

`95-187`: **Consider adding `<html lang="en">` (a11y) unless tests require omission.** 

```diff
-    <html>
+    <html lang="en">
e2e/react-start/basic-hydrate-false/src/utils/posts.tsx (2)

11-15: Prefer a single-expression queryURL (avoid mutable module state).

-let queryURL = 'https://jsonplaceholder.typicode.com'
-
-if (import.meta.env.VITE_NODE_ENV === 'test') {
-  queryURL = `http://localhost:${import.meta.env.VITE_EXTERNAL_PORT}`
-}
+const queryURL =
+  import.meta.env.VITE_NODE_ENV === 'test'
+    ? `http://localhost:${import.meta.env.VITE_EXTERNAL_PORT}`
+    : 'https://jsonplaceholder.typicode.com'

5-9: PostType.id type may not match upstream JSONPlaceholder (number vs string).

If you want this to reflect the actual API payload, consider id: number (or string | number if you’re normalizing elsewhere).

e2e/react-start/basic-hydrate-false/src/components/NotFound.tsx (1)

3-3: Use React.ReactNode for the children prop type.

The any type bypasses TypeScript's type checking. For better type safety and adherence to strict mode, use React.ReactNode instead.

Apply this diff:

-export function NotFound({ children }: { children?: any }) {
+export function NotFound({ children }: { children?: React.ReactNode }) {
e2e/react-start/basic-hydrate-false/src/routes/deferred.tsx (2)

40-49: Use JSX children syntax instead of children prop.

The Await component uses a children prop, but React's canonical way is to pass children as JSX content between opening and closing tags.

Apply this diff to use JSX children syntax:

-      <Suspense fallback={<div>Loading person...</div>}>
-        <Await
-          promise={deferredPerson}
-          children={(data) => (
-            <div data-testid="deferred-person">
-              {data.name} - {data.randomNumber}
-            </div>
-          )}
-        />
-      </Suspense>
+      <Suspense fallback={<div>Loading person...</div>}>
+        <Await promise={deferredPerson}>
+          {(data) => (
+            <div data-testid="deferred-person">
+              {data.name} - {data.randomNumber}
+            </div>
+          )}
+        </Await>
+      </Suspense>

50-55: Use JSX children syntax instead of children prop.

Same issue as the previous Await component - use JSX children syntax for better readability and to follow React conventions.

Apply this diff:

-      <Suspense fallback={<div>Loading stuff...</div>}>
-        <Await
-          promise={deferredStuff}
-          children={(data) => <h3 data-testid="deferred-stuff">{data}</h3>}
-        />
-      </Suspense>
+      <Suspense fallback={<div>Loading stuff...</div>}>
+        <Await promise={deferredStuff}>
+          {(data) => <h3 data-testid="deferred-stuff">{data}</h3>}
+        </Await>
+      </Suspense>
e2e/react-start/basic-hydrate-false/src/routes/redirect/$target/via-loader.tsx (1)

8-15: Add default case to handle unexpected target values.

The switch statement lacks a default case, which means unexpected target values will be silently ignored and the component will render. While this may be intentional for testing, explicit handling improves clarity.

Apply this diff to add a default case:

  loader: ({ params: { target }, deps: { externalHost, reloadDocument } }) => {
    switch (target) {
      case 'internal':
        throw redirect({ to: '/posts', reloadDocument })
      case 'external':
        throw redirect({ href: externalHost })
+     default:
+       // Allow component to render for testing unexpected values
+       return
    }
  },
e2e/react-start/basic-hydrate-false/src/routes/stream.tsx (1)

30-45: Add cleanup to cancel the stream reader on unmount.

The useEffect lacks a cleanup function to cancel the stream reader when the component unmounts, which could lead to memory leaks or errors if the component is unmounted before the stream completes.

Apply this diff to add cleanup:

  useEffect(() => {
    async function fetchStream() {
      const reader = stream.getReader()
      let chunk

      while (!(chunk = await reader.read()).done) {
        let value = chunk.value
        if (typeof value !== 'string') {
          value = decoder.decode(value, { stream: !chunk.done })
        }
        setStreamData((prev) => [...prev, value])
      }
    }

    fetchStream()
+
+   return () => {
+     // Cancel the stream reader on unmount
+     stream.cancel()
+   }
  }, [])
e2e/react-start/basic-hydrate-false/src/routes/users.$userId.tsx (1)

15-17: Consider preserving original error details.

The catch block throws a generic error message, losing the original error details. While acceptable for test code, consider whether preserving the original error would aid debugging.

Optional improvement:

      .catch((error) => {
-       throw new Error('Failed to fetch user')
+       throw new Error(`Failed to fetch user: ${error.message}`)
      })
e2e/react-start/basic-hydrate-false/tests/hydrate.spec.ts (3)

4-40: Harden “no main bundle scripts” and “non-interactive” assertions (current checks are too broad).
html.includes('type="module"') can false-fail (any module script trips it), and the “non-interactive” claim isn’t asserted via user action.

-    const hasMainBundleScript = html.includes('type="module"')
-    expect(hasMainBundleScript).toBe(false)
+    // More specific: no TanStack client entry marker/scripts
+    expect(html).not.toContain('data-tsr-client-entry')
+    expect(html).not.toContain('virtual:tanstack-start-client-entry')

@@
-    // Button with onClick should not work
-    await expect(page.getByTestId('inactive-button')).toBeVisible()
+    // Button with onClick should not work (no hydration)
+    const btn = page.getByTestId('inactive-button')
+    await expect(btn).toBeVisible()
+    await btn.click()
+    await expect(page.getByTestId('click-count')).toHaveText('0')

(If there’s no click-count in the fixture, assert any other stable “post-click” change does not occur.)


58-68: Modulepreload assertion may be overly global.
If the page legitimately includes other modulepreload links (unrelated to client hydration), rel="modulepreload" will be too coarse. Consider asserting absence of router/app preloads specifically (eg by href prefix/known chunk markers) if available.


120-129: Prefer role-based navigation for resilience.
page.click('a[href="/static"]') can break if multiple anchors share that href (or if a base path is introduced).

-    await page.click('a[href="/static"]')
+    await page.getByRole('link', { name: 'Static', exact: true }).click()
e2e/react-start/basic-hydrate-false/src/routes/not-found/via-loader.tsx (1)

17-23: Optional: make data-server value less surprising.
data-server={typeof window} yields "undefined"/"object" strings; if you want a boolean-ish marker, consider data-server={String(typeof window === 'undefined')}.

packages/react-router/src/Scripts.tsx (1)

7-14: Remove duplicated docblock.
Two identical “Render body script tags…” blocks appear back-to-back.

 /**
  * Render body script tags collected from route matches and SSR manifests.
  * Should be placed near the end of the document body.
  */
-/**
- * Render body script tags collected from route matches and SSR manifests.
- * Should be placed near the end of the document body.
- */
e2e/react-start/basic-hydrate-false/tests/script-duplication.spec.ts (2)

4-16: Make script src selector resilient (/script.js vs script.js, base paths).
Exact script[src="script.js"] is brittle.

-      return document.querySelectorAll('script[src="script.js"]').length
+      return document.querySelectorAll('script[src$="script.js"]').length

18-43: Same brittleness in navigation tests; switch to $= selector too.

-      return document.querySelectorAll('script[src="script.js"]').length
+      return document.querySelectorAll('script[src$="script.js"]').length

Also applies to: 45-67

e2e/react-start/basic-hydrate-false/tests/not-found.spec.ts (1)

42-45: Consider using a more deterministic wait strategy.

The fixed 250ms setTimeout for preload wait may be flaky in slower CI environments. Consider waiting for a network request or using page.waitForResponse to confirm preload completion.

         if (preload) {
           await link.focus()
-          await new Promise((r) => setTimeout(r, 250))
+          // Wait for preload network request to complete
+          await page.waitForLoadState('networkidle')
         }
e2e/react-start/basic-hydrate-false/src/routes/users.tsx (1)

8-16: Consider preserving original error context.

The catch block discards the original error, which may make debugging harder. Consider including the original error as the cause.

       .catch(() => {
-        throw new Error('Failed to fetch users')
+        throw new Error('Failed to fetch users', { cause: error })
       })

Note: This would require capturing the error: .catch((error) => { ... })

Comment on lines 24 to 26
"@tanstack/react-router": "workspace:^",
"@tanstack/react-router-devtools": "workspace:^",
"@tanstack/react-start": "workspace:^",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use workspace:* for internal workspace deps (repo guideline).

Per coding guidelines for **/package.json, switch internal deps to workspace:* (e.g. @tanstack/react-router, @tanstack/react-start, @tanstack/router-e2e-utils).

   "dependencies": {
-    "@tanstack/react-router": "workspace:^",
-    "@tanstack/react-router-devtools": "workspace:^",
-    "@tanstack/react-start": "workspace:^",
+    "@tanstack/react-router": "workspace:*",
+    "@tanstack/react-router-devtools": "workspace:*",
+    "@tanstack/react-start": "workspace:*",
     "express": "^5.1.0",
     ...
   },
   "devDependencies": {
     "@playwright/test": "^1.50.1",
     "@tailwindcss/postcss": "^4.1.15",
-    "@tanstack/router-e2e-utils": "workspace:^",
+    "@tanstack/router-e2e-utils": "workspace:*",
     ...
   }

Also applies to: 38-38

🤖 Prompt for AI Agents
In e2e/react-start/basic-hydrate-false/package.json around lines 24-26 (and also
line 38), the internal workspace dependencies use "workspace:^" but must follow
the repo guideline and use "workspace:*"; update the listed internal packages
(e.g. @tanstack/react-router, @tanstack/react-router-devtools,
@tanstack/react-start, @tanstack/router-e2e-utils where present) to use the
exact specifier "workspace:*" instead of "workspace:^" so the package.json
conforms to the workspace dependency convention.

Comment on lines 57 to 67
createSpaServer().then(async ({ app }) =>
app.listen(port, () => {
console.info(`Client Server: http://localhost:${port}`)
}),
)

createStartServer().then(async ({ app }) =>
app.listen(startPort, () => {
console.info(`Start Server: http://localhost:${startPort}`)
}),
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add .catch(...) on server startup promises to avoid silent boot failures.

Right now a failed dynamic import (or port bind error) can become an unhandled rejection with incomplete logs. Add a .catch((e) => { console.error(e); process.exitCode = 1 }) (or similar) on both startup chains.

🤖 Prompt for AI Agents
In e2e/react-start/basic-hydrate-false/server.js around lines 57 to 67, the
Promise chains returned by createSpaServer() and createStartServer() lack catch
handlers so startup failures (dynamic import errors or port bind failures) can
become unhandled rejections; add .catch((e) => { console.error(e);
process.exitCode = 1 }) to each chain (or equivalent error logging and setting a
non‑zero exit code) so errors are logged and the process indicates failure.

Comment on lines 17 to 65
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
...seo({
title:
'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework',
description: `TanStack Start is a type-safe, client-first, full-stack React framework. `,
}),
],
links: [
{ rel: 'stylesheet', href: appCss },
{
rel: 'apple-touch-icon',
sizes: '180x180',
href: '/apple-touch-icon.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
href: '/favicon-32x32.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '16x16',
href: '/favicon-16x16.png',
},
{ rel: 'manifest', href: '/site.webmanifest', color: '#fffff' },
{ rel: 'icon', href: '/favicon.ico' },
],
styles: [
{
media: 'all and (min-width: 500px)',
children: `
.inline-div {
color: white;
background-color: gray;
max-width: 250px;
}`,
},
],
}),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix invalid hex color (#fffff#ffffff).

-      { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' },
+      { rel: 'manifest', href: '/site.webmanifest', color: '#ffffff' },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
...seo({
title:
'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework',
description: `TanStack Start is a type-safe, client-first, full-stack React framework. `,
}),
],
links: [
{ rel: 'stylesheet', href: appCss },
{
rel: 'apple-touch-icon',
sizes: '180x180',
href: '/apple-touch-icon.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
href: '/favicon-32x32.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '16x16',
href: '/favicon-16x16.png',
},
{ rel: 'manifest', href: '/site.webmanifest', color: '#fffff' },
{ rel: 'icon', href: '/favicon.ico' },
],
styles: [
{
media: 'all and (min-width: 500px)',
children: `
.inline-div {
color: white;
background-color: gray;
max-width: 250px;
}`,
},
],
}),
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
...seo({
title:
'TanStack Start | Type-Safe, Client-First, Full-Stack React Framework',
description: `TanStack Start is a type-safe, client-first, full-stack React framework. `,
}),
],
links: [
{ rel: 'stylesheet', href: appCss },
{
rel: 'apple-touch-icon',
sizes: '180x180',
href: '/apple-touch-icon.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
href: '/favicon-32x32.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '16x16',
href: '/favicon-16x16.png',
},
{ rel: 'manifest', href: '/site.webmanifest', color: '#ffffff' },
{ rel: 'icon', href: '/favicon.ico' },
],
styles: [
{
media: 'all and (min-width: 500px)',
children: `
.inline-div {
color: white;
background-color: gray;
max-width: 250px;
}`,
},
],
}),
🤖 Prompt for AI Agents
In e2e/react-start/basic-hydrate-false/src/routes/__root.tsx around lines 17 to
65, the manifest link uses an invalid 5-digit hex color '#fffff'; update it to a
valid 6-digit hex '#ffffff' (or another valid CSS color) so the manifest link's
color attribute is valid.

Comment on lines 17 to 26
filter: (page: { path: string }) =>
![
'/this-route-does-not-exist',
'/redirect',
'/i-do-not-exist',
'/not-found/via-beforeLoad',
'/not-found/via-head',
'/not-found/via-loader',
'/users',
].some((p) => page.path.includes(p)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Refine filter logic to avoid unintended exclusions.

The filter function uses page.path.includes(p), which performs substring matching rather than exact path matching. This could cause false positives—for example, excluding /users will also exclude /users/123, /users/profile, or any path containing the substring /users.

Consider using exact matching or startsWith for more precise path filtering:

  filter: (page: { path: string }) =>
    ![
      '/this-route-does-not-exist',
      '/redirect',
      '/i-do-not-exist',
      '/not-found/via-beforeLoad',
      '/not-found/via-head',
      '/not-found/via-loader',
      '/users',
-    ].some((p) => page.path.includes(p)),
+    ].some((p) => page.path === p || page.path.startsWith(`${p}/`)),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
filter: (page: { path: string }) =>
![
'/this-route-does-not-exist',
'/redirect',
'/i-do-not-exist',
'/not-found/via-beforeLoad',
'/not-found/via-head',
'/not-found/via-loader',
'/users',
].some((p) => page.path.includes(p)),
filter: (page: { path: string }) =>
![
'/this-route-does-not-exist',
'/redirect',
'/i-do-not-exist',
'/not-found/via-beforeLoad',
'/not-found/via-head',
'/not-found/via-loader',
'/users',
].some((p) => page.path === p || page.path.startsWith(`${p}/`)),
🤖 Prompt for AI Agents
In e2e/react-start/basic-hydrate-false/vite.config.ts around lines 17 to 26, the
filter currently uses page.path.includes(p) which causes unintended exclusions
by matching substrings (e.g., excluding /users also excludes /users/123); change
the logic to perform exact matches for the listed full paths (use page.path ===
p) or use prefix matching where appropriate (use page.path.startsWith(p) for
routes meant to exclude whole subpaths), updating the array entries to reflect
whether they are exact routes or prefixes and ensure the filter negates those
precise checks.


const prerenderConfiguration = {
enabled: true,
filter: (page: { path: string }) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the vite.config.ts file to get full context
cat -n e2e/react-start/basic-hydrate-false/vite.config.ts

Repository: TanStack/router

Length of output: 1441


🏁 Script executed:

# Search for prerender configuration and filter type definitions in the codebase
rg -nP --type=ts 'filter.*=.*\(page' -A3 -B3

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

# Look for TanStack Start plugin types and prerender options
rg -nP --type=ts 'PrerenderOptions|PrerenderConfig|type.*Prerender' -A5 -B2

Repository: TanStack/router

Length of output: 2480


🏁 Script executed:

# Check for @tanstack/react-start imports and their types
rg -nP '@tanstack/react-start' --type=ts -A2 -B2

Repository: TanStack/router

Length of output: 50372


🏁 Script executed:

# Search for any type definitions or interfaces related to filter function
ast-grep --pattern 'interface $_ {
  filter: ($_) => $$$
}'

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

# Read the schema.ts file to see the full pagePrerenderOptionsSchema definition
sed -n '90,200p' packages/start-plugin-core/src/schema.ts

Repository: TanStack/router

Length of output: 3003


🏁 Script executed:

# Search for filter property in prerender options/config
rg -n 'filter.*:\s*' packages/start-plugin-core/src/schema.ts -A2 -B2

Repository: TanStack/router

Length of output: 341


🏁 Script executed:

# Check if there are any exported types from the start plugin
rg -n 'export.*type.*Prerender' packages/start-plugin-core/src/ -A5

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

# Look for the actual filter function type definition
rg -n '\bfilter\b.*=>' packages/start-plugin-core/src/schema.ts -B5 -A5

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

# Find pageBaseSchema definition
rg -n 'pageBaseSchema\s*=' packages/start-plugin-core/src/schema.ts -A10

Repository: TanStack/router

Length of output: 436


🏁 Script executed:

# Check if there are any type exports that correspond to the schema
rg -n 'infer.*pageSchema\|z\.infer' packages/start-plugin-core/src/schema.ts

Repository: TanStack/router

Length of output: 41


🏁 Script executed:

# Look for any index.ts or types.ts that might export the types
fd -e ts packages/start-plugin-core/src/ | head -20

Repository: TanStack/router

Length of output: 488


🏁 Script executed:

# Check for exported types in schema.ts or index files
rg -n 'export' packages/start-plugin-core/src/schema.ts | head -20

Repository: TanStack/router

Length of output: 277


🏁 Script executed:

# Check the main index file for the start-plugin-core package
cat packages/start-plugin-core/src/index.ts 2>/dev/null || cat packages/start-plugin-core/index.ts 2>/dev/null || echo "No index file found"

Repository: TanStack/router

Length of output: 265


🏁 Script executed:

# Look for TypeScript declaration files
fd . packages/start-plugin-core/src/ -name "*.d.ts" -o -name "index.ts"

Repository: TanStack/router

Length of output: 289


Use the exported Page type for the filter function parameter.

The inline type { path: string } is incomplete. The @tanstack/react-start/plugin/vite prerender filter should accept the full Page object which includes optional sitemap, fromCrawl, and prerender properties. Import and use the proper type to align with strict TypeScript type safety.

🤖 Prompt for AI Agents
In e2e/react-start/basic-hydrate-false/vite.config.ts around line 17, the
prerender filter parameter is typed as an inline { path: string } which is
incomplete; import and use the exported Page type from
'@tanstack/react-start/plugin/vite' (e.g. add an import for Page) and change the
filter signature to accept page: Page so the function receives the full Page
shape (including optional sitemap, fromCrawl, prerender) and satisfies strict
TypeScript checks.

Comment on lines 21 to 35
const { injectedHeadScripts } = await import(
'tanstack-start-injected-head-scripts:v'
)
if (injectedHeadScripts) {
script = `${injectedHeadScripts + ';'}${script}`
// Add React Refresh script (keep for HMR even when hydrate: false)
rootRoute.assets.push({
tag: 'script',
attrs: {
type: 'module',
async: true,
},
children: injectedHeadScripts,
})
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden script children strings (escape clientEntry; consider guarding injected scripts).

  • children: \import('${startManifest.clientEntry}')`will break ifclientEntryever contains'`. Safer:
  • children: import('${startManifest.clientEntry}'),
  • children: import(${JSON.stringify(startManifest.clientEntry)}),
- Dev-only `injectedHeadScripts` is injected verbatim into a `<script>` tag. If it can include `</script>`, it can terminate the tag early. If there’s any chance it’s not strictly controlled, consider sanitizing/encoding or enforcing a trusted source invariant.




Also applies to: 39-46

<details>
<summary>🤖 Prompt for AI Agents</summary>

In packages/start-server-core/src/router-manifest.ts around lines 21-35 (and
similarly at 39-46), the code injects raw script strings using children:
import('${startManifest.clientEntry}') and verbatim injectedHeadScripts, which
can break if clientEntry contains single quotes or if injectedHeadScripts
contains a closing </script>; to fix, always escape/serialize the clientEntry
when building the script string (e.g. replace the inline '${...}'
interpolation with a safe serialization such as JSON.stringify or an equivalent
escaping helper so any quotes are escaped), and treat injectedHeadScripts as
untrusted: either restrict this path to dev-only with an explicit invariant
check, sanitize/encode the content to disallow </script> (or base64/URL-encode
and decode in a safe runtime insertion), or assert a trusted-source invariant
before injecting; apply these changes at both locations mentioned.


</details>

<!-- fingerprinting:phantom:poseidon:falcon -->

<!-- This is an auto-generated comment by CodeRabbit -->

'data-tsr-client-entry': 'true',
},
children: script,
children: `import('${startManifest.clientEntry}')`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could use a bit more commenting here, just so we explcitly point it out for anyone looking at this in the future, that when hydrate: false, then these scripts tagged with data-tsr-client-entry='true' will outright not be loaded into the DOM.
Also, for the default use-case, we should probably use the preview packages and init a few apps to make sure that HMR really is working just fine. Mostly, since the current flow of having the HMR script be synchronously imported in before even starting to bring in the client entry was something that got settled on (by Manuel) after a decent bit of trial and error.

}))

// If hydrate is false, remove client entry imports but keep React Refresh for HMR
if (!shouldHydrate) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit, might make more sense for it to be function that lives in router-core, to then be consumed by React, Solid, and Vue.

@@ -0,0 +1,30 @@
export function getHydrateStatus(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also to be bounced into router-core.

})

// Warn about conflicting hydrate options
if (hydrateStatus.hasConflict) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't have such a warning for ssr: false etc., we just use the "least common denominator".
do we need this warning here really?
also, if we do, we should only emit in DEV

// ❌ Renders on client ❌ Data loads on client ✅ Interactive on client

// This combination doesn't make sense
ssr: false, hydrate: false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably should warn if this combination happens?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since ssr and hydrate props can conflict, should we use a single config option to express both?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or should we make it configurable whether ssr:false wins over hydrate:false?


## 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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be flippable via a config?

so that you can also express

"If any route in the match has hydrate: true, the entire page will be hydrated"

@tannerlinsley tannerlinsley force-pushed the feat/selective-hydration branch from 9c4b380 to 8f342d1 Compare February 17, 2026 23:44
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/start/framework/react/guide/selective-hydration.md`:
- Around line 313-318: Update the fenced code block containing the TanStack
Router warning in selective-hydration.md to include a language specifier (e.g.,
plaintext or text) so the warning is syntax-highlighted correctly; locate the
block with the warning text "⚠️ [TanStack Router] Conflicting hydrate options
detected in route matches..." and change the opening triple backticks to include
the specifier (```plaintext).

Comment on lines +313 to +318
```
⚠️ [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.
```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language specifier to fenced code block.

The warning message code block is missing a language specifier, which prevents proper syntax highlighting. Since this is a plain text warning message, specify plaintext or text.

📝 Proposed fix
-```
+```plaintext
 ⚠️ [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.
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 313-313: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/start/framework/react/guide/selective-hydration.md` around lines 313 -
318, Update the fenced code block containing the TanStack Router warning in
selective-hydration.md to include a language specifier (e.g., plaintext or text)
so the warning is syntax-highlighted correctly; locate the block with the
warning text "⚠️ [TanStack Router] Conflicting hydrate options detected in route
matches..." and change the opening triple backticks to include the specifier
(```plaintext).

@tannerlinsley tannerlinsley force-pushed the feat/selective-hydration branch from 43c8661 to 2ecfbb7 Compare February 18, 2026 08:09
Introduce route-level and default hydration controls, skip hydration client-entry/modulepreload assets when hydration is disabled, and add focused react-start e2e/unit coverage with documentation.
@tannerlinsley tannerlinsley force-pushed the feat/selective-hydration branch from 2ecfbb7 to c3acaab Compare February 18, 2026 08:13
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
e2e/react-start/basic/src/routes/hydrate-true.tsx (1)

4-9: Consider explicitly setting hydrate: true for test completeness.

The route is named /hydrate-true but doesn't explicitly set the hydrate option. Comparing to hydrate-false.tsx which explicitly sets hydrate: false, this test route should probably also explicitly set hydrate: true to test the explicit opt-in behavior rather than relying on the default.

♻️ Suggested change
 export const Route = createFileRoute('/hydrate-true')({
+  hydrate: true,
   loader: () => ({
     message: 'hydrate true route rendered',
   }),
   component: HydrateTrueComponent,
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/react-start/basic/src/routes/hydrate-true.tsx` around lines 4 - 9, The
Route currently created with createFileRoute('/hydrate-true') sets a loader and
component but does not explicitly set hydrate; update the Route definition to
include hydrate: true in the route options object passed to createFileRoute so
the test explicitly opts into hydration (i.e., add hydrate: true alongside
loader and component in the object used to create the Route).
packages/react-router/src/Scripts.tsx (1)

20-22: Consider consolidating getHydrateStatus calls.

getHydrateStatus is called three times per render cycle (lines 21, 26, 65). While this is a pure function and the selectors are memoized, you could potentially hoist the hydration status to a single useRouterState call and derive the dependent computations from it, reducing redundant iterations over matches.

This is a minor optimization concern and the current implementation is functionally correct.

Also applies to: 26-26, 65-65

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router/src/Scripts.tsx` around lines 20 - 22, Hoist the
hydration status into a single useRouterState invocation: call useRouterState
once with select: (state) => getHydrateStatus(state.matches, router) (the
current hydrateStatus variable) and then derive any other computations that
previously invoked getHydrateStatus (the other select calls around lines
referencing getHydrateStatus) from that single hydrateStatus value; update
places that call getHydrateStatus again to read from hydrateStatus instead so
you avoid three separate iterations over matches while keeping logic in
functions/components like Scripts.tsx unchanged otherwise.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@docs/start/framework/react/guide/selective-hydration.md`:
- Around line 320-325: The fenced code block in
docs/start/framework/react/guide/selective-hydration.md containing the warning
lines starting with "⚠️ [TanStack Router] Conflicting hydrate options detected
in route matches." is missing a language specifier; update that fenced block to
include a language token (use plaintext or text, e.g., ```plaintext) so the
warning is rendered with proper formatting.

---

Nitpick comments:
In `@e2e/react-start/basic/src/routes/hydrate-true.tsx`:
- Around line 4-9: The Route currently created with
createFileRoute('/hydrate-true') sets a loader and component but does not
explicitly set hydrate; update the Route definition to include hydrate: true in
the route options object passed to createFileRoute so the test explicitly opts
into hydration (i.e., add hydrate: true alongside loader and component in the
object used to create the Route).

In `@packages/react-router/src/Scripts.tsx`:
- Around line 20-22: Hoist the hydration status into a single useRouterState
invocation: call useRouterState once with select: (state) =>
getHydrateStatus(state.matches, router) (the current hydrateStatus variable) and
then derive any other computations that previously invoked getHydrateStatus (the
other select calls around lines referencing getHydrateStatus) from that single
hydrateStatus value; update places that call getHydrateStatus again to read from
hydrateStatus instead so you avoid three separate iterations over matches while
keeping logic in functions/components like Scripts.tsx unchanged otherwise.

Comment on lines +30 to +34
if (hydrateOption === false) {
hasExplicitFalse = true
} else if (hydrateOption === true && routeHydrate !== undefined) {
hasExplicitTrue = true
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we skip some iterations w/ a for loop and a break here?

Suggested change
if (hydrateOption === false) {
hasExplicitFalse = true
} else if (hydrateOption === true && routeHydrate !== undefined) {
hasExplicitTrue = true
}
if (hydrateOption === false) {
hasExplicitFalse = true
} else if (hydrateOption === true && routeHydrate !== undefined) {
hasExplicitTrue = true
}
if (hasExplicitFalse && hasExplicitTrue) break

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants