Troubleshooting
The library is designed to fail loudly — A11yAssertionError exceptions include the offending element, its location in the tree, and a suggested fix. Most problems are caught there. This page covers the edge cases that look like bugs but usually aren't.
My audit returned nothing
const { nodes } = extractA11yTree(document.getElementById("app"));
// nodes.size === 0Three usual causes:
- Root element doesn't exist yet. If you call the extractor from a component's body (rather than inside
useEffect/onMount), the ref may not be attached to the DOM. Extract on mount, not during render. - Selector didn't match.
document.querySelector("main")returnsnullif there's no<main>landmark yet. Check the return value. - Whole subtree is hidden.
display: none,visibility: hidden, oraria-hidden="true"on an ancestor excludes the subtree from the a11y tree by design. That's what a screen reader sees — nothing.
If the tree genuinely is empty but you expect content, open the Chrome extension on the same page and check the A11y view — the same extraction runs there.
Snapshot flaps between runs or between local and CI
treeSnapshot() is deterministic, but it surfaces text that isn't. Common culprits:
- Timestamps / relative times — "2 hours ago", "Updated: 2026-04-23T14:00".
- User-generated content — names, emails, IDs.
- Locale-variable copy — i18n strings that differ between en/es runs.
- Random IDs / correlation tokens — React's
useId, auth tokens, request IDs. - Time-of-day greetings — "Good morning, Alice".
Use the redact option to replace the noisy patterns with [redacted]:
import { treeSnapshot } from "@real-a11y-dev/testing";
expect(
treeSnapshot(container, {
redact: [
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/, // ISO timestamps
/\b\d+ (minutes?|hours?|days?) ago\b/, // relative times
/[A-Za-z0-9+/=]{20,}/, // bearer tokens
],
}),
).toMatchSnapshot();The same option is on the Playwright adapter: sn.treeSnapshot({ redact: [...] }).
Heading outline has no h1
assertHeadingOrder() throws if there is not exactly one h1. That's correct for top-level pages. For embedded content — a portaled dialog, a docs widget, an email preview iframe — scope the assertion to the subtree that should have its own hierarchy, or skip it.
// Scope to main content
const main = document.querySelector("main")!;
assertHeadingOrder(main);If your page legitimately has multiple h1s (rare — usually an anti-pattern), the rule doesn't fit your design and assertHeadingOrder isn't the right check.
Tab sequence is empty
tabSequenceSnapshot() lists only keyboard-focusable elements. Common reasons for an empty sequence:
- The root is wrapped in
[inert], which removes the whole subtree from the focus order (Chrome, Safari, Firefox all honor this). - Every focusable element has
tabindex="-1"— some design systems over-use this to disable default focus. - The subtree is
display: noneoraria-hidden="true". - The focusable elements sit in the body of a closed
<details>, which the browser renders as its summary alone until it opens. - You're passing a root that doesn't contain the expected elements (e.g. a portaled modal that mounts elsewhere in the DOM). Note that scoping to a subtree does not reliably exclude portaled content — see My snapshot contains the whole page for why, and what to do instead.
If the tab order should be empty on a given route (a 404, a splash screen with no interactive content), suppress the assertion for that route rather than the whole suite.
My snapshot contains the whole page, not the component I passed
Extraction widens to the whole document when a portal-mounted overlay sits outside the root you passed — a dropdown, a tooltip, an open dialog, or a live region such as a toast. That is deliberate: a menu rendered through a React Portal is a sibling of <body>, not of its trigger, and a tree that stopped at your root would omit the thing the user is looking at.
The catch is that the check is "is it outside the root", which cannot tell a portal from an ordinary in-page live region. A <p role="status">4 tickets</p> elsewhere on the page is enough:
const { container } = render(<Filters />);
expect(treeSnapshot(container)).toMatchSnapshot(); // ← may be the whole pageNothing is lost — the document contains your root — but the committed snapshot becomes a page snapshot, so unrelated changes elsewhere start breaking a component test. If that is happening:
- Assert with
toMatchA11yContractinstead of a full snapshot. It is containment-matched, so it holds the part you care about and ignores everything the pivot dragged in. - Or render the component with no app-level toaster/announcer mounted, which is usually a test-setup change rather than a product one.
A root that is not in the document is never widened — extraction returns exactly what you passed. That covers a createElement fixture, a component inspected before mount, and a root inside a shadow root. It matters because widening one of those replaces it outright rather than adding to it, and an audit of the wrong subtree reports no findings, which reads as a clean component.
The trade is worth knowing: a detached root therefore never sees teleported content either. If you render into an unappended container — Vue Test Utils and Enzyme both do — and the component teleports a dialog to document.body, that dialog is outside your root and stays outside it. Assert on it by passing a root that contains it (document.body), or mount into the document first.
Content inside a web component is missing or stale
The tree follows what the browser renders, including open shadow roots. A custom element's shadow content appears under its host, and slotted children appear where their <slot> is. Light-DOM children that no slot takes aren't rendered, so they aren't in the tree either. aria-labelledby, aria-describedby and <label for> resolve inside the component's own shadow root, as they do in the browser.
Two cases still come up short:
- Closed shadow roots (
attachShadow({ mode: "closed" })) can't be read from page script, so their host shows up with nothing inside it. The CLI and MCP server read Chromium's own accessibility tree, which does include them. If you own the component, an open root costs you nothing in encapsulation that matters for styling. - Live views don't yet notice changes inside a shadow root. The panel and the extension re-extract on DOM mutations, and mutations inside a component's shadow tree don't reach that observer yet. If a component updates and the panel doesn't, press refresh. One-shot extraction (the
testingmatchers and assertions,getTree()) always reads the current state.
React hydration mismatch when rendering the panel
If you drop <SemanticNavigator /> into an SSR-rendered component tree, the server renders the wrapper <div> but no inspector content — the inspector mounts on the client. That's fine. Hydration warnings typically come from other sources in the same tree; the panel itself is hydration-safe.
If you're specifically seeing a mismatch on a <div> that should be an aside or nav, check whether you have Chrome extensions injecting markup into the page — they're a common cause of hydration warnings that look library-related.
If you're adding your own wrapper around <SemanticNavigator /> that uses document.* during render, wrap that in a mount effect or use next/dynamic({ ssr: false }).
Tests pass in jsdom but fail in Playwright (or vice versa)
jsdom implements most of the accessibility tree, but a few areas diverge from real browsers:
- Computed style — jsdom's
displaycomputation is limited. If your test depends ondisplay: contentsor complex CSS-driven visibility, trust Playwright. inertattribute — support landed in jsdom later than in browsers. Upgrade tojsdom ≥ 23.element.focus()insiderequestAnimationFrame— jsdom's rAF queue differs from browsers; focus traps can look flaky.- Shadow DOM event retargeting — jsdom is mostly correct but has been known to drop
composed: truelisteners in edge cases.
The rule: jsdom covers ~95% of cases at jsdom speed. When it diverges, the truth is in Playwright. Use @real-a11y-dev/testing/playwright for the remaining 5%.
A11yAssertionError with a message I don't understand
The error message includes the element type, the location in the tree (path from the root), and the reason. If the reason is still unclear, pair the assertion with a snapshot of the same root — the snapshot shows the element in context.
try {
assertNoUnlabeledInteractive(container);
} catch (err) {
console.log(treeSnapshot(container));
throw err;
}Every assertion is documented with an example in @real-a11y-dev/testing.
The CLI or MCP says Playwright isn't installed
Both @real-a11y-dev/cli and @real-a11y-dev/mcp import Playwright lazily — the library API (types, buildServer) loads without a browser, but the moment a command actually drives one, Playwright has to be present. It is an optional peer of the CLI, so when it is missing the CLI says:
Playwright is required to drive a browser, but it isn't installed.
npm i -D playwright && npx real-a11y installIts hint names the commands for how it was installed: those above for a project dependency, npm i -g playwright && real-a11y install for a global install, and for a run through npx -y @real-a11y-dev/cli, an install of both in the project, since npx sees only what it installed beside the CLI.
Of the MCP server it is a required peer: npm installs it with the server, npx -y @real-a11y-dev/mcp included, and so does pnpm by default. Yarn, pnpm with auto-install-peers off, and a --legacy-peer-deps install leave it out, as npx did for @real-a11y-dev/mcp 0.1.0-beta.9 and earlier, which listed it as optional. The server's tools then answer Cannot find package 'playwright'. Install the two together, and launch the server as:
npx -y -p @real-a11y-dev/mcp -p playwright real-a11y-mcpIn a client's config file that is "command": "npx" with "args": ["-y", "-p", "@real-a11y-dev/mcp", "-p", "playwright", "real-a11y-mcp"]. Or install both in a project (npm i -D @real-a11y-dev/mcp playwright) and point the client at that install. Either way, the server needs a browser once:
npx -y @real-a11y-dev/cli install # downloads Chrome for Testing, first time onlyThat is the CLI's installer, which needs Node.js 22.12 or newer. Use the scoped name there: npx real-a11y runs the CLI only where it is already installed, and anywhere else asks the registry for an unscoped package that isn't ours.
A separate "No browser is downloaded yet." error means Playwright itself is installed but no browser binary is — run just the install step the hint names.
Chrome fails to launch with a shared-library error
real-a11y install downloads a plain Chrome binary — unlike playwright install --with-deps, it doesn't install any OS-level packages. On a bare Linux container you may see something like error while loading shared libraries: libnss3.so: cannot open shared object file. Install just the system dependencies, without downloading another browser:
npx playwright install-deps chromium # Debian/Ubuntu; needs rootIf a previously-working installed Chrome starts failing to launch (a corrupted or partially-written binary), reinstall it:
real-a11y install --forceAn audit of a logged-in page comes back empty (or shows the login screen)
An authenticated audit needs a saved session, and it has to match the origin you're auditing:
- CLI. Record the session once with
real-a11y login <url> --save auth.json, then pass it withreal-a11y audit <url> --storage-state auth.json. Without--storage-statethe audit runs logged-out, so you get the sign-in page's tree instead of the app's. - MCP. Point the server at the same file through the
REAL_A11Y_MCP_STORAGE_STATEenvironment variable before it starts.
If the session is present but the audit still looks logged-out, the file is usually stale (tokens expired — re-run login), or the app keeps its auth in session storage, which login doesn't capture — attach to a browser you logged into by hand with --cdp instead.
When a session is loaded, extraction is pinned to the target's own origin, so a redirect onto an auth domain is refused rather than silently audited ("not an allowed audit origin"). Allow the extra origin explicitly with --audit-origin <origin> (CLI) or REAL_A11Y_MCP_ALLOWED_ORIGINS (MCP).
The MCP server refuses to open a file:// URL
The MCP server blocks file:// navigation by default — an LLM that can open file:///…/.env and read the DOM back is a local-file exfiltration primitive. If you genuinely need to audit a local HTML file, opt in with REAL_A11Y_MCP_ALLOW_FILE=1 in the server's environment. (The CLI approves a file:// target you pass on the command line directly, so this refusal is specific to the MCP server.)
Still stuck?
- Check the Peer Dependencies recipe — most "it doesn't install" issues are peer version mismatches.
- Check the relevant recipe (Next.js, Storybook + React 19) if you're on one of those stacks.
- Reproduce in the Chrome extension against the same URL — if the extension also produces the unexpected output, it's a real tree extraction question (open an issue with the minimal HTML). If the extension matches your expectation but the library disagrees, it's likely a config or environment issue.