Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2.0.0: Only audit nodes that have changed #8

Merged
merged 3 commits into from
Nov 30, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions AuditQueue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { rIC as requestIdleCallback } from 'idlize/idle-callback-polyfills.mjs'

export default class AuditQueue {
constructor() {
this._pendingAudits = []
this._isRunning = false

this.run = this.run.bind(this)
this._scheduleAudits = this._scheduleAudits.bind(this)
}
run(getAuditResult) {
// Returns a promise that resolves to the result of the auditCallback.
return new Promise((resolve, reject) => {
const runAudit = async () => {
try {
const result = await getAuditResult()
resolve(await result)
} catch (error) {
reject(error)
}
}

this._pendingAudits.push(runAudit)
if (!this._isRunning) this._scheduleAudits()
})
}
_scheduleAudits() {
this._isRunning = true
requestIdleCallback(async IdleDeadline => {
// Run pending audits as long as they exist & we have time.
while (
this._pendingAudits.length > 0 &&
IdleDeadline.timeRemaining() > 0
) {
// Only run one audit at a time, as axe-core does not allow for
// concurrent runs.
// Ref: https://github.com/dequelabs/axe-core/issues/1041
const runAudit = this._pendingAudits[0]
await runAudit()

// Once an audit has run, remove it from the queue.
this._pendingAudits.shift()
}

if (this._pendingAudits.length > 0) {
// If pending audits remain, schedule them for the next idle phase.
this._scheduleAudits()
} else {
// The queue is empty, we're no longer running
this._isRunning = false
}
})
}
}
59 changes: 23 additions & 36 deletions AxeObserver.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
import axeCore from 'axe-core'
import debounce from 'lodash.debounce'
import PQueue from 'p-queue'

// If requestIdleCallback is not supported, we fallback to setTimeout
// Ref: https://developers.google.com/web/updates/2015/08/using-requestidlecallback
const requestIdleCallback =
window.requestIdleCallback ||
function(callback) {
setTimeout(callback, 1)
}

// axe-core cannot be run concurrently. There are no plans to implement this
// functionality or a queue. Hence we use PQueue to queue calls ourselves.
// Ref: https://github.com/storybookjs/storybook/pull/4086
const axeQueue = new PQueue({ concurrency: 1 })
import AuditQueue from './AuditQueue.js'

// The AxeObserver class takes a violationsCallback, which is invoked with an
// array of observed violations.
Expand Down Expand Up @@ -46,10 +32,17 @@ export default class AxeObserver {

this.observe = this.observe.bind(this)
this.disconnect = this.disconnect.bind(this)
this._auditTargetNode = this._auditTargetNode.bind(this)
this._auditNode = this._auditNode.bind(this)

this._mutationObservers = []
this._alreadyReportedIncidents = new Set()
this._mutationObserver = new window.MutationObserver(mutationRecords => {
mutationRecords.forEach(mutationRecord => {
this._auditNode(mutationRecord.target)
})
})
Comment on lines +38 to +42
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audit each mutated node individually


// AuditQueue sequentially runs audits when the browser is idle.
this._auditQueue = new AuditQueue()

// Allow for registering plugins etc
if (typeof axeInstanceCallback === 'function') {
Expand All @@ -59,36 +52,30 @@ export default class AxeObserver {
// Configure axe
axeCore.configure(axeCoreConfiguration)
}
observe(targetNode, { debounceMs = 1000, maxWaitMs = debounceMs * 5 } = {}) {
observe(targetNode) {
if (!targetNode) {
throw new Error('AxeObserver.observe requires a targetNode')
}

const scheduleAudit = debounce(
() => requestIdleCallback(() => this._auditTargetNode(targetNode)),
Copy link
Collaborator Author

@jul-sh jul-sh Nov 29, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the debounced function fired, we also scheduled the whole node audit via requestIdleCallback. While the audit was started during an idle period, it would often take several hundred ms to run. Hence the requestIdleCallback wasn't that useful, since the work would continue way past the actual idle period.

In the new version, smaller chunks of work are used. They get executed for only as long as the idle period lasts.

debounceMs,
{ leading: true, maxWait: maxWaitMs }
)
const mutationObserver = new window.MutationObserver(scheduleAudit)

// observe changes
mutationObserver.observe(targetNode, {
this._mutationObserver.observe(targetNode, {
attributes: true,
subtree: true
})

this._mutationObservers.push(mutationObserver)

// run initial audit
scheduleAudit(targetNode)
// run initial audit on the whole targetNode
this._auditNode(targetNode)
}
disconnect() {
this._mutationObservers.forEach(mutationObserver => {
mutationObserver.disconnect()
})
this.mutationObserver.disconnect()
}
async _auditTargetNode(targetNode) {
const response = await axeQueue.add(() => axeCore.run(targetNode))
async _auditNode(node) {
const response = await this._auditQueue.run(async () => {
// Since audits are scheduled asynchronously, it can happen that
// the node is no longer connected. We cannot analyze it then.
return node.isConnected ? axeCore.run(node) : null
})

if (!response) return

const violationsToReport = response.violations.filter(violation => {
const filteredNodes = violation.nodes.filter(node => {
Expand Down
9 changes: 3 additions & 6 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ You can install the module from NPM via (`npm install --save-dev agnostic-axe`)
## Usage

```js
import('https://unpkg.com/agnostic-axe@1').then(
import('https://unpkg.com/agnostic-axe@2').then(
({ AxeObserver, logViolations }) => {
const MyAxeObserver = new AxeObserver(logViolations)
MyAxeObserver.observe(document)
Expand All @@ -19,7 +19,7 @@ import('https://unpkg.com/agnostic-axe@1').then(

> Be sure to only run the module in your development environment. Else your application will use more resources than necessary when in production.

In the example above, once the `observe` method has been invoked, `MyAxeObserver` starts reporting accessibility defects to the browser console. It continously observes the passed node for changes. If a change has been detected, it will reanalyze the node and report any new accessibility defects.
In the example above, once the `observe` method has been invoked, `MyAxeObserver` audits the passed node and reports any accessibility defects to the browser console. It continously observes the passed node for changes. If a change has been detected, it will audit the parts of the nodes that have changed, and report any new accessibility defects.

To observe multiple nodes, one can call the `observe` method multiple times.

Expand All @@ -46,12 +46,9 @@ The `AxeObserver` constructor takes two parameters:
- `axeCoreConfiguration` (optional). A configuration object for axe-core. Read about the object here in the [axe-core docs](https://github.com/dequelabs/axe-core/blob/master/doc/API.md#api-name-axeconfigure) and see the `agnostic-axe` source code for the default options used.
- `axeCoreInstanceCallback` (optional). A function that is invoked with the instance of [axe-core](https://github.com/dequelabs/axe-core) used by module. It can be used for complex interactions with the [axe-core API](https://github.com/dequelabs/axe-core/blob/develop/doc/API.md) such as registering plugins.

The `AxeObserver.observe` method takes two parameters:
The `AxeObserver.observe` method takes one parameter:

- `targetNode` (required). The node that should be observed & analyzed.
- `options` (optional). An object with that supports the following configuration keys:
- `debounceMs` (optional, defaults to `1000`). The number of milliseconds that updates should cease before an analysis is performed.
- `maxWaitMs` (optional, defaults to `debounceMs * 5`). Number of milliseconds after which an analysis will be performed, even if the targetNode is still updating.

## Credits

Expand Down
37 changes: 8 additions & 29 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agnostic-axe",
"version": "1.1.1",
"version": "2.0.0",
"description": "Framework agnostic accessibility auditing with axe-core",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down Expand Up @@ -43,7 +43,6 @@
},
"dependencies": {
"axe-core": "^3.4.0",
"lodash.debounce": "^4.0.8",
"p-queue": "^6.2.1"
"idlize": "^0.1.1"
}
}