Repository navigation
fix(parser): report incomplete control-flow blocks like Angular - #568
Merged
Brooooooklyn merged 11 commits intoOct 10, 2026
Merged
Brooooooklyn merged 11 commits into
Brooooooklyn merged 11 commits into
Conversation
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 voidzero-dev#529 closed for `IncompleteLet`. It now reports Angular's
`Incomplete block "<name>". If you meant to write the @ character, you
should use the "&voidzero-dev#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.
voidzero-dev#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 voidzero-dev#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.
…ons (voidzero-dev#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>
…e definition (voidzero-dev#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>
voidzero-dev#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>
…#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>
…#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>
…ero-dev#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 (voidzero-dev#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 voidzero-dev#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 (voidzero-dev#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 (voidzero-dev#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 (voidzero-dev#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 (voidzero-dev#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 (voidzero-dev#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>
# Conflicts: # napi/angular-compiler/test/transform.test.ts
…r offsets Fixes verified from the adversarial review of voidzero-dev#568: - `parse_expansion_case` dispatched every token it didn't name through `advance()`, so `IncompleteBlockOpen` (and block/@let/comment tokens in general) inside an ICU case was dropped without the new "Incomplete block" diagnostic. The case body now goes through `parse_and_add_node`, matching upstream, which runs a full sub-`_TreeBuilder` per case. A block left open at the case's `}` is reported "Unclosed block", same as at EOF. Upstream additionally drops the whole expansion on a case error; we keep the case — divergence documented. - `scan_block_parameters` only treated `'` and `"` as quote starts; upstream `chars.isQuote` includes the backtick, so `)`/`;` inside a template literal ended the parameter early. Backtick added. - Lexer errors were converted with `ParseLocation::new(file, lex_err.position.0, 0, 0)` — the (line, column) pair was read as a byte offset, pointing every lexer-origin error near file start. `HtmlTokenError` now carries `offset` alongside `position`. - `compile_template_for_hmr` reported parse errors with `TemplateOrigin::InSource { path: file_path }`, but `file_path` names the component file while the rendered text is the template (usually a `templateUrl` file) — wrong filename in `file:line:column` help. Changed to `Unknown`, like `compile_component_template` and `compile_template_for_linker`. - vite plugin: the HMR endpoint built the failure from `e.message` only; now uses `describeDiagnostic` so `file:line:column` help reaches the invalidate message, like the transform/warn paths. Regression tests: quoted parameter delimiters (backtick `)`/`;`), incomplete block inside an expansion case, and a lexer error pointing at the offending character's byte offset. Reviewed but documented, not fixed (pre-existing divergences, outside the PR's diff): - `is_block_start` requires a non-identifier char after the keyword; upstream `_isBlockStart` is a pure prefix match, so `@ifx` stays text here instead of `Incomplete block "ifx"`. - `get_block_name` collapses interior whitespace; upstream only trims (`Incomplete block "else if"`, `@else\nif` not matching ELSE_IF_PATTERN, `default never` check). - `scan_block_parameters` honors `paren_depth` on `;`; upstream splits on any `;` outside quotes. - `parse_and_add_node` lacks an `IncompleteComponentOpen` arm (selectorless mode): silent drop where upstream reports "Opening tag \"<name>\" not terminated." - `parse_incomplete_let_declaration` reports a zero-width span at the `@` instead of the whole `INCOMPLETE_LET` token. - `read_block_open` ends the block span at `t.start`; upstream uses `sourceSpan.fullStart` — inert today, no caller enables `leading_trivia_chars`. - Pipeline warnings are dropped: `FullCompilationResult` has no diagnostics field and napi hardcodes `warnings: vec![]` while every diagnostic lands in `errors` (the plugin's `this.warn` arm is unreachable today; every diagnostic produced is Error severity). - `r3_result.errors` mixes `ParseErrorLevel::Warning` entries into the fatal path — unreachable: `TemplateBindingParseResult.warnings` is always empty, matching upstream's identical dead warning channel. - Case-error recovery shape: upstream discards the whole expansion on a case error; we keep the case and its siblings.
- `TemplateOrigin::of_component` inferred `InSource` purely from content equality, so a component with both `template` and `templateUrl` holding identical text labelled the inline literal in the .ts instead of reporting `./cut.html:L:C`. `templateUrl` now wins when resolved resources exist, matching `resolve_template`'s precedence and upstream `parseTemplateDeclaration` (`isInline: false` whenever templateUrl is set). Regression test covers the identical-content case. - Documents, at their sites, the verified pre-existing divergences the review surfaced: `is_block_start`'s identifier boundary (upstream is a pure prefix match), `get_block_name`'s whitespace collapse, `;` inside nested parens in block params, silent `IncompleteComponentOpen` drops, unclosed-block drain order/spans, the zero-width incomplete-@let span, the unused `full_start` block-span end, non-error job diagnostics being discarded, partial-mode templates never parsed, and unresolvable `template` values producing no diagnostic.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A control-flow block whose header is never completed compiled with no error, and the block and everything it swallowed were silently dropped from the template. Present in 0.0.40.
gave
errors: [],decls: 2and only the first<text>. It now fails with Angular's error:Root cause
The lexer already emitted
IncompleteBlockOpenfor these, as Angular's does, but the HTML parser had no case for the token and skipped it, along with the parameter that had swallowed the rest of the template. Same hole #529 closed forIncompleteLet.Changes
@to the next token, and kept in the tree with no children. Port of Angular's_consumeIncompleteBlock. Covers unclosed parameters, a block with no{, and a bare@if.errors. HTML parse errors reached theerrorsarray as a bare message withlabels: []. This applies to all of them, including the@leterror from fix(parser): report incomplete@letdeclarations like Angular #529. They now carry a position:@if, andhelpMessageof/x/cut.ts:7:5.ComponentMetadatarecords the inline template's span for this.templateUrlfile:helpMessageof./cut.html:2:3, and no label because the offsets belong to the HTML file.line 2, column 1 of the template, because its offsets cannot be mapped to the source exactly.messagetothis.error, so none of the above would be visible to plugin users. It now appends the help text for every error and warning.Tests
html_parser_test.rs: 30 cases whose expected errors (message, start, end) and trees are the output of@angular/compiler22.2.1'sHtmlParser, including the three incomplete-block cases in itshtml_parser_spec.ts. Angular's lexer spec cases were already ported. With the parser change reverted, 25 fail; the other five are the cases that must keep working (user@example.com, a lone@, well-formed blocks).template_parse_error_position_test.rs: 6 tests for the surfaced diagnostic throughtransform_angular_fileandcompile_template_to_js, for inline, escaped inline andtemplateUrltemplates.transform.test.tsandplugin.test.ts: 4 JS tests, including the template above.Differences from Angular left as they are
"cond {\n"); this lexer trims it. Spans and errors match.Unclosed block "if"with a span; this repo saysUnclosed block "@if"with a zero-width one. That error predates this change.;outside a string, so@for (item of fn(a; b); track item) {x}is an error there but compiles here.Verified
cargo test --all-features --no-fail-fast: 3152 passedcargo fmt --all -- --check,cargo check --all-features: cleancargo run -p oxc_angular_conformance: 1264/1264, no snapshot diffpnpm test: 405 passed;pnpm check: clean;pnpm test:e2e: 37 passedpnpm --filter @oxc-angular/compare compare --fixtures: 980 fixtures, 0 mismatches, output byte-identical to mainReview round 1 (deep review, verified against vendored Angular 22.2.1)
Fixed on top of the PR head:
{x, plural, =a {@if}}never produced the new "Incomplete block" diagnostic. Case bodies now parse through the same dispatch as everywhere else (Angular runs a full sub-TreeBuilderper case), and a block left open at the case's}reports "Unclosed block" like EOF does.chars.isQuoteincludes it, so)/;inside a template literal like`;`no longer close or split the parameter.HtmlTokenErrornow carries the real offset, so lexer-origin errors label the offending character instead of pointing near file start.compile_template_for_hmrreported withInSourceon the component.tspath overtemplateUrltext; nowUnknownlike the other entry points that don't know where the template text lives. The vite HMR endpoint builds its failure viadescribeDiagnostic, so thefile:line:columnhelp reaches the invalidate message.templateUrlvs identical inlinetemplate. Origin inference used content equality, so when both existed with the same text, errors labelled the inline literal.templateUrlnow wins when resources resolve, matching upstream precedence.Documented at their sites, not fixed (all verified pre-existing at the PR base or untouched paths):
is_block_start's identifier-boundary check,get_block_name's whitespace collapse,;inside nested parens, silentIncompleteComponentOpen, unclosed-block drain order/spans, zero-width incomplete-@let span, pipeline warnings being dropped (FullCompilationResulthas no diagnostics field; napiwarnings: vec![]), partial-mode templates never parsed, and unresolvabletemplatevalues producing no diagnostic.Tests:
cargo test --all-features: 3203 passed, 0 failed.cargo fmt --check, conformance 1264/1264, fixture compare 1089 fixtures 0 mismatches,pnpm test441 passed.