diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 49ac8cc03..6cc695d9c 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 +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. 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 +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..78a5861d2 --- /dev/null +++ b/napi/angular-compiler/test/lower-async.test.ts @@ -0,0 +1,146 @@ +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 { 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/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', code = SOURCE } = {}, +): 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) + }, + }, + code, + 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('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() + }) + + 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..d74958b85 --- /dev/null +++ b/napi/angular-compiler/vite-plugin/angular-async-lowering-plugin.ts @@ -0,0 +1,88 @@ +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$/ + +/** + * 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', + // 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: { + 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. `.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.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..1a035baf7 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 { 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' @@ -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 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 /** @@ -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, }), + asyncLoweringPlugin({ + zoneless: options.zoneless, + sourcemap: pluginOptions.sourceMap, + }), ].filter(Boolean) as Plugin[] }