perf(desktop): cut FadeText forced layouts during streaming
The slowest user-felt path is typing into the composer while the assistant is streaming. Profile (scripts/profile-under-stream.mjs): FadeText measureOverflow self time: 35.8 ms → 18.1 ms (-50%) total active CPU during 7s window: ~150 ms → ~50 ms Two changes in src/components/ui/fade-text.tsx: 1. Drop the `useEffect([children])` that re-ran `measureOverflow` (reads scrollWidth + clientWidth — forced layout) on every parent re-render. `useResizeObserver` already fires the same callback on mount and whenever the host span's box size changes; that covers the only case where overflow state can legitimately change. The previous explicit useEffect was a forced-layout flush on every parent render, which during streaming meant every token tick. 2. Wrap the component in `memo` with a custom comparator that short-circuits the entire render when scalar string `children` and the className/fadeWidth/style props are unchanged. The hot path was tool-fallback's title chips being re-rendered by parent streaming updates even though their text was stable; memo+ comparator skips that. Also adds two harness scripts under apps/desktop/scripts/: - latency-under-stream.mjs (key→paint latency while a turn streams) - profile-under-stream.mjs (CPU profile while a turn streams) Updates profile-typing-lag.md with the streaming numbers and confirms the Enter→paint submit path is already fast (≤320ms on the populated session; the 2s "stall after Enter" the user noticed once was a one-time cold-start, not reproducible at the UI layer). I'd guess the felt jank in real use is fast-burst typing during a long-form streaming reply (code blocks + markdown lists multiply the per-token render cost). The CPU savings here scale linearly with token volume.
This commit is contained in:
@@ -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,23 @@ 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.
|
||||
*
|
||||
* `memo` with a custom comparator skips re-renders entirely when the parent
|
||||
* passed the same scalar `children` (e.g. a tool title string that didn't
|
||||
* change between streaming frames). This matters during assistant streaming,
|
||||
* where parents re-render on every token; without the memo+comparator,
|
||||
* tool-fallback's title FadeTexts re-rendered for every token even though
|
||||
* the title text was unchanged, and the `useResizeObserver` callback paid
|
||||
* the `scrollWidth`/`clientWidth` cost (forced layout) on each one.
|
||||
*
|
||||
* The internal `useResizeObserver` fires the measure callback once on mount
|
||||
* and whenever the host span's size changes; that covers initial render and
|
||||
* any container resize. The previous explicit `useEffect([children, ...])`
|
||||
* is redundant in that picture — RO already handles the only case where
|
||||
* overflow state can legitimately change (host size changes) — and was the
|
||||
* cause of the per-token forced-layout flushes.
|
||||
*/
|
||||
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)
|
||||
|
||||
@@ -34,15 +49,13 @@ export function FadeText({ children, className, fadeWidth = '3rem', style, ...re
|
||||
return
|
||||
}
|
||||
|
||||
setOverflowing(el.scrollWidth - el.clientWidth > 1)
|
||||
const overflow = el.scrollWidth - el.clientWidth > 1
|
||||
|
||||
setOverflowing(prev => (prev === overflow ? prev : overflow))
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureOverflow, ref)
|
||||
|
||||
useEffect(() => {
|
||||
measureOverflow()
|
||||
}, [children, measureOverflow])
|
||||
|
||||
const maskStyle: CSSProperties = overflowing
|
||||
? {
|
||||
maskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`,
|
||||
@@ -62,3 +75,27 @@ export function FadeText({ children, className, fadeWidth = '3rem', style, ...re
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function arePropsEqual(prev: FadeTextProps, next: FadeTextProps): boolean {
|
||||
// Cheap scalar-children short-circuit — the hot path during streaming is
|
||||
// re-rendering FadeText with the same string children every token tick.
|
||||
// For non-string children we skip the optimization and fall through to
|
||||
// React's default referential check (returning false re-renders, but
|
||||
// crucially the inner `useResizeObserver` is still the only thing that
|
||||
// can trigger a forced-layout pass).
|
||||
if (prev.children !== next.children) {
|
||||
if (typeof prev.children !== 'string' || typeof next.children !== 'string') {
|
||||
return false
|
||||
}
|
||||
if (prev.children !== next.children) return false
|
||||
}
|
||||
|
||||
return (
|
||||
prev.className === next.className &&
|
||||
prev.fadeWidth === next.fadeWidth &&
|
||||
prev.style === next.style
|
||||
)
|
||||
}
|
||||
|
||||
export const FadeText = memo(FadeTextImpl, arePropsEqual)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user