Skip to content

fix(@angular/build): allow top-level await in the unit test bundle - #34147

Closed
thekhegay wants to merge 1 commit into
angular:mainfrom
thekhegay:fix/unit-test-tla
Closed

thekhegay wants to merge 1 commit into
angular:mainfrom
thekhegay:fix/unit-test-tla

Conversation

@thekhegay

Copy link
Copy Markdown
Contributor

PR Checklist

Please check to confirm your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • Other... Please describe:

What is the current behavior?

Issue Number: #33324

The Vitest runner generates a virtual angular:test-bed-init module. 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 the import is skipped by a project that never loads Zone.js.

The test 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 that 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.

What is the new behavior?

The unit-test builder marks top-level await as supported for the test bundle, through an internal option that applies to the browser code bundle of that build alone.

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. Top-level await has been available in Chrome and Edge 89, Firefox 89, and Safari and iOS 15 since 2021, so it is supported by every browser within Angular's support window, browser mode included. Application builds keep their configured targets and still reject the syntax for a target that lacks it.

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

Regression test: packages/angular/build/src/builders/unit-test/tests/behavior/vitest-top-level-await_spec.ts. It fails on main with the esbuild error above and passes with this change.

The exact targets in the issue — chrome145.0, ios17.0, safari17.0 — build on main: all three support the syntax in the pinned esbuild 0.28.2 (and in 0.28.0, which 22.0.0 shipped), so the regression test reaches the same code path through an older Browserslist target instead.

One shape this does not cover, in case someone reports it on the same issue: when the project's polyfills are a local file, isZonelessApp() returns false, async-await is set to false, and esbuild then rejects top-level await for every target — the error reads + 6 overrides rather than + 2. No supported entry helps there, since esbuild has no downleveled form to fall back to; that one needs the generated module to stop using the syntax, which changes TestBed initialization ordering rather than build options.

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 angular#33324

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new internal option, supportTopLevelAwait, to allow marking top-level await as supported in browser code bundles regardless of the target browsers. This is primarily used for test bundles (such as those run by Vitest) where the test runner itself supports top-level await, preventing esbuild from failing the build when targeting older browsers. The option is integrated into the build options normalization, the Vitest runner, and the esbuild feature support utility, and is accompanied by a new behavior test. I have no feedback to provide as there are no review comments.

@alan-agius4

alan-agius4 commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Thank you for looking into this issue and opening the PR.

However, the approach of enabling top level await in the bundle options is not the right direction. Native async/await cannot be used when zone.js is loaded because native async/await microtasks bypass Zone.js execution context tracking. To support Zone.js, esbuild must downlevel async/await. Because top level await cannot be downleveled, it is fundamentally incompatible with Zone.js, and esbuild will reject top level await whenever async/await is disabled, regardless of whether top level await is marked as supported.

The actual root cause is in getZoneTestingStrategy().

When an application is zoneless (polyfills is defined, for example [], without zone.js), resolving zone.js in node_modules causes this function to fall through and return 'dynamic'.

In a zoneless application, Zone is never defined at runtime, so this import is never executed. However, the syntactic presence of top level await forces the bundle to require top level await support in esbuild. For zoneless applications, this should likely return 'none' instead.

I do not recall why dynamic was originally added here in the first place. @clydin would have more info on why it was introduced.

Closing this PR in favor of addressing the strategy in getZoneTestingStrategy().

@thekhegay

Copy link
Copy Markdown
Contributor Author

@alan-agius4 thank you. i've investigate a bit.

dynamic branch came in with 414320d (#32492, Feb 2026), "support runtime Zone.js detection in Vitest unit test runner". The commit message gives reason: "dynamic: If zone.js is installed but not explicitly in polyfills. This uses a runtime check and dynamic import to load testing support if Zone is present", motivated by "better supporting zoneless applications and implicit Zone.js loading scenarios": i.e. polyfills: ["src/polyfills.ts"] with import 'zone.js' inside, which the build-time check cannot see. dynamic-zone came later in 61e25fb (#33478, Fixes #33477) for library targets, where polyfills is undefined.

So returning 'none' for a zoneless app fixes the reported case but not other two. Running each polyfills shape against 22.1.8:

  • polyfills: [] - report. 'none' fixes it.
  • polyfills: ["src/polyfills.ts"] that imports zone.js - the case dynamic was added for. With 'none' the build is green and TestBed initializes zoneful without zone.js/testing; today it at least fails loudly.
  • library target - dynamic-zone emits two unconditional top-level awaits, so it fails on a target without TLA either way.

That last one carries no design decision: static imports there are semantically identical and drops await. I can send that as a separate PR if it is useful.

Worth knowing about the coverage: vitest-zone-init_spec.ts has "should NOT load Zone when zoneless (no zone.js in polyfills)" with polyfills: [], but since 0010b92 scoped the resolver to the project root, zone.js is not resolvable from the harness temp dir, so the strategy returns 'none' and no top-level await is emitted. Make zone.js resolvable in that fixture and it reproduces #33324.

@alan-agius4

Copy link
Copy Markdown
Collaborator

Thanks for the additional information now I remember why! I do have an idea how we can address this and will try to open a PR shortly. I will also address the two top-level awaits.

@alan-agius4

Copy link
Copy Markdown
Collaborator

#34150

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants