[Regression on closed #366] all six route-level loading.tsx skeletons have lost their ARIA live region — route changes are silent for screen readers

Open
#842 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
72/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
next.js, react, tailwindcss, typescript

Research direction

Read the six route boundaries at src/app/loading.tsx, src/app/dashboard/loading.tsx, src/app/issues/loading.tsx, src/app/issues/[id]/loading.tsx, src/app/milestones/loading.tsx, and src/app/reputation/[handle]/loading.tsx. Compare their accessibility pattern with src/app/auth/callback/page.tsx and check the loading-boundary test file mentioned for #667. Done when each boundary announces loading accessibly, supports reduced motion, and has test coverage for role="status".

Written by the indexing model from the issue text.

Description

accessibility bug

Location

All six route-level loading boundaries, none of which carries a live region:

  • src/app/loading.tsx:5
  • src/app/dashboard/loading.tsx:5
  • src/app/issues/loading.tsx:3
  • src/app/issues/[id]/loading.tsx
  • src/app/milestones/loading.tsx
  • src/app/reputation/[handle]/loading.tsx

Problem

Each file returns a purely visual skeleton — a container with animate-pulse and a grid of grey placeholder <div>s — and nothing else:

export default function Loading() {
  return (
    <div className="mx-auto max-w-6xl px-6 py-12 animate-pulse">
      <div className="h-8 w-48 rounded-lg bg-slate-200 dark:bg-slate-800" />
      ...

grep -rn 'role=\|aria-' src/app/**/loading.tsx returns zero matches across all six files. There is no role="status", no aria-live, no aria-busy, and no visually-hidden text naming what is loading. Nothing higher in the tree supplies one either: src/app/layout.tsx wraps {children} in a bare <main id="main-content"> with no live region, and Next.js renders a loading.tsx as the Suspense fallback in that same slot.

This is precisely what closed issue #366 was about — "None of the five route-level loading.tsx skeleton screens have an ARIA live-region/status role" — and the fix is no longer present in any of the files, including the sixth one added since. For contrast, the pattern is still correct elsewhere in the app: src/app/auth/callback/page.tsx:15 and src/app/auth/callback/CallbackClient.tsx:154 both use <p role="status" aria-live="polite">, so the convention exists and these six files diverge from it.

Why it matters

Client-side navigation in the App Router does not reload the document, so a screen reader gets no page-load announcement. The skeleton is the only indication that anything is happening, and it is announced as nothing at all — a sighted user sees a shimmer, a screen-reader user hears silence between activating a link and the new page's content appearing. On /issues/[id] and /reputation/[handle], where the fetch can take a full second against a live backend, the user has no way to tell whether their activation registered, and is likely to activate the link again.

animate-pulse also runs unconditionally, so the skeleton ignores prefers-reduced-motion — a smaller instance of what closed issue #258 covered app-wide.

Reproduction

  1. npm run dev, then enable VoiceOver (macOS) or NVDA (Windows).
  2. From /issues, activate a bounty card link.
  3. The skeleton from src/app/issues/[id]/loading.tsx renders. Nothing is announced; the screen reader stays silent until the resolved page content lands and focus happens to be moved there.
  4. curl -s http://localhost:3000/issues | grep -c 'role="status"' against a slow backend returns 0 for the skeleton markup.

Suggested fix

Give each skeleton a polite live region with an accessible name, and mark the region busy while it is up. For example, in each loading.tsx:

<div
  role="status"
  aria-live="polite"
  aria-busy="true"
  className="mx-auto max-w-6xl px-6 py-12 animate-pulse motion-reduce:animate-none"
>
  <span className="sr-only">Loading bounties…</span>
  {/* existing placeholder divs, with aria-hidden="true" on the wrapper that holds them */}

Because all six files repeat the same structure, the cleanest version is a single shared <RouteSkeleton label="…"> component in src/components/ui/ that owns the live region, the sr-only label and the motion-reduce:animate-none class, with each loading.tsx passing only its own label and placeholder shape. That also stops the next new route from reintroducing the gap.

Add an assertion to the loading-boundary test file (added for closed #667) that every loading.tsx default export renders an element with role="status", so this cannot silently regress a third time.

Dominant language
TypeScript
Stars
1
Forks
94
Avg merge
1h 2m
Merged PRs (30d)
25

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from MergeFi/frontend

All issues in MergeFi/frontend

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.