Skip to content

Commit 443b8e4

Browse files
committed
refactor(file-viewer): simplify + cleanup chunked-parse (linear merge, parse-once seed)
From the /simplify + /cleanup passes: - splitMarkdownBlocks: build continuation runs and join each once instead of concatenating onto the growing previous block per group, which was O(n2) for a pathological single long loose list (now linear: 208KB loose list splits in ~3ms). - rich-markdown-editor: seed the editor's initial content via a lazy useState initializer instead of useRef(parseMarkdownToDoc(...)), whose argument re-parsed the whole document on every render (i.e. every keystroke). Parses exactly once at mount. - Document that the indent-merge rule is load-bearing for nested fenced code, and tighten the verbose inline comment blocks.
1 parent f8fa284 commit 443b8e4

2 files changed

Lines changed: 38 additions & 41 deletions

File tree

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

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ const BLOCKQUOTE = /^[ ]{0,3}>/
5050
* single loose list/quote). Merging is intentionally conservative — over-merging only yields a larger
5151
* chunk, whereas under-merging would shatter a structure — and every block is parsed by
5252
* `@tiptap/markdown`'s own lexer, so block boundaries always match the parser.
53+
*
54+
* The indent-merge rule is load-bearing for fenced code indented past 3 spaces (e.g. inside a list
55+
* item): {@link FENCE_OPEN} only tracks fences at the document margin, so a nested fence's interior
56+
* blank lines are held together by the indent merge, not the fence tracker. Weakening that merge
57+
* would silently shatter nested fences.
5358
*/
5459
export function splitMarkdownBlocks(body: string): string[] {
5560
const lines = body.split('\n')
@@ -81,19 +86,21 @@ export function splitMarkdownBlocks(body: string): string[] {
8186
}
8287
flush()
8388

84-
const blocks: string[] = []
89+
// Build continuation runs and join each once — concatenating onto the growing block per group would be
90+
// O(n²) for one long loose list. A group continues the run when indented, or when its first line and the
91+
// group open the same marker kind (list or blockquote) — i.e. they form one loose list/quote.
92+
const runs: string[][] = []
8593
for (const group of groups) {
86-
const prev = blocks.length > 0 ? blocks[blocks.length - 1] : null
87-
const indented = /^\s/.test(group)
94+
const head = runs.length > 0 ? runs[runs.length - 1][0] : null
8895
const continues =
89-
prev !== null &&
90-
(indented ||
91-
(LIST_MARKER.test(prev) && LIST_MARKER.test(group)) ||
92-
(BLOCKQUOTE.test(prev) && BLOCKQUOTE.test(group)))
93-
if (continues) blocks[blocks.length - 1] = `${prev}\n\n${group}`
94-
else blocks.push(group)
96+
head !== null &&
97+
(/^\s/.test(group) ||
98+
(LIST_MARKER.test(head) && LIST_MARKER.test(group)) ||
99+
(BLOCKQUOTE.test(head) && BLOCKQUOTE.test(group)))
100+
if (continues) runs[runs.length - 1].push(group)
101+
else runs.push([group])
95102
}
96-
return blocks
103+
return runs.map((run) => run.join('\n\n'))
97104
}
98105

99106
/**

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

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { memo, useEffect, useRef } from 'react'
3+
import { memo, useEffect, useRef, useState } from 'react'
44
import type { JSONContent } from '@tiptap/core'
55
import type { Editor } from '@tiptap/react'
66
import { EditorContent, useEditor } from '@tiptap/react'
@@ -158,28 +158,24 @@ export function LoadedRichMarkdownEditor({
158158
onChange,
159159
onSaveShortcut,
160160
}: LoadedRichMarkdownEditorProps) {
161-
// Whether this editor mounted mid-stream. If so it starts empty + read-only and syncs the streamed
162-
// content until the stream settles; otherwise it uses the plain create-time initial-content model.
161+
// Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle.
163162
const streamingAtMountRef = useRef(isStreaming)
164163

165-
// The verdict + frontmatter locked via {@link lockSettled} at mount for a settled file, or at the
166-
// moment a stream settles (in the effect below). Null until then; reads default to read-only.
164+
// Verdict + frontmatter locked once via {@link lockSettled} (at mount when settled, else when the
165+
// stream settles below); null until then reads as read-only.
167166
const settledRef = useRef<SettledContent | null>(null)
168167
if (!streamingAtMountRef.current && settledRef.current === null) {
169168
settledRef.current = lockSettled(content)
170169
}
171170
const isEditable = canEdit && !isStreaming && (settledRef.current?.verdict ?? false)
172171

173-
// The parsed doc that seeds the editor at create time — chunked-parsed (linear) rather than handed
174-
// to the editor as a raw markdown string (whose parse is ~O(n²)). Empty when streaming: the sync
175-
// effect pushes the streamed body in via setContent (this ref is never written again).
176-
const initialContentRef = useRef<JSONContent | string>(
172+
// Seed the editor with the chunked-parsed doc (linear vs the editor's ~O(n²) markdown parse), computed
173+
// once via lazy state init — `useRef(parseMarkdownToDoc(...))` would re-parse the whole body every render.
174+
const [initialContent] = useState<JSONContent | string>(() =>
177175
streamingAtMountRef.current ? '' : parseMarkdownToDoc(splitFrontmatter(content).body)
178176
)
179-
// The frontmatter re-attached on every change. Empty until the content settles (the editor never
180-
// displays frontmatter, so a streamed doc simply shows its body). Re-derived in the settle effect
181-
// on each stream→settle, so a repeat stream re-attaches the settled doc's frontmatter, never a
182-
// stale one.
177+
// Frontmatter held aside and re-attached on every change (the editor never shows it); re-derived per
178+
// stream→settle in the settle effect, so a repeat stream uses the new doc's frontmatter, not a stale one.
183179
const frontmatterRef = useRef(settledRef.current?.frontmatter ?? '')
184180
const onChangeRef = useRef(onChange)
185181
onChangeRef.current = onChange
@@ -229,7 +225,7 @@ export function LoadedRichMarkdownEditor({
229225
autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false,
230226
immediatelyRender: false,
231227
shouldRerenderOnTransaction: false,
232-
content: initialContentRef.current,
228+
content: initialContent,
233229
editorProps: {
234230
attributes: { class: 'rich-markdown-prose' },
235231
handleKeyDown: (_view, event) => {
@@ -273,19 +269,15 @@ export function LoadedRichMarkdownEditor({
273269
})
274270
editorInstanceRef.current = editor
275271

276-
// Stream content into the editor (read-only) until it settles, then lock the verdict + frontmatter
277-
// and hand control to the user. After the hand-off, only `canEdit` changes touch the editor — the
278-
// editor owns the content, so there is no sync that could clobber a user edit.
272+
// Stream content in read-only until it settles, then lock the verdict + frontmatter and hand off; after
273+
// that only `canEdit` touches the editor (it owns the content, so no sync can clobber a user edit).
279274
const lastSyncedBodyRef = useRef<string | null>(null)
280-
// Whether the editor was streaming on the previous effect run, so the settle branch can re-lock on
281-
// each stream→settle transition. An agent can edit the same file more than once within a chat, and
282-
// `previewContextKey` (the chat id) keeps this instance mounted across those edits — so the verdict
283-
// + frontmatter must be re-derived per stream, not frozen on the first settled snapshot.
275+
// Tracks whether the previous run was streaming so the settle branch re-locks on every stream→settle:
276+
// one instance can receive several agent edits in a chat (kept mounted by `previewContextKey`), so the
277+
// verdict/frontmatter must follow the latest stream, not the first settled snapshot.
284278
const wasStreamingRef = useRef(streamingAtMountRef.current)
285-
// Coalesce streaming chunk-syncs to one re-parse per animation frame. A fast-streaming agent emits
286-
// many chunks per frame; without this each one re-parses the whole accumulating markdown
287-
// (`@tiptap/markdown`'s parse is superlinear), saturating the main thread. The editor is read-only
288-
// while streaming, so only the latest frame's content needs to render.
279+
// Coalesce streamed chunks to one re-parse per animation frame — a fast agent emits many per frame and
280+
// each would re-parse the whole accumulating body. Read-only while streaming, so only the latest renders.
289281
const pendingStreamBodyRef = useRef<string | null>(null)
290282
const streamRafRef = useRef<number | null>(null)
291283
useEffect(() => {
@@ -312,16 +304,14 @@ export function LoadedRichMarkdownEditor({
312304
})
313305
return
314306
}
315-
// A streamed frame scheduled just before settle must not land afterward and clobber the final
316-
// content, so drop it before settling.
307+
// Drop a frame scheduled just before settle so it can't land afterward and clobber the final content.
317308
if (streamRafRef.current !== null) {
318309
cancelAnimationFrame(streamRafRef.current)
319310
streamRafRef.current = null
320311
}
321-
// Settle: lock the verdict + frontmatter on the freshly-settled content. Re-lock on the initial
322-
// settle and on every later stream→settle, so a repeat agent edit gates editability + frontmatter
323-
// on the NEW content rather than a stale pre-stream snapshot. User edits never re-derive (they
324-
// keep `isStreaming`/`wasStreamingRef` false), preserving the don't-strand-edits rule.
312+
// Settle: re-lock the verdict + frontmatter on the freshly-settled content — on the first settle and
313+
// every later stream→settle, so a repeat agent edit gates on the NEW content, not a stale snapshot.
314+
// User edits never reach here (`isStreaming`/`wasStreamingRef` stay false), preserving don't-strand-edits.
325315
const isInitialSettle = settledRef.current === null
326316
if (isInitialSettle || wasStreamingRef.current) {
327317
wasStreamingRef.current = false

0 commit comments

Comments
 (0)