Skip to content

Commit 8bebab9

Browse files
authored
fix(files): keep the shared markdown parser editor unmounted (#8308)
* fix(files): keep the shared markdown parser editor unmounted * fix(files): serialize without deferred work and guard it with a regression test * fix(files): destroy every editor a markdown paste test mounts
1 parent 9a21fbb commit 8bebab9

4 files changed

Lines changed: 49 additions & 19 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.test.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** @vitest-environment jsdom */
22
import { getSchema, type JSONContent } from '@tiptap/core'
3-
import { afterEach, describe, expect, it } from 'vitest'
3+
import { describe, expect, it } from 'vitest'
44
import { splitBlockImageParagraph } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph'
55
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
66
import {
@@ -9,8 +9,6 @@ import {
99
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse'
1010

1111
const schema = getSchema(createMarkdownContentExtensions())
12-
const cleanups: Array<() => void> = []
13-
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
1412

1513
describe('block images within Markdown paragraphs', () => {
1614
it('retains a whitespace-only code span beside an image', () => {

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment jsdom
33
*/
44
import { Editor } from '@tiptap/core'
5-
import { afterAll, describe, expect, it } from 'vitest'
5+
import { afterAll, describe, expect, it, vi } from 'vitest'
66
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
77
import {
88
parseMarkdownToDoc,
@@ -255,3 +255,24 @@ describe('chunked parse — property test over randomized documents', () => {
255255
// the file, and adding it is what pushed both past 30s under whole-suite parallelism.
256256
}, 60000)
257257
})
258+
259+
describe('serializeMarkdownBody', () => {
260+
/**
261+
* Serializing is synchronous, so any timer it leaves behind outlives the call — and a DOM-touching
262+
* one fires after a jsdom environment is torn down, failing whichever suite finished first. A fresh
263+
* module instance makes the shared editor get built under the fake clock, whatever ran before.
264+
*/
265+
it('leaves no deferred work behind, including when it builds the shared editor', async () => {
266+
vi.resetModules()
267+
vi.useFakeTimers()
268+
try {
269+
const fresh = await import(
270+
'@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse'
271+
)
272+
fresh.serializeMarkdownBody('# Before ![Logo](/logo.png "Title") after\n\n- a\n- b')
273+
expect(vi.getTimerCount()).toBe(0)
274+
} finally {
275+
vi.useRealTimers()
276+
}
277+
})
278+
})

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,19 @@ import {
1515
* `setContent`. Both are safe because all access is synchronous and single-threaded — each call fully
1616
* completes before the next — so no call ever observes another's partial state. One bounded instance
1717
* for the session, not a per-call allocation.
18+
*
19+
* It is never mounted: a mounted view renders every document into the DOM and its mutation observer
20+
* schedules flush timers nothing can cancel, which fire after a jsdom test environment is torn down.
21+
* TipTap installs extension plugins only when it mounts, so they are installed here the same way, keeping
22+
* normalization such as the trailing paragraph identical to the live editor.
1823
*/
1924
let parser: Editor | null = null
2025

2126
function parserEditor(): Editor {
22-
if (!parser) parser = new Editor({ extensions: createMarkdownContentExtensions() })
27+
if (!parser) {
28+
parser = new Editor({ element: null, extensions: createMarkdownContentExtensions() })
29+
parser.view.updateState(parser.state.reconfigure({ plugins: parser.extensionManager.plugins }))
30+
}
2331
return parser
2432
}
2533

@@ -344,10 +352,11 @@ export function serializeMarkdownBody(body: string): string {
344352
* editor applies — output identical to `editor.getMarkdown()`. The server-side collab-doc converter
345353
* uses this to project a Yjs doc back to markdown through the exact client engine (parity by
346354
* construction), so it must stay the single serialize path (do not inline `getMarkdown` elsewhere).
355+
* Nothing listens to the scratchpad, so it emits no update, which would only defer core bookkeeping.
347356
*/
348357
export function serializeDocToMarkdown(doc: JSONContent): string {
349358
const editor = parserEditor()
350-
editor.commands.setContent(doc, { contentType: 'json' })
359+
editor.commands.setContent(doc, { contentType: 'json', emitUpdate: false })
351360
return editor.getMarkdown()
352361
}
353362

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

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,21 @@ import { afterEach, describe, expect, it } from 'vitest'
99
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
1010
import { MarkdownPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste'
1111

12-
let editor: Editor | null = null
12+
const editors: Editor[] = []
1313

1414
afterEach(() => {
15-
editor?.destroy()
16-
editor = null
15+
for (const editor of editors.splice(0)) editor.destroy()
1716
})
1817

18+
/** Every editor is destroyed after its test, including a second one a single test mounts. */
1919
function mount(editable = true): Editor {
20-
return new Editor({
20+
const editor = new Editor({
2121
extensions: [...createMarkdownContentExtensions(), MarkdownPaste],
2222
enablePasteRules: false,
2323
editable,
2424
})
25+
editors.push(editor)
26+
return editor
2527
}
2628

2729
function dispatchPaste(
@@ -71,29 +73,29 @@ function transformHtml(ed: Editor, html: string): string {
7173

7274
describe('markdown paste', () => {
7375
it('keeps pasted markdown literal inside a code block', () => {
74-
editor = mount()
76+
const editor = mount()
7577
editor.commands.setContent('```js\ncode here\n```', { contentType: 'markdown' })
7678
editor.commands.setTextSelection(5)
7779
expect(editor.isActive('codeBlock')).toBe(true)
7880
expect(paste(editor, '[link](https://example.com)')).toBe(false)
7981
})
8082

8183
it('keeps pasted markdown literal inside inline code', () => {
82-
editor = mount()
84+
const editor = mount()
8385
editor.commands.setContent('a `codehere` b', { contentType: 'markdown' })
8486
editor.commands.setTextSelection(6)
8587
expect(editor.isActive('code')).toBe(true)
8688
expect(paste(editor, '*italic*')).toBe(false)
8789
})
8890

8991
it('rejects the paste entirely in a read-only editor', () => {
90-
editor = mount(false)
92+
const editor = mount(false)
9193
expect(paste(editor, '# heading\n\n- one\n- two')).toBe(false)
9294
expect(editor.getText()).toBe('')
9395
})
9496

9597
it('does not flatten a rich table containing literal Markdown-shaped cell text', () => {
96-
editor = mount()
98+
const editor = mount()
9799
dispatchPaste(
98100
editor,
99101
'Label\tValue\n**literal**\t42',
@@ -105,7 +107,7 @@ describe('markdown paste', () => {
105107
})
106108

107109
it('preserves the structural blocks of a multi-block document, in order, on paste', () => {
108-
editor = mount()
110+
const editor = mount()
109111
expect(paste(editor, '# Title\n\nA paragraph.\n\n- a\n- b\n\n> quote')).toBe(true)
110112
const structural = (editor.getJSON().content ?? [])
111113
.map((node) => node.type)
@@ -114,7 +116,7 @@ describe('markdown paste', () => {
114116
})
115117

116118
it('strips <style>/<script> from pasted HTML so their text never leaks into the doc', () => {
117-
editor = mount()
119+
const editor = mount()
118120
const gsheets =
119121
'<google-sheets-html-origin><style>td{mso-1:2}</style><table><tr><td>a</td></tr></table></google-sheets-html-origin>'
120122
const cleaned = transformHtml(editor, gsheets)
@@ -125,14 +127,14 @@ describe('markdown paste', () => {
125127
})
126128

127129
it('strips nested/repeated <script> tags in a single pass, even deeply nested', () => {
128-
editor = mount()
130+
const editor = mount()
129131
expect(transformHtml(editor, 'a<script>x<script>y</script></script>b')).toBe('ab')
130132
const deeplyNested = `a${'<script>'.repeat(50)}x${'</script>'.repeat(50)}b`
131133
expect(transformHtml(editor, deeplyNested)).toBe('ab')
132134
})
133135

134136
it('drops an unterminated <script>/<style> and everything after it, without duplicating the prefix', () => {
135-
editor = mount()
137+
const editor = mount()
136138
expect(transformHtml(editor, 'abc<script>never-closes')).toBe('abc')
137139
expect(transformHtml(editor, 'abc<style>never-closes')).toBe('abc')
138140
expect(transformHtml(editor, '<script>x<script>y</script>')).toBe('')
@@ -145,7 +147,7 @@ describe('linkify a selection on URL paste', () => {
145147
from = 1,
146148
to = 10
147149
): { handled: boolean; href?: string; text: string } {
148-
editor = mount()
150+
const editor = mount()
149151
editor.commands.setContent('select me here', { contentType: 'markdown' })
150152
editor.commands.setTextSelection({ from, to })
151153
const handled = paste(editor, pasted)

0 commit comments

Comments
 (0)