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

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-unique-id
[![Version](https://img.shields.io/npm/v/@tiptap/extension-unique-id.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-unique-id)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-unique-id.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-unique-id.svg)](https://www.npmjs.com/package/@tiptap/extension-unique-id)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,55 @@
{
"name": "@tiptap/extension-unique-id",
"version": "3.30.3",
"description": "unique id extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev/api/extensions/unique-id",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-unique-id"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"dependencies": {
"uuid": "^14.0.0"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/pm": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*"
}
}

View File

@@ -0,0 +1,79 @@
import type { Extensions, JSONContent } from '@tiptap/core'
import { findChildren, getSchema } from '@tiptap/core'
import { Node as PMNode } from '@tiptap/pm/model'
import { EditorState } from '@tiptap/pm/state'
import type { UniqueID } from './unique-id.js'
/**
* Creates a new document with unique IDs added to the nodes. Does the same
* thing as the UniqueID extension, but without the need to create an `Editor`
* instance. This lets you add unique IDs to the document in the server.
*
* When you call it, include the `UniqueID` extension in the `extensions` array.
* The configuration from the `UniqueID` extension will be picked up
* automatically, including its configuration options like `types` and
* `attributeName`.
*
* @see `UniqueID` extension for more information.
*
* @throws {Error} If the `UniqueID` extension is not found in the extensions array.
*
* @example
* const doc = {
* type: 'doc',
* content: [
* { type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }] }
* ]
* }
* const newDoc = addUniqueIds(doc, [StarterKit, UniqueID.configure({ types: ['paragraph', 'heading'] })])
* console.log(newDoc)
* // Result:
* // {
* // type: 'doc',
* // content: [
* // { type: 'paragraph', content: [{ type: 'text', text: 'Hello, world!' }], id: '123' }
* // ]
* // }
*
* @param doc - A Tiptap JSON document to add unique IDs to.
* @param extensions - The extensions to use. Must include the `UniqueID` extension.
* @returns The updated Tiptap JSON document, with the unique IDs added to the nodes.
*/
export function generateUniqueIds(doc: JSONContent, extensions: Extensions): JSONContent {
// Find the UniqueID extension in the extensions array. If it's not found, throw an error.
const uniqueIDExtension = extensions.find(ext => ext.name === 'uniqueID') as
| typeof UniqueID
| undefined
if (!uniqueIDExtension) {
throw new Error('UniqueID extension not found in the extensions array')
}
// Convert the JSON content to a ProseMirror node
const schema = getSchema([
...extensions.filter(ext => ext.name !== 'uniqueID'),
uniqueIDExtension,
])
const { types: configuredTypes, attributeName, generateID } = uniqueIDExtension.options
const types =
configuredTypes === 'all'
? Object.keys(schema.nodes).filter(type => type !== 'doc' && type !== 'text')
: configuredTypes
const contentNode = PMNode.fromJSON(schema, doc)
// Find nodes that don't have a unique ID
const nodesWithoutId = findChildren(contentNode, node => {
return !node.attrs[attributeName] && types.includes(node.type.name)
})
// Edit the document to add unique IDs to the nodes that don't have a unique ID
let tr = EditorState.create({
doc: contentNode,
}).tr
// oxlint-disable-next-line no-restricted-syntax
for (const { node, pos } of nodesWithoutId) {
tr = tr.setNodeAttribute(pos, attributeName, generateID({ node, pos }))
}
// Return the updated document
return tr.doc.toJSON()
}

View File

@@ -0,0 +1,17 @@
/**
* Returns a list of duplicated items within an array.
*/
export function findDuplicates(items: any[]): any[] {
const seen = new Set()
const duplicates = new Set<any>()
items.forEach(item => {
if (seen.has(item)) {
duplicates.add(item)
} else {
seen.add(item)
}
})
return Array.from(duplicates)
}

View File

@@ -0,0 +1,6 @@
import { UniqueID } from './unique-id.js'
export * from './generate-unique-ids.js'
export * from './unique-id.js'
export default UniqueID

View File

@@ -0,0 +1,45 @@
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
import { UniqueID } from './unique-id.js'
describe('UniqueID', () => {
let editor: Editor
beforeEach(async () => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [StarterKit, UniqueID.configure({ types: ['heading', 'paragraph'] })],
content: '<h1>Heading</h1><p>Paragraph one.</p><p>Paragraph two.</p>',
})
// UniqueID generates initial ids in onCreate via setTimeout(0)
await new Promise(resolve => {
setTimeout(resolve, 0)
})
})
afterEach(() => {
editor.destroy()
})
it('assigns a unique id to headings', () => {
const heading = editor.view.dom.querySelector('h1')
expect(heading?.getAttribute('data-id')).toMatch(/.+/)
})
it('assigns a unique id to paragraphs', () => {
const paragraphs = editor.view.dom.querySelectorAll('p')
paragraphs.forEach(p => {
expect(p.getAttribute('data-id')).toMatch(/.+/)
})
})
it('gives different ids to different nodes', () => {
const paragraphs = Array.from(editor.view.dom.querySelectorAll('p'))
const ids = paragraphs.map(p => p.getAttribute('data-id'))
expect(new Set(ids).size).toBe(ids.length)
})
})

View File

@@ -0,0 +1,469 @@
import {
type Extensions,
combineTransactionSteps,
Extension,
findChildren,
findChildrenInRange,
getChangedRanges,
splitExtensions,
} from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { Fragment, Slice } from '@tiptap/pm/model'
import type { Transaction } from '@tiptap/pm/state'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { v4 as uuidv4 } from 'uuid'
import { findDuplicates } from './helpers/findDuplicates.js'
export type UniqueIDGenerationContext = {
node: PMNode
pos: number
}
export interface UniqueIDOptions {
/**
* The name of the attribute to add the unique ID to.
* @default "id"
*/
attributeName: string
/**
* The types of nodes to add unique IDs to.
* Use `"all"` to add IDs to every node type except `doc` and `text`.
* @default []
*/
types: string[] | 'all'
/**
* The function that generates the unique ID. By default, a UUID v4 is
* generated. However, you can provide your own function to generate the
* unique ID based on the node type and the position.
*/
generateID: (ctx: UniqueIDGenerationContext) => any
/**
* Ignore some mutations, for example applied from other users through the collaboration plugin.
*
* @default null
*/
filterTransaction: ((transaction: Transaction) => boolean) | null
/**
* Whether to update the document by adding unique IDs to the nodes. Set this
* property to `false` if the document is in `readonly` mode, is immutable, or
* you don't want it to be modified.
*
* @default true
*/
updateDocument: boolean
}
const resolveTypes = (types: UniqueIDOptions['types'], extensions: Extensions): string[] => {
if (types !== 'all') {
return types
}
const { nodeExtensions } = splitExtensions(extensions)
return nodeExtensions
.map(extension => extension.name)
.filter(type => type !== 'doc' && type !== 'text')
}
export const UniqueID = Extension.create<UniqueIDOptions>({
name: 'uniqueID',
// we’ll set a very high priority to make sure this runs first
// and is compatible with `appendTransaction` hooks of other extensions
priority: 10000,
addOptions() {
return {
attributeName: 'id',
types: [],
generateID: () => uuidv4(),
filterTransaction: null,
updateDocument: true,
}
},
/**
* Extension storage for coordination between `addProseMirrorPlugins` and `appendTransaction`.
* `needsInitialIdGeneration` is set to `true` when the Collaboration extension is
* detected but no provider is available in its options, deferring ID creation
* to the first `y-sync$` transaction.
*/
addStorage() {
return {
needsInitialIdGeneration: false,
}
},
addGlobalAttributes() {
const types = resolveTypes(this.options.types, this.extensions)
return [
{
types,
attributes: {
[this.options.attributeName]: {
default: null,
parseHTML: element => element.getAttribute(`data-${this.options.attributeName}`),
renderHTML: attributes => {
if (!attributes[this.options.attributeName]) {
return {}
}
return {
[`data-${this.options.attributeName}`]: attributes[this.options.attributeName],
}
},
},
},
},
]
},
// check initial content for missing ids
onCreate() {
if (!this.options.updateDocument) {
return
}
const collaboration = this.editor.extensionManager.extensions.find(
ext => ext.name === 'collaboration',
)
const collaborationCaret = this.editor.extensionManager.extensions.find(
ext => ext.name === 'collaborationCaret',
)
const collabExtensions = [collaboration, collaborationCaret].filter(Boolean)
const collab = collabExtensions.find(ext => ext?.options?.provider)
const provider = collab?.options?.provider
const createIds = () => {
const { view, state } = this.editor
const { tr, doc } = state
const types = resolveTypes(this.options.types, this.editor.extensionManager.extensions)
const { attributeName, generateID } = this.options
const nodesWithoutId = findChildren(doc, node => {
return types.includes(node.type.name) && node.attrs[attributeName] === null
})
nodesWithoutId.forEach(({ node, pos }) => {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generateID({ node, pos }),
})
})
tr.setMeta('addToHistory', false)
view.dispatch(tr)
if (provider) {
provider.off('synced', createIds)
}
}
/**
* We need to handle collaboration a bit different here
* because we can't automatically add IDs when the provider is not yet synced
* otherwise we end up with empty paragraphs
*/
if (collaboration) {
if (provider) {
provider.on('synced', createIds)
// Detach on destroy too, in case the editor is destroyed before the
// provider syncs — otherwise `createIds` (and the editor it closes over)
// stays referenced by the shared provider and leaks.
this.storage.cleanupSyncedListener = () => provider.off('synced', createIds)
}
// When collaboration is present but no provider is in extension options,
// needsInitialIdGeneration was already set in addProseMirrorPlugins
// (which runs synchronously during editor construction, before any
// y-sync$ transaction can arrive).
} else {
return createIds()
}
},
onDestroy() {
this.storage.cleanupSyncedListener?.()
},
addProseMirrorPlugins() {
if (!this.options.updateDocument) {
return []
}
// Capture storage via closure so appendTransaction can access `needsInitialIdGeneration`.
// `extensionManager.extensions` returns different objects than `this` due to
// Tiptap's child-extension flattening, so we capture the reference directly.
const extensionStorage = this.storage
// Detect collaboration early — before any y-sync$ transaction can arrive.
// onCreate runs on a deferred setTimeout(0), so a y-sync$ transaction could
// be applied before it. Setting the flag here (synchronous during editor
// construction) ensures the first y-sync$ is always handled.
const collaboration = this.editor.extensionManager.extensions.find(
ext => ext.name === 'collaboration',
)
const collaborationCaret = this.editor.extensionManager.extensions.find(
ext => ext.name === 'collaborationCaret',
)
const collabExtensions = [collaboration, collaborationCaret].filter(Boolean)
const collabWithProvider = collabExtensions.find(ext => ext?.options?.provider)
if (collaboration && !collabWithProvider) {
extensionStorage.needsInitialIdGeneration = true
}
let dragSourceElement: Element | null = null
let transformPasted = false
const types = resolveTypes(this.options.types, this.editor.extensionManager.extensions)
return [
new Plugin({
key: new PluginKey('uniqueID'),
appendTransaction: (transactions, oldState, newState) => {
const hasDocChanges =
transactions.some(transaction => transaction.docChanged) &&
!oldState.doc.eq(newState.doc)
const filterTransactions =
this.options.filterTransaction &&
transactions.some(tr => !this.options.filterTransaction?.(tr))
const isCollabTransaction = transactions.find(tr => tr.getMeta('y-sync$'))
if (isCollabTransaction) {
if (extensionStorage.needsInitialIdGeneration) {
extensionStorage.needsInitialIdGeneration = false
// Run full-document ID creation after the first Yjs sync.
// Use a seen-set so only the second+ occurrence of a duplicated
// ID is regenerated, preserving one existing ID per value.
const { tr } = newState
const { attributeName, generateID } = this.options
const allNodes = findChildren(newState.doc, node => types.includes(node.type.name))
const seen = new Set<string>()
allNodes.forEach(({ node, pos }) => {
const currentId = node.attrs[attributeName]
if (currentId === null || seen.has(currentId)) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generateID({ node, pos }),
})
} else {
seen.add(currentId)
}
})
if (!tr.steps.length) {
return
}
// Restore stored marks since setNodeMarkup resets them
tr.setStoredMarks(newState.tr.storedMarks)
// Mark this transaction as coming from UniqueID to prevent
// infinite loops with other extensions (e.g., TrailingNode)
tr.setMeta('__uniqueIDTransaction', true)
tr.setMeta('addToHistory', false)
return tr
}
return
}
if (!hasDocChanges || filterTransactions) {
return
}
const { tr } = newState
const { attributeName, generateID } = this.options
const transform = combineTransactionSteps(oldState.doc, transactions as Transaction[])
const { mapping } = transform
// get changed ranges based on the old state
const changes = getChangedRanges(transform)
changes.forEach(({ newRange }) => {
const newNodes = findChildrenInRange(newState.doc, newRange, node => {
return types.includes(node.type.name)
})
const newIds = newNodes
.map(({ node }) => node.attrs[attributeName])
.filter(id => id !== null)
newNodes.forEach(({ node, pos }, i) => {
// instead of checking `node.attrs[attributeName]` directly
// we look at the current state of the node within `tr.doc`.
// this helps to prevent adding new ids to the same node
// if the node changed multiple times within one transaction
const id = tr.doc.nodeAt(pos)?.attrs[attributeName]
if (id === null) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generateID({ node, pos }),
})
return
}
const nextNode = newNodes[i + 1]
if (nextNode && node.content.size === 0) {
const nextNodeInTr = tr.doc.nodeAt(nextNode.pos)
if (
nextNodeInTr?.attrs[attributeName] &&
nextNodeInTr.attrs[attributeName] !== id
) {
return
}
tr.setNodeMarkup(nextNode.pos, undefined, {
...nextNode.node.attrs,
[attributeName]: id,
})
newIds[i + 1] = id
const generatedId = generateID({ node, pos })
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generatedId,
})
newIds[i] = generatedId
return tr
}
const duplicatedNewIds = findDuplicates(newIds)
// check if the node doesn’t exist in the old state
const { deleted } = mapping.invert().mapResult(pos)
const newNode = deleted && duplicatedNewIds.includes(id)
if (newNode) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generateID({ node, pos }),
})
}
})
})
if (!tr.steps.length) {
return
}
// `tr.setNodeMarkup` resets the stored marks
// so we'll restore them if they exist
tr.setStoredMarks(newState.tr.storedMarks)
// Mark this transaction as coming from UniqueID
// to prevent infinite loops with other extensions (e.g., TrailingNode)
tr.setMeta('__uniqueIDTransaction', true)
return tr
},
// we register a global drag handler to track the current drag source element
view(view) {
const handleDragstart = (event: DragEvent) => {
dragSourceElement = view.dom.parentElement?.contains(event.target as Element)
? view.dom.parentElement
: null
}
window.addEventListener('dragstart', handleDragstart)
return {
destroy() {
window.removeEventListener('dragstart', handleDragstart)
},
}
},
props: {
// `handleDOMEvents` is called before `transformPasted`
// so we can do some checks before
handleDOMEvents: {
// only create new ids for dropped content
// or dropped content while holding `alt`
// or content is dragged from another editor
drop: (view, event) => {
if (
dragSourceElement !== view.dom.parentElement ||
event.dataTransfer?.effectAllowed === 'copyMove' ||
event.dataTransfer?.effectAllowed === 'copy'
) {
dragSourceElement = null
transformPasted = true
}
return false
},
// always create new ids on pasted content
paste: () => {
transformPasted = true
return false
},
},
// we’ll remove ids for every pasted node
// so we can create a new one within `appendTransaction`
transformPasted: slice => {
if (!transformPasted) {
return slice
}
const { attributeName } = this.options
const removeId = (fragment: Fragment): Fragment => {
const list: PMNode[] = []
fragment.forEach(node => {
// don’t touch text nodes
if (node.isText) {
list.push(node)
return
}
// check for any other child nodes
if (!types.includes(node.type.name)) {
list.push(node.copy(removeId(node.content)))
return
}
// remove id
const nodeWithoutId = node.type.create(
{
...node.attrs,
[attributeName]: null,
},
removeId(node.content),
node.marks,
)
list.push(nodeWithoutId)
})
return Fragment.from(list)
}
// reset check
transformPasted = false
return new Slice(removeId(slice.content), slice.openStart, slice.openEnd)
},
},
}),
]
},
})

View File

@@ -0,0 +1,227 @@
// @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()
})
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})