Rehoboth Builds

A saved DOM Range does not survive a repaint, and nothing tells you

You walk the page once, build a Range per sentence, and highlight them one at a time. Then the page re-renders. The voice keeps reading, the highlight never comes back, and no error is thrown anywhere.

8 September 2026 ยท from a shipped Chrome extension

1. A Range points at nodes, not at text

A Range stores live references: a start node, a start offset, an end node, an end offset. It does not store the string. When a script replaces the text nodes underneath it — a virtual-DOM diff, an infinite scroll, a lazily hydrated section — the old nodes are detached from the document, and your Range keeps pointing at them.

The object stays usable. You can still read range.toString() and still get your sentence back, because the detached nodes are alive as long as you hold them. What you cannot get is a position on screen: getClientRects() comes back empty, and a highlight registered against that Range paints nothing.

2. The failure is not the sentence you are on

This is the part that cost us the most time. Losing one sentence is a blink. But the ranges were all built in the same pass, so a repaint invalidates every remaining one — the highlight stops for the rest of the page while the speech carries on, at the right sentence, out loud, with nothing marked.

From the user's side that reads as "the highlighting broke," which is a different bug from "this page is not supported," and both of them look identical from inside the extension: no throw, no rejected promise, no console entry. The only observable is that a rectangle you asked for came back empty.

3. Re-find the sentence by its own text

What we ship now: when the Range for the current sentence yields no rectangle, walk the page again into a fresh snapshot, and look for the sentence by the string we were already going to speak. If the same string appears more than once, use its ordinal from the original pass — the third occurrence of "Read more" stays the third one.

Two details matter more than the search itself. The re-walk goes into a separate snapshot rather than overwriting the sentence list, because that list is the index the speaking side is stepping through; rewriting it mid-utterance swaps out the queue underneath the voice. And a repaint that rewrites the words, rather than moving them, is not recoverable this way. We do not pretend otherwise: no match, no highlight.

4. Refusing to relocate is a feature

We do not relocate sentences shorter than about a dozen characters. "Ready." "Yes." "Save" occur all over a page, and a text search for one of them will confidently find the wrong element. Highlighting the wrong sentence while the right one is being spoken is worse than highlighting nothing: the reader trusts the mark and we have moved their eye away from the words in their ears.

The general shape, which is not specific to speech: when a recovery heuristic can be wrong, its failure mode has to be "do less," never "do something plausible." A missing highlight is a gap the user can see past. A confident wrong one is us lying quietly.

How we know

This came out of a shipped extension and its own test suite, not from a specification. The behaviour we rely on is the ordinary one: a Range whose nodes have been detached returns no client rectangles, and a highlight over it paints nothing. Both the relocation and the minimum length are covered by assertions that we deliberately broke to check they were doing work — removing the relocation call, and lowering the minimum to three characters, each turn a specific test red.

We are not telling you which frameworks repaint this way or how to observe it, because we did not measure that. We detect the condition at the moment we need a rectangle and cannot get one, which is the one signal we know is true when we read it.

Where this came from

It came out of Highlight Reader, a Chrome extension that reads a page aloud and highlights each sentence as it speaks. It is free on the Chrome Web Store, needs no account, and uses the voices the browser already ships. A paid add-on — word-level highlighting and higher-quality voices — is described here, and it is an add-on, not the extension.

The API and the code are the easy half. The half that costs days is the store: the slot cap on a new account, the listing text going read-only mid-review, the uninstall URL you cannot change after you ship. The ten that caught us — three of them in full, no signup.