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.
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ pluginReactRouter({
unstableLazyCompilationPrewarm: false,
logPerformance: false,
federation: false,
remixCompat: false,
});
```

Expand All @@ -113,6 +114,72 @@ pluginReactRouter({
| `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). |

### 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
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);
}
135 changes: 135 additions & 0 deletions src/compat/responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { data, type useLoaderData } from 'react-router';

declare const typedResponseBrand: unique symbol;

export type TypedResponse<T = unknown> = Omit<Response, 'json'> & {
json(): Promise<T>;
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<T> = 0 extends 1 & T ? true : false;
type NonJsonBranch<T> = T extends unknown
? Jsonify<T> extends never
? T
: never
: never;
type JsonifyArrayValue<T> =
| Jsonify<T>
| ([NonJsonBranch<T>] extends [never] ? never : null);
type JsonifyList<T extends readonly unknown[]> = T extends readonly []
? []
: T extends readonly [infer First, ...infer Rest]
? [JsonifyArrayValue<First>, ...JsonifyList<Rest>]
: Array<JsonifyArrayValue<T[number]>>;
type JsonifyObject<T extends object> = {
[Key in keyof T as Key extends string | number
? [Jsonify<T[Key]>] extends [never]
? never
: [NonJsonBranch<T[Key]>] extends [never]
? Key
: never
: never]-?: Jsonify<T[Key]>;
} & {
[Key in keyof T as Key extends string | number
? [Jsonify<T[Key]>] extends [never]
? never
: [NonJsonBranch<T[Key]>] extends [never]
? never
: Key
: never]?: Jsonify<T[Key]>;
};

export type Jsonify<T> =
IsAny<T> extends true
? any
: T extends { toJSON(): infer Json }
? Jsonify<Json>
: T extends JsonPrimitive
? T
: T extends String
? string
: T extends Number
? number
: T extends Boolean
? boolean
: T extends
| Map<unknown, unknown>
| Set<unknown>
| Promise<unknown>
? { [key: string]: never }
: T extends TypedArray
? Record<string, number>
: T extends NotJson
? never
: T extends readonly unknown[]
? JsonifyList<T>
: T extends object
? {
[Key in keyof JsonifyObject<T>]: JsonifyObject<T>[Key];
}
: unknown extends T
? JsonValue
: never;

type DataFunctionResult<T> = T extends (...args: any[]) => infer Result
? Awaited<Result>
: Awaited<T>;
type ReactRouterSerialize<T> = ReturnType<typeof useLoaderData<() => T>>;

type SerializeResult<T> =
T extends TypedResponse<infer Data>
? Data
: T extends TypedDeferredData<infer Data>
? ReactRouterSerialize<Data>
: ReactRouterSerialize<T>;

export type SerializeFrom<T> = SerializeResult<DataFunctionResult<T>>;

export type TypedDeferredData<Data extends Record<string, unknown>> =
ReturnType<typeof data<Data>> & {
type: 'DataWithResponseInit';
data: Data;
};

export type JsonFunction = <Data>(
value: Data,
init?: number | ResponseInit
) => TypedResponse<Jsonify<Data>>;

export type DeferFunction = <Data extends Record<string, unknown>>(
value: Data,
init?: number | ResponseInit
) => TypedDeferredData<Data>;

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<Jsonify<typeof value>>;
};

export const defer: DeferFunction = (value, init) =>
data(value, init) as TypedDeferredData<typeof value>;
Loading
Loading