perf(chat): measure the composer's auto-size once per keystroke, not twice - #8632
perf(chat): measure the composer's auto-size once per keystroke, not twice#8632dpb1 wants to merge 2 commits into
Conversation
UX Review (Fable 5, fork) — ✅ PASSUX-level review of This is a performance-only change — memoizing the composer textarea's auto-size measurement in UX-Verdict: PASS Invisible-by-design perf memo: no strings, layout, or flow changes; height behavior (including manual-resize reset) is preserved and test-pinned. [UX-REVIEWED] 3547cca |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS A measured redundant per-keystroke measurement, elided behind a memo whose key and invalidation the author reasoned through and mutation-tested; sound and proportionate. Suggestions
[DESIGN-REVIEWED] 3547cca |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of I've verified the diff's claims against the trusted base. Both call sites exist as described ( First-Principles-Verdict: CONCERNS The memo cheapens the duplicate call; the duplicate call site is the cause, and deleting it was never weighed against caching over it. What this change shipsIntent: stop the composer measuring its own height twice per keystroke — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 3547cca |
…twice `applyHeight` runs twice per keystroke -- the input handler, then the auto-size effect once the new `value` commits -- and both calls measured. The second call's inputs are identical to the first's, so it re-derived a known height: a `getComputedStyle` on the live element, ~30 style writes onto the off-screen twin, and a `twin.scrollHeight` read forcing the recalc those writes dirtied. Memoize the height against its inputs in a `WeakMap` keyed by the element. `placeholder` is in the key because an empty value is measured as `el.placeholder || ''`; `value` is last so no user text can forge the delimiter. Only the MEASUREMENT is elided -- `next !== prev` still runs on a memo hit. That is what lets the drag handle's double-click reset re-apply the cached height without measuring, and why the memo needs no validation against `el.style.height`. Eliding the write too strands the box at no height. Two builds of this commit differing only by this change, 3 runs of ~70 keystrokes into an idle composer: forced style recalculations fall 7.82 -> 4.88 per keystroke. Layout is unchanged at 2.97 vs 2.98, and resolved heights are unchanged. Tests count measurement passes and pin the re-measure cases -- content changed, width changed at an unchanged value, manual-resize reset. With the memo disabled all four fail at 2 measurements per keystroke. The parked early-return also drops the memo entry: unparking re-runs the effect at an unchanged value, so a cached height would be re-applied without measuring across a round-trip that may have moved font metrics.
12948b3 to
fa13d28
Compare
|
Tried it. Deleting the Verified three ways: passes on |
Problem / Motivation
applyHeightruns twice for every keystroke in the composer -- once from theinput handler, then again from the auto-size effect once the new
valuecommits.Both measured, and the second call's inputs were identical to the first's. Each
measurement costs a
getComputedStyleon the live element, ~30 style writes ontothe off-screen twin, and a
twin.scrollHeightread that forces the style recalcthose writes dirtied.
Why it matters
Paid on every keystroke anyone types, for a height the previous call had already
computed.
What changed (motivation → approach → change)
A
WeakMapkeyed by the textarea holds the inputs that produced its currentauto-sized height and the height they produced; the cached height is reused when
width, cap, placeholder and value all match.
placeholderis in the key becausemeasuredContentHeightmeasures an emptyvalue as
el.placeholder || '', so a placeholder swap changes the height.valueis last so no user text can forge the delimiter against the fields aheadof it.
Only the measurement is elided, never the write -- the existing
next !== prevguard still runs on a memo hit. That is load-bearing: the drag handle's
double-click reset clears the inline height while the value stays put, so the
cached height is still correct and only needs re-applying. Eliding the write
leaves the box with no height at all. The same guard is why nothing validates the
memo against
el.style.height.Tests
New
website/src/test/ChatInput.autoSizeMemo.test.tsx, 4 cases, countingmeasurement passes through the single
getComputedStyle(el)each pass makes:Mutation-checked: with the memo disabled all four fail at 2 measurements per
keystroke; with the memo also skipping the write, the reset case fails with the
box stranded at no height.
Manual verification
Two builds of this commit differing only by this change, served locally and typed
into an idle composer -- ~70 keystrokes, 3 runs per build, sidebar expanded, empty
transcript, Chrome DevTools performance traces.
Layout is unchanged, as expected for a change that elides a style measurement and
not a layout pass. Resolved heights are unchanged. No latency claim is made: each
build's runs were captured as a block, so session drift is not separable from the
variant.
Screenshots / video
Why no screenshot: the composer resolves to the same heights as before -- the
change elides a redundant measurement, it does not alter the measured result.
Height equality is pinned by the tests above (the manual-resize-reset case asserts
the exact prior pixel height is restored).
Related Issues
N/A -- no filed issue; found while profiling composer typing.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)