DOM Inspection for Dynamic Content
An accessibility scan is a snapshot of a DOM that has no obligation to hold still while it is being taken. This guide is part of Custom Rule Development & Context-Aware Testing, and it covers the settle problem: how to decide that a page has finished changing, how to prove that decision with an observable signal instead of a duration, how to scan the states that only exist while a user is interacting with something, and how to make all of it repeat identically on a CI runner that is four times slower than the laptop the test was written on.
Problem Statement
A scan that fires too early does not simply find fewer violations. It finds different violations, and both directions of the error cost real engineering time.
The false-positive direction is the loud one. A server-rendered document arrives with a bare <button> whose accessible name is attached during hydration, a <div> that becomes role="tablist" when the tabs component mounts, and an <img> whose alt is filled from a resolved data fetch. Scan at 200 milliseconds and axe-core reports button-name, aria-required-children and image-alt violations against markup that will be correct 400 milliseconds later. Nobody can fix those findings, because there is nothing wrong. What actually happens is that the team adds the rules to a disabled list, and the disabled list is permanent.
The false-negative direction is the expensive one, because it is silent. A skeleton placeholder is usually a grid of empty <div> elements: no text, no controls, no images, nothing for a scanner to object to. A scan that lands while the skeleton is still on screen walks a DOM in which the broken data table, the unlabelled filter chips and the icon-only row actions have not been created yet. The job goes green. The report even looks plausible — thirty passes, no violations — and the only clue is a passes array that is suspiciously short. Teams discover this months later when an auditor opens the same page by hand.
Mid-transition scans produce a third category that is worse than either: results that are internally contradictory. During a view transition both the outgoing and incoming subtrees are in the document at once, so the page briefly contains two <h1> elements, two <main> landmarks, and two elements with the same id. landmark-one-main and duplicate-id-active fire, the screenshot attached to the failure shows a perfectly normal page, and the finding cannot be reproduced by opening the URL. That combination — a red build and an unreproducible defect — burns more credibility than a missed violation.
The reflex fix is page.waitForTimeout(2000), and it is the worst available answer for three separate reasons. It is wrong when it is too short, which is discovered as flakiness. It is wrong when it is too long, which is never discovered at all: the two seconds are simply paid on every scan of every page in every shard, so a 60-page suite spends two minutes of runner time sleeping. And most importantly, a duration asserts nothing. When a sleep-based scan fails, the failure carries no information about what the page was doing, so the only available response is to increase the number, which converts a correctness bug into a slow, permanently unreliable pipeline. A sleep is not a wait; it is a guess with a comment attached.
waitForLoadState('networkidle') feels more principled and is only marginally better. Network quiet is not render quiet: a framework can resolve every request and still be several animation frames away from committing the tree, and an application with a polling endpoint, an analytics beacon or an open WebSocket never reaches network idle at all, so the wait resolves on a timeout that behaves exactly like the sleep it replaced.
Every reliable answer has the same shape. Wait for a fact the page can be asked about — a locator reaching a state, an aria-busy attribute clearing, a mutation-free interval, an idle callback firing — and treat the wait’s own outcome as test data, so that “I gave up waiting” is a distinguishable result rather than a silent scan of a moving target.
Key implementation targets:
- A settle signal chosen per page region, with the reasoning recorded in the test rather than in tribal memory.
- A
waitForQuiethelper built onMutationObserverthat reports whether quiet was actually reached, how many mutations it absorbed, and when the last one arrived. - A scan matrix keyed on interaction state, so overlay-only, expanded-only and error-only content is inside some scan’s scope.
- An explicit exclusion policy for nodes that are meant to be transient, so skeletons and enter animations never become findings.
- Determinism controls — animations off, seeded data, fixed viewport, no automatic retries on the accessibility project — that make a red result mean something.
Prerequisites
1. Choose a Settle Signal per Region
Do not look for one global “page is ready” moment. A dashboard has a shell that settles in 300 milliseconds, a chart that settles when its data resolves, and a notification list that never settles because it is subscribed to a stream. A single wait that satisfies all three either resolves too early for the chart or never resolves at all because of the stream.
Work region by region and pick the cheapest signal that is actually a fact about that region. In order of preference: an application-owned attribute (aria-busy, or a data-state the app sets); a locator reaching a state that only exists post-render, such as getByRole('table') becoming visible; and a mutation-quiet period over the region’s subtree when the application offers nothing else. The last option is the fallback, not the default, because it measures a side effect rather than an intent.
The aria-busy route deserves preference because it costs nothing extra. A container that sets aria-busy="true" while fetching is already required for assistive technology to avoid announcing a half-populated region, so the attribute is production behaviour that the test can lean on rather than test scaffolding. If the application does not set it, adding it is a two-line change that improves the product and gives the pipeline a signal in the same commit.
// tests/a11y/regions.ts — the settle contract for one page, kept next to the specs
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export type Region = {
name: string;
selector: string;
// 'busy' — the app sets aria-busy while the region loads (preferred)
// 'render' — nothing to observe but the node itself appearing
// 'quiet' — no application signal exists; fall back to mutation quiet
signal: 'busy' | 'render' | 'quiet';
};
export const ORDERS_REGIONS: Region[] = [
{ name: 'shell', selector: 'header nav', signal: 'render' },
{ name: 'summary cards', selector: '[data-region="summary"]', signal: 'busy' },
{ name: 'orders table', selector: '[data-region="orders"]', signal: 'busy' },
{ name: 'activity feed', selector: '[data-region="activity"]', signal: 'quiet' },
];
export async function settleRegion(page: Page, region: Region) {
const node = page.locator(region.selector);
await node.first().waitFor({ state: 'attached' });
if (region.signal === 'busy') {
// Poll the attribute rather than asserting once: it is set to "true" a tick
// after mount, so an immediate read can observe the pre-fetch state.
await expect
.poll(() => node.first().getAttribute('aria-busy'), { timeout: 10_000 })
.not.toBe('true');
}
if (region.signal === 'render') {
await expect(node.first()).toBeVisible();
}
}
A region table like this one is worth the twenty lines it costs. It makes the wait strategy reviewable in a pull request, it gives a name to every failure (“activity feed never went quiet” rather than “timeout 30000ms exceeded”), and it removes the temptation to paste a sleep into whichever spec happened to break.
2. A Reusable waitForQuiet Helper
When a region has no application signal, measure the DOM directly. A MutationObserver over the region’s subtree with a restarting timer resolves once nothing has changed for a chosen interval — 300 milliseconds is a good starting point, long enough to bridge two animation frames and a microtask flush, short enough that a 60-page suite does not notice.
The design decision that matters is what the helper returns. A wait that resolves either way, silently, is a sleep with extra steps. This one returns a report: whether quiet was genuinely reached or the deadline won, how many mutation records arrived, and how late the last one was. Those three numbers turn “flaky scan” into a diagnosis, and they belong in the test attachment for every run, not only for failures.
// tests/a11y/settle.ts — quiet-period detection that reports why it stopped
import type { Page } from '@playwright/test';
export type QuietReport = {
quiet: boolean; // false = the deadline fired while the DOM was still moving
mutations: number; // mutation records absorbed while waiting
waitedMs: number;
lastMutationMs: number; // offset of the final mutation, 0 when none arrived
};
export function waitForQuiet(
page: Page,
opts: { root?: string; quietMs?: number; deadlineMs?: number } = {},
): Promise<QuietReport> {
const { root = 'body', quietMs = 300, deadlineMs = 6000 } = opts;
return page.evaluate(
(args) =>
new Promise<QuietReport>((resolve) => {
const target = document.querySelector(args.root) ?? document.body;
const started = performance.now();
let mutations = 0;
let lastMutationMs = 0;
let quietTimer = 0;
const deadline = window.setTimeout(() => finish(false), args.deadlineMs);
const observer = new MutationObserver((records) => {
mutations += records.length;
lastMutationMs = performance.now() - started;
window.clearTimeout(quietTimer); // any mutation restarts the window
quietTimer = window.setTimeout(() => finish(true), args.quietMs);
});
observer.observe(target, {
childList: true,
subtree: true,
attributes: true, // aria-* churn is activity: state is still settling
characterData: true, // a label rewritten in place mutates no element
});
quietTimer = window.setTimeout(() => finish(true), args.quietMs);
function finish(quiet: boolean) {
window.clearTimeout(quietTimer);
window.clearTimeout(deadline);
observer.disconnect();
const done = () =>
resolve({
quiet,
mutations,
waitedMs: performance.now() - started,
lastMutationMs,
});
// One idle callback past quiet: a scheduler can hold committed work
// that has not yet produced a mutation record.
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => done(), { timeout: 200 });
} else {
window.setTimeout(done, 0);
}
}
}),
{ root, quietMs, deadlineMs },
);
}
Wrap the scan itself so that a deadline hit fails the test instead of producing an optimistic green. The wrapper below is the only place in the suite that calls analyze(), which means every scan in every spec inherits the same settle discipline and the same attachment.
// tests/a11y/scan.ts — the single entry point every spec uses to scan
import type { Page, TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { waitForQuiet } from './settle';
export async function scanSettled(
page: Page,
testInfo: TestInfo,
label: string,
context: { include?: string[][]; exclude?: string[][] } = {},
) {
const report = await waitForQuiet(page, { quietMs: 300, deadlineMs: 6000 });
await testInfo.attach(`settle-${label}.json`, {
body: JSON.stringify(report, null, 2),
contentType: 'application/json',
});
if (!report.quiet) {
// Scanning a moving DOM is worse than not scanning: it produces findings
// nobody can reproduce. Fail with the numbers that explain why.
throw new Error(
`DOM never went quiet for "${label}": ${report.mutations} mutations, ` +
`last at ${Math.round(report.lastMutationMs)}ms of ${report.waitedMs}ms`,
);
}
let builder = new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']);
for (const inc of context.include ?? []) builder = builder.include(inc);
for (const exc of context.exclude ?? []) builder = builder.exclude(exc);
return builder.analyze();
}
3. Scan Once per Interaction State
One scan per URL is a habit inherited from server-rendered sites, where one URL really did mean one document. In an application, a URL is a set of states, and a large share of the accessibility surface exists in exactly one of them. A modal’s aria-modal, its focus trap and its close button only exist while it is open. A combobox’s listbox and its aria-activedescendant only exist while it is expanded. A form’s error summary, its aria-invalid attributes and its aria-describedby wiring only exist after a failed submit. Scan the initial state only, and none of those elements is ever evaluated by any rule.
Model the states explicitly as data: a name, an arrange function that drives the page into the state, and the axe context to use once there. This makes the coverage auditable — a reviewer can count the states and notice that nobody scans the error state — and it keeps the specs from turning into one long imperative script where the fifth scan depends on the first four having run.
Overlay states need a context, not just a wait. When a dialog is open, the content behind it is typically aria-hidden or inert, and scanning the whole document produces aria-hidden-focus findings for the page underneath that are an artefact of the state rather than a defect. Point include at the dialog for that leg and let the base state own the page behind it.
// tests/a11y/orders-states.spec.ts — one scan per interaction state
import { test, expect, type Page } from '@playwright/test';
import { scanSettled } from './scan';
import { ORDERS_REGIONS, settleRegion } from './regions';
type State = {
name: string;
arrange: (page: Page) => Promise<void>;
context?: { include?: string[][]; exclude?: string[][] };
};
const STATES: State[] = [
{
name: 'loaded-list',
arrange: async () => {},
context: { exclude: [['[data-region="activity"]']] }, // streams forever
},
{
name: 'filters-panel-open',
arrange: async (page) => {
await page.getByRole('button', { name: 'Filters' }).click();
await page.getByRole('dialog', { name: 'Filter orders' }).waitFor();
},
// Only the dialog: the page behind it is inert in this state.
context: { include: [['[role="dialog"]']] },
},
{
name: 'row-expanded',
arrange: async (page) => {
await page.getByRole('button', { name: 'Expand order 10482' }).click();
await expect(page.getByRole('region', { name: 'Order 10482 detail' }))
.toBeVisible();
},
},
{
name: 'submit-validation-errors',
arrange: async (page) => {
await page.getByRole('button', { name: 'Filters' }).click();
await page.getByLabel('Minimum total').fill('-5');
await page.getByRole('button', { name: 'Apply' }).click();
await page.getByRole('alert').waitFor(); // the error summary only exists now
},
context: { include: [['[role="dialog"]']] },
},
];
for (const state of STATES) {
test(`orders page is accessible in state: ${state.name}`, async ({ page }, info) => {
await page.goto('/orders');
for (const region of ORDERS_REGIONS) await settleRegion(page, region);
await state.arrange(page);
const results = await scanSettled(page, info, state.name, state.context);
const blocking = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious',
);
expect(blocking.map((v) => `${v.id} x${v.nodes.length}`)).toEqual([]);
});
}
Note the shape of the arrange functions: each one starts from a fresh page.goto rather than continuing where the previous test left off. That costs a page load per state and buys independence — a failure in the validation-error state does not cascade from a failure in the filters state, and the four tests can run on four workers.
Some state changes deserve a rule rather than a scan. An aria-expanded attribute that never updates because the framework batched the write is a state-tracking defect that axe cannot see in either snapshot, which is the subject of handling dynamic ARIA states in modern JavaScript frameworks. Focus behaviour across a view swap is likewise invisible to any single snapshot and needs a transition-aware assertion, covered in testing focus management after client-side route changes.
4. Exclude Intentionally Transient Nodes
Some nodes are supposed to be temporary and are supposed to be invisible to assistive technology while they exist. A skeleton placeholder, a shimmer block, a toast in its enter animation, and the spacer elements a virtualised list uses to fake scroll height are all correct markup that will produce findings if a rule is pointed at them.
The right response is a policy the application participates in, not a growing list of disabled rules. Require three things of any transient node: the container carries aria-busy="true" while it is present, the placeholder itself carries aria-hidden="true" so it is not announced, and it carries a data-a11y-transient attribute the test suite can exclude. Then the exclusion in the axe context is one selector rather than one per component, and a component that forgets the contract shows up as a scan failure rather than as silence.
// a11y/rules/checks/transient-placeholder.js
// A node marked transient must also be hidden from assistive technology and
// must sit inside a container that declares itself busy.
export const transientNodeIsHidden = {
id: 'transient-node-is-hidden',
metadata: {
impact: 'serious',
messages: {
pass: 'Transient placeholder is hidden and its container is busy',
fail: 'Transient placeholder is exposed: ${data.reason}',
incomplete: 'Placeholder has no container to check; verify by hand',
},
},
evaluate: function (node) {
const container = node.closest('[aria-busy]');
if (!container) return undefined; // cannot prove either way
const hidden = node.getAttribute('aria-hidden') === 'true';
const busy = container.getAttribute('aria-busy') === 'true';
const reason = !hidden
? 'missing aria-hidden'
: !busy
? 'container is not aria-busy'
: '';
this.data({ reason });
this.relatedNodes([container]);
return hidden && busy;
},
};
The exclusion then goes in one place, and the excluded selector is the same string the rule enforces:
// tests/a11y/scan-context.ts — the transient policy, expressed once
export const TRANSIENT = '[data-a11y-transient]';
// Excluded from every scan, because by contract these nodes are aria-hidden
// and their container is aria-busy while they exist.
export const BASE_EXCLUDE: string[][] = [
[TRANSIENT],
['[data-region="activity"]'], // subscribed to a stream; never goes quiet
['.leaflet-container'], // third-party map canvas, audited separately
];
Keep this list short and comment every entry with the reason, because an exclusion list is where accountability goes to die. Two entries that each say “third-party widget” become twenty entries in a year, at which point the scan covers the parts of the page nobody was worried about. A related failure mode — a list that renders only its visible window, so the scan sees twenty rows out of ten thousand and reports a clean pass — is not solved by exclusion at all and needs the sampling strategies in scanning virtualised lists without false negatives.
5. Make It Deterministic in CI
A settle signal removes the guesswork about when to scan. It does not, on its own, make two runs of the same page produce the same DOM. Four controls do most of that work.
Kill animation and transition timing. A CSS transition is a stream of mutations to the style attribute in some frameworks and a source of geometric instability in all of them, and axe’s color-contrast and target-size checks read geometry. Setting prefers-reduced-motion and injecting a stylesheet that zeroes durations makes the quiet period arrive sooner and makes measurements stable.
Freeze the data. A relative timestamp that renders “2 minutes ago” mutates the DOM on a timer, so the quiet period is never reached on a page with a live clock. Route the fixture data through a request handler and pin Date.now at page-init time, or accept that the region must be excluded.
Pin the viewport, and pick it deliberately. target-size and color-contrast results depend on layout, and a responsive page at 1280 pixels is a different accessibility surface from the same page at 375. Scan both if both ship, as two projects, not as an accident of the runner’s default window size.
Turn retries off for the accessibility project specifically. Retries are the correct tool for network flakiness and the wrong tool here: a scan that passes on the second attempt is telling you the settle signal is wrong, and a retry hides exactly the information the QuietReport was added to surface.
// playwright.config.ts — the accessibility project is deliberately strict
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests/a11y',
timeout: 90_000,
expect: { timeout: 10_000 },
reporter: [['list'], ['json', { outputFile: 'a11y/state-scan.json' }]],
projects: [
{
name: 'a11y-desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 900 },
// Honoured by @media queries and by axe's motion-sensitive checks.
reducedMotion: 'reduce',
trace: 'retain-on-failure',
},
retries: 0, // a scan that only passes on retry has a broken wait, not a flake
},
{
name: 'a11y-mobile',
use: { ...devices['Pixel 7'], reducedMotion: 'reduce' },
retries: 0,
},
],
webServer: {
command: 'npm run build && npm run preview -- --port 4300 --strictPort',
url: 'http://127.0.0.1:4300',
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
use: { baseURL: 'http://127.0.0.1:4300' },
});
// tests/a11y/fixtures.ts — determinism applied before any application code runs
import { test as base } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use) => {
await page.addInitScript(() => {
// A fixed clock stops relative timestamps from mutating on a timer.
const FIXED = new Date('2026-03-04T09:00:00Z').getTime();
const RealDate = Date;
// @ts-expect-error deliberate global override inside the page context
window.Date = class extends RealDate {
constructor(...args: unknown[]) {
super(...(args.length ? (args as []) : [FIXED]));
}
static now() {
return FIXED;
}
};
});
await page.addStyleTag({
content: `*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}`,
});
await use(page);
},
});
addStyleTag runs per navigation, so for a suite that navigates more than once it belongs in the arrange step or in an addInitScript that appends the sheet on DOMContentLoaded. That detail is worth checking, because a stylesheet that silently stopped applying after the second goto is a classic source of “the mobile project is flaky and nobody knows why”.
Pipeline Integration
The state matrix changes the unit of reporting. Instead of “the orders page failed”, the job reports “orders page, submit-validation-errors state, aria-describedby points at a removed node”, which is a bug report a developer can act on without reproducing anything. Preserve that granularity all the way to the pull request: name each test after its state, keep one JSON artifact per state, and let the step summary list states rather than URLs.
name: a11y-dynamic-states
on:
pull_request:
paths:
- 'src/**'
- 'tests/a11y/**'
- '.github/workflows/a11y-dynamic-states.yml'
concurrency:
group: a11y-states-${{ github.head_ref }}
cancel-in-progress: true
jobs:
state-matrix-scan:
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Scan every interaction state
run: npx playwright test --project=a11y-desktop --workers=2
- name: List the settle reports for failed states
if: failure()
run: |
# settle-*.json is attached by scanSettled for every scan, so a
# deadline hit is visible without opening the HTML report.
find test-results -name 'settle-*.json' -print -exec cat {} \;
- uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-state-scan
path: |
a11y/state-scan.json
test-results/
retention-days: 21
Exit codes stay conventional: Playwright exits non-zero when any state fails its blocking-impact assertion, and the job is a required status check on the branch. The one addition worth making is a distinct failure vocabulary for settle problems. A thrown DOM never went quiet error is not an accessibility violation, and it should not be counted as one in trend reporting — otherwise a slow runner shows up as an accessibility regression on the dashboards described in the reporting and violation-tracking guide. Tag those failures separately, and treat a rising count as an infrastructure signal.
Troubleshooting and Flaky-Test Mitigation
Quiet is never reached on a page with a live region. A ticker, a relative timestamp, a polling status pill or a carousel mutates forever, so the observer never sees a 300-millisecond gap. Scope the observer to the region under test with the root option instead of body, or exclude the animating region from the scan and give it a dedicated component-level test. Raising quietMs does not help: the interval between mutations is the problem, not its length.
The deadline is hit only in CI. The helper’s report distinguishes this from every other failure: quiet: false with a high mutations count means the page is genuinely still working, while quiet: false with mutations: 0 and a lastMutationMs of 0 means the observed root never existed and the selector is wrong. The first needs a longer deadlineMs or a faster fixture; the second is a test bug that a longer timeout will never fix.
A violation appears on roughly one run in ten. Check whether the finding is color-contrast or target-size. Both read geometry, and both produce intermittent results when a transition is still running or a web font swaps after first paint. Disable animation timing as shown above, and preload the fonts the page uses, so metrics do not change between the first and second paint.
aria-busy never clears on an empty result set. A common implementation sets aria-busy="true" before the fetch and clears it in the row-rendering code path, which does not run when the response has zero rows. The empty state then waits forever. Clear the attribute in the request’s settle handler rather than in the render branch, and add the empty-result case to the state matrix so it is exercised.
Two <h1> elements or duplicate ids during a transition. This is the mid-transition scan, and it means the settle step ran before the outgoing subtree was removed. Wait for the outgoing view’s marker to detach — expect(locator).toHaveCount(0) — before starting the quiet period, and for a route change specifically, use the transition-aware wait described in waiting for route transitions before an axe scan.
The suite got slower after adopting quiet periods. Measure before assuming. waitedMs in the attached reports is the exact cost, and it is usually lower than the sleeps it replaced; when it is not, the cause is nearly always one region that keeps mutating well after the rest of the page has stopped, and it should have been excluded and tested separately. If a genuine baseline of noisy findings makes the whole exercise unpleasant, deal with the noise first using the tactics in reducing false positives in automated accessibility scanners.
Common Pitfalls
- Replacing a failed wait with a longer sleep, which converts a diagnosable timing bug into a permanent tax on every run of the suite.
- Treating
networkidleas a render signal, so the scan lands before hydration on fast pages and never resolves on pages with a polling endpoint. - Letting the settle helper resolve on its deadline without failing, which produces a green build from a scan of a moving DOM.
- Observing
bodyon every page instead of the region under test, guaranteeing that one animated widget prevents quiet across the whole suite. - Scanning only the initial state, leaving every dialog, expanded panel and validation-error surface unevaluated by any rule.
- Scanning the whole document while an overlay is open, then disabling
aria-hidden-focusto silence the inert content behind the dialog. - Excluding a region without a comment explaining why, so the exclusion list grows until the scan covers only the static parts of the page.
- Leaving retries enabled on the accessibility project, which hides the difference between a wait that is wrong and a runner that was briefly busy.
- Forgetting that
addStyleTagdoes not survive a navigation, so animations are only disabled for the first page of a multi-step spec.
FAQ
How long should the quiet window be?
Start at 300 milliseconds and let the data decide. The window has to be longer than the largest normal gap between mutations in a single render pass — two animation frames plus a microtask flush, which is comfortably under 100 milliseconds on a modern runner — and shorter than the shortest gap you would consider “finished”. If the attached reports show lastMutationMs clustering just under the window on a healthy page, the window is too short and the scan is racing; if waitedMs is consistently the window plus a few milliseconds, the page settled long before the wait started and the window can come down.
Is requestIdleCallback a substitute for the quiet period?
No, it is a complement, and a weak one on its own. An idle callback fires when the main thread has spare time, which can happen in the middle of a sequence of renders that are waiting on network responses, so it is not evidence that the DOM has stopped changing. It is useful immediately after quiet is detected, because it lets a scheduler flush work it has already committed but not yet turned into mutation records. Note also that it is not implemented everywhere, so any use needs the fallback shown in the helper.
Should the scan run once per state or once per page with all states visited in sequence? Once per state, with a fresh navigation each time. Visiting states in sequence inside one test is faster by one page load per state, and it costs independence: the third scan inherits whatever the first two left behind, a failure early in the chain masks everything after it, and the states cannot be spread across workers. The exception is a genuinely sequential flow — a three-step checkout — where the states only exist as a continuation and each step should be scanned as it is reached.
Does excluding a transient node hide real violations inside it?
It hides violations in nodes marked data-a11y-transient, which is exactly why the marker needs a rule attached to it rather than just an exclusion. The custom check in section four asserts that anything wearing the marker is also aria-hidden inside an aria-busy container, so a component that marks a permanent node as transient fails the scan instead of quietly opting out of it. Review the exclusion list on a schedule, and treat its length as a metric.
What about content that is only reachable by scrolling? Content below the fold is in the DOM and is scanned normally; content that does not exist until it scrolls into view is not, and the two look identical from the outside. Lazy-mounted sections need an arrange step that scrolls them into view and waits for their own settle signal before the scan, and a windowed list that recycles rows needs a different approach entirely, because scrolling to the bottom removes the rows at the top from the document.
Related
- Custom Rule Development & Context-Aware Testing — the parent section covering custom checks, rules and the contexts they run in.
- Handling Dynamic ARIA States in Modern JavaScript Frameworks — asserting that state attributes track component state through a render cycle.
- Testing Focus Management After Client-Side Route Changes — where focus goes when the view it was in stops existing.
- Scanning Virtualized Lists Without False Negatives — coverage strategies for a DOM that is deliberately incomplete.
- Handling Single-Page Application Routing — route matrices, arrival signals and the failures that only appear on the second navigation.