Repository navigation
Admin form: a form-state answer that lands after a newer change overwrites it, and nothing asks again (conditional fields stay hidden, a failed save’s field errors vanish) #18425
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)