Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions crates/oxc_angular_compiler/src/hmr/update_module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ");
Expand Down Expand Up @@ -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(
Expand Down
79 changes: 79 additions & 0 deletions napi/angular-compiler/e2e/tests/hmr-style-removal.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
9 changes: 7 additions & 2 deletions napi/angular-compiler/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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
*
Expand Down
59 changes: 38 additions & 21 deletions napi/angular-compiler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -522,7 +523,11 @@ pub fn generate_style_module(component_id: String, styles: Vec<String>) -> 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
///
Expand Down Expand Up @@ -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 <style> tags
let mut all_styles: Vec<String> = styles.unwrap_or_default();
all_styles.extend(output.styles);

// Apply style encapsulation for ViewEncapsulation.Emulated
// Angular uses %COMP% as a placeholder that the runtime replaces with the component ID
let encapsulated_styles: Option<Vec<String>> = if all_styles.is_empty() {
None
} else {
let styles: Vec<String> = all_styles
.iter()
.map(|style| {
oxc_angular_compiler::styles::finalize_component_style(
style,
true,
"_ngcontent-%COMP%",
"_nghost-%COMP%",
opts.minify_component_styles,
)
})
.filter(|style| !style.trim().is_empty())
.collect();

if styles.is_empty() { None } else { Some(styles) }
};
let encapsulated: Vec<String> = all_styles
.iter()
.map(|style| {
oxc_angular_compiler::styles::finalize_component_style(
style,
true,
"_ngcontent-%COMP%",
"_nghost-%COMP%",
opts.minify_component_styles,
)
})
.filter(|style| !style.trim().is_empty())
.collect();

// Emit the array when it is an answer: the caller was definitive,
// or the template's own <style> tags produced content.
let encapsulated_styles: Option<Vec<String>> =
if caller_is_definitive || !encapsulated.is_empty() {
Some(encapsulated)
} else {
None
};

// Generate HMR module with declarations, encapsulated styles, and consts
let hmr_module = generate_hmr_update_module_from_js(
Expand Down
Loading
Loading