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 machanis to determind 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 constructable, 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 registryconstmyRegistry=newCustomElementRegistry(window.customElements);
// Define a trivial subclass of XFoo so that we can register it ourselvesclassMyFooextendsXFoo {}
// Register it as `my-foo` locally.myRegistry.define('my-foo', MyFoo);
classMyElementextendsHTMLElement {
constructor() {
super();
// Use the local registry when creating the ShadowRootthis.attachShadow({mode:'open', customElements: myRegistry});
// Use the scoped element creation APIs to create elements:constmyFoo=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>`;
}
}

Questions

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?

This comment has been minimized.

Questions:

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.:

This comment has been minimized.

edited

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

This comment has been minimized.

@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>classMyLinkextendsHTMLElement {
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.

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.

This comment has been minimized.

edited

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.

constfoo="foo"~function() {
constfoo="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.

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?

This comment has been minimized.

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?

This comment has been minimized.

edited

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:

<templateis="declarative-shadow-root"> to be stamped in different places.
<linkrel="import"href="/path/to/my-element/definition.html">
or
<scriptsrc="/path/to/my-element/definition.js"></script>
or
<scripttype="module"src="/path/to/my-element/definition.html"></script>
or
<scripttype="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.

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

This comment has been minimized.

@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:

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:

This comment has been minimized.

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:

This comment has been minimized.

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.

This comment has been minimized.

@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.

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.

This comment has been minimized.

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?

This comment has been minimized.

@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.

This comment has been minimized.

edited

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.

This comment has been minimized.

@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.

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.

This comment has been minimized.

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.

This comment has been minimized.

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.

This comment has been minimized.

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.

This comment has been minimized.

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.

This comment has been minimized.

@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.

This comment has been minimized.

@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.

This comment has been minimized.

This comment has been minimized.

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.