Skip to content

Commit 38e982f

Browse files
committed
fixup! fix(ui): prevent race conditions after move row by only replacing state for the latest change
1 parent 6688080 commit 38e982f

File tree

8 files changed

+720
-0
lines changed

8 files changed

+720
-0
lines changed

Diff for: test/drag-and-drop-blocks/collections/fruits.ts

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Block, CollectionConfig } from 'payload'
2+
3+
function createFruitBlock(slug: string, name: string): Block {
4+
return {
5+
slug,
6+
fields: [
7+
{
8+
type: 'text',
9+
name: `${name}Type`,
10+
},
11+
{ name: `seed${name}`, type: 'relationship', relationTo: 'seeds', hasMany: true },
12+
],
13+
}
14+
}
15+
16+
export const Fruits: CollectionConfig = {
17+
versions: {
18+
drafts: { autosave: true },
19+
},
20+
slug: 'fruits',
21+
fields: [
22+
{
23+
name: 'blocks',
24+
type: 'blocks',
25+
blocks: [
26+
createFruitBlock('apples', 'Apple'),
27+
createFruitBlock('pears', 'Pear'),
28+
createFruitBlock('oranges', 'Orange'),
29+
],
30+
},
31+
],
32+
}
33+
34+
export const Seeds: CollectionConfig = {
35+
admin: {
36+
useAsTitle: 'name',
37+
},
38+
slug: 'seeds',
39+
fields: [
40+
{
41+
type: 'text',
42+
name: 'name',
43+
},
44+
],
45+
}

Diff for: test/drag-and-drop-blocks/config.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { lexicalEditor } from '@payloadcms/richtext-lexical'
2+
import { fileURLToPath } from 'node:url'
3+
import path from 'path'
4+
5+
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
6+
import { devUser } from '../credentials.js'
7+
import { Fruits, Seeds } from './collections/fruits.js'
8+
9+
const filename = fileURLToPath(import.meta.url)
10+
const dirname = path.dirname(filename)
11+
12+
export default buildConfigWithDefaults({
13+
// ...extend config here
14+
collections: [Fruits, Seeds],
15+
admin: {
16+
importMap: {
17+
baseDir: path.resolve(dirname),
18+
},
19+
},
20+
editor: lexicalEditor({}),
21+
onInit: async (payload) => {
22+
await payload.create({
23+
collection: 'users',
24+
data: {
25+
email: devUser.email,
26+
password: devUser.password,
27+
},
28+
})
29+
},
30+
typescript: {
31+
outputFile: path.resolve(dirname, 'payload-types.ts'),
32+
},
33+
})

Diff for: test/drag-and-drop-blocks/e2e.spec.ts

+309
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import type { BrowserContext, Page } from '@playwright/test'
2+
3+
import { expect, test } from '@playwright/test'
4+
import * as path from 'path'
5+
import { fileURLToPath } from 'url'
6+
7+
import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js'
8+
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
9+
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
10+
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
11+
import { Config, Fruit, Seed } from './payload-types.js'
12+
import { PayloadTestSDK } from 'helpers/sdk/index.js'
13+
14+
type Block = NonNullable<NonNullable<Fruit['blocks']>[0]>
15+
16+
const filename = fileURLToPath(import.meta.url)
17+
const dirname = path.dirname(filename)
18+
19+
test.describe('Drag & drop blocks', () => {
20+
let page: Page
21+
let url: AdminUrlUtil
22+
let fruit: Fruit
23+
let blockAppleA: Block
24+
let blockAppleB: Block
25+
let blockPearC: Block
26+
let seedsAppleA: Seed[]
27+
let seedsOrangeB: Seed[]
28+
let seedsPearC: Seed[]
29+
let context: BrowserContext
30+
let serverURL: string
31+
let payload: PayloadTestSDK<Config>
32+
let blocksIn: Block[]
33+
34+
test.beforeAll(async ({ browser }, testInfo) => {
35+
testInfo.setTimeout(TEST_TIMEOUT_LONG)
36+
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
37+
url = new AdminUrlUtil(serverURL, 'fruits')
38+
39+
context = await browser.newContext()
40+
page = await context.newPage()
41+
await page.setViewportSize({ width: 1270, height: 960 })
42+
43+
initPageConsoleErrorCatch(page)
44+
await ensureCompilationIsDone({ page, serverURL })
45+
46+
seedsAppleA = await createSeeds(payload as any, 'Seeds for Apple A')
47+
seedsOrangeB = await createSeeds(payload as any, 'Seeds for Orange B')
48+
seedsPearC = await createSeeds(payload as any, 'Seeds for Pear C')
49+
50+
fruit = await payload.create({
51+
collection: 'fruits',
52+
data: {
53+
blocks: [
54+
{
55+
blockName: 'Apple A',
56+
blockType: 'apples',
57+
AppleType: 'Apple A',
58+
seedApple: seedsAppleA,
59+
},
60+
{
61+
blockName: 'Orange B',
62+
blockType: 'oranges',
63+
OrangeType: 'Large Orange',
64+
seedOrange: seedsOrangeB,
65+
},
66+
{
67+
blockName: 'Pear C',
68+
blockType: 'pears',
69+
PearType: 'Pear C',
70+
seedPear: seedsPearC,
71+
},
72+
// Add an extra dummy block so that we have a larger droppable area for the third block
73+
{
74+
blockName: 'Dummy last block to make dragging easier',
75+
blockType: 'pears',
76+
PearType: 'Dummy pear',
77+
seedPear: [],
78+
},
79+
],
80+
},
81+
})
82+
blockAppleA = fruit.blocks![0] as any
83+
blockAppleB = fruit.blocks![1] as any
84+
blockPearC = fruit.blocks![2] as any
85+
blocksIn = [blockAppleA, blockAppleB, blockPearC]
86+
})
87+
88+
test.afterEach(async () => {
89+
// Reset to same block order
90+
await payload.update({ collection: 'fruits', id: fruit.id, data: { blocks: blocksIn } })
91+
})
92+
93+
test('should drag different blocks around and maintain values even with different block types', async ({}, testInfo) => {
94+
await page.goto(`${url.serverURL}/admin/collections/fruits/${fruit.id}`)
95+
96+
// Wait for the blocks to be loaded
97+
await page.waitForSelector('#field-blocks')
98+
99+
// Add zoom style so that we can see all blocks when running in headed mode
100+
await page.evaluate(() => {
101+
const el = document.getElementsByClassName('collection-edit__form')[0] as HTMLElement
102+
el.style.zoom = '0.5'
103+
})
104+
105+
const blocksIn = [blockAppleA, blockAppleB, blockPearC]
106+
107+
// Expand all blocks first
108+
await expandAllBlocks(page, blocksIn)
109+
110+
const { dragAndDropRandomBlock } = await createRandomBlockDragger(page, blocksIn)
111+
112+
let blocksOut: Block[] = []
113+
for (let i = 0; i < 10; i++) {
114+
blocksOut = await dragAndDropRandomBlock()
115+
if (i % 2 === 0) {
116+
await verifyBlockFieldValues(page, blocksOut)
117+
}
118+
}
119+
const autosavePromise = awaitAutosave(page, fruit)
120+
await verifyBlockFieldValues(page, blocksOut)
121+
122+
// Verify that all changes are correctly stored after autosave
123+
await autosavePromise
124+
await verifyBlockFieldValuesAfterReload(page, blocksOut)
125+
})
126+
// NOTE: this test is added to prevent future regressions of the race condition described here:
127+
// https://github.com/payloadcms/payload/pull/8961
128+
test('should be able to drag blocks during get form state without race condition', async ({}, testInfo) => {
129+
await page.goto(`${url.serverURL}/admin/collections/fruits/${fruit.id}`)
130+
131+
// Wait for the blocks to be loaded
132+
await page.waitForSelector('#field-blocks')
133+
134+
// Add zoom style so that we can see all blocks when running in headed mode
135+
await page.evaluate(() => {
136+
const el = document.getElementsByClassName('collection-edit__form')[0] as HTMLElement
137+
el.style.zoom = '0.5'
138+
})
139+
140+
const blocksIn = [blockAppleA, blockAppleB, blockPearC]
141+
142+
// Expand all blocks first
143+
await expandAllBlocks(page, blocksIn)
144+
145+
const { dragAndDropRandomBlock } = await createRandomBlockDragger(page, blocksIn)
146+
147+
// For the race condition to occur, the blocks need to be dragged at the moment
148+
// the form state is requested in the on change handler
149+
let blocksOut: Block[] = []
150+
let requestCount = 0
151+
let raceconditionResolve: (value: unknown) => void
152+
const raceconditionPromise = new Promise((resolve) => {
153+
raceconditionResolve = resolve
154+
})
155+
await page.route(`**/admin/collections/fruits/${fruit.id}`, async (route) => {
156+
// ignore other type of requests
157+
const request = route.request()
158+
if (request.method() !== 'POST' || request.headers()['accept'] !== 'text/x-component') {
159+
await route.continue()
160+
return
161+
}
162+
// At the first request, perform the drag at that moment
163+
requestCount++
164+
if (requestCount === 1) {
165+
blocksOut = await dragAndDropRandomBlock()
166+
}
167+
await route.continue()
168+
raceconditionResolve('resolved')
169+
})
170+
171+
// Perform first drag that triggers the second drag during on change handler execution above with the form state request
172+
blocksOut = await dragAndDropRandomBlock()
173+
await verifyBlockFieldValues(page, blocksOut)
174+
175+
// Perform another drag to initiate a render with the incorrect state
176+
await raceconditionPromise
177+
const autosavePromise = awaitAutosave(page, fruit)
178+
blocksOut = await dragAndDropRandomBlock()
179+
await verifyBlockFieldValues(page, blocksOut)
180+
181+
// Verify that all changes are correctly stored after autosave
182+
await autosavePromise
183+
await verifyBlockFieldValuesAfterReload(page, blocksOut)
184+
})
185+
})
186+
187+
async function createSeeds(payload: PayloadTestSDK<Config>, namePrefix: string) {
188+
return [
189+
await payload.create({
190+
collection: 'seeds',
191+
data: {
192+
name: `${namePrefix} - 1`,
193+
},
194+
}),
195+
await payload.create({
196+
collection: 'seeds',
197+
data: {
198+
name: `${namePrefix} - 2`,
199+
},
200+
}),
201+
]
202+
}
203+
204+
async function expandAllBlocks(page: Page, blocks: Block[]) {
205+
await page.locator('button:text("Show All")').click()
206+
await expect(page.locator(`#field-blocks #blocks-row-${blocks.length - 1}`)).toBeVisible()
207+
}
208+
209+
async function createRandomBlockDragger(page: Page, blocksInput: Block[]) {
210+
const blocks = [...blocksInput]
211+
212+
const blockBox1 = await page.locator('#blocks-row-0').boundingBox()
213+
const draggableIconLocator = page.locator('#field-blocks .collapsible__drag').first()
214+
const draggableIconBox = await draggableIconLocator.boundingBox()
215+
const draggableIconOffsetX = draggableIconBox!.x + draggableIconBox!.width * 0.5 - blockBox1!.x
216+
217+
async function dragAndDropRandomBlock() {
218+
const sourceBlockIndex = 1
219+
const possibleIndices = [0, 2]
220+
const targetBlockIndex = possibleIndices[Math.floor(Math.random() * possibleIndices.length)]
221+
222+
const sourceDragIconLocator = page.locator(`#blocks-row-${sourceBlockIndex} .collapsible__drag`)
223+
const targetBlockBox = await page.locator(`#blocks-row-${targetBlockIndex}`).boundingBox()
224+
if (!targetBlockBox) {
225+
throw new Error(`Target block box ${targetBlockIndex} not found`)
226+
}
227+
228+
// Calculate target position based on where we want to insert the block
229+
const target = {
230+
x: targetBlockBox.x + draggableIconOffsetX,
231+
y:
232+
sourceBlockIndex < targetBlockIndex
233+
? targetBlockBox.y + 20 // Drop after target
234+
: targetBlockBox.y - 20, // Drop before target
235+
}
236+
237+
await sourceDragIconLocator.hover()
238+
await page.mouse.down()
239+
await page.mouse.move(target.x, target.y, { steps: 2 })
240+
await page.mouse.up()
241+
242+
// Update blocks array to match the actual DOM order
243+
const movedBlock = blocks[sourceBlockIndex]
244+
blocks.splice(sourceBlockIndex, 1)
245+
blocks.splice(targetBlockIndex, 0, movedBlock)
246+
247+
return blocks
248+
}
249+
250+
return {
251+
dragAndDropRandomBlock,
252+
}
253+
}
254+
255+
async function verifyBlockFieldValues(page: Page, blocksOut: Block[]) {
256+
for (let blockIndex = 0; blockIndex < blocksOut.length; blockIndex++) {
257+
const block = blocksOut[blockIndex] as Block
258+
const namePrefix = `blocks.${blockIndex}`
259+
260+
const fieldNamePrefix = block.blockType[0].toUpperCase() + block.blockType.slice(1, -1)
261+
262+
// Verify apple kind
263+
await expect(page.locator(`input[name="${namePrefix}.${fieldNamePrefix}Type"]`)).toBeVisible()
264+
await expect(page.locator(`input[name="${namePrefix}.${fieldNamePrefix}Type"]`)).toHaveValue(
265+
block[`${fieldNamePrefix}Type`] ?? '',
266+
)
267+
// Verify seed relationships
268+
const seedsSelector = `#field-blocks__${blockIndex}__seed${fieldNamePrefix} div[class="relationship--multi-value-label__text"]`
269+
await expect(page.locator(seedsSelector).last()).toBeVisible()
270+
const seedNames = await page.locator(seedsSelector).allInnerTexts()
271+
272+
const seedLenth = block[`seed${fieldNamePrefix}`]?.length ?? 0
273+
for (let seedIndex = 0; seedIndex < seedLenth; seedIndex++) {
274+
const seed = block[`seed${fieldNamePrefix}`]![seedIndex] as Seed
275+
expect(seedNames[seedIndex]).toEqual(seed.name)
276+
}
277+
}
278+
}
279+
280+
function awaitAutosave(page: Page, fruit: Fruit) {
281+
return page
282+
.waitForResponse(
283+
(resp) => {
284+
const url = new URL(resp.url())
285+
if (
286+
url.pathname.includes(`/api/fruits/${fruit.id}`) &&
287+
url.searchParams.get('autosave') === 'true'
288+
) {
289+
expect(resp.status()).toBe(200)
290+
return true
291+
}
292+
return false
293+
},
294+
{ timeout: 9_000 },
295+
)
296+
.catch((err) => {
297+
/*ignore already autosaved */
298+
})
299+
}
300+
301+
async function verifyBlockFieldValuesAfterReload(page: Page, blocksOut: Block[]) {
302+
await page.reload()
303+
await page.evaluate(() => {
304+
const el = document.getElementsByClassName('collection-edit__form')[0] as HTMLElement
305+
el.style.zoom = '0.5'
306+
})
307+
await expandAllBlocks(page, blocksOut)
308+
await verifyBlockFieldValues(page, blocksOut)
309+
}

0 commit comments

Comments
 (0)