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 49ac8cc03..c5bf62d04 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -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 } @@ -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 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/downlevel-async.test.ts b/napi/angular-compiler/test/downlevel-async.test.ts new file mode 100644 index 000000000..b5b8514f8 --- /dev/null +++ b/napi/angular-compiler/test/downlevel-async.test.ts @@ -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 { + 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 {\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) +}) diff --git a/napi/angular-compiler/test/fixtures/downlevel-async/async-mock.check.ts b/napi/angular-compiler/test/fixtures/downlevel-async/async-mock.check.ts new file mode 100644 index 000000000..0badd8e87 --- /dev/null +++ b/napi/angular-compiler/test/fixtures/downlevel-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 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') +}) diff --git a/napi/angular-compiler/test/fixtures/downlevel-async/client-environment.ts b/napi/angular-compiler/test/fixtures/downlevel-async/client-environment.ts new file mode 100644 index 000000000..ca7a634d9 --- /dev/null +++ b/napi/angular-compiler/test/fixtures/downlevel-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/downlevel-async/dep.ts b/napi/angular-compiler/test/fixtures/downlevel-async/dep.ts new file mode 100644 index 000000000..428761a9a --- /dev/null +++ b/napi/angular-compiler/test/fixtures/downlevel-async/dep.ts @@ -0,0 +1,3 @@ +export async function load(): Promise { + return 'real' +} diff --git a/napi/angular-compiler/test/fixtures/downlevel-async/service.ts b/napi/angular-compiler/test/fixtures/downlevel-async/service.ts new file mode 100644 index 000000000..19469dd1c --- /dev/null +++ b/napi/angular-compiler/test/fixtures/downlevel-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/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-downlevel-plugin.ts b/napi/angular-compiler/vite-plugin/angular-async-downlevel-plugin.ts new file mode 100644 index 000000000..dd9a15032 --- /dev/null +++ b/napi/angular-compiler/vite-plugin/angular-async-downlevel-plugin.ts @@ -0,0 +1,91 @@ +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 JSX_EXT_REGEX = /\.[jt]sx$/ +const SCRIPT_EXT_REGEX = /\.m?[jt]s$/ + +/** + * 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()` + * 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 asyncDownlevelPlugin({ + zoneless, + sourcemap, +}: { + zoneless: boolean | undefined + sourcemap: boolean +}): Plugin { + let downlevelAsync = false + + return { + 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) { + downlevelAsync = !(zoneless ?? !isZoneJsInstalled(config.root)) + }, + transform: { + filter: { + id: { include: SCRIPT_REGEX, exclude: /node_modules/ }, + code: /\basync\b/, + }, + async handler(code, id, options) { + 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`, + // 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, { + downlevelAsync: true, + sourcemap, + }) + if (result.errors.length > 0) { + this.error(result.errors.map((error) => error.message).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 ee1284497..afb5803e4 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -31,6 +31,7 @@ import { type AngularVersion, } from '#binding' +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' @@ -59,7 +60,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 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. + * + * Default: zoneless unless `zone.js` is installed in a `node_modules` above the + * Vite root. + */ zoneless?: boolean /** @@ -305,7 +316,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Like Angular CLI's `normalizeSourceMaps`, vendor maps are off unless the // object form turns them on; `sourceMap: true` only covers scripts. vendorSourceMap: typeof options.sourceMap === 'object' && (options.sourceMap.vendor ?? false), - zoneless: options.zoneless ?? false, fileReplacements, angularVersion: options.angularVersion, emitClassMetadata: options.emitClassMetadata ?? true, @@ -1696,6 +1706,10 @@ export function angular(options: PluginOptions = {}): Plugin[] { ssrManifestPlugin({ ssrEntry: options.ssrEntry, }), + asyncDownlevelPlugin({ + zoneless: options.zoneless, + sourcemap: pluginOptions.sourceMap, + }), ].filter(Boolean) as Plugin[] }