diff --git a/.changeset/guarded-lazy-compilation.md b/.changeset/guarded-lazy-compilation.md new file mode 100644 index 00000000..329cf86c --- /dev/null +++ b/.changeset/guarded-lazy-compilation.md @@ -0,0 +1,6 @@ +--- +'rsbuild-plugin-react-router': minor +--- + +Expose a plugin-level `lazyCompilation` option that keeps React Router hydration +modules eager while preserving user lazy compilation filters. diff --git a/src/index.ts b/src/index.ts index 358bbc24..30d52618 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ import { import { warnOnClientSourceMaps } from './warnings/warn-on-client-source-maps.js'; import { validatePluginOrderFromConfig } from './validation/validate-plugin-order.js'; import { getSsrExternals } from './ssr-externals.js'; +import { guardReactRouterLazyCompilation } from './lazy-compilation.js'; const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); @@ -1082,6 +1083,18 @@ export const pluginReactRouter = ( /\.js$/, '' ); + const requestedLazyCompilation = + pluginOptions.lazyCompilation === undefined + ? config.dev?.lazyCompilation + : pluginOptions.lazyCompilation; + const guardedLazyCompilation = guardReactRouterLazyCompilation({ + lazyCompilation: requestedLazyCompilation, + entryClientPath: finalEntryClientPath, + }); + const devLazyCompilation = + guardedLazyCompilation === undefined + ? {} + : { lazyCompilation: guardedLazyCompilation }; const nodeEntries: Record = { ...(hasServerApp @@ -1110,6 +1123,7 @@ export const pluginReactRouter = ( }, dev: { writeToDisk: true, + ...devLazyCompilation, // Only add SSR middleware if SSR is enabled and not using a custom server // In SPA mode (ssr: false), we just serve static files from the client build setupMiddlewares: diff --git a/src/lazy-compilation.ts b/src/lazy-compilation.ts new file mode 100644 index 00000000..7c7d594a --- /dev/null +++ b/src/lazy-compilation.ts @@ -0,0 +1,94 @@ +import { BUILD_CLIENT_ROUTE_QUERY_STRING } from './constants.js'; +import type { PluginOptions } from './types.js'; + +type LazyCompilationOptions = Exclude< + NonNullable, + boolean +>; + +type LazyCompilationModule = { + request?: string; + userRequest?: string; + rawRequest?: string; + resource?: string; + identifier?: () => string; + nameForCondition?: () => string | null; +}; + +const normalizeSlashes = (value: string): string => value.replace(/\\/g, '/'); + +const getLazyCompilationModuleValues = ( + module: LazyCompilationModule +): string[] => + [ + module.request, + module.userRequest, + module.rawRequest, + module.resource, + module.identifier?.(), + module.nameForCondition?.(), + ].filter((value): value is string => Boolean(value)); + +const matchesLazyCompilationTest = ( + test: LazyCompilationOptions['test'] | undefined, + module: LazyCompilationModule +): boolean => { + if (!test) { + return true; + } + if (typeof test === 'function') { + return test(module as Parameters[0]); + } + const conditionName = module.nameForCondition?.(); + if (!conditionName) { + return false; + } + test.lastIndex = 0; + return test.test(conditionName); +}; + +const createReactRouterHydrationModuleTest = (entryClientPath: string) => { + const eagerPatterns = [ + normalizeSlashes(entryClientPath), + 'virtual/react-router/browser-manifest', + BUILD_CLIENT_ROUTE_QUERY_STRING, + '?react-router-route', + ]; + + return (module: LazyCompilationModule): boolean => + getLazyCompilationModuleValues(module).some(value => { + const normalizedValue = normalizeSlashes(value); + return eagerPatterns.some(pattern => normalizedValue.includes(pattern)); + }); +}; + +export const guardReactRouterLazyCompilation = ({ + lazyCompilation, + entryClientPath, +}: { + lazyCompilation: PluginOptions['lazyCompilation'] | undefined; + entryClientPath: string; +}): PluginOptions['lazyCompilation'] | undefined => { + if (lazyCompilation === undefined || lazyCompilation === false) { + return lazyCompilation; + } + + const options: LazyCompilationOptions = + lazyCompilation === true + ? { entries: true, imports: true } + : lazyCompilation; + const userTest = options.test; + const isReactRouterHydrationModule = + createReactRouterHydrationModuleTest(entryClientPath); + + return { + ...options, + test(module) { + const lazyModule = module as LazyCompilationModule; + if (isReactRouterHydrationModule(lazyModule)) { + return false; + } + return matchesLazyCompilationTest(userTest, lazyModule); + }, + }; +}; diff --git a/src/types.ts b/src/types.ts index a8d3fcaa..7954aad7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { RsbuildConfig } from '@rsbuild/core'; + export type Route = { id: string; parentId?: string; @@ -27,6 +29,16 @@ export type PluginOptions = { * Federation mode configuration */ federation?: boolean; + + /** + * Opt in to Rsbuild's dev-only lazy compilation behavior. + * + * React Router hydration modules remain eager so initial dev requests can + * load the browser manifest and route modules without lazy proxy delays. + * + * @default undefined + */ + lazyCompilation?: NonNullable['lazyCompilation']; }; /** diff --git a/tests/index.test.ts b/tests/index.test.ts index 47c3e4b5..f0fed1d4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -2,6 +2,28 @@ import { createStubRsbuild } from '@scripts/test-helper'; import { describe, expect, it } from '@rstest/core'; import { pluginReactRouter } from '../src'; +type LazyCompilationTestModule = { + resource?: string; + nameForCondition?: () => string | null; +}; + +type LazyCompilationConfig = { + test?: (module: LazyCompilationTestModule) => boolean; +}; + +const getLazyCompilationTest = ( + lazyCompilation: boolean | LazyCompilationConfig | undefined +) => { + if ( + !lazyCompilation || + typeof lazyCompilation === 'boolean' || + typeof lazyCompilation.test !== 'function' + ) { + throw new Error('Expected lazy compilation to install a test function.'); + } + return lazyCompilation.test; +}; + describe('pluginReactRouter', () => { it('should configure basic plugin options', async () => { const rsbuild = await createStubRsbuild({ @@ -31,6 +53,110 @@ describe('pluginReactRouter', () => { expect(nodeConfig.output.module).toBe(false); }); + it('should forward lazy compilation when explicitly configured', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([ + pluginReactRouter({ + lazyCompilation: { + entries: true, + imports: true, + }, + }), + ]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toMatchObject({ + entries: true, + imports: true, + }); + const test = getLazyCompilationTest(config.dev.lazyCompilation); + expect( + test({ + resource: '/project/app/root.tsx?__react-router-build-client-route', + }) + ).toBe(false); + expect( + test({ + resource: '/project/app/components/card.tsx', + nameForCondition: () => '/project/app/components/card.tsx', + }) + ).toBe(true); + }); + + it('should allow lazy compilation to be enabled with a boolean', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter({ lazyCompilation: true })]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toMatchObject({ + entries: true, + imports: true, + }); + const test = getLazyCompilationTest(config.dev.lazyCompilation); + expect( + test({ + resource: `${process.cwd()}/app/entry.client.tsx`, + }) + ).toBe(false); + }); + + it('guards direct Rsbuild lazy compilation config for React Router hydration entries', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: { + dev: { + lazyCompilation: { + entries: true, + imports: false, + test: /app/, + }, + }, + }, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toMatchObject({ + entries: true, + imports: false, + }); + const test = getLazyCompilationTest(config.dev.lazyCompilation); + expect( + test({ + resource: '/project/app/routes/home.tsx?__react-router-build-client-route', + }) + ).toBe(false); + expect( + test({ + resource: '/project/app/components/card.tsx', + nameForCondition: () => '/project/app/components/card.tsx', + }) + ).toBe(true); + expect( + test({ + resource: '/project/vendor/react.tsx', + nameForCondition: () => '/project/vendor/react.tsx', + }) + ).toBe(false); + }); + + it('should allow lazy compilation to be disabled', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter({ lazyCompilation: false })]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toBe(false); + }); + it('should configure web environment correctly', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, diff --git a/tests/lazy-compilation.test.ts b/tests/lazy-compilation.test.ts new file mode 100644 index 00000000..a0fafc2f --- /dev/null +++ b/tests/lazy-compilation.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from '@rstest/core'; +import { guardReactRouterLazyCompilation } from '../src/lazy-compilation'; + +type LazyCompilationTestModule = { + request?: string; + rawRequest?: string; + resource?: string; + identifier?: () => string; + nameForCondition?: () => string | null; +}; + +const getGuardedTest = ( + guarded: ReturnType +): ((module: LazyCompilationTestModule) => boolean) => { + if ( + !guarded || + typeof guarded === 'boolean' || + typeof guarded.test !== 'function' + ) { + throw new Error('Expected a guarded lazy compilation test function.'); + } + return guarded.test; +}; + +describe('guardReactRouterLazyCompilation', () => { + const entryClientPath = '/project/app/entry.client.tsx'; + + it('leaves disabled and unspecified lazy compilation unchanged', () => { + expect( + guardReactRouterLazyCompilation({ + lazyCompilation: undefined, + entryClientPath, + }) + ).toBeUndefined(); + expect( + guardReactRouterLazyCompilation({ + lazyCompilation: false, + entryClientPath, + }) + ).toBe(false); + }); + + it('keeps boolean lazy compilation enabled while guarding hydration modules', () => { + const guarded = guardReactRouterLazyCompilation({ + lazyCompilation: true, + entryClientPath, + }); + + expect(guarded).toMatchObject({ + entries: true, + imports: true, + }); + const test = getGuardedTest(guarded); + expect( + test({ + resource: `${entryClientPath}!lazy-compilation-proxy`, + }) + ).toBe(false); + expect(test({ resource: '/project/app/components/card.tsx' })).toBe(true); + }); + + it('preserves user tests for non-React Router hydration modules', () => { + const guarded = guardReactRouterLazyCompilation({ + lazyCompilation: { + entries: true, + imports: false, + test: /app/g, + }, + entryClientPath, + }); + + expect(guarded).toMatchObject({ + entries: true, + imports: false, + }); + const test = getGuardedTest(guarded); + expect( + test({ + resource: '/project/app/root.tsx?__react-router-build-client-route', + }) + ).toBe(false); + expect( + test({ + resource: '/project/app/components/card.tsx', + nameForCondition: () => '/project/app/components/card.tsx', + }) + ).toBe(true); + expect( + test({ + resource: '/project/vendor/react.tsx', + nameForCondition: () => '/project/vendor/react.tsx', + }) + ).toBe(false); + }); + + it('applies RegExp user tests to the module condition name', () => { + const guarded = guardReactRouterLazyCompilation({ + lazyCompilation: { + entries: true, + imports: true, + test: /node_modules/g, + }, + entryClientPath, + }); + const test = getGuardedTest(guarded); + + expect( + test({ + rawRequest: 'node_modules-loader!/project/app/page.tsx', + resource: '/project/app/page.tsx', + nameForCondition: () => '/project/app/page.tsx', + }) + ).toBe(false); + expect( + test({ + rawRequest: './react', + resource: '/project/node_modules/react/index.js', + nameForCondition: () => '/project/node_modules/react/index.js', + }) + ).toBe(true); + }); + + it('guards all React Router hydration-critical module shapes', () => { + const guarded = guardReactRouterLazyCompilation({ + lazyCompilation: { + entries: true, + imports: true, + }, + entryClientPath, + }); + const test = getGuardedTest(guarded); + + expect( + test({ + request: 'virtual/react-router/browser-manifest', + }) + ).toBe(false); + expect( + test({ + identifier: () => + '/project/app/routes/home.tsx?__react-router-build-client-route', + }) + ).toBe(false); + expect( + test({ + nameForCondition: () => + '/project/app/routes/home.tsx?react-router-route', + }) + ).toBe(false); + }); +});