mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
perf(native-chat): stop re-rendering composer controls on every keystroke (#35913)
* perf(native-chat): stop re-rendering composer controls on every keystroke (#35836) Each keystroke (and each IME compositionupdate) re-rendered the composer's action bar and TipTap editor, which re-applied editor options and re-decorated the view, even though neither had changed. The draft store also gave each write a fresh image-row array, which invalidated every image consumer. Memoize NativeChatComposerActions and NativeChatPromptEditor (initialValue only seeds the editor at mount), pass them stable handlers that call the latest committed closure, and keep the stored image row's identity when it's unchanged. Draft-store writes, onDraftChange and every consumer still see each edit synchronously, so composition semantics are unchanged. CDP-simulated pinyin IME, 4x CPU throttle, median per compositionupdate: 14.5ms -> 6.8ms (PTY composer), 14.3ms -> 7.2ms (structured composer). * chore(native-chat): note the prompt editor memo's stable-props contract
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { memo } from 'react'
|
||||
import { ArrowUp, CircleAlert, Mic, Plus, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface
|
||||
@@ -45,7 +47,8 @@ export type NativeChatComposerActionsProps = {
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export function NativeChatComposerActions({
|
||||
// Memoized: typing re-renders the composer per keystroke, and these controls rarely change with it.
|
||||
export const NativeChatComposerActions = memo(function NativeChatComposerActions({
|
||||
editSession,
|
||||
attachDisabled,
|
||||
dictationDisabled,
|
||||
@@ -70,6 +73,7 @@ export function NativeChatComposerActions({
|
||||
focusComposer,
|
||||
agent
|
||||
}: NativeChatComposerActionsProps): React.JSX.Element {
|
||||
useUiLanguageRevision()
|
||||
const stops = isWorking
|
||||
const handleCriticalAction = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
// Ignore a double-click’s second press after Send becomes Stop so it cannot cancel the newly started turn.
|
||||
@@ -223,7 +227,7 @@ export function NativeChatComposerActions({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function DraftNotSavedIcon({ shown }: { shown: boolean }): React.JSX.Element | null {
|
||||
if (!shown) {
|
||||
|
||||
@@ -28,6 +28,9 @@ import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPre
|
||||
import type { NativeChatComposerGoalMode } from './use-native-chat-composer-submit'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useNativeChatComposerDraftUnsaved } from './use-native-chat-draft-unsaved'
|
||||
import { useLatestHandler } from '@/lib/use-latest-handler'
|
||||
|
||||
function noop(): void {}
|
||||
|
||||
export type NativeChatComposerFieldProps = {
|
||||
editSession?: { cancel: () => void }
|
||||
@@ -180,6 +183,59 @@ export function NativeChatComposerField({
|
||||
// Remember the editor’s last draft because chip node views can flushSync a render before its onChange publishes.
|
||||
const editorDraftRef = useRef<string | null>(null)
|
||||
|
||||
const settleImeValue = (element: NativeChatComposerInput): void => {
|
||||
if (droppedDraftClearRef.current) {
|
||||
droppedDraftClearRef.current = false
|
||||
element.value = imeComposedSegment(compositionBaseRef.current, element.value)
|
||||
}
|
||||
onImeSettled(element)
|
||||
}
|
||||
|
||||
// Why: stable identities let the memoized editor and actions skip the per-keystroke render (#35836).
|
||||
const editorChange = useLatestHandler((input: NativeChatComposerInput) => {
|
||||
const value = input.value
|
||||
editorDraftRef.current = value
|
||||
onDraftChange(value, input)
|
||||
})
|
||||
const editorSelect = useLatestHandler(onTextareaSelect)
|
||||
const editorKeyDownCapture = useLatestHandler((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!imeEnterGesture.ownsKeyDown(event)) {
|
||||
onKeyDown(event)
|
||||
}
|
||||
})
|
||||
const editorKeyUp = useLatestHandler(imeEnterGesture.onKeyUp)
|
||||
const editorBlur = useLatestHandler(() => {
|
||||
const compositionWasActive = imeEnterGesture.isComposing()
|
||||
imeEnterGesture.reset()
|
||||
if (compositionWasActive) {
|
||||
settleImeValue(textareaRef.current!)
|
||||
}
|
||||
})
|
||||
const editorCompositionStart = useLatestHandler(() => {
|
||||
if (imeEnterGesture.isComposing()) {
|
||||
imeEnterGesture.setComposing(false)
|
||||
// Settle the interrupted composition before the new one takes browser ownership.
|
||||
flushSync(() => settleImeValue(textareaRef.current!))
|
||||
}
|
||||
compositionBaseRef.current = textareaRef.current!.value
|
||||
imeEnterGesture.setComposing(true)
|
||||
})
|
||||
const editorCompositionEnd = useLatestHandler((event: React.CompositionEvent<HTMLDivElement>) => {
|
||||
const compositionWasActive = imeEnterGesture.isComposing()
|
||||
imeEnterGesture.setComposing(false, event.data)
|
||||
if (compositionWasActive) {
|
||||
settleImeValue(textareaRef.current!)
|
||||
}
|
||||
})
|
||||
const editorPaste = useLatestHandler(onPaste)
|
||||
const attach = useLatestHandler(onAttach)
|
||||
const dictationToggle = useLatestHandler(onDictationToggle)
|
||||
const dictationHoldStart = useLatestHandler(onDictationHoldStart)
|
||||
const dictationHoldEnd = useLatestHandler(onDictationHoldEnd)
|
||||
const send = useLatestHandler(onSend)
|
||||
const stop = useLatestHandler(onStop ?? noop)
|
||||
const exitGoalMode = useLatestHandler(goalMode?.exit ?? noop)
|
||||
|
||||
// Browser owns the provisional value; React synchronizes drafts only between IME sessions.
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
@@ -198,14 +254,6 @@ export function NativeChatComposerField({
|
||||
editorDraftRef.current = draft
|
||||
}, [draft, imeEnterGesture, textareaRef])
|
||||
|
||||
const settleImeValue = (element: NativeChatComposerInput): void => {
|
||||
if (droppedDraftClearRef.current) {
|
||||
droppedDraftClearRef.current = false
|
||||
element.value = imeComposedSegment(compositionBaseRef.current, element.value)
|
||||
}
|
||||
onImeSettled(element)
|
||||
}
|
||||
|
||||
// Stable so the memoized option pickers keep their identity.
|
||||
const focusComposer = useCallback(() => textareaRef.current?.focus(), [textareaRef])
|
||||
|
||||
@@ -274,42 +322,14 @@ export function NativeChatComposerField({
|
||||
inputRef={textareaRef}
|
||||
initialValue={draft}
|
||||
disabled={disabled}
|
||||
onChange={(input) => {
|
||||
const value = input.value
|
||||
editorDraftRef.current = value
|
||||
onDraftChange(value, input)
|
||||
}}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (!imeEnterGesture.ownsKeyDown(event)) {
|
||||
onKeyDown(event)
|
||||
}
|
||||
}}
|
||||
onKeyUp={imeEnterGesture.onKeyUp}
|
||||
onBlur={() => {
|
||||
const compositionWasActive = imeEnterGesture.isComposing()
|
||||
imeEnterGesture.reset()
|
||||
if (compositionWasActive) {
|
||||
settleImeValue(textareaRef.current!)
|
||||
}
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
if (imeEnterGesture.isComposing()) {
|
||||
imeEnterGesture.setComposing(false)
|
||||
// Settle the interrupted composition before the new one takes browser ownership.
|
||||
flushSync(() => settleImeValue(textareaRef.current!))
|
||||
}
|
||||
compositionBaseRef.current = textareaRef.current!.value
|
||||
imeEnterGesture.setComposing(true)
|
||||
}}
|
||||
onCompositionEnd={(event) => {
|
||||
const compositionWasActive = imeEnterGesture.isComposing()
|
||||
imeEnterGesture.setComposing(false, event.data)
|
||||
if (compositionWasActive) {
|
||||
settleImeValue(textareaRef.current!)
|
||||
}
|
||||
}}
|
||||
onPasteCapture={onPaste}
|
||||
onSelect={onTextareaSelect}
|
||||
onChange={editorChange}
|
||||
onKeyDownCapture={editorKeyDownCapture}
|
||||
onKeyUp={editorKeyUp}
|
||||
onBlur={editorBlur}
|
||||
onCompositionStart={editorCompositionStart}
|
||||
onCompositionEnd={editorCompositionEnd}
|
||||
onPasteCapture={editorPaste}
|
||||
onSelect={editorSelect}
|
||||
aria-expanded={autocomplete.mode !== 'none'}
|
||||
aria-controls={autocomplete.mode !== 'none' ? pickerListboxId : undefined}
|
||||
aria-activedescendant={
|
||||
@@ -347,18 +367,18 @@ export function NativeChatComposerField({
|
||||
isStopping={isStopping}
|
||||
isDictating={isDictating}
|
||||
isDictationHoldMode={isDictationHoldMode}
|
||||
onAttach={onAttach}
|
||||
onDictationToggle={onDictationToggle}
|
||||
onDictationHoldStart={onDictationHoldStart}
|
||||
onDictationHoldEnd={onDictationHoldEnd}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
onAttach={attach}
|
||||
onDictationToggle={dictationToggle}
|
||||
onDictationHoldStart={dictationHoldStart}
|
||||
onDictationHoldEnd={dictationHoldEnd}
|
||||
onSend={send}
|
||||
onStop={onStop ? stop : undefined}
|
||||
sessionOptionsSurface={sessionOptionsSurface}
|
||||
sessionOptionsSnapshot={sessionOptionsSnapshot}
|
||||
contextUsage={contextUsage}
|
||||
sessionOptionsPickerRequest={sessionOptionsPickerRequest}
|
||||
focusComposer={focusComposer}
|
||||
onExitGoalMode={goalMode?.active ? goalMode.exit : undefined}
|
||||
onExitGoalMode={goalMode?.active ? exitGoalMode : undefined}
|
||||
{...(agent ? { agent } : {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { closeHistory } from '@tiptap/pm/history'
|
||||
import { Slice } from '@tiptap/pm/model'
|
||||
import {
|
||||
memo,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
type HTMLAttributes,
|
||||
type RefObject
|
||||
} from 'react'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
@@ -51,7 +53,14 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onSelect'> & {
|
||||
|
||||
const NO_IMAGES: readonly string[] = []
|
||||
|
||||
export function NativeChatPromptEditor({
|
||||
// `initialValue` only seeds the editor at mount; keep the other props referentially stable or this memo stops helping.
|
||||
export const NativeChatPromptEditor = memo(
|
||||
NativePromptEditor,
|
||||
({ initialValue: _previous, ...previous }, { initialValue: _next, ...next }) =>
|
||||
shallow(previous, next)
|
||||
)
|
||||
|
||||
function NativePromptEditor({
|
||||
scopeKey,
|
||||
imageRow = NO_IMAGES,
|
||||
inputRef,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { changePrompt, promptValue } from './native-chat-prompt-editor.test-supp
|
||||
|
||||
import { createRef } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as NativeI18n from '@/i18n/i18n'
|
||||
|
||||
@@ -37,8 +37,15 @@ import {
|
||||
readNativeChatComposerDraft,
|
||||
updateNativeChatComposerDraft,
|
||||
clearNativeChatComposerDraftsForTests,
|
||||
hydrateNativeChatComposerDrafts
|
||||
hydrateNativeChatComposerDrafts,
|
||||
nativeChatComposerDraftWritesSettled
|
||||
} from './native-chat-composer-draft-store'
|
||||
import { appendNativeChatDraftCache } from './native-chat-draft-cache'
|
||||
import { settleNativeChatPastedText } from './native-chat-composer-pasted-text-references'
|
||||
import {
|
||||
nativeChatPastedTextOperations,
|
||||
nativeChatPastedTextToken
|
||||
} from './native-chat-pasted-text-reference'
|
||||
import {
|
||||
createMemoryNativeChatComposerDraftStorage,
|
||||
setNativeChatComposerDraftStorageForTests
|
||||
@@ -517,3 +524,112 @@ it('keeps an actual composing return through command acceptance, composition set
|
||||
view.unmount()
|
||||
clearNativeChatComposerDraftsForTests()
|
||||
})
|
||||
|
||||
describe('an open IME composition stays in the draft every consumer reads (#35836)', () => {
|
||||
const scope = 'agent-session:open-composition'
|
||||
let storage: ReturnType<typeof createMemoryNativeChatComposerDraftStorage>
|
||||
|
||||
beforeEach(async () => {
|
||||
clearNativeChatComposerDraftsForTests()
|
||||
storage = createMemoryNativeChatComposerDraftStorage()
|
||||
setNativeChatComposerDraftStorageForTests(storage)
|
||||
await hydrateNativeChatComposerDrafts()
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearNativeChatComposerDraftsForTests()
|
||||
nativeChatPastedTextOperations.clear()
|
||||
})
|
||||
|
||||
// The pane's Send reads its React draft, as this field's Send does.
|
||||
function LiveField({ onSend = vi.fn() }: { onSend?: (text: string) => void }) {
|
||||
const ime = useImeEnterGestureOwnership()
|
||||
const draft = useNativeChatDraft(scope, ime.isComposing)
|
||||
const events = useNativeChatDraftEvents({
|
||||
...draft,
|
||||
setCaret: vi.fn(),
|
||||
setActiveSuggestion: vi.fn(),
|
||||
handleDraftOrCaretChange: vi.fn(),
|
||||
flushPendingAttachments: vi.fn()
|
||||
})
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<NativeChatComposerField
|
||||
{...fieldProps()}
|
||||
draftScopeKey={scope}
|
||||
draft={draft.draft}
|
||||
imeEnterGesture={ime}
|
||||
sendButtonDisabled={draft.draft.trim() === ''}
|
||||
onSend={() => onSend(draft.draft)}
|
||||
{...events}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('sends the composing text from a Send press that keeps focus', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<LiveField onSend={onSend} />)
|
||||
const field = textarea()
|
||||
fireEvent.compositionStart(field)
|
||||
changePrompt(field, 'nihao')
|
||||
|
||||
const sendButton = screen.getByRole('button', { name: 'Send' })
|
||||
expect(sendButton.hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.mouseDown(sendButton)
|
||||
fireEvent.click(sendButton)
|
||||
expect(onSend).toHaveBeenCalledExactlyOnceWith('nihao')
|
||||
})
|
||||
|
||||
it('keeps a message returned mid-composition when the composer unmounts', () => {
|
||||
const view = render(<LiveField />)
|
||||
const field = textarea()
|
||||
fireEvent.compositionStart(field)
|
||||
changePrompt(field, '가')
|
||||
act(() => {
|
||||
appendNativeChatDraftCache(scope, 'returned message')
|
||||
})
|
||||
|
||||
view.unmount()
|
||||
expect(readNativeChatComposerDraft(scope).text).toBe('가\n\nreturned message')
|
||||
})
|
||||
|
||||
it('saves edits made after a composition that blur ended without compositionend', () => {
|
||||
render(<LiveField />)
|
||||
const field = textarea()
|
||||
fireEvent.compositionStart(field)
|
||||
changePrompt(field, '각')
|
||||
fireEvent.blur(field)
|
||||
changePrompt(field, '각a')
|
||||
|
||||
expect(readNativeChatComposerDraft(scope).text).toBe('각a')
|
||||
})
|
||||
|
||||
it('keeps a pasted-text path that finished saving mid-composition', () => {
|
||||
const id = '00000000-0000-4000-8000-000000000035'
|
||||
const token = nativeChatPastedTextToken(id)
|
||||
nativeChatPastedTextOperations.set(id, { scopeKey: scope, text: 'pasted' })
|
||||
updateNativeChatComposerDraft(scope, { text: token }, 'immediate')
|
||||
render(<LiveField />)
|
||||
const field = textarea()
|
||||
fireEvent.compositionStart(field)
|
||||
changePrompt(field, `${token} ni`)
|
||||
act(() => {
|
||||
settleNativeChatPastedText(id, '/tmp/pasted.txt')
|
||||
})
|
||||
endComposition(field, 'ni')
|
||||
|
||||
expect(readNativeChatComposerDraft(scope).pastedTextFiles).toEqual({ [id]: '/tmp/pasted.txt' })
|
||||
})
|
||||
|
||||
it('saves the composing text when the window hides mid-composition', async () => {
|
||||
render(<LiveField />)
|
||||
const field = textarea()
|
||||
fireEvent.compositionStart(field)
|
||||
changePrompt(field, 'nihao')
|
||||
|
||||
window.dispatchEvent(new Event('pagehide'))
|
||||
await nativeChatComposerDraftWritesSettled()
|
||||
expect(storage.drafts.get(scope)?.text).toBe('nihao')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -174,7 +174,10 @@ export function updateNativeChatComposerDraft(
|
||||
text,
|
||||
...(Object.keys(pastedTextFiles).length ? { pastedTextFiles } : {}),
|
||||
...(document ? { document } : {}),
|
||||
images: [...images],
|
||||
// Why: an unchanged row keeps its identity, so typing does not re-render every image consumer.
|
||||
images: sameNativeChatComposerDraftImages(images, current.images)
|
||||
? current.images
|
||||
: [...images],
|
||||
savedAt: nextSavedAt(),
|
||||
...(owner ? { owner } : {}),
|
||||
...(unsavedText === undefined ? {} : { unsavedText })
|
||||
|
||||
+4
-2
@@ -245,9 +245,10 @@ describe('native chat workspace file drops', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('consumes a nested editor drop once and inserts top-level paths at the caret', () => {
|
||||
it('consumes a nested editor drop once and inserts top-level paths at the caret', async () => {
|
||||
render(<ComposerProbe initialDraft="$rev tail" />)
|
||||
act(() => {
|
||||
// Tiptap mounts node-view portals from a microtask.
|
||||
await act(async () => {
|
||||
latestInput!.insertSkill!(0, 4, '$review')
|
||||
})
|
||||
expect(editor().querySelectorAll('[data-native-chat-skill]')).toHaveLength(1)
|
||||
@@ -260,6 +261,7 @@ describe('native chat workspace file drops', () => {
|
||||
])
|
||||
transfer.setData('text/plain', 'must not be inserted by ProseMirror')
|
||||
const accepted = dispatchDragEvent('drop', editor(), transfer)
|
||||
await act(async () => {})
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(screen.getByTestId('draft').textContent).toBe(
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
/** A stable function that calls the latest committed `handler`, so memoized children skip re-rendering when only a closure changed. */
|
||||
export function useLatestHandler<A extends unknown[], R>(
|
||||
handler: (...args: A) => R
|
||||
): (...args: A) => R {
|
||||
const latest = useRef(handler)
|
||||
useLayoutEffect(() => {
|
||||
latest.current = handler
|
||||
})
|
||||
return useCallback((...args: A) => latest.current(...args), [])
|
||||
}
|
||||
Reference in New Issue
Block a user