Auditing Vue Single-Page Apps
When a Vue scan captures a view mid-transition — a panel still fading in, a Teleport-ed modal that has not finished mounting, a list the reactivity system has not yet patched — the engine reads interpolated styles and an incomplete tree, producing flaky color-contrast failures and modals that never appear in any report. This page resolves that for Vue 3 applications: wait for router.isReady(), flush pending reactivity with nextTick, hold past component transitions, and pull Teleport targets back into scope before running axe-core.
This is the Vue-specific guide within the Framework-Specific Rule Mapping topic area, under the broader Automated Scanning & Dynamic Content Ingestion pipeline. The parent guide sets out the shared settle-signal approach; this page defines exactly what “settled” means for Vue 3’s asynchronous reactivity flush, Vue Router navigation guards, <Teleport> components, and <Transition> animations.
When This Applies
Reach for this pattern with any Vue 3 SPA that uses Vue Router for client-side navigation and Vue’s reactivity to drive the DOM. The symptoms are specific to Vue’s async update model:
color-contrastortarget-sizefindings appear on one run and disappear on the next, tracking whether a<Transition>happened to finish before the scan.- A modal built with
<Teleport to="body">never shows up in the report even though it clearly mislabels its close button. - A scan taken immediately after mutating reactive state reports the old DOM, because Vue batches updates into a later microtask rather than applying them synchronously.
- A route guard that resolves data asynchronously lets the scan fire on an empty view before the guard’s
next()resolves.
If your app renders entirely server-side with no client reactivity, this timing model does not apply — but any interactive Vue island on the page still needs the flush handling below.
Minimal Reproducible Example
The naive scan mutates state or navigates and reads the DOM in the same tick. Vue does not update the DOM synchronously — it queues a flush — so the engine sees the pre-update tree.
# scan_vue_naive.py — reads the DOM before Vue's async flush applies.
from playwright.async_api import async_playwright
async def scan(url):
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto(url, wait_until="load")
await page.click("button#open-settings") # sets a reactive `isOpen = true`
# Vue has only QUEUED the DOM patch in a microtask; the Teleported
# dialog is not in the DOM yet. This scan misses it entirely.
await page.add_script_tag(path="node_modules/axe-core/axe.min.js")
return await page.evaluate("() => axe.run()")The click flips a reactive ref, but Vue defers the actual DOM patch to a queued microtask so multiple mutations coalesce into one render. Reading the DOM in the same synchronous stretch — as the scan does here — observes the state before the patch lands.
Correct Implementation
Publish a settled flag from a Vue Router afterEach guard composed with nextTick, so the flag flips true only after the router has committed the route and Vue has flushed the pending reactivity queue. Then, on the scanner side, wait for the flag, hold past any running transition, and fold Teleport targets into scope.
// settle-plugin.js — installed once: app.use(settlePlugin, { router }).
import { nextTick } from "vue";
export const settlePlugin = {
install(app, { router }) {
// Reset the moment a navigation begins so the outgoing view's settled
// flag cannot satisfy the scanner while the incoming view still resolves.
router.beforeEach((to, from, next) => {
window.__A11Y_SETTLED = false;
next();
});
// afterEach fires once the route is confirmed and its component is mounting.
router.afterEach(async () => {
// Await isReady so the very first navigation (async guards, lazy routes)
// has fully resolved before we consider the app settled.
await router.isReady();
// nextTick resolves after Vue flushes the queued reactivity patches,
// so Teleported content and reactive DOM updates are now committed.
await nextTick();
// One more frame lets a <Transition> enter-hook start; the scanner then
// waits out transitionend so opacity/transform have finished interpolating.
requestAnimationFrame(() => {
window.__A11Y_SETTLED = true;
});
});
},
};# scan_vue.py — Vue-aware scan: flush + transition + Teleport scoping.
from playwright.async_api import Page
async def scan_vue_route(page: Page, timeout_ms: int = 15000) -> dict:
"""Scan only after the router settles, reactivity flushes, and transitions end."""
# 1. Wait for the afterEach + nextTick settled flag.
await page.wait_for_function(
"() => window.__A11Y_SETTLED === true", timeout=timeout_ms
)
# 2. Hold until no element is mid-<Transition>. Vue tags entering/leaving
# nodes with *-enter-active / *-leave-active classes; wait them out so
# the engine does not read interpolated contrast or a shifting target.
await page.wait_for_function(
"() => document.querySelectorAll("
"'[class*=-enter-active],[class*=-leave-active]').length === 0",
timeout=timeout_ms,
)
# 3. <Teleport to="body"> renders outside #app; include the target when a
# dialog is live so its violations attribute to your build, not nowhere.
teleport_open = await page.evaluate(
"() => !!document.querySelector('body > [role=dialog], #teleport-target [role=dialog]')"
)
include = [["#app"]]
if teleport_open:
include.append(["#teleport-target"])
await page.add_script_tag(path="node_modules/axe-core/axe.min.js")
return await page.evaluate(
"(ctx) => axe.run(ctx, {runOnly: {type: 'tag', values: ['wcag2a','wcag2aa','wcag22aa']}})",
{"include": include, "exclude": [["#cookie-banner"]]},
)Waiting out the transition is not cosmetic. An entering <Transition> interpolates opacity from 0, and axe-core reads computed style, so a contrast check against a half-faded element can fail at 30% opacity and pass at 100% on the next run. The transition-class guard makes that flake deterministic. For a fully reliable audit build, prefer disabling animations outright, which mirrors the viewport-and-motion pinning discipline in dynamic content boundary detection.
Pipeline Integration Note
In a batched run, the Vue settle flag lets Vue routes flow through the same impact-threshold gate as every other framework without bespoke waits. The driver blocks on __A11Y_SETTLED, clears transitions, folds Teleport targets in, and emits a payload identical in shape to the rest of the fleet — so it feeds the same sharded aggregation and the same impact gate described in running Playwright accessibility checks in CI/CD. The rule set that decides whether a Teleported dialog’s missing label maps to WCAG 4.1.2 lives once in your axe-core enterprise configuration; this Vue handling only guarantees the flushed, animation-stable DOM the engine reads.
Gotchas
- Async navigation guards can strand the scan on an empty view. A
beforeEachguard that awaits an auth check or data fetch before callingnext()leaves the destination component unmounted; gating onrouter.isReady()plusafterEachensures the guard resolved before the settled flag flips. v-ifversusv-showchanges what the engine sees. Av-show-hidden panel is still in the DOM withdisplay:noneand can surfacecolor-contrastfindings axe knows to skip, while av-ifpanel is absent entirely until toggled. Know which one a component uses before deciding a clean scan means the panel is accessible.- Multiple Teleport targets need each one folded in. Apps often teleport toasts, tooltips, and dialogs to different mount nodes. Enumerate every live teleport target at scan time rather than assuming a single
bodychild, or a tooltip’saria-describedbydefect slips the scope.
Frequently Asked Questions
Why does my scan read the old DOM right after I change reactive state?
Vue does not patch the DOM synchronously; it queues updates into a microtask so several mutations batch into one render. Reading the DOM in the same tick observes the pre-update tree, so you must await nextTick (or gate on a flag set after it) before scanning.
How do I keep component transitions from causing flaky contrast failures?
An entering <Transition> interpolates opacity and transform, and axe reads computed style, so a contrast check can fail mid-animation and pass once it settles. Wait until no element carries a -enter-active or -leave-active class, or disable animations in the audit build so styles are final when the engine looks.
My Teleport modal is missing from the report. What scope do I need?
<Teleport to="body"> moves the modal outside your #app root, so an include scoped to #app never traverses it. Detect the live teleport target when a dialog is open and add that node to the axe context so the modal’s violations are captured.
What is the right settle signal for Vue Router navigation?
Compose router.isReady() with nextTick inside an afterEach guard, and reset the flag in beforeEach. That sequence guarantees the route is confirmed, any async guard has resolved, and Vue has flushed its reactivity queue before the scanner is allowed to run.