Skip to content

Conversation

@tannerlinsley
Copy link
Member

@tannerlinsley tannerlinsley commented Dec 13, 2025

Summary

This PR adds a new hydrate route option that enables page-level selective hydration in TanStack Router/Start. Routes can now opt out of client-side React hydration to create truly static pages with zero JavaScript overhead.

Motivation

Some pages (legal documents, blog posts, static content) don't need client-side interactivity but still benefit from server-side rendering for SEO. Previously, all SSR pages would load React and hydrate on the client, even when no interactivity was needed. This feature allows developers to create static server-rendered pages that skip the React bundle entirely, reducing JavaScript overhead and improving performance.

Implementation

API Design

Route Option:

export const Route = createFileRoute('/legal/privacy')({
  hydrate: false, // Skip React hydration for this page
  component: PrivacyPage,
})

Router Option:

export const startInstance = createStart(() => ({
  defaultHydrate: false, // Change default behavior
}))

Opt-in/opt-out Mechanism

  • Omitted (undefined): Uses default behavior (hydrate)
  • hydrate: true: Explicitly ensures hydration (useful for conflict resolution)
  • hydrate: false: Disables hydration (truly static page)

Key Features

  1. Page-level control: Entire page either hydrates or doesn't
  2. Inheritance: If any route in match has hydrate: false, page won't hydrate
  3. Conflict detection: Warns when routes have conflicting explicit settings
  4. No serialization: Reads directly from route options during SSR
  5. Dev mode support: Keeps React Refresh for HMR even with hydrate: false

What gets excluded with hydrate: false:

  • React runtime & DOM bundles (~260KB raw, ~90KB gzipped)
  • TanStack Router client bundle
  • Application JavaScript
  • Modulepreload links
  • Client entry scripts

What still works:

  • Server-side rendering
  • Loader data (during SSR)
  • Meta tags from head()
  • External scripts from head()
  • CSS and static assets

Technical Details

Files Modified

Core Router (@tanstack/router-core):

  • src/router.ts - Added HydrateOption type and defaultHydrate option
  • src/route.ts - Added hydrate?: boolean to route options
  • src/Matches.ts - Added hydrate property to RouteMatch interface (not serialized)

React Router (@tanstack/react-router):

  • src/HeadContent.tsx - Filter modulepreload links when hydrate: false
  • src/Scripts.tsx - Filter client entry scripts when hydrate: false
  • src/hydrate-status.ts - New utility for determining hydrate status from route options

Start Server Core (@tanstack/start-server-core):

  • src/router-manifest.ts - Split React Refresh from client entry for dev mode

Design Decisions

  1. No function syntax: Unlike ssr, hydrate is boolean-only for simplicity
  2. No match property storage: Reads directly from route options at render time
  3. No serialization: Client JS only runs if hydrating, so no need to send hydrate info
  4. Conflict handling: Don't hydrate if ANY route has hydrate: false (safer default)

Documentation

Added comprehensive guide at docs/start/framework/react/guide/selective-hydration.md covering:

  • What is page-level selective hydration
  • Comparison with ssr option
  • Configuration and usage patterns
  • Inheritance and conflict resolution
  • Use cases and performance impact
  • Troubleshooting

Testing

Created demo app at e2e/react-start/basic-hydrate-false with:

  • Routes demonstrating hydrate: true and hydrate: false
  • E2E tests verifying behavior
  • Manual verification with curl showing correct script filtering

Breaking Changes

None. This is a new opt-in feature with safe defaults.

Migration

No migration needed. Existing apps continue to work unchanged with full hydration (current behavior).

Future Work

  • Component-level selective hydration (Server Components) - separate upcoming feature
  • Per-route bundle splitting optimizations
  • Static generation improvements

Notes

  • This is page-level hydration only (entire page or nothing)
  • Component-level selective hydration will come with Server Components
  • Use case is narrow: truly static sites with zero client-side React
  • Most apps should keep default hydrate: true for navigation/interactivity

Summary by CodeRabbit

  • New Features

    • Added page-level selective hydration support, allowing routes to control whether JavaScript hydration is required via the new hydrate option (true/false/undefined).
    • Added router-level defaultHydrate option to set hydration behavior across routes.
    • Improved performance for static pages by enabling server-rendered content without client-side JavaScript overhead.
  • Documentation

    • Added comprehensive guide on selective hydration, including configuration options, use cases, examples, and troubleshooting.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 13, 2025

Walkthrough

This PR introduces selective hydration support for TanStack Router, allowing developers to mark individual routes with a hydrate option to control whether pages are rendered as interactive (hydrated) or static (non-hydrated). It includes new configuration options in router core, hydration-aware script and asset filtering, comprehensive E2E testing, and documentation.

Changes

Cohort / File(s) Summary
Documentation
docs/start/config.json, docs/start/framework/react/guide/selective-hydration.md
Added guide entry and comprehensive documentation for page-level selective hydration in React, covering hydrate options, inheritance, conflicts, and use cases.
Core Hydration Logic
packages/router-core/src/router.ts, packages/router-core/src/route.ts, packages/router-core/src/Matches.ts
Added HydrateOption type, defaultHydrate router option, and hydrate field to RouteMatch and route configuration interfaces.
Hydration Status Utilities
packages/react-router/src/hydrate-status.ts, packages/react-router/src/HeadContent.tsx, packages/react-router/src/Scripts.tsx
New getHydrateStatus() function determines whether routes should hydrate; updated script and preload link filtering to skip client-entry imports and modulepreload when hydration is disabled.
Server-Side Manifest
packages/start-server-core/src/router-manifest.ts
Refactored script assembly to inject head scripts separately in dev mode and mark client-entry scripts with a data attribute for downstream filtering.
Example Configuration
examples/react/start-basic/src/routes/__root.tsx
Added hydrate: false option to root route configuration.
E2E Test Project Scaffolding
e2e/react-start/basic-hydrate-false/ (configuration & setup)
.gitignore, .prettierignore, README.md, package.json, playwright.config.ts, postcss.config.mjs, tsconfig.json, vite.config.ts, public/*, server.js
New e2e test project with complete project configuration, build scripts for SPA/SSR/prerender modes, static asset serving, and test infrastructure.
E2E Test Project Source
e2e/react-start/basic-hydrate-false/src/
New React application with client/server entry points, reusable components (CustomMessage, DefaultCatchBoundary, NotFound, RedirectOnClick), router configuration, and comprehensive route tree covering posts, users, layouts, redirects, search params, scripts, cookies, streaming, and unicode routes.
E2E Tests
e2e/react-start/basic-hydrate-false/tests/ (specs)
Comprehensive Playwright test suites covering hydration behavior, navigation, not-found handling, redirects, params, prerendering, script duplication, search params, streaming, and cookie preservation.
E2E Test Utilities & Setup
e2e/react-start/basic-hydrate-false/tests/setup/*, tests/utils/*
Global setup/teardown hooks for e2e dummy server and mode-detection utilities (isPrerender, isPreview, isSpaMode).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Areas requiring close attention:

  • packages/react-router/src/Scripts.tsx: Complex hydration-aware script filtering logic with multiple conditional paths; verify client-entry filtering, React Refresh HMR preservation, and conflict warning logic.
  • packages/react-router/src/hydrate-status.ts: Core logic for determining hydration status and detecting conflicts across route matches; ensure correct precedence and edge cases.
  • E2E test project coverage: ~70 route files follow similar patterns; sampling a few key routes (posts, users, redirects, search-params) is sufficient to validate structure and conventions used throughout.

Possibly related PRs

  • PR #5215: Modifies client hydration/boot logic in start-client-core, overlapping with this PR's hydration-aware changes.
  • PR #5895: Modifies head/script rendering in react-router and server-side script buffering, sharing code-level overlap with Scripts.tsx and HeadContent.tsx updates.
  • PR #5095: Addresses script deduplication via hydration-aware filtering in router script handling, complementary to this PR's selective hydration logic.

Suggested labels

documentation, package: react-router, package: router-core, package: start-server-core, feature: selective-hydration

Suggested reviewers

  • schiller-manuel

Poem

🐰 Hydrate this, don't hydrate that—
Pick and choose where interactivity sat!
Static pages dance in the sun so bright,
While JavaScript rests, the server sleeps tight.
Selective power, oh what a sight! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and accurately describes the main feature introduced: page-level selective hydration controlled by the hydrate route option.
✨ 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

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit 9c4b380

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 15m 6s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 45s View ↗

☁️ Nx Cloud last updated this comment at 2025-12-13 07:31:49 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/directive-functions-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/directive-functions-plugin@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/server-functions-plugin

npm i https://pkg.pr.new/TanStack/router/@tanstack/server-functions-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-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: 9c4b380

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 +120 to +131
### Dynamic Hydration
You can use a function to determine hydration dynamically:

```typescript
export const Route = createFileRoute('/dynamic')({
hydrate: ({ search, params }) => {
// Conditionally hydrate based on query params or other factors
return search.interactive === 'true'
},
component: MyComponent,
})
```
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:

#!/bin/bash
# Search for HydrateOption type definition to verify if function syntax is supported
ast-grep --pattern 'type HydrateOption = $_'

Repository: TanStack/router

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Search for hydrate property type in route options
rg -n "hydrate\?:" --type=ts -C 3 | head -80

Repository: TanStack/router

Length of output: 2816


🏁 Script executed:

#!/bin/bash
# Search for hydrate option handling in route configuration
rg -n "hydrate" --type=ts --type=tsx -A 2 -B 2 | grep -E "(hydrate|boolean|function|=>)" | head -100

Repository: TanStack/router

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Find the createFileRoute implementation to see hydrate parameter type
fd -e ts -e tsx | xargs rg -l "createFileRoute" | head -5 | xargs cat

Repository: TanStack/router

Length of output: 3372


Remove the dynamic hydration function example—it is not supported.

The HydrateOption type is explicitly defined as boolean (packages/router-core/src/router.ts:145), and the hydrate property is typed as hydrate?: boolean in route options (packages/router-core/src/route.ts:962). The example at lines 125-128 showing function-based dynamic hydration (hydrate: ({ search, params }) => ...) is not supported and will confuse users. Either remove this example or mark it as a planned feature.

🤖 Prompt for AI Agents
In e2e/react-start/basic-hydrate-false/README.md around lines 120 to 131, the
README shows a function-based dynamic hydration example (hydrate: ({ search,
params }) => ...) which is not supported because HydrateOption is a boolean;
remove the function example and replace it with a supported boolean example
(e.g., hydrate: true or hydrate: false), or explicitly mark the dynamic-function
snippet as a planned/unsupported feature with a short note linking to an issue
if desired.

Comment on lines +133 to +134
### Conflict Warning
If conflicting `hydrate` settings exist in the route tree (some true, some false), the page will hydrate and log a warning to help you debug.
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

Inconsistent conflict behavior description.

This states "the page will hydrate" on conflict, but the main documentation at docs/start/framework/react/guide/selective-hydration.md (lines 310-311) states "Does not hydrate the page (safer default)". Please align the documentation.

🤖 Prompt for AI Agents
In e2e/react-start/basic-hydrate-false/README.md around lines 133-134, the
conflict warning currently says "the page will hydrate" which contradicts the
main docs; change the sentence to state that on conflicting `hydrate` settings
the page will not hydrate (safer default) and will emit a warning to help
debugging, matching the wording/intent in
docs/start/framework/react/guide/selective-hydration.md.

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 +1 to +30
export function getHydrateStatus(
matches: Array<any>,
router: any,
): {
shouldHydrate: boolean
hasConflict: boolean
} {
let hasExplicitFalse = false
let hasExplicitTrue = false
const defaultHydrate = router.options.defaultHydrate ?? true

matches.forEach((match) => {
const route = router.looseRoutesById[match.routeId]
const hydrateOption = route?.options.hydrate ?? defaultHydrate

if (hydrateOption === false) {
hasExplicitFalse = true
} else if (hydrateOption === true && route?.options.hydrate !== undefined) {
// Only count as explicit true if it was actually set on the route
hasExplicitTrue = true
}
})

const hasConflict = hasExplicitFalse && hasExplicitTrue

// If any route has false, don't hydrate (even if there's a conflict)
const shouldHydrate = !hasExplicitFalse

return { shouldHydrate, hasConflict }
}
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 | 🔴 Critical

Fix hydrate resolution: defaultHydrate must not count as “explicit false”, and explicit hydrate: true must override a default false.
Current logic breaks the intended semantics when router.options.defaultHydrate === false and a route sets hydrate: true.

 export function getHydrateStatus(
-  matches: Array<any>,
-  router: any,
+  matches: Array<{ routeId: string }>,
+  router: {
+    options: { defaultHydrate?: boolean }
+    looseRoutesById: Record<string, { options?: { hydrate?: boolean } } | undefined>
+  },
 ): {
   shouldHydrate: boolean
   hasConflict: boolean
 } {
-  let hasExplicitFalse = false
-  let hasExplicitTrue = false
-  const defaultHydrate = router.options.defaultHydrate ?? true
+  let hasExplicitFalse = false
+  let hasExplicitTrue = false
+  const defaultHydrate = router.options.defaultHydrate ?? true

   matches.forEach((match) => {
     const route = router.looseRoutesById[match.routeId]
-    const hydrateOption = route?.options.hydrate ?? defaultHydrate
-
-    if (hydrateOption === false) {
+    const explicit = route?.options?.hydrate
+
+    if (explicit === false) {
       hasExplicitFalse = true
-    } else if (hydrateOption === true && route?.options.hydrate !== undefined) {
-      // Only count as explicit true if it was actually set on the route
+    } else if (explicit === true) {
       hasExplicitTrue = true
     }
   })

   const hasConflict = hasExplicitFalse && hasExplicitTrue
-
-  // If any route has false, don't hydrate (even if there's a conflict)
-  const shouldHydrate = !hasExplicitFalse
+  // Resolution:
+  // - any explicit false => don't hydrate
+  // - else any explicit true => hydrate
+  // - else => defaultHydrate
+  const shouldHydrate = hasExplicitFalse
+    ? false
+    : hasExplicitTrue
+      ? true
+      : defaultHydrate

   return { shouldHydrate, hasConflict }
 }

Also: please replace the structural types above with the proper router-core types (to keep TS strict end-to-end).

🤖 Prompt for AI Agents
In packages/react-router/src/hydrate-status.ts lines 1-30: the current logic
treats the resolved hydrate value (which uses defaultHydrate) as an explicit
false and therefore a route relying on the router default can incorrectly count
as "explicit false"; also a route with hydrate: true should override a
defaultHydrate false. Change the loop to distinguish between an explicit option
and a default: read route?.options.hydrate into a local variable, if
route.options.hydrate === true set hasExplicitTrue, if route.options.hydrate ===
false set hasExplicitFalse, otherwise (undefined) use defaultHydrate only for
resolution but do not mark either explicit flag. After the loop compute
hasConflict = hasExplicitTrue && hasExplicitFalse and compute shouldHydrate by
precedence: if hasExplicitTrue return true, else if hasExplicitFalse return
false, else return defaultHydrate. Also replace Array<any> and router:any with
the appropriate types from router-core (import Router and Match/Route types) to
keep TypeScript strict.

Comment on lines +130 to +138
// Warn about conflicting hydrate options
if (hydrateStatus.hasConflict) {
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 be hydrated, but this may not be the intended behavior.\n' +
'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 | 🟠 Major

Fix conflict warning: message is incorrect and should not fire on every render (SSR/StrictMode noise).
Currently it says “The page will be hydrated” even though hydrate:false disables hydration. Also, render-time warnings can spam logs.

-  if (hydrateStatus.hasConflict) {
-    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 be hydrated, but this may not be the intended behavior.\n' +
-        'Please ensure all routes in the match have consistent hydrate settings.',
-    )
-  }
+  if (hydrateStatus.hasConflict && process.env.NODE_ENV !== 'production') {
+    console.warn(
+      '[TanStack Router] Conflicting hydrate options detected in route matches.\n' +
+        'Some routes have hydrate: false while others have hydrate: true.\n' +
+        'hydrate: false wins for the page, so the page will NOT hydrate.\n' +
+        'Please ensure all routes in the match have consistent hydrate settings.',
+    )
+  }

(If you want to avoid SSR + StrictMode double-logging, move this into a useEffect guarded on hydrateStatus.hasConflict.)

📝 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
// Warn about conflicting hydrate options
if (hydrateStatus.hasConflict) {
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 be hydrated, but this may not be the intended behavior.\n' +
'Please ensure all routes in the match have consistent hydrate settings.',
)
}
// Warn about conflicting hydrate options
if (hydrateStatus.hasConflict && process.env.NODE_ENV !== 'production') {
console.warn(
'[TanStack Router] Conflicting hydrate options detected in route matches.\n' +
'Some routes have hydrate: false while others have hydrate: true.\n' +
'hydrate: false wins for the page, so the page will NOT hydrate.\n' +
'Please ensure all routes in the match have consistent hydrate settings.',
)
}
🤖 Prompt for AI Agents
In packages/react-router/src/Scripts.tsx around lines 130 to 138, the conflict
warning message is incorrect ("The page will be hydrated" is false when
hydrate:false is present) and it currently logs during render causing SSR/React
StrictMode duplicate spam; change this to run inside a useEffect that only
triggers when hydrateStatus.hasConflict is true (e.g., useEffect(() => { if
(!hydrateStatus.hasConflict) return; console.warn(...) },
[hydrateStatus.hasConflict])), and update the text to accurately reflect
ambiguity (e.g., warn that mixed hydrate settings may lead to some routes not
being hydrated or unintended behavior) so the message is correct and the warning
only fires client-side once.

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"

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