add support for react 19 ref props (#6405)

* add support for react 19 ref props

* added changeset

* Update packages/react/src/ReactRenderer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* use partial imports instead of importing the whole React library

* fix react renderer not passing ref prop

* upgrade dev dependencies for react

* updated lockfile

* upgrade dev dependencies

* update package.json

* remove optionalDependencies and move react deps to peerDependencies

* enhance ReactRenderer for React 19 compatibility and improve ref handling

* remove unused 'node' property from ReactNodeViewProps type definition

* fix: update ref type in ReactNodeView to be generic

* fix: replace FunctionComponent with NamedExoticComponent for better performance in ReactNodeView

* cloned react renderer element props to avoid side effects

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
bdbch 2025-06-04 13:48:27 +02:00 committed by bdbch
parent 25891a67c3
commit a77f645e4e
11 changed files with 235 additions and 104 deletions

View File

@ -0,0 +1,5 @@
---
'@tiptap/react': minor
---
Added support for React 19 ref in props

View File

@ -36,8 +36,8 @@
"postcss": "^8.4.49",
"postcss-import": "^15.1.0",
"prosemirror-dev-tools": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sass": "^1.81.0",
"svelte": "^4.2.19",
"tailwindcss": "^3.4.15",

View File

@ -10,7 +10,7 @@ export default props => {
return (
<NodeViewWrapper className="react-component">
<label>React Component</label>
<label ref={props.ref}>React Component</label>
<div className="content">
<button onClick={increase}>

View File

@ -1,11 +1,11 @@
import './MentionList.scss'
import React, {
forwardRef, useEffect, useImperativeHandle,
useEffect, useImperativeHandle,
useState,
} from 'react'
export default forwardRef((props, ref) => {
export default props => {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = index => {
@ -30,7 +30,7 @@ export default forwardRef((props, ref) => {
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => ({
useImperativeHandle(props.ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler()
@ -67,4 +67,4 @@ export default forwardRef((props, ref) => {
}
</div>
)
})
}

180
package-lock.json generated
View File

@ -23,8 +23,8 @@
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^26.0.3",
"@rollup/plugin-node-resolve": "^15.3.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"@typescript-eslint/parser": "^8.19.0",
"babel-loader": "^9.2.1",
@ -83,8 +83,8 @@
"postcss": "^8.4.49",
"postcss-import": "^15.1.0",
"prosemirror-dev-tools": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sass": "^1.81.0",
"svelte": "^4.2.19",
"tailwindcss": "^3.4.15",
@ -347,6 +347,36 @@
"node": ">=8"
}
},
"demos/node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"demos/node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"demos/node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"dev": true,
"license": "MIT"
},
"demos/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
@ -5656,24 +5686,23 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
"version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz",
"integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==",
"version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
"peerDependencies": {
"@types/react": "^19.0.0"
}
},
"node_modules/@types/resolve": {
@ -15319,6 +15348,18 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/prosemirror-dev-tools/node_modules/@types/react": {
"version": "18.3.23",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/prosemirror-dev-tools/node_modules/nanoid": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
@ -15326,6 +15367,42 @@
"dev": true,
"license": "MIT"
},
"node_modules/prosemirror-dev-tools/node_modules/react-dock": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/react-dock/-/react-dock-0.6.0.tgz",
"integrity": "sha512-jEOhv1s+pqRQ4JxgUw4XUotnprOehZ23mqchf3whxYXnvNgTQOXCxh6bpcqW8P6OybIk2bYO18r3qimZ3ypCbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@types/lodash": "^4.14.182",
"@types/prop-types": "^15.7.5",
"lodash.debounce": "^4.0.8",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@types/react": "^16.3.0 || ^17.0.0 || ^18.0.0",
"react": "^16.3.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/prosemirror-dev-tools/node_modules/react-json-tree": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-json-tree/-/react-json-tree-0.17.0.tgz",
"integrity": "sha512-hcWjibI/fAvsKnfYk+lka5OrE1Lvb1jH5pSnFhIU5T8cCCxB85r6h/NOzDPggSSgErjmx4rl3+2EkeclIKBOhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@types/lodash": "^4.14.182",
"@types/prop-types": "^15.7.5",
"prop-types": "^15.8.1",
"react-base16-styling": "^0.9.1"
},
"peerDependencies": {
"@types/react": "^16.3.0 || ^17.0.0 || ^18.0.0",
"react": "^16.3.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.1.tgz",
@ -15652,6 +15729,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -15675,29 +15753,12 @@
"lodash.curry": "^4.1.1"
}
},
"node_modules/react-dock": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/react-dock/-/react-dock-0.6.0.tgz",
"integrity": "sha512-jEOhv1s+pqRQ4JxgUw4XUotnprOehZ23mqchf3whxYXnvNgTQOXCxh6bpcqW8P6OybIk2bYO18r3qimZ3ypCbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@types/lodash": "^4.14.182",
"@types/prop-types": "^15.7.5",
"lodash.debounce": "^4.0.8",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@types/react": "^16.3.0 || ^17.0.0 || ^18.0.0",
"react": "^16.3.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -15729,24 +15790,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-json-tree": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-json-tree/-/react-json-tree-0.17.0.tgz",
"integrity": "sha512-hcWjibI/fAvsKnfYk+lka5OrE1Lvb1jH5pSnFhIU5T8cCCxB85r6h/NOzDPggSSgErjmx4rl3+2EkeclIKBOhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@types/lodash": "^4.14.182",
"@types/prop-types": "^15.7.5",
"prop-types": "^15.8.1",
"react-base16-styling": "^0.9.1"
},
"peerDependencies": {
"@types/react": "^16.3.0 || ^17.0.0 || ^18.0.0",
"react": "^16.3.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz",
@ -16722,6 +16765,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -21028,10 +21072,10 @@
"devDependencies": {
"@tiptap/core": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"funding": {
"type": "github",
@ -21044,6 +21088,36 @@
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"packages/react/node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"packages/react/node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"packages/react/node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"dev": true,
"license": "MIT"
},
"packages/starter-kit": {
"name": "@tiptap/starter-kit",
"version": "2.12.0",

View File

@ -47,8 +47,8 @@
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^26.0.3",
"@rollup/plugin-node-resolve": "^15.3.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.19.0",
"@typescript-eslint/parser": "^8.19.0",
"babel-loader": "^9.2.1",

View File

@ -38,10 +38,10 @@
"devDependencies": {
"@tiptap/core": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",

View File

@ -1,19 +1,17 @@
import {
DecorationWithType,
Editor,
getRenderedAttributes,
NodeView,
NodeViewProps,
NodeViewRenderer,
NodeViewRendererOptions,
import type {
DecorationWithType, Editor, NodeViewRenderer, NodeViewRendererOptions,
} from '@tiptap/core'
import { Node, Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
import React, { ComponentType } from 'react'
import { getRenderedAttributes, NodeView } from '@tiptap/core'
import type { Node, Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
import type { ComponentType, NamedExoticComponent } from 'react'
import React, { createElement, createRef, memo } from 'react'
import { EditorWithContentComponent } from './Editor.js'
import { ReactRenderer } from './ReactRenderer.js'
import { ReactNodeViewContext, ReactNodeViewContextProps } from './useReactNodeView.js'
import type { ReactNodeViewProps } from './types.js'
import type { ReactNodeViewContextProps } from './useReactNodeView.js'
import { ReactNodeViewContext } from './useReactNodeView.js'
export interface ReactNodeViewRendererOptions extends NodeViewRendererOptions {
/**
@ -53,14 +51,15 @@ export interface ReactNodeViewRendererOptions extends NodeViewRendererOptions {
}
export class ReactNodeView<
Component extends ComponentType<NodeViewProps> = ComponentType<NodeViewProps>,
T = HTMLElement,
Component extends ComponentType<ReactNodeViewProps<T>> = ComponentType<ReactNodeViewProps<T>>,
NodeEditor extends Editor = Editor,
Options extends ReactNodeViewRendererOptions = ReactNodeViewRendererOptions,
> extends NodeView<Component, NodeEditor, Options> {
/**
* The renderer instance.
*/
renderer!: ReactRenderer<unknown, NodeViewProps>
renderer!: ReactRenderer<unknown, ReactNodeViewProps<T>>
/**
* The element that holds the rich-text content of the node.
@ -84,7 +83,8 @@ export class ReactNodeView<
getPos: () => this.getPos(),
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
deleteNode: () => this.deleteNode(),
} satisfies NodeViewProps
ref: createRef<T>(),
} satisfies ReactNodeViewProps<T>
if (!(this.component as any).displayName) {
const capitalizeFirstChar = (string: string): string => {
@ -104,15 +104,13 @@ export class ReactNodeView<
const Component = this.component
// For performance reasons, we memoize the provider component
// And all of the things it requires are declared outside of the component, so it doesn't need to re-render
const ReactNodeViewProvider: React.FunctionComponent<NodeViewProps> = React.memo(
componentProps => {
return (
<ReactNodeViewContext.Provider value={context}>
{React.createElement(Component, componentProps)}
</ReactNodeViewContext.Provider>
)
},
)
const ReactNodeViewProvider: NamedExoticComponent<ReactNodeViewProps<T>> = memo(componentProps => {
return (
<ReactNodeViewContext.Provider value={context}>
{createElement(Component, componentProps)}
</ReactNodeViewContext.Provider>
)
})
ReactNodeViewProvider.displayName = 'ReactNodeView'
@ -320,8 +318,8 @@ export class ReactNodeView<
/**
* Create a React node view renderer.
*/
export function ReactNodeViewRenderer(
component: ComponentType<NodeViewProps>,
export function ReactNodeViewRenderer<T = HTMLElement>(
component: ComponentType<ReactNodeViewProps<T>>,
options?: Partial<ReactNodeViewRendererOptions>,
): NodeViewRenderer {
return props => {
@ -332,6 +330,6 @@ export function ReactNodeViewRenderer(
return {} as unknown as ProseMirrorNodeView
}
return new ReactNodeView(component, props, options)
return new ReactNodeView<T>(component, props, options)
}
}

View File

@ -1,5 +1,13 @@
import { Editor } from '@tiptap/core'
import React from 'react'
import type { Editor } from '@tiptap/core'
import type {
ComponentClass,
ForwardRefExoticComponent,
FunctionComponent,
PropsWithoutRef,
ReactNode,
RefAttributes,
} from 'react'
import React, { version as reactVersion } from 'react'
import { flushSync } from 'react-dom'
import { EditorWithContentComponent } from './Editor.js'
@ -29,6 +37,27 @@ function isForwardRefComponent(Component: any) {
)
}
/**
* Check if we're running React 19+ by detecting if function components support ref props
* @returns {boolean}
*/
function isReact19Plus(): boolean {
// React 19 is detected by checking React version if available
// In practice, we'll use a more conservative approach and assume React 18 behavior
// unless we can definitively detect React 19
try {
// @ts-ignore
if (reactVersion) {
const majorVersion = parseInt(reactVersion.split('.')[0], 10)
return majorVersion >= 19
}
} catch {
// Fallback to React 18 behavior if we can't determine version
}
return false
}
export interface ReactRendererOptions {
/**
* The editor instance.
@ -60,9 +89,9 @@ export interface ReactRendererOptions {
}
type ComponentType<R, P> =
React.ComponentClass<P> |
React.FunctionComponent<P> |
React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<R>>;
| ComponentClass<P>
| FunctionComponent<P>
| ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<R>>
/**
* The ReactRenderer class. It's responsible for rendering React components inside the editor.
@ -86,7 +115,7 @@ export class ReactRenderer<R = unknown, P extends Record<string, any> = object>
props: P
reactElement: React.ReactNode
reactElement: ReactNode
ref: R | null = null
@ -129,14 +158,32 @@ export class ReactRenderer<R = unknown, P extends Record<string, any> = object>
const props = this.props
const editor = this.editor as EditorWithContentComponent
if (isClassComponent(Component) || isForwardRefComponent(Component)) {
// @ts-ignore This is a hack to make the ref work
props.ref = (ref: R) => {
this.ref = ref
// Handle ref forwarding with React 18/19 compatibility
const isReact19 = isReact19Plus()
const isClassComp = isClassComponent(Component)
const isForwardRefComp = isForwardRefComponent(Component)
const elementProps = { ...props }
if (!elementProps.ref) {
if (isReact19) {
// React 19: ref is a standard prop for all components
// @ts-ignore - Setting ref prop for React 19 compatibility
elementProps.ref = (ref: R) => {
this.ref = ref
}
} else if (isClassComp || isForwardRefComp) {
// React 18 and prior: only set ref for class components and forwardRef components
// @ts-ignore - Setting ref prop for React 18 class/forwardRef components
elementProps.ref = (ref: R) => {
this.ref = ref
}
}
// For function components in React 18, we can't use ref - the component won't receive it
// This is a limitation we have to accept for React 18 function components without forwardRef
}
this.reactElement = <Component {...props} />
this.reactElement = <Component {...elementProps} />
editor?.contentComponent?.setRenderer(this.id, this)
}

View File

@ -6,6 +6,7 @@ export * from './NodeViewContent.js'
export * from './NodeViewWrapper.js'
export * from './ReactNodeViewRenderer.js'
export * from './ReactRenderer.js'
export * from './types.js'
export * from './useEditor.js'
export * from './useEditorState.js'
export * from './useReactNodeView.js'

View File

@ -0,0 +1,6 @@
import type { NodeViewProps as CoreNodeViewProps } from '@tiptap/core'
import type React from 'react'
export type ReactNodeViewProps<T = HTMLElement> = CoreNodeViewProps & {
ref: React.RefObject<T>
}