Skip to content
This repository has been archived by the owner on Oct 26, 2021. It is now read-only.

Implementing customized builtin elements. #88

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ and [HTML](https://html.spec.whatwg.org/) specifications are marked with the
that `F.prototype.constructor === F` remains true. Otherwise, the polyfill
will not be able to create or upgrade your custom elements.

## Customized built-in elements.
[Customized built-in elements](https://html.spec.whatwg.org/multipage/scripting.html#customized-built-in-element)
are implemented behind a flag and can be enabled by setting
`customElements.enableCustomizedBuiltins = true` before defining any customized
builtin elements.

The status of customized built-in elements is somewhat controversial, which is why
the implementation is turned off by default. Although the v1 spec for custom
elements is finalized, there is not a consensus from browsers about whether
customized builtins will be implemented. For example, Webkit (Safari) has
stated that they will not implement customized builtins.

You can read more in [this github issue](https://github.com/w3c/webcomponents/issues/509).

### ES5 vs ES2015

The custom elements v1 spec is not compatible with ES5 style classes. This means
Expand Down
4 changes: 4 additions & 0 deletions externs/custom-elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const CustomElementState = {

/**
* @typedef {{
* name: string,
* localName: string,
* constructor: !Function,
* connectedCallback: Function,
Expand Down Expand Up @@ -59,3 +60,6 @@ Element.prototype.__CE_definition;

/** @type {!DocumentFragment|undefined} */
Element.prototype.__CE_shadowRoot;

/** @type {!String|undefined} */
Element.prototype.__CE_is;
36 changes: 27 additions & 9 deletions src/CustomElementInternals.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CEState from './CustomElementState.js';
export default class CustomElementInternals {
constructor() {
/** @type {!Map<string, !CustomElementDefinition>} */
this._localNameToDefinition = new Map();
this._nameToDefinition = new Map();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For autonomous custom elements, the custom element name is the same as localName. But for customized builtin elements, the localName is different than the name of the custom element. I have changed this Map so that it looks thing up by name instead of localName, so that it works with both autonomous and customized builtin elements.


/** @type {!Map<!Function, !CustomElementDefinition>} */
this._constructorToDefinition = new Map();
Expand All @@ -17,20 +17,20 @@ export default class CustomElementInternals {
}

/**
* @param {string} localName
* @param {string} name
* @param {!CustomElementDefinition} definition
*/
setDefinition(localName, definition) {
this._localNameToDefinition.set(localName, definition);
setDefinition(name, definition) {
this._nameToDefinition.set(name, definition);
this._constructorToDefinition.set(definition.constructor, definition);
}

/**
* @param {string} localName
* @param {string} name
* @return {!CustomElementDefinition|undefined}
*/
localNameToDefinition(localName) {
return this._localNameToDefinition.get(localName);
nameToDefinition(name) {
return this._nameToDefinition.get(name);
}

/**
Expand All @@ -41,6 +41,20 @@ export default class CustomElementInternals {
return this._constructorToDefinition.get(constructor);
}

/**
* @param {!Node} node
* @return {!CustomElementDefinition|null}
*/
nodeToCustomElementDefinition(node) {
const name = node.__CE_is || node.localName;
const definition = this.nameToDefinition(name);
if (definition && definition.localName === node.localName) {
return definition;
} else {
return null;
}
}

/**
* @param {!function(!Node)} listener
*/
Expand Down Expand Up @@ -173,6 +187,8 @@ export default class CustomElementInternals {
const elements = [];

const gatherElements = element => {
let isAttribute;

if (element.localName === 'link' && element.getAttribute('rel') === 'import') {
// The HTML Imports polyfill sets a descendant element of the link to
// the `import` property, specifically this is *not* a Document.
Expand Down Expand Up @@ -208,6 +224,9 @@ export default class CustomElementInternals {
this.patchAndUpgradeTree(importNode, visitedImports);
});
}
} else if (isAttribute = element.getAttribute('is')) {
element.__CE_is = isAttribute;
elements.push(element);
} else {
elements.push(element);
}
Expand All @@ -234,8 +253,7 @@ export default class CustomElementInternals {
upgradeElement(element) {
const currentState = element.__CE_state;
if (currentState !== undefined) return;

const definition = this.localNameToDefinition(element.localName);
const definition = this.nodeToCustomElementDefinition(element);
if (!definition) return;

definition.constructionStack.push(element);
Expand Down
66 changes: 43 additions & 23 deletions src/CustomElementRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default class CustomElementRegistry {
* @private
* @type {!Array<string>}
*/
this._unflushedLocalNames = [];
this._unflushedNames = [];
Copy link
Contributor Author

@joeldenning joeldenning Apr 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to localNameToDefinition, we now need to keep track of things based on the custom element name, not on the local name. This is because customized builtins have a different localName than a CE name.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


/**
* @private
Expand All @@ -57,20 +57,39 @@ export default class CustomElementRegistry {
}

/**
* @param {string} localName
* @param {string} name
* @param {!Function} constructor
*/
define(localName, constructor) {
define(name, constructor, options) {
if (!(constructor instanceof Function)) {
throw new TypeError('Custom element constructors must be functions.');
}

if (!Utilities.isValidCustomElementName(localName)) {
throw new SyntaxError(`The element name '${localName}' is not valid.`);
if (!Utilities.isValidCustomElementName(name)) {
throw new SyntaxError(`The element name '${name}' is not valid.`);
}

if (this._internals.localNameToDefinition(localName)) {
throw new Error(`A custom element with name '${localName}' has already been defined.`);
if (this._internals.nameToDefinition(name)) {
throw new Error(`A custom element with name '${name}' has already been defined.`);
}

let localName = name;

if (options && options.extends) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See 6. and 7. in the spec for define

if (!this['enableCustomizedBuiltins']) {
throw new Error(`Customized builtin elements are disabled by default. Set customElements.enableCustomizedBuiltins = true.`);
}

if (Utilities.isValidCustomElementName(options.extends)) {
throw new Error(`A customized builtin element may not extend a custom element.`);
}

const el = document.createElement(options.extends);
if (el instanceof window['HTMLUnknownElement']) {
throw new Error(`Cannot extend '${options.extends}': is not a real HTML element`);
}

localName = options.extends;
}

if (this._elementDefinitionIsRunning) {
Expand Down Expand Up @@ -110,6 +129,7 @@ export default class CustomElementRegistry {
}

const definition = {
name,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add name: string to CustomElementDefinition (here).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 updated

localName,
constructor,
connectedCallback,
Expand All @@ -120,9 +140,9 @@ export default class CustomElementRegistry {
constructionStack: [],
};

this._internals.setDefinition(localName, definition);
this._internals.setDefinition(name, definition);

this._unflushedLocalNames.push(localName);
this._unflushedNames.push(name);

// If we've already called the flush callback and it hasn't called back yet,
// don't call it again.
Expand All @@ -141,21 +161,21 @@ export default class CustomElementRegistry {
this._flushPending = false;
this._internals.patchAndUpgradeTree(document);

while (this._unflushedLocalNames.length > 0) {
const localName = this._unflushedLocalNames.shift();
const deferred = this._whenDefinedDeferred.get(localName);
while (this._unflushedNames.length > 0) {
const name = this._unflushedNames.shift();
const deferred = this._whenDefinedDeferred.get(name);
if (deferred) {
deferred.resolve(undefined);
}
}
}

/**
* @param {string} localName
* @param {string} name
* @return {Function|undefined}
*/
get(localName) {
const definition = this._internals.localNameToDefinition(localName);
get(name) {
const definition = this._internals.nameToDefinition(name);
if (definition) {
return definition.constructor;
}
Expand All @@ -164,27 +184,27 @@ export default class CustomElementRegistry {
}

/**
* @param {string} localName
* @param {string} name
* @return {!Promise<undefined>}
*/
whenDefined(localName) {
if (!Utilities.isValidCustomElementName(localName)) {
return Promise.reject(new SyntaxError(`'${localName}' is not a valid custom element name.`));
whenDefined(name) {
if (!Utilities.isValidCustomElementName(name)) {
return Promise.reject(new SyntaxError(`'${name}' is not a valid custom element name.`));
}

const prior = this._whenDefinedDeferred.get(localName);
const prior = this._whenDefinedDeferred.get(name);
if (prior) {
return prior.toPromise();
}

const deferred = new Deferred();
this._whenDefinedDeferred.set(localName, deferred);
this._whenDefinedDeferred.set(name, deferred);

const definition = this._internals.localNameToDefinition(localName);
const definition = this._internals.nameToDefinition(name);
// Resolve immediately only if the given local name has a definition *and*
// the full document walk to upgrade elements with that local name has
// already happened.
if (definition && this._unflushedLocalNames.indexOf(localName) === -1) {
if (definition && this._unflushedNames.indexOf(name) === -1) {
deferred.resolve(undefined);
}

Expand Down
18 changes: 14 additions & 4 deletions src/Patch/Document.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,22 @@ export default function(internals) {
* @param {string} localName
* @return {!Element}
*/
function(localName) {
function(localName, options) {
// Only create custom elements if this document is associated with the registry.
if (this.__CE_hasRegistry) {
const definition = internals.localNameToDefinition(localName);
const name = options && options.is ? options.is : localName;
const definition = internals.nameToDefinition(name);
if (definition) {
return new (definition.constructor)();
}
}

const result = /** @type {!Element} */
(Native.Document_createElement.call(this, localName));
if (options && options.is) {
result.__CE_is = options.is;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec's definition of createElement also adds the is attribute to the element, if is was given in options. (Although, I'm guessing this was left out for the same reason the attribute was removed earlier.)

result.setAttribute('is', options.is)
}
internals.patch(result);
return result;
});
Expand Down Expand Up @@ -56,17 +61,22 @@ export default function(internals) {
* @param {string} localName
* @return {!Element}
*/
function(namespace, localName) {
function(namespace, localName, options) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method also needs to add the is attribute.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 updated. Also I added a test for this.

// Only create custom elements if this document is associated with the registry.
if (this.__CE_hasRegistry && (namespace === null || namespace === NS_HTML)) {
const definition = internals.localNameToDefinition(localName);
const name = options && options.is ? options.is : localName;
const definition = internals.nameToDefinition(name);
if (definition) {
return new (definition.constructor)();
}
}

const result = /** @type {!Element} */
(Native.Document_createElementNS.call(this, namespace, localName));
if (options && options.is) {
result.__CE_is = options.is;
result.setAttribute('is', options.is)
}
internals.patch(result);
return result;
});
Expand Down
64 changes: 64 additions & 0 deletions src/Patch/HTMLElementSubclasses.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Native from './Native.js';
import CustomElementInternals from '../CustomElementInternals.js';
import CEState from '../CustomElementState.js';
import AlreadyConstructedMarker from '../AlreadyConstructedMarker.js';

/**
* @param {!CustomElementInternals} internals
*/
export default function(internals) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Browsers currently throw errors if you try to subclass native DOM elements. So we patch each of the DOM element constructors so that they don't throw errors.

for (let subclass in Native.HTMLElement_subclasses) {
patchElement(`HTML${subclass}Element`, Native.HTMLElement_subclasses[subclass]);
}

function patchElement(constructorName, NativeElement) {
if (!NativeElement) {
return;
}

window[constructorName] = (function() {
/**
* @type {function(new: HTMLElement): !HTMLElement}
*/
function PatchedElement() {
// This should really be `new.target` but `new.target` can't be emulated
// in ES5. Assuming the user keeps the default value of the constructor's
// prototype's `constructor` property, this is equivalent.
const constructor = /** @type {!Function} */ (this.constructor);

const definition = internals.constructorToDefinition(constructor);
if (!definition) {
throw new Error('The custom element being constructed was not registered with `customElements`.');
}

const constructionStack = definition.constructionStack;

if (constructionStack.length === 0) {
let element = Native.Document_createElement.call(document, definition.localName);
element.setAttribute('is', definition.name);
Object.setPrototypeOf(element, constructor.prototype);
element.__CE_state = CEState.custom;
element.__CE_definition = definition;
internals.patch(element);
return /** @type {!HTMLElement} */ (element);
}

const lastIndex = constructionStack.length - 1;
const element = constructionStack[lastIndex];
if (element === AlreadyConstructedMarker) {
throw new Error(`The ${constructorName} constructor was either called reentrantly for this constructor or called multiple times.`);
}
constructionStack[lastIndex] = AlreadyConstructedMarker;

Object.setPrototypeOf(element, constructor.prototype);
internals.patch(/** @type {!HTMLElement} */ (element));

return /** @type {!HTMLElement} */ (element);
}

PatchedElement.prototype = NativeElement.prototype;

return PatchedElement;
})();
}
};
Loading