Skip to content
Draft
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/react-router-8-default-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rsbuild-plugin-react-router': minor
---

Add React Router 8 compatibility while preserving React Router 7 behavior. The plugin now supports stable React Router 8 config fields, resolves prerender data requests for the installed React Router major version, and includes a copied React Router 8 default-template example with dev and production e2e coverage.
6 changes: 0 additions & 6 deletions .changeset/stable-prerender-concurrency.md

This file was deleted.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,7 @@ The repository includes several examples demonstrating different use cases:
| [custom-node-server](./examples/custom-node-server) | Custom Express server with SSR | 3003 | `pnpm dev` |
| [cloudflare](./examples/cloudflare) | Cloudflare Workers deployment | 3004 | `pnpm dev` |
| [client-only](./examples/client-only) | `.client` modules with SSR hydration | 3010 | `pnpm dev` |
| [react-router-8](./examples/react-router-8) | React Router 8 framework-mode SSR | 3020 | `pnpm dev` |
| [epic-stack](./examples/epic-stack) | Full-featured Epic Stack example | 3005 | `pnpm dev` |
| [federation/epic-stack](./examples/federation/epic-stack) | Module Federation host | 3006 | `pnpm dev` |
| [federation/epic-stack-remote](./examples/federation/epic-stack-remote) | Module Federation remote | 3007 | `pnpm dev` |
Expand Down
6 changes: 6 additions & 0 deletions examples/react-router-8/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules

/.cache
/build
.env
.react-router
5 changes: 5 additions & 0 deletions examples/react-router-8/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# React Router 8 Default Template

This example starts as a direct copy of React Router's `integration/helpers/vite-8-template` and swaps the Vite config/scripts for `rsbuild-plugin-react-router`.

Run `pnpm --filter react-router-8-default-template test:e2e` to exercise development and production browser flows.
19 changes: 19 additions & 0 deletions examples/react-router-8/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";

export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
4 changes: 4 additions & 0 deletions examples/react-router-8/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { type RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";

export default flatRoutes() satisfies RouteConfig;
16 changes: 16 additions & 0 deletions examples/react-router-8/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { MetaFunction } from "react-router";

export const meta: MetaFunction = () => {
return [
{ title: "New React Router App" },
{ name: "description", content: "Welcome to React Router!" },
];
};

export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>Welcome to React Router</h1>
</div>
);
}
2 changes: 2 additions & 0 deletions examples/react-router-8/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/// <reference types="@react-router/node" />
/// <reference types="vite/client" />
42 changes: 42 additions & 0 deletions examples/react-router-8/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "react-router-8-default-template",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "NODE_OPTIONS=\"--experimental-vm-modules --experimental-global-webcrypto\" rsbuild dev --port 3020 --host 127.0.0.1",
"build": "rsbuild build",
"start": "HOST=127.0.0.1 PORT=3020 react-router-serve ./build/server/static/js/app.js",
"test:e2e": "pnpm run test:e2e:dev && pnpm run build && pnpm run test:e2e:prod",
"test:e2e:dev": "playwright test",
"test:e2e:prod": "cross-env RR8_E2E_MODE=production playwright test",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@react-router/fs-routes": "^8.0.1",
"@react-router/node": "^8.0.1",
"@react-router/serve": "^8.0.1",
"@vanilla-extract/css": "^1.20.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^8.0.1",
"serialize-javascript": "^6.0.1"
},
"devDependencies": {
"@playwright/test": "^1.58.0",
"@react-router/dev": "^8.0.1",
"@rsbuild/core": "2.1.0",
"@rsbuild/plugin-react": "2.1.0",
"@types/node": "^25.0.10",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"cross-env": "^10.1.0",
"rsbuild-plugin-react-router": "workspace:*",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-env-only": "^3.0.3"
},
"engines": {
"node": ">=22.22.0"
}
}
27 changes: 27 additions & 0 deletions examples/react-router-8/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';

const isProduction = process.env.RR8_E2E_MODE === 'production';

export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: {
timeout: 10_000,
},
use: {
baseURL: 'http://127.0.0.1:3020',
trace: 'on-first-retry',
},
webServer: {
command: isProduction ? 'pnpm run start' : 'pnpm run dev',
url: 'http://127.0.0.1:3020',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
Binary file added examples/react-router-8/public/favicon.ico
Binary file not shown.
6 changes: 6 additions & 0 deletions examples/react-router-8/react-router.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
ssr: true,
routeDiscovery: { mode: 'initial' },
splitRouteModules: true,
subResourceIntegrity: true,
};
7 changes: 7 additions & 0 deletions examples/react-router-8/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginReactRouter } from 'rsbuild-plugin-react-router';

export default defineConfig({
plugins: [pluginReactRouter(), pluginReact()],
});
44 changes: 44 additions & 0 deletions examples/react-router-8/tests/e2e/react-router-8.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test';

test('renders the React Router 8 default template without browser errors', async ({
page,
}) => {
const browserProblems: string[] = [];
page.on('console', message => {
if (message.type() === 'error') {
browserProblems.push(`console error: ${message.text()}`);
}
});
page.on('pageerror', error => {
browserProblems.push(`page error: ${error.message}`);
});
page.on('response', response => {
if (response.status() >= 500) {
browserProblems.push(`${response.status()} response: ${response.url()}`);
}
});
page.on('requestfailed', request => {
if (request.resourceType() !== 'websocket') {
browserProblems.push(
`${request.method()} ${request.url()} failed: ${
request.failure()?.errorText ?? 'unknown error'
}`
);
}
});

const response = await page.goto('/');
expect(response?.ok()).toBe(true);
await expect(
page.getByRole('heading', { name: 'Welcome to React Router' })
).toBeVisible();
await expect(page).toHaveTitle('New React Router App');
await page.waitForFunction(
() =>
(window as Window & { __reactRouterRouteModules?: unknown })
.__reactRouterRouteModules !== undefined
);
await page.waitForTimeout(250);

expect(browserProblems).toEqual([]);
});
22 changes: 22 additions & 0 deletions examples/react-router-8/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"jsx": "react-jsx",
"resolveJsonModule": true,
"allowJs": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"noEmit": true,
"rootDirs": [".", ".react-router/types/"],
"skipLibCheck": true,
"strict": true
}
}
8 changes: 8 additions & 0 deletions examples/rsc-mode/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
/node_modules/

# React Router
/.react-router/
/build/
test-results
.rspack-profile-*
25 changes: 25 additions & 0 deletions examples/rsc-mode/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# RSC Mode Example

This example is a small React Router RSC Framework Mode app wired for `rsbuild-plugin-react-router`.

It includes:

- `pluginReactRouter({ rsc: true })` in `rsbuild.config.ts`
- RSC-safe React Router config without `splitRouteModules` or `subResourceIntegrity`
- A server-first index route using `ServerComponent`
- A loader that returns a React element rendered on the server
- A `"use client"` island mounted inside the server-first route
- A client-first route for soft navigation coverage
- A Playwright smoke test for dev and production mode

## Commands

```sh
pnpm run dev
pnpm run build
pnpm run test:e2e
```

## Notes

This example uses `react-server-dom-rspack` and `rsbuild-plugin-rsc`; it does not use the Vite RSC runtime packages from the upstream React Router template.
17 changes: 17 additions & 0 deletions examples/rsc-mode/app/client-counter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client';

import { useState } from 'react';

export function ClientCounter() {
const [count, setCount] = useState(0);

return (
<button
className="counter-button"
type="button"
onClick={() => setCount(current => current + 1)}
>
Client island count: {count}
</button>
);
}
64 changes: 64 additions & 0 deletions examples/rsc-mode/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Route } from './+types/root';
import {
isRouteErrorResponse,
Link,
Links,
Meta,
Outlet,
} from 'react-router';

import './styles.css';

export function meta() {
return [
{ title: 'Rsbuild RSC example' },
{
name: 'description',
content: 'React Router RSC Framework Mode with Rsbuild',
},
];
}

export function ServerLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<header className="site-header">
<Link className="brand" to="/">
RSC Mode
</Link>
<nav aria-label="Primary navigation">
<Link to="/">Server route</Link>
<Link to="/client">Client route</Link>
</nav>
</header>
<main>{children}</main>
</body>
</html>
);
}

export function ServerComponent() {
return <Outlet />;
}

export function ServerErrorBoundary({ error }: Route.ServerErrorBoundaryProps) {
const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: 'Unknown error';

return (
<section className="page-shell">
<h1>Route error</h1>
<p>{message}</p>
</section>
);
}
6 changes: 6 additions & 0 deletions examples/rsc-mode/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
index('routes/_index.tsx'),
route('client', 'routes/client.tsx'),
] satisfies RouteConfig;
32 changes: 32 additions & 0 deletions examples/rsc-mode/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Route } from './+types/_index';
import { Link } from 'react-router';

import { ClientCounter } from '~/client-counter';
import { getRscShowcase } from '~/rsc-data';

export async function loader() {
return getRscShowcase();
}

export function ServerComponent({ loaderData }: Route.ServerComponentProps) {
return (
<section className="page-shell hero-panel">
<div className="hero-copy">
<h1>Rsbuild React Router RSC</h1>
<p data-testid="server-message">{loaderData.message}</p>
<p className="server-element">{loaderData.element}</p>
</div>

<div className="interaction-panel">
<p>
This route renders through React Router RSC Framework Mode and mounts a
small client island inside the server-first route.
</p>
<ClientCounter />
<Link className="text-link" to="/client">
Visit the client-first route
</Link>
</div>
</section>
);
}
Loading
Loading