diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index f7e60f4cc029..9433055e159a 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -139,6 +139,23 @@ interface InternalOptions { */ disableCodeSplitting?: boolean; + /** + * Marks top-level await as supported by the browser code bundle regardless of the browsers the + * project targets. + * + * The test bundle contains a generated TestBed initializer that imports `zone.js/testing` behind + * a top-level await so that the import is skipped when Zone.js is not loaded. esbuild rejects the + * syntax outright when the project's Browserslist configuration resolves to a browser released + * before top-level await was available, so the build fails before the guard can run. + * + * That configuration describes the browsers an application is deployed to. A test bundle is only + * ever 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. + * + * Used exclusively for tests and shouldn't be used for other kinds of builds. + */ + supportTopLevelAwait?: boolean; + /** * An array of files to restrict the TypeScript compilation root names to. */ @@ -463,6 +480,7 @@ export async function normalizeOptions( externalRuntimeStyles, instrumentForCoverage, disableCodeSplitting, + supportTopLevelAwait, } = options; // Return all the normalized options @@ -500,6 +518,7 @@ export async function normalizeOptions( workspaceRoot, entryPoints, disableCodeSplitting, + supportTopLevelAwait, rootFiles: rootFiles?.map((file: string) => path.resolve(workspaceRoot, file)), optimizationOptions, outputOptions, diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts index 622ca7beebd3..67ad9eb7ea2b 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts @@ -278,6 +278,9 @@ export async function getVitestBuildOptions( // live ESM bindings across chunk boundaries. This can cause uninitialized exports or break mocking. // Disabling code splitting avoids shared chunks, but increases build and coverage memory/time. disableCodeSplitting: !options.splitting, + // The generated TestBed initializer can import 'zone.js/testing' behind a top-level await. + // The test bundle is loaded by the test runner, not by the browsers the project targets. + supportTopLevelAwait: true, // Enable support for vitest browser prebundling. Excludes can be controlled with a runnerConfig // and the `optimizeDeps.exclude` option. externalPackages: true, diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-top-level-await_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-top-level-await_spec.ts new file mode 100644 index 000000000000..12669e612be0 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-top-level-await_spec.ts @@ -0,0 +1,47 @@ +/** + * @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 { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "Vitest top-level await"', () => { + it('builds the test bundle when the project targets browsers without top-level await', async () => { + // Zone.js is not a polyfill but is resolvable as a transitive dependency, so the generated + // TestBed initializer imports 'zone.js/testing' behind a top-level await. + setupApplicationTarget(harness, { polyfills: [] }); + + await harness.writeFile('.browserslistrc', 'Chrome 88'); + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + await harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Top-level await', () => { + it('runs', () => { + expect(true).toBe(true); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index af53e04f58af..d4a6d2010c44 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -67,7 +67,7 @@ export function createBrowserCodeBundleOptions( entryNames: outputNames.bundles, entryPoints, target, - supported: getFeatureSupport(zoneless), + supported: getFeatureSupport(zoneless, options.supportTopLevelAwait), }; if (options.disableCodeSplitting) { diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index 3bbaabdd2400..2daac2bdac81 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -194,10 +194,17 @@ export async function withNoProgress(text: string, action: () => T | Promise< * Generates a syntax feature object map for Angular applications. * A full set of feature names can be found here: https://esbuild.github.io/api/#supported * @param nativeAsyncAwait Indicate whether to support native async/await. + * @param topLevelAwait Indicate whether to support top-level await regardless of the target. * @returns An object that can be used with the esbuild build `supported` option. */ -export function getFeatureSupport(nativeAsyncAwait: boolean): BuildOptions['supported'] { +export function getFeatureSupport( + nativeAsyncAwait: boolean, + topLevelAwait = false, +): BuildOptions['supported'] { return { + // Top-level await has no downleveled form, so esbuild rejects it outright for a target without + // it. Only enabled for bundles that are not deployed to the browsers a project targets. + ...(topLevelAwait ? { 'top-level-await': true } : {}), // Native async/await is not supported with Zone.js. Disabling support here will cause // esbuild to downlevel async/await, async generators, and for await...of to a Zone.js supported form. 'async-await': nativeAsyncAwait,