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

Add opt-in Remix v2 root-import compatibility facades backed by one React Router runtime.
6 changes: 6 additions & 0 deletions .changeset/spa-recovery-route-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'rsbuild-plugin-react-router': minor
---

Add an opt-in SPA recovery mode that strips server-only route exports while
preserving explicit client loaders, actions, middleware, and hydration UI.
114 changes: 103 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,20 +99,112 @@ pluginReactRouter({
unstableLazyCompilationPrewarm: false,
logPerformance: false,
federation: false,
remixCompat: false,
stripServerOnlyRouteExportsInSpa: false,
});
```

| Option | Default | Description |
| -------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `typegen` | `true` | Generates React Router route types during development and builds. Set to `false` when type generation is managed separately. |
| `customServer` | `false` | Disables the built-in development SSR middleware. Enable this when an app owns the server with `createDevServer()` or an adapter. |
| `serverOutput` | Derived | Emitted Rsbuild server format: `'module'` or `'commonjs'`. When omitted, React Router's `serverModuleFormat` selects the format (`'esm'` -> `'module'`, `'cjs'` -> `'commonjs'`); setting `serverOutput` overrides it. |
| `lazyCompilation` | `true` | Optional Rsbuild dev lazy-compilation config. When enabled here or through `dev.lazyCompilation`, React Router hydration-critical modules stay eager so the browser manifest and route modules are not replaced by lazy proxies. |
| `unstableLazyCompilationPrewarm` | `false` | Experimental prewarm for emitted lazy-compilation proxy modules after dev compiles. Enable with `true` when route JS proxy startup should happen shortly after compiler readiness. |
| `logPerformance` | `false` | Logs structured React Router plugin timing information. |
| `parallelRouteTransform` | `undefined` | Controls worker-thread route transforms. `undefined` and `false` keep transforms inline, `true` uses an automatic worker count, and a positive integer sets the maximum worker count. |
| `onRouteTopologyChange` | `undefined` | Notification for programmatic/custom dev servers. Recreate the Rsbuild server when route files are added, removed, or moved. The callback is not awaited. |
| `federation` | `false` | Enables the plugin's experimental Module Federation integration. |
| Option | Default | Description |
| ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `typegen` | `true` | Generates React Router route types during development and builds. Set to `false` when type generation is managed separately. |
| `customServer` | `false` | Disables the built-in development SSR middleware. Enable this when an app owns the server with `createDevServer()` or an adapter. |
| `serverOutput` | Derived | Emitted Rsbuild server format: `'module'` or `'commonjs'`. When omitted, React Router's `serverModuleFormat` selects the format (`'esm'` -> `'module'`, `'cjs'` -> `'commonjs'`); setting `serverOutput` overrides it. |
| `lazyCompilation` | `true` | Optional Rsbuild dev lazy-compilation config. When enabled here or through `dev.lazyCompilation`, React Router hydration-critical modules stay eager so the browser manifest and route modules are not replaced by lazy proxies. |
| `unstableLazyCompilationPrewarm` | `false` | Experimental prewarm for emitted lazy-compilation proxy modules after dev compiles. Enable with `true` when route JS proxy startup should happen shortly after compiler readiness. |
| `logPerformance` | `false` | Logs structured React Router plugin timing information. |
| `parallelRouteTransform` | `undefined` | Controls worker-thread route transforms. `undefined` and `false` keep transforms inline, `true` uses an automatic worker count, and a positive integer sets the maximum worker count. |
| `onRouteTopologyChange` | `undefined` | Notification for programmatic/custom dev servers. Recreate the Rsbuild server when route files are added, removed, or moved. The callback is not awaited. |
| `federation` | `false` | Enables the plugin's experimental Module Federation integration. |
| `remixCompat` | `false` | Aliases supported Remix v2 root imports to the package's React Router compatibility facades. See [Remix source compatibility](#remix-source-compatibility). |
| `stripServerOnlyRouteExportsInSpa` | `false` | For a client-only SPA recovery build, strips `loader`, `action`, `middleware`, and `headers` while preserving explicit client equivalents. See [SPA recovery builds](#spa-recovery-builds). |

### SPA recovery builds

An application that normally builds with SSR can produce a separate, strictly
client-only recovery artifact from the same route sources:

```ts
pluginReactRouter({ stripServerOnlyRouteExportsInSpa: true });
```

This option only applies when React Router is configured with `ssr: false` and
without prerender paths. It removes `loader`, `action`, `middleware`, and
`headers` from both browser and build-time server route modules. Browser and
server manifests therefore report no server loader or action, so recovery
navigations do not issue `.data` requests. `clientLoader`, `clientAction`,
`clientMiddleware`, and the root `HydrateFallback` remain available. A route
with a server loader, action, or middleware must define the corresponding
client export; the build fails with the missing export otherwise.

This does not turn server logic into client logic, synthesize `serverLoader`,
or emit an SSR snapshot. The default SPA validation is unchanged when the
option is absent. Non-root `HydrateFallback` exports remain invalid, and
`ssr: false` prerender builds retain their normal server-export validation.

### Remix source compatibility

Set `remixCompat: true` to keep selected Remix v2 source imports while running
one React Router 7 or 8 runtime:

```ts
pluginReactRouter({ remixCompat: true });
```

The option installs exact build aliases for `@remix-run/react`,
`@remix-run/node`, `react-router-dom`, and `react-router-dom6`. Both DOM package
names resolve to the application's `react-router` installation so an installed
React Router 6 package cannot create a second router context. It does not alias
package prefixes or arbitrary deep imports. The same facades are public package
exports for explicit use. For old import names to work in both TypeScript and
Rsbuild, add runtime modules to the app:

```ts title="app/compat/remix-node.ts"
export * from 'rsbuild-plugin-react-router/compat/remix-node';
```

```ts title="app/compat/remix-react.ts"
export * from 'rsbuild-plugin-react-router/compat/remix-react';
```

Then point the exact TypeScript paths at those modules:

```json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@remix-run/react": ["./app/compat/remix-react.ts"],
"@remix-run/node": ["./app/compat/remix-node.ts"],
"react-router-dom6": ["./app/compat/remix-react.ts"],
"react-router-dom": ["./app/compat/remix-react.ts"]
}
}
}
```

Do not point `paths` directly at files under this package's `dist` directory.
Rsbuild also applies TypeScript paths during bundling, so a `.d.ts` target can
be treated as runtime source. The local modules above resolve through the
package exports in both the compiler and bundler.

`compat/remix-react` re-exports React Router's public root API and provides
`RemixBrowser` as `HydratedRouter` plus a `RemixServer` wrapper around
`ServerRouter`. `compat/remix-node` re-exports the public `react-router` and
`@react-router/node` APIs. Both facades add these source-compatible helpers:

- `json(value, init)` returns a real JSON `Response`, preserving status and
headers.
- `defer(value, init)` delegates to React Router `data(value, init)`. Promises,
including rejected promises, are streamed by React Router's single-fetch
protocol and retain response status and headers. It is not Remix's old
`TypedDeferredData` runtime protocol.

The compatibility boundary is intentionally narrow. It does not support
Remix private context/protocol APIs, package deep imports, `installGlobals`,
legacy upload APIs, Remix config/build conventions, or two router runtimes.
`RemixServer` accepts `abortDelay` to ease source migration but cannot apply it;
the server entry must abort its render stream. The wrapper emits a one-time
warning when the prop is present. RSC mode rejects `remixCompat`.

Lazy compilation applies to the browser build. Server dynamic imports still
compile eagerly; `lazyCompilation` does not enable on-demand SSR compilation.
Expand Down
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./compat/remix-react": {
"types": "./dist/compat/remix-react.d.ts",
"import": "./dist/compat/remix-react.js",
"require": "./dist/compat/remix-react.cjs"
},
"./compat/remix-node": {
"types": "./dist/compat/remix-node.d.ts",
"import": "./dist/compat/remix-node.js",
"require": "./dist/compat/remix-node.cjs"
},
"./templates/entry.client": {
"types": "./dist/templates/entry.client.d.ts",
"import": "./dist/templates/entry.client.js",
Expand Down Expand Up @@ -69,7 +79,7 @@
"e2e:no-build": "pnpm test:package-interop && pnpm --filter './examples/{default-template,spa-mode,prerender,custom-node-server,cloudflare,client-only,react-router-8,rsc-mode}' test:e2e",
"dev": "rslib build --watch",
"test": "pnpm typecheck && rstest run",
"typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json && pnpm --filter integration typecheck && pnpm --filter integration-rsbuild-template typecheck && pnpm --filter integration-rsc-framework typecheck && pnpm --filter integration-rsc-preview typecheck",
"typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json && tsc -p tests/tsconfig.remix-compat.json && pnpm --filter integration typecheck && pnpm --filter integration-rsbuild-template typecheck && pnpm --filter integration-rsc-framework typecheck && pnpm --filter integration-rsc-preview typecheck",
"test:watch": "rstest watch",
"test:coverage": "rstest run --coverage",
"test:core": "rstest run -c ./rstest.config.ts",
Expand Down
4 changes: 4 additions & 0 deletions rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const config = defineConfig({
source: {
entry: {
index: './src/index.ts',
'compat/remix-node': './src/compat/remix-node.ts',
'compat/remix-react': './src/compat/remix-react.tsx',
'route-module-transform-loader':
'./src/route-module-transform-loader.ts',
'rsc-route-transform-loader': './src/rsc-route-transform-loader.ts',
Expand All @@ -28,6 +30,8 @@ const config = defineConfig({
source: {
entry: {
index: './src/index.ts',
'compat/remix-node': './src/compat/remix-node.ts',
'compat/remix-react': './src/compat/remix-react.tsx',
'rsc-route-transform-loader': './src/rsc-route-transform-loader.ts',
'templates/entry.server': './src/templates/entry.server.tsx',
'templates/entry.client': './src/templates/entry.client.tsx',
Expand Down
21 changes: 21 additions & 0 deletions scripts/test-package-interop.mts
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,32 @@ const verifyRscPublicSurface = (esm, commonjs) => {
}
};

const verifyRemixCompatPublicSurface = async () => {
const esmNode = await import('rsbuild-plugin-react-router/compat/remix-node');
const esmReact =
await import('rsbuild-plugin-react-router/compat/remix-react');
const commonjsNode = require('rsbuild-plugin-react-router/compat/remix-node');
const commonjsReact = require('rsbuild-plugin-react-router/compat/remix-react');

for (const runtime of [esmNode, commonjsNode]) {
assert.equal(typeof runtime.defer, 'function');
assert.equal(typeof runtime.json, 'function');
assert.equal(typeof runtime.createReadableStreamFromReadable, 'function');
}
for (const runtime of [esmReact, commonjsReact]) {
assert.equal(typeof runtime.RemixBrowser, 'function');
assert.equal(typeof runtime.RemixServer, 'function');
assert.equal(typeof runtime.Await, 'function');
assert.equal(typeof runtime.useLoaderData, 'function');
}
};

const main = async () => {
const [esm, commonjs] = await loadEntryPoints();
await verifyPackIncludesOriginalSource();
verifyEffectFreeRuntimeOutputs();
verifyRscPublicSurface(esm, commonjs);
await verifyRemixCompatPublicSurface();
process.chdir(
fileURLToPath(new URL('../tests/fixtures/dev-runtime/', import.meta.url))
);
Expand Down
7 changes: 7 additions & 0 deletions src/build-output-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ type RegisterBuildOutputTransformsOptions = {
ssr: boolean;
isSpaMode: boolean;
rootRoutePath: string;
stripServerOnlyRouteExportsInSpa: boolean;
outputClientPath: string;
isDevHmrEnabled?: () => boolean;
onRouteModuleAnalysis?: (
Expand Down Expand Up @@ -133,6 +134,7 @@ export const registerBuildOutputTransforms = ({
ssr,
isSpaMode,
rootRoutePath,
stripServerOnlyRouteExportsInSpa,
outputClientPath,
isDevHmrEnabled = () => false,
onRouteModuleAnalysis,
Expand Down Expand Up @@ -173,6 +175,7 @@ export const registerBuildOutputTransforms = ({
isBuild,
isSpaMode,
rootRoutePath,
stripServerOnlyRouteExportsInSpa,
devHmr: isDevHmrEnabled(),
})
);
Expand Down Expand Up @@ -243,6 +246,7 @@ export const registerBuildOutputTransforms = ({
createReactRouterManifestOptions({
routeChunks: routeChunkOptions,
routeModuleAnalysis,
stripServerOnlyRouteExports: stripServerOnlyRouteExportsInSpa,
})
));
return {
Expand Down Expand Up @@ -297,6 +301,7 @@ export const registerBuildOutputTransforms = ({
.exports,
resourcePath: args.resourcePath,
rootRoutePath,
stripServerOnlyRouteExports: stripServerOnlyRouteExportsInSpa,
});
}

Expand Down Expand Up @@ -328,6 +333,8 @@ export const registerBuildOutputTransforms = ({
isBuild,
isSpaMode,
rootRoutePath,
stripServerOnlyRouteExportsInSpa,
skipSpaValidation: true,
});
}
);
Expand Down
11 changes: 11 additions & 0 deletions src/compat/remix-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export * from '@react-router/node';
export * from 'react-router';
export { defer, json } from './responses.js';
export type {
DeferFunction,
JsonFunction,
Jsonify,
SerializeFrom,
TypedDeferredData,
TypedResponse,
} from './responses.js';
55 changes: 55 additions & 0 deletions src/compat/remix-react.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createElement, type ReactElement } from 'react';
import { ServerRouter, type ServerRouterProps } from 'react-router';
import {
useActionData as useActionDataRR,
useLoaderData as useLoaderDataRR,
useRouteLoaderData as useRouteLoaderDataRR,
} from 'react-router';
import type { SerializeFrom } from './responses.js';

export * from 'react-router';
export { HydratedRouter as RemixBrowser } from 'react-router/dom';
export type { HydratedRouterProps as RemixBrowserProps } from 'react-router/dom';
export { defer, json } from './responses.js';
export type {
DeferFunction,
JsonFunction,
Jsonify,
SerializeFrom,
TypedDeferredData,
TypedResponse,
} from './responses.js';

export function useLoaderData<T = unknown>(): SerializeFrom<T> {
return useLoaderDataRR() as SerializeFrom<T>;
}

export function useActionData<T = unknown>(): SerializeFrom<T> | undefined {
return useActionDataRR() as SerializeFrom<T> | undefined;
}

export function useRouteLoaderData<T = unknown>(
routeId: string
): SerializeFrom<T> | undefined {
return useRouteLoaderDataRR(routeId) as SerializeFrom<T> | undefined;
}

export interface RemixServerProps extends ServerRouterProps {
/** @deprecated Abort the render stream in entry.server instead. */
abortDelay?: number;
}

let warnedAboutAbortDelay = false;

export function RemixServer({
abortDelay,
...props
}: RemixServerProps): ReactElement {
if (abortDelay !== undefined && !warnedAboutAbortDelay) {
warnedAboutAbortDelay = true;
console.warn(
'[rsbuild-plugin-react-router] RemixServer abortDelay does not control React Router streaming. Abort the render stream in entry.server instead.'
);
}
return createElement(ServerRouter, props);
}
Loading
Loading