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..6c6b7890 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ pluginReactRouter({ unstableLazyCompilationPrewarm: false, logPerformance: false, federation: false, + remixCompat: false, }); ``` @@ -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. 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..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..2e6c9ebd 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,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 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..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/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..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/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/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"] +}