From 7d2914bca026accf51c0d79d032384d949d09a15 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:03:44 +0000 Subject: [PATCH 1/8] fix: migrate dev middleware registration to server.setup Rsbuild 2.1 deprecates `dev.setupMiddlewares` in favor of `server.setup`. Register the React Router dev middleware through a `server.setup` function that returns a post-built-in-middlewares callback, preserving the ordering guarantee that static assets are served before the request handler. Co-Authored-By: Claude Fable 5 --- src/dev-server.ts | 23 ++++++++++++++++++++ src/index.ts | 26 +++++++++++----------- tests/features.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++- tests/setup.ts | 1 - 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/dev-server.ts b/src/dev-server.ts index f404a233..3b0e3281 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -1,6 +1,12 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { RsbuildConfig } from '@rsbuild/core'; import type { ServerBuild } from 'react-router'; +export type ServerSetup = Exclude< + NonNullable['setup']>, + unknown[] +>; + export type DevServerMiddleware = ( req: IncomingMessage, res: ServerResponse, @@ -63,3 +69,20 @@ export const createDevServerMiddleware = ( } }; }; + +export const createReactRouterDevServerSetup = ({ + loadBuild, +}: { + loadBuild: BuildProvider; +}): ServerSetup => + function reactRouterDevServerSetup(context) { + if (context.action !== 'dev') { + return; + } + // The returned callback runs after Rsbuild registers its built-in + // middlewares, so static assets are served before the React Router + // request handler. + return () => { + context.server.middlewares.use(createDevServerMiddleware({ loadBuild })); + }; + }; diff --git a/src/index.ts b/src/index.ts index 81d28804..2f5a5100 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ import { PLUGIN_NAME, } from './constants.js'; import { guardReactRouterLazyCompilation } from './lazy-compilation.js'; -import { createDevServerMiddleware } from './dev-server.js'; +import { createReactRouterDevServerSetup } from './dev-server.js'; import { generateWithProps, findEntryFile, @@ -768,19 +768,19 @@ export const pluginReactRouter = ( writeToDisk: true, ...lazyCompilation, watchFiles: mergeWatchFiles(config.dev?.watchFiles, routeWatchFiles), - setupMiddlewares: - pluginOptions.customServer || !ssr - ? [] - : [ - middlewares => { - middlewares.push( - createDevServerMiddleware({ - loadBuild: devRuntime.createBuildLoader(), - }) - ); - }, - ], }, + ...(pluginOptions.customServer || !ssr + ? {} + : { + server: { + setup: [ + createReactRouterDevServerSetup({ + // Lazy: the dev runtime binding does not exist yet here. + loadBuild: () => devRuntime.createBuildLoader()(), + }), + ], + }, + }), tools: { rspack: { plugins: [vmodPlugin], diff --git a/tests/features.test.ts b/tests/features.test.ts index b7e25807..a247b405 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -6,6 +6,20 @@ import path from 'node:path'; import { pluginReactRouter } from '../src'; import { getVirtualModuleFilePath } from '../src/virtual-modules'; +type ReactRouterTestGlobal = typeof globalThis & { + __reactRouterTestConfig?: unknown; +}; + +const testGlobal = globalThis as ReactRouterTestGlobal; + +const getServerSetupNames = (config: { + server?: { setup?: unknown }; +}): string[] => + [config.server?.setup] + .flat() + .filter((fn): fn is (...args: unknown[]) => unknown => Boolean(fn)) + .map(fn => fn.name); + describe('pluginReactRouter', () => { describe('basic configuration', () => { it('should apply default options when no options provided', async () => { @@ -22,6 +36,21 @@ describe('pluginReactRouter', () => { expect(config.dev.writeToDisk).toBe(true); }); + it('should register the dev server middleware via server.setup', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + // The deprecated `dev.setupMiddlewares` API must stay untouched. + expect(config.dev.setupMiddlewares).toBeUndefined(); + expect(getServerSetupNames(config)).toContain( + 'reactRouterDevServerSetup' + ); + }); + it('should respect customServer option', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, @@ -30,7 +59,25 @@ describe('pluginReactRouter', () => { rsbuild.addPlugins([pluginReactRouter({ customServer: true })]); const config = await rsbuild.unwrapConfig(); - expect(config.dev.setupMiddlewares).toEqual([]); + expect(config.dev.setupMiddlewares).toBeUndefined(); + expect(getServerSetupNames(config)).not.toContain( + 'reactRouterDevServerSetup' + ); + }); + + it('should not register the dev server middleware in SPA mode', async () => { + testGlobal.__reactRouterTestConfig = { ssr: false }; + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.setupMiddlewares).toBeUndefined(); + expect(getServerSetupNames(config)).not.toContain( + 'reactRouterDevServerSetup' + ); }); it('should configure server output format correctly', async () => { diff --git a/tests/setup.ts b/tests/setup.ts index ce03b4c8..4b5f77df 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -105,7 +105,6 @@ rstest.mock('@scripts/test-helper', () => ({ hmr: true, liveReload: true, writeToDisk: false, - setupMiddlewares: [], }, environments: { web: { From 7ce1f3e80fd88a560a6f715733789c2a76e6339b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:44:11 +0000 Subject: [PATCH 2/8] fix: serve the SPA shell for ssr:false apps in rsbuild dev SPA mode apps returned 404 for every route in dev because the plugin skipped its dev middleware when ssr:false and all web entries disable HTML generation, so no HTML document existed. React Router's request handler natively renders the SPA shell for ssr:false builds, and the dev server build (isSpaMode: true) is already compiled by the node environment, so register the middleware for SPA mode too. - Add a dev-mode Playwright suite to examples/spa-mode (separate config since the dev server writes into the build/ directory the static suite asserts against) - Remove the devRoutes: 'none' benchmark override from PR #75 and wait for the node environment before fetching SPA dev routes, since route serving now depends on the server build Co-Authored-By: Claude Fable 5 --- examples/spa-mode/package.json | 2 +- examples/spa-mode/playwright.dev.config.ts | 35 +++++++++++++ .../tests/e2e-dev/spa-mode-dev.test.ts | 50 +++++++++++++++++++ scripts/bench-builds.mts | 10 ++-- scripts/benchmark/profiles.mjs | 5 -- src/index.ts | 6 ++- tests/features.test.ts | 14 ++++++ 7 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 examples/spa-mode/playwright.dev.config.ts create mode 100644 examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts 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..7d9bbeae --- /dev/null +++ b/examples/spa-mode/playwright.dev.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +// 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({ + testDir: './tests/e2e-dev', + timeout: 30 * 1000, + expect: { + timeout: 5000, + }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: 'list', + use: { + baseURL: 'http://localhost:3002', + headless: true, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm run dev --port 3002', + url: 'http://localhost:3002', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); 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..6c42b240 --- /dev/null +++ b/examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts @@ -0,0 +1,50 @@ +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 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(); + + // Nested routes resolve as document requests too. + await page.goto('/docs/getting-started'); + await expect( + page.locator('h1:has-text("Getting Started")') + ).toBeVisible(); + }); + + test('document responses contain SPA hydration data', async ({ page }) => { + const response = await page.goto('/'); + 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'); + }); + + 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..3be3a9a2 100644 --- a/scripts/bench-builds.mts +++ b/scripts/bench-builds.mts @@ -495,8 +495,10 @@ const runBenchmarkIteration = (benchmarkContext, index) => } : {}), }, - readyEnvironments: - benchmark.variant === 'spa' ? ['web'] : ['web', 'node'], + // SPA dev route serving goes through the node server build + // (the dev middleware renders the SPA shell from it), so wait + // for both environments before fetching routes. + readyEnvironments: ['web', 'node'], origin: `http://localhost:${devPort}`, routePaths: devRoutePaths, routeTimeoutMs: args.devRouteTimeoutMs, @@ -592,8 +594,8 @@ 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). + // Profile entries can pin dev-route behavior (`devRoutes: 'none'` skips + // per-route fetches for a fixture). const devRoutesValue = benchmark.devRoutes ?? args.devRoutes; const devRoutePaths = args.mode === 'dev' 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..219a2c5e 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -80,6 +80,20 @@ describe('pluginReactRouter', () => { ); }); + it('should register the dev middleware for SPA mode (ssr: false)', async () => { + (globalThis as { __reactRouterTestConfig?: unknown }).__reactRouterTestConfig = + { ssr: false }; + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.setupMiddlewares).toHaveLength(1); + expect(config.dev.setupMiddlewares[0]).toBeInstanceOf(Function); + }); + it('should configure server output format correctly', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, From dbc09778de56706579e9abecd0e8af00685a9f24 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:49:57 +0000 Subject: [PATCH 3/8] refactor: dedupe SPA dev test scaffolding and drop dead bench override Review cleanup: derive playwright.dev.config.ts from the sibling static config instead of copying it, reuse the testGlobal pattern in features.test.ts, fold the hydration-payload assertions into the shell test to drop a duplicate navigation, and remove the per-profile benchmark devRoutes override mechanism now that nothing produces it. Co-Authored-By: Claude Fable 5 --- benchmarks/README.md | 7 ------ examples/spa-mode/playwright.dev.config.ts | 25 ++++--------------- .../tests/e2e-dev/spa-mode-dev.test.ts | 17 ++++++------- scripts/bench-builds.mts | 7 ++---- tests/features.test.ts | 4 +-- 5 files changed, 15 insertions(+), 45 deletions(-) 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/playwright.dev.config.ts b/examples/spa-mode/playwright.dev.config.ts index 7d9bbeae..0da49bae 100644 --- a/examples/spa-mode/playwright.dev.config.ts +++ b/examples/spa-mode/playwright.dev.config.ts @@ -1,35 +1,20 @@ -import { defineConfig, devices } from '@playwright/test'; +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', - timeout: 30 * 1000, - expect: { - timeout: 5000, - }, - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - reporter: 'list', use: { + ...baseConfig.use, baseURL: 'http://localhost:3002', - headless: true, - trace: 'on-first-retry', - screenshot: 'only-on-failure', }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], webServer: { + ...baseConfig.webServer, command: 'pnpm run dev --port 3002', url: 'http://localhost:3002', - reuseExistingServer: !process.env.CI, - timeout: 120000, }, }); 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 index 6c42b240..12031918 100644 --- a/examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts +++ b/examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts @@ -11,6 +11,13 @@ test.describe('SPA Mode dev server', () => { 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")') @@ -30,16 +37,6 @@ test.describe('SPA Mode dev server', () => { ).toBeVisible(); }); - test('document responses contain SPA hydration data', async ({ page }) => { - const response = await page.goto('/'); - 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'); - }); - test('performs client-side navigation after hydration', async ({ page }) => { await page.goto('/'); diff --git a/scripts/bench-builds.mts b/scripts/bench-builds.mts index 3be3a9a2..7a7d33d8 100644 --- a/scripts/bench-builds.mts +++ b/scripts/bench-builds.mts @@ -594,12 +594,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 (`devRoutes: 'none'` skips - // per-route fetches for a fixture). - const devRoutesValue = benchmark.devRoutes ?? args.devRoutes; const devRoutePaths = args.mode === 'dev' - ? resolveDevRoutePaths(devRoutesValue, benchmark) + ? resolveDevRoutePaths(args.devRoutes, benchmark) : []; const fixtureResult = yield* tryPromise(() => generateSyntheticFixture({ @@ -616,7 +613,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/tests/features.test.ts b/tests/features.test.ts index 219a2c5e..6d1cd38b 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -81,8 +81,7 @@ describe('pluginReactRouter', () => { }); it('should register the dev middleware for SPA mode (ssr: false)', async () => { - (globalThis as { __reactRouterTestConfig?: unknown }).__reactRouterTestConfig = - { ssr: false }; + testGlobal.__reactRouterTestConfig = { ssr: false }; const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, }); @@ -91,7 +90,6 @@ describe('pluginReactRouter', () => { const config = await rsbuild.unwrapConfig(); expect(config.dev.setupMiddlewares).toHaveLength(1); - expect(config.dev.setupMiddlewares[0]).toBeInstanceOf(Function); }); it('should configure server output format correctly', async () => { From b3fce8d53001946dc4215327e6649a04a1dcae13 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:01:36 +0000 Subject: [PATCH 4/8] refactor: wait on explicit dev environments in the bench harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The benchmark harness waits for an explicit, caller-provided set of dev environments (['web', 'node'] for every fixture variant — SPA dev route serving goes through the node server build too, so 'node' must be ready before route fetches). The ready-line pattern captures any environment name, and a startup timeout now reports which expected environments were still awaited vs. observed instead of hanging silently. Also aligns the SPA-mode feature test with the server.setup registration that now includes ssr:false apps. Co-Authored-By: Claude Fable 5 --- scripts/bench-builds.mts | 8 +++++--- scripts/benchmark/dev-server.mjs | 33 +++++++++++++++++++++++++++++++- tests/features.test.ts | 17 +--------------- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/scripts/bench-builds.mts b/scripts/bench-builds.mts index 7a7d33d8..d02fe276 100644 --- a/scripts/bench-builds.mts +++ b/scripts/bench-builds.mts @@ -495,9 +495,11 @@ const runBenchmarkIteration = (benchmarkContext, index) => } : {}), }, - // SPA dev route serving goes through the node server build - // (the dev middleware renders the SPA shell from it), so wait - // for both environments before fetching routes. + // 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, 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/tests/features.test.ts b/tests/features.test.ts index 6d1cd38b..6e1489f2 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -65,21 +65,6 @@ describe('pluginReactRouter', () => { ); }); - it('should not register the dev server middleware in SPA mode', async () => { - testGlobal.__reactRouterTestConfig = { ssr: false }; - const rsbuild = await createStubRsbuild({ - rsbuildConfig: {}, - }); - - rsbuild.addPlugins([pluginReactRouter()]); - const config = await rsbuild.unwrapConfig(); - - expect(config.dev.setupMiddlewares).toBeUndefined(); - expect(getServerSetupNames(config)).not.toContain( - 'reactRouterDevServerSetup' - ); - }); - it('should register the dev middleware for SPA mode (ssr: false)', async () => { testGlobal.__reactRouterTestConfig = { ssr: false }; const rsbuild = await createStubRsbuild({ @@ -89,7 +74,7 @@ describe('pluginReactRouter', () => { rsbuild.addPlugins([pluginReactRouter()]); const config = await rsbuild.unwrapConfig(); - expect(config.dev.setupMiddlewares).toHaveLength(1); + expect(config.server.setup).toHaveLength(1); }); it('should configure server output format correctly', async () => { From 28076b0ca020378f9182bd17bd93353ce6215c6a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:49:55 +0000 Subject: [PATCH 5/8] test: wait for SPA dev compilers before e2e --- examples/spa-mode/playwright.dev.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/spa-mode/playwright.dev.config.ts b/examples/spa-mode/playwright.dev.config.ts index 0da49bae..c2bde6fa 100644 --- a/examples/spa-mode/playwright.dev.config.ts +++ b/examples/spa-mode/playwright.dev.config.ts @@ -15,6 +15,10 @@ export default defineConfig({ webServer: { ...baseConfig.webServer, command: 'pnpm run dev --port 3002', - url: 'http://localhost: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\)/, + }, }, }); From 19888ecfcd63cb903eefb60c363b1f5f0b96c302 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:15:51 +0000 Subject: [PATCH 6/8] test: split SPA dev deep-link coverage --- examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 12031918..2a2948fc 100644 --- a/examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts +++ b/examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts @@ -29,8 +29,9 @@ test.describe('SPA Mode dev server', () => { expect(response?.status()).toBe(200); await expect(page.locator('h1:has-text("About This Demo")')).toBeVisible(); + }); - // Nested routes resolve as document requests too. + 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")') From 619864f21013e6c4bfdd3aa468966ff3cbe03d3e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:15:29 +0000 Subject: [PATCH 7/8] chore: add dev server setup changeset --- .changeset/fresh-dev-server-setup.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-dev-server-setup.md diff --git a/.changeset/fresh-dev-server-setup.md b/.changeset/fresh-dev-server-setup.md new file mode 100644 index 00000000..662f2634 --- /dev/null +++ b/.changeset/fresh-dev-server-setup.md @@ -0,0 +1,5 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Migrate the dev middleware hook from `dev.setupMiddlewares` to `server.setup`. From dd8688b6eb8f0b3ada9a2a7640fe3ac3bb7e901b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:15:55 +0000 Subject: [PATCH 8/8] chore: add spa dev changeset --- .changeset/lazy-spa-shell-dev.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lazy-spa-shell-dev.md 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.