Skip to content

Wait for the DOM to settle before the audit runs - #44

Merged
MatheusRich merged 1 commit into
mainfrom
wait-for-the-dom-to-settle
Sep 23, 2026
Merged

MatheusRich merged 1 commit into
mainfrom
wait-for-the-dom-to-settle

Conversation

@MatheusRich

@MatheusRich MatheusRich commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Stacks on PR #39.

Problem

The audit starts as soon as the Capybara action returns. A page that renders on the client keeps changing after that point, so the audit reads the page in its previous state and misses the violations that the new state introduces. We hit this in a React application, where the router commits the new page a frame after the click returns, but the bug is not framework specific. The suite of this gem has one instance of it already: flunks on violations detected after #click_button audits the page before the new document arrives, and it fails every time in isolation on selenium_chrome_headless.

Proposed solution

Wait for the DOM to stop changing before the audit. A MutationObserver records when the document last changed, and the auditor polls until the document stays quiet for quiet_period_ms milliseconds (default 100), or until the quiet_period_timeout_ms deadline (default 2000), so a page that never settles cannot block the suite. Both are class attributes on AxeAuditor. The commit message explains the shape: the auditor polls in short round trips because a navigation kills a long evaluate_async_script, the wait runs before the axe install, and a rejected axe.run now reports its error instead of a script timeout.

The cost is about 115 milliseconds per audit. The rspec suite goes from 3.4s to 8.3s on selenium, from 12.8s to 17.7s on cuprite, and from 33.1s to 38.1s on playwright. That cost is the part to weigh: quiet_period_ms could start lower, or the wait could be opt-in, but then the silent miss stays the default.

One open question. Some applications need a wait of their own before the audit, for example Playwright's network idle, or the presence of a specific element. Today the only way to add one is to replace the auditor and override a private method. Should the gem expose a public hook for that, or is the DOM quiet period enough?

A playwright options fix that this branch carried at first is now its own PR #45.

@MatheusRich
MatheusRich force-pushed the wait-for-the-dom-to-settle branch from 4076157 to ad90f50 Compare September 23, 2026 12:55
Comment thread spec/system/axe_auditor_spec.rb Resolved
Comment thread README.md Resolved
@MatheusRich
MatheusRich force-pushed the wait-for-the-dom-to-settle branch from ad90f50 to 9e3b0d8 Compare September 23, 2026 14:29
@MatheusRich
MatheusRich force-pushed the resolve-session-at-audit-time branch from 9448c0a to 74fa47d Compare September 23, 2026 14:29
@MatheusRich
MatheusRich force-pushed the wait-for-the-dom-to-settle branch from 9e3b0d8 to a89dd55 Compare September 23, 2026 15:48
@MatheusRich
MatheusRich marked this pull request as ready for review September 23, 2026 16:52
@MatheusRich
MatheusRich force-pushed the resolve-session-at-audit-time branch from 74fa47d to 5e6cfba Compare September 23, 2026 17:55
Base automatically changed from resolve-session-at-audit-time to main September 23, 2026 18:00
The audit starts as soon as the Capybara action returns. A page that
renders on the client keeps changing after that point, so the audit
reads the page in its previous state. A router transition that commits
one frame after the click, or content that a later fetch injects, is
never audited. The violations that the new state introduces pass in
silence, which is the worst result for an audit tool.

The test suite of this gem already contains one instance of the
problem. The example `flunks on violations detected after #click_button`
submits a form and audits the page before the new document arrives. It
fails in isolation with `selenium_chrome_headless`, and in about one
full run in three.

This commit waits for the DOM before the audit. A `MutationObserver`
records when the document last changed. The auditor reads that value
until the document stays unchanged for `quiet_period_ms` milliseconds.
A page that never settles is audited at the `quiet_period_timeout_ms`
deadline, so the wait cannot block the suite. Both values are class
attributes, so a suite can tune them.

The auditor reads the value in short round trips, and not in one long
`evaluate_async_script` call. A page load discards the document that
such a script runs in, and the callback is then lost until the driver
reports a script timeout. The wait also happens before `axe` is
installed, because a page load between the install and the audit leaves
`axe` in the document that goes away.

The audit also reports the error that `axe` rejects with. Before this
commit, a rejected `axe.run` never called the callback, and the driver
raised a script timeout after its own deadline.

The wait costs about 115ms per audit. For the three spec files that
exist today, the suite goes from 3.4s to 8.3s with
`selenium_chrome_headless`, from 12.8s to 17.7s with `cuprite`, and
from 33.1s to 38.1s with `playwright`.
@MatheusRich
MatheusRich force-pushed the wait-for-the-dom-to-settle branch from a89dd55 to 109d556 Compare September 23, 2026 18:04
@MatheusRich
MatheusRich merged commit 777cb9e into main Sep 23, 2026
130 of 132 checks passed
@MatheusRich
MatheusRich deleted the wait-for-the-dom-to-settle branch September 23, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants