From 027676c3b7100469ef983e550c71856af07e3ac2 Mon Sep 17 00:00:00 2001 From: wookhyung Date: Sat, 6 Sep 2025 18:08:17 +0900 Subject: [PATCH 1/9] fix(router): prevent script tag duplication in SSR and client-side navigation --- e2e/react-start/basic/src/routeTree.gen.ts | 45 ++++++ e2e/react-start/basic/src/routes/__root.tsx | 8 + .../basic/src/routes/inline-scripts.tsx | 25 +++ .../basic/tests/navigation.spec.ts | 2 +- .../basic/tests/script-duplication.spec.ts | 150 ++++++++++++++++++ e2e/solid-start/basic/src/routeTree.gen.ts | 21 +++ e2e/solid-start/basic/src/routes/__root.tsx | 8 + .../basic/src/routes/inline-scripts.tsx | 27 ++++ .../basic/tests/navigation.spec.ts | 2 +- .../basic/tests/script-duplication.spec.ts | 150 ++++++++++++++++++ packages/react-router/src/Asset.tsx | 18 +++ packages/solid-router/src/Asset.tsx | 18 +++ 12 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 e2e/react-start/basic/src/routes/inline-scripts.tsx create mode 100644 e2e/react-start/basic/tests/script-duplication.spec.ts create mode 100644 e2e/solid-start/basic/src/routes/inline-scripts.tsx create mode 100644 e2e/solid-start/basic/tests/script-duplication.spec.ts diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index b6fc9282f67..1298d9b2113 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as StreamRouteImport } from './routes/stream' import { Route as ScriptsRouteImport } from './routes/scripts' import { Route as PostsRouteImport } from './routes/posts' import { Route as LinksRouteImport } from './routes/links' +import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' @@ -90,6 +91,11 @@ const LinksRoute = LinksRouteImport.update({ path: '/links', getParentRoute: () => rootRouteImport, } as any) +const InlineScriptsRoute = InlineScriptsRouteImport.update({ + id: '/inline-scripts', + path: '/inline-scripts', + getParentRoute: () => rootRouteImport, +} as any) const DeferredRoute = DeferredRouteImport.update({ id: '/deferred', path: '/deferred', @@ -264,6 +270,7 @@ export interface FileRoutesByFullPath { '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/deferred': typeof DeferredRoute + '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute @@ -298,6 +305,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/deferred': typeof DeferredRoute + '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute @@ -332,6 +340,7 @@ export interface FileRoutesById { '/search-params': typeof SearchParamsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/deferred': typeof DeferredRoute + '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute @@ -372,6 +381,7 @@ export interface FileRouteTypes { | '/not-found' | '/search-params' | '/deferred' + | '/inline-scripts' | '/links' | '/posts' | '/scripts' @@ -406,6 +416,7 @@ export interface FileRouteTypes { to: | '/' | '/deferred' + | '/inline-scripts' | '/links' | '/scripts' | '/stream' @@ -439,6 +450,7 @@ export interface FileRouteTypes { | '/search-params' | '/_layout' | '/deferred' + | '/inline-scripts' | '/links' | '/posts' | '/scripts' @@ -479,6 +491,7 @@ export interface RootRouteChildren { SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren DeferredRoute: typeof DeferredRoute + InlineScriptsRoute: typeof InlineScriptsRoute LinksRoute: typeof LinksRoute PostsRoute: typeof PostsRouteWithChildren ScriptsRoute: typeof ScriptsRoute @@ -552,6 +565,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DeferredRouteImport parentRoute: typeof rootRouteImport } + '/inline-scripts': { + id: '/inline-scripts' + path: '/inline-scripts' + fullPath: '/inline-scripts' + preLoaderRoute: typeof InlineScriptsRouteImport + parentRoute: typeof rootRouteImport + } '/links': { id: '/links' path: '/links' @@ -829,6 +849,13 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: unknown parentRoute: typeof rootServerRouteImport } + '/inline-scripts': { + id: '/inline-scripts' + path: '/inline-scripts' + fullPath: '/inline-scripts' + preLoaderRoute: unknown + parentRoute: typeof rootServerRouteImport + } '/links': { id: '/links' path: '/links' @@ -1148,6 +1175,23 @@ declare module './routes/deferred' { unknown > } +declare module './routes/inline-scripts' { + const createFileRoute: CreateFileRoute< + '/inline-scripts', + FileRoutesByPath['/inline-scripts']['parentRoute'], + FileRoutesByPath['/inline-scripts']['id'], + FileRoutesByPath['/inline-scripts']['path'], + FileRoutesByPath['/inline-scripts']['fullPath'] + > + + const createServerFileRoute: CreateServerFileRoute< + ServerFileRoutesByPath['/inline-scripts']['parentRoute'], + ServerFileRoutesByPath['/inline-scripts']['id'], + ServerFileRoutesByPath['/inline-scripts']['path'], + ServerFileRoutesByPath['/inline-scripts']['fullPath'], + unknown + > +} declare module './routes/links' { const createFileRoute: CreateFileRoute< '/links', @@ -1858,6 +1902,7 @@ const rootRouteChildren: RootRouteChildren = { SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, DeferredRoute: DeferredRoute, + InlineScriptsRoute: InlineScriptsRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, ScriptsRoute: ScriptsRoute, diff --git a/e2e/react-start/basic/src/routes/__root.tsx b/e2e/react-start/basic/src/routes/__root.tsx index 3283cb8a1ae..9c3a7138b0e 100644 --- a/e2e/react-start/basic/src/routes/__root.tsx +++ b/e2e/react-start/basic/src/routes/__root.tsx @@ -141,6 +141,14 @@ function RootDocument({ children }: { children: React.ReactNode }) { > Scripts {' '} + + Inline Scripts + {' '} ({ + scripts: [ + { + children: 'window.INLINE_SCRIPT_1 = true; console.log("Inline script 1 executed");' + }, + { + children: 'window.INLINE_SCRIPT_2 = "test"; console.log("Inline script 2 executed");', + type: 'text/javascript' + } + ], + }), + component: InlineScriptsComponent, +}) + +function InlineScriptsComponent() { + return ( +
+

Inline Scripts Test

+

+ This route tests inline script duplication prevention. Two inline scripts should be loaded. +

+
+ ) +} \ No newline at end of file diff --git a/e2e/react-start/basic/tests/navigation.spec.ts b/e2e/react-start/basic/tests/navigation.spec.ts index 5d21f2adbe1..62433a0134d 100644 --- a/e2e/react-start/basic/tests/navigation.spec.ts +++ b/e2e/react-start/basic/tests/navigation.spec.ts @@ -34,7 +34,7 @@ test('Navigating nested layouts', async ({ page }) => { test('client side navigating to a route with scripts', async ({ page }) => { await page.goto('/') - await page.getByRole('link', { name: 'Scripts' }).click() + await page.getByRole('link', { name: 'Scripts', exact: true }).click() await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() expect(await page.evaluate('window.SCRIPT_1')).toBe(true) expect(await page.evaluate('window.SCRIPT_2')).toBe(undefined) diff --git a/e2e/react-start/basic/tests/script-duplication.spec.ts b/e2e/react-start/basic/tests/script-duplication.spec.ts new file mode 100644 index 00000000000..a60d49fb325 --- /dev/null +++ b/e2e/react-start/basic/tests/script-duplication.spec.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test' + +test.describe('Script Duplication Prevention', () => { + test('should not create duplicate scripts on SSR route', async ({ page }) => { + // Navigate directly to scripts route (SSR scenario) + await page.goto('/scripts') + + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Count script tags with src="script.js" + const scriptCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + + // Should have exactly one script tag + expect(scriptCount).toBe(1) + + // Verify the script executed correctly + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + }) + + test('should not create duplicate scripts during client-side navigation', async ({ page }) => { + // Start from home page + await page.goto('/') + + // Navigate to scripts route (client-side navigation) + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Count script tags after first navigation + const firstNavCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + expect(firstNavCount).toBe(1) + + // Navigate away from scripts route + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible() + + // Navigate back to scripts route + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Count script tags after second navigation - should still be 1 + const secondNavCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + expect(secondNavCount).toBe(1) + + // Verify the script is still working + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + }) + + test('should not create duplicate scripts with multiple navigation cycles', async ({ page }) => { + // Start from home page + await page.goto('/') + + // Navigate to scripts route multiple times + for (let i = 0; i < 3; i++) { + // Go to scripts + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Go back to home + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible() + } + + // Final navigation to scripts + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Count script tags - should still be exactly 1 + const finalCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + expect(finalCount).toBe(1) + + // Verify the script is still working + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + }) + + test('should not create duplicate inline scripts', async ({ page }) => { + // Navigate directly to inline scripts route (SSR scenario) + await page.goto('/inline-scripts') + + await expect(page.getByTestId('inline-scripts-test-heading')).toBeInViewport() + + // Count specific inline scripts + const script1Count = 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 + }) + + const script2Count = 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 + }) + + // Should have exactly one of each inline script + expect(script1Count).toBe(1) + expect(script2Count).toBe(1) + + // Verify the scripts executed correctly + expect(await page.evaluate('window.INLINE_SCRIPT_1')).toBe(true) + expect(await page.evaluate('window.INLINE_SCRIPT_2')).toBe('test') + }) + + test('should not create duplicate inline scripts during client-side navigation', async ({ page }) => { + // Start from home page + await page.goto('/') + + // Navigate to inline scripts route (client-side navigation) + await page.getByRole('link', { name: 'Inline Scripts' }).click() + await expect(page.getByTestId('inline-scripts-test-heading')).toBeInViewport() + + // Count inline scripts after first navigation + const firstNavScript1Count = 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(firstNavScript1Count).toBe(1) + + // Navigate away and back + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible() + + await page.getByRole('link', { name: 'Inline Scripts' }).click() + await expect(page.getByTestId('inline-scripts-test-heading')).toBeInViewport() + + // Count inline scripts after second navigation - should still be 1 + const secondNavScript1Count = 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(secondNavScript1Count).toBe(1) + + // Verify the scripts are still working + expect(await page.evaluate('window.INLINE_SCRIPT_1')).toBe(true) + expect(await page.evaluate('window.INLINE_SCRIPT_2')).toBe('test') + }) +}) \ No newline at end of file diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index 307552541dc..cf6db920d4e 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as StreamRouteImport } from './routes/stream' import { Route as ScriptsRouteImport } from './routes/scripts' import { Route as PostsRouteImport } from './routes/posts' import { Route as LinksRouteImport } from './routes/links' +import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' @@ -74,6 +75,11 @@ const LinksRoute = LinksRouteImport.update({ path: '/links', getParentRoute: () => rootRouteImport, } as any) +const InlineScriptsRoute = InlineScriptsRouteImport.update({ + id: '/inline-scripts', + path: '/inline-scripts', + getParentRoute: () => rootRouteImport, +} as any) const DeferredRoute = DeferredRouteImport.update({ id: '/deferred', path: '/deferred', @@ -234,6 +240,7 @@ export interface FileRoutesByFullPath { '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/deferred': typeof DeferredRoute + '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute @@ -265,6 +272,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/deferred': typeof DeferredRoute + '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/scripts': typeof ScriptsRoute '/stream': typeof StreamRoute @@ -297,6 +305,7 @@ export interface FileRoutesById { '/search-params': typeof SearchParamsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/deferred': typeof DeferredRoute + '/inline-scripts': typeof InlineScriptsRoute '/links': typeof LinksRoute '/posts': typeof PostsRouteWithChildren '/scripts': typeof ScriptsRoute @@ -333,6 +342,7 @@ export interface FileRouteTypes { | '/not-found' | '/search-params' | '/deferred' + | '/inline-scripts' | '/links' | '/posts' | '/scripts' @@ -364,6 +374,7 @@ export interface FileRouteTypes { to: | '/' | '/deferred' + | '/inline-scripts' | '/links' | '/scripts' | '/stream' @@ -395,6 +406,7 @@ export interface FileRouteTypes { | '/search-params' | '/_layout' | '/deferred' + | '/inline-scripts' | '/links' | '/posts' | '/scripts' @@ -431,6 +443,7 @@ export interface RootRouteChildren { SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren DeferredRoute: typeof DeferredRoute + InlineScriptsRoute: typeof InlineScriptsRoute LinksRoute: typeof LinksRoute PostsRoute: typeof PostsRouteWithChildren ScriptsRoute: typeof ScriptsRoute @@ -502,6 +515,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof LinksRouteImport parentRoute: typeof rootRouteImport } + '/inline-scripts': { + id: '/inline-scripts' + path: '/inline-scripts' + fullPath: '/inline-scripts' + preLoaderRoute: typeof InlineScriptsRouteImport + parentRoute: typeof rootRouteImport + } '/deferred': { id: '/deferred' path: '/deferred' @@ -843,6 +863,7 @@ const rootRouteChildren: RootRouteChildren = { SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, DeferredRoute: DeferredRoute, + InlineScriptsRoute: InlineScriptsRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, ScriptsRoute: ScriptsRoute, diff --git a/e2e/solid-start/basic/src/routes/__root.tsx b/e2e/solid-start/basic/src/routes/__root.tsx index 6598d8b3164..fe2dc996f04 100644 --- a/e2e/solid-start/basic/src/routes/__root.tsx +++ b/e2e/solid-start/basic/src/routes/__root.tsx @@ -103,6 +103,14 @@ function RootComponent() { > Scripts {' '} + + Inline Scripts + {' '} ({ + scripts: [ + { + children: 'window.INLINE_SCRIPT_1 = true; console.log("Inline script 1 executed");' + }, + { + children: 'window.INLINE_SCRIPT_2 = "test"; console.log("Inline script 2 executed");', + type: 'text/javascript' + } + ], + }), + component: InlineScriptsComponent, +}) + +function InlineScriptsComponent() { + return ( +
+

Inline Scripts Test

+

+ This route tests inline script duplication prevention. Two inline scripts should be loaded. +

+
+ ) +} \ No newline at end of file diff --git a/e2e/solid-start/basic/tests/navigation.spec.ts b/e2e/solid-start/basic/tests/navigation.spec.ts index 5d21f2adbe1..62433a0134d 100644 --- a/e2e/solid-start/basic/tests/navigation.spec.ts +++ b/e2e/solid-start/basic/tests/navigation.spec.ts @@ -34,7 +34,7 @@ test('Navigating nested layouts', async ({ page }) => { test('client side navigating to a route with scripts', async ({ page }) => { await page.goto('/') - await page.getByRole('link', { name: 'Scripts' }).click() + await page.getByRole('link', { name: 'Scripts', exact: true }).click() await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() expect(await page.evaluate('window.SCRIPT_1')).toBe(true) expect(await page.evaluate('window.SCRIPT_2')).toBe(undefined) diff --git a/e2e/solid-start/basic/tests/script-duplication.spec.ts b/e2e/solid-start/basic/tests/script-duplication.spec.ts new file mode 100644 index 00000000000..a60d49fb325 --- /dev/null +++ b/e2e/solid-start/basic/tests/script-duplication.spec.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test' + +test.describe('Script Duplication Prevention', () => { + test('should not create duplicate scripts on SSR route', async ({ page }) => { + // Navigate directly to scripts route (SSR scenario) + await page.goto('/scripts') + + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Count script tags with src="script.js" + const scriptCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + + // Should have exactly one script tag + expect(scriptCount).toBe(1) + + // Verify the script executed correctly + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + }) + + test('should not create duplicate scripts during client-side navigation', async ({ page }) => { + // Start from home page + await page.goto('/') + + // Navigate to scripts route (client-side navigation) + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Count script tags after first navigation + const firstNavCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + expect(firstNavCount).toBe(1) + + // Navigate away from scripts route + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible() + + // Navigate back to scripts route + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Count script tags after second navigation - should still be 1 + const secondNavCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + expect(secondNavCount).toBe(1) + + // Verify the script is still working + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + }) + + test('should not create duplicate scripts with multiple navigation cycles', async ({ page }) => { + // Start from home page + await page.goto('/') + + // Navigate to scripts route multiple times + for (let i = 0; i < 3; i++) { + // Go to scripts + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Go back to home + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible() + } + + // Final navigation to scripts + await page.getByRole('link', { name: 'Scripts', exact: true }).click() + await expect(page.getByTestId('scripts-test-heading')).toBeInViewport() + + // Count script tags - should still be exactly 1 + const finalCount = await page.evaluate(() => { + return document.querySelectorAll('script[src="script.js"]').length + }) + expect(finalCount).toBe(1) + + // Verify the script is still working + expect(await page.evaluate('window.SCRIPT_1')).toBe(true) + }) + + test('should not create duplicate inline scripts', async ({ page }) => { + // Navigate directly to inline scripts route (SSR scenario) + await page.goto('/inline-scripts') + + await expect(page.getByTestId('inline-scripts-test-heading')).toBeInViewport() + + // Count specific inline scripts + const script1Count = 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 + }) + + const script2Count = 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 + }) + + // Should have exactly one of each inline script + expect(script1Count).toBe(1) + expect(script2Count).toBe(1) + + // Verify the scripts executed correctly + expect(await page.evaluate('window.INLINE_SCRIPT_1')).toBe(true) + expect(await page.evaluate('window.INLINE_SCRIPT_2')).toBe('test') + }) + + test('should not create duplicate inline scripts during client-side navigation', async ({ page }) => { + // Start from home page + await page.goto('/') + + // Navigate to inline scripts route (client-side navigation) + await page.getByRole('link', { name: 'Inline Scripts' }).click() + await expect(page.getByTestId('inline-scripts-test-heading')).toBeInViewport() + + // Count inline scripts after first navigation + const firstNavScript1Count = 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(firstNavScript1Count).toBe(1) + + // Navigate away and back + await page.getByRole('link', { name: 'Home' }).click() + await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible() + + await page.getByRole('link', { name: 'Inline Scripts' }).click() + await expect(page.getByTestId('inline-scripts-test-heading')).toBeInViewport() + + // Count inline scripts after second navigation - should still be 1 + const secondNavScript1Count = 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(secondNavScript1Count).toBe(1) + + // Verify the scripts are still working + expect(await page.evaluate('window.INLINE_SCRIPT_1')).toBe(true) + expect(await page.evaluate('window.INLINE_SCRIPT_2')).toBe('test') + }) +}) \ No newline at end of file diff --git a/packages/react-router/src/Asset.tsx b/packages/react-router/src/Asset.tsx index 218663e24f2..a555f259ede 100644 --- a/packages/react-router/src/Asset.tsx +++ b/packages/react-router/src/Asset.tsx @@ -46,6 +46,13 @@ function Script({ }) { React.useEffect(() => { if (attrs?.src) { + const existingScript = document.querySelector( + `script[src="${attrs.src}"]`, + ) + if (existingScript) { + return + } + const script = document.createElement('script') for (const [key, value] of Object.entries(attrs)) { @@ -71,6 +78,13 @@ function Script({ } if (typeof children === 'string') { + const existingScript = Array.from( + document.querySelectorAll('script:not([src])'), + ).find((script) => script.textContent === children) + if (existingScript) { + return + } + const script = document.createElement('script') script.textContent = children @@ -101,6 +115,10 @@ function Script({ return undefined }, [attrs, children]) + if (typeof window !== 'undefined') { + return null + } + if (attrs?.src && typeof attrs.src === 'string') { return