fix(ui): client should add back default values for valid and passesCondition form field properties (#10709)

As a result of #9388, the `valid` and `passesCondition` properties no
longer appear in form state. This leads to breaking logic if you were
previously relying on these properties to have explicit values. To fix
this, we simply perform the inverse on these properties before accepting
them into client side form state. In the next major release, we can
accept form state as it is received and instruct users to modify their
logic as needed.

Also comes with a small perf optimization, by keeping the old object
reference of fields if they did not change when server form state comes
back
This commit is contained in:
Alessio Gravili
2025-01-30 14:21:31 -07:00
committed by GitHub
parent 398589397e
commit 35e5be8558
4 changed files with 63 additions and 24 deletions

View File

@@ -284,20 +284,39 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
// ..
// This is a performance enhancement for saving
// large documents with hundreds of fields
const newState = {}
const newState: FormState = {}
Object.entries(action.state).forEach(([path, field]) => {
for (const [path, newField] of Object.entries(action.state)) {
const oldField = state[path]
const newField = field
if (newField.valid !== false) {
newField.valid = true
}
if (newField.passesCondition !== false) {
newField.passesCondition = true
}
if (!dequal(oldField, newField)) {
newState[path] = newField
} else if (oldField) {
newState[path] = oldField
}
})
}
return newState
}
//TODO: Remove this in 4.0 - this is a temporary fix to prevent a breaking change
if (action.sanitize) {
for (const field of Object.values(action.state)) {
if (field.valid !== false) {
field.valid = true
}
if (field.passesCondition !== false) {
field.passesCondition = true
}
}
}
// If we're not optimizing, just set the state to the new state
return action.state
}

View File

@@ -637,7 +637,12 @@ export const Form: React.FC<FormProps> = (props) => {
useEffect(() => {
if (initialState) {
contextRef.current = { ...initContextState } as FormContextType
dispatchFields({ type: 'REPLACE_STATE', optimize: false, state: initialState })
dispatchFields({
type: 'REPLACE_STATE',
optimize: false,
sanitize: true,
state: initialState,
})
}
}, [initialState, dispatchFields])

View File

@@ -22,28 +22,29 @@ export const mergeServerFormState = ({
existingState,
incomingState,
}: Args): { changed: boolean; newState: FormState } => {
const serverPropsToAccept = [
'passesCondition',
'valid',
'errorMessage',
'rows',
'customComponents',
'requiresRender',
]
if (acceptValues) {
serverPropsToAccept.push('value')
}
let changed = false
const newState = {}
if (existingState) {
Object.entries(existingState).forEach(([path, newFieldState]) => {
const serverPropsToAccept = [
'passesCondition',
'valid',
'errorMessage',
'rows',
'customComponents',
'requiresRender',
]
if (acceptValues) {
serverPropsToAccept.push('value')
}
for (const [path, newFieldState] of Object.entries(existingState)) {
if (!incomingState[path]) {
return
continue
}
let fieldChanged = false
/**
* Handle error paths
@@ -65,6 +66,7 @@ export const mergeServerFormState = ({
if (incomingState[path]?.filterOptions || newFieldState.filterOptions) {
if (!dequal(incomingState[path]?.filterOptions, newFieldState.filterOptions)) {
changed = true
fieldChanged = true
newFieldState.filterOptions = incomingState[path].filterOptions
}
}
@@ -75,6 +77,7 @@ export const mergeServerFormState = ({
serverPropsToAccept.forEach((prop) => {
if (!dequal(incomingState[path]?.[prop], newFieldState[prop])) {
changed = true
fieldChanged = true
if (!(prop in incomingState[path])) {
// Regarding excluding the customComponents prop from being deleted: the incoming state might not have been rendered, as rendering components for every form onchange is expensive.
// Thus, we simply re-use the initial render state
@@ -87,18 +90,25 @@ export const mergeServerFormState = ({
}
})
if (newFieldState.valid !== false) {
newFieldState.valid = true
}
if (newFieldState.passesCondition !== false) {
newFieldState.passesCondition = true
}
// Conditions don't work if we don't memcopy the new state, as the object references would otherwise be the same
newState[path] = { ...newFieldState }
})
newState[path] = fieldChanged ? { ...newFieldState } : newFieldState
}
// Now loop over values that are part of incoming state but not part of existing state, and add them to the new state.
// This can happen if a new array row was added. In our local state, we simply add out stubbed `array` and `array.[index].id` entries to the local form state.
// However, all other array sub-fields are not added to the local state - those will be added by the server and may be incoming here.
for (const [path, newFieldState] of Object.entries(incomingState)) {
for (const [path, field] of Object.entries(incomingState)) {
if (!existingState[path]) {
changed = true
newState[path] = newFieldState
newState[path] = field
}
}
}

View File

@@ -91,6 +91,11 @@ export type Reset = (data: unknown) => Promise<void>
export type REPLACE_STATE = {
optimize?: boolean
/**
* If `sanitize` is true, default values will be set for form field properties that are not present in the incoming state.
* For example, `valid` will be set to true if it is not present in the incoming state.
*/
sanitize?: boolean
state: FormState
type: 'REPLACE_STATE'
}