Skip to content
Open
Prev Previous commit
Next Next commit
fix(core): apply and remove stylesheets based on containing StyleRoot
This fixes an issue whereby bootstrapping an Angular component within a shadow root would incorrectly apply its styles to outer document `HTMLHeadElement` instead of the containing shadow root.

The main change is that applying styles can no longer be done during renderer creation as it was done previously, because at that time we don't know where the component is being rendered and therefore what `StyleRoot` should contain its styles. We must defer style application until component render / attach, because it is only then that we know the broader context of where the component exists and therefore which `StyleRoot` which hold its styles. Instead, the `applyStyles` call is moved from renderer creation to when the component renders (for basic `<app-foo />` usage in a template) xor view attach (for more complicated `@if (...) { <app-foo /> }` and similar use cases).

This is done primarily by refactoring `dom_renderer.ts` and `shared_styles_host.ts` to apply styles based on a given `StyleRoot` parameter. This allows any style to target a specific location in the DOM and align better with the Shadow DOM model where style location is important.

We actually find the correct element `StyleRoot` via `Node.prototype.getRootNode`, which returns either `document` (if the `Node` is attached to light DOM) or the containing `ShadowRoot` object (if it exists within shadow DOM). This is able to discover shadow roots outside the root node of a render tree and support a many-to-many relationship between component renders and style roots. It also optimally supports `ViewEncapsulation.ExperimentalIsolatedShadowDom` which will only add its styles to shadow roots it is rendered within and will remove those styles when all instances of that component have been detached from the shadow root. `ViewEncapsulation.ShadowDom` is left behaviorally unchanged for backwards compatibility.
  • Loading branch information
dgp1130 committed Jan 20, 2026
commit 1a644c8887fe33a9c855470bc8a8fc7048a9b72c
4 changes: 3 additions & 1 deletion goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1563,7 +1563,7 @@ export function reflectComponentType<C>(component: Type<C>): ComponentMirror<C>
export abstract class Renderer2 {
abstract addClass(el: any, name: string): void;
abstract appendChild(parent: any, newChild: any): void;
abstract applyStyles?(): void;
abstract applyStyles?(styleRoot: StyleRoot): void;
abstract createComment(value: string): any;
abstract createElement(name: string, namespace?: string | null): any;
abstract createText(value: string): any;
Expand All @@ -1580,11 +1580,13 @@ export abstract class Renderer2 {
abstract removeChild(parent: any, oldChild: any, isHostElement?: boolean, requireSynchronousElementRemoval?: boolean): void;
abstract removeClass(el: any, name: string): void;
abstract removeStyle(el: any, style: string, flags?: RendererStyleFlags2): void;
abstract removeStyles?(styleRoot: StyleRoot): void;
abstract selectRootElement(selectorOrNode: string | any, preserveContent?: boolean): any;
abstract setAttribute(el: any, name: string, value: string, namespace?: string | null): void;
abstract setProperty(el: any, name: string, value: any): void;
abstract setStyle(el: any, style: string, value: any, flags?: RendererStyleFlags2): void;
abstract setValue(node: any, value: string): void;
shadowRoot?: ShadowRoot;
}

// @public
Expand Down
11 changes: 10 additions & 1 deletion packages/animations/browser/src/render/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ export class BaseAnimationRenderer implements Renderer2 {
// See https://github.com/microsoft/rushstack/issues/4390
readonly ɵtype: AnimationRendererType.Regular = AnimationRendererType.Regular;

applyStyles: Renderer2['applyStyles'];
removeStyles: Renderer2['removeStyles'];

constructor(
protected namespaceId: string,
public delegate: Renderer2,
public engine: AnimationEngine,
private _onDestroy?: () => void,
) {}
) {
// Conditionally define styling functionality based on the delegate, so we don't
// always implement `applyStyles` / `removeStyles` but then no-op if the delegate
// doesn't support it.
this.applyStyles = delegate.applyStyles?.bind(delegate);
this.removeStyles = delegate.removeStyles?.bind(delegate);
}

get data() {
return this.delegate.data;
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/render/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,19 @@ export abstract class Renderer2 {
/** The component's internal shadow root if one is used. */
shadowRoot?: ShadowRoot;

/** Attach any required stylesheets to the DOM. */
abstract applyStyles?(): void;
/**
* Attach any required stylesheets for the associated component to the provided
* {@link StyleRoot}. This is called when the component is initially rendered
* xor attached to a view.
*/
abstract applyStyles?(styleRoot: StyleRoot): void;

/**
* Detach any required stylesheets for the associated component from the
* provided {@link StyleRoot}. This is called when the component is destroyed
* xor detached from a view.
*/
abstract removeStyles?(styleRoot: StyleRoot): void;

/**
* @internal
Expand Down
36 changes: 35 additions & 1 deletion packages/core/src/render3/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
RENDERER,
T_HOST,
TVIEW,
TViewType,
} from './interfaces/view';
import {assertTNodeType} from './node_assert';
import {destroyLView, removeViewFromDOM} from './node_manipulation';
Expand All @@ -44,6 +45,7 @@ import {
getInitialLViewFlagsFromDef,
getOrCreateComponentTView,
} from './view/construction';
import {getStyleRoot, walkDescendants} from './util/view_traversal_utils';

/** Represents `import.meta` plus some information that's not in the built-in types. */
type ImportMetaExtended = ImportMeta & {
Expand Down Expand Up @@ -241,6 +243,16 @@ function recreateLView(
ngDevMode && assertNotEqual(newDef, oldDef, 'Expected different component definition');
const zone = lView[INJECTOR].get(NgZone, null);
const recreate = () => {
// Remove old styles to make sure we drop internal references and track usage
// counts correctly. We might be an `Emulated` or `None` component inside a
// shadow root.
const oldRenderer = lView[RENDERER];
if (oldRenderer.removeStyles) {
const oldStyleRoot = getStyleRoot(lView);
ngDevMode && assertDefined(oldStyleRoot, 'oldStyleRoot');
oldRenderer.removeStyles(oldStyleRoot!);
}

// If we're recreating a component with shadow DOM encapsulation, it will have attached a
// shadow root. The browser will throw if we attempt to attach another one and there's no way
// to detach it. Our only option is to make a clone only of the root node, replace the node
Expand All @@ -249,6 +261,17 @@ function recreateLView(
oldDef.encapsulation === ViewEncapsulation.ShadowDom ||
oldDef.encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom
) {
// Remove all descendants' styles because they will be destroyed with this
// shadow root.
for (const view of walkDescendants(lView)) {
if (isLContainer(view) || view[TVIEW].type !== TViewType.Component) continue;

const styleRoot = getStyleRoot(view);
ngDevMode && assertDefined(styleRoot, 'styleRoot');
const renderer = view[RENDERER];
renderer.removeStyles?.(styleRoot!);
}

const newHost = host.cloneNode(false) as HTMLElement;
host.replaceWith(newHost);
host = newHost;
Expand Down Expand Up @@ -286,7 +309,18 @@ function recreateLView(

// Patch a brand-new renderer onto the new view only after the old
// view is destroyed so that the runtime doesn't try to reuse it.
newLView[RENDERER] = rendererFactory.createRenderer(host, newDef);
const newRenderer = rendererFactory.createRenderer(host, newDef);
newLView[RENDERER] = newRenderer;

// Reapply styles potentially with a newly created shadow root.
if (newRenderer.applyStyles) {
const newStyleRoot = getStyleRoot(newLView);
ngDevMode && assertDefined(newStyleRoot, 'newStyleRoot');
newRenderer.applyStyles(newStyleRoot!);

// Don't need to reapply styles for descendent components because
// that will be handled as part of rendering the view.
}

// Remove the nodes associated with the destroyed LView. This removes the
// descendants, but not the host which we want to stay in place.
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/render3/instructions/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {retrieveHydrationInfo} from '../../hydration/utils';
import {assertEqual, assertNotReactive} from '../../util/assert';
import {assertDefined, assertEqual, assertNotReactive} from '../../util/assert';
import {RenderFlags} from '../interfaces/definition';
import {
CONTEXT,
Expand All @@ -18,6 +18,7 @@ import {
LView,
LViewFlags,
QUERIES,
RENDERER,
TVIEW,
TView,
} from '../interfaces/view';
Expand All @@ -28,6 +29,7 @@ import {enterView, leaveView} from '../state';
import {getComponentLViewByIndex, isCreationMode} from '../util/view_utils';

import {executeTemplate} from './shared';
import {getStyleRoot} from '../util/view_traversal_utils';

export function renderComponent(hostLView: LView, componentHostIdx: number) {
ngDevMode && assertEqual(isCreationMode(hostLView), true, 'Should be run in creation mode');
Expand All @@ -45,6 +47,20 @@ export function renderComponent(hostLView: LView, componentHostIdx: number) {

try {
renderView(componentTView, componentView, componentView[CONTEXT]);

// TODO: test
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a reminder

if (hostRNode?.isConnected) {
// Element is already attached to the DOM, apply its styles immediately.
ngDevMode && assertDefined(componentView[CONTEXT], 'component instance');
const styleRoot = getStyleRoot(componentView);
ngDevMode && assertDefined(styleRoot, 'styleRoot');

const componentRenderer = componentView[RENDERER];
componentRenderer.applyStyles?.(styleRoot!);
} else {
// Element is *not* attached to the DOM, can't know where its styles should go.
// Styles will be applied when attaching the view to a container.
}
} finally {
profiler(ProfilerEvent.ComponentEnd, componentView[CONTEXT] as any as {});
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/render3/interfaces/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {RendererStyleFlags2, RendererType2} from '../../render/api_flags';
import type {ListenerOptions} from '../../render/api';
import type {ListenerOptions, StyleRoot} from '../../render/api';
import {TrustedHTML, TrustedScript, TrustedScriptURL} from '../../util/security/trusted_type_defs';

import {RComment, RElement, RNode, RText} from './renderer_dom';
Expand Down Expand Up @@ -82,7 +82,10 @@ export interface Renderer {
shadowRoot?: ShadowRoot;

/** Attach any required stylesheets to the DOM. */
applyStyles?(): void;
applyStyles?(styleRoot: StyleRoot): void;

/** Detach any stylesheets from the DOM. */
removeStyles?(styleRoot: StyleRoot): void;
}

export interface RendererFactory {
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/render3/util/view_traversal_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,28 @@ function* walkChildren(parent: LView | LContainer): Generator<LView | LContainer
}
}

/** Combine multiple iterables into a single stream with the same ordering. */
export function* concat<T>(...iterables: Array<Iterable<T>>): Iterable<T> {
for (const iterable of iterables) {
yield* iterable;
}
}

/** Returns the {@link StyleRoot} where styles for the component should be applied. */
export function getStyleRoot(lView: LView): StyleRoot | undefined {
// DOM emulation does not support shadow DOM and `Node.prototype.getRootNode`, so we
// need to feature detect and fallback even though it is already Baseline Widely
// Available. In theory, we could do this only on SSR, but Jest, Vitest, and other
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a performance concern for this fallback where we're doing an injector lookup on for every lView creation and move? Should we consider patching the domino adapter to avoid this?

// In platform-server/src/domino_adapter.ts
if (!domino.impl.Node.prototype.getRootNode) {
  domino.impl.Node.prototype.getRootNode = function() {
    let node = this;
    while (node.parentNode) {
      node = node.parentNode;
    }
    return node;
  };
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm hesitant to patch Domino ourselves for this, especially given that we need to support JSDom for Jest too, so we can't really assume we're on Domino. I guess the patch is the same either way, but I'd rather not add a polyfill for this.

Can you help me understand the performance implications of an injector lookup in this context? Would that really be meaningfully slower than walking the DOM tree up to the root?

There's potentially a path to storing the Document used during creation from DomRendererFactory2 and reusing it for moves / destroys to skip repeated injections, but that would probably involve storing the document on the LView directly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you help me understand the performance implications of an injector lookup in this context? Would that really be meaningfully slower than walking the DOM tree up to the root?

I really don't know, to be honest...

// Node testing solutions lack DOM emulation as well.
Comment thread
dgp1130 marked this conversation as resolved.
if (!Node.prototype.getRootNode) {
const injector = lView[INJECTOR];
const doc = injector.get(DOCUMENT);
return doc;
// TODO: Can't use injector during destroy because it is destroyed before the
// component. Is it ok to depend on the `document` global? If not, might need to
// change the contract of `getStyleRoot` and inject `DOCUMENT` prior to
// destruction.
// const injector = lView[INJECTOR];
// const doc = injector.get(DOCUMENT);

return document;
}

const renderer = lView[RENDERER];
Expand Down
35 changes: 34 additions & 1 deletion packages/core/src/render3/view/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import {addToArray, removeFromArray} from '../../util/array_utils';
import {assertDefined, assertEqual} from '../../util/assert';
import {assertLContainer, assertLView} from '../assert';
import {isComponentInstance} from '../context_discovery';
import {
CONTAINER_HEADER_OFFSET,
LContainer,
Expand All @@ -18,11 +19,13 @@ import {
} from '../interfaces/container';
import {TNode} from '../interfaces/node';
import {RComment, RElement} from '../interfaces/renderer_dom';
import {isLView} from '../interfaces/type_checks';
import {isLContainer, isLView} from '../interfaces/type_checks';
import {
CONTEXT,
DECLARATION_COMPONENT_VIEW,
DECLARATION_LCONTAINER,
FLAGS,
HOST,
HYDRATION,
LView,
LViewFlags,
Expand All @@ -33,6 +36,7 @@ import {
T_HOST,
TView,
TVIEW,
TViewType,
} from '../interfaces/view';
import {
addViewToDOM,
Expand All @@ -41,6 +45,7 @@ import {
getBeforeNodeForView,
removeViewFromDOM,
} from '../node_manipulation';
import {concat, getStyleRoot, walkDescendants} from '../util/view_traversal_utils';
import {updateAncestorTraversalFlagsOnAttach} from '../util/view_utils';

/**
Expand Down Expand Up @@ -113,6 +118,20 @@ export function addLViewToLContainer(
const parentRNode = renderer.parentNode(lContainer[NATIVE] as RElement | RComment);
if (parentRNode !== null) {
addViewToDOM(tView, lContainer[T_HOST], renderer, lView, parentRNode, beforeNode);

if (parentRNode.isConnected) {
Comment thread
dgp1130 marked this conversation as resolved.
for (const view of concat([lView], walkDescendants(lView))) {
if (isLContainer(view) || view[TVIEW].type !== TViewType.Component) continue;

// Element is already attached to the DOM, apply its styles immediately.
const componentRenderer = view[RENDERER];
if (componentRenderer.applyStyles && isComponentInstance(view[CONTEXT])) {
const styleRoot = getStyleRoot(view);
ngDevMode && assertDefined(styleRoot, 'styleRoot');
componentRenderer.applyStyles(styleRoot!);
}
}
}
}
}

Expand Down Expand Up @@ -161,9 +180,23 @@ export function detachView(lContainer: LContainer, removeIndex: number): LView |
if (removeIndex > 0) {
lContainer[indexInContainer - 1][NEXT] = viewToDetach[NEXT] as LView;
}

const removedLView = removeFromArray(lContainer, CONTAINER_HEADER_OFFSET + removeIndex);
removeViewFromDOM(viewToDetach[TVIEW], viewToDetach);

for (const view of concat([viewToDetach], walkDescendants(viewToDetach))) {
if (isLContainer(view) || view[TVIEW].type !== TViewType.Component) continue;

const hostRNode = view[HOST];
const renderer = view[RENDERER];
if (hostRNode && renderer?.removeStyles && isComponentInstance(view[CONTEXT])) {
// Component might already have been detached and removed from the DOM if it was manually destroyed
// while present in a `ViewContainerRef`.
const styleRoot = getStyleRoot(view);
if (styleRoot) renderer.removeStyles(styleRoot);
}
}

// notify query that a view has been removed
const lQueries = removedLView[QUERIES];
if (lQueries !== null) {
Expand Down
29 changes: 26 additions & 3 deletions packages/core/src/render3/view_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import {
LView,
LViewFlags,
PARENT,
RENDERER,
TVIEW,
TViewType,
} from './interfaces/view';
import {destroyLView, detachMovedView, detachViewFromDOM} from './node_manipulation';
import {
Expand All @@ -41,6 +43,7 @@ import {
requiresRefreshOrTraversal,
} from './util/view_utils';
import {detachView, trackMovedView} from './view/container';
import {concat, getStyleRoot, walkDescendants} from './util/view_traversal_utils';

// Needed due to tsickle downleveling where multiple `implements` with classes creates
// multiple @extends in Closure annotations, which is illegal. This workaround fixes
Expand Down Expand Up @@ -107,9 +110,8 @@ export class ViewRef<T> implements EmbeddedViewRef<T>, ChangeDetectorRefInterfac
}

destroy(): void {
if (this._appRef) {
this._appRef.detachView(this);
} else if (this._attachedToViewContainer) {
let attached = true;
if (!this._appRef && this._attachedToViewContainer) {
const parent = this._lView[PARENT];
if (isLContainer(parent)) {
const viewRefs = parent[VIEW_REFS] as ViewRef<unknown>[] | null;
Expand All @@ -121,12 +123,33 @@ export class ViewRef<T> implements EmbeddedViewRef<T>, ChangeDetectorRefInterfac
parent.indexOf(this._lView) - CONTAINER_HEADER_OFFSET,
'An attached view should be in the same position within its container as its ViewRef in the VIEW_REFS array.',
);
// Implicitly removes styles as part of detaching the view.
detachView(parent, index);
removeFromArray(viewRefs!, index);
attached = false;
}
}
this._attachedToViewContainer = false;
}

if (attached) {
for (const view of concat([this._lView], walkDescendants(this._lView))) {
if (isLContainer(view) || view[TVIEW].type !== TViewType.Component) continue;

// Component might already be destroyed in cases like `NgModuleRef.prototype.destroy`
// being called before `ComponentRef.prototype.destroy`.
if (isDestroyed(view)) continue;

const renderer = view[RENDERER];
const styleRoot = getStyleRoot(view);

// Component might already be detached prior to destroy and have had its styles removed previously.
if (styleRoot) renderer.removeStyles?.(styleRoot!);
}
}

if (this._appRef) this._appRef.detachView(this);

destroyLView(this._lView[TVIEW], this._lView);
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/acceptance/view_container_ref_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,7 @@ describe('ViewContainerRef', () => {
{provide: ErrorHandler, useValue: TestBed.inject(ErrorHandler)},
{provide: RendererFactory2, useValue: TestBed.inject(RendererFactory2)},
{provide: ANIMATION_QUEUE, useValue: TestBed.inject(ANIMATION_QUEUE)},
{provide: DOCUMENT, useValue: TestBed.inject(DOCUMENT)},
],
})
class MyAppModule {}
Expand Down
Loading