fix(vite): resolve @ng/component styles per class - #455
Conversation
The HMR endpoint built the update module for any requested class from the FILE's styleUrls, which extractComponentUrls returns as the union of every component in the file. In a multi-component file a class was served its siblings' stylesheets. The same block held a second defect of the shape #449 fixed on the template side: the file-level list was tried FIRST, so being non-empty it shadowed the inline `styles:` of a class that declares no external ones. One root cause, both fixed here. Resolve per class, in the order the template block above already uses: the class's own styleUrls, then its inline styles, then the file-level list as a fallback for decorator shapes the locator cannot parse. Both Angular spellings are handled — `styleUrls: [...]` and the singular `styleUrl: '...'` — with the cross-match guards under test: neither url field matches the other, and neither matches inline `styles:`. Note for future tests in hmr-hot-update.test.ts: the shared setupPluginWithServer passes a stub resolved config, so preprocessCSS throws and the per-style catch drops every external stylesheet. The new endpoint tests build a real config via resolveConfig; without it they could not observe external styles at all. Fixes #451 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_9a88e946-d917-4733-9430-7f8ab1053d12) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3261b8cb13
ℹ️ 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".
… its own Three things, all in the endpoint's style resolution. 1. Replace the regex string-walking in extractStyleUrlsFor and its twin in extractInlineStyles with a real scanner, readStringLiterals, built on the file's existing skipComment and findClosingDelim. The regex was blind to comments: `['/* don't */ './a.css']` yielded ["t touch */ "] and dropped the real URL. That is a main bug on the inline-styles path, which has shipped with the regex. A 16-input differential probe shows the two disagree on exactly the 3 comment cases and nowhere else. 2. A class declaring no styles at all fell through to the file-level union and was served a sibling's scoped CSS — the isolation defect this change exists to remove. 3. A class declaring both `styles` and `styleUrls` lost the inline half; the branches were exclusive. The compiler merges them, so HMR diverged from compile after any update. The fix distinguishes three states, which the old null could not express: the decorator is not locatable (fall back to the file-level list), it is locatable with no style field (serve nothing — a real empty answer), or it declares a field holding no string literal (fall back, since the Rust extractor folds constants this text scan cannot). Merge order is inline first, resolved external appended, taken from the implementation rather than assumed: decorator.rs assigns the inline array into metadata.styles and transform.rs pushes resolved content onto it. The contract test asserts presence only, so it would not have caught a reversal. Whitespace-only entries are dropped, matching Angular's trim filter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_e39b3ac6-f744-487a-bcd8-79fe4b0f7f2b) |
Review round —
|
| array body | regex yields |
|---|---|
/* don't touch */ './a.css' |
["t touch */ "] — real URL lost |
// it's here + './a.css' |
["s here\n "] |
An apostrophe in a comment returns a garbage path and drops the entry. This ships on main today — extractStyleUrlsFor copied the regex from extractInlineStyles, so inline styles: arrays have been served corrupt since well before this branch. The RED output showed it plainly: styles: [ "s fine */\n " ].
readStringLiterals replaces both copies, built on the file's existing skipComment and findClosingDelim rather than a new tokenizer. A 16-input differential probe puts the two implementations side by side: they disagree on exactly the 3 comment cases and agree everywhere else — escaped quotes, mixed quote styles, template literals with ${…}, nested arrays, empty arrays, non-literal entries, and CSS text with an apostrophe inside a double-quoted string. Strict bug fix, no drift.
Findings acted on
| # | finding | verdict | action |
|---|---|---|---|
| 1 | a styleless class still gets a sibling's CSS via the file-level fallback | confirmed — a hole in the isolation this PR claims | fixed |
| 2 | a class declaring both styles and styleUrls loses the inline half |
confirmed against the repo's own contract test | fixed |
| 3 | text locators cannot resolve const-folded URLs | pre-existing, and applies equally to the template locator from #449 | declined → #456 |
The review's suggested merge order was backwards. It recommended externalStyles.concat(inlineStyles). The compiler does the opposite: decorator.rs assigns the decorator's inline array into metadata.styles, and resolved content is pushed onto it afterwards. So the canonical order is inline first, external appended. The contract test asserts presence only, never order, so it would not have caught the reversal — the order here is taken from the implementation and asserted by index in the new test.
The three-way state
Fixing finding 1 could not simply mean "no styles found → serve nothing", or a const-form styleUrls (finding 3's case) would lose every style instead of falling back. The locator now reports the distinction the old null was destroying:
| decorator state | served |
|---|---|
| not locatable | file-level fallback (unchanged) |
| locatable, no style field | nothing — a real empty answer |
| field present, no string literal | file-level fallback (unchanged) |
| parseable | inline + external, merged |
Test 3 is the guard for row 3, and it passed before the change as well as after — it exists to prove the refactor preserves that path.
270 unit tests pass (248 → 270), oxfmt --check clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c853252c89
ℹ️ 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 three-way classifier inferred completeness from how many literals it read, which got two cases backwards. - `styleUrl: STYLE_URL` yields no locator range, because an identifier is not a quote opener. That was read as "field absent", i.e. a confident "this class has no styles", so the endpoint served none — stripping the CSS of a component whose const the Rust extractor folds. Before this branch it fell back. Same for a mixed `[STYLE_URL, './b.css']`, which silently served only the literal half. - `styleUrls: []` is valid and means "no styles", but zero literals was read as unknown, so the fallback served a sibling's CSS. Field presence is now detected by the key, independent of the value, and the value is classified absent / literal / unreadable. An unreadable element anywhere makes the whole field unknown: acting on the literals beside it would drop whatever the rest names. An interpolated template literal counts as unreadable. Verified against the Rust extractor, which folds both consts and interpolations, so falling back is right. locateFieldInsideArgs keeps its nullable-range shape as a wrapper, so its existing callers are untouched — only the style path wants the third state. readStringLiterals now reports `complete` alongside its literals rather than gaining a sibling function that discards it. Also fix a Windows-only test failure caught by NAPI Smoke: one test passed the raw path to `transform` and a normalized one as `ctx.file`, which are the same file but different strings on Windows, so `componentsByFile.has(ctx.file)` missed and nothing was dispatched. Vite normalizes both in production, so this was a test artifact; the test now uses one spelling like its long-standing neighbours. An expectDispatched guard asserts the queued id matches the requested one, so this class of bug names itself instead of surfacing as an empty response body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_11a567f6-625f-4d65-adbf-8daf04f8bf4f) |
Review round —
|
| # | finding | verdict |
|---|---|---|
| 1 | a const/identifier style value is read as "field absent" and the class is served NO styles | confirmed — a regression from c853252, worse than the fallback it replaced |
| 2 | a valid styleUrls: [] / styles: [] is read as unknown and inherits a sibling's CSS |
confirmed — the contamination this PR exists to remove |
The first was mine and the worst kind: silently stripping a component's CSS. locateFieldInsideArgs returns the same null for "no such key" and "key present, value is not a literal", so my absent/unparseable distinction collapsed one level below where I checked it.
Field presence is now detected by the key, independent of the value, and the value classifies as absent / literal / unreadable:
| value | served |
|---|---|
| key absent | nothing |
[] |
nothing — a real empty answer |
['./a.css'], './a.css' |
those styles |
IDENT, [IDENT, './b.css'], `${X}/a.css` |
file-level fallback |
An unreadable element anywhere makes the whole field unknown — acting on the literals beside it would drop whatever the rest names. Verified against the Rust extractor rather than assumed: extractComponentUrls folds both same-file consts and interpolations, so falling back is correct. It does not fold DIR + '/b.css', which is why the classifier only has to be conservative, not clever.
locateFieldInsideArgs keeps its nullable-range shape as a wrapper, so its existing callers are untouched — only the style path wants the third state.
Windows
NAPI Smoke (windows-latest) failed on c853252 while every other job passed. Not a flake: Test runs cargo test, and NAPI Smoke is the job that runs pnpm test, so only one job runs vitest at all.
One test I added passed the raw path to transform and a normalizePath'd one as ctx.file — the same file, different strings on Windows — so componentsByFile.has(ctx.file) missed, Branch 2 was never entered, and the endpoint returned an empty body.
A test artifact, not a plugin bug. Vite normalizes both the watcher's file and the resolver's ids, so production never mixes spellings; and structurally, if these disagreed in production, Branch 2 would be dead on Windows for every user. Reproduced on macOS by substituting // for the separator difference, which produced the identical assertion and the plain .ts: triggering full reload fall-through in the plugin's debug output.
The test now uses one spelling, like its long-standing neighbours. The assertion is unchanged and nothing is skipped. An expectDispatched guard now asserts the queued id matches the requested one, so this class of bug names itself rather than surfacing as an unexplained empty body. Every other .ts-editing test in the block was audited; the rest were already single-spelling and now carry the guard too.
282 unit tests pass, oxfmt --check clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9066e4325b
ℹ️ 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".
…ble one
`@Component({ 'styleUrls': [...] })` is valid TS, and the Rust extractor
resolves it, but the text scan saw no field and the classifier reported a
confident "this class declares no styles" — so the endpoint served none
and the component lost its CSS. Before this branch's styleless fix the
case fell back and kept working. Same failure mode as the const case in
9066e43, different trigger.
Quoted keys, single and double, are now matched like bare ones. That
widens template/templateUrl/styles too; the cross-match guards are
unaffected, since only the key's spelling changed, not its identity.
The general form matters more than the trigger: "no styles" is only safe
to conclude after seeing every top-level key. A computed key or an
escaped one hides a field that may exist, so the style fields become
unreadable and the endpoint falls back. A field that IS visible stays
readable — a decorator holding one computed key does not lose the fields
beside it.
A spread is deliberately NOT treated that way. Measured: the Rust
extractor drops `...BASE` entirely and reports no styleUrls, so the
compiled component really has none. Falling back would hand that class
its siblings' stylesheets, which is the contamination this PR removes.
Serving nothing is what matches the compile path, and a test guards it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_e0dc0761-3b9d-4751-8075-9730b00c1b40) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 90edef81d2
ℹ️ 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".
Two more ways the per-class scan disagreed with the Rust extractor, both
ending in a component served the wrong CSS or none.
Escapes: the scanner returned raw source text and called the read
complete, so `styleUrls: ['./cmp.css']` produced a path containing
the literal escape, the read failed, the per-style catch swallowed it,
and the component lost its stylesheet. Literals are now cooked with JS
semantics — single-char escapes, \xHH, \uHHHH, \u{…}, line continuations,
and NonEscapeCharacter — matching what Rust resolves. Malformed hex or
unicode, a trailing backslash, and legacy octal report incomplete so the
caller falls back; we are exact where we can be and defer where we
cannot. Literals with no backslash take a fast path and are byte-for-byte
unchanged.
Decoding was chosen over marking escaped literals unknown: falling back
hands a multi-component file the sibling union, which is the very
contamination this change removes.
Phantom decorators: `locateComponentDecorators` enumerated `@Component(`
with a regex, so a commented-out decorator between a real one and its
class captured the class. The endpoint then read the commented metadata —
serving old.css instead of real.css, or nothing at all when the phantom
also hid the template. Enumeration now skips comments and string
literals. Narrower than it first appeared: only a phantom sitting between
a decorator and its class did damage; one appearing first was already
dropped by the second pass, and both orderings are now pinned.
Two tests from earlier in this branch asserted the raw text — the defect
itself — and now assert the cooked value. Verified against Rust:
`./it's.css`, `./cmp.css`, `./aqb.css`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_348b6b10-f906-4d2e-968a-ee7a331f0dbc) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c2342b0490
ℹ️ 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".
…y forms A shorthand property matched the key but had no colon, so the lookup fell through and the classifier concluded "definitively styleless" — the endpoint served nothing while Rust resolved the constant: const styleUrl = './x.css' @component({ template: '<p/>', styleUrl }) rust ./x.css, scan absent This was the fourth round fixing one form of the same defect, each found by a reviewer rather than by us. So rather than patch shorthand alone, 26 key and value forms were probed against extractComponentUrls. Exactly one was broken; 10 of the new guards passed on first run, which is what makes the audit credible rather than selective. Calibration matters as much as the fix. Reporting unreadable too eagerly sends the class to the file-level union, which is the sibling contamination this work removes. So findFieldInArgs now tracks key versus value position — without it the fix would have degraded `selector: styleUrl`, a readable field sitting right there — and only the SINGULAR styleUrl shorthand is unreadable, because that is the only form the compiler resolves. Unrelated methods, getters, and deeper nesting are untouched. Three divergences are deliberately left alone. Duplicate keys are a TypeScript error, so the input is invalid either way. For a numeric key and an `as` cast it is Rust that returns nothing while our scan reads the literal — matching it would mean dropping styles to mirror a parsing gap on the other side, which is the wrong direction to converge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_6711738f-fa7e-4ae1-920f-06a2eb3c9da5) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3a43c73ede
ℹ️ 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".
`styleUrls /* why */: ['./x.css']` was read as absent at c2342b0, so the endpoint served nothing while Rust resolved the stylesheet. Round 8's skipToToken fixed it incidentally while fixing shorthand properties, and nothing pinned it — an incidental fix can regress silently. The earlier 26-form audit framed itself around key SHAPES and never asked where a comment may sit inside a declaration. That axis is covered now: around the key and colon, before the key, the line-comment form, the singular form, inside the array before, between and after elements, after a quoted key's closing quote, two consecutive comments, and comments carrying decoy syntax or an apostrophe. A comment-only array stays parsed-empty, which is right — it is still a valid empty array. The shorthand terminator cases stay unreadable, so the guards pin round 8's behavior rather than quietly widening it. These are not vacuous: replayed against c2342b0, 7 fail — 5 as absent, the CSS-stripping outcome, and 2 by falling back instead of reading the value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_05a083d6-4bdd-4904-bec9-50a9ed2a915b) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21d92d4896
ℹ️ 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".
A decorator may spell a key with a unicode escape. `styleUrls` is
`styleUrls`, and the Rust extractor reads it. The text scanner did not,
so it reported the field absent and the endpoint served the wrong CSS.
`readIdentifierKey` now decodes `\uHHHH` and `\u{...}` in a key position,
the only two escapes JS permits in an identifier. It validates each
decoded character against the identifier start/continue classes for its
position. A malformed or illegal escape marks the key unreadable, not
absent, so the endpoint falls back instead of guessing.
The same decoding now applies to quoted keys, which were unreadable
before. `'styleUrls'` matches the field exactly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_56761ed3-a2e7-494b-bca0-69ed88fd7d71) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 20c13a0c58
ℹ️ 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".
`styleUrl: './a.css' + SUFFIX` opens with a literal the value does not denote. The scan returned that leading piece and called the field readable, so the endpoint served a stylesheet the compiled component never had — and, being a confident answer, skipped the fallback that exists for values the scan cannot resolve. A literal now counts as the value only when the property ends at its closing delimiter. `endsPropertyValue` skips whitespace and comments, then requires `,`, `}` or `)`. Anything else marks the field unreadable. Measured against the Rust extractor, which compiles no styles for any of these: concatenation, a method call, `as`, `as const`, `!`, `satisfies`. The guard is shared with `template` and `templateUrl`, which moves those from wrong to correct the same way. `stripComponentMetadata` strips one less shape, which only downgrades HMR to a full reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_07768b01-0017-4acb-865f-28fd37662439) |
Decision: closing the scanner-form loop hereFive consecutive rounds each fixed one more form the text scan read differently from the Rust extractor — const values, quoted keys, escaped literals, shorthand, escaped identifier keys, trailing expressions. Every one was found by review rather than by us, and each fix revealed an adjacent form. That is not converging, so I am settling the remaining question rather than running a seventh round. The governing ruleThe scan may report "unreadable" freely, but must never assert a literal it cannot verify. A wrong literal makes HMR apply CSS the compiled app does not have; an "unreadable" only costs a fallback. This supersedes a call I made in round 8, and I want that on the record rather than buried. Round 8 found three forms where Rust is the side that fails — a numeric key, an Duplicate keys stay untouched. That input is a TypeScript error, so neither side is meaningfully right. The residual, measuredFor a value neither side can resolve, the fallback serves the file-level union. In a multi-component file that union is the siblings' stylesheets: So E gets the sibling's CSS. In a single-component file the union is empty and E is correctly served nothing. This is the known #456 limitation, not new: the fallback cannot distinguish "Rust folded a constant, so the union holds the right answer" from "Rust failed too, so the union holds only other components". Both arrive as I am not encoding a guess about which of the two the value is. That distinction belongs in the extractor, not in a text scan. State378 unit tests pass. CI was fully green on |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f483d08d4
ℹ️ 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".
`styleUrls: [...SHARED, './own.css']` marked the array incomplete, so `extractClassStylesFor` returned null and the endpoint fell back to the file-level union — handing the class every sibling stylesheet in the file. The deciding property is structural. `extract_string_array` asks each element for `as_expression()`, and `ArrayExpressionElement::is_expression()` enumerates neither `SpreadElement` nor `Elision`. A spread is therefore dropped before any value resolution, for every possible program, with the const table never consulted. An element that IS an expression reaches the resolver, which folds constants this text scan cannot read — so those still mark the array unknown, and the guard added in 7f483d0 stands. The spread is skipped whole via `advanceOneToken`, so a literal nested inside it is never mistaken for one of this class's styles; an unclosed delimiter still reports unknown rather than guessing. This also settles a contradiction inside the file: `hasUnreadableKey` already treats a decorator-level `...BASE` as droppable, on exactly this reasoning. Measured against the Rust extractor across 12 array shapes: every answer the scan now asserts matches, and `[S, './own.css']` still bails because Rust folds the constant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_dc69a6d9-ecd8-4bde-9c9e-95fc75aee1ae) |
Fixes #451. The style-side counterpart to the per-class template repair in #449.
Two defects, one block, one root cause
The
@ng/componentHMR endpoint built the update module for any requested class from the file-levelstyleUrls, whichextractComponentUrlsreturns as the union of every component in the file.styles:of a class that declares no external ones. This is exactly the shape fix(vite): dispatch template HMR to every component file sharing a templateUrl #449 fixed on the template side.The fix
The styles block now uses the same three-step order as the template block directly above it:
The fallback preserves today's behavior for decorator shapes the per-class locator cannot parse. The read/preprocess loop is extracted to a local
readStyles(urls)used by both paths, so the per-styletry/catch, theresolvedConfigguard aroundpreprocessCSS, and thestyleContents.length > 0check are unchanged.Both Angular spellings
Harder than the template case, which had one field name and one value shape. This handles
styleUrls: ['./a.css', './b.css']and the singularstyleUrl: './a.css', with all four cross-match guards under test — neither url field matches the other, and neither matches inlinestyles:.isFieldKeyAt's two-sided word boundary is what does it: searchingstyleUrlinsidestyleUrls:is rejected because the followingsis a word character.styleUrlswins if a decorator carries both. Angular rejects that combination outright, so this only makes the choice deterministic.A test-harness trap worth knowing
The first RED run failed with neither marker present, plus
Failed to preprocess style: … Cannot read properties of undefined (reading 'client'). The sharedsetupPluginWithServerinhmr-hot-update.test.tspasses a stub resolved config ({ build: {}, isProduction: false }), sopreprocessCSSthrows and the existing per-stylecatchsilently drops every external stylesheet.Left unnoticed, tests 1 and 2 would have gone green after the fix while proving nothing. The new describe block builds a real config via
resolveConfig, the waystyle-deps-hmr.test.tsalready does. I did not change the shared helper — around 30 other tests depend on it — but anyone adding endpoint tests that assert on external stylesheet content needs the real-config setup.Tests
TDD; each behavior test failed on
0b56f50for the intended served-content reason:serves each component its own styleUrls in a multi-component file— served the sibling's marker.serves the singular styleUrl of the requested class— served the sibling's marker.serves the inline styles of a class whose sibling uses a styleUrl— inline marker absent.Plus 8 locator unit tests in
decorator-fields.test.tsfollowing thelocateTemplateUrlForprecedent from #449.Verification
248 unit tests pass (237 pre-existing + 11 new),
oxfmt --checkclean. Verified by diff that the template block, pending-slot handling, error path, and transient-empty logic are untouched.🤖 Generated with Claude Code
https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
Note
Medium Risk
Changes core HMR middleware and a large, heuristic
@Componenttext parser; regressions would show up as wrong or missing styles in dev, not in production builds.Overview
Fixes incorrect HMR style delivery when multiple components share one file: the
@ng/componentendpoint no longer builds update modules from the file-levelstyleUrlsunion, which leaked siblings’ CSS and could hide a class’s inlinestyles.Endpoint behavior mirrors the existing per-class template path: read that class’s inline
stylesandstyleUrl/styleUrls, preprocess external paths, merge inline first then external (compiler order), and only fall back to the file-level URL list when the per-class metadata cannot be read (e.g. constants the text scan does not resolve).decorator-fieldsgains substantial parsing: comment/string-aware@Componentdiscovery,locateStyleFieldsForwith absent / unreadable / literal states,readStringLiteralswith acompleteflag, quoted and escaped keys, and rules aligned with the Rust extractor (spreads dropped, shorthandstyleUrlunreadable vs array shorthands absent, partial literals not acted on).Tests add broad unit coverage in
decorator-fields.test.tsand integration cases inhmr-hot-update.test.tsusing a real ViteresolveConfigso external CSS preprocessing is exercised;expectDispatchedguards Windows path spelling for HMR ids.Reviewed by Cursor Bugbot for commit 39b4d45. Bugbot is set up for automated code reviews on this repo. Configure here.