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:
@@ -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,69 +22,63 @@ 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])
|
||||
|
||||
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])
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
onClick={() => {
|
||||
if (enabled !== false) {
|
||||
editor.focus(() => {
|
||||
editor.update(() => {
|
||||
$addUpdateTag('toolbar')
|
||||
@@ -91,21 +86,20 @@ export const ToolbarButton = ({
|
||||
// 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,
|
||||
isActive: _state.active,
|
||||
})
|
||||
})
|
||||
}, [editor, item, _state])
|
||||
|
||||
return true
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
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()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<button className={className} onClick={handleClick} onMouseDown={handleMouseDown} type="button">
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -31,20 +31,16 @@ export function DropDownItem({
|
||||
item: ToolbarGroupItem
|
||||
tooltip?: string
|
||||
}): React.ReactNode {
|
||||
const [className, setClassName] = useState<string>(baseClass)
|
||||
|
||||
useEffect(() => {
|
||||
setClassName(
|
||||
[
|
||||
const className = useMemo(() => {
|
||||
return [
|
||||
baseClass,
|
||||
enabled === false ? 'disabled' : '',
|
||||
active ? 'active' : '',
|
||||
item?.key ? `${baseClass}-${item.key}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
}, [enabled, active, className, item.key])
|
||||
.join(' ')
|
||||
}, [enabled, active, item.key])
|
||||
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user