Skip to content

fix(vite): downlevel async/await for zone.js like the Angular CLI - #642

Merged
Brooooooklyn merged 8 commits into
voidzero-dev:mainfrom
edbzn:fix/vite-lower-async
Oct 27, 2026
Merged

Brooooooklyn merged 8 commits into
voidzero-dev:mainfrom
edbzn:fix/vite-lower-async

Conversation

@edbzn

@edbzn edbzn commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #641.

Builds on #607 (transformForBrowser, merged).

Problem

@oxc-angular/vite leaves async/await native. zone.js can't track a native await, so fakeAsync with tick()/flushMicrotasks() can't drive code that awaits, and change detection after an await doesn't run as a zone task. The Angular CLI downlevels async to generators when zone.js is in the polyfills (esbuild supported: { 'async-await': false }, getFeatureSupport in @angular/build).

Fix

A new sub-plugin, @oxc-angular/vite-downlevel-async, runs #607's transformForBrowser with downlevelAsync: true on application scripts that contain async (not node_modules).

  • Why feat(napi): transformForBrowser - TypeScript with Angular's settings, async lowered for zone.js #607 and not a config hook: Vite 8 transforms with Oxc. Oxc's target downlevels by ES version, with no per-feature switch like esbuild's supported. The Angular CLI's own Vite dev server hits the same limit and falls back to es2016 for dependencies. That would also downlevel spread, optional chaining and class fields. esbuild.supported needs esbuild, and esbuild's inline var __async helper breaks under vi.mock hoisting (see vite: async/await is not lowered for zone.js, so fakeAsync/tick cannot control awaits #641).
  • Order: the plugin is enforce: 'post', with a normal-order transform hook. So it runs:
    • after TypeScript is stripped. It passes that code as JavaScript, so only async is downleveled and class fields are left alone;
    • after Vitest hoists vi.mock(). If the downleveling ran first, an async mock factory would become an eager _asyncToGenerator(...) call, and hoisting would move it above its helper's import: Cannot access '__vi_import_1__' before initialization;
    • before vite:import-analysis, which resolves the helper imports for the dev server. A hook-level order: 'post' would run after it, and the browser would get a bare @oxc-project/runtime/... import.
  • Module mode: the code is named .mjs (.jsx for JSX files), because Vite serves it as a module. As .js, a file without import/export would get its helpers through require().
  • Helpers are imported from @oxc-project/runtime. Vite 8 resolves them without the package being installed: its builtin oxc-runtime plugin covers dev and Vitest, and Rolldown inlines them in builds. No new dependency.
  • When: the existing zoneless option was unused. It now controls the downleveling. When it is unset, an app is zoneless unless node_modules/zone.js exists above the Vite root. That replaces the CLI's signal, zone.js in the polyfills, which a Vite app doesn't declare.
  • Not covered:

Checks

  • New test/downlevel-async.test.ts (9 tests):
    • async is downleveled;
    • a class field stays a field;
    • a file without import/export gets imported helpers, not require();
    • an error is reported by its message;
    • a dev-server request (transformRequest) gets the helper import resolved by import analysis;
    • async stays native when zoneless, and in SSR transforms;
    • the default follows whether zone.js is installed;
    • a nested Vitest run in a client environment, with an async vi.mock factory. It fails if the hook runs before the hoisting.
  • Checked by hand, not committed because zone.js isn't a dependency of this package: the fakeAsync + flushMicrotasks() repro from vite: async/await is not lowered for zone.js, so fakeAsync/tick cannot control awaits #641 under Vitest with zone.js 0.16.3. It passes with the downleveling. With zoneless: true it fails with expected "vi.fn()" to be called at least once.
  • Checked by hand: a Vite dev server in headless Chromium, with an async module and a file without import/export. Both run in zone and zoneless modes.
  • Checked by hand: a vite build with async code. The helpers are inlined from Rolldown's runtime and no async is left. A zoneless build keeps it native.
  • On the branch (merged with main):
    • vitest run --dir ./test: 21 files, 397 tests pass.
    • tsc -p tsconfig.json --noEmit is clean.
    • oxfmt --check and oxlint --type-aware --type-check are clean on the changed files.

🤖 Generated with Claude Code

Rush and others added 5 commits October 21, 2026 20:13
zone.js cannot track native `await`, so `fakeAsync` with `tick()` or
`flushMicrotasks()` could not drive code that awaits. Unless the app is
zoneless, lower `async`, async generators and `for await` in application
code with `transformForBrowser`, as the Angular CLI does for apps with
zone.js in their polyfills. `zoneless` defaults to whether zone.js is
installed; server transforms stay native.

The transform runs after Vitest hoists `vi.mock()`, so an async mock
factory, now an eager `_asyncToGenerator(...)` call, still finds its
helper import.

Fixes voidzero-dev#641

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Resolves the conflict with main's vendorSourceMap option next to the removed zoneless default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread napi/angular-compiler/vite-plugin/angular-async-downlevel-plugin.ts
Brooooooklyn and others added 2 commits October 27, 2026 17:03
# Conflicts:
#	crates/oxc_angular_compiler/src/browser.rs
#	napi/angular-compiler/index.d.ts
#	napi/angular-compiler/src/lib.rs
#	napi/angular-compiler/test/transform-for-browser.test.ts
- A hook-level `order: 'post'` runs after `vite:import-analysis`, so the
  dev server sent the bare `@oxc-project/runtime/helpers/...` import to
  the browser and the app failed to load. A plugin-level `enforce: 'post'`
  still runs after the TypeScript strip and Vitest's `vi.mock` hoisting,
  but before import analysis.
- Name the code `.mjs` rather than `.js`: as `.js`, a file without
  `import`/`export` was parsed as a script and got `require()` helpers.
- Report lowering errors by their message instead of `[object Object]`.
- README: Vitest's `node` environment transforms as SSR, so it keeps
  async native.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-27T15:18:31.702381Z 13020eb Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

As suggested in review: `lowerAsync` becomes `downlevelAsync` (not
released yet), the Vite sub-plugin `@oxc-angular/vite-downlevel-async`,
and the docs, tests and files follow.

Also run the dev-server test from the real temp path: on Windows the
temp dir can be an 8.3 short path (`RUNNER~1`), and Vite does not serve
a file whose id differs from the real root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn Brooooooklyn changed the title fix(vite): lower async/await for zone.js like the Angular CLI fix(vite): downlevel async/await for zone.js like the Angular CLI Oct 27, 2026
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@Brooooooklyn
Brooooooklyn merged commit b27594d into voidzero-dev:main Oct 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

vite: async/await is not lowered for zone.js, so fakeAsync/tick cannot control awaits

4 participants