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

The lexical fixed and inline toolbars do active / enabled state
calculations for toolbar buttons / dropdowns on every keystroke. This
can incur a performance hit on slow machines.

This PR
- deprioritizes these state calculations using `useDeferredValue` and
`requestIdleCallback`
- introduces additional memoization and replace unnecessary `useEffect`s
to reduce re-rendering

## Before (20x cpu throttling)


https://github.com/user-attachments/assets/dfb6ed79-b5bd-4937-a01d-cd26f9a23831

## After (20x cpu throttling)


https://github.com/user-attachments/assets/d4722fb4-5fd0-48b5-928c-35fcd4f98f78
This commit is contained in:
Alessio Gravili
2025-06-05 09:51:32 -07:00
committed by GitHub
parent 6466684ede
commit aef4f779b1
6 changed files with 204 additions and 134 deletions

View File

@@ -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'
@@ -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)
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
}
}
if (item.isEnabled) {
const isEnabled = item.isEnabled({ editor, editorConfigContext, selection })
if (enabled !== isEnabled) {
setEnabled(isEnabled)
}
}
return { active: newActive, enabled: newEnabled }
})
})
}, [active, editor, editorConfigContext, enabled, item])
}, [editor, editorConfigContext, item])
const runDeprioritized = useRunDeprioritized()
useEffect(() => {
updateStates()
}, [updateStates])
const listener = () => runDeprioritized(updateStates)
const cleanup = mergeRegister(editor.registerUpdateListener(listener))
document.addEventListener('mouseup', listener)
useEffect(() => {
document.addEventListener('mouseup', updateStates)
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>
)

View File

@@ -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)

View File

@@ -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'
@@ -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,
@@ -78,6 +79,8 @@ const ToolbarItem = ({
)
}
const MemoToolbarItem = React.memo(ToolbarItem)
export const ToolbarDropdown = ({
anchorElem,
classNames,
@@ -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()
@@ -137,11 +150,14 @@ 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 })
@@ -149,17 +165,28 @@ export const ToolbarDropdown = ({
})
}, [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
@@ -167,26 +194,13 @@ export const ToolbarDropdown = ({
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>
)
}

View File

@@ -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'
@@ -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'
@@ -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 browsers 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])

View File

@@ -0,0 +1,77 @@
'use client'
import { useCallback, useRef } from 'react'
/**
* Simple hook that lets you run any callback once the main thread is idle
* (via `requestIdleCallback`) or when that API is missing (Safari) - after the
* next animation frame (`interactionResponse`).
*
* This will help you to avoid blocking the main thread with heavy work.
*
* The latest invocation wins: if a new run is queued before the previous one
* executes, the previous task is cancelled.
*
* Usage:
* ```ts
* const runDeprioritized = useRunDeprioritized();
*
* const onEditorChange = (state: EditorState) => {
* runDeprioritized(() => {
* // heavy work here …
* });
* };
* ```
*
* @param timeout Optional timeout (ms) for `requestIdleCallback`; defaults to 500 ms.
* @returns A `runDeprioritized(fn)` helper.
*/
export function useRunDeprioritized(timeout = 500) {
const idleHandleRef = useRef<number>(undefined)
/**
* Schedule `fn` and resolve when it has executed.
*/
const runDeprioritized = useCallback(
(fn: () => void): Promise<void> => {
return new Promise<void>((resolve) => {
const exec = () => {
fn()
resolve()
}
if ('requestIdleCallback' in window) {
// Cancel any previously queued task so only the latest runs.
if ('cancelIdleCallback' in window && idleHandleRef.current !== undefined) {
// 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.
cancelIdleCallback(idleHandleRef.current)
}
// Schedule the state update to happen the next time the browser has sufficient resources,
// or the latest after 500ms.
idleHandleRef.current = requestIdleCallback(exec, { timeout })
} else {
// Safari fallback: rAF + setTimeout shim.
void interactionResponse().then(exec)
}
})
},
[timeout],
)
return runDeprioritized
}
function interactionResponse(): Promise<unknown> {
// Taken from https://github.com/vercel-labs/await-interaction-response/tree/main/packages/await-interaction-response/src
return new Promise((resolve) => {
setTimeout(resolve, 100) // Fallback for the case where the animation frame never fires.
requestAnimationFrame(() => {
setTimeout(resolve, 0)
})
})
}

View File

@@ -767,6 +767,7 @@ describe('lexicalMain', () => {
// make text bold
await boldButton.click()
await wait(300)
// Save drawer
await docDrawer.locator('button').getByText('Save').first().click()