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
5 changes: 5 additions & 0 deletions .changeset/spotty-poems-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-router': patch
---

Fix repeated `innerHTML` writes for unchanged styles and data scripts during React re-renders. This prevents unnecessary CSS parsing and Trusted Types errors during client navigation.
3 changes: 3 additions & 0 deletions e2e/react-start/css-inline/src/routes/app/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { NestedPanel } from '~/components/NestedPanel'
import styles from '~/styles/dashboard-index.module.css'

export const Route = createFileRoute('/app/dashboard/')({
head: () => ({
meta: [{ title: 'Inline CSS dashboard' }],
}),
component: DashboardIndex,
})

Expand Down
83 changes: 82 additions & 1 deletion e2e/react-start/css-inline/tests/css-inline.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { test } from '@tanstack/router-e2e-utils'
import { collectBrowserErrors, test } from '@tanstack/router-e2e-utils'

const buildUrl = (baseURL: string, pathname: string) =>
baseURL.replace(/\/$/, '') + pathname
Expand Down Expand Up @@ -151,6 +151,23 @@ test('client navigation preserves the SSR inline shell stylesheet', async ({
await page.goto(buildUrl(baseURL!, '/'))
await waitForHydration(page)

const inlineStyle = page.locator('style[data-tsr-inline-css]')
await expect(inlineStyle).toHaveCount(1)
const original = await inlineStyle.evaluateHandle(
(style: HTMLStyleElement) => {
const mutations: Array<MutationRecord> = []
const observer = new MutationObserver((records) => {
mutations.push(...records)
})
observer.observe(style, {
childList: true,
characterData: true,
subtree: true,
})
return { style, sheet: style.sheet, observer, mutations }
},
)

await expect.poll(() => getInlineCssTexts(page)).toHaveLength(1)
await expect
.poll(() => getInlineCssTexts(page))
Expand All @@ -161,6 +178,28 @@ test('client navigation preserves the SSR inline shell stylesheet', async ({

await page.getByTestId('nav-dashboard').click()
await page.waitForURL('**/app/dashboard')
await expect(page).toHaveTitle('Inline CSS dashboard')
await expect(page.getByTestId('dashboard-card')).toBeVisible()

await page.getByTestId('nav-home').click()
await expect(page).toHaveTitle('Inline CSS E2E')
await expect(page.getByTestId('home')).toBeVisible()

// #8250: unchanged CSS text can hide a redundant innerHTML write that
// replaces the stylesheet or fails under Trusted Types. Observe native DOM
// mutations and CSSOM identity without patching the innerHTML setter.
expect(
await original.evaluate(({ style, sheet, observer, mutations }) => {
const mutationCount = mutations.length + observer.takeRecords().length
observer.disconnect()
return {
connected: style.isConnected,
sameSheet: style.sheet === sheet,
mutationCount,
}
}),
).toEqual({ connected: true, sameSheet: true, mutationCount: 0 })
await original.dispose()

await expect.poll(() => getInlineCssTexts(page)).toHaveLength(1)
await expect
Expand All @@ -170,3 +209,45 @@ test('client navigation preserves the SSR inline shell stylesheet', async ({
.poll(() => getStyle(page, 'shell', 'background-color'))
.toBe('rgb(240, 249, 255)')
})

test('client navigation preserves inline CSS with Trusted Types enforced', async ({
page,
}) => {
const browserErrors = collectBrowserErrors(page)
const csp = "require-trusted-types-for 'script'; trusted-types 'none'"
await page.route('/', async (route) => {
const response = await route.fetch()
await route.fulfill({
response,
headers: { ...response.headers(), 'content-security-policy': csp },
})
})

const response = await page.goto('/')
expect(response?.headers()['content-security-policy']).toBe(csp)
expect(await page.evaluate(() => 'trustedTypes' in window)).toBe(true)
await waitForHydration(page)

const violations = await page.evaluateHandle(() => {
const directives: Array<string> = []
document.addEventListener('securitypolicyviolation', (event) => {
directives.push(event.effectiveDirective)
})
return directives
})

await page.getByTestId('nav-dashboard').click()
await expect(page).toHaveTitle('Inline CSS dashboard')
await expect(page.getByTestId('dashboard-card')).toBeVisible()
await expect
.poll(() => getStyle(page, 'shell', 'background-color'))
.toBe('rgb(240, 249, 255)')

await page.getByTestId('nav-home').click()
await expect(page).toHaveTitle('Inline CSS E2E')
await expect(page.getByTestId('home')).toBeVisible()

expect(await violations.jsonValue()).toEqual([])
expect(browserErrors).toEqual([])
await violations.dispose()
})
48 changes: 26 additions & 22 deletions packages/react-router/src/Asset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export function Asset(
},
): React.ReactElement | null {
const { attrs, children, nonce, preventScriptHoist } = asset
// React 19 compares this object by reference before assigning innerHTML.
const innerHTML = React.useMemo(
() => (children === undefined ? undefined : { __html: children }),
[children],
)

switch (asset.tag) {
case 'title':
Expand Down Expand Up @@ -78,11 +83,7 @@ export function Asset(
}

return (
<style
{...attrs}
dangerouslySetInnerHTML={{ __html: children as string }}
nonce={nonce}
/>
<style {...attrs} dangerouslySetInnerHTML={innerHTML} nonce={nonce} />
)
case 'script':
return (
Expand Down Expand Up @@ -119,12 +120,13 @@ function InlineCssStyle({
const html = isInlineCssPlaceholder
? (hydratedInlineCss ?? '')
: (children ?? '')
const innerHTML = React.useMemo(() => ({ __html: html }), [html])

return (
<style
{...attrs}
{...{ [INLINE_CSS_HYDRATION_ATTR]: '' }}
dangerouslySetInnerHTML={{ __html: html }}
dangerouslySetInnerHTML={innerHTML}
nonce={nonce}
suppressHydrationWarning
/>
Expand All @@ -142,6 +144,10 @@ function Script({
}) {
const router = useRouter()
const hydrated = useHydrated()
const innerHTML = React.useMemo(
() => (children === undefined ? undefined : { __html: children }),
[children],
)
const dataScript =
typeof attrs?.type === 'string' &&
attrs.type !== '' &&
Expand All @@ -160,19 +166,17 @@ function Script({
}

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

if (attrs?.src) {
const normSrc = (() => {
try {
const base = document.baseURI || window.location.href
return new URL(attrs.src, base).href
} catch {
return attrs.src
}
})()
for (const el of document.querySelectorAll('script[src]')) {
if ((el as HTMLScriptElement).src === normSrc) {
// Anchors resolve relative URLs and preserve invalid URLs without throwing.
const link = document.createElement('a')
link.href = attrs.src
const normSrc = link.href
for (const el of document.scripts) {
if (el.src === normSrc) {
return
}
}
Expand All @@ -191,8 +195,8 @@ function Script({
typeof attrs?.type === 'string' ? attrs.type : 'text/javascript'
const nonceAttr =
typeof attrs?.nonce === 'string' ? attrs.nonce : undefined
for (const el of document.querySelectorAll('script:not([src])')) {
if (!(el instanceof HTMLScriptElement)) {
for (const el of document.scripts) {
if (el.hasAttribute('src')) {
continue
}

Expand Down Expand Up @@ -242,7 +246,7 @@ function Script({
return (
<script
{...attrs}
dangerouslySetInnerHTML={{ __html: children }}
dangerouslySetInnerHTML={innerHTML}
suppressHydrationWarning
/>
)
Expand All @@ -260,7 +264,7 @@ function Script({
<script
{...attrs}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: children }}
dangerouslySetInnerHTML={innerHTML}
/>
)
}
Expand All @@ -277,7 +281,7 @@ function Script({
return (
<script
{...attrs}
dangerouslySetInnerHTML={{ __html: children }}
dangerouslySetInnerHTML={innerHTML}
suppressHydrationWarning
/>
)
Expand Down
Loading
Loading