Files
orca/mobile/src/session/mobile-native-chat-message-text.ts
T
Brennan Benson 80246238fc fix(mobile): reset recycled paragraph layout before reuse (#16692)
* fix(mobile): quantize chat pinch font scale so a zoom stops re-measuring the list every frame

A user bubble on a 390pt iPhone painted five lines inside a frame that
reserved six, with the last painted line cut through a glyph at the content
edge and "no longer needed." gone.

The paint is React Native's: a `<Text>` with no `numberOfLines` gets a text
container whose `lineBreakMode` is `NSLineBreakByClipping`
(RCTTextLayoutManager.mm). Measure lays out into `{width, CGFLOAT_MAX}`, paint
lays out into the mounted content frame — so a frame one line short does not
re-wrap, it dumps the remainder onto the last fitting line and clips it, with
no ellipsis. Reproduced on-device against the real component with the message
text held constant, so five painted lines can only be truncation.

Two conditions are each necessary, and removing either makes it vanish over
~6000 measured bubble renders: a pooled `RCTParagraphComponentView` carrying a
shorter row's content frame (`prepareForRecycle` clears `state` but not
`_textView.layoutMetrics`), and whole-list re-measure churn while rows enter
and leave that pool.

The churn was ours. `renderItem` closes over `fontScale`, and the pinch handler
committed a new scale on every gesture frame, so one zoom drove hundreds of
full-list re-measures. The pinch is composed `Simultaneous` with the list's own
scroll, so a stray second finger during a scroll started that storm at scales
the user cannot see — matching the report, whose glyph metrics are `fontScale`
1.0 exactly.

`quantizeFontScale` snaps commits to a 5% grid. React bails out of a same-value
`setState`, so gesture noise now commits nothing and a full-range pinch commits
at most ~20 times. Under the churn that produced 67 defects in 6636 bubble
renders, the quantized build measured 0 in 6064 — with a forced-defect control
bubble flagged in 100% of frames of both runs to prove the detector was live.

This removes the trigger we own; it does not close the RN recycling window
itself. That needs a one-line reset in `prepareForRecycle`, which cannot land
here without refreshing the `patchedDependencies` hash under `mobile/`.

* fix(mobile): reset recycled paragraph layout before reuse
2026-08-26 19:17:31 -07:00

49 lines
2.0 KiB
TypeScript

import { isTextBlock, type NativeChatBlock } from '../../../src/shared/native-chat-types'
/** Concatenate a message's text blocks into a single copyable string. Tool
* calls/results and image refs are skipped — Copy is for the agent's prose. */
export function nativeChatMessageText(blocks: readonly NativeChatBlock[]): string {
return blocks
.filter(isTextBlock)
.map((b) => b.text)
.join('\n\n')
.trim()
}
/** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */
export const FONT_SCALE_MIN = 0.8
export const FONT_SCALE_MAX = 1.8
/** Clamp a proposed font scale into the supported range. */
export function clampFontScale(scale: number): number {
if (Number.isNaN(scale)) {
return 1
}
return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale))
}
/** Granularity a pinch is allowed to commit at. */
export const FONT_SCALE_STEP = 0.05
/** Snap a proposed font scale to the nearest committed step.
*
* Why quantize: the chat's `renderItem` closes over `fontScale`, so every
* distinct value re-renders and re-measures every message in the list. A raw
* pinch proposes a new scale on each gesture frame, so a single zoom drives
* hundreds of whole-list re-measures — and because the pinch is composed
* `Simultaneous` with the list's own scroll, an incidental second finger during
* a scroll starts that storm at scales indistinguishable from 1. Those
* re-measures run while rows are mounting and unmounting, which is when a
* recycled iOS paragraph view can repaint with the previous row's content
* frame and silently drop the tail of a long message. Snapping to steps keeps a
* full-range pinch to at most 20 commits and makes a near-neutral pinch commit
* nothing at all. */
export function quantizeFontScale(scale: number): number {
if (Number.isNaN(scale)) {
return 1
}
const stepped = Math.round(clampFontScale(scale) / FONT_SCALE_STEP) * FONT_SCALE_STEP
// Re-clamp: rounding can push the outermost step past the bound.
return clampFontScale(Number(stepped.toFixed(2)))
}