diff --git a/e2e/react-start/basic/public/async-user-script.js b/e2e/react-start/basic/public/async-user-script.js
new file mode 100644
index 00000000000..34f4c034c00
--- /dev/null
+++ b/e2e/react-start/basic/public/async-user-script.js
@@ -0,0 +1,2 @@
+console.log('ASYNC_USER_SCRIPT loaded')
+window.ASYNC_USER_SCRIPT = true
diff --git a/e2e/react-start/basic/public/before-scripts-async-script.js b/e2e/react-start/basic/public/before-scripts-async-script.js
new file mode 100644
index 00000000000..7d082fea1c2
--- /dev/null
+++ b/e2e/react-start/basic/public/before-scripts-async-script.js
@@ -0,0 +1,2 @@
+console.log('BEFORE_SCRIPTS_ASYNC_SCRIPT loaded')
+window.BEFORE_SCRIPTS_ASYNC_SCRIPT = true
diff --git a/e2e/react-start/basic/public/before-scripts-script.js b/e2e/react-start/basic/public/before-scripts-script.js
new file mode 100644
index 00000000000..7150d19d40d
--- /dev/null
+++ b/e2e/react-start/basic/public/before-scripts-script.js
@@ -0,0 +1,2 @@
+console.log('BEFORE_SCRIPTS_SCRIPT loaded')
+window.BEFORE_SCRIPTS_SCRIPT = true
diff --git a/e2e/react-start/basic/public/head-async-script.js b/e2e/react-start/basic/public/head-async-script.js
new file mode 100644
index 00000000000..3da8a928cde
--- /dev/null
+++ b/e2e/react-start/basic/public/head-async-script.js
@@ -0,0 +1,2 @@
+console.log('HEAD_ASYNC_SCRIPT loaded')
+window.HEAD_ASYNC_SCRIPT = true
diff --git a/e2e/react-start/basic/public/head-script.js b/e2e/react-start/basic/public/head-script.js
new file mode 100644
index 00000000000..3a64b23438b
--- /dev/null
+++ b/e2e/react-start/basic/public/head-script.js
@@ -0,0 +1,2 @@
+console.log('HEAD_SCRIPT loaded')
+window.HEAD_SCRIPT = true
diff --git a/e2e/react-start/basic/public/user-script.js b/e2e/react-start/basic/public/user-script.js
new file mode 100644
index 00000000000..8d06a610c02
--- /dev/null
+++ b/e2e/react-start/basic/public/user-script.js
@@ -0,0 +1,2 @@
+console.log('USER_SCRIPT loaded')
+window.USER_SCRIPT = true
diff --git a/e2e/react-start/basic/src/routes/__root.tsx b/e2e/react-start/basic/src/routes/__root.tsx
index 581510f616a..3aa315c67e8 100644
--- a/e2e/react-start/basic/src/routes/__root.tsx
+++ b/e2e/react-start/basic/src/routes/__root.tsx
@@ -97,6 +97,8 @@ function RootDocument({ children }: { children: React.ReactNode }) {
+
+
@@ -205,7 +207,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
+
+
+
+
)
diff --git a/e2e/react-start/basic/tests/root-scripts.spec.ts b/e2e/react-start/basic/tests/root-scripts.spec.ts
new file mode 100644
index 00000000000..80b6d83e40d
--- /dev/null
+++ b/e2e/react-start/basic/tests/root-scripts.spec.ts
@@ -0,0 +1,61 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+
+// All user-rendered scripts added in __root.tsx (not via head() API)
+const ALL_USER_SCRIPTS = [
+ 'USER_SCRIPT', // after
+ 'ASYNC_USER_SCRIPT', // after
+ 'HEAD_SCRIPT', // in
+ 'HEAD_ASYNC_SCRIPT', // in
+ 'BEFORE_SCRIPTS_SCRIPT', // before
+ 'BEFORE_SCRIPTS_ASYNC_SCRIPT', // before
+] as const
+
+test.describe('User-rendered scripts in __root.tsx', () => {
+ test('should not cause hydration errors on SSR load', async ({ page }) => {
+ const consoleErrors: Array = []
+ page.on('console', (m) => {
+ if (m.type() === 'error') {
+ consoleErrors.push(m.text())
+ }
+ })
+
+ await page.goto('/')
+
+ // All user-rendered scripts should have executed
+ for (const scriptVar of ALL_USER_SCRIPTS) {
+ await page.waitForFunction(
+ (v) => (window as any)[v] === true,
+ scriptVar,
+ { timeout: 5000 },
+ )
+ }
+
+ // Assert no hydration errors occurred
+ const hydrationErrors = consoleErrors.filter(
+ (e) =>
+ e.includes('Hydration') ||
+ e.includes('hydration') ||
+ e.includes("didn't match"),
+ )
+ expect(hydrationErrors).toEqual([])
+ })
+
+ test('should execute on client-side navigation to another route', async ({
+ page,
+ }) => {
+ await page.goto('/posts')
+
+ await page.getByRole('link', { name: 'Home' }).click()
+ await expect(page.getByText('Welcome Home!!!')).toBeInViewport()
+
+ // Root layout scripts should have executed during the initial page load
+ for (const scriptVar of ALL_USER_SCRIPTS) {
+ await page.waitForFunction(
+ (v) => (window as any)[v] === true,
+ scriptVar,
+ { timeout: 5000 },
+ )
+ }
+ })
+})
diff --git a/e2e/react-start/basic/tests/script-duplication.spec.ts b/e2e/react-start/basic/tests/script-duplication.spec.ts
index 46e3bff00b3..b081437de87 100644
--- a/e2e/react-start/basic/tests/script-duplication.spec.ts
+++ b/e2e/react-start/basic/tests/script-duplication.spec.ts
@@ -1,35 +1,16 @@
-import { expect, test } from '@playwright/test'
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
test.describe('Async Script Hydration', () => {
test('should not show hydration warning for async scripts', async ({
page,
}) => {
- const warnings: Array = []
- page.on('console', (msg) => {
- if (
- msg.type() === 'warning' ||
- (msg.type() === 'error' &&
- msg.text().toLowerCase().includes('hydration'))
- ) {
- warnings.push(msg.text())
- }
- })
-
await page.goto('/async-scripts')
await expect(
page.getByTestId('async-scripts-test-heading'),
).toBeInViewport()
await page.waitForFunction(() => (window as any).SCRIPT_1 === true)
-
- // Filter for hydration-related warnings
- const hydrationWarnings = warnings.filter(
- (w) =>
- w.toLowerCase().includes('hydration') ||
- w.toLowerCase().includes('mismatch'),
- )
-
- expect(hydrationWarnings).toHaveLength(0)
})
test('should load async and defer scripts correctly', async ({ page }) => {
@@ -51,11 +32,17 @@ test.describe('Script Duplication Prevention', () => {
await expect(page.getByTestId('scripts-test-heading')).toBeInViewport()
+ // Wait for the script to execute — React 19 hoists
- )
+ return null
}
- if (attrs?.src && typeof attrs.src === 'string') {
- return
- }
+ // --- Client rendering ---
- if (typeof children === 'string') {
+ // Data scripts (e.g. application/ld+json) are rendered in the tree;
+ // the useEffect intentionally skips them.
+ if (dataScript && typeof children === 'string') {
return (
)
}
+ // During hydration (before useEffect has fired), render the script element
+ // to match the server-rendered HTML and avoid structural hydration mismatches.
+ // After hydration, return null — the useEffect handles imperative injection.
+ if (!hydrated) {
+ if (attrs?.src) {
+ return
+ }
+ if (typeof children === 'string') {
+ return (
+
+ )
+ }
+ }
+
return null
}
diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx
index e930b1dc1f8..a3f31a3a8af 100644
--- a/packages/react-router/tests/Scripts.test.tsx
+++ b/packages/react-router/tests/Scripts.test.tsx
@@ -175,7 +175,7 @@ describe('scripts with async/defer attributes', () => {
expect(html).toMatch(/