Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/bright-frames-settle.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions e2e/react-router/issue-4759/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Issue 4759</title>
<style>
html,
body,
#app,
[data-state] {
height: 100%;
margin: 0;
}

[data-state='html'] {
background: blue;
}

[data-state='shell'] {
background: red;
}

[data-state='pending'] {
background: yellow;
}

[data-state='loaded'] {
background: green;
}
</style>
</head>
<body>
<div id="app">
<div data-state="html">HTML fallback</div>
</div>
<script>
globalThis.__paintStates = []

function recordPaintState() {
const app = document.getElementById('app')
const state = app.querySelector('[data-state="loaded"]')
? 'loaded'
: app.querySelector('[data-state="pending"]')
? 'pending'
: app.querySelector('[data-state="shell"]')
? 'shell'
: app.querySelector('[data-state="html"]')
? 'html'
: 'empty'

globalThis.__paintStates.push(state)
if (state !== 'loaded') {
requestAnimationFrame(recordPaintState)
}
}

requestAnimationFrame(recordPaintState)
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
26 changes: 26 additions & 0 deletions e2e/react-router/issue-4759/package.json
Original file line number Diff line number Diff line change
@@ -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:^",
Comment thread
Sheraff marked this conversation as resolved.
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.14"
}
}
25 changes: 25 additions & 0 deletions e2e/react-router/issue-4759/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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'] },
},
],
})
50 changes: 50 additions & 0 deletions e2e/react-router/issue-4759/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-state="loaded">loaded</div>,
})
const routePendingRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/route-pending',
pendingMs: 0,
pendingMinMs: 0,
pendingComponent: () => (
<div data-state="pending" data-pending-source="route">
route pending
</div>
),
loader: () => new Promise((resolve) => setTimeout(resolve, 1_000)),
component: () => <div data-state="loaded">loaded</div>,
})
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, routePendingRoute]),
})
const usesRoutePending = window.location.pathname === '/route-pending'

createRoot(document.getElementById('app')!).render(
<StrictMode>
<main data-state="shell">
<RouterProvider
router={router}
defaultPendingMs={usesRoutePending ? 1_000 : 0}
defaultPendingMinMs={0}
defaultPendingComponent={() => (
<div data-state="pending" data-pending-source="default">
default pending
</div>
)}
/>
</main>
</StrictMode>,
)
38 changes: 38 additions & 0 deletions e2e/react-router/issue-4759/tests/issue-4759.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> })
.__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')
})
13 changes: 13 additions & 0 deletions e2e/react-router/issue-4759/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
6 changes: 6 additions & 0 deletions e2e/react-router/issue-4759/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
plugins: [react()],
})
10 changes: 9 additions & 1 deletion packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,15 @@ export function Matches() {

const inner = (
<>
{!(isServer ?? router.isServer) && <Transitioner />}
{!(isServer ?? router.isServer) && (
<Transitioner
// A component-scoped transition schedules the match-tree owner when a
// publication starts. This matters on mount, before the match-store
// subscription has committed, while leaving the store as the source of truth.
// eslint-disable-next-line react-hooks/rules-of-hooks -- server only, condition is static
t={React.useTransition()[1]}
/>
)}
<ResolvedSuspense fallback={pendingElement}>
<MatchesInner />
</ResolvedSuspense>
Expand Down
8 changes: 6 additions & 2 deletions packages/react-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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) {
Expand Down
100 changes: 0 additions & 100 deletions packages/react-router/tests/issue-4759-pending-frame.test.tsx

This file was deleted.

Loading
Loading