fix(web): drop the redundant second mirror

The first version coupled the layers from two places: a `scroll` listener
and a layout effect keyed on the committed draft. Mutation-testing each
hook alone against the built client shows the effect never fires the only
assignment that matters — with just the layout effect disabled the browser
scenario stays green, while disabling just the listener fails it.

Both premises behind the effect were wrong. Typing scrolls the caret into
view, which is an ordinary `scroll`. A draft that shrinks past the current
offset clamps both layers to the same maximum, because their extents are
equal — measured in chromium at 964/964, 1012/1012, 844/844 and 820/820 for
plain, soft-wrapped, unbreakable-run and highlighted drafts — and the
textarea's clamp fires `scroll` too.

The hazard the effect was imagined to cover does not exist either: React
replacing every child of the backdrop when the decoration set changes shape
preserves `scrollTop` (measured: 300 stays 300 through a full child
replacement), and the only replacement that zeroes it shrinks the content
below the offset, which is the clamp case already covered.

The e2e's edit case survives, retitled to say what it actually pins: that
typing is not a separate case needing its own mirror. The unit spec now
asserts the backdrop tracks a second move back to the top, which a one-shot
mirror would fail.
This commit is contained in:
creatixchu
2026-07-31 12:02:38 +08:00
parent a7b7066267
commit 07448093c5
6 changed files with 35 additions and 40 deletions

View File

@@ -6,7 +6,7 @@
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useLayoutEffect, useRef } from 'react'
import { useEffect, useRef } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -104,7 +104,10 @@ export function InputBar({
// scroll — the backdrop paints every visible glyph (the textarea's own text
// is transparent) but is clipped, not scrolled, so it does not follow the
// textarea on its own: without this mirror a draft past the cap moves the
// caret while the words stay frozen in place.
// caret while the words stay frozen in place. Every way the box moves ends
// in a `scroll` event, edits included (the caret is scrolled into view), and
// the layers share an extent, so a draft that shrinks past the offset clamps
// both to the same maximum — one listener covers the coupling.
useEffect(() => {
const el = inputRef.current
if (el === null) return
@@ -129,16 +132,6 @@ export function InputBar({
}
}, [])
// Draft edits reflow both layers without necessarily moving the textarea
// (no scroll event fires when the caret stays in view), and a shrinking
// draft clamps each layer independently. Re-mirror after every committed
// draft so the glyphs never lag the caret by an edit.
useLayoutEffect(() => {
const el = inputRef.current
const backdropEl = backdropRef.current
if (el !== null && backdropEl !== null) backdropEl.scrollTop = el.scrollTop
}, [draft])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
// Absent machine (no session): the textarea is disabled so events cannot
// fire; the guard narrows the faces for the paths below.

View File

@@ -297,11 +297,11 @@ describe('running and lock semantics (queue cut 1)', () => {
textarea.scrollTop = 120
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(120)
// Editing re-mirrors without a scroll event (the caret can stay in view).
backdrop.scrollTop = 0
textarea.scrollTop = 96
fireEvent.change(textarea, { target: { value: 'line\n'.repeat(39) } })
expect(backdrop.scrollTop).toBe(96)
// Every later move tracks too, including back to the top — a one-shot
// mirror would leave the glyphs parked at the first offset it saw.
textarea.scrollTop = 0
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(0)
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {