Skip to content

feat(angular): support v22.0.0-rc.2 — conformance & emit parity at 100%#333

Open
brandonroberts wants to merge 16 commits into
mainfrom
chore/bump-angular-v22-rc2
Open

feat(angular): support v22.0.0-rc.2 — conformance & emit parity at 100%#333
brandonroberts wants to merge 16 commits into
mainfrom
chore/bump-angular-v22-rc2

Conversation

@brandonroberts
Copy link
Copy Markdown
Collaborator

@brandonroberts brandonroberts commented Jun 1, 2026

Summary

Bumps the Angular target from v21.2.2 → v22.0.0-rc.2 (conformance submodule + the validation npm deps) and closes every behavioral gap the bump exposed. Both validation suites are green against v22.0.0-rc.2:

  • Conformance (template AST vs. Angular spec fixtures): 1264/1264 (100%)
  • e2e compare (emitted instructions vs. real @angular/compiler): 686/686 (100%)

What changed

Bump

  • Submodule crates/oxc_angular_compiler/angularv22.0.0-rc.2; regenerated fixtures + snapshot. Fixed two fixture-hygiene issues (repo-relative file_path; restored trailing newline) and dropped the orphaned shadow_css_polyfills_spec fixture (removed in v22).
  • @angular/* npm deps → ^22.0.0-rc.2 in the NAPI package and the e2e/compare output-parity harness — the surfaces that validate v22 compiler behavior.
  • e2e/app and playground stay on v21.2.x: they're runtime apps, and booting them on the v22 RC needs separate app/vite-plugin migration (the v22 runtime fails to render, timing out the Playwright HMR suite). They use no version-divergent template features, so the v22-default compiler emits v21-identical output for them and the HMR e2e suite passes. Pinned benchmark fixtures also stay on 21.2.x.

v22 conformance features (each its own commit)

  • in/instanceof: error on a keyword/EOF in primary position (v21 parsed bare in as the literal 'in' — a bug Angular fixed).
  • @defer: optional idle(timeout) trigger parameter.
  • Templates: stop normalizing the data- attribute prefix (v22 removed normalizeAttributeName).
  • @let: include the trailing ; in the source span.
  • ShadowCss: :host(.a, .b) selector lists stay literal, bare :host-context is no longer a context selector, comment removal no longer inserts blank lines.
  • @switch: @default never; exhaustive check (lexer → parser → new SwitchExhaustiveCheck R3 node).
  • Don't strip namespaced SVG <style>/<script> (:svg:style).

Emit parity (compare harness)

  • v22 broadened control-property specialization: formControl, formControlName, and ngModel (incl. two-way [(ngModel)]) now emit the paired ɵɵcontrolCreate() / ɵɵcontrol() instructions. Implemented across ingest + binding-specialization.
  • Harness fix: thread the installed @angular/compiler version into OXC's options so version-gated emit is compared like-for-like.

v19–v21 compatibility

Version-gated runtime behaviors (AngularVersion::supports_* — optional chaining, conditional create, dom property, chained queries, service decorator) are untouched and remain version-correct.

The control-property emit change has real runtime impact ([(ngModel)] is ubiquitous), so it's gated behind the new supports_extended_control_properties() (major ≥ 22); formField stays unconditional as the v21 baseline, and an unknown version assumes latest. A regression test asserts [(ngModel)] emits only ɵɵtwoWayProperty on v19/v20/v21 and adds the control instructions on v22+.

The remaining parse-layer divergences (data-*, ShadowCss, idle(timeout), @default never, @let span, in) are not version-gated: that layer is intentionally version-agnostic, and the features are additive (old apps never write them), cosmetic (span only), or rare microsyntax.

Testing

  • cargo test -p oxc_angular_compiler — 32/32 binaries pass (two-way [(ngModel)] snapshots updated).
  • cargo run -p oxc_angular_conformance — 1264/1264, snapshot committed (CI git diff --exit-code clean).
  • pnpm --filter @oxc-angular/compare compare --fixtures — 686/686 vs @angular/compiler@22.0.0-rc.2.
  • Playwright HMR e2e suite — passes locally (e2e/app on v21).
  • cargo fmt, oxfmt, oxlint --type-aware --type-check clean.

Note

Based on main, so it includes the still-open #317 / PR #330 (commit 1269b23) at its base. Merge #330 first, or merge this and #330 becomes redundant. The v22 work begins at b5b0eb0.

🤖 Generated with Claude Code

brandonroberts and others added 13 commits June 1, 2026 12:58
Angular v22 changed the safe-navigation operator (`?.`) in template
expressions to yield `undefined` via native optional chaining, gated by
the `legacyOptionalChaining` compiler option. OXC unconditionally emitted
the legacy `== null ? null` ternary, so v22+ projects got the wrong
runtime value for any `?.` expression.

Changes:
- Add `legacyOptionalChaining` to `TransformOptions` (NAPI + Rust) and
  thread it through ingest into the compilation jobs. The effective default
  is derived from `angularVersion`: legacy for < v22, modern (native `?.`)
  for >= v22, and legacy when the version is unknown (matches Angular's
  conservative fallback).
- Add an `optional` flag to the resolved IR read/call nodes
  (`ResolvedPropertyRead`/`ResolvedKeyedRead`/`ResolvedCall`) and pass it
  through reify so it renders as native `?.` / `?.[]` / `?.()`.
- Rewrite `expand_safe_reads` to branch per node: legacy builds the
  `SafeTernary` (`== null ? null`); modern rewrites each safe access into
  the equivalent optional resolved read (no temporaries needed).
- Support the `$safeNavigationMigration(...)` escape hatch: a wrapped
  subtree is forced back to legacy null semantics even on a modern target,
  and the wrapper is stripped.

Two deviations from the issue text, both to match the reference compiler
(angular/angular@2896c93cc1):
- The modern form is native optional chaining (`ctx.user?.name`), not the
  `== null ? undefined` ternary the issue described. Both yield `undefined`
  at runtime; native `?.` matches Angular's emitted output.
- The magic function shipped in v22 is `$safeNavigationMigration(...)`, not
  `$null(...)` (the commit message named `$null` but the code renamed it).

Partial/linker output keeps legacy semantics for now; threading the facade
field through partial emit is deferred (issue required-work #4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the conformance submodule from v21.2.2 to v22.0.0-rc.2 and
regenerate the extracted fixtures + snapshot against the v22 specs.

Pass rate is 1235/1264 (97.7%); the 29 new failures are genuine v22
feature/behavior additions not yet implemented in OXC, tracked as
follow-up: the `in` expression operator, `@switch` exhaustive
`@default never`, `@defer` `idle(timeout)`, `data-*` binding prefixes,
`@let` source spans, and several ShadowCss `:host`/comment cases.

Fixture hygiene fixed along the way:
- store `file_path` repo-relative instead of an absolute home dir
- restore the trailing newline on generated fixtures
- drop orphaned shadow_css_polyfills_spec.json (spec removed in v22)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Angular v22's parser_spec adds "should throw on invalid in expressions",
exercising error recovery around the `in` operator. OXC parsed `in`
correctly as a relational operator but mishandled three error cases:

- A bare keyword in primary position (`in`, `in foo`) was silently
  treated as an identifier read. Angular treats only identifier tokens
  as reads, so a keyword here reports "Unexpected token <kw>".
- Running out of input mid-expression (`'foo' in`) produced a silent
  EmptyExpr. Angular reports "Unexpected end of expression: <input>".
- The error formatter now appends `[<source>]` to the end-of-expression
  message to match Angular's getParseError output.

Empty input still parses error-free (parse_chain guards via the token
list being empty). Conformance expression_parser: 270/270.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Angular v22 lets the `idle` deferred trigger take an optional timeout
parameter (`on idle(100)`), mirroring `timer`. Previously OXC rejected
any parameter on `idle`.

- Add `timeout: Option<f64>` to `R3IdleDeferredTrigger`.
- Parse zero-or-one parameter in the `idle` arm via `parse_deferred_time`,
  matching the reference error strings for >1 param and unparseable time.
- Humanize `['IdleDeferredTrigger', <timeout>]` when present.

Conformance r3_transform: 165 -> 168. Emit threading of the timeout into
the defer config is deferred (not exercised by these AST-level specs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Angular v22 removed `data-` prefix normalization: binding syntax
(`bind-`, `on-`, `bindon-`, `ref-`, `let-`) is now matched against the
raw attribute name. So `data-ref-a`, `data-let-a`, `data-on-event`, and
`data-bindon-prop` are plain text attributes, and an interpolated
`data-prop="{{v}}"` keeps its `data-` prefix in the bound attribute name.

Drop `normalize_attribute_name` and the data- strip in
`parse_binding_prefix`, using the raw name throughout.

Conformance r3_transform: 168 -> 173.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Angular v22 ends a `@let` declaration's sourceSpan at the end of the
terminating semicolon (`end = endToken.sourceSpan.end`). OXC ended the
span just before the `;`, and the conformance humanizer additionally
stripped a trailing `;`. Both now keep the semicolon.

Conformance html_parser 85->86, r3_transform 173->174.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Angular v22 simplified ShadowCss shimming. Three behavior changes,
bringing the shadow_css conformance subsystem back to 100%:

- Comment removal no longer appends an extra newline; only the newlines
  inside the comment are preserved (`/* c */ b {}` -> ` b[contenta] {}`).
- A `:host(...)` whose argument is a selector list (top-level commas) is
  left literal and prefixed with the content attribute
  (`:host(.a, .b)` -> `[contenta]:host(.a, .b)`), instead of being split.
- A `:host-context` without a non-empty argument list (bare,
  `:host-context()`, `:host-context( )`) is no longer a context selector;
  it stays literal and is content-scoped. `:host` is no longer mangled
  inside `:host-context` (mirrors `/:host(?!\-context)/`).

Drops the now-unused data-/host-attr conversion fallback and updates two
comment integration tests to the v22 output.

Conformance shadow_css: 160 -> 169 (100%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Angular classifies `<script>`/`<style>` as special (content-extracting)
elements by their lowercased element name, which for SVG content is
namespaced (`:svg:style`) and therefore not matched. OXC checked the raw
name before namespace resolution, so an SVG `<style>` was wrongly
stripped. Gate the special handling on the HTML namespace.

Conformance r3_transform: 174 -> 175.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Angular v22 added `@default never;` inside `@switch` as an exhaustive
type-narrowing marker. It is lexed as a block named "default never",
terminated by `;` instead of a `{ ... }` body, and lowers to a
`SwitchExhaustiveCheck` node on the switch (no runtime output).

- Lexer: emit BlockOpenEnd + BlockClose for `@default never;` (and
  `@default never(expr);`), mirroring the reference `_consumeBlockStart`.
- AST: add `R3SwitchExhaustiveCheck` and `SwitchBlock.exhaustive_check`,
  with a visitor hook visited after the case groups.
- Transform: intercept the "default never" block (optional expression,
  no body) and attach it as the exhaustive check.
- Humanize `SwitchExhaustiveCheck` after the case groups.

Conformance: html_lexer, html_parser and r3_transform back to 100% —
the full suite is now 1264/1264 (100%) against v22.0.0-rc.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All 1,264 extracted assertions pass after implementing the v22 features.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump the npm @angular/* dependencies to 22.0.0-rc.2 in the packages that
validate compiler behaviour against the real Angular compiler — the
NAPI package, the e2e app and the output-parity `compare` harness — plus
the playground. This keeps the npm side aligned with the conformance
submodule (also v22.0.0-rc.2).

The pinned real-world benchmark apps (typedb-web, bitwarden) stay on
their 21.2.x snapshots; they are perf fixtures, not compat validation,
and bumping a full app to a pre-release would risk breaking their builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Angular v22 broadened `specializeControlProperties` beyond `formField`:
`formControl`, `formControlName` and `ngModel` (including two-way
`[(ngModel)]`) now also emit the paired `ɵɵcontrolCreate()` (create) and
`ɵɵcontrol()` (update) instructions.

- ingest: detect the control property across `[..]` property bindings,
  two-way `[(ngModel)]`, and static `formControlName=`/`ngModel`
  attributes, and add the `ControlCreateOp` after element creation.
- binding_specialization: add the `ControlOp` update after the property
  update for `formField`/`formControl`/`formControlName`/`ngModel`, and
  after the two-way property update for `[(ngModel)]`.

Brings the e2e compare harness to 100% parity (686/686) against
@angular/compiler 22.0.0-rc.2. Snapshots updated for the two-way ngModel
fixtures (now emit controlCreate + control).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The compare harness compiled OXC without an `angularVersion`, so OXC fell
back to its conservative legacy default (e.g. legacy safe-navigation
`?.`) while comparing against a newer @angular/compiler. After the v22
bump this surfaced as safe-navigation mismatches. Thread the installed
@angular/compiler VERSION into OXC's TransformOptions so version-gated
emit aligns with the baseline it's compared against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security
Copy link
Copy Markdown

socket-security Bot commented Jun 1, 2026

The extended control-property specialization (ɵɵcontrolCreate/ɵɵcontrol for
formControl/formControlName/ngModel, including two-way [(ngModel)]) is a
v22 emit change; v21 only did this for [formField]. Emitting the extra
instructions against a < v22 runtime would diverge.

Gate the extended set behind `AngularVersion::supports_extended_control_
properties()` (major >= 22), keeping `formField` unconditional as the v21
baseline. Per the crate convention an unknown version assumes latest, so
the default (and the e2e compare harness, which now targets v22) is
unchanged. Threaded through both the ingest control-create detection and
the binding-specialization control-update insertion.

Adds a version-gate regression test: `[(ngModel)]` emits only
ɵɵtwoWayProperty on v19/v20/v21, and additionally ɵɵcontrolCreate/ɵɵcontrol
on v22+ and when the version is unknown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@brandonroberts brandonroberts force-pushed the chore/bump-angular-v22-rc2 branch from 4b6b061 to 32839b2 Compare June 1, 2026 21:33
brandonroberts and others added 2 commits June 1, 2026 17:04
`pnpm check` runs `oxfmt --check`, which the regenerated conformance
fixtures (serde_json pretty-printed) and the edited compare harness
didn't satisfy. Run oxfmt over them; whitespace-only, so the conformance
runner parses them identically (still 1264/1264) and the snapshot is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The e2e HMR fixture app and the playground are runtime apps; booting them
on Angular v22.0.0-rc.2 needs app/vite-plugin migration work that is out
of scope here (the v22 runtime fails to render, timing out every
Playwright HMR test). Revert those two to v21.2.x.

The compiler-validation surface stays on v22: the `compare` harness still
diffs OXC output against @angular/compiler@22.0.0-rc.2 (686/686), and the
NAPI package keeps its v22 compiler-cli. Both reverted apps use none of
the version-divergent template features, so the (v22-default) compiler
emits v21-identical output for them; the local HMR e2e suite passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@brandonroberts brandonroberts enabled auto-merge (squash) June 2, 2026 02:36
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