diff --git a/.changeset/spotty-poems-smell.md b/.changeset/spotty-poems-smell.md new file mode 100644 index 00000000000..0a7f24eee0a --- /dev/null +++ b/.changeset/spotty-poems-smell.md @@ -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. diff --git a/e2e/react-start/css-inline/src/routes/app/dashboard/index.tsx b/e2e/react-start/css-inline/src/routes/app/dashboard/index.tsx index a796656b3d8..35205cc9cf3 100644 --- a/e2e/react-start/css-inline/src/routes/app/dashboard/index.tsx +++ b/e2e/react-start/css-inline/src/routes/app/dashboard/index.tsx @@ -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, }) diff --git a/e2e/react-start/css-inline/tests/css-inline.spec.ts b/e2e/react-start/css-inline/tests/css-inline.spec.ts index 339fedd4cac..89d7d3992f4 100644 --- a/e2e/react-start/css-inline/tests/css-inline.spec.ts +++ b/e2e/react-start/css-inline/tests/css-inline.spec.ts @@ -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 @@ -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 = [] + 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)) @@ -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 @@ -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 = [] + 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() +}) diff --git a/packages/react-router/src/Asset.tsx b/packages/react-router/src/Asset.tsx index 5e4f159f526..04cbac90640 100644 --- a/packages/react-router/src/Asset.tsx +++ b/packages/react-router/src/Asset.tsx @@ -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': @@ -78,11 +83,7 @@ export function Asset( } return ( -