Skip to content

Commit d64f95d

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 d64f95d

6 files changed

Lines changed: 237 additions & 45 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { type BuilderContext, targetFromTargetString } from '@angular-devkit/architect';
10+
import type { logging } from '@angular-devkit/core';
1011
import { constants, promises as fs } from 'node:fs';
1112
import path from 'node:path';
1213
import { normalizeCacheOptions } from '../../utils/normalize-cache';
@@ -77,6 +78,7 @@ export async function normalizeOptions(
7778
runnerConfig,
7879
isolate,
7980
splitting = true,
81+
zoneless,
8082
} = options;
8183

8284
if (ui && runner !== Runner.Vitest) {
@@ -87,6 +89,10 @@ export async function normalizeOptions(
8789
throw new Error('The "isolate" option is only available for the "vitest" runner.');
8890
}
8991

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

9298
let tsConfig = options.tsConfig;
@@ -164,6 +170,8 @@ export async function normalizeOptions(
164170
? true
165171
: path.resolve(workspaceRoot, runnerConfig)
166172
: runnerConfig,
173+
zoneless,
174+
logger: context.logger,
167175
};
168176
}
169177

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

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { toPosixPath } from '../../../../utils/path';
1616
import { createProjectResolver } from '../../../../utils/resolve-project';
1717
import type { ApplicationBuilderInternalOptions } from '../../../application/options';
1818
import { OutputHashing } from '../../../application/schema';
19-
import { NormalizedUnitTestBuilderOptions } from '../../options';
19+
import type { NormalizedUnitTestBuilderOptions } from '../../options';
2020
import { findTests, getTestEntrypoints } from '../../test-discovery';
2121
import { RunnerOptions } from '../api';
2222

@@ -26,14 +26,12 @@ import { RunnerOptions } from '../api';
2626
* @param providersFile Optional path to a file that exports default providers.
2727
* @param projectSourceRoot The root directory of the project source.
2828
* @param teardown Whether to configure TestBed to destroy after each test.
29-
* @param zoneTestingStrategy How zone.js should be loaded during initialization.
3029
* @returns The string content of the virtual initialization file.
3130
*/
3231
function createTestBedInitVirtualFile(
3332
providersFile: string | undefined,
3433
projectSourceRoot: string,
3534
teardown: boolean,
36-
zoneTestingStrategy: 'none' | 'static' | 'dynamic' | 'dynamic-zone',
3735
hasLocalize: boolean,
3836
): string {
3937
let providersImport = 'const providers = [];';
@@ -44,21 +42,6 @@ function createTestBedInitVirtualFile(
4442
providersImport = `import providers from './${importPath}';`;
4543
}
4644

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-
6245
// The DynamicDOMTestComponentRenderer is used to avoid stale document references
6346
// when running Vitest in non-isolated mode with JSDOM. It looks up the
6447
// document dynamically on every operation instead of caching it.
@@ -72,8 +55,6 @@ function createTestBedInitVirtualFile(
7255
import { afterEach, beforeEach } from 'vitest';
7356
${providersImport}
7457
75-
${zoneTestingSnippet}
76-
7758
// The beforeEach and afterEach hooks are registered outside the globalThis guard.
7859
// This ensures that the hooks are always applied, even in non-isolated browser environments.
7960
// Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/srcs/test_hooks.ts#L21-L29
@@ -108,7 +89,6 @@ function createTestBedInitVirtualFile(
10889
const ANGULAR_TESTBED_SETUP = Symbol.for('@angular/cli/testbed-setup');
10990
if (!globalThis[ANGULAR_TESTBED_SETUP]) {
11091
globalThis[ANGULAR_TESTBED_SETUP] = true;
111-
11292
// The Angular TestBed needs to be initialized before any tests are run.
11393
// In a non-isolated environment, this setup file can be executed multiple times.
11494
// The guard condition above ensures that the setup is only performed once.
@@ -150,37 +130,65 @@ function adjustOutputHashing(hashing?: OutputHashing): OutputHashing {
150130
}
151131

152132
/**
153-
* Resolves the Zone.js testing strategy by inspecting polyfills and resolving zone.js package.
133+
* Injects Zone.js and Zone.js testing polyfills into the build options based on the
134+
* project configuration, polyfills, and the `zoneless` option.
154135
*
155-
* @param buildOptions The partial application builder options.
136+
* @param unitTestOptions The normalized unit test builder options.
137+
* @param baseBuildOptions The partial application builder options.
156138
* @param projectSourceRoot The root directory of the project source.
157-
* @returns The resolved zone testing strategy ('none', 'static', 'dynamic', 'dynamic-zone').
139+
* @returns An array of polyfill specifiers to use for testing.
158140
*/
159-
function getZoneTestingStrategy(
160-
buildOptions: Partial<ApplicationBuilderInternalOptions>,
141+
function injectZoneJsTestingPolyfills(
142+
unitTestOptions: NormalizedUnitTestBuilderOptions,
143+
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
161144
projectSourceRoot: string,
162-
): 'none' | 'static' | 'dynamic' | 'dynamic-zone' {
163-
if (buildOptions.polyfills?.includes('zone.js/testing')) {
164-
return 'none';
145+
): string[] {
146+
const { polyfills } = baseBuildOptions;
147+
const { zoneless, logger } = unitTestOptions;
148+
149+
if (zoneless) {
150+
// Tests have been marked as zoneless.
151+
return polyfills ? polyfills.filter((polyfill) => !polyfill.startsWith('zone.js')) : [];
165152
}
166153

167-
if (buildOptions.polyfills?.includes('zone.js')) {
168-
return 'static';
154+
if (zoneless === false) {
155+
// Tests have been marked as zone.js dependent.
156+
const polyfillsSet = new Set(polyfills ?? []);
157+
polyfillsSet.add('zone.js');
158+
polyfillsSet.add('zone.js/testing');
159+
160+
return [...polyfillsSet];
169161
}
170162

163+
// If polyfills is defined, use it directly.
164+
if (polyfills !== undefined) {
165+
const polyfillsSet = new Set(polyfills);
166+
if (polyfillsSet.has('zone.js/testing')) {
167+
return polyfills;
168+
}
169+
170+
if (polyfillsSet.has('zone.js')) {
171+
return [...polyfills, 'zone.js/testing'];
172+
}
173+
174+
// Explicit polyfills were provided without zone.js (e.g. zoneless application).
175+
return polyfills;
176+
}
177+
178+
// If polyfills is undefined (e.g. library build target), attempt to load zone.js if installed.
171179
try {
172180
const projectResolve = createProjectResolver(projectSourceRoot);
173181
projectResolve('zone.js');
174182

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-
}
183+
logger?.warn(
184+
'Zone.js polyfills are being automatically injected because "zone.js" was detected in the project dependencies. ' +
185+
'This behavior is deprecated. If your project is zoneless, set the "zoneless" option to true in the ' +
186+
'test configuration. Otherwise, set the "zoneless" option to false or explicitly add "zone.js" to the "polyfills" option.',
187+
);
180188

181-
return 'dynamic';
189+
return ['zone.js', 'zone.js/testing'];
182190
} catch {
183-
return 'none';
191+
return [];
184192
}
185193
}
186194

@@ -254,9 +262,13 @@ export async function getVitestBuildOptions(
254262
externalDependencies.push(...baseBuildOptions.externalDependencies);
255263
}
256264

265+
// Inject the zone.js testing polyfill if Zone.js is installed.
266+
const polyfills = injectZoneJsTestingPolyfills(options, baseBuildOptions, projectSourceRoot);
267+
257268
const buildOptions: Partial<ApplicationBuilderInternalOptions> = {
258269
...baseBuildOptions,
259270
watch,
271+
polyfills,
260272
incrementalResults: watch,
261273
index: false,
262274
browser: undefined,
@@ -284,9 +296,6 @@ export async function getVitestBuildOptions(
284296
externalDependencies,
285297
};
286298

287-
// Inject the zone.js testing polyfill if Zone.js is installed.
288-
const zoneTestingStrategy = getZoneTestingStrategy(buildOptions, projectSourceRoot);
289-
290299
let hasLocalize = false;
291300
try {
292301
const projectResolve = createProjectResolver(projectSourceRoot);
@@ -298,7 +307,6 @@ export async function getVitestBuildOptions(
298307
providersFile,
299308
projectSourceRoot,
300309
!options.debug,
301-
zoneTestingStrategy,
302310
hasLocalize,
303311
);
304312

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

Lines changed: 0 additions & 1 deletion
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

‎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": {

‎packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts‎

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
describeBuilder,
55
UNIT_TEST_BUILDER_INFO,
66
setupApplicationTarget,
7+
expectLog,
8+
expectNoLog,
79
} from '../setup';
810

911
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
@@ -68,7 +70,61 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
6870
expect(result?.success).toBe(true);
6971
});
7072

71-
it('should load Zone and Zone testing support when testing a library and zone.js is installed', async () => {
73+
it('should NOT load Zone when zoneless is true even if zone.js is in polyfills', async () => {
74+
setupApplicationTarget(harness, {
75+
polyfills: ['zone.js'],
76+
});
77+
78+
harness.useTarget('test', {
79+
...BASE_OPTIONS,
80+
zoneless: true,
81+
});
82+
83+
harness.writeFile(
84+
'src/app/app.component.spec.ts',
85+
`
86+
import { describe, it, expect } from 'vitest';
87+
88+
describe('Zoneless Override Test', () => {
89+
it('should NOT have Zone defined', () => {
90+
expect((globalThis as any).Zone).toBeUndefined();
91+
});
92+
});
93+
`,
94+
);
95+
96+
const { result } = await harness.executeOnce();
97+
expect(result?.success).toBeTrue();
98+
});
99+
100+
it('should load Zone when zoneless is false even if polyfills is empty', async () => {
101+
setupApplicationTarget(harness, {
102+
polyfills: [],
103+
});
104+
105+
harness.useTarget('test', {
106+
...BASE_OPTIONS,
107+
zoneless: false,
108+
});
109+
110+
harness.writeFile(
111+
'src/app/app.component.spec.ts',
112+
`
113+
import { describe, it, expect } from 'vitest';
114+
115+
describe('Zone Forced Test', () => {
116+
it('should have Zone defined', () => {
117+
expect((globalThis as any).Zone).toBeDefined();
118+
});
119+
});
120+
`,
121+
);
122+
123+
const { result } = await harness.executeOnce();
124+
expect(result?.success).toBeTrue();
125+
});
126+
127+
it('should load Zone and emit a deprecation warning when testing a library and zone.js is installed', async () => {
72128
harness.withBuilderTarget(
73129
'build',
74130
async () => ({ success: true }),
@@ -107,8 +163,54 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
107163
`,
108164
);
109165

110-
const { result } = await harness.executeOnce();
166+
const { result, logs } = await harness.executeOnce();
167+
expect(result?.success).toBeTrue();
168+
expectLog(logs, /Zone\.js polyfills are being automatically injected/);
169+
});
170+
171+
it('should NOT load Zone and not emit warning when testing a library with zoneless: true', async () => {
172+
harness.withBuilderTarget(
173+
'build',
174+
async () => ({ success: true }),
175+
{
176+
project: 'ng-package.json',
177+
},
178+
{
179+
builderName: '@angular/build:ng-packagr',
180+
},
181+
);
182+
183+
await harness.writeFile(
184+
'ng-package.json',
185+
JSON.stringify({
186+
lib: {
187+
entryFile: 'src/public-api.ts',
188+
},
189+
}),
190+
);
191+
192+
harness.useTarget('test', {
193+
...BASE_OPTIONS,
194+
zoneless: true,
195+
include: ['src/app.component.spec.ts'],
196+
});
197+
198+
await harness.writeFile(
199+
'src/app.component.spec.ts',
200+
`
201+
import { describe, it, expect } from 'vitest';
202+
203+
describe('Library Zoneless Test', () => {
204+
it('should NOT have Zone defined', () => {
205+
expect((globalThis as any).Zone).toBeUndefined();
206+
});
207+
});
208+
`,
209+
);
210+
211+
const { result, logs } = await harness.executeOnce();
111212
expect(result?.success).toBeTrue();
213+
expectNoLog(logs, /Zone\.js polyfills are being automatically injected/);
112214
});
113215
});
114216
});

0 commit comments

Comments
 (0)