Skip to content

Commit 3ebcbbf

Browse files
committed
fix(@angular/build): avoid top-level await and add zoneless option for Vitest runner
Previously, the Vitest unit-test runner used dynamic import strategies (`'dynamic'` and `'dynamic-zone'`) within the generated TestBed initialization virtual file (`createTestBedInitVirtualFile`) to load `zone.js` and `zone.js/testing`. This logic was fundamentally flawed: 1. When Zone.js was loaded dynamically at runtime via the `'dynamic'` strategy, esbuild did not downlevel `async`/`await` because `isZonelessApp` checked the build target's `polyfills` configuration (which did not explicitly list `zone.js`). Consequently, native async/await microtasks bypassed Zone.js context tracking, breaking Zone.js at runtime. 2. If a project used a local polyfills file (e.g. `polyfills: ["src/polyfills.ts"]`), `isZonelessApp` considered the application zoneful and disabled `async-await` support in esbuild. However, esbuild cannot downlevel top-level await when async/await is downleveled, causing esbuild to reject top-level await unconditionally. Hence, the `'dynamic'` strategy never worked as intended. 3. For zoneless applications (such as `polyfills: []`), the syntactic presence of top-level `await` in the virtual file caused esbuild builds to fail when targeting older browsers or Browserslist targets that lack top-level await support, even though Zone was never present at runtime. This commit resolves these issues by: - Eliminating top-level `await import()` from `createTestBedInitVirtualFile` entirely. - Inverting the polyfill strategy so that Zone.js and its testing entry-point are injected directly into `buildOptions.polyfills` before bundling. - Introducing a new `zoneless` option for the Vitest runner to explicitly control zoneless test execution: - `zoneless: true`: Zone.js polyfills are excluded and not loaded. - `zoneless: false`: Zone.js and `zone.js/testing` are explicitly injected. - Omitted (`undefined`): Existing explicit polyfills are preserved. For library targets where `polyfills` is undefined, Zone.js is injected if installed, accompanied by a deprecation warning advising users to configure the `zoneless` option. Fixes #33324
1 parent 5b7f0a5 commit 3ebcbbf

8 files changed

Lines changed: 249 additions & 48 deletions

File tree

‎packages/angular/build/src/builders/unit-test/builder.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ export async function* execute(
295295
buildOptions: runnerBuildOptions,
296296
virtualFiles,
297297
testEntryPointMappings,
298-
} = await runner.getBuildOptions(normalizedOptions, buildTargetOptions));
298+
} = await runner.getBuildOptions(normalizedOptions, buildTargetOptions, context.logger));
299299
} catch (e) {
300300
assertIsError(e);
301301
context.logger.error(

‎packages/angular/build/src/builders/unit-test/options.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export async function normalizeOptions(
7777
runnerConfig,
7878
isolate,
7979
splitting = true,
80+
zoneless,
8081
} = options;
8182

8283
if (ui && runner !== Runner.Vitest) {
@@ -87,6 +88,10 @@ export async function normalizeOptions(
8788
throw new Error('The "isolate" option is only available for the "vitest" runner.');
8889
}
8990

91+
if (zoneless !== undefined && (runner ?? Runner.Vitest) !== Runner.Vitest) {
92+
throw new Error('The "zoneless" option is only available for the "vitest" runner.');
93+
}
94+
9095
const [width, height] = browserViewport?.split('x').map(Number) ?? [];
9196

9297
let tsConfig = options.tsConfig;
@@ -164,6 +169,7 @@ export async function normalizeOptions(
164169
? true
165170
: path.resolve(workspaceRoot, runnerConfig)
166171
: runnerConfig,
172+
zoneless,
167173
};
168174
}
169175

‎packages/angular/build/src/builders/unit-test/runners/api.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface TestRunner {
6363
getBuildOptions(
6464
options: NormalizedUnitTestBuilderOptions,
6565
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
66+
logger: BuilderContext['logger'],
6667
): RunnerOptions | Promise<RunnerOptions>;
6768

6869
/**

‎packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts‎

Lines changed: 60 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111
* Provides Vitest-specific build options and virtual file contents for Angular unit testing.
1212
*/
1313

14+
import type { BuilderContext } from '@angular-devkit/architect';
1415
import path from 'node:path';
1516
import { toPosixPath } from '../../../../utils/path';
1617
import { createProjectResolver } from '../../../../utils/resolve-project';
1718
import type { ApplicationBuilderInternalOptions } from '../../../application/options';
1819
import { OutputHashing } from '../../../application/schema';
19-
import { NormalizedUnitTestBuilderOptions } from '../../options';
20+
import type { NormalizedUnitTestBuilderOptions } from '../../options';
2021
import { findTests, getTestEntrypoints } from '../../test-discovery';
2122
import { RunnerOptions } from '../api';
2223

@@ -26,14 +27,12 @@ import { RunnerOptions } from '../api';
2627
* @param providersFile Optional path to a file that exports default providers.
2728
* @param projectSourceRoot The root directory of the project source.
2829
* @param teardown Whether to configure TestBed to destroy after each test.
29-
* @param zoneTestingStrategy How zone.js should be loaded during initialization.
3030
* @returns The string content of the virtual initialization file.
3131
*/
3232
function createTestBedInitVirtualFile(
3333
providersFile: string | undefined,
3434
projectSourceRoot: string,
3535
teardown: boolean,
36-
zoneTestingStrategy: 'none' | 'static' | 'dynamic' | 'dynamic-zone',
3736
hasLocalize: boolean,
3837
): string {
3938
let providersImport = 'const providers = [];';
@@ -44,21 +43,6 @@ function createTestBedInitVirtualFile(
4443
providersImport = `import providers from './${importPath}';`;
4544
}
4645

47-
let zoneTestingSnippet = '';
48-
if (zoneTestingStrategy === 'static') {
49-
zoneTestingSnippet = `import 'zone.js/testing';`;
50-
} else if (zoneTestingStrategy === 'dynamic') {
51-
zoneTestingSnippet = `if (typeof Zone !== 'undefined') {
52-
// 'zone.js/testing' is used to initialize the ZoneJS testing environment.
53-
// It must be imported dynamically to avoid a static dependency on 'zone.js'.
54-
await import('zone.js/testing');
55-
}`;
56-
} else if (zoneTestingStrategy === 'dynamic-zone') {
57-
zoneTestingSnippet = `
58-
await import('zone.js');
59-
await import('zone.js/testing');`;
60-
}
61-
6246
// The DynamicDOMTestComponentRenderer is used to avoid stale document references
6347
// when running Vitest in non-isolated mode with JSDOM. It looks up the
6448
// document dynamically on every operation instead of caching it.
@@ -72,8 +56,6 @@ function createTestBedInitVirtualFile(
7256
import { afterEach, beforeEach } from 'vitest';
7357
${providersImport}
7458
75-
${zoneTestingSnippet}
76-
7759
// The beforeEach and afterEach hooks are registered outside the globalThis guard.
7860
// This ensures that the hooks are always applied, even in non-isolated browser environments.
7961
// Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/srcs/test_hooks.ts#L21-L29
@@ -108,7 +90,6 @@ function createTestBedInitVirtualFile(
10890
const ANGULAR_TESTBED_SETUP = Symbol.for('@angular/cli/testbed-setup');
10991
if (!globalThis[ANGULAR_TESTBED_SETUP]) {
11092
globalThis[ANGULAR_TESTBED_SETUP] = true;
111-
11293
// The Angular TestBed needs to be initialized before any tests are run.
11394
// In a non-isolated environment, this setup file can be executed multiple times.
11495
// The guard condition above ensures that the setup is only performed once.
@@ -150,37 +131,67 @@ function adjustOutputHashing(hashing?: OutputHashing): OutputHashing {
150131
}
151132

152133
/**
153-
* Resolves the Zone.js testing strategy by inspecting polyfills and resolving zone.js package.
134+
* Injects Zone.js and Zone.js testing polyfills into the build options based on the
135+
* project configuration, polyfills, and the `zoneless` option.
154136
*
155-
* @param buildOptions The partial application builder options.
137+
* @param unitTestOptions The normalized unit test builder options.
138+
* @param baseBuildOptions The partial application builder options.
156139
* @param projectSourceRoot The root directory of the project source.
157-
* @returns The resolved zone testing strategy ('none', 'static', 'dynamic', 'dynamic-zone').
140+
* @param logger The logger instance for reporting deprecation warnings.
141+
* @returns An array of polyfill specifiers to use for testing.
158142
*/
159-
function getZoneTestingStrategy(
160-
buildOptions: Partial<ApplicationBuilderInternalOptions>,
143+
function injectZoneJsTestingPolyfills(
144+
unitTestOptions: NormalizedUnitTestBuilderOptions,
145+
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
161146
projectSourceRoot: string,
162-
): 'none' | 'static' | 'dynamic' | 'dynamic-zone' {
163-
if (buildOptions.polyfills?.includes('zone.js/testing')) {
164-
return 'none';
147+
logger: BuilderContext['logger'],
148+
): string[] {
149+
const { polyfills } = baseBuildOptions;
150+
const { zoneless } = unitTestOptions;
151+
152+
if (zoneless) {
153+
// Tests have been marked as zoneless.
154+
return polyfills ? polyfills.filter((polyfill) => !polyfill.startsWith('zone.js')) : [];
165155
}
166156

167-
if (buildOptions.polyfills?.includes('zone.js')) {
168-
return 'static';
157+
if (zoneless === false) {
158+
// Tests have been marked as zone.js dependent.
159+
const polyfillsSet = new Set(polyfills ?? []);
160+
polyfillsSet.add('zone.js');
161+
polyfillsSet.add('zone.js/testing');
162+
163+
return [...polyfillsSet];
164+
}
165+
166+
// If polyfills is defined, use it directly.
167+
if (polyfills !== undefined) {
168+
const polyfillsSet = new Set(polyfills);
169+
if (polyfillsSet.has('zone.js/testing')) {
170+
return polyfills;
171+
}
172+
173+
if (polyfillsSet.has('zone.js')) {
174+
return [...polyfills, 'zone.js/testing'];
175+
}
176+
177+
// Explicit polyfills were provided without zone.js (e.g. zoneless application).
178+
return polyfills;
169179
}
170180

181+
// If polyfills is undefined (e.g. library build target), attempt to load zone.js if installed.
171182
try {
172183
const projectResolve = createProjectResolver(projectSourceRoot);
173184
projectResolve('zone.js');
174185

175-
// If polyfills is undefined (e.g. library build target), load zone.js dynamically.
176-
// If polyfills is defined but doesn't include zone.js (e.g. zoneless application), do NOT load zone.js.
177-
if (buildOptions.polyfills === undefined) {
178-
return 'dynamic-zone';
179-
}
186+
logger.warn(
187+
'Zone.js polyfills are being automatically injected because "zone.js" was detected in the project dependencies. ' +
188+
'This behavior is deprecated. If your project is zoneless, set the "zoneless" option to true in the ' +
189+
'test configuration. Otherwise, set the "zoneless" option to false or explicitly add "zone.js" to the "polyfills" option.',
190+
);
180191

181-
return 'dynamic';
192+
return ['zone.js', 'zone.js/testing'];
182193
} catch {
183-
return 'none';
194+
return [];
184195
}
185196
}
186197

@@ -192,11 +203,13 @@ function getZoneTestingStrategy(
192203
*
193204
* @param options The normalized unit test builder options.
194205
* @param baseBuildOptions The base build config to derive testing config from.
206+
* @param logger The logger instance for reporting deprecation warnings.
195207
* @returns An async RunnerOptions configuration.
196208
*/
197209
export async function getVitestBuildOptions(
198210
options: NormalizedUnitTestBuilderOptions,
199211
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
212+
logger: BuilderContext['logger'],
200213
): Promise<RunnerOptions> {
201214
const {
202215
workspaceRoot,
@@ -254,9 +267,18 @@ export async function getVitestBuildOptions(
254267
externalDependencies.push(...baseBuildOptions.externalDependencies);
255268
}
256269

270+
// Inject the zone.js testing polyfill if Zone.js is installed.
271+
const polyfills = injectZoneJsTestingPolyfills(
272+
options,
273+
baseBuildOptions,
274+
projectSourceRoot,
275+
logger,
276+
);
277+
257278
const buildOptions: Partial<ApplicationBuilderInternalOptions> = {
258279
...baseBuildOptions,
259280
watch,
281+
polyfills,
260282
incrementalResults: watch,
261283
index: false,
262284
browser: undefined,
@@ -284,9 +306,6 @@ export async function getVitestBuildOptions(
284306
externalDependencies,
285307
};
286308

287-
// Inject the zone.js testing polyfill if Zone.js is installed.
288-
const zoneTestingStrategy = getZoneTestingStrategy(buildOptions, projectSourceRoot);
289-
290309
let hasLocalize = false;
291310
try {
292311
const projectResolve = createProjectResolver(projectSourceRoot);
@@ -298,7 +317,6 @@ export async function getVitestBuildOptions(
298317
providersFile,
299318
projectSourceRoot,
300319
!options.debug,
301-
zoneTestingStrategy,
302320
hasLocalize,
303321
);
304322

‎packages/angular/build/src/builders/unit-test/runners/vitest/index.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import assert from 'node:assert';
1010
import type { TestRunner } from '../api';
1111
import { DependencyChecker } from '../dependency-checker';
12-
import { normalizeBrowserName } from './browser-provider';
1312
import { getVitestBuildOptions } from './build-options';
1413
import { VitestExecutor } from './executor';
1514

@@ -60,8 +59,8 @@ const VitestTestRunner: TestRunner = {
6059
checker.report();
6160
},
6261

63-
getBuildOptions(options, baseBuildOptions) {
64-
return getVitestBuildOptions(options, baseBuildOptions);
62+
getBuildOptions(options, baseBuildOptions, logger) {
63+
return getVitestBuildOptions(options, baseBuildOptions, logger);
6564
},
6665

6766
async createExecutor(context, options, testEntryPointMappings) {

‎packages/angular/build/src/builders/unit-test/schema.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,10 @@
265265
"description": "Specifies the path to a TypeScript file that provides an array of Angular providers for the test environment. The file must contain a default export of the provider array.",
266266
"minLength": 1
267267
},
268+
"zoneless": {
269+
"type": "boolean",
270+
"description": "Specifies whether to execute tests in zoneless mode. When set to true, Zone.js polyfills are excluded and Zone.js is not loaded. When set to false, Zone.js and its testing support are explicitly loaded. When omitted, Zone.js is loaded based on the build target polyfills. This option is only available for the Vitest runner."
271+
},
268272
"setupFiles": {
269273
"type": "array",
270274
"items": {

0 commit comments

Comments
 (0)