Skip to content

feat(class_metadata): inline templateUrl/styleUrls + default-on, matching ngc#299

Merged
Brooooooklyn merged 6 commits into
voidzero-dev:mainfrom
ashley-hunter:feat/inline-metadata-resources
May 27, 2026
Merged

feat(class_metadata): inline templateUrl/styleUrls + default-on, matching ngc#299
Brooooooklyn merged 6 commits into
voidzero-dev:mainfrom
ashley-hunter:feat/inline-metadata-resources

Conversation

@ashley-hunter
Copy link
Copy Markdown
Contributor

Summary

Brings OXC's setClassMetadata emission into byte-exact parity with Angular's ngc, so TestBed JIT recompilation works without users having to discover and flip a flag.

Three threads of work:

  1. Inline templateUrl / styleUrls / styleUrl into the metadata args (d1d3ac8). Pre-existing emission preserved the raw resource literals, so Angular's componentNeedsResolution(metadata) check tripped at TestBed time ('Component is not resolved: templateUrl…').

  2. Default emit_class_metadata to true at all three layers (Rust crate, NAPI binding, Vite plugin) — ngc always emits the call wrapped in (typeof ngDevMode === "undefined" || ngDevMode) && …, so production bundles tree-shake it. Off-by-default silently broke TestBed/Vitest setups.

  3. Match ngc's exact semantics, found via deeper audit:

    • Map.delete('templateUrl') + Map.set('template', …) ordering (append at end, not in-place) — the e2e compare does string-equality of the decorators payload so the in-place divergence would have silently miscompared.
    • Deduplicate when source illegally contained both template and templateUrl.
    • Gate inlining on the decorator's name being Component (matches Angular's if (dec.name !== 'Component') return dec;) — without this, @Inject({ templateUrl: '…' }) had its keys silently stripped.
    • resolve_template precedence: templateUrl wins over inline template (matches parseTemplateDeclaration); OXC was preferring inline, which is JIT's behavior, not AOT.

The Rust port now reads as a near-line-by-line mirror of transformDecoratorResources, with citations to the upstream source in the doc comments.

Empirical ngc parity

All 686 non-skipped fixtures across 22 categories of the e2e ngc-compare suite match byte-for-byte: animations, bindings, class-metadata, control-flow, defer, edge-cases, host-bindings, host-directives, i18n, pipes, providers, regressions, schemas, styles, templates, etc.

Test plan

  • cargo check --all-features
  • cargo test (21 binaries, 2685+ tests, all ok)
  • cargo fmt --all -- --check
  • cargo run -p oxc_angular_conformance && git diff --exit-code (1252/1252, git-clean)
  • pnpm build-dev + pnpm --filter ./napi/angular-compiler build:ts
  • pnpm test (193/193 vitest)
  • pnpm check (oxfmt + oxlint, clean)
  • pnpm test:e2e (34/34 playwright)
  • pnpm --filter @oxc-angular/compare compare --fixtures (686/686 against ngc 21.2.14)

Notes for reviewers

  • Behavior change: every component now emits setClassMetadata by default. Production strips it via ngDevMode. Anyone who actively wants the old behavior passes emitClassMetadata: false.
  • 14 integration snapshots updated to include the new (correctly-guarded) setClassMetadata block.
  • One assertion narrowed (test_standalone_component_omits_standalone_field) to scope its contains("standalone:true") check to the ɵɵdefineComponent literal — the metadata block faithfully preserves the source standalone: true, which is what ngc does.
  • Known limitations locked in by tests: spread elements containing resource fields (@Component({ ...config, … })) pass through unchanged; fixing requires structural pre-extraction spread resolution upstream of metadata.

ashley-hunter and others added 6 commits May 26, 2026 15:44
When emit_class_metadata is enabled, OXC was preserving the original
`templateUrl` and `styleUrls` literals in the setClassMetadata args.
TestBed's JIT recompilation path then trips on Angular's
`componentNeedsResolution` check ('Component is not resolved: templateUrl…').

Match Angular's reference behavior (transformDecoratorResources in
compiler-cli/src/ngtsc/annotations/component/src/resources.ts):
- Replace `templateUrl` in place with `template` carrying the inlined content.
- Drop `styleUrls` / `styleUrl` / `styles` and re-emit a single consolidated
  `styles` array, filtering whitespace-only entries.
- Bail out unchanged when the source has no resource fields.

The new `build_decorator_metadata_array` takes optional `inlined_template`
and `inlined_styles` params; the component transform call site passes the
resolved template and `metadata.styles` (which resolve_styles already merges
with inline source styles). Other callers — ctor params, prop decorators, the
NAPI standalone API — pass None.

The Vite plugin gains an `emitClassMetadata?: boolean` option that forwards
through to `TransformOptions`.
Angular's reference compiler (`ngc`) unconditionally emits `setClassMetadata`
calls, wrapped in `(typeof ngDevMode === "undefined" || ngDevMode) && …` so
production bundles tree-shake them. OXC was defaulting this off at every layer,
which silently broke TestBed/Vitest setups until users discovered the flag.

Flip the default to `true` at all three layers so behavior matches `ngc` end
to end:

  - Rust `TransformOptions::default()` (crate API)
  - NAPI binding `unwrap_or` (transformAngularFile JS entry point)
  - Vite plugin `?? true` (the `@oxc-angular/vite` user-facing option)

The flag remains an escape hatch — passing `false` still elides the call.

Test-driven: added `test/class-metadata-default.test.ts` covering default-on,
ngDevMode wrapping, explicit-false, and explicit-true. Updated 14 integration
snapshots to include the new (correctly-guarded) `setClassMetadata` block, and
scoped `test_standalone_component_omits_standalone_field` to the
`ɵɵdefineComponent` literal — the metadata block faithfully preserves the
source decorator's `standalone: true` (matching ngc), which the test was
inadvertently catching with a whole-file substring check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`transformDecoratorResources` in compiler-cli operates on a `Map<string,
ts.Expression>`. Its `templateUrl` → `template` substitution is:

    metadata.delete('templateUrl');
    metadata.set('template', …);

`Map.set` on a fresh key appends at the end; on an existing key it overwrites
in place. Two divergences in our port:

1. **Duplicate `template` keys** when source illegally contained both `template`
   and `templateUrl`. Source `template` fell through the loop's `_` arm and the
   `templateUrl` arm pushed a second `template:`, emitting invalid object syntax
   in strict mode and differing from ngc's single-key output.

2. **In-place ordering** for `templateUrl` replacement. We kept `template` at
   `templateUrl`'s original position; ngc's `delete` + `set` appends to the end
   of the Map's insertion order. The e2e compare does whitespace-normalized
   string equality on the decorators payload, so any component shaped like
   `{ selector, templateUrl, encapsulation }` would silently miscompare.

Implementation:
- Drop all of `templateUrl` / `styleUrls` / `styleUrl` / `styles` in the pass
  loop unconditionally (matches Angular's unconditional `metadata.delete`s).
- If `template` was in source AND `templateUrl` was in source, overwrite the
  existing `template` entry in place during the loop (preserves position,
  matches `Map.set` overwrite semantics).
- Otherwise, if `templateUrl` was in source, append `template` at the end
  after the loop (matches `Map.set` on a fresh key).
- Styles continue to be appended at the end — that already matched.

Test-driven: added two RED tests covering each divergence; both fail against
the previous in-place implementation and pass under the new logic. Pulled the
template-entry construction into `build_template_entry` since it's now used
in two places.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three deeper-audit findings, all confirmed by reading Angular's source
side-by-side and verified empirically by the e2e ngc-compare harness
(now 759/759 fixtures matching across 23 categories).

1. **Decorator-name guard.** `inline_component_resources` ran on
   `decorator_idx == 0 && arg_idx == 0` regardless of the decorator's
   identifier. Hidden by upstream filtering at the @component call site,
   but visible through `build_decorator_metadata_array`'s use for
   constructor-param decorators: `@Inject({ templateUrl: '…', styleUrls: […] })`
   (legal TS, semantically nonsensical) had its keys silently stripped.
   Added `is_component_decorator` check to the rewrite gate, matching
   `transformDecoratorResources`' opening `if (dec.name !== 'Component')
   return dec;`.

2. **`resolve_template` precedence.** When source had BOTH inline `template`
   and `templateUrl`, OXC preferred the inline content. Angular's AOT
   compiler (`parseTemplateDeclaration` in
   `compiler-cli/.../component/src/resources.ts`) checks
   `component.has('templateUrl')` first and returns immediately — `templateUrl`
   wins, inline `template` is silently ignored. (Angular's JIT runtime
   diverges via `componentNeedsResolution`, preferring inline — irrelevant
   here since OXC is AOT-equivalent.) Flipped the order in `resolve_template`
   and updated its docstring with the source citation. Affects both the
   runtime template parse and the metadata inlining; only manifests in
   illegal "both present" components.

3. **Spread elements.** `@Component({ ...config, … })` survives untouched —
   the spread argument isn't statically evaluated, so resource fields inside
   it can leak past `componentNeedsResolution` at runtime. Angular's
   reference operates on a post-spread-resolution `Map`, so it doesn't hit
   this. Fixing it requires structural upstream work (pre-extraction spread
   resolution) beyond this branch's scope. Added a locking-in test so any
   future change has to consciously decide what to do with spreads.

All three findings get RED tests written first; the fix flips them GREEN.
Total: 21 class-metadata-resources tests pass (up from 17 in the previous
commit), full Rust suite + JS suite + e2e ngc fixtures all 100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cosmetic line wrapping changes from `cargo fmt --check`; no behavior delta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lugin

The function-level doc for `build_decorator_metadata_array` said the
rewrite "matches `@analogjs/vite-plugin-angular`'s behavior". That's
misleading on two counts:

- The real reference is `transformDecoratorResources` in Angular's own
  `compiler-cli/.../component/src/resources.ts` (which the deeper
  `inline_component_resources` doc cites correctly with line semantics).
- Pointing at a third-party plugin in a generic library's API docs
  conflates the upstream source-of-truth with one downstream consumer.

Updated the comment to direct readers to the actual source-cited spec
right below it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Copy link
Copy Markdown
Member

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d06374a8b3

ℹ️ 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".

Comment thread crates/oxc_angular_compiler/src/component/transform.rs
@Brooooooklyn Brooooooklyn merged commit d83445f into voidzero-dev:main May 27, 2026
9 checks passed
Brooooooklyn added a commit that referenced this pull request May 27, 2026
After #299 turned `emit_class_metadata` on by default, the raw
`${UNRESOLVED}-tag` template literal is intentionally preserved verbatim
inside `ɵsetClassMetadata(...)` to mirror ngc — that's runtime metadata,
not the compiled selector. The original test asserted on the full output
and now panics on every CI run since d83445f landed on main.

Narrow the assertion to the `ɵcmp` definition itself and additionally
verify the compiled selector falls back to `ng-component`, matching the
test's stated intent ("unresolved interpolation must not produce a
partial/garbage selector").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brooooooklyn added a commit that referenced this pull request May 28, 2026
)

* fix(decorator): hoist consts referenced by emitted Ivy definitions

Closes #287.

When `@Component` metadata such as `providers` references a top-level
`const` declared *after* the class, the emitted `static ɵcmp` field
evaluates `ɵɵProvidersFeature([…])` eagerly at class-init time. With
the binding still in TDZ, this threw `ReferenceError` at module load.

Match Angular's official compiler by hoisting referenced VariableDeclaration
statements above the earliest class that needs them. Identifier
collection walks decorator metadata but stops at function/arrow/class
bodies, so lazy references like `useFactory: () => DEP` don't trigger
unnecessary moves. Function declarations are JS-hoisted already, and
class declarations are intentionally skipped to avoid clobbering the
transform pipeline's existing edits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: scope unresolved-interpolation assertion to ɵcmp selectors only

After #299 turned `emit_class_metadata` on by default, the raw
`${UNRESOLVED}-tag` template literal is intentionally preserved verbatim
inside `ɵsetClassMetadata(...)` to mirror ngc — that's runtime metadata,
not the compiled selector. The original test asserted on the full output
and now panics on every CI run since d83445f landed on main.

Narrow the assertion to the `ɵcmp` definition itself and additionally
verify the compiled selector falls back to `ng-component`, matching the
test's stated intent ("unresolved interpolation must not produce a
partial/garbage selector").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): transitive deps + deterministic dedup for shared statements

Two PR-review bugs in the hoist pass:

1. **Transitive deps were dropped.** When `@Component({ providers: PROVIDERS
   })` was followed by `const PROVIDERS = [{ provide: TOKEN, ... }]; const
   TOKEN = ...;`, only `PROVIDERS` got hoisted. The hoisted `const PROVIDERS
   = [...]` then evaluated above the class and TDZ-threw on `TOKEN` — just
   moving the same `ReferenceError` one frame deeper.

2. **Multi-declarator dedup was nondeterministic.** The plan was keyed by
   binding *name*, so `const A = 1, B = 2;` referenced by two different
   classes produced two `HoistEntry` values sharing `stmt_start` but
   carrying different `insert_at` targets. The `emitted_stmts` dedup kept
   whichever HashMap iteration visited first — often the *later* class —
   leaving the earlier class in the TDZ.

Fix:

* Key the plan by `stmt_start` and merge collisions by taking MIN
  `insert_at` (no more nondeterministic dedup; multi-declarators collapse
  to one entry by construction).
* Per-statement, collect the union of identifier references across every
  declarator initializer and feed them back into a BFS worklist — so
  hoisting `PROVIDERS` also schedules `TOKEN` (transitive closure).
* Topologically sort the planned statements before emission so
  dependencies land *before* their dependents in the hoisted prelude
  (e.g. `const TOKEN` precedes `const PROVIDERS = [{ provide: TOKEN, ...
  }]`). DFS is iterative to avoid stack overflow on deep chains; cycles
  are broken silently because they can't yield a valid evaluation order
  anyway.

Adds two integration tests that lock in both fixes:
* `component_provider_aggregate_const_pulls_in_transitive_tdz_dep`
* `component_shared_multideclarator_const_hoists_above_earliest_referencer`
  (the latter was flaky against the old code — 7/20 failure rate locally
  before this change, 30/30 passes after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): boundary + function-call transitive TDZ deps

Two PR #302 review fixes for the const-hoist:

1. Off-by-one at the class-body boundary (Cursor): the check
   `stmt_start <= class_body_end` skipped a const declared at exactly
   `class.body.span.end` (no whitespace between `}` and `const`).
   `class.body.span.end` is the exclusive end of the body, so a stmt
   starting there is the very next byte after the class and still needs
   hoisting. Switched to `<`. To keep the new delete from chewing into
   the `decls_after_class` insert at the same offset, the hoist delete
   now runs with a negative priority so it applies before that insert.

2. Transitive deps through function calls (Codex): a hoisted initializer
   that calls a top-level function (`const PROVIDERS = makeProviders()`)
   evaluates that function's body at module load, so any later-declared
   binding the body reads still TDZ-throws. Indexed top-level function
   declarations via `oxc_ast_visit::Visit` (skipping nested function /
   arrow / class expression bodies for the same lazy-evaluation reason
   as the existing expression walker), and chase identifiers through
   them in the BFS and in the topological-sort edge expansion.

Added two regression tests:
- `component_provider_const_immediately_after_class_brace_is_hoisted`
- `component_provider_const_via_function_call_pulls_in_transitive_tdz_dep`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(hoist): resolve bindings via oxc_semantic SymbolIds

Replace the hand-rolled name-string indexing in `collect_hoist_edits`
with SymbolId-based resolution backed by `oxc_semantic`. The AOT path
in `transform_angular_file` now builds a `Semantic` and threads it
into the hoister so every `IdentifierReference` resolves through the
symbol table to the actual declaring binding.

Previously, `binding_to_stmt: HashMap<&str, u32>` and
`fn_body_idents: HashMap<&str, HashSet<&str>>` keyed everything by
the identifier's spelling. If a nested scope shadowed a top-level
binding with the same name, the walker couldn't tell them apart and
might count a non-top-level reference as a TDZ-relevant hit on the
top-level binding. After this refactor, every reference is resolved
to a `SymbolId` and matched against the top-level binding set, so
shadows are impossible.

No observable behavior change is intended: all 344 integration tests
(including the two regression tests from PR #302
`component_provider_const_immediately_after_class_brace_is_hoisted`
and `component_provider_const_via_function_call_pulls_in_transitive_tdz_dep`)
continue to pass. The hoist priorities, off-by-one boundary check,
topological sort, and lazy-body skipping are all preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): lazy fn-value, optional chain, destructured bindings

Three Codex review findings on PR #302:

1. Lazy fn-value over-hoisting: when a top-level function is referenced
   from decorator metadata as a *value* (e.g. `useFactory: makeFactory`)
   the BFS still chased its body refs and hoisted them above the class.
   Since Angular invokes such factories lazily, this could introduce a
   fresh TDZ that didn't exist in the source. Introduce an
   `eagerly_called` closure (direct callees in any top-level initializer
   or decorator metadata, transitively expanded through
   `fn_body_called_symbols`) and gate the BFS body-chase branch plus
   `expand_through_functions` on it. Regression test:
   `component_provider_useFactory_function_value_does_not_hoist_body_deps`.

2. `Expression::ChainExpression` swallowed by catch-all: optional
   chaining like `{ provide: TOKEN?.id, useValue: 1 }` recorded no
   reference to `TOKEN`. Add an explicit arm that dispatches each
   `ChainElement` variant (`CallExpression`, member variants,
   `TSNonNullExpression`) to the matching collection logic, also
   recording the inner call's direct callee for `f?.()`. Regression
   test: `component_provider_optional_chain_token_is_hoisted`.

3. Destructured top-level bindings not indexed:
   `collect_top_level_bindings` only handled
   `BindingPattern::BindingIdentifier`, so `const { TOKEN } = TOKENS;`
   never made it into `symbol_to_stmt`. Add
   `for_each_binding_identifier` recursive walker covering
   `ObjectPattern`/`ArrayPattern`/`AssignmentPattern` (plus rest
   elements and nested patterns). Regression test:
   `component_provider_destructured_top_level_token_is_hoisted`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): per-class eager-call, IIFE metadata, multi-decl class-ref guard

Three bot-review findings on the post-refactor hoist:

1. Multi-declarator over-hoist (Codex). When a `const TOKEN = 'tok',
   BACKREF = TestComponent;` declarator list is hoisted because TOKEN is
   referenced in decorator metadata, the peer `BACKREF = TestComponent`
   moves above the class and introduces a new TDZ. Added a safe-skip:
   refuse to hoist a statement when any of its initializer symbols
   resolves to a top-level class declared at position >=
   `effective_start`. Indexed via a new `collect_top_level_class_positions`
   helper. (Split-hoist is intentionally out of scope; the safe-skip
   preserves the "no regressions" invariant — the user's existing TDZ on
   the directly-referenced symbol stays, but we don't introduce a new one.)

2. IIFE bodies in decorator metadata missed (Codex). `providers: (() =>
   [{ provide: TOKEN }])()` runs the arrow body eagerly at class init,
   but the lazy-bodies rule was treating every arrow/function expression
   body as opaque. New `walk_iife_callee_body` detects an immediately-
   invoked function/arrow callee (peeling parens + TS wrappers) and
   walks the body via `FunctionBodyIdentVisitor`; the wider lazy rule
   still applies elsewhere. Symmetric handling for `NewExpression` and
   `ChainElement::CallExpression`.

3. Global `eagerly_called` bleeds across classes (Cursor). Previously
   seeded from every top-level initializer's call sites + every
   decorator's call sites, so `const X = foo()` in one part of the
   module would mark `foo` as eagerly-called for every other class
   too — even classes that only reference `foo` as a value
   (`useFactory: foo`). Replaced the global precomputation with a
   per-class closure inside the BFS: seeded only from THIS class's
   `decorator_called`, extended incrementally as the BFS plans
   bindings (adding each planned init's `init_called_symbols` and
   re-closing through `fn_body_called_symbols`). Functions popped
   before they became eagerly_called are deferred and belatedly
   chased when promotion happens. Topological sort uses the union
   of per-class sets so dependency edges still see every fn whose
   body fires at module load for some hoisted statement.

Regression tests for each finding:
- component_provider_multi_declarator_with_class_self_ref_skips_hoist
- component_provider_iife_metadata_hoists_inner_token
- component_provider_useFactory_value_does_not_chase_global_eager_caller

All 350 integration tests pass; fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): chase param default refs of eagerly called functions

Parameter default expressions (`function f(x = TOKEN)` /
`function f({ a = X } = {})`) evaluate at call time, before the body
runs. When a hoisted initializer eagerly calls a top-level function,
any later-declared binding read by a parameter default is just as
TDZ-relevant as a body ref. The previous scan only walked the function
body, so such defaults left their referenced bindings below the class
and the hoisted call-site threw `ReferenceError: Cannot access ... before
initialization`.

Add a `for_each_pattern_default` helper that yields every nested
`AssignmentPattern::right` inside a `BindingPattern`. Use it (alongside
`FormalParameter::initializer` for top-level defaults) in
`collect_top_level_bindings` for top-level function declarations and in
`walk_iife_callee_body` for IIFE callee arrow/function expressions. The
collected refs and direct callees join the same `fn_body_symbol_refs` /
`fn_body_called_symbols` sets the body walk populates, so the existing
BFS and eagerly-called closure pick them up transparently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): chase destructuring defaults; walk IIFE bodies in eagerly-called fns

`collect_top_level_bindings` now walks `AssignmentPattern.right` defaults
nested in each declarator's binding pattern, so `const { TOKEN = FALLBACK }
= {}` records `FALLBACK` as an init-time dep and keeps the dependency
graph TDZ-correct. `FunctionBodyIdentVisitor` now detects IIFE callees in
`visit_call_expression` / `visit_new_expression` and walks their bodies
eagerly via `walk_iife_callee_body`, so IIFEs inside an eagerly-called
top-level function no longer drop their identifier reads. Addresses Codex
P2 #3311274924 and Cursor #3311313158 on PR #302.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): close class-TDZ guard transitively; walk assignment refs

Resolves the two new inline bot comments on PR #302 and the follow-on
adversarial findings the fix surfaced.

* Codex P2 #3311493528 — the `stmt_references_later_class` guard now
  closes `init_called_symbols` through `fn_body_called_symbols` and
  checks each function's `fn_body_symbol_refs` for class refs, so an
  initializer that eagerly calls a function whose body reads a later
  class is no longer hoisted above that class.
* Cursor Low #3311551145 — `collect_expr_symbols` gains explicit
  `AssignmentExpression` / `UpdateExpression` arms plus
  `collect_assignment_target_symbols` and siblings, so identifier
  refs on assignment-target lvalues (including pattern targets with
  defaults and member targets) flow into the dependency graph.
* Cascade un-planning ("Step 2c"): when a planned statement's full
  dep closure reaches a top-level binding that isn't planned at an
  `insert_at` ≤ the dependent's `insert_at`, the dependent is dropped
  and the loop iterates to a fixed point — prevents leaving caller
  hoists stranded when a transitive dep was guard-skipped or planned
  only for a later class.
* Function-valued bindings indexed: each per-declarator `const f =
  () => …` / `function() { … }` populates `fn_body_*` maps so the
  guard chases through them like a function declaration. Covers
  single- and multi-declarator forms.
* Indirect call shapes: `fn.call(...)`, `fn.apply(...)`, and
  `fn.bind(...)()` recorded as eager invocations of `fn` at every
  call/new visit site.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): chase fn-valued binding bodies in guard, cascade, and topo

Resolves the new inline bot comments on PR #302 and the follow-on
adversarial findings the fix surfaced.

* Codex P2 #3311913006 — when a popped symbol both has `symbol_to_stmt`
  and `fn_body_symbol_refs`, the BFS branch now plans the statement
  AND chases body refs (or defers them via `deferred_fns` when the
  binding isn't yet eagerly called).
* Cursor Low #3311962888 — `topological_order` no longer takes the
  global `combined_eagerly_called`; per-S `stmt_eager_sets` and a
  shared `stmt_fn_valued_bindings` map drive the dep-edge construction,
  matching the cascade's reasoning.

Adversarial follow-up: the safe-skip guard and cascade un-planning
were blind to fn-valued bindings whose arrow body reads a class — for
`@Component({providers: make()}) class C {} const make = () => C;`
the planner hoisted `make` above `C` and Ivy's eager `make()` then
hit TDZ on `C`. The fix lifts `stmt_fn_valued_bindings` to right after
`collect_top_level_bindings` and folds each statement's eagerly-called
fn-valued binding bodies into both the per-class safe-skip closure
(via the BFS's `eagerly_called`) and the cascade closure (via
`combined_eagerly_called`), keeping all three passes symmetric.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): index class constructors and walk eager class-expr parts

* Codex P2 #3312108552 — top-level class declarations are now indexed
  into `fn_body_symbol_refs` / `fn_body_called_symbols` (with
  `include_constructor: true`) via a new `walk_class_eager_parts` helper.
  The helper covers the union of class-definition-time eager refs
  (super_class, computed keys, static field / accessor initializers,
  static blocks) and `new`-time eager refs (constructor body + parameter
  defaults, instance field / accessor initializers). The BFS body-chase
  branch then chases through `new ClassName()` callers in hoisted
  initializers — `const PROVIDERS = [new S()]` no longer hoists past
  `class TestComponent` when `S`'s constructor reads a later const.
* Codex P2 #3312108558 — `collect_expr_symbols`'s previously-opaque
  `E::ClassExpression` arm now calls `walk_class_eager_parts` with
  `include_constructor: false`, so the eager parts of an inline class
  expression (super_class, computed keys, static initializers, static
  blocks) feed the dependency graph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): walk classes inside fn bodies, branch callees, tag template tags

* Codex P2 #3314767088 — `FunctionBodyIdentVisitor::visit_class` was a
  no-op, dropping inline `class extends TOKEN {}` defined inside an
  eagerly-called function body. It now calls `walk_class_eager_parts`
  with `include_constructor: true` so super_class, computed keys,
  static initializers/blocks, and (conservatively) constructor body
  refs feed `fn_body_symbol_refs` / `fn_body_called_symbols`.
* Codex P2 #3314767091 — `record_direct_callee` only recognised bare
  identifiers under parens / TS wrappers. Restructured as an iterative
  worklist that also descends into both branches of
  `ConditionalExpression` / `LogicalExpression` and the last expression
  of a `SequenceExpression`, so callees like `(cond ? makeA : makeB)()`
  and `(makeA || makeB)()` enter the eager-call set.
* Cursor Low #3314770575 — `FunctionBodyIdentVisitor` gained
  `visit_tagged_template_expression`, recording the tag via the same
  direct/indirect/bind callee helpers as `visit_call_expression` so
  `tag\`…\`` inside a top-level function body chases the tag's body
  refs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): record indirect/bind callees on tagged-template tags

`collect_expr_symbols::E::TaggedTemplateExpression` only called
`record_direct_callee`, while the call/new arms and the body visitor's
override already invoke all three callee helpers. Decorator metadata
like `make.bind(null)\`...\`` or `make.call(this)\`...\`` therefore
recorded `make` as a value reference but never as an eager callee, so
its body wasn't chased and a later-declared binding it reads stayed
below the class. Now mirrors call/new: direct + indirect + bind. Closes
Cursor Low #3314809112 and Codex P2 #3314810080.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): chase fn-valued binding bodies for pre-class bindings too

The BFS branch for a `symbol_to_stmt` hit ran the fn-valued binding
body-chase only AFTER the `stmt_start < class_body_end` early-continue,
so a `const make = () => [{ provide: TOKEN }];` declared *before* the
decorated class slipped past the chase entirely. `make`'s binding is
indeed initialized at class-eval time, but the arrow body still fires
when the decorator calls `make()` — and its later-declared reads stay
in TDZ. Moved the body-chase block to BEFORE the early-continue so it
runs regardless of stmt position; removed the now-duplicate post-plan
copy. Closes Codex P2 #3314836115.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(hoist): drop stale 'moved from' comment in BFS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(hoist): strip PR/issue bookkeeping references from comments

Removes trailing `See PR #302 ...`, `Regression for/test for ... review #...`,
`Round N follow-on review`, `Codex P2/P3 review Finding N`, etc. — the
references that belong in PR descriptions and rot as the codebase moves.
Substantive technical justifications kept; only the bookkeeping tails
removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): descend through branch receivers; symmetric topo eager close

* `record_indirect_callee` / `record_bind_callee` previously required the
  static-member receiver to be a bare identifier, missing shapes like
  `(cond ? makeA : makeB).call(null)` and `(cond ? makeA : makeB).bind(null)()`.
  Both now delegate the receiver descent to `record_direct_callee`, which
  already peels parens / TS wrappers and walks conditional / logical /
  sequence branches.
* The topo-precompute eager set was closed under `fn_body_called_symbols`
  BEFORE folding in fn-valued binding symbols, while the cascade did the
  opposite order. Reordered the precompute to match the cascade so both
  passes expand through the same transitive callees and dependency edges
  through fn-valued bindings stay visible to the topological sort.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): chase named nested function bodies in body visitor

`FunctionBodyIdentVisitor::visit_function` no-opped on every nested
function, so a local declaration like
`function outer() { function inner() { return TOKEN; } return inner(); }`
called from an eagerly-evaluated body never contributed `TOKEN` to the
eager surface — `inner` is a local symbol that's not indexed in any
`fn_body_*` map, so `close_eagerly_called` can't chase through it. The
visitor now walks the body and parameter defaults of any nested
`Function` with `id: Some(_)`. Anonymous functions and arrow expressions
remain lazy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): chase locally-bound arrow/fn helpers when called eagerly

`FunctionBodyIdentVisitor` skipped arrows entirely, so a local binding
like `const inner = () => TOKEN;` inside an eagerly-called body left
`inner` recorded as a callee but with no body to chase — `TOKEN` stayed
below the class. Added a per-visitor `local_fn_bodies` map populated by
a new `visit_variable_declarator` override that indexes arrow/function
inits via a scratch walk. At each `CallExpression` / `NewExpression` /
`TaggedTemplateExpression` we resolve every reachable callee identifier
(through parens, TS wrappers, conditional / logical / sequence branches,
`.call` / `.apply` receivers, and the inner call of `.bind(...)()`) and
fold the indexed body's refs / callees into the eager surface, with a
`folded` guard for self- and mutually-recursive arrows. Value-passed
arrows that are never invoked inside the body (`useFactory: lazy`) stay
lazy because the fold only fires at call sites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hoist): index nested fn declarations lazily; fold only at call sites

The prior round made `FunctionBodyIdentVisitor::visit_function` walk
named nested function bodies unconditionally, which broke laziness — an
uncalled `function unused() { return TOKEN; }` declared inside an
eagerly-called helper folded `TOKEN` into the eager surface and could
hoist it above the class for no reason. Mirror the local-arrow path:
`visit_function` now indexes the function into `local_fn_bodies` and
defers the fold to call sites. A new `visit_function_body` /
`visit_block_statement` pre-pass indexes hoisted function declarations
before source-order walking so a call that textually precedes its
declaration still resolves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Brooooooklyn pushed a commit that referenced this pull request May 28, 2026
…d all decorated class kinds (#303)

* feat(class_metadata): emit initializer-API prop decorators for JIT/TestBed

Signal-based members (`input()`/`input.required()`/`output()`/`model()`/
`viewChild()`/`viewChildren()`/`contentChild()`/`contentChildren()`) live only
in the AOT `ɵcmp`, which `TestBed.overrideComponent` discards before recompiling
via JIT. The JIT recompile reconstructs inputs/outputs/queries from decorator and
prop metadata reflected off the class, so signal members were silently dropped on
recompile — `setInput`/router-binding then failed with NG0315/NG0303/NG0950.

Synthesize the equivalent `@Input`/`@Output`/query prop decorators into
`setClassMetadata`, mirroring `@angular/compiler-cli`'s `initializer_api_transforms`
(applied by the Angular CLI in test builds). The decorator type is referenced via
the `@angular/core` namespace import (`i0.Input`), exactly as ngc emits it; output
verified byte-for-byte against ngc 21.2 across all initializer-API member kinds.

* fix(class_metadata): drop unresolvable template-literal config fields from setClassMetadata

A `@Component` config field whose value is a template literal with an
unresolvable `${…}` interpolation (e.g. ``selector: `${UNRESOLVED}-tag` ``) was
copied verbatim into `ɵsetClassMetadata`, leaking the raw `${…}` text. The AOT
`ɵcmp` path already drops such fields (#300); apply the same fold-or-drop on the
`setClassMetadata` decorator-args path.

Expose `resolve_template_literal` as `pub(crate)` and thread the file-scope string
consts into `build_decorator_metadata_array`. Resolvable interpolations
(`${KNOWN_CONST}`) still fold and are left untouched; only unresolvable fields are
dropped (post-conversion, so key/quoting/ordering fidelity is preserved). Fixes the
pre-existing `component_template_literal_unresolved_identifier_drops_field` test,
which default-on `emit_class_metadata` (#299) had turned red.

* fix(directive): recognize as-cast/parenthesized and namespaced-required initializer APIs

Match ngc's `tryParseInitializerApi`, which unwraps `as`/parenthesized expressions
and resolves namespaced calls:

- `x = input(0) as any` / `x = (input(0))` — unwrap `TSAsExpression`/
  `ParenthesizedExpression` before matching the initializer call (applies to
  input/output/model/query detectors, so both the AOT ɵcmp and setClassMetadata
  paths recognize them).
- `core.input.required()` / `core.model.required()` — recognize the namespaced
  `<ns>.<fn>.required()` form, not just `<fn>.required()`.

Output verified byte-for-byte against @angular/compiler-cli 21.2.14.

* feat(directive): emit setClassMetadata for directives

Directives previously emitted no `setClassMetadata`, so `TestBed.overrideDirective`
could not reflect their decorators and signal inputs/outputs/queries were lost on
recompile. Emit it for the `@Directive` AOT branch, mirroring the `@Component` path:
the `@Directive` decorator metadata, `ctorParameters` (reflected from the class),
and prop decorators (real `@Input`/`@Output`/query decorators plus synthesized
initializer-API ones). Matches ngc, which emits directive class metadata.

Constructor-token types are reflected from the AST (not namespace-prefixed) since
directive metadata carries a different dependency representation than
`build_ctor_params_metadata` consumes — the common no-constructor directive emits
`null` ctorParameters, exactly like ngc. Updates two host-directive snapshots that
now include the directive `setClassMetadata`.

* feat(class_metadata): emit setClassMetadata for pipes, injectables, and NgModules

Extends directive metadata support to the remaining decorated class kinds so
TestBed.overridePipe/overrideModule and reflection work, matching ngc which emits
setClassMetadata for every decorated class. Each emits the decorator metadata,
ctorParameters (reflected from the class — imported token types are namespace-
prefixed via the import map, e.g. `() => [{ type: i0.Injector }]`), and prop
decorators.

Extracts the shared `build_set_class_metadata_decls` helper (used by the
@Directive/@Pipe/@Injectable/@NgModule branches), replacing the inline directive
block. Output verified byte-for-byte against @angular/compiler-cli 21.2.14 for
each kind, including constructor-DI cases.

* test(class_metadata): cover full classic decorator set in setClassMetadata

Add regression guards for the classic (non-signal) decorators in setClassMetadata,
on both @component and @directive: plain/aliased/config @input, plain/aliased
@output, @ViewChild/@ViewChildren, @ContentChild/@ContentChildren (with read/
descendants), @HostBinding, and @HostListener (with args) — plus a mixed
classic+signal class. Expected shapes verified byte-for-byte against
@angular/compiler-cli 21.2.14.

* refactor(class_metadata): require a locator when synthesizing query decorators

A signal query with no locator (`viewChild()`) is invalid (ngc errors); skip
synthesis instead of emitting a malformed decorator that treats the options
object as the predicate. No behavior change for valid queries. Also drops a
stale duplicate comment.

* style: cargo fmt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants