From 9a22787f4e4cc9334b2904fd55aa16a24ed133db Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Thu, 23 May 2024 09:42:18 -0600 Subject: [PATCH] perf: memoize DqElement (#4452) Noticed this when trying to debug perf issues in `duplicate-id-aria`. We've run into problems on sites that have a module repeat 1000s of times on the page and the module has an aria id that is also then repeated. Axe-core would take a really long time to run the rule. Looking into it what I discovered is that a majority of the time was spent on resolving the `relatedNodes` for each check. Since each each duplicate id node was also in the `relatedNodes` for every other node, this caused the single node to [be converted to a DqElement](https://github.com/dequelabs/axe-core/blob/develop/lib/core/utils/check-helper.js#L46) _n_ times. This lead to many performance problems, but specifically calling the `getSelector` of a DqElement would call `outerHTML` for the node _n*2_ times which would be very slow. To solve this I decided to memoize the DqElement creation. That way a single node will only ever output a single DqElement, thus saving significant time in creation. Not only that but every time a node appears in a result (either as the check node or a related node) the memory is now shared so this change also reduces the in-memory size of the results object. Testing a simple page with 5000 nodes of duplicate id, here are the results for running the `duplicate-id-aria` check. | Before change (in ms) | After change (in ms) | | ------------- | ------------- | | 21,280.1 | 11,841.1 | _Flamechart before the change. The majority of the time is spent in getSelector_ _Chrome performance timer of getSelector showing it spent 12000ms total in the function_ _Flamechart after the change. Time is now spent mostly resolving the cache which results in no time spent in getSelector_ --- lib/core/utils/dq-element.js | 10 +- test/core/utils/dq-element.js | 241 +++++++++++++++-------------- test/core/utils/node-serializer.js | 15 +- 3 files changed, 141 insertions(+), 125 deletions(-) diff --git a/lib/core/utils/dq-element.js b/lib/core/utils/dq-element.js index 3cf790fe1a..edf3b6d39e 100644 --- a/lib/core/utils/dq-element.js +++ b/lib/core/utils/dq-element.js @@ -4,6 +4,7 @@ import getXpath from './get-xpath'; import getNodeFromTree from './get-node-from-tree'; import AbstractVirtualNode from '../base/virtual-node/abstract-virtual-node'; import cache from '../base/cache'; +import memoize from './memoize'; const CACHE_KEY = 'DqElm.RunOptions'; @@ -36,7 +37,10 @@ function getSource(element) { * @param {Object} options Propagated from axe.run/etc * @param {Object} spec Properties to use in place of the element when instantiated on Elements from other frames */ -function DqElement(elm, options = null, spec = {}) { +const DqElement = memoize(function DqElement(elm, options, spec) { + options ??= null; + spec ??= {}; + if (!options) { options = cache.get(CACHE_KEY) ?? {}; } @@ -82,7 +86,9 @@ function DqElement(elm, options = null, spec = {}) { if (!axe._audit.noHtml) { this.source = this.spec.source ?? getSource(this._element); } -} + + return this; +}); DqElement.prototype = { /** diff --git a/test/core/utils/dq-element.js b/test/core/utils/dq-element.js index 3e6f036f9e..dbef96ea64 100644 --- a/test/core/utils/dq-element.js +++ b/test/core/utils/dq-element.js @@ -1,135 +1,140 @@ -describe('DqElement', function () { - 'use strict'; +describe('DqElement', () => { + const DqElement = axe.utils.DqElement; + const fixture = document.getElementById('fixture'); + const fixtureSetup = axe.testUtils.fixtureSetup; + const queryFixture = axe.testUtils.queryFixture; - var DqElement = axe.utils.DqElement; - var fixture = document.getElementById('fixture'); - var fixtureSetup = axe.testUtils.fixtureSetup; - var queryFixture = axe.testUtils.queryFixture; - - afterEach(function () { + afterEach(() => { axe.reset(); }); - it('should be exposed to utils', function () { + it('should be exposed to utils', () => { assert.equal(axe.utils.DqElement, DqElement); }); - it('should take a virtual node as a parameter and return an object', function () { - var vNode = queryFixture('
'); - var result = new DqElement(vNode); + it('should take a virtual node as a parameter and return an object', () => { + const vNode = queryFixture(''); + const result = new DqElement(vNode); assert.equal(result.element, vNode.actualNode); }); - it('should take an actual node as a parameter and return an object', function () { - var vNode = queryFixture(''); - var result = new DqElement(vNode.actualNode); + it('should take an actual node as a parameter and return an object', () => { + const vNode = queryFixture(''); + const result = new DqElement(vNode.actualNode); assert.equal(result.element, vNode.actualNode); }); - describe('element', function () { - it('should store reference to the element', function () { - var vNode = queryFixture(''); - var dqEl = new DqElement(vNode); + it('should return the same DqElement when instantiated with the same element', () => { + const vNode = queryFixture(''); + const result = new DqElement(vNode); + const result2 = new DqElement(vNode); + assert.equal(result, result2); + }); + + describe('element', () => { + it('should store reference to the element', () => { + const vNode = queryFixture(''); + const dqEl = new DqElement(vNode); assert.equal(dqEl.element, vNode.actualNode); }); - it('should not be present in stringified version', function () { - var vNode = queryFixture(''); - var dqEl = new DqElement(vNode); + it('should not be present in stringified version', () => { + const vNode = queryFixture(''); + const dqEl = new DqElement(vNode); assert.isUndefined(JSON.parse(JSON.stringify(dqEl)).element); }); }); - describe('source', function () { - it('should include the outerHTML of the element', function () { - var vNode = queryFixture(' '); - var outerHTML = vNode.actualNode.outerHTML; - var result = new DqElement(vNode); + describe('source', () => { + it('should include the outerHTML of the element', () => { + const vNode = queryFixture(' '); + const outerHTML = vNode.actualNode.outerHTML; + const result = new DqElement(vNode); assert.equal(result.source, outerHTML); }); - it('should work with SVG elements', function () { - var vNode = queryFixture(''); - var result = new DqElement(vNode); + it('should work with SVG elements', () => { + const vNode = queryFixture(''); + const result = new DqElement(vNode); assert.equal(result.source, vNode.actualNode.outerHTML); }); - it('should work with MathML', function () { - var vNode = queryFixture( + it('should work with MathML', () => { + const vNode = queryFixture( '' ); - var result = new DqElement(vNode); + const result = new DqElement(vNode); assert.equal(result.source, vNode.actualNode.outerHTML); }); - it('should truncate large elements', function () { - var div = '