perf(ui): useAsTitle field lags on slow cpu (#12436)

When running the Payload admin panel on a machine with a slower CPU,
form state lags significantly and can become nearly unusable or even
crash when interacting with the document's `useAsTitle` field.

Here's an example:


https://github.com/user-attachments/assets/3535fa99-1b31-4cb6-b6a8-5eb9a36b31b7

#### Why this happens

The reason for this is that entire React component trees are
re-rendering on every keystroke of the `useAsTitle` field, twice over.

Here's a breakdown of the flow:

1. First, we dispatch form state events to the form context. Only the
components that are subscribed to form state re-render when this happens
(good).
2. Then, we sync the `useAsTitle` field to the document info provider,
which lives outside the form. Regardless of whether its children need to
be aware of the document title, all components subscribed to the
document info context will re-render (there are many, including the form
itself).

Given how far up the rendering tree the document info provider is, its
rendering footprint, and the rate of speed at which these events are
dispatched, this is resource intensive.

#### What is the fix

The fix is to isolate the document's title into it's own context. This
way only the components that are subscribed to specifically this context
will re-render as the title changes.

Here's the same test with the same CPU throttling, but no lag:


https://github.com/user-attachments/assets/c8ced9b1-b5f0-4789-8d00-a2523d833524
This commit is contained in:
Jacob Fletcher
2025-05-16 15:51:57 -04:00
committed by GitHub
parent d4899b84cc
commit 18f2f899c5
22 changed files with 203 additions and 25 deletions

View File

@@ -251,6 +251,57 @@ test.describe('Form State', () => {
).toHaveValue('This is a default value')
})
// TODO: This test is not very reliable but would be really nice to have
test.skip('should not lag on slow CPUs', async () => {
await page.goto(postsUrl.create)
await expect(page.locator('#field-title')).toBeEnabled()
const cdpSession = await context.newCDPSession(page)
await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 25 })
// Start measuring input and render times
await page.evaluate(() => {
const inputField = document.querySelector('#field-title') as HTMLInputElement
const logs: Record<string, { elapsedTime: number }> = {}
inputField.addEventListener('input', (event) => {
const startTime = performance.now()
requestAnimationFrame(() => {
const endTime = performance.now()
const elapsedTime = endTime - startTime
logs[event.target?.value] = { elapsedTime }
})
})
window.getLogs = () => logs
})
const text = 'This is a test string to measure input lag.'
await page.locator('#field-title').pressSequentially(text, { delay: 0 })
const logs: Record<string, { elapsedTime: number }> = await page.evaluate(() =>
window.getLogs(),
)
console.log('Logs:', logs)
const lagTimes = Object.values(logs).map((log) => log.elapsedTime || 0)
console.log('Lag times:', lagTimes)
const maxInputLag = Math.max(...lagTimes)
const allowedThreshold = 50
expect(maxInputLag).toBeLessThanOrEqual(allowedThreshold)
// Reset CPU throttling
await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 1 })
await cdpSession.detach()
})
describe('Throttled tests', () => {
let cdpSession: CDPSession