From 46ebf8b58436e6cfb0a587e36c114b29fe5ab90d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 5 Aug 2026 10:55:17 +0200 Subject: [PATCH 1/5] fix(react-router): prevent blank initial pending frame --- .changeset/bright-frames-settle.md | 8 +++ e2e/react-router/issue-4759/index.html | 62 +++++++++++++++++++ e2e/react-router/issue-4759/package.json | 26 ++++++++ .../issue-4759/playwright.config.ts | 25 ++++++++ e2e/react-router/issue-4759/src/main.tsx | 50 +++++++++++++++ .../issue-4759/tests/issue-4759.spec.ts | 38 ++++++++++++ e2e/react-router/issue-4759/tsconfig.json | 13 ++++ e2e/react-router/issue-4759/vite.config.ts | 6 ++ packages/react-router/src/Matches.tsx | 10 ++- packages/react-router/src/Transitioner.tsx | 8 ++- pnpm-lock.yaml | 31 ++++++++++ 11 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 .changeset/bright-frames-settle.md create mode 100644 e2e/react-router/issue-4759/index.html create mode 100644 e2e/react-router/issue-4759/package.json create mode 100644 e2e/react-router/issue-4759/playwright.config.ts create mode 100644 e2e/react-router/issue-4759/src/main.tsx create mode 100644 e2e/react-router/issue-4759/tests/issue-4759.spec.ts create mode 100644 e2e/react-router/issue-4759/tsconfig.json create mode 100644 e2e/react-router/issue-4759/vite.config.ts diff --git a/.changeset/bright-frames-settle.md b/.changeset/bright-frames-settle.md new file mode 100644 index 00000000000..d429b00b084 --- /dev/null +++ b/.changeset/bright-frames-settle.md @@ -0,0 +1,8 @@ +--- +'@tanstack/react-router': patch +--- + +Prevent a blank frame before zero-delay initial pending UI by using a +component-scoped React transition for match publication. Pending timing, +boundary selection, and rendered matches continue to come from the router +core. diff --git a/e2e/react-router/issue-4759/index.html b/e2e/react-router/issue-4759/index.html new file mode 100644 index 00000000000..9609ea710b7 --- /dev/null +++ b/e2e/react-router/issue-4759/index.html @@ -0,0 +1,62 @@ + + + + + + Issue 4759 + + + +
+
HTML fallback
+
+ + + + diff --git a/e2e/react-router/issue-4759/package.json b/e2e/react-router/issue-4759/package.json new file mode 100644 index 00000000000..91a7a5053e4 --- /dev/null +++ b/e2e/react-router/issue-4759/package.json @@ -0,0 +1,26 @@ +{ + "name": "tanstack-router-e2e-react-issue-4759", + "private": true, + "type": "module", + "scripts": { + "build": "vite build && tsc --noEmit", + "dev": "vite --port 3000", + "dev:e2e": "vite", + "preview": "vite preview", + "start": "vite", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "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-router/issue-4759/playwright.config.ts b/e2e/react-router/issue-4759/playwright.config.ts new file mode 100644 index 00000000000..b5e8249edc7 --- /dev/null +++ b/e2e/react-router/issue-4759/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const 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-router/issue-4759/src/main.tsx b/e2e/react-router/issue-4759/src/main.tsx new file mode 100644 index 00000000000..bc4a99c37fb --- /dev/null +++ b/e2e/react-router/issue-4759/src/main.tsx @@ -0,0 +1,50 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router' + +const rootRoute = createRootRoute() +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => new Promise((resolve) => setTimeout(resolve, 1_000)), + component: () =>
loaded
, +}) +const routePendingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/route-pending', + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: () => ( +
+ route pending +
+ ), + loader: () => new Promise((resolve) => setTimeout(resolve, 1_000)), + component: () =>
loaded
, +}) +const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, routePendingRoute]), +}) +const usesRoutePending = window.location.pathname === '/route-pending' + +createRoot(document.getElementById('app')!).render( + +
+ ( +
+ default pending +
+ )} + /> +
+
, +) diff --git a/e2e/react-router/issue-4759/tests/issue-4759.spec.ts b/e2e/react-router/issue-4759/tests/issue-4759.spec.ts new file mode 100644 index 00000000000..7556b1c47a4 --- /dev/null +++ b/e2e/react-router/issue-4759/tests/issue-4759.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +async function expectNoBlankFrame( + page: Page, + pendingSource: 'default' | 'route', +) { + await expect( + page.locator( + `[data-state="pending"][data-pending-source="${pendingSource}"]`, + ), + ).toBeVisible() + + const paintStates = await page.evaluate( + () => + (globalThis as typeof globalThis & { __paintStates: Array }) + .__paintStates, + ) + + expect(paintStates).toContain('pending') + expect(paintStates).not.toContain('shell') + expect(paintStates).not.toContain('empty') + await expect(page.locator('[data-state="loaded"]')).toBeVisible() +} + +test('#4759: the initial default pending component has no blank frame before it', async ({ + page, +}) => { + await page.goto('/') + await expectNoBlankFrame(page, 'default') +}) + +test('an initial route pending component has no blank frame before it', async ({ + page, +}) => { + await page.goto('/route-pending') + await expectNoBlankFrame(page, 'route') +}) diff --git a/e2e/react-router/issue-4759/tsconfig.json b/e2e/react-router/issue-4759/tsconfig.json new file mode 100644 index 00000000000..e4ba4788a35 --- /dev/null +++ b/e2e/react-router/issue-4759/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "resolveJsonModule": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/e2e/react-router/issue-4759/vite.config.ts b/e2e/react-router/issue-4759/vite.config.ts new file mode 100644 index 00000000000..9ffcc675746 --- /dev/null +++ b/e2e/react-router/issue-4759/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 3be41739960..6fc44a0a63e 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -57,7 +57,15 @@ export function Matches() { const inner = ( <> - {!(isServer ?? router.isServer) && } + {!(isServer ?? router.isServer) && ( + + )} diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index 341d09ffcca..5b483f194e1 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -15,7 +15,11 @@ export function settleOwner( settle?.(rendered) } -export function Transitioner() { +export function Transitioner({ + t, +}: { + t: React.TransitionStartFunction +}) { const router = useRouter() const acknowledgement = (router._rendered ??= []) const mounted = @@ -28,7 +32,7 @@ export function Transitioner() { new Promise((resolve, reject) => { settleOwner(acknowledgement, false) acknowledgement.push(expected, resolve) - React.startTransition(() => { + t(() => { try { fn() } catch (cause) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d02e7e8623..f61aa14b961 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1169,6 +1169,37 @@ 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-router/issue-4759: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + 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-router/issue-7120: dependencies: '@tanstack/react-router': From dbc479642deaf7ae2a7fad30f582dedfdf8c5271 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 5 Aug 2026 11:00:59 +0200 Subject: [PATCH 2/5] test(react-router): remove obsolete issue 4759 unit test --- .../tests/issue-4759-pending-frame.test.tsx | 100 ------------------ 1 file changed, 100 deletions(-) delete mode 100644 packages/react-router/tests/issue-4759-pending-frame.test.tsx diff --git a/packages/react-router/tests/issue-4759-pending-frame.test.tsx b/packages/react-router/tests/issue-4759-pending-frame.test.tsx deleted file mode 100644 index 7b8b74161c0..00000000000 --- a/packages/react-router/tests/issue-4759-pending-frame.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import * as React from 'react' -import { - afterEach, - beforeEach, - describe, - expect, - onTestFinished, - test, - vi, -} from 'vitest' -import { act, cleanup, render, screen } from '@testing-library/react' -import { - RouterProvider, - createBrowserHistory, - createRootRoute, - createRoute, - createRouter, -} from '../src' -import type { RouterHistory } from '../src' - -let history: RouterHistory - -beforeEach(() => { - history = createBrowserHistory() - expect(window.location.pathname).toBe('/') -}) - -afterEach(() => { - history.destroy() - window.history.replaceState(null, 'root', '/') - vi.resetAllMocks() - cleanup() -}) - -// Repro for https://github.com/TanStack/router/issues/4759 -// -// JSDOM cannot observe browser paints. This unit reduction verifies the event -// ordering behind the issue: pending DOM must be published before the first -// macrotask when pendingMs is 0. -describe('issue #4759: pendingMs 0 publishes pending DOM before a macrotask', () => { - test('pending fallback is committed on mount without waiting for a macrotask', async () => { - vi.useFakeTimers() - let resolveLoader!: (value: string) => void - const loaderPromise = new Promise((resolve) => { - resolveLoader = resolve - }) - - const rootRoute = createRootRoute() - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - loader: () => loaderPromise, - component: () =>
loaded
, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute]), - history, - }) - let resolveRendered!: () => void - const rendered = new Promise((resolve) => { - resolveRendered = resolve - }) - const unsubscribe = router.subscribe('onRendered', (event) => { - if (event.toLocation.pathname === '/') { - resolveRendered() - } - }) - - onTestFinished(() => { - unsubscribe() - resolveLoader('done') - vi.useRealTimers() - }) - - render( -
- ( -
pending...
- )} - /> -
, - ) - - // Fake timers keep the first macrotask frozen. An implementation that - // publishes pending state with setTimeout cannot satisfy this assertion. - await act(async () => {}) - expect(screen.getByTestId('pending')).toBeInTheDocument() - - // Sanity: the load still completes normally afterwards. - resolveLoader('done') - await act(() => rendered) - expect(screen.getByTestId('loaded')).toBeInTheDocument() - expect(screen.queryByTestId('pending')).not.toBeInTheDocument() - }) -}) From 6c3527fcf8cc82e72280860e05070a45c5c7d484 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 5 Aug 2026 16:35:27 +0200 Subject: [PATCH 3/5] alternative fix --- packages/react-router/src/Matches.tsx | 8 ++++---- packages/react-router/src/Transitioner.tsx | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 6fc44a0a63e..c7772715767 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -59,11 +59,11 @@ export function Matches() { <> {!(isServer ?? router.isServer) && ( ()[1]} /> )} diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index 5b483f194e1..a46f4e49cc5 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -18,7 +18,7 @@ export function settleOwner( export function Transitioner({ t, }: { - t: React.TransitionStartFunction + t: React.Dispatch> }) { const router = useRouter() const acknowledgement = (router._rendered ??= []) @@ -32,7 +32,8 @@ export function Transitioner({ new Promise((resolve, reject) => { settleOwner(acknowledgement, false) acknowledgement.push(expected, resolve) - t(() => { + t(router) + React.startTransition(() => { try { fn() } catch (cause) { From 35e975b5eddcee6166d120f7d0f084709771ada5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:37:27 +0000 Subject: [PATCH 4/5] ci: apply automated fixes --- packages/react-router/src/Transitioner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index a46f4e49cc5..cd20673ff16 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -18,7 +18,7 @@ export function settleOwner( export function Transitioner({ t, }: { - t: React.Dispatch> + t: React.Dispatch> }) { const router = useRouter() const acknowledgement = (router._rendered ??= []) From 282a402fde5c350b606013f33454023a96a011f9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 5 Aug 2026 22:56:26 +0200 Subject: [PATCH 5/5] simplify code comment --- packages/react-router/src/Matches.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index c7772715767..3820f99f898 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -59,9 +59,10 @@ export function Matches() { <> {!(isServer ?? router.isServer) && ( ()[1]} />