Skip to content

Commit 95416f3

Browse files
committed
perf(file-viewer): cap the round-trip probe at 24KB and coalesce streaming syncs
@tiptap/markdown's parse is superlinear (~O(n2)) in document size — measured ~170ms at 11KB, ~875ms at 23KB, multiple seconds past ~35KB — and it runs synchronously at mount inside the round-trip-safety probe (twice) and the editor's own setContent. The 128KB cap allowed multi-second main-thread freezes; lower it to 24KB so the worst-case mount stays near a second while still covering the vast majority of real markdown files (larger files open read-only). Separately, coalesce streaming chunk-syncs to one re-parse per animation frame so a fast-streaming agent doesn't re-parse the whole accumulating doc per token. Typing latency was measured to be already excellent (sub-ms median, no change needed); the only hot cost was the mount parse.
1 parent 249be95 commit 95416f3

4 files changed

Lines changed: 47 additions & 14 deletions

File tree

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -278,20 +278,39 @@ export function LoadedRichMarkdownEditor({
278278
// `previewContextKey` (the chat id) keeps this instance mounted across those edits — so the verdict
279279
// + frontmatter must be re-derived per stream, not frozen on the first settled snapshot.
280280
const wasStreamingRef = useRef(streamingAtMountRef.current)
281+
// Coalesce streaming chunk-syncs to one re-parse per animation frame. A fast-streaming agent emits
282+
// many chunks per frame; without this each one re-parses the whole accumulating markdown
283+
// (`@tiptap/markdown`'s parse is superlinear), saturating the main thread. The editor is read-only
284+
// while streaming, so only the latest frame's content needs to render.
285+
const pendingStreamBodyRef = useRef<string | null>(null)
286+
const streamRafRef = useRef<number | null>(null)
281287
useEffect(() => {
282288
if (!editor) return
283289
if (isStreaming) {
284290
wasStreamingRef.current = true
285291
const body = splitFrontmatter(content).body
286292
if (body === lastSyncedBodyRef.current) return
287-
lastSyncedBodyRef.current = body
288-
const el = containerRef.current
289-
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
290-
editor.setEditable(false)
291-
editor.commands.setContent(body, { contentType: 'markdown', emitUpdate: false })
292-
if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight
293+
pendingStreamBodyRef.current = body
294+
if (streamRafRef.current !== null) return
295+
streamRafRef.current = requestAnimationFrame(() => {
296+
streamRafRef.current = null
297+
const pending = pendingStreamBodyRef.current
298+
if (pending === null || pending === lastSyncedBodyRef.current) return
299+
lastSyncedBodyRef.current = pending
300+
const el = containerRef.current
301+
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
302+
editor.setEditable(false)
303+
editor.commands.setContent(pending, { contentType: 'markdown', emitUpdate: false })
304+
if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight
305+
})
293306
return
294307
}
308+
// A streamed frame scheduled just before settle must not land afterward and clobber the final
309+
// content, so drop it before settling.
310+
if (streamRafRef.current !== null) {
311+
cancelAnimationFrame(streamRafRef.current)
312+
streamRafRef.current = null
313+
}
295314
// Settle: lock the verdict + frontmatter on the freshly-settled content. Re-lock on the initial
296315
// settle and on every later stream→settle, so a repeat agent edit gates editability + frontmatter
297316
// on the NEW content rather than a stale pre-stream snapshot. User edits never re-derive (they
@@ -315,6 +334,13 @@ export function LoadedRichMarkdownEditor({
315334
if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict)
316335
}, [editor, content, isStreaming, canEdit, autoFocus, disableStreamingAutoScroll])
317336

337+
useEffect(
338+
() => () => {
339+
if (streamRafRef.current !== null) cancelAnimationFrame(streamRafRef.current)
340+
},
341+
[]
342+
)
343+
318344
return (
319345
<div
320346
ref={containerRef}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-editable-corpus.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,8 @@ describe('editability gate — realistic documents stay editable', () => {
161161
}
162162

163163
it('a large-but-ordinary document (just under the probe limit) stays editable', () => {
164-
const big = `# Big Doc\n\n${'A paragraph of perfectly ordinary prose. '.repeat(2000)}`
165-
expect(big.length).toBeLessThan(128 * 1024)
164+
const big = `# Big Doc\n\n${'A paragraph of perfectly ordinary prose. '.repeat(500)}`
165+
expect(big.length).toBeLessThan(24 * 1024)
166166
expect(isRoundTripSafe(big)).toBe(true)
167167
})
168168

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ describe('isRoundTripSafe', () => {
8989
expect(isRoundTripSafe('see <https://sim.ai> for more')).toBe(true)
9090
})
9191

92-
it('falls back for very large documents without probing', () => {
93-
expect(isRoundTripSafe(`# Title\n\n${'word '.repeat(110_000)}`)).toBe(false)
92+
it('probes documents up to the size cap but falls back (read-only) above it', () => {
93+
// ~20KB of simple safe prose is under the 24KB cap → probed and editable.
94+
expect(isRoundTripSafe(`# Title\n\n${'word '.repeat(4000)}`)).toBe(true)
95+
// ~30KB is over the cap → not probed, opens read-only (the synchronous probe is too slow).
96+
expect(isRoundTripSafe(`# Title\n\n${'word '.repeat(6000)}`)).toBe(false)
9497
})
9598
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ import {
77
} from './markdown-fidelity'
88

99
/**
10-
* Above this size we don't run the (synchronous) round-trip probe — building two editors to
11-
* serialize a large document blocks the main thread for too long, and a very large markdown file
12-
* is heavier to edit richly anyway, so it opens read-only.
10+
* Above this size we don't run the (synchronous) round-trip probe, and the file opens read-only.
11+
* The probe builds throwaway editors and parses the markdown, and `@tiptap/markdown`'s parse is
12+
* superlinear (~O(n²)) in document size — measured ~170ms at 11KB, ~875ms at 23KB, multiple seconds
13+
* past ~35KB — so a high cap means a multi-second main-thread freeze at mount. 24KB keeps the
14+
* worst-case probe near a second while still covering the vast majority of real markdown files; a
15+
* very large markdown file is also heavier to edit richly anyway. The editor's own markdown parse
16+
* shares this cost, so the cap protects mount render too.
1317
*/
14-
const PROBE_SIZE_LIMIT = 128 * 1024
18+
const PROBE_SIZE_LIMIT = 24 * 1024
1519

1620
/**
1721
* Constructs the editor drops or mangles in a way that survives a second serialization

0 commit comments

Comments
 (0)