feat(ui): form state queues (#11579)

Implements a form state task queue. This will prevent onChange handlers
within the form component from processing unnecessarily often, sometimes
long after the user has stopped making changes. This leads to a
potentially huge number of network requests if those changes were made
slower than the debounce rate. This is especially noticeable on slow
networks.

Does so through a new `useQueue` hook. This hook maintains a stack of
events that need processing but only processes the final event to
arrive. Every time a new event is pushed to the stack, the currently
running process is aborted (if any), and that event becomes the next in
the queue. This results in a shocking reduction in the time it takes
between final change to form state and the final network response, from
~1.5 minutes to ~3 seconds (depending on the scenario, see below).

This likely fixes a number of existing open issues. I will link those
issues here once they are identified and verifiably fixed.

Before:

I'm typing slowly here to ensure my changes aren't debounce by the form.
There are a total of 60 characters typed, triggering 58 network requests
and taking around 1.5 minutes to complete after the final change was
made.


https://github.com/user-attachments/assets/49ba0790-a8f8-4390-8421-87453ff8b650

After:

Here there are a total of 69 characters typed, triggering 11 network
requests and taking only about 3 seconds to complete after the final
change was made.


https://github.com/user-attachments/assets/447f8303-0957-41bd-bb2d-9e1151ed9ec3
This commit is contained in:
Jacob Fletcher
2025-03-10 21:25:14 -04:00
committed by GitHub
parent 397c1f1ae7
commit ac1e3cf69e
24 changed files with 2641 additions and 96 deletions

View File

@@ -263,25 +263,6 @@ export const Posts: CollectionConfig = {
position: 'sidebar',
},
},
{
name: 'validateUsingEvent',
type: 'text',
admin: {
description:
'This field should only validate on submit. Try typing "Not allowed" and submitting the form.',
},
validate: (value, { event }) => {
if (event === 'onChange') {
return true
}
if (value === 'Not allowed') {
return 'This field has been validated only on submit'
}
return true
},
},
],
labels: {
plural: slugPluralLabel,

View File

@@ -174,36 +174,12 @@ describe('Document View', () => {
})
})
describe('form state', () => {
test('collection — should re-enable fields after save', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toBeEnabled()
})
test('global — should re-enable fields after save', async () => {
await page.goto(globalURL.global(globalSlug))
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toBeEnabled()
})
test('should thread proper event argument to validation functions', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await page.locator('#field-validateUsingEvent').fill('Not allowed')
await saveDocAndAssert(page, '#action-save', 'error')
})
})
describe('document titles', () => {
test('collection — should render fallback titles when creating new', async () => {
await page.goto(postsUrl.create)
await checkPageTitle(page, '[Untitled]')
await checkBreadcrumb(page, 'Create New')
await saveDocAndAssert(page)
expect(true).toBe(true)
})
test('collection — should render `useAsTitle` field', async () => {
@@ -213,7 +189,6 @@ describe('Document View', () => {
await wait(500)
await checkPageTitle(page, title)
await checkBreadcrumb(page, title)
expect(true).toBe(true)
})
test('collection — should render `id` as `useAsTitle` fallback', async () => {

View File

@@ -65,7 +65,12 @@ export const testEslintConfig = [
'playwright/expect-expect': [
'error',
{
assertFunctionNames: ['assertToastErrors', 'saveDocAndAssert', 'runFilterOptionsTest'],
assertFunctionNames: [
'assertToastErrors',
'saveDocAndAssert',
'runFilterOptionsTest',
'assertNetworkRequests',
],
},
],
},

View File

@@ -23,7 +23,7 @@ import type {
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js'
import { assertNetworkRequests } from '../helpers/e2e/assertNetworkRequests.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
@@ -176,7 +176,7 @@ describe('Relationship Field', () => {
await expect(options).toHaveCount(2) // two docs
await options.nth(0).click()
await expect(field).toContainText(relationOneDoc.id)
await trackNetworkRequests(page, `/api/${relationOneSlug}`, async () => {
await assertNetworkRequests(page, `/api/${relationOneSlug}`, async () => {
await saveDocAndAssert(page)
await wait(200)
})

View File

@@ -114,7 +114,7 @@ describe('Array', () => {
await expect(page.locator('#field-customArrayField__0__text')).toBeVisible()
})
// eslint-disable-next-line playwright/expect-expect
test('should bypass min rows validation when no rows present and field is not required', async () => {
await page.goto(url.create)
await saveDocAndAssert(page)

View File

@@ -25,7 +25,7 @@ import {
} from '../../../../../helpers.js'
import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js'
import { assertToastErrors } from '../../../../../helpers/assertToastErrors.js'
import { trackNetworkRequests } from '../../../../../helpers/e2e/trackNetworkRequests.js'
import { assertNetworkRequests } from '../../../../../helpers/e2e/assertNetworkRequests.js'
import { initPayloadE2ENoConfig } from '../../../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../../../helpers/rest.js'
@@ -400,11 +400,12 @@ describe('lexicalBlocks', () => {
await dependsOnBlockData.locator('.rs__control').click()
// Fill and wait for form state to come back
await trackNetworkRequests(page, '/admin/collections/lexical-fields', async () => {
await assertNetworkRequests(page, '/admin/collections/lexical-fields', async () => {
await topLevelDocTextField.fill('invalid')
})
// Ensure block form state is updated and comes back (=> filter options are updated)
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
@@ -442,7 +443,7 @@ describe('lexicalBlocks', () => {
topLevelDocTextField,
} = await setupFilterOptionsTests()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
@@ -478,7 +479,7 @@ describe('lexicalBlocks', () => {
topLevelDocTextField,
} = await setupFilterOptionsTests()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
@@ -571,19 +572,21 @@ describe('lexicalBlocks', () => {
await topLevelDocTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await assertToastErrors({
page,
errors: ['Lexical With Blocks', 'Lexical With Blocks → Group → Text Depends On Doc Data'],
})
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await topLevelDocTextField.fill('Rich Text') // Default value
},
{ allowedNumberOfRequests: 2 },
{ allowedNumberOfRequests: 1 },
)
await saveDocAndAssert(page)
@@ -604,13 +607,13 @@ describe('lexicalBlocks', () => {
})
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockGroupTextField.fill('')
},
{ allowedNumberOfRequests: 3 },
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)
@@ -628,13 +631,13 @@ describe('lexicalBlocks', () => {
})
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
await assertNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockTextField.fill('')
},
{ allowedNumberOfRequests: 3 },
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)

View File

@@ -111,7 +111,6 @@ describe('Number', () => {
test('should bypass min rows validation when no rows present and field is not required', async () => {
await page.goto(url.create)
await saveDocAndAssert(page)
expect(true).toBe(true) // the above fn contains the assertion
})
test('should fail min rows validation when rows are present', async () => {

2
test/form-state/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/media
/media-gif

View File

@@ -0,0 +1,59 @@
import type { CollectionConfig } from 'payload'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'validateUsingEvent',
type: 'text',
admin: {
description:
'This field should only validate on submit. Try typing "Not allowed" and submitting the form.',
},
validate: (value, { event }) => {
if (event === 'onChange') {
return true
}
if (value === 'Not allowed') {
return 'This field has been validated only on submit'
}
return true
},
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'text',
fields: [
{
name: 'text',
type: 'text',
},
],
},
{
slug: 'number',
fields: [
{
name: 'number',
type: 'number',
},
],
},
],
},
],
}

37
test/form-state/config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
collections: [PostsCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
await payload.create({
collection: postsSlug,
data: {
title: 'example post',
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

126
test/form-state/e2e.spec.ts Normal file
View File

@@ -0,0 +1,126 @@
import type { BrowserContext, Page } from '@playwright/test'
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 { fileURLToPath } from 'url'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const title = 'Title'
let context: BrowserContext
test.describe('Form State', () => {
let page: Page
let postsUrl: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
const { payload, serverURL } = await initPayloadE2ENoConfig({ dirname })
postsUrl = new AdminUrlUtil(serverURL, 'posts')
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
test('collection — should re-enable fields after save', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toBeEnabled()
})
test('should thread proper event argument to validation functions', async () => {
await page.goto(postsUrl.create)
await page.locator('#field-title').fill(title)
await page.locator('#field-validateUsingEvent').fill('Not allowed')
await saveDocAndAssert(page, '#action-save', 'error')
})
test('should fire a single network request for onChange events when manipulating blocks', async () => {
await page.goto(postsUrl.create)
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
await addBlock({
page,
blockLabel: 'Text',
fieldName: 'blocks',
})
},
{
allowedNumberOfRequests: 1,
},
)
})
test('should debounce onChange events', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await assertNetworkRequests(
page,
postsUrl.create,
async () => {
// Need to type _faster_ than the debounce rate (250ms)
await field.pressSequentially('Some text to type', { delay: 50 })
},
{
allowedNumberOfRequests: 1,
},
)
})
test('should queue onChange functions', async () => {
await page.goto(postsUrl.create)
const field = page.locator('#field-title')
await field.fill('Test')
// only throttle test after initial load to avoid timeouts
const cdpSession = await throttleTest({
page,
context,
delay: 'Slow 3G',
})
await assertNetworkRequests(
page,
postsUrl.create,
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 })
},
{
allowedNumberOfRequests: 1,
timeout: 10000, // watch network for 10 seconds to allow requests to build up
},
)
await cdpSession.send('Network.emulateNetworkConditions', {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
await cdpSession.detach()
})
})

View File

@@ -0,0 +1,19 @@
import { rootParserOptions } from '../../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -0,0 +1,47 @@
import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { postsSlug } from './collections/Posts/index.js'
let payload: Payload
let token: string
let restClient: NextRESTClient
const { email, password } = devUser
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Form State', () => {
// --__--__--__--__--__--__--__--__--__
// Boilerplate test setup/teardown
// --__--__--__--__--__--__--__--__--__
beforeAll(async () => {
const initialized = await initPayloadInt(dirname)
;({ payload, restClient } = initialized)
const data = await restClient
.POST('/users/login', {
body: JSON.stringify({
email,
password,
}),
})
.then((res) => res.json())
token = data.token
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
it.todo('should execute form state endpoint')
})

View File

@@ -0,0 +1,306 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
posts: Post;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
title?: string | null;
/**
* This field should only validate on submit. Try typing "Not allowed" and submitting the form.
*/
validateUsingEvent?: string | null;
blocks?:
| (
| {
text?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'text';
}
| {
number?: number | null;
id?: string | null;
blockName?: string | null;
blockType: 'number';
}
)[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
validateUsingEvent?: T;
blocks?:
| T
| {
text?:
| T
| {
text?: T;
id?: T;
blockName?: T;
};
number?:
| T
| {
number?: T;
id?: T;
blockName?: T;
};
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}

9
test/form-state/types.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { RequestContext as OriginalRequestContext } from 'payload'
declare module 'payload' {
// Create a new interface that merges your additional fields with the original one
export interface RequestContext extends OriginalRequestContext {
myObject?: string
// ...
}
}

View File

@@ -2,23 +2,37 @@ import type { Page, Request } from '@playwright/test'
import { expect } from '@playwright/test'
// Allows you to test the number of network requests triggered by an action
// This can be used to ensure various actions do not trigger unnecessary requests
// For example, an effect within a component might fetch data multiple times unnecessarily
export const trackNetworkRequests = async (
/**
* Counts the number of network requests every `interval` milliseconds until `timeout` is reached.
* Useful to ensure unexpected network requests are not triggered by an action.
* For example, an effect within a component might fetch data multiple times unnecessarily.
* @param page The Playwright page
* @param url The URL to match in the network requests
* @param action The action to perform
* @param options Options
* @param options.allowedNumberOfRequests The number of requests that are allowed to be made, defaults to 1
* @param options.beforePoll A function to run before polling the network requests
* @param options.interval The interval in milliseconds to poll the network requests, defaults to 1000
* @param options.timeout The timeout in milliseconds to poll the network requests, defaults to 5000
* @returns The matched network requests
*/
export const assertNetworkRequests = async (
page: Page,
url: string,
action: () => Promise<any>,
options?: {
{
beforePoll,
allowedNumberOfRequests = 1,
timeout = 5000,
interval = 1000,
}: {
allowedNumberOfRequests?: number
beforePoll?: () => Promise<any> | void
interval?: number
timeout?: number
},
} = {},
): Promise<Array<Request>> => {
const { beforePoll, allowedNumberOfRequests = 1, timeout = 5000, interval = 1000 } = options || {}
const matchedRequests = []
const matchedRequests: Request[] = []
// begin tracking network requests
page.on('request', (request) => {

View File

@@ -42,7 +42,7 @@ import {
selectTableRow,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js'
import { assertNetworkRequests } from '../helpers/e2e/assertNetworkRequests.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { waitForAutoSaveToRunAndComplete } from '../helpers/waitForAutoSaveToRunAndComplete.js'
@@ -416,7 +416,7 @@ describe('Versions', () => {
await page.goto(postURL.edit(postID))
await trackNetworkRequests(
await assertNetworkRequests(
page,
`${serverURL}/admin/collections/${postCollectionSlug}/${postID}`,
async () => {
@@ -1224,6 +1224,7 @@ describe('Versions', () => {
test('should render diff', async () => {
await navigateToVersionDiff()
expect(true).toBe(true)
})
test('should render diff for nested fields', async () => {