Skip to content

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

Open
ashley-hunter wants to merge 6 commits into
voidzero-dev:mainfrom
ashley-hunter:feat/inline-metadata-resources
Open

feat(class_metadata): inline templateUrl/styleUrls + default-on, matching ngc#299
ashley-hunter wants to merge 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>
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.

1 participant