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) => {