Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

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-08-28-react-shared-node-view-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/react': patch
---

Reduce overhead in React node views on documents with many nodes.
72 changes: 25 additions & 47 deletions packages/react/src/ReactNodeViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createElement, createRef, memo } from 'react'

import { captureDOMSelection } from './captureDOMSelection.js'
import type { EditorWithContentComponent } from './Editor.js'
import { getReactNodeViewSelectionManager } from './ReactNodeViewSelectionManager.js'
import { ReactRenderer } from './ReactRenderer.js'
import type { ReactNodeViewProps } from './types.js'
import type { ReactNodeViewContextProps } from './useReactNodeView.js'
Expand Down Expand Up @@ -71,11 +72,6 @@ export class ReactNodeView<
*/
contentDOMElement!: HTMLElement | null

/**
* The requestAnimationFrame ID used for selection updates.
*/
selectionRafId: number | null = null

/**
* The last known position of this node view, used to detect position-only
* changes that don't produce a new node object reference.
Expand Down Expand Up @@ -227,16 +223,14 @@ export class ReactNodeView<

const { className = '' } = this.options

this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this)

this.renderer = new ReactRenderer(ReactNodeViewProvider, {
editor: this.editor,
props: mountProps,
as,
className: `node-${this.node.type.name} ${className}`.trim(),
})

this.editor.on('selectionUpdate', this.handleSelectionUpdate)
getReactNodeViewSelectionManager(this.editor).register(this)
this.updateElementAttributes()
this.currentPos = this.getPos()
}
Expand Down Expand Up @@ -268,45 +262,34 @@ export class ReactNodeView<
return this.contentDOMElement
}

/**
* On editor selection update, check if the node is selected.
* If it is, call `selectNode`, otherwise call `deselectNode`.
*/
handleSelectionUpdate() {
if (this.selectionRafId) {
cancelAnimationFrame(this.selectionRafId)
this.selectionRafId = null
// Called by the shared ReactNodeViewSelectionManager on selection updates.
checkSelection() {
// getPos() is undefined while the node view is mid-update, so fall back to the last known position.
const pos = this.getPos() ?? this.currentPos
if (typeof pos !== 'number') {
return
}

this.selectionRafId = requestAnimationFrame(() => {
this.selectionRafId = null
// getPos() is undefined while the node view is mid-update, so fall back to the last known position.
const pos = this.getPos() ?? this.currentPos
if (typeof pos !== 'number') {
const isSelected = isNodeViewSelected({
selection: this.editor.state.selection,
pos,
nodeSize: this.node.nodeSize,
selectedOnTextSelection: this.options.selectedOnTextSelection,
})

if (isSelected) {
if (this.renderer.props.selected) {
return
}

const isSelected = isNodeViewSelected({
selection: this.editor.state.selection,
pos,
nodeSize: this.node.nodeSize,
selectedOnTextSelection: this.options.selectedOnTextSelection,
})

if (isSelected) {
if (this.renderer.props.selected) {
return
}

this.selectNode()
} else {
if (!this.renderer.props.selected) {
return
}

this.deselectNode()
this.selectNode()
} else {
if (!this.renderer.props.selected) {
return
}
})

this.deselectNode()
}
}

/**
Expand Down Expand Up @@ -421,18 +404,13 @@ export class ReactNodeView<
*/
destroy() {
this.renderer.destroy()
this.editor.off('selectionUpdate', this.handleSelectionUpdate)
getReactNodeViewSelectionManager(this.editor).unregister(this)

if (this.options.trackNodeViewPosition) {
this.editor.off('update', this.handlePositionUpdate)
}

this.contentDOMElement = null

if (this.selectionRafId) {
cancelAnimationFrame(this.selectionRafId)
this.selectionRafId = null
}
}

/**
Expand Down
69 changes: 69 additions & 0 deletions packages/react/src/ReactNodeViewSelectionManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Editor } from '@tiptap/core'

interface SelectableNodeView {
checkSelection(): void
}

/**
* One selection listener per editor, instead of one per node view.
*/
class ReactNodeViewSelectionManager {
private editor: Editor

private views = new Set<SelectableNodeView>()

private rafId: number | null = null

constructor(editor: Editor) {
this.editor = editor
}

register(view: SelectableNodeView) {
if (this.views.size === 0) {
this.editor.on('selectionUpdate', this.handleSelectionUpdate)
}

this.views.add(view)
}

unregister(view: SelectableNodeView) {
this.views.delete(view)

if (this.views.size > 0) {
return
}

this.editor.off('selectionUpdate', this.handleSelectionUpdate)

if (this.rafId !== null) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
}

private handleSelectionUpdate = () => {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId)
}

this.rafId = requestAnimationFrame(() => {
this.rafId = null
for (const view of this.views) {
view.checkSelection()
}
})
}
}

const managers = new WeakMap<Editor, ReactNodeViewSelectionManager>()

export function getReactNodeViewSelectionManager(editor: Editor): ReactNodeViewSelectionManager {
let manager = managers.get(editor)

if (!manager) {
manager = new ReactNodeViewSelectionManager(editor)
managers.set(editor, manager)
}

return manager
}
Loading