From bff133c73af22c3fda58d4bae37724819aca82c1 Mon Sep 17 00:00:00 2001 From: xiaolai Date: Sat, 27 Jun 2026 19:43:23 +0800 Subject: [PATCH 1/2] fix(lint): revert TiptapEditor latest-value refs to render-phase sync (#1063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second cross-model audit round caught a real regression the first pass missed: the six latest-value refs were moved to a passive useEffect, but the deferred init parse (setTimeout) reads contentRef/preserveLineBreaksRef to recover from content drift, and the unmount-flush path (#755) also needs pre-effect freshness. A passive effect has nondeterministic ordering versus setTimeout(0), risking a stale read that initializes the editor with old content. Revert to a render-phase sync (block-scoped react-hooks/refs disable) — the original, correct behavior. Also corrects the useGenieInvocation disable reason: the MCP bridge handler is a synchronous CustomEvent handler (not async), so the render-phase write is needed for the synchronous handshake to see the latest invokeGenie. --- src/components/Editor/TiptapEditor.tsx | 18 +++++++++--------- src/hooks/useGenieInvocation.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/Editor/TiptapEditor.tsx b/src/components/Editor/TiptapEditor.tsx index 49f528254..23fecad67 100644 --- a/src/components/Editor/TiptapEditor.tsx +++ b/src/components/Editor/TiptapEditor.tsx @@ -180,15 +180,15 @@ export function TiptapEditorInner({ hidden = false, readOnly = false, preview = const contentRef = useRef(content); const editorRef = useRef(null); const flushToStoreRef = useRef<((editor: TiptapEditor) => void) | null>(null); - // Sync "latest value" refs after commit (read only from callbacks/effects) — concurrent-safe (#1063). - useEffect(() => { - cursorInfoRef.current = cursorInfo; - preserveLineBreaksRef.current = preserveLineBreaks; - hardBreakStyleOnSaveRef.current = hardBreakStyleOnSave; - hiddenRef.current = hidden; - previewRef.current = preview; - contentRef.current = content; - }); + // Latest-value refs synced during render: a deferred init parse (setTimeout) + the unmount-flush read these and need the latest committed value before effects run (#1063). + /* eslint-disable react-hooks/refs */ + cursorInfoRef.current = cursorInfo; + preserveLineBreaksRef.current = preserveLineBreaks; + hardBreakStyleOnSaveRef.current = hardBreakStyleOnSave; + hiddenRef.current = hidden; + previewRef.current = preview; + contentRef.current = content; + /* eslint-enable react-hooks/refs */ const extensions = useMemo( () => createTiptapExtensions({ tabId: activeTabId, lintEnabled }), diff --git a/src/hooks/useGenieInvocation.ts b/src/hooks/useGenieInvocation.ts index 2662e47c3..ea5604291 100644 --- a/src/hooks/useGenieInvocation.ts +++ b/src/hooks/useGenieInvocation.ts @@ -487,7 +487,7 @@ export function useGenieInvocation() { [runGenie] ); - // eslint-disable-next-line react-hooks/refs -- latest-value ref read only by the async MCP bridge listener (#1063) + // eslint-disable-next-line react-hooks/refs -- render-synced so the synchronous MCP bridge handler sees the latest invokeGenie before passive effects run (#1063) invokeGenieRef.current = invokeGenie; const invokeFreeform = useCallback( From 51aa16a8a29657e4fe97a254b63a8f6993cd0dc6 Mon Sep 17 00:00:00 2001 From: xiaolai Date: Sat, 27 Jun 2026 19:57:22 +0800 Subject: [PATCH 2/2] fix(lint): render-sync SourceEditor + useTabDragOut latest-value refs (#1063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 of the cross-model audit found two more refs with the same hazard as TiptapEditor — moved to a passive effect but read from paths that can run in the commit-to-passive-flush gap: - SourceEditor: hiddenRef/cursorInfoRef (+ setter refs) are read by CodeMirror's update listener, a delayed focus/restore setTimeout, and an interval poll. A stale hiddenRef could let a now-hidden editor write/sync/focus; a stale cursorInfoRef could restore the previous render's cursor. - useTabDragOut: the drag callback/bar refs are read by synchronous document pointer listeners during an active drag. Both reverted to render-phase sync (block-scoped react-hooks/refs disable). The other passive-synced refs (SourcePane, useContentServer, useTerminalSessions) were audited and confirmed safe — their readers are async and the mirrored callbacks are stable, so the commit-to-passive window is harmless there. --- src/components/Editor/SourceEditor.tsx | 16 ++++++++-------- src/hooks/useTabDragOut.ts | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/Editor/SourceEditor.tsx b/src/components/Editor/SourceEditor.tsx index 55e0908bb..144e48518 100644 --- a/src/components/Editor/SourceEditor.tsx +++ b/src/components/Editor/SourceEditor.tsx @@ -79,14 +79,14 @@ export function SourceEditor({ hidden = false, readOnly = false }: SourceEditorP const setCursorInfoRef = useRef(setCursorInfo); const setSelectedTextRef = useRef(setSelectedText); const cursorInfoRef = useRef(cursorInfo); - // Sync "latest value" refs after commit (read only from the CodeMirror listener/effects) — concurrent-safe (#1063). - useEffect(() => { - hiddenRef.current = hidden; - setContentRef.current = setContent; - setCursorInfoRef.current = setCursorInfo; - setSelectedTextRef.current = setSelectedText; - cursorInfoRef.current = cursorInfo; - }); + // Latest-value refs synced during render: read by CodeMirror's update listener, a delayed focus/restore setTimeout, and an interval poll — all of which can fire before a passive effect would flush, so they need pre-commit freshness (#1063). + /* eslint-disable react-hooks/refs */ + hiddenRef.current = hidden; + setContentRef.current = setContent; + setCursorInfoRef.current = setCursorInfo; + setSelectedTextRef.current = setSelectedText; + cursorInfoRef.current = cursorInfo; + /* eslint-enable react-hooks/refs */ // Use editor store for global settings const wordWrap = useUIStore((state) => state.wordWrap); diff --git a/src/hooks/useTabDragOut.ts b/src/hooks/useTabDragOut.ts index c6301a4e0..cb511e0b8 100644 --- a/src/hooks/useTabDragOut.ts +++ b/src/hooks/useTabDragOut.ts @@ -14,7 +14,7 @@ * @module hooks/useTabDragOut */ -import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; +import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; /** Vertical distance (px) outside the tab bar to trigger drag-out. */ const DRAG_OUT_THRESHOLD = 40; @@ -150,17 +150,17 @@ export function useTabDragOut({ tabBarRef, onDragOut, onReorder, onDragMove }: U } }, []); - // Latest-value refs read only from the document drag listeners; synced after commit (#1063). + // Latest-value refs read by synchronous document pointer listeners during a drag, so they must be render-synced (fresh before a pointer event fires), not passive (#1063). const onDragOutRef = useRef(onDragOut); const onReorderRef = useRef(onReorder); const onDragMoveRef = useRef(onDragMove); const stableBarRef = useRef(tabBarRef); - useEffect(() => { - onDragOutRef.current = onDragOut; - onReorderRef.current = onReorder; - onDragMoveRef.current = onDragMove; - stableBarRef.current = tabBarRef; - }); + /* eslint-disable react-hooks/refs */ + onDragOutRef.current = onDragOut; + onReorderRef.current = onReorder; + onDragMoveRef.current = onDragMove; + stableBarRef.current = tabBarRef; + /* eslint-enable react-hooks/refs */ // Detach document listeners and reset state /* v8 ignore start -- @preserve reason: cleanupRef empty-function initializer and reset are uncovered; drag cleanup not triggered in unit tests */