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
+
+
+
+
+
+
+
+
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..3820f99f898 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -57,7 +57,16 @@ export function Matches() {
const inner = (
<>
- {!(isServer ?? router.isServer) && }
+ {!(isServer ?? router.isServer) && (
+ ()[1]}
+ />
+ )}
diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx
index 341d09ffcca..cd20673ff16 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.Dispatch>
+}) {
const router = useRouter()
const acknowledgement = (router._rendered ??= [])
const mounted =
@@ -28,6 +32,7 @@ export function Transitioner() {
new Promise((resolve, reject) => {
settleOwner(acknowledgement, false)
acknowledgement.push(expected, resolve)
+ t(router)
React.startTransition(() => {
try {
fn()
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()
- })
-})
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':