Skip to content

Commit 9351e99

Browse files
fix: Fix React Fast Refresh state preservation for auto code-split ro… (#7000)
1 parent 21e39bd commit 9351e99

38 files changed

Lines changed: 931 additions & 399 deletions

.changeset/fair-llamas-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/router-plugin': patch
3+
---
4+
5+
Fix React Fast Refresh state preservation for auto code-split route components during HMR updates.

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ node_modules
1616
**/tests/generator/**/routeTree*.snapshot.ts
1717
/.nx/workspace-data
1818
**/src/routeTree.gen.ts
19+
packages/router-plugin/tests/**/test-files/**
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { chromium } from '@playwright/test'
2+
import type { Page } from '@playwright/test'
3+
4+
export async function waitForServer(url: string) {
5+
const start = Date.now()
6+
7+
while (Date.now() - start < 30_000) {
8+
const controller = new AbortController()
9+
const timer = setTimeout(() => controller.abort(), 5_000)
10+
11+
try {
12+
const res = await fetch(url, {
13+
redirect: 'manual',
14+
signal: controller.signal,
15+
})
16+
17+
if (res.status >= 200 && res.status < 400) {
18+
return
19+
}
20+
} catch {
21+
// ignore aborted/network errors
22+
} finally {
23+
clearTimeout(timer)
24+
}
25+
26+
await new Promise((resolve) => setTimeout(resolve, 250))
27+
}
28+
29+
throw new Error(`Timed out waiting for dev server at ${url}`)
30+
}
31+
32+
export async function preOptimizeDevServer(opts: {
33+
baseURL: string
34+
readyTestId?: string
35+
warmup?: (page: Page) => Promise<void>
36+
}) {
37+
const browser = await chromium.launch()
38+
const context = await browser.newContext()
39+
const page = await context.newPage()
40+
41+
try {
42+
await page.goto(`${opts.baseURL}/`, { waitUntil: 'domcontentloaded' })
43+
44+
if (opts.readyTestId) {
45+
await page.getByTestId(opts.readyTestId).waitFor({ state: 'visible' })
46+
}
47+
48+
await page.waitForLoadState('networkidle')
49+
50+
await opts.warmup?.(page)
51+
52+
for (let i = 0; i < 40; i++) {
53+
const currentUrl = page.url()
54+
await page.waitForTimeout(250)
55+
56+
if (page.url() === currentUrl) {
57+
await page.waitForTimeout(250)
58+
if (page.url() === currentUrl) {
59+
return
60+
}
61+
}
62+
}
63+
64+
throw new Error('Dev server did not reach a stable URL after warmup')
65+
} finally {
66+
await context.close()
67+
await browser.close()
68+
}
69+
}

e2e/e2e-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ export { localDummyServer } from './localDummyServer'
33
export { toRuntimePath } from './to-runtime-path'
44
export { resolveRuntimeSuffix } from './resolve-runtime-suffix'
55
export { e2eStartDummyServer, e2eStopDummyServer } from './e2eSetupTeardown'
6+
export { preOptimizeDevServer, waitForServer } from './devServerWarmup'
67
export type { Post } from './posts'
78
export { test } from './fixture'
Lines changed: 38 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,11 @@
1-
import { chromium } from '@playwright/test'
21
import {
32
e2eStartDummyServer,
43
getTestServerPort,
4+
preOptimizeDevServer,
5+
waitForServer,
56
} from '@tanstack/router-e2e-utils'
67
import packageJson from '../../package.json' with { type: 'json' }
78

8-
async function waitForServer(url: string) {
9-
const start = Date.now()
10-
while (Date.now() - start < 30_000) {
11-
const controller = new AbortController()
12-
const timer = setTimeout(() => controller.abort(), 5_000)
13-
try {
14-
const res = await fetch(url, {
15-
redirect: 'manual',
16-
signal: controller.signal,
17-
})
18-
if (res.status >= 200 && res.status < 400) return
19-
} catch {
20-
// ignore aborted/network errors
21-
} finally {
22-
clearTimeout(timer)
23-
}
24-
await new Promise((r) => setTimeout(r, 250))
25-
}
26-
throw new Error(`Timed out waiting for dev server at ${url}`)
27-
}
28-
29-
async function preOptimizeDevServer(baseURL: string) {
30-
const browser = await chromium.launch()
31-
const context = await browser.newContext()
32-
const page = await context.newPage()
33-
34-
try {
35-
await page.goto(`${baseURL}/`, { waitUntil: 'domcontentloaded' })
36-
await page.getByTestId('global-styled').waitFor({ state: 'visible' })
37-
await page.waitForLoadState('networkidle')
38-
39-
await page.goto(`${baseURL}/modules`, { waitUntil: 'domcontentloaded' })
40-
await page.getByTestId('module-card').waitFor({ state: 'visible' })
41-
await page.waitForLoadState('networkidle')
42-
43-
await page.goto(`${baseURL}/sass-mixin`, { waitUntil: 'domcontentloaded' })
44-
await page.getByTestId('mixin-styled').waitFor({ state: 'visible' })
45-
await page.waitForLoadState('networkidle')
46-
47-
await page.goto(`${baseURL}/quotes`, { waitUntil: 'domcontentloaded' })
48-
await page.getByTestId('quote-styled').waitFor({ state: 'visible' })
49-
await page.getByTestId('after-quote-styled').waitFor({ state: 'visible' })
50-
await page.waitForLoadState('networkidle')
51-
52-
// Exercise client-side navigation so Vite discovers any remaining deps
53-
// that only load via the client router (not full-page navigations).
54-
await page.goto(`${baseURL}/`, { waitUntil: 'domcontentloaded' })
55-
await page.getByTestId('global-styled').waitFor({ state: 'visible' })
56-
await page.waitForLoadState('networkidle')
57-
58-
await page.getByTestId('nav-modules').click()
59-
await page.waitForURL('**/modules')
60-
await page.getByTestId('module-card').waitFor({ state: 'visible' })
61-
await page.waitForLoadState('networkidle')
62-
63-
await page.getByTestId('nav-home').click()
64-
await page.waitForURL(/\/([^/]*)(\/)?($|\?)/)
65-
await page.getByTestId('global-styled').waitFor({ state: 'visible' })
66-
await page.waitForLoadState('networkidle')
67-
68-
// Ensure we end in a stable state. Vite's optimize step triggers a reload;
69-
// this waits until no further navigations happen for a short window.
70-
for (let i = 0; i < 40; i++) {
71-
const currentUrl = page.url()
72-
await page.waitForTimeout(250)
73-
if (page.url() === currentUrl) {
74-
await page.waitForTimeout(250)
75-
if (page.url() === currentUrl) return
76-
}
77-
}
78-
79-
throw new Error('Dev server did not reach a stable URL after warmup')
80-
} finally {
81-
await context.close()
82-
await browser.close()
83-
}
84-
}
85-
869
export default async function setup() {
8710
await e2eStartDummyServer(packageJson.name)
8811

@@ -96,5 +19,40 @@ export default async function setup() {
9619
const baseURL = `http://localhost:${port}${basePath}`
9720

9821
await waitForServer(baseURL)
99-
await preOptimizeDevServer(baseURL)
22+
await preOptimizeDevServer({
23+
baseURL,
24+
readyTestId: 'global-styled',
25+
warmup: async (page) => {
26+
await page.goto(`${baseURL}/modules`, { waitUntil: 'domcontentloaded' })
27+
await page.getByTestId('module-card').waitFor({ state: 'visible' })
28+
await page.waitForLoadState('networkidle')
29+
30+
await page.goto(`${baseURL}/sass-mixin`, {
31+
waitUntil: 'domcontentloaded',
32+
})
33+
await page.getByTestId('mixin-styled').waitFor({ state: 'visible' })
34+
await page.waitForLoadState('networkidle')
35+
36+
await page.goto(`${baseURL}/quotes`, { waitUntil: 'domcontentloaded' })
37+
await page.getByTestId('quote-styled').waitFor({ state: 'visible' })
38+
await page.getByTestId('after-quote-styled').waitFor({
39+
state: 'visible',
40+
})
41+
await page.waitForLoadState('networkidle')
42+
43+
await page.goto(`${baseURL}/`, { waitUntil: 'domcontentloaded' })
44+
await page.getByTestId('global-styled').waitFor({ state: 'visible' })
45+
await page.waitForLoadState('networkidle')
46+
47+
await page.getByTestId('nav-modules').click()
48+
await page.waitForURL('**/modules')
49+
await page.getByTestId('module-card').waitFor({ state: 'visible' })
50+
await page.waitForLoadState('networkidle')
51+
52+
await page.getByTestId('nav-home').click()
53+
await page.waitForURL(/\/([^/]*)(\/)?($|\?)/)
54+
await page.getByTestId('global-styled').waitFor({ state: 'visible' })
55+
await page.waitForLoadState('networkidle')
56+
},
57+
})
10058
}
Lines changed: 6 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { chromium } from '@playwright/test'
21
import {
32
e2eStartDummyServer,
43
getTestServerPort,
4+
preOptimizeDevServer,
5+
waitForServer,
56
} from '@tanstack/router-e2e-utils'
67
import packageJson from '../../package.json' with { type: 'json' }
78
import { ssrStylesMode, useNitro } from '../../env'
@@ -17,54 +18,6 @@ function getPortKey() {
1718
return key
1819
}
1920

20-
async function waitForServer(url: string) {
21-
const start = Date.now()
22-
while (Date.now() - start < 30_000) {
23-
const controller = new AbortController()
24-
const timer = setTimeout(() => controller.abort(), 5_000)
25-
try {
26-
const res = await fetch(url, {
27-
redirect: 'manual',
28-
signal: controller.signal,
29-
})
30-
if (res.status >= 200 && res.status < 400) return
31-
} catch {
32-
// ignore aborted/network errors
33-
} finally {
34-
clearTimeout(timer)
35-
}
36-
await new Promise((r) => setTimeout(r, 250))
37-
}
38-
throw new Error(`Timed out waiting for dev server at ${url}`)
39-
}
40-
41-
async function preOptimizeDevServer(baseURL: string) {
42-
const browser = await chromium.launch()
43-
const context = await browser.newContext()
44-
const page = await context.newPage()
45-
46-
try {
47-
await page.goto(`${baseURL}/`, { waitUntil: 'domcontentloaded' })
48-
await page.getByTestId('home-heading').waitFor({ state: 'visible' })
49-
await page.waitForLoadState('networkidle')
50-
51-
// Ensure we end in a stable state
52-
for (let i = 0; i < 40; i++) {
53-
const currentUrl = page.url()
54-
await page.waitForTimeout(250)
55-
if (page.url() === currentUrl) {
56-
await page.waitForTimeout(250)
57-
if (page.url() === currentUrl) return
58-
}
59-
}
60-
61-
throw new Error('Dev server did not reach a stable URL after warmup')
62-
} finally {
63-
await context.close()
64-
await browser.close()
65-
}
66-
}
67-
6821
export default async function setup() {
6922
const portKey = getPortKey()
7023

@@ -76,5 +29,8 @@ export default async function setup() {
7629
const baseURL = `http://localhost:${port}`
7730

7831
await waitForServer(baseURL)
79-
await preOptimizeDevServer(baseURL)
32+
await preOptimizeDevServer({
33+
baseURL,
34+
readyTestId: 'home-heading',
35+
})
8036
}

e2e/react-start/hmr/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
dist
3+
test-results
4+
playwright-report
5+
port*.txt

e2e/react-start/hmr/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "tanstack-react-start-e2e-hmr",
3+
"private": true,
4+
"sideEffects": false,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite dev --port 3000",
8+
"dev:e2e": "vite dev --port $PORT",
9+
"build": "vite build && tsc --noEmit",
10+
"test:e2e": "MODE=dev playwright test --project=chromium"
11+
},
12+
"dependencies": {
13+
"@tanstack/react-router": "workspace:^",
14+
"@tanstack/react-start": "workspace:^",
15+
"react": "^19.0.0",
16+
"react-dom": "^19.0.0"
17+
},
18+
"devDependencies": {
19+
"@playwright/test": "^1.50.1",
20+
"@tanstack/router-e2e-utils": "workspace:^",
21+
"@types/node": "^22.10.2",
22+
"@types/react": "^19.0.8",
23+
"@types/react-dom": "^19.0.3",
24+
"@vitejs/plugin-react": "^6.0.1",
25+
"typescript": "^5.7.2",
26+
"vite": "^8.0.0"
27+
}
28+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { defineConfig, devices } from '@playwright/test'
2+
import { getTestServerPort } from '@tanstack/router-e2e-utils'
3+
import packageJson from './package.json' with { type: 'json' }
4+
5+
const PORT = await getTestServerPort(packageJson.name)
6+
const baseURL = `http://localhost:${PORT}`
7+
8+
export default defineConfig({
9+
testDir: './tests',
10+
workers: 1,
11+
reporter: [['line']],
12+
13+
globalSetup: './tests/setup/global.setup.ts',
14+
globalTeardown: './tests/setup/global.teardown.ts',
15+
16+
use: {
17+
baseURL,
18+
},
19+
20+
webServer: {
21+
command: `pnpm dev:e2e`,
22+
url: baseURL,
23+
reuseExistingServer: !process.env.CI,
24+
stdout: 'pipe',
25+
env: {
26+
VITE_NODE_ENV: 'test',
27+
PORT: String(PORT),
28+
},
29+
},
30+
31+
projects: [
32+
{
33+
name: 'chromium',
34+
use: {
35+
...devices['Desktop Chrome'],
36+
},
37+
},
38+
],
39+
})

0 commit comments

Comments
 (0)