fix(ui): form state infinite render (#11665)

The task queue triggers an infinite render of form state. This is
because we return an object from the `useQueues` hook that is recreated
on every render. We then use the `queueTask` function as an unstable
dependency of the `useEffect` responsible for requesting new form state,
ultimately triggering an infinite rendering loop.

The fix is to stabilize the `queueTask` function within a `useCallback`.
Adds a test to prevent future regression.
This commit is contained in:
Jacob Fletcher
2025-03-12 15:06:06 -04:00
committed by GitHub
parent 4defa33221
commit b81358ce7e
7 changed files with 65 additions and 7 deletions

View File

@@ -1,4 +1,4 @@
import { useRef } from 'react'
import { useCallback, useRef } from 'react'
export function useQueues(): {
queueTask: (fn: (signal: AbortSignal) => Promise<void>) => void
@@ -7,7 +7,7 @@ export function useQueues(): {
const queuedTask = useRef<((signal: AbortSignal) => Promise<void>) | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const queueTask = (fn: (signal: AbortSignal) => Promise<void>) => {
const queueTask = useCallback((fn: (signal: AbortSignal) => Promise<void>) => {
// Overwrite the queued task every time a new one arrives
queuedTask.current = fn
@@ -42,7 +42,7 @@ export function useQueues(): {
}
void executeTask()
}
}, [])
return { queueTask }
}

View File

@@ -0,0 +1,10 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import { useField } from '@payloadcms/ui'
export const RenderTracker: TextFieldClientComponent = ({ path }) => {
useField({ path })
console.count('Renders') // eslint-disable-line no-console
return null
}

View File

@@ -12,6 +12,15 @@ export const PostsCollection: CollectionConfig = {
name: 'title',
type: 'text',
},
{
name: 'renderTracker',
type: 'text',
admin: {
components: {
Field: './collections/Posts/RenderTracker.js#RenderTracker',
},
},
},
{
name: 'validateUsingEvent',
type: 'text',

View File

@@ -4,6 +4,7 @@ import { expect, test } from '@playwright/test'
import { addBlock } from 'helpers/e2e/addBlock.js'
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
import * as path from 'path'
import React from 'react'
import { fileURLToPath } from 'url'
import {
@@ -71,6 +72,42 @@ test.describe('Form State', () => {
)
})
test('should not throw fields into an infinite rendering loop', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
let numberOfRenders = 0
page.on('console', (msg) => {
if (msg.type() === 'count' && msg.text().includes('Renders')) {
numberOfRenders++
}
})
const allowedNumberOfRenders = 25
const pollInterval = 200
const maxTime = 5000
let elapsedTime = 0
const intervalId = setInterval(() => {
if (numberOfRenders > allowedNumberOfRenders) {
clearInterval(intervalId)
throw new Error(`Render count exceeded the threshold of ${allowedNumberOfRenders}`)
}
elapsedTime += pollInterval
if (elapsedTime >= maxTime) {
clearInterval(intervalId)
}
}, pollInterval)
await page.waitForTimeout(maxTime)
expect(numberOfRenders).toBeLessThanOrEqual(allowedNumberOfRenders)
})
test('should debounce onChange events', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
@@ -106,10 +143,10 @@ test.describe('Form State', () => {
async () => {
await field.fill('')
// Need to type into a _slower_ than the debounce rate (250ms), but _faster_ than the network request
await field.pressSequentially('Some text to type', { delay: 300 })
await field.pressSequentially('Some text to type', { delay: 275 })
},
{
allowedNumberOfRequests: 1,
allowedNumberOfRequests: 2,
timeout: 10000, // watch network for 10 seconds to allow requests to build up
},
)

View File

@@ -119,6 +119,7 @@ export interface UserAuthOperations {
export interface Post {
id: string;
title?: string | null;
renderTracker?: string | null;
/**
* This field should only validate on submit. Try typing "Not allowed" and submitting the form.
*/
@@ -222,6 +223,7 @@ export interface PayloadMigration {
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
renderTracker?: T;
validateUsingEvent?: T;
blocks?:
| T

View File

@@ -47,7 +47,7 @@ const networkConditions = {
},
'Slow 3G': {
download: ((500 * 1000) / 8) * 0.8,
latency: 400 * 5,
latency: 2500,
upload: ((500 * 1000) / 8) * 0.8,
},
'Slow 4G': {

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/plugin-multi-tenant/config.ts"],
"@payload-config": ["./test/_community/config.ts"],
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],