Testing React Hydration Accessibility
If a React scan reports zero violations on a server-rendered route that is obviously broken in the browser, the engine almost certainly ran against the static pre-hydration HTML — before hydrateRoot attached event handlers, before the root useEffect moved focus, and before any portalled dialog mounted. This page resolves that specific race for React 18 applications: expose a hydration-complete signal, discard the pre-hydration pass, and only trust a scan taken after React has committed and its effects have flushed.
This is the React-specific guide within the Framework-Specific Rule Mapping topic area, which itself sits under the Automated Scanning & Dynamic Content Ingestion pipeline. The parent guide covers the shared settle-signal model across frameworks; this page fills in what “settled” means for React 18’s concurrent hydration, useEffect focus moves, React Router transitions, and createPortal dialogs.
When This Applies
Reach for this pattern when a React app is server-rendered or statically generated — Next.js, Remix, Gatsby, or a custom SSR setup — and the scanner sees HTML before the client takes over. The symptoms are specific:
- A route reports clean, but the same page fails an interactive check (a focus trap, an
aria-expandedtoggle) when a human tests it. - Violation counts differ between the first scan and a rerun on the same commit, tracking whether hydration happened to finish in time.
- A dialog opened by a button never appears in any report, because it renders through a portal that had not mounted when the engine ran.
- Under React StrictMode in development, effects run twice and the scan captures a transient intermediate DOM.
If your app is a pure client-side React SPA with no server render, there is no pre-hydration skeleton to race — but the route-change and portal handling below still apply.
Minimal Reproducible Example
The naive scan loads the route and runs the engine as soon as the network settles. Against an SSR route this reads the server HTML, which is inert: buttons have no handlers, the focus-managed dialog has not been triggered, and effect-driven ARIA state has not been applied.
# scan_react_naive.py — races hydration on any server-rendered route.
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="networkidle") # HTML is here...
# ...but React may not have hydrated yet. This reads the static shell:
# effect-driven roles, focus moves, and portals are all still absent.
await page.add_script_tag(path="node_modules/axe-core/axe.min.js")
return await page.evaluate("() => axe.run()")networkidle fires when requests stop, which has nothing to do with whether React finished attaching. On a fast connection the JS bundle can still be parsing and hydrating after the network goes quiet, so the engine reads a DOM that no user ever interacts with.
Correct Implementation
Publish a hydration-complete flag from a root effect that only runs on the client, reset it on every React Router navigation, and make the scanner wait for it. Because a root useEffect runs after React commits the tree and the browser paints, the flag flipping true is a precise “hydration and first-paint effects are done” signal.
// HydrationSignal.jsx — mounted once at the app root, inside the Router.
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
export function HydrationSignal() {
const location = useLocation();
useEffect(() => {
// Reset FIRST so a route change cannot leave the previous view's
// "settled" flag standing while the new view reconciles.
window.__A11Y_SETTLED = false;
// A useEffect fires only on the client, after commit and paint — so by
// here hydration is complete and any focus move queued by child effects
// has been scheduled. Defer two frames so those focus/ARIA effects and
// a just-opened portal have actually committed before we allow a scan.
let raf1 = requestAnimationFrame(() => {
let raf2 = requestAnimationFrame(() => {
window.__A11Y_SETTLED = true;
});
return () => cancelAnimationFrame(raf2);
});
return () => cancelAnimationFrame(raf1);
}, [location.pathname, location.search]); // re-arm on every route change
return null;
}# scan_react.py — hydration-aware React scan.
from playwright.async_api import Page
async def scan_react_route(page: Page, timeout_ms: int = 15000) -> dict:
"""Scan only after React has hydrated and its first-paint effects flushed."""
# Wait for the client-only root effect to publish the settled flag.
await page.wait_for_function(
"() => window.__A11Y_SETTLED === true", timeout=timeout_ms
)
# Fold in a portalled dialog: createPortal usually targets document.body,
# outside #root, so scope it back in only when a dialog is actually open.
dialog_open = await page.evaluate(
"() => !!document.querySelector('body > [role=dialog], #portal-root [role=dialog]')"
)
include = [["#root"]]
if dialog_open:
include.append(["#portal-root"])
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": [["#chat-widget"]]},
)
async def navigate_react_router(page: Page, link_selector: str) -> dict:
"""Follow a React Router link and rescan the destination once it settles."""
await page.click(link_selector) # client transition; the effect resets the flag
return await scan_react_route(page)Discarding the pre-hydration pass is the whole point: any scan whose flag never turned true is not a clean page, it is an unmeasured one, and must be treated as a failure to scan rather than a pass. This distinction feeds directly into how counts are gated downstream in running Playwright accessibility checks in CI/CD, where an unmeasured route must not be allowed to satisfy a zero-violation threshold.
Pipeline Integration Note
In a batched pipeline, the hydration flag is what lets React routes share the same impact-threshold gate as server-rendered ones without special-casing. The scan driver waits on __A11Y_SETTLED, folds portals into scope, and emits a payload shaped identically to every other route, so it validates against the same JSON Schema validation for accessibility data contract and merges into the same per-shard aggregation. Which rules the engine runs and how portalled-dialog findings like aria-required-attr map to WCAG 4.1.2 is fixed once in your axe-core enterprise configuration; this React handling only guarantees the DOM the engine reads is the hydrated one.
Gotchas
- React StrictMode double-invokes effects in development. Your root effect runs twice, and a scan launched between the two invocations can catch a torn DOM. Audit the production build, where StrictMode’s double-invoke does not occur, so the settled flag flips exactly once per route.
- Suppressed hydration mismatches hide real defects. A
suppressHydrationWarningon a node whose server and client markup differ can leave the accessible name or role that the server emitted diverging from what the client renders. Scan the hydrated client DOM, never the server HTML, and spot-check any suppressed subtree by hand. - Authenticated Next.js routes need storage state, not a login effect. A client-side auth redirect fires inside an effect and can bounce the scanner to a login shell that hydrates clean and passes for a page it never reached. Load committed
storage_stateinto the context and assert a route-specific landmark before trusting the count.
Frequently Asked Questions
Why does networkidle not guarantee React has hydrated?
networkidle only means outstanding requests have stopped; it says nothing about whether the JavaScript bundle has parsed and hydrateRoot has attached to the server HTML. On a fast connection hydration frequently completes after the network goes quiet, so a scan gated on networkidle reads the inert pre-hydration shell.
Where exactly should the hydration-complete flag be set?
In a useEffect at the application root, mounted inside the Router so it can key on location changes. A root effect runs only on the client and only after React commits and the browser paints, which makes it a reliable marker that hydration and first-paint effects have finished.
My React Router modal is missing from the report. Why?
Dialogs built with createPortal mount outside the #root container into document.body or a dedicated portal node, so an include scoped to #root never reaches them. Detect the open dialog at scan time and add the portal container to the context so its violations are captured.
Should I scan the development build or the production build?
The production build. Development adds StrictMode’s double-invoked effects, dev-only warnings, and error overlays that alter the DOM and can be captured mid-transition. Production hydrates once and matches what users receive, so the settled flag and the resulting counts are stable.