Skip to content

fix(compiler): resolve arrow function parameters in template expressions - #566

Merged
Brooooooklyn merged 2 commits into
voidzero-dev:mainfrom
ashley-hunter:t3code/fix/template-arrow-parameter-scope
Oct 9, 2026
Merged

Brooooooklyn merged 2 commits into
voidzero-dev:mainfrom
ashley-hunter:t3code/fix/template-arrow-parameter-scope

Conversation

@ashley-hunter

@ashley-hunter ashley-hunter commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

An arrow function in a template that reads its own parameter was compiled as though the parameter were a component property. Present in 0.0.40.

@Component({
  selector: 'x-toggle',
  template: '<pressable (press)="open.update((o) => !o)"></pressable>',
})
export class Toggle {
  open = signal(false);
}

emitted ctx.open.update((o) => !ctx.o), which reads undefined at runtime with no diagnostic. It now emits ctx.open.update((o) => !o), as ngtsc does.

Root cause

Ingest had no arm for ArrowFunction, so the whole arrow was stored as a raw AST blob that skipped name resolution, and reify then turned every implicit-receiver read in it into ctx.<name>. Parameters were the visible symptom, but template variables and, in embedded views, the component itself were also read off the wrong ctx inside an arrow body (ctx.item + ctx.other inside a @for).

Changes

  • ingest.rs: convert arrow functions to IR and port Angular's updateParameterReferences (template/pipeline/src/ingest.ts), which replaces reads of an arrow's parameters, and of enclosing arrows' parameters, with plain variable reads before name resolution runs. Covers templates and host bindings. The host path also gained LiteralArray / LiteralMap / TemplateLiteral / Empty arms so arrows inside those containers resolve instead of being stored as opaque AST.
  • resolve_names.rs: resolve the remaining names in an arrow body against template variables and the component context, including arrows inside stored AST expressions such as Chain (multi-statement handlers).
  • resolve_dollar_event.rs: a $event read inside an arrow body resolves to a plain variable, matching upstream resolveDollarEvent.
  • ir/expression.rs: new TaggedTemplateLiteral and SpreadElement IR kinds so arrows inside tagged templates and spreads are converted and resolved rather than stored as raw AST.
  • generate_arrow_functions.rs / compilation.rs: only top-level arrows join view.functions, matching upstream addArrowFunctions (listener and nested arrows stay in place). Collected arrows are marked with a sentinel var_offset so var_counting reserves one slot for them, per upstream varsUsedByIrExpression, while in-place arrows reserve none.
  • reify/ir_expression.rs: emit the arrow in place.
  • save_restore_view.rs: the handler expression is moved into ResetView rather than cloned, preserving pointer identity for collected arrows.

Tests

  • tests/arrow_function_scope_test.rs: 19 exact-string tests. With the source change reverted, 17 fail; the two that pass are the parameterless cases that already worked.
  • e2e/compare/fixtures/templates/arrow-functions.fixture.ts: 14 fixtures compared with ngtsc 22.2.1, including the four listener cases ported from Angular's r3_view_compiler_arrow_functions compliance suite (no_context, dollar_event, nested_listeners, host_listener).

Covered: one parameter, several parameters, a parameter named like a component property, a parameter plus a component property, a nested arrow reading the outer parameter, a parameter shadowing an @let / a @for item / a #ref, $event inside an arrow, and parameterless arrows, in event bindings, property bindings, interpolations and @let.

Not in this PR

  • Outside listeners ngtsc hoists the arrow (const arrowFn0 = (ctx, view) => o => !o plus i0.ɵɵarrowFunction(1, arrowFn0, ctx)). That instruction is not implemented here, so those arrows are still emitted in place, now with the same body as ngtsc's hoisted function. A consequence worth noting: an arrow bound outside a listener gets a fresh function identity on every change-detection run, which can re-trigger input setters/ngOnChanges. Follow-up planned.
  • f(...other) in a template compiles to f(other) on main, in or out of an arrow — the spread collapse itself is a separate bug. Arrow parameters inside spreads now resolve correctly.

One ngtsc quirk is ported on purpose: in (a) => f((b) => b) + b ngtsc leaves the trailing b as a bare variable rather than ctx.b, because the parameter set only grows. There is a test documenting it.

Verified

  • cargo test -p oxc_angular_compiler: 3090 passed
  • cargo fmt --all -- --check, cargo check --all-features: clean
  • cargo run -p oxc_angular_conformance: 1264/1264, no snapshot diff
  • pnpm test: 401 passed; pnpm check: clean; pnpm test:e2e: 37 passed
  • pnpm --filter @oxc-angular/compare compare --fixtures: 994 fixtures, 0 mismatches

An arrow function in a template that read its own parameter was compiled
as though the parameter were a component property:
`open.update((o) => !o)` emitted `(o) => !ctx.o`, which reads `undefined`
at runtime with no diagnostic.

Ingest had no arm for `ArrowFunction`, so the whole arrow was stored as a
raw AST blob that skipped name resolution, and reify then turned every
implicit-receiver read in it into `ctx.<name>`. That also meant template
variables and, in embedded views, the component itself were read off the
wrong `ctx` inside an arrow body.

- ingest.rs: convert arrow functions to IR and port Angular's
  `updateParameterReferences`, which replaces reads of an arrow's
  parameters (and of enclosing arrows' parameters) with plain variable
  reads before name resolution runs. Covers templates and host bindings.
- resolve_names.rs: resolve the remaining names in an arrow body against
  template variables and the component context.
- reify/ir_expression.rs: emit the arrow in place.
- var_counting.rs: an in-place arrow does not reserve a var slot.

Verified against ngtsc 22.2.1. Arrows in event bindings now match it,
including the four listener cases ported from Angular's
`r3_view_compiler_arrow_functions` compliance suite. Outside listeners
ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not
implemented yet: the arrow is still emitted in place, now with the same
body as ngtsc's hoisted function.
Follow-up to the listener-arrow fix, addressing cases where arrow
parameters still emitted ctx.-prefixed reads:

- generate_arrow_functions: only top-level arrows join view.functions
  (matching upstream, where unit.functions holds just the non-listener
  arrows created by addArrowFunctions); listener and nested arrows are
  emitted in place and no longer receive dead variable ops, naming,
  or save/restore processing. Collected arrows are marked with a
  var_offset sentinel so var_counting can distinguish them; collected
  arrows consume a slot per upstream varsUsedByIrExpression.
- ir: IN_ARROW_FUNCTION_OPERATION is set only inside top-level arrow
  bodies, as upstream applies it; arrow ops lists are traversed.
- resolve_dollar_event: LexicalRead('$event') inside IR handler
  expressions resolves to a plain ReadVar, matching upstream.
- resolve_names: resolve_angular_expression resolves arrow parameters
  inside stored AST expressions (Chain, and other raw-AST contexts)
  with a shared grow-only parameter set, matching updateParameterReferences.
- ingest: convert LiteralArray, LiteralMap, TemplateLiteral and Empty
  on the host path too, so host bindings no longer store them as opaque
  AST blobs that skip name resolution.
- ir: new TaggedTemplateLiteral and SpreadElement expression kinds so
  arrows inside tagged templates and spreads are converted and
  resolved instead of stored as raw AST; threaded through emit,
  reify, generate_advance, next_context_merging, resolve_dollar_event,
  store_let_optimization, strip_nonrequired_parentheses,
  temporary_variables and variable_optimization.
- save_restore_view: move the handler expression into ResetView instead
  of cloning, preserving pointer identity for collected arrows.

Verified: cargo test -p oxc_angular_compiler 3090/3090, conformance
1264/1264, fixture compare vs ngtsc 22.2.1 994 fixtures 0 mismatches.
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Brooooooklyn
Brooooooklyn merged commit 250510c into voidzero-dev:main Oct 9, 2026
10 checks passed
Brooooooklyn added a commit that referenced this pull request Oct 10, 2026
* fix(directive): evaluate `host` metadata statically like ngtsc

A `host` that is not a plain object literal of string values was
silently dropped:

    const SHARED = { '(press)': 'go()' };
    @component({ host: { ...SHARED, accessibilityRole: 'button' } })

kept the attribute and lost the listener, and `host: HOST` compiled to no
host bindings at all. Neither reported an error.

The component and directive extractors each read `host` by walking an
`ObjectExpression`'s own properties, skipping spreads and returning
nothing for any other expression. ngtsc evaluates the expression with
its partial evaluator (`evaluateHostExpressionBindings`). The evaluator
that models it here already serves `inputs:`, `outputs:` and `queries:`;
`host` now goes through it too, from one shared `evaluate_host_metadata`.

- A spread, a constant (`const`, `let`, `as const`), a member access, a
  same-file function call and a computed key all resolve, and compile to
  the same bindings as the entries written out. As in a `Map`, a repeated
  key takes the later value and keeps the first position.
- A host that cannot be used is reported on the host expression, with
  ngtsc's message: `Decorator host metadata must be an object`, or `...
  must be a string -> string object, but found unparseable value`, each
  with the line describing the value, and `Property/Event/Class/Style
  binding must be string` for a binding whose value is dynamic. The
  error joins the `decorator_io_errors` chain after `selector`, which is
  where ngtsc checks it.
- An import is read when `resolve_imported_values` is on, as for the
  other fields; otherwise the existing "imported from another module"
  error says why.

One case does not match ngtsc: it emits a plain attribute's dynamic
value as an expression (`hostAttrs: ["role", make()]`). Host attributes
are strings here, so that is an error rather than a drop.

`host_attribute_unknown_identifier_dropped` pinned the silent drop for an
unresolvable computed key; it now expects ngtsc's error.

Verified against @angular/compiler-cli 22.2.1: 15 compare fixtures, and
83 cases diffed against its output and diagnostics.

* fix(dts): keep the comments ngtsc keeps on `ngAcceptInputType_*` types (#565)

* fix(dts): keep the comments ngtsc keeps on `ngAcceptInputType_*` types

The dts type printer dropped every comment; ngtsc's path through
TypeScript's emitter (`onlyPrintJsDocStyle`) keeps `/**`-style and `/*!`
comments on a node's leading/trailing edges, plus every comment the
intervening list scans collect.

Rewrite the printer to port the emitter's comment paths verbatim:
`emitNodeList`/`emitNodeListItems` list handling with `ListFormat` flags,
`emitTokenWithComment`, the `containerPos`/`containerEnd` dedup in
`emitCommentsBeforeNode`/`emitCommentsAfterNode`, `iterateCommentRanges`
(including U+2028/U+2029 counting as line breaks while the scan reads
past them), and `writeCommentRange` re-indenting. This also reproduces
the quirks: a comment scanned by two enclosing lists prints twice, and a
comment after a named tuple member's `?` is dropped because the colon's
position scan starts after it.

Verified against ngtsc 22.1.7: the `COMMENTS_NOT_KEPT` fixture becomes
`COMMENTS_KEPT` and gains the remaining issue cases (`string /** doc */`,
`string | number /** doc */`, a leading `/** doc */` on its own line, a
multiline `/* */` inside type arguments, and the doubled `Array<\u2028`
comment).

Closes #506

* fix(dts): comment-scan edges in the dts type printer

Follow-ups from review of the emitter port:

- `token_end_before` misread `//` inside strings/comments and paired a
  trailing `*/` with the last `/*` instead of the first unpaired one,
  corrupting output like `{ a: 'x//y' ...}` and `/** a /** b */`.
  Both scans are now string/comment aware.
- Type-reference names are synthesized nodes upstream (pos/end -1), so
  they keep no comments: `Signal /** j */ <number>` prints
  `i0.Signal<number>`.
- A tuple's `]` scans from the element list's `end`, past a trailing
  comma; mapped-type `±readonly`/`±?` are two tokens each; the `...` of a
  binding rest and `${` of a template literal are emitted tokens; a
  `typeof` entity name is emitted part by part. Each picks up the
  comments TypeScript's own scans keep.
- `isWhiteSpaceSingleLine` is TypeScript's exact codepoint list (adds
  NEL, ZWSP and friends), and `ListFormat.Indented` applies
  unconditionally, indenting index-signature parameters' comments one
  extra level.

Also documents the known multi-line gap: ngtsc's `disposeEmitNodes`
clears `SingleLine` marks on earlier-emitted fields, so bare tuple/type
literal/mapped types can print multi-line there. That needs the
field-order mechanism and predates the comment port.

* fix(dts): one write per template quasi; `//` inside `/* */` is comment text

- TSTemplateLiteralType emits each quasi chunk (`` `text${` ``,
  `}text${`, `}text``) as a single write like TypeScript's emitLiteral,
  so a line break inside the text never gets re-indented: `` `a\n${string}` ``
  prints `${` at column 0. The chunk stays inside emit_node, so the
  head's trailing `/** j */` scan still fires.
- line_comment_start now scans the whole text with string/block-comment
  state, so a `//` on the continuation line of a `/* */` no longer
  truncates the line: `string | /* a\n // b */\n number` keeps the
  comment like ngtsc.

* fix(dts): real elision positions and trailing commas in binding lists

- An OmittedExpression is zero-width right after its preceding comma,
  not at prev_end + 1 (which landed inside the previous element's
  trivia). Track a cursor past each element's terminating comma instead.
- Both binding list formats carry AllowTrailingComma: `[a, ,]` and
  `{a,}` keep their trailing comma (emitTokenWithComment on the last
  element — an elision suppresses the comma's leading scan since
  pos == startPos — plus the comma's own same-line trailing scan, and
  the closing scan then runs at the list's end).

* fix(dts): `${ }` holes in a template lex as code in the trivia scan

A backward trivia scan that starts inside a template placeholder saw the
opening backtick as an unterminated string and gave up, so
`` `${\n/** doc */\nstring}` `` lost the JSDoc that ngtsc keeps. Template
text is now skipped as text while `${ }` regions are lexed as code —
comments inside them count, braces track `{ T: U }` members, and nested
templates recurse — in both `line_comment_start` and
`block_comment_start`.

* fix(dts): lex the source's comments once; skip regex literals

Two changes in the backward trivia scan's lexer:

- `lex_comments` builds the file's `//`/`/* */` table in one pass and
  `pos_of` queries it, instead of rescanning the whole prefix per node.
- The lexer skips regex literals (operand heuristic + `[...]`/escape
  aware), so a `/[/*]/` earlier in the file can't pair its `/*` with a
  comment's `*/` inside the type and drop the comment ngtsc keeps.

* fix(dts): hole scan resume, keyword regexes, TS trivia trim

- `template_end` resumed past `hole_end`'s return plus one more byte,
  skipping the closing backtick of `` `${string}` `` and swallowing the
  rest of the file as template text.
- A `/` after a keyword (`return`, `throw`, `case`, `typeof`, …) opens a
  regex — the operand heuristic only saw the identifier's last byte and
  called it division, so `return /[/*]/` leaked a `/*` into the comment
  table.
- `token_end_before` trims trivia with `is_whitespace_like`
  (`isWhiteSpaceSingleLine` + line breaks) instead of `char::is_whitespace`:
  U+200B/U+FEFF count as trivia and U+2028/9 stay line breaks.

* fix(dts): build the comment table from the parser's own comments

The hand-rolled lexer was chasing lexing edge cases (`\`` escapes,
regexes after `return`, regexes as statement bodies — `if (ok) /re/.test(x)`)
and re-lexed the whole file for every printed type. `program.comments`
already has the file's comment ranges — lexed correctly once — so
`Lexed` is now built from it and shared across a class's printers via
`Rc`, deleting the lexer (and its regex/keyword/prev-token machinery)
outright.

* fix(dts): binary-search the comment table in the trivia scan

The sorted, disjoint comment ranges admit a partition_point lookup: the
only candidate covering a window end is the last range starting before
it — O(log n) instead of a linear scan per pos_of call.

* fix(dts): signed mapped modifiers emit only the sign token

`readonlyToken`/`questionToken` are composite: `emit` prints the sign
and `writeKeyword`/`writePunctuation` prints `readonly`/`?` with no
comment scan, so a comment after a line break between them is dropped
upstream. Wrapping the keyword in emit_node preserved it. Same-line
comments still print as the sign's own trailing comments.

* fix(compiler): resolve arrow function parameters in template expressions (#566)

* fix(compiler): resolve arrow function parameters in template expressions

An arrow function in a template that read its own parameter was compiled
as though the parameter were a component property:
`open.update((o) => !o)` emitted `(o) => !ctx.o`, which reads `undefined`
at runtime with no diagnostic.

Ingest had no arm for `ArrowFunction`, so the whole arrow was stored as a
raw AST blob that skipped name resolution, and reify then turned every
implicit-receiver read in it into `ctx.<name>`. That also meant template
variables and, in embedded views, the component itself were read off the
wrong `ctx` inside an arrow body.

- ingest.rs: convert arrow functions to IR and port Angular's
  `updateParameterReferences`, which replaces reads of an arrow's
  parameters (and of enclosing arrows' parameters) with plain variable
  reads before name resolution runs. Covers templates and host bindings.
- resolve_names.rs: resolve the remaining names in an arrow body against
  template variables and the component context.
- reify/ir_expression.rs: emit the arrow in place.
- var_counting.rs: an in-place arrow does not reserve a var slot.

Verified against ngtsc 22.2.1. Arrows in event bindings now match it,
including the four listener cases ported from Angular's
`r3_view_compiler_arrow_functions` compliance suite. Outside listeners
ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not
implemented yet: the arrow is still emitted in place, now with the same
body as ngtsc's hoisted function.

* fix(compiler): close arrow-parameter resolution gaps found in review

Follow-up to the listener-arrow fix, addressing cases where arrow
parameters still emitted ctx.-prefixed reads:

- generate_arrow_functions: only top-level arrows join view.functions
  (matching upstream, where unit.functions holds just the non-listener
  arrows created by addArrowFunctions); listener and nested arrows are
  emitted in place and no longer receive dead variable ops, naming,
  or save/restore processing. Collected arrows are marked with a
  var_offset sentinel so var_counting can distinguish them; collected
  arrows consume a slot per upstream varsUsedByIrExpression.
- ir: IN_ARROW_FUNCTION_OPERATION is set only inside top-level arrow
  bodies, as upstream applies it; arrow ops lists are traversed.
- resolve_dollar_event: LexicalRead('$event') inside IR handler
  expressions resolves to a plain ReadVar, matching upstream.
- resolve_names: resolve_angular_expression resolves arrow parameters
  inside stored AST expressions (Chain, and other raw-AST contexts)
  with a shared grow-only parameter set, matching updateParameterReferences.
- ingest: convert LiteralArray, LiteralMap, TemplateLiteral and Empty
  on the host path too, so host bindings no longer store them as opaque
  AST blobs that skip name resolution.
- ir: new TaggedTemplateLiteral and SpreadElement expression kinds so
  arrows inside tagged templates and spreads are converted and
  resolved instead of stored as raw AST; threaded through emit,
  reify, generate_advance, next_context_merging, resolve_dollar_event,
  store_let_optimization, strip_nonrequired_parentheses,
  temporary_variables and variable_optimization.
- save_restore_view: move the handler expression into ResetView instead
  of cloning, preserving pointer identity for collected arrows.

Verified: cargo test -p oxc_angular_compiler 3090/3090, conformance
1264/1264, fixture compare vs ngtsc 22.2.1 994 fixtures 0 mismatches.

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* fix(hmr): write the new template's fields and stop redefining the live definition (#570)

* fix(hmr): write the new template's fields and stop redefining the live definition

The update module `compileForHmrSync` returns replaced a component's
template like this:

    X.ɵcmp = i0.ɵɵdefineComponent({
      ...X.ɵcmp,
      ...(X.ɵcmp.inputConfig ? { inputs: X.ɵcmp.inputConfig } : {}),
      template: function X_Template(rf, ctx) { ... },
    });

Three things were wrong with it.

- `decls`, `vars` and `ngContentSelectors` were not written, so the
  spread kept the old template's. A swap that added an element or a
  binding rebuilt a view sized for the old template and failed partway
  ("Index expected to be less than 30", "TNodes should be created before
  any bindings"). `consts` was written only when the new template had
  some, so a template that lost its last attribute kept the old ones.
- The live definition's `outputs` went back through `ɵɵdefineComponent`,
  which had already converted them to `{ publicName: propertyName }` and
  converted them again. A component with `checked = model(false)` ended
  up with `outputs: { checked: 'checkedChange' }`.
- The live definition has no `changeDetection`, so `onPush` was derived
  again as true: an Eager component became OnPush after a swap.

`inputs` was already restored from `inputConfig` for the second reason.
Rather than undo each conversion, the module no longer calls
`ɵɵdefineComponent`. Only the template is compiled on this path, so the
rest of the definition is the live one, copied as it is:

    X.ɵcmp = {
      ...X.ɵcmp,
      decls: 4,
      vars: 1,
      consts: null,
      ngContentSelectors: _c1,
      template: function X_Template(rf, ctx) { ... },
      tView: null,
    };

Every field the template decides is written, including the ones the new
template has no value for. `tView` is cleared so the runtime builds the
view for the new template. `ɵɵreplaceMetadata` then merges this into the
live definition and re-creates the views, as before.

- `compile_template_for_hmr` returns `decls`, `vars` and the
  `ngContentSelectors` expression alongside the template.
- `HmrUpdateModuleOptions` takes them as `HmrTemplateFields`.
- `generate_hmr_update_module_from_js` (`generateHmrModule`), which is
  given only template JavaScript, emits the same shape without the
  counts it cannot know; its docs say so.

Angular's own update module is a whole new `ɵɵdefineComponent({...})`
built from the component class. That needs the class, which this
template-only path does not have, so the shape differs: the definition
is not rebuilt and the component id does not change.

Tests:
- `test/hmr-update-module.test.ts`: 18 tests that compile a component,
  load it with the real `@angular/core`, apply the update module through
  `ɵɵreplaceMetadata`, and compare the template's fields with a fresh
  compile of the new template and every other field with what it was.
  `@angular/core` is a dev dependency for this.
- `e2e/tests/hmr-update-definition.spec.ts`: 6 browser tests that edit
  templates on disk and check what renders: elements and bindings gained
  and lost, `<ng-content select>` gained, changed and lost, consts,
  outputs still reaching the parent after one and two swaps, the change
  detection strategy, and a component whose path contains `@`.

* test(e2e): space repeat edits past the watcher's change throttle

On Linux a hot swap is on screen about 30ms after the write, so a test that
edited the same template again straight away wrote inside chokidar's 50ms
change throttle and the dev server never saw the second edit. Wait 200ms
before editing a file a second time.

* fix(hmr): close update-module gaps found in review

Follow-up to the definition-merge fix, addressing cases where a hot
swap still carried stale or invalid state:

- update_module: emit `tView: null` only when a template is being
  replaced. Styles-only updates no longer force a needless view
  rebuild through getOrCreateComponentTView.
- update_module: `generate_hmr_update_module_from_js` emitted invalid
  JS (`template: ,`) for a styles-only call with empty template; empty
  template_js now produces a styles-only module with no
  template/consts/tView keys.
- update_module: a template swap with no consts now emits
  `consts: null` instead of omitting the key, so the new template no
  longer indexes into the old template's constant pool.
- transform: compile_template_for_hmr propagates error-severity
  diagnostics instead of emitting decls/vars = 0 output, and always
  uses DeferBlockDepsEmitMode::PerComponent (PerBlock left @defer
  resolver calls with no deferred-dependencies block to resolve,
  matching upstream handler.ts:1281 for local compilation).
- lib.rs / vite-plugin: honor the component's declared encapsulation
  when encapsulating styles for a hot update instead of assuming
  Emulated, so ViewEncapsulation.None/ShadowDom components keep
  unshimmed CSS; error-severity diagnostics surface as errors and the
  plugin falls back to a full reload.
- Document that the JS-facing generateHmrModule only produces correct
  modules when decls/vars/ngContentSelectors are unchanged; the vite
  plugin's live path (compileForHmrSync) writes all of them.

Verified: cargo test -p oxc_angular_compiler 3065 passed, vitest
hmr-update-module + ssr-hmr 30/30 against real @angular/core 22.2.1.

* style: format hmr-update-module.test.ts with oxfmt

* fix(hmr): resolve listener diagnostics that surfaced as full reloads

The fatal-diagnostics change exposed two latent false-positive
diagnostics in the template-only HMR path, which CI's e2e suite turned
into page reloads for any component using `$event` or a two-way
binding:

- resolve_names verifier: `$event` inside listener handler ops
  legitimately remains a LexicalRead until reify emits it as the
  handler parameter (upstream's resolveDollarEvent rewrites it to a
  ReadVarExpr before its verifier runs; this port defers to reify).
  Exempt `$event` reads visited with IN_CHILD_OPERATION so plain
  bindings like `{{ $event }}` still error as upstream does.
- transform_two_way_binding_set: validate resolved target forms
  (ResolvedPropertyRead/ResolvedKeyedRead/ResolvedSafePropertyRead)
  alongside the raw-Ast ones, matching upstream's acceptance of
  o.ReadPropExpr / o.ReadKeyExpr / ir.ReadVariableExpr. A resolved
  `[(checked)]="ioChecked"` target no longer reports "Unsupported
  expression in two-way action binding".

Verified: cargo test 3065/3065, vitest hmr suites 30/30, and the
previously failing playwright specs pass locally (hmr-css, hmr-html,
hmr-html-write-strategies 12/12, hmr-update-definition +
hmr-input-component 10/10).

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* feat(compiler): hoist template arrow functions through ɵɵarrowFunction (#567)

* fix(compiler): resolve arrow function parameters in template expressions

An arrow function in a template that read its own parameter was compiled
as though the parameter were a component property:
`open.update((o) => !o)` emitted `(o) => !ctx.o`, which reads `undefined`
at runtime with no diagnostic.

Ingest had no arm for `ArrowFunction`, so the whole arrow was stored as a
raw AST blob that skipped name resolution, and reify then turned every
implicit-receiver read in it into `ctx.<name>`. That also meant template
variables and, in embedded views, the component itself were read off the
wrong `ctx` inside an arrow body.

- ingest.rs: convert arrow functions to IR and port Angular's
  `updateParameterReferences`, which replaces reads of an arrow's
  parameters (and of enclosing arrows' parameters) with plain variable
  reads before name resolution runs. Covers templates and host bindings.
- resolve_names.rs: resolve the remaining names in an arrow body against
  template variables and the component context.
- reify/ir_expression.rs: emit the arrow in place.
- var_counting.rs: an in-place arrow does not reserve a var slot.

Verified against ngtsc 22.2.1. Arrows in event bindings now match it,
including the four listener cases ported from Angular's
`r3_view_compiler_arrow_functions` compliance suite. Outside listeners
ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not
implemented yet: the arrow is still emitted in place, now with the same
body as ngtsc's hoisted function.

* fix(directive): declare constants pooled by host bindings

A `@Directive` host binding that needs a pooled constant referenced it
without declaring it. `host: {'[attr.x]': 'f([a, b])'}` emitted
`i0.ɵɵpureFunction2(1, _c0, ctx.a, ctx.b)` with no `const _c0`, which
throws a ReferenceError the first time the binding runs. Components
already declared theirs.

`compile_directive` returned the declarations, but
`generate_dir_definition` dropped them. They are now carried on
`DirectiveDefinitions` and emitted before the class.

* feat(compiler): hoist template arrow functions through ɵɵarrowFunction

Outside event listeners, ngtsc does not emit a template arrow function
in place. It hoists it to a shared factory and instantiates it once per
view:

    const arrowFn0 = (ctx, view) => (o) => !o;
    i0.ɵɵproperty("title", ctx.run(i0.ɵɵarrowFunction(1, arrowFn0, ctx)));

We emitted the arrow inline, so a new function was created on every
change detection pass. This ports the hoisting.

- ir: `ArrowFunctionExpr` gains a `hoisted` flag, set by
  generateArrowFunctions for every arrow that is not inside a listener
  or nested in another arrow. Its ops now survive cloning.
- A hoisted arrow is its own lexical scope. generateVariables,
  saveAndRestoreView, resolveNames, resolveContexts,
  generateTemporaryVariables and optimizeVariables process its ops and
  body the way they process a listener's handler, through a new
  `ArrowFunctionExpr::with_handler`. In an embedded view the arrow
  restores the view it was created in and resets it around its result.
- naming: variables that come from a parent view share their name with
  the rest of the view; only the arrow's own view scope is fresh. Let
  reference variables record their declaring view so this can be told.
- countVariables reserves a var slot per hoisted arrow, and reify emits
  the factory into the constant pool as `arrowFn<N>`, sharing equivalent
  factories. Those names take part in the per-file offset so two
  compilations in one file cannot collide.
- output: cloning a `WrappedIrNode` cloned it as `undefined`; it is now
  cloned properly, since a hoisted arrow keeps such statements.

The phases keep pointers to the hoisted arrows of a view, and several
phases rebuild expressions, so the list is collected again by each phase
that walks it.

Verified against ngtsc 22.2.1 with 55 compare fixtures, including every
case in Angular's `r3_view_compiler_arrow_functions` compliance suite
that does not need `legacyOptionalChaining`.

Two deliberate differences from ngtsc:

- An arrow in a `@for` `track` expression stays in place. ngtsc hoists
  it into the track function, where `ctx` is not in scope, so its output
  throws a ReferenceError.
- When one file has several compilations (a class with both host
  bindings and a template, or several classes), ngtsc numbers `arrowFn`
  constants file-wide and shares identical ones between them. Constant
  pools here are per compilation, so the names are unique but not
  contiguous and identical arrows are not shared across pools.

* fix(compiler): resolve names in spread arguments and tagged templates

A spread call argument and a tagged template literal were stored as raw
expressions that skipped name resolution, so the names they read were
taken off the current view's `ctx` whatever they referred to. Inside a
`@for`:

    {{ f(1, ...item) }}       ->  ctx_r0.f(1, ctx.item)
    {{ tag`a${item}` }}       ->  ctx.tag(<template object>, ctx.item)

The spread also lost its `...`, in any view, and a tagged template read
its tag off the embedded view. The same hole meant an arrow function
parameter used in either position resolved to `ctx.<param>`.

- ingest.rs: convert both to IR so every phase resolves their operands.
  A spread argument becomes a unary `Spread` operator. A tagged template
  becomes a `ResolvedTemplateLiteral` flagged `tagged`, with the tag as
  its first expression. Host bindings get the same arms, plus the plain
  template literal arm they were missing.
- reify: emit `...expr`, and rebuild the tagged template with its text
  escaped again from the cooked form.
- emitter.rs: print tagged templates natively, as ngtsc does, instead of
  as `tag(__makeTemplateObject(cooked, raw), ...)`. The tag now receives
  the same strings array on every call.

Verified against ngtsc 22.2.1 with 14 compare fixtures.

* fix(compiler): emit tagged-template raw text like ngtsc

escapeForTemplateLiteral(escapeSlashes(text)) escapes ${ as $\{ and
leaves line breaks literal; cooked_to_raw_text now matches so a tag's
strings.raw is identical to ngtsc's.

* fix(compiler): close arrow-hoisting review gaps

- transform/visit expression visitors now recurse into Statement ops'
  WrappedIrNode payloads (both create and update lists), matching
  upstream where OpKind.Statement shares the same transform
- host-path resolve_names and generate_temporary_variables refresh and
  process root.functions, matching upstream Kind.Both phases that
  iterate unit.functions
- assign_temp_names no longer descends into arrow bodies; hoisted arrows
  name their own temporaries through unit.functions
- var_counting counts inner var consumers before the arrow's own
  offset, matching upstream's post-order visit
- OutputExpression::is_equivalent gains TemplateLiteral,
  TaggedTemplateLiteral, Instantiate, DynamicImport, WrappedNode,
  WrappedIrNode, LocalizedString arms for factory dedup
- ResolvedTemplateLiteral element raw_text is re-escaped with
  cooked_to_raw_text at the reify/emit conversion sites
- ir_is_unary no longer treats Unary(Spread) as a unary needing parens
  before **
- remove dead emit_additional_pool_constants

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* fix(component): evaluate `styles` statically like ngtsc (#572)

* fix(component): evaluate `styles` statically like ngtsc

A `styles` entry that was not a string literal, a template literal or a
same-file string constant was dropped without an error: a concatenation, a
spread, an array constant or an import all compiled to fewer styles than
written, or to none.

`styles` is now read through the static evaluator, as ngtsc's
`parseDirectiveStyles` does. A value that is a string or an array of strings
gives those styles. Anything else is ngtsc's error on the `styles` expression
(`Failed to resolve @component.styles to a string or an array of strings`,
`Failed to resolve styles at position N to a string`), reported after the
selector as ngtsc orders it. An import that cannot be read, which ngtsc
resolves with the whole program, is the existing one-file-at-a-time error
unless `resolveImportedValues` resolves it.

`extractComponentMetadataSync` reads the same value, so the HMR endpoint now
serves the styles of an array constant instead of `styles: []`.

* fix(compiler): report styles errors after the constructor checks, keep enum identity across files, and flag unresolved styles for HMR

Deep-review fixes on top of the styles static-evaluation port:

- Ordering: ngtsc's getConstructorDependencies throws inside
  extractDirectiveMetadata, before the component handler reads `styles`
  (parseDirectiveStyles). Emitting the styles error from
  decorator_io_errors suppressed the parameter errors; it now runs only
  when the io and constructor checks pass.
- Enums: ngtsc keeps EnumValue identity across module resolution, so
  `styles: [E.A]` from an import is the same `Value is of type 'E'`
  error as a same-file one. StaticValue::to_static unwrapped the enum
  to its value and the import silently compiled as a string; it now
  round-trips through StaticValue::Enum.
- HMR: extractComponentMetadataSync evaluates `styles` without
  resolveImportedValues, so imported styles extract as an empty array —
  an unknown answer the vite plugin served as "clear the styles",
  wiping live CSS. ComponentMetadata and ExtractedComponentMetadata now
  carry `styles_resolved`, and the plugin only clears when it is true.

Co-Authored-By: Grok <noreply@x.ai>

* fix(e2e): set stylesResolved on TypeScript-extracted metadata

ExtractedComponentMetadata gained stylesResolved; the finder builds the
type from the TypeScript extractor's output, which always resolves
styles itself.

* fix(compiler): fold duplicate styles keys last-wins, and serve no HMR styles when the extractor cannot evaluate them

Second round of deep-review fixes:

- Duplicate `styles:` properties: ngtsc's reflectObjectLiteral folds
  them with Map.set, so the last key is the only one parseDirectiveStyles
  sees. extract_component_metadata kept the last SUCCESSFUL entry —
  `{styles:['a'], styles:1}` emitted 'a' alongside the error for `1` —
  and computed `[K]: styles` entries leaked into emit that upstream
  drops. Each `styles` prop now fully overwrites (computed keys skipped),
  matching the last-wins semantics `config_property` already gives the
  diagnostic path.
- HMR: `stylesResolved` only gated the empty-answer case. A component
  with `styles: [IMPORTED]` + `styleUrls: ['./a.css']` merged external
  CSS alone and served it as definitive, dropping the imported inline
  styles the transform compiled. An unresolvable `styles` field now
  makes the whole answer `null` regardless of what external contents
  were read.
- Tests: the imported-constant HMR test now transforms the disk source
  verbatim (as Vite does), so stylesResolved — not the strip-match
  fallback — is the leg being exercised; a new test pins the
  imported-styles + styleUrls mix; a Rust test pins last-wins.

Co-Authored-By: Grok <noreply@x.ai>

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: Grok <noreply@x.ai>

* fix(parser): keep capitalised element names as elements (#571)

* fix(parser): keep capitalised element names as elements

A template containing an element whose name starts with a capital letter
compiled to an empty template with no error:

    @component({ selector: 'x', template: '<View><text>hi</text></View>' })

gave `errors: []` and `decls: 0`. Some such templates never finished
compiling at all.

Angular's parser is case-sensitive for element names and keeps them as
written; a tag definition is looked up by the lowercased name only to
learn how a known HTML element behaves. Four places here disagreed.

- html_to_r3.rs: an element whose name began with a capital or `_` was
  turned into a selectorless component node, whatever the parse mode.
  Only the selectorless lexer mode produces those, the compile pipeline
  never enables it, and ingest discards them, as Angular's does, so the
  element and its whole subtree vanished. The element's own
  `is_component` flag, set by the lexer, now decides.
- parser.rs: an open element with an optional end tag (`<LI>`, `<P>`,
  `<Td>`) was found to need closing by its lowercased name, then looked
  up on the stack by that lowercased name, never found, and never
  popped: `<UL><LI>a<LI>b</UL>` looped forever. It is now looked up by
  the name it was opened with.
- lexer.rs: the closing tag of a raw-text element was emitted with a
  lowercased name, so `<STYLE>`, `<Script>`, `<TEXTAREA>` and `<Title>`
  failed with `Unexpected closing tag "style"`. It is matched ignoring
  case and emitted with the opening tag's spelling, as in Angular's
  `_consumeRawTextWithTagClose`.
- lexer.rs: `<_x>` was read as a tag. A tag name starts with an ASCII
  letter; an underscore starts one only in selectorless mode.

Two things follow ngtsc where it is not case-sensitive: its preparser
lowercases the name before looking for `ng-content` and `link`, so
`<NG-CONTENT>` projects content. `ng-container` and `ng-template` are
matched as written, so in capitals they are ordinary elements.

Ingest now reports a selectorless component node instead of skipping
it. Angular skips it silently; that only happens with selectorless
parsing enabled, and a template should never compile to less than it
contains without saying so.

Verified against ngtsc 22.2.1: 16 compare fixtures and 49 further
templates diffed against its output and diagnostics.

* fix(parser): keep upstream preparser semantics for case-insensitive names

Review fixes on top of the capitalised-element-names change:

- `preparseElement` matches `link`, `ng-content`, `rel`, `href` and `select`
  case-insensitively on the namespace-stripped local name. A resolvable
  stylesheet `<link>` is collected into `styleUrls` and dropped from the
  tree; inside `ngNonBindable` it is still dropped but its URL is discarded
  (`NonBindableVisitor`), and an unresolvable stylesheet link outside
  `ngNonBindable` stays an ordinary element. `ng-template` keeps
  case-sensitive local-name matching like `isNgTemplate`.
- Collected template link URLs are now actually used: `compile_component_full`
  resolves them through `resolved_resources`, appends their contents to
  `metadata.styles`, reports unresolved URLs as non-fatal
  COMPONENT_RESOURCE_NOT_FOUND diagnostics, and returns them as watch
  dependencies (`template_style_urls`).
- Raw-text closes: the selectorless component path now matches its close
  tag case-insensitively like `_consumeRawTextWithTagClose`, and a
  namespaced raw-text element emits the close token with the opener's
  `[prefix, name]` parts so `</script>` closes `<svg:script>`.
- `R3Element.is_void` uses the parser's tag-definition lookup
  (`element.is_void`), which is case-insensitive like `getHtmlTagDefinition`,
  instead of a case-sensitive `matches!` table.
- `scan_text` no longer treats `<_` mid-text as a component start —
  upstream `_isTagStart` has no `_` case; a component is only recognized
  at a token boundary.

Verified: cargo test -p oxc_angular_compiler --all-features (0 failures),
angular_conformance 1264/1264, fixture compare 1074 fixtures 0 mismatches,
vitest transform.test.ts 30/30.

Known pre-existing divergences found during review, left as-is (they
predate this change — verified against git diff 7f11595..81a81b2 — and
warrant their own PR):

- `auto_close_element_if_needed` pops containers in a loop; upstream
  `_pushContainer` pops at most one (`<rtc><rt><rp>` nests upstream,
  flattens here). It also passes the component class name where upstream
  checks `component.tagName`.
- `</svg:br>` and `<svg:br/>` look up the local name's tag definition and
  are treated as void; upstream uses the merged full name.
- `ComponentClose` matches by class name, not `component.fullName`
  (`</MyComp>` closes `<MyComp:button>` here; upstream errors with a
  "did you mean" hint).
- `<ng-content>`/`<ng-template>` inside `ngNonBindable` still produce
  Content/Template nodes; upstream `NonBindableVisitor` emits verbatim
  elements. `<style>` inside it is also still collected.

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* fix(directive): host evaluation follow-ups from deep review

Review fixes on top of the static host-metadata evaluation:

- `config_property` now skips methods and accessors like ngtsc's
  `reflectObjectLiteral`: `host() {}`, `get host()` and `set host()` in the
  decorator object are class members, not metadata, and must not produce
  "Decorator host metadata must be an object". (Regression: the new
  `host_metadata_error` path matched them by name.)
- Port `verifyHostBindings`' `validateNoEventBindings`: `[onclick]` /
  `[attr.onload]` host keys are rejected with the same "disallowed for
  security reasons" error upstream throws.
- `__proto__` keys are skipped when building the host map — upstream's
  `hostMetaMap.forEach` assigns to `hostMetadata['__proto__']`, setting the
  prototype rather than adding an entry, so the key never reaches
  `parseHostBindings`.
- `{ ...ns }` (`import * as ns`) spreads the module's exports into the host
  map via `ResolvedModule.getExports()`, through a new
  `ImportValueResolver::exports` implemented by the cross-file resolver
  (direct exports first, star exports after; an export that can't be read
  is an error on its entry, matching ngtsc's `DynamicValue`). Without a
  resolver the spread still reports "could not be determined statically".
- The `host:` expression is evaluated once per class — `decorator_io_errors`
  and the metadata extractors share the result through a cache on
  `StringConsts`.
- `!prop.computed` gates in the extractors: `['host']` (a computed literal
  key) is a class member per `reflectObjectLiteral`, so it no longer both
  applies silently and escapes the diagnostic pass.
- Document, without changing behavior, pre-existing upstream divergences
  found during review (verified present at the PR base): member/decorator
  host merge order (upstream `host:` first, member overwrites; here
  appended, winner reversed), @HostBinding/@HostListener arity and
  "argument must be a string" diagnostics, non-literal `hostDirectives`
  and io mappings, integer-like key ordering in `hostAttrs`/`hostVars`
  (FxHashMap iteration), partial-mode emit writing `(x)`/`[x]` verbatim
  as binding keys, and remaining `verifyHostBindings` errors that need
  binding-expression parsing.

Verified: cargo test -p oxc_angular_compiler --all-features (0 failures),
angular_conformance 1264/1264, fixture compare 1074 fixtures 0 mismatches,
vitest 135/135 (hmr-hot-update, transform, extract-component-metadata with
cross_file_elision build).

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: Grok <noreply@x.ai>
Brooooooklyn added a commit that referenced this pull request Oct 10, 2026
* fix(parser): report incomplete control-flow blocks like Angular

A block whose header is never completed compiled with no error, and the
block and everything it swallowed were dropped from the template:

    <text>before</text>
    @if (on() {
      <text>inside</text>
    }
    <text>after</text>

gave `errors: []`, `decls: 2` and only the first `<text>`.

- The lexer already emitted `IncompleteBlockOpen` for these, as Angular's
  does, but the HTML parser had no case for the token and skipped it,
  the same hole #529 closed for `IncompleteLet`. It now reports Angular's
  `Incomplete block "<name>". If you meant to write the @ character, you
  should use the "&#64;" HTML entity instead.` over the same span, from
  the `@` to the next token, and keeps the block in the tree with no
  children, as Angular's `_consumeIncompleteBlock` does. This covers
  unclosed parameters, a block with no `{`, and a bare `@if`.
- HTML parse errors reached the `errors` array as a bare message. They
  now carry a position: a label with file offsets when the template text
  sits verbatim in the source (so the codeframe points at the `@if`), and
  `file:line:column` in the help text. For a `templateUrl` template the
  help text names that file. An inline template written with escapes
  cannot be mapped, so it gets a line and column within the template.
  `ComponentMetadata` records the inline template's span for this.
- The Vite plugin passed only `message` to `this.error`. It now appends
  the help text, so the location is visible there.

Tests: 30 parser cases whose expected errors and trees are the output of
`@angular/compiler` 22.2.1's `HtmlParser`, including the three incomplete
block cases in its html_parser_spec; 6 Rust tests and 4 JS tests for the
surfaced error and its position.

* fix(dts): keep the comments ngtsc keeps on `ngAcceptInputType_*` types (#565)

* fix(dts): keep the comments ngtsc keeps on `ngAcceptInputType_*` types

The dts type printer dropped every comment; ngtsc's path through
TypeScript's emitter (`onlyPrintJsDocStyle`) keeps `/**`-style and `/*!`
comments on a node's leading/trailing edges, plus every comment the
intervening list scans collect.

Rewrite the printer to port the emitter's comment paths verbatim:
`emitNodeList`/`emitNodeListItems` list handling with `ListFormat` flags,
`emitTokenWithComment`, the `containerPos`/`containerEnd` dedup in
`emitCommentsBeforeNode`/`emitCommentsAfterNode`, `iterateCommentRanges`
(including U+2028/U+2029 counting as line breaks while the scan reads
past them), and `writeCommentRange` re-indenting. This also reproduces
the quirks: a comment scanned by two enclosing lists prints twice, and a
comment after a named tuple member's `?` is dropped because the colon's
position scan starts after it.

Verified against ngtsc 22.1.7: the `COMMENTS_NOT_KEPT` fixture becomes
`COMMENTS_KEPT` and gains the remaining issue cases (`string /** doc */`,
`string | number /** doc */`, a leading `/** doc */` on its own line, a
multiline `/* */` inside type arguments, and the doubled `Array<\u2028`
comment).

Closes #506

* fix(dts): comment-scan edges in the dts type printer

Follow-ups from review of the emitter port:

- `token_end_before` misread `//` inside strings/comments and paired a
  trailing `*/` with the last `/*` instead of the first unpaired one,
  corrupting output like `{ a: 'x//y' ...}` and `/** a /** b */`.
  Both scans are now string/comment aware.
- Type-reference names are synthesized nodes upstream (pos/end -1), so
  they keep no comments: `Signal /** j */ <number>` prints
  `i0.Signal<number>`.
- A tuple's `]` scans from the element list's `end`, past a trailing
  comma; mapped-type `±readonly`/`±?` are two tokens each; the `...` of a
  binding rest and `${` of a template literal are emitted tokens; a
  `typeof` entity name is emitted part by part. Each picks up the
  comments TypeScript's own scans keep.
- `isWhiteSpaceSingleLine` is TypeScript's exact codepoint list (adds
  NEL, ZWSP and friends), and `ListFormat.Indented` applies
  unconditionally, indenting index-signature parameters' comments one
  extra level.

Also documents the known multi-line gap: ngtsc's `disposeEmitNodes`
clears `SingleLine` marks on earlier-emitted fields, so bare tuple/type
literal/mapped types can print multi-line there. That needs the
field-order mechanism and predates the comment port.

* fix(dts): one write per template quasi; `//` inside `/* */` is comment text

- TSTemplateLiteralType emits each quasi chunk (`` `text${` ``,
  `}text${`, `}text``) as a single write like TypeScript's emitLiteral,
  so a line break inside the text never gets re-indented: `` `a\n${string}` ``
  prints `${` at column 0. The chunk stays inside emit_node, so the
  head's trailing `/** j */` scan still fires.
- line_comment_start now scans the whole text with string/block-comment
  state, so a `//` on the continuation line of a `/* */` no longer
  truncates the line: `string | /* a\n // b */\n number` keeps the
  comment like ngtsc.

* fix(dts): real elision positions and trailing commas in binding lists

- An OmittedExpression is zero-width right after its preceding comma,
  not at prev_end + 1 (which landed inside the previous element's
  trivia). Track a cursor past each element's terminating comma instead.
- Both binding list formats carry AllowTrailingComma: `[a, ,]` and
  `{a,}` keep their trailing comma (emitTokenWithComment on the last
  element — an elision suppresses the comma's leading scan since
  pos == startPos — plus the comma's own same-line trailing scan, and
  the closing scan then runs at the list's end).

* fix(dts): `${ }` holes in a template lex as code in the trivia scan

A backward trivia scan that starts inside a template placeholder saw the
opening backtick as an unterminated string and gave up, so
`` `${\n/** doc */\nstring}` `` lost the JSDoc that ngtsc keeps. Template
text is now skipped as text while `${ }` regions are lexed as code —
comments inside them count, braces track `{ T: U }` members, and nested
templates recurse — in both `line_comment_start` and
`block_comment_start`.

* fix(dts): lex the source's comments once; skip regex literals

Two changes in the backward trivia scan's lexer:

- `lex_comments` builds the file's `//`/`/* */` table in one pass and
  `pos_of` queries it, instead of rescanning the whole prefix per node.
- The lexer skips regex literals (operand heuristic + `[...]`/escape
  aware), so a `/[/*]/` earlier in the file can't pair its `/*` with a
  comment's `*/` inside the type and drop the comment ngtsc keeps.

* fix(dts): hole scan resume, keyword regexes, TS trivia trim

- `template_end` resumed past `hole_end`'s return plus one more byte,
  skipping the closing backtick of `` `${string}` `` and swallowing the
  rest of the file as template text.
- A `/` after a keyword (`return`, `throw`, `case`, `typeof`, …) opens a
  regex — the operand heuristic only saw the identifier's last byte and
  called it division, so `return /[/*]/` leaked a `/*` into the comment
  table.
- `token_end_before` trims trivia with `is_whitespace_like`
  (`isWhiteSpaceSingleLine` + line breaks) instead of `char::is_whitespace`:
  U+200B/U+FEFF count as trivia and U+2028/9 stay line breaks.

* fix(dts): build the comment table from the parser's own comments

The hand-rolled lexer was chasing lexing edge cases (`\`` escapes,
regexes after `return`, regexes as statement bodies — `if (ok) /re/.test(x)`)
and re-lexed the whole file for every printed type. `program.comments`
already has the file's comment ranges — lexed correctly once — so
`Lexed` is now built from it and shared across a class's printers via
`Rc`, deleting the lexer (and its regex/keyword/prev-token machinery)
outright.

* fix(dts): binary-search the comment table in the trivia scan

The sorted, disjoint comment ranges admit a partition_point lookup: the
only candidate covering a window end is the last range starting before
it — O(log n) instead of a linear scan per pos_of call.

* fix(dts): signed mapped modifiers emit only the sign token

`readonlyToken`/`questionToken` are composite: `emit` prints the sign
and `writeKeyword`/`writePunctuation` prints `readonly`/`?` with no
comment scan, so a comment after a line break between them is dropped
upstream. Wrapping the keyword in emit_node preserved it. Same-line
comments still print as the sign's own trailing comments.

* fix(compiler): resolve arrow function parameters in template expressions (#566)

* fix(compiler): resolve arrow function parameters in template expressions

An arrow function in a template that read its own parameter was compiled
as though the parameter were a component property:
`open.update((o) => !o)` emitted `(o) => !ctx.o`, which reads `undefined`
at runtime with no diagnostic.

Ingest had no arm for `ArrowFunction`, so the whole arrow was stored as a
raw AST blob that skipped name resolution, and reify then turned every
implicit-receiver read in it into `ctx.<name>`. That also meant template
variables and, in embedded views, the component itself were read off the
wrong `ctx` inside an arrow body.

- ingest.rs: convert arrow functions to IR and port Angular's
  `updateParameterReferences`, which replaces reads of an arrow's
  parameters (and of enclosing arrows' parameters) with plain variable
  reads before name resolution runs. Covers templates and host bindings.
- resolve_names.rs: resolve the remaining names in an arrow body against
  template variables and the component context.
- reify/ir_expression.rs: emit the arrow in place.
- var_counting.rs: an in-place arrow does not reserve a var slot.

Verified against ngtsc 22.2.1. Arrows in event bindings now match it,
including the four listener cases ported from Angular's
`r3_view_compiler_arrow_functions` compliance suite. Outside listeners
ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not
implemented yet: the arrow is still emitted in place, now with the same
body as ngtsc's hoisted function.

* fix(compiler): close arrow-parameter resolution gaps found in review

Follow-up to the listener-arrow fix, addressing cases where arrow
parameters still emitted ctx.-prefixed reads:

- generate_arrow_functions: only top-level arrows join view.functions
  (matching upstream, where unit.functions holds just the non-listener
  arrows created by addArrowFunctions); listener and nested arrows are
  emitted in place and no longer receive dead variable ops, naming,
  or save/restore processing. Collected arrows are marked with a
  var_offset sentinel so var_counting can distinguish them; collected
  arrows consume a slot per upstream varsUsedByIrExpression.
- ir: IN_ARROW_FUNCTION_OPERATION is set only inside top-level arrow
  bodies, as upstream applies it; arrow ops lists are traversed.
- resolve_dollar_event: LexicalRead('$event') inside IR handler
  expressions resolves to a plain ReadVar, matching upstream.
- resolve_names: resolve_angular_expression resolves arrow parameters
  inside stored AST expressions (Chain, and other raw-AST contexts)
  with a shared grow-only parameter set, matching updateParameterReferences.
- ingest: convert LiteralArray, LiteralMap, TemplateLiteral and Empty
  on the host path too, so host bindings no longer store them as opaque
  AST blobs that skip name resolution.
- ir: new TaggedTemplateLiteral and SpreadElement expression kinds so
  arrows inside tagged templates and spreads are converted and
  resolved instead of stored as raw AST; threaded through emit,
  reify, generate_advance, next_context_merging, resolve_dollar_event,
  store_let_optimization, strip_nonrequired_parentheses,
  temporary_variables and variable_optimization.
- save_restore_view: move the handler expression into ResetView instead
  of cloning, preserving pointer identity for collected arrows.

Verified: cargo test -p oxc_angular_compiler 3090/3090, conformance
1264/1264, fixture compare vs ngtsc 22.2.1 994 fixtures 0 mismatches.

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* fix(hmr): write the new template's fields and stop redefining the live definition (#570)

* fix(hmr): write the new template's fields and stop redefining the live definition

The update module `compileForHmrSync` returns replaced a component's
template like this:

    X.ɵcmp = i0.ɵɵdefineComponent({
      ...X.ɵcmp,
      ...(X.ɵcmp.inputConfig ? { inputs: X.ɵcmp.inputConfig } : {}),
      template: function X_Template(rf, ctx) { ... },
    });

Three things were wrong with it.

- `decls`, `vars` and `ngContentSelectors` were not written, so the
  spread kept the old template's. A swap that added an element or a
  binding rebuilt a view sized for the old template and failed partway
  ("Index expected to be less than 30", "TNodes should be created before
  any bindings"). `consts` was written only when the new template had
  some, so a template that lost its last attribute kept the old ones.
- The live definition's `outputs` went back through `ɵɵdefineComponent`,
  which had already converted them to `{ publicName: propertyName }` and
  converted them again. A component with `checked = model(false)` ended
  up with `outputs: { checked: 'checkedChange' }`.
- The live definition has no `changeDetection`, so `onPush` was derived
  again as true: an Eager component became OnPush after a swap.

`inputs` was already restored from `inputConfig` for the second reason.
Rather than undo each conversion, the module no longer calls
`ɵɵdefineComponent`. Only the template is compiled on this path, so the
rest of the definition is the live one, copied as it is:

    X.ɵcmp = {
      ...X.ɵcmp,
      decls: 4,
      vars: 1,
      consts: null,
      ngContentSelectors: _c1,
      template: function X_Template(rf, ctx) { ... },
      tView: null,
    };

Every field the template decides is written, including the ones the new
template has no value for. `tView` is cleared so the runtime builds the
view for the new template. `ɵɵreplaceMetadata` then merges this into the
live definition and re-creates the views, as before.

- `compile_template_for_hmr` returns `decls`, `vars` and the
  `ngContentSelectors` expression alongside the template.
- `HmrUpdateModuleOptions` takes them as `HmrTemplateFields`.
- `generate_hmr_update_module_from_js` (`generateHmrModule`), which is
  given only template JavaScript, emits the same shape without the
  counts it cannot know; its docs say so.

Angular's own update module is a whole new `ɵɵdefineComponent({...})`
built from the component class. That needs the class, which this
template-only path does not have, so the shape differs: the definition
is not rebuilt and the component id does not change.

Tests:
- `test/hmr-update-module.test.ts`: 18 tests that compile a component,
  load it with the real `@angular/core`, apply the update module through
  `ɵɵreplaceMetadata`, and compare the template's fields with a fresh
  compile of the new template and every other field with what it was.
  `@angular/core` is a dev dependency for this.
- `e2e/tests/hmr-update-definition.spec.ts`: 6 browser tests that edit
  templates on disk and check what renders: elements and bindings gained
  and lost, `<ng-content select>` gained, changed and lost, consts,
  outputs still reaching the parent after one and two swaps, the change
  detection strategy, and a component whose path contains `@`.

* test(e2e): space repeat edits past the watcher's change throttle

On Linux a hot swap is on screen about 30ms after the write, so a test that
edited the same template again straight away wrote inside chokidar's 50ms
change throttle and the dev server never saw the second edit. Wait 200ms
before editing a file a second time.

* fix(hmr): close update-module gaps found in review

Follow-up to the definition-merge fix, addressing cases where a hot
swap still carried stale or invalid state:

- update_module: emit `tView: null` only when a template is being
  replaced. Styles-only updates no longer force a needless view
  rebuild through getOrCreateComponentTView.
- update_module: `generate_hmr_update_module_from_js` emitted invalid
  JS (`template: ,`) for a styles-only call with empty template; empty
  template_js now produces a styles-only module with no
  template/consts/tView keys.
- update_module: a template swap with no consts now emits
  `consts: null` instead of omitting the key, so the new template no
  longer indexes into the old template's constant pool.
- transform: compile_template_for_hmr propagates error-severity
  diagnostics instead of emitting decls/vars = 0 output, and always
  uses DeferBlockDepsEmitMode::PerComponent (PerBlock left @defer
  resolver calls with no deferred-dependencies block to resolve,
  matching upstream handler.ts:1281 for local compilation).
- lib.rs / vite-plugin: honor the component's declared encapsulation
  when encapsulating styles for a hot update instead of assuming
  Emulated, so ViewEncapsulation.None/ShadowDom components keep
  unshimmed CSS; error-severity diagnostics surface as errors and the
  plugin falls back to a full reload.
- Document that the JS-facing generateHmrModule only produces correct
  modules when decls/vars/ngContentSelectors are unchanged; the vite
  plugin's live path (compileForHmrSync) writes all of them.

Verified: cargo test -p oxc_angular_compiler 3065 passed, vitest
hmr-update-module + ssr-hmr 30/30 against real @angular/core 22.2.1.

* style: format hmr-update-module.test.ts with oxfmt

* fix(hmr): resolve listener diagnostics that surfaced as full reloads

The fatal-diagnostics change exposed two latent false-positive
diagnostics in the template-only HMR path, which CI's e2e suite turned
into page reloads for any component using `$event` or a two-way
binding:

- resolve_names verifier: `$event` inside listener handler ops
  legitimately remains a LexicalRead until reify emits it as the
  handler parameter (upstream's resolveDollarEvent rewrites it to a
  ReadVarExpr before its verifier runs; this port defers to reify).
  Exempt `$event` reads visited with IN_CHILD_OPERATION so plain
  bindings like `{{ $event }}` still error as upstream does.
- transform_two_way_binding_set: validate resolved target forms
  (ResolvedPropertyRead/ResolvedKeyedRead/ResolvedSafePropertyRead)
  alongside the raw-Ast ones, matching upstream's acceptance of
  o.ReadPropExpr / o.ReadKeyExpr / ir.ReadVariableExpr. A resolved
  `[(checked)]="ioChecked"` target no longer reports "Unsupported
  expression in two-way action binding".

Verified: cargo test 3065/3065, vitest hmr suites 30/30, and the
previously failing playwright specs pass locally (hmr-css, hmr-html,
hmr-html-write-strategies 12/12, hmr-update-definition +
hmr-input-component 10/10).

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* feat(compiler): hoist template arrow functions through ɵɵarrowFunction (#567)

* fix(compiler): resolve arrow function parameters in template expressions

An arrow function in a template that read its own parameter was compiled
as though the parameter were a component property:
`open.update((o) => !o)` emitted `(o) => !ctx.o`, which reads `undefined`
at runtime with no diagnostic.

Ingest had no arm for `ArrowFunction`, so the whole arrow was stored as a
raw AST blob that skipped name resolution, and reify then turned every
implicit-receiver read in it into `ctx.<name>`. That also meant template
variables and, in embedded views, the component itself were read off the
wrong `ctx` inside an arrow body.

- ingest.rs: convert arrow functions to IR and port Angular's
  `updateParameterReferences`, which replaces reads of an arrow's
  parameters (and of enclosing arrows' parameters) with plain variable
  reads before name resolution runs. Covers templates and host bindings.
- resolve_names.rs: resolve the remaining names in an arrow body against
  template variables and the component context.
- reify/ir_expression.rs: emit the arrow in place.
- var_counting.rs: an in-place arrow does not reserve a var slot.

Verified against ngtsc 22.2.1. Arrows in event bindings now match it,
including the four listener cases ported from Angular's
`r3_view_compiler_arrow_functions` compliance suite. Outside listeners
ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not
implemented yet: the arrow is still emitted in place, now with the same
body as ngtsc's hoisted function.

* fix(directive): declare constants pooled by host bindings

A `@Directive` host binding that needs a pooled constant referenced it
without declaring it. `host: {'[attr.x]': 'f([a, b])'}` emitted
`i0.ɵɵpureFunction2(1, _c0, ctx.a, ctx.b)` with no `const _c0`, which
throws a ReferenceError the first time the binding runs. Components
already declared theirs.

`compile_directive` returned the declarations, but
`generate_dir_definition` dropped them. They are now carried on
`DirectiveDefinitions` and emitted before the class.

* feat(compiler): hoist template arrow functions through ɵɵarrowFunction

Outside event listeners, ngtsc does not emit a template arrow function
in place. It hoists it to a shared factory and instantiates it once per
view:

    const arrowFn0 = (ctx, view) => (o) => !o;
    i0.ɵɵproperty("title", ctx.run(i0.ɵɵarrowFunction(1, arrowFn0, ctx)));

We emitted the arrow inline, so a new function was created on every
change detection pass. This ports the hoisting.

- ir: `ArrowFunctionExpr` gains a `hoisted` flag, set by
  generateArrowFunctions for every arrow that is not inside a listener
  or nested in another arrow. Its ops now survive cloning.
- A hoisted arrow is its own lexical scope. generateVariables,
  saveAndRestoreView, resolveNames, resolveContexts,
  generateTemporaryVariables and optimizeVariables process its ops and
  body the way they process a listener's handler, through a new
  `ArrowFunctionExpr::with_handler`. In an embedded view the arrow
  restores the view it was created in and resets it around its result.
- naming: variables that come from a parent view share their name with
  the rest of the view; only the arrow's own view scope is fresh. Let
  reference variables record their declaring view so this can be told.
- countVariables reserves a var slot per hoisted arrow, and reify emits
  the factory into the constant pool as `arrowFn<N>`, sharing equivalent
  factories. Those names take part in the per-file offset so two
  compilations in one file cannot collide.
- output: cloning a `WrappedIrNode` cloned it as `undefined`; it is now
  cloned properly, since a hoisted arrow keeps such statements.

The phases keep pointers to the hoisted arrows of a view, and several
phases rebuild expressions, so the list is collected again by each phase
that walks it.

Verified against ngtsc 22.2.1 with 55 compare fixtures, including every
case in Angular's `r3_view_compiler_arrow_functions` compliance suite
that does not need `legacyOptionalChaining`.

Two deliberate differences from ngtsc:

- An arrow in a `@for` `track` expression stays in place. ngtsc hoists
  it into the track function, where `ctx` is not in scope, so its output
  throws a ReferenceError.
- When one file has several compilations (a class with both host
  bindings and a template, or several classes), ngtsc numbers `arrowFn`
  constants file-wide and shares identical ones between them. Constant
  pools here are per compilation, so the names are unique but not
  contiguous and identical arrows are not shared across pools.

* fix(compiler): resolve names in spread arguments and tagged templates

A spread call argument and a tagged template literal were stored as raw
expressions that skipped name resolution, so the names they read were
taken off the current view's `ctx` whatever they referred to. Inside a
`@for`:

    {{ f(1, ...item) }}       ->  ctx_r0.f(1, ctx.item)
    {{ tag`a${item}` }}       ->  ctx.tag(<template object>, ctx.item)

The spread also lost its `...`, in any view, and a tagged template read
its tag off the embedded view. The same hole meant an arrow function
parameter used in either position resolved to `ctx.<param>`.

- ingest.rs: convert both to IR so every phase resolves their operands.
  A spread argument becomes a unary `Spread` operator. A tagged template
  becomes a `ResolvedTemplateLiteral` flagged `tagged`, with the tag as
  its first expression. Host bindings get the same arms, plus the plain
  template literal arm they were missing.
- reify: emit `...expr`, and rebuild the tagged template with its text
  escaped again from the cooked form.
- emitter.rs: print tagged templates natively, as ngtsc does, instead of
  as `tag(__makeTemplateObject(cooked, raw), ...)`. The tag now receives
  the same strings array on every call.

Verified against ngtsc 22.2.1 with 14 compare fixtures.

* fix(compiler): emit tagged-template raw text like ngtsc

escapeForTemplateLiteral(escapeSlashes(text)) escapes ${ as $\{ and
leaves line breaks literal; cooked_to_raw_text now matches so a tag's
strings.raw is identical to ngtsc's.

* fix(compiler): close arrow-hoisting review gaps

- transform/visit expression visitors now recurse into Statement ops'
  WrappedIrNode payloads (both create and update lists), matching
  upstream where OpKind.Statement shares the same transform
- host-path resolve_names and generate_temporary_variables refresh and
  process root.functions, matching upstream Kind.Both phases that
  iterate unit.functions
- assign_temp_names no longer descends into arrow bodies; hoisted arrows
  name their own temporaries through unit.functions
- var_counting counts inner var consumers before the arrow's own
  offset, matching upstream's post-order visit
- OutputExpression::is_equivalent gains TemplateLiteral,
  TaggedTemplateLiteral, Instantiate, DynamicImport, WrappedNode,
  WrappedIrNode, LocalizedString arms for factory dedup
- ResolvedTemplateLiteral element raw_text is re-escaped with
  cooked_to_raw_text at the reify/emit conversion sites
- ir_is_unary no longer treats Unary(Spread) as a unary needing parens
  before **
- remove dead emit_additional_pool_constants

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* fix(component): evaluate `styles` statically like ngtsc (#572)

* fix(component): evaluate `styles` statically like ngtsc

A `styles` entry that was not a string literal, a template literal or a
same-file string constant was dropped without an error: a concatenation, a
spread, an array constant or an import all compiled to fewer styles than
written, or to none.

`styles` is now read through the static evaluator, as ngtsc's
`parseDirectiveStyles` does. A value that is a string or an array of strings
gives those styles. Anything else is ngtsc's error on the `styles` expression
(`Failed to resolve @Component.styles to a string or an array of strings`,
`Failed to resolve styles at position N to a string`), reported after the
selector as ngtsc orders it. An import that cannot be read, which ngtsc
resolves with the whole program, is the existing one-file-at-a-time error
unless `resolveImportedValues` resolves it.

`extractComponentMetadataSync` reads the same value, so the HMR endpoint now
serves the styles of an array constant instead of `styles: []`.

* fix(compiler): report styles errors after the constructor checks, keep enum identity across files, and flag unresolved styles for HMR

Deep-review fixes on top of the styles static-evaluation port:

- Ordering: ngtsc's getConstructorDependencies throws inside
  extractDirectiveMetadata, before the component handler reads `styles`
  (parseDirectiveStyles). Emitting the styles error from
  decorator_io_errors suppressed the parameter errors; it now runs only
  when the io and constructor checks pass.
- Enums: ngtsc keeps EnumValue identity across module resolution, so
  `styles: [E.A]` from an import is the same `Value is of type 'E'`
  error as a same-file one. StaticValue::to_static unwrapped the enum
  to its value and the import silently compiled as a string; it now
  round-trips through StaticValue::Enum.
- HMR: extractComponentMetadataSync evaluates `styles` without
  resolveImportedValues, so imported styles extract as an empty array —
  an unknown answer the vite plugin served as "clear the styles",
  wiping live CSS. ComponentMetadata and ExtractedComponentMetadata now
  carry `styles_resolved`, and the plugin only clears when it is true.

Co-Authored-By: Grok <noreply@x.ai>

* fix(e2e): set stylesResolved on TypeScript-extracted metadata

ExtractedComponentMetadata gained stylesResolved; the finder builds the
type from the TypeScript extractor's output, which always resolves
styles itself.

* fix(compiler): fold duplicate styles keys last-wins, and serve no HMR styles when the extractor cannot evaluate them

Second round of deep-review fixes:

- Duplicate `styles:` properties: ngtsc's reflectObjectLiteral folds
  them with Map.set, so the last key is the only one parseDirectiveStyles
  sees. extract_component_metadata kept the last SUCCESSFUL entry —
  `{styles:['a'], styles:1}` emitted 'a' alongside the error for `1` —
  and computed `[K]: styles` entries leaked into emit that upstream
  drops. Each `styles` prop now fully overwrites (computed keys skipped),
  matching the last-wins semantics `config_property` already gives the
  diagnostic path.
- HMR: `stylesResolved` only gated the empty-answer case. A component
  with `styles: [IMPORTED]` + `styleUrls: ['./a.css']` merged external
  CSS alone and served it as definitive, dropping the imported inline
  styles the transform compiled. An unresolvable `styles` field now
  makes the whole answer `null` regardless of what external contents
  were read.
- Tests: the imported-constant HMR test now transforms the disk source
  verbatim (as Vite does), so stylesResolved — not the strip-match
  fallback — is the leg being exercised; a new test pins the
  imported-styles + styleUrls mix; a Rust test pins last-wins.

Co-Authored-By: Grok <noreply@x.ai>

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: Grok <noreply@x.ai>

* fix(parser): keep capitalised element names as elements (#571)

* fix(parser): keep capitalised element names as elements

A template containing an element whose name starts with a capital letter
compiled to an empty template with no error:

    @Component({ selector: 'x', template: '<View><text>hi</text></View>' })

gave `errors: []` and `decls: 0`. Some such templates never finished
compiling at all.

Angular's parser is case-sensitive for element names and keeps them as
written; a tag definition is looked up by the lowercased name only to
learn how a known HTML element behaves. Four places here disagreed.

- html_to_r3.rs: an element whose name began with a capital or `_` was
  turned into a selectorless component node, whatever the parse mode.
  Only the selectorless lexer mode produces those, the compile pipeline
  never enables it, and ingest discards them, as Angular's does, so the
  element and its whole subtree vanished. The element's own
  `is_component` flag, set by the lexer, now decides.
- parser.rs: an open element with an optional end tag (`<LI>`, `<P>`,
  `<Td>`) was found to need closing by its lowercased name, then looked
  up on the stack by that lowercased name, never found, and never
  popped: `<UL><LI>a<LI>b</UL>` looped forever. It is now looked up by
  the name it was opened with.
- lexer.rs: the closing tag of a raw-text element was emitted with a
  lowercased name, so `<STYLE>`, `<Script>`, `<TEXTAREA>` and `<Title>`
  failed with `Unexpected closing tag "style"`. It is matched ignoring
  case and emitted with the opening tag's spelling, as in Angular's
  `_consumeRawTextWithTagClose`.
- lexer.rs: `<_x>` was read as a tag. A tag name starts with an ASCII
  letter; an underscore starts one only in selectorless mode.

Two things follow ngtsc where it is not case-sensitive: its preparser
lowercases the name before looking for `ng-content` and `link`, so
`<NG-CONTENT>` projects content. `ng-container` and `ng-template` are
matched as written, so in capitals they are ordinary elements.

Ingest now reports a selectorless component node instead of skipping
it. Angular skips it silently; that only happens with selectorless
parsing enabled, and a template should never compile to less than it
contains without saying so.

Verified against ngtsc 22.2.1: 16 compare fixtures and 49 further
templates diffed against its output and diagnostics.

* fix(parser): keep upstream preparser semantics for case-insensitive names

Review fixes on top of the capitalised-element-names change:

- `preparseElement` matches `link`, `ng-content`, `rel`, `href` and `select`
  case-insensitively on the namespace-stripped local name. A resolvable
  stylesheet `<link>` is collected into `styleUrls` and dropped from the
  tree; inside `ngNonBindable` it is still dropped but its URL is discarded
  (`NonBindableVisitor`), and an unresolvable stylesheet link outside
  `ngNonBindable` stays an ordinary element. `ng-template` keeps
  case-sensitive local-name matching like `isNgTemplate`.
- Collected template link URLs are now actually used: `compile_component_full`
  resolves them through `resolved_resources`, appends their contents to
  `metadata.styles`, reports unresolved URLs as non-fatal
  COMPONENT_RESOURCE_NOT_FOUND diagnostics, and returns them as watch
  dependencies (`template_style_urls`).
- Raw-text closes: the selectorless component path now matches its close
  tag case-insensitively like `_consumeRawTextWithTagClose`, and a
  namespaced raw-text element emits the close token with the opener's
  `[prefix, name]` parts so `</script>` closes `<svg:script>`.
- `R3Element.is_void` uses the parser's tag-definition lookup
  (`element.is_void`), which is case-insensitive like `getHtmlTagDefinition`,
  instead of a case-sensitive `matches!` table.
- `scan_text` no longer treats `<_` mid-text as a component start —
  upstream `_isTagStart` has no `_` case; a component is only recognized
  at a token boundary.

Verified: cargo test -p oxc_angular_compiler --all-features (0 failures),
angular_conformance 1264/1264, fixture compare 1074 fixtures 0 mismatches,
vitest transform.test.ts 30/30.

Known pre-existing divergences found during review, left as-is (they
predate this change — verified against git diff 7f11595..81a81b2 — and
warrant their own PR):

- `auto_close_element_if_needed` pops containers in a loop; upstream
  `_pushContainer` pops at most one (`<rtc><rt><rp>` nests upstream,
  flattens here). It also passes the component class name where upstream
  checks `component.tagName`.
- `</svg:br>` and `<svg:br/>` look up the local name's tag definition and
  are treated as void; upstream uses the merged full name.
- `ComponentClose` matches by class name, not `component.fullName`
  (`</MyComp>` closes `<MyComp:button>` here; upstream errors with a
  "did you mean" hint).
- `<ng-content>`/`<ng-template>` inside `ngNonBindable` still produce
  Content/Template nodes; upstream `NonBindableVisitor` emits verbatim
  elements. `<style>` inside it is also still collected.

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* fix(directive): evaluate `host` metadata statically like ngtsc (#569)

* fix(directive): evaluate `host` metadata statically like ngtsc

A `host` that is not a plain object literal of string values was
silently dropped:

    const SHARED = { '(press)': 'go()' };
    @Component({ host: { ...SHARED, accessibilityRole: 'button' } })

kept the attribute and lost the listener, and `host: HOST` compiled to no
host bindings at all. Neither reported an error.

The component and directive extractors each read `host` by walking an
`ObjectExpression`'s own properties, skipping spreads and returning
nothing for any other expression. ngtsc evaluates the expression with
its partial evaluator (`evaluateHostExpressionBindings`). The evaluator
that models it here already serves `inputs:`, `outputs:` and `queries:`;
`host` now goes through it too, from one shared `evaluate_host_metadata`.

- A spread, a constant (`const`, `let`, `as const`), a member access, a
  same-file function call and a computed key all resolve, and compile to
  the same bindings as the entries written out. As in a `Map`, a repeated
  key takes the later value and keeps the first position.
- A host that cannot be used is reported on the host expression, with
  ngtsc's message: `Decorator host metadata must be an object`, or `...
  must be a string -> string object, but found unparseable value`, each
  with the line describing the value, and `Property/Event/Class/Style
  binding must be string` for a binding whose value is dynamic. The
  error joins the `decorator_io_errors` chain after `selector`, which is
  where ngtsc checks it.
- An import is read when `resolve_imported_values` is on, as for the
  other fields; otherwise the existing "imported from another module"
  error says why.

One case does not match ngtsc: it emits a plain attribute's dynamic
value as an expression (`hostAttrs: ["role", make()]`). Host attributes
are strings here, so that is an error rather than a drop.

`host_attribute_unknown_identifier_dropped` pinned the silent drop for an
unresolvable computed key; it now expects ngtsc's error.

Verified against @angular/compiler-cli 22.2.1: 15 compare fixtures, and
83 cases diffed against its output and diagnostics.

* fix(dts): keep the comments ngtsc keeps on `ngAcceptInputType_*` types (#565)

* fix(dts): keep the comments ngtsc keeps on `ngAcceptInputType_*` types

The dts type printer dropped every comment; ngtsc's path through
TypeScript's emitter (`onlyPrintJsDocStyle`) keeps `/**`-style and `/*!`
comments on a node's leading/trailing edges, plus every comment the
intervening list scans collect.

Rewrite the printer to port the emitter's comment paths verbatim:
`emitNodeList`/`emitNodeListItems` list handling with `ListFormat` flags,
`emitTokenWithComment`, the `containerPos`/`containerEnd` dedup in
`emitCommentsBeforeNode`/`emitCommentsAfterNode`, `iterateCommentRanges`
(including U+2028/U+2029 counting as line breaks while the scan reads
past them), and `writeCommentRange` re-indenting. This also reproduces
the quirks: a comment scanned by two enclosing lists prints twice, and a
comment after a named tuple member's `?` is dropped because the colon's
position scan starts after it.

Verified against ngtsc 22.1.7: the `COMMENTS_NOT_KEPT` fixture becomes
`COMMENTS_KEPT` and gains the remaining issue cases (`string /** doc */`,
`string | number /** doc */`, a leading `/** doc */` on its own line, a
multiline `/* */` inside type arguments, and the doubled `Array<\u2028`
comment).

Closes #506

* fix(dts): comment-scan edges in the dts type printer

Follow-ups from review of the emitter port:

- `token_end_before` misread `//` inside strings/comments and paired a
  trailing `*/` with the last `/*` instead of the first unpaired one,
  corrupting output like `{ a: 'x//y' ...}` and `/** a /** b */`.
  Both scans are now string/comment aware.
- Type-reference names are synthesized nodes upstream (pos/end -1), so
  they keep no comments: `Signal /** j */ <number>` prints
  `i0.Signal<number>`.
- A tuple's `]` scans from the element list's `end`, past a trailing
  comma; mapped-type `±readonly`/`±?` are two tokens each; the `...` of a
  binding rest and `${` of a template literal are emitted tokens; a
  `typeof` entity name is emitted part by part. Each picks up the
  comments TypeScript's own scans keep.
- `isWhiteSpaceSingleLine` is TypeScript's exact codepoint list (adds
  NEL, ZWSP and friends), and `ListFormat.Indented` applies
  unconditionally, indenting index-signature parameters' comments one
  extra level.

Also documents the known multi-line gap: ngtsc's `disposeEmitNodes`
clears `SingleLine` marks on earlier-emitted fields, so bare tuple/type
literal/mapped types can print multi-line there. That needs the
field-order mechanism and predates the comment port.

* fix(dts): one write per template quasi; `//` inside `/* */` is comment text

- TSTemplateLiteralType emits each quasi chunk (`` `text${` ``,
  `}text${`, `}text``) as a single write like TypeScript's emitLiteral,
  so a line break inside the text never gets re-indented: `` `a\n${string}` ``
  prints `${` at column 0. The chunk stays inside emit_node, so the
  head's trailing `/** j */` scan still fires.
- line_comment_start now scans the whole text with string/block-comment
  state, so a `//` on the continuation line of a `/* */` no longer
  truncates the line: `string | /* a\n // b */\n number` keeps the
  comment like ngtsc.

* fix(dts): real elision positions and trailing commas in binding lists

- An OmittedExpression is zero-width right after its preceding comma,
  not at prev_end + 1 (which landed inside the previous element's
  trivia). Track a cursor past each element's terminating comma instead.
- Both binding list formats carry AllowTrailingComma: `[a, ,]` and
  `{a,}` keep their trailing comma (emitTokenWithComment on the last
  element — an elision suppresses the comma's leading scan since
  pos == startPos — plus the comma's own same-line trailing scan, and
  the closing scan then runs at the list's end).

* fix(dts): `${ }` holes in a template lex as code in the trivia scan

A backward trivia scan that starts inside a template placeholder saw the
opening backtick as an unterminated string and gave up, so
`` `${\n/** doc */\nstring}` `` lost the JSDoc that ngtsc keeps. Template
text is now skipped as text while `${ }` regions are lexed as code —
comments inside them count, braces track `{ T: U }` members, and nested
templates recurse — in both `line_comment_start` and
`block_comment_start`.

* fix(dts): lex the source's comments once; skip regex literals

Two changes in the backward trivia scan's lexer:

- `lex_comments` builds the file's `//`/`/* */` table in one pass and
  `pos_of` queries it, instead of rescanning the whole prefix per node.
- The lexer skips regex literals (operand heuristic + `[...]`/escape
  aware), so a `/[/*]/` earlier in the file can't pair its `/*` with a
  comment's `*/` inside the type and drop the comment ngtsc keeps.

* fix(dts): hole scan resume, keyword regexes, TS trivia trim

- `template_end` resumed past `hole_end`'s return plus one more byte,
  skipping the closing backtick of `` `${string}` `` and swallowing the
  rest of the file as template text.
- A `/` after a keyword (`return`, `throw`, `case`, `typeof`, …) opens a
  regex — the operand heuristic only saw the identifier's last byte and
  called it division, so `return /[/*]/` leaked a `/*` into the comment
  table.
- `token_end_before` trims trivia with `is_whitespace_like`
  (`isWhiteSpaceSingleLine` + line breaks) instead of `char::is_whitespace`:
  U+200B/U+FEFF count as trivia and U+2028/9 stay line breaks.

* fix(dts): build the comment table from the parser's own comments

The hand-rolled lexer was chasing lexing edge cases (`\`` escapes,
regexes after `return`, regexes as statement bodies — `if (ok) /re/.test(x)`)
and re-lexed the whole file for every printed type. `program.comments`
already has the file's comment ranges — lexed correctly once — so
`Lexed` is now built from it and shared across a class's printers via
`Rc`, deleting the lexer (and its regex/keyword/prev-token machinery)
outright.

* fix(dts): binary-search the comment table in the trivia scan

The sorted, disjoint comment ranges admit a partition_point lookup: the
only candidate covering a window end is the last range starting before
it — O(log n) instead of a linear scan per pos_of call.

* fix(dts): signed mapped modifiers emit only the sign token

`readonlyToken`/`questionToken` are composite: `emit` prints the sign
and `writeKeyword`/`writePunctuation` prints `readonly`/`?` with no
comment scan, so a comment after a line break between them is dropped
upstream. Wrapping the keyword in emit_node preserved it. Same-line
comments still print as the sign's own trailing comments.

* fix(compiler): resolve arrow function parameters in template expressions (#566)

* fix(compiler): resolve arrow function parameters in template expressions

An arrow function in a template that read its own parameter was compiled
as though the parameter were a component property:
`open.update((o) => !o)` emitted `(o) => !ctx.o`, which reads `undefined`
at runtime with no diagnostic.

Ingest had no arm for `ArrowFunction`, so the whole arrow was stored as a
raw AST blob that skipped name resolution, and reify then turned every
implicit-receiver read in it into `ctx.<name>`. That also meant template
variables and, in embedded views, the component itself were read off the
wrong `ctx` inside an arrow body.

- ingest.rs: convert arrow functions to IR and port Angular's
  `updateParameterReferences`, which replaces reads of an arrow's
  parameters (and of enclosing arrows' parameters) with plain variable
  reads before name resolution runs. Covers templates and host bindings.
- resolve_names.rs: resolve the remaining names in an arrow body against
  template variables and the component context.
- reify/ir_expression.rs: emit the arrow in place.
- var_counting.rs: an in-place arrow does not reserve a var slot.

Verified against ngtsc 22.2.1. Arrows in event bindings now match it,
including the four listener cases ported from Angular's
`r3_view_compiler_arrow_functions` compliance suite. Outside listeners
ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not
implemented yet: the arrow is still emitted in place, now with the same
body as ngtsc's hoisted function.

* fix(compiler): close arrow-parameter resolution gaps found in review

Follow-up to the listener-arrow fix, addressing cases where arrow
parameters still emitted ctx.-prefixed reads:

- generate_arrow_functions: only top-level arrows join view.functions
  (matching upstream, where unit.functions holds just the non-listener
  arrows created by addArrowFunctions); listener and nested arrows are
  emitted in place and no longer receive dead variable ops, naming,
  or save/restore processing. Collected arrows are marked with a
  var_offset sentinel so var_counting can distinguish them; collected
  arrows consume a slot per upstream varsUsedByIrExpression.
- ir: IN_ARROW_FUNCTION_OPERATION is set only inside top-level arrow
  bodies, as upstream applies it; arrow ops lists are traversed.
- resolve_dollar_event: LexicalRead('$event') inside IR handler
  expressions resolves to a plain ReadVar, matching upstream.
- resolve_names: resolve_angular_expression resolves arrow parameters
  inside stored AST expressions (Chain, and other raw-AST contexts)
  with a shared grow-only parameter set, matching updateParameterReferences.
- ingest: convert LiteralArray, LiteralMap, TemplateLiteral and Empty
  on the host path too, so host bindings no longer store them as opaque
  AST blobs that skip name resolution.
- ir: new TaggedTemplateLiteral and SpreadElement expression kinds so
  arrows inside tagged templates and spreads are converted and
  resolved instead of stored as raw AST; threaded through emit,
  reify, generate_advance, next_context_merging, resolve_dollar_event,
  store_let_optimization, strip_nonrequired_parentheses,
  temporary_variables and variable_optimization.
- save_restore_view: move the handler expression into ResetView instead
  of cloning, preserving pointer identity for collected arrows.

Verified: cargo test -p oxc_angular_compiler 3090/3090, conformance
1264/1264, fixture compare vs ngtsc 22.2.1 994 fixtures 0 mismatches.

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* fix(hmr): write the new template's fields and stop redefining the live definition (#570)

* fix(hmr): write the new template's fields and stop redefining the live definition

The update module `compileForHmrSync` returns replaced a component's
template like this:

    X.ɵcmp = i0.ɵɵdefineComponent({
      ...X.ɵcmp,
      ...(X.ɵcmp.inputConfig ? { inputs: X.ɵcmp.inputConfig } : {}),
      template: function X_Template(rf, ctx) { ... },
    });

Three things were wrong with it.

- `decls`, `vars` and `ngContentSelectors` were not written, so the
  spread kept the old template's. A swap that added an element or a
  binding rebuilt a view sized for the old template and failed partway
  ("Index expected to be less than 30", "TNodes should be created before
  any bindings"). `consts` was written only when the new template had
  some, so a template that lost its last attribute kept the old ones.
- The live definition's `outputs` went back through `ɵɵdefineComponent`,
  which had already converted them to `{ publicName: propertyName }` and
  converted them again. A component with `checked = model(false)` ended
  up with `outputs: { checked: 'checkedChange' }`.
- The live definition has no `changeDetection`, so `onPush` was derived
  again as true: an Eager component became OnPush after a swap.

`inputs` was already restored from `inputConfig` for the second reason.
Rather than undo each conversion, the module no longer calls
`ɵɵdefineComponent`. Only the template is compiled on this path, so the
rest of the definition is the live one, copied as it is:

    X.ɵcmp = {
      ...X.ɵcmp,
      decls: 4,
      vars: 1,
      consts: null,
      ngContentSelectors: _c1,
      template: function X_Template(rf, ctx) { ... },
      tView: null,
    };

Every field the template decides is written, including the ones the new
template has no value for. `tView` is cleared so the runtime builds the
view for the new template. `ɵɵreplaceMetadata` then merges this into the
live definition and re-creates the views, as before.

- `compile_template_for_hmr` returns `decls`, `vars` and the
  `ngContentSelectors` expression alongside the template.
- `HmrUpdateModuleOptions` takes them as `HmrTemplateFields`.
- `generate_hmr_update_module_from_js` (`generateHmrModule`), which is
  given only template JavaScript, emits the same shape without the
  counts it cannot know; its docs say so.

Angular's own update module is a whole new `ɵɵdefineComponent({...})`
built from the component class. That needs the class, which this
template-only path does not have, so the shape differs: the definition
is not rebuilt and the component id does not change.

Tests:
- `test/hmr-update-module.test.ts`: 18 tests that compile a component,
  load it with the real `@angular/core`, apply the update module through
  `ɵɵreplaceMetadata`, and compare the template's fields with a fresh
  compile of the new template and every other field with what it was.
  `@angular/core` is a dev dependency for this.
- `e2e/tests/hmr-update-definition.spec.ts`: 6 browser tests that edit
  templates on disk and check what renders: elements and bindings gained
  and lost, `<ng-content select>` gained, changed and lost, consts,
  outputs still reaching the parent after one and two swaps, the change
  detection strategy, and a component whose path contains `@`.

* test(e2e): space repeat edits past the watcher's change throttle

On Linux a hot swap is on screen about 30ms after the write, so a test that
edited the same template again straight away wrote inside chokidar's 50ms
change throttle and the dev server never saw the second edit. Wait 200ms
before editing a file a second time.

* fix(hmr): close update-module gaps found in review

Follow-up to the definition-merge fix, addressing cases where a hot
swap still carried stale or invalid state:

- update_module: emit `tView: null` only when a template is being
  replaced. Styles-only updates no longer force a needless view
  rebuild through getOrCreateComponentTView.
- update_module: `generate_hmr_update_module_from_js` emitted invalid
  JS (`template: ,`) for a styles-only call with empty template; empty
  template_js now produces a styles-only module with no
  template/consts/tView keys.
- update_module: a template swap with no consts now emits
  `consts: null` instead of omitting the key, so the new template no
  longer indexes into the old template's constant pool.
- transform: compile_template_for_hmr propagates error-severity
  diagnostics instead of emitting decls/vars = 0 output, and always
  uses DeferBlockDepsEmitMode::PerComponent (PerBlock left @defer
  resolver calls with no deferred-dependencies block to resolve,
  matching upstream handler.ts:1281 for local compilation).
- lib.rs / vite-plugin: honor the component's declared encapsulation
  when encapsulating styles for a hot update instead of assuming
  Emulated, so ViewEncapsulation.None/ShadowDom components keep
  unshimmed CSS; error-severity diagnostics surface as errors and the
  plugin falls back to a full reload.
- Document that the JS-facing generateHmrModule only produces correct
  modules when decls/vars/ngContentSelectors are unchanged; the vite
  plugin's live path (compileForHmrSync) writes all of them.

Verified: cargo test -p oxc_angular_compiler 3065 passed, vitest
hmr-update-module + ssr-hmr 30/30 against real @angular/core 22.2.1.

* style: format hmr-update-module.test.ts with oxfmt

* fix(hmr): resolve listener diagnostics that surfaced as full reloads

The fatal-diagnostics change exposed two latent false-positive
diagnostics in the template-only HMR path, which CI's e2e suite turned
into page reloads for any component using `$event` or a two-way
binding:

- resolve_names verifier: `$event` inside listener handler ops
  legitimately remains a LexicalRead until reify emits it as the
  handler parameter (upstream's resolveDollarEvent rewrites it to a
  ReadVarExpr before its verifier runs; this port defers to reify).
  Exempt `$event` reads visited with IN_CHILD_OPERATION so plain
  bindings like `{{ $event }}` still error as upstream does.
- transform_two_way_binding_set: validate resolved target forms
  (ResolvedPropertyRead/ResolvedKeyedRead/ResolvedSafePropertyRead)
  alongside the raw-Ast ones, matching upstream's acceptance of
  o.ReadPropExpr / o.ReadKeyExpr / ir.ReadVariableExpr. A resolved
  `[(checked)]="ioChecked"` target no longer reports "Unsupported
  expression in two-way action binding".

Verified: cargo test 3065/3065, vitest hmr suites 30/30, and the
previously failing playwright specs pass locally (hmr-css, hmr-html,
hmr-html-write-strategies 12/12, hmr-update-definition +
hmr-input-component 10/10).

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* feat(compiler): hoist template arrow functions through ɵɵarrowFunction (#567)

* fix(compiler): resolve arrow function parameters in template expressions

An arrow function in a template that read its own parameter was compiled
as though the parameter were a component property:
`open.update((o) => !o)` emitted `(o) => !ctx.o`, which reads `undefined`
at runtime with no diagnostic.

Ingest had no arm for `ArrowFunction`, so the whole arrow was stored as a
raw AST blob that skipped name resolution, and reify then turned every
implicit-receiver read in it into `ctx.<name>`. That also meant template
variables and, in embedded views, the component itself were read off the
wrong `ctx` inside an arrow body.

- ingest.rs: convert arrow functions to IR and port Angular's
  `updateParameterReferences`, which replaces reads of an arrow's
  parameters (and of enclosing arrows' parameters) with plain variable
  reads before name resolution runs. Covers templates and host bindings.
- resolve_names.rs: resolve the remaining names in an arrow body against
  template variables and the component context.
- reify/ir_expression.rs: emit the arrow in place.
- var_counting.rs: an in-place arrow does not reserve a var slot.

Verified against ngtsc 22.2.1. Arrows in event bindings now match it,
including the four listener cases ported from Angular's
`r3_view_compiler_arrow_functions` compliance suite. Outside listeners
ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not
implemented yet: the arrow is still emitted in place, now with the same
body as ngtsc's hoisted function.

* fix(directive): declare constants pooled by host bindings

A `@Directive` host binding that needs a pooled constant referenced it
without declaring it. `host: {'[attr.x]': 'f([a, b])'}` emitted
`i0.ɵɵpureFunction2(1, _c0, ctx.a, ctx.b)` with no `const _c0`, which
throws a ReferenceError the first time the binding runs. Components
already declared theirs.

`compile_directive` returned the declarations, but
`generate_dir_definition` dropped them. They are now carried on
`DirectiveDefinitions` and emitted before the class.

* feat(compiler): hoist template arrow functions through ɵɵarrowFunction

Outside event listeners, ngtsc does not emit a template arrow function
in place. It hoists it to a shared factory and instantiates it once per
view:

    const arrowFn0 = (ctx, view) => (o) => !o;
    i0.ɵɵproperty("title", ctx.run(i0.ɵɵarrowFunction(1, arrowFn0, ctx)));

We emitted the arrow inline, so a new function was created on every
change detection pass. This ports the hoisting.

- ir: `ArrowFunctionExpr` gains a `hoisted` flag, set by
  generateArrowFunctions for every arrow that is not inside a listener
  or nested in another arrow. Its ops now survive cloning.
- A hoisted arrow is its own lexical scope. generateVariables,
  saveAndRestoreView, resolveNames, resolveContexts,
  generateTemporaryVariables and optimizeVariables process its ops and
  body the way they process a listener's handler, through a new
  `ArrowFunctionExpr::with_handler`. In an embedded view the arrow
  restores the view it was created in and resets it around its result.
- naming: variables that come from a parent view share their name with
  the rest of the view; only the arrow's own view scope is fresh. Let
  reference variables record their declaring view so this can be told.
- countVariables reserves a var slot per hoisted arrow, and reify emits
  the factory into the constant pool as `arrowFn<N>`, sharing equivalent
  factories. Those names take part in the per-file offset so two
  compilations in one file cannot collide.
- output: cloning a `WrappedIrNode` cloned it as `undefined`; it is now
  cloned properly, since a hoisted arrow keeps such statements.

The phases keep pointers to the hoisted arrows of a view, and several
phases rebuild expressions, so the list is collected again by each phase
that walks it.

Verified against ngtsc 22.2.1 with 55 compare fixtures, including every
case in Angular's `r3_view_compiler_arrow_functions` compliance suite
that does not need `legacyOptionalChaining`.

Two deliberate differences from ngtsc:

- An arrow in a `@for` `track` expression stays in place. ngtsc hoists
  it into the track function, where `ctx` is not in scope, so its output
  throws a ReferenceError.
- When one file has several compilations (a class with both host
  bindings and a template, or several classes), ngtsc numbers `arrowFn`
  constants file-wide and shares identical ones between them. Constant
  pools here are per compilation, so the names are unique but not
  contiguous and identical arrows are not shared across pools.

* fix(compiler): resolve names in spread arguments and tagged templates

A spread call argument and a tagged template literal were stored as raw
expressions that skipped name resolution, so the names they read were
taken off the current view's `ctx` whatever they referred to. Inside a
`@for`:

    {{ f(1, ...item) }}       ->  ctx_r0.f(1, ctx.item)
    {{ tag`a${item}` }}       ->  ctx.tag(<template object>, ctx.item)

The spread also lost its `...`, in any view, and a tagged template read
its tag off the embedded view. The same hole meant an arrow function
parameter used in either position resolved to `ctx.<param>`.

- ingest.rs: convert both to IR so every phase resolves their operands.
  A spread argument becomes a unary `Spread` operator. A tagged template
  becomes a `ResolvedTemplateLiteral` flagged `tagged`, with the tag as
  its first expression. Host bindings get the same arms, plus the plain
  template literal arm they were missing.
- reify: emit `...expr`, and rebuild the tagged template with its text
  escaped again from the cooked form.
- emitter.rs: print tagged templates natively, as ngtsc does, instead of
  as `tag(__makeTemplateObject(cooked, raw), ...)`. The tag now receives
  the same strings array on every call.

Verified against ngtsc 22.2.1 with 14 compare fixtures.

* fix(compiler): emit tagged-template raw text like ngtsc

escapeForTemplateLiteral(escapeSlashes(text)) escapes ${ as $\{ and
leaves line breaks literal; cooked_to_raw_text now matches so a tag's
strings.raw is identical to ngtsc's.

* fix(compiler): close arrow-hoisting review gaps

- transform/visit expression visitors now recurse into Statement ops'
  WrappedIrNode payloads (both create and update lists), matching
  upstream where OpKind.Statement shares the same transform
- host-path resolve_names and generate_temporary_variables refresh and
  process root.functions, matching upstream Kind.Both phases that
  iterate unit.functions
- assign_temp_names no longer descends into arrow bodies; hoisted arrows
  name their own temporaries through unit.functions
- var_counting counts inner var consumers before the arrow's own
  offset, matching upstream's post-order visit
- OutputExpression::is_equivalent gains TemplateLiteral,
  TaggedTemplateLiteral, Instantiate, DynamicImport, WrappedNode,
  WrappedIrNode, LocalizedString arms for factory dedup
- ResolvedTemplateLiteral element raw_text is re-escaped with
  cooked_to_raw_text at the reify/emit conversion sites
- ir_is_unary no longer treats Unary(Spread) as a unary needing parens
  before **
- remove dead emit_additional_pool_constants

---------

Co-authored-by: LongYinan <lynweklm@gmail.com>

* fix(component): evaluate `styles` statically like ngtsc (#572)

* fix(component): evaluate `styles` statically like ngtsc

A `styles` entry that was not a string literal, a template literal or a
same-file string constant was dropped without an error: a concatenation, a
spread, an array constant or an import all compiled to fewer styles than
written, or to none.

`styles` is now read through the static evaluator, as ngtsc's
`parseDirectiveStyles` does. A value that is a string or an array of strings
gives those styles. Anything else is ngtsc's error on the `styles` expression
(`Failed to resolve @Component.styles to a string or an array of strings`,
`Failed to resolve styles at position N to a string`), reported after the
selector as ngtsc orders it. An import that cannot be read, which ngtsc
resolves with the whole program, is the existing one-file-at-a-time error
unless `resolveImportedValues` resolves it.

`extractComponentMetadataSync` reads the same value, so the HMR endpoint now
serves the styles of an array constant instead of `styles: []`.

* fix(compiler): report styles errors after the constructor checks, keep enum identity across files, and flag unresolved styles for HMR

Deep-review fixes on top of the styles static-evaluation port:

- Ordering: ngtsc's getConstructorDependencies throws inside
  extractDirectiveMetadata, before the component handler reads `styles`
  (parseDirectiveStyles). Emitting the styles error from
  decorator_io_errors suppressed the parameter errors; it now runs only
  when the io and constructor checks pass.
- Enums: ngtsc keeps EnumValue identity across module resolution, so
  `styles: [E.A]` from an import is the same `Value is of type 'E'`
  error as a same-file one. StaticValue::to_static unwrapped the enum
  to its value and the import silently compiled as a string; it now
  round-trips through StaticValue::Enum.
- HMR: extractComponentMetadataSync evaluates `styles` without
  resolveImportedValues, so imported styles extract as an empty array —
  an unknown answer the vite plugin served as "clear the styles",
  wiping live CSS. ComponentMetadata and ExtractedComponentMetadata now
  carry `styles_resolved`, and the plugin only clears when it is true.

Co-Authored-By: Grok <noreply@x.ai>

* fix(e2e): set stylesResolved on TypeScript-extracted metadata

ExtractedComponentMetadata gained stylesResolved; the finder builds the
type from the TypeScript extractor's output, which always resolves
styles itself.

* fix(compiler): fold duplicate styles keys last-wins, and serve no HMR styles when the extractor cannot evaluate them

Second round of deep-review fixes:

- Duplicate `styles:` properties: ngtsc's reflectObjectLiteral folds
  them with Map.set, so the last key is the only one parseDirectiveStyles
  sees. extract_component_metadata kept the last SUCCESSFUL entry —
  `{styles:['a'], styles:1}` emitted 'a' alongside the error for `1` —
  and computed `[K]: styles` entries leaked into emit that upstream
  drops. Each `styles` prop now fully overwrites (computed keys skipped),
  matching the last-wins semantics `config_property` already gives the
  diagnostic path.
- HMR: `stylesResolved` only gated the empty-answer case. A component
  with `styles: [IMPORTED]` + `styleUrls: ['./a.css']` merged external
  CSS alone and served it as definitive, dropping the imported inline
  styles the transform compiled. An unresolvable `styles` field now
  makes the whole answer `null` regardless of what external contents
  were read.
- Tests: the imported-constant HMR test now trans…
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