Skip to content

Instantly share code, notes, and snippets.

@kevinpschaaf
Last active September 24, 2020 07:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kevinpschaaf/dc80e70adabb5103cbfacae75be33471 to your computer and use it in GitHub Desktop.
Save kevinpschaaf/dc80e70adabb5103cbfacae75be33471 to your computer and use it in GitHub Desktop.
[OLD] Scoped CustomElementRegistry polyfill

[OLD] Scoped CustomElementRegistry polyfill prototype

🚨 This code has been moved to webcomponents/polyfills.

Scoped CustomElementRegistry polyfill based on proposal: https://github.com/justinfagnani/scoped-custom-elements

Technique: uses native CustomElements to register stand-in classes that delegate to the constructor in the registry for the element's scope; this avoids any manual treewalks to identify custom elements that need upgrading. Constructor delegation is achieved by constructing a bare HTMLElement, inspecting its tree scope (or the tree scope of the node it was created via) to determine its registry, and then applying the "constructor call trick" to upgrade it.

Notes/limitations/deviations from proposal:

  • In order to leverage native CE when available, observedAttributes handling must be simulated by patching setAttribute/getAttribute to call attributeChangedCallback manually, since while we can delegate constructors, the observedAttributes respected by the browser are fixed at define time. This means that native reflecting properties are not observable when set via properties.
  • In theory, this should be able to be layered on top of the Custom Elements polyfill for use on older browsers, although I've yet to test this.

Code is loosely based on earlier "Patchable CustomElementsRegistry" proof-of-concept in: https://gist.github.com/kevinpschaaf/9f8d8fc238a09656b8bad7f7b062a2fd

Demo: http://bl.ocks.org/kevinpschaaf/raw/dc80e70adabb5103cbfacae75be33471/

/**
* @license
* Copyright (c) 2020 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at
* http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at
* http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at
* http://polymer.github.io/PATENTS.txt
*/
const NativeHTMLElement = window.HTMLElement;
const nativeDefine = window.customElements.define;
const nativeGet = window.customElements.get;
const nativeRegistry = window.customElements;
const nativeCreate = Document.prototype.createElement;
const definitionForElement = new WeakMap();
const pendingRegistryForElement = new WeakMap();
const globalDefinitionForConstructor = new WeakMap();
// Constructable CE registry class, which uses the native CE registry to
// register stand-in elements that can delegate out to CE classes registered
// in scoped registries
window.CustomElementRegistry = class {
constructor(options) {
this._definitions = new Map();
this._definedPromises = new Map();
this._definedResolvers = new Map();
this._awaitingUpgrade = new Map();
// Once the registry is patched, this is easy to allow (previously upgraded
// elements won't be affected, but new elements will be upgraded with the
// new definition)
this._allowRedefinition = options && options.allowRedefinition;
}
define(tagName, elementClass) {
tagName = tagName.toLowerCase();
if (!this._allowRedefinition && this._getDefinition(tagName)) {
throw new DOMException(`Failed to execute 'define' on 'CustomElementRegistry': the name "${tagName}" has already been used with this registry`);
}
// Since observedAttributes can't change, we approximate it by patching
// set/removeAttribute on the user's class
const attributeChangedCallback = elementClass.prototype.attributeChangedCallback;
const observedAttributes = new Set(elementClass.observedAttributes || []);
patchAttributes(elementClass, observedAttributes, attributeChangedCallback);
// Register the definition
const definition = {
elementClass,
connectedCallback: elementClass.prototype.connectedCallback,
disconnectedCallback: elementClass.prototype.disconnectedCallback,
adoptedCallback: elementClass.prototype.adoptedCallback,
attributeChangedCallback: attributeChangedCallback,
observedAttributes: observedAttributes,
};
this._definitions.set(tagName, definition);
// Register a stand-in class which will handle the registry lookup & delegation
let standInClass = nativeGet.call(nativeRegistry, tagName);
if (!standInClass) {
standInClass = createStandInElement(tagName);
nativeDefine.call(nativeRegistry, tagName, standInClass);
}
if (this === window.customElements) {
globalDefinitionForConstructor.set(elementClass, definition);
definition.standInClass = standInClass;
}
// Upgrade any elements created in this scope before define was called
const awaiting = this._awaitingUpgrade.get(tagName);
if (awaiting) {
this._awaitingUpgrade.delete(tagName);
awaiting.forEach(element => {
pendingRegistryForElement.delete(element);
upgrade(element, definition);
});
}
// Flush whenDefined callbacks
const resolver = this._definedResolvers.get(tagName);
if (resolver) {
resolver();
}
return elementClass;
}
get(tagName) {
const definition = this._definitions.get(tagName);
return definition ? definition.elementClass : undefined;
}
_getDefinition(tagName) {
return this._definitions.get(tagName);
}
whenDefined(tagName) {
let promise = this._definedPromises.get(tagName);
if (!promise) {
let resolve;
promise = new Promise(r => resolve = r);
this._definedPromises.set(tagName, promise);
this._definedResolvers.set(tagName, resolve);
}
return promise;
}
_upgradeWhenDefined(element, tagName, shouldUpgrade) {
let awaiting = this._awaitingUpgrade.get(tagName);
if (!awaiting) {
this._awaitingUpgrade.set(tagName, awaiting = new Set());
}
if (shouldUpgrade) {
awaiting.add(element);
} else {
awaiting.delete(element);
}
}
}
// User extends this HTMLElement, which returns the CE being upgraded
let upgradingInstance;
window.HTMLElement = function HTMLElement() {
// Upgrading case: the StandInElement constructor was run by the browser's
// native custom elements and we're in the process of running the
// "constructor-call trick" on the natively constructed instance, so just
// return that here
let instance = upgradingInstance;
if (instance) {
upgradingInstance = undefined;
return instance;
}
// Construction case: we need to construct the StandInElement and return
// it; note the current spec proposal only allows new'ing the constructor
// of elements registered with the global registry
const definition = globalDefinitionForConstructor.get(this.constructor);
if (!definition) {
throw new TypeError('Illegal constructor (custom element class must be registered with global customElements registry to be newable)');
}
instance = Reflect.construct(NativeHTMLElement, [], definition.standInClass);
Object.setPrototypeOf(instance, this.constructor.prototype);
definitionForElement.set(instance, definition);
return instance;
}
window.HTMLElement.prototype = NativeHTMLElement.prototype;
// Helpers to return the scope for a node where its registry would be located
const isValidScope = (node) => node == document || node instanceof ShadowRoot;
const scopeForNode = (node) => {
// TODO: this is not per the proposal; assigning a one-time scope at creation
// time would require walking every tree ever created, which is avoided for now
let scope = node.getRootNode();
// If not, get the tree scope from the context that created the node;
// if that is not in a tree root, then bail
if (!isValidScope(scope)) {
scope = creationContext[creationContext.length-1].getRootNode();
if (!isValidScope(scope)) {
throw new Error('Element is being upgrade outside of a valid tree scope');
}
}
return scope;
};
// Helper to create stand-in element for each tagName registered that delegates
// out to the registry for the given element
const createStandInElement = (tagName) => {
return class ScopedCustomElementBase {
constructor() {
// Create a raw HTMLElement first
const instance = Reflect.construct(NativeHTMLElement, [], this.constructor);
// We need to install the minimum the HTMLElement prototype so that
// scopeForNode can use DOM API to determine our construction scope;
// upgrade will eventually install the full CE prototype
Object.setPrototypeOf(instance, HTMLElement.prototype);
// Get the node's scope, and its registry (falls back to global registry)
const scope = scopeForNode(instance);
const registry = scope.customElements || window.customElements;
const definition = registry._getDefinition(tagName);
if (definition) {
upgrade(instance, definition);
} else {
pendingRegistryForElement.set(instance, registry);
}
return instance;
}
connectedCallback() {
const definition = definitionForElement.get(this);
if (definition) {
// Delegate out to user callback
definition.connectedCallback && definition.connectedCallback.apply(this, arguments);
} else {
// Register for upgrade when defined (only when connected, so we don't leak)
pendingRegistryForElement.get(this)._upgradeWhenDefined(this, tagName, true);
}
}
disconnectedCallback() {
const definition = definitionForElement.get(this);
if (definition) {
// Delegate out to user callback
definition.disconnectedCallback && definition.disconnectedCallback.apply(this, arguments);
} else {
// Un-register for upgrade when defined (so we don't leak)
pendingRegistryForElement.get(this)._upgradeWhenDefined(this, tagName, false);
}
}
adoptedCallback() {
const definition = definitionForElement.get(this);
if (definition && definition.adoptedCallback) {
// Delegate out to user callback
definition.adoptedCallback.apply(this, arguments);
}
}
// no attributeChangedCallback or observedAttributes since these
// are simulated via setAttribute/removeAttribute patches
};
}
// Helper to patch CE class setAttribute/getAttribute to implement
// attributeChangedCallback
const patchAttributes = (elementClass, observedAttributes, attributeChangedCallback) => {
if (observedAttributes.size && attributeChangedCallback) {
const setAttribute = elementClass.prototype.setAttribute;
if (setAttribute) {
elementClass.prototype.setAttribute = function(name, value) {
if (observedAttributes.has(name)) {
const old = this.getAttribute(name);
setAttribute.call(this, name, value);
attributeChangedCallback.call(this, name, old, value);
} else {
setAttribute.call(this, name, value);
}
};
}
const removeAttribute = elementClass.prototype.removeAttribute;
if (removeAttribute) {
elementClass.prototype.removeAttribute = function(name) {
if (observedAttributes.has(name)) {
const old = this.getAttribute(name);
removeAttribute.call(this, name);
attributeChangedCallback.call(this, name, old, value);
} else {
removeAttribute.call(this, name);
}
};
}
}
};
// Helper to upgrade an instance with a CE definition using "constructor call trick"
const upgrade = (instance, definition) => {
Object.setPrototypeOf(instance, definition.elementClass.prototype);
definitionForElement.set(instance, definition);
upgradingInstance = instance;
new definition.elementClass();
// Approximate observedAttributes from the user class, since the stand-in element had none
definition.observedAttributes.forEach(attr => {
if (instance.hasAttribute(attr)) {
definition.attributeChangedCallback.call(instance, attr, null, instance.getAttribute(attr));
}
});
};
// Patch attachShadow to set customElements on shadowRoot when provided
const nativeAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function(init) {
const shadowRoot = nativeAttachShadow.apply(this, arguments);
if (init.customElements) {
shadowRoot.customElements = init.customElements;
}
return shadowRoot;
}
// Install scoped creation API on Element & ShadowRoot
let creationContext = [document];
const installScopedCreationMethod = (ctor, method, from) => {
const native = (from ? Object.getPrototypeOf(from) : ctor.prototype)[method];
ctor.prototype[method] = function() {
creationContext.push(this);
const ret = native.apply(from || this, arguments);
creationContext.pop();
return ret;
}
}
installScopedCreationMethod(ShadowRoot, 'createElement', document);
installScopedCreationMethod(ShadowRoot, 'importNode', document);
installScopedCreationMethod(Element, 'insertAdjacentHTML');
// Install scoped innerHTML on Element & ShadowRoot
const installScopedCreationSetter = (ctor, name) => {
const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, name);
Object.defineProperty(ctor.prototype, name, {
...descriptor,
set(value) {
creationContext.push(this);
descriptor.set.apply(this, arguments);
creationContext.pop();
}
})
}
installScopedCreationSetter(Element, 'innerHTML');
installScopedCreationSetter(ShadowRoot, 'innerHTML');
// Install global registry
Object.defineProperty(window, 'customElements',
{value: new CustomElementRegistry(), configurable: true, writable: true});
<!--
/**
* @license
* Copyright (c) 2020 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at
* http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at
* http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at
* http://polymer.github.io/PATENTS.txt
*/
-->
<script src="./custom-elements-scoped.js"></script>
<script type="module">
const baseStyle = `
:host {
display: inline-block;
border: 1px solid gray;
padding: 5px;
margin: 5px;
}`;
class ChildElementA extends HTMLElement {
static observedAttributes = ['attr'];
get name() { return `${this.localName} (ChildElementA)`; }
constructor() {
super();
console.log('constructed', this.name);
this.attachShadow({mode: 'open'}).innerHTML = `
<style>${baseStyle} :host { background: lightblue; }</style>
${this.name}
`;
}
connectedCallback() {
console.log('connected', this.name);
}
attributeChangedCallback(name, old, value) {
console.log(`attributeChangedCallback`, this.name, `${name}=${value}`);
}
}
class ChildElementB extends HTMLElement {
static observedAttributes = ['attr'];
get name() { return `${this.localName} (ChildElementB)`; }
constructor() {
super();
console.log('constructed', this.name);
this.attachShadow({mode: 'open'}).innerHTML = `
<style>${baseStyle} :host { background: orange; }</style>
${this.name}
`;
}
connectedCallback() {
console.log('connected', this.name);
}
attributeChangedCallback(name, old, value) {
console.log(`attributeChangedCallback`, this.name, `${name}=${value}`);
}
}
const hostRegistryA = new CustomElementRegistry();
hostRegistryA.whenDefined('child-element').then(() =>
console.log('child-element defined in hostRegistryA:', hostRegistryA.get('child-element')));
hostRegistryA.whenDefined('other-element').then(() =>
console.log('other-element defined in hostRegistryA:', hostRegistryA.get('other-element')));
hostRegistryA.define('child-element', class extends ChildElementA {});
class HostElementA extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open', customElements: hostRegistryA}).innerHTML = `
<style>${baseStyle} :host { background: pink; }</style>
HostElementA
<child-element id="hostA" attr="child:hostA"></child-element>
<button>Lazy define</button>
<other-element id="hostA" attr="other:hostA"></other-element>
`;
this.shadowRoot.querySelector('button').addEventListener('click', () => {
this.shadowRoot.customElements.define('other-element', class extends ChildElementB {});
});
}
}
customElements.define('host-element-a', HostElementA);
const hostRegistryB = new CustomElementRegistry();
hostRegistryB.whenDefined('child-element').then(() =>
console.log('child-element defined in hostRegistryB:', hostRegistryB.get('child-element')));
hostRegistryB.whenDefined('other-element').then(() =>
console.log('other-element defined in hostRegistryB:', hostRegistryB.get('other-element')));
hostRegistryB.define('child-element', class extends ChildElementB {});
const hostElementBTemplate = document.createElement('template');
hostElementBTemplate.innerHTML = `
<style>${baseStyle} :host { background: lightgreen; }</style>
HostElementB
<child-element id="hostB" attr="child:hostB"></child-element>
<button>Lazy define</button>
<other-element id="hostB" attr="other:hostB"></other-element>
`;
class HostElementB extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open', customElements: hostRegistryB});
this.shadowRoot.appendChild(this.shadowRoot.importNode(hostElementBTemplate.content, true));
this.shadowRoot.querySelector('button').addEventListener('click', () => {
this.shadowRoot.customElements.define('other-element', class extends ChildElementA {});
});
}
}
customElements.define('host-element-b', HostElementB);
</script>
<host-element-a></host-element-a>
<hr>
<host-element-b></host-element-b>
@manolakis
Copy link

manolakis commented Sep 22, 2020

Hello @kevinpschaaf

I have been testing it and works great. The only thing is that I found some problems with constructors. Checking the proposed spec https://github.com/w3c/webcomponents/pull/865/files#diff-5b4b2d16566e599862e53d8e7a4006faR133 customElementRegistry.define() should return a new constructor that should be able to be used programmatically, but the current implementation returns undefined.

import {OtherElement} from './my-element.js';

const registry = new CustomElementRegistry();

// define() returns a new class:
const LocalOtherElement = registry.define('other-element-2', OtherElement);
const el = new LocalOtherElement();
el.tagName === 'other-element-2';

// The same class is available from registry.get():
const O = registry.get('other-element-2')
const el2 = new O();
el2.tagName === 'other-element-2';

On the other hand, I also found that the current way of work with constructors has been altered.

import {OtherElement} from './my-element.js'; // undefined element

const instance = new OtherElement(); // creates an HTMLElement object

document.body.appendChild(instance);

VM679:1 Uncaught TypeError: Failed to execute 'appendChild' on 'Node': parameter 1 is not of type 'Node'.
    at <anonymous>:1:15
(anonymous) @ VM679:1

Defining the component doesn't seem to fix the problem

import {OtherElement} from './my-element.js'; // undefined element

customElements.define('my-elem', OtherElement);
const instance = new OtherElement(); // creates an HTMLElement object

document.body.appendChild(instance);

VM679:1 Uncaught TypeError: Failed to execute 'appendChild' on 'Node': parameter 1 is not of type 'Node'.
    at <anonymous>:1:15
(anonymous) @ VM679:1

I think the second issue could be fixed easily if the proxy is not used with the global registry. About the first one, I didn't deep enough to think about how it could be fixed.

Thanks!

@kevinpschaaf
Copy link
Author

Cool, thanks for the feedback and good catch! I didn't have a ton of time to think about it, but went ahead and made a quick update that I think addresses the issues?

@manolakis
Copy link

manolakis commented Sep 23, 2020

That's a really interesting solution! Unfortunately, the issues are not fully addressed. Those are the test I'm doing

class Sample extends HTMLElement {}

expect(() => new Sample()).to.throw(); // IMHO this is a minor bug because is not throwing an exception if the component is not defined. It returns a component <undefined></undefined>

customElements.define('my-tag', Sample);

const sampleA = new Sample();
expect(sampleA).to.be.instanceof(Sample); // true
expect(sampleA.localName).to.be.equal('my-tag'); // true

const registry = new CustomElementRegistry();
const OtherSample = registry.define('my-othertag', Sample);

expect(OtherSample).to.not.be.undefined; // false. 

const sampleB = new OtherSample(); // throws because OtherSample is undefined
expect(sampleB).to.be.instanceof(OtherSample); // false
expect(sampleB.localName).to.be.equal('my-othertag'); // false

const sampleC = new Sample();
expect(sampleC).to.be.instanceof(Sample); // true
expect(sampleC.localName).to.be.equal('my-tag'); // false. The localName is my-othertag

Hope this can help :)

@kevinpschaaf
Copy link
Author

Ok I think we were looking at different versions of the spec proposal. @justinfagnani just updated that PR with the latest version that incorporates feedback he had been incorporating in https://github.com/justinfagnani/scoped-custom-elements (and based on the comments there, I think the idea is to make that PR the source of truth going forward, sorry for the disconnect).

I believe what's implemented here roughly matches the current state of the proposal: the part about define() returning a newable subclass has been removed, and only classes registered with the global registry are allowed to be newed. I made a couple more changes to make sure that's working as intended.

Based on a discussion on the open-wc call today, we agreed to move this code into webcomponents/polyfills so that it's easier to collaborate and make PR's. I put it up here: webcomponents/polyfills#383.

We'll try and get that landed, and then it would be awesome if you could help move it forward by maybe porting your test suite over?

@manolakis
Copy link

Awesome news! 💪

Of course! I'm pretty busy until 3rd October but I'll add a test battery based on the new proposal as soon I have a time slot.

Looking forward to work on it and push for the standard. Thanks a lot!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment