Skip to content

Commit 6c80e95

Browse files
committed
feat(@angular/build): add opt-in manual rebuild mode to the dev-server
Add a `manualRebuild` option to the `@angular/build:dev-server` builder that pauses automatic rebuilds while keeping the development server live and serving the last successful build. Previously every file change triggered an immediate rebuild and reload, which is costly when making a series of edits. While paused, file-change events are buffered in memory and coalesced per path so redundant events for the same file collapse to a single net change. Touching the trigger file (`rebuildTrigger`, defaulting to `.ng-rebuild`) flushes the queue to the build system as one incremental rebuild, after which the server performs a single HMR/live-reload cycle.
1 parent 3f7494c commit 6c80e95

9 files changed

Lines changed: 277 additions & 1 deletion

File tree

goldens/public-api/angular/build/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,13 @@ export type DevServerBuilderOptions = {
122122
host?: string;
123123
inspect?: Inspect;
124124
liveReload?: boolean;
125+
manualRebuild?: boolean;
125126
open?: boolean;
126127
poll?: number;
127128
port?: number;
128129
prebundle?: PrebundleUnion;
129130
proxyConfig?: string;
131+
rebuildTrigger?: string;
130132
servePath?: string;
131133
ssl?: boolean;
132134
sslCert?: string;

packages/angular/build/src/builders/application/build-action.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export async function* runEsBuildBuildAction(
5959
colors?: boolean;
6060
jsonLogs?: boolean;
6161
incrementalResults?: boolean;
62+
manualRebuildTrigger?: string;
6263
},
6364
): AsyncIterable<Result> {
6465
const {
@@ -76,10 +77,16 @@ export async function* runEsBuildBuildAction(
7677
colors,
7778
jsonLogs,
7879
incrementalResults,
80+
manualRebuildTrigger,
7981
} = options;
8082

8183
const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress;
8284

85+
// Display label for the manual rebuild trigger file, relative to the project root when possible.
86+
const manualTriggerLabel = manualRebuildTrigger
87+
? path.relative(projectRoot, manualRebuildTrigger) || manualRebuildTrigger
88+
: undefined;
89+
8390
// Initial build
8491
let result: ExecutionResult;
8592
try {
@@ -143,6 +150,18 @@ export async function* runEsBuildBuildAction(
143150

144151
// Watch locations provided by the initial build result
145152
watcher.add(result.watchFiles);
153+
154+
// Explicitly watch the manual rebuild trigger file. It is not part of the build's
155+
// watch files (nothing imports it) and the project root is only watched when the
156+
// `NG_BUILD_WATCH_ROOT` environment variable is set, so it must be added directly.
157+
if (manualRebuildTrigger) {
158+
watcher.add(manualRebuildTrigger);
159+
160+
logger.info(
161+
`Manual rebuild mode enabled. Automatic rebuilds are paused; file changes will be ` +
162+
`buffered until you touch "${manualTriggerLabel}" to trigger a single rebuild.`,
163+
);
164+
}
146165
}
147166

148167
// Output the first build results after setting up the watcher to ensure that any code executed
@@ -168,12 +187,49 @@ export async function* runEsBuildBuildAction(
168187

169188
// Wait for changes and rebuild as needed
170189
const currentWatchFiles = new Set(result.watchFiles);
190+
// Buffered, per-path coalesced changes accumulated while manual rebuild mode is paused.
191+
let pendingChanges: ChangedFiles | undefined;
171192
try {
172-
for await (const changes of watcher) {
193+
for await (const batch of watcher) {
173194
if (options.signal?.aborted) {
174195
break;
175196
}
176197

198+
let changes = batch;
199+
if (manualRebuildTrigger) {
200+
// While paused, buffer and coalesce incoming changes; only the trigger file's
201+
// modification flushes the queue and drives a single incremental rebuild.
202+
const flush = batch.modified.has(manualRebuildTrigger);
203+
pendingChanges = mergePendingChanges(pendingChanges, batch, manualRebuildTrigger);
204+
205+
if (!flush) {
206+
const pendingCount = pendingChanges.all.length;
207+
logger.info(
208+
`Manual rebuild mode: ${pendingCount} change(s) buffered. ` +
209+
`Touch "${manualTriggerLabel}" to rebuild.`,
210+
);
211+
if (verbose) {
212+
logger.info(pendingChanges.toDebugString());
213+
}
214+
215+
// Keep serving the last successful build until the trigger file is touched.
216+
continue;
217+
}
218+
219+
if (pendingChanges.all.length === 0) {
220+
// Trigger file touched but nothing was buffered; nothing to rebuild.
221+
logger.info(`Manual rebuild triggered, but no changes are queued. Nothing to rebuild.`);
222+
pendingChanges = undefined;
223+
continue;
224+
}
225+
226+
logger.info(
227+
`Manual rebuild triggered. Rebuilding ${pendingChanges.all.length} buffered change(s)...`,
228+
);
229+
changes = pendingChanges;
230+
pendingChanges = undefined;
231+
}
232+
177233
if (clearScreen) {
178234
// eslint-disable-next-line no-console
179235
console.clear();
@@ -428,6 +484,40 @@ function* emitOutputResults(
428484
}
429485
}
430486

487+
/**
488+
* Merges a batch of watcher changes into an accumulated, per-path coalesced change set used by
489+
* manual rebuild mode. Coalescing is "last event wins": repeated modifications collapse to a
490+
* single modification, a modify followed by a remove becomes a remove, and a remove followed by a
491+
* recreate becomes a modification. The trigger file itself is excluded since it is not a source
492+
* input and only acts as the flush signal.
493+
*/
494+
function mergePendingChanges(
495+
pending: ChangedFiles | undefined,
496+
batch: ChangedFiles,
497+
trigger: string,
498+
): ChangedFiles {
499+
const merged = pending ?? new ChangedFiles();
500+
501+
// The watcher never populates `added`, but include it defensively for completeness.
502+
for (const file of [...batch.added, ...batch.modified]) {
503+
if (file === trigger) {
504+
continue;
505+
}
506+
merged.removed.delete(file);
507+
merged.modified.add(file);
508+
}
509+
510+
for (const file of batch.removed) {
511+
if (file === trigger) {
512+
continue;
513+
}
514+
merged.modified.delete(file);
515+
merged.removed.add(file);
516+
}
517+
518+
return merged;
519+
}
520+
431521
function isCssFilePath(filePath: string): boolean {
432522
return /\.css(?:\.map)?$/i.test(filePath);
433523
}

packages/angular/build/src/builders/application/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export async function* buildApplicationInternal(
137137
colors: normalizedOptions.colors,
138138
jsonLogs: normalizedOptions.jsonLogs,
139139
incrementalResults: normalizedOptions.incrementalResults,
140+
manualRebuildTrigger: normalizedOptions.manualRebuildTrigger,
140141
logger,
141142
signal,
142143
},

packages/angular/build/src/builders/application/options.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,19 @@ interface InternalOptions {
114114
*/
115115
incrementalResults?: boolean;
116116

117+
/**
118+
* Suspends automatic rebuilds in watch mode. File changes are buffered and coalesced until the
119+
* configured trigger file is modified, at which point a single incremental rebuild is performed.
120+
* This option is only intended to be used with a development server.
121+
*/
122+
manualRebuild?: boolean;
123+
124+
/**
125+
* The trigger file path (relative to the project root) used to flush buffered changes when
126+
* `manualRebuild` is enabled. Defaults to `.ng-rebuild`.
127+
*/
128+
rebuildTrigger?: string;
129+
117130
/**
118131
* Enables instrumentation to collect code coverage data for specific files.
119132
*
@@ -517,6 +530,9 @@ export async function normalizeOptions(
517530
security,
518531
templateUpdates: !!options.templateUpdates,
519532
incrementalResults: !!options.incrementalResults,
533+
manualRebuildTrigger: options.manualRebuild
534+
? path.normalize(path.resolve(projectRoot, options.rebuildTrigger || '.ng-rebuild'))
535+
: undefined,
520536
customConditions: options.conditions,
521537
frameworkVersion: await findFrameworkVersion(projectRoot),
522538
};

packages/angular/build/src/builders/dev-server/options.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,20 @@ export async function normalizeOptions(
115115
sslKey,
116116
prebundle,
117117
allowedHosts,
118+
manualRebuild,
119+
rebuildTrigger,
118120
} = options;
119121

122+
if (manualRebuild && watch === false) {
123+
logger.warn(
124+
`Manual rebuilds (\`manualRebuild\` option) have no effect because watching is disabled.`,
125+
);
126+
} else if (!manualRebuild && rebuildTrigger !== undefined) {
127+
logger.warn(
128+
`The \`rebuildTrigger\` option is ignored because manual rebuilds (\`manualRebuild\` option) are not enabled.`,
129+
);
130+
}
131+
120132
// Return all the normalized options
121133
return {
122134
buildTarget,
@@ -142,5 +154,7 @@ export async function normalizeOptions(
142154
prebundle: cacheOptions.enabled && !optimization.scripts && prebundle,
143155
inspect,
144156
allowedHosts: allowedHosts ? allowedHosts : [],
157+
manualRebuild: !!manualRebuild,
158+
rebuildTrigger,
145159
};
146160
}

packages/angular/build/src/builders/dev-server/schema.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@
9898
"description": "Rebuild on change.",
9999
"default": true
100100
},
101+
"manualRebuild": {
102+
"type": "boolean",
103+
"description": "Suspend automatic rebuilds and only rebuild when the trigger file is touched. File changes are buffered and coalesced while paused; the development server stays live and keeps serving the last successful build.",
104+
"default": false
105+
},
106+
"rebuildTrigger": {
107+
"type": "string",
108+
"description": "Path, relative to the project root, of the file whose modification flushes the buffered changes and triggers a single incremental rebuild. Only used when 'manualRebuild' is enabled. Defaults to '.ng-rebuild'."
109+
},
101110
"poll": {
102111
"type": "number",
103112
"description": "Enable and define the file watching poll time period in milliseconds."
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { TimeoutError } from 'rxjs';
10+
import { executeDevServer } from '../../index';
11+
import { describeServeBuilder } from '../jasmine-helpers';
12+
import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
13+
14+
describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => {
15+
describe('Option: "manualRebuild"', () => {
16+
beforeEach(() => {
17+
setupTarget(harness);
18+
});
19+
20+
it('buffers file changes and does not rebuild until the trigger file is touched', async () => {
21+
harness.useTarget('serve', {
22+
...BASE_OPTIONS,
23+
watch: true,
24+
manualRebuild: true,
25+
});
26+
27+
await harness
28+
.executeWithCases([
29+
async ({ result }) => {
30+
// Initial build should succeed
31+
expect(result?.success).toBe(true);
32+
33+
// Modifying a source file should be buffered, not rebuilt
34+
await harness.modifyFile(
35+
'src/main.ts',
36+
(content) => content + 'console.log("abcd1234");',
37+
);
38+
},
39+
() => {
40+
fail('Expected automatic rebuild to be paused until the trigger file is touched.');
41+
},
42+
])
43+
.catch((error) => {
44+
// A timeout is expected because the rebuild is paused.
45+
if (error instanceof TimeoutError) {
46+
return;
47+
}
48+
throw error;
49+
});
50+
});
51+
52+
it('does not rebuild when the trigger file is touched but no changes are queued', async () => {
53+
harness.useTarget('serve', {
54+
...BASE_OPTIONS,
55+
watch: true,
56+
manualRebuild: true,
57+
});
58+
59+
await harness
60+
.executeWithCases([
61+
async ({ result }) => {
62+
// Initial build should succeed
63+
expect(result?.success).toBe(true);
64+
65+
// Touch the trigger file without modifying any source file.
66+
// With an empty queue there is nothing to rebuild.
67+
await harness.writeFile('.ng-rebuild', '');
68+
},
69+
() => {
70+
fail('Expected no rebuild when the trigger file is touched with an empty queue.');
71+
},
72+
])
73+
.catch((error) => {
74+
// A timeout is expected because no rebuild should be emitted.
75+
if (error instanceof TimeoutError) {
76+
return;
77+
}
78+
throw error;
79+
});
80+
});
81+
82+
it('flushes buffered (coalesced) changes as a single rebuild when the trigger file is touched', async () => {
83+
harness.useTarget('serve', {
84+
...BASE_OPTIONS,
85+
watch: true,
86+
manualRebuild: true,
87+
});
88+
89+
await harness.executeWithCases([
90+
async ({ result }) => {
91+
// Initial build should succeed
92+
expect(result?.success).toBe(true);
93+
94+
// Several edits to the same file should coalesce to a single net change...
95+
await harness.modifyFile('src/main.ts', (content) => content + 'console.log("first");');
96+
await harness.modifyFile('src/main.ts', (content) => content + 'console.log("second");');
97+
98+
// ...and only flush once the trigger file is modified.
99+
await harness.writeFile('.ng-rebuild', '');
100+
},
101+
async ({ result }) => {
102+
// Touching the trigger file should produce a single successful rebuild.
103+
expect(result?.success).toBe(true);
104+
},
105+
]);
106+
});
107+
108+
it('supports a custom trigger file via "rebuildTrigger"', async () => {
109+
harness.useTarget('serve', {
110+
...BASE_OPTIONS,
111+
watch: true,
112+
manualRebuild: true,
113+
rebuildTrigger: '.rebuild-now',
114+
});
115+
116+
await harness.executeWithCases([
117+
async ({ result }) => {
118+
// Initial build should succeed
119+
expect(result?.success).toBe(true);
120+
121+
await harness.modifyFile(
122+
'src/main.ts',
123+
(content) => content + 'console.log("abcd1234");',
124+
);
125+
126+
// Touch the custom trigger file to flush the buffered change.
127+
await harness.writeFile('.rebuild-now', '');
128+
},
129+
async ({ result }) => {
130+
expect(result?.success).toBe(true);
131+
},
132+
]);
133+
});
134+
});
135+
});

packages/angular/build/src/builders/dev-server/vite/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ export async function* serveWithVite(
179179
browserOptions.templateUpdates = componentsHmrCanBeUsed && useComponentTemplateHmr;
180180
browserOptions.incrementalResults = true;
181181

182+
// Forward manual ("rebuild now") mode to the application build watch loop.
183+
browserOptions.manualRebuild = serverOptions.manualRebuild;
184+
browserOptions.rebuildTrigger = serverOptions.rebuildTrigger;
185+
182186
// Setup the prebundling transformer that will be shared across Vite prebundling requests
183187
const prebundleTransformer = new JavaScriptTransformer(
184188
// Always enable JIT linking to support applications built with and without AOT.

packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ export function execute(
9494
normalizedOptions.allowedHosts ??= [];
9595
}
9696

97+
// Manual ("rebuild now") mode is not supported by this Webpack compatibility builder.
98+
(normalizedOptions as unknown as { manualRebuild: boolean }).manualRebuild = false;
99+
97100
return defer(() =>
98101
Promise.all([import('@angular/build/private'), import('../browser-esbuild')]),
99102
).pipe(
@@ -103,6 +106,8 @@ export function execute(
103106
hmr: boolean;
104107
allowedHosts: true | string[];
105108
define: { [key: string]: string } | undefined;
109+
manualRebuild: boolean;
110+
rebuildTrigger: string | undefined;
106111
},
107112
builderName,
108113
(options, context, codePlugins) => {

0 commit comments

Comments
 (0)