Skip to content

Conversation

@schiller-manuel
Copy link
Contributor

@schiller-manuel schiller-manuel commented Jan 24, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Fixed hydration mismatches by returning stable, no-handler anchor props during server rendering and applying transition/navigation props only after hydration.
  • Improvements

    • Centralized server-aware URL handling and safer external-link resolution (blocks dangerous protocols).
    • More consistent active-link detection between server and client (search/hash handling).
    • Consolidated class/style merging and streamlined preload/intersection behavior.
  • Note

    • No public API signature changes.

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

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 24, 2026

📝 Walkthrough

Walkthrough

Adds an SSR early-return branch in useLinkProps that computes stable anchor props (href, external, isActive) without subscriptions or client handlers; client branch reuses server-derived decisions and gates transition, preload, and intersection behaviors behind hydration, with centralized href/external helpers and safer protocol checks.

Changes

Cohort / File(s) Summary
useLinkProps SSR & client gating
packages/react-router/src/link.tsx
Introduces a server-side early-return in useLinkProps computing getHrefOption, isSafeInternal, isActive, external and merged active/inactive props without subscriptions or event handlers; client branch reuses decisions and conditionally adds transition, preload, and intersection logic when hydrated; refactors URL/protocol checks and moves helpers to file bottom.

Sequence Diagram(s)

sequenceDiagram
  participant Server as Server (SSR)
  participant Router as useLinkProps
  participant History as RouterState/History
  participant Client as Client (Hydration)

  Server->>Router: call useLinkProps (SSR)
  Router->>History: read current location (no subscription)
  History-->>Router: return location
  Router->>Router: run getHrefOption, isSafeInternal, compute isActive/external
  Router-->Server: return static anchor props (no handlers)

  Client->>Router: call useLinkProps (client/hydration)
  Router->>History: subscribe to router state
  History-->>Router: updates (location, transitioning)
  Router->>Router: reuse SSR decisions, recompute client-only props
  alt isHydrated
    Router->>Client: include transition props, preload, intersection observer
  else not hydrated
    Router->>Client: return stable props without transition handlers
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • birkskyum

Poem

🐰 I stitched the links to rest on server-side ground,
Quiet anchors waiting till the client comes around.
Then with a hop they wake — preload, transition, play,
Hydration lights the path and sends the users on their way.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% 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 'refactor: react link ssr perf' accurately describes the main change: optimizing server-side rendering performance in the React Link component by moving logic behind server-side gating and reducing client-side computations.

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

✨ Finishing touches
  • 📝 Generate docstrings

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

@nx-cloud
Copy link

nx-cloud bot commented Jan 24, 2026

View your CI Pipeline Execution ↗ for commit fe11b45

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

☁️ Nx Cloud last updated this comment at 2026-01-24 12:23:52 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 24, 2026

More templates

@tanstack/arktype-adapter

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

@tanstack/eslint-plugin-router

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

@tanstack/history

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

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-fn-stubs

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

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/vue-router

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

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

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

@tanstack/vue-start-client

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

@tanstack/vue-start-server

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

@tanstack/zod-adapter

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

commit: fe11b45

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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-router/src/link.tsx (1)

496-499: Potential flash of inactive state when includeHash: true.

When activeOptions.includeHash is true, the check at line 497 returns false until hydration completes (isHydrated && ...). This means links with hash-based active state will briefly appear inactive during the hydration window, potentially causing a visual flash.

Consider returning true on server/pre-hydration for hash matches to maintain consistency, or document this as expected behavior.

🧹 Nitpick comments (3)
packages/react-router/src/link.tsx (3)

109-115: Minor inconsistency between server and client isSafeInternal checks.

The server version considers relative paths starting with . (e.g., ./, ../) as safe internal, while the client version (lines 435-438) only checks for absolute paths starting with /. Although this doesn't cause functional issues (relative paths throw in new URL() anyway), the divergence makes the code harder to maintain.

Consider aligning the logic for consistency:

♻️ Suggested alignment
     const isSafeInternal =
       typeof to === 'string' &&
-      (to.charCodeAt(0) === 47
-        ? // '/'
-          to.charCodeAt(1) !== 47 // but not '//'
-        : // '.', '..', './', '../'
-          to.charCodeAt(0) === 46)
+      to.charCodeAt(0) === 47 && // '/'
+      to.charCodeAt(1) !== 47    // but not '//'

195-207: Potentially redundant external URL check.

The code at lines 119-156 already handles absolute URLs with new URL(to) parsing and returns early. The check at lines 196-206 appears to duplicate this logic. If the earlier block returns for valid absolute URLs, this code path may be unreachable.

Consider consolidating or adding a comment explaining when this branch is reached (e.g., specific edge cases where the first check doesn't return).


248-270: Server search comparison is more defensive than client version.

The server-side search comparison (lines 250-269) includes explicit empty object checks before calling deepEqual, while the client version (lines 486-494) delegates directly to deepEqual. While functionally correct, this creates maintenance overhead as changes need to be synchronized.

Consider extracting a shared helper function for search comparison logic if this pattern is stable.

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 (can this) be applied to solid and vue too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think so yes, needs @birkskyum probably

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-router/src/link.tsx (1)

365-583: Biome's useHookAtTopLevel rule will flag hooks after the early return.

Biome's correctness/useHookAtTopLevel rule is enabled by default and will report an error for the hooks placed after the if (_isServer) early return, even though the pattern is safe due to compile-time dead code elimination. Add biome-ignore lint/correctness/useHookAtTopLevel suppressions to each hook call:

🔧 Biome suppression for hook sites
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const isHydrated = useHydrated()
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  // biome-ignore lint/correctness/useHookAtTopLevel
+  const isHydrated = useHydrated()

Alternatively, disable the rule in biome.json:

{
  "linter": {
    "rules": {
      "correctness": {
        "useHookAtTopLevel": "off"
      }
    }
  }
}
🤖 Fix all issues with AI agents
In `@packages/react-router/src/link.tsx`:
- Around line 245-271: The current active-check always returns true at the end,
ignoring includeHash on SSR; update the closure in Link (the active computation
that references activeOptions) to perform a hash comparison similar to the
search logic: when activeOptions?.includeHash (default true) is set, if running
on the server (typeof window === 'undefined') then treat a non-empty next.hash
as non-active (return false) to avoid marking links active when the client hash
may differ; on the client compare currentLocation.hash and next.hash (and use
deepEqual/strict equality as appropriate) to determine activeness, mirroring the
search comparison behavior and preserving exact/explicitUndefined semantics.

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

🤖 Fix all issues with AI agents
In `@packages/react-router/src/link.tsx`:
- Around line 376-381: Add Biome suppression comments alongside the existing
ESLint disables for the conditional hooks so Biome won't flag them;
specifically, for the useHydrated call that assigns isHydrated and the
useRouterState call that assigns currentSearch, keep the existing "//
eslint-disable-next-line react-hooks/rules-of-hooks" and add the corresponding
Biome ignore (e.g., a "// biome-ignore lint: react-hooks/rules-of-hooks" or the
project's Biome lint suppression syntax) immediately above each hook use so both
linters are suppressed while preserving the current comments and behavior.

Comment on lines +376 to 381
// eslint-disable-next-line react-hooks/rules-of-hooks
const isHydrated = useHydrated()

// subscribe to search params to re-build location if it changes
// eslint-disable-next-line react-hooks/rules-of-hooks
const currentSearch = useRouterState({
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

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the file at the specified lines with context
cat -n packages/react-router/src/link.tsx | sed -n '365,390p'

Repository: TanStack/router

Length of output: 1373


🏁 Script executed:

# Check if there's an early return before line 376
cat -n packages/react-router/src/link.tsx | sed -n '1,381p' | tail -100

Repository: TanStack/router

Length of output: 3910


🏁 Script executed:

# Check for package.json to see if eslint-plugin-react-hooks is installed
cat package.json | grep -A 30 "dependencies\|devDependencies"

Repository: TanStack/router

Length of output: 1095


🏁 Script executed:

# Look for eslint and biome configuration
find . -maxdepth 2 -type f \( -name ".eslintrc*" -o -name "eslint.config.*" -o -name "biome.json" -o -name "biome.jsonc" \) 2>/dev/null

Repository: TanStack/router

Length of output: 90


🏁 Script executed:

# Find the early return before line 376
cat -n packages/react-router/src/link.tsx | sed -n '350,375p'

Repository: TanStack/router

Length of output: 1424


🏁 Script executed:

# Check the eslint config to see if react-hooks is properly configured
cat eslint.config.js | head -100

Repository: TanStack/router

Length of output: 770


🏁 Script executed:

# Check biome.json configuration
cat biome.json

Repository: TanStack/router

Length of output: 1833


🏁 Script executed:

# Search for the isServer check and early return before line 363
cat -n packages/react-router/src/link.tsx | sed -n '200,365p' | grep -A 5 -B 5 "isServer"

Repository: TanStack/router

Length of output: 292


🏁 Script executed:

# Check the tanstack config to see what ESLint rules are extended
find . -path ./node_modules -prune -o -type f -name "eslint*" -print | head -20

Repository: TanStack/router

Length of output: 970


🏁 Script executed:

# Look for tanstack config in node_modules or elsewhere
find . -maxdepth 3 -type f -name "package.json" | xargs grep -l "@tanstack/config" 2>/dev/null | head -3

Repository: TanStack/router

Length of output: 73


🏁 Script executed:

# Look for the isServer check and early return statement
cat -n packages/react-router/src/link.tsx | grep -B 5 -A 10 "isServer" | head -40

Repository: TanStack/router

Length of output: 1432


🏁 Script executed:

# Check the tanstack config eslint package
cat package.json | grep -A 5 '"@tanstack/config"'

Repository: TanStack/router

Length of output: 268


🏁 Script executed:

# Look at the react-router eslint config
cat packages/react-router/eslint.config.ts

Repository: TanStack/router

Length of output: 786


Add Biome suppression for conditional hooks.

Biome flags hooks after the early return at line 108. The eslint-plugin-react-hooks rule is already properly installed and configured, so the ESLint disable comments are valid. Add a Biome-specific ignore to suppress the lint error while preserving the existing ESLint directive.

🔧 Add Biome suppression (apply to all affected hooks)
+  // biome-ignore lint/correctness/useHookAtTopLevel: server branch is tree-shaken
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const isHydrated = useHydrated()
📝 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
// eslint-disable-next-line react-hooks/rules-of-hooks
const isHydrated = useHydrated()
// subscribe to search params to re-build location if it changes
// eslint-disable-next-line react-hooks/rules-of-hooks
const currentSearch = useRouterState({
// biome-ignore lint/correctness/useHookAtTopLevel: server branch is tree-shaken
// eslint-disable-next-line react-hooks/rules-of-hooks
const isHydrated = useHydrated()
// subscribe to search params to re-build location if it changes
// biome-ignore lint/correctness/useHookAtTopLevel: server branch is tree-shaken
// eslint-disable-next-line react-hooks/rules-of-hooks
const currentSearch = useRouterState({
🧰 Tools
🪛 Biome (2.1.2)

[error] 377-377: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

Hooks should not be called after an early return.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 381-381: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

Hooks should not be called after an early return.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

🪛 ESLint

[error] 376-376: Definition for rule 'react-hooks/rules-of-hooks' was not found.

(react-hooks/rules-of-hooks)


[error] 380-380: Definition for rule 'react-hooks/rules-of-hooks' was not found.

(react-hooks/rules-of-hooks)

🤖 Prompt for AI Agents
In `@packages/react-router/src/link.tsx` around lines 376 - 381, Add Biome
suppression comments alongside the existing ESLint disables for the conditional
hooks so Biome won't flag them; specifically, for the useHydrated call that
assigns isHydrated and the useRouterState call that assigns currentSearch, keep
the existing "// eslint-disable-next-line react-hooks/rules-of-hooks" and add
the corresponding Biome ignore (e.g., a "// biome-ignore lint:
react-hooks/rules-of-hooks" or the project's Biome lint suppression syntax)
immediately above each hook use so both linters are suppressed while preserving
the current comments and behavior.

schiller-manuel and others added 8 commits January 24, 2026 13:09
[1] 52k requests in 30.01s, 652 MB read
[1]
[1] === SSR Benchmark Results ===
[1] Total requests: 48512
[1] Requests/sec: 1617.07
[1] Latency (avg): 3.85ms
[1] Latency (p99): 12ms
[1] Throughput: 20.72 MB/s
```
[1] 53k requests in 30.01s, 672 MB read
[1]
[1] === SSR Benchmark Results ===
[1] Total requests: 50006
[1] Requests/sec: 1666.87
[1] Latency (avg): 3.67ms
[1] Latency (p99): 11ms
[1] Throughput: 21.36 MB/s
```
@schiller-manuel schiller-manuel merged commit cc01174 into main Jan 24, 2026
6 checks passed
@schiller-manuel schiller-manuel deleted the react-link-perf branch January 24, 2026 12:37
@coderabbitai coderabbitai bot mentioned this pull request Jan 24, 2026
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.

3 participants