Auditing Single Page Applications After Client-Side Route Changes

A single-page application scan that returns zero violations on a route you know is broken — or returns the previous route’s violations — is almost always evaluating the wrong DOM: axe.run() fired against an un-hydrated shell or a mid-transition tree because the client-side route change happened through the History API and never emitted a load event for Playwright to wait on. This page resolves that specific failure. It shows how to detect that a client-side route transition has actually completed before auditing, so a React Router, Vue Router, or Angular Router view produces the same complete, correct violation set the user would experience.

This technique is the single-page-application specialization of the Async Crawling for Infinite Scroll Pages workflow, and both inherit the launch, inject, and stabilize primitives established in Playwright Headless Scanning Workflows within the broader Automated Scanning & Dynamic Content Ingestion strategy. Where infinite scroll grows content within one route, the problem here is different: the router swaps the entire view for a new one with no document navigation event to synchronize against.

When This Applies

Reach for this pattern only when your target renders as a client-routed application — the URL changes via history.pushState rather than a server round-trip, and clicking an in-app link swaps the view without a full page reload. Concrete signals that you are in this situation:

  • page.wait_for_load_state("networkidle") returns almost instantly after an in-app navigation because no document load ever started; the audit then runs against whatever was on screen a few milliseconds after the click.
  • The DOM you drive the crawl over is reachable only by clicking rendered links, not by calling page.goto() for each route. Calling goto() on a deep route triggers a cold boot that hydrates differently — or 404s — on apps that expect the router to build state from the entry route.
  • Violation counts are non-deterministic between runs, and diffing two ledgers shows entire routes present in one run and absent in another, rather than a few flaky nodes.

If your content grows on a single URL as the user scrolls, you want the infinite-scroll workflow instead; the stabilization signal there is scroll-and-mutation, not route-transition completion.

Where auditing is unsafe during an SPA client-side route transition Left to right: Route A (audited, stable DOM) → pushState (URL is now route B, old view still mounted) → shell mounted, hydrating → mutations settling → Route B hydrated (safe to audit). The three middle states sit inside a "transition window — do not audit" band. A downward branch from the hydrating state, labelled auth-guard redirect, reaches a /login or /403 state, showing the requested path is not always the rendered one. TRANSITION WINDOW — DO NOT AUDIT 3 waits proven click link auth-guard redirect pushState('/login') Route A pushState Shell mounted Mutations Route B audited stable DOM URL = /route-B old view mounted hydrating new view not interactive yet tree still changing until quiet 400ms hydrated safe to audit /login or /403 not the route you asked for URL settle · marker · quiescence caught by asserting location.pathname after the transition

Minimal Reproducible Example

The broken pattern below is the one that ships in most first-draft SPA crawlers. It navigates to the entry route, clicks an in-app link, and audits immediately. Because the click triggers a History API push rather than a document load, axe.run() evaluates the outgoing view or a half-mounted shell.

# BROKEN: audits before the client-side route finishes rendering.
await page.goto("https://app.example.com/")          # only real document load
await page.add_script_tag(url=AXE_CDN)               # axe global lives on the document

await page.click("a[href='/reports']")               # history.pushState — no load event
# Nothing here waits for the /reports view to mount and hydrate:
results = await page.evaluate(
    "async () => await axe.run(document, {runOnly: ['wcag2a', 'wcag2aa']})"
)
# results reflects the dashboard we navigated away from, or an empty <main> shell.

The tell is that the run either reports the entry route’s violations under the new URL, or reports nothing because <main> is still an empty container awaiting hydration. Neither is the /reports accessibility state a user encounters.

Correct Implementation

A client-side transition is complete only when three independent conditions hold: the History API URL matches the target route, the framework has finished hydrating the new view, and the DOM has stopped mutating. Prove all three before auditing. The helper below composes them; each wait carries the enterprise-grade 45000 ms ceiling rather than the framework default, because guarded routes and deferred data fetches routinely exceed the shorter budget.

async def wait_for_spa_route(page, expected_path, timeout=45000):
    # 1. History API settle: the router has committed the new URL.
    await page.wait_for_function(
        "path => location.pathname === path",
        arg=expected_path,
        timeout=timeout,
    )

    # 2. Framework hydration marker. Prefer an app-authored signal over guessing
    #    at internal framework state — have the route effect set data-hydrated
    #    on its root once the view is interactive (React useEffect, Vue onMounted,
    #    Angular ngAfterViewInit). This is the most reliable cross-framework hook.
    await page.wait_for_function(
        """() => {
            const root = document.querySelector('[data-testid="route-root"]');
            return root && root.getAttribute('data-hydrated') === 'true';
        }""",
        timeout=timeout,
    )

    # 3. Mutation quiescence: run once, resolve after the tree is quiet for 400ms.
    await page.evaluate("""() => new Promise(resolve => {
        let quiet = setTimeout(finish, 400);
        const obs = new MutationObserver(() => {
            clearTimeout(quiet);
            quiet = setTimeout(finish, 400);
        });
        function finish() { obs.disconnect(); resolve(true); }
        obs.observe(document.body, {childList: true, subtree: true, attributes: true});
    })""")

With transition detection in place, the audit driver clicks the in-app link, waits for the route to prove it is ready, then runs the scoped evaluation. The critical SPA-specific detail is that axe-core is injected exactly once, after the initial goto — the document is never reloaded across client-side navigations, so the axe global persists and re-injection is wasted work.

AXE_CDN = "https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js"

async def audit_spa_route(page, link_selector, expected_path):
    await page.click(link_selector)                  # in-app navigation, no reload
    await wait_for_spa_route(page, expected_path)    # all three conditions proven

    # Guard against a silent auth-guard redirect: confirm we are auditing the
    # route we asked for, not the login screen the router swapped in.
    landed = await page.evaluate("() => location.pathname")
    if landed != expected_path:
        raise RouteRedirected(expected_path, landed)

    report = await page.evaluate(
        "async () => await axe.run(document, {runOnly: ['wcag2a', 'wcag2aa']})"
    )
    return report["violations"]

Restrict runOnly to the same conformance tags defined in your axe-core enterprise configuration so a single source of truth governs which WCAG 2.2 success criteria are evaluated across routed and static pages, and cross-reference the returned tags against the W3C Web Content Accessibility Guidelines (WCAG) 2.2 when mapping each rule to a conformance level.

The hydration marker in step 2 assumes the application cooperates. Where it does not expose one, fall back to a framework-appropriate observable signal rather than a fixed sleep:

Framework Readiness signal to poll
React Router data-hydrated on the route root, or absence of a Suspense fallback [data-loading] node
Vue Router router.isReady() resolved, surfaced as a body attribute the app sets after onMounted
Angular Router NavigationEnd event handled, exposed via a data-nav-settled attribute
No cooperation MutationObserver quiescence plus presence of an expected landmark (role="main" with non-empty children)

Consult the Playwright wait_for_function API for polling-interval and argument-passing specifics when adapting the marker checks to your framework.

CI/CD Pipeline Integration

Slot this route-aware crawler into the same fan-out the rest of your suite uses: a pre-scan step resolves the route registry, each isolated worker container drives one entry point through its client-side navigation graph with a frozen viewport, timezone, and seed data, and every audited route emits a JSON payload. Because SPA navigation is stateful, order matters — a worker must traverse routes in a valid sequence rather than cold-booting each URL, so record the click path, not just the destination. Feed the raw output through the same batch validation architecture and JSON Schema validation for accessibility data contract that normalizes your static-route results, then let the threshold gate fail the build on critical/serious counts. A RouteRedirected exception must be logged as a coverage gap and routed — through the error categorization and triage pipelines — to manual review rather than swallowed, because a route the crawler never reached is a silent hole in the audit, not a pass.

Gotchas

  • Auth-state redirects rewrite the route under you. A client-side route guard redirects an unauthenticated (or under-privileged) crawler to /login or /403 via pushState, so the URL you requested silently becomes a different view that legitimately has zero of the violations you were hunting. Always assert location.pathname equals the expected path after the transition, and run authenticated crawls with a seeded storage state per privilege tier.
  • Multi-tenant routing changes the hydration marker and the component tree. When the tenant lives in a path segment (/t/acme/reports) or a subdomain, feature flags and lazy-loaded modules differ per tenant, so the same logical route can render a different landmark set and even a different data-hydrated root. Parameterize the expected path and marker per tenant, and never reuse one tenant’s transition signal across another.
  • Viewport variance swaps entire route subtrees. A responsive SPA renders a mobile drawer navigation instead of a desktop rail — different role="navigation" structure, different focus order — so a route audited only at 1920x1080 misses failures that exist only in the 375x812 component tree. Run each route at both the desktop and mobile-parity viewport and treat them as distinct entries in the violation ledger.

Frequently Asked Questions

Do I need to re-inject axe-core after each client-side route change?

No. Unlike multi-page navigation, a client-side route change never reloads the document, so the axe global injected after your initial page.goto() survives every subsequent pushState transition. Inject once and call axe.run() per route. The only time you must re-inject is if you hard-navigate with page.goto() or reset the context to about:blank to reclaim memory, because that creates a fresh document with no axe global.

Why does networkidle fire but the new route is still blank?

networkidle reports on HTTP connections, not rendering. On an SPA, the route module and its data may already be cached from a prefetch, so there is no network activity to wait on — yet the framework has not run the render-and-hydrate cycle that turns fetched JSON into DOM. Wait on the History API URL settle plus a framework hydration marker plus mutation quiescence; treat networkidle as a supporting signal, never the sole one.

Should I crawl SPA routes by clicking links or by calling page.goto() for each URL?

Click through in-app links whenever the application builds router state from the entry route. Calling page.goto() on a deep route forces a cold boot that hydrates through a different code path — or fails outright on apps that assume the router was reached progressively. Reserve goto() for the single entry point, then drive the client-side navigation graph with clicks, recording the click path so CI can reproduce the exact traversal.

My audit reports the previous route's violations under the new URL — why?

The evaluation fired during the transition window, after the URL updated but before the old view unmounted. The History API commits the new location.pathname before React, Vue, or Angular finish tearing down the outgoing component tree, so a check that keys only on the URL sees the new path over the old DOM. Add the hydration-marker and mutation-quiescence waits so the audit runs only once the incoming view has fully replaced the outgoing one.