perf(desktop): memo FadeText so it skips re-renders when text unchanged

FadeText is used 110+ times inside `tool-fallback.tsx` on a tool-heavy
thread. During streaming each parent re-render previously triggered the
component's `useEffect([children])`, which forced a `scrollWidth` layout
read even when the title text was unchanged. The `useResizeObserver` was
already covering the genuine resize case, so that effect was strictly
redundant work.

Drops the effect and wraps the component in `React.memo` with a custom
comparator that field-compares `className`, `fadeWidth`, and `style`,
plus identity-compares `children` (scalar fast-path; correct for JSX
nodes too since a new node should force a re-render).

Verified via temporary render counter on the 34 MB
`session_20260514_215353_fe0ac8` thread (110 FadeText instances): a
2 s synthetic stream went from ~11k FadeText render calls to 122 —
roughly one render per truly-new instance instead of one per parent
commit per instance.

Doesn't move the longtask needle on its own (Streamdown's markdown
re-parse dwarfs it) but eliminates a steady CPU floor and a class of
forced layouts during streaming. Profile-typing-lag.md documents the
full investigation, including the remaining Streamdown cost as the
real source of the perceived "5 fps moment" hitches.
This commit is contained in:
Brooklyn Nicholson 2026-05-21 19:38:40 -05:00
parent 99f2a9503c
commit 3143f79b8f

View file

@ -1,5 +1,5 @@
import type { ComponentProps, CSSProperties } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useRef, useState } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { cn } from '@/lib/utils'
@ -22,8 +22,15 @@ interface FadeTextProps extends Omit<ComponentProps<'span'>, 'children'> {
* background is no need to know the surface color, no after-pseudo overlap.
* The mask is only applied when the text is actually overflowing, so short
* strings render as plain text without an unnecessary gradient on their tail.
*
* Layout reads (`el.scrollWidth`) are forced reflows. To avoid measuring
* once per parent re-render which during streaming happens on every token
* we only re-measure when the ResizeObserver fires (real size changes), not
* on every `children` reference change. Wrapped in `memo` with a custom
* comparator so scalar-string children skip re-render entirely when the text
* is unchanged but the parent re-rendered.
*/
export function FadeText({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) {
function FadeTextImpl({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) {
const ref = useRef<HTMLSpanElement>(null)
const [overflowing, setOverflowing] = useState(false)
@ -39,10 +46,6 @@ export function FadeText({ children, className, fadeWidth = '3rem', style, ...re
useResizeObserver(measureOverflow, ref)
useEffect(() => {
measureOverflow()
}, [children, measureOverflow])
const maskStyle: CSSProperties = overflowing
? {
maskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`,
@ -62,3 +65,46 @@ export function FadeText({ children, className, fadeWidth = '3rem', style, ...re
</span>
)
}
function styleEqual(a: CSSProperties | undefined, b: CSSProperties | undefined) {
if (a === b) {
return true
}
if (!a || !b) {
return false
}
const aKeys = Object.keys(a)
if (aKeys.length !== Object.keys(b).length) {
return false
}
for (const k of aKeys) {
if ((a as Record<string, unknown>)[k] !== (b as Record<string, unknown>)[k]) {
return false
}
}
return true
}
export const FadeText = memo(FadeTextImpl, (prev, next) => {
if (prev.className !== next.className) {
return false
}
if (prev.fadeWidth !== next.fadeWidth) {
return false
}
if (!styleEqual(prev.style, next.style)) {
return false
}
// Cheap path: the common case is a scalar string/number child. Identity
// comparison is correct for any other element type (a new JSX node should
// force a re-render).
return prev.children === next.children
})