Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/2026-07-25-fix-react-nodeview-caret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/react': patch
---

Fix caret placement after splitting a block rendered with a React NodeView.
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ export default () => {
<p>
This is still the text editor you’re used to, but enriched with node views.
</p>
<react-component>
<p>This is editable. You can create a new component by pressing Mod+Enter.</p>
</react-component>
<react-component>This is editable. You can create a new component by pressing Mod+Enter.</react-component>
<p>
Did you see that? That’s a React component. We are really living in the future.
</p>
Expand Down
15 changes: 15 additions & 0 deletions demos/src/GuideNodeViews/ReactComponentContent/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { expect, test } from '@playwright/test'

const demoPath = '/src/GuideNodeViews/ReactComponentContent/React/'

test.describe('GuideNodeViews/ReactComponentContent/React', () => {
test.beforeEach(async ({ page }) => {
await page.goto(demoPath)
})

test('renders the initial NodeView content', async ({ page }) => {
await expect(page.locator('.tiptap .react-component .content')).toHaveText(
'This is editable. You can create a new component by pressing Mod+Enter.',
)
})
})
2 changes: 1 addition & 1 deletion demos/src/GuideNodeViews/VueComponentContent/Vue/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default {
This is still the text editor you’re used to, but enriched with node views.
</p>
<vue-component>
<p>This is editable.</p>
This is editable.
</vue-component>
<p>
Did you see that? That’s a Vue component. We are really living in the future.
Expand Down
13 changes: 13 additions & 0 deletions demos/src/GuideNodeViews/VueComponentContent/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, test } from '@playwright/test'

const demoPath = '/src/GuideNodeViews/VueComponentContent/Vue/'

test.describe('GuideNodeViews/VueComponentContent/Vue', () => {
test.beforeEach(async ({ page }) => {
await page.goto(demoPath)
})

test('renders the initial NodeView content', async ({ page }) => {
await expect(page.locator('.tiptap .vue-component .content')).toHaveText('This is editable.')
})
})
47 changes: 47 additions & 0 deletions packages/react/src/ReactNodeViewRenderer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ const ItemComponent = () => {
return React.createElement(NodeViewWrapper, null, React.createElement(NodeViewContent))
}

const ReactParagraphComponent = () => {
return React.createElement(NodeViewWrapper, null, React.createElement(NodeViewContent))
}

const ReactParagraph = Paragraph.extend({
addNodeView() {
return ReactNodeViewRenderer(ReactParagraphComponent)
},
})

const Container = Node.create({
name: 'container',
group: 'block',
Expand Down Expand Up @@ -137,6 +147,13 @@ const createEditorWithContainers = () => {
})
}

const createEditorWithReactParagraph = () => {
return new Editor({
extensions: [Document, ReactParagraph, Text],
content: '<p>Hello</p>',
})
}

const flushMicrotasks = async () => {
await act(async () => {
await Promise.resolve()
Expand Down Expand Up @@ -172,6 +189,36 @@ describe('ReactNodeViewRenderer', () => {
editor.destroy()
})

it('keeps new React paragraph content connected while its portal is queued', async () => {
const editor = createEditorWithReactParagraph()
const { container } = render(React.createElement(EditorContent, { editor }))

await flushMicrotasks()

editor.commands.setTextSelection(6)
editor.commands.splitBlock()

const secondParagraphPosition = editor.state.doc.firstChild!.nodeSize

expect(editor.state.selection.from).toBe(secondParagraphPosition + 1)

const contentElements = container.querySelectorAll('[data-node-view-content-react]')

expect(contentElements).toHaveLength(2)
expect(contentElements[1].isConnected).toBe(true)

editor.commands.insertContent('Second')

expect(editor.state.doc.child(0).textContent).toBe('Hello')
expect(editor.state.doc.child(1).textContent).toBe('Second')

await flushMicrotasks()

expect(contentElements[1].parentElement?.hasAttribute('data-node-view-content')).toBe(true)

editor.destroy()
})

it('resolves getPos to undefined while the view desc is detached mid-update', async () => {
const editor = createEditorWithContainers()
const { container } = render(React.createElement(EditorContent, { editor }))
Expand Down
9 changes: 5 additions & 4 deletions packages/react/src/ReactNodeViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,12 @@ export class ReactNodeView<

const contentTarget = this.dom.querySelector('[data-node-view-content]')

if (!contentTarget) {
return
if (contentTarget) {
contentTarget.appendChild(this.contentDOMElement)
} else {
// ProseMirror maps the selection before the queued portal render.
this.dom.appendChild(this.contentDOMElement)
}

contentTarget.appendChild(this.contentDOMElement)
}

if (this.options.trackNodeViewPosition) {
Expand Down
Loading