diff options
Diffstat (limited to 'toolkit/content/customElements.js')
-rw-r--r-- | toolkit/content/customElements.js | 866 |
1 files changed, 866 insertions, 0 deletions
diff --git a/toolkit/content/customElements.js b/toolkit/content/customElements.js new file mode 100644 index 0000000000..4ac3148c6e --- /dev/null +++ b/toolkit/content/customElements.js @@ -0,0 +1,866 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This file defines these globals on the window object. +// Define them here so that ESLint can find them: +/* globals MozXULElement, MozHTMLElement, MozElements */ + +"use strict"; + +// This is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +(() => { + // Handle customElements.js being loaded as a script in addition to the subscriptLoader + // from MainProcessSingleton, to handle pages that can open both before and after + // MainProcessSingleton starts. See Bug 1501845. + if (window.MozXULElement) { + return; + } + + const MozElements = {}; + window.MozElements = MozElements; + + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + const instrumentClasses = env.get("MOZ_INSTRUMENT_CUSTOM_ELEMENTS"); + const instrumentedClasses = instrumentClasses ? new Set() : null; + const instrumentedBaseClasses = instrumentClasses ? new WeakSet() : null; + + // If requested, wrap the normal customElements.define to give us a chance + // to modify the class so we can instrument function calls in local development: + if (instrumentClasses) { + let define = window.customElements.define; + window.customElements.define = function(name, c, opts) { + instrumentCustomElementClass(c); + return define.call(this, name, c, opts); + }; + window.addEventListener( + "load", + () => { + MozElements.printInstrumentation(true); + }, + { once: true, capture: true } + ); + } + + MozElements.printInstrumentation = function(collapsed) { + let summaries = []; + let totalCalls = 0; + let totalTime = 0; + for (let c of instrumentedClasses) { + // Allow passing in something like MOZ_INSTRUMENT_CUSTOM_ELEMENTS=MozXULElement,Button to filter + let includeClass = + instrumentClasses == 1 || + instrumentClasses + .split(",") + .some(n => c.name.toLowerCase().includes(n.toLowerCase())); + let summary = c.__instrumentation_summary; + if (includeClass && summary) { + summaries.push(summary); + totalCalls += summary.totalCalls; + totalTime += summary.totalTime; + } + } + if (summaries.length) { + let groupName = `Instrumentation data for custom elements in ${document.documentURI}`; + console[collapsed ? "groupCollapsed" : "group"](groupName); + console.log( + `Total function calls ${totalCalls} and total time spent inside ${totalTime.toFixed( + 2 + )}` + ); + for (let summary of summaries) { + console.log(`${summary.name} (# instances: ${summary.instances})`); + if (Object.keys(summary.data).length > 1) { + console.table(summary.data); + } + } + console.groupEnd(groupName); + } + }; + + function instrumentCustomElementClass(c) { + // Climb up prototype chain to see if we inherit from a MozElement. + // Keep track of classes to instrument, for example: + // MozMenuCaption->MozMenuBase->BaseText->BaseControl->MozXULElement + let inheritsFromBase = instrumentedBaseClasses.has(c); + let classesToInstrument = [c]; + let proto = Object.getPrototypeOf(c); + while (proto) { + classesToInstrument.push(proto); + if (instrumentedBaseClasses.has(proto)) { + inheritsFromBase = true; + break; + } + proto = Object.getPrototypeOf(proto); + } + + if (inheritsFromBase) { + for (let c of classesToInstrument.reverse()) { + instrumentIndividualClass(c); + } + } + } + + function instrumentIndividualClass(c) { + if (instrumentedClasses.has(c)) { + return; + } + + instrumentedClasses.add(c); + let data = { instances: 0 }; + + function wrapFunction(name, fn) { + return function() { + if (!data[name]) { + data[name] = { time: 0, calls: 0 }; + } + data[name].calls++; + let n = performance.now(); + let r = fn.apply(this, arguments); + data[name].time += performance.now() - n; + return r; + }; + } + function wrapPropertyDescriptor(obj, name) { + if (name == "constructor") { + return; + } + let prop = Object.getOwnPropertyDescriptor(obj, name); + if (prop.get) { + prop.get = wrapFunction(`<get> ${name}`, prop.get); + } + if (prop.set) { + prop.set = wrapFunction(`<set> ${name}`, prop.set); + } + if (prop.writable && prop.value && prop.value.apply) { + prop.value = wrapFunction(name, prop.value); + } + Object.defineProperty(obj, name, prop); + } + + // Handle static properties + for (let name of Object.getOwnPropertyNames(c)) { + wrapPropertyDescriptor(c, name); + } + + // Handle instance properties + for (let name of Object.getOwnPropertyNames(c.prototype)) { + wrapPropertyDescriptor(c.prototype, name); + } + + c.__instrumentation_data = data; + Object.defineProperty(c, "__instrumentation_summary", { + enumerable: false, + configurable: false, + get() { + if (data.instances == 0) { + return null; + } + + let clonedData = JSON.parse(JSON.stringify(data)); + delete clonedData.instances; + let totalCalls = 0; + let totalTime = 0; + for (let d in clonedData) { + let { time, calls } = clonedData[d]; + time = parseFloat(time.toFixed(2)); + totalCalls += calls; + totalTime += time; + clonedData[d]["time (ms)"] = time; + delete clonedData[d].time; + clonedData[d].timePerCall = parseFloat((time / calls).toFixed(4)); + } + + let timePerCall = parseFloat((totalTime / totalCalls).toFixed(4)); + totalTime = parseFloat(totalTime.toFixed(2)); + + // Add a spaced-out final row with summed up totals + clonedData["\ntotals"] = { + "time (ms)": `\n${totalTime}`, + calls: `\n${totalCalls}`, + timePerCall: `\n${timePerCall}`, + }; + return { + instances: data.instances, + data: clonedData, + name: c.name, + totalCalls, + totalTime, + }; + }, + }); + } + + // The listener of DOMContentLoaded must be set on window, rather than + // document, because the window can go away before the event is fired. + // In that case, we don't want to initialize anything, otherwise we + // may be leaking things because they will never be destroyed after. + let gIsDOMContentLoaded = false; + const gElementsPendingConnection = new Set(); + window.addEventListener( + "DOMContentLoaded", + () => { + gIsDOMContentLoaded = true; + for (let element of gElementsPendingConnection) { + try { + if (element.isConnected) { + element.isRunningDelayedConnectedCallback = true; + element.connectedCallback(); + } + } catch (ex) { + console.error(ex); + } + element.isRunningDelayedConnectedCallback = false; + } + gElementsPendingConnection.clear(); + }, + { once: true, capture: true } + ); + + const gXULDOMParser = new DOMParser(); + gXULDOMParser.forceEnableXULXBL(); + + MozElements.MozElementMixin = Base => { + let MozElementBase = class extends Base { + constructor() { + super(); + + if (instrumentClasses) { + let proto = this.constructor; + while (proto && proto != Base) { + proto.__instrumentation_data.instances++; + proto = Object.getPrototypeOf(proto); + } + } + } + /* + * A declarative way to wire up attribute inheritance and automatically generate + * the `observedAttributes` getter. For example, if you returned: + * { + * ".foo": "bar,baz=bat" + * } + * + * Then the base class will automatically return ["bar", "bat"] from `observedAttributes`, + * and set up an `attributeChangedCallback` to pass those attributes down onto an element + * matching the ".foo" selector. + * + * See the `inheritAttribute` function for more details on the attribute string format. + * + * @return {Object<string selector, string attributes>} + */ + static get inheritedAttributes() { + return null; + } + + static get flippedInheritedAttributes() { + // Have to be careful here, if a subclass overrides inheritedAttributes + // and its parent class is instantiated first, then reading + // this._flippedInheritedAttributes on the child class will return the + // computed value from the parent. We store it separately on each class + // to ensure everything works correctly when inheritedAttributes is + // overridden. + if (!this.hasOwnProperty("_flippedInheritedAttributes")) { + let { inheritedAttributes } = this; + if (!inheritedAttributes) { + this._flippedInheritedAttributes = null; + } else { + this._flippedInheritedAttributes = {}; + for (let selector in inheritedAttributes) { + let attrRules = inheritedAttributes[selector].split(","); + for (let attrRule of attrRules) { + let attrName = attrRule; + let attrNewName = attrRule; + let split = attrName.split("="); + if (split.length == 2) { + attrName = split[1]; + attrNewName = split[0]; + } + + if (!this._flippedInheritedAttributes[attrName]) { + this._flippedInheritedAttributes[attrName] = []; + } + this._flippedInheritedAttributes[attrName].push([ + selector, + attrNewName, + ]); + } + } + } + } + + return this._flippedInheritedAttributes; + } + /* + * Generate this array based on `inheritedAttributes`, if any. A class is free to override + * this if it needs to do something more complex or wants to opt out of this behavior. + */ + static get observedAttributes() { + return Object.keys(this.flippedInheritedAttributes || {}); + } + + /* + * Provide default lifecycle callback for attribute changes that will inherit attributes + * based on the static `inheritedAttributes` Object. This can be overridden by callers. + */ + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue || !this.initializedAttributeInheritance) { + return; + } + + let list = this.constructor.flippedInheritedAttributes[name]; + if (list) { + this.inheritAttribute(list, name); + } + } + + /* + * After setting content, calling this will cache the elements from selectors in the + * static `inheritedAttributes` Object. It'll also do an initial call to `this.inheritAttributes()`, + * so in the simple case, this is the only function you need to call. + * + * This should be called any time the children that are inheriting attributes changes. For instance, + * it's common in a connectedCallback to do something like: + * + * this.textContent = ""; + * this.append(MozXULElement.parseXULToFragment(`<label />`)) + * this.initializeAttributeInheritance(); + * + */ + initializeAttributeInheritance() { + let { flippedInheritedAttributes } = this.constructor; + if (!flippedInheritedAttributes) { + return; + } + + // Clear out any existing cached elements: + this._inheritedElements = null; + + this.initializedAttributeInheritance = true; + for (let attr in flippedInheritedAttributes) { + if (this.hasAttribute(attr)) { + this.inheritAttribute(flippedInheritedAttributes[attr], attr); + } + } + } + + /* + * Implements attribute value inheritance by child elements. + * + * @param {array} list + * An array of (to-element-selector, to-attr) pairs. + * @param {string} attr + * An attribute to propagate. + */ + inheritAttribute(list, attr) { + if (!this._inheritedElements) { + this._inheritedElements = {}; + } + + let hasAttr = this.hasAttribute(attr); + let attrValue = this.getAttribute(attr); + + for (let [selector, newAttr] of list) { + if (!(selector in this._inheritedElements)) { + this._inheritedElements[ + selector + ] = this.getElementForAttrInheritance(selector); + } + let el = this._inheritedElements[selector]; + if (el) { + if (newAttr == "text") { + el.textContent = hasAttr ? attrValue : ""; + } else if (hasAttr) { + el.setAttribute(newAttr, attrValue); + } else { + el.removeAttribute(newAttr); + } + } + } + } + + /** + * Used in setting up attribute inheritance. Takes a selector and returns + * an element for that selector from shadow DOM if there is a shadowRoot, + * or from the light DOM if not. + * + * Here's one problem this solves. ElementB extends ElementA which extends + * MozXULElement. ElementA has a shadowRoot. ElementB tries to inherit + * attributes in light DOM by calling `initializeAttributeInheritance` + * but that fails because it defaults to inheriting from the shadow DOM + * and not the light DOM. (See bug 1545824.) + * + * To solve this, ElementB can override `getElementForAttrInheritance` so + * it queries the light DOM for some selectors as needed. For example: + * + * class ElementA extends MozXULElement { + * static get inheritedAttributes() { + * return { ".one": "attr" }; + * } + * } + * + * class ElementB extends customElements.get("elementa") { + * static get inheritedAttributes() { + * return Object.assign({}, super.inheritedAttributes(), { + * ".two": "attr", + * }); + * } + * getElementForAttrInheritance(selector) { + * if (selector == ".two") { + * return this.querySelector(selector) + * } else { + * return super.getElementForAttrInheritance(selector); + * } + * } + * } + * + * @param {string} selector + * A selector used to query an element. + * + * @return {Element} The element found by the selector. + */ + getElementForAttrInheritance(selector) { + let parent = this.shadowRoot || this; + return parent.querySelector(selector); + } + + /** + * Sometimes an element may not want to run connectedCallback logic during + * parse. This could be because we don't want to initialize the element before + * the element's contents have been fully parsed, or for performance reasons. + * If you'd like to opt-in to this, then add this to the beginning of your + * `connectedCallback` and `disconnectedCallback`: + * + * if (this.delayConnectedCallback()) { return } + * + * And this at the beginning of your `attributeChangedCallback` + * + * if (!this.isConnectedAndReady) { return; } + */ + delayConnectedCallback() { + if (gIsDOMContentLoaded) { + return false; + } + gElementsPendingConnection.add(this); + return true; + } + + get isConnectedAndReady() { + return gIsDOMContentLoaded && this.isConnected; + } + + /** + * Passes DOM events to the on_<event type> methods. + */ + handleEvent(event) { + let methodName = "on_" + event.type; + if (methodName in this) { + this[methodName](event); + } else { + throw new Error("Unrecognized event: " + event.type); + } + } + + /** + * Used by custom elements for caching fragments. We now would be + * caching once per class while also supporting subclasses. + * + * If available, returns the cached fragment. + * Otherwise, creates it. + * + * Example: + * + * class ElementA extends MozXULElement { + * static get markup() { + * return `<hbox class="example"`; + * } + * + * static get entities() { + * // Optional field for parseXULToFragment + * return `["chrome://global/locale/notification.dtd"]`; + * } + * + * connectedCallback() { + * this.appendChild(this.constructor.fragment); + * } + * } + * + * @return {importedNode} The imported node that has not been + * inserted into document tree. + */ + static get fragment() { + if (!this.hasOwnProperty("_fragment")) { + let markup = this.markup; + if (markup) { + this._fragment = MozXULElement.parseXULToFragment( + markup, + this.entities + ); + } else { + throw new Error("Markup is null"); + } + } + return document.importNode(this._fragment, true); + } + + /** + * Allows eager deterministic construction of XUL elements with XBL attached, by + * parsing an element tree and returning a DOM fragment to be inserted in the + * document before any of the inner elements is referenced by JavaScript. + * + * This process is required instead of calling the createElement method directly + * because bindings get attached when: + * + * 1. the node gets a layout frame constructed, or + * 2. the node gets its JavaScript reflector created, if it's in the document, + * + * whichever happens first. The createElement method would return a JavaScript + * reflector, but the element wouldn't be in the document, so the node wouldn't + * get XBL attached. After that point, even if the node is inserted into a + * document, it won't get XBL attached until either the frame is constructed or + * the reflector is garbage collected and the element is touched again. + * + * @param {string} str + * String with the XML representation of XUL elements. + * @param {string[]} [entities] + * An array of DTD URLs containing entity definitions. + * + * @return {DocumentFragment} `DocumentFragment` instance containing + * the corresponding element tree, including element nodes + * but excluding any text node. + */ + static parseXULToFragment(str, entities = []) { + let doc = gXULDOMParser.parseFromSafeString( + ` + ${ + entities.length + ? `<!DOCTYPE bindings [ + ${entities.reduce((preamble, url, index) => { + return ( + preamble + + `<!ENTITY % _dtd-${index} SYSTEM "${url}"> + %_dtd-${index}; + ` + ); + }, "")} + ]>` + : "" + } + <box xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + ${str} + </box> + `, + "application/xml" + ); + + if (doc.documentElement.localName === "parsererror") { + throw new Error("not well-formed XML"); + } + + // The XUL/XBL parser is set to ignore all-whitespace nodes, whereas (X)HTML + // does not do this. Most XUL code assumes that the whitespace has been + // stripped out, so we simply remove all text nodes after using the parser. + let nodeIterator = doc.createNodeIterator(doc, NodeFilter.SHOW_TEXT); + let currentNode = nodeIterator.nextNode(); + while (currentNode) { + // Remove whitespace-only nodes. Regex is taken from: + // https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace_in_the_DOM + if (!/[^\t\n\r ]/.test(currentNode.textContent)) { + currentNode.remove(); + } + + currentNode = nodeIterator.nextNode(); + } + // We use a range here so that we don't access the inner DOM elements from + // JavaScript before they are imported and inserted into a document. + let range = doc.createRange(); + range.selectNodeContents(doc.querySelector("box")); + return range.extractContents(); + } + + /** + * Insert a localization link to an FTL file. This is used so that + * a Custom Element can wait to inject the link until it's connected, + * and so that consuming documents don't require the correct <link> + * present in the markup. + * + * @param path + * The path to the FTL file + */ + static insertFTLIfNeeded(path) { + let container = document.head || document.querySelector("linkset"); + if (!container) { + if ( + document.documentElement.namespaceURI === + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + ) { + container = document.createXULElement("linkset"); + document.documentElement.appendChild(container); + } else if (document.documentURI == AppConstants.BROWSER_CHROME_URL) { + // Special case for browser.xhtml. Here `document.head` is null, so + // just insert the link at the end of the window. + container = document.documentElement; + } else { + throw new Error( + "Attempt to inject localization link before document.head is available" + ); + } + } + + for (let link of container.querySelectorAll("link")) { + if (link.getAttribute("href") == path) { + return; + } + } + + let link = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "link" + ); + link.setAttribute("rel", "localization"); + link.setAttribute("href", path); + + container.appendChild(link); + } + + /** + * Indicate that a class defining a XUL element implements one or more + * XPCOM interfaces by adding a getCustomInterface implementation to it, + * as well as an implementation of QueryInterface. + * + * The supplied class should implement the properties and methods of + * all of the interfaces that are specified. + * + * @param cls + * The class that implements the interface. + * @param names + * Array of interface names. + */ + static implementCustomInterface(cls, ifaces) { + if (cls.prototype.customInterfaces) { + ifaces.push(...cls.prototype.customInterfaces); + } + cls.prototype.customInterfaces = ifaces; + + cls.prototype.QueryInterface = ChromeUtils.generateQI(ifaces); + cls.prototype.getCustomInterfaceCallback = function getCustomInterfaceCallback( + ifaceToCheck + ) { + if ( + cls.prototype.customInterfaces.some(iface => + iface.equals(ifaceToCheck) + ) + ) { + return getInterfaceProxy(this); + } + return null; + }; + } + }; + + // Rename the class so we can distinguish between MozXULElement and MozXULPopupElement, for example. + Object.defineProperty(MozElementBase, "name", { value: `Moz${Base.name}` }); + if (instrumentedBaseClasses) { + instrumentedBaseClasses.add(MozElementBase); + } + return MozElementBase; + }; + + const MozXULElement = MozElements.MozElementMixin(XULElement); + const MozHTMLElement = MozElements.MozElementMixin(HTMLElement); + + /** + * Given an object, add a proxy that reflects interface implementations + * onto the object itself. + */ + function getInterfaceProxy(obj) { + /* globals MozQueryInterface */ + if (!obj._customInterfaceProxy) { + obj._customInterfaceProxy = new Proxy(obj, { + get(target, prop, receiver) { + let propOrMethod = target[prop]; + if (typeof propOrMethod == "function") { + if (propOrMethod instanceof MozQueryInterface) { + return Reflect.get(target, prop, receiver); + } + return function(...args) { + return propOrMethod.apply(target, args); + }; + } + return propOrMethod; + }, + }); + } + + return obj._customInterfaceProxy; + } + + MozElements.BaseControlMixin = Base => { + class BaseControl extends Base { + get disabled() { + return this.getAttribute("disabled") == "true"; + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + } + + get tabIndex() { + return parseInt(this.getAttribute("tabindex")) || 0; + } + + set tabIndex(val) { + if (val) { + this.setAttribute("tabindex", val); + } else { + this.removeAttribute("tabindex"); + } + } + } + + MozXULElement.implementCustomInterface(BaseControl, [ + Ci.nsIDOMXULControlElement, + ]); + return BaseControl; + }; + MozElements.BaseControl = MozElements.BaseControlMixin(MozXULElement); + + const BaseTextMixin = Base => + class BaseText extends MozElements.BaseControlMixin(Base) { + set label(val) { + this.setAttribute("label", val); + return val; + } + + get label() { + return this.getAttribute("label"); + } + + set crop(val) { + this.setAttribute("crop", val); + return val; + } + + get crop() { + return this.getAttribute("crop"); + } + + set image(val) { + this.setAttribute("image", val); + return val; + } + + get image() { + return this.getAttribute("image"); + } + + set command(val) { + this.setAttribute("command", val); + return val; + } + + get command() { + return this.getAttribute("command"); + } + + set accessKey(val) { + // Always store on the control + this.setAttribute("accesskey", val); + // If there is a label, change the accesskey on the labelElement + // if it's also set there + if (this.labelElement) { + this.labelElement.accessKey = val; + } + return val; + } + + get accessKey() { + return this.labelElement + ? this.labelElement.accessKey + : this.getAttribute("accesskey"); + } + }; + MozElements.BaseTextMixin = BaseTextMixin; + MozElements.BaseText = BaseTextMixin(MozXULElement); + + // Attach the base class to the window so other scripts can use it: + window.MozXULElement = MozXULElement; + window.MozHTMLElement = MozHTMLElement; + + customElements.setElementCreationCallback("browser", () => { + Services.scriptloader.loadSubScript( + "chrome://global/content/elements/browser-custom-element.js", + window + ); + }); + + // Skip loading any extra custom elements in the extension dummy document + // and GeckoView windows. + const loadExtraCustomElements = !( + document.documentURI == "chrome://extensions/content/dummy.xhtml" || + document.documentURI == "chrome://geckoview/content/geckoview.xhtml" + ); + if (loadExtraCustomElements) { + for (let script of [ + "chrome://global/content/elements/arrowscrollbox.js", + "chrome://global/content/elements/dialog.js", + "chrome://global/content/elements/general.js", + "chrome://global/content/elements/button.js", + "chrome://global/content/elements/checkbox.js", + "chrome://global/content/elements/menu.js", + "chrome://global/content/elements/menupopup.js", + "chrome://global/content/elements/moz-input-box.js", + "chrome://global/content/elements/notificationbox.js", + "chrome://global/content/elements/panel.js", + "chrome://global/content/elements/popupnotification.js", + "chrome://global/content/elements/radio.js", + "chrome://global/content/elements/richlistbox.js", + "chrome://global/content/elements/autocomplete-popup.js", + "chrome://global/content/elements/autocomplete-richlistitem.js", + "chrome://global/content/elements/tabbox.js", + "chrome://global/content/elements/text.js", + "chrome://global/content/elements/toolbarbutton.js", + "chrome://global/content/elements/tree.js", + "chrome://global/content/elements/wizard.js", + ]) { + Services.scriptloader.loadSubScript(script, window); + } + + for (let [tag, script] of [ + ["findbar", "chrome://global/content/elements/findbar.js"], + ["menulist", "chrome://global/content/elements/menulist.js"], + ["search-textbox", "chrome://global/content/elements/search-textbox.js"], + ["stringbundle", "chrome://global/content/elements/stringbundle.js"], + [ + "printpreview-toolbar", + "chrome://global/content/printPreviewToolbar.js", + ], + [ + "printpreview-pagination", + "chrome://global/content/printPreviewPagination.js", + ], + [ + "autocomplete-input", + "chrome://global/content/elements/autocomplete-input.js", + ], + ["editor", "chrome://global/content/elements/editor.js"], + ]) { + customElements.setElementCreationCallback(tag, () => { + Services.scriptloader.loadSubScript(script, window); + }); + } + } +})(); |