Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/lazy-spa-shell-dev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rsbuild-plugin-react-router': patch
---

Serve the React Router SPA shell during `rsbuild dev` when `ssr` is disabled.
7 changes: 0 additions & 7 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion examples/spa-mode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions examples/spa-mode/playwright.dev.config.ts
Original file line number Diff line number Diff line change
@@ -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\)/,
},
},
Comment thread
ScriptedAlchemy marked this conversation as resolved.
});
48 changes: 48 additions & 0 deletions examples/spa-mode/tests/e2e-dev/spa-mode-dev.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
15 changes: 8 additions & 7 deletions scripts/bench-builds.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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 = {
Expand Down
33 changes: 32 additions & 1 deletion scripts/benchmark/dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `(<environment>)` 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) => {
Expand Down Expand Up @@ -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 = [],
Expand Down Expand Up @@ -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 ... (<env>)" lines; if the plugin renamed a ` +
`dev environment, update the expected environments in ` +
`scripts/bench-builds.mts.`
)
);
}
stopChild();
}, timeoutMs);
timeoutTimer.unref?.();
Expand Down
5 changes: 0 additions & 5 deletions scripts/benchmark/profiles.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 2 additions & 5 deletions tests/features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand All @@ -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);
});

Comment thread
ScriptedAlchemy marked this conversation as resolved.
it('should configure server output format correctly', async () => {
Expand Down
Loading