Skip to content

Commit caa0a73

Browse files
authored
fix(rich-markdown-editor): defer non-collab settle/stream mutations off the render phase (flushSync) (#6073)
* fix(rich-markdown-editor): defer non-collab settle/stream mutations off the render phase (flushSync) The non-collaborative streaming/settle effect called editor.setContent / setEditable / setTextSelection / focus directly in the effect body. setContent mounts the custom node views synchronously through the @tiptap/react flushSync path (tiptap#3764), so when this effect runs while React is mid-render it throws "flushSync was called from inside a lifecycle method." This is the second flushSync source (the collab editability effect was the first, fixed separately); it fires on the agent-streaming-into-a-non-collab-editor surface. Defer the effect-body view mutations to a microtask via a small runOffRender helper (runs right after the current commit, before paint; no-ops if the editor was torn down). The settle block is deferred as ONE microtask so setContent -> collapse selection -> setEditable -> focus keep their order. The streaming rAF tick is left untouched — it already runs off-render, so it keeps writing content directly. queueMicrotask is TipTap's own documented remedy for this warning. 497 rich-markdown-editor tests (incl. stream-settle-selection) pass; tsc + lint + api-validation + boundary + prune green. Needs a live check: stream an agent into a non-collab markdown file and confirm it still renders smoothly. * chore(rich-markdown-editor): trim verbose flushSync-defer comments * fix(rich-markdown-editor): drop superseded settle/stream microtasks via a run token runOffRender previously only guarded editor.isDestroyed, so if React ran the next reconcile pass (a newer stream or settle) before a queued microtask flushed, the stale microtask could still apply setContent/setEditable/setTextSelection over the newer state. Tag each effect run with an incrementing token; a deferred mutation applies only when its run is still the latest (and the editor is alive). A run token fits this effect's several early-return exits better than a per-exit cleanup flag. Addresses Greptile/Cursor review. * fix(rich-markdown-editor): never drop the settle selection-collapse under a superseded run The run token drops a superseded settle's microtask, but the settle had already flipped its state flags synchronously — so a pre-empting steady-sync run took the non-settle path and never collapsed the selection, leaving a post-stream select-all painting the leaf-in-selection decoration. Track the collapse as a debt (pendingCollapseRef): whichever deferred run ultimately applies — settle or the steady-sync path — clears it, so the collapse runs exactly once on the latest content. Addresses the Cursor review finding.
1 parent fd34853 commit caa0a73

1 file changed

Lines changed: 52 additions & 15 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,8 @@ export function LoadedRichMarkdownEditor({
784784
const pendingStreamBodyRef = useRef<string | null>(null)
785785
const streamRafRef = useRef<number | null>(null)
786786
const lastStreamParseAtRef = useRef(0)
787+
const settleRunSeqRef = useRef(0)
788+
const pendingCollapseRef = useRef(false)
787789
useEffect(() => {
788790
if (!editor) return
789791
// Collaboration and agent-streaming are disjoint surfaces: collaboration is enabled
@@ -801,9 +803,29 @@ export function LoadedRichMarkdownEditor({
801803
emitUpdate: false,
802804
})
803805
}
806+
// Editor view mutations flush synchronously through the @tiptap/react binding (setContent mounts
807+
// the React node views via flushSync — tiptap#3764), so calling them directly in this effect body
808+
// throws "flushSync ... cannot flush while rendering" when the effect runs mid-render. Defer to a
809+
// microtask (after commit, before paint). The streaming rAF tick below already runs off-render.
810+
//
811+
// Tag each run: a deferred mutation applies only if it is still the latest run (this effect has
812+
// several early-return exits, so a run-token beats a per-exit cleanup flag) and the editor is
813+
// alive. This drops a superseded run's microtask when React ran the next pass — a newer stream or
814+
// settle — before the microtask flushed, so it can't overwrite the newer state.
815+
const runSeq = ++settleRunSeqRef.current
816+
const runOffRender = (mutate: () => void) => {
817+
queueMicrotask(() => {
818+
if (runSeq !== settleRunSeqRef.current || editor.isDestroyed) return
819+
mutate()
820+
})
821+
}
804822
if (isStreaming) {
805823
wasStreamingRef.current = true
806-
if (editor.isEditable) editor.setEditable(false)
824+
if (editor.isEditable) {
825+
runOffRender(() => {
826+
if (editor.isEditable) editor.setEditable(false)
827+
})
828+
}
807829
const body = splitFrontmatter(content).body
808830
if (body === lastSyncedBodyRef.current) return
809831
pendingStreamBodyRef.current = body
@@ -852,22 +874,37 @@ export function LoadedRichMarkdownEditor({
852874
if (isInitialSettle || wasStreamingRef.current) {
853875
wasStreamingRef.current = false
854876
settledRef.current = lockSettled(content)
855-
syncEditorBody(splitFrontmatter(content).body)
856-
// `setContent` maps any pre-existing selection onto the new doc rather than clearing it — a
857-
// select-all survives as "select everything," permanently painting every divider/image with the
858-
// `rich-leaf-in-selection` decoration (keymap.ts) until the user clicks elsewhere. This must run
859-
// on every settle regardless of whether `setContent` ran just above: the last streaming tick
860-
// already syncs `lastSyncedBodyRef` to the final body before settle, so `body` usually already
861-
// equals it here — collapsing only inside that `if` would skip the common streamed-content case
862-
// entirely. `setTextSelection` (not `.focus()`) so this never steals DOM focus from whatever the
863-
// user is doing outside the editor.
864-
editor.commands.setTextSelection(editor.state.doc.content.size)
865-
editor.setEditable(canEdit && settledRef.current.verdict && collabReady)
866-
if (isInitialSettle && autoFocus) editor.commands.focus('end')
877+
const settledVerdict = settledRef.current.verdict
878+
const shouldFocus = isInitialSettle && autoFocus
879+
// A settle owes a selection collapse. Track it as a ref, not just inline in this microtask: if a
880+
// newer run bumps the token before this microtask fires, this settle's task is dropped — but the
881+
// debt survives, and the run that supersedes it (settle OR the steady-sync path below) clears it.
882+
pendingCollapseRef.current = true
883+
// One ordered microtask: set body → collapse selection → re-apply editability. The collapse is
884+
// load-bearing and runs on every settle even when the body is unchanged — setContent maps a
885+
// pre-existing selection onto the new doc, so a prior select-all survives as "select everything",
886+
// permanently painting every divider/image with the rich-leaf-in-selection decoration (keymap.ts)
887+
// until the user clicks away. setTextSelection (not .focus()) never steals DOM focus.
888+
runOffRender(() => {
889+
syncEditorBody(splitFrontmatter(content).body)
890+
pendingCollapseRef.current = false
891+
editor.commands.setTextSelection(editor.state.doc.content.size)
892+
editor.setEditable(canEdit && settledVerdict && collabReady)
893+
if (shouldFocus) editor.commands.focus('end')
894+
})
867895
return
868896
}
869-
syncEditorBody(splitFrontmatter(content).body)
870-
if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict && collabReady)
897+
const settled = settledRef.current
898+
runOffRender(() => {
899+
syncEditorBody(splitFrontmatter(content).body)
900+
// Honor a collapse a superseded settle owed but never applied (its microtask was dropped when this
901+
// run bumped the token), so a post-stream select-all can't keep the leaf-in-selection decoration.
902+
if (pendingCollapseRef.current) {
903+
pendingCollapseRef.current = false
904+
editor.commands.setTextSelection(editor.state.doc.content.size)
905+
}
906+
if (settled) editor.setEditable(canEdit && settled.verdict && collabReady)
907+
})
871908
}, [
872909
editor,
873910
content,

0 commit comments

Comments
 (0)