From 2d0152a101eb357e3bfd3183701044a78cff1db3 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy
Date: Wed, 21 Oct 2026 04:12:01 -0700
Subject: [PATCH 1/3] feat: add Remix source compatibility facades
---
.changeset/remix-source-compat.md | 5 +
README.md | 54 +++++
package.json | 12 +-
rslib.config.ts | 4 +
scripts/test-package-interop.mts | 21 ++
src/compat/remix-node.ts | 10 +
src/compat/remix-react.tsx | 54 +++++
src/compat/responses.ts | 50 +++++
src/index.ts | 21 +-
src/types.ts | 8 +
tests/fixtures/remix-compat-types.ts | 44 ++++
tests/index.test.ts | 37 ++++
.../integration/helpers/create-fixture.ts | 2 +
.../integration/helpers/rsbuild-config.ts | 3 +-
.../integration/remix-compat-test.ts | 194 ++++++++++++++++++
tests/remix-compat.test.ts | 65 ++++++
tests/tsconfig.remix-compat.json | 19 ++
17 files changed, 600 insertions(+), 3 deletions(-)
create mode 100644 .changeset/remix-source-compat.md
create mode 100644 src/compat/remix-node.ts
create mode 100644 src/compat/remix-react.tsx
create mode 100644 src/compat/responses.ts
create mode 100644 tests/fixtures/remix-compat-types.ts
create mode 100644 tests/react-router-framework/integration/remix-compat-test.ts
create mode 100644 tests/remix-compat.test.ts
create mode 100644 tests/tsconfig.remix-compat.json
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/README.md b/README.md
index 49df75ba..451fdb21 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,7 @@ pluginReactRouter({
unstableLazyCompilationPrewarm: false,
logPerformance: false,
federation: false,
+ remixCompat: false,
});
```
@@ -113,6 +114,59 @@ 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`, and `react-router-dom6`. It does not alias package prefixes
+or arbitrary deep imports. The same facades are public package exports for
+TypeScript and explicit use:
+
+```json
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@remix-run/react": [
+ "./node_modules/rsbuild-plugin-react-router/dist/compat/remix-react.d.ts"
+ ],
+ "@remix-run/node": [
+ "./node_modules/rsbuild-plugin-react-router/dist/compat/remix-node.d.ts"
+ ],
+ "react-router-dom6": [
+ "./node_modules/rsbuild-plugin-react-router/dist/compat/remix-react.d.ts"
+ ]
+ }
+ }
+}
+```
+
+`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/compat/remix-node.ts b/src/compat/remix-node.ts
new file mode 100644
index 00000000..891c532e
--- /dev/null
+++ b/src/compat/remix-node.ts
@@ -0,0 +1,10 @@
+export * from '@react-router/node';
+export * from 'react-router';
+export { defer, json } from './responses.js';
+export type {
+ DeferFunction,
+ JsonFunction,
+ 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..aaac8ff4
--- /dev/null
+++ b/src/compat/remix-react.tsx
@@ -0,0 +1,54 @@
+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,
+ 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..9efe7f67
--- /dev/null
+++ b/src/compat/responses.ts
@@ -0,0 +1,50 @@
+import { data } from 'react-router';
+
+export type TypedResponse = Omit & {
+ json(): Promise;
+};
+
+type DataFunctionResult = T extends (...args: any[]) => infer Result
+ ? Awaited
+ : Awaited;
+
+export type SerializeFrom =
+ DataFunctionResult extends TypedResponse
+ ? Data
+ : DataFunctionResult extends TypedDeferredData
+ ? Data
+ : DataFunctionResult;
+
+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..b953cb3e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -201,6 +201,9 @@ export const pluginReactRouter = (
...options,
};
const isRscMode = Boolean(pluginOptions.rsc);
+ if (isRscMode && pluginOptions.remixCompat) {
+ throw new Error('`remixCompat` is not supported with RSC mode.');
+ }
const logPerformance = pluginOptions.logPerformance === true;
const setupStartMs = logPerformance ? performance.now() : 0;
const performanceProfiler = createReactRouterPerformanceProfiler({
@@ -1012,7 +1015,23 @@ 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) {
+ resolveConfig.alias = {
+ ...resolveConfig.alias,
+ '@remix-run/react$': resolve(__dirname, 'compat/remix-react.js'),
+ '@remix-run/node$': resolve(__dirname, 'compat/remix-node.js'),
+ ...(resolveAppPackagePath('react-router', api.context.rootPath)
+ ? {
+ 'react-router-dom6$': resolveAppPackagePath(
+ 'react-router',
+ api.context.rootPath
+ ),
+ }
+ : {}),
+ };
+ }
// Browser code (React itself) reads `process.env.NODE_ENV`. Rsbuild only
// emits the define for its recognized modes; an unrecognized NODE_ENV
diff --git a/src/types.ts b/src/types.ts
index cf1da055..f03a054b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -11,6 +11,14 @@ 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;
+
/**
* Generate React Router route types during development and builds.
* Set to false when type generation is managed separately.
diff --git a/tests/fixtures/remix-compat-types.ts b/tests/fixtures/remix-compat-types.ts
new file mode 100644
index 00000000..eac6c874
--- /dev/null
+++ b/tests/fixtures/remix-compat-types.ts
@@ -0,0 +1,44 @@
+import {
+ defer,
+ json,
+ type EntryContext,
+ type LinksFunction,
+} from '@remix-run/node';
+import {
+ Await,
+ RemixBrowser,
+ RemixServer,
+ useActionData,
+ useLoaderData,
+} from '@remix-run/react';
+import { Link } from 'react-router-dom6';
+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 });
+
+const data = useLoaderData();
+data.value.then(value => value.toUpperCase());
+data.count.toFixed();
+const actionData = useActionData();
+actionData?.saved.valueOf();
+
+const links: LinksFunction = () => [];
+const context = {} as EntryContext;
+void [
+ Await,
+ RemixBrowser,
+ RemixServer,
+ Link,
+ links,
+ context,
+ actionData,
+ explicitDefer,
+ ExplicitRemixBrowser,
+];
diff --git a/tests/index.test.ts b/tests/index.test.ts
index b6fadcc4..b6379d30 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -100,6 +100,43 @@ 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$'
+ );
+
+ 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-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/react-router-framework/integration/helpers/create-fixture.ts b/tests/react-router-framework/integration/helpers/create-fixture.ts
index f63b2b9e..829d2629 100644
--- a/tests/react-router-framework/integration/helpers/create-fixture.ts
+++ b/tests/react-router-framework/integration/helpers/create-fixture.ts
@@ -163,6 +163,7 @@ export interface FixtureInit {
prerender?: boolean;
port?: number;
templateName?: TemplateName;
+ remixCompat?: boolean;
}
export type Fixture = Awaited>;
@@ -566,6 +567,7 @@ export async function createFixtureProject(
"rsbuild.config.ts": await rsbuildConfig.basic({
port,
templateName,
+ remixCompat: init.remixCompat,
}),
}),
...(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..ffd74046 100644
--- a/tests/react-router-framework/integration/helpers/rsbuild-config.ts
+++ b/tests/react-router-framework/integration/helpers/rsbuild-config.ts
@@ -13,6 +13,7 @@ type RsbuildConfigBuildArgs = {
};
type RsbuildConfigBaseArgs = {
+ remixCompat?: boolean;
templateName?: TemplateName;
/** Rspack persistent cache, kept inside the fixture (fixtures share the template's node_modules). */
buildCache?: boolean;
@@ -188,7 +189,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 ? "{ remixCompat: 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..261bba35
--- /dev/null
+++ b/tests/react-router-framework/integration/remix-compat-test.ts
@@ -0,0 +1,194 @@
+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: {
+ '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 { Link as Link6 } from 'react-router-dom6';
+
+ export function loader() {
+ return defer(
+ {
+ value: new Promise(resolve => setTimeout(resolve, 500, 'document deferred')),
+ failure: new Promise((_, reject) =>
+ setTimeout(reject, 250, 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
+ 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, 500, 'navigation deferred')),
+ failure: new Promise((_, reject) =>
+ setTimeout(reject, 250, 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 =>
+ response.url().includes('.data'),
+ );
+ await page.locator('#next6').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/remix-compat.test.ts b/tests/remix-compat.test.ts
new file mode 100644
index 00000000..eb9ae1e1
--- /dev/null
+++ b/tests/remix-compat.test.ts
@@ -0,0 +1,65 @@
+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('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/tsconfig.remix-compat.json b/tests/tsconfig.remix-compat.json
new file mode 100644
index 00000000..9ef73ca0
--- /dev/null
+++ b/tests/tsconfig.remix-compat.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../config/tsconfig-node16.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "baseUrl": "..",
+ "paths": {
+ "@remix-run/node": [
+ "dist/compat/remix-node.d.ts"
+ ],
+ "@remix-run/react": [
+ "dist/compat/remix-react.d.ts"
+ ],
+ "react-router-dom6": [
+ "dist/compat/remix-react.d.ts"
+ ]
+ }
+ },
+ "files": ["fixtures/remix-compat-types.ts"]
+}
From f4db61096ee483ce5fb9da9a3d1b72932b455437 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy
Date: Wed, 21 Oct 2026 05:14:18 -0700
Subject: [PATCH 2/3] fix: model Remix response serialization
---
README.md | 37 ++++---
src/compat/remix-node.ts | 1 +
src/compat/remix-react.tsx | 1 +
src/compat/responses.ts | 101 ++++++++++++++++--
src/index.ts | 12 ++-
tests/fixtures/remix-compat-types.ts | 40 +++++++
tests/fixtures/remix-compat/remix-node.ts | 1 +
tests/fixtures/remix-compat/remix-react.ts | 1 +
tests/index.test.ts | 4 +
.../integration/remix-compat-test.ts | 38 ++++++-
tests/remix-compat.test.ts | 15 +++
tests/tsconfig.remix-compat.json | 9 +-
12 files changed, 227 insertions(+), 33 deletions(-)
create mode 100644 tests/fixtures/remix-compat/remix-node.ts
create mode 100644 tests/fixtures/remix-compat/remix-react.ts
diff --git a/README.md b/README.md
index 451fdb21..6c6b7890 100644
--- a/README.md
+++ b/README.md
@@ -126,29 +126,42 @@ pluginReactRouter({ remixCompat: true });
```
The option installs exact build aliases for `@remix-run/react`,
-`@remix-run/node`, and `react-router-dom6`. It does not alias package prefixes
-or arbitrary deep imports. The same facades are public package exports for
-TypeScript and explicit use:
+`@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": [
- "./node_modules/rsbuild-plugin-react-router/dist/compat/remix-react.d.ts"
- ],
- "@remix-run/node": [
- "./node_modules/rsbuild-plugin-react-router/dist/compat/remix-node.d.ts"
- ],
- "react-router-dom6": [
- "./node_modules/rsbuild-plugin-react-router/dist/compat/remix-react.d.ts"
- ]
+ "@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
diff --git a/src/compat/remix-node.ts b/src/compat/remix-node.ts
index 891c532e..c0e09f07 100644
--- a/src/compat/remix-node.ts
+++ b/src/compat/remix-node.ts
@@ -4,6 +4,7 @@ export { defer, json } from './responses.js';
export type {
DeferFunction,
JsonFunction,
+ Jsonify,
SerializeFrom,
TypedDeferredData,
TypedResponse,
diff --git a/src/compat/remix-react.tsx b/src/compat/remix-react.tsx
index aaac8ff4..078e750a 100644
--- a/src/compat/remix-react.tsx
+++ b/src/compat/remix-react.tsx
@@ -14,6 +14,7 @@ export { defer, json } from './responses.js';
export type {
DeferFunction,
JsonFunction,
+ Jsonify,
SerializeFrom,
TypedDeferredData,
TypedResponse,
diff --git a/src/compat/responses.ts b/src/compat/responses.ts
index 9efe7f67..67e0082f 100644
--- a/src/compat/responses.ts
+++ b/src/compat/responses.ts
@@ -1,19 +1,104 @@
-import { data } from 'react-router';
+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>>;
-export type SerializeFrom =
- DataFunctionResult extends TypedResponse
+type SerializeResult =
+ T extends TypedResponse
? Data
- : DataFunctionResult extends TypedDeferredData
- ? Data
- : DataFunctionResult;
+ : T extends TypedDeferredData
+ ? ReactRouterSerialize
+ : ReactRouterSerialize;
+
+export type SerializeFrom = SerializeResult>;
export type TypedDeferredData> =
ReturnType> & {
@@ -24,7 +109,7 @@ export type TypedDeferredData> =
export type JsonFunction = (
value: Data,
init?: number | ResponseInit
-) => TypedResponse;
+) => TypedResponse>;
export type DeferFunction = >(
value: Data,
@@ -43,7 +128,7 @@ export const json: JsonFunction = (value, init) => {
return new Response(JSON.stringify(value), {
...responseInit,
headers,
- }) as TypedResponse;
+ }) as TypedResponse>;
};
export const defer: DeferFunction = (value, init) =>
diff --git a/src/index.ts b/src/index.ts
index b953cb3e..2e6c9ebd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1018,16 +1018,18 @@ export const pluginReactRouter = (
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'),
- ...(resolveAppPackagePath('react-router', api.context.rootPath)
+ ...(reactRouterPath
? {
- 'react-router-dom6$': resolveAppPackagePath(
- 'react-router',
- api.context.rootPath
- ),
+ 'react-router-dom$': reactRouterPath,
+ 'react-router-dom6$': reactRouterPath,
}
: {}),
};
diff --git a/tests/fixtures/remix-compat-types.ts b/tests/fixtures/remix-compat-types.ts
index eac6c874..1a055d52 100644
--- a/tests/fixtures/remix-compat-types.ts
+++ b/tests/fixtures/remix-compat-types.ts
@@ -3,6 +3,7 @@ import {
json,
type EntryContext,
type LinksFunction,
+ type SerializeFrom,
} from '@remix-run/node';
import {
Await,
@@ -12,6 +13,7 @@ import {
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';
@@ -22,12 +24,41 @@ export const loader = () =>
});
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;
@@ -36,9 +67,18 @@ void [
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 b6379d30..c0cf5823 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -108,6 +108,9 @@ describe('pluginReactRouter', () => {
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();
@@ -116,6 +119,7 @@ describe('pluginReactRouter', () => {
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(
diff --git a/tests/react-router-framework/integration/remix-compat-test.ts b/tests/react-router-framework/integration/remix-compat-test.ts
index 261bba35..72f411dc 100644
--- a/tests/react-router-framework/integration/remix-compat-test.ts
+++ b/tests/react-router-framework/integration/remix-compat-test.ts
@@ -15,6 +15,32 @@ test.describe('Remix source compatibility', () => {
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';
@@ -72,14 +98,15 @@ test.describe('Remix source compatibility', () => {
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, 500, 'document deferred')),
+ value: new Promise(resolve => setTimeout(resolve, 2_000, 'document deferred')),
failure: new Promise((_, reject) =>
- setTimeout(reject, 250, new Error('document rejection')),
+ setTimeout(reject, 1_000, new Error('document rejection')),
),
},
{ status: 207, headers: { 'X-Remix-Compat': 'document' } },
@@ -116,6 +143,7 @@ test.describe('Remix source compatibility', () => {
{actionData && {actionData.message}
}
next
+ next-dom
next6
;
}
@@ -128,9 +156,9 @@ test.describe('Remix source compatibility', () => {
export function loader() {
return defer(
{
- value: new Promise(resolve => setTimeout(resolve, 500, 'navigation deferred')),
+ value: new Promise(resolve => setTimeout(resolve, 2_000, 'navigation deferred')),
failure: new Promise((_, reject) =>
- setTimeout(reject, 250, new Error('expected rejection')),
+ setTimeout(reject, 1_000, new Error('expected rejection')),
),
},
{ status: 206, headers: { 'X-Remix-Compat': 'navigation' } },
@@ -182,7 +210,7 @@ test.describe('Remix source compatibility', () => {
const navigationResponse = page.waitForResponse(response =>
response.url().includes('.data'),
);
- await page.locator('#next6').click();
+ await page.locator('#next-dom').click();
expect((await navigationResponse).status()).toBe(206);
expect((await navigationResponse).headers()['x-remix-compat']).toBe(
'navigation',
diff --git a/tests/remix-compat.test.ts b/tests/remix-compat.test.ts
index eb9ae1e1..58c2b69e 100644
--- a/tests/remix-compat.test.ts
+++ b/tests/remix-compat.test.ts
@@ -27,6 +27,21 @@ describe('Remix compatibility facades', () => {
);
});
+ 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'));
diff --git a/tests/tsconfig.remix-compat.json b/tests/tsconfig.remix-compat.json
index 9ef73ca0..3f16195a 100644
--- a/tests/tsconfig.remix-compat.json
+++ b/tests/tsconfig.remix-compat.json
@@ -5,13 +5,16 @@
"baseUrl": "..",
"paths": {
"@remix-run/node": [
- "dist/compat/remix-node.d.ts"
+ "tests/fixtures/remix-compat/remix-node.ts"
],
"@remix-run/react": [
- "dist/compat/remix-react.d.ts"
+ "tests/fixtures/remix-compat/remix-react.ts"
],
"react-router-dom6": [
- "dist/compat/remix-react.d.ts"
+ "tests/fixtures/remix-compat/remix-react.ts"
+ ],
+ "react-router-dom": [
+ "tests/fixtures/remix-compat/remix-react.ts"
]
}
},
From efce6cf7bd16567b3ee83eb5f10e12643e5d8436 Mon Sep 17 00:00:00 2001
From: ScriptedAlchemy
Date: Wed, 21 Oct 2026 09:59:33 -0700
Subject: [PATCH 3/3] test: match Remix navigation data response
---
.../react-router-framework/integration/remix-compat-test.ts | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/tests/react-router-framework/integration/remix-compat-test.ts b/tests/react-router-framework/integration/remix-compat-test.ts
index 72f411dc..9700a7e0 100644
--- a/tests/react-router-framework/integration/remix-compat-test.ts
+++ b/tests/react-router-framework/integration/remix-compat-test.ts
@@ -207,8 +207,10 @@ test.describe('Remix source compatibility', () => {
expect((await actionResponse).headers()['x-remix-action']).toBe('json');
await expect(page.locator('#action')).toHaveText('action json');
- const navigationResponse = page.waitForResponse(response =>
- response.url().includes('.data'),
+ 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);