Skip to content
47 changes: 47 additions & 0 deletions packages/core/test/acceptance/hmr_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {compileComponent} from '../../src/render3/jit/directive';
import {angularCoreEnv} from '../../src/render3/jit/environment';
import {clearTranslations, loadTranslations} from '@angular/localize';
import {computeMsgId} from '@angular/compiler';
import {DOCUMENT} from '@angular/common';
import {EVENT_MANAGER_PLUGINS} from '@angular/platform-browser';
import {ComponentType} from '../../src/render3';
import {getComponentLView} from '../../src/render3/util/discovery_utils';
Expand Down Expand Up @@ -314,6 +315,52 @@ describe('hot module replacement', () => {
);
});

it('should clean up stale disabled stylesheets when styles change during HMR', () => {
const initialStyles = `strong { color: red; }`;
const initialMetadata: Component = {
encapsulation: ViewEncapsulation.None,
selector: 'child-cmp',
template: 'Hello <strong>{{state}}</strong>',
styles: initialStyles,
};

@Component(initialMetadata)
class ChildCmp {
state = 0;
}

@Component({
imports: [ChildCmp],
template: `<child-cmp />`,
})
class RootCmp {}

const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();

const doc = TestBed.inject(DOCUMENT);
const findStyle = (text: string) =>
Array.from(doc.head!.querySelectorAll('style')).find((s) => s.textContent === text);

// Verify initial style is present
expect(findStyle(initialStyles)).toBeTruthy();

const newStyles = `strong { background: pink; }`;

// Simulate HMR with different styles
replaceMetadata(ChildCmp, {
...initialMetadata,
template: `Changed <strong>{{state}}</strong>!`,
styles: newStyles,
});
fixture.detectChanges();

// Old style should be fully removed from the DOM, not just disabled
expect(findStyle(initialStyles)).toBeFalsy();
// New style should be present
expect(findStyle(newStyles)).toBeTruthy();
});

it('should continue binding inputs to a component that is replaced', () => {
const initialMetadata: Component = {
selector: 'child-cmp',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -449,10 +449,12 @@
"diPublicInInjector",
"directiveHostEndFirstCreatePass",
"directiveHostFirstCreatePass",
"disableStyles",
"documentElement",
"domOnlyFirstCreatePass",
"elementLikeEndShared",
"elementLikeStartShared",
"enableStyles",
"enterDI",
"enterView",
"epoch",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@
"diPublicInInjector",
"directiveHostEndFirstCreatePass",
"directiveHostFirstCreatePass",
"disableStyles",
"enableStyles",
"enterDI",
"enterView",
"epoch",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/bundling/defer/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"createLinkElement",
"createProvidersConfig",
"createStyleElement",
"disableStyles",
"enableStyles",
"errorHandler",
"getBaseElementHref",
"getDOM",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -520,11 +520,13 @@
"diPublicInInjector",
"directiveHostEndFirstCreatePass",
"directiveHostFirstCreatePass",
"disableStyles",
"effect",
"elementAttributeInternal",
"elementLikeEndShared",
"elementLikeStartShared",
"emailValidator",
"enableStyles",
"enterDI",
"enterView",
"epoch",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,11 +517,13 @@
"diPublicInInjector",
"directiveHostEndFirstCreatePass",
"directiveHostFirstCreatePass",
"disableStyles",
"effect",
"elementAttributeInternal",
"elementLikeEndShared",
"elementLikeStartShared",
"emailValidator",
"enableStyles",
"enterDI",
"enterView",
"epoch",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@
"diPublicInInjector",
"directiveHostEndFirstCreatePass",
"directiveHostFirstCreatePass",
"disableStyles",
"enableApplyRootElementTransformImpl",
"enableFindMatchingDehydratedViewImpl",
"enableHydrationRuntimeSupport",
Expand All @@ -416,6 +417,7 @@
"enableLocateOrCreateElementNodeImpl",
"enableLocateOrCreateTextNodeImpl",
"enableRetrieveHydrationInfoImpl",
"enableStyles",
"enterDI",
"enterSkipHydrationBlock",
"enterView",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Original file line number Diff line number Diff line change
Expand Up @@ -577,11 +577,13 @@
"diPublicInInjector",
"directiveHostEndFirstCreatePass",
"directiveHostFirstCreatePass",
"disableStyles",
"domOnlyFirstCreatePass",
"elementAttributeInternal",
"elementLikeEndShared",
"elementLikeStartShared",
"emptyPathMatch",
"enableStyles",
"encodeUriFragment",
"encodeUriQuery",
"encodeUriSegment",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@
"diPublicInInjector",
"directiveHostEndFirstCreatePass",
"directiveHostFirstCreatePass",
"disableStyles",
"enableStyles",
"enterDI",
"enterView",
"epoch",
Expand Down
42 changes: 29 additions & 13 deletions packages/platform-browser/src/dom/dom_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const REMOVE_STYLES_ON_COMPONENT_DESTROY_DEFAULT = true;

/**
* A DI token that indicates whether styles
* of destroyed components should be removed from DOM.
* of destroyed components should be disabled.
*
* By default, the value is set to `true`.
* @publicApi
Expand Down Expand Up @@ -139,7 +139,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
private readonly eventManager: EventManager,
private readonly sharedStylesHost: SharedStylesHost,
@Inject(APP_ID) private readonly appId: string,
@Inject(REMOVE_STYLES_ON_COMPONENT_DESTROY) private removeStylesOnCompDestroy: boolean,
@Inject(REMOVE_STYLES_ON_COMPONENT_DESTROY) private disableStylesOnCompDestroy: boolean,
@Inject(DOCUMENT) private readonly doc: Document,
readonly ngZone: NgZone,
@Inject(CSP_NONCE) private readonly nonce: string | null = null,
Expand Down Expand Up @@ -186,7 +186,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
const ngZone = this.ngZone;
const eventManager = this.eventManager;
const sharedStylesHost = this.sharedStylesHost;
const removeStylesOnCompDestroy = this.removeStylesOnCompDestroy;
const disableStylesOnCompDestroy = this.disableStylesOnCompDestroy;
const tracingService = this.tracingService;

switch (type.encapsulation) {
Expand All @@ -196,7 +196,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
sharedStylesHost,
type,
this.appId,
removeStylesOnCompDestroy,
disableStylesOnCompDestroy,
doc,
ngZone,
tracingService,
Expand Down Expand Up @@ -229,7 +229,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
eventManager,
sharedStylesHost,
type,
removeStylesOnCompDestroy,
disableStylesOnCompDestroy,
doc,
ngZone,
tracingService,
Expand All @@ -252,6 +252,10 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
* @param componentId ID of the component that is being replaced.
*/
protected componentReplaced(componentId: string) {
const renderer = this.rendererByCompId.get(componentId);
if (renderer instanceof NoneEncapsulationDomRenderer) {
renderer.removeStyles();
}
this.rendererByCompId.delete(componentId);
}
}
Expand Down Expand Up @@ -577,14 +581,14 @@ class ShadowDomRenderer extends DefaultDomRenderer2 {
}

class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
private readonly styles: string[];
private readonly styleUrls?: string[];
protected styles: string[];
private styleUrls?: string[];

constructor(
eventManager: EventManager,
private readonly sharedStylesHost: SharedStylesHost,
component: RendererType2,
private removeStylesOnCompDestroy: boolean,
private disableStylesOnCompDestroy: boolean,
doc: Document,
ngZone: NgZone,
tracingService: TracingService<TracingSnapshot> | null,
Expand All @@ -598,20 +602,31 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
styles = addBaseHrefToCssSourceMap(baseHref, styles);
}

this.styles = compId ? shimStylesContent(compId, styles) : styles;
this.styles = styles;
this.styleUrls = component.getExternalStyles?.(compId);
}

applyStyles(): void {
this.sharedStylesHost.addStyles(this.styles, this.styleUrls);
}

removeStyles(): void {
this.sharedStylesHost.removeUsagesAndElements(this.styles, this.styleUrls);
// Clear the styles so that any future `destroy()` call on this renderer is a
// no-op. This is necessary because `BaseAnimationRenderer.destroy()` defers
// `delegate.destroy()` via `afterFlushAnimationsDone` + `queueMicrotask`.
// Without this, the deferred `disableStyles()` would find the *replacement*
// component's newly-added style record (same content key) and disable it.
this.styles = [];
this.styleUrls = undefined;
}

override destroy(): void {
if (!this.removeStylesOnCompDestroy) {
if (!this.disableStylesOnCompDestroy) {
return;
}
if (allLeavingAnimations.size === 0) {
this.sharedStylesHost.removeStyles(this.styles, this.styleUrls);
this.sharedStylesHost.disableStyles(this.styles, this.styleUrls);
}
}
}
Expand All @@ -625,7 +640,7 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer {
sharedStylesHost: SharedStylesHost,
component: RendererType2,
appId: string,
removeStylesOnCompDestroy: boolean,
disableStylesOnCompDestroy: boolean,
doc: Document,
ngZone: NgZone,
tracingService: TracingService<TracingSnapshot> | null,
Expand All @@ -635,12 +650,13 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer {
eventManager,
sharedStylesHost,
component,
removeStylesOnCompDestroy,
disableStylesOnCompDestroy,
doc,
ngZone,
tracingService,
compId,
);
this.styles = shimStylesContent(compId, component.styles);
this.contentAttr = shimContentAttribute(compId);
this.hostAttr = shimHostAttribute(compId);
}
Expand Down
Loading
Loading