Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/full-lazy-compilation-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rsbuild-plugin-react-router': minor
---

Add opt-in lazy route entries with eager-route exclusions and preserve strict CSS, manifest, and client-export behavior across lazy compilations.
3 changes: 2 additions & 1 deletion examples/default-template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"start:cjs": "react-router-serve ./cjs-serve-patch.cjs",
"typecheck": "react-router typegen && tsc",
"test:e2e": "playwright test",
"test:e2e:lazy": "cross-env RR_LAZY_COMPILATION=entries playwright test tests/e2e/lazy-compilation.test.ts"
"test:e2e:lazy": "cross-env RR_LAZY_COMPILATION=entries playwright test tests/e2e/lazy-compilation.test.ts",
"test:e2e:full-lazy": "cross-env RR_LAZY_COMPILATION=full playwright test tests/e2e/lazy-compilation.test.ts --grep \"activates a lazy route entry\""
},
"dependencies": {
"@react-router/express": "^7.13.0",
Expand Down
16 changes: 15 additions & 1 deletion examples/default-template/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { pluginLess } from '@rsbuild/plugin-less';
import { pluginSass } from '@rsbuild/plugin-sass';
import { pluginReactRouter } from 'rsbuild-plugin-react-router';
import 'react-router';
import { join } from 'node:path';

// Extend the app load context type for loaders/actions.
declare module 'react-router' {
Expand All @@ -16,11 +17,24 @@ export default defineConfig(() => {
const lazyCompilation =
process.env.RR_LAZY_COMPILATION === 'entries'
? { entries: true }
: process.env.RR_LAZY_COMPILATION === 'full'
? { entries: true, imports: true }
: undefined;
const fullLazyCompilation = process.env.RR_LAZY_COMPILATION === 'full';

return {
plugins: [
pluginReactRouter({ lazyCompilation }),
pluginReactRouter({
lazyCompilation,
unstableLazyCompilationRouteEntries: fullLazyCompilation
? {
eagerRouteFiles: [
join(process.cwd(), 'app/root.tsx'),
join(process.cwd(), 'app/routes/home.tsx'),
],
}
: undefined,
}),
pluginReact(),
pluginLess(),
pluginSass(),
Expand Down
40 changes: 40 additions & 0 deletions examples/default-template/tests/e2e/lazy-compilation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,46 @@ test.describe('lazy compilation', () => {
restoreAboutRoute();
});

test('activates a lazy route entry without reloading the document', async ({
page,
}) => {
test.skip(process.env.RR_LAZY_COMPILATION !== 'full');
const errors: string[] = [];
const lazyTriggerRequests: string[] = [];
page.on('console', message => {
if (message.type() === 'error') errors.push(message.text());
});
page.on('pageerror', error => errors.push(error.message));
page.on('request', request => {
if (request.url().includes('/_rspack/lazy/trigger')) {
lazyTriggerRequests.push(request.url());
}
});
await page.addInitScript(() => {
const key = 'full-lazy-document-loads';
sessionStorage.setItem(
key,
String(Number(sessionStorage.getItem(key) ?? 0) + 1)
);
});

await page.goto('/');
await page.getByRole('link', { name: 'Client Features' }).click();

await expect(page).toHaveURL('/client-features', { timeout: 60000 });
await expect(
page.getByRole('heading', { name: 'Client Features' })
).toBeVisible();
await expect(page.getByTestId('loader-source')).toHaveText('client');
expect(
await page.evaluate(() =>
sessionStorage.getItem('full-lazy-document-loads')
)
).toBe('1');
expect(lazyTriggerRequests.length).toBeGreaterThan(0);
expect(errors).toEqual([]);
});

test('hydrates with entries:true while manifest route modules stay synchronous', async ({
page,
}) => {
Expand Down
9 changes: 9 additions & 0 deletions src/build-output-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ export const registerBuildOutputTransforms = ({
)
);

api.transform(
{
resourceQuery: /route-chunk=/,
environments: ['web'],
order: 'post',
},
transformRouteModule
);

if (isBuild && splitRouteModules) {
api.transform(
{
Expand Down
27 changes: 22 additions & 5 deletions src/dev-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ type CreateReactRouterDevRuntimeOptions = {
server: RsbuildDevServer;
buildPlan: ReactRouterDevBuildPlan;
onEvaluationError: (error: Error) => void;
onCssAssetOwnershipChanged?: (change: 'removed' | 'restored') => void;
onCssAssetOwnershipChanged?: (
change: 'added' | 'removed' | 'restored'
) => void;
onRouteManifestChanged?: () => void;
onWarning?: (message: string) => void;
};
Expand Down Expand Up @@ -274,7 +276,7 @@ export const createReactRouterDevRuntime = ({
>();

const notifyCssAssetOwnershipChanged = (
change: 'removed' | 'restored'
change: 'added' | 'removed' | 'restored'
): void => {
try {
onCssAssetOwnershipChanged(change);
Expand Down Expand Up @@ -502,6 +504,12 @@ export const createReactRouterDevRuntime = ({
previous.web.manifestsByEntryName,
manifestsByEntryName
);
// Lazy compilation activation can change chunk ownership without any
// source file changing. Reloading for that transient manifest delta
// throws away the UI action that activated the module.
// Unknown provenance is strict: only an explicitly marked lazy
// compilation may suppress a required route/CSS reload.
const hasFileBackedWebChange = changes.web.fileBackedInvalidation ?? true;
const cssOnlyWebManifestChange =
(cssAssetsRemoved || cssAssetsAdded) &&
hasOnlyCssAssetOwnershipChanges(
Expand Down Expand Up @@ -577,16 +585,25 @@ export const createReactRouterDevRuntime = ({
if (!committed) {
return 'ignored';
}
if (cssAssetsRemoved) {
let notifiedCssAssetOwnershipChange = false;
if (cssAssetsRemoved && hasFileBackedWebChange) {
reloadAfterCssRemoval = !cssAssetsAdded;
notifyCssAssetOwnershipChanged('removed');
} else if (cssAssetsAdded) {
notifiedCssAssetOwnershipChange = true;
} else if (cssAssetsAdded && hasFileBackedWebChange) {
if (reloadAfterCssRemoval) {
notifyCssAssetOwnershipChanged('restored');
} else {
notifyCssAssetOwnershipChanged('added');
}
reloadAfterCssRemoval = false;
notifiedCssAssetOwnershipChange = true;
}
if (routeManifestMetadataChanged) {
if (
routeManifestMetadataChanged &&
hasFileBackedWebChange &&
!notifiedCssAssetOwnershipChange
) {
notifyRouteManifestChanged();
}
return 'committed';
Expand Down
7 changes: 7 additions & 0 deletions src/dev-runtime-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export type DependencySnapshot = {
export type DevChangedFiles = {
known: boolean;
files: ReadonlySet<string>;
/**
* Rspack reports file-watcher invalidations with a concrete file name and
* lazy-compilation/programmatic invalidations with null. Only an explicitly
* marked lazy compilation may set this to false; unknown provenance must
* stay undefined so reload decisions fail closed.
*/
fileBackedInvalidation?: boolean;
};

export type DevGraphChanges = {
Expand Down
80 changes: 63 additions & 17 deletions src/dev-runtime-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ const escapeHtml = (value: string): string =>
.replaceAll('>', '&gt;');

const CSS_SOURCE_RELOAD_DELAY_MS = 1000;
const LAZY_COMPILATION_CURRENT = Symbol.for('rspack.lazyCompilationCurrent');

const isExplicitLazyCompilation = (compiler: Rspack.Compiler): boolean =>
(compiler as unknown as Record<PropertyKey, unknown>)[
LAZY_COMPILATION_CURRENT
] === true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const isHdrRevisionFile = (file: string): boolean =>
file.includes(DEV_HDR_REVISION_RELATIVE_PATH);
Expand Down Expand Up @@ -114,12 +120,14 @@ export const createReactRouterDevRuntimeController = ({
| undefined;
let lastCssAssetOwnershipReloadAt = 0;
let reloadAfterCssAssetOwnershipRemoval = false;
let cssAssetOwnershipReloadSentForCurrentAttempt = false;

const sendCssAssetOwnershipReload = (): void => {
const binding = sessions.getActiveBinding();
if (!binding) {
if (!binding || cssAssetOwnershipReloadSentForCurrentAttempt) {
return;
}
cssAssetOwnershipReloadSentForCurrentAttempt = true;
lastCssAssetOwnershipReloadAt = Date.now();
binding.server.sockWrite('full-reload', { path: '*' });
};
Expand All @@ -144,6 +152,7 @@ export const createReactRouterDevRuntimeController = ({
scheduledCssAssetOwnershipReload = undefined;
}
reloadAfterCssAssetOwnershipRemoval = false;
cssAssetOwnershipReloadSentForCurrentAttempt = false;
const pair = binding.compilers;
if (pair) {
resetDevCompilerPair(pair);
Expand All @@ -156,6 +165,10 @@ export const createReactRouterDevRuntimeController = ({
const sessions = createDevRuntimeSessionManager(closeBinding);
const compilationIdentities = createCompilationIdentityTracker();
const { getCompilationIdentity } = compilationIdentities;
const webCompilationFileBackedInvalidation = new WeakMap<
Rspack.Compilation,
boolean
>();

// Web-only commits reuse the node compiler's stale `modifiedFiles`
// snapshot, and every HDR bump itself triggers a web rebuild — so signal
Expand Down Expand Up @@ -340,6 +353,7 @@ export const createReactRouterDevRuntimeController = ({
if (!binding || !pair || hasPendingCompilation(pair)) {
return;
}
cssAssetOwnershipReloadSentForCurrentAttempt = false;
beginDevCompilerAttempt(pair);
binding.runtime.beginAttempt();
},
Expand All @@ -364,6 +378,7 @@ export const createReactRouterDevRuntimeController = ({
binding.compilers = pair;
const sessionId = binding.id;
const runtime = binding.runtime;
let pendingWebFileBackedInvalidation = false;
const failCurrentAttempt = (side: 'web' | 'node', error: Error): void => {
if (sessions.getActiveBinding()?.id === sessionId) {
if (side === 'web') {
Expand All @@ -375,26 +390,40 @@ export const createReactRouterDevRuntimeController = ({
}
};
const beginCompilerAttempt = (
side: 'latestWebStart' | 'latestNodeStart'
side: 'latestWebStart' | 'latestNodeStart',
fileBackedInvalidation = false
): void => {
if (
sessions.getActiveBinding()?.id === sessionId &&
pair[side]?.status !== 'pending'
) {
// Invalidation can arrive before the aggregate before-compile hook.
// Supersede any evaluation that could resolve in that gap immediately.
if (markDevCompilerPending(pair, side)) {
runtime.beginAttempt();
}
if (side === 'latestWebStart' && reloadAfterCssAssetOwnershipRemoval) {
if (sessions.getActiveBinding()?.id !== sessionId) {
return;
}
if (side === 'latestWebStart' && fileBackedInvalidation) {
// A real file edit can coalesce into an already-pending lazy build.
// Reset the per-attempt dedupe before the pending guard so that edit
// can still send the CSS ownership reload it requires.
cssAssetOwnershipReloadSentForCurrentAttempt = false;
if (reloadAfterCssAssetOwnershipRemoval) {
reloadAfterCssAssetOwnershipRemoval = false;
scheduleCssAssetOwnershipReload();
}
}
if (pair[side]?.status === 'pending') {
return;
}
// Invalidation can arrive before the aggregate before-compile hook.
// Supersede any evaluation that could resolve in that gap immediately.
if (markDevCompilerPending(pair, side)) {
runtime.beginAttempt();
}
};
web.hooks.invalid.tap(`${PLUGIN_NAME}:dev-web-invalid`, () =>
beginCompilerAttempt('latestWebStart')
);
web.hooks.invalid.tap(`${PLUGIN_NAME}:dev-web-invalid`, fileName => {
// Watching.invalidate(), which powers lazy compilation activation,
// deliberately reports null. Real watcher events report the changed
// path. Keep that signal attached to the compilation it starts so a
// lazy activation cannot be mistaken for a CSS topology edit.
const fileBackedInvalidation = typeof fileName === 'string';
pendingWebFileBackedInvalidation ||= fileBackedInvalidation;
beginCompilerAttempt('latestWebStart', fileBackedInvalidation);
});
node.hooks.invalid.tap(`${PLUGIN_NAME}:dev-node-invalid`, () =>
beginCompilerAttempt('latestNodeStart')
);
Expand Down Expand Up @@ -432,7 +461,19 @@ export const createReactRouterDevRuntimeController = ({
status: 'started',
identity: getCompilationIdentity(compilation),
};
if (reloadAfterCssAssetOwnershipRemoval) {
const explicitLazyCompilation = isExplicitLazyCompilation(web);
const hasFileBackedChanges =
(web.modifiedFiles?.size ?? 0) > 0 ||
(web.removedFiles?.size ?? 0) > 0;
const fileBackedInvalidation =
pendingWebFileBackedInvalidation || hasFileBackedChanges;
if (fileBackedInvalidation) {
webCompilationFileBackedInvalidation.set(compilation, true);
} else if (explicitLazyCompilation) {
webCompilationFileBackedInvalidation.set(compilation, false);
}
pendingWebFileBackedInvalidation = false;
if (fileBackedInvalidation && reloadAfterCssAssetOwnershipRemoval) {
reloadAfterCssAssetOwnershipRemoval = false;
scheduleCssAssetOwnershipReload();
}
Expand Down Expand Up @@ -515,7 +556,12 @@ export const createReactRouterDevRuntimeController = ({
return;
}
const changes = {
web: snapshotDevChangedFiles(pair.web),
web: {
...snapshotDevChangedFiles(pair.web),
fileBackedInvalidation: webStats
? webCompilationFileBackedInvalidation.get(webStats.compilation)
: undefined,
},
node: snapshotDevChangedFiles(pair.node),
};
const webAttempt = webStats
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,11 @@ export const pluginReactRouter = (
const guardedLazyCompilation = guardReactRouterLazyCompilation({
lazyCompilation: configuredLazyCompilation,
entryClientPath: finalEntryClientPath,
eagerRouteFiles:
pluginOptions.unstableLazyCompilationRouteEntries?.eagerRouteFiles,
lazyRouteEntries: Boolean(
pluginOptions.unstableLazyCompilationRouteEntries
),
prewarmReactRouterModules: Boolean(
pluginOptions.unstableLazyCompilationPrewarm
),
Expand Down
Loading
Loading