Skip to content

OXC Linker Omits declarations and exports from ɵɵdefineNgModule #94

@tomer953

Description

@tomer953

Summary

The OXC Rust-based Angular linker (linkAngularPackage) produces incorrect ɵɵdefineNgModule output when linking ɵɵngDeclareNgModule partial declarations from pre-compiled Angular libraries. The linker strips declarations, imports, and exports from the linked ɵɵdefineNgModule() call, leaving only { type: NgModule }.

Without declarations and exports, Angular's runtime DepsTracker cannot resolve which pipes/directives the NgModule provides — so any standalone component importing that NgModule gets an empty pipe/directive scope, causing NG0302 / NG0303 errors at runtime.


Runtime Error

ERROR RuntimeError: NG0302: The pipe 'translate' could not be found
in the 'LeftNavFooterComponent' component. Verify that it is included
in the '@Component.imports' of this component.
    at getPipeDef (core-_KG6psjB.js:22491:23)
    at Module.ɵɵpipe (core-_KG6psjB.js:22451:13)
    at LeftNavFooterComponent_Template (left-nav-footer.component.ts:563:12)

Root Cause

The Buggy Code

In crates/oxc_angular_compiler/src/linker/mod.rs, the link_ng_module function contains this incorrect logic:

fn link_ng_module(
    meta: &ObjectExpression<'_>,
    source: &str,
    ns: &str,
    type_name: &str,
) -> Option<String> {
    let mut parts = vec![format!("type: {type_name}")];

    // ❌ WRONG COMMENT AND WRONG BEHAVIOR:
    // In AOT mode (selectorScopeMode: Omit), declarations/imports/exports are never emitted.
    // Only type, bootstrap, schemas, and id are included.
    if let Some(bootstrap) = get_property_source(meta, "bootstrap", source) {
        parts.push(format!("bootstrap: {bootstrap}"));
    }
    if let Some(schemas) = get_property_source(meta, "schemas", source) {
        parts.push(format!("schemas: {schemas}"));
    }
    // ... declarations, imports, exports are NEVER read or emitted
}

The comment references "AOT mode (selectorScopeMode: Omit)" — this is a concept from the Angular compiler's own source compilation pipeline, where the compiler can choose to omit scope information from ɵɵdefineNgModule and instead emit it via a separate ɵɵsetNgModuleScope side-effect call. This does not apply to the linker.

The linker's job is to convert ɵɵngDeclareNgModule partial declarations (from pre-compiled library FESM bundles) into full ɵɵdefineNgModule calls. The partial declaration always contains declarations and exports when the NgModule has them, and the linker must pass them through to the output.

Input vs. Output Comparison

Input (from @ngx-translate/core FESM bundle):

TranslateModule.ɵmod = i0.ɵɵngDeclareNgModule({
  minVersion: "14.0.0",
  version: "16.0.0",
  ngImport: i0,
  type: TranslateModule,
  declarations: [TranslatePipe, TranslateDirective],  // ← present in partial
  exports: [TranslatePipe, TranslateDirective]         // ← present in partial
});

Expected output (what standard Vite linker produces ✅):

static ɵmod = /* @__PURE__ */ ɵɵdefineNgModule({
  type: TranslateModule,
  declarations: [TranslatePipe, TranslateDirective],  // ✅ PRESENT
  exports: [TranslatePipe, TranslateDirective]         // ✅ PRESENT
});

Actual output (what OXC linker produces ❌):

static ɵmod = ɵɵdefineNgModule({ type: TranslateModule });
// ❌ declarations: [...] is MISSING
// ❌ exports: [...] is MISSING

Why This Breaks at Runtime

Angular's DepsTracker.getStandaloneComponentScope() calls getNgModuleDef(TranslateModule) and walks ngModDef.exports to find which pipes/directives are available in the component's template scope:

collectExportsFromNgModule(ngModDef, scope) {
  for (const exported of ngModDef.exports) {  // ← ngModDef.exports is [] (OXC bug)
    const pipeDef = getPipeDef(exported);
    if (pipeDef) scope.pipes.add(exported);
  }
}

Since ɵɵdefineNgModule({ type: TranslateModule }) produces a module def with empty exports, TranslatePipe is never added to the component's pipe scope. When the template's ɵɵpipe(5, "translate") runs, getPipeDef("translate") finds nothing and throws NG0302.


Proposed Fix

In crates/oxc_angular_compiler/src/linker/mod.rs, update link_ng_module to read and emit declarations, imports, and exports:

/// Link ɵɵngDeclareNgModule → ɵɵdefineNgModule.
fn link_ng_module(
    meta: &ObjectExpression<'_>,
    source: &str,
    ns: &str,
    type_name: &str,
) -> Option<String> {
    let mut parts = vec![format!("type: {type_name}")];

    // declarations and exports must be included so Angular's runtime DepsTracker can resolve
    // which pipes/directives the NgModule provides to standalone components that import it.
    // Without these, getNgModuleDef(type).exports is empty and NG0302/NG0303 errors occur.
    if let Some(declarations) = get_property_source(meta, "declarations", source) {
        parts.push(format!("declarations: {declarations}"));
    }
    if let Some(imports) = get_property_source(meta, "imports", source) {
        parts.push(format!("imports: {imports}"));
    }
    if let Some(exports) = get_property_source(meta, "exports", source) {
        parts.push(format!("exports: {exports}"));
    }
    if let Some(bootstrap) = get_property_source(meta, "bootstrap", source) {
        parts.push(format!("bootstrap: {bootstrap}"));
    }
    if let Some(schemas) = get_property_source(meta, "schemas", source) {
        parts.push(format!("schemas: {schemas}"));
    }
    let id_source = get_property_source(meta, "id", source);
    if let Some(id) = id_source {
        parts.push(format!("id: {id}"));
    }

    let define_call = format!("{ns}.\u{0275}\u{0275}defineNgModule({{ {} }})", parts.join(", "));
    if let Some(id) = id_source {
        Some(format!(
            "(() => {{ {ns}.\u{0275}\u{0275}registerNgModuleType({type_name}, {id}); return {define_call}; }})()"
        ))
    } else {
        Some(define_call)
    }
}

Tests That Need Updating

1. Rename and fix test_link_ng_module_omits_declarations_imports_exports

This test (line ~3293) currently asserts the wrong behavior — it asserts that declarations, imports, and exports are absent from the output. It must be updated to assert they are present.

Current (incorrect) test:

#[test]
fn test_link_ng_module_omits_declarations_imports_exports() {
    // ...
    assert!(
        !result.code.contains("declarations:"),
        "Should NOT contain declarations in AOT mode, got:\n{}",
        result.code
    );
    assert!(
        !result.code.contains("imports:"),
        "Should NOT contain imports in AOT mode, got:\n{}",
        result.code
    );
    assert!(
        !result.code.contains("exports:"),
        "Should NOT contain exports in AOT mode, got:\n{}",
        result.code
    );
}

Corrected test:

#[test]
fn test_link_ng_module_includes_declarations_imports_exports() {
    // declarations and exports MUST be present in the linked ɵɵdefineNgModule call.
    // Angular's runtime DepsTracker reads ngModDef.exports to resolve which pipes/directives
    // an NgModule provides to standalone components that import it.
    let allocator = Allocator::default();
    let code = r#"
import * as i0 from "@angular/core";
class MyModule {
}
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.0.0", ngImport: i0, type: MyModule, declarations: [FooComponent], imports: [CommonModule], exports: [FooComponent] });
"#;
    let result = link(&allocator, code, "test.mjs");
    assert!(result.linked, "Should have linked the declaration");
    assert!(
        result.code.contains("declarations:"),
        "Should contain declarations (required for runtime scope resolution), got:\n{}",
        result.code
    );
    assert!(
        result.code.contains("imports:"),
        "Should contain imports, got:\n{}",
        result.code
    );
    assert!(
        result.code.contains("exports:"),
        "Should contain exports (required for standalone component pipe/directive scope), got:\n{}",
        result.code
    );
}

2. Add regression test for @ngx-translate/core TranslateModule

#[test]
fn test_link_ng_module_translate_module_regression() {
    // Regression test for NG0302: TranslatePipe not found.
    // @ngx-translate/core's TranslateModule declares and exports TranslatePipe/TranslateDirective.
    // The linked ɵɵdefineNgModule MUST include declarations and exports so that standalone
    // components importing TranslateModule can resolve the 'translate' pipe at runtime.
    let allocator = Allocator::default();
    let code = r#"
import * as i0 from "@angular/core";
class TranslatePipe {}
TranslatePipe.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "16.0.0", ngImport: i0, type: TranslatePipe, name: "translate", pure: false });
class TranslateDirective {}
TranslateDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "16.0.0", type: TranslateDirective, selector: "[translate],[ngx-translate]", ngImport: i0 });
class TranslateModule {}
TranslateModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "16.0.0", ngImport: i0, type: TranslateModule, declarations: [TranslatePipe, TranslateDirective], exports: [TranslatePipe, TranslateDirective] });
"#;
    let result = link(&allocator, code, "ngx-translate-core.mjs");
    assert!(result.linked, "Should have linked the declarations");

    assert!(
        result.code.contains("declarations: [TranslatePipe, TranslateDirective]"),
        "ɵɵdefineNgModule must include declarations so Angular runtime can build module scope, got:\n{}",
        result.code
    );
    assert!(
        result.code.contains("exports: [TranslatePipe, TranslateDirective]"),
        "ɵɵdefineNgModule must include exports so standalone components can resolve TranslatePipe, got:\n{}",
        result.code
    );
}

Affected Libraries

Any Angular library that uses NgModule-based architecture with declarations and exports is affected. Known examples:

Library NgModule Exported Pipes/Directives
@ngx-translate/core TranslateModule TranslatePipe, TranslateDirective
@angular/common CommonModule NgIf, NgFor, AsyncPipe, DatePipe, etc.
Any library using ɵɵngDeclareNgModule with declarations/exports

Relationship to Existing Bug

The existing ISSUE_standalone_flag_missing.md covers the OXC linker omitting dependencies from ɵɵdefineComponent. This is a separate but related bug in the same linker:

Partial Declaration Missing Field Runtime Error
ɵɵngDeclareComponent dependencies Component's template directives not resolved
ɵɵngDeclareNgModule declarations, exports NgModule's pipe/directive exports not visible to importers → NG0302/NG0303

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions