mattlewis92 / mattlewis92/oxc-angular-testing
`emitDecoratorMetadata`: `ctorParameters` references a type-only constructor-param type, keeping the import (breaks under native-ESM / optimized deps)
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/transform0.0.12,@oxc-angular-testing/vitest0.0.13@oxc-project/runtime^0.134.0- Angular
21.2.x, Vitest4.1.8, Vite8.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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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