From a1a0cc538487d5463667da9354e1fc6ae40c4ce4 Mon Sep 17 00:00:00 2001 From: Damian Kaczmarek Date: Wed, 21 Oct 2026 20:13:53 -0500 Subject: [PATCH 1/6] feat(napi): transformForBrowser - TypeScript with Angular's settings and async lowered for zone.js --- crates/oxc_angular_compiler/src/browser.rs | 134 +++++++++++++++++++++ crates/oxc_angular_compiler/src/lib.rs | 1 + napi/angular-compiler/src/lib.rs | 81 +++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 crates/oxc_angular_compiler/src/browser.rs diff --git a/crates/oxc_angular_compiler/src/browser.rs b/crates/oxc_angular_compiler/src/browser.rs new file mode 100644 index 000000000..b52ad66d4 --- /dev/null +++ b/crates/oxc_angular_compiler/src/browser.rs @@ -0,0 +1,134 @@ +//! What a bundler without per-feature lowering needs for an Angular app that runs zone.js: TypeScript stripped as +//! `tsc` does it under Angular's settings, and `async`/`await` turned into generators, because zone.js cannot see +//! native `await` (the Angular CLI does the same through esbuild's `supported` option). + +use std::path::{Path, PathBuf}; + +use oxc_allocator::Allocator; +use oxc_codegen::{Codegen, CodegenOptions}; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; +use oxc_transformer::{ + ClassPropertiesOptions, CompilerAssumptions, DecoratorOptions, ES2017Options, ES2018Options, ES2022Options, + EnvOptions, HelperLoaderMode, HelperLoaderOptions, TransformOptions, Transformer, TypeScriptOptions, +}; + +#[derive(Debug, Clone, Default)] +pub struct BrowserTransformOptions { + pub sourcemap: bool, + /// `async` functions and generators, and `for await`, as generators (helpers from `@oxc-project/runtime`) + pub lower_async: bool, +} + +#[derive(Debug, Default)] +pub struct BrowserTransformResult { + pub code: String, + pub map: Option, + pub errors: Vec, +} + +/// TypeScript files get `experimentalDecorators` and `useDefineForClassFields: false` semantics (fields assigned in +/// the constructor, after parameter properties; statics assigned after the class), as Angular's tsconfig has them. +/// JavaScript keeps its class fields. +pub fn transform_for_browser( + allocator: &Allocator, + code: &str, + filename: &str, + options: &BrowserTransformOptions, +) -> BrowserTransformResult { + let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::mjs()); + let parsed = Parser::new(allocator, code, source_type).parse(); + if parsed.fatal_error || parsed.diagnostics.has_errors() { + return BrowserTransformResult { + errors: parsed.diagnostics.errors().map(|e| e.to_string()).collect(), + ..Default::default() + }; + } + let mut program = parsed.program; + let typescript = source_type.is_typescript(); + + let mut env = EnvOptions::default(); + if options.lower_async { + env.es2017 = ES2017Options { async_to_generator: true }; + env.es2018 = ES2018Options { async_generator_functions: true, ..env.es2018 }; + } + if typescript { + env.es2022 = ES2022Options { class_properties: Some(ClassPropertiesOptions { loose: true }), ..env.es2022 }; + } + let transform_options = TransformOptions { + cwd: PathBuf::new(), + typescript: TypeScriptOptions { remove_class_fields_without_initializer: true, ..Default::default() }, + assumptions: CompilerAssumptions { set_public_class_fields: true, ..Default::default() }, + decorator: DecoratorOptions { legacy: typescript, ..Default::default() }, + env, + helper_loader: HelperLoaderOptions { mode: HelperLoaderMode::Runtime, ..Default::default() }, + ..Default::default() + }; + let scoping = SemanticBuilder::new().with_excess_capacity(2.0).build(&program).semantic.into_scoping(); + let transformed = Transformer::new(allocator, Path::new(filename), &transform_options) + .build_with_scoping(scoping, &mut program); + if transformed.diagnostics.has_errors() { + return BrowserTransformResult { + errors: transformed.diagnostics.errors().map(|e| e.to_string()).collect(), + ..Default::default() + }; + } + + let codegen = Codegen::new() + .with_options(CodegenOptions { + source_map_path: options.sourcemap.then(|| PathBuf::from(filename)), + ..CodegenOptions::default() + }) + .with_source_text(code) + .build(&program); + BrowserTransformResult { + code: codegen.code, + map: codegen.map.map(|m| m.to_json_string()), + errors: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(code: &str, filename: &str) -> String { + let allocator = Allocator::default(); + let r = transform_for_browser( + &allocator, + code, + filename, + &BrowserTransformOptions { sourcemap: false, lower_async: true }, + ); + assert!(r.errors.is_empty(), "{:?}", r.errors); + r.code + } + + #[test] + fn lowers_async_and_for_await() { + let out = run("export async function f(xs) { for await (const x of xs) await x; }\nexport async function* g() { yield 1; }", "a.mjs"); + assert!(!out.contains("async function"), "{out}"); + assert!(!out.contains("for await"), "{out}"); + assert!(out.contains("@oxc-project/runtime/helpers/asyncToGenerator"), "{out}"); + } + + #[test] + fn typescript_fields_are_assigned_after_parameter_properties() { + let out = run( + "export class A { b = this.a.x; static s = 1; c: number; constructor(private a: { x: number }) {} }", + "a.ts", + ); + let a = out.find("this.a = a").expect(&out); + let b = out.find("this.b = this.a.x").expect(&out); + assert!(a < b, "{out}"); + assert!(out.contains("A.s = 1"), "{out}"); + assert!(!out.contains("this.c"), "{out}"); + } + + #[test] + fn javascript_keeps_class_fields() { + let out = run("export class A { b = 1; static s = 2; }", "a.mjs"); + assert!(out.contains("b = 1;") && out.contains("static s = 2"), "{out}"); + } +} diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index a34f97c69..009aec7ef 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -28,6 +28,7 @@ mod util; mod validation; pub mod ast; +pub mod browser; pub mod class_debug_info; pub mod class_metadata; pub mod compilation_mode; diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 8e838932f..a0a8ae58e 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -3008,3 +3008,84 @@ pub fn link_angular_package( ) -> AsyncTask { AsyncTask::new(LinkAngularPackageTask { code, filename, options }) } + +// ============================================================================= +// Browser transform (TypeScript and async lowering for zone.js) +// ============================================================================= + +/// Options for `transformForBrowser`. +#[napi(object)] +#[derive(Default)] +pub struct BrowserTransformOptions { + /// Generate a source map (default: false) + pub sourcemap: Option, + /// `async`/`await`, async generators and `for await` as generators, for zone.js (default: true) + pub lower_async: Option, +} + +/// Result of `transformForBrowser`. +#[napi(object)] +pub struct BrowserTransformResult { + pub code: String, + pub map: Option, + pub errors: Vec, +} + +/// TypeScript stripped with Angular's settings (`experimentalDecorators`, `useDefineForClassFields: false`) and +/// `async` lowered to generators, which zone.js needs; JavaScript gets only the lowering. Runtime helpers are +/// imported from `@oxc-project/runtime`. +#[napi] +pub fn transform_for_browser_sync( + code: String, + filename: String, + options: Option, +) -> BrowserTransformResult { + use oxc_angular_compiler::browser::{BrowserTransformOptions as RustOptions, transform_for_browser}; + + let options = options.unwrap_or_default(); + let allocator = Allocator::default(); + let result = transform_for_browser( + &allocator, + &code, + &filename, + &RustOptions { + sourcemap: options.sourcemap.unwrap_or(false), + lower_async: options.lower_async.unwrap_or(true), + }, + ); + BrowserTransformResult { code: result.code, map: result.map, errors: result.errors } +} + +pub struct TransformForBrowserTask { + code: String, + filename: String, + options: Option, +} + +#[napi] +impl Task for TransformForBrowserTask { + type JsValue = BrowserTransformResult; + type Output = BrowserTransformResult; + + fn compute(&mut self) -> napi::Result { + Ok(transform_for_browser_sync( + std::mem::take(&mut self.code), + std::mem::take(&mut self.filename), + self.options.take(), + )) + } + + fn resolve(&mut self, _: napi::Env, result: Self::Output) -> napi::Result { + Ok(result) + } +} + +/// `transformForBrowserSync` on a worker thread +#[napi] +pub fn transform_for_browser( + code: String, + filename: String, + options: Option, +) -> AsyncTask { + AsyncTask::new(TransformForBrowserTask { code, filename, options }) +} From dd27b7f1195670bae119c37a60e327588f67f151 Mon Sep 17 00:00:00 2001 From: Damian Kaczmarek Date: Wed, 21 Oct 2026 20:23:29 -0500 Subject: [PATCH 2/6] style: cargo fmt --- crates/oxc_angular_compiler/src/browser.rs | 28 ++++++++++++++++------ napi/angular-compiler/src/lib.rs | 4 +++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/oxc_angular_compiler/src/browser.rs b/crates/oxc_angular_compiler/src/browser.rs index b52ad66d4..419ca1d3c 100644 --- a/crates/oxc_angular_compiler/src/browser.rs +++ b/crates/oxc_angular_compiler/src/browser.rs @@ -10,8 +10,9 @@ use oxc_parser::Parser; use oxc_semantic::SemanticBuilder; use oxc_span::SourceType; use oxc_transformer::{ - ClassPropertiesOptions, CompilerAssumptions, DecoratorOptions, ES2017Options, ES2018Options, ES2022Options, - EnvOptions, HelperLoaderMode, HelperLoaderOptions, TransformOptions, Transformer, TypeScriptOptions, + ClassPropertiesOptions, CompilerAssumptions, DecoratorOptions, ES2017Options, ES2018Options, + ES2022Options, EnvOptions, HelperLoaderMode, HelperLoaderOptions, TransformOptions, + Transformer, TypeScriptOptions, }; #[derive(Debug, Clone, Default)] @@ -54,18 +55,28 @@ pub fn transform_for_browser( env.es2018 = ES2018Options { async_generator_functions: true, ..env.es2018 }; } if typescript { - env.es2022 = ES2022Options { class_properties: Some(ClassPropertiesOptions { loose: true }), ..env.es2022 }; + env.es2022 = ES2022Options { + class_properties: Some(ClassPropertiesOptions { loose: true }), + ..env.es2022 + }; } let transform_options = TransformOptions { cwd: PathBuf::new(), - typescript: TypeScriptOptions { remove_class_fields_without_initializer: true, ..Default::default() }, + typescript: TypeScriptOptions { + remove_class_fields_without_initializer: true, + ..Default::default() + }, assumptions: CompilerAssumptions { set_public_class_fields: true, ..Default::default() }, decorator: DecoratorOptions { legacy: typescript, ..Default::default() }, env, - helper_loader: HelperLoaderOptions { mode: HelperLoaderMode::Runtime, ..Default::default() }, + helper_loader: HelperLoaderOptions { + mode: HelperLoaderMode::Runtime, + ..Default::default() + }, ..Default::default() }; - let scoping = SemanticBuilder::new().with_excess_capacity(2.0).build(&program).semantic.into_scoping(); + let scoping = + SemanticBuilder::new().with_excess_capacity(2.0).build(&program).semantic.into_scoping(); let transformed = Transformer::new(allocator, Path::new(filename), &transform_options) .build_with_scoping(scoping, &mut program); if transformed.diagnostics.has_errors() { @@ -107,7 +118,10 @@ mod tests { #[test] fn lowers_async_and_for_await() { - let out = run("export async function f(xs) { for await (const x of xs) await x; }\nexport async function* g() { yield 1; }", "a.mjs"); + let out = run( + "export async function f(xs) { for await (const x of xs) await x; }\nexport async function* g() { yield 1; }", + "a.mjs", + ); assert!(!out.contains("async function"), "{out}"); assert!(!out.contains("for await"), "{out}"); assert!(out.contains("@oxc-project/runtime/helpers/asyncToGenerator"), "{out}"); diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index a0a8ae58e..a1f77e107 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -3040,7 +3040,9 @@ pub fn transform_for_browser_sync( filename: String, options: Option, ) -> BrowserTransformResult { - use oxc_angular_compiler::browser::{BrowserTransformOptions as RustOptions, transform_for_browser}; + use oxc_angular_compiler::browser::{ + BrowserTransformOptions as RustOptions, transform_for_browser, + }; let options = options.unwrap_or_default(); let allocator = Allocator::default(); From 6a5b00468028483794cf2397930ad86957532aec Mon Sep 17 00:00:00 2001 From: Damian Kaczmarek Date: Wed, 21 Oct 2026 20:26:41 -0500 Subject: [PATCH 3/6] feat(napi): export transformForBrowser from the loader and type definitions, with tests --- napi/angular-compiler/index.d.ts | 33 ++++++++++++ napi/angular-compiler/index.js | 4 ++ .../test/transform-for-browser.test.ts | 51 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 napi/angular-compiler/test/transform-for-browser.test.ts diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 4f1b6fda8..a1bdd9cf2 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -25,6 +25,21 @@ export interface AngularVersion { prerelease?: string } +/** Options for `transformForBrowser`. */ +export interface BrowserTransformOptions { + /** Generate a source map (default: false) */ + sourcemap?: boolean + /** `async`/`await`, async generators and `for await` as generators, for zone.js (default: true) */ + lowerAsync?: boolean +} + +/** Result of `transformForBrowser`. */ +export interface BrowserTransformResult { + code: string + map?: string + errors: Array +} + /** Result of class metadata compilation. */ export interface ClassMetadataNapiCompileResult { /** @@ -855,6 +870,24 @@ export declare function transformAngularFileSync( resolvedResources?: ResolvedResources | undefined | null, ): TransformResult +/** `transformForBrowserSync` on a worker thread */ +export declare function transformForBrowser( + code: string, + filename: string, + options?: BrowserTransformOptions | undefined | null, +): Promise + +/** + * TypeScript stripped with Angular's settings (`experimentalDecorators`, `useDefineForClassFields: false`) and + * `async` lowered to generators, which zone.js needs; JavaScript gets only the lowering. Runtime helpers are + * imported from `@oxc-project/runtime`. + */ +export declare function transformForBrowserSync( + code: string, + filename: string, + options?: BrowserTransformOptions | undefined | null, +): BrowserTransformResult + /** Options for transforming an Angular component. */ export interface TransformOptions { /** Generate source maps. */ diff --git a/napi/angular-compiler/index.js b/napi/angular-compiler/index.js index f5ebb7a14..352c63908 100644 --- a/napi/angular-compiler/index.js +++ b/napi/angular-compiler/index.js @@ -926,6 +926,8 @@ const { parseComponentId, transformAngularFile, transformAngularFileSync, + transformForBrowser, + transformForBrowserSync, Severity, } = nativeBinding export { compileClassMetadata } @@ -948,5 +950,7 @@ export { optimizeAngularPackage } export { parseComponentId } export { transformAngularFile } export { transformAngularFileSync } +export { transformForBrowser } +export { transformForBrowserSync } export { Severity } export const __napiBindingTarget = __napiLoadedBindingTarget diff --git a/napi/angular-compiler/test/transform-for-browser.test.ts b/napi/angular-compiler/test/transform-for-browser.test.ts new file mode 100644 index 000000000..b489a34ab --- /dev/null +++ b/napi/angular-compiler/test/transform-for-browser.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' + +import { transformForBrowser, transformForBrowserSync } from '../index.js' + +describe('transformForBrowser', () => { + it('lowers async functions, async generators and for await to generators for zone.js', async () => { + const result = await transformForBrowser( + `export async function load(urls) { for await (const r of urls) await r; } +export async function* pages() { yield 1; }`, + 'load.mjs', + ) + expect(result.errors).toEqual([]) + expect(result.code).not.toMatch(/async function|for await/) + expect(result.code).toContain('@oxc-project/runtime/helpers/asyncToGenerator') + }) + + it("keeps async native with lowerAsync: false, and leaves JavaScript's class fields alone", () => { + const result = transformForBrowserSync( + `export class A { b = 1; static s = 2; async f() { await 1; } }`, + 'a.mjs', + { lowerAsync: false }, + ) + expect(result.code).toContain('async f()') + expect(result.code).toMatch(/b = 1;[\s\S]*static s = 2/) + }) + + it('strips TypeScript with useDefineForClassFields: false: fields assigned after parameter properties', () => { + const { code, errors } = transformForBrowserSync( + `export class A { b = this.a.x; static s = 1; c: number; constructor(private a: { x: number }) {} }`, + 'a.ts', + ) + expect(errors).toEqual([]) + expect(code.indexOf('this.a = a')).toBeLessThan(code.indexOf('this.b = this.a.x')) + expect(code).toContain('A.s = 1') + expect(code).not.toContain('this.c') + }) + + it('applies legacy decorators to TypeScript', () => { + const { code, errors } = transformForBrowserSync( + `function d(t: any) {}\n@d export class A {}`, + 'a.ts', + ) + expect(errors).toEqual([]) + expect(code).not.toContain('@d') + expect(code).toContain('_decorate') + }) + + it('reports syntax errors', () => { + expect(transformForBrowserSync('let = ;', 'broken.ts').errors.length).toBeGreaterThan(0) + }) +}) From a59e630c1ae9f3d9c537da694413ca4d6e46799f Mon Sep 17 00:00:00 2001 From: Edouard Bozon Date: Sun, 25 Oct 2026 10:46:27 +0100 Subject: [PATCH 4/6] fix(vite): lower async/await for zone.js like the Angular CLI 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 #641 Co-Authored-By: Claude Opus 5.5 (1M context) --- napi/angular-compiler/README.md | 16 +++ .../fixtures/lower-async/async-mock.check.ts | 14 +++ .../lower-async/client-environment.ts | 8 ++ .../test/fixtures/lower-async/dep.ts | 3 + .../test/fixtures/lower-async/service.ts | 5 + .../angular-compiler/test/lower-async.test.ts | 110 ++++++++++++++++++ .../angular-async-lowering-plugin.ts | 82 +++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 18 ++- 8 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 napi/angular-compiler/test/fixtures/lower-async/async-mock.check.ts create mode 100644 napi/angular-compiler/test/fixtures/lower-async/client-environment.ts create mode 100644 napi/angular-compiler/test/fixtures/lower-async/dep.ts create mode 100644 napi/angular-compiler/test/fixtures/lower-async/service.ts create mode 100644 napi/angular-compiler/test/lower-async.test.ts create mode 100644 napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 47f730c53..7bd95c324 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -203,6 +203,7 @@ interface PluginOptions { // Features jit?: boolean liveReload?: boolean + // Default: zoneless unless zone.js is installed (see below) zoneless?: boolean sourceMap?: boolean | { scripts?: boolean; vendor?: boolean } @@ -232,6 +233,21 @@ interface PluginOptions { For `"auto"`, the plugin uses `build.cssMinify` when it is set, otherwise it falls back to `build.minify`. In dev, `"auto"` defaults to `false`. +### zone.js and `async`/`await` + +zone.js cannot track native `await`. Unless the app is zoneless, the plugin +lowers `async` functions, async generators and `for await` in application code +to generators, as the Angular CLI does for apps with `zone.js` in their +polyfills, so `fakeAsync` with `tick()`/`flushMicrotasks()` drives code that +awaits, and change detection runs after an `await`. The helpers are imported +from `@oxc-project/runtime`, which Vite resolves. Server (SSR) transforms and +`node_modules` are left as they are. + +Without `zoneless`, an app is taken to be zoneless when no `node_modules` above +the Vite root contains `zone.js`. Set `zoneless: true` to keep `async` native in a +zoneless app that still has zone.js installed, or `zoneless: false` to force the +lowering. + ### Library builds (`.d.ts`) For publishing an Angular library (the ng-packagr-style workflow, e.g. with diff --git a/napi/angular-compiler/test/fixtures/lower-async/async-mock.check.ts b/napi/angular-compiler/test/fixtures/lower-async/async-mock.check.ts new file mode 100644 index 000000000..2c463d985 --- /dev/null +++ b/napi/angular-compiler/test/fixtures/lower-async/async-mock.check.ts @@ -0,0 +1,14 @@ +import { expect, it, vi } from 'vitest' + +import { run } from './service' + +// Hoisted above the imports by Vitest; the lowered factory must still find its helper. +vi.mock('./dep', async () => { + const value = await Promise.resolve('mocked') + return { load: async () => value } +}) + +it('lowers async and keeps an async vi.mock factory working', async () => { + expect(await run()).toBe('mocked') + expect(run.toString()).not.toContain('async') +}) diff --git a/napi/angular-compiler/test/fixtures/lower-async/client-environment.ts b/napi/angular-compiler/test/fixtures/lower-async/client-environment.ts new file mode 100644 index 000000000..ca7a634d9 --- /dev/null +++ b/napi/angular-compiler/test/fixtures/lower-async/client-environment.ts @@ -0,0 +1,8 @@ +// Runs the spec through Vite's client transforms, as jsdom and happy-dom do. +export default { + name: 'client', + viteEnvironment: 'client', + setup() { + return { teardown() {} } + }, +} diff --git a/napi/angular-compiler/test/fixtures/lower-async/dep.ts b/napi/angular-compiler/test/fixtures/lower-async/dep.ts new file mode 100644 index 000000000..428761a9a --- /dev/null +++ b/napi/angular-compiler/test/fixtures/lower-async/dep.ts @@ -0,0 +1,3 @@ +export async function load(): Promise { + return 'real' +} diff --git a/napi/angular-compiler/test/fixtures/lower-async/service.ts b/napi/angular-compiler/test/fixtures/lower-async/service.ts new file mode 100644 index 000000000..19469dd1c --- /dev/null +++ b/napi/angular-compiler/test/fixtures/lower-async/service.ts @@ -0,0 +1,5 @@ +import { load } from './dep' + +export async function run(): Promise { + return await load() +} diff --git a/napi/angular-compiler/test/lower-async.test.ts b/napi/angular-compiler/test/lower-async.test.ts new file mode 100644 index 000000000..c25ab4bb8 --- /dev/null +++ b/napi/angular-compiler/test/lower-async.test.ts @@ -0,0 +1,110 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { Plugin } from 'vite' +import { afterAll, describe, expect, it } from 'vitest' +import { startVitest } from 'vitest/node' + +import { angular, type PluginOptions } from '../vite-plugin/index.js' + +const fixtureRoot = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/lower-async') + +const SOURCE = `export class A { + b = 1; + async f() { + for await (const x of this.xs()) await x; + } +} +export const g = async () => 1; +` + +const roots: string[] = [] +afterAll(() => roots.forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function tempRoot(withZoneJs: boolean): string { + const root = mkdtempSync(join(tmpdir(), 'oxc-angular-lower-async-')) + roots.push(root) + if (withZoneJs) { + const zone = join(root, 'node_modules/zone.js') + mkdirSync(zone, { recursive: true }) + writeFileSync(join(zone, 'package.json'), '{"name":"zone.js","main":"zone.js"}') + writeFileSync(join(zone, 'zone.js'), '') + } + return root +} + +async function transform( + options: PluginOptions, + { root = tempRoot(false), ssr = false, id = '/src/a.ts' } = {}, +): Promise { + const plugin = angular({ liveReload: false, sourceMap: false, ...options }).find( + (candidate) => candidate.name === '@oxc-angular/vite-lower-async', + ) as Plugin & { configResolved: (config: unknown) => void; transform: { handler: Function } } + plugin.configResolved({ root }) + const result = await plugin.transform.handler.call( + { + error(message: string) { + throw new Error(message) + }, + }, + SOURCE, + id, + { ssr }, + ) + return result?.code +} + +describe('async lowering for zone.js', () => { + it('lowers async functions, async generators and for await to generators', async () => { + const code = await transform({ zoneless: false }) + expect(code).not.toMatch(/\basync\b|for await/) + expect(code).toContain( + 'import _asyncToGenerator from "@oxc-project/runtime/helpers/asyncToGenerator"', + ) + }) + + it('only lowers async: the class field of the already-stripped TypeScript stays a field', async () => { + const code = await transform({ zoneless: false }) + expect(code).toMatch(/^\s*b = 1;/m) + expect(code).not.toContain('this.b') + }) + + it('keeps async native when zoneless', async () => { + expect(await transform({ zoneless: true })).toBeUndefined() + }) + + it('keeps async native in server (SSR) transforms', async () => { + expect(await transform({ zoneless: false }, { ssr: true })).toBeUndefined() + }) + + it('defaults to lowering when zone.js resolves from the root, and to native otherwise', async () => { + expect(await transform({}, { root: tempRoot(true) })).toContain('_asyncToGenerator') + expect(await transform({}, { root: tempRoot(false) })).toBeUndefined() + }) + + it('runs after Vitest hoists vi.mock, so an async mock factory still finds its helper', async () => { + const vitest = await startVitest( + 'test', + [], + { + root: fixtureRoot, + config: false, + include: ['*.check.ts'], + environment: './client-environment.ts', + watch: false, + reporters: [], + }, + { plugins: [angular({ zoneless: false, liveReload: false })] }, + ) + const files = vitest.state.getFiles() + await vitest.close() + const errors = files.flatMap((file) => [ + ...(file.result?.errors ?? []), + ...file.tasks.flatMap((task) => task.result?.errors ?? []), + ]) + expect(errors.map((error) => error.message)).toEqual([]) + expect(files.map((file) => file.result?.state)).toEqual(['pass']) + }, 60_000) +}) diff --git a/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts b/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts new file mode 100644 index 000000000..bb0725a99 --- /dev/null +++ b/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts @@ -0,0 +1,82 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +import { transformForBrowser } from '#binding' +import type { Plugin } from 'vite' + +// Application scripts. `.cjs`/`.cts` are left out: the runtime helpers are ES imports. +const SCRIPT_REGEX = /\.(?:[jt]sx?|m[jt]s)$/ +const TS_EXT_REGEX = /\.([cm]?)ts(x?)$/ + +/** + * Lowers `async` functions, async generators and `for await` to generators in + * application code when the app runs zone.js, as the Angular CLI does + * (esbuild `supported: { 'async-await': false }` unless the app is zoneless). + * zone.js cannot patch native `await`, so without this `fakeAsync`/`tick()` + * cannot drive code that awaits, and change detection after an `await` is not + * triggered by a zone task. + * + * Helpers are imported from `@oxc-project/runtime`, which Vite resolves itself. + * Server transforms are left native. + */ +export function asyncLoweringPlugin({ + zoneless, + sourcemap, +}: { + zoneless: boolean | undefined + sourcemap: boolean +}): Plugin { + let lowerAsync = false + + return { + name: '@oxc-angular/vite-lower-async', + configResolved(config) { + lowerAsync = !(zoneless ?? !isZoneJsInstalled(config.root)) + }, + transform: { + // After TypeScript is stripped, and after Vitest hoists `vi.mock()`: lowered + // earlier, an async mock factory becomes an eager `_asyncToGenerator(...)` call + // that the hoisting would move above the helper's import. + order: 'post', + filter: { + id: { include: SCRIPT_REGEX, exclude: /node_modules/ }, + code: /\basync\b/, + }, + async handler(code, id, options) { + if (!lowerAsync || options?.ssr) return + + // The code is JavaScript by now: name it so, or it would be stripped as + // TypeScript a second time, under Angular's class-field semantics. + const result = await transformForBrowser(code, id.replace(TS_EXT_REGEX, '.$1js$2'), { + lowerAsync: true, + sourcemap, + }) + if (result.errors.length > 0) { + this.error(result.errors.join('\n')) + } + return { code: result.code, map: result.map ?? null } + }, + }, + } +} + +/** + * Without an explicit `zoneless`, an app that has zone.js installed is taken to use it. Looked up + * in `node_modules` rather than with `require.resolve`, which also follows `NODE_PATH` (set by + * pnpm to its hoisted store). + */ +function isZoneJsInstalled(root: string): boolean { + if (process.versions['pnp']) { + try { + createRequire(join(root, 'package.json')).resolve('zone.js/package.json') + return true + } catch { + return false + } + } + for (let dir = root; ; dir = dirname(dir)) { + if (existsSync(join(dir, 'node_modules/zone.js/package.json'))) return true + if (dirname(dir) === dir) return false + } +} diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 3dfb9a0e0..ff9bf82a8 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -32,6 +32,7 @@ import { type AngularVersion, } from '#binding' +import { asyncLoweringPlugin } from './angular-async-lowering-plugin.js' import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js' import { jitPlugin } from './angular-jit-plugin.js' import { angularLinkerPlugin } from './angular-linker-plugin.js' @@ -66,7 +67,17 @@ export interface PluginOptions { /** Enable source map generation. */ sourceMap?: boolean | { scripts?: boolean; vendor?: boolean } - /** Enable zoneless mode. */ + /** + * Whether the app runs without zone.js. + * + * Unless it is zoneless, `async`/`await`, async generators and `for await` in + * application code are lowered to generators, as the Angular CLI does, so + * zone.js can track them (`fakeAsync`/`tick()`, change detection after an + * `await`). Server (SSR) transforms keep them native. + * + * Default: zoneless unless `zone.js` is installed in a `node_modules` above the + * Vite root. + */ zoneless?: boolean /** @@ -262,7 +273,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { typeof options.sourceMap === 'boolean' ? options.sourceMap : (options.sourceMap?.scripts ?? true), - zoneless: options.zoneless ?? false, fileReplacements, angularVersion: options.angularVersion, emitClassMetadata: options.emitClassMetadata ?? true, @@ -1559,6 +1569,10 @@ export function angular(options: PluginOptions = {}): Plugin[] { ssrManifestPlugin({ ssrEntry: options.ssrEntry, }), + asyncLoweringPlugin({ + zoneless: options.zoneless, + sourcemap: pluginOptions.sourceMap, + }), ].filter(Boolean) as Plugin[] } From 6f5c88762b3bfe3819bab990c8a531d97ac81ead Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 27 Oct 2026 17:03:42 +0800 Subject: [PATCH 5/6] fix(vite): lower async before import analysis, as a module - 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 --- napi/angular-compiler/README.md | 3 +- .../angular-compiler/test/lower-async.test.ts | 40 ++++++++++++++++++- .../angular-async-lowering-plugin.ts | 28 ++++++++----- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 6fe5b0452..6cc695d9c 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -246,7 +246,8 @@ to generators, as the Angular CLI does for apps with `zone.js` in their polyfills, so `fakeAsync` with `tick()`/`flushMicrotasks()` drives code that awaits, and change detection runs after an `await`. The helpers are imported from `@oxc-project/runtime`, which Vite resolves. Server (SSR) transforms and -`node_modules` are left as they are. +`node_modules` are left as they are. Vitest's `node` environment transforms as +SSR, so run zone.js specs in `jsdom`, `happy-dom` or the browser. Without `zoneless`, an app is taken to be zoneless when no `node_modules` above the Vite root contains `zone.js`. Set `zoneless: true` to keep `async` native in a diff --git a/napi/angular-compiler/test/lower-async.test.ts b/napi/angular-compiler/test/lower-async.test.ts index c25ab4bb8..78a5861d2 100644 --- a/napi/angular-compiler/test/lower-async.test.ts +++ b/napi/angular-compiler/test/lower-async.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Plugin } from 'vite' +import { createServer } from 'vite' import { afterAll, describe, expect, it } from 'vitest' import { startVitest } from 'vitest/node' @@ -37,7 +38,7 @@ function tempRoot(withZoneJs: boolean): string { async function transform( options: PluginOptions, - { root = tempRoot(false), ssr = false, id = '/src/a.ts' } = {}, + { root = tempRoot(false), ssr = false, id = '/src/a.ts', code = SOURCE } = {}, ): Promise { const plugin = angular({ liveReload: false, sourceMap: false, ...options }).find( (candidate) => candidate.name === '@oxc-angular/vite-lower-async', @@ -49,7 +50,7 @@ async function transform( throw new Error(message) }, }, - SOURCE, + code, id, { ssr }, ) @@ -71,6 +72,41 @@ describe('async lowering for zone.js', () => { expect(code).not.toContain('this.b') }) + it('imports the helpers as a module in a file without import or export', async () => { + const code = await transform({ zoneless: false }, { code: 'async function f() {}\nf();\n' }) + expect(code).toContain('import _asyncToGenerator from') + expect(code).not.toContain('require(') + }) + + it('reports a lowering error by its message', async () => { + await expect( + transform({ zoneless: false }, { id: '/src/a.js', code: 'async function f( {' }), + ).rejects.toThrow('Expected `}` but found `EOF`') + }) + + it('lowers before import analysis, so the dev server resolves the helper import', async () => { + const root = tempRoot(false) + mkdirSync(join(root, 'src')) + writeFileSync( + join(root, 'src/main.ts'), + 'export async function f(): Promise {\n return 1\n}\n', + ) + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + server: { middlewareMode: true, ws: false }, + plugins: [angular({ zoneless: false, liveReload: false })], + }) + try { + const result = await server.transformRequest('/src/main.ts') + expect(result?.code).toContain('_asyncToGenerator') + expect(result?.code).not.toContain('from "@oxc-project/runtime') + } finally { + await server.close() + } + }) + it('keeps async native when zoneless', async () => { expect(await transform({ zoneless: true })).toBeUndefined() }) diff --git a/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts b/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts index bb0725a99..d74958b85 100644 --- a/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts @@ -7,7 +7,8 @@ import type { Plugin } from 'vite' // Application scripts. `.cjs`/`.cts` are left out: the runtime helpers are ES imports. const SCRIPT_REGEX = /\.(?:[jt]sx?|m[jt]s)$/ -const TS_EXT_REGEX = /\.([cm]?)ts(x?)$/ +const JSX_EXT_REGEX = /\.[jt]sx$/ +const SCRIPT_EXT_REGEX = /\.m?[jt]s$/ /** * Lowers `async` functions, async generators and `for await` to generators in @@ -31,14 +32,16 @@ export function asyncLoweringPlugin({ return { name: '@oxc-angular/vite-lower-async', + // After TypeScript is stripped, and after Vitest hoists `vi.mock()`: lowered + // earlier, an async mock factory becomes an eager `_asyncToGenerator(...)` call + // that the hoisting would move above the helper's import. A plugin-level + // `post`, not a hook-level one, so it still runs before `vite:import-analysis`, + // which resolves the helper imports for the dev server. + enforce: 'post', configResolved(config) { lowerAsync = !(zoneless ?? !isZoneJsInstalled(config.root)) }, transform: { - // After TypeScript is stripped, and after Vitest hoists `vi.mock()`: lowered - // earlier, an async mock factory becomes an eager `_asyncToGenerator(...)` call - // that the hoisting would move above the helper's import. - order: 'post', filter: { id: { include: SCRIPT_REGEX, exclude: /node_modules/ }, code: /\basync\b/, @@ -47,13 +50,16 @@ export function asyncLoweringPlugin({ if (!lowerAsync || options?.ssr) return // The code is JavaScript by now: name it so, or it would be stripped as - // TypeScript a second time, under Angular's class-field semantics. - const result = await transformForBrowser(code, id.replace(TS_EXT_REGEX, '.$1js$2'), { - lowerAsync: true, - sourcemap, - }) + // TypeScript a second time, under Angular's class-field semantics. `.mjs`, + // because Vite serves it as a module: as `.js`, a file without `import` or + // `export` would get its helpers through `require()`. `.jsx` keeps parsing + // JSX that Vite was told to preserve. + const filename = JSX_EXT_REGEX.test(id) + ? id.replace(JSX_EXT_REGEX, '.jsx') + : id.replace(SCRIPT_EXT_REGEX, '.mjs') + const result = await transformForBrowser(code, filename, { lowerAsync: true, sourcemap }) if (result.errors.length > 0) { - this.error(result.errors.join('\n')) + this.error(result.errors.map((error) => error.message).join('\n')) } return { code: result.code, map: result.map ?? null } }, From 13020eb2732e81933ce31a92881f19154ea0e20c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 27 Oct 2026 23:17:51 +0800 Subject: [PATCH 6/6] refactor: say "downlevel" for the async transform, as Angular does 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 --- crates/oxc_angular_compiler/src/browser.rs | 22 +++++++++---------- napi/angular-compiler/README.md | 4 ++-- napi/angular-compiler/index.d.ts | 6 ++--- napi/angular-compiler/src/lib.rs | 10 ++++----- ...-async.test.ts => downlevel-async.test.ts} | 22 ++++++++++--------- .../async-mock.check.ts | 4 ++-- .../client-environment.ts | 0 .../{lower-async => downlevel-async}/dep.ts | 0 .../service.ts | 0 .../test/transform-for-browser.test.ts | 8 +++---- ...n.ts => angular-async-downlevel-plugin.ts} | 19 +++++++++------- napi/angular-compiler/vite-plugin/index.ts | 6 ++--- 12 files changed, 53 insertions(+), 48 deletions(-) rename napi/angular-compiler/test/{lower-async.test.ts => downlevel-async.test.ts} (81%) rename napi/angular-compiler/test/fixtures/{lower-async => downlevel-async}/async-mock.check.ts (62%) rename napi/angular-compiler/test/fixtures/{lower-async => downlevel-async}/client-environment.ts (100%) rename napi/angular-compiler/test/fixtures/{lower-async => downlevel-async}/dep.ts (100%) rename napi/angular-compiler/test/fixtures/{lower-async => downlevel-async}/service.ts (100%) rename napi/angular-compiler/vite-plugin/{angular-async-lowering-plugin.ts => angular-async-downlevel-plugin.ts} (88%) diff --git a/crates/oxc_angular_compiler/src/browser.rs b/crates/oxc_angular_compiler/src/browser.rs index aa50d4075..54f29734d 100644 --- a/crates/oxc_angular_compiler/src/browser.rs +++ b/crates/oxc_angular_compiler/src/browser.rs @@ -1,6 +1,6 @@ -//! TypeScript stripped as `tsc` does it under Angular's settings, and `async` lowered for zone.js. +//! TypeScript stripped as `tsc` does it under Angular's settings, and `async` downleveled for zone.js. //! -//! This is what a bundler without per-feature lowering needs to build an Angular app that runs zone.js: zone.js +//! This is what a bundler without per-feature downleveling needs to build an Angular app that runs zone.js: zone.js //! cannot see native `await`, so the Angular CLI turns `async` functions into generators (esbuild's `supported` //! option) and leaves everything else native. @@ -30,7 +30,7 @@ pub struct BrowserTransformOptions { pub sourcemap: bool, /// `async` functions and generators, and `for await`, as generators (default: false; the napi binding /// defaults to true). Helpers are imported from `@oxc-project/runtime`. - pub lower_async: bool, + pub downlevel_async: bool, } /// Result of [`transform_for_browser`]. @@ -44,12 +44,12 @@ pub struct BrowserTransformResult { pub errors: Vec, } -/// Strips TypeScript and lowers `async`, as the Angular CLI does for an app that runs zone.js. +/// Strips TypeScript and downlevels `async`, as the Angular CLI does for an app that runs zone.js. /// /// TypeScript files get `experimentalDecorators` and `useDefineForClassFields: false` semantics, as `tsc` emits them /// for target ES2022: fields are assigned in the constructor after parameter properties, statics in static blocks, -/// and `#private` members stay native. JavaScript keeps its class fields. `using` declarations are lowered in -/// TypeScript files (as `tsc` does) and wherever `async` is lowered (`await using` cannot live in a generator). +/// and `#private` members stay native. JavaScript keeps its class fields. `using` declarations are downleveled in +/// TypeScript files (as `tsc` does) and wherever `async` is downleveled (`await using` cannot live in a generator). pub fn transform_for_browser( allocator: &Allocator, code: &str, @@ -81,11 +81,11 @@ pub fn transform_for_browser( } let mut env = EnvOptions::default(); - if options.lower_async { + if options.downlevel_async { env.es2017 = ES2017Options { async_to_generator: true }; env.es2018 = ES2018Options { async_generator_functions: true, ..env.es2018 }; } - if options.lower_async || typescript { + if options.downlevel_async || typescript { env.es2026 = ES2026Options { explicit_resource_management: true }; } // No class-properties plugin: with `set_public_class_fields`, the TypeScript transform alone moves fields with an @@ -223,14 +223,14 @@ mod tests { &allocator, code, filename, - &BrowserTransformOptions { sourcemap: false, lower_async: true }, + &BrowserTransformOptions { sourcemap: false, downlevel_async: true }, ); assert!(r.errors.is_empty(), "{:?}", r.errors); r.code } #[test] - fn lowers_async_and_for_await() { + fn downlevels_async_and_for_await() { let out = run( "export async function f(xs) { for await (const x of xs) await x; }\nexport async function* g() { yield 1; }", "a.mjs", @@ -310,7 +310,7 @@ mod tests { } #[test] - fn lowers_await_using_before_async() { + fn downlevels_await_using_before_async() { let out = run("export async function f(r) { await using x = r; return 1; }", "a.mjs"); assert!(!out.contains("using x"), "{out}"); assert!(!out.contains("async function"), "{out}"); diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 6cc695d9c..c5bf62d04 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -241,7 +241,7 @@ For `"auto"`, the plugin uses `build.cssMinify` when it is set, otherwise it fal ### zone.js and `async`/`await` zone.js cannot track native `await`. Unless the app is zoneless, the plugin -lowers `async` functions, async generators and `for await` in application code +downlevels `async` functions, async generators and `for await` in application code to generators, as the Angular CLI does for apps with `zone.js` in their polyfills, so `fakeAsync` with `tick()`/`flushMicrotasks()` drives code that awaits, and change detection runs after an `await`. The helpers are imported @@ -252,7 +252,7 @@ SSR, so run zone.js specs in `jsdom`, `happy-dom` or the browser. Without `zoneless`, an app is taken to be zoneless when no `node_modules` above the Vite root contains `zone.js`. Set `zoneless: true` to keep `async` native in a zoneless app that still has zone.js installed, or `zoneless: false` to force the -lowering. +downleveling. ### Library builds (`.d.ts`) diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index c3c885ebb..4b6a907db 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -30,7 +30,7 @@ export interface BrowserTransformOptions { /** Generate a source map (default: false) */ sourcemap?: boolean /** `async`/`await`, async generators and `for await` as generators, for zone.js (default: true) */ - lowerAsync?: boolean + downlevelAsync?: boolean } /** Result of `transformForBrowser`. */ @@ -1046,8 +1046,8 @@ export declare function transformForBrowser( /** * TypeScript stripped as `tsc` does it with Angular's settings (`experimentalDecorators`, - * `useDefineForClassFields: false`, target ES2022) and `async` lowered to generators, which zone.js needs; - * JavaScript gets only the lowering. Runtime helpers are imported from `@oxc-project/runtime`. + * `useDefineForClassFields: false`, target ES2022) and `async` downleveled to generators, which zone.js needs; + * JavaScript gets only the downleveling. Runtime helpers are imported from `@oxc-project/runtime`. */ export declare function transformForBrowserSync( code: string, diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 72e906492..e1974d4a8 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -3280,7 +3280,7 @@ pub fn link_angular_package( } // ============================================================================= -// Browser transform (TypeScript and async lowering for zone.js) +// Browser transform (TypeScript and async downleveling for zone.js) // ============================================================================= /// Options for `transformForBrowser`. @@ -3290,7 +3290,7 @@ pub struct BrowserTransformOptions { /// Generate a source map (default: false) pub sourcemap: Option, /// `async`/`await`, async generators and `for await` as generators, for zone.js (default: true) - pub lower_async: Option, + pub downlevel_async: Option, } /// Result of `transformForBrowser`. @@ -3305,8 +3305,8 @@ pub struct BrowserTransformResult { } /// TypeScript stripped as `tsc` does it with Angular's settings (`experimentalDecorators`, -/// `useDefineForClassFields: false`, target ES2022) and `async` lowered to generators, which zone.js needs; -/// JavaScript gets only the lowering. Runtime helpers are imported from `@oxc-project/runtime`. +/// `useDefineForClassFields: false`, target ES2022) and `async` downleveled to generators, which zone.js needs; +/// JavaScript gets only the downleveling. Runtime helpers are imported from `@oxc-project/runtime`. #[napi] pub fn transform_for_browser_sync( code: String, @@ -3325,7 +3325,7 @@ pub fn transform_for_browser_sync( &filename, &RustOptions { sourcemap: options.sourcemap.unwrap_or(false), - lower_async: options.lower_async.unwrap_or(true), + downlevel_async: options.downlevel_async.unwrap_or(true), }, ); BrowserTransformResult { diff --git a/napi/angular-compiler/test/lower-async.test.ts b/napi/angular-compiler/test/downlevel-async.test.ts similarity index 81% rename from napi/angular-compiler/test/lower-async.test.ts rename to napi/angular-compiler/test/downlevel-async.test.ts index 78a5861d2..b5b8514f8 100644 --- a/napi/angular-compiler/test/lower-async.test.ts +++ b/napi/angular-compiler/test/downlevel-async.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -10,7 +10,7 @@ import { startVitest } from 'vitest/node' import { angular, type PluginOptions } from '../vite-plugin/index.js' -const fixtureRoot = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/lower-async') +const fixtureRoot = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/downlevel-async') const SOURCE = `export class A { b = 1; @@ -25,7 +25,9 @@ const roots: string[] = [] afterAll(() => roots.forEach((root) => rmSync(root, { recursive: true, force: true }))) function tempRoot(withZoneJs: boolean): string { - const root = mkdtempSync(join(tmpdir(), 'oxc-angular-lower-async-')) + // realpath.native: the dev server only serves files under the real root, and + // Windows' temp dir can be an 8.3 short path (`RUNNER~1`). + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'oxc-angular-downlevel-async-'))) roots.push(root) if (withZoneJs) { const zone = join(root, 'node_modules/zone.js') @@ -41,7 +43,7 @@ async function transform( { root = tempRoot(false), ssr = false, id = '/src/a.ts', code = SOURCE } = {}, ): Promise { const plugin = angular({ liveReload: false, sourceMap: false, ...options }).find( - (candidate) => candidate.name === '@oxc-angular/vite-lower-async', + (candidate) => candidate.name === '@oxc-angular/vite-downlevel-async', ) as Plugin & { configResolved: (config: unknown) => void; transform: { handler: Function } } plugin.configResolved({ root }) const result = await plugin.transform.handler.call( @@ -57,8 +59,8 @@ async function transform( return result?.code } -describe('async lowering for zone.js', () => { - it('lowers async functions, async generators and for await to generators', async () => { +describe('async downleveling for zone.js', () => { + it('downlevels async functions, async generators and for await to generators', async () => { const code = await transform({ zoneless: false }) expect(code).not.toMatch(/\basync\b|for await/) expect(code).toContain( @@ -66,7 +68,7 @@ describe('async lowering for zone.js', () => { ) }) - it('only lowers async: the class field of the already-stripped TypeScript stays a field', async () => { + it('only downlevels async: the class field of the already-stripped TypeScript stays a field', async () => { const code = await transform({ zoneless: false }) expect(code).toMatch(/^\s*b = 1;/m) expect(code).not.toContain('this.b') @@ -78,13 +80,13 @@ describe('async lowering for zone.js', () => { expect(code).not.toContain('require(') }) - it('reports a lowering error by its message', async () => { + it('reports a downleveling error by its message', async () => { await expect( transform({ zoneless: false }, { id: '/src/a.js', code: 'async function f( {' }), ).rejects.toThrow('Expected `}` but found `EOF`') }) - it('lowers before import analysis, so the dev server resolves the helper import', async () => { + it('downlevels before import analysis, so the dev server resolves the helper import', async () => { const root = tempRoot(false) mkdirSync(join(root, 'src')) writeFileSync( @@ -115,7 +117,7 @@ describe('async lowering for zone.js', () => { expect(await transform({ zoneless: false }, { ssr: true })).toBeUndefined() }) - it('defaults to lowering when zone.js resolves from the root, and to native otherwise', async () => { + it('defaults to downleveling when zone.js resolves from the root, and to native otherwise', async () => { expect(await transform({}, { root: tempRoot(true) })).toContain('_asyncToGenerator') expect(await transform({}, { root: tempRoot(false) })).toBeUndefined() }) diff --git a/napi/angular-compiler/test/fixtures/lower-async/async-mock.check.ts b/napi/angular-compiler/test/fixtures/downlevel-async/async-mock.check.ts similarity index 62% rename from napi/angular-compiler/test/fixtures/lower-async/async-mock.check.ts rename to napi/angular-compiler/test/fixtures/downlevel-async/async-mock.check.ts index 2c463d985..0badd8e87 100644 --- a/napi/angular-compiler/test/fixtures/lower-async/async-mock.check.ts +++ b/napi/angular-compiler/test/fixtures/downlevel-async/async-mock.check.ts @@ -2,13 +2,13 @@ import { expect, it, vi } from 'vitest' import { run } from './service' -// Hoisted above the imports by Vitest; the lowered factory must still find its helper. +// Hoisted above the imports by Vitest; the downleveled factory must still find its helper. vi.mock('./dep', async () => { const value = await Promise.resolve('mocked') return { load: async () => value } }) -it('lowers async and keeps an async vi.mock factory working', async () => { +it('downlevels async and keeps an async vi.mock factory working', async () => { expect(await run()).toBe('mocked') expect(run.toString()).not.toContain('async') }) diff --git a/napi/angular-compiler/test/fixtures/lower-async/client-environment.ts b/napi/angular-compiler/test/fixtures/downlevel-async/client-environment.ts similarity index 100% rename from napi/angular-compiler/test/fixtures/lower-async/client-environment.ts rename to napi/angular-compiler/test/fixtures/downlevel-async/client-environment.ts diff --git a/napi/angular-compiler/test/fixtures/lower-async/dep.ts b/napi/angular-compiler/test/fixtures/downlevel-async/dep.ts similarity index 100% rename from napi/angular-compiler/test/fixtures/lower-async/dep.ts rename to napi/angular-compiler/test/fixtures/downlevel-async/dep.ts diff --git a/napi/angular-compiler/test/fixtures/lower-async/service.ts b/napi/angular-compiler/test/fixtures/downlevel-async/service.ts similarity index 100% rename from napi/angular-compiler/test/fixtures/lower-async/service.ts rename to napi/angular-compiler/test/fixtures/downlevel-async/service.ts diff --git a/napi/angular-compiler/test/transform-for-browser.test.ts b/napi/angular-compiler/test/transform-for-browser.test.ts index 1afe04438..579f5da94 100644 --- a/napi/angular-compiler/test/transform-for-browser.test.ts +++ b/napi/angular-compiler/test/transform-for-browser.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest' import { transformForBrowser, transformForBrowserSync } from '../index.js' describe('transformForBrowser', () => { - it('lowers async functions, async generators and for await to generators for zone.js', async () => { + it('downlevels async functions, async generators and for await to generators for zone.js', async () => { const result = await transformForBrowser( `export async function load(urls) { for await (const r of urls) await r; } export async function* pages() { yield 1; }`, @@ -14,11 +14,11 @@ export async function* pages() { yield 1; }`, expect(result.code).toContain('@oxc-project/runtime/helpers/asyncToGenerator') }) - it("keeps async native with lowerAsync: false, and leaves JavaScript's class fields alone", () => { + it("keeps async native with downlevelAsync: false, and leaves JavaScript's class fields alone", () => { const result = transformForBrowserSync( `export class A { b = 1; static s = 2; async f() { await 1; } }`, 'a.mjs', - { lowerAsync: false }, + { downlevelAsync: false }, ) expect(result.code).toContain('async f()') expect(result.code).toMatch(/b = 1;[\s\S]*static s = 2/) @@ -53,7 +53,7 @@ export async function* pages() { yield 1; }`, expect(code).toContain('E[E["B"] = 1] = "B"') }) - it('lowers await using before async', () => { + it('downlevels await using before async', () => { const { code, errors } = transformForBrowserSync( `export async function f(r) { await using x = r; return 1; }`, 'f.mjs', diff --git a/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts b/napi/angular-compiler/vite-plugin/angular-async-downlevel-plugin.ts similarity index 88% rename from napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts rename to napi/angular-compiler/vite-plugin/angular-async-downlevel-plugin.ts index d74958b85..dd9a15032 100644 --- a/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-async-downlevel-plugin.ts @@ -11,7 +11,7 @@ const JSX_EXT_REGEX = /\.[jt]sx$/ const SCRIPT_EXT_REGEX = /\.m?[jt]s$/ /** - * Lowers `async` functions, async generators and `for await` to generators in + * Downlevels `async` functions, async generators and `for await` to generators in * application code when the app runs zone.js, as the Angular CLI does * (esbuild `supported: { 'async-await': false }` unless the app is zoneless). * zone.js cannot patch native `await`, so without this `fakeAsync`/`tick()` @@ -21,25 +21,25 @@ const SCRIPT_EXT_REGEX = /\.m?[jt]s$/ * Helpers are imported from `@oxc-project/runtime`, which Vite resolves itself. * Server transforms are left native. */ -export function asyncLoweringPlugin({ +export function asyncDownlevelPlugin({ zoneless, sourcemap, }: { zoneless: boolean | undefined sourcemap: boolean }): Plugin { - let lowerAsync = false + let downlevelAsync = false return { - name: '@oxc-angular/vite-lower-async', - // After TypeScript is stripped, and after Vitest hoists `vi.mock()`: lowered + name: '@oxc-angular/vite-downlevel-async', + // After TypeScript is stripped, and after Vitest hoists `vi.mock()`: downleveled // earlier, an async mock factory becomes an eager `_asyncToGenerator(...)` call // that the hoisting would move above the helper's import. A plugin-level // `post`, not a hook-level one, so it still runs before `vite:import-analysis`, // which resolves the helper imports for the dev server. enforce: 'post', configResolved(config) { - lowerAsync = !(zoneless ?? !isZoneJsInstalled(config.root)) + downlevelAsync = !(zoneless ?? !isZoneJsInstalled(config.root)) }, transform: { filter: { @@ -47,7 +47,7 @@ export function asyncLoweringPlugin({ code: /\basync\b/, }, async handler(code, id, options) { - if (!lowerAsync || options?.ssr) return + if (!downlevelAsync || options?.ssr) return // The code is JavaScript by now: name it so, or it would be stripped as // TypeScript a second time, under Angular's class-field semantics. `.mjs`, @@ -57,7 +57,10 @@ export function asyncLoweringPlugin({ const filename = JSX_EXT_REGEX.test(id) ? id.replace(JSX_EXT_REGEX, '.jsx') : id.replace(SCRIPT_EXT_REGEX, '.mjs') - const result = await transformForBrowser(code, filename, { lowerAsync: true, sourcemap }) + const result = await transformForBrowser(code, filename, { + downlevelAsync: true, + sourcemap, + }) if (result.errors.length > 0) { this.error(result.errors.map((error) => error.message).join('\n')) } diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 1a035baf7..afb5803e4 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -31,7 +31,7 @@ import { type AngularVersion, } from '#binding' -import { asyncLoweringPlugin } from './angular-async-lowering-plugin.js' +import { asyncDownlevelPlugin } from './angular-async-downlevel-plugin.js' import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js' import { jitPlugin } from './angular-jit-plugin.js' import { angularLinkerPlugin } from './angular-linker-plugin.js' @@ -64,7 +64,7 @@ export interface PluginOptions { * Whether the app runs without zone.js. * * Unless it is zoneless, `async`/`await`, async generators and `for await` in - * application code are lowered to generators, as the Angular CLI does, so + * application code are downleveled to generators, as the Angular CLI does, so * zone.js can track them (`fakeAsync`/`tick()`, change detection after an * `await`). Server (SSR) transforms keep them native. * @@ -1706,7 +1706,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { ssrManifestPlugin({ ssrEntry: options.ssrEntry, }), - asyncLoweringPlugin({ + asyncDownlevelPlugin({ zoneless: options.zoneless, sourcemap: pluginOptions.sourceMap, }),