Skip to content

fix(ui): prevent stale responses from overwriting form state - #17455

Draft
jacobsfletch wants to merge 70 commits into
fix/form-revision-sequencing-17455from
fix/autosave-race-conditions
Draft

jacobsfletch wants to merge 70 commits into
fix/form-revision-sequencing-17455from
fix/autosave-race-conditions

Conversation

@jacobsfletch

@jacobsfletch jacobsfletch commented Jul 22, 2026 •

Copy link
Copy Markdown
Member

Requires #19203.

Concurrent autosave and form-state requests could resolve out of order and overwrite form values with stale data. This was especially visible with server-computed fields, such as fields modified by a beforeChange hook. With a fast autosave interval on a throttled network, inputs could freeze to a partial value while typing quickly.

Root cause:

  1. An autosave request starts with revision A.
  2. The user continues editing while revision A is in flight.
  3. A form-state request is sent with revision B, on its own independent queue.
  4. Another autosave is scheduled, queued behind revision A.
  5. The first autosave resolves, so the queued autosave starts with revision B, but before the form-state response for revision B has merged.
  6. The form-state response for revision B returns after the queued autosave response, so it is merged last and can overwrite values produced by the newer autosave, including server-computed fields.

Because autosave and form-state requests ran through independent client queues, their responses could merge out of order. A subsequent autosave could also be built from form state that had not yet incorporated the earlier form-state response, causing newer local or server-computed values to be lost.

Fix:

Builds on the shared form request queue in #19203. Each request captures the current form revision when it starts, and responses invalidated by newer edits cannot overwrite newer form values or validation state.

Server-computed values are accepted when the user has not edited since the request was sent. Responses invalidated by newer edits leave the current values and validation feedback intact. Completed explicit saves still retain their metadata and newly created document identity.

Huge win: this also removes the per-field isModified flag introduced in #13460. Staleness is now handled consistently for every field and every form request instead of protecting only the most recently edited field.

Fixes:

Previous work related this issue:

Also related:

For the reviewer

Although the diff is large, there are really just 4 files that orchestrate pretty much this whole thing. The rest of the changes are mostly small, downstream compatibility updates based on these changes, plus a whole lotta tests.

Roughly two-thirds of the LOC changed in this PR are isolated to the test/ dir.

Review should be focused on these files specifically:

  • packages/ui/src/forms/Form/index.tsx
  • packages/ui/src/forms/Form/fieldReducer.ts
  • packages/ui/src/forms/Form/mergeServerFormState.ts
  • packages/ui/src/views/Edit/index.tsx

Before

pr-17455-before-latest-chrome-take-1.mp4

After

pr-17455-after-latest-chrome-take-1.mp4

Autosave and form-state requests run on independent client queues and can
resolve out of order. `mergeServerFormState` accepted a server value for any
non-`isModified` field, so a straggler response whose payload was built from an
older snapshot could clobber a value a later response already wrote. For
auto-tracked fields like the native slug (never marked modified), the stale
value was then re-sent and frozen by the field `beforeChange` hook, persisting
the wrong value.

Tags each value-accepting submit with a monotonic `requestSequence` in send
order and records the writing sequence on each field as `valueSequence`.
`mergeServerFormState` now rejects a response older than the sequence that last
wrote the field. `valueSequence` is stripped before form state is serialized to
the server, so it adds no wire cost.
…uence

Replaces the per-field `isModified` flag with a monotonic sequence clock shared
by local edits and value-accepting submits. `isModified` only guarded the single
most-recently-edited field (it was cleared on every other field), so editing one
field then another could let a background autosave response clobber the first.

Each local edit and each accepted server response now stamps the field's
`valueSequence`; `mergeServerFormState` accepts an autosave value only when the
response's `requestSequence` is not older than the field's current sequence. This
one check subsumes the old `isModified` guard and protects every recently-edited
field independently, while still rejecting stale, out-of-order responses.
@github-actions

github-actions Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

📦 esbuild Bundle Analysis for payload

This analysis was generated by esbuild-bundle-analyzer. 🤖

Meta File Out File Size (raw) Note
packages/next/meta_index.json esbuild/index.js 279.40 KB 🆕 Added
packages/payload/meta_index.json esbuild/index.js 1.95 MB 🆕 Added
packages/payload/meta_shared.json esbuild/exports/shared.js 544.21 KB 🆕 Added
packages/richtext-lexical/meta_client.json esbuild/exports/client_optimized/index.js 302.88 KB 🆕 Added
packages/ui/meta_client.json esbuild/exports/client_optimized/index.js 1.39 MB 🆕 Added
packages/ui/meta_shared.json esbuild/exports/shared_optimized/index.js 20.56 KB 🆕 Added
Largest paths These visualization shows top 20 largest paths in the bundle.

Meta file: packages/next/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ████████████████████████▋ }}}$ 98.7%, 272.71 KB
dist/adapters/router.js ${{\color{Goldenrod}{ }}}$ 0.4%, 1.00 KB
dist/adapters/server.js ${{\color{Goldenrod}{ }}}$ 0.2%, 533 B
dist/adapters/layout.js ${{\color{Goldenrod}{ }}}$ 0.2%, 529 B
dist/adapters/views.js ${{\color{Goldenrod}{ }}}$ 0.2%, 469 B
dist/adapters/UnmountWhenHidden ${{\color{Goldenrod}{ }}}$ 0.2%, 449 B
dist/utilities/initAdminContext.js ${{\color{Goldenrod}{ }}}$ 0.1%, 366 B
dist/utilities/selectiveCache.js ${{\color{Goldenrod}{ }}}$ 0.1%, 263 B
dist/esbuildEntry.js ${{\color{Goldenrod}{ }}}$ 0.0%, 0 B

Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ █████████████████ }}}$ 68.2%, 1.33 MB
dist/collections/operations ${{\color{Goldenrod}{ ▉ }}}$ 3.5%, 68.11 KB
dist/fields/hooks ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 48.51 KB
dist/uploads/fileVersioning ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 22.25 KB
dist/globals/operations ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 19.11 KB
dist/auth/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 18.06 KB
dist/utilities/configToJSONSchema.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 16.04 KB
dist/queues/operations ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 14.33 KB
dist/fields/config ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 13.77 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 12.21 KB
dist/utilities/telemetry ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 11.93 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 10.33 KB
dist/cli/commands ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 9.93 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 8.78 KB
dist/uploads/fetchAPI-multipart ${{\color{Goldenrod}{ }}}$ 0.4%, 8.39 KB
dist/uploads/generateFileData.js ${{\color{Goldenrod}{ }}}$ 0.4%, 8.36 KB
dist/config/sanitize.js ${{\color{Goldenrod}{ }}}$ 0.4%, 8.28 KB
dist/database/migrations ${{\color{Goldenrod}{ }}}$ 0.4%, 8.16 KB
dist/auth/strategies ${{\color{Goldenrod}{ }}}$ 0.4%, 8.12 KB
dist/utilities/entityInputSchema ${{\color{Goldenrod}{ }}}$ 0.4%, 8.02 KB
(other) ${{\color{Goldenrod}{ ███████▉ }}}$ 31.8%, 616.89 KB

Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ██████████████████████ }}}$ 88.1%, 475.32 KB
dist/fields/validations.js ${{\color{Goldenrod}{ ▍ }}}$ 1.9%, 10.30 KB
dist/fields/config ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 5.83 KB
dist/utilities/traverseFields.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 4.34 KB
dist/utilities/deepCopyObject.js ${{\color{Goldenrod}{ ▏ }}}$ 0.7%, 3.52 KB
dist/collections/config ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 3.46 KB
dist/config/orderable ${{\color{Goldenrod}{ ▏ }}}$ 0.6%, 3.13 KB
dist/fields/baseFields ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 2.79 KB
dist/config/client.js ${{\color{Goldenrod}{ ▏ }}}$ 0.5%, 2.62 KB
dist/auth/cookies.js ${{\color{Goldenrod}{ }}}$ 0.3%, 1.55 KB
dist/utilities/flattenTopLevelFields.js ${{\color{Goldenrod}{ }}}$ 0.3%, 1.42 KB
dist/utilities/getVersionsConfig.js ${{\color{Goldenrod}{ }}}$ 0.2%, 1.04 KB
dist/globals/config ${{\color{Goldenrod}{ }}}$ 0.2%, 957 B
dist/uploads/generatePayloadFileURL.js ${{\color{Goldenrod}{ }}}$ 0.2%, 864 B
dist/utilities/unflatten.js ${{\color{Goldenrod}{ }}}$ 0.2%, 850 B
dist/auth/extractJWT.js ${{\color{Goldenrod}{ }}}$ 0.1%, 807 B
dist/utilities/flattenAllFields.js ${{\color{Goldenrod}{ }}}$ 0.1%, 798 B
dist/errors/ValidationError.js ${{\color{Goldenrod}{ }}}$ 0.1%, 772 B
dist/utilities/sanitizeUserDataForEmail.js ${{\color{Goldenrod}{ }}}$ 0.1%, 713 B
dist/utilities/getFieldPermissions.js ${{\color{Goldenrod}{ }}}$ 0.1%, 681 B
(other) ${{\color{Goldenrod}{ ██▉ }}}$ 11.9%, 63.96 KB

Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
dist/features/blocks ${{\color{Goldenrod}{ ███▌ }}}$ 14.3%, 42.74 KB
dist/lexical/plugins ${{\color{Goldenrod}{ ███ }}}$ 12.4%, 37.19 KB
dist/lexical/ui ${{\color{Goldenrod}{ ██▊ }}}$ 11.2%, 33.66 KB
dist/features/table ${{\color{Goldenrod}{ ██▎ }}}$ 9.3%, 27.80 KB
dist/features/toolbars ${{\color{Goldenrod}{ █▊ }}}$ 7.4%, 22.25 KB
dist/features/link ${{\color{Goldenrod}{ █▌ }}}$ 6.2%, 18.61 KB
dist/features/upload ${{\color{Goldenrod}{ █▏ }}}$ 4.7%, 14.03 KB
dist/features/textState ${{\color{Goldenrod}{ ▉ }}}$ 3.7%, 11.08 KB
dist/lexical/utils ${{\color{Goldenrod}{ ▊ }}}$ 3.3%, 10.01 KB
dist/features/relationship ${{\color{Goldenrod}{ ▊ }}}$ 3.1%, 9.32 KB
dist/features/converters ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 8.44 KB
dist/utilities/fieldsDrawer ${{\color{Goldenrod}{ ▋ }}}$ 2.8%, 8.37 KB
dist/features/debug ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 7.40 KB
dist/lexical/config ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 5.14 KB
dist/features/indent ${{\color{Goldenrod}{ ▎ }}}$ 1.4%, 4.16 KB
dist/features/lists ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 3.67 KB
dist/lexical/LexicalEditor.js ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.33 KB
dist/features/horizontalRule ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.29 KB
dist/features/format ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 3.28 KB
dist/field/Field.js ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 3.08 KB
(other) ${{\color{Goldenrod}{ █████████████████████▍ }}}$ 85.7%, 256.92 KB

Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js

Path Size
../../node_modules ${{\color{Goldenrod}{ ███████▏ }}}$ 28.6%, 394.56 KB
dist/elements/Hierarchy ${{\color{Goldenrod}{ ▉ }}}$ 3.6%, 49.27 KB
dist/views/Version ${{\color{Goldenrod}{ ▌ }}}$ 2.2%, 30.53 KB
dist/elements/BulkUpload ${{\color{Goldenrod}{ ▌ }}}$ 2.2%, 29.90 KB
dist/elements/Table ${{\color{Goldenrod}{ ▌ }}}$ 2.1%, 29.55 KB
dist/forms/Form ${{\color{Goldenrod}{ ▌ }}}$ 2.1%, 28.86 KB
dist/views/Dashboard ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 23.55 KB
dist/views/HierarchyList ${{\color{Goldenrod}{ ▍ }}}$ 1.6%, 21.48 KB
dist/views/Edit ${{\color{Goldenrod}{ ▍ }}}$ 1.5%, 20.35 KB
dist/elements/WhereBuilder ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.69 KB
dist/providers/Auth ${{\color{Goldenrod}{ ▎ }}}$ 1.3%, 17.39 KB
dist/fields/Relationship ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 17.18 KB
dist/fields/Blocks ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 16.79 KB
dist/fields/Upload ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 15.40 KB
dist/elements/Popup ${{\color{Goldenrod}{ ▎ }}}$ 1.1%, 14.51 KB
dist/widgets/Activity ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 14.12 KB
dist/views/List ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.68 KB
dist/elements/ReactSelect ${{\color{Goldenrod}{ ▎ }}}$ 1.0%, 13.28 KB
dist/elements/PublishButton ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 12.16 KB
dist/elements/QueryPresets ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 12.09 KB
(other) ${{\color{Goldenrod}{ █████████████████▊ }}}$ 71.4%, 984.73 KB

Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js

Path Size
dist/graphics/Logo ${{\color{Goldenrod}{ ███████ }}}$ 28.3%, 5.61 KB
../../node_modules ${{\color{Goldenrod}{ ███▎ }}}$ 13.4%, 2.65 KB
dist/graphics/Icon ${{\color{Goldenrod}{ █▉ }}}$ 7.6%, 1.51 KB
dist/utilities/formatDocTitle ${{\color{Goldenrod}{ █▋ }}}$ 6.6%, 1.32 KB
dist/utilities/reduceFormStateToData.js ${{\color{Goldenrod}{ █▎ }}}$ 5.4%, 1.07 KB
dist/providers/TableColumns ${{\color{Goldenrod}{ █ }}}$ 4.4%, 866 B
dist/utilities/getGlobalData.js ${{\color{Goldenrod}{ ▉ }}}$ 3.8%, 762 B
dist/utilities/api.js ${{\color{Goldenrod}{ ▉ }}}$ 3.8%, 756 B
dist/utilities/groupNavItems.js ${{\color{Goldenrod}{ ▉ }}}$ 3.8%, 745 B
dist/utilities/traverseForLocalizedFields.js ${{\color{Goldenrod}{ ▋ }}}$ 2.9%, 570 B
dist/elements/Translation ${{\color{Goldenrod}{ ▋ }}}$ 2.5%, 493 B
dist/forms/Form ${{\color{Goldenrod}{ ▌ }}}$ 2.4%, 474 B
dist/utilities/handleTakeOver.js ${{\color{Goldenrod}{ ▌ }}}$ 2.2%, 440 B
dist/elements/withMergedProps ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 339 B
dist/utilities/getNavGroups.js ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 338 B
dist/utilities/getVisibleEntities.js ${{\color{Goldenrod}{ ▍ }}}$ 1.7%, 329 B
dist/elements/WithServerSideProps ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 235 B
dist/layouts/Root ${{\color{Goldenrod}{ ▎ }}}$ 1.2%, 230 B
dist/utilities/handleGoBack.js ${{\color{Goldenrod}{ ▏ }}}$ 0.9%, 180 B
dist/fields/mergeFieldStyles.js ${{\color{Goldenrod}{ ▏ }}}$ 0.8%, 158 B
(other) ${{\color{Goldenrod}{ █████████████████▉ }}}$ 71.7%, 14.23 KB
Details

Next to the size is how much the size has increased or decreased compared with the base branch of this PR.

  • ‼️: Size increased by 20% or more. Special attention should be given to this.
  • ⚠️: Size increased in acceptable range (lower than 20%).
  • ✅: No change or even downsized.
  • 🗑️: The out file is deleted: not found in base branch.
  • 🆕: The out file is newly found: will be added to base branch.

*
* @experimental This property is experimental and may change in the future. Use at your own risk.
*/
isModified?: boolean

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank god

Comment on lines -431 to -442
// reset `isModified` in all other fields
// Use destructuring instead of delete to avoid mutating field objects
// shared with the previous state (shallow copy shares references)
if ('value' in action) {
for (const [path, field] of Object.entries(newState)) {
if (path !== action.path && 'isModified' in field) {
const { isModified: _, ...fieldWithoutIsModified } = field
newState[path] = fieldWithoutIsModified as typeof field
}
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels so good

@jacobsfletch
jacobsfletch removed this pull request from stack #19212 November 3, 2026 20:03
@jacobsfletch
jacobsfletch added this pull request to stack #19309 November 3, 2026 20:34
Merges the current #19203 head into #17455.
The queue keeps the `queueTask({ task })` / `resetQueue` API and gains the external sequence check.
Explicit saves disable the form before queueing and release only what they took.
- Restore replaceState as a full reset of queued and in-flight form requests
- Paste array and block rows through dispatchFields so they don't reset the queue
- Drop tests that edit or re-save while an explicit save is queued, since the form is disabled once a save is requested
@jacobsfletch
jacobsfletch removed this pull request from stack #19344 November 5, 2026 23:33
@jacobsfletch
jacobsfletch added this pull request to stack #19344 November 5, 2026 23:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Autosave can overwrite recently typed field values and reload the media library

2 participants