Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ build
coverage
dist
package.json
# Git-excluded scratch area holding manual-check harnesses, which are written
# against the browser rather than this project's config. Absent for anyone
# Git-excluded scratch area, outside this project's config. Absent for anyone
# else, so this entry is inert for them and keeps `npm test` passing here.
roadmap
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ through `await waitFor(...)`. The warm-cache loading test is the one deliberate
exception: it needs the cache to hit, so it uses a fixed URL no other test
touches.

jsdom has no accessibility layer and no paint, so the suite pins the ARIA
wiring as markup and can go no further: whether a screen reader does anything
with that markup is outside what any test here can answer. That is this repo's
blind spot rather than a shared one, because the wiring lives here —
svg-injector ships no ARIA behaviour of its own. `test/manual/` is the check for
those questions: a node server and a page driven by hand under VoiceOver. It is
not run by `npm test` and not wired into CI, deliberately, since the instrument
is a real screen reader and automating it would mean simulating the thing it
exists to escape. Run it and record the result in the PR when you change the
ARIA wiring or the `loading` element's lifecycle, and update its recorded run in
the same commit as any deliberate change to either.

Raising a `size-limit` budget in `package.json` is a decision, not a fix. Find
what grew first, and say why in the commit message.

Expand Down
31 changes: 28 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ export default tseslint.config(
'**/coverage/',
'**/dist/',
'**/node_modules/',
// Git-excluded scratch area, outside every tsconfig and written for the
// browser and node rather than this project's globals. Absent for
// anyone else.
// Git-excluded scratch area, outside every tsconfig and this project's
// globals. Absent for anyone else.
'roadmap/',
],
},
Expand Down Expand Up @@ -70,5 +69,31 @@ export default tseslint.config(
'@typescript-eslint/no-require-imports': 'off',
},
},
{
// The hand-run screen-reader harness. Neither file is part of the build or
// the suite: one is a node server that reports on stdout, the other runs in
// the browser. `sort-keys` is off because app.mjs's step table is in the
// order the steps are run in, which is the order it has to be read in.
files: ['test/manual/*.mjs'],

rules: {
'no-console': 'off',
'sort-keys': 'off',
},
},
{
files: ['test/manual/app.mjs'],

languageOptions: {
globals: globals.browser,
},
},
{
files: ['test/manual/server.mjs'],

languageOptions: {
globals: globals.node,
},
},
eslintConfigPrettier,
)
166 changes: 166 additions & 0 deletions test/manual/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Manual screen-reader checks

The suite runs in jsdom, which has no accessibility layer and no paint. Every
accessibility claim this package makes is therefore pinned as markup: that
`role="img"` is set, that the `<title>` and `<desc>` nodes exist, that
`aria-labelledby` points at their IDs. Nothing in the suite observes what
assistive technology does with any of it.

That gap matters here more than in most repos. svg-injector ships no ARIA
behaviour of its own, so the wiring in `beforeEach` and the `loading` element
this package mounts and unmounts are the accessibility contract, and this repo
owns all of it.

This harness is how that contract gets checked. It is deliberately not wired
into CI: the instrument is a real screen reader, and automating it would mean
simulating the thing it exists to escape.

It currently covers one question, the one it was built for. Extending it to the
`role="img"` / `<title>` / `<desc>` / `aria-labelledby` path is the obvious next
use and has not been done.

## The question it answers

Does a briefly-mounted `loading` element announce?

svg-injector defers its callbacks, so on a second mount of the same `src`
react-svg commits the `loading` element to the DOM and removes it a couple of
milliseconds later. `should render the specified loader for a cached src` in
`test/browser.spec.tsx` pins that DOM behaviour. Whether a screen reader queues an announcement for an element with
that lifetime is what the suite cannot answer, since assistive technology
observes the DOM rather than the screen and paint timing has nothing to do with
it.

A cold load mounts and unmounts `loading` in every version, and always has. What
changed is how often: every cached mount now does what previously only a genuine
load did. Report any finding as a frequency change, not as a new defect.

## Running it

```
npm run build
node test/manual/server.mjs
```

Then open <http://localhost:4191>, with:

- **VoiceOver on, caption panel open** (`VO`+`Command`+`F10`). The panel shows
queued announcements as text, which is the whole reason it is the instrument
of choice here.
- **The window foregrounded** for the entire run.

Safari is the representative pairing for VoiceOver. Worth a second run in Chrome
if the two disagree, since the AT-to-browser bridge differs.

Everything the page loads comes from the working tree: react-svg from `dist/`,
svg-injector and React from `node_modules/`. So it tests the build in front of
you, it needs no network, and there is no second React version to keep in step
with `package.json`.

React ships no ES module build, which is why `server.mjs` wraps the CJS files it
does ship and `index.html` loads them as classic scripts before anything else.
The import map then points `react` and `react-dom/client` at generated shims
over those globals, so `dist/react-svg.mjs` resolves `react` to the same
instance react-dom is using.

## The steps

Run them in order. Each prints a DOM log; the caption panel is what you are
actually reading.

- **0 — check the instrument.** Changes the text of a live region that has been
in the DOM since page load. No react-svg involved. This is the canonical
live-region pattern, the one screen readers reliably announce, so it must
appear in the caption panel. If it does not, VoiceOver is not reaching the
panel and nothing else here can be read.
- **0b — insert a populated live region.** Inserts a `role="status"` element
that arrives with its text already in it, again with no react-svg involved.
Read against step 0 it says whether any silence is about insertion versus
mutation. That is structurally what react-svg does with a `loading` component,
minus React and svg-injector, so a silent 0b puts the finding on the platform
rather than on this package.
- **1 — warm the cache.** Mounts eight icons with no `loading` component at all,
waits for injection, unmounts. Nothing here can announce; it exists only to
leave svg-injector's cache holding all eight.
- **A — cached remount, `role="status"`.** Remounts the same eight `src`s with a
loading component carrying live-region semantics, which is how loading
indicators are conventionally written. The log must report `0 requests
served`; anything else means the remount refetched and the run is void.
- **B — cached remount, plain span.** The same again with no live-region
semantics.
- **4 — slow cold load, `role="status"`.** A cold load held open for ~2.5
seconds, so the loading element is mounted for a human-scale stretch rather
than a couple of milliseconds.

Step 4 is **not** the instrument check, though an earlier version of this
harness treated it as one. It inserts an element that already carries
`role="status"` and its text, and whether assistive technology announces a
*newly inserted* live region is an open question in its own right — the same one
the cached cases turn on. A silent step 4 therefore cannot tell a broken setup
apart from a real finding. Step 0 exists to do that job, and it is the only step
that can.

## What the DOM log is and is not

It records each loading element entering and leaving the DOM, and how long it
stayed. That proves the elements existed, which is what tells an *absence of
announcements* apart from an *absence of loading elements*. It does not prove
anything was announced. Do not report a finding from the log alone.

## The pieces that look incidental

Each one is a wrong turn already taken once.

| Piece | Covers |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Step 0 against step 0b | Mutation versus insertion. Without it a silent run cannot be told from a broken setup, which is exactly what happened first. |
| The `/hits` delta | Proves a cached remount did not refetch. The screen looks identical either way, so nothing else would catch it. |
| The MutationObserver log | Tells "nothing announced" apart from "no loading element ever appeared". |
| Port 4191, not 4190 | 4190 is ManageSieve, on the Fetch standard's blocked port list. WebKit refuses to connect and shows `about:blank`; Chromium loads it and hides the problem. |
| Visually hidden live regions | A visible region gets read as an announcement. This produced a contradictory reading before the region was hidden. |

## Recording a run

| Case | Loading elements in DOM | Median lifetime | Caption panel |
| ------------------------------ | ----------------------- | --------------- | ------------- |
| 0 — instrument check | n/a | n/a | |
| 0b — inserted populated region | n/a | n/a | |
| A — cached, `role="status"` | | | |
| B — cached, plain span | | | |
| 4 — slow cold, `role="status"` | | | |

Browser and version:
VoiceOver / macOS version:

## Last run

Recorded so a later run has something to compare against.

19.0.0, 2026-08-04, Safari 26.5 on macOS 26.5, VoiceOver with the caption panel
open:

| Case | Caption panel | DOM |
| ------------------------------------------------------- | ------------- | ------------------------------------ |
| 0 — existing live region, text changed | announces | n/a |
| 0b — `role="status"` element inserted already-populated | silent | n/a |
| A — cached remount, `role="status"` | silent | 8 elements, 0 requests |
| B — cached remount, plain span | silent | 8 elements, median 2.0ms, 0 requests |
| 4 — `loading`, `role="status"`, ~2.5s mounted | silent | 1 element |

**No announcement, and lifetime is not the variable.** VoiceOver announces a
live region whose content changes and ignores one that arrives with its content
already in it. Step 0b establishes that with neither React nor svg-injector in
the picture, so it is platform behaviour react-svg inherits. React mounts a
`loading` component as a complete element, which is always the second shape, and
a `role="status"` element mounted for a full 2.5 seconds was as silent as the
two-millisecond ones. Live-region semantics made no difference either: A and B
were equally silent.

Re-run this against a different browser or screen reader, or if the mounting
behaviour changes.

The mechanics were re-checked in Chrome 151 on 2026-08-04, after the move here
and to React from `node_modules`: all six steps ran, both cached remounts served
0 requests, the control held its loading element for 2509ms, and the console was
clean apart from React's DevTools notice. No screen reader was running, so that
run says nothing about announcements — it only says the harness works.
Loading
Loading