Skip to content

perf(chat): measure the composer's auto-size once per keystroke, not twice - #8632

Open
dpb1 wants to merge 2 commits into
kirodotdev:mainfrom
dpb1:perf/composer-resize-reflow-guard
Open

perf(chat): measure the composer's auto-size once per keystroke, not twice#8632
dpb1 wants to merge 2 commits into
kirodotdev:mainfrom
dpb1:perf/composer-resize-reflow-guard

Conversation

@dpb1

@dpb1 dpb1 commented Sep 5, 2026

Copy link
Copy Markdown

Problem / Motivation

applyHeight runs twice for every keystroke in the composer -- once from the
input handler, then again from the auto-size effect once the new value commits.
Both measured, and the second call's inputs were identical to the first's. Each
measurement costs a getComputedStyle on the live element, ~30 style writes onto
the off-screen twin, and a twin.scrollHeight read that forces the style recalc
those 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 WeakMap keyed by the textarea holds the inputs that produced its current
auto-sized height and the height they produced; the cached height is reused when
width, cap, placeholder and value all match.

placeholder is in the key because measuredContentHeight measures an empty
value as el.placeholder || '', so a placeholder swap changes the height.
value is last so no user text can forge the delimiter against the fields ahead
of it.

Only the measurement is elided, never the write -- the existing next !== prev
guard 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, counting
measurement passes through the single getComputedStyle(el) each pass makes:

  • one measurement per keystroke rather than one per call site
  • re-measures when content changes
  • re-measures when the box width changes at an unchanged value
  • restores the height after a manual-resize reset without measuring

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.

per keystroke without memo with memo
forced style recalculations 7.82 (7.78-7.90) 4.88 (4.87-4.88)
...attributed to the composer frame 7.77 4.86
Layout count 2.97 2.98

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

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable) -- N/A, no user-facing behaviour change
  • No secrets, credentials, or internal references in the diff

@dpb1
dpb1 requested a review from a team September 5, 2026 02:45
@dpb1
dpb1 requested a review from a team as a code owner September 5, 2026 02:45
@dpb1
dpb1 requested a review from smeyffret September 5, 2026 02:45
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5, fork) — ✅ PASS

UX-level review of 3547ccaca0349ea809fd596903949f254bf697a4 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

This is a performance-only change — memoizing the composer textarea's auto-size measurement in ChatInput.tsx plus a new test file. No new user-facing strings, no visual or layout changes, no flow changes; the height writes are preserved (including the drag-handle double-click reset path, which the tests pin). There's nothing for a UX lens to flag — correctness of the memo keying is the other reviewers' lane.

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

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — ✅ PASS

Design-level review of 3547ccaca0349ea809fd596903949f254bf697a4 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • The parked branch deletes the memo because "font metrics may have changed across the round-trip," but the manual-resize round-trip is the same shape and instead reuses the cache (with a test pinning zero measurements). One measurement per double-click reset is as cheap as one per unpark; treating both as re-measure events would remove the one path where the key's omission of font metrics (which the twin does copy) can reapply a stale height, and keep the invalidation rule to a single sentence.

[DESIGN-REVIEWED] 3547cca

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — ✅ no blocking findings

Reviewed 3547ccaca0349ea809fd596903949f254bf697a4 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 3547cca

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

Reviewed 3547ccaca0349ea809fd596903949f254bf697a4 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 3547cca

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — 🟡 CONCERNS

Premise-level review of 3547ccaca0349ea809fd596903949f254bf697a4 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

I've verified the diff's claims against the trusted base. Both call sites exist as described (handleInput at ChatInput.tsx:1944, the auto-size effect at :2848, both firing per keystroke on a controlled input), measuredContentHeight does one getComputedStyle plus ~30 twin style writes plus a scrollHeight read per pass, no existing memo mechanism covers this (the WeakMap-cache idiom is established elsewhere: searchableText.ts, cardDataKey.ts), and the other ~8 auto-grow textareas in the repo use the cheap height='auto' pattern, not the twin, so there are no unfixed siblings of this root cause. The one depth question: the memo makes the second call's measurement free, but the second call site itself is the nameable cause and it still runs (its caret-follow tail reads live layout), which the description never weighs.

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 ships

Intent: stop the composer measuring its own height twice per keystroke — a FIX.

  1. Typing costs one height measurement per keystroke, not two — justified, measured (7.82→4.88 forced recalcs).
  2. Resolved heights unchanged; double-click reset restores the exact prior height — justified, test-pinned.
  3. Returning from voice/dictation re-measures once, never reuses a cached height — undeclared, behavior parity with base.
  4. A per-textarea height cache lives as long as the element — declared; 1 consumer (applyHeight).
  5. Four new tests count measurement passes — declared, mutation-checked.

Watch

  • The problem exists because applyHeight has two per-keystroke call sites — grepped applyHeight: 2 callers, ChatInput.tsx:1944 (input handler) and :2848 (effect on [value, …]). Deleting the handler call and making the effect layout-timed removes the duplication itself — no WeakMap, no NUL-delimiter key, no park invalidation — and also stops the redundant call's caret-follow tail (scrollHeight/clientHeight reads at :372) running twice. The description ("the second call's inputs were identical to the first's") diagnoses this cause and then caches around it. The memo does earn one thing unification wouldn't: the reset case re-applies without any measurement. A human should decide whether that is worth the permanent cache-plus-invalidation concept in the most timing-annotated component in the tree.

Subtractions

  • Replace lastMeasured (ChatInput.tsx, new WeakMap) with the smaller shape above: drop the applyHeight call in handleInput (:1944) and run the one remaining call site before paint — accepting one measurement, instead of zero, on the double-click reset.

[FIRST-PRINCIPLES-REVIEWED] 3547cca

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
…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.
@dpb1
dpb1 force-pushed the perf/composer-resize-reflow-guard branch from 12948b3 to fa13d28 Compare September 5, 2026 03:33
@dpb1

dpb1 commented Sep 5, 2026

Copy link
Copy Markdown
Author

Tried it. Deleting the handleInput call and hoisting the auto-size effect to useLayoutEffect is genuinely shorter -- 35 lines fewer, byte-identical in length to base -- but it fails an existing test:

src/test/ChatInput.test.tsx > caret-follow scroll >
  snaps to the bottom when typing at the end so the caret stays visible
  AssertionError: expected 71 to be 289

Verified three ways: passes on origin/main, passes with the memo, fails with the subtraction. The caret-follow tail lives inside applyHeight, so removing the handleInput call also removes the caret snap on the input event itself -- the residual you named, already pinned upstream.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant