From a1a0cc538487d5463667da9354e1fc6ae40c4ce4 Mon Sep 17 00:00:00 2001 From: Damian Kaczmarek Date: Wed, 21 Oct 2026 20:13:53 -0500 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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[] }