Skip to content

Commit aef4f77

Browse files
authored
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
1 parent 6466684 commit aef4f77

File tree

6 files changed

+204
-134
lines changed

6 files changed

+204
-134
lines changed

packages/richtext-lexical/src/features/toolbars/shared/ToolbarButton/index.tsx

Lines changed: 61 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import type { LexicalEditor } from 'lexical'
33

44
import { mergeRegister } from '@lexical/utils'
55
import { $addUpdateTag, $getSelection } from 'lexical'
6-
import React, { useCallback, useEffect, useState } from 'react'
6+
import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'
77

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

1010
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
1111
import './index.scss'
12+
import { useRunDeprioritized } from '../../../../utilities/useRunDeprioritized.js'
1213

1314
const baseClass = 'toolbar-popup__button'
1415

@@ -21,91 +22,84 @@ export const ToolbarButton = ({
2122
editor: LexicalEditor
2223
item: ToolbarGroupItem
2324
}) => {
24-
const [enabled, setEnabled] = useState<boolean>(true)
25-
const [active, setActive] = useState<boolean>(false)
26-
const [className, setClassName] = useState<string>(baseClass)
25+
const [_state, setState] = useState({ active: false, enabled: true })
26+
const deferredState = useDeferredValue(_state)
27+
2728
const editorConfigContext = useEditorConfigContext()
2829

30+
const className = useMemo(() => {
31+
return [
32+
baseClass,
33+
!deferredState.enabled ? 'disabled' : '',
34+
deferredState.active ? 'active' : '',
35+
item.key ? `${baseClass}-${item.key}` : '',
36+
]
37+
.filter(Boolean)
38+
.join(' ')
39+
}, [deferredState, item.key])
2940
const updateStates = useCallback(() => {
3041
editor.getEditorState().read(() => {
3142
const selection = $getSelection()
3243
if (!selection) {
3344
return
3445
}
35-
if (item.isActive) {
36-
const isActive = item.isActive({ editor, editorConfigContext, selection })
37-
if (active !== isActive) {
38-
setActive(isActive)
39-
}
40-
}
41-
if (item.isEnabled) {
42-
const isEnabled = item.isEnabled({ editor, editorConfigContext, selection })
43-
if (enabled !== isEnabled) {
44-
setEnabled(isEnabled)
46+
const newActive = item.isActive
47+
? item.isActive({ editor, editorConfigContext, selection })
48+
: false
49+
50+
const newEnabled = item.isEnabled
51+
? item.isEnabled({ editor, editorConfigContext, selection })
52+
: true
53+
54+
setState((prev) => {
55+
if (prev.active === newActive && prev.enabled === newEnabled) {
56+
return prev
4557
}
46-
}
58+
return { active: newActive, enabled: newEnabled }
59+
})
4760
})
48-
}, [active, editor, editorConfigContext, enabled, item])
61+
}, [editor, editorConfigContext, item])
4962

50-
useEffect(() => {
51-
updateStates()
52-
}, [updateStates])
63+
const runDeprioritized = useRunDeprioritized()
5364

5465
useEffect(() => {
55-
document.addEventListener('mouseup', updateStates)
66+
const listener = () => runDeprioritized(updateStates)
67+
68+
const cleanup = mergeRegister(editor.registerUpdateListener(listener))
69+
document.addEventListener('mouseup', listener)
70+
5671
return () => {
57-
document.removeEventListener('mouseup', updateStates)
72+
cleanup()
73+
document.removeEventListener('mouseup', listener)
5874
}
59-
}, [updateStates])
75+
}, [editor, runDeprioritized, updateStates])
6076

61-
useEffect(() => {
62-
return mergeRegister(
63-
editor.registerUpdateListener(() => {
64-
updateStates()
65-
}),
66-
)
67-
}, [editor, updateStates])
77+
const handleClick = useCallback(() => {
78+
if (!_state.enabled) {
79+
return
80+
}
6881

69-
useEffect(() => {
70-
setClassName(
71-
[
72-
baseClass,
73-
enabled === false ? 'disabled' : '',
74-
active ? 'active' : '',
75-
item?.key ? `${baseClass}-` + item.key : '',
76-
]
77-
.filter(Boolean)
78-
.join(' '),
79-
)
80-
}, [enabled, active, className, item.key])
82+
editor.focus(() => {
83+
editor.update(() => {
84+
$addUpdateTag('toolbar')
85+
})
86+
// We need to wrap the onSelect in the callback, so the editor is properly focused before the onSelect is called.
87+
item.onSelect?.({
88+
editor,
89+
isActive: _state.active,
90+
})
91+
})
92+
}, [editor, item, _state])
93+
94+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
95+
// 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).
96+
// 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
97+
// 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.
98+
e.preventDefault()
99+
}, [])
81100

82101
return (
83-
<button
84-
className={className}
85-
onClick={() => {
86-
if (enabled !== false) {
87-
editor.focus(() => {
88-
editor.update(() => {
89-
$addUpdateTag('toolbar')
90-
})
91-
// We need to wrap the onSelect in the callback, so the editor is properly focused before the onSelect is called.
92-
item.onSelect?.({
93-
editor,
94-
isActive: active,
95-
})
96-
})
97-
98-
return true
99-
}
100-
}}
101-
onMouseDown={(e) => {
102-
// 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).
103-
// 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
104-
// 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.
105-
e.preventDefault()
106-
}}
107-
type="button"
108-
>
102+
<button className={className} onClick={handleClick} onMouseDown={handleMouseDown} type="button">
109103
{children}
110104
</button>
111105
)

packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/DropDown.tsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,16 @@ export function DropDownItem({
3131
item: ToolbarGroupItem
3232
tooltip?: string
3333
}): React.ReactNode {
34-
const [className, setClassName] = useState<string>(baseClass)
35-
36-
useEffect(() => {
37-
setClassName(
38-
[
39-
baseClass,
40-
enabled === false ? 'disabled' : '',
41-
active ? 'active' : '',
42-
item?.key ? `${baseClass}-${item.key}` : '',
43-
]
44-
.filter(Boolean)
45-
.join(' '),
46-
)
47-
}, [enabled, active, className, item.key])
34+
const className = useMemo(() => {
35+
return [
36+
baseClass,
37+
enabled === false ? 'disabled' : '',
38+
active ? 'active' : '',
39+
item?.key ? `${baseClass}-${item.key}` : '',
40+
]
41+
.filter(Boolean)
42+
.join(' ')
43+
}, [enabled, active, item.key])
4844

4945
const ref = useRef<HTMLButtonElement>(null)
5046

packages/richtext-lexical/src/features/toolbars/shared/ToolbarDropdown/index.tsx

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use client'
2-
import React, { useCallback, useEffect } from 'react'
2+
import React, { useCallback, useDeferredValue, useEffect, useMemo } from 'react'
33

44
const baseClass = 'toolbar-popup__dropdown'
55

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

1414
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
15-
import { DropDown, DropDownItem } from './DropDown.js'
15+
import { useRunDeprioritized } from '../../../../utilities/useRunDeprioritized.js'
1616
import './index.scss'
17+
import { DropDown, DropDownItem } from './DropDown.js'
1718

1819
const ToolbarItem = ({
1920
active,
@@ -78,6 +79,8 @@ const ToolbarItem = ({
7879
)
7980
}
8081

82+
const MemoToolbarItem = React.memo(ToolbarItem)
83+
8184
export const ToolbarDropdown = ({
8285
anchorElem,
8386
classNames,
@@ -103,12 +106,22 @@ export const ToolbarDropdown = ({
103106
maxActiveItems?: number
104107
onActiveChange?: ({ activeItems }: { activeItems: ToolbarGroupItem[] }) => void
105108
}) => {
106-
const [activeItemKeys, setActiveItemKeys] = React.useState<string[]>([])
107-
const [enabledItemKeys, setEnabledItemKeys] = React.useState<string[]>([])
108-
const [enabledGroup, setEnabledGroup] = React.useState<boolean>(true)
109+
const [toolbarState, setToolbarState] = React.useState<{
110+
activeItemKeys: string[]
111+
enabledGroup: boolean
112+
enabledItemKeys: string[]
113+
}>({
114+
activeItemKeys: [],
115+
enabledGroup: true,
116+
enabledItemKeys: [],
117+
})
118+
const deferredToolbarState = useDeferredValue(toolbarState)
119+
109120
const editorConfigContext = useEditorConfigContext()
110121
const { items, key: groupKey } = group
111122

123+
const runDeprioritized = useRunDeprioritized()
124+
112125
const updateStates = useCallback(() => {
113126
editor.getEditorState().read(() => {
114127
const selection = $getSelection()
@@ -137,56 +150,57 @@ export const ToolbarDropdown = ({
137150
_enabledItemKeys.push(item.key)
138151
}
139152
}
140-
if (group.isEnabled) {
141-
setEnabledGroup(group.isEnabled({ editor, editorConfigContext, selection }))
142-
}
143-
setActiveItemKeys(_activeItemKeys)
144-
setEnabledItemKeys(_enabledItemKeys)
153+
154+
setToolbarState({
155+
activeItemKeys: _activeItemKeys,
156+
enabledGroup: group.isEnabled
157+
? group.isEnabled({ editor, editorConfigContext, selection })
158+
: true,
159+
enabledItemKeys: _enabledItemKeys,
160+
})
145161

146162
if (onActiveChange) {
147163
onActiveChange({ activeItems: _activeItems })
148164
}
149165
})
150166
}, [editor, editorConfigContext, group, items, maxActiveItems, onActiveChange])
151167

152-
useEffect(() => {
153-
updateStates()
154-
}, [updateStates])
155-
156168
useEffect(() => {
157169
return mergeRegister(
158-
editor.registerUpdateListener(() => {
159-
updateStates()
170+
editor.registerUpdateListener(async () => {
171+
await runDeprioritized(updateStates)
160172
}),
161173
)
162-
}, [editor, updateStates])
174+
}, [editor, runDeprioritized, updateStates])
175+
176+
const renderedItems = useMemo(() => {
177+
return items?.length
178+
? items.map((item) => (
179+
<MemoToolbarItem
180+
active={deferredToolbarState.activeItemKeys.includes(item.key)}
181+
anchorElem={anchorElem}
182+
editor={editor}
183+
enabled={deferredToolbarState.enabledItemKeys.includes(item.key)}
184+
item={item}
185+
key={item.key}
186+
/>
187+
))
188+
: null
189+
}, [items, deferredToolbarState, anchorElem, editor])
163190

164191
return (
165192
<DropDown
166193
buttonAriaLabel={`${groupKey} dropdown`}
167194
buttonClassName={[baseClass, `${baseClass}-${groupKey}`, ...(classNames || [])]
168195
.filter(Boolean)
169196
.join(' ')}
170-
disabled={!enabledGroup}
197+
disabled={!deferredToolbarState.enabledGroup}
171198
Icon={Icon}
172199
itemsContainerClassNames={[`${baseClass}-items`, ...(itemsContainerClassNames || [])]}
173200
key={groupKey}
174201
label={label}
175202
>
176-
{items.length
177-
? items.map((item) => {
178-
return (
179-
<ToolbarItem
180-
active={activeItemKeys.includes(item.key)}
181-
anchorElem={anchorElem}
182-
editor={editor}
183-
enabled={enabledItemKeys.includes(item.key)}
184-
item={item}
185-
key={item.key}
186-
/>
187-
)
188-
})
189-
: null}
203+
{renderedItems}
190204
</DropDown>
191205
)
192206
}

packages/richtext-lexical/src/field/Field.tsx

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
useField,
1313
} from '@payloadcms/ui'
1414
import { mergeFieldStyles } from '@payloadcms/ui/shared'
15-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
15+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
1616
import { ErrorBoundary } from 'react-error-boundary'
1717

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

2626
import { LexicalProvider } from '../lexical/LexicalProvider.js'
27+
import { useRunDeprioritized } from '../utilities/useRunDeprioritized.js'
2728

2829
const baseClass = 'rich-text-lexical'
2930

@@ -116,35 +117,22 @@ const RichTextComponent: React.FC<
116117

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

119-
const dispatchFieldUpdateTask = useRef<number>(undefined)
120+
const runDeprioritized = useRunDeprioritized() // defaults to 500 ms timeout
120121

121122
const handleChange = useCallback(
122123
(editorState: EditorState) => {
123-
const updateFieldValue = (editorState: EditorState) => {
124+
// Capture `editorState` in the closure so we can safely run later.
125+
const updateFieldValue = () => {
124126
const newState = editorState.toJSON()
125127
prevValueRef.current = newState
126128
setValue(newState)
127129
}
128130

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

150138
const styles = useMemo(() => mergeFieldStyles(field), [field])

0 commit comments

Comments
 (0)