Skip to content

fix(i18n): match ngc for attribute and ICU messages, and cover i18n with Angular's own tests - #491

Merged
Brooooooklyn merged 55 commits into
voidzero-dev:mainfrom
ashley-hunter:t3code/fix-angular-i18n-bugs
Sep 23, 2026
Merged

Brooooooklyn merged 55 commits into
voidzero-dev:mainfrom
ashley-hunter:t3code/fix-angular-i18n-bugs

Conversation

@ashley-hunter

@ashley-hunter ashley-hunter commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes the i18n code generation so the emitted output matches ngc, and adds the test coverage that makes those differences visible.

The two bugs this started from

  • An i18n-<attr> message lost its source text: goog.getMsg("") instead of goog.getMsg("Close"). The message was built from the i18n- attribute's own value (the meaning/description metadata) rather than from the target attribute's value, as Angular's I18nMetaVisitor does.
  • An ICU message lost its placeholders and its post-processing: a stray empty sub-message, {$interpolation} split out of the ICU text, and no ɵɵi18nPostprocess.

What else came out of it

Reproducing those two against real ngc output surfaced 29 further divergences, each fixed test-first with the expectation taken from ngc, not from memory. The larger ones:

  • placeholder names lost namespaces (:svg:circle), underscores, and custom names from //i18n(ph="...") comments
  • nested tags, blocks and ICUs were named in the wrong order, so every name after the first could disagree with Angular's
  • ICUs outside an i18n block were not compiled, ICU VAR_ names were not taken from the ICU's own message, and interpolations inside ICU markup were not bound
  • <ng-content> inside an i18n block got no placeholder values
  • $localize metadata blocks were not escaped in the raw strings, so a literal {$...} in template text round-tripped wrongly
  • whitespace handling next to ICUs at the template root did not match parseTemplate
  • a missing advance() for i18nAttributes update ops

Test coverage

  • 281 i18n comparison fixtures, generated from Angular's own tests: the r3_view_compiler_i18n compliance cases and the templates in the runtime i18n_spec acceptance suite, plus hand-written scenarios for select/plural ICUs, ICUs beside text and interpolation, and i18n-<attr> alongside an i18n body. pnpm generate:i18n-fixtures regenerates them from the Angular submodule.
  • ~25 Rust regression tests covering each fix.
  • The comparison tool had a blind spot: static class fields (ɵcmp, ɵfac) were not compared semantically, so the pre-fix compiler passed every i18n fixture. It now compares them, which is what makes the i18n fixtures meaningful. Differences that are real but out of scope here are recorded in fixtures/known-differences.ts with the fields they affect, so a new difference elsewhere in the same fixture still fails.

Verification

cargo test 2846 passed, conformance 1264/1264 with no snapshot changes, compare --fixtures 941 fixtures with 0 mismatched, 53 known differences and 11 skipped. cargo fmt --check and pnpm check are clean. Against the pre-fix compiler, 216 of the i18n fixtures fail.

upstream/main is merged in; the five conflicts were in the i18n files and are resolved in favour of upstream's namespace and security work (#334) plus this branch's i18n changes. Running this branch's comparison tool against upstream/main's compiler gives the same failures outside i18n and no fixture that fails only here.

Performance

Release build, median of 5 runs on one machine, through the full parse -> transform -> ingest -> emit pipeline, comparing this branch with upstream/main:

template upstream/main this branch
2000 elements with interpolations inside one i18n block 6280.7 ms 3803.6 ms
2000 plain elements with interpolations, no i18n 610.3 ms 598.4 ms
2000 tag-only elements inside one i18n block 265.2 ms 269.9 ms
400 plural ICUs inside one i18n block 66.3 ms 72.1 ms

The first row is the change worth noting: resolving element and block placeholders once per view instead of once per element removes a repeated walk, which dominates when an i18n block holds many interpolations. The other three are within run-to-run noise on this machine.

Known gaps, unchanged by this PR

  • Legacy message IDs (enableI18nLegacyMessageIdFormat) are still not emitted, so translations keyed only by legacy IDs are not applied. Documented in the README.
  • The Closure MSG_EXTERNAL_<id>$$<FILE> variable name is only emitted for messages with an explicit @@id. This affects Closure Compiler translation extraction only; $localize IDs are correct.

Message IDs for affected messages change, by design: they are content hashes, and they now match ngc. Translations extracted from this compiler's own earlier output need re-extracting.

Expected output is taken from Angular 22.1.5 ngc.

- i18n-<attr> messages (plain, interpolated, and alongside an i18n
  element body) currently lose their text: goog.getMsg("") and
  $localize`:@@id:`.
- An element whose only child is an ICU currently emits an extra empty
  sub-message referenced as {"icu": "�i18n_N�"}, splits the
  ICU's inner {{count}} into a $localize substitution, and emits no
  i18nPostprocess call, so the runtime fails to parse the ICU.
- An ICU next to other content currently gets an empty sub-message
  instead of the ICU text.
Messages for i18n-<attr> were built from the i18n-<attr> metadata alone
(meaning, description, @@id), so their text was always empty:
goog.getMsg("") and $localize`:@@close:`. At runtime the attribute
rendered as an empty string in the source locale.

Angular's I18nMetaVisitor builds these messages with
_generateI18nMessage([attr], meta) whenever the attribute has a value.
Do the same through I18nMessageFactory, and keep the message's text and
interpolation placeholder nodes. Ingest now reads interpolation
placeholder names from those nodes, matching Angular's
Object.keys(message.placeholders), so an interpolated attribute gets
its INTERPOLATION param instead of "".
Angular 22.1.5 maps each tag placeholder inside an ICU (START_BOLD_TEXT
and so on) back to its markup through ɵɵi18nPostprocess.
ICU messages were built as an empty, id-less message wrapping a single
ICU placeholder, and ingest never linked an ICU to its message. As a
result every ICU became an empty sub-message (i18n_N = $localize``),
referenced from the parent as the string "�i18n_N�"; the ICU's
inner {{count}} was split into a $localize substitution with value "";
and no ɵɵi18nPostprocess call was emitted. At runtime @angular/core
threw "Unable to parse ICU expression".

This ports Angular's behaviour:

- I18nMetaVisitor builds an ICU's message from [expansion], and an ICU
  that is the only child of an i18n element reuses that element's
  message (currentMessage || meta). ingestIcu passes icu.i18n to the
  IcuStart op, so create_i18n_contexts treats such an ICU as the message
  itself rather than a sub-message.
- Placeholders inside an ICU serialize as literal {NAME} ICU text
  (IcuSerializerVisitor), not {$name} / $localize substitutions.
- r3 ICU placeholders are keyed by the message's placeholder names
  (INTERPOLATION, START_BOLD_TEXT, ...) and include element markup, as
  in r3_template_transform's visitExpansion.
- Each message gets its context's post-processing params plus its ICU
  placeholder literals (extractI18nMessages), with formatIcuPlaceholder
  joining strings and values without a ${} wrapper.
- collectMessage emits `i18n_N = ɵɵi18nPostprocess(i18n_N, {...})` after
  the Closure/$localize branch, and passes a single ICU sub-message by
  its variable ({ "icu": i18n_0 }), as addSubMessageParams does.
Angular 22.1.5 names the placeholders ICU and ICU_1 and passes each its
own sub-message. oxc names both ICU, so the second ICU renders as "".
Every ICU placeholder was hardcoded to "ICU". With two ICUs in one
message, both sub-messages mapped to the same placeholder, so the root
message passed "�I18N_EXP_ICU�" for ICU and "" for ICU_1, and the
second ICU rendered as an empty string.

Angular names ICU placeholders with getPlaceholderName('ICU', ...),
giving ICU, ICU_1, ... when it builds the enclosing message. Take the
names from that message, which the factory already builds, matched by
each ICU's start offset.
Found by compiling Angular 22.1.5's r3_view_compiler_i18n compliance
cases with ngc and oxc and comparing messages and rendered output.
Expected values are ngc's output.

- $localize placeholder names lose their underscores (STARTBOLDTEXT
  instead of START_BOLD_TEXT), which changes the message id.
- i18n-<attr> on an empty attribute creates an empty message.
- Custom placeholder names (// i18n(ph="PH")) are not used for the
  param, so the interpolation renders as "".
- Whitespace next to an ICU is dropped from the message.
- Elements with structural directives get duplicated tag values and a
  spurious i18nPostprocess.
- @if/@else inside i18n gets duplicated and missing block values.
Messages were stored with camelCase placeholders ({$startBoldText}) for
goog.getMsg, and $localize recovered the name by upper-casing it, which
drops the underscores: :STARTBOLDTEXT: instead of :START_BOLD_TEXT:.
Placeholder names are part of the id $localize computes, so every
message containing an element, block or custom placeholder got a
different id from the one ng extract-i18n produces, and its translation
was never applied.

Angular serializes the message separately for each target:
LocalizeSerializerVisitor uses formatI18nPlaceholderName(name, false)
and GetMsgSerializerVisitor the camelCase form. Store the original
names ({$START_BOLD_TEXT}), use them as-is for $localize and param
lookup, and convert to camelCase only for goog.getMsg.
i18n-title on title="" produced an empty translatable message. Angular's
I18nMetaVisitor only attaches a message to a plain attribute of that
name when it has a value (`meta !== undefined && attr.value`), so an
empty attribute, or a bound [title], stays a regular attribute/binding.
`{{ name // i18n(ph="PH") }}` produced a message with {$ph} but a param
named INTERPOLATION, so goog.getMsg got {"interpolation": ...} and
$localize substituted "" for PH. The placeholder names used for the
binding came from a separate code path that always used INTERPOLATION.

Use the custom name there too, as Angular's i18n parser does with
extractPlaceholderName.
The i18n message factory dropped whitespace-only text nodes, so
`{count, plural, ...} <b>x</b>` became "{$icu}{$startBoldText}..." and
whitespace in preserveWhitespaces templates was lost from messages.

Angular's _I18nVisitor.visitText keeps every text node; retainEmptyTokens
only concerns zero-length tokens in interpolated text. Whitespace that
should not be translated is removed earlier by the WhitespaceVisitor,
which keeps whitespace next to ICUs.
…rder

resolve_i18n_element_placeholders walked the view tree from the root and
then processed every child view again on its own. Placeholders in child
views were recorded twice, the second time without their structural
directive, giving values like "[�*2:1��#1:1�|�#1:1�]" and a
spurious i18nPostprocess. @else/@else if branches (ConditionalBranch
ops) were not handled, so their block placeholders had no value, and
child views were resolved after their parent's ops rather than at their
position.

Port Angular's resolvePlaceholdersForView: only walk from the root,
handle ConditionalCreate and ConditionalBranchCreate like templates, and
record template start, recurse, then record template close in op order.
recordTemplateClose also uses the enclosing block's subTemplateIndex,
not null.
Block placeholders were named from a counter shared by every block, so
the @else in `@if (a) {...} @else {...}` became START_BLOCK_ELSE_1 in the
params while the message said START_BLOCK_ELSE, and its value was lost.

Angular names block placeholders with the message's placeholder
registry, which numbers per name and reuses a name for an identical
block. Take the names from the message the factory already builds,
matched by each block's start offset, as is done for ICUs.
Second batch from comparing oxc with Angular 22.1.5 ngc on the
r3_view_compiler_i18n compliance cases (expected values are ngc's):

- colons in meaning/description are not escaped in the $localize raw
  metadata block, so the text renders the metadata
- ICUs outside an i18n block get no message and render nothing
- nested ICU VAR placeholders are numbered parent first
- interpolated attributes on elements inside an ICU are not mapped
- spaces around ICU keywords leave VAR_SELECT unresolved (runtime error)
- void elements add an empty {$} placeholder
- nested tag placeholders are numbered parent first
- <ng-content> tag placeholders get no value
- custom placeholder names with spaces (ph = "who") are ignored
- interpolations lose their value in an i18n element with a structural
  directive, or next to an element with an interpolated i18n attribute
- elements inheriting a namespace are not named with it
<img> inside an i18n block produced "{$TAG_IMG}{$}": the message
serializer always wrote a close placeholder, empty for void elements,
which added an unnamed placeholder with no value. Angular's
serializers write only the start placeholder when isVoid.
`{{ name // i18n(ph = "who") }}` was named INTERPOLATION because the
custom name was only recognised as the exact text i18n(ph=. Port
Angular's _CUSTOM_PH_EXP, which allows whitespace between the comment,
i18n, (, ph, = and the quoted name.
$localize finds the end of a message's metadata block by the first `:`
not escaped in the template's raw string. oxc emitted identical cooked
and raw strings with no escapes, so a meaning or description containing
`:` ended the block early: i18n="meaning:A|descA@@ida" rendered
"A|descA@@ida:Content A" and lost its custom id. A message starting with
`:` got a backslash in its cooked text instead.

Port Angular's createCookedRawString: cooked strings are unescaped, and
raw strings escape backslashes, colons inside the metadata block, a
leading colon, backticks and `${`. LocalizedStringExpr carries the raw
parts for the emitter.
In `<span title="x">a<span>inner</span></span>` the outer span was
START_TAG_SPAN and the inner START_TAG_SPAN_1; Angular's
_I18nVisitor._visitElementLike visits children before naming the
element, so the inner span is START_TAG_SPAN and the outer
START_TAG_SPAN_1. The message id and the param mapping both depend on
this.

Visit children first in the message factory, and take the tag names
used for element placeholders from that message (matched by the
element's start offset) instead of a separate registry that could
disagree with it.
For nested ICUs the message (built by the factory, which follows
Angular) numbers inner ICUs first: `{VAR_SELECT_2, select, male
{{VAR_SELECT, ...}} female {{VAR_SELECT_1, ...}} ...}`. The r3 ICU vars
were named separately, parent first, so VAR_SELECT_2 was mapped to the
first nested ICU's expression and the outer ICU switched on the wrong
value at runtime.

Name each ICU var from the ICU's message placeholders, matched by the
switch expression's start offset.
`{count, select , ...}` has the type "select ", so the message's VAR
placeholder is "VAR_SELECT ". oxc used that name for the ICU's var, and
the post-processing key became VAR_SELECT_, which left the ICU's
{VAR_SELECT , ...} unresolved: "Unable to parse ICU expression" at
runtime. Angular's r3 visitExpansion trims VAR keys (key.trim()).
In `{x, select, other {<span title="{{a}}-{{a}}">foo</span>}}` the
START_TAG_SPAN post-processing value was the literal markup, so the
rendered title was "{a}-{a}". Angular's r3 visitExpansion passes each
non-VAR placeholder through _visitTextWithInterpolation, making the
markup bound text whose expressions become placeholders:
<span title="�1�-�2�">.
<ng-content> in an i18n block appeared in the message as
START_TAG_NG_CONTENT/CLOSE_TAG_NG_CONTENT, but the r3 Content node had
no i18n metadata and ingest never set the projection's placeholder, so
both params were "" and the projected content was dropped from the
translated message. Angular's ingestContent passes content.i18n (a tag
placeholder) to createProjectionOp.
A text node inside an i18n block that is only an interpolation, like
`<b>{{ name }}</b>` or `{{ name }}<h1 ...>`, got a bare placeholder as
its i18n metadata instead of a container. Ingest reads interpolation
placeholder names from a container only, so the expression got no
placeholder, INTERPOLATION had no value, and the text rendered empty.

Angular's _visitTextWithInterpolation returns a Container whenever the
text has an interpolation; do the same.
`<div>{gender, select, male {...} other {...}}</div>` without an i18n
attribute rendered nothing: the ICU was dropped when not inside an i18n
element. Angular's I18nMetaVisitor gives every expansion a message, and
the wrapI18nIcus phase wraps an ICU that is not in an i18n block in its
own i18nStart/i18nEnd, which oxc already implements.
Angular 22.1.5 names SVG elements in an i18n message with their
namespace (START_TAG__SVG_SVG, START_TAG__SVG_CIRCLE), which is part of
the message id. oxc uses START_TAG_SVG.
Angular's HTML parser gives elements their full namespaced name (an
<svg> is :svg:svg, a <circle> inside it :svg:circle, a <span> inside
<xhtml:div> :xhtml:span), and i18n placeholder names derive from it:
START_TAG__SVG_SVG. oxc's HTML AST keeps the raw name, so any SVG icon
inside an i18n message was START_TAG_SVG, giving the message a
different id from ng extract-i18n.

Port _getElementFullName (explicit prefix, else the tag's implicit
namespace, else the parent's unless it prevents inheritance) into the
i18n message factory, tracking the parent element's full name, and
pass the i18n root's full name from html_to_r3.
Angular 22.1.5 compiles a template that is only an ICU surrounded by
whitespace to a single i18n instruction; oxc keeps the whitespace as
text nodes.
A template that is only `{age, select, ...}` with surrounding newlines
compiled to text(" "), i18n, text("\n"), so the rendered ICU had
extra whitespace. Angular's parseTemplate runs the whitespace visitor
over root nodes with visitAll rather than visitAllWithSiblings, so
root-level text has no sibling context and whitespace next to a
root-level ICU is removed (it is kept inside elements).
Angular 22.1.5 keeps &ngsp; as U+E500 in the message text, which is
part of the message id; oxc replaces it with a space.
`a&ngsp;b` in an i18n block became the message "a b"; Angular's is
"a\uE500b", so the message ids differed. Angular's _I18nVisitor builds
text with more than one token from the tokens, where whitespace
processing has only rewritten plain text tokens and an entity token
keeps its decoded value. Do the same, and return a Container for any
text with an interpolation, as _visitTextWithInterpolation does.
oxc emits no legacy message IDs (enableI18nLegacyMessageIdFormat) and
has no i18nNormalizeLineEndingsInICUs option, so translations keyed
only by legacy IDs are not applied. Remove the i18nNormalizeLineEndingsInIcus
option from the root README, which does not exist.
compareFullFileSemantically only compared `ClassName.ɵcmp = ...`
assignments, but both Angular and Oxc now emit `static ɵcmp = ...`
class fields, so no ɵcmp/ɵfac/ɵdir/ɵpipe/ɵmod was compared in
full-file mode. The template function, consts (where every i18n
message lives), decls and vars were never checked: with the compiler
from before the recent i18n fixes, which emitted goog.getMsg("") where
Angular emits goog.getMsg("Close"), all i18n fixtures still matched.
With this change 20 of the 28 fail against that compiler.

- Extract `static ɵ*` class fields from the AST.
- Compare field values by AST, ignoring differences that do not
  change behaviour: formatting and string escapes, arrow vs function
  for consts, $localize tagged template vs __makeTemplateObject call
  (cooked and raw strings are still compared), pure: true in
  ɵɵdefinePipe, and Closure-only i18n metadata Oxc does not emit
  (goog.getMsg variable names, original_code).
- Add fixtures/known-differences.ts for the 40 fixtures that this
  surfaces with real, non-i18n differences (query chaining, factory
  form, NgModule imports, host attribute order, style shimming, ...).
  Listed fixtures report as known differences; a listed fixture that
  matches fails so the entry gets removed.
Expected values are Angular 22.1.5 ngc output (legacy message ids off,
as in the compare tool):

- ICU placeholders in $localize carry their sub-message's id
  (:ICU@@911278603808503436:); oxc writes :ICU:.
- i18n text expressions are applied after advancing to the i18n
  block's last slot; oxc skips the advance() when an element with i18n
  attributes comes after the text.
- an interpolated i18n attribute on an explicit <ng-template> uses the
  bindings marker (3); oxc uses the i18n marker (6).
An interpolated i18n attribute on an explicit <ng-template> was
extracted with the i18n attribute marker (6). Angular's attribute
extraction uses the i18n marker only when the binding's templateKind is
null, i.e. not on a template, so it emits the bindings marker (3).
oxc's PropertyOp has no templateKind; treat bindings that target a
Template op as having one.
generate_advance only mapped some slot-consuming ops to their slots.
Update ops targeting an i18nAttributes op (such as i18n text
expressions, which assign_i18n_slot_dependencies moves to the i18n
block's last slot consumer) or a conditional branch got no advance(),
so they ran with the previous element selected. Angular's
generateAdvance maps every op with the consumes-slot trait.
When a message has no legacy ids, Angular's serializeI18nTemplatePart
writes the id of an ICU placeholder's sub-message into the placeholder
metadata: `${i18n_0}:ICU@@911278603808503436:`. oxc emits no legacy ids,
which is Angular's enableI18nLegacyMessageIdFormat: false output, but
wrote plain `:ICU:`.

Compute each ICU sub-message's id as Angular does,
computeMsgId(message.messageString, meaning), with a port of
serializeMessage (the messageString serializer, which differs from the
code-generation strings), and carry it through the message metadata to
the $localize placeholder. Message metadata is now built by one
I18nMessageMetadata::from_message instead of six copies.

The three tests that asserted `:ICU:` took their expected values from
ngc with legacy ids on; they now use ngc's legacy-off output, matching
oxc's legacy-less messages.
Class static fields were whitespace-collapsed before the AST
comparison, which also collapsed newlines inside Angular's multi-line
$localize template literals, so messages with newlines looked
different from Oxc's (which escapes them). Compare the raw field text:
the AST comparison already ignores formatting.

Known differences: remove edge-cases/unicode-special-escapes, which
was an artifact of the collapsing, and add the fixtures whose
remaining differences are selector whitespace in shimmed styles and
the pipe factory form (used by the i18n fixtures added next).
Generated by the new `pnpm generate:i18n-fixtures [angular-dir]`
(defaults to the Angular submodule) from Angular 22.1.5:

- every r3_view_compiler_i18n compliance case (99 input files), one
  fixture file per directory
- every template in the runtime i18n acceptance spec (129)

plus 34 hand-written scenarios checked while fixing oxc's i18n output
(entities, &ngsp;, @let, @switch, *ngFor, listeners, pipes in ICUs,
namespaces, custom placeholder names, ...).

NgModule declarations are made standalone so that fixtures compare
i18n output rather than NgModule scoping, which Oxc does not resolve.
Skipped, with reasons: external-template cases, three acceptance
templates that are intentionally invalid or need a spec-local
directive, and ngNonBindable inside i18n (Angular's compiler crashes).

Against the compiler from before the recent i18n fixes, 211 of the 262
active i18n fixtures fail; all pass now.
- Remove serialize_i18n_nodes and its helpers: the i18n message string
  now comes from the message factory, leaving them unused.
- Group the placeholder names taken from a message into
  MessagePlaceholderNames (icus/blocks/tags) and IcuPlaceholderNames
  (vars/interpolations) instead of five separate maps on the
  transformer.
- Merge create_message_in back into create_message with the
  parent_element parameter, rather than keeping two entry points.
Angular keeps `{$notAPlaceholder}` in an i18n message as text; oxc
treats it as a placeholder, dropping the text from $localize and
case-folding it in goog.getMsg.
`&voidzero-dev#123;$notAPlaceholder&voidzero-dev#125;` in an i18n message was treated as a
placeholder: $localize replaced it with an empty substitution and
goog.getMsg case-folded it, so the text was lost and the message id
was wrong. The message string cannot tell `{$NAME}` markers from text
that looks like one.

Escape `{$` and `{\` in message text when serializing the message
string, and unescape when generating goog.getMsg and $localize. The
serializer used for message ids is Angular's and stays unescaped.
…ldren

The i18n message an element shares with its only ICU child is consumed
by that child. Clear it afterwards so it cannot be picked up by a later
ICU if the child is ever not visited.
…the file

Use NormAstNode and unknown instead of any in the new AST comparison
and class field extraction.
The fixtures were generated from a 22.1.5 checkout; regenerate from the
repository's submodule (22.0.0) so that `pnpm generate:i18n-fixtures`
with no arguments reproduces them. Only the version header differs: the
i18n test cases are identical in both.
element_full_name allocated a String for every element in the template,
including the common case of an un-namespaced tag in a non-i18n template
where the name is returned unchanged. Returning a Cow borrows the name
unless a namespace prefix has to be prepended.
A fixture listed in known-differences.ts had every difference forgiven, so
a new regression in one of those 51 fixtures was reported as the known
difference instead of a failure. Each entry now records the static fields
that differ, and a difference in any other field of the same fixture fails
as usual.
The generator copied angularCompilerOptions.i18nUseExternalIds from
Angular's test cases into the fixtures, but neither compiler wrapper
passes it on, so both sides compile with external IDs either way. Drop
it rather than imply coverage of local message IDs that does not exist.
TransformOptions has crossFileElision and i18NUseExternalIds (N-API
capitalises the N), not enableCrossFileElision or i18nUseExternalIds, and
has no useDomOnlyMode. The plugin options block listed an interface name
and options the plugin does not have, and omitted ssrEntry,
angularVersion and templateTransform.
…i18n-bugs

# Conflicts:
#	crates/oxc_angular_compiler/src/pipeline/ingest.rs
#	crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs
#	crates/oxc_angular_compiler/src/pipeline/phases/i18n_closure.rs
#	crates/oxc_angular_compiler/src/pipeline/phases/i18n_const_collection.rs
#	crates/oxc_angular_compiler/src/transform/html_to_r3.rs
The recorded differences were captured against Angular 21.2.6. Fourteen
of them now match and are removed; the change-detection and incremental
hydration differences that 22.1.7 exposes are recorded with the fields
they affect.
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@Brooooooklyn
Brooooooklyn merged commit 0baf86c into voidzero-dev:main Sep 23, 2026
10 checks passed
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