feat(class_metadata): inline templateUrl/styleUrls + default-on, matching ngc#299
Open
ashley-hunter wants to merge 6 commits into
Open
feat(class_metadata): inline templateUrl/styleUrls + default-on, matching ngc#299ashley-hunter wants to merge 6 commits into
ashley-hunter wants to merge 6 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings OXC's
setClassMetadataemission into byte-exact parity with Angular'sngc, so TestBed JIT recompilation works without users having to discover and flip a flag.Three threads of work:
Inline
templateUrl/styleUrls/styleUrlinto the metadata args (d1d3ac8). Pre-existing emission preserved the raw resource literals, so Angular'scomponentNeedsResolution(metadata)check tripped at TestBed time ('Component is not resolved: templateUrl…').Default
emit_class_metadatatotrueat all three layers (Rust crate, NAPI binding, Vite plugin) —ngcalways emits the call wrapped in(typeof ngDevMode === "undefined" || ngDevMode) && …, so production bundles tree-shake it. Off-by-default silently broke TestBed/Vitest setups.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.templateandtemplateUrl.Component(matches Angular'sif (dec.name !== 'Component') return dec;) — without this,@Inject({ templateUrl: '…' })had its keys silently stripped.resolve_templateprecedence:templateUrlwins over inlinetemplate(matchesparseTemplateDeclaration); 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-featurescargo test(21 binaries, 2685+ tests, all ok)cargo fmt --all -- --checkcargo run -p oxc_angular_conformance && git diff --exit-code(1252/1252, git-clean)pnpm build-dev+pnpm --filter ./napi/angular-compiler build:tspnpm 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
setClassMetadataby default. Production strips it viangDevMode. Anyone who actively wants the old behavior passesemitClassMetadata: false.setClassMetadatablock.test_standalone_component_omits_standalone_field) to scope itscontains("standalone:true")check to theɵɵdefineComponentliteral — the metadata block faithfully preserves the sourcestandalone: true, which is what ngc does.@Component({ ...config, … })) pass through unchanged; fixing requires structural pre-extraction spread resolution upstream of metadata.