snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import type { Plugin } from '@tiptap/pm/state'
|
||||
import { ySyncPluginKey } from '@tiptap/y-tiptap'
|
||||
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import Collaboration from '@tiptap/extension-collaboration'
|
||||
|
||||
describe('filterInvalidContent', () => {
|
||||
let editor: Editor | null = null
|
||||
let el: HTMLElement | null = null
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
el?.remove()
|
||||
editor = null
|
||||
el = null
|
||||
})
|
||||
|
||||
const createCollabEditor = async (
|
||||
opts: {
|
||||
onContentError?: (args: { disableCollaboration: () => void }) => void
|
||||
} = {},
|
||||
) => {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
|
||||
editor = new Editor({
|
||||
element: el,
|
||||
extensions: [Document, Paragraph, Text, Collaboration.configure({ document: ydoc })],
|
||||
enableContentCheck: true,
|
||||
onContentError: opts.onContentError ?? (() => {}),
|
||||
})
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
|
||||
return { editor, el, ydoc }
|
||||
}
|
||||
|
||||
const findFilterPlugin = (e: Editor): Plugin | undefined =>
|
||||
e.state.plugins.find(p => p.spec.filterTransaction && p.key.includes('filterInvalidContent'))
|
||||
|
||||
it('emits contentError and disables collaboration on invalid Yjs content while allowing the transaction', async () => {
|
||||
let contentErrorCalled = false
|
||||
|
||||
const { editor: ed } = await createCollabEditor({
|
||||
onContentError: () => {
|
||||
contentErrorCalled = true
|
||||
},
|
||||
})
|
||||
|
||||
const plugin = findFilterPlugin(ed)
|
||||
|
||||
expect(plugin).toBeDefined()
|
||||
|
||||
const tr = ed.state.tr.insertText('x', 1)
|
||||
|
||||
tr.setMeta(ySyncPluginKey, { binding: true })
|
||||
|
||||
vi.spyOn(tr.doc, 'check').mockImplementation(() => {
|
||||
throw new RangeError('Invalid content for node doc')
|
||||
})
|
||||
|
||||
const result = plugin!.spec.filterTransaction!(tr, ed.state)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(contentErrorCalled).toBe(true)
|
||||
expect(ed.storage.collaboration.isDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('allows local (non-Yjs) transactions through', async () => {
|
||||
const { editor: ed } = await createCollabEditor({
|
||||
onContentError: () => {
|
||||
throw new Error('contentError should not fire for local transactions')
|
||||
},
|
||||
})
|
||||
|
||||
ed.commands.insertContent('hello')
|
||||
expect(ed.getText()).toContain('hello')
|
||||
})
|
||||
|
||||
it('blocks Yjs transactions when isDisabled is true', async () => {
|
||||
const { editor: ed } = await createCollabEditor()
|
||||
|
||||
ed.storage.collaboration.isDisabled = true
|
||||
|
||||
const docBefore = ed.state.doc.toJSON()
|
||||
|
||||
const tr = ed.state.tr.insertText('injected', 1)
|
||||
|
||||
tr.setMeta(ySyncPluginKey, { binding: true })
|
||||
ed.view.dispatch(tr)
|
||||
|
||||
expect(ed.state.doc.toJSON()).toEqual(docBefore)
|
||||
})
|
||||
|
||||
it('allows Yjs transactions that do not change the doc', async () => {
|
||||
const { editor: ed } = await createCollabEditor({
|
||||
onContentError: () => {
|
||||
throw new Error('contentError should not fire for metadata-only transactions')
|
||||
},
|
||||
})
|
||||
|
||||
const plugin = findFilterPlugin(ed)
|
||||
|
||||
expect(plugin).toBeDefined()
|
||||
|
||||
const tr = ed.state.tr
|
||||
|
||||
tr.setMeta(ySyncPluginKey, { binding: true })
|
||||
|
||||
expect(tr.docChanged).toBe(false)
|
||||
|
||||
const result = plugin!.spec.filterTransaction!(tr, ed.state)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('emits contentError with disableCollaboration callback', async () => {
|
||||
let disableCollab: (() => void) | null = null
|
||||
|
||||
const { editor: ed, ydoc } = await createCollabEditor({
|
||||
onContentError: args => {
|
||||
disableCollab = args.disableCollaboration
|
||||
},
|
||||
})
|
||||
|
||||
const plugin = findFilterPlugin(ed)
|
||||
|
||||
expect(plugin).toBeDefined()
|
||||
|
||||
const tr = ed.state.tr.insertText('x', 1)
|
||||
|
||||
tr.setMeta(ySyncPluginKey, { binding: true })
|
||||
vi.spyOn(tr.doc, 'check').mockImplementation(() => {
|
||||
throw new RangeError('Invalid content')
|
||||
})
|
||||
|
||||
plugin!.spec.filterTransaction!(tr, ed.state)
|
||||
|
||||
expect(disableCollab).not.toBeNull()
|
||||
|
||||
const destroySpy = vi.spyOn(ydoc, 'destroy')
|
||||
|
||||
disableCollab!()
|
||||
expect(destroySpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers from invalid initial content via disableCollaboration + setContent (#7493)', () => {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
|
||||
let ranHandler = false
|
||||
|
||||
expect(() => {
|
||||
editor = new Editor({
|
||||
element: el as HTMLElement,
|
||||
extensions: [Document, Paragraph, Text, Collaboration.configure({ document: ydoc })],
|
||||
content: '<p>keepme</p><foobar></foobar>',
|
||||
enableContentCheck: true,
|
||||
onContentError: ({ editor: contentErrorEditor, disableCollaboration }) => {
|
||||
ranHandler = true
|
||||
disableCollaboration()
|
||||
contentErrorEditor.commands.setContent('<p>recovered</p>')
|
||||
},
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(ranHandler).toBe(true)
|
||||
expect(editor!.storage.collaboration).toBeUndefined()
|
||||
expect(editor!.getText()).toBe('recovered')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import Collaboration from '@tiptap/extension-collaboration'
|
||||
|
||||
/**
|
||||
* Regression test for the memory leak where destroying an editor while the
|
||||
* Y.Doc/provider lives on (e.g. several editors sharing one provider) left the
|
||||
* editor reachable.
|
||||
*
|
||||
* Root cause: Yjs' UndoManager registers a `doc.on('destroy', ...)` listener in
|
||||
* its constructor that `UndoManager.destroy()` never removes. The long-lived doc
|
||||
* then keeps the UndoManager — and through it the whole editor — alive.
|
||||
*
|
||||
* The extension now removes that listener on destroy. We assert the doc's
|
||||
* 'destroy' listener count returns to its pre-creation baseline, which is a
|
||||
* deterministic proxy for the listener no longer pinning the editor.
|
||||
*/
|
||||
describe('extension-collaboration memory leak', () => {
|
||||
let editor: Editor | null = null
|
||||
let el: HTMLElement | null = null
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
el?.remove()
|
||||
editor = null
|
||||
el = null
|
||||
})
|
||||
|
||||
const docDestroyListenerCount = (ydoc: Y.Doc) => {
|
||||
// lib0 ObservableV2 stores listeners in a Map<eventName, Set<fn>>
|
||||
// oxlint-disable-next-line no-underscore-dangle
|
||||
const observers = (ydoc as any)._observers?.get('destroy') as Set<unknown> | undefined
|
||||
return observers ? observers.size : 0
|
||||
}
|
||||
|
||||
it("removes the UndoManager's doc 'destroy' listener when the editor is destroyed", () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const baseline = docDestroyListenerCount(ydoc)
|
||||
|
||||
el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
|
||||
editor = new Editor({
|
||||
element: el,
|
||||
extensions: [Document, Paragraph, Text, Collaboration.configure({ document: ydoc })],
|
||||
})
|
||||
|
||||
// An edit populates the undo stack, mirroring real usage.
|
||||
editor.commands.insertContent('<p>hello world</p>')
|
||||
|
||||
// The UndoManager added its listener during construction.
|
||||
expect(docDestroyListenerCount(ydoc)).toBeGreaterThan(baseline)
|
||||
|
||||
editor.destroy()
|
||||
editor = null
|
||||
|
||||
// After destroy, the doc is back to its baseline — the listener that pinned
|
||||
// the editor to the long-lived doc is gone.
|
||||
expect(docDestroyListenerCount(ydoc)).toBe(baseline)
|
||||
})
|
||||
|
||||
it('does not accumulate doc listeners across many editor lifecycles on a shared doc', () => {
|
||||
const ydoc = new Y.Doc()
|
||||
const baseline = docDestroyListenerCount(ydoc)
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
|
||||
const instance = new Editor({
|
||||
element,
|
||||
extensions: [Document, Paragraph, Text, Collaboration.configure({ document: ydoc })],
|
||||
})
|
||||
instance.commands.insertContent(`<p>edit ${i}</p>`)
|
||||
instance.destroy()
|
||||
element.remove()
|
||||
}
|
||||
|
||||
expect(docDestroyListenerCount(ydoc)).toBe(baseline)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user