Skip to content

Commit 394a683

Browse files
crisbetopkozlowski-opensource
authored andcommitted
fix(core): handle shadow DOM encapsulated component with HMR (angular#59597)
When a component is created with shadow DOM encapsulation, we attach a shadow root to it. When the component is re-created during HMR, it was throwing an error because only one shadow root can be attached to a node at a time. Since there's no way to detach a shadow root from a node, these changes resolve the issue by making a shallow clone of the element, replacing it and using the clone for any future updates. Fixes angular#59588. PR Close angular#59597
1 parent 30c4404 commit 394a683

File tree

2 files changed

+73
-2
lines changed

2 files changed

+73
-2
lines changed

packages/core/src/render3/hmr.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {assertTNodeType} from './node_assert';
4444
import {destroyLView, removeViewFromDOM} from './node_manipulation';
4545
import {RendererFactory} from './interfaces/renderer';
4646
import {NgZone} from '../zone';
47+
import {ViewEncapsulation} from '../metadata/view';
4748

4849
/**
4950
* Replaces the metadata of a component type and re-renders all live instances of the component.
@@ -149,7 +150,7 @@ function recreateLView(
149150
lView: LView<unknown>,
150151
): void {
151152
const instance = lView[CONTEXT];
152-
const host = lView[HOST]!;
153+
let host = lView[HOST]! as HTMLElement;
153154
// In theory the parent can also be an LContainer, but it appears like that's
154155
// only the case for embedded views which we won't be replacing here.
155156
const parentLView = lView[PARENT] as LView;
@@ -159,6 +160,16 @@ function recreateLView(
159160
ngDevMode && assertNotEqual(newDef, oldDef, 'Expected different component definition');
160161
const zone = lView[INJECTOR].get(NgZone, null);
161162
const recreate = () => {
163+
// If we're recreating a component with shadow DOM encapsulation, it will have attached a
164+
// shadow root. The browser will throw if we attempt to attach another one and there's no way
165+
// to detach it. Our only option is to make a clone only of the root node, replace the node
166+
// with the clone and use it for the newly-created LView.
167+
if (oldDef.encapsulation === ViewEncapsulation.ShadowDom) {
168+
const newHost = host.cloneNode(false) as HTMLElement;
169+
host.replaceWith(newHost);
170+
host = newHost;
171+
}
172+
162173
// Recreate the TView since the template might've changed.
163174
const newTView = getOrCreateComponentTView(newDef);
164175

packages/core/test/acceptance/hmr_spec.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
ViewChild,
2727
ViewChildren,
2828
ViewContainerRef,
29+
ViewEncapsulation,
2930
ɵNG_COMP_DEF,
3031
ɵɵreplaceMetadata,
3132
} from '@angular/core';
@@ -248,6 +249,65 @@ describe('hot module replacement', () => {
248249
);
249250
});
250251

252+
it('should replace a component using shadow DOM encapsulation', () => {
253+
// Domino doesn't support shadow DOM.
254+
if (isNode) {
255+
return;
256+
}
257+
258+
let instance!: ChildCmp;
259+
const initialMetadata: Component = {
260+
encapsulation: ViewEncapsulation.ShadowDom,
261+
selector: 'child-cmp',
262+
template: 'Hello <strong>{{state}}</strong>',
263+
styles: `strong {color: red;}`,
264+
};
265+
266+
@Component(initialMetadata)
267+
class ChildCmp {
268+
state = 0;
269+
270+
constructor() {
271+
instance = this;
272+
}
273+
}
274+
275+
@Component({
276+
standalone: true,
277+
imports: [ChildCmp],
278+
template: '<child-cmp/>',
279+
})
280+
class RootCmp {}
281+
282+
const fixture = TestBed.createComponent(RootCmp);
283+
fixture.detectChanges();
284+
const getShadowRoot = () => fixture.nativeElement.querySelector('child-cmp').shadowRoot;
285+
286+
markNodesAsCreatedInitially(getShadowRoot());
287+
expectHTML(getShadowRoot(), `<style>strong {color: red;}</style>Hello <strong>0</strong>`);
288+
289+
instance.state = 1;
290+
fixture.detectChanges();
291+
expectHTML(getShadowRoot(), `<style>strong {color: red;}</style>Hello <strong>1</strong>`);
292+
293+
replaceMetadata(ChildCmp, {
294+
...initialMetadata,
295+
template: `Changed <strong>{{state}}</strong>!`,
296+
styles: `strong {background: pink;}`,
297+
});
298+
fixture.detectChanges();
299+
300+
verifyNodesWereRecreated([
301+
fixture.nativeElement.querySelector('child-cmp'),
302+
...childrenOf(getShadowRoot()),
303+
]);
304+
305+
expectHTML(
306+
getShadowRoot(),
307+
`<style>strong {background: pink;}</style>Changed <strong>1</strong>!`,
308+
);
309+
});
310+
251311
it('should continue binding inputs to a component that is replaced', () => {
252312
const initialMetadata: Component = {
253313
selector: 'child-cmp',
@@ -2071,7 +2131,7 @@ describe('hot module replacement', () => {
20712131
function expectHTML(element: HTMLElement, expectation: string) {
20722132
const actual = element.innerHTML
20732133
.replace(/<!--(\W|\w)*?-->/g, '')
2074-
.replace(/\sng-reflect-\S*="[^"]*"/g, '');
2134+
.replace(/\s(ng-reflect|_nghost|_ngcontent)-\S*="[^"]*"/g, '');
20752135
expect(actual.replace(/\s/g, '') === expectation.replace(/\s/g, ''))
20762136
.withContext(`HTML does not match expectation. Actual HTML:\n${actual}`)
20772137
.toBe(true);

0 commit comments

Comments
 (0)