Skip to content

perf(richtext-lexical): improve typing performance while toolbars are enabled #12669

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 5, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type { LexicalEditor } from 'lexical'

import { mergeRegister } from '@lexical/utils'
import { $addUpdateTag, $getSelection } from 'lexical'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'

import type { ToolbarGroupItem } from '../../types.js'

import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import './index.scss'
import { useRunDeprioritized } from '../../../../utilities/useRunDeprioritized.js'

const baseClass = 'toolbar-popup__button'

Expand All @@ -21,91 +22,84 @@ export const ToolbarButton = ({
editor: LexicalEditor
item: ToolbarGroupItem
}) => {
const [enabled, setEnabled] = useState<boolean>(true)
const [active, setActive] = useState<boolean>(false)
const [className, setClassName] = useState<string>(baseClass)
const [_state, setState] = useState({ active: false, enabled: true })
const deferredState = useDeferredValue(_state)

const editorConfigContext = useEditorConfigContext()

const className = useMemo(() => {
return [
baseClass,
!deferredState.enabled ? 'disabled' : '',
deferredState.active ? 'active' : '',
item.key ? `${baseClass}-${item.key}` : '',
]
.filter(Boolean)
.join(' ')
}, [deferredState, item.key])
const updateStates = useCallback(() => {
editor.getEditorState().read(() => {
const selection = $getSelection()
if (!selection) {
return
}
if (item.isActive) {
const isActive = item.isActive({ editor, editorConfigContext, selection })
if (active !== isActive) {
setActive(isActive)
}
}
if (item.isEnabled) {
const isEnabled = item.isEnabled({ editor, editorConfigContext, selection })
if (enabled !== isEnabled) {
setEnabled(isEnabled)
const newActive = item.isActive
? item.isActive({ editor, editorConfigContext, selection })
: false

const newEnabled = item.isEnabled
? item.isEnabled({ editor, editorConfigContext, selection })
: true

setState((prev) => {
if (prev.active === newActive && prev.enabled === newEnabled) {
return prev
}
}
return { active: newActive, enabled: newEnabled }
})
})
}, [active, editor, editorConfigContext, enabled, item])
}, [editor, editorConfigContext, item])

useEffect(() => {
updateStates()
}, [updateStates])
const runDeprioritized = useRunDeprioritized()

useEffect(() => {
document.addEventListener('mouseup', updateStates)
const listener = () => runDeprioritized(updateStates)

const cleanup = mergeRegister(editor.registerUpdateListener(listener))
document.addEventListener('mouseup', listener)

return () => {
document.removeEventListener('mouseup', updateStates)
cleanup()
document.removeEventListener('mouseup', listener)
}
}, [updateStates])
}, [editor, runDeprioritized, updateStates])

useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(() => {
updateStates()
}),
)
}, [editor, updateStates])
const handleClick = useCallback(() => {
if (!_state.enabled) {
return
}

useEffect(() => {
setClassName(
[
baseClass,
enabled === false ? 'disabled' : '',
active ? 'active' : '',
item?.key ? `${baseClass}-` + item.key : '',
]
.filter(Boolean)
.join(' '),
)
}, [enabled, active, className, item.key])
editor.focus(() => {
editor.update(() => {
$addUpdateTag('toolbar')
})
// We need to wrap the onSelect in the callback, so the editor is properly focused before the onSelect is called.
item.onSelect?.({
editor,
isActive: _state.active,
})
})
}, [editor, item, _state])

const handleMouseDown = useCallback((e: React.MouseEvent) => {
// This fixes a bug where you are unable to click the button if you are in a NESTED editor (editor in blocks field in editor).
// Thus only happens if you click on the SVG of the button. Clicking on the outside works. Related issue: https://github.com/payloadcms/payload/issues/4025
// TODO: Find out why exactly it happens and why e.preventDefault() on the mouseDown fixes it. Write that down here, or potentially fix a root cause, if there is any.
e.preventDefault()
}, [])

return (
<button
className={className}
onClick={() => {
if (enabled !== false) {
editor.focus(() => {
editor.update(() => {
$addUpdateTag('toolbar')
})
// We need to wrap the onSelect in the callback, so the editor is properly focused before the onSelect is called.
item.onSelect?.({
editor,
isActive: active,
})
})

return true
}
}}
onMouseDown={(e) => {
// This fixes a bug where you are unable to click the button if you are in a NESTED editor (editor in blocks field in editor).
// Thus only happens if you click on the SVG of the button. Clicking on the outside works. Related issue: https://github.com/payloadcms/payload/issues/4025
// TODO: Find out why exactly it happens and why e.preventDefault() on the mouseDown fixes it. Write that down here, or potentially fix a root cause, if there is any.
e.preventDefault()
}}
type="button"
>
<button className={className} onClick={handleClick} onMouseDown={handleMouseDown} type="button">
{children}
</button>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,16 @@ export function DropDownItem({
item: ToolbarGroupItem
tooltip?: string
}): React.ReactNode {
const [className, setClassName] = useState<string>(baseClass)

useEffect(() => {
setClassName(
[
baseClass,
enabled === false ? 'disabled' : '',
active ? 'active' : '',
item?.key ? `${baseClass}-${item.key}` : '',
]
.filter(Boolean)
.join(' '),
)
}, [enabled, active, className, item.key])
const className = useMemo(() => {
return [
baseClass,
enabled === false ? 'disabled' : '',
active ? 'active' : '',
item?.key ? `${baseClass}-${item.key}` : '',
]
.filter(Boolean)
.join(' ')
}, [enabled, active, item.key])

const ref = useRef<HTMLButtonElement>(null)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client'
import React, { useCallback, useEffect } from 'react'
import React, { useCallback, useDeferredValue, useEffect, useMemo } from 'react'

const baseClass = 'toolbar-popup__dropdown'

Expand All @@ -12,8 +12,9 @@ import { $getSelection } from 'lexical'
import type { ToolbarDropdownGroup, ToolbarGroupItem } from '../../types.js'

import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import { DropDown, DropDownItem } from './DropDown.js'
import { useRunDeprioritized } from '../../../../utilities/useRunDeprioritized.js'
import './index.scss'
import { DropDown, DropDownItem } from './DropDown.js'

const ToolbarItem = ({
active,
Expand Down Expand Up @@ -78,6 +79,8 @@ const ToolbarItem = ({
)
}

const MemoToolbarItem = React.memo(ToolbarItem)

export const ToolbarDropdown = ({
anchorElem,
classNames,
Expand All @@ -103,12 +106,22 @@ export const ToolbarDropdown = ({
maxActiveItems?: number
onActiveChange?: ({ activeItems }: { activeItems: ToolbarGroupItem[] }) => void
}) => {
const [activeItemKeys, setActiveItemKeys] = React.useState<string[]>([])
const [enabledItemKeys, setEnabledItemKeys] = React.useState<string[]>([])
const [enabledGroup, setEnabledGroup] = React.useState<boolean>(true)
const [toolbarState, setToolbarState] = React.useState<{
activeItemKeys: string[]
enabledGroup: boolean
enabledItemKeys: string[]
}>({
activeItemKeys: [],
enabledGroup: true,
enabledItemKeys: [],
})
const deferredToolbarState = useDeferredValue(toolbarState)

const editorConfigContext = useEditorConfigContext()
const { items, key: groupKey } = group

const runDeprioritized = useRunDeprioritized()

const updateStates = useCallback(() => {
editor.getEditorState().read(() => {
const selection = $getSelection()
Expand Down Expand Up @@ -137,56 +150,57 @@ export const ToolbarDropdown = ({
_enabledItemKeys.push(item.key)
}
}
if (group.isEnabled) {
setEnabledGroup(group.isEnabled({ editor, editorConfigContext, selection }))
}
setActiveItemKeys(_activeItemKeys)
setEnabledItemKeys(_enabledItemKeys)

setToolbarState({
activeItemKeys: _activeItemKeys,
enabledGroup: group.isEnabled
? group.isEnabled({ editor, editorConfigContext, selection })
: true,
enabledItemKeys: _enabledItemKeys,
})

if (onActiveChange) {
onActiveChange({ activeItems: _activeItems })
}
})
}, [editor, editorConfigContext, group, items, maxActiveItems, onActiveChange])

useEffect(() => {
updateStates()
}, [updateStates])

useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(() => {
updateStates()
editor.registerUpdateListener(async () => {
await runDeprioritized(updateStates)
}),
)
}, [editor, updateStates])
}, [editor, runDeprioritized, updateStates])

const renderedItems = useMemo(() => {
return items?.length
? items.map((item) => (
<MemoToolbarItem
active={deferredToolbarState.activeItemKeys.includes(item.key)}
anchorElem={anchorElem}
editor={editor}
enabled={deferredToolbarState.enabledItemKeys.includes(item.key)}
item={item}
key={item.key}
/>
))
: null
}, [items, deferredToolbarState, anchorElem, editor])

return (
<DropDown
buttonAriaLabel={`${groupKey} dropdown`}
buttonClassName={[baseClass, `${baseClass}-${groupKey}`, ...(classNames || [])]
.filter(Boolean)
.join(' ')}
disabled={!enabledGroup}
disabled={!deferredToolbarState.enabledGroup}
Icon={Icon}
itemsContainerClassNames={[`${baseClass}-items`, ...(itemsContainerClassNames || [])]}
key={groupKey}
label={label}
>
{items.length
? items.map((item) => {
return (
<ToolbarItem
active={activeItemKeys.includes(item.key)}
anchorElem={anchorElem}
editor={editor}
enabled={enabledItemKeys.includes(item.key)}
item={item}
key={item.key}
/>
)
})
: null}
{renderedItems}
</DropDown>
)
}
30 changes: 9 additions & 21 deletions packages/richtext-lexical/src/field/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
useField,
} from '@payloadcms/ui'
import { mergeFieldStyles } from '@payloadcms/ui/shared'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

import type { SanitizedClientEditorConfig } from '../lexical/config/types.js'
Expand All @@ -24,6 +24,7 @@ import './index.scss'
import type { LexicalRichTextFieldProps } from '../types.js'

import { LexicalProvider } from '../lexical/LexicalProvider.js'
import { useRunDeprioritized } from '../utilities/useRunDeprioritized.js'

const baseClass = 'rich-text-lexical'

Expand Down Expand Up @@ -116,35 +117,22 @@ const RichTextComponent: React.FC<

const pathWithEditDepth = `${path}.${editDepth}`

const dispatchFieldUpdateTask = useRef<number>(undefined)
const runDeprioritized = useRunDeprioritized() // defaults to 500 ms timeout

const handleChange = useCallback(
(editorState: EditorState) => {
const updateFieldValue = (editorState: EditorState) => {
// Capture `editorState` in the closure so we can safely run later.
const updateFieldValue = () => {
const newState = editorState.toJSON()
prevValueRef.current = newState
setValue(newState)
}

if (typeof window.requestIdleCallback === 'function') {
// Cancel earlier scheduled value updates,
// so that a CPU-limited event loop isn't flooded with n callbacks for n keystrokes into the rich text field,
// but that there's only ever the latest one state update
// dispatch task, to be executed with the next idle time,
// or the deadline of 500ms.
if (typeof window.cancelIdleCallback === 'function' && dispatchFieldUpdateTask.current) {
cancelIdleCallback(dispatchFieldUpdateTask.current)
}
// Schedule the state update to happen the next time the browser has sufficient resources,
// or the latest after 500ms.
dispatchFieldUpdateTask.current = requestIdleCallback(() => updateFieldValue(editorState), {
timeout: 500,
})
} else {
updateFieldValue(editorState)
}
// Queue the update for the browser’s idle time (or Safari shim)
// and let the hook handle debouncing/cancellation.
void runDeprioritized(updateFieldValue)
},
[setValue],
[setValue, runDeprioritized], // `runDeprioritized` is stable (useCallback inside hook)
)

const styles = useMemo(() => mergeFieldStyles(field), [field])
Expand Down
Loading
Loading