Skip to content
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

Revamped Scoped Custom Element Registries #10854

Open
annevk opened this issue Dec 12, 2024 · 44 comments
Open

Revamped Scoped Custom Element Registries #10854

annevk opened this issue Dec 12, 2024 · 44 comments
Labels
addition/proposal New features or enhancements agenda+ To be discussed at a triage meeting needs implementer interest Moving the issue forward requires implementers to express interest topic: custom elements Relates to custom elements (as defined in DOM and HTML) topic: shadow Relates to shadow trees (as defined in DOM)

Comments

@annevk
Copy link
Member

annevk commented Dec 12, 2024

https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Scoped-Custom-Element-Registries.md is a good proposal, but it ties the functionality too much to shadow roots. This is Ryosuke and I's proposed improvement attempting to account for feedback given in various Web Components issues on this topic: https://github.com/WICG/webcomponents/issues?q=is%3Aissue+label%3A%22scoped+custom+element+registry%22.

First, the IDL, illustrating the new members:

CustomElementRegistry {
  constructor();

  ...

  [CEReactions, NewObject] HTMLElement createElement(DOMString name);
  [CEReactions, NewObject] Node cloneSubtree(Node root);
  undefined initializeSubtree((Element or ShadowRoot) root);
};

partial interface Element {
  readonly attribute CustomElementRegistry? customElements;
};

dictionary ShadowRootInit { // used by Element.prototype.attachShadow
  ...
  CustomElementRegistry customElements;
};

partial interface ShadowRoot {
  readonly attribute CustomElementRegistry? customElements;
};

partial interface HTMLTemplateElement {
  [CEReactions] attribute DOMString shadowRootCustomElements;
}

Here’s a summary of how the proposal evolved:

  • CustomElementRegistry still gains a constructor.
  • ShadowRoot still supports a CustomElementRegistry, exposed through a customElements getter.
    • It seems important for the shadow root to be able to be independent from its host in terms of registries.
    • Interaction with declarative shadow DOM WICG/webcomponents#914 has a proposal for declarative shadow trees to be able to disable inheriting from the global registry. This adopts that with the shadowrootcustomelements attribute, which is reflected as a string for forward compatibility.
    • ElementInternals gains initializeShadowRoot() CustomElementRegistry gains initializeSubtree() so a declarative shadow root (or any element) can have its CustomElementRegistry set (when it’s null).
    • The attachShadow() member is now called customElements for consistency.
  • Element should support an associated CustomElementRegistry, exposed through a customElements getter. This impacts elements created through innerHTML and future such methods, such as setHTMLUnsafe(). This will allow using non-global CustomElementRegistry outside of shadow roots.
    • setHTMLUnsafe() in the future could maybe also set its own CustomElementRegistry. Given the ergonomics of that it makes sense to expose it directly on Element as well.
  • CustomElementRegistry should gain a createElement(). It should fallback to creating built-in elements. Any element created this way will have the CustomElementRegistry associated with it.
    • We should make some improvements to this method compared to document.createElement():
      • Always create an HTML element. No longer vary on the document.
      • No longer lowercase.
      • (Validation against XML's Name is kept for now until it's changed everywhere.)
  • CustomElementRegistry should gain a cloneSubtree() method that clones a node and upgrades it and its children using the registry.

I’ll create specification PRs as well to allow for review of the processing model changes. We believe this resolves the remaining issues with the latest iteration of the initial proposal.

I'd like to briefly go over this in the December 19 WHATNOT meeting and will also be available then to answer any questions. Marking agenda+ therefore.

cc @rniwa @justinfagnani @whatwg/components

@annevk annevk added addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest topic: shadow Relates to shadow trees (as defined in DOM) topic: custom elements Relates to custom elements (as defined in DOM and HTML) agenda+ To be discussed at a triage meeting labels Dec 12, 2024
@thepassle
Copy link

So to createElement from a scoped registry, inside a custom element with a shadowroot, you would now have to do:

this.shadowRoot.customElements.createElement('my-el');

Is that correct?

@EisenbergEffect
Copy link

EisenbergEffect commented Dec 12, 2024

Will the global customElements also get a cloneSubtree() method and will this also support built-ins? Essentially, I'm looking for a consistent way to clone templates/fragments with different scopes. So, I'd like to be able to do something like this:

function cloneInScope(src: DocumentFragment, scope: Document | ShadowRoot | Element = document) {
  const registry = scope.customElements ?? globalThis.customElements;
  return registry.cloneSubtree(src);
}

Assuming the above, then at first glance, this proposal looks like it will enable all my scenarios.

@rniwa
Copy link

rniwa commented Dec 12, 2024

@EisenbergEffect : Yes, cloneSubtree method would be exposed on all registries including the global one. And indeed one of the design constraints we had was to allow a consistent way of creating an element regardless of the registry being used.

@rniwa
Copy link

rniwa commented Dec 12, 2024

So to createElement from a scoped registry, inside a custom element with a shadowroot, you would now have to do:

this.shadowRoot.customElements.createElement('my-el');

Is that correct?

Yes although a more convenient way is to use whatever node you already have in the shadow tree and do:

node.customElements.createElement('my-el');

@thepassle
Copy link

Right so from “inside” a custom element you could either do this.customElements or this.shadowRoot.customElements? To give some context, at ING (bank) we make heavy use of the current scoped registries polyfill and @open-wc/scoped-elements so this wil likely be something we need to address in our codebases, since we currently use this.shadowRoot.createElement. I dont think thats a huge problem though.

Additionally, I think the addition of allowing a registry for a node is a good addition 👍

@matthewp
Copy link

Why happens in this scenario?

<outer-element>
  <template shadowrootmode="open">
    <inner-element></inner-element>
  </template>
<outer-element>

<inner-element></inner-element>

Assuming that there are different definitions for inner-element in the light DOM vs the outer-element's template. Let's assume that inner-element gets defined before outer-element.

@rniwa
Copy link

rniwa commented Dec 12, 2024

@matthewp : in that scenario, all the elements will use the global registry since there is nothing on template or any other element to indicate it should use a scoped registry.

@matthewp
Copy link

@rniwa Thanks, that was my suspicion. That seems like a show-stopper to me. Can we add something to template or somewhere else to prevent this problem?

@rniwa
Copy link

rniwa commented Dec 12, 2024

@rniwa Thanks, that was my suspicion. That seems like a show-stopper to me. Can we add something to template or somewhere else to prevent this problem?

In the proposal @annevk made above, there is shadowrootcustomelements content attribute you can add on template to indicate that a declarative shadow DOM will use a scoped custom element registry.

@sorvell
Copy link

sorvell commented Dec 13, 2024

Thanks very much for working on this @rniwa and @annevk! I think this proposal is an improvement over the original. I have a few questions and refinement suggestions.

initializing a registry

It seems problematic to expose this only via ElementInternals since this tightly couples the ability to control a shadowRoot's registry to it being used on a custom element. In other words, how would this work, assuming the developer wants to associate #host with a specific registry?

<div id="host">
  <template shadowrootmode="open" shadowrootcustomelements="">
    <x-foo></x-foo>
  </template>
</div>

Putting an API to initialize a registry on shadowRoot seems like an obvious alternative, but this would be inconvenient for closed shadowRoots. However, (apparently) you can call attachShadow on an element with a declarative shadow root. This may be ok for now until there is a general solution for getting a closed shadowRoot for a non-custom element (like allowing it to call attachInternals?).

I also think it would be great to be able to create an imperative shadowRoot with a blank customElements for symmetry and expressiveness. If that's the case, perhaps it could be ok to set customElements on anything that has it only if its current value is null?

Might this be workable?

const shadowRoot = element.attachShadow({mode: 'closed', customElements: null}); 
shadowRoot.innerHTML = `
  <x-foo> <x-bar></x-bar>...</x-foo> 
  <x-foo> <x-bar></x-bar>... </x-foo>
`;
const xFoo1 = shadowRoot.firstElementChild;
const xFoo2 = shadowRoot.lastElementChild;
xFoo1.customElements = registryA; // upgrades XFoo1 and its subtree in registryA?
shadowRoot.customElements = registryB; // upgrades XFoo2 in registryB?
//
xFoo1.customElements = registryB // throws.

cloning

  1. What does cloneNode do if an element has a customElements set on it? Does it upgrade it in that registry?
console.assert(element.customElements == registryA); // ok
const clone = element.cloneNode(true);
console.assert(clone.constructor == registryA.get(clone.localName)) // ok?
  1. How is cloneSubtree different from importNode and if the difference is trivial, perhaps that's a better (slightly more familar) name?

@justinfagnani
Copy link

justinfagnani commented Dec 13, 2024

Thanks for making this revision @annevk and @rniwa. I'm very glad that it seems like we can just have elements remember their registry and not have to always defer to shadow roots!

A few questions / concerns:

Element creation

I think that in order to get frameworks and rendering libraries to support for scoped registries we have to make it extremely easy and performance-neutral for them to add.

The way I had proposed this was to add createElement(), importNode(), etc., on ShadowRoot not just as a way to create scoped elements, a way that shared an API subset with Document so that a library could choose or be passed an object to create elements with that's likely compatible with their current callsites.

Because ShadowRoot's optionally had an associated CustomElementsRegistry and fell back to global creation when they didn't have one, a library could always use the shadow root as the creation object, and fall back to the document when not rendering into a shadow root. This simplifies element creation a lot - there's little code or perf overhead to supporting scoped registries.

For example, In lit-html, we pass either document or a ShadowRoot as an option to render(), and so any templates cloned for that render will use the correct registry.

// Use the global scope always. (document is also the default)
render(html`<x-foo></x-foo>`, {creationScope: document});

// In a web component, use registry of the shadow root, which may or may not have a scoped registry
render(html`<x-foo></x-foo>`, {creationScope: this.shadowRoot});

On the library side, support for scopes is as simple as:

const fragment = (options?.creationScope ?? document).importNode(template.content, true);

I worry that an API like cloneSubtree() being separate from importNode() means that element creation code would have to change too much. It's either need an abstraction for creating elements and cloning templates, or type check some object and call either .importNode() or .customElements.cloneSubtree().

I think it'd be an easier lift if instead we made it possible to use a Document or ShadowRoot in more cases as a scope object. They have other useful common APIs like .styleSheets and .adoptedStyleSheets as well.

This wouldn't preclude element creation APIs from also existing on CustomElementsRegistry.

Non-shadow DOM usage and SSR

I think that like shadowRootCustomElements option on templates, we also need a way to disable upgrades for light DOM subtrees. Consider a page like:

<body>
  <x-feature-1>
    <x-foo></x-foo>
  </x-feature-1>
  <x-feature-2>
    <x-foo></x-foo>
  </x-feature-2>
</body>

Where <x-feature-1> and <x-feature-2> have independently versioned dependencies and render to light DOM, not shadow DOM. <x-foo> may be have different versions within the features. We'd like to defer upgrades of those subtrees until each feature element can setup the appropriate custom element registry. This also means that we'd need Element. customElements to be settable once like with ShadowRoots.

@rniwa
Copy link

rniwa commented Dec 14, 2024

The way we are envisioning this API will be used is that we'd use CustomElementRegistry as scoping object instead of ShadowRoot. It's more natural that way since you'd often construct a tree without necessarily having access to the future root node. You can easily fallback to document like this: (customElements || document).createElement('~').
If you wanted to use the shadow root as a scoping object, you still can. You just need to do: (shadowRoot.customElements || document).createElement(~) or (root.customElements.?createElement || root.createElement)('~').

It's possible to add convenience functions on ShadowRoot as well but it did seem like something we can wait for the community feedback.

@rniwa
Copy link

rniwa commented Dec 14, 2024

It's possible to extend this API to support an element with null registry in a document tree as a thing by introducing a new content attribute for the parser to consume but I don't think we should include that in the initial version unless we can find very important use cases that require that.

@rniwa
Copy link

rniwa commented Dec 14, 2024

It seems problematic to expose this only via ElementInternals since this tightly couples the ability to control a shadowRoot's registry to it being used on a custom element. In other words, how would this work, assuming the developer wants to associate #host with a specific registry?

Is that a common scenario? I can't think of a situation in which author uses a declarative DOM in conjunction with a scoped custom element without using a custom element on the shadow host. What are concrete use cases?

@rniwa
Copy link

rniwa commented Dec 14, 2024

I also think it would be great to be able to create an imperative shadowRoot with a blank customElements for symmetry and expressiveness. If that's the case, perhaps it could be ok to set customElements on anything that has it only if its current value is null?

We had considered that option but concluded that a setter which allows setting once then starts throwing is an exotic behavior we want to avoid. We also had hopes to make it so that elements are never exposed to scripts until its registry is initialized. However, now we realize this is not possible since end user could interact with such an element and trigger a composed event before scripts had a chance to define its registry (or else it sort of defeats the whole point of SSR). So given that, we can revisit this alternative design.

@rniwa
Copy link

rniwa commented Dec 14, 2024

How is cloneSubtree different from importNode and if the difference is trivial, perhaps that's a better (slightly more familar) name?

The primary way cloneSubtree differs from importNode is that it does deep cloning by default as Mozilla had advocated in the past (since that's what you want in most cases anyway) when we were standardizing cloneNode's default argument to be optional. We thought using the same method name would be confusing given that distinction.

@sorvell
Copy link

sorvell commented Dec 14, 2024

It seems problematic to expose this only via ElementInternals since this tightly couples the ability to control a shadowRoot's registry to it being used on a custom element. In other words, how would this work, assuming the developer wants to associate #host with a specific registry?

Is that a common scenario? I can't think of a situation in which author uses a declarative DOM in conjunction with a scoped custom element without using a custom element on the shadow host. What are concrete use cases?

We've seen a lot of interest in using scoped registries for micro-frontends (MFE) where a subtree might be managed in a framework that either doesn't use custom elements and/or doesn't want Shadow DOM. The general problem with these MFE use cases is that tend to be very over-constrained so the platform must be expressive and flexible to handle them. We can definitely try to get more feedback on these issues. Consider this scenario...

  <div id="svelte-app">
    <template shadowrootmode="closed" shadowrootcustomelements>
       My svelte MFE
      <design-system-button>version 1.2.3 so must be that registry</design-system-buttton>
    </template>
  </div>

  <div id="vue-app">
    <template shadowrootmode="closed" shadowrootcustomelements>
       My vue MFE
      <design-system-button>version 1.1.7 so must be that registry</design-system-buttton>
    </template>
  </div>

  <div id="react-app" customelements="">
    My react MFE (needs global styling!)
    <design-system-button>version 1.3.8 so must be that registry</design-system-buttton>
  </div>

@justinfagnani
Copy link

@rniwa

The way we are envisioning this API will be used is that we'd use CustomElementRegistry as scoping object instead of ShadowRoot.

One reason I (mildy) prefer at least the option of using a ShadowRoot as the scoping object is that it has other scope-related APIs, like .adoptedStyleSheets, and .getElementById(). This makes the union type of Document | ShadowRoot useful as a scope object for several purposes.

Of course, as you point out, both Document and ShadowRoot would have .customElements, but it's also somewhat weird to me that you would need to use the CustomElements interface to create elements, even built-in ones, to get scoping correct. .createElement() on Document or ShadowRoot (or maybe Element?) feels more generic.

I know similar arguments were made about the getName() API. As of now, there would be an asymmetry with .get() where customElements.createElement('div') works, but customElements.get('div') returns undefined.

Another issue for me is cloneSubtree() vs importNode(). This would seem to require more conditional code, like:

(root.customElements?.cloneSubtree?.(template.content) ?? document.importNode(template.content, true)

vs

(root.importNode ?? document.importNode)(template.content, true)

It might not seem like much, but we've seen pushback over similar things.


I also have a question about the value .customElements - when is it defined?

  • If you call attachShadow() without customElements, does shadowRoot.customElements point to the global registry, or is it undefined?
  • If it's undefined, is there a way to tell the difference between a root that uses the global registry, and a DSD that's await it's registry to be initialized?
  • Similar questions for Elements, and those created within DSD with shadowrootcustomelements

One nice thing about .createElement() on ShadowRoot is that it could throw if shadowrootcustomelements was set but initializeShadowRoot() was not yet called.

@rniwa
Copy link

rniwa commented Dec 14, 2024

Is that a common scenario? I can't think of a situation in which author uses a declarative DOM in conjunction with a scoped custom element without using a custom element on the shadow host. What are concrete use cases?

We've seen a lot of interest in using scoped registries for micro-frontends (MFE) where a subtree might be managed in a framework that either doesn't use custom elements and/or doesn't want Shadow DOM.

I can see MFE may not want to use shadow DOM. But the combination of waiting to use shadow DOM and scoped custom element registry but not custom elements for the host seems like odd combination to me. What are examples of frameworks / libraries / websites that do this?

@rniwa
Copy link

rniwa commented Dec 14, 2024

Of course, as you point out, both Document and ShadowRoot would have .customElements, but it's also somewhat weird to me that you would need to use the CustomElements interface to create elements, even built-in ones, to get scoping correct. .createElement() on Document or ShadowRoot (or maybe Element?) feels more generic.

To us, it seemed weird that ShadowRoot gets a method to create a custom / builtin element with the new design where each element is associated with a scoped custom element regardless of its root node. Why is ShadowRoot special compared to other root nodes in this new world?

I know similar arguments were made about the getName() API. As of now, there would be an asymmetry with .get() where customElements.createElement('div') works, but customElements.get('div') returns undefined.

That might be an argument for making getName and get work with builtin elements.

Another issue for me is cloneSubtree() vs importNode(). This would seem to require more conditional code, like:

(root.customElements?.cloneSubtree?.(template.content) ?? document.importNode(template.content, true)

vs

(root.importNode ?? document.importNode)(template.content, true)

Over time (with any polyfill), the former will simplify to just element.customElements.cloneSubtree(template.content). We're envisioning that the future will be custom element registry centric so that most frameworks and libraries will take registry as an argument / configuration option to create a DOM tree.

I also have a question about the value .customElements - when is it defined?

  • If you call attachShadow() without customElements, does shadowRoot.customElements point to the global registry, or is it undefined?

It points to the global registry. element.customElements will always point to a valid registry except the case of "null registry" (i.e. for elements in DSD awaiting registry initialization), in which case, it should probably return null.

One nice thing about .createElement() on ShadowRoot is that it could throw if shadowrootcustomelements was set but initializeShadowRoot() was not yet called.

That is tautologically true of createElement on CustomElementRegistry as well since there is no createElement method to call until customElements starts to return a valid registry.

@matthewp
Copy link

It seems really odd to me to have shadowRoot as a general feature but then hide some APIs behind custom element only. It's hard to answer a use-case question because it seems like it's already answered for why you use shadow DOM outside of custom elements in general. Shadow DOM is a lightweight scoping mechanism. I have used Shadow DOM outside of custom elements to render email HTML, for example. I would like to enhance this capability to run custom elements and I want to be able to version them.

@michaelwarren1106
Copy link

Is that a common scenario? I can't think of a situation in which author uses a declarative DOM in conjunction with a scoped custom element without using a custom element on the shadow host. What are concrete use cases?

We've seen a lot of interest in using scoped registries for micro-frontends (MFE) where a subtree might be managed in a framework that either doesn't use custom elements and/or doesn't want Shadow DOM.

I can see MFE may not want to use shadow DOM. But the combination of waiting to use shadow DOM and scoped custom element registry but not custom elements for the host seems like odd combination to me. What are examples of frameworks / libraries / websites that do this?

here’s the MFE use case that is of interest to me. as usual it’s a design system use case. let’s say i have a design system written in web components. so all my buttons, modals, tooltips, etc are custom elements but the app i’m writing is a react app that consumes those elements.

and my app is an MFE remote app that gets loaded async on the same page as another MFE remote app also in react AND the “host” app which is the parent of all the MFE remotes. my app is not the whole page shown to users, but just a part of it. and my app is rendered by react (a shared dependency from
the host app) into some root div.

my app contains v1.0.0 of the design system button, x-button. and the host app has v0.0.2 of x-button and another MFE remote app has v2.0.0 of x-button all at the same time.

scoping is needed so we go to set it up so that my MFE app version of the design system components can’t conflict with the host app or other MFEs remote apps that might be rendered into the same page as my MFE remote.

under the existing proposal, i’d have to:

  • render my whole MFE app in a shadow root
  • list each definition that needs scoping and register them in the registry
  • somehow tell react that it needs to use the registry for any WCs it’s rendering in my MFE

it would be easier if registries and shadow roots were disconnected because i wouldn’t have render my MFE app in shadow root. if there was a way to programmatically just “apply a registry to some div perpetually” then an MFE setup could just create the registry and the react render root separately with js, then link them together without having to involve react internals at all.

if i could do something like:

const registry = new Registry();
//add els to registry
registry.define(‘x-button’);
const root = React.createRoot(‘div’);

// tell the root that all WCs in it should use the registry first, global as a fallback
root.attachRegistry(registry);

root.render(<MyApp/>);

and not involve react at all that would be amazing for MFEs

@vospascal
Copy link

vospascal commented Dec 14, 2024

I agree with @michaelwarren1106 like I tried to make clear in discord I would love to have a similar way to how forms work currently if you wrap a form around input elements they register to that unless you set the form attribute on the input to something else. You can also apply this attribute form I think to an input outside the form tag.
This way moving an element doesn’t really matter if you have assigned the form attribute (didn’t test) same goes probably for cloning. Sure it doesn’t cover everything but it’s a well known pattern that could bing us a long way.

<!-- this registereds all to the form / could this method also work for custom elements? -->
<form id="myForm" action="/submit" method="post">
  <button type="submit">Submit</button>
  <input type="password" name="password" placeholder="password">
</form>

<input type="text" name="username" form="myForm" placeholder="Username">
<input type="text" name="email" form="myForm" placeholder="Email">

@rniwa
Copy link

rniwa commented Dec 15, 2024

if i could do something like:

const registry = new Registry();

//add els to registry

registry.define(‘x-button’);

const root = React.createRoot(‘div’);



// tell the root that all WCs in it should use the registry first, global as a fallback

root.attachRegistry(registry);



root.render(<MyApp/>);

and not involve react at all that would be amazing for MFEs

In this example, is the assumption that we must use React.createRoot to create the root element and we can't expect React to support scoped custom element registry?

I don't think we can support that due to backwards compatibility requirements. At minimum, React needs to set some flag on the element during creation to signify that the element uses a scoped registry. Browser engines have no way of knowing whether an element is a regular builtin vs. one that uses a scoped registry without some information about it during the initial construction.

@rniwa
Copy link

rniwa commented Dec 15, 2024

I have used Shadow DOM outside of custom elements to render email HTML, for example. I would like to enhance this capability to run custom elements and I want to be able to version them.

Email doesn't run scripts so you can't use scoped custom registry with it at least in its current form. We should probably explore that problem space but we're not proposing a solution for that now.

@rniwa
Copy link

rniwa commented Dec 15, 2024

I agree with @michaelwarren1106 like I tried to make clear in discord I would love to have a similar way to how forms work currently if you wrap a form around input elements they register to that unless you set the form attribute on the input to something else. You can also apply this attribute form I think to an input outside the form tag.

This way moving an element doesn’t really matter if you have assigned the form attribute (didn’t test) same goes probably for cloning. Sure it doesn’t cover everything but it’s a well known pattern that could bing us a long way.

<!-- this registereds all to the form / could this method also work for custom elements? -->

<form id="myForm" action="/submit" method="post">

  <button type="submit">Submit</button>

  <input type="password" name="password" placeholder="password">

</form>



<input type="text" name="username" form="myForm" placeholder="Username">

<input type="text" name="email" form="myForm" placeholder="Email">

How does this relate back to scoped custom element registry? In our proposal, innerHTML and alike will use the context object's custom element registry to create elements.

Our proposal does change the semantics in that a node remembers its originating custom element registry regardless of where it's inserted. So if you create a div using the global registry, it would continue to use the global registry even if it was inserted into a shadow root which uses a different registry (so that div.innerHTML will use the global registry to create elements).

In the old proposal, we always used the root node's registry so if you created a div using the global registry but inserted it into a shadow root which uses a different scoped custom element registry then div.innerHTML and alike will use the same scoped custom element registry as shadow root's.

In our view, the new proposal simplifies the mental model of using a scoped custom element registry. So long as you create an element from a desirable scoped custom element registry, all of its methods and properties will use the same registry to create elements. It doesn't change the behavior depending on where it gets inserted. This specifically allows so that if you created an element from a scoped custom element registry, the element will continue to use the same scoped registry for innerHTML and alike, allowing the use of scoped custom element registry without involving shadow DOM. It does make it difficult to use this API if you didn't have no way of controlling how an element is created, however. Both approaches have pros and cons in terms of developer ergonomics.

@matthewp
Copy link

Email doesn't run scripts so you can't use scoped custom registry with it at least in its current form. We should probably explore that problem space but we're not proposing a solution for that now.

Email doesn't need to run scripts in order to support custom elements. I, email client author, can say that <enhanced-el> is supported in my client for an enhanced experience. Non-supporting clients will display it as plain HTML. As the author of the advanced client I still want these to be scoped to the email renderer and not pollute the global scope.

@michaelwarren1106
Copy link

michaelwarren1106 commented Dec 15, 2024

In this example, is the assumption that we must use React.createRoot to create the root element and we can't expect React to support scoped custom element registry?

I don't think we can support that due to backwards compatibility requirements. At minimum, React needs to set some flag on the element during creation to signify that the element uses a scoped registry. Browser engines have no way of knowing whether an element is a regular builtin vs. one that uses a scoped registry without some information about it during the initial construction.

that was a little bit of pseudo code. createRoot takes a DOM element that already exists, it doesn’t create it.

https://react.dev/reference/react-dom/client/createRoot#createroot

my main point is that the hard part about scoping in MFEs with frameworks involved isn’t necessarily the creation of the registry and establishing the parent element for which the registry should be used. the hard part is the mechanism by which an element created with document.createElement and then inserted into a parent with a registry.

in the scenario Im talking about above, react renders the app using document.createElement for its render tree and we want to avoid having to PR into react to make react aware of a registry and use a registry.createElement instead of the global create because of what a pain it’s been to get frameworks to implement WC-related things.

so i love that the new proposal separates registries and shadow roots some, but i’m definitely way more interested in what happens in the above case. a custom element is created with document.createElement and then gets appended to the dom as a child of a parent el with a registry without knowing in advance that the parent el has a registey. imo this is the major MFE framework use case because we can assume that frameworks are using the global create when rendering and wouldn’t have any knowledge of scoped registries attached to els in its render tree.

it seems like what you’re saying is that if document.createElement is used to create a custom element, then the global registry is used. and when it’s inserted into a parent that has a registry, that element will stay the global definition and won’t re-upgrade etc.

i would want the opposite for the MFE use case. i’d want a custom element created with document.createElement to somehow become the one in a registry once it’s appended to the dom if the parent has a registry etc. not sure if that’s doable, but that would be amazing if possible

@rniwa
Copy link

rniwa commented Dec 15, 2024

in the scenario Im talking about above, react renders the app using document.createElement for its render tree and we want to avoid having to PR into react to make react aware of a registry and use a registry.createElement instead of the global create because of what a pain it’s been to get frameworks to implement WC-related things.

so i love that the new proposal separates registries and shadow roots some, but i’m definitely way more interested in what happens in the above case. a custom element is created with document.createElement and then gets appended to the dom as a child of a parent el with a registry without knowing in advance that the parent el has a registey. imo this is the major MFE framework use case because we can assume that frameworks are using the global create when rendering and wouldn’t have any knowledge of scoped registries attached to els in its render tree.

it seems like what you’re saying is that if document.createElement is used to create a custom element, then the global registry is used. and when it’s inserted into a parent that has a registry, that element will stay the global definition and won’t re-upgrade etc.

Re-upgrading isn't a thing and probably never will be. Once an element is created, the custom element is already constructed using the global registry, and we can't change that due to backwards compatibility requirements.

i would want the opposite for the MFE use case. i’d want a custom element created with document.createElement to somehow become the one in a registry once it’s appended to the dom if the parent has a registry etc. not sure if that’s doable, but that would be amazing if possible

Unfortunately, I don't think that's possible. We can't change the behavior of document.createElement at this point.

@michaelwarren1106
Copy link

ya, i figured that would be pretty difficult, just wanted to call it out as a potential goal just in case it’s somehow doable. i just wanted to avoid having to get framework buy in on the approach in order to merge a pr to enable it, but if that’s the path we need to go down, so be it. hopefully it won’t be a difficult feature to implement across the frameworks because not having frameworks support scoping will prevent adoption in MFE use cases until they do support registries somehow.

@nolanlawson
Copy link

From the Salesforce perspective, my initial feedback is that this new proposal looks fine. In our use case, we tightly control which scripts have access to which element.shadowRoots or "scoped" registries. So either with the old proposal or this one, we'd effectively be implementing:

runInScope(registry, () => {
  // code that may be creating elements with `document`, `innerHTML`, etc.
})

So either way, we're going to have to hook up all the wiring so that element creation goes to the "right" registry. Whether it's attached to the element or the shadow root is kind of irrelevant to us.

The question on our side is whether we'd even allow scripts to actually use these new APIs, or if we'd just block them entirely. Probably for a v1 we'd do the latter, especially since that's what would work for 100% of existing code anyway. (Note we are not just working with first-party code: we build a platform for second- and third-party code.)

Removing my Salesforce hat for a moment, I am still kind of concerned (as I was with the previous API) that, to build an effective MFE on top of this, you'd need to get every framework / script / library on board. And if everyone isn't playing ball, then you can easily escape the MFE boundary and cause mayhem.

It's a very tough coordination problem that people are largely solving with <iframe>s today. With scoped registries, the pitch is "It'll be more performant than <iframe>s, but it might not always work." I'm not sure that that's a compelling enough pitch for people to want to migrate. (It works for a pure-first-party situation, but in that situation you also have leverage to force people to scope their tag names manually – e.g. <my-button-v2>.)

That said, if ShadowRealms ship as well, then you could imagine a MFE framework building something like Salesforce's solution on top of this new proposal, which would work for existing scripts calling document.createElement() et al.

@Westbrook
Copy link

@rniwa do you think WebKit might be interested in putting this proposal behind a flag in Tech Preview so that WCCG members might experiment with this before the Web Components Face-to-Face in Jan/Feb to empower this conversation?

@rniwa
Copy link

rniwa commented Dec 17, 2024

@rniwa do you think WebKit might be interested in putting this proposal behind a flag in Tech Preview so that WCCG members might experiment with this before the Web Components Face-to-Face in Jan/Feb to empower this conversation?

Maybe! That would be great indeed.

@keithamus
Copy link
Contributor

partial interface Element {
  readonly attribute CustomElementRegistry? customElements;
};

Why ?? Can it return the document.customElements if the slot is empty?

@sorvell
Copy link

sorvell commented Dec 17, 2024

Why ?? Can it return the document.customElements if the slot is empty?

I think it would return null if an element is created in a shadowRoot without a registry initialized: <template shadowrootmode="open" shadowrootcustomelements><x-foo></x-foo>...

@annevk
Copy link
Member Author

annevk commented Dec 17, 2024

It likely will return null in all cases where there's no browsing context and it's not been inserted into a tree with a non-null registry parent. Working out the DOM and HTML PRs now for review that will define all of this in full detail.

annevk added a commit that referenced this issue Dec 17, 2024
Do not comment directly on this PR while it is in draft state. Use #10854 instead.

DOM PR: whatwg/dom#1341.

Tests: ...
@annevk
Copy link
Member Author

annevk commented Dec 17, 2024

I've put up draft PRs. Please do not comment directly on them. That makes them far too unwieldy. They both identify where you can leave comments. New issues are also fine. initializeShadowRoot() is still missing. Some other smaller bits might be too. I ran out of time today.

DOM side: whatwg/dom#1341
HTML side: #10869

@keithamus
Copy link
Contributor

keithamus commented Dec 18, 2024

@annevk Is the purpose of keep custom element registry null to prevent existing reified shadowdoms from trying to upgrade to scoped registries? I understand the PR is draft but right now this flag is never set, but I presume we'll want to set it when a shadowroot is appended to, to avoid shenanigans of upgrade steps during insertion of a shadowroot into a scoped-registry-owning shadow root?

Or otherwise - should insertion of a shadowroot into a scoped-registry-owning shadowroot run upgrade?

@sorvell
Copy link

sorvell commented Dec 18, 2024

Or otherwise - should insertion of a shadowroot into a scoped-registry-owning shadowroot run upgrade?

I'm not sure this could ever happen since elements are created always with a known registry. The exception is a shadowRoot with a null registry, but this can only be set via initializeShadowRoot if I understand the proposal correctly.

@annevk
Copy link
Member Author

annevk commented Dec 18, 2024

@keithamus it's set when a declarative shadow root has its shadowrootcustomelements attribute set, in the HTML PR. And when it's set it prevents inheritance of a registry upon insertion. (I contemplated giving shadow root's custom element registry a third type of value instead, but I think that would end up being less clear.)

@rniwa and I also discussed some of the feedback on initializeShadowRoot() and instead of adding that, I plan to add registry.initializeSubtree((Element or ShadowRoot) root) which allows you to set the registry of any nodes in a subtree whose current registry is null. (Initially we hoped to avoid exposing the null state to script, but that does not seem viable.)

@EisenbergEffect

This comment was marked as duplicate.

@sorvell
Copy link

sorvell commented Dec 18, 2024

@annevk To me one of the key improvements of this spec is that the need to "to look up a registry on insertion" could completely go away.

The only case that a registry needs to be set should(?) be:

  1. upon creation (if it's not global but is instead explicitly exists on the creating context)
  2. when registry.initializeSubtree is called (for elements in the given subtree where it's null).
  3. when an un-upgraded element is adopted into the document.

This (3) case is tricky. For web compat. (I think), this must default to the global registry. I would proposal that if you do registry.upgrade or registry.initializeSubtree, this should allow you to set the registry for an x-document node and otherwise it should get the global registry? I'm not sure if this makes sense though.

EDIT: upgrade can't do this because the node has to be adopted first. Perhaps just initializeSubtree could work the way I'm proposing?

If the above rules make sense and we do endeavor to remove "look up registry on insertion," we can open a separate issue for how x-document should work

@annevk
Copy link
Member Author

annevk commented Dec 18, 2024

The current proposal is that for a connected insert we'll set each inclusive descendant's registry (if it's null) to that of its parent. And if it's a ShadowRoot (whose keep custom element registry null is false) to that of its host. In certain cases this might mean it remains null, but that's okay. And generally this preserves compatibility with today's behavior. You can use initializeSubtree() before insertion if you have other plans. This is already in the DOM PR and more detailed discussion about that should probably go in whatwg/dom#1339.

@sorvell
Copy link

sorvell commented Dec 18, 2024

What are the differences between upgrade and initializeSubtree and if they are esoteric do we need both?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
addition/proposal New features or enhancements agenda+ To be discussed at a triage meeting needs implementer interest Moving the issue forward requires implementers to express interest topic: custom elements Relates to custom elements (as defined in DOM and HTML) topic: shadow Relates to shadow trees (as defined in DOM)
Development

No branches or pull requests