-
Notifications
You must be signed in to change notification settings - Fork 378
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
Interaction with declarative shadow DOM #914
Comments
The proposed solution would mean that a custom element in a declarative shadow DOM can't be upgraded until all its ancestor custom elements had been upgraded. Is that desired behavior in all relevant use cases for declarative shadow DOM whenever scoped custom element registries are involved? |
This seems fine to me, but maybe I should spell out that implication? I'm not quite how it could be any other way, since imperatively created shadow roots using scoped registries would have been created in top-down order, with each shadow root being created with a scoped registry. With the "Find a Custom Element Registry" change we have in the explainer:
we allow for shadow roots that use the global registry to be descendants of shadow roots with a scoped registry. This does allow those nested roots to have their custom elements upgraded. The |
This might be a bad idea—I’m really good at bad ideas—but this feels like a place for a more declarative API (perhaps in addition to the current proposal). If we could name registries and refer to those in the DOM, that could solve for some these issues: <script>
const registry = new CustomElementRegistry({ name: 'foobar' });
registry.define('x-', class extends HTMLElement {});
</script>
<template shadowroot="open" shadowrootregistry="foobar">
<x-></x->
</template> Such that the template will look in some global scope for registries with that name or id and only upgrade from that registry. The obvious downside is that it leaks details about scoped registries which might not be desirable. |
A declarative html registry could be another option e.g.: <head>
<registry id="my-element-registry">
<link rel="customelementdefinition" name="some-element-1" url="path/to/some-element-1.js"/>
<link rel="customelementdefinition" name="some-element-2" url="path/to/some-element-2.js"/>
</registry>
</head>
<my-element>
<not-scoped>content</not-scoped>
<template shadowroot="open" registry="my-element-registry">
<some-element-1><slot></slot></some-element-1>
<some-element-2></some-element-2>
</template>
</my-element> |
Given that element definitions require script, I'm not sure there's much to be gained by making registries declarative at this point. Same with registry names. Since you need script already, you can use script to associate registries and shadow roots. When we get to declarative custom elements that may change. |
The issue is of the order. In the case of a partial hydration, is it true that all element definitions come in top-down order in all use cases? As in, is it really okay that all ancestor custom elements need to be upgraded in order for any descendant custom element to be upgraded? |
Yeah, the pattern of: // my-component.js
import Component1 from "component1";
import Component2 from "component2";
const registry = new CustomElementRegistry();
registry.define("component-1", Component1);
registry.define("component-2", Component2);
export default class MyComponent extends HTMLElement {
#shadowRoot = this.attachShadow({ mode: 'closed', registry });
// ...
} Means that upgrading will always happen top-down, even if we have something like: const registry = new CustomElementRegistry();
import("component-1").then(mod => registry.define("component-1", mod.default));
import("component-1").then(mod => registry.define("component-2", mod.default));
export default class MyComponent extends HTMLElement {
#shadowRoot = this.attachShadow({ mode: "closed", registry });
// ..
} Upgrading the scoped components is still contingent on when Ideally components could opt into as much eager upgrading of subcomponents as they want without being blocked on We can't even really hack it properly using inline scripts either, because if want to associate a registry we have to use As such, it really needs to be declared in the HTML somehow, whether by my suggestion of Some alternative bikesheds for declarative definitions:<!-- Have elements with set of links and link via <template> attribute -->
<registry id="my-registry">
<link rel="customelementdefinition" name="component-1" href="./component-1.js">
</registry>
<my-component>
<template shadowroot="closed" registry="my-registry">
<component-1></component-1>
</template>
</my-component> <!-- Attributes on the to-upgrade elements themselves -->
<my-component>
<template shadowroot="closed">
<component-1 customelementdefinition="./component-1.js"></component-1>
</template>
</my-component> <!-- Attributes on the template -->
<my-component>
<template shadowroot="closed" customelement-component-1="./component-1.js">
<component-1></component-1>
</template>
</my-component> |
Another interaction between declarative shadow DOM & scoped custom element registries. The aforementioned ordering means that adopting scoped custom element registries will change the order by which custom elements are upgraded. Now, the parent custom element needs to be upgraded before their descendent custom elements are upgraded. This might be somewhat surprising consequence of start using a scoped custom element registry and may cause havoc for some websites / web apps with a large number of web components with complex dependencies. |
For any chain of nested scoped registry using shadow roots, this is necessarily true. We cannot know what class to use for a name ahead of it.
"Is it ok" is an interesting question... With imperative only APIs, there's no other practical ordering possible - the parent has to be created before it creates it's own shadow root to have any children to upgrade. With a new way to have deeply nested shadow roots exist before instantiation we may find use cases where we want to upgrade out of order as an optimization. That seems like something that can be pretty transparently added with declarative registries like @Jamesernator has sketched out. We can reserve the space for this in the HTML attribute: <template shadowroot="open" shadowrootregistry=""> means a null registry that has to be added by script. <template shadowroot="open" shadowrootregistry="foo"> would utilize some registry-registry system so roots can be associated with registries in arbitrary order. |
The aforementioned ordering issue, if it turns out that it is really needed, it looks to me like it is fatal. Now, I still don't understand the argument from @rniwa very well. As far as I understand, you only need to stop upgrading elements if the nearest declarative shadow has the custom register mark, otherwise you rely on the global registry. In other words: <my-element>
<template shadowroot="open" shadowrootregistry="">
<some-scoped-element>
<template shadowroot="open">
<some-other-element></some-other-element>
</template>
</some-scoped-element>
</template>
</my-element> The |
I don't understand this statement. The top-down ordering is exactly what we already have. What's "fatal" about it? |
@justinfagnani the main thing here is whether or not this new feature changes the existing behavior? (e.g.: order of upgrading) I, like you, don't think it does it, but I'm not sure that's what @rniwa is saying, so I want to understand more. What he is saying is:
There are two parts of that statement:
My question to @rniwa is the same, is that statement correct? can you provide an example? I don't think this feature is that disruptive, but as always, I want to understand more about his concern. If @rniwa concerns turns out to be true, and therefore it requires a change in the current behavior (according to my description above), then I will consider this fatal, and we should get back to the drawing board. |
Yeah, sorry I should have been more precise. It doesn't change the behavior of exiting websites.
What I mean is that if you have nested custom elements that each use scoped custom element registry and declarative shadow DOM at the same time, then we can only upgrade inner custom elements after outer custom elements had been upgraded regardless of whether they're already available or not in terms of scripts because the browser wouldn't know when which registry will be used for which declarative shadow root. e.g.
Then This may hinder the ability to adopt scoped custom element registries and declarative shadow DOM in existing apps that use web components because now we're basically forcing the upgrading order to be top-down regardless of whether their respective custom element definitions are ready to go or not. For example, today, you can force bottom-up upgrades in the following tree with just declarative shadow tree if you first define
The inconsistency between when scoped custom element registry is used vs. not used seems problematic / not ergonomic to me. |
@rniwa thanks for the detailed explanation. I do agree with you that there will be some inconsistencies, but that seems ok! Developers will have to choose whether or not they want scoped registry anyways, and if they choose to use it, it is very likely that they have to make significant changes to their app/components to accommodate it while considering the pros and cons. @justinfagnani I'm good with this proposal. Adding the new attribute on template tag, and adding the setter for |
A web component author today currently has to design their components to work no matter the order that global-registry elements are upgraded. F.e., an author needs to make sure all of these permutations work: <script src="parent-el.js"></script>
<script src="child-el.js"></script>
<parent-el><child-el></child-el></parent-el> <script src="child-el.js"></script>
<script src="parent-el.js"></script>
<parent-el><child-el></child-el></parent-el> more permutations...<script src="parent-el.js"></script>
<parent-el><child-el></child-el></parent-el>
<script src="child-el.js"></script> <script src="child-el.js"></script>
<parent-el><child-el></child-el></parent-el>
<script src="parent-el.js"></script> <parent-el><child-el></child-el></parent-el>
<script src="parent-el.js"></script>
<script src="child-el.js"></script> <parent-el><child-el></child-el></parent-el>
<script src="child-el.js"></script>
<script src="parent-el.js"></script> ... etc ... For any CE author that already designs their elements robustly, the load order differences will not be a problem. The top-down order is very intuitive and will lead to web developers having an easier time with less upgrade order issues. In React, Vue, Svelte, etc, the order is always top-down. It would confuse people otherwise. The ease of the developer experience may be something to consider here. The single most difficult part of Custom Elements (for me) has been dealing with upgrade order (in my case I have elements with custom WebGL rendering instead of CSS, so I need to understand the composed tree shape, and upgrade order has had more impact on that). |
Before that can be possible, #754 needs a solution (and the issue should be left open). If a registry is removed, then
|
This does not necessarily follow. It could be decided that if a registry changes that already upgraded elements are left as-is. There is a tension between trying to put the shadow_root into the state it would have been if it was created with the new registry (no element upgraded from previous registries), and trying to put each individual element into the state it would have been if it was created with the new registry (no orphaned instance properties, event listeners, attributes, shadow children, etc.). I can see arguments either way, as well as one that says because of the tension the registry should not be changeable, but only go from undefined to defined at most once. |
That's true, that could definitely be decided, but it may lead to unexpected results where a new registry uses the same element name(s) as the previous registry yet the existing elements don't behave according to the new definitions.
I think it would be a safe bet to start with this approach, then if/when we want to allow the registry to be changeable it can be added later once it is known what the ideal behavior would be. Another thing I just thought of is what happens moving an element from one root to another root that has the same element name defined? Would the element be downgraded, then upgraded to use its new class? This would be a rare thing regardless. I've personally never created elements for one root, then moved them to another. |
The CE author would clean up event listeners in I believe DOM Mutation events could be improved (batched like MutationObserver if not already), and that Dangling properties and attributes are fine: a user would need to know that they have swapped the definition of an element, and they should set new properties or attributes after the re-upgrade (or leave them if the new definition is compatible, so similar to swapping implementations of an interface, but at runtime). As for Shadow DOM, that can be handled similar to declarative shadow DOM and ElementInternals: the re-upgraded element will already have a shadow root and the root can be re-used just like in the DSD case. Maybe there would need to be an additional way to detect re-upgrade vs DSD (so to either overwrite the root's content, or re-use the root's content). If not, then perhaps the only sure way is to always overwrite the root's content and never re-use it (or did I miss something?). Well even in the DSD case, how does the author know that the DSD root is own by their framework, and not just some random shadow root some user arbitrarily added into the DOM? Seems like always overwriting content is the safe way to go (but not best for performance due to always re-creating everything). |
This is unknowable, but at the end of day they could as easily mess with your component in a plenty of ways e.g. they could override Although if your component is loaded as a third-party component (e.g. like a social media button or whatever), then yeah probably best to distrust the shadow root (and still even then, they could just download a copy of the script and modify it, so there's really no winning here). What I'll probably do is just assume that the component is used correctly, and maybe have a couple basic assertions on things to verify that the wrong thing isn't done and leave it at that. |
Question on the use of This may be way late in the process (or maybe just in time 😉), but what if rather than or in addition to applying a scoped registry at attach time: this.attachShadow({mode: 'open', registry}); We were able to create a shadow root with a scoped registry, much like the import { Button } from './button.js';
import { Input } from './input.js';
// ...
this.attachShadow({mode: 'open', registryType: 'scoped'}); // or some nicer API options
this.shadowRoot.registry.define('my-button', class extends Button {});
this.shadowRoot.registry.define('my-input', class extends Input {});
// ... etc. If this were the case, we might even be able to recreate the bottom up upgrading missing in: <some-element>
<template shadowroot="open" customregistry>
<some-scoped-element>
<template shadowroot="open" customregistry>
<other-scoped-element></other-scoped-element>
</template>
</some-scoped-element>
</template>
</some-element> Via JS, like: const someElement = document.querySelector('some-element');
const someScopedElement = someElement.shadowRoot.querySelector('some-scoped-element');
someScopedElement.shadowRoot.registry.define('other-scoped-element', class extends GenericElement {}); I feel like there's some security something about |
@Westbrook, I think you're right, for something like: @mfreed7 do you recall the details on why it throws for built-ins? Update: Thinking more about this, the scenario described above might be working fine if we look at this from the perspective of the built-ins. Maybe the |
@caridy does that means that you'd be OK with the following code not being possible:
Or would built-in's only be hobbled from a Declarative Shadow DOM standpoint? |
Well, But importantly, I don't think the usage of <div id=host></div>
<script>
const shadow = host.attachShadow({mode: 'open'});
shadow.registry = new CustomElementRegistry();
</script> Right? |
Scoping registry only within current Root based scoping is a good default for scoping though. |
Sounds like the winning proposal is to add a content attribute to signify that it uses a custom registry as in: <some-element>
<template shadowroot="open" customregistry>
<some-scoped-element>
<template shadowroot="open" customregistry>
<other-scoped-element></other-scoped-element>
</template>
</some-scoped-element>
</template>
</some-element> We can either automatically create a registry in that case to allow bottom-up upgrades, or force more natural top-down recursive upgrades. |
Based on the thumbs-ups on @rniwa's comment above, perhaps this issue is closed/resolved? |
👍🏻 with disclaimer to provide the namespace as value of |
The "winning proposal" has a (minor?) downside that we can't reuse registry for different shadow roots, even for the same component. So we need to create duplicate registries. For example: <some-element>
<template shadowrootmode="open" customregistry>
<some-scoped-element></some-scoped-element>
<some-other-scoped-element></some-other-scoped-element>
</template>
</some-element>
<script type="module">
class SomeElement extends HTMLElement {
constructor() {
super();
if (this.shadowRoot) {
// This is duplicated on every instance of SomeElement
this.shadowRoot.registry.define('some-scoped-element', SomeScopedElement);
this.shadowRoot.registry.define('some-other-scoped-element', SomeOtherScopedElement);
} else { ... }
}
}
customElements.define('some-element', SomeElement);
</script> Not sure how big of an issue it is, since declarative shadow DOM already needs to duplicate the markup and other things. But we can still avoid this by allowing setting <some-element>
<template shadowrootmode="open" customregistry>
<some-scoped-element></some-scoped-element>
<some-other-scoped-element></some-other-scoped-element>
</template>
</some-element>
<script type="module">
import { registry } from './some-element-registry.mjs';
class SomeElement extends HTMLElement {
constructor() {
super();
if (this.shadowRoot) {
// This makes all SomeElement instances share the same registry
this.shadowRoot.registry = registry;
} else { ... }
}
}
customElements.define('some-element', SomeElement);
</script> I think it has been debated many times at various places whether we should allow setting |
This is what I'm proposing here. I think the idea of an attribute is just to note that the shadow root should not use the global registry and await a scoped registry to be set. |
Just discussed this at F2F and resolved:
|
As has already been discussed, WCCG had their spring F2F in which this was discussed. I'd like to point out that you can read the full notes of the discussion (#978 (comment)) in which this was discussed, heading entitled "Scoped Custom Element Registries". As has been pointed out in the comment above, present members of WCCG reached a consensus this issue. |
We need to specify how scoped custom element registries interact with declarative shadow DOM.
The fundamental behavior we need is for a declarative shadow root to not use the global registry when it's going to be used with a scoped registry once its host is defined. Then we need the host to be able to assign a scoped registry to a shadow root that's already been created.
This is accomplishable with an attribute that maps to an
attachShadow()
option to opt-out of the global registry, without yet having a scoped registry to use. InattachShadow()
options, we can allowregistry
to be undefined. In declarative shadow DOM, we can introduce an attribute, likeshadowrootregistry
:Next, we need to be able to add the ability for a scoped registry to be set on an existing ShadowRoot. Possibly like:
This means we need to clarify whether the registry can change over time, or whether it can only be set on declarative shadow roots that have been opted out of the global registry. A registry changing over time is something that has also come up in #907 (moving shadow roots between documents).
The text was updated successfully, but these errors were encountered: