-
Notifications
You must be signed in to change notification settings - Fork 27k
fix(platform-browser): Fixes IsolatedShadowDom encapsulation method #63131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
@thePunderWoman et al 👋 - me again! A follow up to the I've done a bit of a refactor, providing an alternative way for This is very much a RFC, or even better if you have capacity to have a quick test? It's a bit of a grey area for me in regards to the shadowdom spec, which doesn't explicitly talk about child component styling (and Angular muddies the waters a bit with different encapsulation methods). However this feels like the best of both worlds, parent styles cannot leak/be added into the shadowdom, but child component styles are correctly applied. |
5039a7e to
367d39d
Compare
bba0484 to
8813776
Compare
0b8d24e to
42511ea
Compare
42511ea to
19ea9b8
Compare
|
👋 - any chance of getting this merged (stops me having to keep the branch up to date) 😅 ? Would be good to get this in a |
a0c6ebe to
3b5f324
Compare
We're hoping to take a deeper look at it soon! Thanks for the effort keeping this up to date. |
dgp1130
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apologies for the slow review @ryan-bendel, thanks for pushing through this.
Just some minor comments, main thing is just trying to understand the lifecycle of SharedStylesHost and how shadow roots are tracked there.
|
@dgp1130 @thePunderWoman - Addressed the comments on the PR. I did encounter a bug though, which meant i've had to do a bit of a refactor (and arguably add a bit more complexity) I had only tested a single My fix now tracks usage, and copies the styles into both app & shadowroot, so styles persist across usages. The one "gotcha", which I don't think we need to fix (i think it'll exist today) - is if you put a "none" encapsulation component inside a shadowdom, you get into css specificity issues, as there is not a unique identifier in ShadowDom/None like there is with Emulated. Hard to articulate exactly what i mean, hopefully the below image helps. These components are all Lastly, i'm unable to really test SSR in practice (i don't really use it so don't feel qualified enough to thoroughly test it) - if one of you would have any capacity to check there's no regressions (even outside of SSR), that would be much appreciated! The more i touch this the less confident i get 🤣
|
1dabb83 to
34391f7
Compare
dgp1130
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't worry too much about SSR given that we don't support declarative shadow DOM just yet. AFAICT, it's just broken today as we seem to put component styles into the <head> tag, which is necessary for initial render but also leaks those styles out of the shadow root.
IsolatedShadowRoot should probably throw when used in SSR until we support declarative shadow DOM. So should ShadowRoot frankly, but I'll check with the rest of the team to see if that's worth the breaking change at the moment.
c6d687f to
59457eb
Compare
| } | ||
| } | ||
|
|
||
| // Priority 2: Check if element's parent is a shadow root host |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Why do we care about this case? Shouldn't Element.prototype.getRootNode cover this case as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this is critical, without it, any child Shadowdom component of Isolated loses it's styles (they're hoisted to Isolated, outside of the scope of the style shadow boundary, so it loses it's styling)
The use case would be Isolated parent, that has a Shadowdom child (with an emulated component projected into the Shadowdom component) - without this, the emulated styles would go to the Isolated (and need to go to the ShadowDom host)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I don't think I'm following this. In my mind, the core logic here should be:
function determineStyleTargets(el: Element): ShadowRoot[] {
for (const component of walkAncestorComponents(el)) { // Or use `getRootNode` I guess...
if (usesIsolatedShadowDomEncapsulation(component)) {
return [component.shadowRoot]; // Apply styles to this one root.
} else if (usesStandardShadowDomEncapsulation(component)) {
return allStandardShadowRoots; // Legacy behavior, apply to all legacy roots.
} else {
continue; // Move to next ancestor.
}
}
return []; // Not in a shadow root, append to `doc.head`.
}Is there some combination of IsolatedShadowDom and ShadowDom components for which that doesn't work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm probably doing a terrible job of articulating why this is needed tbf as it's quite nuanced (to me at least)! Let me try and break down what I see as happening:
Priority 1 handles projected content (elements in light DOM that are direct children of shadow hosts), while Priority 2 handles elements that are actually inside shadow trees.
When determineStyleTargets gets called:
- An Emulated or None component needs to apply its styles
- The renderer calls applyStyles(element) where element is the component's host element
- Inside applyStyles, it calls this.styleScopeService.determineStyleTargets(element)
What Priority 1 and 2 are checking:
Priority 1: "Is this component a projected direct child of a shadow DOM component?"
Checks if element.parentElement is a shadow root host
This catches: <shadow-component><!-- ng-content --><emulated-component/></shadow-component>
Priority 2: "Is this component inside a shadow DOM tree?"
Checks if element.getRootNode() is a shadow root
This catches components that are deeper inside shadow trees
Projected content has a disconnect between:
Physical DOM location (what getRootNode() sees)
Rendering context (where styles need to be applied)
Priority 1 bridges this gap by checking the parent-child relationship in the light DOM to infer the correct shadow root context for style targeting.
Your suggestion doesn't work because:
It only works for elements that are physically inside shadow roots. It completely misses the projected content case where:
- Element physically exists in light DOM
- Element visually renders inside shadow DOM
- Styles need to target the visual context (shadow root), not the physical context (document)
Priority 1 is essential for content projection directly into an IsolatedShadow, because getRootNode() would return the document instead of the shadow root where styles actually need to be applied - your suggested getRootNode()-only approach fails for projected content because it targets the physical DOM location rather than the visual rendering context.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More visually, without the 2 conditions/priorities, this is the use case that breaks:
I don't think we could ship without allowing/fixing the projection into an isolated shadow component as that's a valid use case (I know in my app of ~10,000+ components we will have use cases where we have some very dumb ui component like a card/panel from our ui library using Isolated, but we want to project more complex app specific Emulated components inside of it) - so suspect we'd be opening a can of worms of support tickets without it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry I think I'm still struggling with this. Maybe a Stackblitz demo of this use case would be helpful to demonstrate why this branch is needed?
It might just be my confused understanding, but there are a few aspects of this which stand out to me as smells:
-
Checks if element.parentElement is a shadow root host
This catches:<shadow-component><!-- ng-content --><emulated-component/></shadow-component>Content projection may not be a direct child of the host though (ex.
<shadow-component><div><!-- ng-content --><emulated-component /><!-- /ng-content --></div></shadow-component>), so I feel likeelement.parentElementis the wrong mechanism here. At minimum, you probably need to iterate through some framework-level data structures to find the parent component you're being projected into. -
Priority 1 handles projected content (elements in light DOM that are direct children of shadow hosts)
Angular doesn't use native
<slot>for content projection though, so how do you end up with light DOM in anIsolatedShadowDomcomponent? All the content should be in the shadow tree, right? -
Element physically exists in light DOM
Element visually renders inside shadow DOM
Styles need to target the visual context (shadow root), not the physical context (document)If the element is in light DOM, don't you need to style that "physical context"? (
document.heador an ancestor shadow root?) I would expect to not style the shadow root for content projected from light DOM. -
Does this all matter right now if we disable content projection for
IsolatedShadowDomas I suggested in another comment? Is this a problem we can solve if/when we get to content projection via native<slot>?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry I think I'm still struggling with this. Maybe a Stackblitz demo of this use case would be helpful to demonstrate why this branch is needed?
It might just be my confused understanding, but there are a few aspects of this which stand out to me as smells:
Checks if element.parentElement is a shadow root host
This catches:<shadow-component><!-- ng-content --><emulated-component/></shadow-component>Content projection may not be a direct child of the host though (ex.
<shadow-component><div><!-- ng-content --><emulated-component /><!-- /ng-content --></div></shadow-component>), so I feel likeelement.parentElementis the wrong mechanism here. At minimum, you probably need to iterate through some framework-level data structures to find the parent component you're being projected into.Priority 1 handles projected content (elements in light DOM that are direct children of shadow hosts)
Angular doesn't use native
<slot>for content projection though, so how do you end up with light DOM in anIsolatedShadowDomcomponent? All the content should be in the shadow tree, right?Element physically exists in light DOM
Element visually renders inside shadow DOM
Styles need to target the visual context (shadow root), not the physical context (document)If the element is in light DOM, don't you need to style that "physical context"? (
document.heador an ancestor shadow root?) I would expect to not style the shadow root for content projected from light DOM.Does this all matter right now if we disable content projection for
IsolatedShadowDomas I suggested in another comment? Is this a problem we can solve if/when we get to content projection via native<slot>?
Hey @dgp1130 - Currently laid up with COVID atm so my brain isn't thinking straight, trying to rack my brains of how we could create a stackblitz where we'd be able to fiddle with a local version of the core/platform-browser packages so we can tweak the code and make sure the use cases work and then go from there ... any ideas?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dgp1130 - nevermind, i remembered how to do it 😄 - I've created this stackblitz. You should be able to edit the two packages in the /angular folder. If you comment out that first priority check we were talking about, you'll be able to see what stops working without it.
I think if we didn't style projected content, we'd cause ourselves a headache, as that's a pretty common use case (i know i'll definitely use it with isolated)
I'll hold off addressing the other comments until you've had a chance to have a look and have a fiddle, then we're on the same page about what it's doing. I probably won't get a chance to look till the end of next week
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently laid up with COVID atm
Oh no! Hope you feel better soon, take it easy and don't stress out about this too much.
Thanks for that Stackblitz, it really helps I lot. I see how the priority 1 snippet is load-bearing, but I'm not convinced it's the right solution.
Firstly, as I mentioned, you can content project into a nested element, meaning checking the direct parent of the component is not sufficient. The example breaks if you just do:
<isolated-shadow-dom-component>
<div>
<button-component />
</div>
</isolated-shadow-dom-component>Secondly, I suspect there's a timing bug here. If I call (<button-component>).getRootNode() after rendering, I correctly get the expected shadow root. But if I breakpoint during determineStyleTargets and call (<button-component>).getRootNode(), I get document. Looking at the DOM, it seems that <button-component> is initially rendered in light DOM before somehow being moved into the shadow root.
I'm not very familiar with how content projection works under the hood, so this is a learning experience for me as well, but AFAICT from looking at the generated instructions and breakpointing around, I see that the main component includes:
function App_Template() {
// ...
\u0275\u0275elementStart(9, "isolated-shadow-dom-component")(10, "div");
\u0275\u0275element(11, "button-component");
\u0275\u0275elementEnd()();
// ...
}This appears to render <isolated-shadow-dom-component><div><button-component></button-component></div></isolated-shadow-dom-component> (all in light DOM) and is triggered here. Afterwards, we render component children to expand <isolated-shadow-dom-component> itself. This executes its template:
function ShadowDomComponent_Template(rf, ctx) {
\u0275\u0275projectionDef();
\u0275\u0275text(0, "Projected content:\n");
\u0275\u0275domElementStart(1, "h4");
\u0275\u0275text(2, "This will be green (from global styles)");
\u0275\u0275domElementEnd();
\u0275\u0275projection(3);
},That last \u0275\u0275projection(3); call appears to move the element into the shadow root to its final position.
I feel like the fundamental issue here is that we're attempting to apply styles based on an intermediate state of the DOM. Ideally, we would render the element initially within the shadow root so it is always in a correct position, though that seems complicated from a timing perspective, since the parent element performing the projection renders before the child component has rendered the container element it will ultimately be projected into. I suspect the easier solution is to change the timing of when we apply styles to happen after the full component is fully rendered into its final position, then we could probably get rid of this "priority 1" case and .getRootNode() should return the desired ShadowRoot 100% of the time.
Third point is that I'm still not convinced content projection into a shadow root is a good idea in general or that we should propagate that design decision into IsolatedShadowRoot. In conversation with @jelbourn, it seems that you can apparently use native <slot> in the framework today. Our current thinking is that it's actually better to disable <ng-content> altogether for IsolatedShadowDom and point users are native <slot> instead. There's definitely a conceptual gap between those two concepts and this is definitely a non-trivial breaking change between ViewEncapsulation.ShadowDom and ViewEncapsulation.IsolatedShadowDom, but should leave us in a position closer to the web standard. From a styling perspective, this should make our job easier, as projected content will always be in the light DOM of its target component, so element.getRootNode() should return the correct value (once we figure out the timing issue above). Actually, I'd be curious if this fixes the timing issue because we wouldn't go through \u0275\u0275projection at all. None or Emulated components also won't leak their styles into shadow roots they happen to be projected into, which provides better isolation.
Does all that sound reasonable? Am I understanding the issue "priority 1" was supposed to address?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dgp1130 - i think i'm with you
I've taken a stab at implementing this, stackblitz here
It's meant a few changes to a couple of packages which are hard to see from the compiled code, but essentially it was a change in projection.ts to the ɵɵprojectionDef function (this throws the console error)
And a change to handler.ts in compiler-cli to give us the nice dialog/compilation style error
Slot works, ng-content now throws an error
The other thing i've done is refactored the isolated_style_scope service to get rid of that priority check now we're using only slot as you suggested, and to use a load of framework helper functions i stumbled across in core (lview stuff essentially, namely the getLContext unwrapRNode HOST and PARENT)
I've not looked at if you start trying to project content inside a component that is in turn projected into isolated, as that's a rabbit hole to go down, maybe something we can add later (or someone from the team who is a lot more knowledgeable of the framework than I! 😆 )
39be8de to
f0a0dc5
Compare
7755745 to
f8deb43
Compare
9f3ae01 to
a4d94cd
Compare
|
Thanks for the follow up, I haven't had a chance to look at it just yet, but FYI, I'm gonna be out for a couple weeks and will look at this when I get back. |
| this.shadowRoot = (hostEl as HTMLElement).attachShadow({mode: 'open'}); | ||
| } else { | ||
| // In SSR or environments without shadow DOM support, throw | ||
| throw new Error( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Won't this throw for standard ViewEncapsulation.ShadowDom? I think we need to maintain the existing behavior and only throw for IsolatedShadowDom.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dgp1130 - maybe i'm misremembering, but standard Shadowdom doesn't work in SSR either, right? Which is what this would catch. I could put in a condition to only throw for Isolated, but i'm not clear on what standard would do in a server environment here....
|
Looks like I found a usecase on angular.dev for that feature once the fix lands |
1439753 to
94d7b2e
Compare
I've refactored IsolatedShadowDom now to use native Removed dom walking in isolated_style_scope and replaced with using Angulars LView hierarchy which is much nicer. Demo if you want to have play (if you uncomment |
032f709 to
8530d0e
Compare
| "dist/main.js": 153915, | ||
| "dist/polyfills.js": 34023, | ||
| "dist/open-close.component-[hash].js": 1190 | ||
| "dist/main.js": 159053, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is quite an increase, we should investigate better tree shaking when the feature isn't used
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I shall take a look @JeanMeche - but i am struggling to run the integration tests locally (i bumped all the size.json from the output in the gh test runner output),
I get:
ERROR Failed to switch pnpm to v10.23.0. Looks like pnpm CLI is missing at "/var/folders/kj/cqmyhbyx0zv7mrtvpdlj19dw0000gp/T/ng-integration-test7nTJJM/tmp-env-0/Library/pnpm/.tools/pnpm/10.23.0/bin" or is incorrect
spawnSync /var/folders/kj/cqmyhbyx0zv7mrtvpdlj19dw0000gp/T/ng-integration-test7nTJJM/tmp-env-0/Library/pnpm/.tools/pnpm/10.23.0/bin/pnpm ENOENT
Command failed: `pnpm install`
🤔
2c2deab to
60acf6d
Compare
7cdd447 to
c0d2fb9
Compare
Implement IsolatedStyleScopeService. Refactor IsolatedShadowDom implementation to fix various bugs. Uses native slot instead of ng-content.
4254fca to
2471f4e
Compare
|
Sorry for the slow responses on my end, I've been meaning to take another look here but have just been held up. FYI, I made a related PR #66048 to fix bootstrapping Angular inside of a shadow root and found myself rewriting most of the styling system to better support |
|
Looking at this PR again, I'm thinking the changes to Where there is still value is in the changes around that. Specifically the errors for running |

This fixes an issue where a child component inside of IsolatedShadowDom would get it's styles moved to the root style object, causing it to be unstyled. This new approach hoists the child styles to the shadowroot. This change only applies to IsolatedShadowDom
PR Checklist
Please check if your PR fulfills the following requirements:
PR Type
What kind of change does this PR introduce?
What is the current behavior?
After introducing IsolatedShadowDom, it was raised on the PR that any child components using other encapsulation methods would have their styles hoisted to the root styles of the app. This would cause the child component to be unstyled as it is inside a shadowroot.
What is the new behavior?
The new behaviour hoists the styles of any child component into the head of the shadowroot.
Does this PR introduce a breaking change?
Other information
Spun up a test project:
emulated, with a button styled redComparisons:
Standard/old
Shadowdomencapsulation with style leakage:IsolatedShadowDompre-fix (styles are moved to root so child component loses styling):IsolatedShadowDompost-fix (styles are moved to shadowhost, fixing child component styling):As you can see from the last screenshot, the styles are now correctly applied to the child (
Emulated) component