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:
@@ -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 }
|
||||
}
|
||||
|
||||
10
test/form-state/collections/Posts/RenderTracker.tsx
Normal file
10
test/form-state/collections/Posts/RenderTracker.tsx
Normal 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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user