From af8117c07af87e1a7b215ff1dfd696571ae099b5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:12:00 -0700 Subject: [PATCH] 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 --- .../native-chat/NativeChatComposerActions.tsx | 8 +- .../native-chat/NativeChatComposerField.tsx | 122 ++++++++++-------- .../native-chat/NativeChatPromptEditor.tsx | 11 +- .../native-chat-composer-composition.test.tsx | 120 ++++++++++++++++- .../native-chat-composer-draft-store.ts | 5 +- ...chat-composer-workspace-file-drop.test.tsx | 6 +- src/renderer/src/lib/use-latest-handler.ts | 12 ++ 7 files changed, 225 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/lib/use-latest-handler.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 565558895fc..a3b5d5d46b7 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -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): 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({ ) -} +}) function DraftNotSavedIcon({ shown }: { shown: boolean }): React.JSX.Element | null { if (!shown) { diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index a37b46416a0..055df61afa9 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -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(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) => { + 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) => { + 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 } : {})} /> diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index b9284604289..7e7a81b3e5b 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -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, '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, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx index 1753e71071a..f0aaf381f0a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx @@ -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 + + 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 ( + + onSend(draft.draft)} + {...events} + /> + + ) + } + + it('sends the composing text from a Send press that keeps focus', () => { + const onSend = vi.fn() + render() + 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() + 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() + 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() + 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() + const field = textarea() + fireEvent.compositionStart(field) + changePrompt(field, 'nihao') + + window.dispatchEvent(new Event('pagehide')) + await nativeChatComposerDraftWritesSettled() + expect(storage.drafts.get(scope)?.text).toBe('nihao') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index ff62ad9b42b..870e735bf49 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -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 }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx index bb8b5043f29..6416b2be73b 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx @@ -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() - 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( diff --git a/src/renderer/src/lib/use-latest-handler.ts b/src/renderer/src/lib/use-latest-handler.ts new file mode 100644 index 00000000000..6cf038ba5f8 --- /dev/null +++ b/src/renderer/src/lib/use-latest-handler.ts @@ -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( + handler: (...args: A) => R +): (...args: A) => R { + const latest = useRef(handler) + useLayoutEffect(() => { + latest.current = handler + }) + return useCallback((...args: A) => latest.current(...args), []) +}