Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(react-router): prevent blank frame before initial pending UI#7961
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
46ebf8bdbc47966c3527f35e975b282a402File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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> |
| 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:^", | ||
| "@types/react": "^19.0.8", | ||
| "@types/react-dom": "^19.0.3", | ||
| "@vitejs/plugin-react": "^6.0.1", | ||
| "vite": "^8.0.14" | ||
| } | ||
| } | ||
| 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'] }, | ||
| }, | ||
| ], | ||
| }) |
| 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>, | ||
| ) |
| 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') | ||
| }) |
| 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"] | ||
| } |
| 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()], | ||
| }) |
This file was deleted.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.