Repository navigation
Description
Describe the Bug
When the form-state request the Edit view sends after a change (250 ms after it) is still out while the editor makes the next change or saves, its answer is merged over the newer state when it lands — and the form then takes the merged state as already checked, so it never asks again. Two visible results, both reproduced on 3.90.2 with the steps below (every run):
- A radio shows the new choice, but the fields whose
admin.conditiondepends on it don't follow. Type in a field, then pick "custom" in a radio before the answer for the typing comes back: that answer, computed with the old choice, hides the field that "custom" shows — the radio stays on "custom" — and no further form-state request is sent, so the field stays hidden until the next edit. - A failed save's field errors disappear. Type, then save before the answer for the typing comes back: the save's field error shows, then the late answer (computed before the save, with
skipValidation) clears it about 100 ms later, leaving only the toast, and nothing brings it back.
It happens whenever a form-state request takes longer than the editor's next step — a slow network or a busy server. We first hit it in our e2e suite on 3.80.0 under load: a type click landing as the answer for the previous edit came back, and saves whose field errors vanished.
Cause (packages/ui/src, at 31ba7ee):
mergeServerFormStatekeeps local values but takes the answer'spassesCondition,valid,errorMessageetc., which were computed from the state the request was sent with.MERGE_SERVER_STATEsetsprevStateRef.current = newState, and the Form's debounced effect only runsonChangewhenformStatediffers fromprevFormState.current. The change made while the request was out is already in the merged state (local values are kept), so it never gets a request of its own.- The Edit view's
onChangeaborts the previous request only when the next one starts, 250 ms after the next change; a save doesn't abort it at all, andqueueTaskmakes any later request wait for it anyway. Before a save, the request runs without validation, so its answer marks every field valid.
Possible fix: remember the form state (or a change counter) each onChange request was sent with; when its answer lands and the form has changed since, or has been submitted since, merge it without taking the result as checked — leave prevStateRef so the newer change still gets its own request — and don't let it overwrite a later submit's field errors (or abort it on submit).
Link to the code that reproduces this issue
| prevStateRef.current = newState |
(A collection for the blank template and a Playwright script are below.)
Reproduction Steps
-
pnpx create-payload-app@latest -t blank(SQLite), and add this collection topayload.config.ts:import type { CollectionConfig } from 'payload' // `title` is only checked on the server (on save, and on change after a // failed save); `url` only shows for type "custom". export const RaceProbes: CollectionConfig = { slug: 'race-probes', fields: [ { name: 'title', type: 'text', validate: (value: null | string | undefined) => value?.startsWith('ok') ? true : 'Must start with "ok".', }, { name: 'type', type: 'radio', defaultValue: 'internal', options: ['internal', 'custom'], }, { name: 'url', type: 'text', admin: { condition: (_, siblingData) => siblingData?.type === 'custom', }, }, ], }
-
pnpm dev, then run the script below (BASE=http://localhost:3000 node repro.mjs; it creates the first user). It captures the form-state request sent after typing in Title withroute.fetch()— the server answers it at once — and hands the answer to the page only after the next step, which is what a slow server or network does by chance.repro.mjs
// repro.mjs — run against `pnpm dev`: BASE=http://localhost:3000 node repro.mjs import { chromium } from '@playwright/test' const BASE = process.env.BASE ?? 'http://localhost:3000' const CREATE = '/admin/collections/race-probes/create' const user = { email: 'race@example.test', password: 'race-repro-password' } const browser = await chromium.launch() const context = await browser.newContext({ baseURL: BASE }) const first = await context.request.post('/api/users/first-register', { data: user }) if (!first.ok()) await context.request.post('/api/users/login', { data: user }) const isServerAction = (req) => req.method() === 'POST' && 'next-action' in req.headers() // Opens the create view. With `late`, the first form-state request sent after // arm() is answered by the server at once (route.fetch), but the answer only // reaches the page on deliver() — what a slow server or network does by chance. const open = async (late) => { const page = await context.newPage() const held = { armed: false, delivered: false, route: null, response: null, after: 0 } page.on('request', (req) => { if (held.delivered && isServerAction(req)) held.after++ }) if (late) { await page.route(`**${CREATE}`, async (route) => { if (held.armed && isServerAction(route.request())) { held.armed = false held.response = await route.fetch() held.route = route return } await route.continue() }) } await page.goto(CREATE, { timeout: 120_000 }) await page.locator('form[data-form-ready="true"]').first().waitFor({ timeout: 120_000 }) return { page, arm: () => (held.armed = true), captured: async () => { while (!held.route) await page.waitForTimeout(50) }, deliver: async () => { held.delivered = true await held.route.fulfill({ response: held.response }) }, requestsAfter: () => held.after, } } // When `locator` shows or hides, sampled every 100 ms for 6 s. const watch = async (page, locator) => { const changes = [] let last = null const start = Date.now() while (Date.now() - start < 6_000) { const visible = await locator.isVisible() if (visible !== last) changes.push(`${Date.now() - start}ms ${visible ? 'shown' : 'hidden'}`) last = visible await page.waitForTimeout(100) } return changes.join(' -> ') } const label = (late) => (late ? 'late answer' : 'control ') const after = (late, form) => late ? `; form-state requests after the late answer: ${form.requestsAfter()}` : '' // A: pick "custom" while the answer for the Title edit is still out. for (const late of [false, true]) { const form = await open(late) if (late) form.arm() await form.page.locator('input[name="title"]').fill('ok title') if (late) await form.captured() await form.page.locator('label[for="field-type-custom"]').click() if (late) await form.deliver() const url = await watch(form.page, form.page.locator('input[name="url"]')) const checked = await form.page.locator('#field-type-custom').isChecked() console.log(`A ${label(late)}: "custom" checked=${checked}; URL field: ${url}${after(late, form)}`) await form.page.close() } // B: save while the answer for the Title edit (sent before the save, so // without validation) is still out; it lands once the save's error shows. for (const late of [false, true]) { const form = await open(late) if (late) form.arm() await form.page.locator('input[name="title"]').fill('not ok') if (late) await form.captured() await form.page.locator('#action-save').click() const error = form.page .locator('.field-type.text', { has: form.page.locator('input[name="title"]') }) .locator('.field-error') await error.waitFor() if (late) await form.deliver() const timeline = await watch(form.page, error) console.log(`B ${label(late)}: Title error after the failed save: ${timeline}${after(late, form)}`) await form.page.close() } await browser.close()
-
Output (the same in every one of 5 runs):
A control : "custom" checked=true; URL field: 52ms hidden -> 360ms shown A late answer: "custom" checked=true; URL field: 1ms hidden; form-state requests after the late answer: 0 B control : Title error after the failed save: 2ms shown B late answer: Title error after the failed save: 2ms shown -> 105ms hidden; form-state requests after the late answer: 0
Expected: a late answer doesn't undo a newer change — the URL field shows (A) and the Title error stays (B) — or the form asks again.
Which area(s) are affected?
area: ui
Environment Info
Binaries:
Node: 22.22.1
npm: 10.9.4
Yarn: N/A
pnpm: 10.32.1
Relevant Packages:
payload: 3.90.2
next: 16.3.3
@payloadcms/db-sqlite: 3.90.2
@payloadcms/drizzle: 3.90.2
@payloadcms/graphql: 3.90.2
@payloadcms/next/utilities: 3.90.2
@payloadcms/richtext-lexical: 3.90.2
@payloadcms/translations: 3.90.2
@payloadcms/ui/shared: 3.90.2
react: 19.2.6
react-dom: 19.2.6
Operating System:
Platform: darwin
Arch: x64
Version: Darwin Kernel Version 24.6.0
Available memory (MB): 32768
Available CPU cores: 16
First seen on payload 3.80.0 / next 16.2.1 with @payloadcms/db-postgres.
Activity
jacobsfletch commentedon Oct 1, 2026
This is possibly fixed by #17455
sam9191 commentedon Oct 1, 2026
Thanks. I ran the repro against #17455: it fixes both sequences in the report, but not the race in general. With it, two closely related sequences that work today lose state, and a third still fails either way.
Setup: the collection from the report, unchanged, as a
test/race-probessuite in the monorepo (PAYLOAD_DATABASE=sqlite pnpm dev race-probes). PR head5e1c2c5against its merge-base5a6d003, 3 runs of each sequence, all consistent. Without the PR, A and B fail exactly as the report shows for 3.90.2.5a6d003)5e1c2c5)Why
submitted: false. The queue holds it until the save is done, then sends it withskipValidation(Edit/index.tsx#L530). Its revision is read when it starts (useQueue.ts#L47), which is after the save, so its answer counts as current and the merge setsvalid: true(mergeServerFormState.ts#L196-L198). With the PR this happens whenever a failing save is still running 250 ms after the last edit, as with Playwright'sfill()followed by a click on Save against a slow server. Sending the request with the form'ssubmittedas of when it's sent would avoid it.modified(#L436) and only adds errors (#L551-L554), so nothing re-evaluates the condition onurl. Re-requesting after a save that superseded form-state work would avoid it.The PR also makes a save wait for any form-state request in flight: in B, the save went out only when the held answer arrived, 1.5 s after the click.
The script in the report can't show the B fix on the PR: it waits for the save's error before releasing the held answer, and the save now waits for that answer. On v4 the error tooltip is also portaled to
#field-error-title. The script below handles both, and logs a timeline per run from the network and a MutationObserver in the page.test/race-probes/config.tstest/race-probes/race.mjsTimelines, run 1 of 3 (ms since the create view was ready; END = state 5 s after the last step)
OksentukVlad commentedon Oct 16, 2026
We found another consequence of this race, in Lexical
BlocksFeatureblocks: an edit stays visible in the block's input but is missing from the saved document.Reproduced on 3.91.0 with the blank template (script below), and in our app on 3.84.1.
Why it loses data. For inline block edits, the block's
onChangehandler copies the returned form state into the Lexical node (node.setFields(newData, true)). When the snapshot update suppresses the request for a newer edit, that edit never reaches the node. No block form-state request ever carries it, and Save stores the previous value even after waiting several seconds.Repro. Blank template (SQLite), plus the collection below with one block of two text fields. Run
pnpm generate:importmap, thenpnpm dev, thenBASE=http://localhost:3000 node repro.mjs. The script:route.continue(); payloads and responses are unchanged.On 3.91.0 it lost the edit in 3/3 runs: the input showed
A latest, no block request carried it, andfieldAwas saved asA first. In our app on 3.84.1 the same sequence lost it in 6/6 runs, across two different blocks.Workaround. In
fieldReducer.ts:The merge and return stay the same, and
dequalis already imported. If the state changed after the debounce snapshot was taken, the snapshot is left alone, so the pending change still triggers a request. We apply it as a pnpm patch to the distributed reducer and the client bundle. With it, the repro kept the edit in 3/3 runs on 3.91.0 and 4/4 in our app. A follow-up block request carried the edit, and there were no admin server-action requests in a 2.5 s idle window afterwards.It only addresses the suppressed follow-up request. It doesn't stop a stale reply from clearing a failed save's errors, and a save made while an edit is still on its way to the document can still miss it. We haven't tested #17455; since it reworks this area, this is mainly another case for it to cover.
src/collections/Posts.ts(add tocollectionsinpayload.config.ts)repro.mjstrivium-united commentedon Nov 1, 2026
One more visible consequence of this race, on 3.92.1 (the current
latest): a newly added array row can stay a loading skeleton indefinitely.What happens.
addFieldRowdispatchesADD_ROW, which adds the row withisLoading: trueand sends no request ("the debounced onChange effect will do that for us"). If the row is added less than ~250 ms before the answer to an earlier form-state request lands,MERGE_SERVER_STATEsetsprevStateRef.currentto the merged state, which already contains the new row (local state is kept). When the debounced effect then runs,formStateequalsprevFormState.current, so no request is sent for the row.ArrayRowkeeps renderingShimmerEffectuntil some other change triggers a form-state request.Saving with the row stuck is rejected (400, "The following fields are invalid: … → Date" for a required sub-field) and nothing is written. But the row then shows "1 error" while still rendering only the skeleton, so there is no field to fix. Touching any other field brings the row back.
Repro. Reproduced in our app, not in the blank template. Open the edit view of any collection with an array field and a top-level number or text field, and paste this into the console. It delays form-state answers by 1.5 s, types into the field, then clicks the array's "Add …" button
esperams later:The request for the typing goes out at ~260 ms and its answer lands at ~1800 ms:
The same happens with an array whose rows have only a
selectand twotextfields (nodate, noadmin.condition), so it doesn't depend on the row's field types. Without the artificial delay we hit it right afternext devstarted, while the first form-state requests were slow to compile. In production we expect the same, since answers take longer there than on localhost.We haven't tested #17455 against this sequence.
emmpauldev commentedon Nov 3, 2026
Same race, measured on 3.92.0 with a plain
required: truefield and no customvalidate,in a document drawer (create). The code is unchanged on the current
3.xhead(
49b3c0b)and on
main(
be95e1a,4.0.0-beta.6).
What the editor sees. In a drawer creating a document, pick a value in a relationship field,
leave a required date field empty, click Save right away. The toast says "The following field is
invalid: Start date", and the date field shows no error style and no "This field is required."
until the next edit.
Measured timeline (Playwright, the form-state answer held with
route.fetch()until thesave's 400 has been handled, then released with
route.fulfill()):skipValidation: truePOST /api/<collection>answers 400,data.errorsnaming the date fieldWith the answer released before Save (the control case), the mark shows and stays, on the same
unpatched 3.92.0.
Where it happens (links pinned to
mainbe95e1a;3.x49b3c0bline numbers in brackets):onChangebuilds the request withskipValidation: !submitted(3.x L524),
so before the first save the answer carries no
valid: false.Formsends it from a250 ms
useDebouncedEffectthrough
executeOnChange(3.x L841-L876),
which merges whatever comes back.
submitdoes not go throughuseQueue,and the queue only discards tasks not yet started, never the one running.
submitdispatchesADD_SERVER_ERRORS(3.x L508-L513),
which
sets
valid: falseanderrorMessageon each named path.
MERGE_SERVER_STATE, andmergeServerFormStatesetsvalid = trueon every incoming field whosevalidis notfalse:the marks are erased.
sets
prevStateRef.currentto the merged state,so the debounced effect sees no change. On
3.xthe refused save has alsocleared
modified.On
main, fix(ui): address remaining accessibility audit findings #18631 keepsmodifiedtrue after a refusal(L442-L445),
but the
prevStateRefupdate alone is enough to suppress the re-request.Minimal reproduction. Blank template, any collection with a
required: truetext fieldplus any other field. In the edit view, edit the other field, wait until the form-state request
has left (about 250 ms), then click Save while its answer is still out (DevTools throttling on the
form-state server action, or a Playwright
routeholding its answer). The toast names the field,the field is not marked. The
Bscenario of the script in this issue shows the same with a customvalidate.The in-flight case (B2 and B3 of the comment above), measured too on 3.92.0: Save clicked
within the 250 ms debounce, with the create's 400 delayed. The debounce then fires while the save
is in flight, the request leaves with
skipValidation: true(submittedis still false), and itsanswer, merged after the refusal, erases the mark the same way. With a fast 400 the same gesture
sends no request at all: the refusal clears
modifiedbefore the debounce fires.The fix we carry as a pnpm patch on 3.92.0. A save counter in
Formthat moves at BOTH endsof
submit, so that an answer whose request was queued before or during the latest save isdropped instead of merged:
A start increment alone is not enough: a request queued while the save is in flight reads the
counter after it moved and is merged. The second increment, once the save is over, closes that
window; a request queued after it is built with validation,
submittedbeing true, and is merged.Dropping is safe here: after a refused save the client keeps its own values, and the next edit
sends its own request, with validation since
submittedis now true. After an accepted save,onSuccessmerges a newer state anyway. An edit made while the save is in flight gets no servermerge until the next edit, which only delays validation results and server-computed data. The Edit
view already keeps a similar
saveCounterRef, incremented inonSubmit,but uses it only for the
stale-data modal.
Questions:
3.x, or only on 4.0?increment above does, on 3.92.0.
sam9191 commentedon Nov 4, 2026
Not at
5e1c2c5, the head I tested: B2 and B3 both ended with the error cleared (table above).Since then #17455 has become the top of a stack, #19307 → #19203 → #17455, and
256060aon #19203 makes a queued form-state request readsubmittedwhen it runs instead of when it was scheduled. That's the change I suggested for B2 and B3. I haven't re-run the probes against it, because #17455's branch (7472aef) doesn't include it yet. I'll re-run them once it does.That's for the maintainers. For reference, the whole stack is based on
main(4.0.0-beta.6), and #19203 is marked as a breaking change.