Skip to content

Conversation

@dgp1130
Copy link
Contributor

@dgp1130 dgp1130 commented Dec 13, 2025

(I'm still working through a handful of edge cases and weird tests, but think this is in a good enough state for a first look at least.)

This PR allows Angular components to be bootstrapped inside of a shadow root and properly apply their styles to that shadow root instead of the document <head>. As a side-effect, it also lands some incidental improvements to ViewEncapsulation.IsolatedShadowDom related to #63131. This PR involves practically rewriting the way style management is done in Angular.

The early commits are just refactoring some internals and setting up the right utilities and types required. The final test in the last commit is the ultimate goal of this PR.

The main fix commit is the real work. It moves applyStyles from happening at renderer creation to instead happening a component render / attach to container. This is necessary, because the correct place to apply styles is now dependent on where a component is rendered. If it is within a shadow root, its styles must go on that shadow root. Since styling is now location-dependent, this requires more work and bookkeeping than before.

Based on some discussions with @AndrewKushnir, we believe the right time to do this is during component render xor attach to view container. Since a component could theoretically be moved from one container to another or rendered in a detached state (via createComponent) and then attached to a specific container later.

We achieve this by introducing a new concept, StyleRoot which represents "a place where styles can be applied". Effectively just Document | ShadowRoot (meaning "put styles in the <head> tag" or "put styles in this ShadowRoot". We now track styles based on specific StyleRoot objects, with an approach based on the old reference counting system. We have to break the rules to maintain legacy shadow root behavior unfortunately, but it's the best we can do. We use Node.prototype.getRootNode to determine whether or not a particular component is within a ShadowRoot. We could use some framework-internal data structures, but ultimately we need to support shadow roots outside the root component anyways and unknown to Angular, so we cannot expect to rely on framework data structures to solve this entire problem.

H/T @ryan-bendel who's been exploring many of these same ideas in #63131 and helped inform a lot of my thinking of the right way to architect shared_styles_host.ts and dom_renderer.ts.

@dgp1130 dgp1130 added action: review The PR is still awaiting reviews from at least one requested reviewer area: core Issues related to the framework runtime core: stylesheets target: minor This PR is targeted for the next minor release labels Dec 13, 2025
@ngbot ngbot bot added this to the Backlog milestone Dec 13, 2025

it('should support content projection', fakeAsync(() => {
// TODO
xit('should support content projection', fakeAsync(() => {
Copy link
Member

@JeanMeche JeanMeche Dec 14, 2025

Choose a reason for hiding this comment

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

I noticed that the issue is related to isConnected being false on the animated component. Same with the broken test below. (cc @thePunderWoman)

Copy link
Contributor

Choose a reason for hiding this comment

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

@dgp1130 Both of these are regression issues. We cannot just skip them.

Copy link
Contributor

@thePunderWoman thePunderWoman left a comment

Choose a reason for hiding this comment

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

I realized I left these comments and then never submitted them.


it('should support content projection', fakeAsync(() => {
// TODO
xit('should support content projection', fakeAsync(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

@dgp1130 Both of these are regression issues. We cannot just skip them.


it('should run animations using the root injector so that the animation queue still runs when the component is destroyed before afterNextRender occurs', fakeAsync(() => {
// TODO
xit('should run animations using the root injector so that the animation queue still runs when the component is destroyed before afterNextRender occurs', fakeAsync(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar to earlier, this was specifically added to catch a regression. So this will need to be addressed before you land code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, the TODO was a note for me. I need to look into the tests to figure out what's going on here.

Comment on lines 96 to 117
if (!Node.prototype.getRootNode) {
const injector = lView![INJECTOR];
const doc = injector.get(DOCUMENT);
return doc;
}

const renderer = lView![RENDERER];
if (renderer?.shadowRoot) return renderer.shadowRoot;

const hostRNode = lView![HOST];
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (!Node.prototype.getRootNode) {
const injector = lView![INJECTOR];
const doc = injector.get(DOCUMENT);
return doc;
}
const renderer = lView![RENDERER];
if (renderer?.shadowRoot) return renderer.shadowRoot;
const hostRNode = lView![HOST];
if (!Node.prototype.getRootNode) {
const injector = lView[INJECTOR];
const doc = injector.get(DOCUMENT);
return doc;
}
const renderer = lView[RENDERER];
if (renderer?.shadowRoot) return renderer.shadowRoot;
const hostRNode = lView[HOST];

Comment on lines +94 to +157
// Available. In theory, we could do this only on SSR, but Jest, Vitest, and other
// Node testing solutions lack DOM emulation as well.
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this have any practical implications for SSR and hydration if the styles cannot be effectively added to the correct root on the server?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should not change the current behavior, which is that it's broken.

ViewEncapsulation.ShadowDom in SSR renders <style> tags into the <head> and does not use shadow DOM at all. That behavior is frankly wrong, but needs to be retained for backwards compatibility.

ViewEncapuslation.IsolatedShadowDom will be restricted to throw in SSR until we have support for declarative shadow DOM such that we could make this work as expected. I didn't do that in this PR, but I think it is a necessary precondition to stabilization of that feature.

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
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
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
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...

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

// TODO: test
Copy link
Contributor

Choose a reason for hiding this comment

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

just a reminder

return;
}

// TODO: Problematic?
Copy link
Contributor

Choose a reason for hiding this comment

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

agree, this seems concerning. At the least, this could use a comment about how the style cleanup is handled once leaveAnimations reaches 0. Presumably the styles should be removed once there are no leave animations for the style root rather than every global leave animation

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems related #66244

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At the least, this could use a comment about how the style cleanup is handled once leaveAnimations reaches 0.

I'm pretty sure it just doesn't right now. I'm inclined to leave that behavior as is for the scope of this PR but will need to update the tests to not fail at least which seems to be a problem at the moment.

@AndrewKushnir AndrewKushnir removed their request for review December 16, 2025 20:04
@dgp1130 dgp1130 force-pushed the bootstrap-shadow branch 4 times, most recently from 30dcb50 to 4d93ce1 Compare December 20, 2025 03:02
@dgp1130 dgp1130 force-pushed the bootstrap-shadow branch 2 times, most recently from 341b8ee to 05750e6 Compare January 5, 2026 23:14
): void {
// Attempt to get any current usage of the value
const record = usages.get(value);
const rootMap = usages.get(styleRoot);
Copy link
Member

Choose a reason for hiding this comment

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

If this is usages.get(styleRoot) ?? new Map<StyleRoot, Map<string, UsageRecord<T>>>() then we could remove the first check. Maybe slightly easier to read? Up to you.

Copy link
Contributor Author

@dgp1130 dgp1130 Jan 6, 2026

Choose a reason for hiding this comment

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

The ?? new Map() won't be eliminated in production builds (and we should never need to create a Map here), so there's an unnecessary bundle size regression there. I think the two checks aren't too big a deal to leave as is.

removeElements(record.elements);
usages.delete(value);
}
record!.usage--;
Copy link
Member

Choose a reason for hiding this comment

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

What about if record !== null but ngDevMode === false?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

record should always be defined here, I'm just only asserting it when ngDevMode === true so we don't pay a bundle size cost for that assertion.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe we should update the comment above which implies the record may not always be present.


afterEach(() => {
for (const host of shadowRootHosts) host.remove();
shadowRootHosts.splice(0, shadowRootHosts.length);
Copy link
Member

Choose a reason for hiding this comment

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

Is this more common in JS than shadowRootHosts.length = 0?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IMHO, the intent behind .splice(0, length) is more clear than assigning to length. I don't think there really is an idiomatic way of clearing an array though, so it's mostly personal preference.

Copy link
Member

Choose a reason for hiding this comment

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

fwiw .length = 0 is quite common in our codebase but we have no instance of .splice(0, length).

Copy link
Member

Choose a reason for hiding this comment

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

Precisely one instance of .splice(0, length) :) I agree it's personal preference. FWIW .length = 0 is obvious to me and works in many languages, and I had to look up the .splice API on MDN to understand what you were doing here.

This represents a unique location where styles can be rendered and will apply to distinct elements. This is defined as simply `Document | ShadowRoot`. Styles rendered to a `Document` should go in its `HTMLHeadElement` while styles applied to a `ShadowRoot` should just be rendered within that root.

This needs to be public API as it will be added to `Renderer2` which is already public API.
This makes shadow roots accessible from renderers which is necessary to find the style root of `ViewEncapsulation.{Isolated,}ShadowDom` components.
All the `ViewEncapsulation` modes have a form of the `applyStyles` logic. This defines a stable interface for working with all of them.

`applyStyles` itself needs to be optional because other renderers (such as `DefaultDomRenderer2`) don't support it.
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.
This tests that Angular can bootstrap under a shadow root and correctly apply styles to that shadow root rather than the document `<head>` tag.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: review The PR is still awaiting reviews from at least one requested reviewer area: core Issues related to the framework runtime core: stylesheets target: minor This PR is targeted for the next minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants