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.

The window in which both views are mounted Three lanes span a time axis from zero to six hundred milliseconds: the outgoing view is mounted from zero to 290 milliseconds, the incoming view from 210 milliseconds onward, and DOM mutations run from 205 to 430 milliseconds. A shaded band marks the 210 to 290 millisecond overlap. Scan A at 8 milliseconds sees only the outgoing view, scan B at 215 milliseconds sits inside the overlap, and scan C at 520 milliseconds sits after everything has settled. One navigation, three moments a scan could run both views mounted outgoing view — animating out incoming view — attached at 210 ms DOM mutations — data and layout A B C 0 150 ms 300 ms 450 ms 600 ms A · click resolved, +8 ms scans the previous view, files it under the new URL B · heading found, +215 ms both views mounted, so every id exists twice C · settled, +520 ms one view, quiet DOM, the only honest snapshot
Only moment C describes a document a user could be looking at; A and B both produce reports that survive triage far longer than they deserve.

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 {};
Three stages, one budget, two outcomes Stages run downward: outgoing view detached, incoming heading attached, and DOM quiet for three hundred milliseconds. Each stage draws from a single five second budget and can raise a SettleTimeoutError naming the stage; when all three pass, the axe scan runs on the settled view. settle() — every stage can fail the spec, none can skip 1 outgoing-view-detached handle.isConnected === false 2 incoming-heading-attached level-1 heading in the DOM 3 dom-quiet no mutation for 300 ms axe.run() on one settled view every violation is reachable SettleTimeoutError(stage) shared 5000 ms budget spent the spec fails immediately nothing is ever scanned A named stage turns a flake into a one-line diagnosis: stage 1 means an animation, stage 3 means a component that never stops writing.
The budget is shared across stages so a slow first stage cannot buy the third one extra time, and a stall always reports which stage it stalled in.

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.

Violation counts at three scan moments At moment A the scan reports three violations that belong to the previous view and none of the destination's real findings. At moment B it reports eight transition artifacts plus the one real violation. At moment C it reports only the one real violation. The signal is constant; only the noise moves 8 6 4 2 0 3 0 8 1 0 1 A · +8 ms previous view scanned B · +215 ms mid-transition overlap C · +520 ms settled, one view violations no user can reach the destination's real finding
Nine reported violations at moment B, one of which is worth a ticket — and the eight cheap ones are the reason the real one gets ignored.

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 of document, 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 a data-route-id attribute changing rather than on isConnected.
  • 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-view and 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 waitForURL as 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-aria or landmark-one-main to 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.