Framework-Specific Rule Mapping
A single-page framework does not hand your scanner a finished document; it hands you a moving target. The same axe-core rule set that returns a clean, deterministic report against a server-rendered page will return empty scopes, phantom passes, or duplicated violations against a React, Vue, or Angular application, because the DOM the engine sees depends entirely on when you looked. This guide, part of the broader Automated Scanning & Dynamic Content Ingestion strategy, maps the shared timing problem every client-rendered framework creates onto one reusable solution — wait for a framework-settled signal before axe.run() — and then shows how that signal specializes for each of the three dominant frameworks.
The trap is that every one of these failures is silent. A scan that fires before hydration finishes evaluates a skeleton and reports zero violations on a page that is unusable with a screen reader. A scan that fires during a client-side route transition captures a half-swapped view whose landmarks belong to neither the old page nor the new one. A modal rendered through a portal escapes a scoped include selector entirely, so its aria-required-attr and focus-trap defects never enter the report. Getting the settle signal right per framework is what turns an accessibility scanner from a green rubber stamp into an instrument that measures what a user actually experiences.
Prerequisites & Environment Parity
Framework-aware waiting only works if the runtime is pinned tightly enough that the settle signal means the same thing on every runner. Before wiring detection into a scan, lock the following:
- The framework runtime and its router, pinned to exact versions in the application’s lockfile. React 18’s concurrent
hydrateRoot, Vue 3’s async reactivity flush, and Angular’s zone-based stability each expose a different readiness contract, and a minor version bump can change when that contract resolves. - A pinned browser engine, driven through the same install your Playwright headless scanning workflows use (
playwright install --with-deps chromium). Framework settle timing is sensitive to CPU throttling, and a system browser that updates independently will shift the window in which hydration completes. - A build-mode decision. Development builds ship extra reconciliation passes, double-invoked effects under React StrictMode, and unminified error overlays that change the DOM the scanner sees. Audit the production build, or at minimum the same build mode in CI and locally.
- A settle hook you control. None of the three frameworks emits a “the page is ready to audit” event by default. You either consume an internal testability API (Angular) or expose your own signal from application code (React, Vue). Decide which per app before writing the scan.
Treat the boundary of what you scan as deliberately as the timing. Which containers your audit owns versus which belong to a third party is governed by dynamic content boundary detection; a portalled modal that renders to document.body sits outside your app root and must be brought back into scope on purpose, not by accident.
The Settle-Signal Model
Every client-rendered framework moves through the same three phases between a navigation and a stable, auditable DOM, and the scanner must wait out all three. Reconciliation is the framework computing the new virtual tree and mutating real DOM nodes to match. Commit-side effects are the code that runs after those mutations paint — the useEffect that moves focus into a dialog, the router guard that sets a page title, the animation that fades a panel in. Quiescence is the moment no further scheduled work remains: no pending microtask flush, no in-flight transition, no queued change-detection pass. Fire axe.run() before quiescence and you measure a DOM that is still rearranging itself.
The reason a fixed page.wait_for_timeout(2000) is the wrong tool is that it is simultaneously too slow on a fast machine and too fast on a throttled CI runner. The correct pattern replaces wall-clock guessing with a signal: a boolean the framework can only make true once its own work queue is empty. The three frameworks expose that boolean differently — React through a flag you set in a root-level effect, Vue through router.isReady() composed with a nextTick flush, Angular through its built-in Testability API — but the scanning code that consumes the signal is nearly identical in shape. The diagram below traces the shared route-change lifecycle and shows which per-framework signal marks quiescence for each.
The shared solution has four moving parts, and only the third changes per framework. First, drive the navigation the way a user would — click a router link rather than issuing a full page.goto(), so the client router, not the browser, owns the transition. Second, hold until the framework’s quiescence signal resolves. Third, re-scope the include context to the container the new view actually rendered into, folding any portalled overlay back in. Fourth, run the engine and attribute the result to the route you navigated to, not the URL you started from.
Step-by-Step Implementation
The following sequence builds a framework-agnostic scan driver, then plugs a per-framework settle detector into a single seam. Each step is independently testable against a fixture app.
1. Expose a settle signal the scanner can poll
Two of the three frameworks give you nothing to wait on by default, so expose a boolean on window from application code. Set it once hydration and the first paint’s effects have run, and reset it on every route change so a stale true from the previous view never lets the scanner fire early. Angular is the exception — it already publishes stability through getAllAngularTestabilities(), so no app change is needed there.
// app-settle-signal.js — imported once at the React or Vue app root.
// Publishes window.__A11Y_SETTLED, which the scanner polls before axe.run().
function markSettled(value) {
window.__A11Y_SETTLED = value;
}
// Reset to false the instant a navigation starts, so the scanner cannot
// mistake the previous route's settled DOM for the incoming one.
export function onRouteChangeStart() {
markSettled(false);
}
// Flip to true only after reconciliation AND commit-side effects have run.
// Each framework calls this from its own lifecycle hook (see child guides).
export function onViewSettled() {
// Defer one frame so any focus move or transition kicked off in an effect
// has committed before the scanner is allowed to look at the DOM.
requestAnimationFrame(() => requestAnimationFrame(() => markSettled(true)));
}2. Wait on the signal from the Playwright side
The scanner blocks on the framework’s own boolean rather than a fixed timeout. A short polling wait with a hard ceiling turns “never settles” into a clean, attributable timeout instead of a silent early scan.
from playwright.async_api import Page
async def wait_for_framework_settled(page: Page, timeout_ms: int = 15000) -> None:
"""Block until the app publishes its settled signal, or fail loudly.
Consumes window.__A11Y_SETTLED for React/Vue. For Angular, swap the
predicate for the Testability API (see the Angular guide).
"""
await page.wait_for_function(
"() => window.__A11Y_SETTLED === true",
timeout=timeout_ms,
)3. Drive client-side navigation, not full page loads
A full page.goto() reboots the framework and re-runs hydration on every route, which both hides client-router bugs and triples scan time. Navigate through the app’s own router so the transition exercises the real reconciliation path a user triggers.
async def navigate_in_app(page: Page, link_selector: str) -> None:
"""Trigger a client-side route change and wait for the new view to settle."""
# onRouteChangeStart() has already reset the signal to false in app code,
# so this wait cannot resolve against the outgoing view.
await page.click(link_selector)
await wait_for_framework_settled(page)4. Re-scope context and fold in portalled overlays
Modals, tooltips, and toasts frequently render through a portal into document.body, outside the application root your include selector targets. Detect the open overlay and add its container to the scan scope so its violations are attributed to your build rather than silently dropped.
async def build_scan_context(page: Page) -> dict:
"""Scope to the app root, plus any live portalled overlay."""
include = [["#app-root"]]
# Portals commonly mount to a dedicated node; include it only when populated.
overlay_open = await page.evaluate(
"() => !!document.querySelector('[data-portal-root] [role=dialog]')"
)
if overlay_open:
include.append(["[data-portal-root]"])
return {"include": include, "exclude": [["#chat-widget"], ["iframe[src*='ads']"]]}5. Run the engine against the settled, scoped DOM
With the signal resolved and the context assembled, the engine call itself is ordinary — the correctness came from steps 1 through 4. Which rules run is governed by your axe-core enterprise configuration; this driver only guarantees the DOM is stable and the scope is complete before the engine reads it.
import json
from pathlib import Path
AXE_SRC = Path("node_modules/axe-core/axe.min.js").read_text()
async def scan_route(page: Page) -> dict:
await wait_for_framework_settled(page)
context = await build_scan_context(page)
await page.add_script_tag(content=AXE_SRC)
return await page.evaluate(
"(ctx) => axe.run(ctx, {runOnly: {type: 'tag', values: ['wcag2a','wcag2aa','wcag22aa']}})",
context,
)Configuration Reference
The knobs below govern how the driver waits and what it scans. The settle-hook column is where the three frameworks diverge; everything else is shared.
| Setting | Type | Default | Description |
|---|---|---|---|
settle_signal |
string / predicate | window.__A11Y_SETTLED |
The boolean the scanner polls. Angular overrides this with getAllAngularTestabilities().every(t => t.isStable()). |
settle_timeout_ms |
number | 15000 |
Hard ceiling on the settle wait; on expiry, fail the route as a timeout rather than scanning a half-rendered view. |
settle_hook |
framework API | per framework | Where app code flips the signal true — a root useEffect (React), router.afterEach + nextTick (Vue), or the built-in Testability queue (Angular). |
navigation_mode |
spa / full |
spa |
spa clicks router links to exercise client routing; full uses page.goto() only for the entry point. |
portal_root_selector |
CSS selector | [data-portal-root] |
Node portalled overlays mount into; added to include only when it holds a live dialog. |
reset_on_navigate |
boolean | true |
Whether the signal resets to false at route-change start; disabling it re-introduces the stale-true early-scan bug. |
double_render_guard |
boolean | false |
For SSR apps, whether to discard the pre-hydration scan and only trust the post-hydration one (see the React guide). |
Verification & Testing
Prove the settle detection works by asserting that a scan launched too early fails, and one launched after the signal passes against fixtures with known violations. If your early-scan test does not fail, the signal is resolving too soon and every real scan is racing hydration.
import pytest
@pytest.mark.asyncio
async def test_settle_signal_gates_the_scan(page):
"""A known-broken view must only surface its violation after settle."""
await page.goto("http://localhost:8080/fixtures/spa/#/broken-dialog")
# Before settle, the portalled dialog has not mounted: the rule is absent.
early = await page.evaluate("() => window.__A11Y_SETTLED === true")
assert early is False
await wait_for_framework_settled(page)
result = await scan_route(page)
rule_ids = {v["id"] for v in result["violations"]}
# The dialog is missing an accessible name (SC 4.1.2) once it is in scope.
assert "aria-dialog-name" in rule_ids or "aria-required-attr" in rule_ids
@pytest.mark.asyncio
async def test_route_change_rescans_fresh_dom(page):
"""Navigating in-app must reset the signal and re-evaluate the new view."""
await page.goto("http://localhost:8080/fixtures/spa/#/clean")
await wait_for_framework_settled(page)
await navigate_in_app(page, "a[href='#/broken']")
result = await scan_route(page)
assert result["violations"], "route change scanned the stale clean view"In CI, run these gating fixtures before scanning real routes. If test_settle_signal_gates_the_scan regresses, the framework upgrade changed the quiescence contract and every downstream count is suspect. Confirmed findings then flow into the error categorization and triage pipelines for deduplication and ownership routing.
Failure Modes & Troubleshooting
Early scan against a pre-hydration skeleton. Symptom: server-rendered routes report zero violations even where the hydrated version is clearly broken. Root cause: the scan fired against static HTML before the framework attached, so interactive defects did not exist yet. Fix: gate on the settle signal, and for SSR apps enable double_render_guard so the pre-hydration pass is discarded — the mechanics are covered in testing React hydration accessibility.
Stale signal from the previous route. Symptom: after an in-app navigation, the report describes the page you left, not the one you landed on. Root cause: the signal was never reset at navigation start, so the scanner saw a leftover true. Fix: call the route-change-start reset in the router hook and assert the signal is false immediately after a click before it flips back to true.
Portalled modal invisible to the scan. Symptom: dialogs that clearly trap focus poorly still report clean. Root cause: the overlay renders to document.body, outside the #app-root include scope. Fix: detect the live portal container and add it to the context, as in step 4; the ownership boundary this respects is formalized in dynamic content boundary detection.
Transition mid-flight at scan time. Symptom: intermittent color-contrast or target-size failures that vanish on rerun. Root cause: an enter/leave animation was still interpolating opacity or transform when the engine read computed styles. Fix: defer the settle flag past the transition’s transitionend, or disable animations in the audit build — the Vue-specific handling lives in auditing Vue single-page apps.
Never-settles timeout on background polling. Symptom: a route times out on the settle wait even though it looks idle. Root cause: a long-lived timer, websocket, or polling loop keeps the framework’s work queue perpetually non-empty — Angular’s zone stability is especially sensitive to this. Fix: run such work outside the stability zone or exclude the timer during audits, as detailed in automating Angular accessibility audits.
Frequently Asked Questions
Why not just wait a fixed number of seconds after navigation before scanning?
A wall-clock wait is wrong in both directions: it wastes time on a fast machine and still races hydration on a throttled CI runner, so scans flake exactly where you cannot reproduce them. Poll a boolean the framework can only make true once its own work queue is empty, which adapts to whatever speed the machine happens to run at.
Do React, Vue, and Angular need different settle detection or is one approach enough?
The scanning driver is shared, but the signal it polls differs. React and Vue expose nothing by default, so you publish your own flag from a root effect or router hook, whereas Angular already publishes readiness through its Testability API. The four-step wait-navigate-scope-scan flow around that signal stays identical across all three.
How do I stop server-side rendering and hydration from being scanned twice?
An SSR app paints static HTML, then hydrates it into an interactive tree, and a naive scanner can capture both. Discard the pre-hydration pass by only trusting a scan taken after the hydration-complete signal, which for React means a flag set in a root effect that runs solely on the client.
Why does my scoped scan miss modals and dropdowns entirely?
Frameworks routinely render overlays through a portal that mounts to document.body, outside the application root your include selector targets, so the engine never traverses them. Detect the live portal container at scan time and add it to the context so the overlay’s violations are attributed to your build.
The settle signal never resolves and the route times out. What is holding it open?
Some background task is keeping the framework’s work queue non-empty — a recurring timer, an open websocket, or a polling request. This is most visible under Angular’s zone-based stability, where any pending macrotask blocks isStable; run that work outside the stability zone so the queue can drain and the signal can resolve.