mattlewis92 / mattlewis92/oxc-angular-testing

`emitDecoratorMetadata`: `ctorParameters` references a type-only constructor-param type, keeping the import (breaks under native-ESM / optimized deps)

Open
#4 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
0
Forks
2
Avg merge
27d 19h
Merged PRs (30d)
1

Description

Summary

When emitDecoratorMetadata is on, the Angular constructor-parameters downlevel emits the generated ctorParameters static with an unguarded reference to the parameter's type (type: Signal). When that type is a type-only import (an interface/type, e.g. @angular/core's Signal), the reference keeps the import that would otherwise be elided. Since a type-only symbol has no runtime export, the kept import fails to link at runtime under a native-ESM loader (Vitest browser mode), and the whole module errors before any test runs:

SyntaxError: The requested module '/…/deps/@angular_core.js' does not provide an export named 'Signal'

Interestingly, the sibling design:paramtypes emission in the same output is already guarded with an Object fallback — but it still references Signal, so the import is kept regardless.

Minimal repro

// repro.cjs — node repro.cjs
const { transform } = require('@oxc-angular-testing/transform');
const opts = { module: 'esm', experimentalDecorators: true, emitDecoratorMetadata: true };

const typeOnly =
  "import { Signal } from '@angular/core';\n" +
  'export function f(s: Signal<number>) { return s; }';

const ctorParam =
  "import { Inject, Injectable, Signal } from '@angular/core';\n" +
  'export const TOKEN = {};\n' +
  '@Injectable()\n' +
  'export class Svc { constructor(@Inject(TOKEN) private s: Signal<number>) {} }';

for (const [name, src] of [['type-only usage', typeOnly], ['ctor-param + metadata', ctorParam]]) {
  const code = String(transform(src, 'x.ts', opts).code);
  console.log(name, '-> import kept:', /import\s*\{[^}]*\bSignal\b/.test(code));
}

Output:

type-only usage       -> import kept: false   ✅ elided correctly
ctor-param + metadata -> import kept: true    ❌ bug

Actual emitted output (case B)

import { Inject, Injectable, Signal } from "@angular/core";   // ← kept
import _decorateMetadata from "@oxc-project/runtime/helpers/decorateMetadata";
import _decorate from "@oxc-project/runtime/helpers/decorate";
var _ref;
export const TOKEN = {};
let Svc = class Svc {
  constructor(s) { this.s = s; }
  static {
    this.ctorParameters = () => [{
      type: Signal,                                           // ← unguarded value reference
      decorators: [{ type: Inject, args: [TOKEN] }]
    }];
  }
};
Svc = _decorate([Injectable(), _decorateMetadata("design:paramtypes",
  [typeof (_ref = typeof Signal !== "undefined" && Signal) === "function" ? _ref : Object])], Svc);  // ← already guarded
export { Svc };

Expected

The transform can't know Signal is an interface without type info, but it already knows (from import-elision analysis) that the binding is used only in type positions. In that case the emitted metadata should not reference the symbol — emit Object (or undefined) for ctorParameters.type the same way design:paramtypes already falls back to Object. The import { Signal } then becomes elidable again (no value use remains), matching tsc's output, and native-ESM runtimes link fine.

(For an @Inject(TOKEN) parameter the reflected type is never read for DI anyway — the token resolves it — so degrading it to Object is behaviorally safe.)

Impact

Blocks Vitest browser-mode tests that mount any Angular graph containing a service/directive with a type-only @Inject'd constructor parameter — a common, correct pattern (@Inject(TOKEN) x: Signal<T>, or any injected-by-token interface). Invisible in the real app bundle (esbuild/terser fold the dead metadata) and under the jest/CJS runtime (tolerates the missing binding); only the native-ESM browser runner surfaces it.

Versions

  • @oxc-angular-testing/transform 0.0.12, @oxc-angular-testing/vitest 0.0.13
  • @oxc-project/runtime ^0.134.0
  • Angular 21.2.x, Vitest 4.1.8, Vite 8.0.16

Workaround (for reference)

Stub the type-only @angular/core exports (Signal, WritableSignal, InputSignal, ModelSignal) as runtime no-ops on the dep-optimized bundle via an esbuild optimizeDeps onLoad plugin, so the kept import links. Works, but it's per-consumer and only covers the symbols we hit — the transform-level fix above would remove the need.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with repro.cjs and the transform(...) entry point, then trace the constructor-parameter metadata emission alongside the design:paramtypes emission. Run node repro.cjs; done means the ctor-param case no longer keeps the type-only Signal import and native-ESM linking can proceed without the missing export.

Written by the indexing model from the issue text.

Assessment

Tech stack
angular, javascript, rust, typescript
Domain
build-system, testing, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.