diff --git a/.changeset/smooth-rice-obey.md b/.changeset/smooth-rice-obey.md new file mode 100644 index 000000000..8a73ee788 --- /dev/null +++ b/.changeset/smooth-rice-obey.md @@ -0,0 +1,12 @@ +--- +"@tiptap/react": patch +--- + +Optimize `useEditor` and `useEditorState` to reduce number of instances created while still being performant #5432 + +The core of this change is two-fold: + - have the effect run on every render (i.e. without a dep array) + - schedule destruction of instances, but bail on the actual destruction if the instance was still mounted and a new instance had not been created yet + +It should plug a memory leak, where editor instances could be created but not cleaned up in strict mode. +As well as fixing a bug where a re-render, with deps, was not applying new options that were set on `useEditor`. diff --git a/.eslintrc.js b/.eslintrc.js index ebea8e454..18cd55d3a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,6 +8,15 @@ module.exports = { node: true, }, overrides: [ + { + files: [ + './**/*.ts', + './**/*.tsx', + './**/*.js', + './**/*.jsx', + ], + extends: ['plugin:react-hooks/recommended'], + }, { files: [ './**/*.ts', diff --git a/demos/src/Commands/Cut/React/index.jsx b/demos/src/Commands/Cut/React/index.jsx index 66a871ec2..02bea76cd 100644 --- a/demos/src/Commands/Cut/React/index.jsx +++ b/demos/src/Commands/Cut/React/index.jsx @@ -8,10 +8,6 @@ import StarterKit from '@tiptap/starter-kit' import React, { useCallback } from 'react' const MenuBar = ({ editor }) => { - if (!editor) { - return null - } - const onCutToStart = useCallback(() => { editor.chain().cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, 1).run() }, [editor]) @@ -20,6 +16,10 @@ const MenuBar = ({ editor }) => { editor.chain().cut({ from: editor.state.selection.$from.pos, to: editor.state.selection.$to.pos }, editor.state.doc.nodeSize - 2).run() }, [editor]) + if (!editor) { + return null + } + return (
diff --git a/demos/src/Examples/Performance/React/index.jsx b/demos/src/Examples/Performance/React/index.jsx index 4dce96ff9..827ec6991 100644 --- a/demos/src/Examples/Performance/React/index.jsx +++ b/demos/src/Examples/Performance/React/index.jsx @@ -62,7 +62,7 @@ function EditorInstance({ shouldOptimizeRendering }) { }) return ( - <> +
Number of renders: {countRenderRef.current}
@@ -89,12 +89,13 @@ function EditorInstance({ shouldOptimizeRendering }) { )} - +
) } const EditorControls = () => { const [shouldOptimizeRendering, setShouldOptimizeRendering] = React.useState(true) + const [rendered, setRendered] = React.useState(true) return ( <> @@ -123,8 +124,9 @@ const EditorControls = () => { Render every transaction (default behavior)
+
- + {rendered && } ) } diff --git a/demos/src/GuideContent/ReadOnly/React/index.jsx b/demos/src/GuideContent/ReadOnly/React/index.jsx index 657c33382..095d3540c 100644 --- a/demos/src/GuideContent/ReadOnly/React/index.jsx +++ b/demos/src/GuideContent/ReadOnly/React/index.jsx @@ -7,6 +7,7 @@ import React, { useEffect, useState } from 'react' export default () => { const [editable, setEditable] = useState(false) const editor = useEditor({ + shouldRerenderOnTransaction: false, editable, content: `

diff --git a/package-lock.json b/package-lock.json index 04c67060c..795aab055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "eslint-plugin-cypress": "^2.15.2", "eslint-plugin-html": "^6.2.0", "eslint-plugin-import": "^2.29.1", + "eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-vue": "^9.27.0", "husky": "^8.0.3", @@ -8942,6 +8943,18 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, "node_modules/eslint-plugin-simple-import-sort": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-7.0.0.tgz", @@ -17902,159 +17915,159 @@ }, "packages/core": { "name": "@tiptap/core", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/pm": "^2.5.7" + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^2.5.7" + "@tiptap/pm": "^2.5.8" } }, "packages/extension-blockquote": { "name": "@tiptap/extension-blockquote", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-bold": { "name": "@tiptap/extension-bold", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-bubble-menu": { "name": "@tiptap/extension-bubble-menu", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" }, "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-bullet-list": { "name": "@tiptap/extension-bullet-list", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-character-count": { "name": "@tiptap/extension-character-count", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-code": { "name": "@tiptap/extension-code", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-code-block": { "name": "@tiptap/extension-code-block", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-code-block-lowlight": { "name": "@tiptap/extension-code-block-lowlight", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/extension-code-block": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/extension-code-block": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/extension-code-block": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/extension-code-block": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-collaboration": { "name": "@tiptap/extension-collaboration", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", "y-prosemirror": "^1.2.9" }, "funding": { @@ -18062,17 +18075,17 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", "y-prosemirror": "^1.2.6" } }, "packages/extension-collaboration-cursor": { "name": "@tiptap/extension-collaboration-cursor", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", + "@tiptap/core": "^2.5.8", "y-prosemirror": "^1.2.9" }, "funding": { @@ -18080,607 +18093,607 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", + "@tiptap/core": "^2.5.8", "y-prosemirror": "^1.2.6" } }, "packages/extension-color": { "name": "@tiptap/extension-color", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/extension-text-style": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/extension-text-style": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/extension-text-style": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/extension-text-style": "^2.5.8" } }, "packages/extension-document": { "name": "@tiptap/extension-document", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-dropcursor": { "name": "@tiptap/extension-dropcursor", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-floating-menu": { "name": "@tiptap/extension-floating-menu", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" }, "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-focus": { "name": "@tiptap/extension-focus", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-font-family": { "name": "@tiptap/extension-font-family", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/extension-text-style": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/extension-text-style": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/extension-text-style": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/extension-text-style": "^2.5.8" } }, "packages/extension-gapcursor": { "name": "@tiptap/extension-gapcursor", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-hard-break": { "name": "@tiptap/extension-hard-break", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-heading": { "name": "@tiptap/extension-heading", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-highlight": { "name": "@tiptap/extension-highlight", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-history": { "name": "@tiptap/extension-history", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-horizontal-rule": { "name": "@tiptap/extension-horizontal-rule", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-image": { "name": "@tiptap/extension-image", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-italic": { "name": "@tiptap/extension-italic", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-link": { "name": "@tiptap/extension-link", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "dependencies": { "linkifyjs": "^4.1.0" }, "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-list-item": { "name": "@tiptap/extension-list-item", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-list-keymap": { "name": "@tiptap/extension-list-keymap", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-mention": { "name": "@tiptap/extension-mention", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", - "@tiptap/suggestion": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", + "@tiptap/suggestion": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", - "@tiptap/suggestion": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", + "@tiptap/suggestion": "^2.5.8" } }, "packages/extension-ordered-list": { "name": "@tiptap/extension-ordered-list", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-paragraph": { "name": "@tiptap/extension-paragraph", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-placeholder": { "name": "@tiptap/extension-placeholder", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-strike": { "name": "@tiptap/extension-strike", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-subscript": { "name": "@tiptap/extension-subscript", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-superscript": { "name": "@tiptap/extension-superscript", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-table": { "name": "@tiptap/extension-table", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-table-cell": { "name": "@tiptap/extension-table-cell", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-table-header": { "name": "@tiptap/extension-table-header", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-table-row": { "name": "@tiptap/extension-table-row", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-task-item": { "name": "@tiptap/extension-task-item", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/extension-task-list": { "name": "@tiptap/extension-task-list", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-text": { "name": "@tiptap/extension-text", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-text-align": { "name": "@tiptap/extension-text-align", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-text-style": { "name": "@tiptap/extension-text-style", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-typography": { "name": "@tiptap/extension-typography", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-underline": { "name": "@tiptap/extension-underline", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/extension-youtube": { "name": "@tiptap/extension-youtube", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7" + "@tiptap/core": "^2.5.8" } }, "packages/html": { "name": "@tiptap/html", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "dependencies": { "zeed-dom": "^0.10.9" }, "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/pm": { "name": "@tiptap/pm", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.2.1", @@ -18709,17 +18722,17 @@ }, "packages/react": { "name": "@tiptap/react", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.5.7", - "@tiptap/extension-floating-menu": "^2.5.7", + "@tiptap/extension-bubble-menu": "^2.5.8", + "@tiptap/extension-floating-menu": "^2.5.8", "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.2.2" }, "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", "react": "^18.0.0", @@ -18730,36 +18743,36 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0" } }, "packages/starter-kit": { "name": "@tiptap/starter-kit", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "dependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/extension-blockquote": "^2.5.7", - "@tiptap/extension-bold": "^2.5.7", - "@tiptap/extension-bullet-list": "^2.5.7", - "@tiptap/extension-code": "^2.5.7", - "@tiptap/extension-code-block": "^2.5.7", - "@tiptap/extension-document": "^2.5.7", - "@tiptap/extension-dropcursor": "^2.5.7", - "@tiptap/extension-gapcursor": "^2.5.7", - "@tiptap/extension-hard-break": "^2.5.7", - "@tiptap/extension-heading": "^2.5.7", - "@tiptap/extension-history": "^2.5.7", - "@tiptap/extension-horizontal-rule": "^2.5.7", - "@tiptap/extension-italic": "^2.5.7", - "@tiptap/extension-list-item": "^2.5.7", - "@tiptap/extension-ordered-list": "^2.5.7", - "@tiptap/extension-paragraph": "^2.5.7", - "@tiptap/extension-strike": "^2.5.7", - "@tiptap/extension-text": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/extension-blockquote": "^2.5.8", + "@tiptap/extension-bold": "^2.5.8", + "@tiptap/extension-bullet-list": "^2.5.8", + "@tiptap/extension-code": "^2.5.8", + "@tiptap/extension-code-block": "^2.5.8", + "@tiptap/extension-document": "^2.5.8", + "@tiptap/extension-dropcursor": "^2.5.8", + "@tiptap/extension-gapcursor": "^2.5.8", + "@tiptap/extension-hard-break": "^2.5.8", + "@tiptap/extension-heading": "^2.5.8", + "@tiptap/extension-history": "^2.5.8", + "@tiptap/extension-horizontal-rule": "^2.5.8", + "@tiptap/extension-italic": "^2.5.8", + "@tiptap/extension-list-item": "^2.5.8", + "@tiptap/extension-ordered-list": "^2.5.8", + "@tiptap/extension-paragraph": "^2.5.8", + "@tiptap/extension-strike": "^2.5.8", + "@tiptap/extension-text": "^2.5.8" }, "funding": { "type": "github", @@ -18768,33 +18781,33 @@ }, "packages/suggestion": { "name": "@tiptap/suggestion", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7" + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8" } }, "packages/vue-2": { "name": "@tiptap/vue-2", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.5.7", - "@tiptap/extension-floating-menu": "^2.5.7", + "@tiptap/extension-bubble-menu": "^2.5.8", + "@tiptap/extension-floating-menu": "^2.5.8", "vue-ts-types": "^1.6.0" }, "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", "vue": "^2.6.0" }, "funding": { @@ -18802,8 +18815,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", "vue": "^2.6.0" } }, @@ -18834,15 +18847,15 @@ }, "packages/vue-3": { "name": "@tiptap/vue-3", - "version": "2.5.7", + "version": "2.5.8", "license": "MIT", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.5.7", - "@tiptap/extension-floating-menu": "^2.5.7" + "@tiptap/extension-bubble-menu": "^2.5.8", + "@tiptap/extension-floating-menu": "^2.5.8" }, "devDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", "vue": "^3.0.0" }, "funding": { @@ -18850,8 +18863,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.5.7", - "@tiptap/pm": "^2.5.7", + "@tiptap/core": "^2.5.8", + "@tiptap/pm": "^2.5.8", "vue": "^3.0.0" } }, diff --git a/package.json b/package.json index cf21df115..84132d251 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "eslint-plugin-cypress": "^2.15.2", "eslint-plugin-html": "^6.2.0", "eslint-plugin-import": "^2.29.1", + "eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-vue": "^9.27.0", "husky": "^8.0.3", diff --git a/packages/react/src/useEditor.ts b/packages/react/src/useEditor.ts index 2eca1f2f0..2fde5b2ce 100644 --- a/packages/react/src/useEditor.ts +++ b/packages/react/src/useEditor.ts @@ -1,8 +1,13 @@ import { EditorOptions } from '@tiptap/core' import { - DependencyList, MutableRefObject, - useDebugValue, useEffect, useRef, useState, + DependencyList, + MutableRefObject, + useDebugValue, + useEffect, + useRef, + useState, } from 'react' +import { useSyncExternalStore } from 'use-sync-external-store/shim' import { Editor } from './Editor.js' import { useEditorState } from './useEditorState.js' @@ -31,22 +36,241 @@ export type UseEditorOptions = Partial & { }; /** - * Create a new editor instance. And attach event listeners. + * This class handles the creation, destruction, and re-creation of the editor instance. */ -function createEditor(options: MutableRefObject): Editor { - const editor = new Editor(options.current) +class EditorInstanceManager { + /** + * The current editor instance. + */ + private editor: Editor | null = null - editor.on('beforeCreate', (...args) => options.current.onBeforeCreate?.(...args)) - editor.on('blur', (...args) => options.current.onBlur?.(...args)) - editor.on('create', (...args) => options.current.onCreate?.(...args)) - editor.on('destroy', (...args) => options.current.onDestroy?.(...args)) - editor.on('focus', (...args) => options.current.onFocus?.(...args)) - editor.on('selectionUpdate', (...args) => options.current.onSelectionUpdate?.(...args)) - editor.on('transaction', (...args) => options.current.onTransaction?.(...args)) - editor.on('update', (...args) => options.current.onUpdate?.(...args)) - editor.on('contentError', (...args) => options.current.onContentError?.(...args)) + /** + * The most recent options to apply to the editor. + */ + private options: MutableRefObject - return editor + /** + * The subscriptions to notify when the editor instance + * has been created or destroyed. + */ + private subscriptions = new Set<() => void>() + + /** + * A timeout to destroy the editor if it was not mounted within a time frame. + */ + private scheduledDestructionTimeout: ReturnType | undefined + + /** + * Whether the editor has been mounted. + */ + private isComponentMounted = false + + /** + * The most recent dependencies array. + */ + private previousDeps: DependencyList | null = null + + /** + * The unique instance ID. This is used to identify the editor instance. And will be re-generated for each new instance. + */ + public instanceId = '' + + constructor(options: MutableRefObject) { + this.options = options + this.subscriptions = new Set<() => void>() + this.setEditor(this.getInitialEditor()) + + this.getEditor = this.getEditor.bind(this) + this.getServerSnapshot = this.getServerSnapshot.bind(this) + this.subscribe = this.subscribe.bind(this) + this.refreshEditorInstance = this.refreshEditorInstance.bind(this) + this.scheduleDestroy = this.scheduleDestroy.bind(this) + this.onRender = this.onRender.bind(this) + this.createEditor = this.createEditor.bind(this) + } + + private setEditor(editor: Editor | null) { + this.editor = editor + this.instanceId = Math.random().toString(36).slice(2, 9) + + // Notify all subscribers that the editor instance has been created + this.subscriptions.forEach(cb => cb()) + } + + private getInitialEditor() { + if (this.options.current.immediatelyRender === undefined) { + if (isSSR || isNext) { + // TODO in the next major release, we should throw an error here + if (isDev) { + /** + * Throw an error in development, to make sure the developer is aware that tiptap cannot be SSR'd + * and that they need to set `immediatelyRender` to `false` to avoid hydration mismatches. + */ + console.warn( + 'Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.', + ) + } + + // Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production + return null + } + + // Default to immediately rendering when client-side rendering + return this.createEditor() + } + + if (this.options.current.immediatelyRender && isSSR && isDev) { + // Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches. + throw new Error( + 'Tiptap Error: SSR has been detected, and `immediatelyRender` has been set to `true` this is an unsupported configuration that may result in errors, explicitly set `immediatelyRender` to `false` to avoid hydration mismatches.', + ) + } + + if (this.options.current.immediatelyRender) { + return this.createEditor() + } + + return null + } + + /** + * Create a new editor instance. And attach event listeners. + */ + private createEditor(): Editor { + const editor = new Editor(this.options.current) + + // Always call the most recent version of the callback function by default + editor.on('beforeCreate', (...args) => this.options.current.onBeforeCreate?.(...args)) + editor.on('blur', (...args) => this.options.current.onBlur?.(...args)) + editor.on('create', (...args) => this.options.current.onCreate?.(...args)) + editor.on('destroy', (...args) => this.options.current.onDestroy?.(...args)) + editor.on('focus', (...args) => this.options.current.onFocus?.(...args)) + editor.on('selectionUpdate', (...args) => this.options.current.onSelectionUpdate?.(...args)) + editor.on('transaction', (...args) => this.options.current.onTransaction?.(...args)) + editor.on('update', (...args) => this.options.current.onUpdate?.(...args)) + editor.on('contentError', (...args) => this.options.current.onContentError?.(...args)) + + // no need to keep track of the event listeners, they will be removed when the editor is destroyed + + return editor + } + + /** + * Get the current editor instance. + */ + getEditor(): Editor | null { + return this.editor + } + + /** + * Always disable the editor on the server-side. + */ + getServerSnapshot(): null { + return null + } + + /** + * Subscribe to the editor instance's changes. + */ + subscribe(onStoreChange: () => void) { + this.subscriptions.add(onStoreChange) + + return () => { + this.subscriptions.delete(onStoreChange) + } + } + + /** + * On each render, we will create, update, or destroy the editor instance. + * @param deps The dependencies to watch for changes + * @returns A cleanup function + */ + onRender(deps: DependencyList) { + // The returned callback will run on each render + return () => { + this.isComponentMounted = true + // Cleanup any scheduled destructions, since we are currently rendering + clearTimeout(this.scheduledDestructionTimeout) + + if (this.editor && !this.editor.isDestroyed && deps.length === 0) { + // if the editor does exist & deps are empty, we don't need to re-initialize the editor + // we can fast-path to update the editor options on the existing instance + this.editor.setOptions(this.options.current) + } else { + // When the editor: + // - does not yet exist + // - is destroyed + // - the deps array changes + // We need to destroy the editor instance and re-initialize it + this.refreshEditorInstance(deps) + } + + return () => { + this.isComponentMounted = false + this.scheduleDestroy() + } + } + } + + /** + * Recreate the editor instance if the dependencies have changed. + */ + private refreshEditorInstance(deps: DependencyList) { + + if (this.editor && !this.editor.isDestroyed) { + // Editor instance already exists + if (this.previousDeps === null) { + // If lastDeps has not yet been initialized, reuse the current editor instance + this.previousDeps = deps + return + } + const depsAreEqual = this.previousDeps.length === deps.length + && this.previousDeps.every((dep, index) => dep === deps[index]) + + if (depsAreEqual) { + // deps exist and are equal, no need to recreate + return + } + } + + if (this.editor && !this.editor.isDestroyed) { + // Destroy the editor instance if it exists + this.editor.destroy() + } + + this.setEditor(this.createEditor()) + + // Update the lastDeps to the current deps + this.previousDeps = deps + } + + /** + * Schedule the destruction of the editor instance. + * This will only destroy the editor if it was not mounted on the next tick. + * This is to avoid destroying the editor instance when it's actually still mounted. + */ + private scheduleDestroy() { + const currentInstanceId = this.instanceId + const currentEditor = this.editor + + // Wait a tick to see if the component is still mounted + this.scheduledDestructionTimeout = setTimeout(() => { + if (this.isComponentMounted && this.instanceId === currentInstanceId) { + // If still mounted on the next tick, with the same instanceId, do not destroy the editor + if (currentEditor) { + // just re-apply options as they might have changed + currentEditor.setOptions(this.options.current) + } + return + } + if (currentEditor && !currentEditor.isDestroyed) { + currentEditor.destroy() + if (this.instanceId === currentInstanceId) { + this.setEditor(null) + } + } + }, 0) + } } /** @@ -68,99 +292,29 @@ export function useEditor( * @returns The editor instance * @example const editor = useEditor({ extensions: [...] }) */ -export function useEditor( - options?: UseEditorOptions, - deps?: DependencyList -): Editor | null; +export function useEditor(options?: UseEditorOptions, deps?: DependencyList): Editor | null; export function useEditor( options: UseEditorOptions = {}, deps: DependencyList = [], ): Editor | null { const mostRecentOptions = useRef(options) - const [editor, setEditor] = useState(() => { - if (options.immediatelyRender === undefined) { - if (isSSR || isNext) { - // TODO in the next major release, we should throw an error here - if (isDev) { - /** - * Throw an error in development, to make sure the developer is aware that tiptap cannot be SSR'd - * and that they need to set `immediatelyRender` to `false` to avoid hydration mismatches. - */ - console.warn( - 'Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.', - ) - } - // Best faith effort in production, run the code in the legacy mode to avoid hydration mismatches and errors in production - return null - } + mostRecentOptions.current = options - // Default to immediately rendering when client-side rendering - return createEditor(mostRecentOptions) - } + const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions)) - if (options.immediatelyRender && isSSR && isDev) { - // Warn in development, to make sure the developer is aware that tiptap cannot be SSR'd, set `immediatelyRender` to `false` to avoid hydration mismatches. - throw new Error( - 'Tiptap Error: SSR has been detected, and `immediatelyRender` has been set to `true` this is an unsupported configuration that may result in errors, explicitly set `immediatelyRender` to `false` to avoid hydration mismatches.', - ) - } - - if (options.immediatelyRender) { - return createEditor(mostRecentOptions) - } - - return null - }) - const mostRecentEditor = useRef(editor) - - mostRecentEditor.current = editor + const editor = useSyncExternalStore( + instanceManager.subscribe, + instanceManager.getEditor, + instanceManager.getServerSnapshot, + ) useDebugValue(editor) // This effect will handle creating/updating the editor instance - useEffect(() => { - const destroyUnusedEditor = (editorInstance: Editor | null) => { - if (editorInstance) { - // We need to destroy the editor asynchronously to avoid memory leaks - // because the editor instance is still being used in the component. - - setTimeout(() => { - // re-use the editor instance if it hasn't been replaced yet - // otherwise, asynchronously destroy the old editor instance - if (editorInstance !== mostRecentEditor.current && !editorInstance.isDestroyed) { - editorInstance.destroy() - } - }) - } - } - - let editorInstance = mostRecentEditor.current - - if (!editorInstance) { - editorInstance = createEditor(mostRecentOptions) - setEditor(editorInstance) - return () => destroyUnusedEditor(editorInstance) - } - - if (!Array.isArray(deps) || deps.length === 0) { - // if the editor does exist & deps are empty, we don't need to re-initialize the editor - // we can fast-path to update the editor options on the existing instance - editorInstance.setOptions(options) - - return () => destroyUnusedEditor(editorInstance) - } - - // We need to destroy the editor instance and re-initialize it - // when the deps array changes - editorInstance.destroy() - - // the deps array is used to re-initialize the editor instance - editorInstance = createEditor(mostRecentOptions) - setEditor(editorInstance) - return () => destroyUnusedEditor(editorInstance) - }, deps) + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(instanceManager.onRender(deps)) // The default behavior is to re-render on each transaction // This is legacy behavior that will be removed in future versions diff --git a/packages/react/src/useEditorState.ts b/packages/react/src/useEditorState.ts index 0c8f02517..079605a80 100644 --- a/packages/react/src/useEditorState.ts +++ b/packages/react/src/useEditorState.ts @@ -30,68 +30,83 @@ export type UseEditorStateOptions< * To synchronize the editor instance with the component state, * we need to create a separate instance that is not affected by the component re-renders. */ -function makeEditorStateInstance(initialEditor: TEditor) { - let transactionNumber = 0 - let lastTransactionNumber = 0 - let lastSnapshot: EditorStateSnapshot = { editor: initialEditor, transactionNumber: 0 } - let editor = initialEditor - const subscribers = new Set<() => void>() +class EditorStateManager { + private transactionNumber = 0 - const editorInstance = { - /** - * Get the current editor instance. - */ - getSnapshot(): EditorStateSnapshot { - if (transactionNumber === lastTransactionNumber) { - return lastSnapshot - } - lastTransactionNumber = transactionNumber - lastSnapshot = { editor, transactionNumber } - return lastSnapshot - }, - /** - * Always disable the editor on the server-side. - */ - getServerSnapshot(): EditorStateSnapshot { - return { editor: null, transactionNumber: 0 } - }, - /** - * Subscribe to the editor instance's changes. - */ - subscribe(callback: () => void) { - subscribers.add(callback) - return () => { - subscribers.delete(callback) - } - }, - /** - * Watch the editor instance for changes. - */ - watch(nextEditor: Editor | null) { - editor = nextEditor as TEditor + private lastTransactionNumber = 0 - if (editor) { - /** - * This will force a re-render when the editor state changes. - * This is to support things like `editor.can().toggleBold()` in components that `useEditor`. - * This could be more efficient, but it's a good trade-off for now. - */ - const fn = () => { - transactionNumber += 1 - subscribers.forEach(callback => callback()) - } + private lastSnapshot: EditorStateSnapshot - const currentEditor = editor + private editor: TEditor - currentEditor.on('transaction', fn) - return () => { - currentEditor.off('transaction', fn) - } - } - }, + private subscribers = new Set<() => void>() + + constructor(initialEditor: TEditor) { + this.editor = initialEditor + this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 } + + this.getSnapshot = this.getSnapshot.bind(this) + this.getServerSnapshot = this.getServerSnapshot.bind(this) + this.watch = this.watch.bind(this) + this.subscribe = this.subscribe.bind(this) } - return editorInstance + /** + * Get the current editor instance. + */ + getSnapshot(): EditorStateSnapshot { + if (this.transactionNumber === this.lastTransactionNumber) { + return this.lastSnapshot + } + this.lastTransactionNumber = this.transactionNumber + this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber } + return this.lastSnapshot + } + + /** + * Always disable the editor on the server-side. + */ + getServerSnapshot(): EditorStateSnapshot { + return { editor: null, transactionNumber: 0 } + } + + /** + * Subscribe to the editor instance's changes. + */ + subscribe(callback: () => void): () => void { + this.subscribers.add(callback) + return () => { + this.subscribers.delete(callback) + } + } + + /** + * Watch the editor instance for changes. + */ + watch(nextEditor: Editor | null): undefined | (() => void) { + this.editor = nextEditor as TEditor + + if (this.editor) { + /** + * This will force a re-render when the editor state changes. + * This is to support things like `editor.can().toggleBold()` in components that `useEditor`. + * This could be more efficient, but it's a good trade-off for now. + */ + const fn = () => { + this.transactionNumber += 1 + this.subscribers.forEach(callback => callback()) + } + + const currentEditor = this.editor + + currentEditor.on('transaction', fn) + return () => { + currentEditor.off('transaction', fn) + } + } + + return undefined + } } export function useEditorState( @@ -104,7 +119,7 @@ export function useEditorState( export function useEditorState( options: UseEditorStateOptions | UseEditorStateOptions, ): TSelectorResult | null { - const [editorInstance] = useState(() => makeEditorStateInstance(options.editor)) + const [editorInstance] = useState(() => new EditorStateManager(options.editor)) // Using the `useSyncExternalStore` hook to sync the editor instance with the component state const selectedState = useSyncExternalStoreWithSelector( @@ -117,7 +132,7 @@ export function useEditorState( useEffect(() => { return editorInstance.watch(options.editor) - }, [options.editor]) + }, [options.editor, editorInstance]) useDebugValue(selectedState) diff --git a/packages/vue-3/src/Editor.ts b/packages/vue-3/src/Editor.ts index 8d10526ca..9ed5597d3 100644 --- a/packages/vue-3/src/Editor.ts +++ b/packages/vue-3/src/Editor.ts @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/rules-of-hooks */ import { Editor as CoreEditor, EditorOptions } from '@tiptap/core' import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state' import {