diff --git a/.changeset/lazy-spa-shell-dev.md b/.changeset/lazy-spa-shell-dev.md new file mode 100644 index 00000000..8f573140 --- /dev/null +++ b/.changeset/lazy-spa-shell-dev.md @@ -0,0 +1,5 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Serve the React Router SPA shell during `rsbuild dev` when `ssr` is disabled. diff --git a/benchmarks/README.md b/benchmarks/README.md index f7626dc3..cabef6a9 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -63,13 +63,6 @@ node scripts/bench-builds.mts --profile=large --mode=dev --dev-routes=/,/route-0 Numeric route indexes are ordinal benchmark positions: `0` is `/`, and `1` is the first non-index generated route for that fixture. -Profile entries can pin dev-route behavior with a `devRoutes` field that takes -precedence over the CLI flag. SPA fixtures (`ssr: false`) set -`devRoutes: 'none'` because the dev server has no HTML document to serve for -route paths (no SSR middleware, and all web entries disable HTML generation), -so those fixtures measure dev readiness and update rebuild timings without -issuing route requests. - Route requests automatically add `--experimental-vm-modules` to `NODE_OPTIONS` for SSR ESM evaluation. diff --git a/examples/spa-mode/package.json b/examples/spa-mode/package.json index b061a793..fd108c8e 100644 --- a/examples/spa-mode/package.json +++ b/examples/spa-mode/package.json @@ -7,7 +7,7 @@ "dev": "NODE_OPTIONS=\"--experimental-vm-modules --experimental-global-webcrypto\" rsbuild dev", "start": "serve build/client -l 3001 -s", "typecheck": "react-router typegen && tsc", - "test:e2e": "playwright test" + "test:e2e": "playwright test && playwright test --config playwright.dev.config.ts" }, "dependencies": { "@react-router/express": "^7.13.0", diff --git a/examples/spa-mode/playwright.dev.config.ts b/examples/spa-mode/playwright.dev.config.ts new file mode 100644 index 00000000..c2bde6fa --- /dev/null +++ b/examples/spa-mode/playwright.dev.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from '@playwright/test'; +import baseConfig from './playwright.config'; + +// Dev-mode config — runs the SPA against `rsbuild dev` instead of the +// prerendered static build. Kept separate from playwright.config.ts because +// the dev server writes to the same build directory the static suite asserts +// against, so the two servers cannot run side by side. +export default defineConfig({ + ...baseConfig, + testDir: './tests/e2e-dev', + use: { + ...baseConfig.use, + baseURL: 'http://localhost:3002', + }, + webServer: { + ...baseConfig.webServer, + command: 'pnpm run dev --port 3002', + url: undefined, + wait: { + stdout: + /ready[\s\S]*?\(web\)[\s\S]*?ready[\s\S]*?\(node\)|ready[\s\S]*?\(node\)[\s\S]*?ready[\s\S]*?\(web\)/, + }, + }, +}); diff --git a/examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts b/examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts new file mode 100644 index 00000000..2a2948fc --- /dev/null +++ b/examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; + +// Regression coverage for SPA mode (`ssr: false`) under `rsbuild dev`. +// The dev server used to return 404 for every path because no dev middleware +// was registered and no HTML entry existed; these tests hit the dev server +// directly (see playwright.dev.config.ts), unlike the static-build suite. +test.describe('SPA Mode dev server', () => { + test('serves the SPA shell at /', async ({ page }) => { + const response = await page.goto('/'); + + expect(response?.status()).toBe(200); + expect(response?.headers()['content-type']).toContain('text/html'); + + // The shell carries the SPA hydration payload. + const html = (await response?.text()) ?? ''; + expect(html).toContain('window.__reactRouterContext'); + expect(html).toContain('"isSpaMode":true'); + expect(html).toContain('"ssr":false'); + expect(html).toContain('entry.client.js'); + + // The page hydrates and renders the home route client-side. + await expect( + page.locator('h1:has-text("Welcome to React Router")') + ).toBeVisible(); + }); + + test('serves the SPA shell for deep links', async ({ page }) => { + const response = await page.goto('/about'); + + expect(response?.status()).toBe(200); + await expect(page.locator('h1:has-text("About This Demo")')).toBeVisible(); + }); + + test('serves the SPA shell for nested routes', async ({ page }) => { + await page.goto('/docs/getting-started'); + await expect( + page.locator('h1:has-text("Getting Started")') + ).toBeVisible(); + }); + + test('performs client-side navigation after hydration', async ({ page }) => { + await page.goto('/'); + + await page.locator('a[href="/about"]').first().click(); + await expect(page).toHaveURL('/about'); + await expect(page.locator('h1:has-text("About This Demo")')).toBeVisible(); + }); +}); diff --git a/scripts/bench-builds.mts b/scripts/bench-builds.mts index 0ab266b2..d02fe276 100644 --- a/scripts/bench-builds.mts +++ b/scripts/bench-builds.mts @@ -495,8 +495,12 @@ const runBenchmarkIteration = (benchmarkContext, index) => } : {}), }, - readyEnvironments: - benchmark.variant === 'spa' ? ['web'] : ['web', 'node'], + // The plugin builds `web` + `node` dev environments for every + // fixture variant. SPA (`ssr:false`) fixtures need `node` too: + // the dev middleware renders the SPA shell from the node server + // build, so route fetches would race an unfinished node compile + // if we only waited for `web`. + readyEnvironments: ['web', 'node'], origin: `http://localhost:${devPort}`, routePaths: devRoutePaths, routeTimeoutMs: args.devRouteTimeoutMs, @@ -592,12 +596,9 @@ const runBenchmark = ({ Effect.gen(function* () { const measuredIterations = getMeasuredIterationCount(benchmark, args); const fixtureRoot = path.join(benchmarkRoot, 'fixtures', benchmark.id); - // Profile entries can pin dev-route behavior (e.g. `devRoutes: 'none'` - // for SPA fixtures, which serve no HTML document per route in dev). - const devRoutesValue = benchmark.devRoutes ?? args.devRoutes; const devRoutePaths = args.mode === 'dev' - ? resolveDevRoutePaths(devRoutesValue, benchmark) + ? resolveDevRoutePaths(args.devRoutes, benchmark) : []; const fixtureResult = yield* tryPromise(() => generateSyntheticFixture({ @@ -614,7 +615,7 @@ const runBenchmark = ({ ); const totalRuns = args.warmup + measuredIterations; const devUpdateRoutePaths = - args.mode === 'dev' && devRoutesValue !== 'none' + args.mode === 'dev' && args.devRoutes !== 'none' ? (fixtureResult.updateRoutePaths ?? ['/']) : []; const benchmarkContext = { diff --git a/scripts/benchmark/dev-server.mjs b/scripts/benchmark/dev-server.mjs index 1efdb4d4..321b9a66 100644 --- a/scripts/benchmark/dev-server.mjs +++ b/scripts/benchmark/dev-server.mjs @@ -2,7 +2,13 @@ import { spawn } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; import { performance } from 'node:perf_hooks'; -const READY_LOG_PATTERN = /ready\s+built in .*?\((web|node)\)/gi; +// Rsbuild 2.x announces per-environment readiness with a trailing +// `()` token, e.g. `ready built in 0.51s (node)` / +// `ready built in 0.74s (web)`. The environment name is captured +// generically (any identifier); which environments to wait for is decided by +// the caller via `readyEnvironments`. +const READY_LOG_PATTERN = /ready\s+built in .*?\(([\w:-]+)\)/gi; + const MAX_CAPTURED_OUTPUT_CHARS = 128 * 1024; export const appendNodeOption = (value, option) => { @@ -170,6 +176,9 @@ export const runDevServerBenchmark = async ({ cwd, env = {}, shell = process.platform === 'win32', + // The exact set of environments that must print a ready line before startup + // is considered complete. The caller derives this from the fixture (the + // plugin builds `web` + `node` dev environments for both ssr and spa apps). readyEnvironments, origin, routePaths = [], @@ -361,6 +370,28 @@ export const runDevServerBenchmark = async ({ const timeoutTimer = setTimeout(() => { timedOut = true; + // Fail loudly about *why* we timed out: report which expected + // environments never printed a ready line vs. which did, so a renamed + // or removed dev environment surfaces as an actionable message instead + // of an opaque hang. + if (!ready) { + const awaiting = [...requiredReady].filter( + environment => !readyCounts.has(environment) + ); + const seen = [...readyCounts.keys()]; + appendError( + new Error( + `Dev server did not become ready within ${timeoutMs} ms. ` + + `Awaiting ready lines for environment(s): ` + + `[${awaiting.join(', ')}]. ` + + `Environments observed ready: [${seen.join(', ') || '(none)'}]. ` + + `Readiness is matched against rsbuild ` + + `"ready built in ... ()" lines; if the plugin renamed a ` + + `dev environment, update the expected environments in ` + + `scripts/bench-builds.mts.` + ) + ); + } stopChild(); }, timeoutMs); timeoutTimer.unref?.(); diff --git a/scripts/benchmark/profiles.mjs b/scripts/benchmark/profiles.mjs index d7a15f9e..7ebd8a15 100644 --- a/scripts/benchmark/profiles.mjs +++ b/scripts/benchmark/profiles.mjs @@ -11,11 +11,6 @@ export const profiles = { id: 'synthetic-256-spa', routeCount: 256, variant: 'spa', - // SPA fixtures (`ssr: false`) have no dev-server HTML document for - // route paths: the plugin registers no SSR middleware in dev and all - // web entries disable HTML generation, so every route fetch would 404. - // Measure dev readiness and update rebuilds only. - devRoutes: 'none', }, { id: 'synthetic-256-sourcemaps', diff --git a/src/index.ts b/src/index.ts index 2f5a5100..7238c38e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -769,7 +769,11 @@ export const pluginReactRouter = ( ...lazyCompilation, watchFiles: mergeWatchFiles(config.dev?.watchFiles, routeWatchFiles), }, - ...(pluginOptions.customServer || !ssr + // React Router's request handler natively supports `ssr:false` + // builds (it renders the SPA shell for document requests), so the + // middleware is registered for SPA mode too — without it, dev + // requests would 404 because no HTML entry exists. + ...(pluginOptions.customServer ? {} : { server: { diff --git a/tests/features.test.ts b/tests/features.test.ts index a247b405..6e1489f2 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -65,7 +65,7 @@ describe('pluginReactRouter', () => { ); }); - it('should not register the dev server middleware in SPA mode', async () => { + it('should register the dev middleware for SPA mode (ssr: false)', async () => { testGlobal.__reactRouterTestConfig = { ssr: false }; const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, @@ -74,10 +74,7 @@ describe('pluginReactRouter', () => { rsbuild.addPlugins([pluginReactRouter()]); const config = await rsbuild.unwrapConfig(); - expect(config.dev.setupMiddlewares).toBeUndefined(); - expect(getServerSetupNames(config)).not.toContain( - 'reactRouterDevServerSetup' - ); + expect(config.server.setup).toHaveLength(1); }); it('should configure server output format correctly', async () => {