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
auditSnapshot() 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 { auditSnapshot } from "@real-a11y-dev/testing";
expect(
auditSnapshot(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.auditSnapshot({ 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". - You're passing a root that doesn't contain the expected elements (e.g. a portaled modal that mounts elsewhere in the DOM).
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.
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(auditSnapshot(container));
throw err;
}Every assertion is documented with an example in @real-a11y-dev/testing.
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.