ReviewOS

stacks/very-happy-dom

innerText does not collapse whitespace, so it differs from textContent only by trimming

#1614
Closed glennmichael123 opened this 8 days ago ·
8 days ago

Problem

innerText excludes hidden subtrees and trims the ends, but does not collapse interior runs of whitespace the way a browser does.

const w = new Window()
w.document.body.innerHTML = '<p>  Save   changes \n  more  </p>'
const p = w.document.querySelector('p')

p.textContent  // '  Save   changes \n  more  '   correct
p.innerText    // 'Save   changes \n  more'       browser: 'Save changes more'

VirtualElement.innerText (:196) walks the tree, skips display: none and visibility: hidden subtrees, turns <br> into \n, and concatenates the text nodes as written. The two hard parts are done; the collapsing step is missing.

Why it matters

innerText exists to answer "what does the user see", and a user does not see three spaces. The distinction from textContent is the whole reason to reach for it, so an innerText that only differs by trimming and hidden subtrees is doing two thirds of a job while carrying a name that promises all three.

It bites hardest where the markup is indented, which is to say all templated markup:

<button>
  Save changes
</button>

innerText there is 'Save changes' only because the ends are trimmed. Put two elements on separate lines inside one container and the container's innerText carries the indentation between them, so a comparison against 'Save changes' fails for a reason that is invisible in the source.

Playwright's toHaveText normalises before comparing, so an assertion routed through a matcher is insulated; a direct innerText read is not. That split is its own trap — the same markup passes one and fails the other.

Proposed scope

Apply CSS whitespace processing to the collected text:

  • Collapse runs of spaces, tabs and newlines to a single space within a text run.
  • Drop whitespace at the start and end of each block-level line, not only at the ends of the whole result.
  • Keep the <br> → \n behaviour, and keep block boundaries as line breaks (a <div> after a <div> should not run its text together with the previous one, which is worth checking separately — it may already be wrong).
  • Honour white-space: pre, pre-wrap and pre-line where a rule sets it. getComputedStyle resolves stylesheet rules now (#1597), so the value is reachable; before that it could only have read an inline style.

Note that innerText currently reads el.style.getPropertyValue('display') directly rather than going through getComputedStyle, so a display: none from a stylesheet is not excluded — the visibility half has the same gap #1600 had. Worth fixing in the same pass, and isRendered() in src/aria/visibility.ts already does this walk correctly and could be shared.

Acceptance

  • The reproduction above reports 'Save changes more'.
  • Indented markup gives the same innerText as the equivalent single-line markup, asserted side by side.
  • A display: none set by a stylesheet rule is excluded, not only an inline one.
  • white-space: pre preserves runs.
  • textContent is unchanged, asserted, since it is correct as it stands.
  • locator.allInnerTexts() and locator.innerText() keep agreeing with each other afterwards — they agree today and a test pins it (test/locator-evaluate.test.ts).

Found while adding allInnerTexts() (#1607). Normalising there was the obvious shortcut and the wrong one: it would have made allInnerTexts() disagree with innerText() about the same element, which is a worse bug than the imprecision. The fix belongs here instead.

Sign in to comment on this issue.