diff --git a/crates/oxc_angular_compiler/src/hmr/update_module.rs b/crates/oxc_angular_compiler/src/hmr/update_module.rs index c69cf77fa..1032e821d 100644 --- a/crates/oxc_angular_compiler/src/hmr/update_module.rs +++ b/crates/oxc_angular_compiler/src/hmr/update_module.rs @@ -177,9 +177,16 @@ fn generate_hmr_update_module_internal( output.push_str(",\n"); } - // Add styles if present + // Add styles whenever the caller has an answer — an EMPTY list included. + // The definition above spreads `...ClassName.ɵcmp`, so every key this + // module does not emit keeps its previous value. A component that lost its + // last stylesheet therefore needs an explicit `styles: []` to clear it; + // omitting the key would leave the old CSS applied until a full reload. + // `None` stays omitted: that is "the caller does not know", not "empty". if let Some(styles) = styles { - if !styles.is_empty() { + if styles.is_empty() { + output.push_str(" styles: [],\n"); + } else { output.push_str(" styles: [\n"); for style in styles { output.push_str(" "); @@ -233,6 +240,23 @@ mod tests { assert!(!result.contains("styles:")); } + #[test] + fn test_generate_hmr_update_module_empty_styles_clears() { + // An EMPTY list is a definitive answer, not silence. The module opens + // with `...ClassName.ɵcmp`, so a key it does not emit keeps its old + // value — omitting `styles` here would leave the component's last + // stylesheet applied until a full reload. + let result = generate_hmr_update_module_from_js( + "src/app/app.component.ts@AppComponent", + "function AppComponent_Template(rf, ctx) { }", + Some(&[]), + None, + None, + ); + + assert!(result.contains("styles: [],")); + } + #[test] fn test_class_name_extraction() { let result = generate_hmr_update_module_from_js( diff --git a/napi/angular-compiler/e2e/tests/hmr-style-removal.spec.ts b/napi/angular-compiler/e2e/tests/hmr-style-removal.spec.ts new file mode 100644 index 000000000..ba654f79b --- /dev/null +++ b/napi/angular-compiler/e2e/tests/hmr-style-removal.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '../fixtures/test-fixture.js' + +/** + * Regression test for https://github.com/voidzero-dev/oxc-angular-compiler/issues/457 + * + * Losing the LAST style is the one style change HMR used to get wrong. The + * generated update module opens with `...ClassName.ɵcmp`, so every property it + * does not emit keeps its previous value — and the `styles` property was + * omitted for every "no styles" answer. The component updated, the old CSS + * stayed applied, and only a full reload cleared it. + * + * The unit tests assert the module now carries an explicit `styles: []`. This + * one asserts the thing a user actually sees: the CSS is gone from the page, + * and the page did not reload to get there. + */ +test.describe('Removing a component last style', () => { + test('emptying inline `styles` drops the CSS with no full reload', async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + // Must run before `goto` — Vite's client opens its socket during load, + // and the wire is the only honest evidence about a reload request. + await hmrDetector.captureWirePayloads() + await page.goto('/') + await page.waitForLoadState('networkidle') + + // The previous spec's teardown restores the file it edited, and that + // write can still be in flight when this page connects — its `full-reload` + // would otherwise land in this test's recording and in its DOM. Let the + // stray settle first, then start measuring. + await waitForHmr() + const payloadsBeforeEdit = (await hmrDetector.getWirePayloads()).length + const sentinelId = await hmrDetector.addSentinel() + + const card = page.locator('app-inline-card .inline-card') + await expect(card).toBeVisible() + + // The fixture's inline styles put a dashed border on the card and make + // the host a block. Both come from the component's own `styles`. + const beforeBorder = await card.evaluate((el) => getComputedStyle(el).borderStyle) + expect(beforeBorder).toBe('dashed') + const beforeHostDisplay = await page + .locator('app-inline-card') + .evaluate((el) => getComputedStyle(el).display) + expect(beforeHostDisplay).toBe('block') + + // Drop the last (and only) inline style. `styles: []` is the transition + // the issue names: a component that HAD a style and now has none. + await fileModifier.modifyFile('inline-card.component.ts', (content) => { + const emptied = content.replace(/styles: \[[\s\S]*?\n {2}\],/, 'styles: [],') + if (emptied === content) { + throw new Error('failed to empty the inline `styles` array in the fixture') + } + return emptied + }) + await waitForHmr() + + // The component is still mounted — this is an update, not a teardown. + await expect(page.locator('app-inline-card h2')).toHaveText('INLINE_TITLE') + + // The CSS is gone: the border reverts to the UA default, and the host + // stops being a block. Before the fix these kept their old values. + const afterBorder = await card.evaluate((el) => getComputedStyle(el).borderStyle) + expect(afterBorder).toBe('none') + const afterHostDisplay = await page + .locator('app-inline-card') + .evaluate((el) => getComputedStyle(el).display) + expect(afterHostDisplay).toBe('inline') + + // And no full reload got us there. The sentinel proves the browser did + // not act on one; the wire proves the server never asked for one. + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + const payloads = (await hmrDetector.getWirePayloads()).slice(payloadsBeforeEdit) + expect(payloads.map((p) => p.event)).toContain('angular:component-update') + expect(payloads.map((p) => p.type)).not.toContain('full-reload') + }) +}) diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 97a18ff1f..bf0d4976b 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -77,7 +77,11 @@ export declare function compileForHmr( * * `template` - The template HTML string * * `component_name` - The name of the component class * * `file_path` - The path to the component file - * * `styles` - Optional array of CSS styles + * * `styles` - The component's CSS styles, or `None` when the caller cannot + * tell. `Some` is a definitive answer — an EMPTY array means "this component + * has no styles" and makes the generated module emit `styles: []`, clearing + * whatever it had. `None` omits the key, so the module's `...ɵcmp` spread + * keeps the previous styles. * * # Returns * @@ -447,7 +451,8 @@ export interface FactoryNapiCompileResult { * * * `component_id` - The component ID (path@ClassName) * * `template_js` - The compiled template function as JavaScript - * * `styles` - Optional array of CSS styles + * * `styles` - The component's CSS styles, or `None` when unknown. An empty + * array is definitive and emits `styles: []`, clearing the old styles. * * # Returns * diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 8404f96e4..5d1f2d901 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -472,7 +472,8 @@ pub fn compile_template( /// /// * `component_id` - The component ID (path@ClassName) /// * `template_js` - The compiled template function as JavaScript -/// * `styles` - Optional array of CSS styles +/// * `styles` - The component's CSS styles, or `None` when unknown. An empty +/// array is definitive and emits `styles: []`, clearing the old styles. /// /// # Returns /// @@ -522,7 +523,11 @@ pub fn generate_style_module(component_id: String, styles: Vec) -> Strin /// * `template` - The template HTML string /// * `component_name` - The name of the component class /// * `file_path` - The path to the component file -/// * `styles` - Optional array of CSS styles +/// * `styles` - The component's CSS styles, or `None` when the caller cannot +/// tell. `Some` is a definitive answer — an EMPTY array means "this component +/// has no styles" and makes the generated module emit `styles: []`, clearing +/// whatever it had. `None` omits the key, so the module's `...ɵcmp` spread +/// keeps the previous styles. /// /// # Returns /// @@ -551,31 +556,43 @@ pub fn compile_for_hmr_sync( Some(output.declarations_js.as_str()) }; + // The caller's `Option` carries whether it KNOWS the answer, and + // that has to survive to the generated module: `Some` (even + // `Some([])`) is definitive, so the module emits `styles: [...]` + // and clears whatever the component had; `None` is "unknown", so + // the module omits the key and the spread keeps the old value. + // Collapsing an empty-but-definitive answer to `None` here is what + // left a component's last stylesheet applied after HMR. + let caller_is_definitive = styles.is_some(); + // Merge external styles with styles extracted from template