@tiptap/react
Advanced tools
| import { act, render } from '@testing-library/react' | ||
| import { Editor, Node } from '@tiptap/core' | ||
| import Document from '@tiptap/extension-document' | ||
| import Paragraph from '@tiptap/extension-paragraph' | ||
| import Text from '@tiptap/extension-text' | ||
| import React, { useEffect, useState } from 'react' | ||
| import { afterEach, describe, expect, it } from 'vitest' | ||
| import { EditorContent } from './EditorContent.js' | ||
| import { NodeViewContent } from './NodeViewContent.js' | ||
| import { NodeViewWrapper } from './NodeViewWrapper.js' | ||
| import { ReactNodeViewRenderer } from './ReactNodeViewRenderer.js' | ||
| import type { ReactNodeViewProps } from './types.js' | ||
| const renderedPositions: Array<number | undefined> = [] | ||
| const renderErrors: unknown[] = [] | ||
| const bumpHandles = new Map<string, () => void>() | ||
| const explodingIds = new Set<string>() | ||
| // Optional hook run inside the click handler before the replace commands. | ||
| let beforeReplace: (() => void) | undefined | ||
| const ContainerComponent = (props: ReactNodeViewProps) => { | ||
| const [, setTick] = useState(0) | ||
| const id = props.node.attrs.id as string | ||
| useEffect(() => { | ||
| bumpHandles.set(id, () => setTick(tick => tick + 1)) | ||
| setTick(tick => tick + 1) | ||
| return () => { | ||
| bumpHandles.delete(id) | ||
| } | ||
| }, [id]) | ||
| if (explodingIds.has(id)) { | ||
| throw new Error(`render bomb in container ${id}`) | ||
| } | ||
| // Calling getPos in the render path is what crashes with React 19. | ||
| try { | ||
| renderedPositions.push(props.getPos()) | ||
| } catch (error) { | ||
| renderErrors.push(error) | ||
| throw error | ||
| } | ||
| const replaceSelf = () => { | ||
| beforeReplace?.() | ||
| // setState first so React has pending sync work when the | ||
| // transactions below run. | ||
| setTick(tick => tick + 1) | ||
| const pos = props.getPos() | ||
| if (typeof pos !== 'number') { | ||
| return | ||
| } | ||
| // The first transaction destroys this node view while its component is | ||
| // still mounted. The second one constructs a new node view, whose | ||
| // ReactRenderer flushSync then re-renders pending components while | ||
| // ProseMirror's view tree is mid-update. | ||
| props.editor.commands.deleteRange({ from: pos, to: pos + props.node.nodeSize }) | ||
| props.editor.commands.insertContentAt(0, { | ||
| type: 'container', | ||
| attrs: { id: 'fresh' }, | ||
| content: [{ type: 'item', content: [{ type: 'paragraph' }] }], | ||
| }) | ||
| } | ||
| return React.createElement( | ||
| NodeViewWrapper, | ||
| null, | ||
| React.createElement('button', { | ||
| type: 'button', | ||
| 'data-testid': `replace-${id}`, | ||
| onClick: replaceSelf, | ||
| }), | ||
| React.createElement(NodeViewContent), | ||
| ) | ||
| } | ||
| const ItemComponent = () => { | ||
| return React.createElement(NodeViewWrapper, null, React.createElement(NodeViewContent)) | ||
| } | ||
| const Container = Node.create({ | ||
| name: 'container', | ||
| group: 'block', | ||
| content: 'item+', | ||
| addAttributes() { | ||
| return { | ||
| id: { default: null }, | ||
| } | ||
| }, | ||
| parseHTML() { | ||
| return [{ tag: 'div[data-type="container"]' }] | ||
| }, | ||
| renderHTML() { | ||
| return ['div', { 'data-type': 'container' }, 0] | ||
| }, | ||
| addNodeView() { | ||
| return ReactNodeViewRenderer(ContainerComponent) | ||
| }, | ||
| }) | ||
| const Item = Node.create({ | ||
| name: 'item', | ||
| group: 'block', | ||
| content: 'paragraph+', | ||
| parseHTML() { | ||
| return [{ tag: 'div[data-type="item"]' }] | ||
| }, | ||
| renderHTML() { | ||
| return ['div', { 'data-type': 'item' }, 0] | ||
| }, | ||
| addNodeView() { | ||
| return ReactNodeViewRenderer(ItemComponent) | ||
| }, | ||
| }) | ||
| const createEditorWithContainers = () => { | ||
| return new Editor({ | ||
| extensions: [Document, Paragraph, Text, Container, Item], | ||
| content: | ||
| '<div data-type="container" id="a"><div data-type="item"><p>first</p></div></div>' + | ||
| '<div data-type="container" id="b"><div data-type="item"><p>second</p></div></div>', | ||
| }) | ||
| } | ||
| const flushMicrotasks = async () => { | ||
| await act(async () => { | ||
| await Promise.resolve() | ||
| }) | ||
| } | ||
| const clickReplace = (id: string) => { | ||
| document | ||
| .querySelector(`[data-testid="replace-${id}"]`)! | ||
| .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) | ||
| } | ||
| describe('ReactNodeViewRenderer', () => { | ||
| afterEach(() => { | ||
| renderedPositions.length = 0 | ||
| renderErrors.length = 0 | ||
| bumpHandles.clear() | ||
| explodingIds.clear() | ||
| beforeReplace = undefined | ||
| document.body.innerHTML = '' | ||
| }) | ||
| it('renders nested node views and resolves getPos during render', async () => { | ||
| const editor = createEditorWithContainers() | ||
| const { container } = render(React.createElement(EditorContent, { editor })) | ||
| await flushMicrotasks() | ||
| expect(container.querySelector('[data-node-view-wrapper]')).not.toBeNull() | ||
| expect(renderedPositions.length).toBeGreaterThan(0) | ||
| expect(renderedPositions).toContain(0) | ||
| 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 })) | ||
| await flushMicrotasks() | ||
| // Recreate the state ProseMirror's view tree goes through while it | ||
| // updates: the desc has a parent but is not in parent.children yet. | ||
| // getPos then walks past the end of the children array and throws. | ||
| const desc = (container.querySelector('.react-renderer') as any).pmViewDesc | ||
| const siblings = desc.parent.children | ||
| siblings.splice(siblings.indexOf(desc), 1) | ||
| renderedPositions.length = 0 | ||
| await act(async () => { | ||
| bumpHandles.get('a')?.() | ||
| }) | ||
| // put the tree back so teardown works on a consistent view | ||
| siblings.unshift(desc) | ||
| expect(renderErrors).toEqual([]) | ||
| expect(renderedPositions).toEqual([undefined]) | ||
| editor.destroy() | ||
| }) | ||
| it('does not crash when node views are created while React has pending updates', async () => { | ||
| const editor = createEditorWithContainers() | ||
| const { container } = render(React.createElement(EditorContent, { editor })) | ||
| await flushMicrotasks() | ||
| expect(() => clickReplace('b')).not.toThrow() | ||
| await flushMicrotasks() | ||
| // getPos must not blow up inside the synchronous React flush. | ||
| expect(renderErrors).toEqual([]) | ||
| // The editor and the React tree must stay intact and usable. | ||
| expect(editor.state.doc.firstChild?.attrs.id).toBe('fresh') | ||
| expect(container.querySelector('.tiptap')).not.toBeNull() | ||
| expect( | ||
| editor.commands.insertContentAt(editor.state.doc.content.size, { type: 'paragraph' }), | ||
| ).toBe(true) | ||
| editor.destroy() | ||
| }) | ||
| it('keeps the editor intact when a component throws during the synchronous flush', async () => { | ||
| const editor = createEditorWithContainers() | ||
| render(React.createElement(EditorContent, { editor })) | ||
| await flushMicrotasks() | ||
| // Make container "a" throw on its next render and give it a pending | ||
| // update, so the new node view's flushSync renders it mid-transaction. | ||
| beforeReplace = () => { | ||
| explodingIds.add('a') | ||
| bumpHandles.get('a')?.() | ||
| } | ||
| let clickError: unknown | ||
| try { | ||
| clickReplace('b') | ||
| } catch (error) { | ||
| clickError = error | ||
| } | ||
| await flushMicrotasks() | ||
| // React 19 reports the render error via onUncaughtError instead of | ||
| // rethrowing, so nothing may propagate into the ProseMirror transaction. | ||
| expect(clickError).toBeUndefined() | ||
| // The ProseMirror transaction must complete even though a component | ||
| // render threw during the flush. | ||
| expect(editor.state.doc.firstChild?.attrs.id).toBe('fresh') | ||
| expect( | ||
| editor.commands.insertContentAt(editor.state.doc.content.size, { type: 'paragraph' }), | ||
| ).toBe(true) | ||
| editor.destroy() | ||
| }) | ||
| }) |
| import { act, 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 { TextSelection } from '@tiptap/pm/state' | ||
| import React from 'react' | ||
| import { afterEach, describe, expect, it } from 'vitest' | ||
| import { useEditorState } from './useEditorState.js' | ||
| const createTestEditor = () => { | ||
| return new Editor({ | ||
| extensions: [Document, Paragraph, Text], | ||
| content: '<p>Hello</p>', | ||
| }) | ||
| } | ||
| const EditableIndicator = ({ editor }: { editor: Editor }) => { | ||
| const isEditable = useEditorState({ | ||
| editor, | ||
| selector: ctx => ctx.editor.isEditable, | ||
| }) | ||
| return React.createElement('div', { 'data-testid': 'editable-state' }, String(isEditable)) | ||
| } | ||
| const ContentIndicator = ({ editor }: { editor: Editor }) => { | ||
| const text = useEditorState({ | ||
| editor, | ||
| selector: ctx => ctx.editor.getText(), | ||
| }) | ||
| return React.createElement('div', { 'data-testid': 'content-state' }, text) | ||
| } | ||
| const TransactionCountIndicator = ({ editor }: { editor: Editor }) => { | ||
| const transactionNumber = useEditorState({ | ||
| editor, | ||
| selector: ctx => ctx.transactionNumber, | ||
| }) | ||
| return React.createElement('div', { 'data-testid': 'tx-number' }, String(transactionNumber)) | ||
| } | ||
| describe('useEditorState', () => { | ||
| afterEach(() => { | ||
| document.body.innerHTML = '' | ||
| }) | ||
| it('re-renders when editor.setEditable() toggles the editable state', () => { | ||
| const editor = createTestEditor() | ||
| const { getByTestId } = render(React.createElement(EditableIndicator, { editor })) | ||
| expect(getByTestId('editable-state').textContent).toBe('true') | ||
| act(() => { | ||
| editor.setEditable(false) | ||
| }) | ||
| expect(getByTestId('editable-state').textContent).toBe('false') | ||
| act(() => { | ||
| editor.setEditable(true) | ||
| }) | ||
| expect(getByTestId('editable-state').textContent).toBe('true') | ||
| editor.destroy() | ||
| }) | ||
| it('still re-renders on a normal content-changing transaction (regression)', () => { | ||
| const editor = createTestEditor() | ||
| const { getByTestId } = render(React.createElement(ContentIndicator, { editor })) | ||
| expect(getByTestId('content-state').textContent).toBe('Hello') | ||
| act(() => { | ||
| editor.commands.setContent('<p>World</p>') | ||
| }) | ||
| expect(getByTestId('content-state').textContent).toBe('World') | ||
| editor.destroy() | ||
| }) | ||
| it('notifies once per document change (transaction + update are deduped)', () => { | ||
| const editor = createTestEditor() | ||
| const { getByTestId } = render(React.createElement(TransactionCountIndicator, { editor })) | ||
| const before = Number(getByTestId('tx-number').textContent) | ||
| act(() => { | ||
| // A single doc-changing transaction emits both `transaction` and `update`. | ||
| editor.view.dispatch(editor.state.tr.insertText('!')) | ||
| }) | ||
| const after = Number(getByTestId('tx-number').textContent) | ||
| expect(after - before).toBe(1) | ||
| editor.destroy() | ||
| }) | ||
| it('still notifies on selection-only transactions (which emit no update event)', () => { | ||
| const editor = createTestEditor() | ||
| const { getByTestId } = render(React.createElement(TransactionCountIndicator, { editor })) | ||
| const before = Number(getByTestId('tx-number').textContent) | ||
| act(() => { | ||
| editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc))) | ||
| }) | ||
| const after = Number(getByTestId('tx-number').textContent) | ||
| expect(after - before).toBe(1) | ||
| editor.destroy() | ||
| }) | ||
| }) |
+8
-1
@@ -274,3 +274,8 @@ "use strict"; | ||
| if (this.editor) { | ||
| const fn = () => { | ||
| let lastTransaction; | ||
| const fn = (props) => { | ||
| if ((props == null ? void 0 : props.transaction) !== void 0 && props.transaction === lastTransaction) { | ||
| return; | ||
| } | ||
| lastTransaction = props == null ? void 0 : props.transaction; | ||
| this.transactionNumber += 1; | ||
@@ -281,4 +286,6 @@ this.subscribers.forEach((callback) => callback()); | ||
| currentEditor.on("transaction", fn); | ||
| currentEditor.on("update", fn); | ||
| return () => { | ||
| currentEditor.off("transaction", fn); | ||
| currentEditor.off("update", fn); | ||
| }; | ||
@@ -285,0 +292,0 @@ } |
+8
-1
@@ -212,3 +212,8 @@ "use client"; | ||
| if (this.editor) { | ||
| const fn = () => { | ||
| let lastTransaction; | ||
| const fn = (props) => { | ||
| if ((props == null ? void 0 : props.transaction) !== void 0 && props.transaction === lastTransaction) { | ||
| return; | ||
| } | ||
| lastTransaction = props == null ? void 0 : props.transaction; | ||
| this.transactionNumber += 1; | ||
@@ -219,4 +224,6 @@ this.subscribers.forEach((callback) => callback()); | ||
| currentEditor.on("transaction", fn); | ||
| currentEditor.on("update", fn); | ||
| return () => { | ||
| currentEditor.off("transaction", fn); | ||
| currentEditor.off("update", fn); | ||
| }; | ||
@@ -223,0 +230,0 @@ } |
+7
-7
| { | ||
| "name": "@tiptap/react", | ||
| "version": "3.28.0", | ||
| "version": "3.29.0", | ||
| "description": "React components for tiptap", | ||
@@ -60,4 +60,4 @@ "keywords": [ | ||
| "react-dom": "^19.0.0", | ||
| "@tiptap/core": "^3.28.0", | ||
| "@tiptap/pm": "^3.28.0" | ||
| "@tiptap/core": "^3.29.0", | ||
| "@tiptap/pm": "^3.29.0" | ||
| }, | ||
@@ -69,8 +69,8 @@ "peerDependencies": { | ||
| "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", | ||
| "@tiptap/core": "3.28.0", | ||
| "@tiptap/pm": "3.28.0" | ||
| "@tiptap/core": "3.29.0", | ||
| "@tiptap/pm": "3.29.0" | ||
| }, | ||
| "optionalDependencies": { | ||
| "@tiptap/extension-bubble-menu": "^3.28.0", | ||
| "@tiptap/extension-floating-menu": "^3.28.0" | ||
| "@tiptap/extension-bubble-menu": "^3.29.0", | ||
| "@tiptap/extension-floating-menu": "^3.29.0" | ||
| }, | ||
@@ -77,0 +77,0 @@ "scripts": { |
@@ -98,3 +98,10 @@ import type { Editor } from '@tiptap/core' | ||
| */ | ||
| const fn = () => { | ||
| // Changes can emit both events, so notify only once | ||
| let lastTransaction: unknown | ||
| const fn = (props?: { transaction?: unknown }) => { | ||
| if (props?.transaction !== undefined && props.transaction === lastTransaction) { | ||
| return | ||
| } | ||
| lastTransaction = props?.transaction | ||
| this.transactionNumber += 1 | ||
@@ -107,4 +114,6 @@ this.subscribers.forEach(callback => callback()) | ||
| currentEditor.on('transaction', fn) | ||
| currentEditor.on('update', fn) | ||
| return () => { | ||
| currentEditor.off('transaction', fn) | ||
| currentEditor.off('update', fn) | ||
| } | ||
@@ -111,0 +120,0 @@ } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
493899
2.74%40
5.26%7078
4.67%