From aa11f7002ed1c1ecfd40f936e463ccebc0056993 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:36:36 -0700 Subject: [PATCH] refactor(web): composer editor stops mirroring props into refs with effects The Tiptap prompt editor kept six callback props in refs, each updated by its own useEffect, plus an effect that copied the editor instance into a holder ref and a layout effect that mirrored the controlled value. The composer's prompt-wrap measurement re-ran a layout effect off a state-held element, and the command-key hook mirrored prompt history into a ref every render. - ComposerPromptEditorTiptap: one useStableCallback reads the current callbacks from the once-built editor config; ProseMirror handlers reach the editor through view.dom.editor; the controlled-value layout effect records the committed value itself; copy/cut reads its prop directly (it is a React handler, not part of the editor config). - useComposerMultilinePrompt returns a ref callback that observes the composer body for as long as that element is mounted. - useComposerCommandKey becomes the plain composerCommandKeyHandler, built on the keypress, so prompt history is read without a mirror ref. Effects kept: setEditable, attribute push, the controlled-value sync and skill reconcile in the editor are real DOM/editor synchronization. Co-Authored-By: Claude Opus 5.5 --- .../components/ComposerPromptEditorTiptap.tsx | 69 ++++++--------- apps/web/src/components/chat/ChatComposer.tsx | 88 ++++++++++--------- ...serCommandKey.ts => composerCommandKey.ts} | 18 ++-- .../chat/useComposerMultilinePrompt.ts | 16 ++-- 4 files changed, 90 insertions(+), 101 deletions(-) rename apps/web/src/components/chat/{useComposerCommandKey.ts => composerCommandKey.ts} (95%) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 58c5b107644fb..4b0ad2daa7dc5 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -7,6 +7,7 @@ import { Node, wrappingInputRule, type JSONContent, + type TiptapEditorHTMLElement, } from "@tiptap/core"; import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; @@ -976,36 +977,15 @@ function ComposerPromptEditorTiptapInner( const expandEditorCursor = (text: string, position: number) => literalText ? position : expandCollapsedComposerCursor(text, position); - const onChangeRef = useRef(onChange); - const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); - const onCommandKeyDownRef = useRef(onCommandKeyDown); - const isPathQueryActiveRef = useRef(isPathQueryActive); - const buildFragmentRef = useRef(buildContextClipboardFragment); - const importFragmentRef = useRef(importContextFragment); const latestValueRef = useRef(value); - const editorHolder = useRef(null); - - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - useEffect(() => { - onVisibleSelectionChangeRef.current = onVisibleSelectionChange; - }, [onVisibleSelectionChange]); - useEffect(() => { - onCommandKeyDownRef.current = onCommandKeyDown; - }, [onCommandKeyDown]); - useEffect(() => { - isPathQueryActiveRef.current = isPathQueryActive; - }, [isPathQueryActive]); - useEffect(() => { - buildFragmentRef.current = buildContextClipboardFragment; - }, [buildContextClipboardFragment]); - useEffect(() => { - importFragmentRef.current = importContextFragment; - }, [importContextFragment]); - useLayoutEffect(() => { - latestValueRef.current = value; - }, [value]); + // The editor config is built once, so its handlers reach the current callbacks through here. + const readCallbacks = useStableCallback(() => ({ + onChange, + onVisibleSelectionChange, + onCommandKeyDown, + isPathQueryActive, + importContextFragment, + })); // Stable so a skills refresh does not rerun the controlled-value effect. const skillLabelFor = useStableCallback((name: string) => composerSkillMeta(skills, name)); @@ -1074,7 +1054,7 @@ function ComposerPromptEditorTiptapInner( if (isApplyingControlledUpdateRef.current) return; if (!snapshotChanged) { if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { - onVisibleSelectionChangeRef.current?.(); + readCallbacks().onVisibleSelectionChange?.(); } return; } @@ -1096,7 +1076,13 @@ function ComposerPromptEditorTiptapInner( [updated.state.selection.$from.nodeBefore, updated.state.selection.$from.nodeAfter].some( (node) => node?.isInline && node.isAtom && !node.isText && node.type.name !== "hardBreak", ); - onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds); + readCallbacks().onChange( + nextValue, + nextCursor, + nextExpandedCursor, + suppressTrigger, + contextIds, + ); }; const editorAttributes = { @@ -1301,9 +1287,9 @@ function ComposerPromptEditorTiptapInner( return true; } } - const handler = onCommandKeyDownRef.current; + const handler = readCallbacks().onCommandKeyDown; if (event.key === "Enter") { - const instance = editorHolder.current; + const instance = (view.dom as TiptapEditorHTMLElement).editor; const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); const isListItem = richText && (instance?.isActive("listItem") ?? false); const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false; @@ -1390,7 +1376,7 @@ function ComposerPromptEditorTiptapInner( ); return true; } - const importFragment = importFragmentRef.current; + const importFragment = readCallbacks().importContextFragment; let text = !literalText && importFragment ? importPastedComposerText(clipboardData, importFragment) @@ -1412,8 +1398,8 @@ function ComposerPromptEditorTiptapInner( const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); const literalLength = literalText ? 0 - : pastedPathQueryLength(lineBefore, text, isPathQueryActiveRef.current?.()); - const editorInstance = editorHolder.current; + : pastedPathQueryLength(lineBefore, text, readCallbacks().isPathQueryActive?.()); + const editorInstance = (view.dom as TiptapEditorHTMLElement).editor; if (editorInstance) { const $paste = view.state.selection.$from; const nested = ["listItem", "taskItem", "blockquote"].some((name) => @@ -1476,10 +1462,6 @@ function ComposerPromptEditorTiptapInner( editor?.setEditable(!disabled); }, [disabled, editor]); - useEffect(() => { - editorHolder.current = editor; - }, [editor]); - // Push attributes before layout measurements; Tiptap updates them later in a passive effect. useLayoutEffect(() => { if (!editor?.isInitialized) return; @@ -1539,7 +1521,10 @@ function ComposerPromptEditorTiptapInner( onCitationDraftSave(replacedValue, serializeEditorDoc(current.state.doc).value); }); + // The one place the controlled value reaches the editor. Imperative handlers compare against + // the committed value, so it is recorded here even before the editor exists. useLayoutEffect(() => { + latestValueRef.current = value; if (!editor) return; const initialSelection = !hasAppliedControlledSelectionRef.current; hasAppliedControlledSelectionRef.current = true; @@ -1665,7 +1650,7 @@ function ComposerPromptEditorTiptapInner( start: snapshotRef.current.expandedCursor, end: snapshotRef.current.expandedCursor, }; - onChangeRef.current( + readCallbacks().onChange( snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, @@ -1756,7 +1741,7 @@ function ComposerPromptEditorTiptapInner( ); const handleCopyCut = (event: React.ClipboardEvent, cut: boolean) => { - const build = buildFragmentRef.current; + const build = buildContextClipboardFragment; if (!editor || (cut && !editor.isEditable)) return; const clipboardData = event.clipboardData; const { from, to } = editor.state.selection; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e8c8256467bf8..9932df357a194 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -295,7 +295,7 @@ import { usePreparingComposerImages, } from "./preparingComposerImages"; import { ComposerControlsBar } from "./ComposerControlsBar"; -import { useComposerCommandKey } from "./useComposerCommandKey"; +import { composerCommandKeyHandler } from "./composerCommandKey"; function consumePasteAsTextShortcut(deadline: { current: number }): boolean { const armed = Date.now() <= deadline.current; @@ -1096,7 +1096,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) null, ); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); - const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor); + const [hasWrappedPrompt, observeWrappedPrompt] = useComposerMultilinePrompt(); const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false); // A pending question stays expanded: its card moves into the banner dock, @@ -2234,43 +2234,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const expandMobileComposerStable = useStableCallback(expandMobileComposer); - const { onComposerCommandKey } = useComposerCommandKey({ - keybindings, - sendShortcut: settings.sendShortcut, - routeKind, - phase, - isCompacting, - followUpBehavior, - isSoftwareKeyboardOpen, - planModeUiEnabled, - isLiteralPendingAnswer, - pendingUserInputs, - promptHistoryMessages, - promptHistoryPositionRef, - composerDraftTarget, - promptRef, - composerImagesRef, - composerFilesRef, - composerTerminalContextsRef, - composerPreviewAnnotations, - composerReviewComments, - composerEditorRef, - composerMenuOpenRef, - composerMenuItemsRef, - activeComposerMenuItemRef, - setComposerDraftPrompt, - setComposerCursor, - setComposerTrigger, - setComposerHighlightedItemId, - dismissComposerTrigger, - toggleInteractionMode, - nudgeComposerMenuHighlight, - onSelectComposerItem, - resolveActiveComposerTrigger, - readComposerSnapshot, - applyPromptReplacement, - submitComposer, - }); + const onComposerCommandKey = (key: string, event: KeyboardEvent, isTaskItem?: boolean) => + composerCommandKeyHandler({ + keybindings, + sendShortcut: settings.sendShortcut, + routeKind, + phase, + isCompacting, + followUpBehavior, + isSoftwareKeyboardOpen, + planModeUiEnabled, + isLiteralPendingAnswer, + pendingUserInputs, + promptHistoryMessages, + promptHistoryPositionRef, + composerDraftTarget, + promptRef, + composerImagesRef, + composerFilesRef, + composerTerminalContextsRef, + composerPreviewAnnotations, + composerReviewComments, + composerEditorRef, + composerMenuOpenRef, + composerMenuItemsRef, + activeComposerMenuItemRef, + setComposerDraftPrompt, + setComposerCursor, + setComposerTrigger, + setComposerHighlightedItemId, + dismissComposerTrigger, + toggleInteractionMode, + nudgeComposerMenuHighlight, + onSelectComposerItem, + resolveActiveComposerTrigger, + readComposerSnapshot, + applyPromptReplacement, + submitComposer, + })(key, event, isTaskItem); const { stashQueue, @@ -3653,7 +3654,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null}
{ + setComposerMenuAnchor(node); + const stopObserving = observeWrappedPrompt(node); + return () => { + setComposerMenuAnchor(null); + stopObserving?.(); + }; + }} data-chat-composer-body="true" className={cn( "relative px-3 pb-2 sm:px-4", diff --git a/apps/web/src/components/chat/useComposerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts similarity index 95% rename from apps/web/src/components/chat/useComposerCommandKey.ts rename to apps/web/src/components/chat/composerCommandKey.ts index c4166b4370530..477485472914d 100644 --- a/apps/web/src/components/chat/useComposerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -21,13 +21,14 @@ import { type PreviewAnnotationPayload } from "@t3tools/contracts"; import { type ReviewCommentContext } from "../../reviewCommentContext"; import { composerTargetKey } from "../../composerDraftStore"; import { listContinuationForEnter, listIndentForTab } from "../../composer-list-continuation"; -import { type Dispatch, type RefObject, type SetStateAction, useLayoutEffect, useRef } from "react"; +import type { Dispatch, RefObject, SetStateAction } from "react"; /** * The composer's key handling inside the editor: send shortcuts, the slash/mention menu, * prompt history on ArrowUp/ArrowDown, plan-mode toggle, and list continuation. + * Returns the editor's key handler; it reads the inputs captured by the render that built it. */ -export function useComposerCommandKey(input: { +export function composerCommandKeyHandler(input: { readonly keybindings: ChatComposerProps["keybindings"]; readonly sendShortcut: ChatComposerProps["settings"]["sendShortcut"]; readonly routeKind: ChatComposerProps["routeKind"]; @@ -116,12 +117,6 @@ export function useComposerCommandKey(input: { applyPromptReplacement, submitComposer, } = input; - // Entries are built on the keypress, not per render: the timeline changes - // on every streamed delta and ArrowUp is rare. - const promptHistoryMessagesRef = useRef(promptHistoryMessages); - useLayoutEffect(() => { - promptHistoryMessagesRef.current = promptHistoryMessages; - }); // The composer persists across threads. A recall from thread A must not // be treated as active in thread B, where the text-match fallback could @@ -164,7 +159,8 @@ export function useComposerCommandKey(input: { } const step = stepComposerPromptHistory({ direction, - entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), + // Built on the keypress, not per render: the timeline changes on every streamed delta. + entries: buildComposerPromptHistoryEntries(promptHistoryMessages), position, currentPrompt: promptRef.current, }); @@ -258,7 +254,5 @@ export function useComposerCommandKey(input: { return false; }; - return { - onComposerCommandKey, - }; + return onComposerCommandKey; } diff --git a/apps/web/src/components/chat/useComposerMultilinePrompt.ts b/apps/web/src/components/chat/useComposerMultilinePrompt.ts index b5025b1becf9b..c09c371aa997a 100644 --- a/apps/web/src/components/chat/useComposerMultilinePrompt.ts +++ b/apps/web/src/components/chat/useComposerMultilinePrompt.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState } from "react"; +import { useState } from "react"; import { observeResize } from "../../lib/observeResize"; function measureComposerMultilinePrompt(body: HTMLElement): boolean | null { @@ -19,10 +19,13 @@ function measureComposerMultilinePrompt(body: HTMLElement): boolean | null { return bounds.height > lineHeight + 1 || bounds.width > expandedWidth + 1; } -export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { +/** Whether the prompt wraps, and the ref callback that measures it on the composer body. */ +export function useComposerMultilinePrompt(): readonly [ + boolean, + (body: HTMLElement | null) => (() => void) | undefined, +] { const [isMultiline, setIsMultiline] = useState(false); - - useLayoutEffect(() => { + const observeBody = (body: HTMLElement | null) => { if (!body) return; const measure = () => { const next = measureComposerMultilinePrompt(body); @@ -37,7 +40,6 @@ export function useComposerMultilinePrompt(body: HTMLElement | null): boolean { stopObserving(); mutationObserver.disconnect(); }; - }, [body]); - - return isMultiline; + }; + return [isMultiline, observeBody] as const; }