Skip to content
Open
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
21 changes: 21 additions & 0 deletions e2e/react-start/basic/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ 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 ClientOnlyRouteImport } from './routes/client-only'
import { Route as AsyncScriptsRouteImport } from './routes/async-scripts'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route'
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
Expand Down Expand Up @@ -118,6 +119,11 @@ const ClientOnlyRoute = ClientOnlyRouteImport.update({
path: '/client-only',
getParentRoute: () => rootRouteImport,
} as any)
const AsyncScriptsRoute = AsyncScriptsRouteImport.update({
id: '/async-scripts',
path: '/async-scripts',
getParentRoute: () => rootRouteImport,
} as any)
const LayoutRoute = LayoutRouteImport.update({
id: '/_layout',
getParentRoute: () => rootRouteImport,
Expand Down Expand Up @@ -374,6 +380,7 @@ export interface FileRoutesByFullPath {
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/async-scripts': typeof AsyncScriptsRoute
'/client-only': typeof ClientOnlyRoute
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
Expand Down Expand Up @@ -430,6 +437,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/async-scripts': typeof AsyncScriptsRoute
'/client-only': typeof ClientOnlyRoute
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
Expand Down Expand Up @@ -485,6 +493,7 @@ export interface FileRoutesById {
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/_layout': typeof LayoutRouteWithChildren
'/async-scripts': typeof AsyncScriptsRoute
'/client-only': typeof ClientOnlyRoute
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
Expand Down Expand Up @@ -546,6 +555,7 @@ export interface FileRouteTypes {
| '/not-found'
| '/search-params'
| '/specialChars'
| '/async-scripts'
| '/client-only'
| '/deferred'
| '/inline-scripts'
Expand Down Expand Up @@ -602,6 +612,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/specialChars'
| '/async-scripts'
| '/client-only'
| '/deferred'
| '/inline-scripts'
Expand Down Expand Up @@ -656,6 +667,7 @@ export interface FileRouteTypes {
| '/search-params'
| '/specialChars'
| '/_layout'
| '/async-scripts'
| '/client-only'
| '/deferred'
| '/inline-scripts'
Expand Down Expand Up @@ -717,6 +729,7 @@ export interface RootRouteChildren {
SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren
SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren
LayoutRoute: typeof LayoutRouteWithChildren
AsyncScriptsRoute: typeof AsyncScriptsRoute
ClientOnlyRoute: typeof ClientOnlyRoute
DeferredRoute: typeof DeferredRoute
InlineScriptsRoute: typeof InlineScriptsRoute
Expand Down Expand Up @@ -808,6 +821,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ClientOnlyRouteImport
parentRoute: typeof rootRouteImport
}
'/async-scripts': {
id: '/async-scripts'
path: '/async-scripts'
fullPath: '/async-scripts'
preLoaderRoute: typeof AsyncScriptsRouteImport
parentRoute: typeof rootRouteImport
}
'/_layout': {
id: '/_layout'
path: ''
Expand Down Expand Up @@ -1343,6 +1363,7 @@ const rootRouteChildren: RootRouteChildren = {
SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren,
SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren,
LayoutRoute: LayoutRouteWithChildren,
AsyncScriptsRoute: AsyncScriptsRoute,
ClientOnlyRoute: ClientOnlyRoute,
DeferredRoute: DeferredRoute,
InlineScriptsRoute: InlineScriptsRoute,
Expand Down
8 changes: 8 additions & 0 deletions e2e/react-start/basic/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ function RootDocument({ children }: { children: React.ReactNode }) {
>
Scripts
</Link>{' '}
<Link
to="/async-scripts"
activeProps={{
className: 'font-bold',
}}
>
Async Scripts
</Link>{' '}
<Link
to="/inline-scripts"
activeProps={{
Expand Down
26 changes: 26 additions & 0 deletions e2e/react-start/basic/src/routes/async-scripts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/async-scripts')({
head: () => ({
scripts: [
{
src: 'script.js',
async: true,
},
{
src: 'script2.js',
defer: true,
},
],
}),
component: AsyncScriptsComponent,
})

function AsyncScriptsComponent() {
return (
<div className="p-2">
<h3 data-testid="async-scripts-test-heading">Async Scripts Test</h3>
<p>This page tests scripts with async and defer attributes.</p>
</div>
)
}
45 changes: 45 additions & 0 deletions e2e/react-start/basic/tests/script-duplication.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
import { expect, test } from '@playwright/test'

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 }) => {
await page.goto('/async-scripts')
await expect(
page.getByTestId('async-scripts-test-heading'),
).toBeInViewport()

// script.js (async) sets window.SCRIPT_1 = true
// script2.js (defer) sets window.SCRIPT_2 = true
expect(await page.evaluate('window.SCRIPT_1')).toBe(true)
expect(await page.evaluate('window.SCRIPT_2')).toBe(true)
})
})

test.describe('Script Duplication Prevention', () => {
test('should not create duplicate scripts on SSR route', async ({ page }) => {
await page.goto('/scripts')
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/Asset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ function Script({
}, [attrs, children])

if (!router.isServer) {
const { src, ...rest } = attrs || {}
const { src: _src, async: _async, defer: _defer, ...rest } = attrs || {}
// render an empty script on the client just to avoid hydration errors
return (
<script
Expand Down
108 changes: 108 additions & 0 deletions packages/react-router/tests/Scripts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,114 @@ describe('ssr scripts', () => {
})
})

describe('scripts with async/defer attributes', () => {
test('server renders scripts with async/defer attributes in output', async () => {
const rootRoute = createRootRoute({
scripts: () => [
{
src: 'script.js',
async: true,
},
{
src: 'script2.js',
defer: true,
},
],
component: () => {
return (
<div>
<div data-testid="server-root">root</div>
<Outlet />
<Scripts />
</div>
)
},
})

const indexRoute = createRoute({
path: '/',
getParentRoute: () => rootRoute,
component: () => {
return <div data-testid="server-index">index</div>
},
})

const router = createRouter({
history: createMemoryHistory({
initialEntries: ['/'],
}),
routeTree: rootRoute.addChildren([indexRoute]),
isServer: true,
})

await router.load()

// Use ReactDOMServer.renderToString to test actual server output
const html = ReactDOMServer.renderToString(
<RouterProvider router={router} />,
)

expect(html).toMatch(/<script[^>]*src="script\.js"[^>]*async=""/)
expect(html).toMatch(/<script[^>]*src="script2\.js"[^>]*defer=""/)
})

test('client renders scripts without async/defer on placeholder', async () => {
const rootRoute = createRootRoute({
scripts: () => [
{
src: 'script.js',
async: true,
crossOrigin: 'anonymous',
},
],
component: () => {
return (
<div>
<div data-testid="async-root">root</div>
<Outlet />
<Scripts />
</div>
)
},
})

const indexRoute = createRoute({
path: '/',
getParentRoute: () => rootRoute,
component: () => {
return <div data-testid="async-index">index</div>
},
})

const router = createRouter({
history: createMemoryHistory({
initialEntries: ['/'],
}),
routeTree: rootRoute.addChildren([indexRoute]),
isServer: false, // Client-side rendering
})

await router.load()

const { container } = await act(() =>
render(<RouterProvider router={router} />),
)

expect(await screen.findByTestId('async-root')).toBeInTheDocument()

const script = container.querySelector('script')
expect(script).toBeTruthy()

// async and defer should NOT be on the client placeholder
expect(script?.hasAttribute('async')).toBe(false)
expect(script?.hasAttribute('defer')).toBe(false)

expect(script?.getAttribute('crossorigin')).toBe('anonymous')

expect(script?.hasAttribute('src')).toBe(false)
})
})

describe('ssr HeadContent', () => {
test('derives title, dedupes meta, and allows non-loader HeadContent', async () => {
const rootRoute = createRootRoute({
Expand Down