Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/spotty-shadows-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tiptap/vue-3': patch
'@tiptap/vue-2': patch
'@tiptap/react': patch
---

Fixed the cursor no longer following clicks after an editor inside a shadow root was unmounted and remounted.
61 changes: 59 additions & 2 deletions packages/react/src/EditorContent.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { render } from '@testing-library/react'
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { createContentComponent } from './EditorContent.js'
import { createContentComponent, EditorContent } from './EditorContent.js'
import type { ReactRenderer } from './ReactRenderer.js'

const createRenderer = (id: string) =>
Expand Down Expand Up @@ -37,3 +42,55 @@ describe('createContentComponent', () => {
expect(subscriber).toHaveBeenCalledTimes(2)
})
})

describe('EditorContent', () => {
const mountedElements: HTMLElement[] = []

afterEach(() => {
mountedElements.forEach(element => element.remove())
mountedElements.length = 0
})

function mountEditorContentInShadowRoot(editor: Editor) {
const host = document.createElement('div')

document.body.appendChild(host)
mountedElements.push(host)

const shadowRoot = host.attachShadow({ mode: 'open' })
const container = document.createElement('div')

shadowRoot.appendChild(container)

const view = render(React.createElement(EditorContent, { editor }), { container })

return { view, shadowRoot }
}

it('re-resolves the ProseMirror root when remounted into another shadow tree', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello World</p>',
})

try {
const first = mountEditorContentInShadowRoot(editor)

// Populate ProseMirror's lazy root cache, like any selection work would.
expect(editor.view.root).toBe(first.shadowRoot)

first.view.unmount()

const second = mountEditorContentInShadowRoot(editor)

expect(editor.view.dom.getRootNode()).toBe(second.shadowRoot)
// A stale root breaks all selection handling: the cursor no longer
// follows clicks because ProseMirror consults the discarded shadow root.
expect(editor.view.root).toBe(second.shadowRoot)

second.view.unmount()
} finally {
editor.destroy()
}
})
})
4 changes: 4 additions & 0 deletions packages/react/src/EditorContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export class PureEditorContent extends React.Component<

element.append(...editor.view.dom.parentNode.childNodes)

// The move above may have carried the view into a different document or
// shadow tree; ProseMirror caches its root node, so make it re-resolve.
editor.view.updateRoot()

editor.setOptions({
element,
})
Expand Down
72 changes: 72 additions & 0 deletions packages/vue-2/__tests__/EditorContent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vitest'
import Vue from 'vue'

import { Editor, EditorContent } from '../src/index.js'

describe('EditorContent', () => {
const mountedVms: Vue[] = []
const mountedElements: HTMLElement[] = []

afterEach(() => {
mountedVms.forEach(vm => vm.$destroy())
mountedVms.length = 0

mountedElements.forEach(element => element.remove())
mountedElements.length = 0
})

function mountEditorContentInShadowRoot(editor: Editor) {
const host = document.createElement('div')

document.body.appendChild(host)
mountedElements.push(host)

const shadowRoot = host.attachShadow({ mode: 'open' })
const target = document.createElement('div')

shadowRoot.appendChild(target)

const vm = new Vue({
render: createElement => createElement(EditorContent, { props: { editor } }),
})

vm.$mount(target)
mountedVms.push(vm)

return { vm, shadowRoot }
}

it('re-resolves the ProseMirror root when remounted into another shadow tree', async () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello World</p>',
})

try {
const first = mountEditorContentInShadowRoot(editor)

await Vue.nextTick()
await Vue.nextTick()

// Populate ProseMirror's lazy root cache, like any selection work would.
expect(editor.view.root).toBe(first.shadowRoot)

first.vm.$destroy()

const second = mountEditorContentInShadowRoot(editor)

await Vue.nextTick()
await Vue.nextTick()

expect(editor.view.dom.getRootNode()).toBe(second.shadowRoot)
// A stale root breaks all selection handling: the cursor no longer
// follows clicks because ProseMirror consults the discarded shadow root.
expect(editor.view.root).toBe(second.shadowRoot)
} finally {
editor.destroy()
}
})
})
5 changes: 5 additions & 0 deletions packages/vue-2/src/EditorContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export const EditorContent: Component = {
}

element.append(...editor.view.dom.parentNode.childNodes)

// The move above may have carried the view into a different document or
// shadow tree; ProseMirror caches its root node, so make it re-resolve.
editor.view.updateRoot()

editor.contentComponent = this

editor.setOptions({
Expand Down
71 changes: 71 additions & 0 deletions packages/vue-3/__tests__/EditorContent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vitest'
import type { App } from 'vue'
import { createApp, h, nextTick } from 'vue'

import { Editor, EditorContent } from '../src/index.js'

describe('EditorContent', () => {
const mountedApps: App[] = []
const mountedElements: HTMLElement[] = []

afterEach(() => {
mountedApps.forEach(app => app.unmount())
mountedApps.length = 0

mountedElements.forEach(element => element.remove())
mountedElements.length = 0
})

function mountEditorContentInShadowRoot(editor: Editor) {
const host = document.createElement('div')

document.body.appendChild(host)
mountedElements.push(host)

const shadowRoot = host.attachShadow({ mode: 'open' })
const target = document.createElement('div')

shadowRoot.appendChild(target)

const app = createApp({ render: () => h(EditorContent, { editor }) })

app.mount(target)
mountedApps.push(app)

return { app, shadowRoot }
}

it('re-resolves the ProseMirror root when remounted into another shadow tree', async () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello World</p>',
})

try {
const first = mountEditorContentInShadowRoot(editor)

await nextTick()
await nextTick()

// Populate ProseMirror's lazy root cache, like any selection work would.
expect(editor.view.root).toBe(first.shadowRoot)

first.app.unmount()

const second = mountEditorContentInShadowRoot(editor)

await nextTick()
await nextTick()

expect(editor.view.dom.getRootNode()).toBe(second.shadowRoot)
// A stale root breaks all selection handling: the cursor no longer
// follows clicks because ProseMirror consults the discarded shadow root.
expect(editor.view.root).toBe(second.shadowRoot)
} finally {
editor.destroy()
}
})
})
4 changes: 4 additions & 0 deletions packages/vue-3/src/EditorContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export const EditorContent = defineComponent({

rootEl.value.append(...editor.view.dom.parentNode.childNodes)

// The move above may have carried the view into a different document or
// shadow tree; ProseMirror caches its root node, so make it re-resolve.
editor.view.updateRoot()

// @ts-ignore
editor.contentComponent = instance.ctx._

Expand Down