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
2 changes: 2 additions & 0 deletions goldens/public-api/angular/build/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
92 changes: 91 additions & 1 deletion packages/angular/build/src/builders/application/build-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export async function* runEsBuildBuildAction(
colors?: boolean;
jsonLogs?: boolean;
incrementalResults?: boolean;
manualRebuildTrigger?: string;
},
): AsyncIterable<Result> {
const {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the trigger file is created for the first time (or recreated after being deleted), the file watcher might report it as an added event rather than a modified event. To ensure the manual rebuild is reliably triggered in all cases, we should check both batch.modified and batch.added for the trigger file.

Suggested change
const flush = batch.modified.has(manualRebuildTrigger);
const flush = batch.modified.has(manualRebuildTrigger) || batch.added.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();
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions packages/angular/build/src/builders/application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export async function* buildApplicationInternal(
colors: normalizedOptions.colors,
jsonLogs: normalizedOptions.jsonLogs,
incrementalResults: normalizedOptions.incrementalResults,
manualRebuildTrigger: normalizedOptions.manualRebuildTrigger,
logger,
signal,
},
Expand Down
16 changes: 16 additions & 0 deletions packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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),
};
Expand Down
14 changes: 14 additions & 0 deletions packages/angular/build/src/builders/dev-server/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -142,5 +154,7 @@ export async function normalizeOptions(
prebundle: cacheOptions.enabled && !optimization.scripts && prebundle,
inspect,
allowedHosts: allowedHosts ? allowedHosts : [],
manualRebuild: !!manualRebuild,
rebuildTrigger,
};
}
9 changes: 9 additions & 0 deletions packages/angular/build/src/builders/dev-server/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
},
]);
});
});
});
4 changes: 4 additions & 0 deletions packages/angular/build/src/builders/dev-server/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) => {
Expand Down
Loading