fix(web): reserve one scrollbar gutter across the composer's text layers

Second review round escalated the wrap-width divergence from a separate
concern to a defect in this fix's own premise, and it is right.

Only .input scrolls, so only .input loses content width to a scrollbar that
consumes layout space — what Windows and Firefox draw, and what the theme's
global `::-webkit-scrollbar` width makes chromium treat as occupying space.
A narrower .input wraps a long soft-wrapped draft onto more lines, so it
grows taller, its scroll maximum exceeds the backdrop's, and the mirrored
offset clamps below the caret. That is the same failure the trailing-line
sentinel fixes, in the same direction, so deferring it would have shipped a
fix that does not hold where users run a classic scrollbar.

My first attempt to reproduce it found nothing and was wrong: the probe
content was not wrap-sensitive. With varied-length words the effect is
plain — the same draft laid out at 8px-apart widths differs by 2 to 5
lines, while at equal widths a textarea and a div agree exactly.

The three layers now reserve the gutter together, in the shared metrics
block that already exists to keep them symmetric. `overflow: hidden` is
still a scroll container, so the non-scrolling layers honour it: 8px is
reserved on each, measured. The cost is a text column 8px narrower on every
platform, which is the price of one geometry rather than a per-platform one.

The browser scenario asserts the premise directly — equal wrap widths, and a
reserved band greater than zero on each layer. The band is what stops the
assertion being vacuous: the widths would also match with no reservation at
all on this engine's overlay scrollbar, and it is the reservation, not the
match, that carries the guarantee to a platform whose scrollbar takes real
width. Removing the declaration fails it with `expected 0 to be greater
than 0`, and fails the golden with it.

Also from the same round, three comment corrections: the e2e file header no
longer describes the deleted layout effect, the measurement guard no longer
claims the backdrop holds exactly one text node (the sentinel makes a
second), and the sentinel comment now carries the one-sidedness argument
that also settles the ghost hint — the mirror only fails when the backdrop
is SHORTER, and the hint can only add content, never remove a line box.
This commit is contained in:
creatixchu
2026-07-31 12:39:52 +08:00
parent f8ef2cf36b
commit 2143361195
7 changed files with 98 additions and 12 deletions

View File

@@ -13,7 +13,16 @@
// So past the cap the textarea scrolled and the words did not: the caret walked
// off the bottom of a block of text frozen at line 1, and no gesture — wheel,
// drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
// backdrop on every textarea `scroll` and after every committed draft.
// backdrop on every textarea `scroll`, which is the one event every way of
// moving the box ends in.
//
// Mirroring an offset is only correct while both layers can reach it, so two
// pieces of geometry are asserted here alongside the visible outcome: the
// backdrop's trailing-line sentinel (a textarea reserves a line box for the
// caret after a final newline; `pre-wrap` collapses one) and the scrollbar
// gutter reserved on all three layers (only the textarea scrolls, so only it
// would lose wrap width to a space-consuming scrollbar). Either one breaks the
// extent equality, and an unreachable offset clamps the glyphs below the caret.
//
// Only a real engine can show this. Scrolling is layout: jsdom reports
// `scrollHeight === clientHeight` for every element and never scrolls one, so
@@ -95,6 +104,19 @@ interface ComposerMetrics {
inputMax: number
/** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
backdropMax: number
/** Content width the textarea wraps at. */
inputWrapWidth: number
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
backdropWrapWidth: number
/**
* Width each layer holds back for a scrollbar. Non-zero is the proof the
* reservation is in force: were it absent, both would read 0 here and stay
* equal on this engine's overlay scrollbar while diverging on a platform
* whose scrollbar consumes layout width.
*/
inputGutter: number
/** The same reservation on the clipped layer, which never draws a scrollbar. */
backdropGutter: number
}
/**
@@ -109,10 +131,12 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
const box = input.getBoundingClientRect()
// The draft carries no chips or claim token, so the backdrop holds exactly
// one text node — the plain-text arm of the decoration walk.
// The draft carries no chips or claim token, so the decoration walk emits it
// as one text node — the backdrop's first, ahead of the trailing-line
// sentinel React renders as a second one. Both markers live in that first
// node, which is what the Range below needs.
const text = backdrop.firstChild
if (!(text instanceof Text)) throw new Error('backdrop is not a single plain text node')
if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
const offsetOf = (marker: string): number => {
const at = text.data.indexOf(marker)
if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
@@ -136,6 +160,10 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
return {
inputMax,
backdropMax,
inputWrapWidth: input.clientWidth,
backdropWrapWidth: backdrop.clientWidth,
inputGutter: Math.round(input.getBoundingClientRect().width - input.clientWidth),
backdropGutter: Math.round(backdrop.getBoundingClientRect().width - backdrop.clientWidth),
overflows: input.scrollHeight > input.clientHeight,
clientHeight: input.clientHeight,
visibleLines: Math.floor(input.clientHeight / lineHeight),
@@ -170,6 +198,8 @@ function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingN
`- draft overflows the capped box: ${String(top.overflows)}`,
`- visible lines: ${String(top.visibleLines)}`,
`- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
`- both layers wrap at one width: ${String(top.inputWrapWidth === top.backdropWrapWidth)}`,
`- scrollbar gutter reserved on each layer: ${String(top.inputGutter)}px / ${String(top.backdropGutter)}px`,
`- textarea scroll offset: ${String(top.inputScrollTop)}px`,
`- glyph layer tracks it: ${String(top.layersAgree)}`,
`- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
@@ -239,6 +269,25 @@ describe('web e2e: composer draft scrolling', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('reserves one scrollbar gutter on every text layer, so they wrap at one width', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-gutter'))
// The premise under the mirror. Only the textarea scrolls, so only it would
// lose width to a space-consuming scrollbar — and a narrower textarea wraps
// a long draft onto more lines than the backdrop, ending up taller, with a
// larger maximum, which makes the mirrored offset clamp below the caret.
// Measured on a standalone harness: an 8px width difference moves a
// wrap-sensitive draft by 2 to 5 lines.
const metrics = await measureComposer(page)
expect(metrics.inputWrapWidth).toBe(metrics.backdropWrapWidth)
// Not a tautology on this engine: the widths would also match with no
// reservation at all, because headless chromium draws an overlay scrollbar.
// The reserved band is what distinguishes the two states, and it is what
// carries the guarantee to a platform whose scrollbar takes real width.
expect(metrics.inputGutter).toBeGreaterThan(0)
expect(metrics.backdropGutter).toBe(metrics.inputGutter)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
const input = page.locator('textarea:enabled').first()

View File

@@ -5,6 +5,8 @@
- draft overflows the capped box: true
- visible lines: 14
- both layers share one scroll extent: true
- both layers wrap at one width: true
- scrollbar gutter reserved on each layer: 8px / 8px
- textarea scroll offset: 0px
- glyph layer tracks it: true
- first draft line is on screen: true