diff --git a/build-system/dep-check-config.js b/build-system/dep-check-config.js index 68621aab83e1..f55c6f41e0e1 100644 --- a/build-system/dep-check-config.js +++ b/build-system/dep-check-config.js @@ -291,6 +291,8 @@ exports.rules = [ 'src/polyfills.js->src/polyfills/object-assign.js', 'src/polyfills.js->src/polyfills/promise.js', 'src/polyfills.js->src/polyfills/array-includes.js', + 'src/polyfills.js->src/polyfills/custom-elements.js', + 'src/service/extensions-impl.js->src/polyfills/custom-elements.js', 'src/service/extensions-impl.js->src/polyfills/document-contains.js', 'src/service/extensions-impl.js->src/polyfills/domtokenlist-toggle.js', ], diff --git a/build-system/tasks/bundle-size.js b/build-system/tasks/bundle-size.js index 054a62c63b18..590bab259fed 100644 --- a/build-system/tasks/bundle-size.js +++ b/build-system/tasks/bundle-size.js @@ -22,7 +22,7 @@ const log = require('fancy-log'); const {getStdout} = require('../exec'); const runtimeFile = './dist/v0.js'; -const maxSize = '79.31KB'; +const maxSize = '80.60KB'; const {green, red, cyan, yellow} = colors; diff --git a/build-system/tasks/compile.js b/build-system/tasks/compile.js index ff8884fee452..3984858c9680 100644 --- a/build-system/tasks/compile.js +++ b/build-system/tasks/compile.js @@ -278,21 +278,19 @@ function compile(entryModuleFilenames, outputDir, // once. Since all files automatically wait for the main binary to load // this works fine. if (options.includeOnlyESMLevelPolyfills) { - const polyfillsShadowList = [ - 'array-includes.js', - 'document-contains.js', - 'domtokenlist-toggle.js', - 'math-sign.js', - 'object-assign.js', - 'promise.js', - ]; + const polyfills = fs.readdirSync('src/polyfills'); + const polyfillsShadowList = polyfills.filter(p => { + // custom-elements polyfill must be included. + return p !== 'custom-elements.js'; + }); srcs.push( '!build/fake-module/src/polyfills.js', '!build/fake-module/src/polyfills/**/*.js', '!build/fake-polyfills/src/polyfills.js', - '!src/polyfills/*.js', + 'src/polyfills/custom-elements.js', 'build/fake-polyfills/**/*.js'); polyfillsShadowList.forEach(polyfillFile => { + srcs.push(`!src/polyfills/${polyfillFile}`); fs.writeFileSync('build/fake-polyfills/src/polyfills/' + polyfillFile, 'export function install() {}'); }); diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js index b8d2f3e5434e..903e96c4ffc0 100644 --- a/build-system/tasks/presubmit-checks.js +++ b/build-system/tasks/presubmit-checks.js @@ -545,6 +545,7 @@ const forbiddenTerms = { whitelist: [ 'src/log.js', // Has actual implementation of assertElement. 'dist.3p/current/integration.js', // Includes the previous. + 'src/polyfills/custom-elements.js', ], }, 'startupChunk\\(': { diff --git a/extensions/amp-form/0.1/form-proxy.js b/extensions/amp-form/0.1/form-proxy.js index be393820edda..cc2508ede9fc 100644 --- a/extensions/amp-form/0.1/form-proxy.js +++ b/extensions/amp-form/0.1/form-proxy.js @@ -88,28 +88,39 @@ function createFormProxyConstr(win) { } const FormProxyProto = FormProxy.prototype; + const {Object} = win; + const ObjectProto = Object.prototype; // Hierarchy: // Node <== Element <== HTMLElement <== HTMLFormElement // EventTarget <== HTMLFormElement - const inheritance = [ + const baseClasses = [ win.HTMLFormElement, - win.HTMLElement, - win.Element, - win.Node, win.EventTarget, ]; - inheritance.forEach(function(klass) { - const prototype = klass && klass.prototype; - for (const name in prototype) { - const property = win.Object.getOwnPropertyDescriptor(prototype, name); + const inheritance = baseClasses.reduce((all, klass) => { + let proto = klass && klass.prototype; + while (proto && proto !== ObjectProto) { + if (all.indexOf(proto) >= 0) { + break; + } + all.push(proto); + proto = Object.getPrototypeOf(proto); + } + + return all; + }, []); + + inheritance.forEach(proto => { + for (const name in proto) { + const property = win.Object.getOwnPropertyDescriptor(proto, name); if (!property || // Exclude constants. name.toUpperCase() == name || // Exclude on-events. startsWith(name, 'on') || // Exclude properties that already been created. - win.Object.prototype.hasOwnProperty.call(FormProxyProto, name) || + ObjectProto.hasOwnProperty.call(FormProxyProto, name) || // Exclude some properties. Currently only used for testing. (blacklistedProperties && blacklistedProperties.includes(name))) { continue; @@ -118,19 +129,19 @@ function createFormProxyConstr(win) { // A method call. Call the original prototype method via `call`. const method = property.value; FormProxyProto[name] = function() { - return method.apply(this.form_, arguments); + return method.apply(/** @type {!FormProxy} */(this).form_, arguments); }; } else { // A read/write property. Call the original prototype getter/setter. const spec = {}; if (property.get) { spec.get = function() { - return property.get.call(this.form_); + return property.get.call(/** @type {!FormProxy} */(this).form_); }; } if (property.set) { - spec.set = function(value) { - return property.set.call(this.form_, value); + spec.set = function(v) { + return property.set.call(/** @type {!FormProxy} */(this).form_, v); }; } win.Object.defineProperty(FormProxyProto, name, spec); diff --git a/src/custom-element.js b/src/custom-element.js index 45883d80ed38..1dc1f47d0507 100644 --- a/src/custom-element.js +++ b/src/custom-element.js @@ -1756,11 +1756,11 @@ function isInternalOrServiceNode(node) { * @param {function(new:./base-element.BaseElement, !Element)=} opt_implementationClass For testing only. * @return {!Object} Prototype of element. */ -export function createAmpElementProtoForTesting( +export function createAmpElementForTesting( win, name, opt_implementationClass) { - const ElementProto = createCustomElementClass(win, name).prototype; + const Element = createCustomElementClass(win, name); if (getMode().test && opt_implementationClass) { - ElementProto.implementationClassForTesting = opt_implementationClass; + Element.prototype.implementationClassForTesting = opt_implementationClass; } - return ElementProto; + return Element; } diff --git a/src/polyfills.js b/src/polyfills.js index 771a63654b09..7b574c8b929e 100644 --- a/src/polyfills.js +++ b/src/polyfills.js @@ -14,7 +14,9 @@ * limitations under the License. */ +import {getMode} from './mode'; import {install as installArrayIncludes} from './polyfills/array-includes'; +import {install as installCustomElements} from './polyfills/custom-elements'; import { install as installDOMTokenListToggle, } from './polyfills/domtokenlist-toggle'; @@ -22,12 +24,15 @@ import {install as installDocContains} from './polyfills/document-contains'; import {install as installMathSign} from './polyfills/math-sign'; import {install as installObjectAssign} from './polyfills/object-assign'; import {install as installPromise} from './polyfills/promise'; -// Importing the document-register-element module has the side effect -// of installing the custom elements polyfill if necessary. -import {installCustomElements} from +import {installCustomElements as installRegisterElement} from 'document-register-element/build/document-register-element.patched'; +import {isExperimentOn} from './experiments'; -installCustomElements(self, 'auto'); +if (isExperimentOn(self, 'custom-elements-v1') || getMode().test) { + installCustomElements(self, class {}); +} else { + installRegisterElement(self, 'auto'); +} installDOMTokenListToggle(self); installMathSign(self); installObjectAssign(self); diff --git a/src/polyfills/custom-elements.js b/src/polyfills/custom-elements.js new file mode 100644 index 000000000000..52b7769587e6 --- /dev/null +++ b/src/polyfills/custom-elements.js @@ -0,0 +1,713 @@ +/** + * Copyright 2018 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @typedef {{ + * promise: !Promise, + * resolve: function(), + * }} + */ +let DeferredDef; + +/** + * @typedef {!Function} + */ +let CustomElementConstructorDef; + +/** + * @typedef {{ + * name: string, + * ctor: !CustomElementConstructorDef, + * }} + */ +let CustomElementDef; + +/** + * Validates the custom element's name. + * This intentionally ignores "valid" higher Unicode Code Points. + * https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name + */ +const VALID_NAME = /^[a-z][a-z0-9._]*-[a-z0-9._-]*$/; +const INVALID_NAMES = [ + 'annotation-xml', + 'color-profile', + 'font-face', + 'font-face-src', + 'font-face-uri', + 'font-face-format', + 'font-face-name', + 'missing-glyph', +]; + +/** + * Asserts that the custom element name conforms to the spec. + * + * @param {!Function} SyntaxError + * @param {string} name + */ +function assertValidName(SyntaxError, name) { + if (!VALID_NAME.test(name) || INVALID_NAMES.indexOf(name) >= 0) { + throw new SyntaxError(`invalid custom element name "${name}"`); + } +} + +/** + * Does win have a full Custom Elements registry? + * + * @param {!Window} win + * @return {boolean} + */ +function hasCustomElements(win) { + const {customElements} = win; + + return !!( + customElements && + customElements.define && + customElements.get && + customElements.whenDefined); +} + +/** + * Was HTMLElement already patched for this window? + * + * @param {!Window} win + * @return {boolean} + */ +function isPatched(win) { + const tag = win.HTMLElement.toString(); + return tag.indexOf('[native code]') === -1; +} + +/** + * The public Custom Elements API. + */ +class CustomElementRegistry { + /** + * @param {!Window} win + * @param {!Registry} registry + */ + constructor(win, registry) { + /** + * @const @private + */ + this.win_ = win; + + /** + * @const @private + */ + this.registry_ = registry; + + /** + * @type {!Object} + * @private + * @const + */ + this.pendingDefines_ = this.win_.Object.create(null); + } + + /** + * Register the custom element. + * + * @param {string} name + * @param {!CustomElementConstructorDef} ctor + * @param {!Object=} options + */ + define(name, ctor, options) { + this.registry_.define(name, ctor, options); + + // If anyone is waiting for this custom element to be defined, resolve + // their promise. + const pending = this.pendingDefines_; + const deferred = pending[name]; + if (deferred) { + deferred.resolve(); + delete pending[name]; + } + } + + /** + * Get the constructor of the (already defined) custom element. + * + * @param {string} name + * @return {!CustomElementConstructorDef|undefined} + */ + get(name) { + const def = this.registry_.getByName(name); + if (def) { + return def.ctor; + } + } + + /** + * Returns a promise that waits until the custom element is defined. + * If the custom element is already defined, returns a resolved promise. + * + * @param {string} name + * @return {!Promise} + */ + whenDefined(name) { + const {Promise, SyntaxError} = this.win_; + assertValidName(SyntaxError, name); + + if (this.registry_.getByName(name)) { + return Promise.resolve(); + } + + const pending = this.pendingDefines_; + const deferred = pending[name]; + if (deferred) { + return deferred.promise; + } + + let resolve; + const promise = new /*OK*/Promise(res => resolve = res); + pending[name] = { + promise, + resolve, + }; + + return promise; + } + + /** + * Upgrade all custom elements inside root. + * + * @param {!Node} root + */ + upgrade(root) { + this.registry_.upgrade(root); + } +} + +/** + * This internal APIs necessary to run the CustomElementRegistry. + * Since Registry is never exposed externally, all methods are actually + * available on the instance. + */ +class Registry { + /** + * @param {!Window} win + */ + constructor(win) { + /** + * @private @const + */ + this.win_ = win; + + /** + * @private @const + */ + this.doc_ = win.document; + + /** + * @type {!Object} + * @private + * @const + */ + this.definitions_ = win.Object.create(null); + + /** + * A up-to-date DOM selector for all custom elements. + * @type {string} + */ + this.query_ = ''; + + /** + * The currently upgrading element. + * @private {Element} + */ + this.current_ = null; + } + + /** + * The currently-being-upgraded custom element. + * + * When an already created (through the DOM parsing APIs, or innerHTML) + * custom element node is being upgraded, we can't just create a new node + * (it's illegal in the spec). But we still need to run the custom element's + * constructor code on the node. We avoid this conundrum by running the + * constructor while returning this current node in the HTMLElement + * class constructor (the base class of all custom elements). + * + * @return {Element} + */ + current() { + const current = this.current_; + this.current_ = null; + return current; + } + + /** + * Finds the custom element definition by name. + * + * @param {string} name + * @return {CustomElementDef|undefined} + */ + getByName(name) { + const definition = this.definitions_[name]; + if (definition) { + return definition; + } + } + + /** + * Finds the custom element definition by constructor instance. + * + * @param {CustomElementConstructorDef} ctor + * @return {CustomElementDef|undefined} + */ + getByConstructor(ctor) { + const definitions = this.definitions_; + + for (const name in definitions) { + const def = definitions[name]; + if (def.ctor === ctor) { + return def; + } + } + } + + /** + * Registers the custom element definition, and upgrades all elements by that + * name in the root document. + * + * @param {string} name + * @param {!CustomElementConstructorDef} ctor + * @param {!Object|undefined} options + */ + define(name, ctor, options) { + const {Error, SyntaxError} = this.win_; + + if (options) { + throw new Error('Extending native custom elements is not supported'); + } + + assertValidName(SyntaxError, name); + + if (this.getByName(name) || + this.getByConstructor(ctor)) { + throw new Error(`duplicate definition "${name}"`); + } + + // TODO(jridgewell): Record connectedCallback, disconnectedCallback, + // adoptedCallback, attributeChangedCallback, and observedAttributes. + // TODO(jridgewell): If attributeChangedCallback, gather observedAttributes + this.definitions_[name] = { + name, + ctor, + }; + + this.observe_(name); + this.upgrade(this.doc_, name); + } + + /** + * Upgrades custom elements descendants of root (but not including root). + * + * When called with an opt_query, it both upgrades and connects the custom + * elements (this is used during the custom element define algorithm). + * + * @param {!Node} root + * @param {string=} opt_query + */ + upgrade(root, opt_query) { + // Only CustomElementRegistry.p.define provides a query (the newly defined + // custom element). In this case, we are both upgrading _and_ connecting + // the custom elements. + const newlyDefined = !!opt_query; + const query = opt_query || this.query_; + const upgradeCandidates = this.queryAll_(root, query); + + for (let i = 0; i < upgradeCandidates.length; i++) { + const candidate = upgradeCandidates[i]; + if (newlyDefined) { + this.connectedCallback_(candidate); + } else { + this.upgradeSelf(candidate); + } + } + } + + /** + * Upgrades the custom element node, if a custom element has been registered + * by this name. + * + * @param {!Node} node + */ + upgradeSelf(node) { + const def = this.getByName(node.localName); + if (!def) { + return; + } + + this.upgradeSelf_(/** @type {!Element} */(node), def); + } + + /** + * @param {!Node} root + * @param {string} query + * @return {!Array|!NodeList} + */ + queryAll_(root, query) { + if (!query || !root.querySelectorAll) { + // Nothing to do... + return []; + } + + return root.querySelectorAll(query); + } + + /** + * Upgrades the (already created via DOM parsing) custom element. + * + * @param {!Element} node + * @param {!CustomElementDef} def + */ + upgradeSelf_(node, def) { + const {ctor} = def; + if (node instanceof ctor) { + return; + } + + // Despite how it looks, this is not a useless construction. + // HTMLElementPolyfill (the base class of all custom elements) will return + // the current node, allowing the custom element's subclass constructor to + // run on the node. The node itself is already constructed, so the return + // value is just the node. + this.current_ = node; + const el = new ctor(); + + if (el !== node) { + throw new this.win_.Error( + 'Constructor illegally returned a different instance.'); + } + } + + /** + * Fires connectedCallback on the custom element, if it has one. + * This also upgrades the custom element, since it may not have been + * accessible via the root document before (a detached DOM tree). + * + * @param {!Node} node + */ + connectedCallback_(node) { + const def = this.getByName(node.localName); + if (!def) { + return; + } + this.upgradeSelf_(/** @type {!Element} */(node), def); + // TODO(jridgewell): It may be appropriate to adoptCallback, if the node + // used to be in another doc. + // TODO(jridgewell): I should be calling the definitions connectedCallback + // with node as the context. + if (node.connectedCallback) { + node.connectedCallback(); + } + } + + /** + * Fires disconnectedCallback on the custom element, if it has one. + * + * @param {!Node} node + */ + disconnectedCallback_(node) { + // TODO(jridgewell): I should be calling the definitions connectedCallback + // with node as the context. + if (node.disconnectedCallback) { + node.disconnectedCallback(); + } + } + + /** + * Records name as a registered custom element to observe. + * + * Starts the Mutation Observer if this is the first registered custom + * element. This is deferred until the first custom element is defined to + * speed up initial rendering of the page. + * + * Mutation Observers are conveniently available in every browser we care + * about. When a node is connected to the root document, all custom + * elements (including that node iteself) will be upgraded and call + * connectedCallback. When a node is disconnectedCallback from the root + * document, all custom elements will call disconnectedCallback. + * + * @param {string} name + */ + observe_(name) { + if (this.query_) { + this.query_ += `,${name}`; + return; + } + + this.query_ = name; + + // The first registered name starts the mutation observer. + const observer = new this.win_.MutationObserver(records => { + if (records) { + this.handleRecords_(records); + } + }); + observer.observe(this.doc_, { + childList: true, + subtree: true, + }); + } + + /** + * Handle all the Mutation Observer's Mutation Records. + * All added custom elements will be upgraded (if not already) and call + * connectedCallback. All removed custom elements will call + * disconnectedCallback. + * + * @param {!Array} records + */ + handleRecords_(records) { + for (let i = 0; i < records.length; i++) { + const record = records[i]; + if (!record) { + continue; + } + + const {addedNodes, removedNodes} = record; + for (let i = 0; i < addedNodes.length; i++) { + const node = addedNodes[i]; + const connectedCandidates = this.queryAll_(node, this.query_); + this.connectedCallback_(node); + for (let i = 0; i < connectedCandidates.length; i++) { + this.connectedCallback_(connectedCandidates[i]); + } + } + + for (let i = 0; i < removedNodes.length; i++) { + const node = removedNodes[i]; + const disconnectedCandidates = this.queryAll_(node, this.query_); + this.disconnectedCallback_(node); + for (let i = 0; i < disconnectedCandidates.length; i++) { + this.disconnectedCallback_(disconnectedCandidates[i]); + } + } + } + } +} + +/** + * Does the polyfilling. + * @param {!Window} win + */ +function polyfill(win) { + const {HTMLElement, Element, Node, Document, Object, document} = win; + const {createElement, cloneNode, importNode} = document; + + const registry = new Registry(win); + const customElements = new CustomElementRegistry(win, registry); + + // Expose the custom element registry. + // Object.getOwnPropertyDescriptor(window, 'customElements') + // {get: ƒ, set: undefined, enumerable: true, configurable: true} + Object.defineProperty(win, 'customElements', { + enumerable: true, + configurable: true, + // writable: false, + value: customElements, + }); + + // Patch createElement to immediately upgrade the custom element. + // This has the added benefit that it avoids the "already created but needs + // constructor code run" chicken-and-egg problem. + Document.prototype.createElement = function createElementPolyfill(name) { + const def = registry.getByName(name); + if (def) { + return new def.ctor(); + } + return createElement.apply(this, arguments); + }; + + // Patch importNode to immediately upgrade custom elements. + // TODO(jridgewell): Can fire adoptedCallback for cross doc imports. + Document.prototype.importNode = function importNodePolyfill() { + const imported = importNode.apply(this, arguments); + if (imported) { + registry.upgradeSelf(imported); + registry.upgrade(imported); + } + return imported; + }; + + // Patch cloneNode to immediately upgrade custom elements. + Node.prototype.cloneNode = function cloneNodePolyfill() { + const cloned = cloneNode.apply(this, arguments); + registry.upgradeSelf(cloned); + registry.upgrade(cloned); + return cloned; + }; + + // Patch the innerHTML setter to immediately upgrade custom elements. + // Note, this could technically fire connectedCallbacks if this node was + // connected, but we leave that to the Mutation Observer. + const innerHTMLDesc = Object.getOwnPropertyDescriptor(Element.prototype, + 'innerHTML'); + const innerHTMLSetter = innerHTMLDesc.set; + innerHTMLDesc.set = function(html) { + innerHTMLSetter.call(this, html); + registry.upgrade(this); + }; + Object.defineProperty(Element.prototype, 'innerHTML', innerHTMLDesc); + + /** + * You can't use the real HTMLElement constructor, because you can't subclass + * it without using native classes. So, mock its approximation using + * createElement. + */ + function HTMLElementPolyfill() { + const {constructor} = this; + + // If we're upgrading an already created custom element, we can't create + // another new node (by the spec, it must be the same node). + let el = registry.current(); + + // If there's not a already created custom element, we're being invoked via + // `new`ing the constructor. + // + // Technically, we could get here via createElement, but we patched that. + // If it the custom element was registered, the patch turned it into a + // `new` call. + // If it was not registered, the native createElement is used. And if + // native createElement is being used and we got to this code, we're really + // in an infinite loop (a native createElement call just below) so we've + // got bigger problems. + // + // So just take my word we got here via `new`. + if (!el) { + // The custom element definition is an invariant. If the custom element + // is registered, everything works. If it's not, it throws in the member + // property access (only defined custom elements can be directly + // constructed via `new`). + const def = registry.getByConstructor(constructor); + el = createElement.call(document, def.name); + } + + // Finally, if the node was already constructed, we need to reset it's + // prototype to the custom element prototype. And if it wasn't already + // constructed, we created a new node via native createElement, and we need + // to reset it's prototype. Basically always reset the prototype. + Object.setPrototypeOf(el, constructor.prototype); + return el; + } + subClass(Object, HTMLElement, HTMLElementPolyfill); + + // Expose the polyfilled HTMLElement constructor for everyone to extend from. + win.HTMLElement = HTMLElementPolyfill; +} + +/** + * Wraps HTMLElement in a Reflect.construct constructor, so that transpiled + * classes can `_this = superClass.call(this)` during their construction. + * + * This is only used when Custom Elements v1 is already available _and_ we're + * using transpiled classes (which use ES5 construction idioms). + * + * @param {!Window} win + */ +function wrapHTMLElement(win) { + const {HTMLElement, Reflect, Object} = win; + /** + */ + function HTMLElementWrapper() { + const ctor = /** @type {function(...?):?|undefined} */( + /** @type {!HTMLElement} */(this).constructor); + + // Reflect.construct allows us to construct a new HTMLElement without using + // `new` (which will always fail because native HTMLElement is a restricted + // constructor). + return Reflect.construct(HTMLElement, [], ctor); + } + subClass(Object, HTMLElement, HTMLElementWrapper); + + // Expose the wrapped HTMLElement constructor for everyone to extend from. + win.HTMLElement = HTMLElementWrapper; +} + +/** + * Setups up prototype inheritance + * + * @param {!Object} Object + * @param {!Function} superClass + * @param {!Function} subClass + */ +function subClass(Object, superClass, subClass) { + // Object.getOwnPropertyDescriptor(superClass.prototype, 'constructor') + // {value: ƒ, writable: true, enumerable: false, configurable: true} + subClass.prototype = Object.create(superClass.prototype, { + constructor: { + // enumerable: false, + configurable: true, + writable: true, + value: subClass, + }, + }); +} + +/** + * Polyfills Custom Elements v1 API. This has 4 modes: + * + * 1. Custom elements v1 already supported, using native classes + * 2. Custom elements v1 already supported, using transpiled classes + * 3. Custom elements v1 not supported, using native classes + * 4. Custom elements v1 not supported, using transpiled classes + * + * In mode 1, nothing is done. In mode 2, a minimal polyfill is used to support + * extending the HTMLElement base class. In mode 3 and 4, a full polyfill is + * done. + * + * @param {!Window} win + * @param {!Function} ctor + */ +export function install(win, ctor) { + if (isPatched(win)) { + return; + } + + let install = true; + let installWrapper = false; + + if (hasCustomElements(win)) { + // If ctor is constructable without new, it's a function. That means it was + // compiled down, and we need to do the minimal polyfill because all you + // cannot extend HTMLElement without native classes. + try { + const {Object, Reflect} = win; + + // "Construct" ctor using ES5 idioms + const instance = Object.create(ctor.prototype); + ctor.call(instance); + + // If that succeeded, we're in a transpiled environment + // Let's find out if we can wrap HTMLElement and avoid a full patch. + installWrapper = !!(Reflect && Reflect.construct); + } catch (e) { + + // The ctor threw when we constructed is via ES5, so it's a real class. + // We're ok to not install the polyfill. + install = false; + } + } + + if (installWrapper) { + wrapHTMLElement(win); + } else if (install) { + polyfill(win); + } +} diff --git a/src/service/extensions-impl.js b/src/service/extensions-impl.js index f368293b018e..cd7ba627309a 100644 --- a/src/service/extensions-impl.js +++ b/src/service/extensions-impl.js @@ -32,8 +32,7 @@ import { import {cssText} from '../../build/css'; import {dev, rethrowAsync} from '../log'; import {getMode} from '../mode'; -import {installCustomElements} from - 'document-register-element/build/document-register-element.patched'; +import {install as installCustomElements} from '../polyfills/custom-elements'; import { install as installDOMTokenListToggle, } from '../polyfills/domtokenlist-toggle'; @@ -41,7 +40,10 @@ import {install as installDocContains} from '../polyfills/document-contains'; import {installImg} from '../../builtins/amp-img'; import {installLayout} from '../../builtins/amp-layout'; import {installPixel} from '../../builtins/amp-pixel'; +import {installCustomElements as installRegisterElement} from + 'document-register-element/build/document-register-element.patched'; import {installStylesForDoc, installStylesLegacy} from '../style-installer'; +import {isExperimentOn} from '../experiments'; import {map} from '../utils/object'; import {startsWith} from '../string'; import {toWin} from '../types'; @@ -420,7 +422,7 @@ export class Extensions { setParentWindow(childWin, parentWin); // Install necessary polyfills. - installPolyfillsInChildWindow(childWin); + installPolyfillsInChildWindow(parentWin, childWin); // Install runtime styles. installStylesLegacy(childWin.document, cssText, /* callback */ null, @@ -660,12 +662,17 @@ export function stubLegacyElements(win) { /** * Install polyfills in the child window (friendly iframe). + * @param {!Window} parentWin * @param {!Window} childWin */ -function installPolyfillsInChildWindow(childWin) { +function installPolyfillsInChildWindow(parentWin, childWin) { installDocContains(childWin); installDOMTokenListToggle(childWin); - installCustomElements(childWin, 'auto'); + if (isExperimentOn(parentWin, 'custom-elements-v1')) { + installCustomElements(childWin, class {}); + } else { + installRegisterElement(childWin, 'auto'); + } } diff --git a/test/functional/test-base-element.js b/test/functional/test-base-element.js index d25ab8b84de7..36712e318e55 100644 --- a/test/functional/test-base-element.js +++ b/test/functional/test-base-element.js @@ -18,7 +18,7 @@ import {BaseElement} from '../../src/base-element'; import {LayoutPriority} from '../../src/layout'; import {Resource} from '../../src/service/resource'; import {Services} from '../../src/services'; -import {createAmpElementProtoForTesting} from '../../src/custom-element'; +import {createAmpElementForTesting} from '../../src/custom-element'; import {layoutRectLtwh} from '../../src/layout-rect'; import {listenOncePromise} from '../../src/event-helper'; import {toggleExperiment} from '../../src/experiments'; @@ -32,10 +32,8 @@ describes.realWin('BaseElement', {amp: true}, env => { beforeEach(() => { win = env.win; doc = win.document; - doc.registerElement('amp-test-element', { - prototype: createAmpElementProtoForTesting(win, - 'amp-test-element', BaseElement), - }); + win.customElements.define('amp-test-element', + createAmpElementForTesting(win, 'amp-test-element', BaseElement)); customElement = doc.createElement('amp-test-element'); element = new BaseElement(customElement); }); diff --git a/test/functional/test-custom-element.js b/test/functional/test-custom-element.js index cdc1dee384f0..240448379d84 100644 --- a/test/functional/test-custom-element.js +++ b/test/functional/test-custom-element.js @@ -21,7 +21,7 @@ import {ElementStub} from '../../src/element-stub'; import {LOADING_ELEMENTS_, Layout} from '../../src/layout'; import {ResourceState} from '../../src/service/resource'; import {Services} from '../../src/services'; -import {createAmpElementProtoForTesting} from '../../src/custom-element'; +import {createAmpElementForTesting} from '../../src/custom-element'; import {poll} from '../../testing/iframe'; @@ -113,14 +113,13 @@ describes.realWin('CustomElement', {amp: true}, env => { container = doc.createElement('div'); doc.body.appendChild(container); - ElementClass = doc.registerElement('amp-test', { - prototype: createAmpElementProtoForTesting( - win, 'amp-test', TestElement), - }); - StubElementClass = doc.registerElement('amp-stub', { - prototype: createAmpElementProtoForTesting( - win, 'amp-stub', ElementStub), - }); + ElementClass = createAmpElementForTesting(win, 'amp-test', TestElement); + StubElementClass = createAmpElementForTesting(win, 'amp-stub', + ElementStub); + + win.customElements.define('amp-test', ElementClass); + win.customElements.define('amp-stub', StubElementClass); + win.ampExtendedElements['amp-test'] = TestElement; win.ampExtendedElements['amp-stub'] = ElementStub; ampdoc.declareExtension('amp-stub'); @@ -1469,9 +1468,9 @@ describes.realWin('CustomElement Service Elements', {amp: true}, env => { beforeEach(() => { win = env.win; doc = win.document; - StubElementClass = doc.registerElement('amp-stub2', { - prototype: createAmpElementProtoForTesting(win, 'amp-stub2', ElementStub), - }); + StubElementClass = createAmpElementForTesting(win, 'amp-stub2', + ElementStub); + win.customElements.define('amp-stub2', StubElementClass); env.ampdoc.declareExtension('amp-stub2'); element = new StubElementClass(); }); @@ -1647,10 +1646,9 @@ describes.realWin('CustomElement', {amp: true}, env => { win = env.win; doc = win.document; clock = lolex.install({target: win}); - ElementClass = doc.registerElement('amp-test-loader', { - prototype: createAmpElementProtoForTesting( - win, 'amp-test-loader', TestElement), - }); + ElementClass = createAmpElementForTesting(win, 'amp-test-loader', + TestElement); + win.customElements.define('amp-test-loader', ElementClass); win.ampExtendedElements['amp-test-loader'] = TestElement; LOADING_ELEMENTS_['amp-test-loader'.toUpperCase()] = true; resources = Services.resourcesForDoc(doc); @@ -1986,10 +1984,9 @@ describes.realWin('CustomElement Overflow Element', {amp: true}, env => { beforeEach(() => { win = env.win; doc = win.document; - ElementClass = doc.registerElement('amp-test-overflow', { - prototype: createAmpElementProtoForTesting( - win, 'amp-test-overflow', TestElement), - }); + ElementClass = createAmpElementForTesting(win, 'amp-test-overflow', + TestElement); + win.customElements.define('amp-test-overflow', ElementClass); resources = Services.resourcesForDoc(doc); resourcesMock = sandbox.mock(resources); element = new ElementClass(); diff --git a/test/functional/test-dom.js b/test/functional/test-dom.js index 4367601acddd..e4cdeea7eecf 100644 --- a/test/functional/test-dom.js +++ b/test/functional/test-dom.js @@ -16,7 +16,7 @@ import * as dom from '../../src/dom'; import {BaseElement} from '../../src/base-element'; -import {createAmpElementProtoForTesting} from '../../src/custom-element'; +import {createAmpElementForTesting} from '../../src/custom-element'; import {loadPromise} from '../../src/event-helper'; import {toArray} from '../../src/types'; @@ -1041,10 +1041,8 @@ describes.realWin('DOM', { const element = doc.createElement('amp-test'); doc.body.appendChild(element); env.win.setTimeout(() => { - doc.registerElement('amp-test', { - prototype: createAmpElementProtoForTesting( - env.win, 'amp-test', TestElement), - }); + env.win.customElements.define('amp-test', createAmpElementForTesting( + env.win, 'amp-test', TestElement)); }, 100); return dom.whenUpgradedToCustomElement(element).then(element => { expect(element.whenBuilt).to.exist; diff --git a/test/functional/test-intersection-observer.js b/test/functional/test-intersection-observer.js index d66d4a346628..a88d7f8842ce 100644 --- a/test/functional/test-intersection-observer.js +++ b/test/functional/test-intersection-observer.js @@ -19,7 +19,7 @@ import { IntersectionObserver, getIntersectionChangeEntry, } from '../../src/intersection-observer'; -import {createAmpElementProtoForTesting} from '../../src/custom-element'; +import {createAmpElementForTesting} from '../../src/custom-element'; import {layoutRectLtwh} from '../../src/layout-rect'; @@ -323,9 +323,9 @@ describe('IntersectionObserver', () => { } } - const ElementClass = document.registerElement('amp-int', { - prototype: createAmpElementProtoForTesting(window, 'amp-int', TestElement), - }); + const ElementClass = createAmpElementForTesting(window, 'amp-int', + TestElement); + customElements.define('amp-int', ElementClass); const iframeSrc = 'http://iframe.localhost:' + location.port + '/test/fixtures/served/iframe-intersection.html'; diff --git a/testing/describes.js b/testing/describes.js index f40023ead900..0946c380bd7a 100644 --- a/testing/describes.js +++ b/testing/describes.js @@ -95,7 +95,7 @@ import { installAmpdocServices, installRuntimeServices, } from '../src/runtime'; -import {createAmpElementProtoForTesting} from '../src/custom-element'; +import {createAmpElementForTesting} from '../src/custom-element'; import {createElementWithAttributes} from '../src/dom'; import {cssText} from '../build/css'; import {doNotLoadExternalResourcesInTest} from './iframe'; @@ -103,8 +103,9 @@ import { installBuiltinElements, installExtensionsService, } from '../src/service/extensions-impl'; -import {installCustomElements} from - 'document-register-element/build/document-register-element.patched'; +import { + install as installCustomElements, +} from '../src/polyfills/custom-elements'; import {installDocService} from '../src/service/ampdoc-impl'; import {installFriendlyIframeEmbed} from '../src/friendly-iframe-embed'; import { @@ -586,7 +587,7 @@ class RealWinFixture { get: () => customElements, }); } else { - installCustomElements(win); + installCustomElements(win, class {}); } // Intercept event listeners @@ -860,7 +861,7 @@ function installAmpAdStylesPromise(win) { function createAmpElement(win, opt_name, opt_implementationClass) { // Create prototype and constructor. const name = opt_name || 'amp-element'; - const proto = createAmpElementProtoForTesting(win, name); + const proto = createAmpElementForTesting(win, name).prototype; const ctor = function() { const el = win.document.createElement(name); el.__proto__ = proto; diff --git a/testing/iframe.js b/testing/iframe.js index 8c5465b430f3..4087271920ef 100644 --- a/testing/iframe.js +++ b/testing/iframe.js @@ -25,8 +25,7 @@ import { installAmpdocServices, installRuntimeServices, } from '../src/runtime'; -import {installCustomElements} from - 'document-register-element/build/document-register-element.patched'; +import {install as installCustomElements} from '../src/polyfills/custom-elements'; import {installDocService} from '../src/service/ampdoc-impl'; import {installExtensionsService} from '../src/service/extensions-impl'; import {installStylesLegacy} from '../src/style-installer'; @@ -232,7 +231,7 @@ export function createIframePromise(opt_runtimeOff, opt_beforeLayoutCallback) { Services.ampdocServiceFor(iframe.contentWindow).getAmpDoc(); installExtensionsService(iframe.contentWindow); installRuntimeServices(iframe.contentWindow); - installCustomElements(iframe.contentWindow); + installCustomElements(iframe.contentWindow, class {}); installAmpdocServices(ampdoc); Services.resourcesForDoc(ampdoc).ampInitComplete(); // Act like no other elements were loaded by default. diff --git a/tools/experiments/experiments.js b/tools/experiments/experiments.js index a3f99a18bd37..a7f8f13f668c 100644 --- a/tools/experiments/experiments.js +++ b/tools/experiments/experiments.js @@ -321,12 +321,17 @@ const EXPERIMENTS = [ spec: 'https://github.com/ampproject/amphtml/pull/17229', cleanupIssue: 'https://github.com/ampproject/amphtml/issues/17230', }, + { + id: 'custom-elements-v1', + name: 'Enable a new custom elements v1 polyfill', + spec: 'https://github.com/ampproject/amphtml/pull/17205', + cleanupIssue: 'https://github.com/ampproject/amphtml/issues/17243', + }, { id: 'amp-carousel-scroll-snap', name: 'Enables scroll snap on carousel across all browsers/OSes', cleanupIssue: 'https://github.com/ampproject/amphtml/issues/16508', }, - ]; if (getMode().localDev) {