perf(ui): make the composer in-place measurement work on phone viewports - #552
Conversation
#543 sized the composer by measuring in place, which is only possible while the textarea reports `scrollHeight > clientHeight`. Below 768px `tailwind.css` clamps the body font to 13px, and at the default `line-height: 1.5` that is a 19.5px line box, so one line plus `py-2.5` came to 41.5px against the `min-h-[44px]` minimum. min-height then owned the box, the two metrics were equal on every keystroke, and the fast path never applied. That is worse than doing nothing: the probing read is wasted and the collapse forces a SECOND layout, where collapsing unconditionally forced one. Measured in the built app at 390px over 26 keystrokes: pre-#543 78 layouts, 99 recalcs, 7.95 ms layout, 17.05 ms style #543 as merged 77 layouts, 86 recalcs, 12.10 ms layout, 19.49 ms style this change 28 layouts, 38 recalcs, 5.58 ms layout, 3.85 ms style The recorded op trace shows the mechanism directly: as merged, every keystroke below 768px ran read -> write auto -> read -> write, against pre-#543's write auto -> read -> write. `leading-6` pins the line box to 24px, so one line clears the 44px minimum at every body font size and the in-place measurement applies everywhere. Resting composer height is unchanged (44px at every viewport); only multi-line drafts on phones get the roomier 24px line spacing. The pin missed this because both composer tests fixed the viewport at 1280x800, and `test.use({viewport})` overrides the project's device viewport — so running under mobile-chrome / mobile-safari varied the engine but not the layout regime. The test body now runs at 390x844 as well, and asserts `scrollHeight > clientHeight` before relying on it, so a configuration where the measurement cannot work fails loudly instead of passing vacuously. Refs #468
Self-review (first pass) — 2 lenses, run seriallyNot the gating review; independent reviewers run separately. Recording what I Lens 1 — code-first
Lens 2 — skeptical
The bound I could not remove, stated plainly: this works because the mobile Worth noting the failure stays safe in that case — the composer still sizes [AI-assisted - Claude] |
Follow-up to #543 (issue #468). Fixes a regression that #543 introduced on
phone-width viewports, found by independent review.
Problem
#543 sizes the composer by measuring it in place, which only works while the
textarea reports
scrollHeight > clientHeight. Below 768pxtailwind.cssclamps the body font to 13px; at the default
line-height: 1.5that is a19.5px line box, so one line plus
py-2.5came to 41.5px against themin-h-[44px]minimum. min-height then owned the box, the two metrics wereequal on every keystroke, and the fast path never applied.
That is not merely "no benefit" — it is worse than doing nothing. The probing
read is wasted and the collapse forces a second layout, where collapsing
unconditionally forced one. So #543 made its own target metric worse on the
weakest hardware.
Confirmed in the built app, not in a model of it. The recorded op trace per
keystroke at 390px:
CDP
Performance.getMetricsat 390x844 over 26 keystrokes:Note the honest detail:
LayoutCountbarely separates the first two rows,because frame-level layouts coalesce and hide the extra forced one. The op
trace is the precise evidence for the forced-layout claim;
LayoutDuration(+52% as merged) is the metric that shows the cost.
Break-even measured by sweeping viewports: the fast path fails at 390 / 393 /
414px and works from 600px up, matching the ~587px predicted from the font
clamp.
Approach
leading-6pins the line box to 24px, so one line of text plus padding clearsthe 44px minimum at every body font size and the in-place measurement
applies everywhere. Verified across 390 / 393 / 414 / 600 / 640 / 768 / 1280:
fastPath=true, zero writes and zero collapses over five keystrokes at all ofthem.
Resting composer height is unchanged at 44px at every viewport (min-height was
already producing 44px on phones), so there is no visual change at rest. The
only visual difference is that multi-line drafts on phones get 24px line
spacing instead of 19.5px — a 2-line draft goes 63.5px to 68px. Checked at
390x844 for horizontal overflow (none) and that the composer stays fully on
screen at the clamped maximum.
Alternatives considered: making the algorithm stateful so it could predict a
failing probe, or remembering the failure. Both add state that needs
invalidating on viewport change. Fixing the geometric inconsistency — a
composer whose natural height sits below its own minimum — is the smaller and
more durable change, and it makes the invariant unconditional rather than
configuration-dependent.
Testing
The pin missed this because both composer tests fixed the viewport at
1280x800, and
test.use({viewport})overrides the project's deviceviewport — so running under mobile-chrome / mobile-safari varied the engine
but not the layout regime. There was no composer-height coverage below 768px
at all.
scrollHeight > clientHeightafter priming, before relying on it,so a configuration where the measurement cannot work fails loudly instead
of passing vacuously. That guard is what makes the phone case a real
discriminator rather than a second copy of the desktop one.
Verified non-vacuous by rebuilding the wasm and re-running: with
leading-6removed, the phone test fails and the desktop test still passes —
Full Playwright suite on the rebased HEAD: 707 passed, 23 skipped, 0 failed.
The tests called out on #468 pass on all five projects:
textarea height resets after sending a multi-line message(#221) andedit container fits within viewport at narrow widths(#205).cargo fmt --checkclean, clippy adds nowarnings in the changed file,
cargo test -p river-ui792 passed.One flake appeared on an earlier (pre-rebase) full-suite run —
mobile-touch-ux"React from the kebab", failing in
waitForAppat app boot — and did not recuron the rebased run. It passed 5/5 in isolation. I am deliberately not
asserting a root cause: the machine was at load 56 on 16 cores from parallel
agent jobs, which would explain it, but #545's note on #538 records that this
exact "timeout under parallel load" theory was plausible and wrong before. With
trace: "on-first-retry"now in place from #545, the next occurrence willleave evidence worth reading.
Does
leading-6also dodge iOS Safari's zoom-on-focus?No, and it cannot — raised on review as unverified, so answering it rather than
leaving it open.
That behaviour keys off
font-size, notline-height: iOS Safari zooms thepage when a focused input's font size is under 16px.
leading-6sets onlyline-height, so it leaves the trigger condition exactly as it was. Measuredcomputed
font-sizeon the textarea, unchanged by this PR:clamp(13px, 2.5vw, 16px)reaches 16px at a 640px viewport, so any width below640px puts the composer under the threshold. If the zoom-on-focus happens today
it happened before this PR too, and it still will after.
Out of scope here, and I could not have verified it anyway: the harness has
no real iOS. Playwright's
mobile-safariemulates viewport and user agent, notthe native zoom heuristic, so a green run there would have been no evidence
either way. Fixing it means raising the composer's font to 16px on phones,
which is a deliberate typography decision affecting the mobile clamp, not a
side effect to smuggle into a perf PR.
Refs #468
[AI-assisted - Claude]