WICG/webcomponents

Scoped Custom Element Registries

justinfagnani opened this issue ยท 115 comments

Since #488 is closed, I thought I'd open up a new issue to discuss a relatively specific proposal I have for Scoped Custom Element Registries.

Scoped Custom Element Definitions

Overview

Scoped Custom Element definitions is an oft-requested feature of Web Components. The global registry is a possible source of name collisions that may arise from coincidence, or from an app trying to define multiple versions of the same element, or from more advanced scenarios like registering mocks during tests, or a component explicitly replacing an element definition for its scope.

Since the key DOM creation APIs are global, scoping definitions is tricky because we'd need a mechanism to determine which scope to use. But if we offer scoped versions of these APIs the problem is tractable. This requires that DOM creation code is upgraded to use the new scoped APIs, something that hopefully could be done in template libraries and frameworks.

This proposal adds the ability to construct CustomElementRegistrys and chain them in order to inherit custom element definitions. It uses ShadowRoot as a scope for definitions. ShadowRoot can be associated with a CustomElementRegistry when created and gains element creation methods, like createElement. When new elements are created within a ShadowRoot, that ShadowRoot's registry is used to Custom Element upgrades.

API Changes

CustomElementRegistry

  • CustomElementRegistry(parent?: CustomElementRegistry)

    CustomElementRegistry is constructible, and able to inherit from a parent registry.

    New definitions added to a registry are not visible to the parent, and mask any registrations with the same name defined in the parent so that definitions can be overridden.

  • CustomElementRegistry.prototype.get(name: string)

    get() now returns the closest constructor defined for a tag name in a chain of registries.

  • CustomElementRegistry.prototype.getRegistry(name: string)

    Returns the closest registry in which a tag name is defined.

ShadowRoot

ShadowRoots are already the scoping boundary for DOM and CSS, so it's natural to be the scope for custom elements. ShadowRoot needs a CustomElementRegistry and the DOM creation APIs that current exist on document.

  • customElements: CustomElementRegistry

    The CustomElementRegistry the ShadowRoot uses, set on attachShadowRoot().

  • createElement(), createElementNS()
    These methods create new elements using the CustomElementRegistry of the ShadowRoot.

  • importNode()
    Imports a node into the document that owns the ShadowRoot, using the CustomElementRegistry of the ShadowRoot.

    This enables cloning a template into multiple scopes to use different custom element definitions.

Element

New properties:

  • Element.prototype.scope: Document | ShadowRoot
    Elements have DOM creation APIs, like innerHTML, so they need a reference to their scope. Elements expose this with a scope property. One difference between this and getRootNode() is that the scope for an element can never change.

  • Element.prototype.attachShadow(init: ShadowRootInit)

    ShadowRootInit adds a new property, customElements, in its options argument which is a CustomElementRegistry.

With a scope, DOM creation APIs like innerHTML and insertAdjacentHTML will use the element's scope's registry to construct new custom elements. Appending or inserting an existing element doesn't use the scope, nor does it change the scope of the appended element. Scopes are completely defined when an element is created.

Example

// x-foo.js is an existing custom element module that registers a class
// as 'x-foo' in the global registry.
import {XFoo} from './x-foo.js';

// Create a new registry that inherits from the global registry
const myRegistry = new CustomElementRegistry(window.customElements);

// Define a trivial subclass of XFoo so that we can register it ourselves
class MyFoo extends XFoo {}

// Register it as `my-foo` locally.
myRegistry.define('my-foo', MyFoo);

class MyElement extends HTMLElement {
  constructor() {
    super();
    // Use the local registry when creating the ShadowRoot
    this.attachShadow({mode: 'open', customElements: myRegistry});

    // Use the scoped element creation APIs to create elements:
    const myFoo = this.shadowRoot.createElement('my-foo');
    this.shadowRoot.appendChild(myFoo);

    // myFoo is now associated with the scope of `this.shadowRoot`, and registy
    // of `myRegistry`. When it creates new DOM, is uses `myRegistry`:
    myFoo.innerHTML = `<my-bar></my-bar>`;
  }
}

Open Issues

Questions

This section is not current. See the open issues list

  • What happens to existing upgraded elements when an overriding definition is added to a child registry?

    The simplest answer is that elements are only ever upgraded once, and adding a new definition that's visible in an element's scope will not cause a re-upgrade or prototype change.

  • Should classes only be allow to be defined once, across all registries?

    This would preserve the 1-1 relationship between a class and a tag name and the ability to do new MyElement() even if a class is not registered in the global registry.

    It's easy to define a trivial subclass if there's a need to register the same class in different registries or with different names.

  • Should registries inherit down the tree-of-trees by default, or only via the parent chain of CustomElementRegistry?

    Inheriting down the DOM tree leads to dynamic-like scoping where definitions can change depending on your position in the tree. Restricting to inheriting in CustomElementRegistry means there's a fixed lookup path.

  • Should the registry of a ShadowRoot be final?

  • Is Element.prototype.scope neccessary?

    It requires all elements to remember where they were created, possibly increasing their memory footprint. Scopes could be dynamically looked up during new DOM creation via the getRootNode() process instead, but this might slow down operations like innerHTML.

  • How does this interact with the Template Instantiation proposal?

    With Template Instantiation document.importNode() isn't used to create template instances, but HTMLTemplateElement.prototype.createInstance(). How will that know which scope to use? Should it take a registry or ShadowRoot?

/cc @domenic @rniwa @hayatoito @TakayoshiKochi

Questions:

  • when you talk about "inheritance", do you mean "hierarchy" instead?
  • is parent exposed somehow in a custom element registry? or is it just an internal slot of some sort?
  • can you clarify the use case for exposing CustomElementRegistry.prototype.getRegistry(name: string)
  • is there a way to know when a name is being defined in a particular registry?
  • when a new entry is registered in the global registry, and 10 other registers are depending on it (waiting to be upgraded), what's the process to upgrade them all? Is there a booking process somewhere?

Missing features

  • Today, with the global registry, there is no way to intercept the usage of a particular tag, which forces application to load them all during the booting process, or do some sort of book keeping on each template to load what they needed as dependencies. I wonder if we can have some sort of hook at the registry level to tell you when an tagName is being used, so you can decide what to do, go fetch it and register it, register it from another registry, etc.
  • Having to have a lineal chain of registries might be insufficient, and harder to use (this about the namespacing use-case where components in the same namespace can see/use each other, while some namespaces will have some hierarchical organization).

Recommendation

Based on those two possible missing features, and extensibility point of view, it is probably easier to find an API that delegates part of the resolution to user-land, and let users to implement the hierarchical/resolution algo. e.g.:

class MyFoo extends HTMLElement {}

// lookup must return a registry
function lookup(registry, name) {
    if (name === 'my-bar') {
        return customElements; // delegate the lookup to another known registry (in this case the global registry)
    }
    if (name === 'my-baz') {
        registry.define('my-baz', class extends HTMLElement {}); // define inline
    } else {
        // import `name` component, and define it in `register` when it is ready...
    }
    return registry;
}
const myRegistry = new CustomElementRegistry(lookup);
myRegistry.define('my-foo', MyFoo); // you can still prime the registry

This will require effectible a new constructor with a single argument, nothing else. Or could potentially be fold into ShadowRoot constructor as well.

Wish List

  • fully composable registry graph where the resolution of a name can be delegated to any registry where the logic can be customized in user-land.
  • the ability to introspect into the resolution mechanism to support mocking, lazy fetching and registration, custom resolution rules.
  • preserve the semantics of the current registry to lower the friction for implementers (being realistic here).

hey @justinfagnani, this is the comment that I've mentioned today, let's chat about this tomorrow, I can explain more.

What happens in my-foo from the above example is removed then appended into another tree unrelated to the scope? Does it just continue to work? Or is an error thrown?

fully composable registry graph where the resolution of a name can be delegated to any registry where the logic can be customized in user-land.

This seems like it could be useful for some sort of framework, but would definitely be nice to have a default (that just looks in the parent scope) so that the end user can just do something perhaps as easy as

const myRegistry = new CustomElementRegistry()
myRegistry.define('my-foo', MyFoo);
this.root = this.attachShadow({mode: 'open', registry: myRegistry})

which causes lookup to look in the parent scope (parent shadow root) when the element name is not found in the current registry.

Just tossing in a syntax idea:

this.root = this.attachShadow({mode: 'open', registry: true})
this.root.define('my-foo', MyFoo);

or maybe just simply:

this.root = this.attachShadow({mode: 'open'})
this.root.define('my-foo', MyFoo); // creates a registry internally on first use

And for advanced use (f.e. defining lookup):

this.root = this.attachShadow({mode: 'open'})

console.log(this.root.registry) // null

this.root.define('my-foo', MyFoo); // creates a registry on first use

console.log(this.root.registry) // CustomElementsRegistry

// ... and for advanced users:
this.root.registry.defineLookup(function() { ... })

// also use the registry directly:
this.root.registry.define('other-foo', OtherFoo)

This way it's easier, yet still configurable for advanced cases.

Oh! This is also a great opportunity to not require hyphens in element names of scoped registries! Maybe it's possible?

@trusktr Although I really like the idea of hyphen-less names what would happen if you happen to upgrade an existing name?

e.g. What on earth would happen in this situation:

<link rel="stylesheet" src=".." />

<script>
    class MyLink extends HTMLElement {
        constructor() {
            // Would this still be a proper link element?

            // If so this clearly wouldn't work as HTMLLinkElement
            // doesn't support attachShadow 
            this.attachShadow({ mode: 'open' })
        }
    }

    window.customElements.define('link', MyLink)
</script>

Now I think this could actually be resolvable by having a special element that must be included in head (similar to <base>/<meta>) that declares all names that will be overridden so that the browser knows ahead of time not to assign any builtin behavior to those elements.

For example it might look something like this:

<head>
    <override element="link">
    <override element="input">
    <!-- Or maybe <override elements="link input">
</head>
<body>
    <!-- link no longer works as a link tag but is just a plain html tag -->
    <link rel="stylesheet" />

    <!-- Neither does input -->
    <input type="date">

    <script>
        ...
        customElements.define('input', MyCustomInput)
    </script>
</body> 

Of course this doesn't explain what'd happen if override itself is overriden (not allowed? namespaces (<html:override element="link">)?) or any other tag like meta. Perhaps metadata tags would need to be strictly reserved by html (which would prevent future metadata tags being added but maybe the existing metadata tags (particularly <meta>) are already sufficiently flexible for all such purposes?).

I think it's simpler to keep this and #658 separate given that I don't think it's worth blocking scoped registries on a topic that I personally think is much more complicated than scoped registries.

What on earth would happen in this situation:

Just like with variable shadowing in practically any programming language, then in that case that <link> is no longer the sort of <link> from the outer scope, and it will not pull in the stylesheet, unless the Custom Element implements that.

const foo = "foo"

~function() {
  const foo = "bar"

  console.log(foo)
  // is "foo" here the same "foo" as outside? Nope, not any more, we're in a new scope!
}()

Same thing for elements! If you override a "variable" (in this case an element name) in the inner scope, then it is no longer what it was in the outer scope.

<override element="input">

But, that's in global scope. Overriding should only be possible in non-global scope. Maybe, <override element="XXX"> is something that could work, as it can signal the parser not to do certain things if xxx is tr, for example.

But then again, I don't like redundancy, I like things DRY.

Imagine if this was required in JavaScript:

const foo = "foo"

~function() {
  override foo;
  const foo = "bar"
  console.log(foo)
}()

I would not prefer a similar thing for HTML name scope. But, what if an HTML/CSS no-JS person looks at the markup? They might get confused? True! I would probably not want to do that, just like I don't override globals in JS. What it would really be useful for is, for example, coming up with new element names that don't already exist (like <node3d> or <magic>), and then if the browser by chance introduces one of those names, oh well, then the component will just work, and everyone can be happy. If a component wasn't using a yet-to-exist <magic> element before, but rather a custom element called <magic>, then, who cares if the browser introduces a new <magic> element later, as long as that component continues to work. Some other component can decide to use the builtin <magic> element by not overriding it.

I find myself in situation where I'm forced to think of another word to add to a single-word component, just to appease the hyphen rule. Sometimes I do something dumb like <stu-pid> just to get around the limitation and keep the single <wo-rd> element, which is awkward.

So my specific argument isn't leaning towards overriding certain builtins, though I can imagine that if someone wanted to implement a "super <link>" that worked the same, plus did much more, while making it easy to adopt by simple override, then why not?

Personally, I just want to use single words when I want to.

I don't think it's worth blocking scoped registries on a topic that I personally think is much more complicated than scoped registries.

Good point. That'd be great regardless of hyphen-or-not!

Scopes are completely defined when an element is created.

This sounds the most important principle in the proposal, to understand the behaviors. It's a new ownerDocument, so to say.

comments

  • We already have a global registry on window, not on document. Do we also want a registry which is associated to document such that any descendant shadow roots is not affected?

  • Related, for Element.prototype.scope, if the element is from the global registry, why document but not window?

  • Why CustomElementRegistry inherits from another CustomElementRegistry? I'd guess users may want to use elements registered to its parent scope, but rarely want to use sibling or descendant ones. Why not this inheritance (custom elements name lookup chain) be specified via ShadowRoot creation? (e.g. ShadowRootInit has inheritCustomElementRegistry: true?)

  • For mix-ins, CustomElementRegistry.prototype.import(registry: CustomElementRegistry) to import already defined elements? And for not overridden definitions, a newly created element will be given its definition's original scope.

  • If global registry contains a definition for <my-element>, and if a shadow root contains <my-element> as well, it will be upgraded before the definition for scope-local <my-element> is given, but as the element is already upgraded, it never gets upgraded to its scoped version. Later if <my-element> is appended after the scoped definition, it becomes a scoped <my-element>. This behavior is understandable as well as confusing - will this be better if we have explicit customElements.upgrade (#710)?

  • Shall we introduce ShadowRoot.prototype.adoptNode in addition to importNode?

I'd like to ask, what is the desired approach of providing definitions of scoped custom elements?
In the example above I can see it's done imperatively by someone who attaches the shadow root. That is not necessarily the same person or entity who created the shadow tree.

I have a number of use cases where shadow root is created, therefore scoped custom elements registry could be used, not for custom elements. Even for custom elements, it does not have to be exactly the same for every instance. In those cases, shadow dom is created by a separate entity and just employed by the host.

I'd like to ask about a more declarative approach and defining elements closer to the markup that uses them, like:

<template is="declarative-shadow-root"> to be stamped in different places.
  <link rel="import" href="/path/to/my-element/definition.html">
  or
  <script src="/path/to/my-element/definition.js"></script>
  or
  <script type="module" src="/path/to/my-element/definition.html"></script>
  or
  <script type="module">
    import {MyElement} from '/path/to/my-element/definition.js';
    import.meta.scriptElement.getRootNode().customElements.define('my-element',MyElement);
  </script>

   <p>Shadow dom that's encapsulated, independent, and works exactly the same anywhere it's attached</p>
   <my-element>scoped custom element, working in a scoped tree</my-element>  
</template>

The person who creates the markup for shadow dom is the one who knows best what elements need to be used.

I believe, above approach would be intuitive, and should play well with declarative Shadow DOM.

For the document tree, you can provide custom elements and scripts that work in its scope. I don't have to provide them by the entity who stamps the document - like browser or HTTP. It would be useful to be able to provide element definitions from within the shadow tree scope, that would be scoped to this tree and do not pollute the document.

However, given the HTML Imports are dead, classic <script>s have no access to currentScript, currentRoot, the only chance to achieve that is to give HTML Modules an access to current root #645, whatwg/html#1013

@tomalec one of the use cases we're trying to address is a large application that may not be able to guarantee that each tag name is used only once, whether because there are version conflicts, or because portions of the app are built and distributed separately. We see this with decentralized teams, or with applications with plug-in systems like IDEs.

The pattern that would need to develop is that elements would be distributed without self-registering:

export class MyElement extends HTMLElement {}
// no customElements.define() call

The user of the element imports and defines the element in the registry it's using:

import {MyElement} from './my-element.js';
const localRegistry = new CustomElementRegistry();
localRegistry.define('my-element', class extends MyElement {});

class MyContainer extends HTMLElement {
  constructor() {
    this.attachShadow({mode: 'open', customElements: localRegistry});
  }
}

This scopes the definition so it doesn't conflict with any other registration is the entire page.

I prefer the imperative API as a start because it's an incremental change from current patterns and doesn't tie this proposal with with another. Tying the scope to the ShadowRoot is mainly because ShadowRoot is the one scoping mechanism we have in the DOM, and it makes sense that a scope will work for a number of things like CSS, element definitions, and DOM.

If there's a situation where the shadow root creator and the registry owner are different, I suspect there will usually be a way to route the registry to or from the ShadowRoot creator to be able to get the registry to the right place.

For any declarative feature we do have a problem of referencing values in JS. The current solution is exactly CustomElementRegistry: a global keyed object that's specced to be used as a lookup from a DOM value. In general I don't think we've identified a slam-dunk pattern for referencing non-global objects from markup. This came up in the Template Instantiation discussion too, for choosing template processors from markup. Once we solve that we should be able to tell a declarative shadow root which registry to use. Speculatively (and probably a poor choice of syntax, tbh) it could be something like this:

<template is="declarative-shadow-root" registry="registry from ./registry.js">
  ...
</template>

Where registry from ./registry.js is like a JS import.

I think this is a separable concern though

I think a common pattern that might emerge is sharing a registry across a whole package rather than per-module. Since in npm generally a package can only resolve a dependency to a single version, it'll be relatively safe to have a package-wide registry that handles the element subclassing automatically and is tolerant of re-registrations:

registry.js

export class AutoScopingCustomElementRegistry extends CustomElementRegistry {
  constructor() {
    this.bases = new Map();
  }

  define(name, constructor, options) {
    if (this.bases.has(tagname)) {
      const base = defined.get(tagname);
      if (base !== constructor) {
        throw new Error('Tried to redefine a tagname with a different class');
      }
      return; // already defined
    }
    super.define(name, class extends constructor {}, options);
  }
}
export const registry = new AutoScopingCustomElementRegistry();

Now one module can define the element without making a trivial subclass:

container-a.js

import {registry} from './registry.js';
import {ChildElement) from './child-element.js';
registry.define('child-element', ChildElement);

And another module too, but it's safe and shares the definition:

container-b.js

import {registry} from './registry.js';
import {ChildElement) from './child-element.js';
registry.define('child-element', ChildElement);

It's possible this is useful enough that it should make it into the proposal.

Not all APIs can be high-level, just like not all APIs can be low-level, but no API should be mid-level :). I truly believe that scope registry should be a low-level API, imperative, following the principles of EWM. It should be something that libraries, transpilers and framework authors can rely on. Most likely tools that can do the static analysis to either bundle things together, or create the corresponding registries. I don't think we should create an API for this and expect that citizen developers will use it on the daily basics.

@caridy I don't think this proposal is at different of a level than the current CustomElementRegistry, and I don't think it would be only for tools. In fact a major reason for proposing this is to get closer to the scoping and workflow that pure-JS component solution enjoy.

Consider the following React-ish example:

import {ChildComponent} from './child-component.js';

export class ParentComponent extends Component {
  render() {
    return <ChildComponent>Hello</ChildComponent>;
  }
}

This has no problem with several components being named ChildComponent, supports renaming, and multiple versions in the same program.

I think we can get very close to this with custom elements (this example using LitElement for conciseness, it just creates a shadow root automatically):

import {ChildElement} from './child-element.js';
import {registry} from './registry.js';
registry.define('child-element', ChildElement);

export class ParentElement extends LitElement {
  render() {
    return html`<child-element>Hello</child-element>;
  }
}

This has no problem with several components being named child-element, supports renaming, and multiple versions in the same program, with only a slightly addition in LoC for making sure the element is registered. I see this as very hand-writable.

In the example above (using LitElement), how does the ParentElement connects to the register implemented in ./registry.js? Is this the global registry or a registry that is somehow connected to a container element's shadow?

In this example I'm showing the shared registry per package pattern I described above. I did forget to give the element the registry though.

We need to add:

export class ParentElement extends LitElement {
  static get registry { return registry; }
}

or:

ParentElement.registry = registry;

And have LitElement use that in it's attachShadow() call.

And then it'd be possible for SkateJS, StencilJS, PolymerJS, etc, to abstract away the boilerplate. :)

@justinfagnani the point I was trying to make is that providing a very low level API for the registry allows to implement something like what you have described, and many other cases (like those that we have discussed in our meeting last week), library authors will create abstractions on top of that like the one you just described in Lit, and I believe that will be the primary way devs will consume this API, via abstraction.

In your example above, you're adding the sugar layer via a) the import for the local registry and b) the static registry reference. And that is totally fine! What is not fine is to force framework, tool and library authors to have to do gymnastics to accomplish some scoping mechanism, and that's why I'm favoring a very low level API here.

As far as I can tell, no one has yet explained how this will work:

class MyElement extends HTMLElement {}

customRegistry.define('my-element', MyElement);

new MyElement();

Does this?

  • Throw because it's not in the global registry?
  • Create a new element, calling the constructor, because it exists in some registry? If this is the case, calling define on any registry puts the constructor in a global Set of some sort.

If the answer is the latter, meaning it is allowed, what happens when you then do:

class MyElement extends HTMLElement {}

customRegistry.define('my-element', MyElement);

let el = new MyElement();
document.body.appendChild(el);

Note that this is appending to the document body, which is not part of the customRegistry. What happens at this point? Is this treated as an HTMLUnknownElement?

@matthewp those are very good questions that need concrete answers.

The second example could be simplified to just asking what happen when you insert an already upgraded element into a different shadow or global? IMO, since the shadow dom is not really a security mechanism, it will work just fine. Meaning that the scoped registry is about facilitating the upgrading process rather than a security boundary.

Update: Thinking more about this, I believe the registry (custom or not) is just a mechanism to determine how to upgrade the elements, and not about where the element is used or not.

@matthewp While this proposal removes the 1-1 relationship between a tagname and a constructor, it preserves a 1-1 relationship between a constructor and a registration, so for any given constructor call we know what tagname to create, and what registry to associate with the instance. This doesn't require putting constructors into a global set.

In other words:

class MyElement extends HTMLElement {}
customRegistry.define('my-element', MyElement);
const el = new MyElement();

would just work. And further:

el.innerHTML = '<my-element></my-element>';

would also create a MyElement instance.

For the second question: once upgraded, an element is never downgraded or re-upgraded. Creating an element in one scope and moving it to another should neither change it's prototype, nor the scope associated with the element.

This could conceivably lead to some weird situations where after moving elements around, two elements in the same container produce different results (different prototypes) when setting their innerHTML to the same text. I think this is a very edge case, so it'll be really rare (how often are elements initially appended to ShadowRoot, then moved outside that root?) and at least this behavior is consistent.

Summary of the discussion in Tokyo:

Registry Inheritance & Scope Lookup

There were some suggestions (with possibly mild agreement?) to make registry inheritance and scope lookup dynamic based on tree location. =

That is, the CustomElementRegistry constructor would no longer take a parent argument, but look up a parent via the tree. This ensures that the structure of the registry tree agrees with the structure of the DOM tree.

Likewise, looking up the registry to use for element-creation operations like innerHTML= would be done via the tree, so that an element doesn't need to remember it's scope.

The performance concerns I had seem to not be a concern for implementers, who say they can optimize this.

Constructors & Registrations

There were few questions about how constructors would they behave. Would they only work if registered in the global registry? Could a constructor be registered with multiple registries, or would it requires trivial subclasses be registered in each? There was a desire to match iframe behavior, since it defines another registry scope already, so match what happens if you register a constructor in a frame then send the constructor to another frame.

Some discussion also about how this relates to being able to lookup a tagname by constructor. If you allow a constructor to be registered multiple times, it doesn't have a single tag name. The v0 registerElement() API actually returned the class the browser created, maybe there's something similar as in the AutoScopingCustomElementRegistry example above.

Element Creation APIs

There was a suggestion to put scoped element creation APIs on CustomElementRegistry, not ShadowRoot.

Lazy Registration

This was talked about briefly for the use case of supporting a potentially very, very large number of tag names, by being notified the first time a tag name is used allowing the registry an opportunity to fetch the definition. This feature seems separable from scopes.

For solving the lazy registration problem, after talking to @rniwa there might be some appetite to expose a low level API to observe when a unknown-element its being inserted. I will file a separate issue for that.

And further: el.innerHTML = '<my-element></my-element>'; @justinfagnani

If el.outerHTML = '<my-element></my-element>; Would not el.innerHTML = '<my-element></my-element>'; thus create

<my-element>
  <my-element></my-element>
</my-element>

?

Or were those merely adjacent thoughts?

If trivial subclassing is necessary for scoped registries to work that would be fine in my opinion.

But if this path is taken I think it would preferable for all registries except the global one to perform this trivial subclassing automatically (by default at least) so that developers don't accidentally write components that explode when put in a page together.

I think scoped root EventTarget also will be needed.
Separated elements can only communicate each other via its outer event bus, window.
Events are identified by its name as well as elements. So, as the same idea, I guess something like EventTargetRegistry will be important.

@lacolaco Could you kindly give us an example how scoped root EventTarget works?
Pseudo-code snippet might be helpful to understand the basic idea.

I think I can understand what problem you are trying to solve, but it is unclear to me how EventTargetRegistry works.

@hayatoito Just an idea, for example, I think CustomEelementRegistry can be an EventTarget.

const xRegistry = new CustomElementRegistry();

class XFoo extends HTMLElement {
  constructor() {
    super();
    this.addEventListener('click', () => {
      // dispatch a scoped event
      xRegistry.dispatchEvent(new CustomEvent('xEvent'))
    });
  }
}
class XBar extends HTMLElement {
  constructor() {
    super();
    // subscribe scoped events
    xRegistry.addEventListener('xEvent', () => {
      // ...
    });
  }
}

xRegistry.define('x-foo', XFoo);
xRegistry.define('x-bar', XBar);

image

Thanks. Yup, I think letting CustomElementRegistry (or something which is not in a node tree) inherit EventTarget would be good enough. We wouldn't need an EventTargetRegistry.

I agree. I think so, too. EventTargetRegistry is my initial thought but it was difficult to imagine the detail.

Thanks. Just in case, EventTarget is now constructible. Users can create their own EventTarget and use it for any purpose.

Wow, I didn't know that! Thank you for the information!

@lacolaco I don't quite understand the need here, at least as it related to custom elements. Why would you want to dispatch an event on the registry? Most elements will not know the registry they are registered against, only the registry they define their dependencies in.

I think you may see a similar problem with even names as tag names - that because they're not namespaced, there can be collisions. But this is separate from custom elements - any two uncoordinated pieces of code may fire unrelated events that happen to have the same name.

@justinfagnani IMHO, A set of custom elements created as a micro application may need its own closed event system because event names can conflict as you said.
And I think that system's scope is the same to its registry level.

Most elements will not know the registry they are registered against, only the registry they define their dependencies in.

For example, if elements have this.registry field which allows the element to access its own registry, it may be worth more. Each element doesn't know other elements and they are created in independent js modules without including xRegistry reference as a closure, but they can fire closed events without name collision.

Or, we could allow symbols to be used as event types and not change anything with events here. :)

Has there been any updates on this?

rniwa commented

I've added this as a potential topic for the F2F meeting at TPAC.

F2F update: situation hasn't changed. Google will work an update, but hasn't gotten around to it.

Can we formally assign someone?

I haven't had time to work on a concrete proposal, and I probably won't until January at the earliest. We're in the midst of planning at Google that might free up someone. @hayatoito might have some insight there.

I don't think there are that many more open questions after the Tokyo meeting. We just need to write it all down in one place. The polyfill could probably be updated very easily too.

I have thought some about this. If we want to be able to override elements without a hyphen, two things stand out to me:

  • Scoping of registries will be very confusing.
  • Shadow roots seem like a much better place than on element instances.

So I propose two APIs (that work together) to do this.

In an option when creating a shadow root

This will allow for optimizations, be pleasant to use and ensure that builtin elements are never rendered before elements are upgraded. I'm not against having this be the only way to override builtin elements.

(This part of the proposal could be added as a follow-up proposal.)

const shadow = element.attachShadow({
  mode: 'open',
  customElements: {
    'ul': MyUl,
    'li': MyLi,
    'my-element': MyElement
  }
})

ShadowRoot instances should each have a customElements registry

We can use ShadowRoot#customElements in the exact same way as window.customElements.

shadow.customElements.define(
  'another-element',
  class extends HTMLElement { ... }
)

Registries should not bleed through slots and shadow roots

It would be very confusing to have <ul></ul> not be the builtin element unless explicitly specified. It could also affect the performance of deeply nested shadow roots.

A nice addon proposal for perf and for property/attribute heuristics would be CustomElementRegistry.prototype.createElement.

Perhaps this would better fit a different discussion but it would be nice to be able to use the Class name as the tag name.

class MyElement extends HTMLElement {}
customElements.define(MyElement) // equivalent to `customElements.define('my-element', MyElement)`

this would be nice to fit into the above example like so:

const shadow = element.attachShadow({
  mode: 'open',
  customElements: {
    'ul': MyUl,
    'li': MyLi,
    MyElement
  }
})

Any update on this or any other solutions that would prevent conflicts in names of custom elements?

rniwa commented

Someone has to design an API, write a spec PR, and tests.

A little update here: Since the Toronto web components face-to-face, @bicknellr has been taking some of the feedback from this issue and lazy definitions and experimenting with adding APIs to the webcomponentsjs custom elements polyfill. This is highlighting a number of choices we'll have to make, and we'll be able to come back with a much more concrete proposal for them both very soon I think.

From that, neither @bicknellr or I have experience writing spec PRs, but would be willing to try or find someone who can.

rniwa commented

For the purpose of making a progress faster on this issue, it would be ideal if you split the work for lazy definitions & custom element registries when it comes to making a proposal or a spec PR, not that you can't think about both issues at once while you're working on it.

any progress?

I'd like to join the discussion with additional use case for scoping but in a different context. I am not sure if that is actually related to this but please, let me know if that would require a new feature request.

I have a problem with very complex SPA applications and custom elements. In my organization we (so far) have a one application that is completely build with WC and the rest is React.

In the platform we have several different applications developed by different teams. The applications work under single SPA. The shell app just requires new sources to be downloaded when switching between apps. There is no actual page reload.

This one WC based application is used in multiple other applications (4 so far). If we would like to incorporate the components into the build process of each application this would inevitably lead to name collision and component registration errors when another application sources are requested. Because of that currently we are bundling this single WC based application into own package and it is included by a script from our CDN. Because all apps uses the same script (from the same URL) the import occurs only once. So far it works fine.
But then if we would like to introduce a new application that re-use some of the components used in the first application that would lead to name collision again. We would have to come up with a very clever build system that can work across multiple applications and scripts that imports WC bundles depending on current context without causing names collisions. I am not sure if we would be able to do this to be honest.
Alternative would be not to bundle WC at all and host separate components on CDN. However, even after minification, this would take ages to download the application (the WC based app I mentioned before has over 120 components in the dependencies tree). More applications like that just add to that.
What I was thinking is a way to somehow scope web components bundle so there are no registry collisions. However doing this on the component level would defeat the purpose because the scope is defined at the moment of bundling specific application.
Do you guys have similar use case already? Do you think we could make it easier for complex systems to work with WC?

Yeah, we had a similar setup at my previous company. To get around this, we prefixed each custom element name with an identifier that is scoped to the app or package. In other words, we just created our own custom namespace pattern. But doing so takes a great deal of cooperation and coordination, which can get messy ๐Ÿ˜ฌ

to have a easy implamentation of this we would need

document.createElement('p',class extends ParagraphHTMLElement {})
document.createElement('my-element',class extends HTMLElement {})

note that we don't need to supply extends since we don't hornor the is attribute when we create the element our self its not the ui thats upgrading the element .

Alternativ we could expose upgrade api.

customElements.upgrade(el,class extends ParagraphHTMLElement {})
rniwa commented

Let's not pile on orthogonal proposals or problems onto this issue. We want to keep this issue focused on scoped custom element registry.

I have yet another use case for using scoped registry. I gonna need help to tackle this issue.
In our platform multiple applications can work simultaneously in the same document. Applications are coming from both our internal and external developers that builds for the platform. Because of that each application has to be secured by default.
When using global registry when one application registers an element this element instantly is available to other applications running in the document. Some of them may operate on private data (either user or customers data). This leads to the decision made by our security team to disable global registry and custom elements altogether (we use proxy object on window).
This decision makes WC unusable in our platform and I don't like this idea. So the question here would be how can we make scoping inaccessible from other scripts? Would it be possible to scope a component to a domain for example? So the application A hosted in domain a won't be able to initialize or even detect a component initialized by application B hosted on different sub domain?
I guess that would also fix an issue I mentioned in my previous comment.
Can anyone have a different idea of how to deal with such problem?

@jarrodek I am Security Specialist my Self there is a solution we call it iframe <iframe src="other.domain.to.be.save"></iframe> read also CSP Content Security Policys

Note that a parent frame has no control over a iframe while the iframe has control over the parent.

@jarrodek another note about the registry at all maybe not all got it right the registry don't register any element!!!!! It registers Element Definitions I am the creator of a next-generation Frontend Framework that uses only raw ECMA i call it tag-html and here are some examples how customElements is implamented you can also look into the pollyfill from webcomponents

<my-custom-element-that-is-undefined></my-custom-element-that-is-undefined>
<script>
const definition = { connectedCallback() { this.innerHTML = 'Works' } }
const script = document.currentScript
const previousElementSibling = script.previousElementSibling
definition.connectedCallback.call(previousElementSibling)
script.remove()
</script>

Hope that gives you some insights

class ElementDefinition {
    connectedCallback() {
        this.innerHTML = 'Works'
    }
}

customElements.define('parent-is-element-definition',class ParentIsElementDefinition extends HTMLElement {
    connectedCallback() {
        const target = this.previousElementSibling
        ElementDefinition.prototype.connectedCallback.call(target)
    }
})

customElements.define('parent-is-element-definition',class ParentIsElementDefinition extends HTMLElement {
    connectedCallback() {
        const target = this.previousElementSibling
        customElements.get('my-element-version').prototype.connectedCallback.call(target)
    }
})

the conclusion is all your components and data are always accessable via the window object always there is nothing never like private vars only exempt is nativ code from the browser but even that is opensource and can be reviewed via patching the browser.

About the Code

The Code Samples at the top do illustrate how to apply customElement definitions to the object over the original definition one time JS version one time CustomElement version your free to use it as customElements registry replacement under the Apache-2.0 Licence Cheers

Usage

<my-custom-element-that-is-undefined></my-custom-element-that-is-undefined>
<script>function(){ this.innerHTML = 'Works' }.call(document.currentScript.previousElementSibling);document.currentScript.remove();</script>

the content of my-custom-element-that-is-undefined will be "Works"

you can also define like in the second example a parent-is-element element that applys the definition to the element over it

<my-custom-element-that-is-undefined></my-custom-element-that-is-undefined>
<parent-is-element-definition></parent-is-element-definition>

At the virtual F2F in march the conclusion was to aim for a slimmed down version of #865:

  • No inheritance between registries
  • No getDefinitions()
  • "newing" the constructor of a custom element will evaluate it in the scope of the global registry.

There was a concern raised by @rniwa about moving elements and it's associated custom element registry between iframes, because the custom element registries are backed by JS and native code. (if I recall correctly).

What's the process for moving forward with this? We need to update the proposal with what's agreed on, can I help with that?

For the iframe part, does there need to be any investigation on the implementors side or do we just need to come up with a proposal on how to deal with this problem?

@frank-dspeed pardon my ignorance but what is .previousElementSpiebling ?

@LarsDenBakker I sent a PR to @justinfagnani few weeks ago (right after the meeting) with the updates:

justinfagnani#1

I'm waiting for him at this point to look at it, also waiting for him to create the repo for the proposal, so we can start organizing this better now that we have some tentative consensus. I've few cycles to work on this, and to work on the spec as well, we just need to get rolling.

Sorry Carridy, I didn't get a notification for the PR. Looking now.

As for a repo, I haven't seen other proposals in this area get their own repo. Is there precedence or a template for that?

A repo is easier IMO, e.g.:

https://github.com/mfreed7/declarative-shadow-dom

I'm fine either way! just let me know what works better.

@snuggs

<div></div>
<parent-is-element-definition></parent-is-element-definition>

div is the prevElementSibling of parent-is-element-definition

<div id="instance1"></div>
<parent-is-element-definition></parent-is-element-definition>

<div id="instance2"></div>
<parent-is-element-definition></parent-is-element-definition>

<div></div>
<parent-is-element-definition></parent-is-element-definition>


<custom-name-one></custom-name-one>
<parent-is-element-definition></parent-is-element-definition>

in that scenario i demonstrate the usage of a customElement that does fire a connectedCallback but apply the definition to the element before it

A repo is easier IMO, e.g.:

https://github.com/mfreed7/declarative-shadow-dom

I'm fine either way! just let me know what works better.

@justinfagnani considering the precedence and also using it as an example, I believe a new repo could be useful.

I'd like to offer my help - along with @caridy - to support this moving forward.

I understand I'm a new comer here, but one of the advantages I estimate is that a new repo would find a better place to prepare everything specific. Even more after a good part of the web components migrated to other specs.

WDYT? I'm looking forward to be of any help here!

@jarrodek @frank-dspeed

off topic:

build process of each application this would inevitably lead to name collision and component registration errors

For that problem, what I do is my libraries export only classes, then I let the consumer run

import {TheClass} from ''the-library"
customElement.define('any-name-the-user-wants', TheClass)

// alternative API provided by my lib:
TheClass.define('any-name') // defined <any-name> elements.

This way the end user has control on the application side instead of the library dictating the name.

Lastly, all of my classes come with default names, that the user can opt into:

import {TheClass} from ''the-library"
TheClass.define() // this registers the element with a default name, f.e. <the-class>

// alternatively register all classes in the whole lib with their default names:
import {useDefaultNames} from ''the-library"
useDefaultNames()

This leaves applications with control. If they have a collision, the specific application can be modified to specify a different name.

Some of them may operate on private data (either user or customers data). This leads to the decision made by our security team to disable global registry and custom elements altogether (we use proxy object on window).

As @frank-dspeed said, and as your security team should be saying, you should run entirely separate apps inside iframes. That's how you get security. Plus, an added benefit of this is that it would reduce custom element naming collisions because each iframe has its own registry.

But please, lets not create off-topic conversations, because it makes it more difficult and tedious for spec authors to collaborate on the issue. Your issues are related, but not directly on topic. You can open a new help topic after first asking on StackOverflow and not getting any answers in a reasonable amount of time.

As you may be able to tell, the collaborators are skipping the off-topic comments and attempting to work on the proposal.

Moderators, if you can please mark this (and a couple previous replies) as off-topic, it'll prevent them from showing up in Google search results.

@leobalter glad to hear the offer. I'll create a new repo today.

@leobalter @caridy I created the repo here, just copying the initial text from the PR https://github.com/justinfagnani/scoped-custom-elements

I'll make a PR to this to address the outstanding feedback.

That's great, @justinfagnani! Thanks!

rniwa commented

A repo is easier IMO, e.g.:
https://github.com/mfreed7/declarative-shadow-dom
I'm fine either way! just let me know what works better.

@justinfagnani considering the precedence and also using it as an example, I believe a new repo could be useful.

Please, no. We already have this, HTML, and DOM repositories, and various issues and discussion are scattered across all those repositories' issue trackers and PRs. The last thing we want is adding even more repositories and issues and PRs to follow the discussion.

rniwa commented

One thing that's unclear from https://github.com/justinfagnani/scoped-custom-elements is that in which tree(s) existing unknown custom elements are upgraded in define call.

There is another subtle but important aspect. When a shadow root is created without an explicit registry, it must use document's registry. But this is the owner document of the element, not of the global object to which the element's prototype comes from. This is an important point. Because it would mean that if you adopt a custom element C_1 from a document D_A (e.g. template document) to another document D_B, then any nested shadow roots S_N in that custom element C1's shadow roots S_1 would default to the other document D_B's registry, not the original document D_A's.

One thing that's unclear from https://github.com/justinfagnani/scoped-custom-elements is that in which tree(s) existing unknown custom elements are upgraded in define call.

I believe each registry (customElements or new instances of CustomElementRegistry) will have it's own independent tree (or mapping tree) of element definitions. The trees would be differentiated according to the entity they are associated to. e.g. customElements is always associated to the Document and new registries need to be associated to a ShadowRoot.

FWIW, define should apply definitions this way. The problem goes within the distinction in the shadow root as you already described. Idk yet what would be the best solution here, maybe @justinfagnani and @caridy can help me out on this one.

rniwa commented

One thing that's unclear from https://github.com/justinfagnani/scoped-custom-elements is that in which tree(s) existing unknown custom elements are upgraded in define call.

I believe each registry (customElements or new instances of CustomElementRegistry) will have it's own independent tree (or mapping tree) of element definitions. The trees would be differentiated according to the entity they are associated to. e.g. customElements is always associated to the Document and new registries need to be associated to a ShadowRoot.

FWIW, define should apply definitions this way. The problem goes within the distinction in the shadow root as you already described. Idk yet what would be the best solution here, maybe @justinfagnani and @caridy can help me out on this one.

I'm not sure I follow what you're saying. Multiple ShadowRoot can use a single custom / scoped CustomElementRegistry so it's unclear how it can have its own independent tree. It's more like a list of shadow trees but then the order in which those trees appear becomes a question. Presumably the order by which they're created because they don't necessary have any other well defined order.

@rniwa can you put together some kind of example to showcase the issue? I'm having a hard time understanding the problem.

rniwa commented
const registry = new CustomElementRegistry;

const d1 = document.createElement('div');
d1.attachShadow({mode: 'closed', registry}).innerHTML = '<some-element id="d1s1"></some-element><some-element id="d1s2">';

const d2 = document.createElement('div');
d2.attachShadow({mode: 'closed', registry}).innerHTML = '<some-element id="d2s1">';
const d3 = document.createElement('div');
d3.attachShadow({mode: 'closed', registry}).innerHTML = '<some-element id="d3s1">';

const iframe = document.createElement('iframe');
document.body.append(d2, d1, iframe);
iframe.contentDocument.body.append(d3);

class SomeElement extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'closed'}).innerHTML = '<other-element></other-element>';
    }
}
registry.define('some-element', SomeElement);

customElements.define('other-element', class OtherElement extends HTMLElement { });

Now, should d1s1, d1s2, d2s1, and d3s1 be upgraded? If so, in what order? Should other-element in d1s1, d1s2, d2s1, and d3s1's shadow trees be upgraded if so, in what order?

Ok, this is great @rniwa, that clarifies a lot. My intution here is as follow:

  1. Today, with the global registry, this example will upgrade 6 elements in the following order:
SomeElement with id=d2s1
SomeElement with id=d1s1
SomeElement with id=d1s2
OtherElement inside SomeElement with id=d2s1
OtherElement inside SomeElement with id=d1s1
OtherElement inside SomeElement with id=d1s2

with SomeElement with id=d3s1 being the only one that is never upgraded since it was inserted inside the iframe.

  1. Tomorrow, with the scoped registry, this example will still only 4 elements, in the following order:
SomeElement with id=d2s1
SomeElement with id=d1s1
SomeElement with id=d1s2
SomeElement with id=d3s1 (because it is defined in the scoped registry for `d3`)
OtherElement inside SomeElement with id=d2s1 (updated)
OtherElement inside SomeElement with id=d1s1 (updated)
OtherElement inside SomeElement with id=d1s2 (updated)

Updated: While OtherElement instance inside SomeElement with id=d3s1 is never upgraded because SomeElement's shadow roots is inheriting the registry from the ownerDocument, which is the iframe, and that registry does not have an entry for OtherElement.

I believe your concrete question is about d3s1, and whether or not it should be upgraded due to the fact that it is defined in a scoped registry from the outer window, which is plugged into the iframe while appending the div. But I don't see a reason why not, considering that we don't inherit or use the global registry when a scoped registry is used. Do you see any reason for a scoped registry to not be used by shadow roots attached to elements from different documents?

rniwa commented

While OtherElement instances are never upgraded because all div's shadow roots are using scoped registries.

I don't think this is right. this.attachShadow in SomeElement didn't specify any registry so it should be using the global registry.

If it automatically "inherited" parent shadow tree's registry, then this raises a new question. What happens when SomeElement is instantiated outside of a shadow tree then inserted into another shadow tree with a scoped registry. Note that this is precisely what happens in synchronous element construction case (e.g. by using new) so this behaving differently from the upgrade case is extremely confusing.

In other words, is the shadow tree's custom element registry determined at the time it's attached or is it dynamically determined by the tree structure. In order to have the semantics you described and not have an inconsistency between upgrade and synchronous construction case, we need to do the dynamic determination. But this poses yet another issue about temporarily detaching an element from a shadow tree so it's not great either.

All in all, there is a lot of open questions here.

i am not sure but i want to say something as i am deep into this stuff since some years.

Out of my View There should be no such customRegistry needed all that the current reg does is calling some hooks. we can archive that with custom conditions and mutation observer in round about 10 short lines of code.

So if one has the need for handling elements with a custom lifecycle Mutation Observer is the way to go it supports all that.

Some none tested code examples out of my head showing all 4 hooks

  const attributesObserver = new MutationObserver(function(mutations, observer) {
    mutations.forEach(function(mutation) {
      if (mutation.type == "attributes") {       
          attributeChangedCallback.apply(mutation.target,[mutation.attributeName, mutation.oldValue mutation.target.getAttribute(mutation.attributeName)])
      }
    });
  });

  const childListObserver = new MutationObserver(function(mutations, observer) {
    mutations.forEach(function(mutation) {
      if (mutation.type === 'childList') {
        mutation.addedNodes.forEach(connectedCallback.apply)
        mutation.removededNodes.forEach(disconnectedCallback.apply)
      }
    });
  });

attributesObserver.observe(el, {
  attributes: true, //configure it to listen to attribute changes
  attributeOldValue: true,
  attributeFilter: ['hidden', 'contenteditable', 'data-par'] // individual listen to attributes.
});
  
childListObserver.observe(el.parent, { childList: true, subtree: true });  

@rniwa

While OtherElement instances are never upgraded because all div's shadow roots are using scoped registries.

I don't think this is right. this.attachShadow in SomeElement didn't specify any registry so it should be using the global registry.

I think you're right, let me update my comment above to reflect that OtherElement instances for SomeElement inserted in the main window should get upgraded. Can you take a look at my previous comment again?

If it automatically "inherited" parent shadow tree's registry, then this raises a new question. What happens when SomeElement is instantiated outside of a shadow tree then inserted into another shadow tree with a scoped registry. Note that this is precisely what happens in synchronous element construction case (e.g. by using new) so this behaving differently from the upgrade case is extremely confusing.

I was under the impression that we have some previous agreements about these cases: when an element is upgraded inside a shadow root, the lookup will occur at that point, while attachShadow() will have no side effects on the matter other than setting up the registry to be used if provided.

In other words, is the shadow tree's custom element registry determined at the time it's attached or is it dynamically determined by the tree structure. In order to have the semantics you described and not have an inconsistency between upgrade and synchronous construction case, we need to do the dynamic determination. But this poses yet another issue about temporarily detaching an element from a shadow tree so it's not great either.

In the explainer, it is listed as dynamically determined by the tree structure. This opens the door for replacing the registry at any given time, as a possibility, or just replacing the registry when adopted by another document, etc.

About the temporarily detaching an element issue that you mentioned above, can you provide more information? Maybe another example? The way I see it, if an element is upgraded already (dynamically or via sync construction), and that element is temporarily detaching (while moving it around), it should not have any side effects on any element from its shadow root that was already upgraded.

I realize that we don't have an issue specifically covering scoped registries and declarative shadow DOM interaction. I'll open that now. We also have a couple of issues from #895 to be opened.

I just oped #923 to specifically discuss upgrade ordering.

lazka commented

In the explainer, it is listed as dynamically determined by the tree structure. This opens the door for replacing the registry at any given time, as a possibility, or just replacing the registry when adopted by another document, etc.

Sorry for the noise, maybe this is obvious, but would this also affect slotted nodes? I would assume the registry not changing when you slot something.

@lazka slotted notes are not affected by the local registry since they belong to a different shadow root or document. Scoped registry only operates on the elements inserted inside the shadow root in question.

lazka commented

Makes sense, thank you!

I believe it would be better that if someone does something fishy, like moving a scoped custom element from a ShadowRoot to the main Document or another ShadowRoot, an error would be thrown (f.e. DOMException: Scoped custom element does not belong to this Document or ShadowRoot). I do not think that allowing weird patterns is ideal in any language or system in general, if it can be avoided.

Just having scoped registries, even if we can move elements around and end up with same-tag elements with different prototypes, would still be great though. I just believe in helping end users by throwing meaningful errors when they use APIs in ways that we don't want them to.

Because using Scoped Custom Element Registries is an opt-in feature, there is no harm / no breaking change in throwing errors for those weird scenarios, and honestly it will help people by keeping their programs in states that make sense. Errors serve as self-documenting features of a system.

I believe it would be better that if someone does something fishy, like moving a scoped custom element from a ShadowRoot to the main Document or another ShadowRoot, an error would be thrown (f.e. DOMException: Scoped custom element does not belong to this Document or ShadowRoot). I do not think that allowing weird patterns is ideal in any language or system in general, if it can be avoided.

Even if people want to move elements in and out of a shadow root, it could be an option to require using .adoptNode, which would require the element also be defined in the target ShadowRoot/Document (otherwise throw an error for not being defined in scope), this would change the name as needed and also call adoptedCallback on the element which would be a useful signal that it has changed scopes.

We use "portals" which teleport content from one context to another, say document.body for overlays. I expect this may be a common pattern and it would be nice to be able to utilize scoping somehow (probably driven by the original context).

@robrez scoping for your case is easy simply use MutationObserver on the sections and then upgrade your elements.

I also think it would make more sense as a CSS proposal.

.tooltip {
  layout-context: root;
}

Or with a wrapper

.wrapper {
  layout-id: 'wrapper';
}

.tooltip {
  layout-context: 'wrapper', root;
}

Or something. Portals are a great solution in frameworks for now, but they solve a styling issue which is better fixed by improving CSS.

Does class resolution involve any kind of bubbling?

For example... say there are two classes, "FancyButton1" and "FancyButton2"...

<body>
  <fancy-button> <!-- FancyButton1 -->
    <some-module>
      #shadowRoot (fancy-button = FancyButton2)
        <fancy-button>
        <some-other-module>
          #shadowRoot
            <fancy-button> <!-- what am I? -->

Does class resolution involve any kind of bubbling?

IMO, there shouldn't be any kind of implicit recursive lookup, even just into the global registry. Otherwise, you don't really have control over what's actually in your 'scoped' registry.

I'm less opinionated about whether or not recursive lookup should be possible with an explicit signal - such as passing a 'parent'/'fallback' registry when constructing a new scoped registry - but I don't think that enabling that should be considered a blocker for scoped registries in general. It seems separable enough that it would be ok if scoped custom element registries initially shipped without any recursive lookup at all.

Both of those cases are not part of the MVP. They were at some point, but we settled on a more simpler approach. Either the shadow has a registry associated to it, or it doesn't, in which case it uses the global one.

Yeah, that makes sense. I had a use-case where some-module and some-other-module are kinda related packages such that only some-module uses some-other-module and it was causing me to have some tunnel vision and a bit of distorted view.

Things could probably get quite unreliable if bubbling lookups started happening depending on where you reparented an element that didn't engage in a scoped registration (of its dependencies). I suppose the behavior in this case must be to look at the global registry (otherwise breaking changes start flaring up)

I think non-bubbling is definitely better. Bubbling lead to a bit of confusion in AngularJS and non-bubbling improves separation. I believe non-bubbling will reduce the cognitive load of working with custom elements.

I think globally scoped custom elements should be available in scoped shadow roots. Registering core UI elements in the global registry would be a productivity boost. But as with anything global it is a footgun. It would also allow more gradual refactoring to scoped registries. I also think it would be a source of confusion if your custom element stopped working after adding scoped custom elements. There would be a lot of StackOverflow questions about it! ๐Ÿ˜„

I really like how you register local components in Vue, and I think we should take some inspiration from that.

this.attachShadow({
  mode: 'open',
  customElements: {
    MyInput,
    SomeWidget,
  }
})

With PascalCase it is easier to compose multiple custom element collections. If kebab-case is required, you would have to default export an object. Greppability will however improve if kebab-case is required, so I don't have a strong opinion on the matter.

import * as formElements from './elements/formElements.js'
import * as buttons from './elements/buttons.js'

this.attachShadow({
  mode: 'open',
  customElements: {
    ...formElements,
    ...buttons,
  }
})

If there is a need to access or set the CustomElementsRegistry directly (I can't think of a good reason), it could be accessed through ShadowRoot#customElementsRegistry and specified by passing customElementsRegistry to attachShadow with an XOR check for customElements and customElementsRegistry.

Soo, this has been open for a while. Does anyone have a polyfill for any of the suggestions?
Can we push this or any unregister-way so that native web components would be able to gain HMR capabilities?

Right now, what I can see is that if we hijack the current registry, we could define elements with a hash in them, make a shim for the original name that then pass everything to the hashed version and just continuously re-register with a new hash and re-render.

While it will have some memory-leak issues over time, it would be only really present in the development cycle where we could hit a refresh button every now and then to avoid it creeping too high.

I also think it would be a source of confusion if your custom element stopped working after adding scoped custom elements. There would be a lot of StackOverflow questions about it! ๐Ÿ˜„

This is a really good point. Suppose we took this problem to JavaScript. Imagine we have this code:

let foo = 123

function doIt() {
  console.log(foo)
}

It would be surprising and unexpected if writing

let foo = 123

function doIt() {
  let bar = 456
  console.log(foo)
}

suddenly caused the console.log to start outputting undefined because the function scope now has it's own "registry" of variables.

In this same vein, it seems there has to be a fallback lookup. We have some options (Option 3 is my favorite):

  1. Specifying a downgrade operation for custom elements might be useful: if a custom element was upgraded to a global class because the local scoped registry didn't have a class defined, but then later the registry gets the class defined, the element can be downgraded before being upgraded to its new class.

  2. Any elements already upgraded to a higher-registry's definition, before a scoped registry gets a definition, stay with the original class. Elements inserted after the scoped registry has the definition take on the new definition.

  3. Once a scoped registry is live (belongs to a shadowroot that is connected into a document) all higher-registry definitions that aren't contained by the scoped registry have been inherited and all of the scoped registry's definitions become permanent (we can not add new ones). This would require people to think ahead, and to pre-define a scoped registry's definitions prior to making the registry live. Once live, definitions are permanent in the same sense as how we cannot arbitrarily add or remove variables from a function scope.

Option 3 is the "type safe" way to do it: the developer has a higher chance of knowing ahead of time what elements they will use in their program. Everything works as expected (for the most part): no gotchas, unexpected results, or bugs resulting from elements accidentally treated as the wrong type of element, or resulting from upgraded vs not-yet-upgraded elements (one of the banes of custom elements development), etc.

I say "higher chance" because the global registry is still dynamic, and definitions can be added any time after it is already live (it is already live from the start of a web app).

this.attachShadow({
  mode: 'open',
  customElements: {
    MyInput,
    SomeWidget,
  }
})

This is interesting, and enforces type safety. ๐Ÿ‘ We pre-define what we'll use, with no surprises, and no difficulties from having to think about pre-upgraded instances.

Having to handle pre-existing property values from pre-upgrade instances with the class fields [[Define]] abominable snowman chasing us makes things extremely difficult. People shouldn't have to think about this sort of thing.

Unfortunately, even with the type safe approach, cloneNode does not construct custom elements, which still poses the pre-upgrade problem for anyone using the cloneNode API (like many DOM template libs do). The worst thing is that not all CE authors even know about the pre-upgrade problem, and their elements are destined to not work in various apps using different techs.

Soo, this has been open for a while. Does anyone have a polyfill for any of the suggestions?

we are currently using this
https://github.com/webcomponents/polyfills/tree/master/packages/scoped-custom-element-registry

Can we push this or any unregister-way so that native web components would be able to gain HMR capabilities?

you might wanna take a look at
https://open-wc.org/docs/development/hot-module-replacement/

Right now, what I can see is that if we hijack the current registry, we could define elements with a hash in them, make a shim for the original name that then pass everything to the hashed version and just continuously re-register with a new hash and re-render.

you mean register not the "actual" component name but my-component-2dfjk43?
this is how version of scoped elements was implemented
https://www.npmjs.com/package/@open-wc/scoped-elements/v/1.3.3

it sort of works... but

  • it's not fully scoped (can be misused and you still have access to the global registry which can result in issues if you move nodes from light to shadow dom or vise versa)
  • css selectors do not work
  • annoying to use in tests (as tag names are always different)

in version 2 we are now using the scoped custom elements registry linked above


@trusktr

This is a really good point. Suppose we took this problem to JavaScript. Imagine we have this code:

let foo = 123

function doIt() {
  console.log(foo)
}

IMHO this is taking the wrong "conclusion". Writing code like this couples your function into a specific location in the code. e.g. you will not be able to move your doIt function into any other file... which is probably fine for JS but we don't wanna have web component that only work if rendered in a specific dom.

e.g. if I define my custom element and it uses a <sub-el> in its shadow dom... it needs to bring that sub-el with it.. don't expect other web components to be available globally

<my-el>
  # shadow-dom
      <sub-el></sub-el>
</my-el>      

We have been using ScopeElements for ~2 years now and explicitly defining ALL web component you use in your shadow dom is absolutely essential. And yes you can still say to grab a specific component from the global registry... but you need to be aware that this component becomes "less shareable".

We specifically added an example for that

https://open-wc.org/docs/development/scoped-elements/#usage

Screenshot 2022-02-08 at 12 32 08

I've had many requests from important partners like Adobe, ING, YouTube, and other Google teams to try to get this proposal moving again. For some potential customers, this issue has been a blocker from adopting custom elements at all. The larger the team/organization using custom elements, the more critical the tag name collision problem becomes.

The current open design questions are:

I'll update the main text of this issue to list these as well so that teams can use this as a tracking issue from the spec side.

We at Microsoft are also very, very interested in this and consider it an extremely high priority as well. Let us know how we can help get it moving.

@EisenbergEffect I believe the issues listed above at #716 (comment) are still the missing parts, which require feedback on implementation details, IMO.

While my team at Salesforce cannot provide the implementation aspects of it, I'm happy to have engineers from the LWC framework involved if you need more information.

cc @caridy @nolanlawson @pmdartus

@EisenbergEffect we can try to get the ball rolling again. I will be happy to discuss the current open questions with a small group. In our case, we went from "absolutely needing this feature" to "maybe virtualizing the global registry rather than using a local/scoped registry" due to the lack of progress.

We have quite a few major customers, including Google as a whole that need this feature.

I have talked with @mfreed7 about the open issues and when Chrome could prototype this feature to get real implementation feedback on the open questions.

At least for the upgrade ordering question (#923) , it seems like a path forward might be specifying document order upgrades and letting implementations optimize as they can by tracking which shadow roots are associated with which registries to avoid a whole document tree walk.

I think #914 is solvable by requiring top-down upgrade order, and #907 could be informed with implementor feedback.

I also talked with @dandclark on our side yesterday. Maybe we can re-infuse this with some energy and move it forward. I'll dig into the issues myself in the next couple of days. @justinfagnani What you are saying above makes good sense to me though.

Additionally, I'd love to hear how folks are solving for their scenarios while waiting for this to land. I have a number of ideas but have been hesitant to implement any of them. @caridy I'd love to hear more about your experience there.