Files
upstream-tiptap/packages/extension-unique-id/tests/integration/uniqueIdCollaboration.test.ts
upstream-mirror 083a46e93b
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
snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:11:02 +05:00

228 lines
6.8 KiB
TypeScript

// @vitest-environment happy-dom
import { type Extensions, Editor, Extension } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { UniqueID } from '../../src/unique-id.js'
const ATTR = 'uniqueId'
let idCounter = 0
function generateID() {
idCounter += 1
return `test-id-${idCounter}`
}
function createEditor(options: { withCollaboration?: boolean } = {}) {
idCounter = 0
const extensions: Extensions = [
Document,
Paragraph,
Text,
UniqueID.configure({
attributeName: ATTR,
types: ['paragraph'],
generateID,
}),
]
if (options.withCollaboration) {
extensions.push(Extension.create({ name: 'collaboration' }))
}
return new Editor({ extensions })
}
/**
* Minimal stand-in for a Hocuspocus/TiptapCloud provider's event emitter,
* tracking how many `synced` listeners are currently attached.
*/
function createMockProvider() {
const listeners: Record<string, ((...args: any[]) => void)[]> = {}
return {
on(event: string, cb: (...args: any[]) => void) {
;(listeners[event] ??= []).push(cb)
},
off(event: string, cb: (...args: any[]) => void) {
listeners[event] = (listeners[event] ?? []).filter(fn => fn !== cb)
},
emit(event: string) {
;(listeners[event] ?? []).slice().forEach(fn => fn())
},
listenerCount(event: string) {
return (listeners[event] ?? []).length
},
}
}
/**
* Mirrors the common collaboration setup: the Collaboration extension carries
* no provider (only the Y.Doc), while the provider lives on CollaborationCaret.
*/
function createCollabEditorWithProvider(provider: ReturnType<typeof createMockProvider>) {
idCounter = 0
return new Editor({
extensions: [
Document,
Paragraph,
Text,
UniqueID.configure({
attributeName: ATTR,
types: ['paragraph'],
generateID,
}),
Extension.create({ name: 'collaboration' }),
Extension.create({ name: 'collaborationCaret' }).configure({ provider }),
],
})
}
function getParagraphIds(editor: Editor): (string | null)[] {
const ids: (string | null)[] = []
editor.state.doc.descendants(node => {
if (node.type.name === 'paragraph') {
ids.push(node.attrs[ATTR] ?? null)
}
})
return ids
}
describe('UniqueID collaboration handling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('does not assign IDs immediately when collaboration extension is present without provider', () => {
const editor = createEditor({ withCollaboration: true })
// Flush the setTimeout(0) that triggers onCreate
vi.runAllTimers()
const ids = getParagraphIds(editor)
// All IDs should still be null because we deferred hashing
expect(ids.length).toBeGreaterThan(0)
expect(ids.every(id => id === null)).toBe(true)
editor.destroy()
})
it('assigns IDs after y-sync$ transaction when collaboration has no provider', () => {
const editor = createEditor({ withCollaboration: true })
// Flush the setTimeout(0) that triggers onCreate
vi.runAllTimers()
// Verify no IDs yet
expect(getParagraphIds(editor).every(id => id === null)).toBe(true)
// Simulate a y-sync$ transaction (like Yjs would dispatch after syncing)
const { tr } = editor.state
tr.setMeta('y-sync$', true)
// Insert a space to make the transaction have a doc change,
// which triggers appendTransaction
tr.insertText(' ', 1)
editor.view.dispatch(tr)
const ids = getParagraphIds(editor)
// All paragraphs should now have unique IDs
expect(ids.length).toBeGreaterThan(0)
expect(ids.every(id => id !== null && typeof id === 'string')).toBe(true)
// IDs should be unique
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
editor.destroy()
})
it('assigns IDs when y-sync$ transaction arrives before onCreate runs', () => {
// Do NOT flush timers yet — onCreate has not run
const editor = createEditor({ withCollaboration: true })
// Dispatch a y-sync$ transaction before onCreate (before vi.runAllTimers)
const { tr } = editor.state
tr.setMeta('y-sync$', true)
tr.insertText(' ', 1)
editor.view.dispatch(tr)
// IDs should already be assigned from the appendTransaction handler
const idsBeforeOnCreate = getParagraphIds(editor)
expect(idsBeforeOnCreate.length).toBeGreaterThan(0)
expect(idsBeforeOnCreate.every(id => id !== null && typeof id === 'string')).toBe(true)
// Now flush timers so onCreate runs — should not cause issues
vi.runAllTimers()
// IDs should still be present and unchanged
const idsAfterOnCreate = getParagraphIds(editor)
expect(idsAfterOnCreate).toEqual(idsBeforeOnCreate)
editor.destroy()
})
it('detaches the provider "synced" listener when destroyed before syncing', () => {
const provider = createMockProvider()
const editor = createCollabEditorWithProvider(provider)
// Flush the setTimeout(0) that triggers onCreate, which attaches the listener
vi.runAllTimers()
// While waiting for the provider to sync, exactly one listener is attached
expect(provider.listenerCount('synced')).toBe(1)
// Destroying before the provider syncs must detach the listener — otherwise
// the createIds closure keeps the whole editor reachable from the shared
// provider's emitter (the reported memory leak).
editor.destroy()
expect(provider.listenerCount('synced')).toBe(0)
})
it('detaches the provider "synced" listener after the provider syncs', () => {
const provider = createMockProvider()
const editor = createCollabEditorWithProvider(provider)
vi.runAllTimers()
expect(provider.listenerCount('synced')).toBe(1)
// Provider syncs: createIds runs and removes its own listener
provider.emit('synced')
expect(provider.listenerCount('synced')).toBe(0)
// All paragraphs should now have unique IDs
const ids = getParagraphIds(editor)
expect(ids.length).toBeGreaterThan(0)
expect(ids.every(id => id !== null && typeof id === 'string')).toBe(true)
// A later destroy must not throw or double-detach
editor.destroy()
expect(provider.listenerCount('synced')).toBe(0)
})
it('still assigns IDs immediately when no collaboration extension', () => {
const editor = createEditor({ withCollaboration: false })
// Flush the setTimeout(0) that triggers onCreate
vi.runAllTimers()
const ids = getParagraphIds(editor)
// IDs should already be assigned (no collaboration = immediate)
expect(ids.length).toBeGreaterThan(0)
expect(ids.every(id => id !== null && typeof id === 'string')).toBe(true)
editor.destroy()
})
})