diff --git a/.changeset/remix-source-compat.md b/.changeset/remix-source-compat.md new file mode 100644 index 00000000..4f4a9403 --- /dev/null +++ b/.changeset/remix-source-compat.md @@ -0,0 +1,5 @@ +--- +'rsbuild-plugin-react-router': minor +--- + +Add opt-in Remix v2 root-import compatibility facades backed by one React Router runtime. diff --git a/.changeset/spa-recovery-route-exports.md b/.changeset/spa-recovery-route-exports.md new file mode 100644 index 00000000..49f1acea --- /dev/null +++ b/.changeset/spa-recovery-route-exports.md @@ -0,0 +1,6 @@ +--- +'rsbuild-plugin-react-router': minor +--- + +Add an opt-in SPA recovery mode that strips server-only route exports while +preserving explicit client loaders, actions, middleware, and hydration UI. diff --git a/README.md b/README.md index 49df75ba..e374075f 100644 --- a/README.md +++ b/README.md @@ -99,20 +99,112 @@ pluginReactRouter({ unstableLazyCompilationPrewarm: false, logPerformance: false, federation: false, + remixCompat: false, + stripServerOnlyRouteExportsInSpa: false, }); ``` -| Option | Default | Description | -| -------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `typegen` | `true` | Generates React Router route types during development and builds. Set to `false` when type generation is managed separately. | -| `customServer` | `false` | Disables the built-in development SSR middleware. Enable this when an app owns the server with `createDevServer()` or an adapter. | -| `serverOutput` | Derived | Emitted Rsbuild server format: `'module'` or `'commonjs'`. When omitted, React Router's `serverModuleFormat` selects the format (`'esm'` -> `'module'`, `'cjs'` -> `'commonjs'`); setting `serverOutput` overrides it. | -| `lazyCompilation` | `true` | Optional Rsbuild dev lazy-compilation config. When enabled here or through `dev.lazyCompilation`, React Router hydration-critical modules stay eager so the browser manifest and route modules are not replaced by lazy proxies. | -| `unstableLazyCompilationPrewarm` | `false` | Experimental prewarm for emitted lazy-compilation proxy modules after dev compiles. Enable with `true` when route JS proxy startup should happen shortly after compiler readiness. | -| `logPerformance` | `false` | Logs structured React Router plugin timing information. | -| `parallelRouteTransform` | `undefined` | Controls worker-thread route transforms. `undefined` and `false` keep transforms inline, `true` uses an automatic worker count, and a positive integer sets the maximum worker count. | -| `onRouteTopologyChange` | `undefined` | Notification for programmatic/custom dev servers. Recreate the Rsbuild server when route files are added, removed, or moved. The callback is not awaited. | -| `federation` | `false` | Enables the plugin's experimental Module Federation integration. | +| Option | Default | Description | +| ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `typegen` | `true` | Generates React Router route types during development and builds. Set to `false` when type generation is managed separately. | +| `customServer` | `false` | Disables the built-in development SSR middleware. Enable this when an app owns the server with `createDevServer()` or an adapter. | +| `serverOutput` | Derived | Emitted Rsbuild server format: `'module'` or `'commonjs'`. When omitted, React Router's `serverModuleFormat` selects the format (`'esm'` -> `'module'`, `'cjs'` -> `'commonjs'`); setting `serverOutput` overrides it. | +| `lazyCompilation` | `true` | Optional Rsbuild dev lazy-compilation config. When enabled here or through `dev.lazyCompilation`, React Router hydration-critical modules stay eager so the browser manifest and route modules are not replaced by lazy proxies. | +| `unstableLazyCompilationPrewarm` | `false` | Experimental prewarm for emitted lazy-compilation proxy modules after dev compiles. Enable with `true` when route JS proxy startup should happen shortly after compiler readiness. | +| `logPerformance` | `false` | Logs structured React Router plugin timing information. | +| `parallelRouteTransform` | `undefined` | Controls worker-thread route transforms. `undefined` and `false` keep transforms inline, `true` uses an automatic worker count, and a positive integer sets the maximum worker count. | +| `onRouteTopologyChange` | `undefined` | Notification for programmatic/custom dev servers. Recreate the Rsbuild server when route files are added, removed, or moved. The callback is not awaited. | +| `federation` | `false` | Enables the plugin's experimental Module Federation integration. | +| `remixCompat` | `false` | Aliases supported Remix v2 root imports to the package's React Router compatibility facades. See [Remix source compatibility](#remix-source-compatibility). | +| `stripServerOnlyRouteExportsInSpa` | `false` | For a client-only SPA recovery build, strips `loader`, `action`, `middleware`, and `headers` while preserving explicit client equivalents. See [SPA recovery builds](#spa-recovery-builds). | + +### SPA recovery builds + +An application that normally builds with SSR can produce a separate, strictly +client-only recovery artifact from the same route sources: + +```ts +pluginReactRouter({ stripServerOnlyRouteExportsInSpa: true }); +``` + +This option only applies when React Router is configured with `ssr: false` and +without prerender paths. It removes `loader`, `action`, `middleware`, and +`headers` from both browser and build-time server route modules. Browser and +server manifests therefore report no server loader or action, so recovery +navigations do not issue `.data` requests. `clientLoader`, `clientAction`, +`clientMiddleware`, and the root `HydrateFallback` remain available. A route +with a server loader, action, or middleware must define the corresponding +client export; the build fails with the missing export otherwise. + +This does not turn server logic into client logic, synthesize `serverLoader`, +or emit an SSR snapshot. The default SPA validation is unchanged when the +option is absent. Non-root `HydrateFallback` exports remain invalid, and +`ssr: false` prerender builds retain their normal server-export validation. + +### Remix source compatibility + +Set `remixCompat: true` to keep selected Remix v2 source imports while running +one React Router 7 or 8 runtime: + +```ts +pluginReactRouter({ remixCompat: true }); +``` + +The option installs exact build aliases for `@remix-run/react`, +`@remix-run/node`, `react-router-dom`, and `react-router-dom6`. Both DOM package +names resolve to the application's `react-router` installation so an installed +React Router 6 package cannot create a second router context. It does not alias +package prefixes or arbitrary deep imports. The same facades are public package +exports for explicit use. For old import names to work in both TypeScript and +Rsbuild, add runtime modules to the app: + +```ts title="app/compat/remix-node.ts" +export * from 'rsbuild-plugin-react-router/compat/remix-node'; +``` + +```ts title="app/compat/remix-react.ts" +export * from 'rsbuild-plugin-react-router/compat/remix-react'; +``` + +Then point the exact TypeScript paths at those modules: + +```json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@remix-run/react": ["./app/compat/remix-react.ts"], + "@remix-run/node": ["./app/compat/remix-node.ts"], + "react-router-dom6": ["./app/compat/remix-react.ts"], + "react-router-dom": ["./app/compat/remix-react.ts"] + } + } +} +``` + +Do not point `paths` directly at files under this package's `dist` directory. +Rsbuild also applies TypeScript paths during bundling, so a `.d.ts` target can +be treated as runtime source. The local modules above resolve through the +package exports in both the compiler and bundler. + +`compat/remix-react` re-exports React Router's public root API and provides +`RemixBrowser` as `HydratedRouter` plus a `RemixServer` wrapper around +`ServerRouter`. `compat/remix-node` re-exports the public `react-router` and +`@react-router/node` APIs. Both facades add these source-compatible helpers: + +- `json(value, init)` returns a real JSON `Response`, preserving status and + headers. +- `defer(value, init)` delegates to React Router `data(value, init)`. Promises, + including rejected promises, are streamed by React Router's single-fetch + protocol and retain response status and headers. It is not Remix's old + `TypedDeferredData` runtime protocol. + +The compatibility boundary is intentionally narrow. It does not support +Remix private context/protocol APIs, package deep imports, `installGlobals`, +legacy upload APIs, Remix config/build conventions, or two router runtimes. +`RemixServer` accepts `abortDelay` to ease source migration but cannot apply it; +the server entry must abort its render stream. The wrapper emits a one-time +warning when the prop is present. RSC mode rejects `remixCompat`. Lazy compilation applies to the browser build. Server dynamic imports still compile eagerly; `lazyCompilation` does not enable on-demand SSR compilation. diff --git a/package.json b/package.json index 12565e8c..12da76c6 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,16 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, + "./compat/remix-react": { + "types": "./dist/compat/remix-react.d.ts", + "import": "./dist/compat/remix-react.js", + "require": "./dist/compat/remix-react.cjs" + }, + "./compat/remix-node": { + "types": "./dist/compat/remix-node.d.ts", + "import": "./dist/compat/remix-node.js", + "require": "./dist/compat/remix-node.cjs" + }, "./templates/entry.client": { "types": "./dist/templates/entry.client.d.ts", "import": "./dist/templates/entry.client.js", @@ -69,7 +79,7 @@ "e2e:no-build": "pnpm test:package-interop && pnpm --filter './examples/{default-template,spa-mode,prerender,custom-node-server,cloudflare,client-only,react-router-8,rsc-mode}' test:e2e", "dev": "rslib build --watch", "test": "pnpm typecheck && rstest run", - "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json && pnpm --filter integration typecheck && pnpm --filter integration-rsbuild-template typecheck && pnpm --filter integration-rsc-framework typecheck && pnpm --filter integration-rsc-preview typecheck", + "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json && tsc -p tests/tsconfig.remix-compat.json && pnpm --filter integration typecheck && pnpm --filter integration-rsbuild-template typecheck && pnpm --filter integration-rsc-framework typecheck && pnpm --filter integration-rsc-preview typecheck", "test:watch": "rstest watch", "test:coverage": "rstest run --coverage", "test:core": "rstest run -c ./rstest.config.ts", diff --git a/rslib.config.ts b/rslib.config.ts index 14a20cc8..e91115da 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -11,6 +11,8 @@ const config = defineConfig({ source: { entry: { index: './src/index.ts', + 'compat/remix-node': './src/compat/remix-node.ts', + 'compat/remix-react': './src/compat/remix-react.tsx', 'route-module-transform-loader': './src/route-module-transform-loader.ts', 'rsc-route-transform-loader': './src/rsc-route-transform-loader.ts', @@ -28,6 +30,8 @@ const config = defineConfig({ source: { entry: { index: './src/index.ts', + 'compat/remix-node': './src/compat/remix-node.ts', + 'compat/remix-react': './src/compat/remix-react.tsx', 'rsc-route-transform-loader': './src/rsc-route-transform-loader.ts', 'templates/entry.server': './src/templates/entry.server.tsx', 'templates/entry.client': './src/templates/entry.client.tsx', diff --git a/scripts/test-package-interop.mts b/scripts/test-package-interop.mts index cacfa515..1e18a802 100644 --- a/scripts/test-package-interop.mts +++ b/scripts/test-package-interop.mts @@ -177,11 +177,32 @@ const verifyRscPublicSurface = (esm, commonjs) => { } }; +const verifyRemixCompatPublicSurface = async () => { + const esmNode = await import('rsbuild-plugin-react-router/compat/remix-node'); + const esmReact = + await import('rsbuild-plugin-react-router/compat/remix-react'); + const commonjsNode = require('rsbuild-plugin-react-router/compat/remix-node'); + const commonjsReact = require('rsbuild-plugin-react-router/compat/remix-react'); + + for (const runtime of [esmNode, commonjsNode]) { + assert.equal(typeof runtime.defer, 'function'); + assert.equal(typeof runtime.json, 'function'); + assert.equal(typeof runtime.createReadableStreamFromReadable, 'function'); + } + for (const runtime of [esmReact, commonjsReact]) { + assert.equal(typeof runtime.RemixBrowser, 'function'); + assert.equal(typeof runtime.RemixServer, 'function'); + assert.equal(typeof runtime.Await, 'function'); + assert.equal(typeof runtime.useLoaderData, 'function'); + } +}; + const main = async () => { const [esm, commonjs] = await loadEntryPoints(); await verifyPackIncludesOriginalSource(); verifyEffectFreeRuntimeOutputs(); verifyRscPublicSurface(esm, commonjs); + await verifyRemixCompatPublicSurface(); process.chdir( fileURLToPath(new URL('../tests/fixtures/dev-runtime/', import.meta.url)) ); diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts index b1f9db47..c4676552 100644 --- a/src/build-output-transforms.ts +++ b/src/build-output-transforms.ts @@ -102,6 +102,7 @@ type RegisterBuildOutputTransformsOptions = { ssr: boolean; isSpaMode: boolean; rootRoutePath: string; + stripServerOnlyRouteExportsInSpa: boolean; outputClientPath: string; isDevHmrEnabled?: () => boolean; onRouteModuleAnalysis?: ( @@ -133,6 +134,7 @@ export const registerBuildOutputTransforms = ({ ssr, isSpaMode, rootRoutePath, + stripServerOnlyRouteExportsInSpa, outputClientPath, isDevHmrEnabled = () => false, onRouteModuleAnalysis, @@ -173,6 +175,7 @@ export const registerBuildOutputTransforms = ({ isBuild, isSpaMode, rootRoutePath, + stripServerOnlyRouteExportsInSpa, devHmr: isDevHmrEnabled(), }) ); @@ -243,6 +246,7 @@ export const registerBuildOutputTransforms = ({ createReactRouterManifestOptions({ routeChunks: routeChunkOptions, routeModuleAnalysis, + stripServerOnlyRouteExports: stripServerOnlyRouteExportsInSpa, }) )); return { @@ -297,6 +301,7 @@ export const registerBuildOutputTransforms = ({ .exports, resourcePath: args.resourcePath, rootRoutePath, + stripServerOnlyRouteExports: stripServerOnlyRouteExportsInSpa, }); } @@ -328,6 +333,8 @@ export const registerBuildOutputTransforms = ({ isBuild, isSpaMode, rootRoutePath, + stripServerOnlyRouteExportsInSpa, + skipSpaValidation: true, }); } ); diff --git a/src/compat/remix-node.ts b/src/compat/remix-node.ts new file mode 100644 index 00000000..c0e09f07 --- /dev/null +++ b/src/compat/remix-node.ts @@ -0,0 +1,11 @@ +export * from '@react-router/node'; +export * from 'react-router'; +export { defer, json } from './responses.js'; +export type { + DeferFunction, + JsonFunction, + Jsonify, + SerializeFrom, + TypedDeferredData, + TypedResponse, +} from './responses.js'; diff --git a/src/compat/remix-react.tsx b/src/compat/remix-react.tsx new file mode 100644 index 00000000..078e750a --- /dev/null +++ b/src/compat/remix-react.tsx @@ -0,0 +1,55 @@ +import { createElement, type ReactElement } from 'react'; +import { ServerRouter, type ServerRouterProps } from 'react-router'; +import { + useActionData as useActionDataRR, + useLoaderData as useLoaderDataRR, + useRouteLoaderData as useRouteLoaderDataRR, +} from 'react-router'; +import type { SerializeFrom } from './responses.js'; + +export * from 'react-router'; +export { HydratedRouter as RemixBrowser } from 'react-router/dom'; +export type { HydratedRouterProps as RemixBrowserProps } from 'react-router/dom'; +export { defer, json } from './responses.js'; +export type { + DeferFunction, + JsonFunction, + Jsonify, + SerializeFrom, + TypedDeferredData, + TypedResponse, +} from './responses.js'; + +export function useLoaderData(): SerializeFrom { + return useLoaderDataRR() as SerializeFrom; +} + +export function useActionData(): SerializeFrom | undefined { + return useActionDataRR() as SerializeFrom | undefined; +} + +export function useRouteLoaderData( + routeId: string +): SerializeFrom | undefined { + return useRouteLoaderDataRR(routeId) as SerializeFrom | undefined; +} + +export interface RemixServerProps extends ServerRouterProps { + /** @deprecated Abort the render stream in entry.server instead. */ + abortDelay?: number; +} + +let warnedAboutAbortDelay = false; + +export function RemixServer({ + abortDelay, + ...props +}: RemixServerProps): ReactElement { + if (abortDelay !== undefined && !warnedAboutAbortDelay) { + warnedAboutAbortDelay = true; + console.warn( + '[rsbuild-plugin-react-router] RemixServer abortDelay does not control React Router streaming. Abort the render stream in entry.server instead.' + ); + } + return createElement(ServerRouter, props); +} diff --git a/src/compat/responses.ts b/src/compat/responses.ts new file mode 100644 index 00000000..67e0082f --- /dev/null +++ b/src/compat/responses.ts @@ -0,0 +1,135 @@ +import { data, type useLoaderData } from 'react-router'; + +declare const typedResponseBrand: unique symbol; + +export type TypedResponse = Omit & { + json(): Promise; + readonly [typedResponseBrand]: T; +}; + +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +type NotJson = undefined | symbol | ((...args: any[]) => unknown); +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; +type IsAny = 0 extends 1 & T ? true : false; +type NonJsonBranch = T extends unknown + ? Jsonify extends never + ? T + : never + : never; +type JsonifyArrayValue = + | Jsonify + | ([NonJsonBranch] extends [never] ? never : null); +type JsonifyList = T extends readonly [] + ? [] + : T extends readonly [infer First, ...infer Rest] + ? [JsonifyArrayValue, ...JsonifyList] + : Array>; +type JsonifyObject = { + [Key in keyof T as Key extends string | number + ? [Jsonify] extends [never] + ? never + : [NonJsonBranch] extends [never] + ? Key + : never + : never]-?: Jsonify; +} & { + [Key in keyof T as Key extends string | number + ? [Jsonify] extends [never] + ? never + : [NonJsonBranch] extends [never] + ? never + : Key + : never]?: Jsonify; +}; + +export type Jsonify = + IsAny extends true + ? any + : T extends { toJSON(): infer Json } + ? Jsonify + : T extends JsonPrimitive + ? T + : T extends String + ? string + : T extends Number + ? number + : T extends Boolean + ? boolean + : T extends + | Map + | Set + | Promise + ? { [key: string]: never } + : T extends TypedArray + ? Record + : T extends NotJson + ? never + : T extends readonly unknown[] + ? JsonifyList + : T extends object + ? { + [Key in keyof JsonifyObject]: JsonifyObject[Key]; + } + : unknown extends T + ? JsonValue + : never; + +type DataFunctionResult = T extends (...args: any[]) => infer Result + ? Awaited + : Awaited; +type ReactRouterSerialize = ReturnType T>>; + +type SerializeResult = + T extends TypedResponse + ? Data + : T extends TypedDeferredData + ? ReactRouterSerialize + : ReactRouterSerialize; + +export type SerializeFrom = SerializeResult>; + +export type TypedDeferredData> = + ReturnType> & { + type: 'DataWithResponseInit'; + data: Data; + }; + +export type JsonFunction = ( + value: Data, + init?: number | ResponseInit +) => TypedResponse>; + +export type DeferFunction = >( + value: Data, + init?: number | ResponseInit +) => TypedDeferredData; + +const normalizeResponseInit = (init?: number | ResponseInit): ResponseInit => + typeof init === 'number' ? { status: init } : (init ?? {}); + +export const json: JsonFunction = (value, init) => { + const responseInit = normalizeResponseInit(init); + const headers = new Headers(responseInit.headers); + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json; charset=utf-8'); + } + return new Response(JSON.stringify(value), { + ...responseInit, + headers, + }) as TypedResponse>; +}; + +export const defer: DeferFunction = (value, init) => + data(value, init) as TypedDeferredData; diff --git a/src/index.ts b/src/index.ts index 31ed493f..2bd643c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,6 +201,14 @@ export const pluginReactRouter = ( ...options, }; const isRscMode = Boolean(pluginOptions.rsc); + if (isRscMode && pluginOptions.remixCompat) { + throw new Error('`remixCompat` is not supported with RSC mode.'); + } + if (isRscMode && pluginOptions.stripServerOnlyRouteExportsInSpa) { + throw new Error( + '`stripServerOnlyRouteExportsInSpa` is not supported with RSC mode.' + ); + } const logPerformance = pluginOptions.logPerformance === true; const setupStartMs = logPerformance ? performance.now() : 0; const performanceProfiler = createReactRouterPerformanceProfiler({ @@ -517,6 +525,9 @@ export const pluginReactRouter = ( const isPrerenderEnabled = prerenderConfig !== undefined && prerenderConfig !== false; const isSpaMode = !ssr && !isPrerenderEnabled; + const stripServerOnlyRouteExportsInSpa = Boolean( + isSpaMode && pluginOptions.stripServerOnlyRouteExportsInSpa + ); const routeCount = Object.keys(routes).length; const routeChunkCache: RouteChunkCache = new Map(); const useRouteModuleTransformLoader = @@ -1012,7 +1023,25 @@ export const pluginReactRouter = ( routeCount >= 256 && (config.performance?.printFileSize === undefined || config.performance.printFileSize === true); - const resolveConfig = modePlan.createResolveConfig(api.context.rootPath); + const resolveConfig = + modePlan.createResolveConfig(api.context.rootPath) ?? {}; + if (pluginOptions.remixCompat) { + const reactRouterPath = resolveAppPackagePath( + 'react-router', + api.context.rootPath + ); + resolveConfig.alias = { + ...resolveConfig.alias, + '@remix-run/react$': resolve(__dirname, 'compat/remix-react.js'), + '@remix-run/node$': resolve(__dirname, 'compat/remix-node.js'), + ...(reactRouterPath + ? { + 'react-router-dom$': reactRouterPath, + 'react-router-dom6$': reactRouterPath, + } + : {}), + }; + } // Browser code (React itself) reads `process.env.NODE_ENV`. Rsbuild only // emits the define for its recognized modes; an unrecognized NODE_ENV @@ -1212,6 +1241,7 @@ export const pluginReactRouter = ( isBuild, isSpaMode, rootRoutePath, + stripServerOnlyRouteExportsInSpa, devHmr: environmentDevHmrEnabled, logPerformance, routeByFilePath, @@ -1290,6 +1320,7 @@ export const pluginReactRouter = ( future, manifestChunkNames, routeModuleAnalysis, + stripServerOnlyRouteExports: stripServerOnlyRouteExportsInSpa, onManifest: (manifest, sri, moduleExportsByRouteId, context) => performanceProfiler.recordSync( 'web', @@ -1339,6 +1370,7 @@ export const pluginReactRouter = ( ssr, isSpaMode, rootRoutePath, + stripServerOnlyRouteExportsInSpa, outputClientPath, isDevHmrEnabled: () => devHmrEnabled, onRouteModuleAnalysis: rememberRouteModuleAnalysis, diff --git a/src/manifest.ts b/src/manifest.ts index 4315c449..6a2e4ad0 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -15,6 +15,7 @@ import { combineURLs, createRouteId } from './plugin-utils.js'; import { CLIENT_EXPORTS, DEFAULT_JS_DIST_PATH, + SERVER_ONLY_ROUTE_EXPORTS_SET, SERVER_EXPORTS, } from './constants.js'; import { @@ -119,21 +120,27 @@ export type RouteModuleAnalysisProvider = ( export type ReactRouterManifestOptions = RouteChunkManifestOptions & { routeModuleAnalysis?: RouteModuleAnalysisProvider; + stripServerOnlyRouteExports?: boolean; }; export const createReactRouterManifestOptions = ({ routeChunks, routeModuleAnalysis, + stripServerOnlyRouteExports, }: { routeChunks?: RouteChunkManifestOptions; routeModuleAnalysis?: RouteModuleAnalysisProvider; + stripServerOnlyRouteExports?: boolean; }): ReactRouterManifestOptions | undefined => { - if (!routeChunks && !routeModuleAnalysis) { + if (!routeChunks && !routeModuleAnalysis && !stripServerOnlyRouteExports) { return undefined; } return { ...routeChunks, ...(routeModuleAnalysis ? { routeModuleAnalysis } : {}), + ...(stripServerOnlyRouteExports + ? { stripServerOnlyRouteExports: true } + : {}), }; }; @@ -263,6 +270,7 @@ const analyzeRouteForManifestEffect = ({ routeFilePath, route, routeModuleAnalysis, + stripServerOnlyRouteExports, }: { discoveredCssAssets: string[]; isBuild: boolean; @@ -271,6 +279,7 @@ const analyzeRouteForManifestEffect = ({ routeFilePath: string; route: Route; routeModuleAnalysis?: RouteModuleAnalysisProvider; + stripServerOnlyRouteExports?: boolean; }): Effect.Effect => tryPluginPromise(async () => { const { code, exports: exportNames } = @@ -286,10 +295,14 @@ const analyzeRouteForManifestEffect = ({ ) : null; + const manifestExportNames = stripServerOnlyRouteExports + ? exportNames.filter(name => !SERVER_ONLY_ROUTE_EXPORTS_SET.has(name)) + : exportNames; + return { cssAssets: discoveredCssAssets, - exports: new Set(exportNames), - routeModuleExports: exportNames, + exports: new Set(manifestExportNames), + routeModuleExports: manifestExportNames, hasRouteChunkByExportName: chunkInfo?.hasRouteChunkByExportName ?? null, }; }).pipe( @@ -457,6 +470,8 @@ function generateReactRouterManifestForDevEffect( routeFilePath, route, routeModuleAnalysis: manifestOptions?.routeModuleAnalysis, + stripServerOnlyRouteExports: + manifestOptions?.stripServerOnlyRouteExports, }); if (isBuild && enforceSplitRouteModules && routeChunkConfig) { diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 846439e8..4a439061 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -49,6 +49,7 @@ type ModifyBrowserManifestOptions = { future?: { unstable_subResourceIntegrity?: boolean }; manifestChunkNames?: ReadonlySet; routeModuleAnalysis?: RouteModuleAnalysisProvider; + stripServerOnlyRouteExports?: boolean; onManifest?: ( manifest: ReactRouterManifest, sri: Record | true | undefined, @@ -212,6 +213,7 @@ export function registerModifyBrowserManifestAssets( createReactRouterManifestOptions({ routeChunks: routeChunkOptions, routeModuleAnalysis: options?.routeModuleAnalysis, + stripServerOnlyRouteExports: options?.stripServerOnlyRouteExports, }) ); const browserManifest = { ...manifest }; diff --git a/src/route-module-transform-loader.ts b/src/route-module-transform-loader.ts index c0e22504..92505c27 100644 --- a/src/route-module-transform-loader.ts +++ b/src/route-module-transform-loader.ts @@ -12,6 +12,7 @@ export type RouteModuleTransformLoaderOptions = { isBuild: boolean; isSpaMode: boolean; rootRoutePath: string | null; + stripServerOnlyRouteExportsInSpa: boolean; devHmr: boolean; }; @@ -113,6 +114,8 @@ const routeModuleTransformLoader: LoaderDefinition; @@ -90,6 +92,7 @@ export const registerRouteModuleTransformRules = ( isBuild, isSpaMode, rootRoutePath, + stripServerOnlyRouteExportsInSpa, devHmr, }, parallelRouteTransform diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts index 8bedaf48..e29a6eb4 100644 --- a/src/route-transform-tasks.ts +++ b/src/route-transform-tasks.ts @@ -71,6 +71,8 @@ export type RouteModuleTransformTask = BaseRouteTransformTask & { isBuild: boolean; isSpaMode: boolean; rootRoutePath: string | null; + stripServerOnlyRouteExportsInSpa?: boolean; + skipSpaValidation?: boolean; devHmr?: boolean; }; @@ -314,10 +316,12 @@ export const validateSpaModeRouteExports = ({ exportNames, resourcePath, rootRoutePath, + stripServerOnlyRouteExports = false, }: { exportNames: readonly string[]; resourcePath: string; rootRoutePath: string | null; + stripServerOnlyRouteExports?: boolean; }): void => { const isRootRoute = resourcePath === rootRoutePath; const relativePath = relative(process.cwd(), resourcePath); @@ -326,7 +330,7 @@ export const validateSpaModeRouteExports = ({ return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp); }); - if (invalidServerOnly.length > 0) { + if (!stripServerOnlyRouteExports && invalidServerOnly.length > 0) { const list = invalidServerOnly.map(exp => `\`${exp}\``).join(', '); throw new Error( `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + @@ -335,6 +339,31 @@ export const validateSpaModeRouteExports = ({ ); } + if (stripServerOnlyRouteExports) { + const missingClientEquivalents = [ + ['loader', 'clientLoader'], + ['action', 'clientAction'], + ['middleware', 'clientMiddleware'], + ].filter( + ([serverExport, clientExport]) => + exportNames.includes(serverExport) && + !exportNames.includes(clientExport) + ); + if (missingClientEquivalents.length > 0) { + const list = missingClientEquivalents + .map( + ([serverExport, clientExport]) => + `\`${serverExport}\` requires \`${clientExport}\`` + ) + .join(', '); + throw new Error( + `SPA Mode: Cannot strip server-only route exports in ` + + `\`${relativePath}\`: ${list}. Add explicit client equivalents ` + + `for this client-only build.` + ); + } + } + if (!isRootRoute && exportNames.includes('HydrateFallback')) { throw new Error( `SPA Mode: Invalid \`HydrateFallback\` export found in ` + @@ -359,16 +388,23 @@ const transformRouteModule = async ( } const ast = parse(code, { sourceType: 'module' }); - if (task.environmentName === 'web' && !task.ssr && task.isSpaMode) { + if ( + task.environmentName === 'web' && + !task.ssr && + task.isSpaMode && + !task.skipSpaValidation + ) { validateSpaModeRouteExports({ exportNames: collectProgramExportNames(getProgram(ast)), resourcePath: task.resourcePath, rootRoutePath: task.rootRoutePath, + stripServerOnlyRouteExports: task.stripServerOnlyRouteExportsInSpa, }); } const removedServerOnlyExports = - task.environmentName === 'web' + task.environmentName === 'web' || + (task.isSpaMode && task.stripServerOnlyRouteExportsInSpa) ? removeExports( ast, SERVER_ONLY_ROUTE_EXPORTS, diff --git a/src/types.ts b/src/types.ts index cf1da055..6d8e2340 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,23 @@ export type Route = { }; export type PluginOptions = { + /** + * Alias supported Remix v2 root imports to React Router compatibility + * facades. This keeps one React Router runtime and does not emulate the + * legacy Remix deferred protocol or private APIs. + * @default false + */ + remixCompat?: boolean; + + /** + * In SPA mode, discard server-only route exports after requiring explicit + * client equivalents for loaders, actions, and middleware. This is intended + * for client-only recovery builds of applications whose source routes are + * also used by an SSR build. + * @default false + */ + stripServerOnlyRouteExportsInSpa?: boolean; + /** * Generate React Router route types during development and builds. * Set to false when type generation is managed separately. @@ -98,7 +115,10 @@ export type PluginOptions = { onRouteTopologyChange?: () => void | Promise; }; -export type ReactRouterRSCPluginOptions = Omit & { +export type ReactRouterRSCPluginOptions = Omit< + PluginOptions, + 'rsc' | 'stripServerOnlyRouteExportsInSpa' +> & { /** * Optional overrides forwarded to `rsbuild-plugin-rsc`. * Environment names are managed by this plugin. diff --git a/tests/build-output-transforms.test.ts b/tests/build-output-transforms.test.ts index 0e4c3b74..28a9f2aa 100644 --- a/tests/build-output-transforms.test.ts +++ b/tests/build-output-transforms.test.ts @@ -63,6 +63,7 @@ const createBaseOptions = ( ssr: true, isSpaMode: false, rootRoutePath: resolve(appDirectory, 'root.tsx'), + stripServerOnlyRouteExportsInSpa: false, routePath, }; }; diff --git a/tests/fixtures/remix-compat-types.ts b/tests/fixtures/remix-compat-types.ts new file mode 100644 index 00000000..1a055d52 --- /dev/null +++ b/tests/fixtures/remix-compat-types.ts @@ -0,0 +1,84 @@ +import { + defer, + json, + type EntryContext, + type LinksFunction, + type SerializeFrom, +} from '@remix-run/node'; +import { + Await, + RemixBrowser, + RemixServer, + useActionData, + useLoaderData, +} from '@remix-run/react'; +import { Link } from 'react-router-dom6'; +import { NavLink } from 'react-router-dom'; +import { defer as explicitDefer } from 'rsbuild-plugin-react-router/compat/remix-node'; +import { RemixBrowser as ExplicitRemixBrowser } from 'rsbuild-plugin-react-router/compat/remix-react'; + +export const loader = () => + defer({ + value: Promise.resolve('ok'), + count: 1, + }); + +export const action = () => json({ saved: true }, { status: 201 }); +export const jsonLoader = () => + json({ + created: new Date(), + omitted: undefined, + maybe: Math.random() ? 'value' : undefined, + array: [new Date(), undefined, () => 'omitted'] as const, + custom: { toJSON: () => ({ serialized: 'yes' as const }) }, + }); +export const mixedLoader = (deferred: boolean) => + deferred + ? defer({ tag: 'deferred' as const, created: new Date() }) + : json({ tag: 'json' as const, created: new Date() }); +export const plainLoader = () => ({ + created: new Date(), + promised: Promise.resolve(new Date()), +}); + +const data = useLoaderData(); +data.value.then(value => value.toUpperCase()); +data.count.toFixed(); +const actionData = useActionData(); +actionData?.saved.valueOf(); +const jsonData = null as unknown as SerializeFrom; +const created: string = jsonData.created; +const maybe: string | undefined = jsonData.maybe; +const array: [string, null, null] = jsonData.array; +const custom: { serialized: 'yes' } = jsonData.custom; +// @ts-expect-error JSON object properties whose values are always undefined are omitted. +jsonData.omitted; +const mixedData = null as unknown as SerializeFrom; +const mixedTag: 'json' | 'deferred' = mixedData.tag; +const mixedCreated: string | Date = mixedData.created; +const plainData = null as unknown as SerializeFrom; +const plainCreated: Date = plainData.created; +const promisedDate: Promise = plainData.promised; + +const links: LinksFunction = () => []; +const context = {} as EntryContext; +void [ + Await, + RemixBrowser, + RemixServer, + Link, + NavLink, + links, + context, + actionData, + explicitDefer, + ExplicitRemixBrowser, + created, + maybe, + array, + custom, + mixedTag, + mixedCreated, + plainCreated, + promisedDate, +]; diff --git a/tests/fixtures/remix-compat/remix-node.ts b/tests/fixtures/remix-compat/remix-node.ts new file mode 100644 index 00000000..a681bb35 --- /dev/null +++ b/tests/fixtures/remix-compat/remix-node.ts @@ -0,0 +1 @@ +export * from 'rsbuild-plugin-react-router/compat/remix-node'; diff --git a/tests/fixtures/remix-compat/remix-react.ts b/tests/fixtures/remix-compat/remix-react.ts new file mode 100644 index 00000000..048fd033 --- /dev/null +++ b/tests/fixtures/remix-compat/remix-react.ts @@ -0,0 +1 @@ +export * from 'rsbuild-plugin-react-router/compat/remix-react'; diff --git a/tests/index.test.ts b/tests/index.test.ts index b6fadcc4..c0cf5823 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -100,6 +100,47 @@ describe('pluginReactRouter', () => { }); }); + it('only installs exact Remix compatibility aliases when enabled', async () => { + const disabled = await createStubRsbuild({ rsbuildConfig: {} }); + disabled.context.rootPath = process.cwd(); + disabled.addPlugins([pluginReactRouter()]); + const disabledConfig = await disabled.unwrapConfig(); + expect(disabledConfig.tools.rspack.resolve.alias).not.toHaveProperty( + '@remix-run/react$' + ); + expect(disabledConfig.tools.rspack.resolve.alias).not.toHaveProperty( + 'react-router-dom$' + ); + + const enabled = await createStubRsbuild({ rsbuildConfig: {} }); + enabled.context.rootPath = process.cwd(); + enabled.addPlugins([pluginReactRouter({ remixCompat: true })]); + const enabledConfig = await enabled.unwrapConfig(); + expect(enabledConfig.tools.rspack.resolve.alias).toMatchObject({ + '@remix-run/react$': expect.stringMatching(/compat[\/]remix-react\.js$/), + '@remix-run/node$': expect.stringMatching(/compat[\/]remix-node\.js$/), + 'react-router-dom$': requireFromHere.resolve('react-router'), + 'react-router-dom6$': requireFromHere.resolve('react-router'), + }); + expect(enabledConfig.tools.rspack.resolve.alias).not.toHaveProperty( + '@remix-run/react/' + ); + expect(enabledConfig.tools.rspack.resolve.alias).not.toHaveProperty( + '@remix-run/node/' + ); + }); + + it('rejects Remix compatibility in RSC mode', async () => { + const rsbuild = await createStubRsbuild({ rsbuildConfig: {} }); + rsbuild.addPlugins([ + pluginReactRouter({ remixCompat: true, rsc: true }), + ]); + + await expect(rsbuild.unwrapConfig()).rejects.toThrow( + '`remixCompat` is not supported with RSC mode.' + ); + }); + it('checks RSC support in the target project rather than the caller project', async () => { const directory = fs.mkdtempSync(join(tmpdir(), 'rr-rsc-project-version-')); try { diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 7dfefcd1..c6863039 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -738,6 +738,45 @@ describe('manifest', () => { } }); + it('removes stripped SPA server exports from manifest flags and export metadata', async () => { + const { root, appDir } = createTempApp(` + export const loader = () => 'server'; + export const clientLoader = () => 'client'; + export const action = () => 'server'; + export const clientAction = () => 'client'; + export const headers = () => ({}); + export default function Page() { return null; } + `); + try { + const { manifest, moduleExportsByRouteId } = + await generateReactRouterManifestForDev( + routes, + {}, + clientStats, + appDir, + '/', + { + isBuild: true, + stripServerOnlyRouteExports: true, + } + ); + + expect(manifest.routes['routes/page']).toMatchObject({ + hasLoader: false, + hasAction: false, + hasClientLoader: true, + hasClientAction: true, + }); + expect(moduleExportsByRouteId['routes/page']).toEqual([ + 'clientLoader', + 'clientAction', + 'default', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('does not invent dev CSS assets when the compilation emitted none', async () => { const { root, appDir } = createTempApp(` import './page.css'; diff --git a/tests/react-router-framework/integration/helpers/create-fixture.ts b/tests/react-router-framework/integration/helpers/create-fixture.ts index f63b2b9e..17bdac38 100644 --- a/tests/react-router-framework/integration/helpers/create-fixture.ts +++ b/tests/react-router-framework/integration/helpers/create-fixture.ts @@ -163,6 +163,8 @@ export interface FixtureInit { prerender?: boolean; port?: number; templateName?: TemplateName; + remixCompat?: boolean; + stripServerOnlyRouteExportsInSpa?: boolean; } export type Fixture = Awaited>; @@ -566,6 +568,9 @@ export async function createFixtureProject( "rsbuild.config.ts": await rsbuildConfig.basic({ port, templateName, + remixCompat: init.remixCompat, + stripServerOnlyRouteExportsInSpa: + init.stripServerOnlyRouteExportsInSpa, }), }), ...(hasReactRouterConfig diff --git a/tests/react-router-framework/integration/helpers/rsbuild-config.ts b/tests/react-router-framework/integration/helpers/rsbuild-config.ts index 88ad7b11..acf3c0d2 100644 --- a/tests/react-router-framework/integration/helpers/rsbuild-config.ts +++ b/tests/react-router-framework/integration/helpers/rsbuild-config.ts @@ -13,6 +13,8 @@ type RsbuildConfigBuildArgs = { }; type RsbuildConfigBaseArgs = { + remixCompat?: boolean; + stripServerOnlyRouteExportsInSpa?: boolean; templateName?: TemplateName; /** Rspack persistent cache, kept inside the fixture (fixtures share the template's node_modules). */ buildCache?: boolean; @@ -188,7 +190,7 @@ export const rsbuildConfig = { ] : []), ...(args.cssCodeSplit === false ? [CSS_CODE_SPLIT_NOTE] : []), - `plugins: [pluginReact(), ${args.mdx ? "pluginMdx(), " : ""}${args.svgr ? "pluginSvgr(), " : ""}${args.tailwind ? "pluginTailwindcss(), " : ""}${args.sass ? "pluginSass(), " : ""}${args.less ? "pluginLess(), " : ""}${routerPlugin}()],`, + `plugins: [pluginReact(), ${args.mdx ? "pluginMdx(), " : ""}${args.svgr ? "pluginSvgr(), " : ""}${args.tailwind ? "pluginTailwindcss(), " : ""}${args.sass ? "pluginSass(), " : ""}${args.less ? "pluginLess(), " : ""}${routerPlugin}(${args.remixCompat || args.stripServerOnlyRouteExportsInSpa ? `{${args.remixCompat ? " remixCompat: true," : ""}${args.stripServerOnlyRouteExportsInSpa ? " stripServerOnlyRouteExportsInSpa: true," : ""} }` : ""})],`, ]; return [ diff --git a/tests/react-router-framework/integration/remix-compat-test.ts b/tests/react-router-framework/integration/remix-compat-test.ts new file mode 100644 index 00000000..9700a7e0 --- /dev/null +++ b/tests/react-router-framework/integration/remix-compat-test.ts @@ -0,0 +1,224 @@ +import { expect, test } from '@playwright/test'; +import { + createAppFixture, + createFixture, + js, +} from './helpers/create-fixture.js'; +import type { AppFixture, Fixture } from './helpers/create-fixture.js'; +import { PlaywrightFixture } from './helpers/playwright-fixture.js'; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.describe('Remix source compatibility', () => { + test.beforeAll(async () => { + fixture = await createFixture({ + remixCompat: true, + files: { + 'tsconfig.json': js` + { + "extends": "./node_modules/rsbuild-plugin-react-router/tests/react-router-framework/tsconfig.base.json", + "include": ["env.d.ts", "app/**/*.ts", "app/**/*.tsx", ".react-router/types/**/*"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"], + "@remix-run/node": ["./app/compat/remix-node.ts"], + "@remix-run/react": ["./app/compat/remix-react.ts"], + "react-router-dom": ["./app/compat/remix-react.ts"], + "react-router-dom6": ["./app/compat/remix-react.ts"] + }, + "noEmit": true, + "rootDirs": [".", ".react-router/types/"] + } + } + `, + 'app/compat/remix-node.ts': js` + export * from 'rsbuild-plugin-react-router/compat/remix-node'; + `, + 'app/compat/remix-react.ts': js` + export * from 'rsbuild-plugin-react-router/compat/remix-react'; + `, + 'app/entry.client.tsx': js` + import { startTransition, StrictMode } from 'react'; + import { hydrateRoot } from 'react-dom/client'; + import { RemixBrowser } from '@remix-run/react'; + + startTransition(() => { + hydrateRoot(document, ); + }); + `, + 'app/entry.server.tsx': js` + import { PassThrough } from 'node:stream'; + import { createReadableStreamFromReadable, type EntryContext } from '@remix-run/node'; + import { RemixServer } from '@remix-run/react'; + import { renderToPipeableStream } from 'react-dom/server'; + + const ABORT_DELAY = 5_000; + + export default function handleRequest( + request: Request, + status: number, + headers: Headers, + context: EntryContext, + ) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + headers.set('Content-Type', 'text/html'); + resolve(new Response(createReadableStreamFromReadable(body), { headers, status })); + pipe(body); + }, + onShellError: reject, + onError(error) { + status = 500; + if (shellRendered) console.error(error); + }, + }, + ); + setTimeout(abort, ABORT_DELAY); + }); + } + `, + 'app/routes.ts': js` + import { index, route } from '@react-router/dev/routes'; + export default [ + index('routes/_index.tsx'), + route('next', 'routes/next.tsx'), + ]; + `, + 'app/routes/_index.tsx': js` + import { Suspense, useEffect, useState } from 'react'; + import { defer, json } from '@remix-run/node'; + import { Await, Form, Link, useActionData, useLoaderData } from '@remix-run/react'; + import { NavLink } from 'react-router-dom'; + import { Link as Link6 } from 'react-router-dom6'; + + export function loader() { + return defer( + { + value: new Promise(resolve => setTimeout(resolve, 2_000, 'document deferred')), + failure: new Promise((_, reject) => + setTimeout(reject, 1_000, new Error('document rejection')), + ), + }, + { status: 207, headers: { 'X-Remix-Compat': 'document' } }, + ); + } + + export async function action({ request }) { + let form = await request.formData(); + return json( + { message: form.get('message') }, + { status: 201, headers: { 'X-Remix-Action': 'json' } }, + ); + } + + export function headers({ loaderHeaders, actionHeaders }) { + return actionHeaders.has('X-Remix-Action') ? actionHeaders : loaderHeaders; + } + + export default function Index() { + let { value, failure } = useLoaderData(); + let actionData = useActionData(); + let [hydrated, setHydrated] = useState(false); + useEffect(() => setHydrated(true), []); + return
+ waiting

}> + {value =>

{value}

}
+
+ waiting for document error

}> + rejected

} /> +
+
+ + +
+ {actionData &&

{actionData.message}

} + next + next-dom + next6 +
; + } + `, + 'app/routes/next.tsx': js` + import { Suspense } from 'react'; + import { defer } from '@remix-run/node'; + import { Await, Link, useLoaderData } from '@remix-run/react'; + + export function loader() { + return defer( + { + value: new Promise(resolve => setTimeout(resolve, 2_000, 'navigation deferred')), + failure: new Promise((_, reject) => + setTimeout(reject, 1_000, new Error('expected rejection')), + ), + }, + { status: 206, headers: { 'X-Remix-Compat': 'navigation' } }, + ); + } + + export function headers({ loaderHeaders }) { + return loaderHeaders; + } + + export default function Next() { + let { value, failure } = useLoaderData(); + return
+ waiting

}> + {value =>

{value}

}
+
+ waiting for error

}> + rejected

} /> +
+ home +
; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(async () => appFixture.close()); + + test('SSR, hydration, action JSON, and deferred navigation use one RR runtime', async ({ page }) => { + const app = new PlaywrightFixture(appFixture, page); + const response = await app.goto('/', false); + expect(response.status()).toBe(207); + expect(response.headers()['x-remix-compat']).toBe('document'); + await expect(page.locator('#fallback')).toHaveText('waiting'); + await expect(page.locator('#document-rejected')).toHaveText('rejected'); + await expect(page.locator('#resolved')).toHaveText('document deferred'); + await expect(page.locator('main')).toHaveAttribute('data-hydrated', 'true'); + + const actionResponse = page.waitForResponse( + response => response.url().includes('.data') && response.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'submit' }).click(); + expect((await actionResponse).status()).toBe(201); + expect((await actionResponse).headers()['x-remix-action']).toBe('json'); + await expect(page.locator('#action')).toHaveText('action json'); + + const navigationResponse = page.waitForResponse( + response => + new URL(response.url()).pathname === '/next.data' && + response.request().method() === 'GET', + ); + await page.locator('#next-dom').click(); + expect((await navigationResponse).status()).toBe(206); + expect((await navigationResponse).headers()['x-remix-compat']).toBe( + 'navigation', + ); + await expect(page.locator('#next-fallback')).toHaveText('waiting'); + await expect(page.locator('#rejected')).toHaveText('rejected'); + await expect(page.locator('#next-resolved')).toHaveText('navigation deferred'); + }); +}); diff --git a/tests/react-router-framework/integration/spa-recovery-test.ts b/tests/react-router-framework/integration/spa-recovery-test.ts new file mode 100644 index 00000000..2cc9ecba --- /dev/null +++ b/tests/react-router-framework/integration/spa-recovery-test.ts @@ -0,0 +1,141 @@ +import { globSync, readFileSync } from "node:fs"; +import path from "pathe"; +import { expect, test } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, + type AppFixture, + type Fixture, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { reactRouterConfig } from "./helpers/rsbuild.js"; + +test.describe("SPA recovery route export stripping", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + spaMode: true, + stripServerOnlyRouteExportsInSpa: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, + splitRouteModules: true, + }), + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + const serverOnly = "SERVER_EXPORT_EXECUTED"; + export function loader() { throw new Error(serverOnly); } + export function clientLoader() { return "root client data"; } + export function action() { throw new Error(serverOnly); } + export function clientAction() { return "root client action"; } + export function headers() { throw new Error(serverOnly); } + + export function HydrateFallback() { + return ( + +

Loading recovery app

+ + ); + } + + export default function Root() { + return ( + +

Recovery app

+ + ); + } + `, + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Index() { + return <>

Home

Deep route; + } + `, + "app/routes/deep.tsx": js` + import { Form, Link, useActionData, useLoaderData } from "react-router"; + + const serverOnly = "DEEP_SERVER_EXPORT_EXECUTED"; + export function loader() { throw new Error(serverOnly); } + export function clientLoader() { return "deep client data"; } + export function action() { throw new Error(serverOnly); } + export function clientAction() { return "deep client action"; } + export function headers() { throw new Error(serverOnly); } + + export default function Deep() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + return <> +

Deep

+

{loaderData}

+

{actionData}

+
+ Home + ; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => appFixture.close()); + + test("build strips server exports from build-time and browser route modules", () => { + const emittedJavaScript = globSync("build/**/*.js", { + cwd: fixture.projectDir, + }) + .map((file) => readFileSync(path.join(fixture.projectDir, file), "utf8")) + .join("\n"); + expect(emittedJavaScript).not.toContain("SERVER_EXPORT_EXECUTED"); + expect(emittedJavaScript).not.toContain("DEEP_SERVER_EXPORT_EXECUTED"); + }); + + test("deep static-host navigation stays client-only", async ({ page }) => { + const errors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(error.message)); + + const app = new PlaywrightFixture(appFixture, page); + const dataResponses = app.collectSingleFetchResponses(); + await app.goto("/deep", true); + + await expect(page.locator("[data-loader]")).toHaveText("deep client data"); + expect( + await page.evaluate(() => { + const route = (window as any).__reactRouterManifest.routes["routes/deep"]; + return { + hasLoader: route.hasLoader, + hasAction: route.hasAction, + hasClientLoader: route.hasClientLoader, + hasClientAction: route.hasClientAction, + }; + }), + ).toEqual({ + hasLoader: false, + hasAction: false, + hasClientLoader: true, + hasClientAction: true, + }); + + await page.getByRole("button", { name: "Run action" }).click(); + await expect(page.locator("[data-action]")).toHaveText( + "deep client action", + ); + await app.clickLink("/"); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); + await page.context().setOffline(true); + await app.clickLink("/deep"); + await expect(page.locator("[data-loader]")).toHaveText("deep client data"); + + expect(dataResponses).toHaveLength(0); + expect(errors).toEqual([]); + }); +}); diff --git a/tests/remix-compat.test.ts b/tests/remix-compat.test.ts new file mode 100644 index 00000000..58c2b69e --- /dev/null +++ b/tests/remix-compat.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, rstest } from '@rstest/core'; +import type { EntryContext } from 'react-router'; +import { defer, json } from '../src/compat/responses'; +import { RemixServer } from '../src/compat/remix-react'; + +describe('Remix compatibility facades', () => { + it('creates a JSON Response with status and headers', async () => { + const response = json( + { value: 'ok' }, + { status: 201, headers: { 'X-Compat': 'json' } } + ); + + expect(response).toBeInstanceOf(Response); + expect(response.status).toBe(201); + expect(response.headers.get('X-Compat')).toBe('json'); + expect(response.headers.get('Content-Type')).toContain('application/json'); + await expect(response.json()).resolves.toEqual({ value: 'ok' }); + }); + + it('preserves an explicit JSON content type', () => { + const response = json( + { value: 'ok' }, + { headers: { 'Content-Type': 'application/problem+json' } } + ); + expect(response.headers.get('Content-Type')).toBe( + 'application/problem+json' + ); + }); + + it('matches JSON serialization for dates, undefined values, arrays, and toJSON', async () => { + const response = json({ + created: new Date('2026-10-21T00:00:00.000Z'), + omitted: undefined, + array: [undefined, () => 'omitted'], + custom: { toJSON: () => ({ serialized: 'yes' }) }, + }); + + await expect(response.json()).resolves.toEqual({ + created: '2026-10-21T00:00:00.000Z', + array: [null, null], + custom: { serialized: 'yes' }, + }); + }); + + it('passes deferred values and response metadata to React Router data', async () => { + const resolved = Promise.resolve('done'); + const rejected = Promise.reject(new Error('deferred failure')); + rejected.catch(() => undefined); + const result = defer( + { resolved, rejected }, + { status: 202, headers: { 'X-Compat': 'defer' } } + ); + + expect(result.type).toBe('DataWithResponseInit'); + expect(result.data).toEqual({ resolved, rejected }); + expect(result.init).toEqual({ + status: 202, + headers: { 'X-Compat': 'defer' }, + }); + await expect(result.data.resolved).resolves.toBe('done'); + await expect(result.data.rejected).rejects.toThrow('deferred failure'); + }); + + it('maps RemixServer to ServerRouter and warns about abortDelay', () => { + const warn = rstest.spyOn(console, 'warn').mockImplementation(() => {}); + const context = {} as EntryContext; + const element = RemixServer({ + context, + url: 'https://example.test/', + abortDelay: 1000, + }); + + expect(element.type).toBeDefined(); + expect(element.props).toEqual({ context, url: 'https://example.test/' }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Abort the render stream in entry.server') + ); + warn.mockRestore(); + }); +}); diff --git a/tests/route-module-transform-loader.test.ts b/tests/route-module-transform-loader.test.ts index 736bd324..4e33e2e9 100644 --- a/tests/route-module-transform-loader.test.ts +++ b/tests/route-module-transform-loader.test.ts @@ -3,6 +3,7 @@ import { TraceMap, } from '@jridgewell/trace-mapping'; import { describe, expect, it, rstest } from '@rstest/core'; +import { getExportNames } from '../src/export-utils'; import routeModuleTransformLoader, { flushRouteModuleTransformLoaderPerformance, type RouteModuleTransformLoaderOptions, @@ -18,6 +19,7 @@ const defaultOptions: RouteModuleTransformLoaderOptions = { isBuild: false, isSpaMode: false, rootRoutePath: '/project/app/root.tsx', + stripServerOnlyRouteExportsInSpa: false, devHmr: false, }; @@ -73,6 +75,74 @@ describe('route module transform loader', () => { expect(result.map).toBeUndefined(); }); + it('strips server exports from the SPA server module when opted in', async () => { + const result = await runLoader( + ` + import { serverValue } from './data.server'; + export function loader() { return serverValue; } + export function clientLoader() { return 'client'; } + export function action() { return serverValue; } + export function clientAction() { return 'client action'; } + export function headers() { return {}; } + export default function Route() { return null; } + `, + { + options: { + ...defaultOptions, + environmentName: 'node', + ssr: false, + isBuild: true, + isSpaMode: true, + stripServerOnlyRouteExportsInSpa: true, + }, + } + ); + + expect(await getExportNames(result.code)).toEqual([ + 'clientLoader', + 'clientAction', + 'default', + ]); + expect(result.code).not.toContain('./data.server'); + }); + + it('requires client equivalents before stripping SPA server exports', async () => { + await expect( + runLoader(`export function loader() { return null; }`, { + options: { + ...defaultOptions, + ssr: false, + isBuild: true, + isSpaMode: true, + stripServerOnlyRouteExportsInSpa: true, + }, + }) + ).rejects.toThrow('`loader` requires `clientLoader`'); + }); + + it('keeps non-root HydrateFallback validation when stripping server exports', async () => { + await expect( + runLoader( + ` + export function loader() { return null; } + export function clientLoader() { return null; } + export function HydrateFallback() { return null; } + `, + { + options: { + ...defaultOptions, + ssr: false, + isBuild: true, + isSpaMode: true, + stripServerOnlyRouteExportsInSpa: true, + }, + } + ) + ).rejects.toThrow( + '`HydrateFallback` is only permitted on the root route in SPA Mode' + ); + }); + it('emits a source map when the loader context requests one', async () => { const result = await runLoader( ` diff --git a/tests/route-module-transform-rules.test.ts b/tests/route-module-transform-rules.test.ts index 7adff20d..9f8737c8 100644 --- a/tests/route-module-transform-rules.test.ts +++ b/tests/route-module-transform-rules.test.ts @@ -17,6 +17,7 @@ const createRuleConfig = ( isBuild, isSpaMode: false, rootRoutePath: '/project/app/root.tsx', + stripServerOnlyRouteExportsInSpa: false, devHmr, logPerformance: true, routeByFilePath: new Map([[routePath, { id: 'page' }]]), @@ -62,6 +63,7 @@ describe('route module transform rules', () => { isBuild: false, isSpaMode: false, rootRoutePath: '/project/app/root.tsx', + stripServerOnlyRouteExportsInSpa: false, devHmr: true, }, parallel: true, diff --git a/tests/tsconfig.remix-compat.json b/tests/tsconfig.remix-compat.json new file mode 100644 index 00000000..3f16195a --- /dev/null +++ b/tests/tsconfig.remix-compat.json @@ -0,0 +1,22 @@ +{ + "extends": "../config/tsconfig-node16.json", + "compilerOptions": { + "noEmit": true, + "baseUrl": "..", + "paths": { + "@remix-run/node": [ + "tests/fixtures/remix-compat/remix-node.ts" + ], + "@remix-run/react": [ + "tests/fixtures/remix-compat/remix-react.ts" + ], + "react-router-dom6": [ + "tests/fixtures/remix-compat/remix-react.ts" + ], + "react-router-dom": [ + "tests/fixtures/remix-compat/remix-react.ts" + ] + } + }, + "files": ["fixtures/remix-compat-types.ts"] +}