Handling Single-Page Application Routing
A full document load hands assistive technology a free reset: the browser announces the new document title, focus returns to the start of the document, the accessibility tree is thrown away and rebuilt, and the landmark set of the previous page cannot possibly leak into the new one. A client-side route change gives none of that. history.pushState plus a re-render changes the address bar and swaps a subtree; every reset that used to be free is now something the application has to do on purpose, and something a test has to prove it did. This guide is part of Custom Rule Development & Context-Aware Testing, and it treats a route change as a four-part contract with assertions attached to each part.
Problem Statement
The gap between those two models is not academic. In a router-driven application, a keyboard user presses Enter on a navigation link, the visible content is replaced, and focus is still sitting on the link they just activated inside a navigation region that may no longer be relevant. A screen-reader user hears nothing at all: no title, no heading, no status message, because nothing in the accessibility tree changed in a way that triggers an announcement. The view is correct, the markup is valid, and the navigation is functionally invisible.
The testing consequence is sharper than most pipelines account for. A per-URL crawl — page.goto('/orders'), scan, page.goto('/orders/8123'), scan — exercises the entry render of each view: the app shell mounts fresh, the router resolves one route, and every teardown path in the application is skipped. Reaching /orders by clicking a link from / exercises the transition render, which is a different DOM. The transition render is where a closed dialog leaves aria-hidden="true" on the shell, where an outlet that appends instead of replacing produces a second <main>, where a live region is unmounted and rebuilt empty, and where focus is left behind. None of those failures can appear in a direct URL load, which means a crawl-based accessibility gate is structurally blind to the entire class.
Two more properties make this class expensive to find late. First, it is temporal: any single snapshot of the DOM may be perfectly conformant, and what fails is the transition between two conformant snapshots. A snapshot scanner has no concept of “before” and “after”, so no amount of rule tuning surfaces it. Second, it is asymmetric across visits — the first arrival at a view often works and the second breaks, because the first arrival has no previous view to tear down. A route matrix that visits every URL exactly once will report a clean pipeline on an application whose every return navigation is broken.
Key implementation targets:
- A route matrix expressed as transitions — a starting URL plus the control that navigates — rather than a list of URLs to visit.
- A title assertion that proves
document.titleboth matches the destination and differs from the origin. - A focus assertion that proves the active element entered the new view and did not stay on the navigation control.
- A live-region assertion that proves exactly one status region exists and that it lives outside the swapped subtree.
- A landmark assertion that proves one
mainand no orphanedaria-hiddenafter every transition. - A scan that runs on the settled post-transition DOM, with the results attributed to the transition rather than to the URL.
Prerequisites
The Route-Change Contract
Four obligations turn a DOM swap back into a navigation. They are not stylistic preferences; each maps to a WCAG success criterion that a route change can fail while every individual snapshot passes.
Announce or retitle. The destination must acquire its own document.title, which is what satisfies WCAG 2.2 SC 2.4.2 (Page Titled) for a view that is a page in every sense except the technical one. On its own a title change is silent in most screen readers during a client-side transition, so it pairs with either a focus move or a short route announcement in a live region. Titles still matter independently: they are what appears in the browser history, in the tab, and in the report a scan produces.
Move focus. Focus must land somewhere inside the new view — conventionally the view’s <h1> made programmatically focusable with tabindex="-1", or a wrapper the router owns. This is the obligation that carries WCAG 2.2 SC 2.4.3 (Focus Order), and it is the one most often implemented and then quietly regressed by a refactor of the router. The behavioural detail of picking a target, handling dialog routes and dealing with aria-busy views is worked through in testing focus management after client-side route changes; this guide asserts only the part the contract needs — that focus entered the view and did not stay on the trigger.
Keep a persistent live region alive. A status region is only useful if it is in the accessibility tree before its text changes. If the router owns the region, every navigation destroys and recreates it, and a region that arrives already containing its message announces nothing at all — a WCAG 2.2 SC 4.1.3 (Status Messages) failure that leaves no trace in any snapshot. The region therefore belongs to the shell, not to the view. Detecting detached ARIA live regions in SPA navigation proves node identity across a navigation; the contract test below proves the cheaper structural fact that the region is not inside the swapped subtree.
Keep landmarks stable. After a transition there must still be exactly one main, one banner, one contentinfo, and no leftover aria-hidden="true" or inert from a dialog the previous view opened. Duplicated landmarks make the whole document ambiguous to landmark navigation, which is a WCAG 2.2 SC 1.3.1 (Info and Relationships) failure, and orphaned aria-hidden hides working content from assistive technology while leaving it perfectly visible and clickable on screen.
1. Build a Navigation-Driven Route Matrix
The unit of testing is a transition, not a URL. A transition names where the browser starts, which control performs the navigation, and what the destination must look like once the router has committed. Expressing that as data keeps the spec to a single loop and makes the matrix reviewable in a pull request: adding a route to the application means adding one line here, and a reviewer can see immediately whether the return path was covered.
// tests/a11y/transitions.ts
// One entry per transition. `from` is where the test starts, `via` is how the
// navigation happens, and the rest is the contract the destination must satisfy
// after the router commits. Order matters: `settings-to-orders` is a return
// visit and only makes sense because `home-to-orders` mounted the view first.
export interface Transition {
id: string;
from: string;
via: { kind: 'link'; name: string } | { kind: 'row'; name: string } | { kind: 'back' };
toURL: string;
title: RegExp;
heading: RegExp;
}
export const TRANSITIONS: Transition[] = [
{
id: 'home-to-orders',
from: '/',
via: { kind: 'link', name: 'Orders' },
toURL: '**/orders',
title: /^Orders \| Northwind$/,
heading: /^Orders$/,
},
{
id: 'orders-to-detail',
from: '/orders',
via: { kind: 'row', name: 'Order 8123' },
toURL: '**/orders/8123',
title: /^Order 8123 \| Northwind$/,
heading: /^Order 8123$/,
},
{
id: 'detail-back-to-orders',
from: '/orders/8123',
via: { kind: 'back' },
toURL: '**/orders',
title: /^Orders \| Northwind$/,
heading: /^Orders$/,
},
{
id: 'orders-to-settings',
from: '/orders',
via: { kind: 'link', name: 'Notification settings' },
toURL: '**/settings/notifications',
title: /^Notification settings \| Northwind$/,
heading: /^Notification settings$/,
},
{
id: 'settings-to-orders',
from: '/settings/notifications',
via: { kind: 'link', name: 'Orders' },
toURL: '**/orders',
title: /^Orders \| Northwind$/,
heading: /^Orders$/,
},
];
The last entry is the one that earns its keep. settings-to-orders reaches a view the matrix has already covered, from a different origin, which is exactly the case where an outlet that appends produces a second <main> and where a settings dialog’s inert is still on the shell. Five transitions across four URLs costs about twenty seconds of runner time and covers a failure class that a four-URL crawl cannot reach at any price.
2. Assert the Title Change
Two things can go wrong with a title, and only one of them is caught by matching a pattern. The obvious failure is a title that never updates, leaving the origin view’s text in the tab and in the history entry. The subtler failure is a title template that produces the same string for two different views — common when the router falls back to a static application name for any route without its own metadata. Both are caught by asserting the match and the difference.
// tests/a11y/assert-title.ts
import { expect, type Page } from '@playwright/test';
export async function assertTitleChanged(page: Page, before: string, want: RegExp) {
// toHaveTitle polls, so a title set in a post-commit effect or a microtask
// after paint still passes without adding a fixed delay to the spec.
await expect(page, 'destination title never matched the expected pattern')
.toHaveTitle(want);
const after = await page.title();
// A title that matches the pattern but equals the previous one means the
// router reused the origin's metadata: the history entry is unlabelled and
// a screen-reader user re-reading the title learns nothing new. SC 2.4.2.
expect(after, `title did not change across the transition: "${after}"`)
.not.toBe(before);
// Titles longer than roughly 70 characters are truncated in most tab strips
// and in browser history search, so the distinguishing part must come first.
expect(after.indexOf(' | '), 'view name must precede the app name in the title')
.toBeGreaterThan(0);
}
Capture before immediately prior to the navigation rather than deriving it from the origin URL. Deriving it re-implements the router’s own title logic in the test, which means the test agrees with a bug rather than catching it.
3. Assert Focus Placement
The focus assertion needs to answer two questions at once: did focus enter the new view, and did it leave the control that triggered the navigation. Checking only the first passes when the router focuses a persistent element that happens to sit inside the view; checking only the second passes when focus was reset to <body>, which announces nothing and forces the user to tab from the top of the shell on every navigation.
// tests/a11y/assert-focus.ts
import { expect, type Page } from '@playwright/test';
// A descriptor rather than a boolean: a CI log line that says "focus is on
// a.nav-link 'Orders'" is diagnosable, while "expected true, received false"
// sends the reader to a trace file.
export async function describeActiveElement(page: Page) {
return page.evaluate(() => {
const el = document.activeElement as HTMLElement | null;
if (!el || el === document.body) return { tag: 'body', name: '', inView: false };
return {
tag: el.tagName.toLowerCase(),
name: (el.getAttribute('aria-label') ?? el.textContent ?? '').trim().slice(0, 40),
// data-route-view marks the subtree the router replaces, so closest()
// answers "is focus in the new view" without naming any single element.
inView: !!el.closest('[data-route-view]'),
};
});
}
export async function assertFocusEnteredView(page: Page, triggerName: string) {
const active = await describeActiveElement(page);
const where = `${active.tag} "${active.name}"`;
expect(active.inView, `focus never entered the route view; it is on ${where}`)
.toBe(true);
// Focus left on the trigger is the default browser behaviour after a
// client-side navigation, so this is the regression most likely to reappear.
expect(active.name, `focus stayed on the navigation control ${where}`)
.not.toBe(triggerName);
}
Deliberately absent from this helper is any judgment about whether the target is a good one. A heading, a wrapper, a dialog and a search field are all defensible landing targets depending on the route, and encoding a preference here turns the contract test into a style gate that teams disable. The contract asserts only what is unambiguous.
4. Assert the Live Region Survives
The structural version of the live-region check is cheap enough to run on every transition: exactly one status region, still connected, and not inside the subtree the router replaces. That last condition is the whole fix expressed as an assertion — a region outside the swapped subtree cannot be recreated by a navigation, so it cannot arrive pre-populated and silent.
// tests/a11y/assert-live-region.ts
import { expect, type Page } from '@playwright/test';
export async function assertRegionSurvived(page: Page, expectedText?: string) {
const region = page.locator('#app-status');
// Two regions announce twice, and the second one is usually the one the
// router recreated - so count is the first thing to check, not the last.
await expect(region, 'exactly one #app-status region must exist').toHaveCount(1);
const state = await region.evaluate((node: HTMLElement) => ({
connected: node.isConnected,
insideView: !!node.closest('[data-route-view]'),
live: node.getAttribute('aria-live'),
role: node.getAttribute('role'),
text: node.textContent?.trim() ?? '',
}));
expect(state.connected, 'the status region is detached from the document').toBe(true);
// Inside the route view the region is destroyed and rebuilt on every
// navigation, which is the SC 4.1.3 failure this whole check exists for.
expect(state.insideView, '#app-status is inside the swapped route view').toBe(false);
expect(state.live, 'the status region lost its aria-live value').toBe('polite');
expect(state.role, 'the status region lost its role').toBe('status');
if (expectedText !== undefined) {
// Routes that announce their own arrival must land the text in the region
// that already existed, not in one created alongside the message.
expect(state.text, 'route announcement never reached the region')
.toContain(expectedText);
}
}
Structural placement proves the region can work. It does not prove the region is the same DOM node it was before the navigation — a framework can replace a node in place, outside any outlet, when a memoisation boundary changes. Proving node identity needs a reference held across the navigation and a mutation ledger, which is the subject of detecting detached ARIA live regions in SPA navigation. Run the structural check on every transition and the identity check on the two or three transitions that actually announce something.
5. Scan After the Transition Settles
Only now does axe get involved. The scan runs once the outgoing view has detached, the incoming heading is attached and the DOM has gone quiet — a helper whose implementation and timeout behaviour are covered in waiting for route transitions before an axe scan. What matters at this level is the call order: settle, then the four contract assertions, then the scan, so a timing failure is reported as a timing failure rather than as a pile of phantom duplicate-id violations.
// tests/a11y/route-contract.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { TRANSITIONS } from './transitions';
import { beginRouteChange, settle } from './settle-after-route-change';
import { assertTitleChanged } from './assert-title';
import { assertFocusEnteredView } from './assert-focus';
import { assertRegionSurvived } from './assert-live-region';
for (const t of TRANSITIONS) {
test(`route contract: ${t.id}`, async ({ page }, testInfo) => {
await page.goto(t.from);
// Until the shell reports hydration, a click is a real document navigation
// and every assertion below would be testing a fresh load by accident.
await page.locator('[data-app-hydrated="true"]').waitFor();
const titleBefore = await page.title();
// Opened before the click so the helper can hold a reference to the view
// that is about to be replaced.
const transition = await beginRouteChange(page);
if (t.via.kind === 'back') {
await page.goBack();
} else if (t.via.kind === 'row') {
await page.getByRole('row', { name: t.via.name }).getByRole('link').click();
} else {
await page.getByRole('link', { name: t.via.name, exact: true }).click();
}
await page.waitForURL(t.toURL);
await settle(transition, { heading: t.heading });
await assertTitleChanged(page, titleBefore, t.title);
await assertFocusEnteredView(page, t.via.kind === 'back' ? 'Back' : t.via.name);
await assertRegionSurvived(page);
// Landmark stability: one main, and nothing live hidden behind a stale
// aria-hidden left over from a dialog the previous view opened.
await expect(page.locator('main')).toHaveCount(1);
await expect(page.locator('[aria-hidden="true"] main')).toHaveCount(0);
await expect(page.locator('[inert] a')).toHaveCount(0);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
// Contrast is a static property of the theme, not of the transition, and
// it is already gated in the visual-regression job.
.disableRules(['color-contrast'])
.analyze();
await testInfo.attach(`axe-${t.id}.json`, {
body: JSON.stringify({ transition: t.id, violations: results.violations }, null, 2),
contentType: 'application/json',
});
const blocking = results.violations.filter(
(v) => v.impact === 'serious' || v.impact === 'critical',
);
// Reporting rule ids with node counts turns a failure message into the
// whole diagnosis, so the artifact only gets opened for the details.
expect(blocking.map((v) => `${v.id} x${v.nodes.length}`)).toEqual([]);
});
}
Attaching the report keyed by transition id is what makes the results readable a week later. A violation filed against /orders is ambiguous — three transitions land there — while a violation filed against detail-back-to-orders names the navigation that produced it, and the engineer who owns the Back path knows immediately that it is theirs.
Pipeline Integration
The contract spec produces a normal Playwright exit code, so gating it is mechanical: a non-zero exit fails the job, and the job is a required status check on the branch. What deserves thought is where it sits relative to the rest of the accessibility suite. Route transitions are the slowest accessibility tests in a repository — each one needs a real browser, a hydrated shell and a settle window — so they belong in their own job rather than inflating the runtime of the static scans that gate every pull request.
name: spa-route-contract
on:
pull_request:
paths:
- 'src/router/**'
- 'src/views/**'
- 'src/shell/**'
- 'tests/a11y/**'
- '.github/workflows/spa-route-contract.yml'
concurrency:
group: route-contract-${{ github.head_ref }}
cancel-in-progress: true
jobs:
route-contract:
runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false # one broken transition must not hide the others
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run the route-transition contract
# The github reporter writes ::error annotations onto the changed files,
# so a failing transition appears inline in the pull request diff.
run: >-
npx playwright test tests/a11y/route-contract.spec.ts
--shard=${{ matrix.shard }}/2
--reporter=github,html
- uses: actions/upload-artifact@v4
if: always()
with:
name: route-contract-report-${{ matrix.shard }}
path: |
playwright-report/
test-results/
retention-days: 7
Path filtering matters more here than in a general scan job. A change to src/shell/** can break every transition at once, because the shell owns the persistent region and the landmark structure, while a change to a single view can only break the transitions that reach it. Including the shell in the trigger paths is what stops a “move the announcer into the layout component” refactor from shipping green.
For richer feedback than inline annotations, route the attached JSON through the same commenter used for static scans, described in annotating pull requests with axe-core violation comments. Group the comment by transition id rather than by URL — the transition is the unit a developer can act on, and a comment that says “focus never entered the view on settings-to-orders” needs no further triage.
Troubleshooting and Flaky-Test Mitigation
The click performs a real document navigation. If the test clicks before the router has attached its listener, the browser follows the href and the spec silently tests a fresh load, which passes every contract assertion for the wrong reason. The hydration marker in the prerequisites is the fix; a second signal worth asserting is that performance.getEntriesByType('navigation').length stays at one across the transition.
The title assertion passes locally and fails in CI. Almost always a router that sets the title in an effect scheduled after paint, combined with a CI runner slow enough to make the ordering visible. Use the polling form (expect(page).toHaveTitle) rather than reading page.title() once, and never insert a fixed delay to compensate — the delay tunes itself to one runner and breaks on the next.
Focus assertions flake on views that fetch data. A view that renders its heading before its content may move focus, re-render, and lose focus to <body> when the heading node is replaced. Settle before asserting focus, and if the framework legitimately replaces the heading, have the router focus a stable wrapper it owns instead. The polling and observation techniques for this live in DOM inspection for dynamic content.
page.goBack() does not trigger the router’s transition logic. Some routers only run their post-navigation hooks on link activation, leaving History pops without a title change or a focus move. That is a genuine bug rather than a test artifact, and it is why the matrix contains a back transition: if detail-back-to-orders fails while home-to-orders passes, the popstate path is missing the hook.
Duplicate-id and duplicate-landmark violations that nobody can reproduce. These are the signature of a scan that ran while both views were mounted — a view transition or an exit animation keeps the outgoing subtree in the document for a few hundred milliseconds. The settle helper exists for exactly this, and the failure mode is examined in detail in the scan-timing guide linked from section 5.
The live-region assertion fails only on the second run in a worker. Playwright reuses a browser context across tests in the same worker by default in some configurations; a leftover region from the previous test makes toHaveCount(1) fail. Isolate with a fresh context per test file, and treat a count of two as a real finding until the isolation is proven.
Common Pitfalls
- Testing routes exclusively with
page.goto, which exercises entry renders only and cannot observe a single teardown failure. - Visiting every URL once, so no transition ever arrives at a view that is already mounted and the append-instead-of-replace bug stays invisible.
- Asserting the destination title matches a pattern without asserting it changed, which passes on a router that reuses the origin’s metadata for unlabelled routes.
- Treating focus on
<body>as acceptable after a route change, when it announces nothing and restarts tab order at the top of the shell. - Letting the router own the status region, so every navigation replaces it and every subsequent announcement is silent.
- Scanning while an exit animation still has the outgoing view mounted, then chasing duplicate-id violations that no user can ever encounter.
- Clicking before hydration, turning the whole contract spec into an expensive test of full page loads.
- Filing violations against the destination URL instead of the transition id, which makes the report unactionable when three transitions share a destination.
FAQ
Is a title change enough on its own, or does the route change need a live-region announcement?
A title change alone is usually silent during a client-side transition: most screen readers announce a document title on a real document load, not on a document.title assignment. The reliable pairing is a title change for the history entry and the tab, plus either focus moved into the new view — which makes the screen reader read the focused heading — or a short polite announcement through the persistent region. Doing both a focus move and an announcement can double-speak, so pick one as the primary signal per application and keep it consistent.
Should the accessibility scan run on every transition, or only on every unique destination?
Run the four contract assertions on every transition, because they are milliseconds each, and run the full axe scan on every transition the first time the matrix is introduced. Once the pipeline is stable, scanning one representative transition per destination plus every back transition keeps the runtime reasonable while retaining the teardown coverage. Dropping the back transitions is the one economy that reliably loses the failure class this guide is about.
How does this relate to the View Transitions API? A view transition deliberately keeps a snapshot of the outgoing view in the document while the animation runs, which makes the overlap window longer and more reproducible rather than shorter. The contract does not change — the same four obligations apply — but the settle step becomes mandatory rather than merely advisable, because a scan during the transition sees two of everything. Emulating reduced motion in tests shortens the window without changing what is asserted.
What if the application has no persistent shell, because every route renders its own layout?
Then the live-region obligation has nowhere to live, and the first fix is structural: introduce a shell that owns the status region, the skip link and the landmark scaffolding, even if it renders nothing visible of its own. Until that exists, the contract can only be partially asserted, and the assertion to keep is landmark stability, since a per-route layout is exactly the pattern that produces two main elements during a transition.
Can these assertions be expressed as custom axe rules instead of Playwright assertions?
Partly. Structural facts — the region is outside the route view, there is one main, the view exposes a focusable heading — are provable from a single settled DOM and make good custom rules, which is why the parent section models them that way. The temporal facts are not: no rule evaluated against one snapshot can know that the title differed a moment ago or that focus used to be on the link. Keep the structural half in the rule bundle and the temporal half in the spec.
Related
- Custom Rule Development & Context-Aware Testing — the section this guide belongs to, including the rule bundle that encodes the structural half of the contract.
- Waiting for Route Transitions Before an axe Scan — the settle helper called in section 5, and how to make it fail loudly.
- Detecting Detached aria-live Regions in SPAs — node-identity proof for the region the structural check only places correctly.
- Test Focus After Client-Side Route Changes — choosing and verifying the landing target the focus obligation needs.
- DOM Inspection for Dynamic Content — the observation techniques the settle and focus assertions depend on.