-
Notifications
You must be signed in to change notification settings - Fork 27k
Fix bootstrapping inside shadow roots #66048
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
1eeaba9 to
8ab9c6f
Compare
|
|
||
| it('should support content projection', fakeAsync(() => { | ||
| // TODO | ||
| xit('should support content projection', fakeAsync(() => { |
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 noticed that the issue is related to isConnected being false on the animated component. Same with the broken test below. (cc @thePunderWoman)
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 Both of these are regression issues. We cannot just skip them.
8ab9c6f to
97da4d7
Compare
thePunderWoman
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 realized I left these comments and then never submitted them.
|
|
||
| it('should support content projection', fakeAsync(() => { | ||
| // TODO | ||
| xit('should support content projection', fakeAsync(() => { |
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 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(() => { |
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.
Similar to earlier, this was specifically added to catch a regression. So this will need to be addressed before you land code.
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, the TODO was a note for me. I need to look into the tests to figure out what's going on here.
| 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]; |
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.
| 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]; |
| // Available. In theory, we could do this only on SSR, but Jest, Vitest, and other | ||
| // Node testing solutions lack DOM emulation 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.
Does this have any practical implications for SSR and hydration if the styles cannot be effectively added to the correct root on the server?
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.
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 |
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.
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;
};
}
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 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.
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.
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 |
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.
just a reminder
| return; | ||
| } | ||
|
|
||
| // TODO: Problematic? |
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.
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
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.
Seems related #66244
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.
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.
30dcb50 to
4d93ce1
Compare
341b8ee to
05750e6
Compare
| ): void { | ||
| // Attempt to get any current usage of the value | ||
| const record = usages.get(value); | ||
| const rootMap = usages.get(styleRoot); |
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.
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.
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.
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--; |
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.
What about if record !== null but ngDevMode === false?
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.
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.
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.
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); |
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.
Is this more common in JS than shadowRootHosts.length = 0?
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.
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.
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.
fwiw .length = 0 is quite common in our codebase but we have no instance of .splice(0, length).
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.
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.
5a92f70 to
8c45a49
Compare
4285a97 to
d6d363e
Compare
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 exposes [`Node.prototype.getRootNode`](https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode) and [`Element.prototype.shadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/Element/shadowRoot) to the framework.
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.
d6d363e to
d7f6f27
Compare
d7f6f27 to
c7b6b30
Compare
(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 toViewEncapsulation.IsolatedShadowDomrelated 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
applyStylesfrom 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,
StyleRootwhich represents "a place where styles can be applied". Effectively justDocument | ShadowRoot(meaning "put styles in the<head>tag" or "put styles in thisShadowRoot". We now track styles based on specificStyleRootobjects, 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 useNode.prototype.getRootNodeto determine whether or not a particular component is within aShadowRoot. 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.tsanddom_renderer.ts.