Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions e2e/react-start/basic/public/async-user-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
console.log('ASYNC_USER_SCRIPT loaded')
window.ASYNC_USER_SCRIPT = true
2 changes: 2 additions & 0 deletions e2e/react-start/basic/public/before-scripts-async-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
console.log('BEFORE_SCRIPTS_ASYNC_SCRIPT loaded')
window.BEFORE_SCRIPTS_ASYNC_SCRIPT = true
2 changes: 2 additions & 0 deletions e2e/react-start/basic/public/before-scripts-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
console.log('BEFORE_SCRIPTS_SCRIPT loaded')
window.BEFORE_SCRIPTS_SCRIPT = true
2 changes: 2 additions & 0 deletions e2e/react-start/basic/public/head-async-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
console.log('HEAD_ASYNC_SCRIPT loaded')
window.HEAD_ASYNC_SCRIPT = true
2 changes: 2 additions & 0 deletions e2e/react-start/basic/public/head-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
console.log('HEAD_SCRIPT loaded')
window.HEAD_SCRIPT = true
2 changes: 2 additions & 0 deletions e2e/react-start/basic/public/user-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
console.log('USER_SCRIPT loaded')
window.USER_SCRIPT = true
6 changes: 6 additions & 0 deletions e2e/react-start/basic/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<html>
<head>
<HeadContent />
<script src="/head-script.js" />
<script src="/head-async-script.js" async={true} />
</head>
<body>
<div className="p-2 flex gap-2 text-lg">
Expand Down Expand Up @@ -205,7 +207,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<React.Suspense fallback={null}>
<RouterDevtools position="bottom-right" />
</React.Suspense>
<script src="/before-scripts-script.js" />
<script src="/before-scripts-async-script.js" async={true} />
<Scripts />
<script src="/user-script.js" />
<script src="/async-user-script.js" async={true} />
</body>
</html>
)
Expand Down
61 changes: 61 additions & 0 deletions e2e/react-start/basic/tests/root-scripts.spec.ts
Original file line number Diff line number Diff line change
@@ -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', // <script src="user-script.js" /> after <Scripts />
'ASYNC_USER_SCRIPT', // <script src="async-user-script.js" async /> after <Scripts />
'HEAD_SCRIPT', // <script src="head-script.js" /> in <head>
'HEAD_ASYNC_SCRIPT', // <script src="head-async-script.js" async /> in <head>
'BEFORE_SCRIPTS_SCRIPT', // <script src="before-scripts-script.js" /> before <Scripts />
'BEFORE_SCRIPTS_ASYNC_SCRIPT', // <script src="before-scripts-async-script.js" async /> before <Scripts />
] as const

test.describe('User-rendered scripts in __root.tsx', () => {
test('should not cause hydration errors on SSR load', async ({ page }) => {
const consoleErrors: Array<string> = []
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 },
)
}
})
})
58 changes: 29 additions & 29 deletions e2e/react-start/basic/tests/script-duplication.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> = []
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 }) => {
Expand All @@ -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 <script src> as a
// resource during SSR hydration, so the DOM element may not persist in
// a queryable form. Execution is the reliable check.
await page.waitForFunction(() => (window as any).SCRIPT_1 === true)

// The script element should exist either as a React 19 hoisted resource
// or as an imperatively-created element from useEffect.
const scriptCount = await page.evaluate(() => {
return document.querySelectorAll('script[src="script.js"]').length
return document.querySelectorAll('script[src$="/script.js"]').length
})

expect(scriptCount).toBe(1)
expect(scriptCount).toBeGreaterThanOrEqual(1)

expect(await page.evaluate('window.SCRIPT_1')).toBe(true)
})
Expand Down Expand Up @@ -118,26 +105,39 @@ test.describe('Script Duplication Prevention', () => {
page.getByTestId('inline-scripts-test-heading'),
).toBeInViewport()

const script1Count = await page.evaluate(() => {
// Wait for scripts to execute — useEffect may need a tick after hydration.
// React 19 may hoist inline scripts as resources, so the DOM element may
// not persist; script execution is the reliable check for SSR routes.
await page.waitForFunction(() => (window as any).INLINE_SCRIPT_1 === true)
await page.waitForFunction(() => (window as any).INLINE_SCRIPT_2 === 'test')

// React 19 can hoist/dedupe <script> tags during hydration. Between that
// and TanStack Router's client-side imperative injection (which may
// intentionally skip injection if a matching script already exists), the
// resulting script node might not be consistently queryable in a single
// fixed place.
//
// What we *can* assert reliably for SSR routes is "not duplicated" (<= 1),
// not "exactly one".
const inlineScript1Count = await page.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script:not([src])'))
return scripts.filter(
(script) =>
script.textContent &&
script.textContent.includes('window.INLINE_SCRIPT_1 = true'),
).length
})
expect(inlineScript1Count).toBeLessThanOrEqual(1)

const script2Count = await page.evaluate(() => {
const inlineScript2Count = await page.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script:not([src])'))
return scripts.filter(
(script) =>
script.textContent &&
script.textContent.includes('window.INLINE_SCRIPT_2 = "test"'),
).length
})

expect(script1Count).toBe(1)
expect(script2Count).toBe(1)
expect(inlineScript2Count).toBeLessThanOrEqual(1)

expect(await page.evaluate('window.INLINE_SCRIPT_1')).toBe(true)
expect(await page.evaluate('window.INLINE_SCRIPT_2')).toBe('test')
Expand Down
62 changes: 45 additions & 17 deletions packages/react-router/src/Asset.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react'
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
import { useHydrated } from './ClientOnly'
import type { RouterManagedTag } from '@tanstack/router-core'

interface ScriptAttrs {
Expand Down Expand Up @@ -49,12 +50,24 @@ function Script({
children?: string
}) {
const router = useRouter()
const hydrated = useHydrated()
const dataScript =
typeof attrs?.type === 'string' &&
attrs.type !== '' &&
attrs.type !== 'text/javascript' &&
attrs.type !== 'module'

if (
process.env.NODE_ENV !== 'production' &&
attrs?.src &&
typeof children === 'string' &&
children.trim().length
) {
console.warn(
'[TanStack Router] <Script> received both `src` and `children`. The `children` content will be ignored. Remove `children` or remove `src`.',
)
}

React.useEffect(() => {
if (dataScript) return

Expand Down Expand Up @@ -151,41 +164,56 @@ function Script({
return undefined
}, [attrs, children, dataScript])

if (!(isServer ?? router.isServer)) {
if (dataScript && typeof children === 'string') {
// --- Server rendering ---
if (isServer ?? router.isServer) {
if (attrs?.src) {
return <script {...attrs} suppressHydrationWarning />
}

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

const { src: _src, async: _async, defer: _defer, ...rest } = attrs || {}
// render an empty script on the client just to avoid hydration errors
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: '' }}
{...rest}
></script>
)
return null
}

if (attrs?.src && typeof attrs.src === 'string') {
return <script {...attrs} suppressHydrationWarning />
}
// --- 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 (
<script
{...attrs}
dangerouslySetInnerHTML={{ __html: children }}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: children }}
/>
)
}

// 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 <script {...attrs} suppressHydrationWarning />
}
if (typeof children === 'string') {
return (
<script
{...attrs}
dangerouslySetInnerHTML={{ __html: children }}
suppressHydrationWarning
/>
)
}
}

return null
}
Loading
Loading