Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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.
65 changes: 63 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,59 @@ describe('createContentComponent', () => {
expect(subscriber).toHaveBeenCalledTimes(2)
})
})

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

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

/**
* Renders an `EditorContent` for the given editor inside a fresh shadow root
* attached to `document.body`, and returns the render result plus the shadow root.
*/
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
76 changes: 76 additions & 0 deletions packages/vue-2/__tests__/EditorContent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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
})

/**
* Mounts an `EditorContent` for the given editor inside a fresh shadow root
* attached to `document.body`, and returns the Vue instance plus the shadow root.
*/
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
75 changes: 75 additions & 0 deletions packages/vue-3/__tests__/EditorContent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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
})

/**
* Mounts an `EditorContent` for the given editor inside a fresh shadow root
* attached to `document.body`, and returns the Vue app plus the shadow root.
*/
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