mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-19 10:02:16 +00:00
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:
parent
99f2a9503c
commit
3143f79b8f
1 changed files with 52 additions and 6 deletions
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue