Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 3 additions & 1 deletion packages/react-router/src/lazyRouteComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export function lazyRouteComponent<

return loadPromise
}
const preload = () =>
comp && !(isServer ?? typeof window === 'undefined') ? undefined : load()

const lazyComp = function Lazy(props: any) {
if (error) {
Expand Down Expand Up @@ -81,7 +83,7 @@ export function lazyRouteComponent<
return React.createElement(comp, props)
}

;(lazyComp as any).preload = load
;(lazyComp as any).preload = preload

return lazyComp as any
}
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
Loading