From 8258e33d2dc284a06b14ac6d4615c1bfe4642331 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Wed, 28 Jan 2026 10:49:30 -0800 Subject: [PATCH 01/11] refactor(core): move `SharedStylesHost` interface into `@angular/core` with a dedicated `InjectionToken` This allows code in `@angular/core` to inject and use `SharedStylesHost`, even though the implementation is defined in `@angular/platform-browser`. --- packages/core/src/core_private_export.ts | 4 ++ .../render3/interfaces/shared_styles_host.ts | 47 +++++++++++++++++++ .../bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 1 + .../bundling/defer/bundle.golden_symbols.json | 1 + .../forms_reactive/bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 1 + .../hydration/bundle.golden_symbols.json | 1 + .../router/bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 1 + packages/platform-browser/src/browser.ts | 5 +- .../platform-browser/src/dom/dom_renderer.ts | 3 +- .../src/dom/shared_styles_host.ts | 13 +---- 13 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/render3/interfaces/shared_styles_host.ts diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 2a0a0341a989..45af6a261586 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -61,6 +61,10 @@ export { DeferBlockState as ɵDeferBlockState, } from './defer/interfaces'; export {getDocument as ɵgetDocument} from './render3/interfaces/document'; +export { + SHARED_STYLES_HOST as ɵSHARED_STYLES_HOST, + SharedStylesHost as ɵSharedStylesHost, +} from './render3/interfaces/shared_styles_host'; export { convertToBitFlags as ɵconvertToBitFlags, setCurrentInjector as ɵsetCurrentInjector, diff --git a/packages/core/src/render3/interfaces/shared_styles_host.ts b/packages/core/src/render3/interfaces/shared_styles_host.ts new file mode 100644 index 000000000000..11734f9e0ec6 --- /dev/null +++ b/packages/core/src/render3/interfaces/shared_styles_host.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {InjectionToken} from '../../di/injection_token'; + +/** Token used to retrieve the `SharedStylesHost`. */ +export const SHARED_STYLES_HOST = new InjectionToken( + typeof ngDevMode !== 'undefined' && ngDevMode ? 'SHARED_STYLES_HOST' : '', +); + +/** Manages stylesheets for components in the application. */ +export interface SharedStylesHost { + /** + * Adds embedded styles to the DOM via HTML `style` elements. + * @param styles An array of style content strings. + * @param urls An array of URLs to be added as link tags. + */ + addStyles(styles: string[], urls?: string[]): void; + + /** + * Removes embedded styles from the DOM that were added as HTML `style` elements. + * @param styles An array of style content strings. + * @param urls An array of URLs to be removed as link tags. + */ + removeStyles(styles: string[], urls?: string[]): void; + + /** + * Adds a host node to contain styles added to the DOM and adds all existing style usage to + * the newly added host node. + * + * @param hostNode The node to contain styles added to the DOM. + */ + addHost(hostNode: Node): void; + + /** + * Removes a host node from the set of style hosts and removes all existing style usage from + * the removed host node. + * + * @param hostNode The node to remove from the set of style hosts. + */ + removeHost(hostNode: Node): void; +} diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index c1a1f6ab3059..e925864f2b77 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -226,6 +226,7 @@ "SELF_TOKEN", "SELF_TOKEN_REGEX", "SHARED_ANIMATION_PROVIDERS", + "SHARED_STYLES_HOST", "SIGNAL", "SIMPLE_CHANGES_STORE", "STABILITY_WARNING_THRESHOLD", diff --git a/packages/core/test/bundling/create_component/bundle.golden_symbols.json b/packages/core/test/bundling/create_component/bundle.golden_symbols.json index c8d0d1914985..ee756affe27d 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -173,6 +173,7 @@ "RuntimeError", "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", + "SHARED_STYLES_HOST", "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 4f7adb930611..567c6ef13df5 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -216,6 +216,7 @@ "RuntimeError", "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", + "SHARED_STYLES_HOST", "SIGNAL", "SIMPLE_CHANGES_STORE", "SSR_BLOCK_STATE", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index c4c6b9c925bf..aadc0056b185 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -238,6 +238,7 @@ "RuntimeError", "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", + "SHARED_STYLES_HOST", "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 7cc6fa65bc78..09986fe0a4ef 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -230,6 +230,7 @@ "RuntimeError", "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", + "SHARED_STYLES_HOST", "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 4fc63f0fad7f..96d4bd07c481 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -199,6 +199,7 @@ "RuntimeError", "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", + "SHARED_STYLES_HOST", "SIGNAL", "SIMPLE_CHANGES_STORE", "SKIP_HYDRATION_ATTR_NAME", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 6f8ac5430720..1c6c42b6dd6b 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -276,6 +276,7 @@ "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", "SEGMENT_RE", + "SHARED_STYLES_HOST", "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 693a6a0e967d..411cde5008af 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -165,6 +165,7 @@ "RuntimeError", "SCHEDULE_IN_ROOT_ZONE", "SCHEDULE_IN_ROOT_ZONE_DEFAULT", + "SHARED_STYLES_HOST", "SIGNAL", "SIMPLE_CHANGES_STORE", "STABILITY_WARNING_THRESHOLD", diff --git a/packages/platform-browser/src/browser.ts b/packages/platform-browser/src/browser.ts index d38a7465ec99..c362100f623f 100644 --- a/packages/platform-browser/src/browser.ts +++ b/packages/platform-browser/src/browser.ts @@ -37,6 +37,7 @@ import { ɵTESTABILITY_GETTER as TESTABILITY_GETTER, inject, ɵresolveComponentResources as resolveComponentResources, + ɵSHARED_STYLES_HOST as SHARED_STYLES_HOST, } from '@angular/core'; import {BrowserDomAdapter} from './browser/browser_adapter'; @@ -266,7 +267,9 @@ const BROWSER_MODULE_PROVIDERS: Provider[] = [ }, {provide: EVENT_MANAGER_PLUGINS, useClass: KeyEventsPlugin, multi: true}, DomRendererFactory2, - SharedStylesHost, + {provide: SHARED_STYLES_HOST, useClass: SharedStylesHost}, + // Only remains for backwards compatibility, should be removed once g3 no longer needs it. + {provide: SharedStylesHost, useExisting: SHARED_STYLES_HOST}, EventManager, {provide: RendererFactory2, useExisting: DomRendererFactory2}, {provide: XhrFactory, useClass: BrowserXhr}, diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts index 87b51bc0e260..c4463bc6a7da 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -26,6 +26,7 @@ import { ɵTracingSnapshot as TracingSnapshot, Optional, ɵallLeavingAnimations as allLeavingAnimations, + ɵSHARED_STYLES_HOST as SHARED_STYLES_HOST, } from '@angular/core'; import {RuntimeErrorCode} from '../errors'; @@ -137,7 +138,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { constructor( private readonly eventManager: EventManager, - private readonly sharedStylesHost: SharedStylesHost, + @Inject(SHARED_STYLES_HOST) private readonly sharedStylesHost: SharedStylesHost, @Inject(APP_ID) private readonly appId: string, @Inject(REMOVE_STYLES_ON_COMPONENT_DESTROY) private removeStylesOnCompDestroy: boolean, @Inject(DOCUMENT) private readonly doc: Document, diff --git a/packages/platform-browser/src/dom/shared_styles_host.ts b/packages/platform-browser/src/dom/shared_styles_host.ts index 19101a833f20..f467470cea13 100644 --- a/packages/platform-browser/src/dom/shared_styles_host.ts +++ b/packages/platform-browser/src/dom/shared_styles_host.ts @@ -15,6 +15,7 @@ import { OnDestroy, Optional, PLATFORM_ID, + ɵSharedStylesHost, } from '@angular/core'; /** The style elements attribute name used to set value of `APP_ID` token. */ @@ -102,7 +103,7 @@ export function createLinkElement(url: string, doc: Document): HTMLLinkElement { } @Injectable() -export class SharedStylesHost implements OnDestroy { +export class SharedStylesHost implements ɵSharedStylesHost, OnDestroy { /** * Provides usage information for active inline style content and associated HTML '); }); + it('should track the same host added multiple times', () => { + // Add same host twice. + ssh.addHost(someHost); + ssh.addHost(someHost); + + // Styles are added to host. + ssh.addStyles(['a {};']); + expect(someHost.innerHTML).toContain(''); + + // Only remove host once, styles should continue to be added. + ssh.removeHost(someHost); + ssh.addStyles(['b {};']); + expect(someHost.innerHTML).toContain(''); + + // Remove host second time, new styles are no longer added. + ssh.removeHost(someHost); + ssh.addStyles(['c {};']); + expect(someHost.innerHTML).not.toContain(''); + }); + it('should add styles only once to hosts', () => { ssh.addStyles(['a {};']); ssh.addHost(someHost); @@ -88,6 +108,26 @@ describe('SharedStylesHost', () => { expect(someHost.innerHTML).toEqual(''); }); + it('should track the same host added multiple times', () => { + // Add same host twice. + ssh.addHost(someHost); + ssh.addHost(someHost); + + // Styles are added to host. + ssh.addStyles([], ['component-1.css']); + expect(someHost.innerHTML).toContain(''); + + // Only remove host once, styles should continue to be added. + ssh.removeHost(someHost); + ssh.addStyles([], ['component-2.css']); + expect(someHost.innerHTML).toContain(''); + + // Remove host second time, new styles are no longer added. + ssh.removeHost(someHost); + ssh.addStyles([], ['component-3.css']); + expect(someHost.innerHTML).not.toContain(''); + }); + it('should add styles only once to hosts', () => { ssh.addStyles([], ['component-1.css']); ssh.addHost(someHost); From 294d57886dfa92774673c8083bc7796ffa0656d4 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 26 Feb 2026 15:30:37 -0800 Subject: [PATCH 04/11] refactor(core): add `MockSharedStylesHost` This is useful for testing that `addHost` / `removeHost` were called the correct number of times to ensure hosts are not leaking. --- .../core/test/mock_shared_styles_host_spec.ts | 44 +++++++++++++++++++ .../testing/src/mock_shared_styles_host.ts | 40 +++++++++++++++++ .../testing/src/testing_private_export.ts | 1 + 3 files changed, 85 insertions(+) create mode 100644 packages/core/test/mock_shared_styles_host_spec.ts create mode 100644 packages/core/testing/src/mock_shared_styles_host.ts diff --git a/packages/core/test/mock_shared_styles_host_spec.ts b/packages/core/test/mock_shared_styles_host_spec.ts new file mode 100644 index 000000000000..e06b4939cabf --- /dev/null +++ b/packages/core/test/mock_shared_styles_host_spec.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ɵMockSharedStylesHost as MockSharedStylesHost} from '@angular/core/testing'; + +describe('MockSharedStylesHost', () => { + it('should track active hosts added', () => { + const host = new MockSharedStylesHost(); + const nodeA = document.createElement('div'); + const nodeB = document.createElement('span'); + + host.addHost(nodeA); + expect(host.getActiveHosts()).toEqual([nodeA]); + + host.addHost(nodeB); + expect(host.getActiveHosts()).toEqual([nodeA, nodeB]); + + host.removeHost(nodeA); + expect(host.getActiveHosts()).toEqual([nodeB]); + + host.removeHost(nodeB); + expect(host.getActiveHosts()).toEqual([]); + }); + + it('should store multiple references of same host', () => { + const host = new MockSharedStylesHost(); + const node = document.createElement('div'); + + host.addHost(node); + host.addHost(node); + expect(host.getActiveHosts()).toEqual([node, node]); + + host.removeHost(node); + expect(host.getActiveHosts()).toEqual([node]); + + host.removeHost(node); + expect(host.getActiveHosts()).toEqual([]); + }); +}); diff --git a/packages/core/testing/src/mock_shared_styles_host.ts b/packages/core/testing/src/mock_shared_styles_host.ts new file mode 100644 index 000000000000..6e9dc5b62e99 --- /dev/null +++ b/packages/core/testing/src/mock_shared_styles_host.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {SharedStylesHost} from '../../src/render3/interfaces/shared_styles_host'; + +/** + * Mock implementation of {@link SharedStylesHost} for ensuring usage of the + * class is correct. + */ +export class MockSharedStylesHost implements SharedStylesHost { + // Track as an array because the same host might be added multiple times and + // should be correctly removed as many times. + private readonly hosts: Node[] = []; + + addStyles(_styles: string[]): void {} + removeStyles(_styles: string[]): void {} + + addHost(hostNode: Node): void { + this.hosts.push(hostNode); + } + + removeHost(hostNode: Node): void { + const index = this.hosts.indexOf(hostNode); + if (index > -1) { + this.hosts.splice(index, 1); + } else { + throw new Error('Host not found'); + } + } + + getActiveHosts(): Node[] { + // Defensive copy to prevent mutation of internal state. + return Array.from(this.hosts); + } +} diff --git a/packages/core/testing/src/testing_private_export.ts b/packages/core/testing/src/testing_private_export.ts index 6674987d4d01..93baef9b28c5 100644 --- a/packages/core/testing/src/testing_private_export.ts +++ b/packages/core/testing/src/testing_private_export.ts @@ -7,4 +7,5 @@ */ export {FakeNavigation as ɵFakeNavigation} from '../../primitives/dom-navigation/testing'; +export {MockSharedStylesHost as ɵMockSharedStylesHost} from './mock_shared_styles_host'; export {getCleanupHook as ɵgetCleanupHook} from './test_hooks'; From a1ba01e852c49828f21853a08b36364fcf89f43d Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Tue, 24 Mar 2026 17:28:06 -0700 Subject: [PATCH 05/11] fix(core): ensure `ViewRef.prototype.destroy` is idempotent Prior to this change, `ViewRef.prototype.destroy` did not short-circuit if it had already been called. This led to a serious issue in certain dynamic component setups, particularly with `@angular/cdk/portal`. When the CDK attaches a `ComponentPortal`, it explicitly registers a disposal function that calls `ComponentRef.prototype.destroy()`. If the portal outlet itself is destroyed as part of a classic Angular teardown sequence, the native framework teardown will destroy the portal`s parent `ViewContainerRef`, subsequently destroying its child views. However, moments later, the CDK`s `ngOnDestroy` hook executes and forcibly repeats the process by triggering `ComponentRef.prototype.destroy()`. Without idempotency, this second destroy invocation cascaded down into the `LView` cleanup processes (`destroyLView`) again, which led to duplicate style unregistration via `SharedStylesHost.removeHost`. If a host had already been unregistered, `SharedStylesHost` threw errors or suffered internal tracking corruption. Adding `if (this.destroyed) return;` acts as a necessary guarantee that views, and by extension style hosts, are only cleaned up precisely once, preserving cross-layer stability. --- packages/core/src/render3/view_ref.ts | 2 ++ .../core/test/acceptance/view_ref_spec.ts | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/core/src/render3/view_ref.ts b/packages/core/src/render3/view_ref.ts index f29f27ed81c6..813728b800ff 100644 --- a/packages/core/src/render3/view_ref.ts +++ b/packages/core/src/render3/view_ref.ts @@ -102,6 +102,8 @@ export class ViewRef implements EmbeddedViewRef, ChangeDetectorRef { } destroy(): void { + if (this.destroyed) return; + if (this._appRef) { this._appRef.detachView(this); } else if (this._attachedToViewContainer) { diff --git a/packages/core/test/acceptance/view_ref_spec.ts b/packages/core/test/acceptance/view_ref_spec.ts index 26fecf3ee738..007a746bef7b 100644 --- a/packages/core/test/acceptance/view_ref_spec.ts +++ b/packages/core/test/acceptance/view_ref_spec.ts @@ -164,4 +164,29 @@ describe('ViewRef', () => { componentRef.hostView.destroy(); expect(viewRef.destroyed).toBe(true); }); + + it('should be safe to call `destroy` multiple times', () => { + let ngOnDestroyCount = 0; + + @Component({ + template: '', + standalone: true, + }) + class App { + ngOnDestroy() { + ngOnDestroyCount++; + } + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + // Simulate double-destroy scenario (e.g., framework teardown racing with manual teardown like CDK Portal) + expect(() => { + fixture.componentRef.destroy(); + fixture.componentRef.destroy(); + }).not.toThrow(); + + expect(ngOnDestroyCount).toBe(1); + }); }); From cf94a8ee3e7650c868ffce73a5f25c21beb5d5de Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Wed, 28 Jan 2026 14:13:27 -0800 Subject: [PATCH 06/11] feat(core): support bootstrapping Angular components underneath shadow roots This updates component creation and view attach/detach to identify any containing shadow root and apply required styles to it. Managing this during attach / detach onto a view container seems like the ideal timing for what we need, since a node might theoretically be moved from one shadow root to another post-render. Awkwardly, `ViewEncapsulation.ShadowDom` / `ViewEncapsulation.IsolatedShadowDom` manage tracking of hosts already directly in `dom_renderer.ts`, and components should put their own styles in their own shadow root, not a containing shadow root. So we need to leave this alone for now and only modify the other `ViewEncapsulation` modes. A future refactor should consider unifying all `ViewEncapsulation` modes into a single code path. This is quite inefficient as it adds all styles to all shadow roots, which is excessive and unnecessary. However precisely adding only the required styles is a more complicated refactor. I initially tried to call `SharedStylesHost` via dependency injection, but found that injecting during component destruction does not work reliably, as the injector may already be destroyed. Instead, `SharedStylesHost` is tracked in the `LViewEnvironment` which works well as a single object reference shared amongst all components anyways. --- packages/core/src/render3/component_ref.ts | 33 +++++++ packages/core/src/render3/interfaces/view.ts | 10 ++ packages/core/src/render3/view/container.ts | 23 +++++ .../acceptance/view_container_ref_spec.ts | 14 ++- .../bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 1 + .../bundling/defer/bundle.golden_symbols.json | 1 + .../forms_reactive/bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 1 + .../router/bundle.golden_symbols.json | 1 + .../bundle.golden_symbols.json | 1 + .../core/test/render3/component_ref_spec.ts | 93 ++++++++++++++++++- packages/core/test/render3/di_spec.ts | 2 + .../test/render3/instructions/shared_spec.ts | 2 + packages/core/test/render3/view_fixture.ts | 2 + packages/core/test/test_bed_spec.ts | 6 +- .../src/dom/shared_styles_host.ts | 1 - .../test/dom/shared_styles_host_spec.ts | 6 +- 18 files changed, 187 insertions(+), 12 deletions(-) diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index e77c67a4039c..d60e044d082c 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import {DOCUMENT} from '../document'; import {setActiveConsumer} from '../../primitives/signals'; import {ChangeDetectorRef} from '../change_detection/change_detector_ref'; @@ -17,6 +18,7 @@ import {Injector} from '../di/injector'; import {EnvironmentInjector} from '../di/r3_injector'; import {RuntimeError, RuntimeErrorCode} from '../errors'; import {Type} from '../interface/type'; +import {ViewEncapsulation} from '../metadata/view'; import { ComponentFactory as AbstractComponentFactory, ComponentRef as AbstractComponentRef, @@ -59,7 +61,11 @@ import { TView, TVIEW, TViewType, + ENVIRONMENT, + ON_DESTROY_HOOKS, } from './interfaces/view'; +import {SHARED_STYLES_HOST} from './interfaces/shared_styles_host'; +import {getDocument} from './interfaces/document'; import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; import {retrieveHydrationInfo} from '../hydration/utils'; @@ -178,12 +184,15 @@ function createRootLViewEnvironment(rootLViewInjector: Injector): LViewEnvironme ngReflect = rootLViewInjector.get(NG_REFLECT_ATTRS_FLAG, NG_REFLECT_ATTRS_FLAG_DEFAULT); } + const sharedStylesHost = rootLViewInjector.get(SHARED_STYLES_HOST, null); + return { rendererFactory, sanitizer, changeDetectionScheduler, ngReflect, tracingService, + sharedStylesHost, }; } @@ -338,6 +347,30 @@ export class ComponentFactory extends AbstractComponentFactory { rootLView[HEADER_OFFSET] = hostElement; + // Determine if the component itself uses Shadow DOM, as these components will track their own + // shadow roots when they are created. + const usesShadowDom = + (cmpDef.encapsulation === ViewEncapsulation.ShadowDom || + cmpDef.encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom) && + !(typeof ngServerMode !== 'undefined' && ngServerMode); + if (!usesShadowDom) { + const sharedStylesHost = rootLView[ENVIRONMENT].sharedStylesHost; + if (sharedStylesHost) { + // Check the root node (containing shadow root or document) and provide stylesheets there. + // If the created component is detached from the document, this will be `null` and stylesheets + // will be tracked once the component is attached to the document. + const rootNode = hostElement.getRootNode?.(); + const isShadowRoot = + rootNode && typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot; + const styleHost = isShadowRoot ? rootNode : getDocument().head; + + sharedStylesHost.addHost(styleHost); + (rootLView[ON_DESTROY_HOOKS] ??= []).push( + () => void sharedStylesHost.removeHost(styleHost), + ); + } + } + // rootView is the parent when bootstrapping // TODO(misko): it looks like we are entering view here but we don't really need to as // `renderView` does that. However as the code is written it is needed because diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 6b603c9b51aa..0360d52bb04d 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -35,6 +35,7 @@ import {TConstants, TNode} from './node'; import type {LQueries, TQueries} from './query'; import {Renderer, RendererFactory} from './renderer'; import {RElement} from './renderer_dom'; +import {SharedStylesHost} from './shared_styles_host'; import {TStylingKey, TStylingRange} from './styling'; // Below are constants for LView indices to help us look up LView members @@ -396,6 +397,15 @@ export interface LViewEnvironment { * (always disabled in prod mode). */ ngReflect: boolean; + + /** + * `SharedStylesHost` for managing shared styles within the application. + * + * This property is optional because Angular's core rendering engine is platform-agnostic. + * Custom platforms (e.g., rendering to a terminal or canvas) that do not integrate with + * HTML stylesheets may choose not to provide a `SharedStylesHost`. + */ + sharedStylesHost: SharedStylesHost | null; } /** Flags associated with an LView (saved in LView[FLAGS]) */ diff --git a/packages/core/src/render3/view/container.ts b/packages/core/src/render3/view/container.ts index e8a9e70898f6..81195105d798 100644 --- a/packages/core/src/render3/view/container.ts +++ b/packages/core/src/render3/view/container.ts @@ -33,7 +33,10 @@ import { T_HOST, TView, TVIEW, + HOST, + ENVIRONMENT, } from '../interfaces/view'; +import {getDocument} from '../interfaces/document'; import { addViewToDOM, destroyLView, @@ -116,6 +119,16 @@ export function addLViewToLContainer( } } + // To ensure styles are placed on a parent shadow root, we need to register it as a host. + const sharedStylesHost = lView[ENVIRONMENT].sharedStylesHost; + if (sharedStylesHost) { + const host = lView[HOST] ?? lContainer[NATIVE]; + const rootNode = host.getRootNode?.(); + const isShadowRoot = + rootNode && typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot; + sharedStylesHost.addHost(isShadowRoot ? rootNode : getDocument().head); + } + // When in hydration mode, reset the pointer to the first child in // the dehydrated view. This indicates that the view was hydrated and // further attaching/detaching should work with this view as normal. @@ -153,6 +166,16 @@ export function detachView(lContainer: LContainer, removeIndex: number): LView | const viewToDetach = lContainer[indexInContainer]; if (viewToDetach) { + // Undo the `SharedStylesHost` registration. + const sharedStylesHost = viewToDetach[ENVIRONMENT].sharedStylesHost; + if (sharedStylesHost) { + const host = viewToDetach[HOST] ?? lContainer[NATIVE]; + const rootNode = host?.getRootNode?.(); + const isShadowRoot = + rootNode && typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot; + sharedStylesHost.removeHost(isShadowRoot ? rootNode : getDocument().head); + } + const declarationLContainer = viewToDetach[DECLARATION_LCONTAINER]; if (declarationLContainer !== null && declarationLContainer !== lContainer) { detachMovedView(declarationLContainer, viewToDetach); diff --git a/packages/core/test/acceptance/view_container_ref_spec.ts b/packages/core/test/acceptance/view_container_ref_spec.ts index c24b4383196d..eaa9135c61d4 100644 --- a/packages/core/test/acceptance/view_container_ref_spec.ts +++ b/packages/core/test/acceptance/view_container_ref_spec.ts @@ -41,6 +41,7 @@ import { RendererFactory2, RendererType2, Sanitizer, + ɵSHARED_STYLES_HOST as SHARED_STYLES_HOST, signal, TemplateRef, ViewChild, @@ -50,6 +51,7 @@ import { ChangeDetectionStrategy, } from '../../src/core'; import {ComponentFixture, TestBed, TestComponentRenderer} from '../../testing'; +import {MockSharedStylesHost} from '../../testing/src/mock_shared_styles_host'; describe('ViewContainerRef', () => { /** @@ -1462,6 +1464,8 @@ describe('ViewContainerRef', () => { declarations: [EmbeddedViewInsertionComp, VCRefDirective, EmbeddedComponent], }); + const mockSharedStylesHost = new MockSharedStylesHost(); + @NgModule({ providers: [ {provide: String, useValue: 'root_module'}, @@ -1471,11 +1475,19 @@ describe('ViewContainerRef', () => { {provide: ErrorHandler, useValue: TestBed.inject(ErrorHandler)}, {provide: RendererFactory2, useValue: TestBed.inject(RendererFactory2)}, {provide: ANIMATION_QUEUE, useValue: TestBed.inject(ANIMATION_QUEUE)}, + {provide: SHARED_STYLES_HOST, useValue: mockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, ], }) class MyAppModule {} - @NgModule({providers: [{provide: String, useValue: 'some_module'}]}) + @NgModule({ + providers: [ + {provide: String, useValue: 'some_module'}, + {provide: SHARED_STYLES_HOST, useValue: mockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, + ], + }) class SomeModule {} // Compile test modules in order to be able to pass the NgModuleRef or the diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index e925864f2b77..ea039cfb0ebf 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -505,6 +505,7 @@ "getDOM", "getDeclarationTNode", "getDirectiveDef", + "getDocument", "getElementDepthCount", "getFactoryDef", "getFirstLContainer", diff --git a/packages/core/test/bundling/create_component/bundle.golden_symbols.json b/packages/core/test/bundling/create_component/bundle.golden_symbols.json index ee756affe27d..19e7f4124818 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -408,6 +408,7 @@ "getDOM", "getDeclarationTNode", "getDirectiveDef", + "getDocument", "getFactoryDef", "getFirstLContainer", "getFirstNativeNode", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 567c6ef13df5..628f68bb6123 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -451,6 +451,7 @@ "getDeclarationTNode", "getDeferBlockDataIndex", "getDirectiveDef", + "getDocument", "getElementDepthCount", "getFactoryDef", "getFirstLContainer", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index aadc0056b185..bd24baf522fb 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -594,6 +594,7 @@ "getDOM", "getDeclarationTNode", "getDirectiveDef", + "getDocument", "getElementDepthCount", "getFactoryDef", "getFactoryOf", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 09986fe0a4ef..3f2047200d33 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -590,6 +590,7 @@ "getDOM", "getDeclarationTNode", "getDirectiveDef", + "getDocument", "getElementDepthCount", "getFactoryDef", "getFactoryOf", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 1c6c42b6dd6b..d33daa555ff9 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -669,6 +669,7 @@ "getDataKeys", "getDeclarationTNode", "getDirectiveDef", + "getDocument", "getElementDepthCount", "getFactoryDef", "getFactoryOf", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 411cde5008af..137ba8f8bba4 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -376,6 +376,7 @@ "getDOM", "getDeclarationTNode", "getDirectiveDef", + "getDocument", "getFactoryDef", "getFirstLContainer", "getGlobalLocale", diff --git a/packages/core/test/render3/component_ref_spec.ts b/packages/core/test/render3/component_ref_spec.ts index db2dd2764065..13242993d4f7 100644 --- a/packages/core/test/render3/component_ref_spec.ts +++ b/packages/core/test/render3/component_ref_spec.ts @@ -9,12 +9,14 @@ import {ComponentFactoryResolver} from '../../src/render3/component_ref'; import {Renderer} from '../../src/render3/interfaces/renderer'; import {RElement} from '../../src/render3/interfaces/renderer_dom'; +import {SHARED_STYLES_HOST} from '../../src/render3/interfaces/shared_styles_host'; import {TestBed} from '../../testing'; import { ComponentRef, ChangeDetectionStrategy, Component, + DOCUMENT, Injector, Input, NgModuleRef, @@ -31,6 +33,8 @@ import {RendererFactory2} from '../../src/render/api'; import {Sanitizer} from '../../src/sanitization/sanitizer'; import {MockRendererFactory} from './instructions/mock_renderer_factory'; +import {MockSharedStylesHost} from '../../testing/src/mock_shared_styles_host'; +import {isNode} from '@angular/private/testing'; const THROWING_RENDERER_FACTOR2_PROVIDER = { provide: RendererFactory2, @@ -124,7 +128,11 @@ describe('ComponentFactory', () => { describe('(when `ngModuleRef` is not provided)', () => { it('should retrieve `RendererFactory2` from the specified injector', () => { const injector = Injector.create({ - providers: [{provide: RendererFactory2, useValue: rendererFactorySpy}], + providers: [ + {provide: RendererFactory2, useValue: rendererFactorySpy}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, + ], }); cf.create(injector); @@ -138,6 +146,8 @@ describe('ComponentFactory', () => { providers: [ {provide: RendererFactory2, useValue: rendererFactorySpy}, {provide: Sanitizer, useFactory: sanitizerFactorySpy, deps: []}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, ], }); @@ -150,7 +160,11 @@ describe('ComponentFactory', () => { describe('(when `ngModuleRef` is provided)', () => { it('should retrieve `RendererFactory2` from the specified injector first', () => { const injector = Injector.create({ - providers: [{provide: RendererFactory2, useValue: rendererFactorySpy}], + providers: [ + {provide: RendererFactory2, useValue: rendererFactorySpy}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, + ], }); const mInjector = Injector.create({providers: [THROWING_RENDERER_FACTOR2_PROVIDER]}); @@ -162,7 +176,11 @@ describe('ComponentFactory', () => { it('should retrieve `RendererFactory2` from the `ngModuleRef` if not provided by the injector', () => { const injector = Injector.create({providers: []}); const mInjector = Injector.create({ - providers: [{provide: RendererFactory2, useValue: rendererFactorySpy}], + providers: [ + {provide: RendererFactory2, useValue: rendererFactorySpy}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, + ], }); cf.create(injector, undefined, undefined, {injector: mInjector} as NgModuleRef); @@ -185,6 +203,8 @@ describe('ComponentFactory', () => { providers: [ {provide: RendererFactory2, useValue: rendererFactorySpy}, {provide: Sanitizer, useFactory: mSanitizerFactorySpy, deps: []}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, ], }); @@ -204,6 +224,8 @@ describe('ComponentFactory', () => { providers: [ {provide: RendererFactory2, useValue: rendererFactorySpy}, {provide: Sanitizer, useFactory: mSanitizerFactorySpy, deps: []}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, ], }); @@ -216,7 +238,11 @@ describe('ComponentFactory', () => { describe('(when the factory is bound to a `ngModuleRef`)', () => { it('should retrieve `RendererFactory2` from the specified injector first', () => { const injector = Injector.create({ - providers: [{provide: RendererFactory2, useValue: rendererFactorySpy}], + providers: [ + {provide: RendererFactory2, useValue: rendererFactorySpy}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, + ], }); (cf as any).ngModule = { injector: Injector.create({providers: [THROWING_RENDERER_FACTOR2_PROVIDER]}), @@ -231,7 +257,11 @@ describe('ComponentFactory', () => { const injector = Injector.create({providers: []}); (cf as any).ngModule = { injector: Injector.create({ - providers: [{provide: RendererFactory2, useValue: rendererFactorySpy}], + providers: [ + {provide: RendererFactory2, useValue: rendererFactorySpy}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, + ], }), }; @@ -248,6 +278,8 @@ describe('ComponentFactory', () => { providers: [ {provide: RendererFactory2, useValue: rendererFactorySpy}, {provide: Sanitizer, useFactory: iSanitizerFactorySpy, deps: []}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, ], }); @@ -277,6 +309,8 @@ describe('ComponentFactory', () => { providers: [ {provide: RendererFactory2, useValue: rendererFactorySpy}, {provide: Sanitizer, useFactory: mSanitizerFactorySpy, deps: []}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, ], }), }; @@ -303,6 +337,8 @@ describe('ComponentFactory', () => { const injector = Injector.create({ providers: [ {provide: RendererFactory2, useFactory: () => new TestMockRendererFactory(), deps: []}, + {provide: SHARED_STYLES_HOST, useClass: MockSharedStylesHost}, + {provide: DOCUMENT, useValue: document}, ], }); @@ -310,6 +346,53 @@ describe('ComponentFactory', () => { const componentRef = cf.create(injector, undefined, hostNode); expect(hostNode.className).toEqual('HOST_COMPONENT HOST_RENDERER'); }); + + it('should add styles to / remove styles from document.head when the host is not in a shadow root', () => { + const sharedStylesHost = new MockSharedStylesHost(); + const injector = Injector.create({ + providers: [ + {provide: RendererFactory2, useValue: rendererFactorySpy}, + {provide: SHARED_STYLES_HOST, useValue: sharedStylesHost}, + {provide: DOCUMENT, useValue: document}, + ], + }); + + const hostNode = document.createElement('div'); + const componentRef = cf.create(injector, /* projectableNodes */ undefined, hostNode); + + expect(sharedStylesHost.getActiveHosts()).toEqual([document.head]); + + componentRef.destroy(); + + expect(sharedStylesHost.getActiveHosts()).toEqual([]); + }); + + it('should add styles to / remove styles from the shadow root when the host is in a shadow root', () => { + if (isNode) return; + + const sharedStylesHost = new MockSharedStylesHost(); + + const injector = Injector.create({ + providers: [ + {provide: RendererFactory2, useValue: rendererFactorySpy}, + {provide: SHARED_STYLES_HOST, useValue: sharedStylesHost}, + {provide: DOCUMENT, useValue: document}, + ], + }); + + const parentNode = document.createElement('div'); + const shadowRoot = parentNode.attachShadow({mode: 'open'}); + const hostNode = document.createElement('div'); + shadowRoot.appendChild(hostNode); + + const componentRef = cf.create(injector, /* projectableNodes */ undefined, hostNode); + + expect(sharedStylesHost.getActiveHosts()).toEqual([shadowRoot]); + + componentRef.destroy(); + + expect(sharedStylesHost.getActiveHosts()).toEqual([]); + }); }); describe('setInput', () => { diff --git a/packages/core/test/render3/di_spec.ts b/packages/core/test/render3/di_spec.ts index 1b395d55cc2f..60b615310abd 100644 --- a/packages/core/test/render3/di_spec.ts +++ b/packages/core/test/render3/di_spec.ts @@ -22,6 +22,7 @@ import {HEADER_OFFSET, LViewFlags, TVIEW, TViewType} from '../../src/render3/int import {enterView, leaveView} from '../../src/render3/state'; import {getOrCreateTNode} from '../../src/render3/tnode_manipulation'; import {createLView, createTView} from '../../src/render3/view/construction'; +import {ɵMockSharedStylesHost as MockSharedStylesHost} from '@angular/core/testing'; describe('di', () => { describe('directive injection', () => { @@ -156,6 +157,7 @@ describe('di', () => { changeDetectionScheduler: null, ngReflect: false, tracingService: null, + sharedStylesHost: new MockSharedStylesHost(), }, {} as any, null, diff --git a/packages/core/test/render3/instructions/shared_spec.ts b/packages/core/test/render3/instructions/shared_spec.ts index cfcbd5b92d7a..957b5243338f 100644 --- a/packages/core/test/render3/instructions/shared_spec.ts +++ b/packages/core/test/render3/instructions/shared_spec.ts @@ -19,6 +19,7 @@ import { import {MockRendererFactory} from './mock_renderer_factory'; import {createTNode} from '../../../src/render3/tnode_manipulation'; import {createLView, createTView} from '../../../src/render3/view/construction'; +import {ɵMockSharedStylesHost as MockSharedStylesHost} from '@angular/core/testing'; /** * Setups a simple `LView` so that it is possible to do unit tests on instructions. @@ -71,6 +72,7 @@ export function enterViewWithOneDiv() { changeDetectionScheduler: null, ngReflect: false, tracingService: null, + sharedStylesHost: new MockSharedStylesHost(), }, renderer, null, diff --git a/packages/core/test/render3/view_fixture.ts b/packages/core/test/render3/view_fixture.ts index 2ec77dc8f929..a4e2eaf220a7 100644 --- a/packages/core/test/render3/view_fixture.ts +++ b/packages/core/test/render3/view_fixture.ts @@ -35,6 +35,7 @@ import {noop} from '../../src/util/noop'; import {getRendererFactory2} from './imported_renderer2'; import {createTNode} from '../../src/render3/tnode_manipulation'; import {createLView, createTView} from '../../src/render3/view/construction'; +import {ɵMockSharedStylesHost as MockSharedStylesHost} from '@angular/core/testing'; /** * Fixture useful for testing operations which need `LView` / `TView` @@ -118,6 +119,7 @@ export class ViewFixture { changeDetectionScheduler: null, ngReflect: false, tracingService: null, + sharedStylesHost: new MockSharedStylesHost(), }, hostRenderer, null, diff --git a/packages/core/test/test_bed_spec.ts b/packages/core/test/test_bed_spec.ts index daf24fc341ec..04d4d8211b71 100644 --- a/packages/core/test/test_bed_spec.ts +++ b/packages/core/test/test_bed_spec.ts @@ -56,6 +56,7 @@ import {DeferBlockBehavior} from '../testing'; import {TestBed, TestBedImpl} from '../testing/src/test_bed'; import {NgModuleType} from '../src/render3'; +import {getDocument} from '../src/render3/interfaces/document'; import {depsTracker} from '../src/render3/deps_tracker/deps_tracker'; import {setClassMetadataAsync} from '../src/render3/metadata'; import { @@ -2706,8 +2707,7 @@ describe('TestBed module teardown', () => { }); const fixtures = [TestBed.createComponent(StyledComp1), TestBed.createComponent(StyledComp2)]; - const fixtureDocument = fixtures[0].nativeElement.ownerDocument; - const styleCountBefore = fixtureDocument.querySelectorAll('style').length; + const styleCountBefore = getDocument().querySelectorAll('style').length; // Note that we can only assert that the behavior works as expected by checking that the // number of stylesheets has decreased. We can't expect that they'll be zero, because there @@ -2715,7 +2715,7 @@ describe('TestBed module teardown', () => { // behavior. expect(styleCountBefore).toBeGreaterThan(0); TestBed.resetTestingModule(); - expect(fixtureDocument.querySelectorAll('style').length).toBeLessThan(styleCountBefore); + expect(getDocument().querySelectorAll('style').length).toBeLessThan(styleCountBefore); }); it('should rethrow errors based on the default teardown behavior', () => { diff --git a/packages/platform-browser/src/dom/shared_styles_host.ts b/packages/platform-browser/src/dom/shared_styles_host.ts index dae527a18e97..a8e7bffc2aef 100644 --- a/packages/platform-browser/src/dom/shared_styles_host.ts +++ b/packages/platform-browser/src/dom/shared_styles_host.ts @@ -130,7 +130,6 @@ export class SharedStylesHost implements ɵSharedStylesHost, OnDestroy { @Inject(PLATFORM_ID) platformId: object = {}, ) { addServerStyles(doc, appId, this.inline, this.external); - this.hosts.set(doc.head, 1); } addStyles(styles: string[], urls?: string[]): void { diff --git a/packages/platform-browser/test/dom/shared_styles_host_spec.ts b/packages/platform-browser/test/dom/shared_styles_host_spec.ts index 663d3727eaed..52fa40783a96 100644 --- a/packages/platform-browser/test/dom/shared_styles_host_spec.ts +++ b/packages/platform-browser/test/dom/shared_styles_host_spec.ts @@ -61,7 +61,8 @@ describe('SharedStylesHost', () => { expect(someHost.innerHTML).toEqual(''); }); - it('should use the document head as default host', () => { + it('should allow adding the document head as a host', () => { + ssh.addHost(doc.head); ssh.addStyles(['a {};', 'b {};']); expect(doc.head).toHaveText('a {};b {};'); }); @@ -135,7 +136,8 @@ describe('SharedStylesHost', () => { expect(someHost.innerHTML).toEqual(''); }); - it('should use the document head as default host', () => { + it('should allow adding the document head as a host', () => { + ssh.addHost(doc.head); ssh.addStyles([], ['component-1.css', 'component-2.css']); expect(doc.head.innerHTML).toContain(''); expect(doc.head.innerHTML).toContain(''); From 1ccb813c45922d14b81c4d192d525c40ff84291b Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 19 Mar 2026 16:38:02 -0700 Subject: [PATCH 07/11] refactor(platform-browser): avoid `SharedStylesHost` modifications after destruction Since `SharedStylesHost` can receive `addHost` and `removeHost` calls after it is destroyed in testing environments (e.g., when fakeAsync triggers CD after teardown), we need to safely no-op these operations to avoid errors. --- .../src/dom/shared_styles_host.ts | 16 ++++++++++++++++ .../test/dom/shared_styles_host_spec.ts | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/platform-browser/src/dom/shared_styles_host.ts b/packages/platform-browser/src/dom/shared_styles_host.ts index a8e7bffc2aef..ad8a9c6ea6f9 100644 --- a/packages/platform-browser/src/dom/shared_styles_host.ts +++ b/packages/platform-browser/src/dom/shared_styles_host.ts @@ -121,6 +121,9 @@ export class SharedStylesHost implements ɵSharedStylesHost, OnDestroy { */ private readonly hosts = new Map(); + /** Whether this instance has been destroyed. */ + private destroyed = false; + constructor( @Inject(DOCUMENT) private readonly doc: Document, @Inject(APP_ID) private readonly appId: string, @@ -202,9 +205,16 @@ export class SharedStylesHost implements ɵSharedStylesHost, OnDestroy { removeElements(elements); } this.hosts.clear(); + this.destroyed = true; } addHost(hostNode: Node): void { + // Adding a host after destruction will have no effect and is likely a bug in the caller. + // However, some testing scenarios with fake async appear to trigger CD after `TestBed` + // teardown, meaning Angular may render new components (and then immediately destroy them) + // after the application is destroyed, so we have to allow this to happen and no-op. + if (this.destroyed) return; + const existingUsage = this.hosts.get(hostNode) ?? 0; if (existingUsage === 0) { // Add existing styles to new host @@ -220,6 +230,12 @@ export class SharedStylesHost implements ɵSharedStylesHost, OnDestroy { } removeHost(hostNode: Node): void { + // In some scenarios (such as an explicit `ApplicationRef.prototype.destroy` call), + // this instance's `ngOnDestroy` method may be called before a component is destroyed and + // attempts to remove its own styles. In this case, we need to no-op to avoid throwing an + // error. + if (this.destroyed) return; + const usage = this.hosts.get(hostNode); if (typeof ngDevMode !== 'undefined' && ngDevMode && usage === undefined) { throw new Error('Attempted to remove a host which was not added.'); diff --git a/packages/platform-browser/test/dom/shared_styles_host_spec.ts b/packages/platform-browser/test/dom/shared_styles_host_spec.ts index 52fa40783a96..df04bbbb4158 100644 --- a/packages/platform-browser/test/dom/shared_styles_host_spec.ts +++ b/packages/platform-browser/test/dom/shared_styles_host_spec.ts @@ -184,4 +184,21 @@ describe('SharedStylesHost', () => { expect(doc.head.innerHTML).not.toContain('ng-app-id'); }); }); + + describe('destroy', () => { + it('should ignore `addHost` and `removeHost` calls after destruction', () => { + ssh.addStyles(['a {};']); + ssh.addHost(someHost); + expect(someHost.innerHTML).toEqual(''); + + ssh.ngOnDestroy(); + expect(someHost.innerHTML).toEqual(''); + + // These should be no-ops instead of crashing or modifying things. + expect(() => void ssh.addHost(someHost)).not.toThrow(); + expect(someHost.innerHTML).toEqual(''); + expect(() => void ssh.removeHost(someHost)).not.toThrow(); + expect(someHost.innerHTML).toEqual(''); + }); + }); }); From 16c49c9222a7a6d62208d7932f7dc964d9eeb84a Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 19 Mar 2026 16:37:17 -0700 Subject: [PATCH 08/11] refactor(core): preserve `document` context via `fallbackDocument` in `LViewEnvironment` Prior to this change, when style encapsulation fell back from Shadow DOM, it defaulted to the global `document.head` via `getDocument()`. This was problematic for mocked or isolated environments (such as unit tests) where a custom `DOCUMENT` token is provided, but `setDocument` is not called. Because `SharedStylesHost` relies on the node it is passed, using the global `document.head` in a mocked environment could lead to cross-document DOM insertion errors or just failing to style the component correctly. This commit introduces `fallbackDocument` to `LViewEnvironment`. It is captured at bootstrap in `createRootLViewEnvironment` directly from the `DOCUMENT` injection token, since we can't reliably inject during component destruction, falling back to `getDocument()`. This ensures that the correct document context is plumbed through the view layer, guaranteeing stable style injection inside the expected document environment without cross-document pollution. --- packages/core/src/render3/component_ref.ts | 4 +++- packages/core/src/render3/interfaces/view.ts | 10 ++++++++ packages/core/src/render3/view/container.ts | 22 ++++++++++------- .../core/test/render3/component_ref_spec.ts | 24 +++++++++++++++++++ packages/core/test/render3/di_spec.ts | 1 + .../test/render3/instructions/shared_spec.ts | 1 + packages/core/test/render3/view_fixture.ts | 1 + 7 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index d60e044d082c..b70477406512 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -185,6 +185,7 @@ function createRootLViewEnvironment(rootLViewInjector: Injector): LViewEnvironme } const sharedStylesHost = rootLViewInjector.get(SHARED_STYLES_HOST, null); + const fallbackDocument = rootLViewInjector.get(DOCUMENT, getDocument()); return { rendererFactory, @@ -193,6 +194,7 @@ function createRootLViewEnvironment(rootLViewInjector: Injector): LViewEnvironme ngReflect, tracingService, sharedStylesHost, + fallbackDocument, }; } @@ -362,7 +364,7 @@ export class ComponentFactory extends AbstractComponentFactory { const rootNode = hostElement.getRootNode?.(); const isShadowRoot = rootNode && typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot; - const styleHost = isShadowRoot ? rootNode : getDocument().head; + const styleHost = isShadowRoot ? rootNode : rootLView[ENVIRONMENT].fallbackDocument.head; sharedStylesHost.addHost(styleHost); (rootLView[ON_DESTROY_HOOKS] ??= []).push( diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 0360d52bb04d..983bdb30d6d7 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -406,6 +406,16 @@ export interface LViewEnvironment { * HTML stylesheets may choose not to provide a `SharedStylesHost`. */ sharedStylesHost: SharedStylesHost | null; + + /** + * The fallback document used as the style host when a specific host (like a `ShadowRoot`) + * is not applicable. + * + * This node is captured at bootstrap to ensure document context parity. This is particularly + * important in environments with mocked documents (like unit tests), which provide the + * `DOCUMENT` token but don't call `setDocument`. + */ + fallbackDocument: Document; } /** Flags associated with an LView (saved in LView[FLAGS]) */ diff --git a/packages/core/src/render3/view/container.ts b/packages/core/src/render3/view/container.ts index 81195105d798..5344be6bbb98 100644 --- a/packages/core/src/render3/view/container.ts +++ b/packages/core/src/render3/view/container.ts @@ -126,7 +126,7 @@ export function addLViewToLContainer( const rootNode = host.getRootNode?.(); const isShadowRoot = rootNode && typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot; - sharedStylesHost.addHost(isShadowRoot ? rootNode : getDocument().head); + sharedStylesHost.addHost(isShadowRoot ? rootNode : lView[ENVIRONMENT].fallbackDocument.head); } // When in hydration mode, reset the pointer to the first child in @@ -166,14 +166,18 @@ export function detachView(lContainer: LContainer, removeIndex: number): LView | const viewToDetach = lContainer[indexInContainer]; if (viewToDetach) { - // Undo the `SharedStylesHost` registration. - const sharedStylesHost = viewToDetach[ENVIRONMENT].sharedStylesHost; - if (sharedStylesHost) { - const host = viewToDetach[HOST] ?? lContainer[NATIVE]; - const rootNode = host?.getRootNode?.(); - const isShadowRoot = - rootNode && typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot; - sharedStylesHost.removeHost(isShadowRoot ? rootNode : getDocument().head); + const host = viewToDetach[HOST] ?? lContainer[NATIVE]; + if (host.isConnected) { + const sharedStylesHost = viewToDetach[ENVIRONMENT].sharedStylesHost; + if (sharedStylesHost) { + // Undo the `SharedStylesHost` registration. + const rootNode = host.getRootNode?.(); + const isShadowRoot = + rootNode && typeof ShadowRoot !== 'undefined' && rootNode instanceof ShadowRoot; + sharedStylesHost.removeHost( + isShadowRoot ? rootNode : viewToDetach[ENVIRONMENT].fallbackDocument.head, + ); + } } const declarationLContainer = viewToDetach[DECLARATION_LCONTAINER]; diff --git a/packages/core/test/render3/component_ref_spec.ts b/packages/core/test/render3/component_ref_spec.ts index 13242993d4f7..263a2e22ce6e 100644 --- a/packages/core/test/render3/component_ref_spec.ts +++ b/packages/core/test/render3/component_ref_spec.ts @@ -390,7 +390,31 @@ describe('ComponentFactory', () => { expect(sharedStylesHost.getActiveHosts()).toEqual([shadowRoot]); componentRef.destroy(); + expect(sharedStylesHost.getActiveHosts()).toEqual([]); + }); + it('should use the head of the provided DOCUMENT as the style host (Document context parity)', () => { + const mockHead = document.createElement('head'); + const mockDocument = { + head: mockHead, + createElement: (tag: string) => document.createElement(tag), + }; + const sharedStylesHost = new MockSharedStylesHost(); + const injector = Injector.create({ + providers: [ + {provide: RendererFactory2, useValue: rendererFactorySpy}, + {provide: SHARED_STYLES_HOST, useValue: sharedStylesHost}, + {provide: DOCUMENT, useValue: mockDocument}, + ], + }); + + const hostNode = document.createElement('div'); + const componentRef = cf.create(injector, /* projectableNodes */ undefined, hostNode); + + // Verify it used the mock head, not the global document.head + expect(sharedStylesHost.getActiveHosts()).toEqual([mockHead]); + + componentRef.destroy(); expect(sharedStylesHost.getActiveHosts()).toEqual([]); }); }); diff --git a/packages/core/test/render3/di_spec.ts b/packages/core/test/render3/di_spec.ts index 60b615310abd..09797f518707 100644 --- a/packages/core/test/render3/di_spec.ts +++ b/packages/core/test/render3/di_spec.ts @@ -158,6 +158,7 @@ describe('di', () => { ngReflect: false, tracingService: null, sharedStylesHost: new MockSharedStylesHost(), + fallbackDocument: document, }, {} as any, null, diff --git a/packages/core/test/render3/instructions/shared_spec.ts b/packages/core/test/render3/instructions/shared_spec.ts index 957b5243338f..c29d393cbbf3 100644 --- a/packages/core/test/render3/instructions/shared_spec.ts +++ b/packages/core/test/render3/instructions/shared_spec.ts @@ -73,6 +73,7 @@ export function enterViewWithOneDiv() { ngReflect: false, tracingService: null, sharedStylesHost: new MockSharedStylesHost(), + fallbackDocument: document, }, renderer, null, diff --git a/packages/core/test/render3/view_fixture.ts b/packages/core/test/render3/view_fixture.ts index a4e2eaf220a7..e50a47ff4b85 100644 --- a/packages/core/test/render3/view_fixture.ts +++ b/packages/core/test/render3/view_fixture.ts @@ -120,6 +120,7 @@ export class ViewFixture { ngReflect: false, tracingService: null, sharedStylesHost: new MockSharedStylesHost(), + fallbackDocument: document, }, hostRenderer, null, From a89baf0aae2f0a8ec74d79accec91b21d9d724e7 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Tue, 24 Mar 2026 17:51:13 -0700 Subject: [PATCH 09/11] refactor(platform-browser): avoid duplicate styles when re-adding a host in `SharedStylesHost` `SharedStylesHost` has a known bug where removing hosts does not remove styles from the DOM and fixing that would be a breaking change. However, this leads to a separate bug where removing a style down to zero usages and then adding an extra one, could duplicate styles on the page. This commit ensures styles are only appended if they aren't already present on the parent node from a previously leaked style. --- .../src/dom/shared_styles_host.ts | 12 ++++++-- .../test/dom/shared_styles_host_spec.ts | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/platform-browser/src/dom/shared_styles_host.ts b/packages/platform-browser/src/dom/shared_styles_host.ts index ad8a9c6ea6f9..a0fa883a947d 100644 --- a/packages/platform-browser/src/dom/shared_styles_host.ts +++ b/packages/platform-browser/src/dom/shared_styles_host.ts @@ -219,10 +219,18 @@ export class SharedStylesHost implements ɵSharedStylesHost, OnDestroy { if (existingUsage === 0) { // Add existing styles to new host for (const [style, {elements}] of this.inline) { - elements.push(this.addElement(hostNode, createStyleElement(style, this.doc))); + // `removeHost` currently does not actually remove styles when usage drops to zero. + // Therefore removing a host to zero and then re-adding to one, could cause Angular + // to duplicate the styles on the page. This check makes sure we don't add the styles + // more than once. + if (!elements.some((e) => e.parentNode === hostNode)) { + elements.push(this.addElement(hostNode, createStyleElement(style, this.doc))); + } } for (const [url, {elements}] of this.external) { - elements.push(this.addElement(hostNode, createLinkElement(url, this.doc))); + if (!elements.some((e) => e.parentNode === hostNode)) { + elements.push(this.addElement(hostNode, createLinkElement(url, this.doc))); + } } } diff --git a/packages/platform-browser/test/dom/shared_styles_host_spec.ts b/packages/platform-browser/test/dom/shared_styles_host_spec.ts index df04bbbb4158..27493912c08a 100644 --- a/packages/platform-browser/test/dom/shared_styles_host_spec.ts +++ b/packages/platform-browser/test/dom/shared_styles_host_spec.ts @@ -54,6 +54,20 @@ describe('SharedStylesHost', () => { expect(someHost.innerHTML).not.toContain(''); }); + it('should not duplicate styles when removing *all hosts* and then re-adding a pre-existing host', () => { + ssh.addHost(someHost); + ssh.addStyles(['a {};']); + expect(someHost.innerHTML).toEqual(''); + + // Reproduce undesirable, but known leak. + ssh.removeHost(someHost); + expect(someHost.innerHTML).toEqual(''); + + // Should not duplicate. + ssh.addHost(someHost); + expect(someHost.innerHTML).toEqual(''); + }); + it('should add styles only once to hosts', () => { ssh.addStyles(['a {};']); ssh.addHost(someHost); @@ -129,6 +143,20 @@ describe('SharedStylesHost', () => { expect(someHost.innerHTML).not.toContain(''); }); + it('should not duplicate styles when removing and re-adding a host', () => { + ssh.addStyles([], ['component-1.css']); + ssh.addHost(someHost); + expect(someHost.innerHTML).toEqual(''); + + // Reproduce undesirable, but known leak. + ssh.removeHost(someHost); + expect(someHost.innerHTML).toEqual(''); + + // Should not duplicate + ssh.addHost(someHost); + expect(someHost.innerHTML).toEqual(''); + }); + it('should add styles only once to hosts', () => { ssh.addStyles([], ['component-1.css']); ssh.addHost(someHost); From 5b5a4f0406f345c04936d22c980878ca4a1c0f57 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Tue, 27 Jan 2026 16:03:17 -0800 Subject: [PATCH 10/11] test: verify that Angular supports bootstrapping under shadow roots This tests bootstrapping Angular underneath a shadow root and that styles are applied and removed at the correct locations. --- .../test/dom/shadow_dom_spec.ts | 208 +++++++++++++++++- 1 file changed, 207 insertions(+), 1 deletion(-) diff --git a/packages/platform-browser/test/dom/shadow_dom_spec.ts b/packages/platform-browser/test/dom/shadow_dom_spec.ts index e04ebeae06c6..de8518aa2d8d 100644 --- a/packages/platform-browser/test/dom/shadow_dom_spec.ts +++ b/packages/platform-browser/test/dom/shadow_dom_spec.ts @@ -8,7 +8,7 @@ import {Component, NgModule, ViewEncapsulation} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {BrowserModule} from '../../index'; +import {BrowserModule, createApplication} from '../../index'; import {expect} from '@angular/private/testing/matchers'; import {isNode} from '@angular/private/testing'; @@ -23,6 +23,10 @@ describe('ShadowDOM Support', () => { TestBed.configureTestingModule({imports: [TestModule]}); }); + beforeEach(() => { + for (const node of Array.from(document.body.childNodes)) node.remove(); + }); + it('should attach and use a shadowRoot when ViewEncapsulation.ShadowDom is set', () => { const compEl = TestBed.createComponent(ShadowComponent).nativeElement; expect(compEl.shadowRoot!.textContent).toEqual('Hello World'); @@ -81,6 +85,208 @@ describe('ShadowDOM Support', () => { expect(articleContent.assignedSlot).toBe(articleSlot); expect(articleSubcontent.assignedSlot).toBe(articleSlot); }); + + it('should support bootstrapping under a shadow root', async () => { + @Component({ + selector: 'app-root', + template: '
Hello, World!
', + styles: ` + div { + color: red; + } + `, + }) + class Root {} + + const container = document.createElement('div'); + container.attachShadow({mode: 'open'}); + const root = document.createElement('app-root'); + container.shadowRoot!.append(root); + document.body.append(container); + + const appRef = await createApplication(); + appRef.bootstrap(Root, root); + + expect(getComputedStyle(root.querySelector('div')!).color).toBe('rgb(255, 0, 0)'); + + expect(document.head.innerHTML).not.toContain('