perf: actually debounce rich text editor field value updates to only process latest state (#12086)

Follow-up work to #12046, which was misnamed. It improved UI
responsiveness of the rich text field on CPU-limited clients, but didn't
actually reduce work by debouncing. It only improved scheduling.

Using `requestIdleCallback` lead to better scheduling of change event
handling in the rich text editor, but on CPU-starved clients, this leads
to a large backlog of unprocessed idle callbacks. Since idle callbacks
are called by the browser in submission order, the latest callback will
be processed last, potentially leading to large time delays between a
user typing, and the form state having been updated. An example: When a
user types "I", and the change events for the character "I" is scheduled
to happen in the next browser idle time, but then the user goes on to
type "love Payload", there will be 12 more callbacks scheduled. On a
slow system it's preferable if the browser right away only processes the
event that has the full editor state "I love Payload", instead of only
processing that after 11 other idle callbacks.

So this code change keeps track when requesting an idle callback and
cancels the previous one when a new change event with an updated editor
state occurs.
This commit is contained in:
Philipp Schneider
2025-05-14 18:14:29 +02:00
committed by GitHub
parent faa7794cc7
commit 1d5d96d2c3

View File

@@ -12,7 +12,7 @@ import {
useField,
} from '@payloadcms/ui'
import { mergeFieldStyles } from '@payloadcms/ui/shared'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import type { SanitizedClientEditorConfig } from '../lexical/config/types.js'
@@ -116,16 +116,30 @@ const RichTextComponent: React.FC<
const pathWithEditDepth = `${path}.${editDepth}`
const updateFieldValue = (editorState: EditorState) => {
const newState = editorState.toJSON()
prevValueRef.current = newState
setValue(newState)
}
const dispatchFieldUpdateTask = useRef<number>(undefined)
const handleChange = useCallback(
(editorState: EditorState) => {
const updateFieldValue = (editorState: EditorState) => {
const newState = editorState.toJSON()
prevValueRef.current = newState
setValue(newState)
}
if (typeof window.requestIdleCallback === 'function') {
requestIdleCallback(() => updateFieldValue(editorState))
// 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)
}