gecko-dev/toolkit/content/customElements.js
Brian Grinstead 0c7558a0dd Bug 1541516 - Instrument base custom element class and print call information in the console r=aswan
If MOZ_INSTRUMENT_CUSTOM_ELEMENTS is set in the environment, then modify Custom Elements
to wrap each function and property lookup to keep a count and running time. Then print out
tables for each element at startup. Tables can be re-printed with `MozElements.printInstrumentation()`.

Differential Revision: https://phabricator.services.mozilla.com/D24953

--HG--
extra : moz-landing-system : lando
2019-04-10 17:34:43 +00:00

708 lines
24 KiB
JavaScript

/* 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, 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) {
let summary = c.__instrumentation_summary;
if (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;
}
/*
* 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() {
let {inheritedAttributes} = this;
if (!inheritedAttributes) {
return [];
}
let allAttributes = new Set();
for (let sel in inheritedAttributes) {
for (let attrName of inheritedAttributes[sel].split(",")) {
allAttributes.add(attrName.split("=").pop());
}
}
return [...allAttributes];
}
/*
* 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.inheritedAttributesCache) {
return;
}
this.inheritAttributes();
}
/*
* 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 {inheritedAttributes} = this.constructor;
if (!inheritedAttributes) {
return;
}
this._inheritedAttributesValuesCache = null;
this.inheritedAttributesCache = new Map();
for (let selector in inheritedAttributes) {
let parent = this.shadowRoot || this;
let el = parent.querySelector(selector);
// Skip unmatched selectors in case an element omits some elements in certain cases:
if (!el) {
continue;
}
if (this.inheritedAttributesCache.has(el)) {
console.error(`Error: duplicate element encountered with ${selector}`);
}
this.inheritedAttributesCache.set(el, inheritedAttributes[selector]);
}
this.inheritAttributes();
}
/*
* Loop through the static `inheritedAttributes` Map and inherit attributes to child elements.
*
* This usually won't need to be called directly - `this.initializeAttributeInheritance()` and
* `this.attributeChangedCallback` will call it for you when appropriate.
*/
inheritAttributes() {
let {inheritedAttributes} = this.constructor;
if (!inheritedAttributes) {
return;
}
if (!this.inheritedAttributesCache) {
console.error(`You must call this.initializeAttributeInheritance() for ${this.tagName}`);
return;
}
for (let [ el, attrs ] of this.inheritedAttributesCache.entries()) {
for (let attr of attrs.split(",")) {
this.inheritAttribute(el, attr);
}
}
}
/*
* Implements attribute inheritance by a child element. Uses XBL @inherit
* syntax of |to=from|. This can be used directly, but for simple cases
* you should use the inheritedAttributes getter and let the base class
* handle this for you.
*
* @param {element} child
* A child element that inherits an attribute.
* @param {string} attr
* An attribute to inherit. Optionally in the form of |to=from|, where
* |to| is an attribute defined on custom element, whose value will be
* inherited to |from| attribute, defined a child element. Note |from| may
* take a special value of "text" to propogate attribute value as
* a child's text.
*/
inheritAttribute(child, attr) {
let attrName = attr;
let attrNewName = attr;
let split = attrName.split("=");
if (split.length == 2) {
attrName = split[1];
attrNewName = split[0];
}
let hasAttr = this.hasAttribute(attrName);
let attrValue = this.getAttribute(attrName);
// If our attribute hasn't changed since we last inherited, we don't want to
// propagate it down to the child. This prevents overriding an attribute that's
// been changed on the child (for instance, [checked]).
if (!this._inheritedAttributesValuesCache) {
this._inheritedAttributesValuesCache = new WeakMap();
}
if (!this._inheritedAttributesValuesCache.has(child)) {
this._inheritedAttributesValuesCache.set(child, {});
}
let lastInheritedAttributes = this._inheritedAttributesValuesCache.get(child);
if ((hasAttr && attrValue === lastInheritedAttributes[attrName]) ||
(!hasAttr && !lastInheritedAttributes.hasOwnProperty(attrName))) {
// We got a request to inherit an unchanged attribute - bail.
return;
}
// Store the value we're about to pass down to the child.
if (hasAttr) {
lastInheritedAttributes[attrName] = attrValue;
} else {
delete lastInheritedAttributes[attrName];
}
// Actually set the attribute.
if (attrNewName === "text") {
child.textContent = hasAttr ? attrValue : "";
} else if (hasAttr) {
child.setAttribute(attrNewName, attrValue);
} else {
child.removeAttribute(attrNewName);
}
if (attrNewName == "accesskey" && child.formatAccessKey) {
child.formatAccessKey(false);
}
}
/**
* 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;
}
/**
* 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.parseFromString(`
${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");
// 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.contentType == "application/vnd.mozilla.xul+xml") {
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);
/**
* 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");
}
}
}
Base.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.BaseText = BaseTextMixin(MozXULElement);
// Attach the base class to the window so other scripts can use it:
window.MozXULElement = MozXULElement;
customElements.setElementCreationCallback("browser", () => {
Services.scriptloader.loadSubScript("chrome://global/content/elements/browser-custom-element.js", window);
});
// For now, don't load any elements in the extension dummy document.
// We will want to load <browser> when that's migrated (bug 1441935).
const isDummyDocument = document.documentURI == "chrome://extensions/content/dummy.xul";
if (!isDummyDocument) {
for (let script of [
"chrome://global/content/elements/general.js",
"chrome://global/content/elements/checkbox.js",
"chrome://global/content/elements/menu.js",
"chrome://global/content/elements/menupopup.js",
"chrome://global/content/elements/notificationbox.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/textbox.js",
"chrome://global/content/elements/tabbox.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"],
["stringbundle", "chrome://global/content/elements/stringbundle.js"],
["printpreview-toolbar", "chrome://global/content/printPreviewToolbar.js"],
["editor", "chrome://global/content/elements/editor.js"],
["text-link", "chrome://global/content/elements/text.js"],
]) {
customElements.setElementCreationCallback(tag, () => {
Services.scriptloader.loadSubScript(script, window);
});
}
}
})();