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
6 changes: 6 additions & 0 deletions .changeset/sharp-routes-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"rsbuild-plugin-react-router": patch
---

Harden route module transforms and development route watching so source maps,
server/client-only modules, and route topology restarts behave consistently.
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,17 @@ pluginReactRouter({
/**
* Run route transforms in a worker-thread pool.
* Pass `false` to disable or `{ maxWorkers }` to override the default worker count.
* @default true, using `available CPUs - 2` workers.
* @default Automatically enabled for 256+ resolved routes. The automatic
* pool is capped at 8 workers.
*/
parallelTransforms?: boolean | { maxWorkers?: number },

/**
* Route-topology notification for programmatic/custom dev servers.
* Recreate the Rsbuild server when this fires.
*/
onRouteTopologyChange?: () => void | Promise<void>,

/**
* Enable experimental support for module federation
* @default false
Expand Down Expand Up @@ -277,8 +284,8 @@ export default {
} satisfies Config;
```

For large sites, prerendering defaults to `availableParallelism - 2` concurrent
paths. You can tune prerender concurrency:
Prerendering defaults to one path at a time, matching React Router. You can opt
into concurrent prerendering for large sites:

```ts
export default {
Expand All @@ -302,7 +309,7 @@ If no configuration is provided, the following defaults will be used:
federation: false,
lazyCompilation: false,
logPerformance: false,
parallelTransforms: true // adaptive worker pool
parallelTransforms: undefined // adaptive: workers for 256+ resolved routes
}

// Router defaults (react-router.config.ts)
Expand All @@ -314,9 +321,10 @@ If no configuration is provided, the following defaults will be used:
}
```

`parallelTransforms: true` uses worker threads for route builds. The default
worker count is `availableParallelism - 2`. Pass `{ maxWorkers }` to override
that count, or `false` to run route transforms inline.
Route transforms run inline for fewer than 256 resolved routes and use worker
threads for larger route graphs. The automatic worker count is capped at 8.
Pass `true` to force workers, `{ maxWorkers }` (up to 32) to override that
count, or `false` to force inline transforms.

For builds with 256+ routes, detailed file-size reporting is compacted to totals
by default to avoid gzipping and printing thousands of assets. Set
Expand Down Expand Up @@ -437,6 +445,12 @@ export default defineConfig(() => {
});
```

If the server is created programmatically with `createDevServer()`, pass
`onRouteTopologyChange` and use it to recreate that server. Rsbuild's
`reload-server` watcher is owned by the CLI and is not installed by the
programmatic API. The callback is a notification and is not awaited, so it can
safely close the current server as part of the replacement.

When using a custom server, you'll need to:

1. Create a server handler (`server/index.ts`):
Expand Down
21 changes: 16 additions & 5 deletions benchmarks/manifest-performance-methodology.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,17 @@ The harness:
1. builds the plugin package (`pnpm build`) unless `--skip-root-build` is passed;
2. generates deterministic fixtures under `.benchmark/fixtures/`;
3. runs `node node_modules/@rsbuild/core/bin/rsbuild.js build --config rsbuild.config.mjs`;
4. sets `REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE=1`, enabling structured
`[react-router:performance]` plugin logs;
4. keeps plugin instrumentation disabled for canonical end-to-end A/B runs;
pass `--log-performance` for a separate diagnostic run that emits structured
`[react-router:performance]` logs;
5. wraps builds in `/usr/bin/time -v` when available and records user/sys/RSS;
6. writes `.benchmark/results/<run>/baseline.json` and `baseline.md`.

`rsbuild build --help` in this repo exposes `--log-level`, `--environment`,
`--mode`, and `--config`, but no dedicated benchmark/stats/profiling CLI flag.
Use the plugin `logPerformance` reports as the primary plugin-level source of
truth. If low-level Rspack stats are needed later, add them through fixture
Use end-to-end wall time, process CPU, and RSS as the primary comparison
signals. Plugin `logPerformance` reports are diagnostic because their timers
include queueing and add observer overhead. If low-level Rspack stats are needed later, add them through fixture
`rsbuild.config.mjs`; do not depend on a non-existent CLI flag.

## Pre-flight commands
Expand Down Expand Up @@ -163,13 +165,22 @@ because it controls warmup, cleaning, aggregation, and output format.

## Metric checklist

### Already observable from `baseline.json`
### Canonical metrics in `baseline.json`

| Metric | Source | Why it matters |
| --------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Build wall time | `benchmarks[].summary.wallMs` | End-to-end user-visible build time. |
| CPU time | `summary.userMs` + `summary.sysMs` | Less noisy than wall time when the machine has minor scheduling variance. |
| Peak RSS | `summary.maxRssKb` | Ensures cache dedup does not regress memory. |

### Diagnostic metrics with `--log-performance`

These fields are empty in canonical A/B runs because plugin instrumentation is
disabled by default. Use a separate diagnostic run when operation-level
attribution is needed.

| Metric | Source | Why it matters |
| --------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Compiler lifecycle | each plugin report's `compilerLifecycleMs` | Plugin setup/build lifecycle timing per compiler environment. |
| Transform invocation counts | `pluginOperations[].count` | Counts route/manifest hook invocations. Counts should usually stay stable after dedup; timings should drop. |
| Transform cumulative time | `pluginOperations[].totalMs` | Primary signal for expensive plugin work moving out of duplicate paths. |
Expand Down
5 changes: 5 additions & 0 deletions examples/default-template/app/dev-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { RouteConfig } from '@react-router/dev/routes';

// Kept separate so the dev-route-watch E2E covers route-config dependencies,
// not the direct reload-server watch on app/routes.ts.
export default [] satisfies RouteConfig;
3 changes: 3 additions & 0 deletions examples/default-template/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
prefix,
route,
} from '@react-router/dev/routes';
import devRoutes from './dev-routes';

export default [
// Index route for the home page
Expand All @@ -19,6 +20,8 @@ export default [
// Client loader/action example
route('client-features', 'routes/client-features.tsx'),

...devRoutes,

// Docs section with nested routes
...prefix('docs', [
layout('routes/docs/layout.tsx', [
Expand Down
4 changes: 2 additions & 2 deletions examples/default-template/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ export default defineConfig({
// Maximum time one test can run for
timeout: 30 * 1000,
expect: {
timeout: 5000
timeout: 5000,
},
// Keep this example serial because dev-route-watch mutates routes.ts and
// Keep this example serial because dev-route-watch mutates route config and
// restarts the shared dev server.
fullyParallel: false,
workers: 1,
Expand Down
102 changes: 65 additions & 37 deletions examples/default-template/tests/e2e/dev-route-watch.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { expect, test, type Page } from '@playwright/test';
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
existsSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

Expand All @@ -9,20 +15,28 @@ const restartMarkerPath = join(
__dirname,
'../../build/client/.react-router/route-watch'
);
const routesConfigPath = join(appDirectory, 'routes.ts');
const devRoutesConfigPath = join(appDirectory, 'dev-routes.ts');
const addedRoutePath = join(appDirectory, 'routes/dev-added-route.tsx');
const addedRouteUrl = '/dev-added-route';
const addedRouteText = 'Route added while dev server is running';
const editedAddedRouteText = 'Route edited without dev server restart';
const addedRouteConfigEntry = ` route('dev-added-route', 'routes/dev-added-route.tsx'),`;
const emptyDevRoutesConfig = `import type { RouteConfig } from '@react-router/dev/routes';

// Kept separate so the dev-route-watch E2E covers route-config dependencies,
// not the direct reload-server watch on app/routes.ts.
export default [] satisfies RouteConfig;
`;
const populatedDevRoutesConfig = `import { route, type RouteConfig } from '@react-router/dev/routes';

export default [
route('dev-added-route', 'routes/dev-added-route.tsx'),
] satisfies RouteConfig;
`;

const removeAddedRouteConfig = (): boolean => {
const routesConfig = readFileSync(routesConfigPath, 'utf8');
if (routesConfig.includes(addedRouteConfigEntry)) {
writeFileSync(
routesConfigPath,
routesConfig.replace(`${addedRouteConfigEntry}\n\n`, '')
);
const routesConfig = readFileSync(devRoutesConfigPath, 'utf8');
if (routesConfig !== emptyDevRoutesConfig) {
writeFileSync(devRoutesConfigPath, emptyDevRoutesConfig);
return true;
}
return false;
Expand All @@ -36,10 +50,20 @@ const removeAddedRouteFile = (): boolean => {
return false;
};

const readRestartMarker = (): string | null =>
existsSync(restartMarkerPath)
? readFileSync(restartMarkerPath, 'utf8')
: null;
const readRestartMarkerVersion = (): string | null => {
try {
if (!existsSync(restartMarkerPath)) {
return null;
}
const { mtimeNs } = statSync(restartMarkerPath, { bigint: true });
return `${readFileSync(restartMarkerPath, 'utf8')}:${mtimeNs}`;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw error;
}
};

const expectRestartMarkerStable = async (
expectedMarker: string | null,
Expand All @@ -49,7 +73,7 @@ const expectRestartMarkerStable = async (
await expect
.poll(
() => {
const marker = readRestartMarker();
const marker = readRestartMarkerVersion();
if (marker !== expectedMarker) {
return `changed:${marker ?? 'missing'}`;
}
Expand All @@ -60,11 +84,7 @@ const expectRestartMarkerStable = async (
.toBe('stable');
};

const waitForRouteText = async (
page: Page,
url: string,
text: string
) => {
const waitForRouteText = async (page: Page, url: string, text: string) => {
await expect
.poll(
async () => {
Expand All @@ -86,32 +106,45 @@ const waitForRouteText = async (
.toBe('ready');
};

const waitForRouteToBeRemoved = async (page: Page, url: string) => {
await expect
.poll(
async () => {
try {
const response = await page.request.get(url, { timeout: 2000 });
return response.status() === 404 ? 'removed' : response.status();
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
},
{ timeout: 60000 }
)
.toBe('removed');
};

test.describe('dev route watch', () => {
test.setTimeout(90000);

test.beforeEach(async ({ page }) => {
if (removeAddedRouteConfig()) {
await waitForRouteText(page, '/', 'Welcome to React Router');
}
if (removeAddedRouteFile()) {
await waitForRouteText(page, '/', 'Welcome to React Router');
await waitForRouteToBeRemoved(page, addedRouteUrl);
}
removeAddedRouteFile();
});

test.afterEach(async ({ page }) => {
if (removeAddedRouteConfig()) {
await waitForRouteText(page, '/', 'Welcome to React Router');
}
if (removeAddedRouteFile()) {
await waitForRouteText(page, '/', 'Welcome to React Router');
await waitForRouteToBeRemoved(page, addedRouteUrl);
}
removeAddedRouteFile();
});

test('serves a route added after the dev server starts without restarting on later edits', async ({
page,
}) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Welcome to React Router');
const restartMarkerBeforeAdd = readRestartMarkerVersion();

writeFileSync(
addedRoutePath,
Expand All @@ -121,22 +154,17 @@ test.describe('dev route watch', () => {
`
);

const routesConfig = readFileSync(routesConfigPath, 'utf8');
writeFileSync(
routesConfigPath,
routesConfig.replace(
' // Docs section with nested routes',
`${addedRouteConfigEntry}\n\n // Docs section with nested routes`
)
);
writeFileSync(devRoutesConfigPath, populatedDevRoutesConfig);

await waitForRouteText(page, addedRouteUrl, addedRouteText);

await page.goto(addedRouteUrl);
await expect(page.locator('h1')).toHaveText(addedRouteText);

await expect.poll(readRestartMarker, { timeout: 10000 }).not.toBe(null);
const restartMarkerBefore = readRestartMarker();
await expect
.poll(readRestartMarkerVersion, { timeout: 10000 })
.not.toBe(restartMarkerBeforeAdd);
const restartMarkerBefore = readRestartMarkerVersion();
writeFileSync(
addedRoutePath,
`export default function DevAddedRoute() {
Expand Down
32 changes: 23 additions & 9 deletions rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,30 @@ import {
} from '@rsbuild/config/rslib.config.js';
import { defineConfig } from '@rslib/core';
const config = defineConfig({
source: {
entry: {
index: './src/index.ts',
'parallel-route-transform-worker':
'./src/parallel-route-transform-worker.ts',
'templates/entry.server': './src/templates/entry.server.tsx',
'templates/entry.client': './src/templates/entry.client.tsx',
lib: [
{
...esmConfig,
source: {
entry: {
index: './src/index.ts',
'parallel-route-transform-worker':
'./src/parallel-route-transform-worker.ts',
'templates/entry.server': './src/templates/entry.server.tsx',
'templates/entry.client': './src/templates/entry.client.tsx',
},
},
},
},
lib: [esmConfig, cjsConfig],
{
...cjsConfig,
source: {
entry: {
index: './src/index.ts',
'templates/entry.server': './src/templates/entry.server.tsx',
'templates/entry.client': './src/templates/entry.client.tsx',
},
},
},
],
tools: {
rspack: {
externals: [
Expand Down
Loading