Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions crates/oxc_angular_compiler/src/browser.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! 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<String>,
pub errors: Vec<String>,
}

/// 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}");
}
}
1 change: 1 addition & 0 deletions crates/oxc_angular_compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions napi/angular-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,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 @@ -231,6 +232,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
Expand Down
33 changes: 33 additions & 0 deletions napi/angular-compiler/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
}

/** Result of class metadata compilation. */
export interface ClassMetadataNapiCompileResult {
/**
Expand Down Expand Up @@ -1019,6 +1034,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<BrowserTransformResult>

/**
* 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. */
Expand Down
4 changes: 4 additions & 0 deletions napi/angular-compiler/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,8 @@ const {
parseComponentId,
transformAngularFile,
transformAngularFileSync,
transformForBrowser,
transformForBrowserSync,
Severity,
} = nativeBinding
export { compileClassMetadata }
Expand All @@ -954,5 +956,7 @@ export { optimizeAngularPackage }
export { parseComponentId }
export { transformAngularFile }
export { transformAngularFileSync }
export { transformForBrowser }
export { transformForBrowserSync }
export { Severity }
export const __napiBindingTarget = __napiLoadedBindingTarget
83 changes: 83 additions & 0 deletions napi/angular-compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3253,3 +3253,86 @@ pub fn link_angular_package(
) -> AsyncTask<LinkAngularPackageTask> {
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<bool>,
/// `async`/`await`, async generators and `for await` as generators, for zone.js (default: true)
pub lower_async: Option<bool>,
}

/// Result of `transformForBrowser`.
#[napi(object)]
pub struct BrowserTransformResult {
pub code: String,
pub map: Option<String>,
pub errors: Vec<String>,
}

/// 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<BrowserTransformOptions>,
) -> 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<BrowserTransformOptions>,
}

#[napi]
impl Task for TransformForBrowserTask {
type JsValue = BrowserTransformResult;
type Output = BrowserTransformResult;

fn compute(&mut self) -> napi::Result<Self::Output> {
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<Self::JsValue> {
Ok(result)
}
}

/// `transformForBrowserSync` on a worker thread
#[napi]
pub fn transform_for_browser(
code: String,
filename: String,
options: Option<BrowserTransformOptions>,
) -> AsyncTask<TransformForBrowserTask> {
AsyncTask::new(TransformForBrowserTask { code, filename, options })
}
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 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')
})
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/lower-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'
}
5 changes: 5 additions & 0 deletions napi/angular-compiler/test/fixtures/lower-async/service.ts
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()
}
Loading