Skip to content

feat(vite-plugin): multi-component HMR + tokenizer-based @Component field parsing#284

Open
ashley-hunter wants to merge 3 commits into
voidzero-dev:mainfrom
ashley-hunter:feat/multi-component-hmr-support
Open

feat(vite-plugin): multi-component HMR + tokenizer-based @Component field parsing#284
ashley-hunter wants to merge 3 commits into
voidzero-dev:mainfrom
ashley-hunter:feat/multi-component-hmr-support

Conversation

@ashley-hunter
Copy link
Copy Markdown
Contributor

@ashley-hunter ashley-hunter commented May 21, 2026

Summary

Adds full HMR support for files that declare more than one @Component class, and replaces the regex-based extractor for inline @Component metadata with a parser that handles real-world TypeScript correctly.

Problems addressed

  • A .ts file with multiple @Component decorators only ever received HMR for one of them. Edits to siblings silently no-op'd or were misrouted, depending on declaration order.
  • The previous inline-metadata extraction had several latent correctness bugs:
    • styles arrays with attribute selectors (e.g. [data-test="foo"]) were truncated.
    • templates with mismatched quote shapes (e.g. template: '<div title="x">') were silently cut short.
    • styles: or template: text appearing inside comments or another field's string literal could be picked up as the real field, producing silently incorrect HMR updates.
    • the bare-string form styles: '…' (Angular's string | string[]) wasn't recognized at all.
    • matching wasn't scoped to the decorator, so unrelated object literals in the same source could shadow the real @Component metadata.

Fixes

  • Inline @Component metadata is now extracted by a tokenizer-style scanner that respects strings, template literals, comments, and ${…} interpolations, and only considers properties at the immediate @Component(...) object-literal level.
  • The HMR pipeline (caches, endpoint, dispatch) now identifies components by filePath@ClassName rather than file path, giving each @Component in a multi-component file its own update slot.
  • A multi-component file edit that's contained within template: and/or styles: produces one HMR event per affected sibling with no full reload. Edits outside template/styles still fall back to a full page reload as before.

New capabilities

  • Multi-component files now HMR end-to-end. Editing one component's inline template or styles updates that component in place without disturbing siblings; non-template/styles edits trigger the usual full reload.
  • styles: '…' (bare string form, Angular 17+) is supported alongside the array form.
  • Comments containing example @Component(…) blocks, apostrophes, or commented-out fields no longer fool the parser.
  • Non-ASCII class identifiers are recognized.

Test plan

  • cargo fmt --all -- --check clean
  • cargo check --all-features clean
  • cargo test — all Rust tests pass
  • cargo run -p oxc_angular_conformance — 1252/1252 (100.0%), no diff after
  • pnpm build-dev + TS build clean
  • pnpm test — 189/189 unit tests pass
  • pnpm check (oxfmt + oxlint) clean
  • pnpm test:e2e — 34/34 Playwright e2e tests pass in real Chromium, including 5 new multi-component scenarios
  • pnpm --filter @oxc-angular/compare compare --fixtures — all 23 categories at 100% against @angular/compiler reference output
  • Manual playground smoke confirmed each @Component in a multi-component file gets a distinct HMR id and its own update listener

ashley-hunter and others added 3 commits May 21, 2026 12:09
…ield parsing

The Rust transform pipeline already supported multi-component .ts files
(per-component `templateUpdates` / `styleUpdates` keyed by
`filePath@ClassName`, exercised by `e2e/compare/fixtures/full-file/multi-
component.fixture.ts`), but the JS vite-plugin layer silently broke for
the 2nd+ component in a file. This branch closes the gap end-to-end and
along the way replaces all the regex-based @component decorator parsing
with a small tokenizer-style scanner that handles real-world inputs
correctly.

## Parsing layer (new `vite-plugin/utils/decorator-fields.ts`)

A single character-by-character walker (`advanceOneToken`) is the basis
for everything. It tracks a context stack
(`paren`/`array`/`brace`/`sq`/`dq`/`tpl`) and honors:

- escape sequences inside `'…'`, `"…"`, `` `…` ``,
- `${…}` interpolations inside template literals,
- `//` line and `/* … */` block comments in code context,
- balanced push/pop for `(/)`, `[/]`, `{/}`.

Public API:

- `findClosingDelim(code, openIdx)` — index of the matching closer.
- `emptyDelimitedRange(code, range)` — splice helper.
- `locateComponentDecorators(code)` — every `@Component(...)` paired with
  its class name. Unicode-aware identifiers; anonymous classes skipped.
- `locateStylesInArgs(code, argsRange)` / `locateTemplateInArgs(code, argsRange)`
  — locate the `styles:` / `template:` value inside a given decorator's
  args. The styles variant handles both `string` and `string[]` forms.
- `locateStylesFieldFor(code, className)` / `locateTemplateStringFor(code, className)`
  — convenience wrappers.

The scanner replaces previously buggy regex-based extraction that:

- Stopped at the first inner `]` of `styles\s*:\s*\[[\s\S]*?\]`, mangling
  any styles with attribute selectors like `[data-test="foo"]`.
- Allowed mismatched open/close quotes in
  `template\s*:\s*['"]([^'"]*)['"]`, truncating `template: '<div title="x">'`.
- Missed escaped backticks, `${…}` interpolations, comment-shadowed
  fields, `@Component(...)` text appearing in JSDoc / string literals,
  apostrophes in comments (which would push the walker into a fake
  "string" that never closed), and bare `styles: '…'` (Angular 17+
  `string | string[]`).
- Picked up unrelated object literals' `styles:`/`template:` properties
  anywhere in the source. Now lookups are scoped to the @component
  arg-list, at the immediate object-literal depth.

## Multi-component HMR (`vite-plugin/index.ts`)

Cache and dispatch surface restructured around the per-component
`filePath@ClassName` identity:

- `componentIds: Map<filePath, className>` → `componentsByFile: Map<filePath, Set<className>>`.
- `inlineTemplateCache` / `inlineStylesCache` re-keyed by componentId
  (`filePath@ClassName`).
- `pendingHmrUpdates: Set<filePath>` → `Set<componentId>`.
- `componentMetadataCache` stays file-keyed — the stripped form covers
  the whole file regardless of how many decorators it contains, which
  keeps the strip-equality check meaningful for multi-component edits.

The transform handler iterates every entry in `result.templateUpdates`
and populates one cache row per className. Removed components are pruned
from the per-componentId caches and from `pendingHmrUpdates` so stale
slots can't orphan future requests.

The HMR HTTP endpoint sources className from the request URL directly
(rather than a map keyed by file). A new short-circuit consumes the
pending slot when the requested className is no longer in the file, so
a stale `pendingHmrUpdates` entry can't loop indefinitely against a
template that no longer exists.

`handleHotUpdate` gets two dispatch helpers:

- `dispatchComponentUpdate(file, className)` for a single component.
- `dispatchAllComponentsInFile(file)` fans out to every component when a
  whole-file diff or external resource change can't be attributed to one
  component. Over-dispatch is safe — Angular's runtime no-ops
  `ɵɵreplaceMetadata` when the metadata didn't actually change.

`stripComponentMetadata(code)` now strips `template:` + `styles:` from
EVERY decorator in the file in one pass — enumerating decorators once
and using the args-direct locators makes this O(N) for N components,
instead of the O(N²) the naive className-based path would have given.

## Tests

Two new files, 89 new tests:

- `test/decorator-fields.test.ts` — exhaustive coverage of the parsing
  primitives, including:
    - bracket-in-selector, mismatched-quote, escaped-delimiter cases;
    - all five Angular forms of `styles:` (array of `'`/`"`/`` ` ``
      strings, mixed, bare string in any of the three quote styles);
    - comment-aware scanning (`// apostrophe`, `/* styles: ['fake'] */`,
      multi-line block comments, comments between decorator and class);
    - Unicode class names (`Café`, CJK);
    - decoy `@Component(…)` text inside JSDoc / string literals /
      backtick templates / helper function strings between two real
      decorators;
    - templates that themselves contain `styles:` / `template:` / unbalanced
      braces / `class="…"` / `class Foo` text;
    - CRLF line endings, spread + styles, `as const`, generics, extends,
      `$` and `_` identifier starts, `export default class`, abstract
      classes, anonymous default-exported classes (skipped), class
      methods named `Component`, sibling `@SignalComponent({…})` decorators.

- `test/hmr-hot-update.test.ts` — new multi-component scenarios:
    - inline template edit in a 2-component file dispatches HMR for both
      with no full reload;
    - inline styles edit ditto;
    - non-template/styles change → full reload, no component-update events;
    - per-component HMR module returned via the `@ng/component` endpoint
      addresses the correct className;
    - external templateUrl edit fans out to every component owned by the
      file;
    - stale-className request consumes its pending slot and stays empty.

## Known limitations (documented in the module docstring)

- Regex literals inside `@Component(...)` args (requires a full JS lexer
  to disambiguate `/` from division; never happens in real Angular code).
- Aliased / namespaced decorator imports (`@core.Component`, `import { Component as C }`).
- Parenthesized decorator expressions (`@(Component as any)({…})`).
- Quoted (`{ 'styles': […] }`) and computed (`{ ['styles']: […] }`) property keys.
- Concatenated style strings (`['a' + 'b']`) extract as two elements;
  visually identical CSS so harmless.
- Anonymous default-exported components have no className to address for HMR.
- `resourceToComponent` tracks one owner per resource — a templateUrl
  shared across multiple components in the same file fires HMR for the
  registered owner only.
Add a multi-component file to the e2e fixture app (DuoFirst + DuoSecond
in `duo.components.ts`, both inlined templates and styles) and a new
Playwright spec exercising the full HMR pipeline in a real browser:

- both components render on initial load
- inline template edit in FirstComponent → HMR, sibling untouched
- inline template edit in SecondComponent → HMR, sibling untouched
- inline styles edit in SecondComponent → HMR (color change observable)
- non-template/styles edit anywhere in the file → full reload

The existing 29 specs already covered the single-component cases the
work touches (inline + external template/styles, TS body edits, plain
non-component TS, atomic write strategies, input-bound components,
SSR manifest). With the 5 new multi-component cases, the e2e suite now
passes 34/34 against the real Vite + napi + Angular runtime stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Run `oxfmt` over the new + modified files. Mostly collapses if-condition
  and function-signature wrapping the formatter prefers on a single line.
- Rename e2e fixture `duo.components.ts` → `duo.component.ts` to match
  the singular-`.component.ts` naming convention used across the repo
  (existing fixtures like `e2e/compare/fixtures/full-file/multi-component.fixture.ts`
  and the e2e app's `card.component.ts` / `inline-card.component.ts` all
  use singular even when the file holds multiple component classes).

Verified after the changes:
- `pnpm exec oxfmt --check .` clean
- `pnpm exec oxlint` clean
- 189 unit tests pass
- 34 e2e tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant