Skip to content
22 changes: 11 additions & 11 deletions crates/oxc_angular_compiler/src/browser.rs
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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`].
Expand All @@ -44,12 +44,12 @@ pub struct BrowserTransformResult {
pub errors: Vec<OxcDiagnostic>,
}

/// 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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}");
Expand Down
17 changes: 17 additions & 0 deletions napi/angular-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,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 }

Expand Down Expand Up @@ -237,6 +238,22 @@ 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
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
from `@oxc-project/runtime`, which Vite resolves. Server (SSR) transforms and
`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
zoneless app that still has zone.js installed, or `zoneless: false` to force the
downleveling.

### Library builds (`.d.ts`)

For publishing an Angular library (the ng-packagr-style workflow, e.g. with
Expand Down
6 changes: 3 additions & 3 deletions napi/angular-compiler/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions napi/angular-compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -3290,7 +3290,7 @@ pub struct BrowserTransformOptions {
/// Generate a source map (default: false)
pub sourcemap: Option<bool>,
/// `async`/`await`, async generators and `for await` as generators, for zone.js (default: true)
pub lower_async: Option<bool>,
pub downlevel_async: Option<bool>,
}

/// Result of `transformForBrowser`.
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
148 changes: 148 additions & 0 deletions napi/angular-compiler/test/downlevel-async.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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'

import type { Plugin } from 'vite'
import { createServer } 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/downlevel-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 {
// 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')
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', code = SOURCE } = {},
): Promise<string | undefined> {
const plugin = angular({ liveReload: false, sourceMap: false, ...options }).find(
(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(
{
error(message: string) {
throw new Error(message)
},
},
code,
id,
{ ssr },
)
return result?.code
}

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(
'import _asyncToGenerator from "@oxc-project/runtime/helpers/asyncToGenerator"',
)
})

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')
})

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 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('downlevels 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<number> {\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()
})

it('keeps async native in server (SSR) transforms', async () => {
expect(await transform({ zoneless: false }, { ssr: true })).toBeUndefined()
})

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()
})

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)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect, it, vi } from 'vitest'

import { run } from './service'

// 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('downlevels async and keeps an async vi.mock factory working', async () => {
expect(await run()).toBe('mocked')
expect(run.toString()).not.toContain('async')
})
Original file line number Diff line number Diff line change
@@ -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() {} }
},
}
3 changes: 3 additions & 0 deletions napi/angular-compiler/test/fixtures/downlevel-async/dep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export async function load(): Promise<string> {
return 'real'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { load } from './dep'

export async function run(): Promise<string> {
return await load()
}
8 changes: 4 additions & 4 deletions napi/angular-compiler/test/transform-for-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }`,
Expand All @@ -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/)
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading