Skip to content

fix(react-router): preserve inline head scripts in React tree after hydration#7107

Open
mixelburg wants to merge 2 commits intoTanStack:mainfrom
mixelburg:fix/head-inline-script-lost-after-hydration
Open

fix(react-router): preserve inline head scripts in React tree after hydration#7107
mixelburg wants to merge 2 commits intoTanStack:mainfrom
mixelburg:fix/head-inline-script-lost-after-hydration

Conversation

@mixelburg
Copy link
Copy Markdown

@mixelburg mixelburg commented Apr 5, 2026

Fixes #7104

Problem

When a route's head() function returns scripts[] entries with children (inline script content) but no src, those script tags are rendered correctly on the server and during initial hydration, but disappear from the DOM after hydration completes.

Root cause in Asset.tsx — the Script component:

  1. During SSR: renders <script dangerouslySetInnerHTML={{ __html: children }} />
  2. Pre-hydration (!hydrated): renders the same element to match server HTML ✓
  3. After hydration: falls through to return nullReact unmounts the script from the DOM
  4. useEffect fires after React commits the removal → querySelectorAll('script:not([src])') no longer finds the script → creates a new one and appends it → script re-executes (e.g. gtag config double-fires)

Both solid-router and vue-router already keep inline scripts rendered after hydration — only react-router had this gap.

Fix

After the !hydrated block, add an early-return that keeps inline children scripts in the React tree:

if (!attrs?.src && typeof children === 'string') {
  return <script {...attrs} dangerouslySetInnerHTML={{ __html: children }} suppressHydrationWarning />
}

With this change:

  • React never unmounts the SSR-rendered script element
  • The useEffect still runs, but the existingScript check finds the element and returns early — so the script is not re-executed
  • Behavior is now consistent with solid-router and vue-router

Scripts with src are unaffected (still managed imperatively via useEffect).

Summary by CodeRabbit

  • Bug Fixes
    • Resolved an issue where inline scripts were being removed after page hydration, ensuring they remain active and functional throughout the page lifecycle.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 5, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 57883fc0-a35d-4f34-bf33-c7802439dc7c

📥 Commits

Reviewing files that changed from the base of the PR and between 5a81726 and 97fadb4.

📒 Files selected for processing (1)
  • packages/react-router/src/Asset.tsx

📝 Walkthrough

Walkthrough

The Script component in Asset.tsx now renders inline scripts (those with string children but no src attribute) directly during client-side rendering, persisting through hydration via dangerouslySetInnerHTML. This prevents React from unmounting SSR-rendered inline scripts after hydration, addressing loss of inline script content during the hydration phase.

Changes

Cohort / File(s) Summary
Inline Script Rendering
packages/react-router/src/Asset.tsx
Added client-side render branch for inline scripts without src. Returns <script> element with dangerouslySetInnerHTML post-hydration, preventing unmount of SSR-rendered inline script content while preserving existing useEffect detection logic.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 Inline scripts were taking flight,
Lost after hydration's bite,
But now they stay, secure and bright,
With dangerouslySetInnerHTML's might!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ 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%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: preserving inline head scripts in the React tree after hydration, which directly addresses the core issue and is supported by the code changes.
Linked Issues check ✅ Passed The pull request directly implements the fix for issue #7104 by adding a client-side render branch for inline scripts that prevents React from unmounting SSR-rendered inline scripts after hydration.
Out of Scope Changes check ✅ Passed All changes are within scope: the modification to Asset.tsx's Script component directly addresses the root cause identified in issue #7104 with no extraneous additions.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 5, 2026

Bundle Size Benchmarks

  • Commit: 5a81726f0a2f
  • Measured at: 2026-04-07T00:10:57.564Z
  • Baseline source: history:796406da66cf
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 87.48 KiB 0 B (0.00%) 275.76 KiB 75.97 KiB ████▁▁▁▁▁▂▃
react-router.full 90.79 KiB +10 B (+0.01%) 287.09 KiB 78.99 KiB ▆▆▆█▁▁▁▁▁▂▂▃
solid-router.minimal 35.56 KiB 0 B (0.00%) 107.26 KiB 31.94 KiB ████▁▁▁▁▁▂█
solid-router.full 40.03 KiB 0 B (0.00%) 120.79 KiB 35.94 KiB ████▁▁▁▁▁▂▆
vue-router.minimal 53.38 KiB 0 B (0.00%) 153.07 KiB 47.94 KiB ████▁▁▁▁▁▂▄
vue-router.full 58.25 KiB 0 B (0.00%) 168.53 KiB 52.18 KiB ████▁▁▁▁▁▂▄
react-start.minimal 102.02 KiB +13 B (+0.01%) 324.14 KiB 88.15 KiB ▅▅▅█▁▂▂▂▂▃▃▅
react-start.full 105.40 KiB +21 B (+0.02%) 334.49 KiB 91.09 KiB ▆▆▆█▁▁▁▂▂▃▃▆
solid-start.minimal 49.66 KiB 0 B (0.00%) 153.51 KiB 43.84 KiB ▇▇▇▇▁▁▁▂▂▂█
solid-start.full 55.17 KiB 0 B (0.00%) 169.74 KiB 48.46 KiB ▇▇▇▇▁▂▂▂▂▃█

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.

@schiller-manuel
Copy link
Copy Markdown
Contributor

please add a regression test to the e2e test projects (we already have some tests for the scripts)
please also make sure the other framework adapters (Vue, solid) are working as well

@nx-cloud
Copy link
Copy Markdown
Contributor

nx-cloud bot commented Apr 6, 2026

View your CI Pipeline Execution ↗ for commit 97fadb4

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

☁️ Nx Cloud last updated this comment at 2026-04-06 09:30:26 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 6, 2026

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@7107

@tanstack/eslint-plugin-router

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

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@7107

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@7107

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@7107

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@7107

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@7107

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@7107

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@7107

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@7107

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@7107

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@7107

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@7107

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@7107

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@7107

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@7107

@tanstack/vue-router

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

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@7107

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@7107

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@7107

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@7107

commit: 97fadb4

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Apr 6, 2026

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing mixelburg:fix/head-inline-script-lost-after-hydration (97fadb4) with main (796406d)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (5a81726) during the generation of this report, so 796406d was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Copy link
Copy Markdown
Contributor

@nx-cloud nx-cloud bot left a comment

Choose a reason for hiding this comment

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

Nx Cloud is proposing a fix for your failed CI:

We narrowed the inline-script React-tree-preservation guard to only activate for components that went through the SSR pre-hydration phase, preventing the regression on client-side navigation where useHydrated() always returns true for newly mounted components. Without this fix, client navigation caused React to render an inert dangerouslySetInnerHTML script that the useEffect dedup check mistook for an already-executed script, so the script was never actually run.

Tip

We verified this fix by re-running @tanstack/react-router:test:unit, tanstack-react-start-e2e-basic-spa:test:e2e.

Suggested Fix changes
diff --git a/packages/react-router/src/Asset.tsx b/packages/react-router/src/Asset.tsx
index f4016ae240..fa29d5dfc0 100644
--- a/packages/react-router/src/Asset.tsx
+++ b/packages/react-router/src/Asset.tsx
@@ -51,6 +51,11 @@ function Script({
 }) {
   const router = useRouter()
   const hydrated = useHydrated()
+  // Track whether this component instance went through the !hydrated (pre-hydration) phase.
+  // true  → component was SSR-rendered; the inline script was already executed by the browser.
+  // false → component was mounted fresh on the client (client-side navigation); useEffect must
+  //         inject an executable script because dangerouslySetInnerHTML never executes scripts.
+  const wentThroughNonHydratedPhase = React.useRef(!hydrated)
   const dataScript =
     typeof attrs?.type === 'string' &&
     attrs.type !== '' &&
@@ -215,11 +220,20 @@ function Script({
     }
   }
 
-  // For inline scripts (children, no src), keep the element in the React tree after
-  // hydration so React doesn't unmount the SSR-rendered script from the DOM.
+  // For inline scripts (children, no src) that went through SSR hydration, keep the element
+  // in the React tree so React doesn't unmount the SSR-rendered script from the DOM.
   // The useEffect above detects the existing element via textContent match and skips
   // re-injection, so the script won't execute a second time.
-  if (!attrs?.src && typeof children === 'string') {
+  //
+  // For client-side navigation (wentThroughNonHydratedPhase === false), skip this path and
+  // fall through to return null — the useEffect handles imperative injection in that case.
+  // (dangerouslySetInnerHTML does not execute scripts, so we must not render an inert element
+  // that would fool the existingScript dedup check into returning early without executing.)
+  if (
+    !attrs?.src &&
+    typeof children === 'string' &&
+    wentThroughNonHydratedPhase.current
+  ) {
     return (
       <script
         {...attrs}

Because this branch comes from a fork, it is not possible for us to apply fixes directly, but you can apply the changes locally using the available options below.

Apply changes locally with:

npx nx-cloud apply-locally Mdan-wqFq

Apply fix locally with your editor ↗   View interactive diff ↗



🎓 Learn more about Self-Healing CI on nx.dev

…dratedPhase ref

Narrows the dangerouslySetInnerHTML React-tree-preservation guard to only
activate for components that went through the SSR pre-hydration phase.

On client-side navigation useHydrated() immediately returns true, so
wentThroughNonHydratedPhase.current is false and we fall through to
return null — the useEffect handles imperative injection instead.

Without this fix, client navigation caused React to render an inert
dangerouslySetInnerHTML script that the useEffect dedup check mistook
for an already-executed script, so the script was never actually run.

Fixes the 3 failing tests in Scripts.test.tsx.
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.

The content/children script tags of the head (scripts[].children) are lost from the HTML after hydration

2 participants