@@ -23,6 +23,7 @@ import {
2323 provideZoneChangeDetection ,
2424 QueryList ,
2525 SimpleChanges ,
26+ StyleRoot ,
2627 Type ,
2728 ViewChild ,
2829 ViewChildren ,
@@ -39,14 +40,20 @@ import {clearTranslations, loadTranslations} from '@angular/localize';
3940import { computeMsgId } from '@angular/compiler' ;
4041import { EVENT_MANAGER_PLUGINS } from '@angular/platform-browser' ;
4142import { ComponentType } from '../../src/render3' ;
43+ import { ɵSharedStylesHost as SharedStylesHost } from '@angular/platform-browser' ;
4244import { isNode } from '@angular/private/testing' ;
45+ import { allLeavingAnimations } from '../../src/animation/longest_animation' ;
46+ import { getLViewById } from '../../src/render3/interfaces/lview_tracking' ;
4347
4448describe ( 'hot module replacement' , ( ) => {
4549 beforeEach ( ( ) => {
4650 TestBed . configureTestingModule ( {
4751 providers : [ provideZoneChangeDetection ( ) ] ,
4852 } ) ;
53+
54+ for ( const child of document . body . childNodes ) child . remove ( ) ;
4955 } ) ;
56+
5057 it ( 'should recreate a single usage of a basic component' , ( ) => {
5158 let instance ! : ChildCmp ;
5259 const initialMetadata : Component = {
@@ -285,11 +292,11 @@ describe('hot module replacement', () => {
285292 const getShadowRoot = ( ) => fixture . nativeElement . querySelector ( 'child-cmp' ) . shadowRoot ;
286293
287294 markNodesAsCreatedInitially ( getShadowRoot ( ) ) ;
288- expectHTML ( getShadowRoot ( ) , `< style>strong {color: red;}</style>Hello <strong>0</strong >` ) ;
295+ expectHTML ( getShadowRoot ( ) , `Hello <strong>0</strong>< style>strong {color: red;}</style>` ) ;
289296
290297 instance . state = 1 ;
291298 fixture . detectChanges ( ) ;
292- expectHTML ( getShadowRoot ( ) , `< style>strong {color: red;}</style>Hello <strong>1</strong >` ) ;
299+ expectHTML ( getShadowRoot ( ) , `Hello <strong>1</strong>< style>strong {color: red;}</style>` ) ;
293300
294301 replaceMetadata ( ChildCmp , {
295302 ...initialMetadata ,
@@ -307,6 +314,110 @@ describe('hot module replacement', () => {
307314 getShadowRoot ( ) ,
308315 `<style>strong {background: pink;}</style>Changed <strong>1</strong>!` ,
309316 ) ;
317+
318+ fixture . destroy ( ) ;
319+ assertNoLeakedStyles ( TestBed . inject ( SharedStylesHost ) ) ;
320+ } ) ;
321+
322+ it ( "should replace a component child's styles within shadow DOM encapsulation" , async ( ) => {
323+ // Domino doesn't support shadow DOM.
324+ if ( isNode ) {
325+ return ;
326+ }
327+
328+ await waitForAnimations ( ) ;
329+ if ( allLeavingAnimations . size > 0 ) {
330+ console . log ( 'animations' ) ;
331+ for ( const id of allLeavingAnimations ) {
332+ const lView = getLViewById ( id ) ;
333+ console . log ( lView ) ;
334+ }
335+ }
336+
337+ const initialMetadata : Component = {
338+ selector : 'child-cmp' ,
339+ template : 'Hello <strong>World</strong>!' ,
340+ styles : `strong {color: red;}` ,
341+ encapsulation : ViewEncapsulation . None ,
342+ } ;
343+
344+ @Component ( initialMetadata )
345+ class ChildCmp { }
346+
347+ @Component ( {
348+ template : '<child-cmp/>' ,
349+ encapsulation : ViewEncapsulation . ShadowDom ,
350+ imports : [ ChildCmp ] ,
351+ } )
352+ class RootCmp { }
353+
354+ const fixture = TestBed . createComponent ( RootCmp ) ;
355+ fixture . detectChanges ( ) ;
356+ const getShadowRoot = ( ) => fixture . nativeElement . shadowRoot ;
357+
358+ expect ( getShadowRoot ( ) . innerHTML ) . toContain ( `<style>strong {color: red;}</style>` ) ;
359+
360+ replaceMetadata ( ChildCmp , {
361+ ...initialMetadata ,
362+ styles : `strong {background: pink;}` ,
363+ } ) ;
364+ fixture . detectChanges ( ) ;
365+
366+ expect ( getShadowRoot ( ) . innerHTML ) . toContain ( '<style>strong {background: pink;}</style>' ) ;
367+ expect ( getShadowRoot ( ) . innerHTML ) . not . toContain ( `<style>strong {color: red;}</style>` ) ;
368+
369+ fixture . destroy ( ) ;
370+ assertNoLeakedStyles ( TestBed . inject ( SharedStylesHost ) ) ;
371+ } ) ;
372+
373+ it ( 'should support components within nested shadow DOM' , ( ) => {
374+ // Domino doesn't support shadow DOM.
375+ if ( isNode ) {
376+ return ;
377+ }
378+
379+ @Component ( {
380+ selector : 'child-cmp' ,
381+ template : 'Hello <strong>{{state}}</strong>' ,
382+ styles : `
383+ strong {
384+ color: red;
385+ }
386+ ` ,
387+ encapsulation : ViewEncapsulation . ShadowDom ,
388+ } )
389+ class ChildCmp { }
390+
391+ const initialMetadata : Component = {
392+ template : '<child-cmp />' ,
393+ styles : `:host {color: red;}` ,
394+ encapsulation : ViewEncapsulation . ShadowDom ,
395+ imports : [ ChildCmp ] ,
396+ } ;
397+
398+ @Component ( initialMetadata )
399+ class RootCmp { }
400+
401+ const fixture = TestBed . createComponent ( RootCmp ) ;
402+ fixture . detectChanges ( ) ;
403+
404+ // Can't use `fixture.nativeElement` because the host element is recreated
405+ // during HMR and `fixture` is not updated.
406+ const getShadowRoot = ( ) => document . querySelector ( '[ng-version]' ) ! . shadowRoot ! ;
407+
408+ expect ( getShadowRoot ( ) . innerHTML ) . toContain ( `<style>:host {color: red;}</style>` ) ;
409+
410+ replaceMetadata ( RootCmp , {
411+ ...initialMetadata ,
412+ styles : `:host {background: pink;}` ,
413+ } ) ;
414+ fixture . detectChanges ( ) ;
415+
416+ expect ( getShadowRoot ( ) . innerHTML ) . toContain ( '<style>:host {background: pink;}</style>' ) ;
417+ expect ( getShadowRoot ( ) . innerHTML ) . not . toContain ( `<style>:host {color: red;}</style>` ) ;
418+
419+ fixture . destroy ( ) ;
420+ assertNoLeakedStyles ( TestBed . inject ( SharedStylesHost ) ) ;
310421 } ) ;
311422
312423 it ( 'should continue binding inputs to a component that is replaced' , ( ) => {
@@ -2173,4 +2284,33 @@ describe('hot module replacement', () => {
21732284 }
21742285 }
21752286 }
2287+
2288+ function assertNoLeakedStyles ( sharedStylesHost : SharedStylesHost ) : void {
2289+ const ssh = sharedStylesHost as unknown as Omit < SharedStylesHost , 'inline' | 'external' > & {
2290+ inline : Map < StyleRoot , unknown > ;
2291+ external : Map < StyleRoot , unknown > ;
2292+ } ;
2293+
2294+ const totalStyles = ssh . inline . size + ssh . external . size ;
2295+ if ( totalStyles > 0 ) {
2296+ throw new Error (
2297+ `Expected \`SharedStylesHost\` to have no leaked styles, found: ${ totalStyles } .` ,
2298+ ) ;
2299+ }
2300+ }
2301+ async function waitForAnimations ( ) {
2302+ const timer = timeout ( ( ) => allLeavingAnimations . size > 0 , 1_000 ) ;
2303+ while ( timer ( ) ) {
2304+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
2305+ }
2306+ }
2307+
2308+ function timeout ( predicate : ( ) => boolean , timeout : number ) : ( ) => boolean {
2309+ const start = Date . now ( ) ;
2310+ return ( ) => {
2311+ if ( ! predicate ( ) ) return false ;
2312+ if ( Date . now ( ) - start > timeout ) return false ;
2313+ return true ;
2314+ } ;
2315+ }
21762316} ) ;
0 commit comments