From 7ea2fa8bcf6ab1f0b3a7a80cd3dd94ea1e988d68 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 13:31:38 -0700 Subject: [PATCH 1/3] fix(files): keep the shared markdown parser editor unmounted --- .../rich-markdown-editor/block-image-paragraph.test.ts | 4 +--- .../file-viewer/rich-markdown-editor/markdown-parse.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.test.ts index b5db9b245ac..fa4f59f30f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.test.ts @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ import { getSchema, type JSONContent } from '@tiptap/core' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { splitBlockImageParagraph } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph' import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' import { @@ -9,8 +9,6 @@ import { } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' const schema = getSchema(createMarkdownContentExtensions()) -const cleanups: Array<() => void> = [] -afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup())) describe('block images within Markdown paragraphs', () => { it('retains a whitespace-only code span beside an image', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index 7118ab264ca..44e88a387b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -15,11 +15,19 @@ import { * `setContent`. Both are safe because all access is synchronous and single-threaded — each call fully * completes before the next — so no call ever observes another's partial state. One bounded instance * for the session, not a per-call allocation. + * + * It is never mounted: a mounted view renders every document into the DOM and its mutation observer + * schedules flush timers nothing can cancel, which fire after a jsdom test environment is torn down. + * TipTap installs extension plugins only when it mounts, so they are installed here the same way, keeping + * normalization such as the trailing paragraph identical to the live editor. */ let parser: Editor | null = null function parserEditor(): Editor { - if (!parser) parser = new Editor({ extensions: createMarkdownContentExtensions() }) + if (!parser) { + parser = new Editor({ element: null, extensions: createMarkdownContentExtensions() }) + parser.view.updateState(parser.state.reconfigure({ plugins: parser.extensionManager.plugins })) + } return parser } From 0eae82e9b6f5e4b95b813f2d1225d293c88792a8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 13:38:56 -0700 Subject: [PATCH 2/3] fix(files): serialize without deferred work and guard it with a regression test --- .../markdown-parse.test.ts | 23 ++++++++++++++++++- .../rich-markdown-editor/markdown-parse.ts | 3 ++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts index ded71c38906..ac6835f526a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { Editor } from '@tiptap/core' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' import { parseMarkdownToDoc, @@ -255,3 +255,24 @@ describe('chunked parse — property test over randomized documents', () => { // the file, and adding it is what pushed both past 30s under whole-suite parallelism. }, 60000) }) + +describe('serializeMarkdownBody', () => { + /** + * Serializing is synchronous, so any timer it leaves behind outlives the call — and a DOM-touching + * one fires after a jsdom environment is torn down, failing whichever suite finished first. A fresh + * module instance makes the shared editor get built under the fake clock, whatever ran before. + */ + it('leaves no deferred work behind, including when it builds the shared editor', async () => { + vi.resetModules() + vi.useFakeTimers() + try { + const fresh = await import( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' + ) + fresh.serializeMarkdownBody('# Before ![Logo](/logo.png "Title") after\n\n- a\n- b') + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts index 44e88a387b3..b7f29ab4ea5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse.ts @@ -352,10 +352,11 @@ export function serializeMarkdownBody(body: string): string { * editor applies — output identical to `editor.getMarkdown()`. The server-side collab-doc converter * uses this to project a Yjs doc back to markdown through the exact client engine (parity by * construction), so it must stay the single serialize path (do not inline `getMarkdown` elsewhere). + * Nothing listens to the scratchpad, so it emits no update, which would only defer core bookkeeping. */ export function serializeDocToMarkdown(doc: JSONContent): string { const editor = parserEditor() - editor.commands.setContent(doc, { contentType: 'json' }) + editor.commands.setContent(doc, { contentType: 'json', emitUpdate: false }) return editor.getMarkdown() } From 4e31bbc44822ac5136f072aadf5ef12a8b259dcf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 13:51:51 -0700 Subject: [PATCH 3/3] fix(files): destroy every editor a markdown paste test mounts --- .../markdown-paste.test.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts index b85df9ecf9f..d6520dd2b1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts @@ -9,19 +9,21 @@ import { afterEach, describe, expect, it } from 'vitest' import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' import { MarkdownPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste' -let editor: Editor | null = null +const editors: Editor[] = [] afterEach(() => { - editor?.destroy() - editor = null + for (const editor of editors.splice(0)) editor.destroy() }) +/** Every editor is destroyed after its test, including a second one a single test mounts. */ function mount(editable = true): Editor { - return new Editor({ + const editor = new Editor({ extensions: [...createMarkdownContentExtensions(), MarkdownPaste], enablePasteRules: false, editable, }) + editors.push(editor) + return editor } function dispatchPaste( @@ -71,7 +73,7 @@ function transformHtml(ed: Editor, html: string): string { describe('markdown paste', () => { it('keeps pasted markdown literal inside a code block', () => { - editor = mount() + const editor = mount() editor.commands.setContent('```js\ncode here\n```', { contentType: 'markdown' }) editor.commands.setTextSelection(5) expect(editor.isActive('codeBlock')).toBe(true) @@ -79,7 +81,7 @@ describe('markdown paste', () => { }) it('keeps pasted markdown literal inside inline code', () => { - editor = mount() + const editor = mount() editor.commands.setContent('a `codehere` b', { contentType: 'markdown' }) editor.commands.setTextSelection(6) expect(editor.isActive('code')).toBe(true) @@ -87,13 +89,13 @@ describe('markdown paste', () => { }) it('rejects the paste entirely in a read-only editor', () => { - editor = mount(false) + const editor = mount(false) expect(paste(editor, '# heading\n\n- one\n- two')).toBe(false) expect(editor.getText()).toBe('') }) it('does not flatten a rich table containing literal Markdown-shaped cell text', () => { - editor = mount() + const editor = mount() dispatchPaste( editor, 'Label\tValue\n**literal**\t42', @@ -105,7 +107,7 @@ describe('markdown paste', () => { }) it('preserves the structural blocks of a multi-block document, in order, on paste', () => { - editor = mount() + const editor = mount() expect(paste(editor, '# Title\n\nA paragraph.\n\n- a\n- b\n\n> quote')).toBe(true) const structural = (editor.getJSON().content ?? []) .map((node) => node.type) @@ -114,7 +116,7 @@ describe('markdown paste', () => { }) it('strips
a
' const cleaned = transformHtml(editor, gsheets) @@ -125,14 +127,14 @@ describe('markdown paste', () => { }) it('strips nested/repeated b')).toBe('ab') const deeplyNested = `a${''.repeat(50)}b` expect(transformHtml(editor, deeplyNested)).toBe('ab') }) it('drops an unterminated ')).toBe('') @@ -145,7 +147,7 @@ describe('linkify a selection on URL paste', () => { from = 1, to = 10 ): { handled: boolean; href?: string; text: string } { - editor = mount() + const editor = mount() editor.commands.setContent('select me here', { contentType: 'markdown' }) editor.commands.setTextSelection({ from, to }) const handled = paste(editor, pasted)