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
8 changes: 8 additions & 0 deletions .changeset/fuzzy-pandas-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/router-core': patch
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
---

Reuse resolved lazy route components when revisiting code-split routes, preventing unnecessary pending UI.
25 changes: 25 additions & 0 deletions e2e/react-start/issue-8049/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "tanstack-react-start-e2e-issue-8049",
"private": true,
"type": "module",
"scripts": {
"build": "vite build && tsc --noEmit",
"dev": "vite dev --port 3000",
"dev:e2e": "vite dev",
"test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
"@tanstack/react-start": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.61.0",
"@tanstack/router-e2e-utils": "workspace:^",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.14"
}
}
27 changes: 27 additions & 0 deletions e2e/react-start/issue-8049/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

const PORT =
Number(process.env.VITE_SERVER_PORT) ||
(await getTestServerPort(packageJson.name))
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
testDir: './tests',
workers: 1,
reporter: [['line']],
use: { baseURL },
webServer: {
command: `VITE_SERVER_PORT=${PORT} pnpm dev:e2e --port ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
86 changes: 86 additions & 0 deletions e2e/react-start/issue-8049/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as TestRouteImport } from './routes/test'

const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const TestRoute = TestRouteImport.update({
id: '/test',
path: '/test',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/test': typeof TestRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/test': typeof TestRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/test': typeof TestRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/test'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/test'
id: '__root__' | '/' | '/test'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
TestRoute: typeof TestRoute
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/test': {
id: '/test'
path: '/test'
fullPath: '/test'
preLoaderRoute: typeof TestRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
TestRoute: TestRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
19 changes: 19 additions & 0 deletions e2e/react-start/issue-8049/src/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export function getRouter() {
return createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
defaultPendingMs: 0,
defaultPendingMinMs: 0,
defaultPendingComponent: () => <main data-state="pending">Pending</main>,
})
}

declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}
37 changes: 37 additions & 0 deletions e2e/react-start/issue-8049/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
HeadContent,
Link,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import type { ReactNode } from 'react'

export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
}),
shellComponent: RootDocument,
})

function RootDocument({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<div data-state="shell">
<nav>
<Link to="/">Home</Link>
<Link to="/test">Test</Link>
</nav>
{children}
</div>
<Scripts />
</body>
</html>
)
}
5 changes: 5 additions & 0 deletions e2e/react-start/issue-8049/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
component: () => <main data-state="home">Home Page</main>,
})
5 changes: 5 additions & 0 deletions e2e/react-start/issue-8049/src/routes/test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/test')({
component: () => <main data-state="test">Test Page</main>,
})
75 changes: 75 additions & 0 deletions e2e/react-start/issue-8049/tests/issue-8049.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { expect, test } from '@playwright/test'

test('#8049: revisiting loaded split routes transitions without pending or blank paint', async ({
page,
}) => {
await page.addInitScript(() => {
const scope = globalThis as typeof globalThis & {
__paintStates: Array<string>
__recordPaintStates: boolean
}
scope.__paintStates = []
scope.__recordPaintStates = false

function recordPaintState() {
if (scope.__recordPaintStates) {
const state = document.querySelector('[data-state="home"]')
? 'home'
: document.querySelector('[data-state="test"]')
? 'test'
: document.querySelector('[data-state="pending"]')
? 'pending'
: document.querySelector('[data-state="shell"]')
? 'shell'
: 'empty'
scope.__paintStates.push(state)
}
requestAnimationFrame(recordPaintState)
}

requestAnimationFrame(recordPaintState)
})
await page.goto('/')
await expect(page.getByText('Home Page')).toBeVisible()
await page.waitForTimeout(1_000)

await page.getByRole('link', { name: 'Test' }).click()
await expect(page.getByText('Test Page')).toBeVisible()
await page.getByRole('link', { name: 'Home' }).click()
await expect(page.getByText('Home Page')).toBeVisible()

await page.evaluate(
() =>
new Promise<void>((resolve) => {
const scope = globalThis as typeof globalThis & {
__paintStates: Array<string>
__recordPaintStates: boolean
}
scope.__paintStates = []
scope.__recordPaintStates = true
requestAnimationFrame(() => resolve())
}),
)

await page.getByRole('link', { name: 'Test' }).click()
await expect(page.getByText('Test Page')).toBeVisible()
const paintStates = await page.evaluate(
() =>
new Promise<Array<string>>((resolve) => {
requestAnimationFrame(() => {
const scope = globalThis as typeof globalThis & {
__paintStates: Array<string>
__recordPaintStates: boolean
}
scope.__recordPaintStates = false
resolve(scope.__paintStates)
})
}),
)

expect(paintStates[0]).toBe('home')
expect(paintStates).toContain('test')
expect(paintStates).not.toContain('pending')
expect(paintStates).not.toContain('shell')
expect(paintStates).not.toContain('empty')
})
13 changes: 13 additions & 0 deletions e2e/react-start/issue-8049/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"target": "ESNext",
"moduleResolution": "Bundler",
"module": "ESNext",
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true
}
}
7 changes: 7 additions & 0 deletions e2e/react-start/issue-8049/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
plugins: [tanstackStart(), viteReact()],
})
4 changes: 2 additions & 2 deletions packages/react-router/src/lazyRouteComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ export function lazyRouteComponent<
error = undefined
loadPromise = importer()
.then((res) => {
// Keep browser preload behavior unchanged; SSR can reuse the import.
// Resolved clients have no preload work; SSR can reuse the import.
if (!(isServer ?? typeof window === 'undefined')) {
loadPromise = undefined
;(lazyComp as any).preload = undefined
}
comp = res[exportName ?? 'default']
})
Expand All @@ -48,7 +49,6 @@ export function lazyRouteComponent<

return loadPromise
}

const lazyComp = function Lazy(props: any) {
if (error) {
// A missing module can mean that a newer deployment replaced the URL.
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ export interface DefaultRouteTypes<TProps> {
export interface RouteTypes<TProps> extends DefaultRouteTypes<TProps> {}

export type AsyncRouteComponent<TProps> = RouteTypes<TProps>['component'] & {
preload?: () => Promise<void>
preload?: () => Promise<void> | undefined
}

export type RouteComponent = AsyncRouteComponent<{}>
Expand Down
Loading