Skip to content

Commit 2d7e24c

Browse files
authored
fix(angular-rspack): handler accumulation and watchOptions for double rebuilds (#34154)
# Current Behavior 1. **Handler accumulation**: The `compilation`, `beforeCompile`, and `done` hooks are registered inside `watchRun`, causing new handlers to be added on every rebuild cycle. This leads to performance degradation and duplicate operations during watch mode. 2. **Double rebuilds**: Rapid filesystem events (e.g., editor backup/swap files) trigger multiple rebuilds because there's no aggregation timeout configured. 3. **No watchOptions configuration**: Users cannot customize watcher behavior (aggregateTimeout, ignored patterns, etc.). # Expected Behavior 1. Hooks should be registered once outside of `watchRun` to prevent accumulation. Shared state is used to pass data between watch cycles and compilation hooks. 2. A default `aggregateTimeout: 50` batches rapid filesystem events to prevent double rebuilds. 3. Users can provide custom `watchOptions` to configure watcher behavior, with user options taking precedence over defaults. # Related Issue(s) #34142 (comment)
1 parent 6bb82c0 commit 2d7e24c

File tree

6 files changed

+140
-53
lines changed

6 files changed

+140
-53
lines changed

packages/angular-rspack/src/lib/config/config-utils/common-config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,14 @@ export async function getCommonConfig(
9090
},
9191
watch: normalizedOptions.watch,
9292
watchOptions: {
93+
// Default aggregateTimeout to batch rapid filesystem events (e.g., editor backup files)
94+
aggregateTimeout: 50,
9395
poll: normalizedOptions.poll,
9496
followSymlinks: normalizedOptions.preserveSymlinks,
9597
ignored:
9698
normalizedOptions.poll === undefined ? undefined : '**/node_modules/**',
99+
// User-provided watchOptions take precedence
100+
...normalizedOptions.watchOptions,
97101
},
98102
ignoreWarnings: [
99103
// https://github.com/webpack-contrib/source-map-loader/blob/b2de4249c7431dd8432da607e08f0f65e9d64219/src/index.js#L83

packages/angular-rspack/src/lib/config/create-config.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,55 @@ describe('createConfig', () => {
306306
]);
307307
});
308308

309+
it('should set default watchOptions with aggregateTimeout of 50ms', async () => {
310+
await expect(
311+
createConfig({ options: configBase })
312+
).resolves.toStrictEqual([
313+
expect.objectContaining({
314+
watchOptions: expect.objectContaining({
315+
aggregateTimeout: 50,
316+
}),
317+
}),
318+
]);
319+
});
320+
321+
it('should allow overriding watchOptions.aggregateTimeout', async () => {
322+
await expect(
323+
createConfig({
324+
options: {
325+
...configBase,
326+
watchOptions: { aggregateTimeout: 200 },
327+
},
328+
})
329+
).resolves.toStrictEqual([
330+
expect.objectContaining({
331+
watchOptions: expect.objectContaining({
332+
aggregateTimeout: 200,
333+
}),
334+
}),
335+
]);
336+
});
337+
338+
it('should merge watchOptions with poll option', async () => {
339+
await expect(
340+
createConfig({
341+
options: {
342+
...configBase,
343+
poll: 1000,
344+
watchOptions: { aggregateTimeout: 100 },
345+
},
346+
})
347+
).resolves.toStrictEqual([
348+
expect.objectContaining({
349+
watchOptions: expect.objectContaining({
350+
aggregateTimeout: 100,
351+
poll: 1000,
352+
ignored: '**/node_modules/**',
353+
}),
354+
}),
355+
]);
356+
});
357+
309358
it.each([
310359
['development', 'dev', true],
311360
['production', 'prod', false],

packages/angular-rspack/src/lib/models/angular-rspack-plugin-options.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
StylePreprocessorOptions,
55
} from '@nx/angular-rspack-compiler';
66
import type { BudgetEntry } from '@angular/build/private';
7+
import type { WatchOptions } from '@rspack/core';
78
import { I18nProjectMetadata } from './i18n';
89

910
export interface DevServerOptions {
@@ -336,6 +337,11 @@ export interface AngularRspackPluginOptions {
336337
* Run build when files change.
337338
*/
338339
watch?: boolean;
340+
/**
341+
* Options for the file watcher. Can be used to configure aggregateTimeout,
342+
* ignored patterns, and other watcher behavior.
343+
*/
344+
watchOptions?: WatchOptions;
339345
/**
340346
* @deprecated This is a no-op and can be safely removed.
341347
* The tsconfig file for web workers.
@@ -378,6 +384,11 @@ export interface NormalizedAngularRspackPluginOptions
378384
supportedBrowsers: string[];
379385
tsConfig: string;
380386
vendorChunk: boolean;
387+
/**
388+
* Adds more details to output logging.
389+
*/
390+
verbose?: boolean;
381391
watch: boolean;
392+
watchOptions?: WatchOptions;
382393
zoneless: boolean;
383394
}

packages/angular-rspack/src/lib/models/normalize-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ export async function normalizeOptions(
277277
vendorChunk: options.vendorChunk ?? false,
278278
verbose: options.verbose ?? false,
279279
watch: options.watch ?? false,
280+
watchOptions: options.watchOptions,
280281
zoneless,
281282
};
282283
}

packages/angular-rspack/src/lib/plugins/angular-rspack-plugin.ts

Lines changed: 55 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@nx/angular-rspack-compiler';
1616
import { workspaceRoot } from '@nx/devkit';
1717
import {
18+
type Compilation,
1819
type Compiler,
1920
type RspackOptionsNormalized,
2021
type RspackPluginInstance,
@@ -95,14 +96,21 @@ export class AngularRspackPlugin implements RspackPluginInstance {
9596
}
9697
);
9798

99+
let currentWatchingModifiedFiles = new Set<string>();
100+
let watchRunInitialized = false;
101+
102+
// Register compilation hook once - adds modified files to dependencies
103+
compiler.hooks.compilation.tap(PLUGIN_NAME + '_fileDeps', (compilation) => {
104+
currentWatchingModifiedFiles.forEach((file) => {
105+
compilation.fileDependencies.add(file);
106+
});
107+
});
108+
98109
compiler.hooks.watchRun.tapAsync(
99110
PLUGIN_NAME,
100111
async (compiler, callback) => {
101-
if (
102-
!compiler.hooks.beforeCompile.taps.some(
103-
(tap) => tap.name === PLUGIN_NAME
104-
)
105-
) {
112+
if (!watchRunInitialized) {
113+
watchRunInitialized = true;
106114
compiler.hooks.beforeCompile.tapAsync(
107115
PLUGIN_NAME,
108116
async (params, callback) => {
@@ -127,11 +135,9 @@ export class AngularRspackPlugin implements RspackPluginInstance {
127135
this.#sourceFileCache.typeScriptFileCache,
128136
this.#javascriptTransformer
129137
);
130-
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
131-
watchingModifiedFiles.forEach((file) => {
132-
compilation.fileDependencies.add(file);
133-
});
134-
});
138+
139+
// Update shared state for compilation hook
140+
currentWatchingModifiedFiles = watchingModifiedFiles;
135141

136142
callback();
137143
}
@@ -264,43 +270,49 @@ export class AngularRspackPlugin implements RspackPluginInstance {
264270
callback();
265271
});
266272

273+
// Store compilation reference for budget checking in done hook
274+
let currentEmitCompilation: Compilation | undefined;
275+
267276
compiler.hooks.afterEmit.tap(PLUGIN_NAME, (compilation) => {
268-
// Check for budget errors and display them to the user.
277+
currentEmitCompilation = compilation;
278+
});
279+
280+
// Register done hook once - checks budgets using stored compilation
281+
compiler.hooks.done.tap(PLUGIN_NAME + '_budgets', (statsValue) => {
282+
if (!currentEmitCompilation) return;
283+
269284
const budgets = this.#_options.budgets;
270-
let budgetFailures: BudgetCalculatorResult[] | undefined;
271-
272-
compiler.hooks.done.tap(PLUGIN_NAME, (statsValue) => {
273-
const stats = statsValue.toJson();
274-
const isPlatformServer = Array.isArray(compiler.options.target)
275-
? compiler.options.target.some(
276-
(target) => target === 'node' || target == 'async-node'
277-
)
278-
: compiler.options.target === 'node' ||
279-
compiler.options.target === 'async-node';
280-
if (budgets?.length && !isPlatformServer) {
281-
budgetFailures = [...checkBudgets(budgets, stats)];
282-
for (const { severity, message } of budgetFailures) {
283-
switch (severity) {
284-
case ThresholdSeverity.Warning:
285-
addWarning(compilation, {
286-
message,
287-
name: PLUGIN_NAME,
288-
hideStack: true,
289-
});
290-
break;
291-
case ThresholdSeverity.Error:
292-
addError(compilation, {
293-
message,
294-
name: PLUGIN_NAME,
295-
hideStack: true,
296-
});
297-
break;
298-
default:
299-
assertNever(severity);
300-
}
285+
const stats = statsValue.toJson();
286+
const isPlatformServer = Array.isArray(compiler.options.target)
287+
? compiler.options.target.some(
288+
(target) => target === 'node' || target == 'async-node'
289+
)
290+
: compiler.options.target === 'node' ||
291+
compiler.options.target === 'async-node';
292+
293+
if (budgets?.length && !isPlatformServer) {
294+
const budgetFailures = [...checkBudgets(budgets, stats)];
295+
for (const { severity, message } of budgetFailures) {
296+
switch (severity) {
297+
case ThresholdSeverity.Warning:
298+
addWarning(currentEmitCompilation, {
299+
message,
300+
name: PLUGIN_NAME,
301+
hideStack: true,
302+
});
303+
break;
304+
case ThresholdSeverity.Error:
305+
addError(currentEmitCompilation, {
306+
message,
307+
name: PLUGIN_NAME,
308+
hideStack: true,
309+
});
310+
break;
311+
default:
312+
assertNever(severity);
301313
}
302314
}
303-
});
315+
}
304316
});
305317

306318
compiler.hooks.afterDone.tap(PLUGIN_NAME, (stats) => {

packages/angular-rspack/src/lib/plugins/watch-file-logs-plugin.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,29 @@ const PLUGIN_NAME = 'AngularRspackWatchFilesLogsPlugin';
1212

1313
export class WatchFilesLogsPlugin implements RspackPluginInstance {
1414
apply(compiler: Compiler) {
15+
let currentModifiedFiles: ReadonlySet<string> | undefined;
16+
let currentRemovedFiles: ReadonlySet<string> | undefined;
17+
18+
// Register compilation hook once - logs modified/removed files
19+
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
20+
const logger = compilation.getLogger(PLUGIN_NAME);
21+
if (currentModifiedFiles?.size) {
22+
logger.log(
23+
`Modified files:\n${[...currentModifiedFiles].join('\n')}\n`
24+
);
25+
}
26+
27+
if (currentRemovedFiles?.size) {
28+
logger.log(`Removed files:\n${[...currentRemovedFiles].join('\n')}\n`);
29+
}
30+
});
31+
32+
// Update shared state on each watch cycle
1533
compiler.hooks.watchRun.tap(
1634
PLUGIN_NAME,
1735
({ modifiedFiles, removedFiles }) => {
18-
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
19-
const logger = compilation.getLogger(PLUGIN_NAME);
20-
if (modifiedFiles?.size) {
21-
logger.log(`Modified files:\n${[...modifiedFiles].join('\n')}\n`);
22-
}
23-
24-
if (removedFiles?.size) {
25-
logger.log(`Removed files:\n${[...removedFiles].join('\n')}\n`);
26-
}
27-
});
36+
currentModifiedFiles = modifiedFiles;
37+
currentRemovedFiles = removedFiles;
2838
}
2939
);
3040
}

0 commit comments

Comments
 (0)