Skip to content

Commit b0ea8de

Browse files
fix(router): prevent script tag duplication in SSR and client-side navigation (#5095)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 2c8d5ce commit b0ea8de

File tree

12 files changed

+514
-3
lines changed

12 files changed

+514
-3
lines changed

e2e/react-start/basic/src/routeTree.gen.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { Route as StreamRouteImport } from './routes/stream'
2323
import { Route as ScriptsRouteImport } from './routes/scripts'
2424
import { Route as PostsRouteImport } from './routes/posts'
2525
import { Route as LinksRouteImport } from './routes/links'
26+
import { Route as InlineScriptsRouteImport } from './routes/inline-scripts'
2627
import { Route as DeferredRouteImport } from './routes/deferred'
2728
import { Route as LayoutRouteImport } from './routes/_layout'
2829
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
@@ -90,6 +91,11 @@ const LinksRoute = LinksRouteImport.update({
9091
path: '/links',
9192
getParentRoute: () => rootRouteImport,
9293
} as any)
94+
const InlineScriptsRoute = InlineScriptsRouteImport.update({
95+
id: '/inline-scripts',
96+
path: '/inline-scripts',
97+
getParentRoute: () => rootRouteImport,
98+
} as any)
9399
const DeferredRoute = DeferredRouteImport.update({
94100
id: '/deferred',
95101
path: '/deferred',
@@ -264,6 +270,7 @@ export interface FileRoutesByFullPath {
264270
'/not-found': typeof NotFoundRouteRouteWithChildren
265271
'/search-params': typeof SearchParamsRouteRouteWithChildren
266272
'/deferred': typeof DeferredRoute
273+
'/inline-scripts': typeof InlineScriptsRoute
267274
'/links': typeof LinksRoute
268275
'/posts': typeof PostsRouteWithChildren
269276
'/scripts': typeof ScriptsRoute
@@ -298,6 +305,7 @@ export interface FileRoutesByFullPath {
298305
export interface FileRoutesByTo {
299306
'/': typeof IndexRoute
300307
'/deferred': typeof DeferredRoute
308+
'/inline-scripts': typeof InlineScriptsRoute
301309
'/links': typeof LinksRoute
302310
'/scripts': typeof ScriptsRoute
303311
'/stream': typeof StreamRoute
@@ -332,6 +340,7 @@ export interface FileRoutesById {
332340
'/search-params': typeof SearchParamsRouteRouteWithChildren
333341
'/_layout': typeof LayoutRouteWithChildren
334342
'/deferred': typeof DeferredRoute
343+
'/inline-scripts': typeof InlineScriptsRoute
335344
'/links': typeof LinksRoute
336345
'/posts': typeof PostsRouteWithChildren
337346
'/scripts': typeof ScriptsRoute
@@ -372,6 +381,7 @@ export interface FileRouteTypes {
372381
| '/not-found'
373382
| '/search-params'
374383
| '/deferred'
384+
| '/inline-scripts'
375385
| '/links'
376386
| '/posts'
377387
| '/scripts'
@@ -406,6 +416,7 @@ export interface FileRouteTypes {
406416
to:
407417
| '/'
408418
| '/deferred'
419+
| '/inline-scripts'
409420
| '/links'
410421
| '/scripts'
411422
| '/stream'
@@ -439,6 +450,7 @@ export interface FileRouteTypes {
439450
| '/search-params'
440451
| '/_layout'
441452
| '/deferred'
453+
| '/inline-scripts'
442454
| '/links'
443455
| '/posts'
444456
| '/scripts'
@@ -479,6 +491,7 @@ export interface RootRouteChildren {
479491
SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren
480492
LayoutRoute: typeof LayoutRouteWithChildren
481493
DeferredRoute: typeof DeferredRoute
494+
InlineScriptsRoute: typeof InlineScriptsRoute
482495
LinksRoute: typeof LinksRoute
483496
PostsRoute: typeof PostsRouteWithChildren
484497
ScriptsRoute: typeof ScriptsRoute
@@ -552,6 +565,13 @@ declare module '@tanstack/react-router' {
552565
preLoaderRoute: typeof DeferredRouteImport
553566
parentRoute: typeof rootRouteImport
554567
}
568+
'/inline-scripts': {
569+
id: '/inline-scripts'
570+
path: '/inline-scripts'
571+
fullPath: '/inline-scripts'
572+
preLoaderRoute: typeof InlineScriptsRouteImport
573+
parentRoute: typeof rootRouteImport
574+
}
555575
'/links': {
556576
id: '/links'
557577
path: '/links'
@@ -829,6 +849,13 @@ declare module '@tanstack/react-start/server' {
829849
preLoaderRoute: unknown
830850
parentRoute: typeof rootServerRouteImport
831851
}
852+
'/inline-scripts': {
853+
id: '/inline-scripts'
854+
path: '/inline-scripts'
855+
fullPath: '/inline-scripts'
856+
preLoaderRoute: unknown
857+
parentRoute: typeof rootServerRouteImport
858+
}
832859
'/links': {
833860
id: '/links'
834861
path: '/links'
@@ -1148,6 +1175,23 @@ declare module './routes/deferred' {
11481175
unknown
11491176
>
11501177
}
1178+
declare module './routes/inline-scripts' {
1179+
const createFileRoute: CreateFileRoute<
1180+
'/inline-scripts',
1181+
FileRoutesByPath['/inline-scripts']['parentRoute'],
1182+
FileRoutesByPath['/inline-scripts']['id'],
1183+
FileRoutesByPath['/inline-scripts']['path'],
1184+
FileRoutesByPath['/inline-scripts']['fullPath']
1185+
>
1186+
1187+
const createServerFileRoute: CreateServerFileRoute<
1188+
ServerFileRoutesByPath['/inline-scripts']['parentRoute'],
1189+
ServerFileRoutesByPath['/inline-scripts']['id'],
1190+
ServerFileRoutesByPath['/inline-scripts']['path'],
1191+
ServerFileRoutesByPath['/inline-scripts']['fullPath'],
1192+
unknown
1193+
>
1194+
}
11511195
declare module './routes/links' {
11521196
const createFileRoute: CreateFileRoute<
11531197
'/links',
@@ -1858,6 +1902,7 @@ const rootRouteChildren: RootRouteChildren = {
18581902
SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren,
18591903
LayoutRoute: LayoutRouteWithChildren,
18601904
DeferredRoute: DeferredRoute,
1905+
InlineScriptsRoute: InlineScriptsRoute,
18611906
LinksRoute: LinksRoute,
18621907
PostsRoute: PostsRouteWithChildren,
18631908
ScriptsRoute: ScriptsRoute,

e2e/react-start/basic/src/routes/__root.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ function RootDocument({ children }: { children: React.ReactNode }) {
141141
>
142142
Scripts
143143
</Link>{' '}
144+
<Link
145+
to="/inline-scripts"
146+
activeProps={{
147+
className: 'font-bold',
148+
}}
149+
>
150+
Inline Scripts
151+
</Link>{' '}
144152
<Link
145153
to="/deferred"
146154
activeProps={{
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export const Route = createFileRoute({
2+
head: () => ({
3+
scripts: [
4+
{
5+
children:
6+
'window.INLINE_SCRIPT_1 = true; console.log("Inline script 1 executed");',
7+
},
8+
{
9+
children:
10+
'window.INLINE_SCRIPT_2 = "test"; console.log("Inline script 2 executed");',
11+
type: 'text/javascript',
12+
},
13+
],
14+
}),
15+
component: InlineScriptsComponent,
16+
})
17+
18+
function InlineScriptsComponent() {
19+
return (
20+
<div className="p-2">
21+
<h3 data-testid="inline-scripts-test-heading">Inline Scripts Test</h3>
22+
<p>
23+
This route tests inline script duplication prevention. Two inline
24+
scripts should be loaded.
25+
</p>
26+
</div>
27+
)
28+
}

e2e/react-start/basic/tests/navigation.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ test('Navigating nested layouts', async ({ page }) => {
3434

3535
test('client side navigating to a route with scripts', async ({ page }) => {
3636
await page.goto('/')
37-
await page.getByRole('link', { name: 'Scripts' }).click()
37+
await page.getByRole('link', { name: 'Scripts', exact: true }).click()
3838
await expect(page.getByTestId('scripts-test-heading')).toBeInViewport()
3939
expect(await page.evaluate('window.SCRIPT_1')).toBe(true)
4040
expect(await page.evaluate('window.SCRIPT_2')).toBe(undefined)
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
test.describe('Script Duplication Prevention', () => {
4+
test('should not create duplicate scripts on SSR route', async ({ page }) => {
5+
await page.goto('/scripts')
6+
7+
await expect(page.getByTestId('scripts-test-heading')).toBeInViewport()
8+
9+
const scriptCount = await page.evaluate(() => {
10+
return document.querySelectorAll('script[src="script.js"]').length
11+
})
12+
13+
expect(scriptCount).toBe(1)
14+
15+
expect(await page.evaluate('window.SCRIPT_1')).toBe(true)
16+
})
17+
18+
test('should not create duplicate scripts during client-side navigation', async ({
19+
page,
20+
}) => {
21+
await page.goto('/')
22+
23+
await page.getByRole('link', { name: 'Scripts', exact: true }).click()
24+
await expect(page.getByTestId('scripts-test-heading')).toBeInViewport()
25+
26+
const firstNavCount = await page.evaluate(() => {
27+
return document.querySelectorAll('script[src="script.js"]').length
28+
})
29+
expect(firstNavCount).toBe(1)
30+
31+
await page.getByRole('link', { name: 'Home' }).click()
32+
await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible()
33+
34+
await page.getByRole('link', { name: 'Scripts', exact: true }).click()
35+
await expect(page.getByTestId('scripts-test-heading')).toBeInViewport()
36+
37+
const secondNavCount = await page.evaluate(() => {
38+
return document.querySelectorAll('script[src="script.js"]').length
39+
})
40+
expect(secondNavCount).toBe(1)
41+
42+
expect(await page.evaluate('window.SCRIPT_1')).toBe(true)
43+
})
44+
45+
test('should not create duplicate scripts with multiple navigation cycles', async ({
46+
page,
47+
}) => {
48+
await page.goto('/')
49+
50+
for (let i = 0; i < 3; i++) {
51+
await page.getByRole('link', { name: 'Scripts', exact: true }).click()
52+
await expect(page.getByTestId('scripts-test-heading')).toBeInViewport()
53+
54+
await page.getByRole('link', { name: 'Home' }).click()
55+
await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible()
56+
}
57+
58+
await page.getByRole('link', { name: 'Scripts', exact: true }).click()
59+
await expect(page.getByTestId('scripts-test-heading')).toBeInViewport()
60+
61+
const finalCount = await page.evaluate(() => {
62+
return document.querySelectorAll('script[src="script.js"]').length
63+
})
64+
expect(finalCount).toBe(1)
65+
66+
expect(await page.evaluate('window.SCRIPT_1')).toBe(true)
67+
})
68+
69+
test('should not create duplicate inline scripts', async ({ page }) => {
70+
await page.goto('/inline-scripts')
71+
72+
await expect(
73+
page.getByTestId('inline-scripts-test-heading'),
74+
).toBeInViewport()
75+
76+
const script1Count = await page.evaluate(() => {
77+
const scripts = Array.from(document.querySelectorAll('script:not([src])'))
78+
return scripts.filter(
79+
(script) =>
80+
script.textContent &&
81+
script.textContent.includes('window.INLINE_SCRIPT_1 = true'),
82+
).length
83+
})
84+
85+
const script2Count = await page.evaluate(() => {
86+
const scripts = Array.from(document.querySelectorAll('script:not([src])'))
87+
return scripts.filter(
88+
(script) =>
89+
script.textContent &&
90+
script.textContent.includes('window.INLINE_SCRIPT_2 = "test"'),
91+
).length
92+
})
93+
94+
expect(script1Count).toBe(1)
95+
expect(script2Count).toBe(1)
96+
97+
expect(await page.evaluate('window.INLINE_SCRIPT_1')).toBe(true)
98+
expect(await page.evaluate('window.INLINE_SCRIPT_2')).toBe('test')
99+
})
100+
101+
test('should not create duplicate inline scripts during client-side navigation', async ({
102+
page,
103+
}) => {
104+
await page.goto('/')
105+
106+
await page.getByRole('link', { name: 'Inline Scripts' }).click()
107+
await expect(
108+
page.getByTestId('inline-scripts-test-heading'),
109+
).toBeInViewport()
110+
111+
const firstNavScript1Count = await page.evaluate(() => {
112+
const scripts = Array.from(document.querySelectorAll('script:not([src])'))
113+
return scripts.filter(
114+
(script) =>
115+
script.textContent &&
116+
script.textContent.includes('window.INLINE_SCRIPT_1 = true'),
117+
).length
118+
})
119+
expect(firstNavScript1Count).toBe(1)
120+
121+
await page.getByRole('link', { name: 'Home' }).click()
122+
await expect(page.getByRole('link', { name: 'Posts' })).toBeVisible()
123+
124+
await page.getByRole('link', { name: 'Inline Scripts' }).click()
125+
await expect(
126+
page.getByTestId('inline-scripts-test-heading'),
127+
).toBeInViewport()
128+
129+
const secondNavScript1Count = await page.evaluate(() => {
130+
const scripts = Array.from(document.querySelectorAll('script:not([src])'))
131+
return scripts.filter(
132+
(script) =>
133+
script.textContent &&
134+
script.textContent.includes('window.INLINE_SCRIPT_1 = true'),
135+
).length
136+
})
137+
expect(secondNavScript1Count).toBe(1)
138+
139+
// Verify the scripts are still working
140+
expect(await page.evaluate('window.INLINE_SCRIPT_1')).toBe(true)
141+
expect(await page.evaluate('window.INLINE_SCRIPT_2')).toBe('test')
142+
})
143+
})

0 commit comments

Comments
 (0)