Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/stable-prerender-concurrency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'rsbuild-plugin-react-router': patch
---

Support React Router's stable `prerender.concurrency` config while preserving
the existing `unstable_concurrency` fallback.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
},
"devDependencies": {
"@changesets/cli": "^2.29.8",
"@react-router/dev": "^7.13.0",
"@react-router/dev": "^8.0.1",
"@rsbuild/config": "workspace:*",
"@rsbuild/core": "2.0.15",
"@rsbuild/plugin-react": "2.0.1",
Expand Down
1,123 changes: 911 additions & 212 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ export const pluginReactRouter = (
}

const isBuild = api.context.action === 'build';
const splitRouteModules = future?.v8_splitRouteModules ?? false;
const splitRouteModules = resolvedConfigWithRoutes.splitRouteModules;
const enforceSplitRouteModules = splitRouteModules === 'enforce';
const routeChunkConfig: RouteChunkConfig = {
splitRouteModules,
Expand Down Expand Up @@ -1254,6 +1254,8 @@ export const pluginReactRouter = (
assetPrefix,
routeChunkOptions,
{
subResourceIntegrity:
resolvedConfigWithRoutes.subResourceIntegrity,
future,
onManifest: (manifest, sri) => {
const baseServerManifest = {
Expand Down
4 changes: 3 additions & 1 deletion src/modify-browser-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function createModifyBrowserManifestPlugin(
assetPrefix = '/',
routeChunkOptions?: Parameters<typeof getReactRouterManifestForDev>[5],
options?: {
subResourceIntegrity?: boolean;
future?: { unstable_subResourceIntegrity?: boolean };
onManifest?: (
manifest: Awaited<ReturnType<typeof getReactRouterManifestForDev>>,
Expand Down Expand Up @@ -103,7 +104,8 @@ export function createModifyBrowserManifestPlugin(
let sri: Record<string, string> | undefined;
if (
routeChunkOptions?.isBuild &&
options?.future?.unstable_subResourceIntegrity
(options?.subResourceIntegrity ??
options?.future?.unstable_subResourceIntegrity)
) {
const assets =
typeof compilation.getAssets === 'function'
Expand Down
84 changes: 52 additions & 32 deletions src/prerender.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Config } from './react-router-config.js';
import type { RouteConfigEntry } from '@react-router/dev/routes';

type PrerenderConfig = Config['prerender'];
type ReactRouterPrerenderConfig = Config['prerender'];

type PrerenderPathsConfig =
| boolean
Expand All @@ -10,10 +10,20 @@ type PrerenderPathsConfig =
getStaticPaths: () => string[];
}) => boolean | string[] | Promise<boolean | string[]>);

type PrerenderConfigObject = {
paths?: PrerenderPathsConfig;
type PrerenderConfigObject = Extract<
NonNullable<ReactRouterPrerenderConfig>,
{ paths: unknown }
> & {
unstable_concurrency?: number;
} | null;
};

type PrerenderConfig = ReactRouterPrerenderConfig | PrerenderConfigObject;
type PrerenderConcurrencyConfig =
| {
key: 'prerender.concurrency' | 'prerender.unstable_concurrency';
value: number;
}
| undefined;

type PrerenderResolveOptions = {
logWarning?: boolean;
Expand Down Expand Up @@ -135,19 +145,41 @@ export const resolvePrerenderPaths = async (
};

export const getPrerenderConcurrency = (prerender: PrerenderConfig): number => {
if (
typeof prerender === 'object' &&
prerender !== null &&
'unstable_concurrency' in prerender
) {
const value = (prerender as PrerenderConfigObject)?.unstable_concurrency;
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
return value;
}
const config = getPrerenderConfigObject(prerender);
const value = getPrerenderConcurrencyConfig(config)?.value;
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
return value;
}
return 1;
};

const getPrerenderConfigObject = (
prerender: PrerenderConfig
): PrerenderConfigObject | null =>
typeof prerender === 'object' &&
prerender !== null &&
!Array.isArray(prerender)
? (prerender as PrerenderConfigObject)
: null;

const getPrerenderConcurrencyConfig = (
config: PrerenderConfigObject | null
): PrerenderConcurrencyConfig => {
if (config?.concurrency !== undefined) {
return {
key: 'prerender.concurrency',
value: config.concurrency,
};
}

if (config?.unstable_concurrency !== undefined) {
return {
key: 'prerender.unstable_concurrency',
value: config.unstable_concurrency,
};
}
};

const isValidPrerenderPathsConfig = (
value: unknown
): value is PrerenderPathsConfig =>
Expand All @@ -162,34 +194,22 @@ export const validatePrerenderConfig = (
return null;
}

const pathsConfig =
typeof prerender === 'object' && prerender !== null && 'paths' in prerender
? (prerender as PrerenderConfigObject)?.paths
: prerender;
const config = getPrerenderConfigObject(prerender);
const pathsConfig = config && 'paths' in config ? config.paths : prerender;

const isValidConfig =
isValidPrerenderPathsConfig(pathsConfig) ||
(typeof prerender === 'object' &&
prerender !== null &&
'paths' in prerender &&
isValidPrerenderPathsConfig((prerender as PrerenderConfigObject)?.paths));
const isValidConfig = isValidPrerenderPathsConfig(pathsConfig);

if (!isValidConfig) {
return 'The `prerender`/`prerender.paths` config must be a boolean, an array of string paths, or a function returning a boolean or array of string paths.';
}

const concurrency =
typeof prerender === 'object' &&
prerender !== null &&
'unstable_concurrency' in prerender
? (prerender as PrerenderConfigObject)?.unstable_concurrency
: undefined;
const concurrency = getPrerenderConcurrencyConfig(config);

if (
concurrency !== undefined &&
(!Number.isInteger(concurrency) || concurrency <= 0)
concurrency &&
(!Number.isInteger(concurrency.value) || concurrency.value <= 0)
) {
return 'The `prerender.unstable_concurrency` config must be a positive integer if specified.';
return `The \`${concurrency.key}\` config must be a positive integer if specified.`;
}

return null;
Expand Down
24 changes: 23 additions & 1 deletion src/react-router-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ export type BuildEndHook = {
}): void | Promise<void>;
}['bivarianceHack'];

export type Config = Omit<ReactRouterConfig, 'buildEnd'> & {
type SplitRouteModulesConfig = boolean | 'enforce';

export type Config = Omit<
ReactRouterConfig,
'buildEnd' | 'future' | 'splitRouteModules' | 'subResourceIntegrity'
> & {
buildEnd?: BuildEndHook;
future?: Partial<FutureConfig>;
splitRouteModules?: SplitRouteModulesConfig;
subResourceIntegrity?: boolean;
};

type FutureConfig = {
Expand Down Expand Up @@ -49,6 +57,8 @@ export type ResolvedReactRouterConfig = Readonly<{
serverBuildFile: NonNullable<ReactRouterConfig['serverBuildFile']>;
serverBundles?: Config['serverBundles'];
serverModuleFormat: NonNullable<ReactRouterConfig['serverModuleFormat']>;
splitRouteModules: SplitRouteModulesConfig;
subResourceIntegrity: boolean;
ssr: NonNullable<ReactRouterConfig['ssr']>;
allowedActionOrigins: string[] | false;
unstable_routeConfig: RouteConfigEntry[];
Expand All @@ -60,6 +70,8 @@ const DEFAULT_CONFIG = {
buildDirectory: 'build',
serverBuildFile: 'index.js',
serverModuleFormat: 'esm',
splitRouteModules: false,
subResourceIntegrity: false,
ssr: true,
future: {
unstable_optimizeDeps: false,
Expand Down Expand Up @@ -151,11 +163,21 @@ export const resolveReactRouterConfig = async (
...DEFAULT_CONFIG.future,
...(userAndPresetConfigs.future ?? {}),
};
const splitRouteModules =
userAndPresetConfigs.splitRouteModules ??
userAndPresetConfigs.future?.v8_splitRouteModules ??
DEFAULT_CONFIG.splitRouteModules;
const subResourceIntegrity =
userAndPresetConfigs.subResourceIntegrity ??
userAndPresetConfigs.future?.unstable_subResourceIntegrity ??
DEFAULT_CONFIG.subResourceIntegrity;

let resolved: ResolvedReactRouterConfig = {
...DEFAULT_CONFIG,
...userAndPresetConfigs,
future: resolvedFuture,
splitRouteModules,
subResourceIntegrity,
allowedActionOrigins:
userAndPresetConfigs.allowedActionOrigins ??
DEFAULT_CONFIG.allowedActionOrigins,
Expand Down
37 changes: 36 additions & 1 deletion tests/prerender.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from '@rstest/core';
import { getPrerenderConcurrency, getStaticPrerenderPaths, resolvePrerenderPaths } from '../src/prerender';
import {
getPrerenderConcurrency,
getStaticPrerenderPaths,
resolvePrerenderPaths,
validatePrerenderConfig,
} from '../src/prerender';
import type { RouteConfigEntry } from '@react-router/dev/routes';

const routes: RouteConfigEntry[] = [
Expand Down Expand Up @@ -84,9 +89,39 @@ describe('prerender helpers', () => {
});

it('supports prerender concurrency config', () => {
expect(getPrerenderConcurrency({ paths: ['/'], concurrency: 3 })).toBe(3);
expect(
getPrerenderConcurrency({
paths: ['/'],
concurrency: 4,
unstable_concurrency: 2,
})
).toBe(4);
expect(
getPrerenderConcurrency({ paths: ['/'], unstable_concurrency: 3 })
).toBe(3);
expect(getPrerenderConcurrency({ paths: ['/'] })).toBe(1);
});

it('validates stable prerender concurrency config', () => {
expect(validatePrerenderConfig({ paths: ['/'], concurrency: 2 })).toBeNull();
expect(
validatePrerenderConfig({
paths: ['/'],
concurrency: 2,
unstable_concurrency: 0,
})
).toBeNull();
expect(validatePrerenderConfig({ paths: ['/'], concurrency: 0 })).toBe(
'The `prerender.concurrency` config must be a positive integer if specified.'
);
expect(
validatePrerenderConfig({
paths: ['/'],
unstable_concurrency: 0,
})
).toBe(
'The `prerender.unstable_concurrency` config must be a positive integer if specified.'
);
});
});
31 changes: 31 additions & 0 deletions tests/react-router-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,35 @@ describe('resolveReactRouterConfig', () => {
});
expect(buildEndCalls).toBe(2);
});

it('resolves stable config fields required by React Router 8', async () => {
const defaultResult = await resolveReactRouterConfig({});
const stableResult = await resolveReactRouterConfig({
splitRouteModules: 'enforce',
subResourceIntegrity: true,
});
const futureResult = await resolveReactRouterConfig({
future: {
v8_splitRouteModules: 'enforce',
unstable_subResourceIntegrity: true,
},
});
const precedenceResult = await resolveReactRouterConfig({
splitRouteModules: true,
subResourceIntegrity: false,
future: {
v8_splitRouteModules: false,
unstable_subResourceIntegrity: true,
},
});

expect(defaultResult.resolved.splitRouteModules).toBe(false);
expect(defaultResult.resolved.subResourceIntegrity).toBe(false);
expect(stableResult.resolved.splitRouteModules).toBe('enforce');
expect(stableResult.resolved.subResourceIntegrity).toBe(true);
expect(futureResult.resolved.splitRouteModules).toBe('enforce');
expect(futureResult.resolved.subResourceIntegrity).toBe(true);
expect(precedenceResult.resolved.splitRouteModules).toBe(true);
expect(precedenceResult.resolved.subResourceIntegrity).toBe(false);
});
});
Loading