Skip to content

fix(vite): clear a component's styles when it loses its last one - #461

Merged
Brooooooklyn merged 3 commits into
mainfrom
fix/issue-457-clear-styles-on-empty
Aug 25, 2026
Merged

fix(vite): clear a component's styles when it loses its last one#461
Brooooooklyn merged 3 commits into
mainfrom
fix/issue-457-clear-styles-on-empty

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Aug 25, 2026

Copy link
Copy Markdown
Member

Closes #457.

A component that lost its last style kept the old CSS applied until a full reload.

Cause

The generated HMR module opens with ...ClassName.ɵcmp, so every key it does not emit keeps its previous value. styles was omitted for every "no styles" answer, so the stale array survived.

The issue named one collapse point. There were four.

napi/angular-compiler/vite-plugin/index.ts
  :719  readStyles -> null when nothing was read
  :730  (await readStyles(urls)) ?? []          erases the read failure
  :734  merged.length > 0 ? merged : null       <- never sends [] at all

napi/angular-compiler/src/lib.rs
  :555  styles.unwrap_or_default()              None and Some([]) merge here
  :561  if all_styles.is_empty() { None }
  :577  if styles.is_empty() { None }

crates/oxc_angular_compiler/src/hmr/update_module.rs
  :182  if !styles.is_empty()                   second, independent guard

Fixing only the generator changes nothing, because lib.rs:555 never hands it a Some([]) and the plugin never sends one.

Measured before the fix: null, undefined, [], [""], [" "] and the omitted argument all produced byte-identical output with no styles: key.

The fix

styles is now three-valued, end to end:

value meaning module
non-empty these are the component's styles styles: [ … ]
[] it definitively has none styles: [] — clears
null unknown key omitted — the spread keeps what it had

null was overloaded, and that is the part worth reviewing

In the plugin, null meant three different things:

(a) the class declares no styles, or an empty array    -> MUST clear
(b) the decorator is unreadable, no fallback           -> MUST keep the old CSS
(c) styleUrls listed but no file could be read         -> was ambiguous

Case (b) is why the property cannot simply always be emitted — clearing on an unreadable decorator would wipe live CSS. So the distinction that matters is whether the answer is KNOWN, not whether it is empty.

I decided case (c) rather than leaving it open: a read failure is not evidence of stylelessness. readStyles previously swallowed failures in a catch {} and :730 coalesced the result to [], which made a missing stylesheet indistinguishable from an empty one. Without the distinction, a truncate window during an editor's atomic write would report the component styleless and wipe its CSS.

read succeeded, content empty or whitespace  -> definitively styleless -> clears
read FAILED (missing, permission, throw)     -> unknown                -> left alone

The file-level fallback branch keeps its old semantics and can never clear.

Why styles: [] actually works

Traced through the pinned Angular v22.0.0 submodule:

ɵɵreplaceMetadata (hmr.ts:81) -> recreateLView (hmr.ts:244-312)
  :281  destroyLView(old) -> renderer.destroy()
          -> dom_renderer.ts:610-617  sharedStylesHost.removeStyles(...)
          -> shared_styles_host.ts:184-200  record.usage--; if (<=0) removeElements(...)
  :292  clearRendererCache(oldDef)
  :296  createRenderer(host, newDef) -> this.styles = shim(newDef.styles)   usage++

Styles are ref-counted per CSS string. Today the removed and re-added strings are the same, so the count dips and returns and the <style> element never leaves. With styles: [] nothing is re-added, the count reaches zero and element.remove() runs. definition.ts:365 reads componentDefinition.styles || EMPTY_ARRAY, and an empty array is truthy, so an emitted [] survives intact.

Two caveats found while reading that path, neither blocking: removal is skipped while allLeavingAnimations.size !== 0 (dom_renderer.ts:614), and it is gated on removeStylesOnCompDestroy, default true.

Verification

Binding contract after the fix:

null / undefined         -> key omitted
[] / [""] / ["   "]      -> styles: []
[".a{color:red}"]        -> styles: [ … ]
null + template <style>  -> styles: [ … ]     unchanged
before after
cargo test --lib hmr:: 16 17
cargo test -p oxc_angular_compiler all ok all ok, 0 failed
pnpm test 403 408
pnpm test:e2e 36 37

The five new vitest cases cover both directions — the three clearing transitions, and the two that must NOT clear (an unreadable stylesheet, an unreadable style field).

One existing assertion changed deliberately

test/hmr-hot-update.test.ts, in serves no styles to a class that declares none, even beside a styled sibling, asserted expect(body).not.toContain('styles:'). That encoded the old behavior. Its real intent — this class is not served its sibling's stylesheet — is still asserted by the marker check; the assertion now pins the explicit empty array instead. Two neighbouring tests passed either way and were tightened the same way.

The e2e spec is the one that proves the user-visible fix

e2e/tests/hmr-style-removal.spec.ts loads a component with an inline style, confirms the dashed border and block host, empties styles: [], waits for HMR, and asserts the border reverts to none and the host to inline — with a DOM sentinel proving the browser did not reload and the websocket wire proving the server never asked for one.

Red-checked: with the generator guard restored and the binary rebuilt, it fails with Expected: "none" / Received: "dashed".

Note for the reviewer

Two transitions the issue lists still take a full reload rather than the hot path: styleUrls: ['x'] -> [], and removing the styles: key outright. stripComponentMetadata blanks template: and styles: but not styleUrls:, so those edits break the byte-equality check and force a reload. That masking is pre-existing, is not a wrong result, and is left alone here.


Note

Medium Risk
Changes dev-only HMR style resolution; wrong unknown-vs-empty handling could clear live CSS during failed reads, though the new complete flag and tests target that edge case.

Overview
Fixes #457: after HMR, a component that went from having styles to none kept old CSS until a full reload, because update modules spread ...ClassName.ɵcmp and omitted styles for every “no styles” case.

End-to-end contract: styles is now three-valued — non-empty arrays update CSS, [] means “definitely no styles” and must emit styles: [] to clear the runtime, and null means unknown so the key stays omitted and existing styles are preserved.

The HMR module generator emits styles: [] when given Some([]) instead of skipping the property. compile_for_hmr_sync no longer folds definitive empty caller input into None after encapsulation. The Vite plugin tracks stylesheet read completeness (failed/missing reads vs successful empty files) so atomic-write gaps are not treated as “clear styles,” while still allowing partial updates when one of several styleUrls fails.

API docs (index.d.ts, Rust doc comments) describe the semantics. Tests add a Rust unit case, a Playwright e2e for visible CSS removal without reload, and many Vitest scenarios for clear vs must-not-clear paths.

Reviewed by Cursor Bugbot for commit 8ae92a1. Bugbot is set up for automated code reviews on this repo. Configure here.

A component that lost its last style kept the old CSS applied until a
full reload. The generated HMR module opens with `...ClassName.ɵcmp`, so
every key it does not emit keeps its previous value — and `styles` was
omitted for every "no styles" answer.

`styles` is now three-valued, end to end:

  non-empty  these are the component's styles
  []         it definitively has none — emit `styles: []` and clear
  null       unknown — omit the key, whatever it has survives

Four places collapsed the first two into one value. `index.ts:734` turned
an empty result into null before the call; `lib.rs:555` merged `None` and
`Some([])` with `unwrap_or_default`; `lib.rs:561`/`:577` mapped an empty
list back to `None`; and `update_module.rs:182` guarded the emit a second
time. Fixing only the generator changes nothing, because nothing ever
handed it a `Some([])`.

`null` was also overloaded in the plugin. Clearing on an unreadable
decorator would wipe live CSS, so the distinction that matters is whether
the answer is KNOWN, not whether it is empty. `readStyles` now reports a
failed read instead of swallowing it in a `catch`: a stylesheet read
successfully but holding nothing is definitively styleless and clears,
while one that could not be read at all stays unknown and is left alone.
An editor's atomic write leaves exactly that window. The file-level
fallback branch keeps its old semantics and can never clear.

Verified against the pinned Angular v22.0.0: `ɵɵreplaceMetadata` →
`recreateLView` → `destroyLView` → `sharedStylesHost.removeStyles`, which
is ref-counted per CSS string. Today the removed and re-added strings are
identical, so the count dips and returns and the `<style>` element never
leaves; with `styles: []` nothing is re-added, the count reaches zero and
`element.remove()` runs.

Tests cover both directions: the three clearing transitions, and the two
that must NOT clear (an unreadable stylesheet, an unreadable style
field). One existing assertion changed deliberately —
`hmr-hot-update.test.ts` asserted `not.toContain('styles:')` for a class
declaring none, which encoded the old behavior; it now asserts the
explicit empty array, keeping its original intent. A Playwright spec
proves the user-visible effect: the border reverts and no `full-reload`
crosses the wire.

Closes #457

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
@cursor

cursor Bot commented Aug 25, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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_92ef0746-579d-4c83-a7fd-e7def1961d66)

@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: c6e339d331

ℹ️ 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".

Comment thread napi/angular-compiler/vite-plugin/index.ts Outdated
…tyles

The fallback branch runs when the class's OWN decorator could not be read,
and it falls back to the file-level union — which can belong to a sibling.
Its comment already promised it never clears. The code stopped keeping
that promise once this PR made any non-null array definitive.

A stylesheet that reads fine but holds nothing yields `['']`. That is one
entry, so `contents.length > 0` passed it on; the binding then dropped the
whitespace-only entry and emitted `styles: []`, wiping CSS from a
decorator nobody on this path could read — `styles: STYLE_CONST`, say,
which the Rust extractor folds and the text scan cannot see. Measured:
`['']` and `['  ']` both produced `styles: []`, while `null` omitted the
key. Before this PR the same input was omitted, so the regression is
this PR's.

The whitespace filter now runs BEFORE the null check here, unlike the
class-level branch above where an empty result is a real answer.

A partial read on this branch still presents the successfully-read subset
as the whole style set. That is unchanged from main — the old `readStyles`
swallowed failures in a `catch` and returned whatever it got — and belongs
to the file-level fallback contamination tracked in #456, not here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
@cursor

cursor Bot commented Aug 25, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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_54f4e367-7381-4795-9e77-d5eacbd36b6e)

@Brooooooklyn

Copy link
Copy Markdown
Member Author

The medium finding is confirmed and fixed in 38812d0. The second observation is real but pre-existing, and I am leaving it.

Confirmed: the fallback branch could clear

Measured at the binding on c6e339d:

compileForHmrSync(..., [''])    -> styles: []      CLEARS
compileForHmrSync(..., ['  '])  -> styles: []      CLEARS
compileForHmrSync(..., null)    -> key omitted

fallback.contents.length > 0 counted an empty string as content, so a file-level stylesheet that read fine but held nothing was passed on, went definitive at the binding, and came back as styles: []. On 088e2ab the same input produced an omitted key, so this PR introduced it. The branch's own comment already said it never clears — the code had stopped keeping that promise.

The severity is right for the reason you gave: this branch only runs when classStyles === null, meaning the class's own decorator was unreadable, and styleUrls there is the file-level union across every @Component in the file. So the CSS it would erase can belong to a decorator nothing on that path could read.

Fix:

const fallback = await readStyles(styleUrls)
const usable = fallback.contents.filter((style) => style.trim().length > 0)
styles = usable.length > 0 ? usable : null

The whitespace filter now runs BEFORE the null check on this branch, unlike the class-level branch above, where an empty result IS a definitive answer. That asymmetry is the whole point and is now commented.

Two regressions added, both red-checked. Each is one file with two classes: the requested class declares styles: PS457_FB_STYLES so the fallback branch runs, and the only file-level stylesheet belongs to the sibling and is empty in one test, whitespace-only in the other. Both assert the served module contains no styles: at all. The failure before the fix was expected '// HMR update for: …' not to contain 'styles:' with styles: [], in the body.

The three clearing cases still clear. Suite: 408 -> 410.

Declined: the partial-read replacement

A partial fallback read similarly replaces the full style set with incomplete contents.

True, and unchanged from main. The old code on 088e2ab:

} catch {
  // Style file not found, continue without this style
}
...
return styleContents.length > 0 ? styleContents : null

with a bare styles = await readStyles(styleUrls) at the call site. Two stylesheets, one failing, returned the one that read — the subset presented as the whole set, exactly as today.

I am not adding a fallback.complete guard here. It would change behaviour this PR did not break, and the underlying problem is that the file-level union is not a per-component answer at all. That is tracked in #456, which removes the fallback rather than narrowing it. Tightening it inside a bug-fix PR would be scope drift.

On the framing

The recommendation to "keep styles null whenever per-class extraction is unknown unless a complete per-class inventory is obtained from the compiler" is where the design is already headed — #456 is exactly that. What this PR can honestly guarantee is narrower: an unknown answer must never become a definitive clear. That invariant now holds on every path, which is what the finding was really about.

A component with two external stylesheets, one of them permanently
unreadable, stopped receiving style updates entirely. Editing the HEALTHY
stylesheet dispatched HMR, but `external.complete` was false, so `styles`
stayed null and the module omitted the key. The edit was lost, and the
pending slot is consumed either way, so nothing retried it.

`complete` guards exactly ONE thing: turning an unknown answer into a
definitive `[]` that wipes live CSS. It is not a reason to throw away
content that WAS read. main always delivered the partial read — the old
`readStyles` swallowed failures in a `catch` and returned whatever it
got — and this PR broke that.

  complete                 -> merged, may be [], may clear   (new)
  incomplete, merged non-[] -> merged                        (= main)
  incomplete, merged == []  -> null, key omitted             (= main)

Verified by transcribing both revisions' formulas and sweeping all 105
combinations of inline shape against read outcomes: 21 differ from main,
and all 21 are the intended "complete read, empty result -> styles: []".
Zero other differences.

The fallback branch below is unchanged and already correct. It ignores
`complete` and applies the same never-clear rule, because a successful
read of the file-level union still says nothing definitive about a class
whose own decorator could not be read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx
@cursor

cursor Bot commented Aug 25, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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_ad8325f4-1271-4433-a725-37144f55e5dd)

@Brooooooklyn
Brooooooklyn merged commit ab79282 into main Aug 25, 2026
11 checks passed
@Brooooooklyn
Brooooooklyn deleted the fix/issue-457-clear-styles-on-empty branch August 25, 2026 05:35
Brooooooklyn added a commit that referenced this pull request Aug 25, 2026
…462)

* fix(vite): resolve HMR resources from the extractor, not a text scan

The `@ng/component` endpoint read a class's own `templateUrl` / `template`
/ `styleUrl(s)` / `styles` by scanning decorator TEXT. The Rust extractor
folds same-file constants and interpolates template literals; the scan
cannot. Every shape it could not read fell back to the FILE-LEVEL union,
which in a multi-component file served a class its siblings' template and
stylesheets.

    const DIR = './themes'
    @component({ styleUrls: [`${DIR}/a.css`, SHARED_STYLE] })

The endpoint now asks the extractor. `extract_component_metadata_sync`
was already written and correct but carried no `#[napi]`, so it was not
on the JS surface; adding the attribute is the whole Rust change.

That answer is definitive, because it is the SAME one the compiler uses.
`transform.rs:2557` and the extractor both call `extract_component_metadata`
with the same `collect_string_consts` table. The transform's only extra
step, `resolve_styles`, turns URLs into content and never re-derives the
URL list:

    compiled styles = metadata.styles ++ concat(content(u) for u in style_urls)
                      └── inline, first ──┘

Eight fixtures were compared against the real compile and all matched,
including the one that looks like a partial read: `[OK, IMPORTED, './lit.css']`
resolves to two entries in BOTH. The dropped import is genuinely not a
stylesheet this component gets, so serving two is exact, not partial.

A class MISSING from the metadata is equally definitive — measured, the
compiler skips it too, leaving the decorator intact with no `ɵcmp`. So
both fallbacks are deleted rather than narrowed.

This retires the `unreadable` state, which existed only because the text
scan disagreed with Rust. What does NOT change is `readStyles`, its
`complete` flag, and the three-valued `styles` argument from #457/#461:
those guard a resolved file that cannot be READ, which is a filesystem
question and still real.

Three existing assertions flipped, deliberately. `styles: SOME_ARRAY_CONST`
asserted no `styles:` key on the theory that Rust folded the constant.
It does not — OXC folds string-valued consts, not array-valued ones, so
that component's `ɵcmp` never had a `styles` property. `styles: []` there
clears nothing. Verified against the compiler output, not reasoned.

Closes #456

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx

* refactor(vite): delete the decorator locators the endpoint no longer uses

The previous commit moved HMR resource resolution to the extractor, which
left the per-class text locators reachable only from their own tests.

Gone from the plugin: `extractTemplateUrlFor`, `extractClassStylesFor`,
`extractInlineTemplate`, `extractInlineStyles`, and the two caches the
last pair existed to fill — `inlineTemplateCache` and `inlineStylesCache`,
both of which were written, pruned, refreshed on hot update, and never
read by anything, from before this PR.

Gone from the scanner: the `*For` locator family, `readStringLiterals`,
`StringLiteralsRead`, `ClassStyleFields`, `locateStyleFieldsFor`,
`hasUnreadableKey`, `hasInterpolation`. Its exports drop from 15 to 6.

`stripComponentMetadata` and its closure stay untouched. It decides full
reload versus hot update, so it still parses decorator text and still has
to get comments, decoys and escapes right.

Most of what looked dead was not. Nine of ten candidate helpers turned out
live via `locateStylesInArgs` / `locateTemplateInArgs` ->
`locateFieldInsideArgs` -> `findFieldInArgs`; only `hasInterpolation` was
genuinely unreachable. `FieldValue` stays as `findFieldInArgs`'s return
type, with its `export` dropped.

So the tests were re-pointed rather than dropped: of 206, 22 are
unchanged, 78 keep their title with the call swapped to a surviving
locator, 26 are renamed because the old title named a concept that is
gone, and 76 are deleted — the `readStringLiterals` block and the
url-locator blocks, which test functions that no longer exist. Two new
cases cover strip-path behaviour the salvage exposed: a `]` inside a
comment must not close the array early, and an array holding only a
comment must still strip to `[]`.

Behaviour is proven unchanged beyond the e2e suite: `stripComponentMetadata`
was run against both the old and new scanner over 744 generated sources —
62 decorator shapes by 12 file wrappers, including phantom decorators in
comments and strings, CRLF, Unicode class names, malformed escapes,
spreads and elisions. Zero differences.

343 unit tests pass, e2e stays at 37.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx

* fix(vite): clear styles only from the source the component compiled with

The endpoint re-parses `resolvedId` from disk, but the component was
compiled from the `code` Vite handed `transform`. Those are different
byte streams whenever another plugin exposes a `load` hook or a
pre-ordered `transform`, and — with no third-party plugin at all —
whenever `fileReplacements` points `actualId` at a different file.

On a disk source the compiler never saw, every `styles` shape the
extractor cannot fold resolves to nothing: an array constant, an imported
one, a `.concat(...)`. Reading that as definitive emitted `styles: []`
and wiped CSS the running component genuinely had — from a template edit
that never touched the styles.

Measured on a real dev server, disk holding `styles: STYLE_ARRAY` with an
upstream `load` expanding it, editing only the external `.html`:

    this branch   styles: [],          <- wipes the compiled style
    main          (no styles: key)     <- CSS survives

So this PR introduced it. It fires on the external-resource branch with
no `.ts` change at all.

The evidence needed was already cached. `componentMetadataCache` holds
the transform-time source with the `template:` / `styles:` VALUES
blanked, and blanking only ever empties a delimited range — so an
expression the strip cannot open survives verbatim, and the two stripped
forms disagree exactly when the two sources disagree outside those
fields. Matching strips is proof that the styles read here are the styles
the component compiled with.

This gates the destructive answer ONLY. Content that WAS read is still
served on a mismatch, which is no worse than main, since main scanned the
same disk source. `merged.length > 0` short-circuits, so the strip runs
only when `[]` is on the table.

Not done here, deliberately: the reviewer suggested caching metadata from
each successful transform. `transform` does not re-run before the
endpoint serves an inline `.ts` edit, so that cache is stale on the most
common path.

The four existing array-constant tests keep clearing, because disk and
transform source are identical there. The gate separates "the compiler
also saw this constant and resolved nothing", where clearing is exact,
from "the compiler saw something else", where it is a guess. A new test
pins the other side: identical sources, external-resource path, still
clears.

347 unit tests pass, e2e stays at 37.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx

---------

Co-authored-by: Claude Opus 5 <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.

Removing a component's last style leaves the old CSS applied until a full reload

1 participant