Skip to content

Commit

Permalink
Optimize the named property tracker by not walking the entire tree
Browse files Browse the repository at this point in the history
Testing if a named property can be untracked is now many times faster, which means `setAttribute()` and `removeChild()` will not be slowed by it.
Looking up a named property will also be faster for huge documents
  • Loading branch information
Joris-van-der-Wel committed Jul 26, 2015
1 parent 457ff9c commit bcf0533
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 74 deletions.
56 changes: 33 additions & 23 deletions lib/jsdom/living/named-properties-window.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use strict";
const mapper = require("../utils").mapper;
const hasOwnProp = Object.prototype.hasOwnProperty;
const namedPropertiesTracker = require("../named-properties-tracker");
const NODE_TYPE = require("./node-type");
const treeOrderSorter = require("../utils").treeOrderSorter;

function isNamedPropertyElement(element) {
// (for the name attribute)
Expand All @@ -28,28 +28,29 @@ function isNamedPropertyElement(element) {
return false;
}

function namedPropertyResolver(HTMLCollection, window, name) {
function filter(node) {
if (node.nodeType !== NODE_TYPE.ELEMENT_NODE) {
return false;
}
function namedPropertyResolver(HTMLCollection, window, name, values) {
function getResult() {
const results = [];

if (node.getAttribute("id") === name) {
return true;
}
for (const node of values().keys()) {
if (node.nodeType !== NODE_TYPE.ELEMENT_NODE) {
continue;
}

if (isNamedPropertyElement(node)) {
return node.getAttribute("name") === name;
if (node.getAttribute("id") === name) {
results.push(node);
} else if (node.getAttribute("name") === name && isNamedPropertyElement(node)) {
results.push(node);
}
}

return false;
results.sort(treeOrderSorter);

return results;
}

const document = window._document;
const objects = new HTMLCollection(
document.documentElement,
mapper(document, filter, true)
);
const objects = new HTMLCollection(document.documentElement, getResult);

const length = objects.length;
for (let i = 0; i < length; ++i) {
Expand Down Expand Up @@ -80,13 +81,22 @@ exports.elementAttributeModified = function (element, name, value, oldValue) {
return;
}

if (name === "id" || (name === "name" && isNamedPropertyElement(element))) {
const useName = isNamedPropertyElement(element);

if (name === "id" || (name === "name" && useName)) {
const tracker = namedPropertiesTracker.get(element._ownerDocument._global);

// (tracker will be null if the document has no Window)
if (tracker) {
tracker.maybeUntrack(oldValue);
tracker.track(value);
if (name === "id" && (!useName || element.getAttribute("name") !== oldValue)) {
tracker.untrack(oldValue, element);
}

if (name === "name" && element.getAttribute("id") !== oldValue) {
tracker.untrack(oldValue, element);
}

tracker.track(value, element);
}
}
};
Expand All @@ -101,10 +111,10 @@ exports.nodeAttachedToDocument = function (node) {
return;
}

tracker.track(node.getAttribute("id"));
tracker.track(node.getAttribute("id"), node);

if (isNamedPropertyElement(node)) {
tracker.track(node.getAttribute("name"));
tracker.track(node.getAttribute("name"), node);
}
};

Expand All @@ -118,9 +128,9 @@ exports.nodeDetachedFromDocument = function (node) {
return;
}

tracker.maybeUntrack(node.getAttribute("id"));
tracker.untrack(node.getAttribute("id"), node);

if (isNamedPropertyElement(node)) {
tracker.maybeUntrack(node.getAttribute("name"));
tracker.untrack(node.getAttribute("name"), node);
}
};
94 changes: 81 additions & 13 deletions lib/jsdom/named-properties-tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@
const IS_NAMED_PROPERTY = Symbol();
const TRACKER = Symbol();

/**
* Create a new NamedPropertiesTracker for the given `object`.
*
* Named properties are used in DOM to let you lookup (for example) a Node by accessing a property on another object.
* For example `window.foo` might resolve to an image element with id "foo".
*
* This tracker is a workaround because the ES6 Proxy feature is not yet available.
*
* @param {Object} object
* @param {Function} resolverFunc Each time a property is accessed, this function is called to determine the value of
* the property. The function is passed 3 arguments: (object, name, values).
* `object` is identical to the `object` parameter of this `create` function.
* `name` is the name of the property.
* `values` is a function that returns a Set with all the tracked values for this name. The order of these
* values is undefined.
*
* @returns {NamedPropertiesTracker}
*/
exports.create = function (object, resolverFunc) {
if (object[TRACKER]) {
throw Error("A NamedPropertiesTracker has already been created for this object");
Expand All @@ -25,17 +43,24 @@ exports.get = function (object) {
function NamedPropertiesTracker(object, resolverFunc) {
this.object = object;
this.resolverFunc = resolverFunc;
this.trackedValues = new Map(); // Map<Set<value>>
}

function newPropertyDescriptor(object, resolverFunc, name) {
function newPropertyDescriptor(tracker, name) {
const emptySet = new Set();

function getValues() {
return tracker.trackedValues.get(name) || emptySet;
}

const descriptor = {
enumerable: true,
configurable: true,
get: function () {
return resolverFunc(object, name);
return tracker.resolverFunc(tracker.object, name, getValues);
},
set: function (value) {
Object.defineProperty(object, name, {
Object.defineProperty(tracker.object, name, {
enumerable: true,
configurable: true,
writable: true,
Expand All @@ -49,35 +74,78 @@ function newPropertyDescriptor(object, resolverFunc, name) {
return descriptor;
}

NamedPropertiesTracker.prototype.track = function (name) {
/**
* Track a value (e.g. a Node) for a specified name.
*
* Values can be tracked eagerly, which means that not all tracked values *have* to appear in the output. The resolver
* function that was passed to the output may filter the value.
*
* Tracking the same `name` and `value` pair multiple times has no effect
*
* @param {String} name
* @param {*} value
*/
NamedPropertiesTracker.prototype.track = function (name, value) {
if (name === undefined || name === null || name === "") {
return;
}

let valueSet = this.trackedValues.get(name);
if (!valueSet) {
valueSet = new Set();
this.trackedValues.set(name, valueSet);
}

valueSet.add(value);

if (name in this.object) {
// already tracked or it is not a named property (e.g. "addEventListener")
// already added our getter or it is not a named property (e.g. "addEventListener")
return;
}

const descriptor = newPropertyDescriptor(this.object, this.resolverFunc, name);
const descriptor = newPropertyDescriptor(this, name);
Object.defineProperty(this.object, name, descriptor);
};

NamedPropertiesTracker.prototype.maybeUntrack = function (name) {
/**
* Stop tracking a previously tracked `name` & `value` pair, see track().
*
* Untracking the same `name` and `value` pair multiple times has no effect
*
* @param {String} name
* @param {*} value
*/
NamedPropertiesTracker.prototype.untrack = function (name, value) {
if (name === undefined || name === null || name === "") {
return;
}

const descriptor = Object.getOwnPropertyDescriptor(this.object, name);
const valueSet = this.trackedValues.get(name);
if (!valueSet) {
// the value is not present
return;
}

if (!descriptor || !descriptor.get || descriptor.get[IS_NAMED_PROPERTY] !== true) {
// Not defined by NamedPropertyTracker
if (!valueSet.delete(value)) {
// the value was not present
return;
}

const value = this.object[name];
if (value !== undefined) {
// still associated with a value
if (valueSet.size === 0) {
this.trackedValues.delete(name);
}

if (valueSet.size > 0) {
// other values for this name are still present
return;
}

// at this point there are no more values, delete the property

const descriptor = Object.getOwnPropertyDescriptor(this.object, name);

if (!descriptor || !descriptor.get || descriptor.get[IS_NAMED_PROPERTY] !== true) {
// Not defined by NamedPropertyTracker
return;
}

Expand Down
16 changes: 16 additions & 0 deletions lib/jsdom/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
var path = require("path");
var url = require("url");
const domSymbolTree = require("./living/helpers/internal-constants").domSymbolTree;
const SYMBOL_TREE_POSITION = require("symbol-tree").TreePosition;

exports.toFileUrl = function (fileName) {
// Beyond just the `path.resolve`, this is mostly for the benefit of Windows,
Expand Down Expand Up @@ -284,3 +285,18 @@ exports.simultaneousIterators = function * (first, second) {
];
}
};

exports.treeOrderSorter = function (a, b) {
const compare = domSymbolTree.compareTreePosition(a, b);

if (compare & SYMBOL_TREE_POSITION.PRECEDING) { // b is preceding a
return 1;
}

if (compare & SYMBOL_TREE_POSITION.FOLLOWING) {
return -1;
}

// disconnected or equal:
return 0;
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"parse5": "^1.4.2",
"request": "^2.55.0",
"setimmediate": "^1.0.2",
"symbol-tree": "^3.0.0",
"symbol-tree": ">= 3.1.0 < 4.0.0",
"tough-cookie": "^1.1.0",
"xml-name-validator": ">= 2.0.1 < 3.0.0",
"xmlhttprequest": ">= 1.6.0 < 2.0.0",
Expand Down

0 comments on commit bcf0533

Please sign in to comment.