Why a repeated screen-reader message needs a zero-width space
A screen reader announces a change to an ARIA live region, not a write to it. Set the region's text to what it already says and nothing gets announced at all, so a filter that returns "3 results" twice in a row tells you something the first time and nothing the second, and you reasonably conclude the filter did not run. The fix has to change the text without changing what is heard.
That is one of three problems we handled once in liveregion, our small React library for announcements. The other two, two messages in one frame collapsing into one and assertive cutting into whatever is being read, are queue and ordering problems. This one is different in kind. It is not about when to write. It is about the write we want to perform not being a write at all, as far as the platform is concerned.
The smallest change that is still a change#
Your options for making the second "3 results" distinct are not equally good.
Appending a visible token (a counter, a bullet, a period) works on the DOM and fails on the user: it gets read out, or it changes the phrasing, and now the message a developer wrote is not the message a user hears. Blanking the region and writing the same text back fails too, for the same reason the queue exists: the region's text is replaced and the reader announces what it finds when it looks, so a blank then rewrite inside one frame leaves the node holding exactly the text it held before.
A trailing zero-width space is the one token that changes the node without changing the announcement. It is not spoken, and it has no effect on layout, which matters even though our regions are hidden, because the hiding is clip-based (VISUALLY_HIDDEN keeps the element in the accessibility tree, unlike display: none) and nothing stops a caller from rendering a region you can see.
That split, the text we write versus the message we meant, is a field on the announcement rather than a convention:
export type Politeness = "polite" | "assertive";
export type Announcement = {
readonly message: string;
readonly politeness: Politeness;
/** Distinct text for a repeated message; see `deduplicate`. */
readonly text: string;
readonly id: number;
};Everything that touches the DOM uses text. Everything that reasons about what you were told uses message. In the React binding the only consumer of text is the repaint:
#repaint(): void {
const current = this.#queue.current;
this.#paint(
current?.politeness === "polite" ? current.text : "",
current?.politeness === "assertive" ? current.text : "",
);
}So the test for this behaviour can assert both halves of the requirement separately, and it does:
test("the same message twice is made distinct so it is announced twice", () => {
const announcer = new Announcer();
announcer.announce("3 results");
const first = announcer.current!;
announcer.announce("3 results");
const second = announcer.waiting[0]!;
assert.equal(first.message, second.message, "the message a user hears is the same");
assert.notEqual(first.text, second.text, "the text written to the DOM must differ");
// The distinguishing character is not read out and does not affect layout.
assert.equal(second.text.replace(/\u200b/g, ""), "3 results");
});The suffix has to toggle, not accumulate#
The naive rule, append a zero-width space whenever the message repeats, grows the string without end. Ten identical filter results leave ten invisible characters trailing the text, and while none of them is spoken, we are now writing a string whose length depends on how long you have been clicking.
The rule we settled on is one comparison, and what it compares against is the text we last produced, not the message we were last given. #distinct looks up the previous text for that politeness in #lastText, a Map<Politeness, string>. If it equals the incoming message, the queued text becomes the message plus a trailing zero-width space, and otherwise the message goes through unchanged. Then #lastText records what was produced.
That last detail is what makes the suffix toggle instead of latch. A third identical announcement compares the stored suffixed text against the plain incoming message, finds them different, and goes out plain:
| announce | #lastText before |
text written |
|---|---|---|
"3 results" |
(none) | 3 results |
"3 results" |
3 results |
3 results + ZWSP |
"3 results" |
3 results + ZWSP |
3 results |
Repeats alternate between the bare string and the suffixed one, the string does not grow past one extra character, and the invariant that actually matters holds: consecutive writes to one region do not set it to the text it already holds.
Distinctness is decided on arrival#
#distinct is called from announce, once the message has been trimmed and found non-empty, and not from the repaint:
announce(message: string, politeness: Politeness = "polite"): this {
const trimmed = message.trim();
if (trimmed === "") return this;
const announcement: Announcement = {
message: trimmed,
politeness,
text: this.#distinct(trimmed, politeness),
id: ++this.#counter,
};
if (politeness === "assertive") {
const firstPolite = this.#queue.findIndex((a) => a.politeness === "polite");
this.#queue.splice(firstPolite === -1 ? this.#queue.length : firstPolite, 0, announcement);
} else {
this.#queue.push(announcement);
}
if (!this.#current) this.advance();
return this;
}Computing the text on arrival rather than at paint time means the ordering rules cannot undo it. An assertive message overtakes the waiting polite ones by splicing in ahead of the first of them, which changes what gets painted next but touches no announcement's text. Two queued repeats already differ from each other, even though neither has been painted yet:
test("overtaking does not undo what made a repeated message distinct", () => {
const announcer = new Announcer();
announcer.announce("3 results");
announcer.announce("3 results");
announcer.announce("X", "assertive");
assert.deepEqual(
announcer.waiting.map((a) => a.text),
["X", "3 results\u200b"],
);
});The alternative, deciding at paint time against whatever the node currently holds, sounds more accurate and is worse: it puts DOM state into the queue, and the queue is the part we want testable without a renderer.
The memory is per politeness#
#lastText is keyed by Politeness because a page has two regions and they are separate nodes. The same message sent polite and then assertive gets written to two different elements, so neither needs distinguishing from the other, and suffixing the second would be a change made for no reason:
test("politeness levels do not interfere with each other's repeats", () => {
const announcer = new Announcer();
announcer.announce("Busy", "polite");
announcer.announce("Busy", "assertive");
// Different regions, so neither needs distinguishing from the other.
assert.equal(announcer.current?.text, "Busy");
assert.equal(announcer.waiting[0]?.text, "Busy");
});Both regions are rendered from the start by useAnnouncer, since a region's politeness is read when the page loads rather than when its text changes. The per-politeness map is the queue-side consequence of that decision: one piece of remembered state per node that exists.
And it survives a clear#
clear drops the queue and whatever is showing, and deliberately leaves #lastText alone. So a message repeated after a clear is still made distinct.
This looks like a leak and is really a choice about which way to be wrong. The Announcer touches no DOM (that is why the awkward cases are testable), so it cannot know whether the host blanked the node. The React Driver does blank both regions on clear, but the queue is usable on its own, and a caller who clears without repainting leaves a node holding the old text. Forget the memory and that caller gets silence on the next identical message: a real bug, invisible in code review, reproducible only with a screen reader. Keep the memory and the worst case is one invisible character written to a freshly blanked region. We write the character.
What it costs#
Two map entries at most, one string comparison per announced message, and one character of growth that does not become two. For callers who deduplicate upstream and want the text written exactly as handed over, deduplicate: false in QueueOptions turns the suffix off. The default is true, because a filter that returns the same count twice has to say so twice. The other option, clearAfterMs, defaults to DEFAULT_CLEAR_MS (1000) and belongs to the timer rather than to this rule; the queue itself has no timers, which is why everything above is a synchronous test.
The general shape is probably worth keeping. When a platform reports changes rather than values, the only way to report the same value twice is to make the write different while keeping the perception identical. The work is finding the smallest token the perception layer ignores, and then holding exactly enough state that using it stays bounded: here, the text we produced rather than the message we were given.