Skip to content

Commit 1e13474

Browse files
authored
fix: deeply merge array and block rows from server form state (#13551)
Continuation of #13501. When merging server form state with `acceptValues: true`, like on submit (not autosave), rows are not deeply merged causing custom row components, like row labels, to disappear. This is because we never attach components to the form state response unless it has re-rendered server-side, so unless we merge these rows with the current state, we lose them. Instead of allowing `acceptValues` to override all local changes to rows, we need to flag any newly added rows with `addedByServer` so they can bypass the merge strategy. Existing rows would continue to be merged as expected, and new rows are simply appended to the end. Discovered here: https://discord.com/channels/967097582721572934/967097582721572937/1408367321797365840 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211115023863814
1 parent 598ff4c commit 1e13474

File tree

5 files changed

+101
-25
lines changed

5 files changed

+101
-25
lines changed

packages/payload/src/admin/forms/Form.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type Data = {
1111
}
1212

1313
export type Row = {
14+
addedByServer?: FieldState['addedByServer']
1415
blockType?: string
1516
collapsed?: boolean
1617
customComponents?: {

packages/ui/src/forms/Form/mergeServerFormState.ts

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -89,30 +89,38 @@ export const mergeServerFormState = ({
8989
}
9090

9191
/**
92-
* Intelligently merge the rows array to ensure changes to local state are not lost while the request was pending
92+
* Deeply merge the rows array to ensure changes to local state are not lost while the request was pending
9393
* For example, the server response could come back with a row which has been deleted on the client
9494
* Loop over the incoming rows, if it exists in client side form state, merge in any new properties from the server
9595
* Note: read `currentState` and not `newState` here, as the `rows` property have already been merged above
9696
*/
97-
if (Array.isArray(incomingField.rows)) {
98-
if (acceptValues === true) {
99-
newState[path].rows = incomingField.rows
100-
} else if (path in currentState) {
101-
newState[path].rows = [...(currentState[path]?.rows || [])] // shallow copy to avoid mutating the original array
102-
103-
incomingField.rows.forEach((row) => {
104-
const indexInCurrentState = currentState[path].rows?.findIndex(
105-
(existingRow) => existingRow.id === row.id,
106-
)
107-
108-
if (indexInCurrentState > -1) {
109-
newState[path].rows[indexInCurrentState] = {
110-
...currentState[path].rows[indexInCurrentState],
111-
...row,
112-
}
97+
if (Array.isArray(incomingField.rows) && path in currentState) {
98+
newState[path].rows = [...(currentState[path]?.rows || [])] // shallow copy to avoid mutating the original array
99+
100+
incomingField.rows.forEach((row) => {
101+
const indexInCurrentState = currentState[path].rows?.findIndex(
102+
(existingRow) => existingRow.id === row.id,
103+
)
104+
105+
if (indexInCurrentState > -1) {
106+
newState[path].rows[indexInCurrentState] = {
107+
...currentState[path].rows[indexInCurrentState],
108+
...row,
113109
}
114-
})
115-
}
110+
} else if (row.addedByServer) {
111+
/**
112+
* Note: This is a known limitation of computed array and block rows
113+
* If a new row was added by the server, we append it to the _end_ of this array
114+
* This is because the client is the source of truth, and it has arrays ordered in a certain position
115+
* For example, the user may have re-ordered rows client-side while a long running request is processing
116+
* This means that we _cannot_ slice a new row into the second position on the server, for example
117+
* By the time it gets back to the client, its index is stale
118+
*/
119+
const newRow = { ...row }
120+
delete newRow.addedByServer
121+
newState[path].rows.push(newRow)
122+
}
123+
})
116124
}
117125

118126
// If `valid` is `undefined`, mark it as `true`

packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
352352
newRow.lastRenderedPath = previousRow.lastRenderedPath
353353
}
354354

355-
acc.rows.push(newRow)
355+
// add addedByServer flag
356+
if (!previousRow) {
357+
newRow.addedByServer = true
358+
}
356359

357360
const isCollapsed = isRowCollapsed({
358361
collapsedPrefs: preferences?.fields?.[path]?.collapsed,
@@ -362,9 +365,11 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
362365
})
363366

364367
if (isCollapsed) {
365-
acc.rows[acc.rows.length - 1].collapsed = true
368+
newRow.collapsed = true
366369
}
367370

371+
acc.rows.push(newRow)
372+
368373
return acc
369374
},
370375
{
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react'
22

33
export const ArrayRowLabel = () => {
4-
return <p>This is a custom component</p>
4+
return <p id="custom-array-row-label">This is a custom component</p>
55
}

test/form-state/int.spec.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FieldState, FormState, Payload, User } from 'payload'
2+
import type React from 'react'
23

34
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
45
import path from 'path'
@@ -10,6 +11,7 @@ import type { NextRESTClient } from '../helpers/NextRESTClient.js'
1011
import { devUser } from '../credentials.js'
1112
import { initPayloadInt } from '../helpers/initPayloadInt.js'
1213
import { postsSlug } from './collections/Posts/index.js'
14+
1315
// eslint-disable-next-line payload/no-relative-monorepo-imports
1416
import { mergeServerFormState } from '../../packages/ui/src/forms/Form/mergeServerFormState.js'
1517

@@ -21,6 +23,14 @@ const { email, password } = devUser
2123
const filename = fileURLToPath(import.meta.url)
2224
const dirname = path.dirname(filename)
2325

26+
const DummyReactComponent: React.ReactNode = {
27+
// @ts-expect-error - can ignore, needs to satisfy `typeof value.$$typeof === 'symbol'`
28+
$$typeof: Symbol.for('react.element'),
29+
type: 'div',
30+
props: {},
31+
key: null,
32+
}
33+
2434
describe('Form State', () => {
2535
beforeAll(async () => {
2636
;({ payload, restClient } = await initPayloadInt(dirname, undefined, true))
@@ -566,7 +576,7 @@ describe('Form State', () => {
566576
expect(newState === currentState).toBe(true)
567577
})
568578

569-
it('should accept all values from the server regardless of local modifications, e.g. on submit', () => {
579+
it('should accept all values from the server regardless of local modifications, e.g. `acceptAllValues` on submit', () => {
570580
const title: FieldState = {
571581
value: 'Test Post (modified on the client)',
572582
initialValue: 'Test Post',
@@ -577,14 +587,39 @@ describe('Form State', () => {
577587
const currentState: Record<string, FieldState> = {
578588
title: {
579589
...title,
580-
isModified: true,
590+
isModified: true, // This is critical, this is what we're testing
581591
},
582592
computedTitle: {
583593
value: 'Test Post (computed on the client)',
584594
initialValue: 'Test Post',
585595
valid: true,
586596
passesCondition: true,
587597
},
598+
array: {
599+
rows: [
600+
{
601+
id: '1',
602+
customComponents: {
603+
RowLabel: DummyReactComponent,
604+
},
605+
lastRenderedPath: 'array.0.customTextField',
606+
},
607+
],
608+
valid: true,
609+
passesCondition: true,
610+
},
611+
'array.0.id': {
612+
value: '1',
613+
initialValue: '1',
614+
valid: true,
615+
passesCondition: true,
616+
},
617+
'array.0.customTextField': {
618+
value: 'Test Post (modified on the client)',
619+
initialValue: 'Test Post',
620+
valid: true,
621+
passesCondition: true,
622+
},
588623
}
589624

590625
const incomingStateFromServer: Record<string, FieldState> = {
@@ -600,6 +635,29 @@ describe('Form State', () => {
600635
valid: true,
601636
passesCondition: true,
602637
},
638+
array: {
639+
rows: [
640+
{
641+
id: '1',
642+
lastRenderedPath: 'array.0.customTextField',
643+
// Omit `customComponents` because the server did not re-render this row
644+
},
645+
],
646+
passesCondition: true,
647+
valid: true,
648+
},
649+
'array.0.id': {
650+
value: '1',
651+
initialValue: '1',
652+
valid: true,
653+
passesCondition: true,
654+
},
655+
'array.0.customTextField': {
656+
value: 'Test Post (modified on the client)',
657+
initialValue: 'Test Post',
658+
valid: true,
659+
passesCondition: true,
660+
},
603661
}
604662

605663
const newState = mergeServerFormState({
@@ -614,10 +672,14 @@ describe('Form State', () => {
614672
...incomingStateFromServer.title,
615673
isModified: true,
616674
},
675+
array: {
676+
...incomingStateFromServer.array,
677+
rows: currentState?.array?.rows,
678+
},
617679
})
618680
})
619681

620-
it('should not accept values from the server if they have been modified locally since the request was made, e.g. on autosave', () => {
682+
it('should not accept values from the server if they have been modified locally since the request was made, e.g. `overrideLocalChanges: false` on autosave', () => {
621683
const title: FieldState = {
622684
value: 'Test Post (modified on the client 1)',
623685
initialValue: 'Test Post',

0 commit comments

Comments
 (0)