From 6d901e7cc1019e04821d7487ebf053715402bbe2 Mon Sep 17 00:00:00 2001 From: Dominik Biedebach <6538827+bdbch@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:03:28 +0200 Subject: [PATCH] fix(react): fixes caret placement after split blocks in React NodeViews. (#8127) * fix(react): fixes caret placement after split blocks in React NodeViews. * refactor(react): correct assertion on react portal tests --- .../2026-07-25-fix-react-nodeview-caret.md | 5 ++ .../ReactComponentContent/React/index.jsx | 4 +- .../ReactComponentContent/index.spec.ts | 15 ++++++ .../VueComponentContent/Vue/index.vue | 2 +- .../VueComponentContent/index.spec.ts | 13 +++++ .../react/src/ReactNodeViewRenderer.spec.ts | 47 +++++++++++++++++++ packages/react/src/ReactNodeViewRenderer.tsx | 9 ++-- 7 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 .changeset/2026-07-25-fix-react-nodeview-caret.md create mode 100644 demos/src/GuideNodeViews/ReactComponentContent/index.spec.ts create mode 100644 demos/src/GuideNodeViews/VueComponentContent/index.spec.ts diff --git a/.changeset/2026-07-25-fix-react-nodeview-caret.md b/.changeset/2026-07-25-fix-react-nodeview-caret.md new file mode 100644 index 0000000000..e4704726aa --- /dev/null +++ b/.changeset/2026-07-25-fix-react-nodeview-caret.md @@ -0,0 +1,5 @@ +--- +'@tiptap/react': patch +--- + +Fix caret placement after splitting a block rendered with a React NodeView. diff --git a/demos/src/GuideNodeViews/ReactComponentContent/React/index.jsx b/demos/src/GuideNodeViews/ReactComponentContent/React/index.jsx index 753e37db0b..6ed170682d 100644 --- a/demos/src/GuideNodeViews/ReactComponentContent/React/index.jsx +++ b/demos/src/GuideNodeViews/ReactComponentContent/React/index.jsx @@ -13,9 +13,7 @@ export default () => {

This is still the text editor you’re used to, but enriched with node views.

- -

This is editable. You can create a new component by pressing Mod+Enter.

-
+ This is editable. You can create a new component by pressing Mod+Enter.

Did you see that? That’s a React component. We are really living in the future.

diff --git a/demos/src/GuideNodeViews/ReactComponentContent/index.spec.ts b/demos/src/GuideNodeViews/ReactComponentContent/index.spec.ts new file mode 100644 index 0000000000..01abd389c2 --- /dev/null +++ b/demos/src/GuideNodeViews/ReactComponentContent/index.spec.ts @@ -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.', + ) + }) +}) diff --git a/demos/src/GuideNodeViews/VueComponentContent/Vue/index.vue b/demos/src/GuideNodeViews/VueComponentContent/Vue/index.vue index 39d691703c..753fd6bafa 100644 --- a/demos/src/GuideNodeViews/VueComponentContent/Vue/index.vue +++ b/demos/src/GuideNodeViews/VueComponentContent/Vue/index.vue @@ -27,7 +27,7 @@ export default { This is still the text editor you’re used to, but enriched with node views.

-

This is editable.

+ This is editable.

Did you see that? That’s a Vue component. We are really living in the future. diff --git a/demos/src/GuideNodeViews/VueComponentContent/index.spec.ts b/demos/src/GuideNodeViews/VueComponentContent/index.spec.ts new file mode 100644 index 0000000000..68e86d0513 --- /dev/null +++ b/demos/src/GuideNodeViews/VueComponentContent/index.spec.ts @@ -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.') + }) +}) diff --git a/packages/react/src/ReactNodeViewRenderer.spec.ts b/packages/react/src/ReactNodeViewRenderer.spec.ts index c1f27cae68..96e48a6673 100644 --- a/packages/react/src/ReactNodeViewRenderer.spec.ts +++ b/packages/react/src/ReactNodeViewRenderer.spec.ts @@ -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', @@ -137,6 +147,13 @@ const createEditorWithContainers = () => { }) } +const createEditorWithReactParagraph = () => { + return new Editor({ + extensions: [Document, ReactParagraph, Text], + content: '

Hello

', + }) +} + const flushMicrotasks = async () => { await act(async () => { await Promise.resolve() @@ -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 })) diff --git a/packages/react/src/ReactNodeViewRenderer.tsx b/packages/react/src/ReactNodeViewRenderer.tsx index 462a4b23c9..be88879f3a 100644 --- a/packages/react/src/ReactNodeViewRenderer.tsx +++ b/packages/react/src/ReactNodeViewRenderer.tsx @@ -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) {