Detecting Detached ARIA Live Regions in SPA Navigation

The bug has a distinctive shape: status announcements work perfectly until the user navigates once, and after that the application is mute for the rest of the session. Nothing throws, nothing looks wrong on screen, and the region is still there in the inspector with the right role and the right aria-live value — it is simply a different node than the one assistive technology was watching. This guide is part of Handling Single-Page Application Routing, and it covers one detection technique: keep a reference to the region node, navigate, and prove the reference is still the region.

Root Cause

A screen reader does not read a live region; it reacts to changes inside one. When a region enters the accessibility tree, its current contents are treated as initial state — that is deliberate, otherwise every page load would read out every status container on the page. Announcements come from mutations observed after the region is already being tracked. Two consequences follow, and a router breaks both.

First, a region that the router owns is destroyed on navigation and a fresh one is created in the new view. To assistive technology that new node is a brand-new region with initial contents, not the region it was tracking a moment ago. Second, and worse in practice, frameworks render the message and its container in the same commit, so the region arrives already containing “3 orders updated”. There was never a mutation to observe — the text was initial state — so nothing is announced even though the DOM at the end of the transition is indistinguishable from a working one.

That last sentence is why snapshot scanning cannot help. Run axe against the settled DOM and it finds a well-formed role="status" region with aria-live="polite", correct contents and no violation to report. The failure is not a property of the final DOM; it is a property of the sequence of DOM states, and the only witness to that sequence is something that was watching while it happened. Two witnesses are worth installing: a reference to the original node, which can answer “are you still attached”, and a mutation ledger, which can answer “how many times was a region inserted or removed, and did any of them arrive with text already in it”.

Node identity of a status region across one navigation The upper row shows a region mounted at the application root: the captured node is still connected after navigation and the assertion passes. The lower row shows a region mounted inside the route outlet: the captured node reports isConnected false and a second, brand-new node arrives already containing the message, so the assertion fails. Region owned by the application shell handle to node A captured before the click still node A isConnected: true identity holds announces — PASS Region owned by the route outlet handle to node A captured before the click node A detached isConnected: false node B inserted arrives holding the text identity broken silent — FAIL A selector query resolves in both rows, which is exactly why the check has to compare nodes.
Both rows end with a valid status region in the document; only the upper row still contains the node assistive technology was tracking.

Configuration

The detection has two halves. The ledger goes in first, because it has to be running before the application mounts — a region churned during the initial render is a real finding, and an observer installed after goto would miss it. Playwright’s addInitScript runs the code on every document before any page script, which is the right hook.

// tests/a11y/live-region-ledger.js
// Records every insertion and removal of any live region, tagged with the URL
// at the time, so a ledger entry can be attributed to one navigation.
(() => {
  const SELECTOR = '[aria-live], [role="status"], [role="alert"]';
  const ledger = [];
  window.__liveRegionLedger = ledger;

  const record = (type, node) => {
    ledger.push({
      type,
      url: location.pathname,
      id: node.id || null,
      live: node.getAttribute('aria-live'),
      // Text already present at insertion is the smoking gun: it was never a
      // mutation, so no screen reader will ever speak it.
      textAtEvent: (node.textContent || '').trim().slice(0, 60),
      at: Math.round(performance.now()),
    });
  };

  const scan = (nodes, type) => {
    for (const node of nodes) {
      if (node.nodeType !== 1) continue;            // skip text and comments
      if (node.matches(SELECTOR)) record(type, node);
      // A region nested inside a removed wrapper is removed too, and that is
      // the common case: the router replaces a whole view, not one div.
      for (const nested of node.querySelectorAll(SELECTOR)) record(type, nested);
    }
  };

  new MutationObserver((records) => {
    for (const r of records) {
      scan(r.addedNodes, 'inserted');
      scan(r.removedNodes, 'removed');
    }
    // Observing `document` rather than document.body works from an init script,
    // where body does not exist yet.
  }).observe(document, { childList: true, subtree: true });
})();

The second half is the identity check. page.evaluateHandle returns a handle that keeps the original node alive in the page even after the router drops every reference to it, which is what makes isConnected meaningful — without the handle, a detached node is garbage and there is nothing left to interrogate. Passing the handle back into page.evaluate as an argument gives a real === comparison against whatever the selector resolves to now.

// tests/a11y/live-region-identity.spec.ts
import { test, expect, type JSHandle } from '@playwright/test';
import { readFileSync } from 'node:fs';

declare global {
  interface Window {
    __liveRegionLedger?: {
      type: string; url: string; id: string | null;
      live: string | null; textAtEvent: string; at: number;
    }[];
  }
}

const ledgerScript = readFileSync('tests/a11y/live-region-ledger.js', 'utf8');
// Three navigations, and the third returns to a view already visited once:
// that return trip is where a router-owned region has already been rebuilt.
const SEQUENCE = ['Orders', 'Notification settings', 'Orders'];

test.beforeEach(async ({ page }) => {
  await page.addInitScript({ content: ledgerScript });
});

test('status region survives every navigation as the same node (SC 4.1.3)', async ({ page }) => {
  await page.goto('/');
  await page.locator('[data-app-hydrated="true"]').waitFor();

  const original: JSHandle<Element> = await page.evaluateHandle(() => {
    const node = document.querySelector('[role="status"][aria-live]');
    if (!node) throw new Error('no status region present on first paint');
    return node;
  });

  for (const name of SEQUENCE) {
    await page.getByRole('link', { name, exact: true }).click();
    await page.getByRole('heading', { level: 1, name }).waitFor();

    // 1. The node captured before the first click must still be in the tree.
    const connected = await original.evaluate((node) => node.isConnected);
    expect(connected, `region detached while navigating to ${name}`).toBe(true);

    // 2. And the region the app would announce through must BE that node,
    // not a replacement that merely matches the same selector.
    const sameNode = await page.evaluate(
      (node) => document.querySelector('[role="status"][aria-live]') === node,
      original,
    );
    expect(sameNode, `region was replaced while navigating to ${name}`).toBe(true);
  }

  const ledger = await page.evaluate(() => window.__liveRegionLedger ?? []);
  const churn = ledger.filter((e) => e.id === 'app-status');
  // One insertion for the whole session and no removals: the shell mounted it
  // once and the router never touched it again.
  expect(churn.map((e) => `${e.type}@${e.url}`)).toEqual(['inserted@/']);
  expect(churn[0].textAtEvent, 'the region was inserted with text already in it').toBe('');

  await original.dispose(); // release the retained node
});

The fix the test drives towards is a region the router cannot reach. Creating it from JavaScript at application start works, but putting it in the document markup outside the mount point is stronger: it exists before the framework boots, survives a hot reload of the component tree, and cannot be moved by a layout refactor because no component renders it.

<!-- index.html — the region is part of the document, not of the component tree -->
<body>
  <!-- Outside #app: nothing the framework renders can unmount this node. -->
  <div id="app-status" role="status" aria-live="polite" aria-atomic="true"
       class="visually-hidden"></div>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
// src/shell/announce.ts — the only writer to #app-status in the whole app
const region = document.getElementById('app-status');

export function announce(message: string) {
  if (!region) throw new Error('#app-status is missing from index.html');
  region.textContent = '';
  // The clear and the write land in different frames, so an identical repeat
  // ("2 items saved" twice) is still two mutations and is announced twice.
  requestAnimationFrame(() => {
    region.textContent = message;
  });
}

Validation

Prove the test fails on the broken arrangement before trusting it on the fixed one. Move the region into the routed view — a one-line change in most applications — and run the spec:

npx playwright test tests/a11y/live-region-identity.spec.ts --reporter=list
# Region rendered inside the route view (broken):
#   ✘ status region survives every navigation as the same node (SC 4.1.3)
#     region detached while navigating to Orders
#     Expected: true   Received: false
# Region in index.html outside #app (fixed):
#   ✓ status region survives every navigation as the same node (SC 4.1.3)

The ledger is the more informative artifact, because it names the navigation that did the damage and shows whether the replacement arrived pre-populated. Dumping it on failure produces a record like this, where the second entry is the removal and the third is a new node carrying a message that will never be spoken:

[
  { "type": "inserted", "url": "/", "id": "app-status", "textAtEvent": "", "at": 412 },
  { "type": "removed", "url": "/orders", "id": "app-status", "textAtEvent": "", "at": 918 },
  { "type": "inserted", "url": "/orders", "id": "app-status",
    "textAtEvent": "24 orders loaded", "at": 919 },
  { "type": "removed", "url": "/settings/notifications", "id": "app-status",
    "textAtEvent": "24 orders loaded", "at": 1503 }
]

Two details make this readable at a glance. The removal and insertion share a millisecond, which is the signature of a framework replacing a subtree rather than of two unrelated events. And textAtEvent on the second insertion is non-empty, which means the message was initial content of a brand-new node: even if node identity had somehow been preserved, that announcement was already lost.

What the ledger records during a single broken navigation Five ledger entries in time order down a vertical axis: the click that starts the transition, the removal of the status region, the insertion of a brand-new region that already contains its message, the write that a screen reader never observes, and the outcome of no announcement. One navigation, five ledger entries +0 ms click on Orders — the router begins the transition +12 ms removed: div#app-status — the tracked node leaves the tree +13 ms inserted: div#app-status with textAtEvent = "24 orders loaded" +14 ms the app considers the status delivered and moves on +18 ms no mutation was observed on a tracked region: silence Two entries one millisecond apart are the whole bug; the final DOM looks flawless.
The ledger turns an invisible timing failure into four lines of JSON that name the navigation responsible.

Edge Cases and Conditional Guards

  • The region is replaced in place, outside any outlet. A memoisation boundary or a keyed list can make a framework discard and recreate a node that no router touched. The isConnected check catches it, the structural “is it inside the route view” check does not, which is why identity is worth asserting even in an application whose region already lives in the shell.
  • Two regions, one of them new. When the router adds a second status region, document.querySelector may keep resolving to the original node and the identity assertion passes while every message is announced twice. Guard with a count assertion — expect(page.locator('[role="status"]')).toHaveCount(1) — before comparing nodes, or filter the ledger for insertions whose id is null, since duplicated regions are usually the ones without an id.
  • Regions inside shadow roots. document.querySelector will not cross a shadow boundary, and a MutationObserver on document does not see mutations inside an open shadow root unless it is observing that root. If the design system renders its announcer inside a custom element, capture the handle through element.shadowRoot.querySelector and attach a second observer to the shadow root; otherwise the ledger stays empty and the test passes for the wrong reason.

Pipeline Impact

This is a behavioural test, so it gates through the Playwright exit code with no extra reporting machinery: one spec, one navigation sequence, roughly four seconds of runner time. Keep it in the same job as the rest of the route-transition tests, and make it a required status check on the branch, as described in the guidance on requiring accessibility status checks in branch protection. Because the failure it catches is silent for sighted users and invisible to snapshot scanners, it is the only signal that will ever object when someone moves the announcer into a layout component during a refactor.

Attach the ledger to the test result on failure rather than only logging it. A testInfo.attach of the JSON above turns a two-word failure message into a diagnosis, and it makes the finding reviewable by someone who does not have the branch checked out. Do not soften the ledger assertion to allow a single detach-and-reattach: a transient replacement drops exactly the announcements that happen during a transition, which is when most status messages fire. Pair this identity test with an announcement-level test that reads what a screen reader would actually output, covered in verifying live-region announcements in automated tests, and let this one own the structural half.

Where the status region has to live The left tree nests body, div#app, the route view and main, with the status region deepest inside the route view, marked as destroyed on every navigation. The right tree places the status region as a direct child of body above div#app, outside the route view, marked as mounted once. Inside the route view Above the mount point body div#app div[data-route-view] — replaced main div#app-status — dies here body div#app-status — mounted once div#app div[data-route-view] — replaced main one region per view — SC 4.1.3 fails silently one region per session — SC 4.1.3 holds The only difference is depth in the tree, and it decides whether any status message is ever heard.
Moving one element two levels up the tree is the entire fix, which is why it is also the easiest thing for a refactor to undo.

Common Pitfalls

  • Asserting that a region exists after navigation, which passes for a brand-new silent node and is the check most teams already have.
  • Reading isConnected from a selector query rather than from a retained handle — the query returns the replacement, whose isConnected is always true.
  • Installing the observer after page.goto, so churn during the initial mount and the first navigation is never recorded.
  • Rendering the message and the region in the same commit, so the text is initial content and no mutation is ever observed.
  • Allowing one detach-and-reattach as acceptable noise, which drops exactly the announcements that fire during a transition.
  • Forgetting handle.dispose() in a long spec that captures a handle per navigation, which retains detached subtrees and inflates memory on the runner.

FAQ

Why check node identity when checking isConnected already fails on a detached region? The two catch different faults. isConnected proves the captured node is still in the document, but an application can keep the old node attached somewhere harmless while announcing through a new one — a stale reference held by a closure, or a region moved into a portal. The identity comparison proves the node the test is watching is the same node the selector now resolves to, which is what determines whether an announcement written by the application reaches the tree assistive technology is tracking.

Can this be written as a custom axe rule instead of a Playwright spec? Only the structural half. A rule evaluated against one settled DOM can prove the region is not inside the route view, and that version is worth having in a shared bundle because it runs on every scanned page for free. It cannot prove anything about identity across a navigation, because a rule sees one snapshot and has no memory of the previous one. Keep the placement rule in the bundle and this spec in the route-transition suite.

Does the ledger slow the application down enough to distort timings? Not measurably. A childList observer on document with a selector match per added node costs microseconds per mutation, and it is only installed in test runs through addInitScript. It does change one thing worth knowing about: because the observer holds no references beyond the ledger entries, a removed region can still be garbage collected, so textAtEvent is captured at record time rather than read later — reading it later would sometimes return an empty string for a node the framework had already cleaned up. The same reasoning applies to any settle detection that waits on mutation quiet, which is covered in waiting for route transitions before an axe scan.