Skip to content

Commit d6f37b9

Browse files
committed
fix(@angular/build): allow top-level await in the unit test bundle
The Vitest runner generates a virtual `angular:test-bed-init` module for the test bundle. When Zone.js is resolvable but is not one of the project's polyfills, that module imports `zone.js/testing` behind a top-level await, so that the import is skipped by a project which never loads Zone.js. That bundle is compiled against the targets derived from the project's Browserslist configuration, and esbuild has no downleveled form for top-level await. A project which targets a browser released before the syntax was available therefore fails to build its tests with `Top-level await is not available in the configured target environment`, before the runtime guard in the generated module can run. Browserslist describes the browsers an application is deployed to. A test bundle is never deployed: it is loaded by the test runner's module runner, or by a browser the runner launches, and every browser Angular supports has had top-level await since 2021. The unit-test builder now marks the feature as supported through an internal option, which applies to the test bundle alone and leaves application builds bound to their configured targets. Related to #33324
1 parent 5b7f0a5 commit d6f37b9

5 files changed

Lines changed: 78 additions & 2 deletions

File tree

‎packages/angular/build/src/builders/application/options.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,23 @@ interface InternalOptions {
139139
*/
140140
disableCodeSplitting?: boolean;
141141

142+
/**
143+
* Marks top-level await as supported by the browser code bundle regardless of the browsers the
144+
* project targets.
145+
*
146+
* The test bundle contains a generated TestBed initializer that imports `zone.js/testing` behind
147+
* a top-level await so that the import is skipped when Zone.js is not loaded. esbuild rejects the
148+
* syntax outright when the project's Browserslist configuration resolves to a browser released
149+
* before top-level await was available, so the build fails before the guard can run.
150+
*
151+
* That configuration describes the browsers an application is deployed to. A test bundle is only
152+
* ever loaded by the test runner's module runner or by a browser the runner launches, and every
153+
* browser Angular supports has had top-level await since 2021.
154+
*
155+
* Used exclusively for tests and shouldn't be used for other kinds of builds.
156+
*/
157+
supportTopLevelAwait?: boolean;
158+
142159
/**
143160
* An array of files to restrict the TypeScript compilation root names to.
144161
*/
@@ -463,6 +480,7 @@ export async function normalizeOptions(
463480
externalRuntimeStyles,
464481
instrumentForCoverage,
465482
disableCodeSplitting,
483+
supportTopLevelAwait,
466484
} = options;
467485

468486
// Return all the normalized options
@@ -500,6 +518,7 @@ export async function normalizeOptions(
500518
workspaceRoot,
501519
entryPoints,
502520
disableCodeSplitting,
521+
supportTopLevelAwait,
503522
rootFiles: rootFiles?.map((file: string) => path.resolve(workspaceRoot, file)),
504523
optimizationOptions,
505524
outputOptions,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,9 @@ export async function getVitestBuildOptions(
278278
// live ESM bindings across chunk boundaries. This can cause uninitialized exports or break mocking.
279279
// Disabling code splitting avoids shared chunks, but increases build and coverage memory/time.
280280
disableCodeSplitting: !options.splitting,
281+
// The generated TestBed initializer can import 'zone.js/testing' behind a top-level await.
282+
// The test bundle is loaded by the test runner, not by the browsers the project targets.
283+
supportTopLevelAwait: true,
281284
// Enable support for vitest browser prebundling. Excludes can be controlled with a runnerConfig
282285
// and the `optimizeDeps.exclude` option.
283286
externalPackages: true,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import {
11+
BASE_OPTIONS,
12+
describeBuilder,
13+
UNIT_TEST_BUILDER_INFO,
14+
setupApplicationTarget,
15+
} from '../setup';
16+
17+
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
18+
describe('Behavior: "Vitest top-level await"', () => {
19+
it('builds the test bundle when the project targets browsers without top-level await', async () => {
20+
// Zone.js is not a polyfill but is resolvable as a transitive dependency, so the generated
21+
// TestBed initializer imports 'zone.js/testing' behind a top-level await.
22+
setupApplicationTarget(harness, { polyfills: [] });
23+
24+
await harness.writeFile('.browserslistrc', 'Chrome 88');
25+
26+
harness.useTarget('test', {
27+
...BASE_OPTIONS,
28+
});
29+
30+
await harness.writeFile(
31+
'src/app/app.component.spec.ts',
32+
`
33+
import { describe, it, expect } from 'vitest';
34+
35+
describe('Top-level await', () => {
36+
it('runs', () => {
37+
expect(true).toBe(true);
38+
});
39+
});
40+
`,
41+
);
42+
43+
const { result } = await harness.executeOnce();
44+
expect(result?.success).toBeTrue();
45+
});
46+
});
47+
});

‎packages/angular/build/src/tools/esbuild/application-code-bundle.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function createBrowserCodeBundleOptions(
6767
entryNames: outputNames.bundles,
6868
entryPoints,
6969
target,
70-
supported: getFeatureSupport(zoneless),
70+
supported: getFeatureSupport(zoneless, options.supportTopLevelAwait),
7171
};
7272

7373
if (options.disableCodeSplitting) {

‎packages/angular/build/src/tools/esbuild/utils.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,17 @@ export async function withNoProgress<T>(text: string, action: () => T | Promise<
194194
* Generates a syntax feature object map for Angular applications.
195195
* A full set of feature names can be found here: https://esbuild.github.io/api/#supported
196196
* @param nativeAsyncAwait Indicate whether to support native async/await.
197+
* @param topLevelAwait Indicate whether to support top-level await regardless of the target.
197198
* @returns An object that can be used with the esbuild build `supported` option.
198199
*/
199-
export function getFeatureSupport(nativeAsyncAwait: boolean): BuildOptions['supported'] {
200+
export function getFeatureSupport(
201+
nativeAsyncAwait: boolean,
202+
topLevelAwait = false,
203+
): BuildOptions['supported'] {
200204
return {
205+
// Top-level await has no downleveled form, so esbuild rejects it outright for a target without
206+
// it. Only enabled for bundles that are not deployed to the browsers a project targets.
207+
...(topLevelAwait ? { 'top-level-await': true } : {}),
201208
// Native async/await is not supported with Zone.js. Disabling support here will cause
202209
// esbuild to downlevel async/await, async generators, and for await...of to a Zone.js supported form.
203210
'async-await': nativeAsyncAwait,

0 commit comments

Comments
 (0)