fix(compiler): recognise Angular decorators and signal APIs by their @angular/core import, like ngtsc - #504
Conversation
@angular/core import, like ngtsc
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
8f170d7 to
b84d218
Compare
@angular/core import, like ngtsc@angular/core import, like ngtsc
df18eb1 to
940c32d
Compare
940c32d to
dbf5681
Compare
dbf5681 to
0d8951f
Compare
0d8951f to
8606c3e
Compare
…ore import, like ngtsc
`@Component`, `@Directive`, `@Pipe`, `@Injectable` and `@NgModule` were
matched by name. Like ngtsc's `findAngularDecorator`, a class decorator
is now Angular's only when it's imported from `@angular/core`: by name
under any alias (`import {Component as Cmp}`), or through a namespace
import (`@ng.Component()`). This is the rule #504 uses for member
decorators, and the same check (`angular_core_decorator`).
Checked against @angular/compiler-cli 22.1.7 (44 probes in
tests/fixtures/class_decorators_ngtsc.json, 40 compared):
- Another library's, a local, an undeclared or a default-imported
`@Component` (and the other four) is left on the class, which isn't
compiled, and its `inputs:` aren't checked.
- One re-exported through another module (`import {Component} from
'./shared'`) is left alone too: ngtsc follows the re-export, oxc can't.
- An aliased one (`@Cmp`, `@D`, `@P`, `@M`, `@Inj`) and a namespaced
`@ng.Injectable` are now compiled; `setClassMetadata` lists them as
written (`type: Cmp`). An aliased `@Component` keeps `templateUrl` in
`setClassMetadata`, as ngtsc does.
- `import {Injectable as Component}` compiles as an `@Injectable`.
This holds for AOT compilation and decorator removal, setClassMetadata,
the `@Service` collision check, JIT, the hoisting of declarations a
decorator references, NAPI `extractComponentMetadataSync` /
`extractComponentUrls` / the pipe and class-metadata APIs, and the Vite
plugin: its quick decorator check lets `@Cmp(...)` / `@ng.Component(...)`
files through, and the HMR template/styles diff only blanks the
components the compiler compiled. The public `extract_pipe_metadata`,
`extract_injectable_metadata`, `extract_ng_module_metadata` and
`find_*_decorator_span`, which don't get the file's imports, still match
by name (`extract_pipe_metadata_in` takes them).
Not matched to ngtsc, on purpose: an aliased `@Injectable` gets a
factory (ngtsc's `needsFactory` compares the written name with
'Injectable', so its `ɵprov` points at a missing `ɵfac`).
117 unit-test sources that used `@Component` / `@Directive` without
importing it now import it from `@angular/core`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@angular/core import, like ngtsc@angular/core import, like ngtsc
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa0db68028
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const decorators = locateComponentDecorators(code) | ||
| const ranges: Array<[number, number]> = [] | ||
| for (const d of decorators) { | ||
| if (!componentClassNames?.has(d.className)) continue |
There was a problem hiding this comment.
Strip aliased component metadata during HMR
When a component uses a newly supported aliased decorator such as @Cmp(...) or @ng.Component(...), locateComponentDecorators() returns no entry because it only recognizes the literal @Component form. Consequently this filter never sees the compiled class, so an inline template or styles edit changes the cached stripped source and incorrectly triggers a full page reload instead of component HMR. The stripping path needs to recognize the same imported decorator forms as the compiler.
Useful? React with 👍 / 👎.
…like ngtsc
Checked against @angular/compiler-cli 22.1.7 (probes are in the fixture:
`signal-*`, `shorthandRef-*`):
- Signal members were compiled by the name of the function they call. So
`value = input(0)` with a local `input`, one imported from './other', an
undeclared one, or `local.input(0)` became a signal input
(`value: [1, "value"]`), a foreign `viewChild()` a signal query, and so
on, where ngtsc sees a plain property; an aliased
`import {input as inp}` was missed. Like ngtsc's
`tryParseInitializerApi`, `input` / `model` / `output` / the query
functions (`outputFromObservable` from `@angular/core/rxjs-interop`)
now count only when imported from their module by name, under any
alias, or through a namespace import (`ng.input()`), plus the
`.required` forms. This holds for the `ɵdir`/`ɵcmp` definition, the
`.d.ts`, the synthetic `setClassMetadata` / JIT `propDecorators`, and
NAPI's `extractComponentMetadataSync` / class metadata. The public
`extract_*` / `build_prop_decorators_metadata` functions, which aren't
given the file's imports, still match by name. `ng.input(0, {alias})`
and `ng.model(0, {alias})` now read their alias too.
- A transform that resolves to an imported function or a global is
emitted by the name ngtsc gives it (the reference's identity in the
file): `const transform = booleanAttribute` with `{transform}` or
`transform: t` now emits `booleanAttribute`, not `transform` / `t`;
`import {booleanAttribute as ba}` emits `ba`; and `o.t` in a helper
called with `{t: booleanAttribute}` compiles to `booleanAttribute`
instead of being reported. A value computed from an import (a call, a
member, a condition on one) is still kept as written.
- A shorthand property naming an import (`{transform}` with
`import {toNum as transform}`, `{name: 'x', alias}` with an imported
`alias`) can't be evaluated by ngtsc (TypeScript returns the alias
symbol, which has no value declaration). oxc now treats it the same
way: "Input transform must be a function" for a transform, and an
imported alias or `required` is ignored, as ngtsc does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…port, and keep JIT's i0 free
Member decorators (`@Input`, `@Output`, `@HostBinding`, `@HostListener`,
`@ViewChild(ren)`, `@ContentChild(ren)`) were matched by name. Like ngtsc,
the compiler now only treats one imported from `@angular/core` as Angular's:
by name under any alias (`import { Input as In }`), or through a namespace
import (`@core.Input()`). A same-named decorator from another module, a local
function or an undeclared name is left alone:
- not compiled into the definition, and its options aren't checked
(`@Input({ transform: 5 })` from `./foreign` no longer reports "Input
transform must be a function")
- not removed from the class, and not listed in `setClassMetadata`
- in JIT, applied with `__decorate` instead of moved to `propDecorators`
An aliased or namespaced one is compiled, removed from the class (a
namespaced `@core.Input()` was compiled but left on the class) and, in JIT,
listed in `propDecorators` as written (`type: In`, `type: core.Input`).
The public `extract_*` functions, which don't get the file's imports, still
match by name.
JIT synthesis of signal-member decorators referenced `@angular/core` as a
new `import * as i0`, which redeclared any `i0` the file already had (a
SyntaxError; e.g. `import { input as i0 }`). Like ngtsc's `ImportManager`, it
now reuses the file's own `import * as x from '@angular/core'`, or names the
new import `i0_1`, `i0_2`, ... when `i0` is taken.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…uator, like ngtsc
A same-file value passed to a member decorator lost its alias with no
error: `const NAME = 'y'` with `@Input(NAME)` compiled to `x: "x"` (ngtsc:
`x: [0, "y", "x"]`), `@Input(OPTS)` with `const OPTS = {alias: 'y',
required: true}` dropped the alias and `required`, and `@Output(NAME)`
compiled to `e: "e"` (ngtsc: `e: "y"`). Only literals were read.
Like ngtsc's `tryParseInputFieldMapping` / `tryParseDecoratorOutput`, the
argument is now evaluated (checked against @angular/compiler-cli 22.1.7):
- `@Input`: a string (a const, `let`, template literal, `'a' + 'b'`,
`as const`, a ternary, a same-file function call, ...) is the alias; an
object (a const, a spread, `{alias: NAME}`) gives `alias`, `required`
and `transform`. This holds for the `ɵdir`/`ɵcmp` inputs, the `.d.ts`
and NAPI's `extractComponentMetadataSync`, which now also gets the
transform ngtsc emits.
- `@Output`: a string is the alias.
- ngtsc's errors: "@input can have at most one argument, got N
argument(s)" (named as written: `@In` for `import {Input as In}`),
"@input decorator argument must resolve to a string or an object
literal" (numbers, booleans, `undefined`, arrays, enum members,
`declare`d values; `null` is allowed), and the same two for `@Output`
("must resolve to a string", which also rejects `null` and objects).
- An imported `@Output(NAME)` (`ns.NAME`, `` `${NAME}` ``, a local copy)
gets the "imported from another module" error, like `@Input(OPTS)`.
The public `extract_*` functions, which don't get the file's imports,
still read literals only.
Left as before: ngtsc's "Input/Output 'y' is bound to both ..." error,
and `@Output()` on a setter (ngtsc compiles it; oxc doesn't read outputs
from methods).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…its name A global like the DOM's `atob` is now assumed to be a transform function (see the change below in the stack). Reached through a variable, a helper, an object or a condition (`const t = atob` and `transform: t`), it was emitted as written (`t`), where ngtsc emits `atob`. It now gets the same reference identity as an ES lib global like `parseInt`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and outputs like ngtsc `@Input() value = input(0)` compiled as a plain input with no error; ngtsc reports "Using @input with a signal input is not allowed." oxc only looked at the initializer of a member also listed in `inputs:` (for a different error), and had no such check. The same went for `@Input` on `model()`, and `@Output` on `output()`, `outputFromObservable()` or `model()`. Each is now reported on the decorator with ngtsc's message, in its order: an input's check comes before its `@Input` arguments are read, an output's after its `@Output` arguments. The call counts only when it's Angular's API by its import (any alias, a namespace, `.required`), so a local or foreign `input()` is left alone. The compiled output is unchanged, as for other errors. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and helper-returned transforms
`@Input(LOCAL || NAME)`, `@Input({alias: LOCAL || NAME, required: true || FLAG})`,
`@Output(LOCAL || NAME)` and `transform: 0 || booleanAttribute` now compile like
ngtsc (the evaluator fix at the bottom of the stack). Also pins
`@Input(opts({t: booleanAttribute}))`, which emits `booleanAttribute` through
the reference identity this PR adds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rom namespaces with ngtsc Arguments of member `@Input(...)` / `@Output(...)` read through `typeof NS.X` or `import S = NS.X` (a name, or options with `alias` and `required`) now evaluate like ngtsc, `NS.X` in a value position stays dynamic, and a namespace is a reference to it. Add those cases to the ngtsc snapshot. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ore import, like ngtsc
`@Component`, `@Directive`, `@Pipe`, `@Injectable` and `@NgModule` were
matched by name. Like ngtsc's `findAngularDecorator`, a class decorator
is now Angular's only when it's imported from `@angular/core`: by name
under any alias (`import {Component as Cmp}`), or through a namespace
import (`@ng.Component()`). This is the rule #504 uses for member
decorators, and the same check (`angular_core_decorator`).
Checked against @angular/compiler-cli 22.1.7 (44 probes in
tests/fixtures/class_decorators_ngtsc.json, 40 compared):
- Another library's, a local, an undeclared or a default-imported
`@Component` (and the other four) is left on the class, which isn't
compiled, and its `inputs:` aren't checked.
- One re-exported through another module (`import {Component} from
'./shared'`) is left alone too: ngtsc follows the re-export, oxc can't.
- An aliased one (`@Cmp`, `@D`, `@P`, `@M`, `@Inj`) and a namespaced
`@ng.Injectable` are now compiled; `setClassMetadata` lists them as
written (`type: Cmp`). An aliased `@Component` keeps `templateUrl` in
`setClassMetadata`, as ngtsc does.
- `import {Injectable as Component}` compiles as an `@Injectable`.
This holds for AOT compilation and decorator removal, setClassMetadata,
the `@Service` collision check, JIT, the hoisting of declarations a
decorator references, NAPI `extractComponentMetadataSync` /
`extractComponentUrls` / the pipe and class-metadata APIs, and the Vite
plugin: its quick decorator check lets `@Cmp(...)` / `@ng.Component(...)`
files through, and the HMR template/styles diff only blanks the
components the compiler compiled. The public `extract_pipe_metadata`,
`extract_injectable_metadata`, `extract_ng_module_metadata` and
`find_*_decorator_span`, which don't get the file's imports, still match
by name (`extract_pipe_metadata_in` takes them).
Not matched to ngtsc, on purpose: an aliased `@Injectable` gets a
factory (ngtsc's `needsFactory` compares the written name with
'Injectable', so its `ɵprov` points at a missing `ɵfac`).
117 unit-test sources that used `@Component` / `@Directive` without
importing it now import it from `@angular/core`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… assuming it's a function Only an imported function named directly (`transform: fn`, or one an expression passes through unchanged, like `0 || fn`) is assumed to be a function. A value computed from an import (`!FLAG`, `make()`, `Helpers.fn`, `FLAG ? a : b`, `fn || a`, `FLAG && a`) now gets the "imported from another module" error in both `inputs:` and `@Input`, where it used to be emitted as written with no diagnostic (`!FLAG` is a boolean: a TypeError at runtime). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fa0db68 to
3e0b07f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3e0b07ff2c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let kind = match crate::directive::angular_class_decorator(decorator, string_consts) { | ||
| Some("Component") => AngularDecoratorKind::Component, | ||
| Some("Directive") => AngularDecoratorKind::Directive, | ||
| Some("Pipe") => AngularDecoratorKind::Pipe, | ||
| Some("Injectable") => AngularDecoratorKind::Injectable, | ||
| Some("NgModule") => AngularDecoratorKind::NgModule, |
There was a problem hiding this comment.
Resolve constructor decorators for newly accepted aliases
When an aliased class decorator is newly accepted here, constructor decorators still use written-name matching in extract_jit_ctor_params, extract_param_dependency, and extract_angular_decorators_from_param. For example, import { Component as Cmp, Inject as Inj } from '@angular/core'; @Cmp(...) class C { constructor(@Inj(TOKEN) value: unknown) {} } is now compiled, but Inj is ignored and then removed from the output, so the generated factory/ctorParameters loses the explicit injection token. Constructor decorators need the same import-aware canonical-name resolution before aliased classes can be compiled safely.
Useful? React with 👍 / 👎.
Stack (5/5): #493 inputs/outputs → #494 transform validation → #495
queries:→ #496.d.tstransform types → #504 signal API import identity. This is #504.Recognises signal members by the
@angular/coreimport of the function they call, like ngtsc, and emits a transform aliased through a variable the way ngtsc does.The bug
Signal members were compiled by the name of the function they call:
compiled to
inputs: { value: [1, "value"] }(a signal input). ngtsc sees a plain property here, so it emitsinputs: { value: "value" }. The same happened forlocal.input(), a foreignoutput()/model(), and a foreignviewChild()(which added a signal query). An aliasedimport { input as inp }was missed. #493 and #495 already checked the import for their new collision errors, but compilation didn't.Separately,
const transform = booleanAttributewithinputs: [{ name: 'x', transform }]emittedtransform, where ngtsc emitsbooleanAttribute.The fix
Signal APIs. One check (
initializer_api, reusing the fix(directive): compileinputs:/outputs:declared in decorator metadata #493/fix(queries): compile decoratorqueries:and match ngtsc's query options #495 import check) matches ngtsc'stryParseInitializerApi. It acceptsf()/f.required()withfimported by name from the API's module (any alias), andns.f()/ns.f.required()through a namespace import. The module is@angular/core, or@angular/core/rxjs-interopforoutputFromObservable. Every place that reads signal members uses it:.d.tssetClassMetadataand JITpropDecoratorsextractComponentMetadataSyncand class metadataThe public
extract_*andbuild_prop_decorators_metadatafunctions don't get the file's imports, so they still match by name.ng.input(0, { alias })andng.model(0, { alias })now read their alias.Transforms. A transform that resolves to an imported function or a global is emitted by its reference identity, like ngtsc's
getIdentityIn. That is the name it was first reached by in the file:booleanAttributeforconst t = booleanAttribute+transform: tor{ transform }baforimport { booleanAttribute as ba }booleanAttributeforo.tin a helper called with{ t: booleanAttribute }, which was an error beforeOnly an imported function named directly (or passed through unchanged, like
0 || fn) is assumed to be a function; a transform computed from an import (!FLAG,make(),Helpers.fn,FLAG && a,fn || a) gets the "imported from another module" error ininputs:and@Input. A shorthand naming an import ({ transform }withimport { f as transform }) can't be evaluated, as in ngtsc, so it now gets "Input transform must be a function", and an imported shorthandalias/requiredis ignored, as ngtsc does.Member decorators (
@Input…@ContentChildren) also count as Angular's only when imported from@angular/core(any alias, or a namespace import), in compilation, decorator stripping,setClassMetadataand JITpropDecorators. A foreign or undeclared one is left on the class, like ngtsc. JIT synthesis reuses the file's own@angular/corenamespace import, or names the new onei0_1,i0_2, … wheni0is already taken.Member
@Input(...)/@Output(...)arguments are evaluated like ngtsc: a same-file const, template literal or concatenation gives the alias, and a const or spread object givesalias/required/transform. Before, only literals were read and the alias was dropped silently. Bad arguments get ngtsc's "must resolve to a string (or an object literal)" and "can have at most one argument" errors, and an imported@Output(NAME)reports the "imported from another module" error, like@Input(OPTS).@Inputoninput()/model()and@Outputonoutput()/outputFromObservable()/model()report ngtsc's "Using @input with a signal input is not allowed." (and its other variants) on the decorator, in ngtsc's order. Only Angular's API counts, matched by its import. A global transform declared outside the file (const t = atob) is emitted by its name, likeparseInt.Angular's class decorators (
@Component,@Directive,@Pipe,@Injectable,@NgModule) count only when imported from@angular/core, by name under any alias or through a namespace import, like ngtsc'sisAngularCore. This is the same check member decorators use, and it applies to AOT, JIT, setClassMetadata, hoisting, NAPI metadata and the Vite plugin. A same-named decorator from another library, a local one or an undeclared one is left on the class, which isn't compiled. Like ngtsc, a decorator imported through a module that re-exports Angular's (import {Component} from './shared') isn't recognised either, so import Angular's decorators from@angular/coredirectly.Tests
signal-*: named, aliased, namespace, rxjs-interop, local, undeclared, other-module and default importsshorthandRef-*: const, let, typed, destructured, chained,as, ternary, spread and@Input(OPTS)forms, aliased imports, helpers, shorthand imports, and queriescpropDecoratorssynthesis, and the NAPIextractComponentMetadataSync. All of them failed before this fix.No test source needed new imports.
🤖 Generated with Claude Code