Skip to content

fix: align animate.*, hostDirectives, NgModule lists, host aria and output text with ngtsc; SSR watch; tsc .d.ts (#701–#707) - #708

Open
Brooooooklyn wants to merge 29 commits into
mainfrom
fix/issues-701-707
Open

Brooooooklyn wants to merge 29 commits into
mainfrom
fix/issues-701-707

Conversation

@Brooooooklyn

Copy link
Copy Markdown
Member

Fixes the follow-ups filed from #697, each checked against real ngtsc / the Angular linker (22.2.4, and 19.2.15 / 20.1.8 / 20.2.x / 20.3.x where a version gate applies).

Fixes #701
Fixes #702
Fixes #703
Fixes #704
Fixes #705
Fixes #706
Fixes #707

What changed

#701 template animate.*            < 20.2         plain attr / ɵɵdomProperty / ɵɵlistener (template + host listeners)
                                   20.2.0–20.3.0  + ɵɵAnimationsFeature() for a template animate.leave
                                                  (full, linker and HMR; not for [class.animate.leave])
                                   all            bound animate.* before static, like ngtsc; [(animate.x)] two-way

#702 hostDirectives                partial / .d.ts pairs ["color","c"] (were reversed), aliased import → i1.H,
                                   ns.H kept, "s-dir" → i1["s-dir"]; partial iN numbering like ngtsc
                                   linker rebuilds the list like Angular's (forwardRef → function () { return [...] })

#703 NgModule lists (cross-file)   forwardRef(() => ns.X), ns.X.forRoot(), [...LIST], const lists
                                   → ɵmod .d.ts, setNgModuleScope and partial lists match ngtsc
                                   (no-cross-file mode unchanged: no guesses)

#704 host bindings                 '[]' / '()' → NG5001 (from 21.2.8 / 22.0.0-next.7, like ngtsc)
                                   aria-*: domProperty (< 20.2.0-next.2) │ reify-time ɵɵattribute │ specialized ɵɵattribute (≥ 20.3.3)

#705 SSR --app --watch             server waits for this round's client page (placeholder filled in renderChunk);
                                   a client-only change rebuilds the server via a stamp file in a private 0700 temp dir
                                   non-watch output byte-identical to main

#706 tsc-built .d.ts               new  import { augmentDeclarations } from '@oxc-angular/vite/dts'
                                   { tsconfig } (exact tsc layout via the project's typescript) or { declarationDir, sources }

#707 output text                   non-ASCII escaped like the TS printer (é), source text kept where ngtsc keeps it;
                                   linker prints like Babel 8; changeDetection default per linker version

Decisions

Known limits

Tests

🤖 Generated with Claude Code

Brooooooklyn and others added 25 commits November 10, 2026 20:50
ngtsc's verifyHostBindings reports "Event name is missing in binding" for
a `()` key and "Property name is missing in binding" for a `[]` key, then
the event-binding errors, and joins all messages with "\n" into one
NG5001. Oxc accepted the empty keys and stopped at the first `[on*]` key.

Collect the errors in ngtsc's order and join them. `[ ]` / `( )`, the
HostBinding / HostListener decorators and the linker stay as they were,
which matches ngtsc and Angular's linker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion (#704)

Angular has three eras for host `aria` property bindings:

- before 20.2.0-next.2: a DOM property (kept as is)
- 20.2.0-next.2 to 20.3.2, 21.0.0-next.0 to next.4: when reified, any
  `aria*` name longer than `aria` is written as
  `ɵɵattribute(ariaAttrName(name))` in the place of its DOM property
- 20.3.3+, 21.0.0-next.5+: an `aria-*` property (also a legacy
  `[@aria-*]`, whose name has lost its `@`) becomes an attribute binding
  in binding specialization, so it comes after the `ɵɵdomProperty` calls

Add the two version checks to AngularVersion, the attribute case to
specialize_bindings_for_host, and the reify case to reify_host. Full
builds, partial-then-link and the linker all share this path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plain `tsc -p tsconfig.lib.json` run writes declarations outside the
Vite / Rolldown build, so no plugin hook adds the Ivy members and
consumers fail with NG2012.

New vite-free entry `@oxc-angular/vite/dts` exports
`augmentDeclarations`:

- `{ tsconfig }`: loads the user's `typescript` from the tsconfig dir and
  runs a declaration emit that writes nothing, to get every file tsc
  writes and its exact path. This covers files only reached by imports
  (`"files": [entry]`), the common source dir of TS 5.x, `declarationDir`,
  `.d.mts`, `mapRoot` (the map file path comes from the emit) and
  `emitBOM`. `compilerOptions` takes what was given to tsc on the command
  line, with paths from `cwd`.
- `{ declarationDir, sources }`: no TypeScript; pairs files with sources
  by map or by the one layout of the dir (`matchDtsSources`), like the
  plugin's disk pass. `tsconfig` can be added for `paths` aliases.

Each source is compiled in partial mode, as the plugin does. A source
with compile errors is reported and its file is left alone. CRLF files
keep CRLF. When the file on disk is tsc's text, or tsc's text plus Ivy
members, the members are rebuilt from tsc's fresh text, so an
incremental tsc run that kept an old file still gets fresh members and a
second run changes nothing.

`typescript` is an optional peer. The plugin path is not changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Before v20.2, `animate.*` in a template is plain: a static attribute
  stays in consts, `[animate.x]` is a property, `(animate.x)` a listener
  and `[attr.animate.x]` an attribute. Host `(animate.x)` listeners are
  plain `ɵɵlistener` too. This matches ngtsc 19.2 and 20.1.
- On v20.2.0 to v20.3.0 a template `animate.leave` adds
  `ɵɵAnimationsFeature()` (Angular's `analyzeTemplateForAnimations`), in
  full compile, the linker and the HMR update module. `[class.animate.leave]`
  does not count, `[@animate.leave]` does, as in Angular.
- Animate ops go right after their element one at a time in update order,
  so the last binding comes first, as ngtsc emits them.
- From v20.2, `[(animate.x)]` and `bindon-animate.x` are animation
  bindings with an animation listener, as in ngtsc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Oxc linker copied the partial `hostDirectives` array into
`ɵɵHostDirectivesFeature` as written. A `forwardRef` entry then ran
while the class was still being defined and threw
"Cannot access 'L' before initialization".

Now each entry is read like Angular's `toHostDirectivesMetadata` and
`createHostDirectivesFeatureArg`: the forwardRef is unwrapped, an entry
with no mapping becomes the bare class, any forward reference wraps the
list in `function () { return [...]; }`, and an empty list gets no
feature. Mapping arrays are read into an object, as Angular does. A
shape that cannot be read is still copied as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Partial output wrote each input/output pair as [alias, field]. Both
  partial emitters now share the full compile's
  `create_host_directive_mappings_array`, which writes [field, alias].
- The .d.ts wrote `{ "alias": "field" }` with no trailing `;`. It now
  writes `{ "field": "alias"; }` like ngtsc, from one shared helper.
- A component's imported host directive is referenced by the module's
  export name, as the directive path already did: `i1.H` for
  `import { H as P }`, `i1.default` for a default import. The partial
  component now writes `i1.H` too, and builds the factory first so its
  deps take the lower `iN`, as ngtsc does.
- `dep.H` through `import * as dep` was dropped from the JS and the
  .d.ts. The evaluator now keeps the namespace's local name on the
  reference, and the module comes from the import map entry of that
  name (so `resolved_imports` applies). The export name is never looked
  up as a local name, so another import called `H` is not picked.
- A string export name (`import { "s-dir" as SDir }`, `dep["s-dir"]`)
  is written `i1["s-dir"]`. ngtsc writes `i1.s-dir`, which does not
  parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lists like ngtsc (#703)

With cross-file analysis, an NgModule list entry that ngtsc reads
statically is now read too, so the partial declaration, the full-mode
scope and the .d.ts list the classes instead of dropping the entry:

- forwardRef(fn) is read like ngtsc's expandForwardRef: an arrow or
  function expression (parentheses and `as` around it looked through),
  with an expression body or one `return`. Its target is read like any
  entry, so `forwardRef(() => dep.X)`, `forwardRef(() => LIST)` and
  `forwardRef(() => IMPORTED_LIST)` give their classes. An aliased
  import of forwardRef from @angular/core counts too.
- `dep['X']` is read like `dep.X`.
- A top-level `let` or `var` list of this file is read, like a `const`.
- A package's `declare const X: [typeof A, typeof B]` is read by its
  tuple type (also nested, and spread), instead of being taken for a
  class named X.
- A const list from another file is named through the files of its
  classes, so only those files decide whether the lists stay lazy. A
  file that only holds the list and loads this one back no longer adds
  a wrap (ngtsc writes none either).

The full-mode setNgModuleScope now lists what was read for each list
whose entries were all read, like ngtsc. A list with an entry Oxc can't
read stays as written (the runtime flattens it) and takes no namespace.
The scope takes its namespace numbers after the factory and before
ɵinj, as in ngtsc.

One difference from ngtsc stays on purpose: when the file of a listed
class loads this one back, the lists stay lazy (`() => [...]`). ngtsc's
eager list throws when that file is loaded first, and Oxc runs the scope
call with no ngJitMode guard.

Without cross-file analysis nothing new is guessed: only lists made of
classes of this file change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…703)

Compiles small projects in full mode with cross-file analysis and loads
them in plain Node with the real @angular/core:

- a scope listing what forwardRef(() => ns.X), a spread of an imported
  list and ns.X.forRoot() give still hands a declared component the
  directives of those modules;
- a list with an entry Oxc can't read stays as written next to a read,
  wrapped one, and still works;
- an import cycle keeps the lists lazy and loads in either order, while
  the eager list ngtsc writes there throws when the other file loads
  first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….0-next.7 (#704)

Before 21.2.8 and on 22.0.0-next.0 to next.6, ngtsc sorts host keys with
HOST_REG_EXP, which needs a name inside the brackets. So `[]` and `()`
are plain host attributes there and nothing is reported. Oxc reported
them on every Angular version and failed builds that ngtsc accepts.

Run the two checks only when the version has the newer key parsing. The
metadata read for codegen skips them, as it does not know the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pp --watch` (#705)

In watch mode the client and server environments build at the same time,
so the server read the page of the previous client round (or the source
page in round 1), and a client-only change never rebuilt the server.

Now, in `vite build --app --watch` only:

- The server manifest holds two free names at transform time. The
  server's renderChunk waits for the client round in progress (or the
  first one, once the client watcher exists) and fills in that round's
  index.html, like the Angular CLI builds index.server.html from the
  browser page of the same build.
- Only the environment named `client` gives the page and ends a round.
  A round also ends on buildEnd(error), renderError and the client
  watcher's ERROR event, so a failed client round never blocks the
  server. The server then uses the last page and warns.
- Each watched server watches a file in the temp dir, made in its
  buildStart, so it exists before the build ends. The client writes it
  when its page differs from the one a server embedded and that server
  is not waiting, so a client-only change rebuilds the server, also when
  client round 1 failed in a fresh outDir.

Builds without watch keep the same output and warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arations (#706)

- Take the line break from the last one in the file and give it only to
  the lines the splice adds, so line breaks inside template literal types
  stay as tsc copied them from the source.
- A CRLF file on disk is no longer taken as the same text as tsc's LF
  emit, so newLine given only on the tsc command line keeps CRLF and
  plain files are left alone.
- When tsc's in-memory emit has no map but the file on disk points at
  one (declarationMap only on the command line), follow the file's
  comment and shift that map.
- Write a map only when its declaration file changed, and keep the map
  tsc wrote when the file is tsc's text, so file URL sources under
  mapRoot keep tsc's spelling.
- Tests compare paths with "/" so they pass on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…geDetection default (#707)

Strings the compiler creates are now printed the way ngtsc's TypeScript
printer prints them: every character outside ASCII becomes a \uXXXX
escape, one per UTF-16 unit, with upper-case hex. Strings copied from
the source keep their source text, as ngtsc does: providers,
setClassMetadata arguments and keys, @Attribute and @Inject string
tokens, the NgModule id, template literals, and the inline template in
partial output. The .d.ts strings and added import lines are escaped too.

The linker follows the Angular linker and its Babel 8 generator instead:
it prints raw UTF-8 and escapes only the Unicode spaces (\xA0, \u2028,
\u2029, ...). Input and output names and exportAs are rebuilt as new
strings, as the Angular linker reads them with getString(); host
attribute values keep their source text.

A partial @Attribute token is now a string literal ('é'), not a bare
name.

The linker no longer writes changeDetection: 1 for a component with no
changeDetection field when the app uses Angular 19, 20 or 21. Those
linkers default the field to Default and leave it out of the output.
The v22 linker still writes 1 for declarations made before v22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…707)

ngtsc 22.2.4 prints the trigger string as "@\u03BCAnim.start".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… nested package tuples (#703)

ngtsc wraps an NgModule's lists in `() => [...]` when a name it writes
sits after the class. For an import that name is the one written in a
variable read for the list, so a list, alias or `{ ngModule }` written
after the NgModule (reached directly or through `forwardRef`) is wrapped.
Oxc only did this for a local class declared later.

A package's `declare const` tuple type may hold a nested tuple or named
members. ngtsc's evaluator reads them, so Oxc reads them too instead of
keeping the whole list as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#707)

The linker now rebuilds each hostDirectives input and output name as a
new string, as the Angular linker reads them with getString(). So
"é" from the partial file comes out as "é", like Babel 8 prints it.
The rest of hostDirectives keeps its source text.

The JIT resource imports (angular:jit:template:file;...) now escape text
outside ASCII in the specifier, as the Angular CLI builds them with
createStringLiteral and the TypeScript printer escapes them. In JIT mode
the whole file is printed again, which writes the text raw, so the lines
are fixed after that step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/oxc_angular_compiler/tests/linker_test.rs
# Conflicts:
#	crates/oxc_angular_compiler/src/linker/mod.rs
)

A partial hostDirectives entry written with quoted keys
({ "directive": …, "inputs": [...] }) was copied as written, so a
forwardRef entry was not unwrapped. Each entry is now read like Babel's
parseObjectLiteral: a plain or quoted key, and the last one wins when a
key is written twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The stamp file now lives in a new mkdtemp dir (mode 0700), made once per
  plugin instance. Its path cannot be guessed, so no other user can plant a
  link there or make the dir read-only. If it cannot be made, warn once.
- A server build that is still running takes the new client page in
  renderChunk, so the client no longer writes the stamp for it. A shared
  round with a slow server now builds the server once, not twice.
- The dir is removed on a normal process exit. Vite sets no signal handler
  for `vite build --watch`, so a watch stopped with Ctrl+C leaves the empty
  dir to the system temp cleanup (no signal handler is added on purpose).
- Tests: the "cannot make the watched file" test made its project root
  after pointing TMPDIR at a missing dir, so the test itself threw ENOENT.
  It now makes the root first. Every watched stamp dir is removed after the
  suite, so the tests leave nothing in the temp dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndows

- augmentDeclarations with sources throws when declarationDir is not a
  directory or holds no declaration files, so a wrong path no longer
  passes as nothing to do (#706).
- The #706 comment and README say partial mode writes never for
  ngContentSelectors, where ngc lists them.
- ssr-manifest tests: skip the 0700 mode check on Windows, which has no
  mode bits, and point TEMP and TMP at the missing dir too, as
  os.tmpdir() reads them on Windows (#705).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T15:35:53.624444Z 9e0a5d9 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f145db364

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…lists

A module list resolved across files (#703) printed an export named by a
string as `i1.h-dir`. The scope, the partial lists and the `.d.ts` now
write `i1["h-dir"]`, the way hostDirectives already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 196bb164b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The Windows runner's temp dir is a short name (`RUNNER~1`); take the
`native` real path so tsc sees one spelling of each path, and show tsc's
output when a build fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: c8a3431dc5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

On Windows, tsc in the temp dir did not find `@angular/core` through the
`node_modules` junction (TS2307). The projects now live under
`test/.dts-augment-tmp`, so they find the package's `node_modules` the
usual way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9dac19e357

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The linker rebuilds `outputs` since #707 and only quoted keys with `.` or
`-`, so `"some event": "changed"` came out as `some event: "changed"`,
which does not parse. Quote any key that isn't an identifier name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 9e0a5d9116

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment