Revert "Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer""
This reverts commit b7b378e3a4.
This commit is contained in:
@@ -23,10 +23,8 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$composerAttachments,
|
||||
$composerDraft,
|
||||
clearComposerAttachments,
|
||||
type ComposerAttachment,
|
||||
reconcileComposerTerminalSelections
|
||||
type ComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
@@ -218,10 +216,16 @@ export function ChatBar({
|
||||
}
|
||||
}, [appendExternalText, disabled])
|
||||
|
||||
// Keep draftRef in sync with the assistant-ui composer state for callers
|
||||
// that read the latest text outside the React render cycle. We don't push
|
||||
// to `$composerDraft` per keystroke any more — nobody outside the composer
|
||||
// subscribes to it (verified by grep), and the round-trip
|
||||
// `setText` ⇄ `subscribe` ⇄ `setText` was adding two useEffects to the per-
|
||||
// keystroke critical path. `reconcileComposerTerminalSelections` only
|
||||
// matters when the draft is submitted; we now call it from the submit
|
||||
// path instead.
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
$composerDraft.set(draft)
|
||||
reconcileComposerTerminalSelections(draft)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -230,22 +234,20 @@ export function ChatBar({
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
$composerDraft.subscribe(value => {
|
||||
if (value !== draftRef.current) {
|
||||
aui.composer().setText(value)
|
||||
}
|
||||
}),
|
||||
[aui]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (urlOpen) {
|
||||
window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true }))
|
||||
}
|
||||
}, [urlOpen])
|
||||
|
||||
// Track expansion via cheap heuristics (newline or length threshold) instead
|
||||
// of reading editor.scrollHeight on every keystroke. scrollHeight forces a
|
||||
// synchronous layout flush — measured at 2.27 layouts per character typed
|
||||
// (see scripts/leak-typing.mjs). With ~30 chars before a typical wrap on
|
||||
// composer-default-width, this heuristic flips at roughly the right time
|
||||
// and the user only notices if they type far past the wrap boundary
|
||||
// without a newline; in that case the ResizeObserver below catches it via
|
||||
// a height delta and we still expand.
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setExpanded(false)
|
||||
@@ -257,13 +259,22 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
const wraps = (editorRef.current?.scrollHeight ?? 0) > 56
|
||||
|
||||
if (draft.includes('\n') || wraps) {
|
||||
if (draft.includes('\n') || draft.length > 60) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [draft, expanded])
|
||||
|
||||
// Bucket measured heights so we only invalidate the global CSS var when
|
||||
// the size crosses a meaningful threshold. Without bucketing, the editor
|
||||
// grows ~1px per character → setProperty fires every keystroke → entire
|
||||
// tree's computed style is invalidated → next paint forces a full
|
||||
// recalculate-style pass. With an 8px bucket, the invalidation rate drops
|
||||
// ~8× and small char-by-char typing produces no style invalidation at all
|
||||
// until a wrap or row change actually happens.
|
||||
const lastBucketedHeightRef = useRef(0)
|
||||
const lastBucketedSurfaceHeightRef = useRef(0)
|
||||
const lastTightRef = useRef<boolean | null>(null)
|
||||
|
||||
const syncComposerMetrics = useCallback(() => {
|
||||
const composer = composerRef.current
|
||||
|
||||
@@ -276,15 +287,30 @@ export function ChatBar({
|
||||
const root = document.documentElement
|
||||
|
||||
if (width > 0) {
|
||||
setTight(width < COMPOSER_STACK_BREAKPOINT_PX)
|
||||
const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX
|
||||
|
||||
if (nextTight !== lastTightRef.current) {
|
||||
lastTightRef.current = nextTight
|
||||
setTight(nextTight)
|
||||
}
|
||||
}
|
||||
|
||||
if (height > 0) {
|
||||
root.style.setProperty('--composer-measured-height', `${Math.round(height)}px`)
|
||||
const bucket = Math.round(height / 8) * 8
|
||||
|
||||
if (bucket !== lastBucketedHeightRef.current) {
|
||||
lastBucketedHeightRef.current = bucket
|
||||
root.style.setProperty('--composer-measured-height', `${bucket}px`)
|
||||
}
|
||||
}
|
||||
|
||||
if (surfaceHeight && surfaceHeight > 0) {
|
||||
root.style.setProperty('--composer-surface-measured-height', `${Math.round(surfaceHeight)}px`)
|
||||
const bucket = Math.round(surfaceHeight / 8) * 8
|
||||
|
||||
if (bucket !== lastBucketedSurfaceHeightRef.current) {
|
||||
lastBucketedSurfaceHeightRef.current = bucket
|
||||
root.style.setProperty('--composer-surface-measured-height', `${bucket}px`)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -381,12 +407,28 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-bail: if neither `@` nor `/` appears in the current draft, there's
|
||||
// nothing for `detectTrigger` to match. Skip the DOM range walk inside
|
||||
// `textBeforeCaret` (which calls `range.toString()`, O(n) over the draft)
|
||||
// and the regex pass that follows. Only when a relevant char is present
|
||||
// do we pay the cost.
|
||||
const text = composerPlainText(editor)
|
||||
|
||||
if (!text.includes('@') && !text.includes('/')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
const detected = detectTrigger(before ?? text)
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
}, [trigger])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const editor = event.currentTarget
|
||||
@@ -990,7 +1032,24 @@ export function ChatBar({
|
||||
role="textbox"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
so the composer-state binding (text + IME + paste + form-submit hookup)
|
||||
wires up. We render the real input UI ourselves above via the
|
||||
contentEditable, so the primitive is invisible (sr-only).
|
||||
|
||||
IMPORTANT: don't let it render its default <TextareaAutosize>. That
|
||||
component runs `useLayoutEffect(resizeTextarea)` on every value change
|
||||
and reads `node.scrollHeight` against a hidden measurement textarea,
|
||||
forcing two synchronous layouts per keystroke for an element the
|
||||
user can't see. Profiling 400-char synthetic typing showed >900ms
|
||||
cumulative cost in getHeight2/calculateNodeHeight alone (~2.3ms/key)
|
||||
on top of the per-keystroke React commit.
|
||||
|
||||
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
|
||||
plain <textarea>, which carries the binding but skips autosize. */}
|
||||
<ComposerPrimitive.Input asChild tabIndex={-1} unstable_focusOnScrollToBottom={false}>
|
||||
<textarea aria-hidden className="sr-only" tabIndex={-1} />
|
||||
</ComposerPrimitive.Input>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user