Skip to content

Commit 474683b

Browse files
committed
refactor(@angular/build): introduce AngularCompilationContext to manage compiler lifecycle
Refactors the ambient/global sharedTSCompilationState synchronization singleton into an explicit, orchestrator-instantiated AngularCompilationContext. This context encapsulates the AngularCompilation instance and its ready promise/resolver. The context is instantiated once in executeBuild and passed down to both browser and server compiler plugins, eliminating global state and providing a single cohesive API to manage worker thread lifecycles via context.dispose().
1 parent b9681c0 commit 474683b

5 files changed

Lines changed: 161 additions & 88 deletions

File tree

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

Lines changed: 66 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { BuilderContext } from '@angular-devkit/architect';
1010
import { createAngularCompilation } from '../../tools/angular/compilation';
11+
import { AngularCompilationContext } from '../../tools/esbuild/angular/compilation-state';
1112
import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache';
1213
import { generateBudgetStats } from '../../tools/esbuild/budget-stats';
1314
import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context';
@@ -71,61 +72,74 @@ export async function executeBuild(
7172
let codeBundleCache;
7273
let bundlingResult: BundleContextResult;
7374
let templateUpdates: Map<string, string> | undefined;
74-
if (rebuildState) {
75-
bundlerContexts = rebuildState.rebuildContexts;
76-
componentStyleBundler = rebuildState.componentStyleBundler;
77-
codeBundleCache = rebuildState.codeBundleCache;
78-
templateUpdates = rebuildState.templateUpdates;
79-
// Reset template updates for new rebuild
80-
templateUpdates?.clear();
81-
82-
const allFileChanges = rebuildState.fileChanges.all;
83-
84-
// Bundle all contexts that do not require TypeScript changed file checks.
85-
// These will automatically use cached results based on the changed files.
86-
bundlingResult = await BundlerContext.bundleAll(bundlerContexts.otherContexts, allFileChanges);
87-
88-
// Check the TypeScript code bundling cache for changes. If invalid, force a rebundle of
89-
// all TypeScript related contexts.
90-
const forceTypeScriptRebuild = codeBundleCache?.invalidate(allFileChanges);
91-
const typescriptResults: BundleContextResult[] = [];
92-
for (const typescriptContext of bundlerContexts.typescriptContexts) {
93-
typescriptContext.invalidate(allFileChanges);
94-
const result = await typescriptContext.bundle(forceTypeScriptRebuild);
95-
typescriptResults.push(result);
96-
}
97-
bundlingResult = BundlerContext.mergeResults([bundlingResult, ...typescriptResults]);
98-
} else {
99-
const target = transformSupportedBrowsersToTargets(browsers);
100-
codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined);
101-
componentStyleBundler = createComponentStyleBundler(options, target);
102-
if (options.templateUpdates) {
103-
templateUpdates = new Map<string, string>();
104-
}
105-
bundlerContexts = setupBundlerContexts(
106-
options,
107-
target,
108-
codeBundleCache,
109-
componentStyleBundler,
110-
// Create new reusable compilation for the appropriate mode based on the `jit` plugin option
111-
await createAngularCompilation(!!options.jit, !options.serverEntryPoint),
112-
templateUpdates,
113-
);
75+
let angularCompilationContext: AngularCompilationContext | undefined;
76+
try {
77+
if (rebuildState) {
78+
bundlerContexts = rebuildState.rebuildContexts;
79+
componentStyleBundler = rebuildState.componentStyleBundler;
80+
codeBundleCache = rebuildState.codeBundleCache;
81+
templateUpdates = rebuildState.templateUpdates;
82+
// Reset template updates for new rebuild
83+
templateUpdates?.clear();
84+
85+
const allFileChanges = rebuildState.fileChanges.all;
86+
87+
// Bundle all contexts that do not require TypeScript changed file checks.
88+
// These will automatically use cached results based on the changed files.
89+
bundlingResult = await BundlerContext.bundleAll(
90+
bundlerContexts.otherContexts,
91+
allFileChanges,
92+
);
11493

115-
// Bundle everything on initial build
116-
bundlingResult = await BundlerContext.bundleAll([
117-
...bundlerContexts.typescriptContexts,
118-
...bundlerContexts.otherContexts,
119-
]);
120-
}
94+
// Check the TypeScript code bundling cache for changes. If invalid, force a rebundle of
95+
// all TypeScript related contexts.
96+
const forceTypeScriptRebuild = codeBundleCache?.invalidate(allFileChanges);
97+
const typescriptResults: BundleContextResult[] = [];
98+
for (const typescriptContext of bundlerContexts.typescriptContexts) {
99+
typescriptContext.invalidate(allFileChanges);
100+
const result = await typescriptContext.bundle(forceTypeScriptRebuild);
101+
typescriptResults.push(result);
102+
}
103+
bundlingResult = BundlerContext.mergeResults([bundlingResult, ...typescriptResults]);
104+
} else {
105+
const target = transformSupportedBrowsersToTargets(browsers);
106+
codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined);
107+
componentStyleBundler = createComponentStyleBundler(options, target);
108+
if (options.templateUpdates) {
109+
templateUpdates = new Map<string, string>();
110+
}
111+
const angularCompilation = await createAngularCompilation(
112+
!!options.jit,
113+
!options.serverEntryPoint,
114+
);
115+
angularCompilationContext = new AngularCompilationContext(angularCompilation);
116+
bundlerContexts = setupBundlerContexts(
117+
options,
118+
target,
119+
codeBundleCache,
120+
componentStyleBundler,
121+
angularCompilationContext,
122+
templateUpdates,
123+
);
121124

122-
// Update any external component styles if enabled and rebuilding.
123-
// TODO: Only attempt rebundling of invalidated styles once incremental build results are supported.
124-
if (rebuildState && options.externalRuntimeStyles) {
125-
componentStyleBundler.invalidate(rebuildState.fileChanges.all);
125+
// Bundle everything on initial build
126+
bundlingResult = await BundlerContext.bundleAll([
127+
...bundlerContexts.typescriptContexts,
128+
...bundlerContexts.otherContexts,
129+
]);
130+
}
131+
132+
// Update any external component styles if enabled and rebuilding.
133+
// TODO: Only attempt rebundling of invalidated styles once incremental build results are supported.
134+
if (rebuildState && options.externalRuntimeStyles) {
135+
componentStyleBundler.invalidate(rebuildState.fileChanges.all);
126136

127-
const componentResults = await componentStyleBundler.bundleAllFiles(true, true);
128-
bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]);
137+
const componentResults = await componentStyleBundler.bundleAllFiles(true, true);
138+
bundlingResult = BundlerContext.mergeResults([bundlingResult, ...componentResults]);
139+
}
140+
} catch (error) {
141+
await angularCompilationContext?.dispose();
142+
throw error;
129143
}
130144

131145
const executionResult = new ExecutionResult(

packages/angular/build/src/builders/application/setup-bundling.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { AngularCompilation } from '../../tools/angular/compilation';
9+
import type { AngularCompilationContext } from '../../tools/esbuild/angular/compilation-state';
1010
import { ComponentStylesheetBundler } from '../../tools/esbuild/angular/component-stylesheets';
11-
import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache';
11+
import type { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache';
1212
import {
1313
createBrowserCodeBundleOptions,
1414
createBrowserPolyfillBundleOptions,
@@ -20,7 +20,7 @@ import { BundlerContext } from '../../tools/esbuild/bundler-context';
2020
import { createGlobalScriptsBundleOptions } from '../../tools/esbuild/global-scripts';
2121
import { createGlobalStylesBundleOptions } from '../../tools/esbuild/global-styles';
2222
import { getSupportedNodeTargets } from '../../tools/esbuild/utils';
23-
import { NormalizedApplicationBuildOptions } from './options';
23+
import type { NormalizedApplicationBuildOptions } from './options';
2424

2525
/**
2626
* Generates one or more BundlerContext instances based on the builder provided
@@ -35,7 +35,7 @@ export function setupBundlerContexts(
3535
target: string[],
3636
codeBundleCache: SourceFileCache,
3737
stylesheetBundler: ComponentStylesheetBundler,
38-
angularCompilation: AngularCompilation,
38+
angularCompilationContext: AngularCompilationContext,
3939
templateUpdates: Map<string, string> | undefined,
4040
): {
4141
typescriptContexts: BundlerContext[];
@@ -63,7 +63,7 @@ export function setupBundlerContexts(
6363
target,
6464
codeBundleCache,
6565
stylesheetBundler,
66-
angularCompilation,
66+
angularCompilationContext,
6767
templateUpdates,
6868
),
6969
),
@@ -75,6 +75,7 @@ export function setupBundlerContexts(
7575
target,
7676
codeBundleCache,
7777
stylesheetBundler,
78+
angularCompilationContext.createSecondaryContext(),
7879
);
7980
if (browserPolyfillBundleOptions) {
8081
const browserPolyfillContext = new BundlerContext(
@@ -117,7 +118,13 @@ export function setupBundlerContexts(
117118
new BundlerContext(
118119
workspaceRoot,
119120
watch,
120-
createServerMainCodeBundleOptions(options, nodeTargets, codeBundleCache, stylesheetBundler),
121+
createServerMainCodeBundleOptions(
122+
options,
123+
nodeTargets,
124+
codeBundleCache,
125+
stylesheetBundler,
126+
angularCompilationContext.createSecondaryContext(),
127+
),
121128
),
122129
);
123130

@@ -127,7 +134,13 @@ export function setupBundlerContexts(
127134
new BundlerContext(
128135
workspaceRoot,
129136
watch,
130-
createSsrEntryCodeBundleOptions(options, nodeTargets, codeBundleCache, stylesheetBundler),
137+
createSsrEntryCodeBundleOptions(
138+
options,
139+
nodeTargets,
140+
codeBundleCache,
141+
stylesheetBundler,
142+
angularCompilationContext.createSecondaryContext(),
143+
),
131144
),
132145
);
133146
}

packages/angular/build/src/tools/esbuild/angular/compilation-state.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,23 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
export class SharedTSCompilationState {
9+
import { type AngularCompilation, NoopCompilation } from '../../angular/compilation';
10+
11+
export class AngularCompilationContext {
12+
#compilation: AngularCompilation;
1013
#pendingCompilation = true;
1114
#resolveCompilationReady: ((value: boolean) => void) | undefined;
1215
#compilationReadyPromise: Promise<boolean> | undefined;
1316
#hasErrors = true;
1417

18+
constructor(compilation: AngularCompilation) {
19+
this.#compilation = compilation;
20+
}
21+
22+
get compilation(): AngularCompilation {
23+
return this.#compilation;
24+
}
25+
1526
get waitUntilReady(): Promise<boolean> {
1627
if (!this.#pendingCompilation) {
1728
return Promise.resolve(this.#hasErrors);
@@ -35,14 +46,44 @@ export class SharedTSCompilationState {
3546
this.#pendingCompilation = true;
3647
}
3748

38-
dispose(): void {
49+
#disposed = false;
50+
51+
async dispose(): Promise<void> {
52+
if (this.#disposed) {
53+
return;
54+
}
55+
this.#disposed = true;
3956
this.markAsReady(true);
40-
globalSharedCompilationState = undefined;
57+
try {
58+
await this.#compilation.close?.();
59+
} catch {
60+
// Suppress closure errors to avoid unhandled rejections during teardown.
61+
}
62+
}
63+
64+
createSecondaryContext(): AngularCompilationContext {
65+
return new SecondaryCompilationContext(this);
4166
}
4267
}
4368

44-
let globalSharedCompilationState: SharedTSCompilationState | undefined;
69+
class SecondaryCompilationContext extends AngularCompilationContext {
70+
constructor(private primaryContext: AngularCompilationContext) {
71+
super(new NoopCompilation());
72+
}
4573

46-
export function getSharedCompilationState(): SharedTSCompilationState {
47-
return (globalSharedCompilationState ??= new SharedTSCompilationState());
74+
override get waitUntilReady(): Promise<boolean> {
75+
return this.primaryContext.waitUntilReady;
76+
}
77+
78+
override markAsReady(hasErrors: boolean): void {
79+
this.primaryContext.markAsReady(hasErrors);
80+
}
81+
82+
override markAsInProgress(): void {
83+
this.primaryContext.markAsInProgress();
84+
}
85+
86+
override async dispose(): Promise<void> {
87+
// No-op for secondary context to avoid disposing the primary compilation worker
88+
}
4889
}

packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { AngularCompilation, DiagnosticModes, NoopCompilation } from '../../angu
2727
import { JavaScriptTransformer } from '../javascript-transformer';
2828
import { LoadResultCache, createCachedLoad } from '../load-result-cache';
2929
import { logCumulativeDurations, profileAsync, resetCumulativeDurations } from '../profiling';
30-
import { SharedTSCompilationState, getSharedCompilationState } from './compilation-state';
30+
import { AngularCompilationContext } from './compilation-state';
3131
import { ComponentStylesheetBundler } from './component-stylesheets';
3232
import { FileReferenceTracker } from './file-reference-tracker';
3333
import { setupJitPluginCallbacks } from './jit-plugin-callbacks';
@@ -61,7 +61,10 @@ export interface CompilerPluginOptions {
6161
// eslint-disable-next-line max-lines-per-function
6262
export function createCompilerPlugin(
6363
pluginOptions: CompilerPluginOptions,
64-
compilationOrFactory: AngularCompilation | (() => Promise<AngularCompilation>),
64+
compilationContextOrCompilation:
65+
| AngularCompilationContext
66+
| AngularCompilation
67+
| (() => Promise<AngularCompilation>),
6568
stylesheetBundler: ComponentStylesheetBundler,
6669
): Plugin {
6770
return {
@@ -111,10 +114,15 @@ export function createCompilerPlugin(
111114

112115
// The factory is only relevant for compatibility purposes with the private API.
113116
// TODO: Update private API in the next major to allow compilation function factory removal here.
114-
const compilation =
115-
typeof compilationOrFactory === 'function'
116-
? await compilationOrFactory()
117-
: compilationOrFactory;
117+
const angularCompilationContext =
118+
compilationContextOrCompilation instanceof AngularCompilationContext
119+
? compilationContextOrCompilation
120+
: new AngularCompilationContext(
121+
typeof compilationContextOrCompilation === 'function'
122+
? await compilationContextOrCompilation()
123+
: compilationContextOrCompilation,
124+
);
125+
const compilation: AngularCompilation = angularCompilationContext.compilation;
118126

119127
// The in-memory cache of TypeScript file outputs will be used during the build in `onLoad` callbacks for TS files.
120128
// A string value indicates direct TS/NG output and a Uint8Array indicates fully transformed code.
@@ -136,16 +144,13 @@ export function createCompilerPlugin(
136144
// Determines if transpilation should be handle by TypeScript or esbuild
137145
let useTypeScriptTranspilation = true;
138146

139-
let sharedTSCompilationState: SharedTSCompilationState | undefined;
140-
141147
// To fully invalidate files, track resource referenced files and their referencing source
142148
const referencedFileTracker = new FileReferenceTracker();
143149

144150
// eslint-disable-next-line max-lines-per-function
145151
build.onStart(async () => {
146-
sharedTSCompilationState = getSharedCompilationState();
147152
if (!(compilation instanceof NoopCompilation)) {
148-
sharedTSCompilationState.markAsInProgress();
153+
angularCompilationContext.markAsInProgress();
149154
}
150155

151156
const result: OnStartResult = {
@@ -348,7 +353,7 @@ export function createCompilerPlugin(
348353
}
349354

350355
if (compilation instanceof NoopCompilation) {
351-
hasCompilationErrors = await sharedTSCompilationState.waitUntilReady;
356+
hasCompilationErrors = await angularCompilationContext.waitUntilReady;
352357

353358
return result;
354359
}
@@ -417,7 +422,7 @@ export function createCompilerPlugin(
417422
// Reset the setup warnings so that they are only shown during the first build.
418423
setupWarnings = undefined;
419424

420-
sharedTSCompilationState.markAsReady(hasCompilationErrors);
425+
angularCompilationContext.markAsReady(hasCompilationErrors);
421426

422427
return result;
423428
});
@@ -576,7 +581,7 @@ export function createCompilerPlugin(
576581

577582
build.onEnd((result) => {
578583
// Ensure other compilations are unblocked if the main compilation throws during start
579-
sharedTSCompilationState?.markAsReady(hasCompilationErrors);
584+
angularCompilationContext.markAsReady(hasCompilationErrors);
580585

581586
for (const { outputFiles, metafile } of additionalResults.values()) {
582587
// Add any additional output files to the main output files
@@ -598,8 +603,7 @@ export function createCompilerPlugin(
598603
});
599604

600605
build.onDispose(() => {
601-
sharedTSCompilationState?.dispose();
602-
void compilation.close?.();
606+
void angularCompilationContext.dispose();
603607
void javascriptTransformer.close();
604608
void cacheStore?.close();
605609
});

0 commit comments

Comments
 (0)