feat(vite-plugin): multi-component HMR + tokenizer-based @Component field parsing#284
Open
ashley-hunter wants to merge 3 commits into
Open
Conversation
…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>
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.
Summary
Adds full HMR support for files that declare more than one
@Componentclass, and replaces the regex-based extractor for inline@Componentmetadata with a parser that handles real-world TypeScript correctly.Problems addressed
.tsfile with multiple@Componentdecorators only ever received HMR for one of them. Edits to siblings silently no-op'd or were misrouted, depending on declaration order.[data-test="foo"]) were truncated.template: '<div title="x">') were silently cut short.styles:ortemplate:text appearing inside comments or another field's string literal could be picked up as the real field, producing silently incorrect HMR updates.styles: '…'(Angular'sstring | string[]) wasn't recognized at all.@Componentmetadata.Fixes
@Componentmetadata 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.filePath@ClassNamerather than file path, giving each@Componentin a multi-component file its own update slot.template:and/orstyles: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
styles: '…'(bare string form, Angular 17+) is supported alongside the array form.@Component(…)blocks, apostrophes, or commented-out fields no longer fool the parser.Test plan
cargo fmt --all -- --checkcleancargo check --all-featurescleancargo test— all Rust tests passcargo run -p oxc_angular_conformance— 1252/1252 (100.0%), no diff afterpnpm build-dev+ TS build cleanpnpm test— 189/189 unit tests passpnpm check(oxfmt + oxlint) cleanpnpm test:e2e— 34/34 Playwright e2e tests pass in real Chromium, including 5 new multi-component scenariospnpm --filter @oxc-angular/compare compare --fixtures— all 23 categories at 100% against@angular/compilerreference output@Componentin a multi-component file gets a distinct HMR id and its own update listener