From 6c80e9581503764c0e814ebcc63f4fca971c40e5 Mon Sep 17 00:00:00 2001 From: fhackenberger Date: Tue, 9 Jun 2026 12:34:39 +0200 Subject: [PATCH] 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. --- goldens/public-api/angular/build/index.api.md | 2 + .../src/builders/application/build-action.ts | 92 +++++++++++- .../build/src/builders/application/index.ts | 1 + .../build/src/builders/application/options.ts | 16 +++ .../build/src/builders/dev-server/options.ts | 14 ++ .../build/src/builders/dev-server/schema.json | 9 ++ .../tests/options/manual-rebuild_spec.ts | 135 ++++++++++++++++++ .../src/builders/dev-server/vite/index.ts | 4 + .../src/builders/dev-server/builder.ts | 5 + 9 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 packages/angular/build/src/builders/dev-server/tests/options/manual-rebuild_spec.ts diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index df3f8fe8c777..973f4dec63fe 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -122,11 +122,13 @@ export type DevServerBuilderOptions = { host?: string; inspect?: Inspect; liveReload?: boolean; + manualRebuild?: boolean; open?: boolean; poll?: number; port?: number; prebundle?: PrebundleUnion; proxyConfig?: string; + rebuildTrigger?: string; servePath?: string; ssl?: boolean; sslCert?: string; diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index 77d1f16cbd2a..839427272584 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -59,6 +59,7 @@ export async function* runEsBuildBuildAction( colors?: boolean; jsonLogs?: boolean; incrementalResults?: boolean; + manualRebuildTrigger?: string; }, ): AsyncIterable { const { @@ -76,10 +77,16 @@ export async function* runEsBuildBuildAction( colors, jsonLogs, incrementalResults, + manualRebuildTrigger, } = options; const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; + // Display label for the manual rebuild trigger file, relative to the project root when possible. + const manualTriggerLabel = manualRebuildTrigger + ? path.relative(projectRoot, manualRebuildTrigger) || manualRebuildTrigger + : undefined; + // Initial build let result: ExecutionResult; try { @@ -143,6 +150,18 @@ export async function* runEsBuildBuildAction( // Watch locations provided by the initial build result watcher.add(result.watchFiles); + + // Explicitly watch the manual rebuild trigger file. It is not part of the build's + // watch files (nothing imports it) and the project root is only watched when the + // `NG_BUILD_WATCH_ROOT` environment variable is set, so it must be added directly. + if (manualRebuildTrigger) { + watcher.add(manualRebuildTrigger); + + logger.info( + `Manual rebuild mode enabled. Automatic rebuilds are paused; file changes will be ` + + `buffered until you touch "${manualTriggerLabel}" to trigger a single rebuild.`, + ); + } } // Output the first build results after setting up the watcher to ensure that any code executed @@ -168,12 +187,49 @@ export async function* runEsBuildBuildAction( // Wait for changes and rebuild as needed const currentWatchFiles = new Set(result.watchFiles); + // Buffered, per-path coalesced changes accumulated while manual rebuild mode is paused. + let pendingChanges: ChangedFiles | undefined; try { - for await (const changes of watcher) { + for await (const batch of watcher) { if (options.signal?.aborted) { break; } + let changes = batch; + if (manualRebuildTrigger) { + // While paused, buffer and coalesce incoming changes; only the trigger file's + // modification flushes the queue and drives a single incremental rebuild. + const flush = batch.modified.has(manualRebuildTrigger); + pendingChanges = mergePendingChanges(pendingChanges, batch, manualRebuildTrigger); + + if (!flush) { + const pendingCount = pendingChanges.all.length; + logger.info( + `Manual rebuild mode: ${pendingCount} change(s) buffered. ` + + `Touch "${manualTriggerLabel}" to rebuild.`, + ); + if (verbose) { + logger.info(pendingChanges.toDebugString()); + } + + // Keep serving the last successful build until the trigger file is touched. + continue; + } + + if (pendingChanges.all.length === 0) { + // Trigger file touched but nothing was buffered; nothing to rebuild. + logger.info(`Manual rebuild triggered, but no changes are queued. Nothing to rebuild.`); + pendingChanges = undefined; + continue; + } + + logger.info( + `Manual rebuild triggered. Rebuilding ${pendingChanges.all.length} buffered change(s)...`, + ); + changes = pendingChanges; + pendingChanges = undefined; + } + if (clearScreen) { // eslint-disable-next-line no-console console.clear(); @@ -428,6 +484,40 @@ function* emitOutputResults( } } +/** + * Merges a batch of watcher changes into an accumulated, per-path coalesced change set used by + * manual rebuild mode. Coalescing is "last event wins": repeated modifications collapse to a + * single modification, a modify followed by a remove becomes a remove, and a remove followed by a + * recreate becomes a modification. The trigger file itself is excluded since it is not a source + * input and only acts as the flush signal. + */ +function mergePendingChanges( + pending: ChangedFiles | undefined, + batch: ChangedFiles, + trigger: string, +): ChangedFiles { + const merged = pending ?? new ChangedFiles(); + + // The watcher never populates `added`, but include it defensively for completeness. + for (const file of [...batch.added, ...batch.modified]) { + if (file === trigger) { + continue; + } + merged.removed.delete(file); + merged.modified.add(file); + } + + for (const file of batch.removed) { + if (file === trigger) { + continue; + } + merged.modified.delete(file); + merged.removed.add(file); + } + + return merged; +} + function isCssFilePath(filePath: string): boolean { return /\.css(?:\.map)?$/i.test(filePath); } diff --git a/packages/angular/build/src/builders/application/index.ts b/packages/angular/build/src/builders/application/index.ts index b31fc0a8f81b..53a12ed2b0fe 100644 --- a/packages/angular/build/src/builders/application/index.ts +++ b/packages/angular/build/src/builders/application/index.ts @@ -137,6 +137,7 @@ export async function* buildApplicationInternal( colors: normalizedOptions.colors, jsonLogs: normalizedOptions.jsonLogs, incrementalResults: normalizedOptions.incrementalResults, + manualRebuildTrigger: normalizedOptions.manualRebuildTrigger, logger, signal, }, diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 07d805d1cbf9..658abd109bbc 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -114,6 +114,19 @@ interface InternalOptions { */ incrementalResults?: boolean; + /** + * Suspends automatic rebuilds in watch mode. File changes are buffered and coalesced until the + * configured trigger file is modified, at which point a single incremental rebuild is performed. + * This option is only intended to be used with a development server. + */ + manualRebuild?: boolean; + + /** + * The trigger file path (relative to the project root) used to flush buffered changes when + * `manualRebuild` is enabled. Defaults to `.ng-rebuild`. + */ + rebuildTrigger?: string; + /** * Enables instrumentation to collect code coverage data for specific files. * @@ -517,6 +530,9 @@ export async function normalizeOptions( security, templateUpdates: !!options.templateUpdates, incrementalResults: !!options.incrementalResults, + manualRebuildTrigger: options.manualRebuild + ? path.normalize(path.resolve(projectRoot, options.rebuildTrigger || '.ng-rebuild')) + : undefined, customConditions: options.conditions, frameworkVersion: await findFrameworkVersion(projectRoot), }; diff --git a/packages/angular/build/src/builders/dev-server/options.ts b/packages/angular/build/src/builders/dev-server/options.ts index 5473da832449..bc19678e54ba 100644 --- a/packages/angular/build/src/builders/dev-server/options.ts +++ b/packages/angular/build/src/builders/dev-server/options.ts @@ -115,8 +115,20 @@ export async function normalizeOptions( sslKey, prebundle, allowedHosts, + manualRebuild, + rebuildTrigger, } = options; + if (manualRebuild && watch === false) { + logger.warn( + `Manual rebuilds (\`manualRebuild\` option) have no effect because watching is disabled.`, + ); + } else if (!manualRebuild && rebuildTrigger !== undefined) { + logger.warn( + `The \`rebuildTrigger\` option is ignored because manual rebuilds (\`manualRebuild\` option) are not enabled.`, + ); + } + // Return all the normalized options return { buildTarget, @@ -142,5 +154,7 @@ export async function normalizeOptions( prebundle: cacheOptions.enabled && !optimization.scripts && prebundle, inspect, allowedHosts: allowedHosts ? allowedHosts : [], + manualRebuild: !!manualRebuild, + rebuildTrigger, }; } diff --git a/packages/angular/build/src/builders/dev-server/schema.json b/packages/angular/build/src/builders/dev-server/schema.json index 023478ff7e52..2ca4249cc599 100644 --- a/packages/angular/build/src/builders/dev-server/schema.json +++ b/packages/angular/build/src/builders/dev-server/schema.json @@ -98,6 +98,15 @@ "description": "Rebuild on change.", "default": true }, + "manualRebuild": { + "type": "boolean", + "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.", + "default": false + }, + "rebuildTrigger": { + "type": "string", + "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'." + }, "poll": { "type": "number", "description": "Enable and define the file watching poll time period in milliseconds." diff --git a/packages/angular/build/src/builders/dev-server/tests/options/manual-rebuild_spec.ts b/packages/angular/build/src/builders/dev-server/tests/options/manual-rebuild_spec.ts new file mode 100644 index 000000000000..4fd54ff9c578 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/options/manual-rebuild_spec.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { TimeoutError } from 'rxjs'; +import { executeDevServer } from '../../index'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + describe('Option: "manualRebuild"', () => { + beforeEach(() => { + setupTarget(harness); + }); + + it('buffers file changes and does not rebuild until the trigger file is touched', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + watch: true, + manualRebuild: true, + }); + + await harness + .executeWithCases([ + async ({ result }) => { + // Initial build should succeed + expect(result?.success).toBe(true); + + // Modifying a source file should be buffered, not rebuilt + await harness.modifyFile( + 'src/main.ts', + (content) => content + 'console.log("abcd1234");', + ); + }, + () => { + fail('Expected automatic rebuild to be paused until the trigger file is touched.'); + }, + ]) + .catch((error) => { + // A timeout is expected because the rebuild is paused. + if (error instanceof TimeoutError) { + return; + } + throw error; + }); + }); + + it('does not rebuild when the trigger file is touched but no changes are queued', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + watch: true, + manualRebuild: true, + }); + + await harness + .executeWithCases([ + async ({ result }) => { + // Initial build should succeed + expect(result?.success).toBe(true); + + // Touch the trigger file without modifying any source file. + // With an empty queue there is nothing to rebuild. + await harness.writeFile('.ng-rebuild', ''); + }, + () => { + fail('Expected no rebuild when the trigger file is touched with an empty queue.'); + }, + ]) + .catch((error) => { + // A timeout is expected because no rebuild should be emitted. + if (error instanceof TimeoutError) { + return; + } + throw error; + }); + }); + + it('flushes buffered (coalesced) changes as a single rebuild when the trigger file is touched', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + watch: true, + manualRebuild: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + // Initial build should succeed + expect(result?.success).toBe(true); + + // Several edits to the same file should coalesce to a single net change... + await harness.modifyFile('src/main.ts', (content) => content + 'console.log("first");'); + await harness.modifyFile('src/main.ts', (content) => content + 'console.log("second");'); + + // ...and only flush once the trigger file is modified. + await harness.writeFile('.ng-rebuild', ''); + }, + async ({ result }) => { + // Touching the trigger file should produce a single successful rebuild. + expect(result?.success).toBe(true); + }, + ]); + }); + + it('supports a custom trigger file via "rebuildTrigger"', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + watch: true, + manualRebuild: true, + rebuildTrigger: '.rebuild-now', + }); + + await harness.executeWithCases([ + async ({ result }) => { + // Initial build should succeed + expect(result?.success).toBe(true); + + await harness.modifyFile( + 'src/main.ts', + (content) => content + 'console.log("abcd1234");', + ); + + // Touch the custom trigger file to flush the buffered change. + await harness.writeFile('.rebuild-now', ''); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/dev-server/vite/index.ts b/packages/angular/build/src/builders/dev-server/vite/index.ts index 557b2d34b52a..fa20194dfc10 100644 --- a/packages/angular/build/src/builders/dev-server/vite/index.ts +++ b/packages/angular/build/src/builders/dev-server/vite/index.ts @@ -179,6 +179,10 @@ export async function* serveWithVite( browserOptions.templateUpdates = componentsHmrCanBeUsed && useComponentTemplateHmr; browserOptions.incrementalResults = true; + // Forward manual ("rebuild now") mode to the application build watch loop. + browserOptions.manualRebuild = serverOptions.manualRebuild; + browserOptions.rebuildTrigger = serverOptions.rebuildTrigger; + // Setup the prebundling transformer that will be shared across Vite prebundling requests const prebundleTransformer = new JavaScriptTransformer( // Always enable JIT linking to support applications built with and without AOT. diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts index 1e03eef28ef0..734dc79fc13d 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts @@ -94,6 +94,9 @@ export function execute( normalizedOptions.allowedHosts ??= []; } + // Manual ("rebuild now") mode is not supported by this Webpack compatibility builder. + (normalizedOptions as unknown as { manualRebuild: boolean }).manualRebuild = false; + return defer(() => Promise.all([import('@angular/build/private'), import('../browser-esbuild')]), ).pipe( @@ -103,6 +106,8 @@ export function execute( hmr: boolean; allowedHosts: true | string[]; define: { [key: string]: string } | undefined; + manualRebuild: boolean; + rebuildTrigger: string | undefined; }, builderName, (options, context, codePlugins) => {