diff --git a/.changeset/remix-source-compat.md b/.changeset/remix-source-compat.md
new file mode 100644
index 00000000..4f4a9403
--- /dev/null
+++ b/.changeset/remix-source-compat.md
@@ -0,0 +1,5 @@
+---
+'rsbuild-plugin-react-router': minor
+---
+
+Add opt-in Remix v2 root-import compatibility facades backed by one React Router runtime.
diff --git a/.changeset/spa-recovery-route-exports.md b/.changeset/spa-recovery-route-exports.md
new file mode 100644
index 00000000..49f1acea
--- /dev/null
+++ b/.changeset/spa-recovery-route-exports.md
@@ -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.
diff --git a/README.md b/README.md
index 49df75ba..e374075f 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/package.json b/package.json
index 12565e8c..12da76c6 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -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",
diff --git a/rslib.config.ts b/rslib.config.ts
index 14a20cc8..e91115da 100644
--- a/rslib.config.ts
+++ b/rslib.config.ts
@@ -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',
@@ -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',
diff --git a/scripts/test-package-interop.mts b/scripts/test-package-interop.mts
index cacfa515..1e18a802 100644
--- a/scripts/test-package-interop.mts
+++ b/scripts/test-package-interop.mts
@@ -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))
);
diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts
index b1f9db47..c4676552 100644
--- a/src/build-output-transforms.ts
+++ b/src/build-output-transforms.ts
@@ -102,6 +102,7 @@ type RegisterBuildOutputTransformsOptions = {
ssr: boolean;
isSpaMode: boolean;
rootRoutePath: string;
+ stripServerOnlyRouteExportsInSpa: boolean;
outputClientPath: string;
isDevHmrEnabled?: () => boolean;
onRouteModuleAnalysis?: (
@@ -133,6 +134,7 @@ export const registerBuildOutputTransforms = ({
ssr,
isSpaMode,
rootRoutePath,
+ stripServerOnlyRouteExportsInSpa,
outputClientPath,
isDevHmrEnabled = () => false,
onRouteModuleAnalysis,
@@ -173,6 +175,7 @@ export const registerBuildOutputTransforms = ({
isBuild,
isSpaMode,
rootRoutePath,
+ stripServerOnlyRouteExportsInSpa,
devHmr: isDevHmrEnabled(),
})
);
@@ -243,6 +246,7 @@ export const registerBuildOutputTransforms = ({
createReactRouterManifestOptions({
routeChunks: routeChunkOptions,
routeModuleAnalysis,
+ stripServerOnlyRouteExports: stripServerOnlyRouteExportsInSpa,
})
));
return {
@@ -297,6 +301,7 @@ export const registerBuildOutputTransforms = ({
.exports,
resourcePath: args.resourcePath,
rootRoutePath,
+ stripServerOnlyRouteExports: stripServerOnlyRouteExportsInSpa,
});
}
@@ -328,6 +333,8 @@ export const registerBuildOutputTransforms = ({
isBuild,
isSpaMode,
rootRoutePath,
+ stripServerOnlyRouteExportsInSpa,
+ skipSpaValidation: true,
});
}
);
diff --git a/src/compat/remix-node.ts b/src/compat/remix-node.ts
new file mode 100644
index 00000000..c0e09f07
--- /dev/null
+++ b/src/compat/remix-node.ts
@@ -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';
diff --git a/src/compat/remix-react.tsx b/src/compat/remix-react.tsx
new file mode 100644
index 00000000..078e750a
--- /dev/null
+++ b/src/compat/remix-react.tsx
@@ -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(): SerializeFrom {
+ return useLoaderDataRR() as SerializeFrom;
+}
+
+export function useActionData(): SerializeFrom | undefined {
+ return useActionDataRR() as SerializeFrom | undefined;
+}
+
+export function useRouteLoaderData(
+ routeId: string
+): SerializeFrom | undefined {
+ return useRouteLoaderDataRR(routeId) as SerializeFrom | 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);
+}
diff --git a/src/compat/responses.ts b/src/compat/responses.ts
new file mode 100644
index 00000000..67e0082f
--- /dev/null
+++ b/src/compat/responses.ts
@@ -0,0 +1,135 @@
+import { data, type useLoaderData } from 'react-router';
+
+declare const typedResponseBrand: unique symbol;
+
+export type TypedResponse = Omit & {
+ json(): Promise;
+ readonly [typedResponseBrand]: T;
+};
+
+type JsonPrimitive = string | number | boolean | null;
+type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
+type NotJson = undefined | symbol | ((...args: any[]) => unknown);
+type TypedArray =
+ | Int8Array
+ | Uint8Array
+ | Uint8ClampedArray
+ | Int16Array
+ | Uint16Array
+ | Int32Array
+ | Uint32Array
+ | Float32Array
+ | Float64Array
+ | BigInt64Array
+ | BigUint64Array;
+type IsAny = 0 extends 1 & T ? true : false;
+type NonJsonBranch = T extends unknown
+ ? Jsonify extends never
+ ? T
+ : never
+ : never;
+type JsonifyArrayValue =
+ | Jsonify
+ | ([NonJsonBranch] extends [never] ? never : null);
+type JsonifyList = T extends readonly []
+ ? []
+ : T extends readonly [infer First, ...infer Rest]
+ ? [JsonifyArrayValue, ...JsonifyList]
+ : Array>;
+type JsonifyObject = {
+ [Key in keyof T as Key extends string | number
+ ? [Jsonify] extends [never]
+ ? never
+ : [NonJsonBranch] extends [never]
+ ? Key
+ : never
+ : never]-?: Jsonify;
+} & {
+ [Key in keyof T as Key extends string | number
+ ? [Jsonify] extends [never]
+ ? never
+ : [NonJsonBranch] extends [never]
+ ? never
+ : Key
+ : never]?: Jsonify;
+};
+
+export type Jsonify =
+ IsAny extends true
+ ? any
+ : T extends { toJSON(): infer Json }
+ ? Jsonify
+ : T extends JsonPrimitive
+ ? T
+ : T extends String
+ ? string
+ : T extends Number
+ ? number
+ : T extends Boolean
+ ? boolean
+ : T extends
+ | Map
+ | Set
+ | Promise
+ ? { [key: string]: never }
+ : T extends TypedArray
+ ? Record
+ : T extends NotJson
+ ? never
+ : T extends readonly unknown[]
+ ? JsonifyList
+ : T extends object
+ ? {
+ [Key in keyof JsonifyObject]: JsonifyObject[Key];
+ }
+ : unknown extends T
+ ? JsonValue
+ : never;
+
+type DataFunctionResult = T extends (...args: any[]) => infer Result
+ ? Awaited
+ : Awaited;
+type ReactRouterSerialize = ReturnType T>>;
+
+type SerializeResult =
+ T extends TypedResponse
+ ? Data
+ : T extends TypedDeferredData
+ ? ReactRouterSerialize
+ : ReactRouterSerialize;
+
+export type SerializeFrom = SerializeResult>;
+
+export type TypedDeferredData> =
+ ReturnType> & {
+ type: 'DataWithResponseInit';
+ data: Data;
+ };
+
+export type JsonFunction = (
+ value: Data,
+ init?: number | ResponseInit
+) => TypedResponse>;
+
+export type DeferFunction = >(
+ value: Data,
+ init?: number | ResponseInit
+) => TypedDeferredData;
+
+const normalizeResponseInit = (init?: number | ResponseInit): ResponseInit =>
+ typeof init === 'number' ? { status: init } : (init ?? {});
+
+export const json: JsonFunction = (value, init) => {
+ const responseInit = normalizeResponseInit(init);
+ const headers = new Headers(responseInit.headers);
+ if (!headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json; charset=utf-8');
+ }
+ return new Response(JSON.stringify(value), {
+ ...responseInit,
+ headers,
+ }) as TypedResponse>;
+};
+
+export const defer: DeferFunction = (value, init) =>
+ data(value, init) as TypedDeferredData;
diff --git a/src/index.ts b/src/index.ts
index 31ed493f..2bd643c5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -201,6 +201,14 @@ export const pluginReactRouter = (
...options,
};
const isRscMode = Boolean(pluginOptions.rsc);
+ if (isRscMode && pluginOptions.remixCompat) {
+ throw new Error('`remixCompat` is not supported with RSC mode.');
+ }
+ if (isRscMode && pluginOptions.stripServerOnlyRouteExportsInSpa) {
+ throw new Error(
+ '`stripServerOnlyRouteExportsInSpa` is not supported with RSC mode.'
+ );
+ }
const logPerformance = pluginOptions.logPerformance === true;
const setupStartMs = logPerformance ? performance.now() : 0;
const performanceProfiler = createReactRouterPerformanceProfiler({
@@ -517,6 +525,9 @@ export const pluginReactRouter = (
const isPrerenderEnabled =
prerenderConfig !== undefined && prerenderConfig !== false;
const isSpaMode = !ssr && !isPrerenderEnabled;
+ const stripServerOnlyRouteExportsInSpa = Boolean(
+ isSpaMode && pluginOptions.stripServerOnlyRouteExportsInSpa
+ );
const routeCount = Object.keys(routes).length;
const routeChunkCache: RouteChunkCache = new Map();
const useRouteModuleTransformLoader =
@@ -1012,7 +1023,25 @@ export const pluginReactRouter = (
routeCount >= 256 &&
(config.performance?.printFileSize === undefined ||
config.performance.printFileSize === true);
- const resolveConfig = modePlan.createResolveConfig(api.context.rootPath);
+ const resolveConfig =
+ modePlan.createResolveConfig(api.context.rootPath) ?? {};
+ if (pluginOptions.remixCompat) {
+ const reactRouterPath = resolveAppPackagePath(
+ 'react-router',
+ api.context.rootPath
+ );
+ resolveConfig.alias = {
+ ...resolveConfig.alias,
+ '@remix-run/react$': resolve(__dirname, 'compat/remix-react.js'),
+ '@remix-run/node$': resolve(__dirname, 'compat/remix-node.js'),
+ ...(reactRouterPath
+ ? {
+ 'react-router-dom$': reactRouterPath,
+ 'react-router-dom6$': reactRouterPath,
+ }
+ : {}),
+ };
+ }
// Browser code (React itself) reads `process.env.NODE_ENV`. Rsbuild only
// emits the define for its recognized modes; an unrecognized NODE_ENV
@@ -1212,6 +1241,7 @@ export const pluginReactRouter = (
isBuild,
isSpaMode,
rootRoutePath,
+ stripServerOnlyRouteExportsInSpa,
devHmr: environmentDevHmrEnabled,
logPerformance,
routeByFilePath,
@@ -1290,6 +1320,7 @@ export const pluginReactRouter = (
future,
manifestChunkNames,
routeModuleAnalysis,
+ stripServerOnlyRouteExports: stripServerOnlyRouteExportsInSpa,
onManifest: (manifest, sri, moduleExportsByRouteId, context) =>
performanceProfiler.recordSync(
'web',
@@ -1339,6 +1370,7 @@ export const pluginReactRouter = (
ssr,
isSpaMode,
rootRoutePath,
+ stripServerOnlyRouteExportsInSpa,
outputClientPath,
isDevHmrEnabled: () => devHmrEnabled,
onRouteModuleAnalysis: rememberRouteModuleAnalysis,
diff --git a/src/manifest.ts b/src/manifest.ts
index 4315c449..6a2e4ad0 100644
--- a/src/manifest.ts
+++ b/src/manifest.ts
@@ -15,6 +15,7 @@ import { combineURLs, createRouteId } from './plugin-utils.js';
import {
CLIENT_EXPORTS,
DEFAULT_JS_DIST_PATH,
+ SERVER_ONLY_ROUTE_EXPORTS_SET,
SERVER_EXPORTS,
} from './constants.js';
import {
@@ -119,21 +120,27 @@ export type RouteModuleAnalysisProvider = (
export type ReactRouterManifestOptions = RouteChunkManifestOptions & {
routeModuleAnalysis?: RouteModuleAnalysisProvider;
+ stripServerOnlyRouteExports?: boolean;
};
export const createReactRouterManifestOptions = ({
routeChunks,
routeModuleAnalysis,
+ stripServerOnlyRouteExports,
}: {
routeChunks?: RouteChunkManifestOptions;
routeModuleAnalysis?: RouteModuleAnalysisProvider;
+ stripServerOnlyRouteExports?: boolean;
}): ReactRouterManifestOptions | undefined => {
- if (!routeChunks && !routeModuleAnalysis) {
+ if (!routeChunks && !routeModuleAnalysis && !stripServerOnlyRouteExports) {
return undefined;
}
return {
...routeChunks,
...(routeModuleAnalysis ? { routeModuleAnalysis } : {}),
+ ...(stripServerOnlyRouteExports
+ ? { stripServerOnlyRouteExports: true }
+ : {}),
};
};
@@ -263,6 +270,7 @@ const analyzeRouteForManifestEffect = ({
routeFilePath,
route,
routeModuleAnalysis,
+ stripServerOnlyRouteExports,
}: {
discoveredCssAssets: string[];
isBuild: boolean;
@@ -271,6 +279,7 @@ const analyzeRouteForManifestEffect = ({
routeFilePath: string;
route: Route;
routeModuleAnalysis?: RouteModuleAnalysisProvider;
+ stripServerOnlyRouteExports?: boolean;
}): Effect.Effect =>
tryPluginPromise(async () => {
const { code, exports: exportNames } =
@@ -286,10 +295,14 @@ const analyzeRouteForManifestEffect = ({
)
: null;
+ const manifestExportNames = stripServerOnlyRouteExports
+ ? exportNames.filter(name => !SERVER_ONLY_ROUTE_EXPORTS_SET.has(name))
+ : exportNames;
+
return {
cssAssets: discoveredCssAssets,
- exports: new Set(exportNames),
- routeModuleExports: exportNames,
+ exports: new Set(manifestExportNames),
+ routeModuleExports: manifestExportNames,
hasRouteChunkByExportName: chunkInfo?.hasRouteChunkByExportName ?? null,
};
}).pipe(
@@ -457,6 +470,8 @@ function generateReactRouterManifestForDevEffect(
routeFilePath,
route,
routeModuleAnalysis: manifestOptions?.routeModuleAnalysis,
+ stripServerOnlyRouteExports:
+ manifestOptions?.stripServerOnlyRouteExports,
});
if (isBuild && enforceSplitRouteModules && routeChunkConfig) {
diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts
index 846439e8..4a439061 100644
--- a/src/modify-browser-manifest.ts
+++ b/src/modify-browser-manifest.ts
@@ -49,6 +49,7 @@ type ModifyBrowserManifestOptions = {
future?: { unstable_subResourceIntegrity?: boolean };
manifestChunkNames?: ReadonlySet;
routeModuleAnalysis?: RouteModuleAnalysisProvider;
+ stripServerOnlyRouteExports?: boolean;
onManifest?: (
manifest: ReactRouterManifest,
sri: Record | true | undefined,
@@ -212,6 +213,7 @@ export function registerModifyBrowserManifestAssets(
createReactRouterManifestOptions({
routeChunks: routeChunkOptions,
routeModuleAnalysis: options?.routeModuleAnalysis,
+ stripServerOnlyRouteExports: options?.stripServerOnlyRouteExports,
})
);
const browserManifest = { ...manifest };
diff --git a/src/route-module-transform-loader.ts b/src/route-module-transform-loader.ts
index c0e22504..92505c27 100644
--- a/src/route-module-transform-loader.ts
+++ b/src/route-module-transform-loader.ts
@@ -12,6 +12,7 @@ export type RouteModuleTransformLoaderOptions = {
isBuild: boolean;
isSpaMode: boolean;
rootRoutePath: string | null;
+ stripServerOnlyRouteExportsInSpa: boolean;
devHmr: boolean;
};
@@ -113,6 +114,8 @@ const routeModuleTransformLoader: LoaderDefinition;
@@ -90,6 +92,7 @@ export const registerRouteModuleTransformRules = (
isBuild,
isSpaMode,
rootRoutePath,
+ stripServerOnlyRouteExportsInSpa,
devHmr,
},
parallelRouteTransform
diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts
index 8bedaf48..e29a6eb4 100644
--- a/src/route-transform-tasks.ts
+++ b/src/route-transform-tasks.ts
@@ -71,6 +71,8 @@ export type RouteModuleTransformTask = BaseRouteTransformTask & {
isBuild: boolean;
isSpaMode: boolean;
rootRoutePath: string | null;
+ stripServerOnlyRouteExportsInSpa?: boolean;
+ skipSpaValidation?: boolean;
devHmr?: boolean;
};
@@ -314,10 +316,12 @@ export const validateSpaModeRouteExports = ({
exportNames,
resourcePath,
rootRoutePath,
+ stripServerOnlyRouteExports = false,
}: {
exportNames: readonly string[];
resourcePath: string;
rootRoutePath: string | null;
+ stripServerOnlyRouteExports?: boolean;
}): void => {
const isRootRoute = resourcePath === rootRoutePath;
const relativePath = relative(process.cwd(), resourcePath);
@@ -326,7 +330,7 @@ export const validateSpaModeRouteExports = ({
return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp);
});
- if (invalidServerOnly.length > 0) {
+ if (!stripServerOnlyRouteExports && invalidServerOnly.length > 0) {
const list = invalidServerOnly.map(exp => `\`${exp}\``).join(', ');
throw new Error(
`SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` +
@@ -335,6 +339,31 @@ export const validateSpaModeRouteExports = ({
);
}
+ if (stripServerOnlyRouteExports) {
+ const missingClientEquivalents = [
+ ['loader', 'clientLoader'],
+ ['action', 'clientAction'],
+ ['middleware', 'clientMiddleware'],
+ ].filter(
+ ([serverExport, clientExport]) =>
+ exportNames.includes(serverExport) &&
+ !exportNames.includes(clientExport)
+ );
+ if (missingClientEquivalents.length > 0) {
+ const list = missingClientEquivalents
+ .map(
+ ([serverExport, clientExport]) =>
+ `\`${serverExport}\` requires \`${clientExport}\``
+ )
+ .join(', ');
+ throw new Error(
+ `SPA Mode: Cannot strip server-only route exports in ` +
+ `\`${relativePath}\`: ${list}. Add explicit client equivalents ` +
+ `for this client-only build.`
+ );
+ }
+ }
+
if (!isRootRoute && exportNames.includes('HydrateFallback')) {
throw new Error(
`SPA Mode: Invalid \`HydrateFallback\` export found in ` +
@@ -359,16 +388,23 @@ const transformRouteModule = async (
}
const ast = parse(code, { sourceType: 'module' });
- if (task.environmentName === 'web' && !task.ssr && task.isSpaMode) {
+ if (
+ task.environmentName === 'web' &&
+ !task.ssr &&
+ task.isSpaMode &&
+ !task.skipSpaValidation
+ ) {
validateSpaModeRouteExports({
exportNames: collectProgramExportNames(getProgram(ast)),
resourcePath: task.resourcePath,
rootRoutePath: task.rootRoutePath,
+ stripServerOnlyRouteExports: task.stripServerOnlyRouteExportsInSpa,
});
}
const removedServerOnlyExports =
- task.environmentName === 'web'
+ task.environmentName === 'web' ||
+ (task.isSpaMode && task.stripServerOnlyRouteExportsInSpa)
? removeExports(
ast,
SERVER_ONLY_ROUTE_EXPORTS,
diff --git a/src/types.ts b/src/types.ts
index cf1da055..6d8e2340 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -11,6 +11,23 @@ export type Route = {
};
export type PluginOptions = {
+ /**
+ * Alias supported Remix v2 root imports to React Router compatibility
+ * facades. This keeps one React Router runtime and does not emulate the
+ * legacy Remix deferred protocol or private APIs.
+ * @default false
+ */
+ remixCompat?: boolean;
+
+ /**
+ * In SPA mode, discard server-only route exports after requiring explicit
+ * client equivalents for loaders, actions, and middleware. This is intended
+ * for client-only recovery builds of applications whose source routes are
+ * also used by an SSR build.
+ * @default false
+ */
+ stripServerOnlyRouteExportsInSpa?: boolean;
+
/**
* Generate React Router route types during development and builds.
* Set to false when type generation is managed separately.
@@ -98,7 +115,10 @@ export type PluginOptions = {
onRouteTopologyChange?: () => void | Promise;
};
-export type ReactRouterRSCPluginOptions = Omit & {
+export type ReactRouterRSCPluginOptions = Omit<
+ PluginOptions,
+ 'rsc' | 'stripServerOnlyRouteExportsInSpa'
+> & {
/**
* Optional overrides forwarded to `rsbuild-plugin-rsc`.
* Environment names are managed by this plugin.
diff --git a/tests/build-output-transforms.test.ts b/tests/build-output-transforms.test.ts
index 0e4c3b74..28a9f2aa 100644
--- a/tests/build-output-transforms.test.ts
+++ b/tests/build-output-transforms.test.ts
@@ -63,6 +63,7 @@ const createBaseOptions = (
ssr: true,
isSpaMode: false,
rootRoutePath: resolve(appDirectory, 'root.tsx'),
+ stripServerOnlyRouteExportsInSpa: false,
routePath,
};
};
diff --git a/tests/fixtures/remix-compat-types.ts b/tests/fixtures/remix-compat-types.ts
new file mode 100644
index 00000000..1a055d52
--- /dev/null
+++ b/tests/fixtures/remix-compat-types.ts
@@ -0,0 +1,84 @@
+import {
+ defer,
+ json,
+ type EntryContext,
+ type LinksFunction,
+ type SerializeFrom,
+} from '@remix-run/node';
+import {
+ Await,
+ RemixBrowser,
+ RemixServer,
+ useActionData,
+ useLoaderData,
+} from '@remix-run/react';
+import { Link } from 'react-router-dom6';
+import { NavLink } from 'react-router-dom';
+import { defer as explicitDefer } from 'rsbuild-plugin-react-router/compat/remix-node';
+import { RemixBrowser as ExplicitRemixBrowser } from 'rsbuild-plugin-react-router/compat/remix-react';
+
+export const loader = () =>
+ defer({
+ value: Promise.resolve('ok'),
+ count: 1,
+ });
+
+export const action = () => json({ saved: true }, { status: 201 });
+export const jsonLoader = () =>
+ json({
+ created: new Date(),
+ omitted: undefined,
+ maybe: Math.random() ? 'value' : undefined,
+ array: [new Date(), undefined, () => 'omitted'] as const,
+ custom: { toJSON: () => ({ serialized: 'yes' as const }) },
+ });
+export const mixedLoader = (deferred: boolean) =>
+ deferred
+ ? defer({ tag: 'deferred' as const, created: new Date() })
+ : json({ tag: 'json' as const, created: new Date() });
+export const plainLoader = () => ({
+ created: new Date(),
+ promised: Promise.resolve(new Date()),
+});
+
+const data = useLoaderData();
+data.value.then(value => value.toUpperCase());
+data.count.toFixed();
+const actionData = useActionData();
+actionData?.saved.valueOf();
+const jsonData = null as unknown as SerializeFrom;
+const created: string = jsonData.created;
+const maybe: string | undefined = jsonData.maybe;
+const array: [string, null, null] = jsonData.array;
+const custom: { serialized: 'yes' } = jsonData.custom;
+// @ts-expect-error JSON object properties whose values are always undefined are omitted.
+jsonData.omitted;
+const mixedData = null as unknown as SerializeFrom;
+const mixedTag: 'json' | 'deferred' = mixedData.tag;
+const mixedCreated: string | Date = mixedData.created;
+const plainData = null as unknown as SerializeFrom;
+const plainCreated: Date = plainData.created;
+const promisedDate: Promise = plainData.promised;
+
+const links: LinksFunction = () => [];
+const context = {} as EntryContext;
+void [
+ Await,
+ RemixBrowser,
+ RemixServer,
+ Link,
+ NavLink,
+ links,
+ context,
+ actionData,
+ explicitDefer,
+ ExplicitRemixBrowser,
+ created,
+ maybe,
+ array,
+ custom,
+ mixedTag,
+ mixedCreated,
+ plainCreated,
+ promisedDate,
+];
diff --git a/tests/fixtures/remix-compat/remix-node.ts b/tests/fixtures/remix-compat/remix-node.ts
new file mode 100644
index 00000000..a681bb35
--- /dev/null
+++ b/tests/fixtures/remix-compat/remix-node.ts
@@ -0,0 +1 @@
+export * from 'rsbuild-plugin-react-router/compat/remix-node';
diff --git a/tests/fixtures/remix-compat/remix-react.ts b/tests/fixtures/remix-compat/remix-react.ts
new file mode 100644
index 00000000..048fd033
--- /dev/null
+++ b/tests/fixtures/remix-compat/remix-react.ts
@@ -0,0 +1 @@
+export * from 'rsbuild-plugin-react-router/compat/remix-react';
diff --git a/tests/index.test.ts b/tests/index.test.ts
index b6fadcc4..c0cf5823 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -100,6 +100,47 @@ describe('pluginReactRouter', () => {
});
});
+ it('only installs exact Remix compatibility aliases when enabled', async () => {
+ const disabled = await createStubRsbuild({ rsbuildConfig: {} });
+ disabled.context.rootPath = process.cwd();
+ disabled.addPlugins([pluginReactRouter()]);
+ const disabledConfig = await disabled.unwrapConfig();
+ expect(disabledConfig.tools.rspack.resolve.alias).not.toHaveProperty(
+ '@remix-run/react$'
+ );
+ expect(disabledConfig.tools.rspack.resolve.alias).not.toHaveProperty(
+ 'react-router-dom$'
+ );
+
+ const enabled = await createStubRsbuild({ rsbuildConfig: {} });
+ enabled.context.rootPath = process.cwd();
+ enabled.addPlugins([pluginReactRouter({ remixCompat: true })]);
+ const enabledConfig = await enabled.unwrapConfig();
+ expect(enabledConfig.tools.rspack.resolve.alias).toMatchObject({
+ '@remix-run/react$': expect.stringMatching(/compat[\/]remix-react\.js$/),
+ '@remix-run/node$': expect.stringMatching(/compat[\/]remix-node\.js$/),
+ 'react-router-dom$': requireFromHere.resolve('react-router'),
+ 'react-router-dom6$': requireFromHere.resolve('react-router'),
+ });
+ expect(enabledConfig.tools.rspack.resolve.alias).not.toHaveProperty(
+ '@remix-run/react/'
+ );
+ expect(enabledConfig.tools.rspack.resolve.alias).not.toHaveProperty(
+ '@remix-run/node/'
+ );
+ });
+
+ it('rejects Remix compatibility in RSC mode', async () => {
+ const rsbuild = await createStubRsbuild({ rsbuildConfig: {} });
+ rsbuild.addPlugins([
+ pluginReactRouter({ remixCompat: true, rsc: true }),
+ ]);
+
+ await expect(rsbuild.unwrapConfig()).rejects.toThrow(
+ '`remixCompat` is not supported with RSC mode.'
+ );
+ });
+
it('checks RSC support in the target project rather than the caller project', async () => {
const directory = fs.mkdtempSync(join(tmpdir(), 'rr-rsc-project-version-'));
try {
diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts
index 7dfefcd1..c6863039 100644
--- a/tests/manifest.test.ts
+++ b/tests/manifest.test.ts
@@ -738,6 +738,45 @@ describe('manifest', () => {
}
});
+ it('removes stripped SPA server exports from manifest flags and export metadata', async () => {
+ const { root, appDir } = createTempApp(`
+ export const loader = () => 'server';
+ export const clientLoader = () => 'client';
+ export const action = () => 'server';
+ export const clientAction = () => 'client';
+ export const headers = () => ({});
+ export default function Page() { return null; }
+ `);
+ try {
+ const { manifest, moduleExportsByRouteId } =
+ await generateReactRouterManifestForDev(
+ routes,
+ {},
+ clientStats,
+ appDir,
+ '/',
+ {
+ isBuild: true,
+ stripServerOnlyRouteExports: true,
+ }
+ );
+
+ expect(manifest.routes['routes/page']).toMatchObject({
+ hasLoader: false,
+ hasAction: false,
+ hasClientLoader: true,
+ hasClientAction: true,
+ });
+ expect(moduleExportsByRouteId['routes/page']).toEqual([
+ 'clientLoader',
+ 'clientAction',
+ 'default',
+ ]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
it('does not invent dev CSS assets when the compilation emitted none', async () => {
const { root, appDir } = createTempApp(`
import './page.css';
diff --git a/tests/react-router-framework/integration/helpers/create-fixture.ts b/tests/react-router-framework/integration/helpers/create-fixture.ts
index f63b2b9e..17bdac38 100644
--- a/tests/react-router-framework/integration/helpers/create-fixture.ts
+++ b/tests/react-router-framework/integration/helpers/create-fixture.ts
@@ -163,6 +163,8 @@ export interface FixtureInit {
prerender?: boolean;
port?: number;
templateName?: TemplateName;
+ remixCompat?: boolean;
+ stripServerOnlyRouteExportsInSpa?: boolean;
}
export type Fixture = Awaited>;
@@ -566,6 +568,9 @@ export async function createFixtureProject(
"rsbuild.config.ts": await rsbuildConfig.basic({
port,
templateName,
+ remixCompat: init.remixCompat,
+ stripServerOnlyRouteExportsInSpa:
+ init.stripServerOnlyRouteExportsInSpa,
}),
}),
...(hasReactRouterConfig
diff --git a/tests/react-router-framework/integration/helpers/rsbuild-config.ts b/tests/react-router-framework/integration/helpers/rsbuild-config.ts
index 88ad7b11..acf3c0d2 100644
--- a/tests/react-router-framework/integration/helpers/rsbuild-config.ts
+++ b/tests/react-router-framework/integration/helpers/rsbuild-config.ts
@@ -13,6 +13,8 @@ type RsbuildConfigBuildArgs = {
};
type RsbuildConfigBaseArgs = {
+ remixCompat?: boolean;
+ stripServerOnlyRouteExportsInSpa?: boolean;
templateName?: TemplateName;
/** Rspack persistent cache, kept inside the fixture (fixtures share the template's node_modules). */
buildCache?: boolean;
@@ -188,7 +190,7 @@ export const rsbuildConfig = {
]
: []),
...(args.cssCodeSplit === false ? [CSS_CODE_SPLIT_NOTE] : []),
- `plugins: [pluginReact(), ${args.mdx ? "pluginMdx(), " : ""}${args.svgr ? "pluginSvgr(), " : ""}${args.tailwind ? "pluginTailwindcss(), " : ""}${args.sass ? "pluginSass(), " : ""}${args.less ? "pluginLess(), " : ""}${routerPlugin}()],`,
+ `plugins: [pluginReact(), ${args.mdx ? "pluginMdx(), " : ""}${args.svgr ? "pluginSvgr(), " : ""}${args.tailwind ? "pluginTailwindcss(), " : ""}${args.sass ? "pluginSass(), " : ""}${args.less ? "pluginLess(), " : ""}${routerPlugin}(${args.remixCompat || args.stripServerOnlyRouteExportsInSpa ? `{${args.remixCompat ? " remixCompat: true," : ""}${args.stripServerOnlyRouteExportsInSpa ? " stripServerOnlyRouteExportsInSpa: true," : ""} }` : ""})],`,
];
return [
diff --git a/tests/react-router-framework/integration/remix-compat-test.ts b/tests/react-router-framework/integration/remix-compat-test.ts
new file mode 100644
index 00000000..9700a7e0
--- /dev/null
+++ b/tests/react-router-framework/integration/remix-compat-test.ts
@@ -0,0 +1,224 @@
+import { expect, test } from '@playwright/test';
+import {
+ createAppFixture,
+ createFixture,
+ js,
+} from './helpers/create-fixture.js';
+import type { AppFixture, Fixture } from './helpers/create-fixture.js';
+import { PlaywrightFixture } from './helpers/playwright-fixture.js';
+
+let fixture: Fixture;
+let appFixture: AppFixture;
+
+test.describe('Remix source compatibility', () => {
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ remixCompat: true,
+ files: {
+ 'tsconfig.json': js`
+ {
+ "extends": "./node_modules/rsbuild-plugin-react-router/tests/react-router-framework/tsconfig.base.json",
+ "include": ["env.d.ts", "app/**/*.ts", "app/**/*.tsx", ".react-router/types/**/*"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"],
+ "@remix-run/node": ["./app/compat/remix-node.ts"],
+ "@remix-run/react": ["./app/compat/remix-react.ts"],
+ "react-router-dom": ["./app/compat/remix-react.ts"],
+ "react-router-dom6": ["./app/compat/remix-react.ts"]
+ },
+ "noEmit": true,
+ "rootDirs": [".", ".react-router/types/"]
+ }
+ }
+ `,
+ 'app/compat/remix-node.ts': js`
+ export * from 'rsbuild-plugin-react-router/compat/remix-node';
+ `,
+ 'app/compat/remix-react.ts': js`
+ export * from 'rsbuild-plugin-react-router/compat/remix-react';
+ `,
+ 'app/entry.client.tsx': js`
+ import { startTransition, StrictMode } from 'react';
+ import { hydrateRoot } from 'react-dom/client';
+ import { RemixBrowser } from '@remix-run/react';
+
+ startTransition(() => {
+ hydrateRoot(document, );
+ });
+ `,
+ 'app/entry.server.tsx': js`
+ import { PassThrough } from 'node:stream';
+ import { createReadableStreamFromReadable, type EntryContext } from '@remix-run/node';
+ import { RemixServer } from '@remix-run/react';
+ import { renderToPipeableStream } from 'react-dom/server';
+
+ const ABORT_DELAY = 5_000;
+
+ export default function handleRequest(
+ request: Request,
+ status: number,
+ headers: Headers,
+ context: EntryContext,
+ ) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ shellRendered = true;
+ const body = new PassThrough();
+ headers.set('Content-Type', 'text/html');
+ resolve(new Response(createReadableStreamFromReadable(body), { headers, status }));
+ pipe(body);
+ },
+ onShellError: reject,
+ onError(error) {
+ status = 500;
+ if (shellRendered) console.error(error);
+ },
+ },
+ );
+ setTimeout(abort, ABORT_DELAY);
+ });
+ }
+ `,
+ 'app/routes.ts': js`
+ import { index, route } from '@react-router/dev/routes';
+ export default [
+ index('routes/_index.tsx'),
+ route('next', 'routes/next.tsx'),
+ ];
+ `,
+ 'app/routes/_index.tsx': js`
+ import { Suspense, useEffect, useState } from 'react';
+ import { defer, json } from '@remix-run/node';
+ import { Await, Form, Link, useActionData, useLoaderData } from '@remix-run/react';
+ import { NavLink } from 'react-router-dom';
+ import { Link as Link6 } from 'react-router-dom6';
+
+ export function loader() {
+ return defer(
+ {
+ value: new Promise(resolve => setTimeout(resolve, 2_000, 'document deferred')),
+ failure: new Promise((_, reject) =>
+ setTimeout(reject, 1_000, new Error('document rejection')),
+ ),
+ },
+ { status: 207, headers: { 'X-Remix-Compat': 'document' } },
+ );
+ }
+
+ export async function action({ request }) {
+ let form = await request.formData();
+ return json(
+ { message: form.get('message') },
+ { status: 201, headers: { 'X-Remix-Action': 'json' } },
+ );
+ }
+
+ export function headers({ loaderHeaders, actionHeaders }) {
+ return actionHeaders.has('X-Remix-Action') ? actionHeaders : loaderHeaders;
+ }
+
+ export default function Index() {
+ let { value, failure } = useLoaderData();
+ let actionData = useActionData();
+ let [hydrated, setHydrated] = useState(false);
+ useEffect(() => setHydrated(true), []);
+ return
+ waiting
}>
+ {value => {value}
}
+
+ waiting for document error}>
+ rejected} />
+
+
+ {actionData && {actionData.message}
}
+ next
+ next-dom
+ next6
+ ;
+ }
+ `,
+ 'app/routes/next.tsx': js`
+ import { Suspense } from 'react';
+ import { defer } from '@remix-run/node';
+ import { Await, Link, useLoaderData } from '@remix-run/react';
+
+ export function loader() {
+ return defer(
+ {
+ value: new Promise(resolve => setTimeout(resolve, 2_000, 'navigation deferred')),
+ failure: new Promise((_, reject) =>
+ setTimeout(reject, 1_000, new Error('expected rejection')),
+ ),
+ },
+ { status: 206, headers: { 'X-Remix-Compat': 'navigation' } },
+ );
+ }
+
+ export function headers({ loaderHeaders }) {
+ return loaderHeaders;
+ }
+
+ export default function Next() {
+ let { value, failure } = useLoaderData();
+ return
+ waiting}>
+ {value => {value}
}
+
+ waiting for error}>
+ rejected} />
+
+ home
+ ;
+ }
+ `,
+ },
+ });
+ appFixture = await createAppFixture(fixture);
+ });
+
+ test.afterAll(async () => appFixture.close());
+
+ test('SSR, hydration, action JSON, and deferred navigation use one RR runtime', async ({ page }) => {
+ const app = new PlaywrightFixture(appFixture, page);
+ const response = await app.goto('/', false);
+ expect(response.status()).toBe(207);
+ expect(response.headers()['x-remix-compat']).toBe('document');
+ await expect(page.locator('#fallback')).toHaveText('waiting');
+ await expect(page.locator('#document-rejected')).toHaveText('rejected');
+ await expect(page.locator('#resolved')).toHaveText('document deferred');
+ await expect(page.locator('main')).toHaveAttribute('data-hydrated', 'true');
+
+ const actionResponse = page.waitForResponse(
+ response => response.url().includes('.data') && response.request().method() === 'POST',
+ );
+ await page.getByRole('button', { name: 'submit' }).click();
+ expect((await actionResponse).status()).toBe(201);
+ expect((await actionResponse).headers()['x-remix-action']).toBe('json');
+ await expect(page.locator('#action')).toHaveText('action json');
+
+ const navigationResponse = page.waitForResponse(
+ response =>
+ new URL(response.url()).pathname === '/next.data' &&
+ response.request().method() === 'GET',
+ );
+ await page.locator('#next-dom').click();
+ expect((await navigationResponse).status()).toBe(206);
+ expect((await navigationResponse).headers()['x-remix-compat']).toBe(
+ 'navigation',
+ );
+ await expect(page.locator('#next-fallback')).toHaveText('waiting');
+ await expect(page.locator('#rejected')).toHaveText('rejected');
+ await expect(page.locator('#next-resolved')).toHaveText('navigation deferred');
+ });
+});
diff --git a/tests/react-router-framework/integration/spa-recovery-test.ts b/tests/react-router-framework/integration/spa-recovery-test.ts
new file mode 100644
index 00000000..2cc9ecba
--- /dev/null
+++ b/tests/react-router-framework/integration/spa-recovery-test.ts
@@ -0,0 +1,141 @@
+import { globSync, readFileSync } from "node:fs";
+import path from "pathe";
+import { expect, test } from "@playwright/test";
+
+import {
+ createAppFixture,
+ createFixture,
+ js,
+ type AppFixture,
+ type Fixture,
+} from "./helpers/create-fixture.js";
+import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
+import { reactRouterConfig } from "./helpers/rsbuild.js";
+
+test.describe("SPA recovery route export stripping", () => {
+ let fixture: Fixture;
+ let appFixture: AppFixture;
+
+ test.beforeAll(async () => {
+ fixture = await createFixture({
+ spaMode: true,
+ stripServerOnlyRouteExportsInSpa: true,
+ files: {
+ "react-router.config.ts": reactRouterConfig({
+ ssr: false,
+ splitRouteModules: true,
+ }),
+ "app/root.tsx": js`
+ import { Links, Meta, Outlet, Scripts } from "react-router";
+
+ const serverOnly = "SERVER_EXPORT_EXECUTED";
+ export function loader() { throw new Error(serverOnly); }
+ export function clientLoader() { return "root client data"; }
+ export function action() { throw new Error(serverOnly); }
+ export function clientAction() { return "root client action"; }
+ export function headers() { throw new Error(serverOnly); }
+
+ export function HydrateFallback() {
+ return (
+
+ Loading recovery app
+
+ );
+ }
+
+ export default function Root() {
+ return (
+
+ Recovery app
+
+ );
+ }
+ `,
+ "app/routes/_index.tsx": js`
+ import { Link } from "react-router";
+ export default function Index() {
+ return <>Home
Deep route>;
+ }
+ `,
+ "app/routes/deep.tsx": js`
+ import { Form, Link, useActionData, useLoaderData } from "react-router";
+
+ const serverOnly = "DEEP_SERVER_EXPORT_EXECUTED";
+ export function loader() { throw new Error(serverOnly); }
+ export function clientLoader() { return "deep client data"; }
+ export function action() { throw new Error(serverOnly); }
+ export function clientAction() { return "deep client action"; }
+ export function headers() { throw new Error(serverOnly); }
+
+ export default function Deep() {
+ const loaderData = useLoaderData();
+ const actionData = useActionData();
+ return <>
+ Deep
+ {loaderData}
+ {actionData}
+
+ Home
+ >;
+ }
+ `,
+ },
+ });
+ appFixture = await createAppFixture(fixture);
+ });
+
+ test.afterAll(() => appFixture.close());
+
+ test("build strips server exports from build-time and browser route modules", () => {
+ const emittedJavaScript = globSync("build/**/*.js", {
+ cwd: fixture.projectDir,
+ })
+ .map((file) => readFileSync(path.join(fixture.projectDir, file), "utf8"))
+ .join("\n");
+ expect(emittedJavaScript).not.toContain("SERVER_EXPORT_EXECUTED");
+ expect(emittedJavaScript).not.toContain("DEEP_SERVER_EXPORT_EXECUTED");
+ });
+
+ test("deep static-host navigation stays client-only", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("console", (message) => {
+ if (message.type() === "error") errors.push(message.text());
+ });
+ page.on("pageerror", (error) => errors.push(error.message));
+
+ const app = new PlaywrightFixture(appFixture, page);
+ const dataResponses = app.collectSingleFetchResponses();
+ await app.goto("/deep", true);
+
+ await expect(page.locator("[data-loader]")).toHaveText("deep client data");
+ expect(
+ await page.evaluate(() => {
+ const route = (window as any).__reactRouterManifest.routes["routes/deep"];
+ return {
+ hasLoader: route.hasLoader,
+ hasAction: route.hasAction,
+ hasClientLoader: route.hasClientLoader,
+ hasClientAction: route.hasClientAction,
+ };
+ }),
+ ).toEqual({
+ hasLoader: false,
+ hasAction: false,
+ hasClientLoader: true,
+ hasClientAction: true,
+ });
+
+ await page.getByRole("button", { name: "Run action" }).click();
+ await expect(page.locator("[data-action]")).toHaveText(
+ "deep client action",
+ );
+ await app.clickLink("/");
+ await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
+ await page.context().setOffline(true);
+ await app.clickLink("/deep");
+ await expect(page.locator("[data-loader]")).toHaveText("deep client data");
+
+ expect(dataResponses).toHaveLength(0);
+ expect(errors).toEqual([]);
+ });
+});
diff --git a/tests/remix-compat.test.ts b/tests/remix-compat.test.ts
new file mode 100644
index 00000000..58c2b69e
--- /dev/null
+++ b/tests/remix-compat.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it, rstest } from '@rstest/core';
+import type { EntryContext } from 'react-router';
+import { defer, json } from '../src/compat/responses';
+import { RemixServer } from '../src/compat/remix-react';
+
+describe('Remix compatibility facades', () => {
+ it('creates a JSON Response with status and headers', async () => {
+ const response = json(
+ { value: 'ok' },
+ { status: 201, headers: { 'X-Compat': 'json' } }
+ );
+
+ expect(response).toBeInstanceOf(Response);
+ expect(response.status).toBe(201);
+ expect(response.headers.get('X-Compat')).toBe('json');
+ expect(response.headers.get('Content-Type')).toContain('application/json');
+ await expect(response.json()).resolves.toEqual({ value: 'ok' });
+ });
+
+ it('preserves an explicit JSON content type', () => {
+ const response = json(
+ { value: 'ok' },
+ { headers: { 'Content-Type': 'application/problem+json' } }
+ );
+ expect(response.headers.get('Content-Type')).toBe(
+ 'application/problem+json'
+ );
+ });
+
+ it('matches JSON serialization for dates, undefined values, arrays, and toJSON', async () => {
+ const response = json({
+ created: new Date('2026-10-21T00:00:00.000Z'),
+ omitted: undefined,
+ array: [undefined, () => 'omitted'],
+ custom: { toJSON: () => ({ serialized: 'yes' }) },
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ created: '2026-10-21T00:00:00.000Z',
+ array: [null, null],
+ custom: { serialized: 'yes' },
+ });
+ });
+
+ it('passes deferred values and response metadata to React Router data', async () => {
+ const resolved = Promise.resolve('done');
+ const rejected = Promise.reject(new Error('deferred failure'));
+ rejected.catch(() => undefined);
+ const result = defer(
+ { resolved, rejected },
+ { status: 202, headers: { 'X-Compat': 'defer' } }
+ );
+
+ expect(result.type).toBe('DataWithResponseInit');
+ expect(result.data).toEqual({ resolved, rejected });
+ expect(result.init).toEqual({
+ status: 202,
+ headers: { 'X-Compat': 'defer' },
+ });
+ await expect(result.data.resolved).resolves.toBe('done');
+ await expect(result.data.rejected).rejects.toThrow('deferred failure');
+ });
+
+ it('maps RemixServer to ServerRouter and warns about abortDelay', () => {
+ const warn = rstest.spyOn(console, 'warn').mockImplementation(() => {});
+ const context = {} as EntryContext;
+ const element = RemixServer({
+ context,
+ url: 'https://example.test/',
+ abortDelay: 1000,
+ });
+
+ expect(element.type).toBeDefined();
+ expect(element.props).toEqual({ context, url: 'https://example.test/' });
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('Abort the render stream in entry.server')
+ );
+ warn.mockRestore();
+ });
+});
diff --git a/tests/route-module-transform-loader.test.ts b/tests/route-module-transform-loader.test.ts
index 736bd324..4e33e2e9 100644
--- a/tests/route-module-transform-loader.test.ts
+++ b/tests/route-module-transform-loader.test.ts
@@ -3,6 +3,7 @@ import {
TraceMap,
} from '@jridgewell/trace-mapping';
import { describe, expect, it, rstest } from '@rstest/core';
+import { getExportNames } from '../src/export-utils';
import routeModuleTransformLoader, {
flushRouteModuleTransformLoaderPerformance,
type RouteModuleTransformLoaderOptions,
@@ -18,6 +19,7 @@ const defaultOptions: RouteModuleTransformLoaderOptions = {
isBuild: false,
isSpaMode: false,
rootRoutePath: '/project/app/root.tsx',
+ stripServerOnlyRouteExportsInSpa: false,
devHmr: false,
};
@@ -73,6 +75,74 @@ describe('route module transform loader', () => {
expect(result.map).toBeUndefined();
});
+ it('strips server exports from the SPA server module when opted in', async () => {
+ const result = await runLoader(
+ `
+ import { serverValue } from './data.server';
+ export function loader() { return serverValue; }
+ export function clientLoader() { return 'client'; }
+ export function action() { return serverValue; }
+ export function clientAction() { return 'client action'; }
+ export function headers() { return {}; }
+ export default function Route() { return null; }
+ `,
+ {
+ options: {
+ ...defaultOptions,
+ environmentName: 'node',
+ ssr: false,
+ isBuild: true,
+ isSpaMode: true,
+ stripServerOnlyRouteExportsInSpa: true,
+ },
+ }
+ );
+
+ expect(await getExportNames(result.code)).toEqual([
+ 'clientLoader',
+ 'clientAction',
+ 'default',
+ ]);
+ expect(result.code).not.toContain('./data.server');
+ });
+
+ it('requires client equivalents before stripping SPA server exports', async () => {
+ await expect(
+ runLoader(`export function loader() { return null; }`, {
+ options: {
+ ...defaultOptions,
+ ssr: false,
+ isBuild: true,
+ isSpaMode: true,
+ stripServerOnlyRouteExportsInSpa: true,
+ },
+ })
+ ).rejects.toThrow('`loader` requires `clientLoader`');
+ });
+
+ it('keeps non-root HydrateFallback validation when stripping server exports', async () => {
+ await expect(
+ runLoader(
+ `
+ export function loader() { return null; }
+ export function clientLoader() { return null; }
+ export function HydrateFallback() { return null; }
+ `,
+ {
+ options: {
+ ...defaultOptions,
+ ssr: false,
+ isBuild: true,
+ isSpaMode: true,
+ stripServerOnlyRouteExportsInSpa: true,
+ },
+ }
+ )
+ ).rejects.toThrow(
+ '`HydrateFallback` is only permitted on the root route in SPA Mode'
+ );
+ });
+
it('emits a source map when the loader context requests one', async () => {
const result = await runLoader(
`
diff --git a/tests/route-module-transform-rules.test.ts b/tests/route-module-transform-rules.test.ts
index 7adff20d..9f8737c8 100644
--- a/tests/route-module-transform-rules.test.ts
+++ b/tests/route-module-transform-rules.test.ts
@@ -17,6 +17,7 @@ const createRuleConfig = (
isBuild,
isSpaMode: false,
rootRoutePath: '/project/app/root.tsx',
+ stripServerOnlyRouteExportsInSpa: false,
devHmr,
logPerformance: true,
routeByFilePath: new Map([[routePath, { id: 'page' }]]),
@@ -62,6 +63,7 @@ describe('route module transform rules', () => {
isBuild: false,
isSpaMode: false,
rootRoutePath: '/project/app/root.tsx',
+ stripServerOnlyRouteExportsInSpa: false,
devHmr: true,
},
parallel: true,
diff --git a/tests/tsconfig.remix-compat.json b/tests/tsconfig.remix-compat.json
new file mode 100644
index 00000000..3f16195a
--- /dev/null
+++ b/tests/tsconfig.remix-compat.json
@@ -0,0 +1,22 @@
+{
+ "extends": "../config/tsconfig-node16.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "baseUrl": "..",
+ "paths": {
+ "@remix-run/node": [
+ "tests/fixtures/remix-compat/remix-node.ts"
+ ],
+ "@remix-run/react": [
+ "tests/fixtures/remix-compat/remix-react.ts"
+ ],
+ "react-router-dom6": [
+ "tests/fixtures/remix-compat/remix-react.ts"
+ ],
+ "react-router-dom": [
+ "tests/fixtures/remix-compat/remix-react.ts"
+ ]
+ }
+ },
+ "files": ["fixtures/remix-compat-types.ts"]
+}