fix(ui): significantly more predictable autosave form state (#13460)

This commit is contained in:
jacobsfletch
2025-08-14 19:36:02 -04:00
committed by GitHub
parent 46699ec314
commit 0b60bf2eff
20 changed files with 278 additions and 77 deletions

View File

@@ -56,6 +56,12 @@ export type FieldState = {
fieldSchema?: Field
filterOptions?: FilterOptionsResult
initialValue?: unknown
/**
* @experimental - Note: this property is experimental and may change in the future. Use at your own discretion.
* Every time a field is changed locally, this flag is set to true. Prevents form state from server from overwriting local changes.
* After merging server form state, this flag is reset.
*/
isModified?: boolean
/**
* The path of the field when its custom components were last rendered.
* This is used to denote if a field has been rendered, and if so,
@@ -114,9 +120,11 @@ export type BuildFormStateArgs = {
mockRSCs?: boolean
operation?: 'create' | 'update'
readOnly?: boolean
/*
If true, will render field components within their state object
*/
/**
* If true, will render field components within their state object.
* Performance optimization: Setting to `false` ensures that only fields that have changed paths will re-render, e.g. new array rows, etc.
* For example, you only need to render ALL fields on initial render, not on every onChange.
*/
renderAllFields?: boolean
req: PayloadRequest
returnLockStatus?: boolean

View File

@@ -149,7 +149,9 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate
if (!skipSubmission && modifiedRef.current && url) {
const result = await submit({
const result = await submit<{
incrementVersionCount: boolean
}>({
acceptValues: {
overrideLocalChanges: false,
},

View File

@@ -21,7 +21,7 @@ export type DocumentDrawerContextProps = {
readonly onSave?: (args: {
collectionConfig?: ClientCollectionConfig
/**
* @experimental - Note: this property is experimental and may change in the future. Use as your own discretion.
* @experimental - Note: this property is experimental and may change in the future. Use at your own discretion.
* If you want to pass additional data to the onSuccess callback, you can use this context object.
*/
context?: Record<string, unknown>

View File

@@ -189,12 +189,11 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
}
case 'MERGE_SERVER_STATE': {
const { acceptValues, formStateAtTimeOfRequest, prevStateRef, serverState } = action
const { acceptValues, prevStateRef, serverState } = action
const newState = mergeServerFormState({
acceptValues,
currentState: state || {},
formStateAtTimeOfRequest,
incomingState: serverState,
})
@@ -385,6 +384,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
return {
...field,
[key]: value,
...(key === 'value' ? { isModified: true } : {}),
}
}
@@ -398,6 +398,15 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[action.path]: newField,
}
// reset `isModified` in all other fields
if ('value' in action) {
for (const [path, field] of Object.entries(newState)) {
if (path !== action.path && 'isModified' in field) {
delete newState[path].isModified
}
}
}
return newState
}

View File

@@ -264,23 +264,21 @@ export const Form: React.FC<FormProps> = (props) => {
await wait(100)
}
/**
* Take copies of the current form state and data here. This will ensure it is consistent.
* For example, it is possible for the form state ref to change in the background while this submit function is running.
* TODO: can we send the `formStateCopy` through `reduceFieldsToValues` to even greater consistency? Doing this currently breaks uploads.
*/
const formStateCopy = deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields)
const data = reduceFieldsToValues(contextRef.current.fields, true)
// Execute server side validations
if (Array.isArray(beforeSubmit)) {
const serializableFormState = deepCopyObjectSimpleWithoutReactComponents(
contextRef.current.fields,
)
let revalidatedFormState: FormState
await beforeSubmit.reduce(async (priorOnChange, beforeSubmitFn) => {
await priorOnChange
const result = await beforeSubmitFn({
formState: formStateCopy,
formState: serializableFormState,
})
revalidatedFormState = result
@@ -328,7 +326,7 @@ export const Form: React.FC<FormProps> = (props) => {
data[key] = value
}
onSubmit(formStateCopy, data)
onSubmit(contextRef.current.fields, data)
}
if (!hasFormSubmitAction) {
@@ -379,13 +377,14 @@ export const Form: React.FC<FormProps> = (props) => {
if (res.status < 400) {
if (typeof onSuccess === 'function') {
const newFormState = await onSuccess(json, context)
const newFormState = await onSuccess(json, {
context,
})
if (newFormState) {
dispatchFields({
type: 'MERGE_SERVER_STATE',
acceptValues,
formStateAtTimeOfRequest: formStateCopy,
prevStateRef: prevFormState,
serverState: newFormState,
})

View File

@@ -21,7 +21,6 @@ export type AcceptValues =
type Args = {
acceptValues?: AcceptValues
currentState?: FormState
formStateAtTimeOfRequest?: FormState
incomingState: FormState
}
@@ -37,7 +36,6 @@ type Args = {
export const mergeServerFormState = ({
acceptValues,
currentState = {},
formStateAtTimeOfRequest,
incomingState,
}: Args): FormState => {
const newState = { ...currentState }
@@ -49,8 +47,9 @@ export const mergeServerFormState = ({
/**
* If it's a new field added by the server, always accept the value.
* Otherwise, only accept the values if explicitly requested, e.g. on submit.
* Can also control this granularly by only accepting unmodified values, e.g. for autosave.
* Otherwise:
* a. accept all values when explicitly requested, e.g. on submit
* b. only accept values for unmodified fields, e.g. on autosave
*/
if (
!incomingField.addedByServer &&
@@ -59,7 +58,7 @@ export const mergeServerFormState = ({
(typeof acceptValues === 'object' &&
acceptValues !== null &&
acceptValues?.overrideLocalChanges === false &&
currentState[path]?.value !== formStateAtTimeOfRequest?.[path]?.value))
currentState[path].isModified))
) {
delete incomingField.value
delete incomingField.initialValue

View File

@@ -54,7 +54,15 @@ export type FormProps = {
log?: boolean
onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise<FormState>)[]
onSubmit?: (fields: FormState, data: Data) => void
onSuccess?: (json: unknown, context?: Record<string, unknown>) => Promise<FormState | void> | void
onSuccess?: (
json: unknown,
options?: {
/**
* Arbitrary context passed to the onSuccess callback.
*/
context?: Record<string, unknown>
},
) => Promise<FormState | void> | void
redirect?: string
submitted?: boolean
uuid?: string
@@ -70,14 +78,14 @@ export type FormProps = {
}
)
export type SubmitOptions = {
export type SubmitOptions<T = Record<string, unknown>> = {
acceptValues?: AcceptValues
action?: string
/**
* @experimental - Note: this property is experimental and may change in the future. Use as your own discretion.
* @experimental - Note: this property is experimental and may change in the future. Use at your own discretion.
* If you want to pass additional data to the onSuccess callback, you can use this context object.
*/
context?: Record<string, unknown>
context?: T
/**
* When true, will disable the form while it is processing.
* @default true
@@ -99,11 +107,11 @@ export type SubmitOptions = {
export type DispatchFields = React.Dispatch<any>
export type Submit = (
options?: SubmitOptions,
export type Submit = <T extends Record<string, unknown>>(
options?: SubmitOptions<T>,
e?: React.FormEvent<HTMLFormElement>,
) => Promise</**
* @experimental - Note: the `{ res: ... }` return type is experimental and may change in the future. Use as your own discretion.
* @experimental - Note: the `{ res: ... }` return type is experimental and may change in the future. Use at your own discretion.
* Returns the form state and the response from the server.
*/
{ formState?: FormState; res: Response } | void>
@@ -185,7 +193,6 @@ export type ADD_ROW = {
export type MERGE_SERVER_STATE = {
acceptValues?: AcceptValues
formStateAtTimeOfRequest?: FormState
prevStateRef: React.RefObject<FormState>
serverState: FormState
type: 'MERGE_SERVER_STATE'

View File

@@ -5,7 +5,6 @@ import type {
DocumentPreferences,
Field,
FieldSchemaMap,
FieldState,
FormState,
FormStateWithoutComponents,
PayloadRequest,
@@ -105,6 +104,7 @@ export const fieldSchemasToFormState = async ({
skipValidation,
}: Args): Promise<FormState> => {
if (!clientFieldSchemaMap && renderFieldFn) {
// eslint-disable-next-line no-console
console.warn(
'clientFieldSchemaMap is not passed to fieldSchemasToFormState - this will reduce performance',
)

View File

@@ -5,7 +5,6 @@ import type {
DocumentPreferences,
Field as FieldSchema,
FieldSchemaMap,
FieldState,
FormState,
FormStateWithoutComponents,
PayloadRequest,

View File

@@ -182,11 +182,13 @@ export const buildFormState = async (
}
let documentData = undefined
if (documentFormState) {
documentData = reduceFieldsToValues(documentFormState, true)
}
let blockData = initialBlockData
if (initialBlockFormState) {
blockData = reduceFieldsToValues(initialBlockFormState, true)
}

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-compiler/react-compiler -- TODO: fix */
'use client'
import type { ClientUser, DocumentViewClientProps, FormState } from 'payload'
import type { ClientUser, DocumentViewClientProps } from 'payload'
import { useRouter, useSearchParams } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
@@ -256,10 +256,13 @@ export function DefaultEditView({
user?.id,
])
const onSave = useCallback(
async (json, context?: Record<string, unknown>): Promise<FormState> => {
const onSave = useCallback<FormProps['onSuccess']>(
async (json, options) => {
const { context } = options || {}
const controller = handleAbortRef(abortOnSaveRef)
// @ts-expect-error can ignore
const document = json?.doc || json?.result
const updatedAt = document?.updatedAt || new Date().toISOString()
@@ -290,9 +293,10 @@ export function DefaultEditView({
const operation = id ? 'update' : 'create'
void onSaveFromContext({
...json,
...(json as Record<string, unknown>),
context,
operation,
// @ts-expect-error todo: this is not right, should be under `doc`?
updatedAt:
operation === 'update'
? new Date().toISOString()
@@ -397,13 +401,11 @@ export function DefaultEditView({
formState: prevFormState,
globalSlug,
operation,
skipValidation: !submitted,
// Performance optimization: Setting it to false ensure that only fields that have explicit requireRender set in the form state will be rendered (e.g. new array rows).
// We only want to render ALL fields on initial render, not in onChange.
renderAllFields: false,
returnLockStatus: isLockingEnabled,
schemaPath: schemaPathSegments.join('.'),
signal: controller.signal,
skipValidation: !submitted,
updateLastEdited,
})