From 6c4edd5d82ad9a368f6e89b906ed1da8f7d643da Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Aug 2026 16:26:51 +0200 Subject: [PATCH 1/3] fix(router): reuse resolved lazy route components --- .changeset/fuzzy-pandas-smile.md | 8 ++ e2e/react-start/issue-8049/package.json | 25 ++++++ .../issue-8049/playwright.config.ts | 27 ++++++ .../issue-8049/src/routeTree.gen.ts | 86 +++++++++++++++++++ e2e/react-start/issue-8049/src/router.tsx | 19 ++++ .../issue-8049/src/routes/__root.tsx | 37 ++++++++ .../issue-8049/src/routes/index.tsx | 5 ++ .../issue-8049/src/routes/test.tsx | 5 ++ .../issue-8049/tests/issue-8049.spec.ts | 75 ++++++++++++++++ e2e/react-start/issue-8049/tsconfig.json | 13 +++ e2e/react-start/issue-8049/vite.config.ts | 7 ++ .../react-router/src/lazyRouteComponent.tsx | 4 +- packages/react-router/src/route.tsx | 2 +- .../tests/component-preload-retry.test.tsx | 78 ++++++++++++++++- packages/router-core/src/route.ts | 2 +- .../solid-router/src/lazyRouteComponent.tsx | 4 +- packages/solid-router/src/route.tsx | 2 +- .../tests/component-preload-retry.test.tsx | 9 ++ .../vue-router/src/lazyRouteComponent.tsx | 4 +- packages/vue-router/src/route.ts | 2 +- .../tests/component-preload-retry.test.tsx | 9 ++ pnpm-lock.yaml | 34 ++++++++ 22 files changed, 449 insertions(+), 8 deletions(-) create mode 100644 .changeset/fuzzy-pandas-smile.md create mode 100644 e2e/react-start/issue-8049/package.json create mode 100644 e2e/react-start/issue-8049/playwright.config.ts create mode 100644 e2e/react-start/issue-8049/src/routeTree.gen.ts create mode 100644 e2e/react-start/issue-8049/src/router.tsx create mode 100644 e2e/react-start/issue-8049/src/routes/__root.tsx create mode 100644 e2e/react-start/issue-8049/src/routes/index.tsx create mode 100644 e2e/react-start/issue-8049/src/routes/test.tsx create mode 100644 e2e/react-start/issue-8049/tests/issue-8049.spec.ts create mode 100644 e2e/react-start/issue-8049/tsconfig.json create mode 100644 e2e/react-start/issue-8049/vite.config.ts diff --git a/.changeset/fuzzy-pandas-smile.md b/.changeset/fuzzy-pandas-smile.md new file mode 100644 index 00000000000..338a97692e7 --- /dev/null +++ b/.changeset/fuzzy-pandas-smile.md @@ -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. diff --git a/e2e/react-start/issue-8049/package.json b/e2e/react-start/issue-8049/package.json new file mode 100644 index 00000000000..d76b23fc3b6 --- /dev/null +++ b/e2e/react-start/issue-8049/package.json @@ -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" + } +} diff --git a/e2e/react-start/issue-8049/playwright.config.ts b/e2e/react-start/issue-8049/playwright.config.ts new file mode 100644 index 00000000000..04fa904ba6b --- /dev/null +++ b/e2e/react-start/issue-8049/playwright.config.ts @@ -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'] }, + }, + ], +}) diff --git a/e2e/react-start/issue-8049/src/routeTree.gen.ts b/e2e/react-start/issue-8049/src/routeTree.gen.ts new file mode 100644 index 00000000000..9f42873a9b2 --- /dev/null +++ b/e2e/react-start/issue-8049/src/routeTree.gen.ts @@ -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() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/issue-8049/src/router.tsx b/e2e/react-start/issue-8049/src/router.tsx new file mode 100644 index 00000000000..db215b280b0 --- /dev/null +++ b/e2e/react-start/issue-8049/src/router.tsx @@ -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: () =>
Pending
, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/e2e/react-start/issue-8049/src/routes/__root.tsx b/e2e/react-start/issue-8049/src/routes/__root.tsx new file mode 100644 index 00000000000..d04f5086407 --- /dev/null +++ b/e2e/react-start/issue-8049/src/routes/__root.tsx @@ -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 ( + + + + + +
+ + {children} +
+ + + + ) +} diff --git a/e2e/react-start/issue-8049/src/routes/index.tsx b/e2e/react-start/issue-8049/src/routes/index.tsx new file mode 100644 index 00000000000..be54bce8de1 --- /dev/null +++ b/e2e/react-start/issue-8049/src/routes/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: () =>
Home Page
, +}) diff --git a/e2e/react-start/issue-8049/src/routes/test.tsx b/e2e/react-start/issue-8049/src/routes/test.tsx new file mode 100644 index 00000000000..cca03da69f2 --- /dev/null +++ b/e2e/react-start/issue-8049/src/routes/test.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/test')({ + component: () =>
Test Page
, +}) diff --git a/e2e/react-start/issue-8049/tests/issue-8049.spec.ts b/e2e/react-start/issue-8049/tests/issue-8049.spec.ts new file mode 100644 index 00000000000..25602671099 --- /dev/null +++ b/e2e/react-start/issue-8049/tests/issue-8049.spec.ts @@ -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 + __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((resolve) => { + const scope = globalThis as typeof globalThis & { + __paintStates: Array + __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>((resolve) => { + requestAnimationFrame(() => { + const scope = globalThis as typeof globalThis & { + __paintStates: Array + __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') +}) diff --git a/e2e/react-start/issue-8049/tsconfig.json b/e2e/react-start/issue-8049/tsconfig.json new file mode 100644 index 00000000000..0a82952b9ad --- /dev/null +++ b/e2e/react-start/issue-8049/tsconfig.json @@ -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 + } +} diff --git a/e2e/react-start/issue-8049/vite.config.ts b/e2e/react-start/issue-8049/vite.config.ts new file mode 100644 index 00000000000..f13cf798618 --- /dev/null +++ b/e2e/react-start/issue-8049/vite.config.ts @@ -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()], +}) diff --git a/packages/react-router/src/lazyRouteComponent.tsx b/packages/react-router/src/lazyRouteComponent.tsx index 7c387e0e317..1e17898e9ba 100644 --- a/packages/react-router/src/lazyRouteComponent.tsx +++ b/packages/react-router/src/lazyRouteComponent.tsx @@ -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) { @@ -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 } diff --git a/packages/react-router/src/route.tsx b/packages/react-router/src/route.tsx index 3021e3fb7d2..6f6961807b5 100644 --- a/packages/react-router/src/route.tsx +++ b/packages/react-router/src/route.tsx @@ -664,7 +664,7 @@ export interface DefaultRouteTypes { export interface RouteTypes extends DefaultRouteTypes {} export type AsyncRouteComponent = RouteTypes['component'] & { - preload?: () => Promise + preload?: () => Promise | undefined } export type RouteComponent = AsyncRouteComponent<{}> diff --git a/packages/react-router/tests/component-preload-retry.test.tsx b/packages/react-router/tests/component-preload-retry.test.tsx index ff33ec4beab..2e1ff1213b5 100644 --- a/packages/react-router/tests/component-preload-retry.test.tsx +++ b/packages/react-router/tests/component-preload-retry.test.tsx @@ -1,8 +1,10 @@ import * as React from 'react' import { afterEach, expect, test, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createControlledPromise } from '@tanstack/router-core' import { + Link, + Outlet, RouterProvider, createMemoryHistory, createRootRoute, @@ -45,6 +47,80 @@ test('concurrent component preloads share the import', async () => { await Promise.all([first, second]) }) +test('a resolved client component preload is reused', async () => { + const importer = vi.fn().mockResolvedValue({ default: () => null }) + const Page = lazyRouteComponent(importer) + + await Page.preload?.() + await Page.preload?.() + expect(importer).toHaveBeenCalledTimes(1) +}) + +test('revisiting a resolved lazy component skips pending UI', async () => { + const componentImport = createControlledPromise<{ + default: () => React.JSX.Element + }>() + const repeatedImport = createControlledPromise<{ + default: () => React.JSX.Element + }>() + const importer = vi + .fn() + .mockImplementationOnce(() => componentImport) + .mockImplementationOnce(() => repeatedImport) + const Page = lazyRouteComponent(importer) + const rootRoute = createRootRoute({ + component: () => ( + <> + Home link + Page link + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home content
, + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + defaultPendingComponent: () =>
Loading page
, + }) + + render() + expect(await screen.findByText('Home content')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('link', { name: 'Page link' })) + expect(await screen.findByRole('status')).toHaveTextContent('Loading page') + await act(() => { + componentImport.resolve({ default: () =>
Page content
}) + }) + expect(await screen.findByText('Page content')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('link', { name: 'Home link' })) + expect(await screen.findByText('Home content')).toBeInTheDocument() + fireEvent.click(screen.getByRole('link', { name: 'Page link' })) + await act(() => new Promise((resolve) => setTimeout(resolve, 10))) + + const revisitedContent = screen.queryByText('Page content') + const revisitedPending = screen.queryByRole('status') + await act(() => { + repeatedImport.resolve({ default: () =>
Page content
}) + }) + + expect(revisitedContent).toBeInTheDocument() + expect(revisitedPending).not.toBeInTheDocument() + expect(importer).toHaveBeenCalledOnce() +}) + test('a failed component download is retried from the route error UI', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index eae2f15e304..f2543892677 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -55,7 +55,7 @@ export type AnyContext = {} export interface RouteContext {} -export type PreloadableObj = { preload?: () => Promise } +export type PreloadableObj = { preload?: () => Promise | undefined } export type RoutePathOptions = | { diff --git a/packages/solid-router/src/lazyRouteComponent.tsx b/packages/solid-router/src/lazyRouteComponent.tsx index 64bf1293370..bf1122171f1 100644 --- a/packages/solid-router/src/lazyRouteComponent.tsx +++ b/packages/solid-router/src/lazyRouteComponent.tsx @@ -37,6 +37,8 @@ export function lazyRouteComponent< return loadPromise } + const preload = () => + comp && !(isServer ?? typeof window === 'undefined') ? undefined : load() const lazyComp = function Lazy(props: any) { // Now that we're out of preload and into actual render path, @@ -90,7 +92,7 @@ export function lazyRouteComponent< return } - ;(lazyComp as any).preload = load + ;(lazyComp as any).preload = preload return lazyComp as any } diff --git a/packages/solid-router/src/route.tsx b/packages/solid-router/src/route.tsx index 221a79ffa60..b5e7fe312dd 100644 --- a/packages/solid-router/src/route.tsx +++ b/packages/solid-router/src/route.tsx @@ -545,7 +545,7 @@ export interface DefaultRouteTypes { export interface RouteTypes extends DefaultRouteTypes {} export type AsyncRouteComponent = RouteTypes['component'] & { - preload?: () => Promise + preload?: () => Promise | undefined } export type RouteComponent = AsyncRouteComponent<{}> diff --git a/packages/solid-router/tests/component-preload-retry.test.tsx b/packages/solid-router/tests/component-preload-retry.test.tsx index 0a26f0af26a..a5e0f458e0c 100644 --- a/packages/solid-router/tests/component-preload-retry.test.tsx +++ b/packages/solid-router/tests/component-preload-retry.test.tsx @@ -41,6 +41,15 @@ test('a component loads when rendered before preload', async () => { expect(importer).toHaveBeenCalledTimes(1) }) +test('a resolved client component preload is reused', async () => { + const importer = vi.fn().mockResolvedValue({ default: () => null }) + const Page = lazyRouteComponent(importer) + + await Page.preload?.() + await Page.preload?.() + expect(importer).toHaveBeenCalledTimes(1) +}) + test('a failed component download is retried from the route error UI', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/vue-router/src/lazyRouteComponent.tsx b/packages/vue-router/src/lazyRouteComponent.tsx index 527f3d15ada..8379506df81 100644 --- a/packages/vue-router/src/lazyRouteComponent.tsx +++ b/packages/vue-router/src/lazyRouteComponent.tsx @@ -66,6 +66,8 @@ export function lazyRouteComponent< return loadPromise } + const preload = () => + comp && typeof document !== 'undefined' ? undefined : load() // Create a lazy component wrapper using defineComponent so it works in Vue SFC templates const lazyComp = Vue.defineComponent({ @@ -148,7 +150,7 @@ export function lazyRouteComponent< }) // Add preload method - lazyComp.preload = load + lazyComp.preload = preload return lazyComp as any } diff --git a/packages/vue-router/src/route.ts b/packages/vue-router/src/route.ts index d409d506fe5..53fa54de17c 100644 --- a/packages/vue-router/src/route.ts +++ b/packages/vue-router/src/route.ts @@ -552,7 +552,7 @@ export type VueNode = Vue.VNode export type SyncRouteComponent = (props: TProps) => Vue.VNode export type AsyncRouteComponent = SyncRouteComponent & { - preload?: () => Promise + preload?: () => Promise | undefined } export type RouteComponent = AsyncRouteComponent diff --git a/packages/vue-router/tests/component-preload-retry.test.tsx b/packages/vue-router/tests/component-preload-retry.test.tsx index 9fb8ad5f5a7..118a8cb039f 100644 --- a/packages/vue-router/tests/component-preload-retry.test.tsx +++ b/packages/vue-router/tests/component-preload-retry.test.tsx @@ -16,6 +16,15 @@ afterEach(() => { vi.restoreAllMocks() }) +test('a resolved client component preload is reused', async () => { + const importer = vi.fn().mockResolvedValue({ default: () => null }) + const Page = lazyRouteComponent(importer) + + await Page.preload?.() + await Page.preload?.() + expect(importer).toHaveBeenCalledTimes(1) +}) + test('a failed component download is retried from the route error UI', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99fb327308b..6ae1a61f2fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2759,6 +2759,40 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-start/issue-8049: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/react': + specifier: ^19.2.8 + version: 19.2.9 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-start/query-integration: dependencies: '@tanstack/react-query': From 8d93118950db1e0ee6427691cda34b65744575ea Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Aug 2026 16:55:41 +0200 Subject: [PATCH 2/3] fix vue inconsistency --- packages/vue-router/src/lazyRouteComponent.tsx | 4 +++- .../tests/component-preload-retry.test.tsx | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/vue-router/src/lazyRouteComponent.tsx b/packages/vue-router/src/lazyRouteComponent.tsx index 8379506df81..144801366c3 100644 --- a/packages/vue-router/src/lazyRouteComponent.tsx +++ b/packages/vue-router/src/lazyRouteComponent.tsx @@ -47,7 +47,9 @@ export function lazyRouteComponent< error = undefined loadPromise = importer() .then((res) => { - loadPromise = undefined + if (typeof document !== 'undefined') { + loadPromise = undefined + } comp = res[exportName ?? 'default'] return comp }) diff --git a/packages/vue-router/tests/component-preload-retry.test.tsx b/packages/vue-router/tests/component-preload-retry.test.tsx index 118a8cb039f..1ad11940048 100644 --- a/packages/vue-router/tests/component-preload-retry.test.tsx +++ b/packages/vue-router/tests/component-preload-retry.test.tsx @@ -14,6 +14,19 @@ import type { ErrorComponentProps } from '../src' afterEach(() => { cleanup() vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +test('a successful server component download is reused', async () => { + vi.stubGlobal('document', undefined) + const importer = vi.fn().mockResolvedValue({ default: () => null }) + const Page = lazyRouteComponent(importer) + + const preload = Page.preload?.() + await preload + + expect(Page.preload?.()).toBe(preload) + expect(importer).toHaveBeenCalledTimes(1) }) test('a resolved client component preload is reused', async () => { From bf476d80f7d9f3aec542e4caea45670ee6ab8b2b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 14 Aug 2026 12:11:04 +0200 Subject: [PATCH 3/3] fix(router): clear resolved lazy component preload --- .../react-router/src/lazyRouteComponent.tsx | 8 +-- .../tests/component-preload-retry.test.tsx | 18 +++-- .../solid-router/src/lazyRouteComponent.tsx | 8 +-- .../tests/component-preload-retry.test.tsx | 70 ++++++++++++++++++- .../vue-router/src/lazyRouteComponent.tsx | 6 +- .../tests/component-preload-retry.test.tsx | 70 ++++++++++++++++++- 6 files changed, 157 insertions(+), 23 deletions(-) diff --git a/packages/react-router/src/lazyRouteComponent.tsx b/packages/react-router/src/lazyRouteComponent.tsx index 1e17898e9ba..a7b58c44991 100644 --- a/packages/react-router/src/lazyRouteComponent.tsx +++ b/packages/react-router/src/lazyRouteComponent.tsx @@ -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'] }) @@ -48,9 +49,6 @@ export function lazyRouteComponent< return loadPromise } - const preload = () => - comp && !(isServer ?? typeof window === 'undefined') ? undefined : load() - const lazyComp = function Lazy(props: any) { if (error) { // A missing module can mean that a newer deployment replaced the URL. @@ -83,7 +81,7 @@ export function lazyRouteComponent< return React.createElement(comp, props) } - ;(lazyComp as any).preload = preload + ;(lazyComp as any).preload = load return lazyComp as any } diff --git a/packages/react-router/tests/component-preload-retry.test.tsx b/packages/react-router/tests/component-preload-retry.test.tsx index 2e1ff1213b5..7abe959ebce 100644 --- a/packages/react-router/tests/component-preload-retry.test.tsx +++ b/packages/react-router/tests/component-preload-retry.test.tsx @@ -52,7 +52,7 @@ test('a resolved client component preload is reused', async () => { const Page = lazyRouteComponent(importer) await Page.preload?.() - await Page.preload?.() + expect(Page.preload).toBeUndefined() expect(importer).toHaveBeenCalledTimes(1) }) @@ -68,6 +68,9 @@ test('revisiting a resolved lazy component skips pending UI', async () => { .mockImplementationOnce(() => componentImport) .mockImplementationOnce(() => repeatedImport) const Page = lazyRouteComponent(importer) + const PendingComponent = vi.fn(() => ( +
Loading page
+ )) const rootRoute = createRootRoute({ component: () => ( <> @@ -92,14 +95,16 @@ test('revisiting a resolved lazy component skips pending UI', async () => { history: createMemoryHistory({ initialEntries: ['/'] }), defaultPendingMs: 0, defaultPendingMinMs: 0, - defaultPendingComponent: () =>
Loading page
, + defaultPendingComponent: PendingComponent, }) render() expect(await screen.findByText('Home content')).toBeInTheDocument() fireEvent.click(screen.getByRole('link', { name: 'Page link' })) - expect(await screen.findByRole('status')).toHaveTextContent('Loading page') + expect(await screen.findByTestId('pending-component')).toHaveTextContent( + 'Loading page', + ) await act(() => { componentImport.resolve({ default: () =>
Page content
}) }) @@ -107,17 +112,16 @@ test('revisiting a resolved lazy component skips pending UI', async () => { fireEvent.click(screen.getByRole('link', { name: 'Home link' })) expect(await screen.findByText('Home content')).toBeInTheDocument() + PendingComponent.mockClear() fireEvent.click(screen.getByRole('link', { name: 'Page link' })) await act(() => new Promise((resolve) => setTimeout(resolve, 10))) const revisitedContent = screen.queryByText('Page content') - const revisitedPending = screen.queryByRole('status') - await act(() => { - repeatedImport.resolve({ default: () =>
Page content
}) - }) + const revisitedPending = screen.queryByTestId('pending-component') expect(revisitedContent).toBeInTheDocument() expect(revisitedPending).not.toBeInTheDocument() + expect(PendingComponent).not.toHaveBeenCalled() expect(importer).toHaveBeenCalledOnce() }) diff --git a/packages/solid-router/src/lazyRouteComponent.tsx b/packages/solid-router/src/lazyRouteComponent.tsx index bf1122171f1..ad47717b0df 100644 --- a/packages/solid-router/src/lazyRouteComponent.tsx +++ b/packages/solid-router/src/lazyRouteComponent.tsx @@ -22,9 +22,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'] return comp @@ -37,9 +38,6 @@ export function lazyRouteComponent< return loadPromise } - const preload = () => - comp && !(isServer ?? typeof window === 'undefined') ? undefined : load() - const lazyComp = function Lazy(props: any) { // Now that we're out of preload and into actual render path, // throw the error if it was a module not found error during preload @@ -92,7 +90,7 @@ export function lazyRouteComponent< return } - ;(lazyComp as any).preload = preload + ;(lazyComp as any).preload = load return lazyComp as any } diff --git a/packages/solid-router/tests/component-preload-retry.test.tsx b/packages/solid-router/tests/component-preload-retry.test.tsx index a5e0f458e0c..47cee1d0d6a 100644 --- a/packages/solid-router/tests/component-preload-retry.test.tsx +++ b/packages/solid-router/tests/component-preload-retry.test.tsx @@ -1,6 +1,9 @@ import { afterEach, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { createControlledPromise } from '@tanstack/router-core' import { + Link, + Outlet, RouterProvider, createMemoryHistory, createRootRoute, @@ -46,10 +49,75 @@ test('a resolved client component preload is reused', async () => { const Page = lazyRouteComponent(importer) await Page.preload?.() - await Page.preload?.() + expect(Page.preload).toBeUndefined() expect(importer).toHaveBeenCalledTimes(1) }) +test('revisiting a resolved lazy component skips pending UI', async () => { + const PageContent = () =>
Page content
+ const componentImport = createControlledPromise<{ + default: typeof PageContent + }>() + const repeatedImport = createControlledPromise<{ + default: typeof PageContent + }>() + const importer = vi + .fn() + .mockImplementationOnce(() => componentImport) + .mockImplementationOnce(() => repeatedImport) + const Page = lazyRouteComponent(importer) + const PendingComponent = vi.fn(() => ( +
Loading page
+ )) + const rootRoute = createRootRoute({ + component: () => ( + <> + Home link + Page link + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home content
, + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + defaultPendingComponent: PendingComponent, + }) + + render(() => ) + expect(await screen.findByText('Home content')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('link', { name: 'Page link' })) + expect(await screen.findByTestId('pending-component')).toHaveTextContent( + 'Loading page', + ) + componentImport.resolve({ default: PageContent }) + expect(await screen.findByText('Page content')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('link', { name: 'Home link' })) + expect(await screen.findByText('Home content')).toBeInTheDocument() + PendingComponent.mockClear() + fireEvent.click(screen.getByRole('link', { name: 'Page link' })) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(screen.queryByText('Page content')).toBeInTheDocument() + expect(screen.queryByTestId('pending-component')).not.toBeInTheDocument() + expect(PendingComponent).not.toHaveBeenCalled() + expect(importer).toHaveBeenCalledOnce() +}) + test('a failed component download is retried from the route error UI', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/vue-router/src/lazyRouteComponent.tsx b/packages/vue-router/src/lazyRouteComponent.tsx index 144801366c3..3137e15ba4f 100644 --- a/packages/vue-router/src/lazyRouteComponent.tsx +++ b/packages/vue-router/src/lazyRouteComponent.tsx @@ -49,6 +49,7 @@ export function lazyRouteComponent< .then((res) => { if (typeof document !== 'undefined') { loadPromise = undefined + ;(lazyComp as any).preload = undefined } comp = res[exportName ?? 'default'] return comp @@ -68,9 +69,6 @@ export function lazyRouteComponent< return loadPromise } - const preload = () => - comp && typeof document !== 'undefined' ? undefined : load() - // Create a lazy component wrapper using defineComponent so it works in Vue SFC templates const lazyComp = Vue.defineComponent({ name: 'LazyRouteComponent', @@ -152,7 +150,7 @@ export function lazyRouteComponent< }) // Add preload method - lazyComp.preload = preload + lazyComp.preload = load return lazyComp as any } diff --git a/packages/vue-router/tests/component-preload-retry.test.tsx b/packages/vue-router/tests/component-preload-retry.test.tsx index 1ad11940048..ca74f2ba67f 100644 --- a/packages/vue-router/tests/component-preload-retry.test.tsx +++ b/packages/vue-router/tests/component-preload-retry.test.tsx @@ -1,6 +1,9 @@ import { afterEach, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { createControlledPromise } from '@tanstack/router-core' import { + Link, + Outlet, RouterProvider, createMemoryHistory, createRootRoute, @@ -34,10 +37,75 @@ test('a resolved client component preload is reused', async () => { const Page = lazyRouteComponent(importer) await Page.preload?.() - await Page.preload?.() + expect(Page.preload).toBeUndefined() expect(importer).toHaveBeenCalledTimes(1) }) +test('revisiting a resolved lazy component skips pending UI', async () => { + const PageContent = () =>
Page content
+ const componentImport = createControlledPromise<{ + default: typeof PageContent + }>() + const repeatedImport = createControlledPromise<{ + default: typeof PageContent + }>() + const importer = vi + .fn() + .mockImplementationOnce(() => componentImport) + .mockImplementationOnce(() => repeatedImport) + const Page = lazyRouteComponent(importer) + const PendingComponent = vi.fn(() => ( +
Loading page
+ )) + const rootRoute = createRootRoute({ + component: () => ( + <> + Home link + Page link + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home content
, + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + defaultPendingComponent: PendingComponent, + }) + + render() + expect(await screen.findByText('Home content')).toBeInTheDocument() + + await fireEvent.click(screen.getByRole('link', { name: 'Page link' })) + expect(await screen.findByTestId('pending-component')).toHaveTextContent( + 'Loading page', + ) + componentImport.resolve({ default: PageContent }) + expect(await screen.findByText('Page content')).toBeInTheDocument() + + await fireEvent.click(screen.getByRole('link', { name: 'Home link' })) + expect(await screen.findByText('Home content')).toBeInTheDocument() + PendingComponent.mockClear() + await fireEvent.click(screen.getByRole('link', { name: 'Page link' })) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(screen.queryByText('Page content')).toBeInTheDocument() + expect(screen.queryByTestId('pending-component')).not.toBeInTheDocument() + expect(PendingComponent).not.toHaveBeenCalled() + expect(importer).toHaveBeenCalledOnce() +}) + test('a failed component download is retried from the route error UI', async () => { vi.spyOn(console, 'error').mockImplementation(() => {})