Waiting for Route Transitions Before an axe Scan
There is a window during a client-side navigation, usually two or three hundred milliseconds wide, in which the document contains two views. Scan inside that window and axe reports duplicate ids, two main landmarks and repeated aria-labelledby targets — every one of them a genuine description of the DOM at that instant, and none of them something a user could ever encounter. This guide is part of Handling Single-Page Application Routing, and it is about one narrow question: when is the post-transition DOM safe to hand to a scanner.
Root Cause
Three waits look like they solve this and do not. page.click() resolves once the click event has been dispatched and the browser has processed it — the router’s handler may not even have run yet, and the incoming view certainly has not rendered. A scan that follows immediately walks the outgoing view and files its violations under the destination URL, which is the most confusing artifact in this whole class: the report names a page whose markup it never examined. page.waitForURL() is better but proves only that history.pushState happened, which routers do synchronously with the click, typically several hundred milliseconds before the view commits. And waiting for the incoming view’s heading proves the new DOM has arrived without proving the old DOM has left.
That last case is the one that produces phantom violations, and it is getting more common rather than less. A cross-fade, a slide-out, or the View Transitions API all deliberately keep the outgoing subtree in the document while the animation runs, so for the duration of the animation both views are mounted, both have an <h1>, both have a main, and every id the framework generated deterministically now exists twice. axe is not wrong; the document really does have two id="order-summary" elements. The document is simply mid-flight, and a mid-flight document is not a state any user is ever presented with.
The cost is not only noise. A gate that produces violations which vanish on re-run trains a team to re-run the gate, and a gate that gets re-run reflexively has stopped gating. Worse, duplicate-id-aria carries a critical impact, so the phantoms land in exactly the bucket a blocking threshold cares about and outrank the one real violation the destination view actually has. Chasing them through rule configuration is the wrong repair, as it trades a timing bug for a permanently weakened rule set; the guidance on reducing false positives in automated scanners applies to real ambiguity in the DOM, not to a scan that was taken at the wrong moment.
Configuration
Settling needs a fact the test can only capture before the navigation: a reference to the view that is about to leave. That makes the helper two-phase — open a transition token before the click, then settle it afterwards — which also reads correctly in the spec, because the pair brackets the navigation.
The mutation-quiet stage uses a clock rather than a promise-per-wait. An init script keeps a single timestamp of the last mutation on window, and the settle step polls the difference. That keeps the observer alive across the whole spec instead of installing and tearing one down per wait, and it makes the quiet threshold a comparison rather than a race between timers.
// tests/a11y/mutation-clock.js — installed with page.addInitScript
(() => {
window.__lastMutationAt = 0;
const bump = () => {
window.__lastMutationAt = performance.now();
};
new MutationObserver(bump).observe(document, {
childList: true,
subtree: true,
attributes: true,
// Class churn is how most exit animations run, and aria-busy / inert churn
// means state is still settling. All of it counts as activity.
attributeFilter: ['class', 'aria-busy', 'aria-hidden', 'inert', 'hidden'],
});
})();
// tests/a11y/settle-after-route-change.ts
import type { JSHandle, Page } from '@playwright/test';
export class SettleTimeoutError extends Error {
constructor(public stage: string, public waitedMs: number) {
super(`route transition never settled: stuck at "${stage}" after ${waitedMs} ms`);
this.name = 'SettleTimeoutError';
}
}
export interface RouteTransition {
page: Page;
outgoing: JSHandle<Element>;
startedAt: number;
}
// Call this immediately BEFORE the click: once the router has run, the outgoing
// view may already be gone and there is nothing left to hold a reference to.
export async function beginRouteChange(page: Page): Promise<RouteTransition> {
const outgoing = await page.evaluateHandle(() => {
const view = document.querySelector('[data-route-view]');
if (!view) throw new Error('no [data-route-view] element to navigate away from');
return view;
});
return { page, outgoing, startedAt: Date.now() };
}
async function stage<T>(
name: string,
t: RouteTransition,
budgetMs: number,
run: (timeout: number) => Promise<T>,
): Promise<T> {
const remaining = budgetMs - (Date.now() - t.startedAt);
if (remaining <= 0) throw new SettleTimeoutError(name, budgetMs);
try {
return await run(remaining);
} catch (error) {
// Only a timeout becomes a SettleTimeoutError; a real page error, a closed
// context or a thrown assertion must keep its own stack.
if (error instanceof Error && error.name === 'TimeoutError') {
throw new SettleTimeoutError(name, Date.now() - t.startedAt);
}
throw error;
}
}
export interface SettleOptions {
heading: RegExp; // the incoming view's level-1 heading
quietMs?: number; // how long the DOM must stop mutating
budgetMs?: number; // ceiling for the whole settle, all stages together
}
export async function settle(t: RouteTransition, opts: SettleOptions): Promise<void> {
const { heading, quietMs = 300, budgetMs = 5000 } = opts;
// Stage 1 — the outgoing view must leave the document. This is the stage a
// view transition or an exit animation fails, and the reason a duplicate-id
// report ever appears.
await stage('outgoing-view-detached', t, budgetMs, (timeout) =>
t.page.waitForFunction((node) => !node.isConnected, t.outgoing, { timeout }),
);
// Stage 2 — the incoming view must be attached and identifiable, so the scan
// cannot run against an empty outlet between the two commits.
await stage('incoming-heading-attached', t, budgetMs, (timeout) =>
t.page
.getByRole('heading', { level: 1, name: heading })
.waitFor({ state: 'attached', timeout }),
);
// Stage 3 — the DOM must go quiet, which covers late data, layout shifts and
// the enter animation of the incoming view.
await stage('dom-quiet', t, budgetMs, (timeout) =>
t.page.waitForFunction(
(quiet) => performance.now() - (window.__lastMutationAt ?? 0) >= quiet,
quietMs,
{ timeout, polling: 50 },
),
);
await t.outgoing.dispose(); // stop retaining the detached subtree
}
The window.__lastMutationAt reference needs a type declaration once per project:
// tests/a11y/global.d.ts
declare global {
interface Window {
__lastMutationAt?: number;
}
}
export {};
Validation
The spec below is what the helper is for. Note the deliberate refusal to scan when two views are still mounted: if stage 1 somehow passed while a second view existed — a nested outlet the token did not capture — the count assertion fails rather than producing violations nobody can reproduce.
// tests/a11y/scan-timing.spec.ts
import { readFileSync } from 'node:fs';
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { beginRouteChange, settle } from './settle-after-route-change';
const clock = readFileSync('tests/a11y/mutation-clock.js', 'utf8');
test.beforeEach(async ({ page }) => {
await page.addInitScript({ content: clock });
});
test('the orders view is scanned only after the transition settles', async ({ page }) => {
await page.goto('/');
await page.locator('[data-app-hydrated="true"]').waitFor();
const transition = await beginRouteChange(page);
await page.getByRole('link', { name: 'Orders', exact: true }).click();
// Necessary, nowhere near sufficient: pushState runs in the same task as the
// click, long before the incoming view commits.
await page.waitForURL('**/orders');
await settle(transition, { heading: /^Orders$/, quietMs: 300 });
// Refuse to scan a document that still holds two views.
await expect(page.locator('[data-route-view]')).toHaveCount(1);
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
expect(results.violations.map((v) => `${v.id} x${v.nodes.length}`)).toEqual([]);
});
Prove the settle matters by temporarily scanning at the two earlier moments as well. Running the same navigation three times with the scan at A, B and C produces three different reports from one unchanged application:
npx playwright test tests/a11y/scan-timing.spec.ts --reporter=list
# A — scan straight after click resolved (+8 ms):
# 3 violations, all from /dashboard, filed against /orders
# B — scan after waitForURL and the heading appeared (+215 ms):
# 9 violations: duplicate-id-aria x6, landmark-one-main x1,
# landmark-unique x1, label x1
# C — scan after settle() (+520 ms):
# 1 violation: label x1
The single label violation is the only real finding in the application, and it is present in every run — which is the point. The other eight results at moment B are artifacts of the overlap, and the three at moment A belong to a different view entirely. A gate wired to moment B blocks a pull request nine times and is honest once.
Edge Cases and Conditional Guards
- A view that never goes quiet. A polling widget, a live clock or a virtualised list that re-renders on every animation frame will never satisfy
dom-quiet, and the settle will spend its whole budget and throw. Narrow the observer to the view root instead ofdocument, or scope the scan and accept a shorter quiet window; the sampling strategy for perpetually mutating content is covered in scanning virtualised lists without false negatives. - Routes that reuse the view root. Some routers swap only the inner content and keep the
[data-route-view]element itself, so stage 1 can never pass because the outgoing node is never detached. Detect this once per application: if the element survives navigation by design, key stage 1 on adata-route-idattribute changing rather than onisConnected. - A transition that legitimately keeps both views. A master-detail layout where the list stays mounted beside the detail is not an overlap, it is the design. Mark only the swapped subtree with
data-route-viewand leave the persistent panel unmarked, otherwise stage 1 waits forever for a view that is supposed to stay.
Pipeline Impact
The behaviour that matters in CI is what happens on timeout. A settle helper that swallows its own failure and scans anyway converts a deterministic timeout into a random pile of violations, and the job then fails for a reason that has nothing to do with the actual problem — which is the single fastest way to lose trust in an accessibility gate. Throwing a named error instead means the failure message is stuck at "outgoing-view-detached" after 5000 ms, the report is empty, and the engineer reads the animation code rather than the rule documentation.
Budget accordingly. Three stages at up to five seconds each would let one bad transition eat fifteen seconds; sharing a single budget across all three keeps the worst case at five, which is the number to multiply by the size of the route matrix when setting the job’s timeout-minutes. On a cold CI runner the settle typically completes in 400–700 milliseconds, so a five-second ceiling is roughly seven times the observed cost and still fails fast enough to be useful.
Because a stalled settle is a hard failure rather than a noisy pass, this helper is safe to run in a blocking job from day one, unlike a new axe rule that deserves a soak period in warning mode as described in the guidance on auto-fail versus warning workflows. One caveat: emulate reduced motion in the test environment. It shortens or removes the exit animation, which makes stage 1 resolve in tens of milliseconds instead of hundreds and removes most of the variance between a developer laptop and a loaded runner.
A last note on scope. This page is only about when it is safe to scan. Where focus should land once the transition completes, and how to assert it, is a separate question answered in testing focus management after client-side route changes — the two run in the same spec, in that order, because a focus assertion taken mid-transition is as meaningless as a scan taken there.
Common Pitfalls
- Treating
page.click()as a navigation wait, which scans the outgoing view and files the result under the destination URL. - Treating
waitForURLas a rendering signal, when routers push the URL synchronously with the click and commit the view much later. - Waiting only for the incoming heading, which proves the new DOM arrived and says nothing about whether the old DOM left.
- Disabling
duplicate-id-ariaorlandmark-one-mainto silence overlap artifacts, which removes two genuinely useful rules to paper over a timing bug. - Catching the settle timeout and scanning anyway, turning one clear error into an unpredictable violation list.
- Giving each stage its own full timeout, so a pathological transition can consume three times the budget the job was sized for.
- Leaving animations at production duration in the test environment, which widens the overlap window and makes the same spec pass or fail depending on runner load.
FAQ
Why not just wait for networkidle before scanning?
Network idle and render completion are unrelated. A router can resolve every request and still be several frames from committing the view, and an application with a polling request or an open websocket never reaches idle at all, so the wait either fires too early or hangs until the test times out. Worse, networkidle says nothing whatsoever about the outgoing view having detached, which is the specific condition that produces duplicate-id reports.
Is a fixed waitForTimeout after the click really so bad if it is generous?
It fails in both directions. A delay long enough for the slowest CI runner adds that delay to every transition in the matrix, so a twenty-transition suite pays it twenty times per run, and a delay tuned to a developer laptop starts failing the week someone enables a heavier animation. The settle helper costs whatever the transition actually needs — usually far less than a conservative fixed delay — and when it does exceed the budget it says which stage stalled instead of producing a mysterious violation list.
Should the quiet window be longer than 300 milliseconds? 300 milliseconds is a good default because it comfortably exceeds one animation frame and most enter animations, while staying below the point where the wait dominates the suite runtime. Raise it only in response to an observed flake, and when raising it, check first whether the churn is a component that never settles rather than a slow transition — in that case a longer window makes the suite slower without ever becoming stable, and narrowing the observed subtree is the real fix.
Related
- Accessibility Contract of SPA Route Changes — the parent guide, whose route-contract spec calls this settle helper before every scan.
- Detecting Detached aria-live Regions in SPAs — sibling how-to that watches the same transition for a region being replaced.
- Custom Axe Rules & Context-Aware Testing — the section, including the scan-timing rules the rest of the custom bundle depends on.