Skip to content

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

Open

Description

@sam9191

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):

  1. A radio shows the new choice, but the fields whose admin.condition depends 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.
  2. 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):

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

  1. pnpx create-payload-app@latest -t blank (SQLite), and add this collection to payload.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',
          },
        },
      ],
    }
  2. 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 with route.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()
  3. 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

  1. jacobsfletch commented on Oct 1, 2026

    @jacobsfletch
    Member

    This is possibly fixed by #17455

  2. sam9191 commented on Oct 1, 2026

    @sam9191
    Author

    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-probes suite in the monorepo (PAYLOAD_DATABASE=sqlite pnpm dev race-probes). PR head 5e1c2c5 against its merge-base 5a6d003, 3 runs of each sequence, all consistent. Without the PR, A and B fail exactly as the report shows for 3.90.2.

    Sequence Without #17455 (5a6d003) With #17455 (5e1c2c5)
    A (symptom 1): pick "custom" while the answer for the Title edit is out URL field stays hidden; no further request Fixed: the late answer is dropped, the radio change gets its own request, and the URL field shows
    B (symptom 2): save while that answer is out The save's error shows, then the late answer clears it Fixed: the save waits for that answer, then its error shows and stays
    A2: type, save, then pick "custom", all while that answer is out (the save fails) Ends right: URL field and error shown (the error drops out for ~30 ms) URL field stays hidden; the radio change never gets a request
    B2: save within 250 ms of typing; the save takes 1 s Error stays Error shows, then is cleared ~130 ms later
    B3: as B2, and form-state answers take 1.5 s Error shows, then is cleared Error shows, then is cleared

    Why

    • B2, B3: the form-state request for the typing is scheduled while the save is running, with submitted: false. The queue holds it until the save is done, then sends it with skipValidation (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 sets valid: 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's fill() followed by a click on Save against a slow server. Sending the request with the form's submitted as of when it's sent would avoid it.
    • A2: the request for the radio change is scheduled while the save waits behind the held answer, so it's superseded on the spot (useQueue.ts#L101-L106), but the Form has already taken the change as handled (Form/index.tsx#L1000). The failed save clears modified (#L436) and only adds errors (#L551-L554), so nothing re-evaluates the condition on url. 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.ts
    import type { CollectionConfig } from 'payload'
    
    import { fileURLToPath } from 'node:url'
    import path from 'path'
    
    import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
    import { devUser } from '../credentials.js'
    
    const filename = fileURLToPath(import.meta.url)
    const dirname = path.dirname(filename)
    
    // The collection from payloadcms/payload#18425, unchanged.
    // `title` is only checked on the server (on save, and on change after a failed
    // save); `url` only shows for type "custom".
    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',
          },
        },
      ],
    }
    
    export default buildConfigWithDefaults({
      suite: 'race-probes',
      config: {
        admin: {
          importMap: {
            baseDir: path.resolve(dirname),
          },
        },
        collections: [RaceProbes],
        typescript: {
          outputFile: path.resolve(dirname, 'payload-types.ts'),
        },
      },
      seed: async (payload) => {
        await payload.create({
          collection: 'users',
          data: {
            email: devUser.email,
            password: devUser.password,
          },
        })
      },
    })
    test/race-probes/race.mjs
    // Scenarios from #18425, run against #17455. Put this file and config.ts in
    // test/race-probes, run `PAYLOAD_DATABASE=sqlite PORT=3100 pnpm dev race-probes`, then
    // BASE=http://localhost:3100 RUNS=3 node test/race-probes/race.mjs [scenario ...]
    //
    // B0  baseline: type, let the form-state request settle, save
    // A   pick "custom" while the answer for the Title edit is out (as in the issue)
    // A2  type, save, then pick "custom", all while that answer is out (the save fails)
    // B   save while that answer is out (as in the issue, but it delivers the answer
    //     after 1.5 s instead of waiting for the save's error, which never shows if
    //     the save waits for that answer)
    // B2  type, then save within the 250 ms debounce; the save's answer takes 1 s
    // B3  as B2, and form-state answers take 1.5 s
    //
    // Every line of output is a timeline in ms since the create view was ready:
    // requests from the network, and `error`/`url` from a MutationObserver in the
    // page (`error` = the Title field has its error state, `url` = URL field shown).
    import { chromium } from '@playwright/test'
    
    const BASE = process.env.BASE ?? 'http://localhost:3100'
    const CREATE = '/admin/collections/race-probes/create'
    const user = { email: 'dev@payloadcms.com', password: 'test' }
    const RUNS = Number(process.env.RUNS ?? 1)
    
    const browser = await chromium.launch()
    const context = await browser.newContext({ baseURL: BASE })
    const login = await context.request.post('/api/users/login', { data: user })
    if (!login.ok()) throw new Error(`login failed: ${login.status()}`)
    
    // Records, with Date.now(), each time a watched element appears or disappears.
    await context.addInitScript(() => {
      const selectors = {
        // The field wrapper gets `error` exactly when FieldError shows its message.
        error: '.field-type.error:has(input[name="title"])',
        url: 'input[name="url"]',
      }
      const state = {}
      window.__events = []
      const check = () => {
        for (const [key, selector] of Object.entries(selectors)) {
          const visible = Boolean(document.querySelector(selector))
          if (state[key] !== visible) {
            state[key] = visible
            window.__events.push([Date.now(), key, visible])
          }
        }
      }
      new MutationObserver(check).observe(document, {
        attributes: true,
        childList: true,
        subtree: true,
      })
    })
    
    const isFormState = (req) =>
      req.method() === 'POST' &&
      'next-action' in req.headers() &&
      (req.postData() ?? '').includes('form-state')
    const isSave = (req) => req.method() === 'POST' && new URL(req.url()).pathname === '/api/race-probes'
    
    const open = async () => {
      const page = await context.newPage()
      const log = []
      const at = (msg) => log.push([Date.now(), msg])
      const held = { armed: false, route: null, response: null }
      const delay = { formState: 0, save: 0 }
      page.on('request', (req) => {
        if (isFormState(req)) at('form-state sent')
        if (isSave(req)) at('save sent')
      })
      await page.route(`**${CREATE}`, async (route) => {
        if (!isFormState(route.request())) return route.continue()
        if (held.armed) {
          held.armed = false
          held.response = await route.fetch()
          held.route = route
          return
        }
        const response = await route.fetch()
        if (delay.formState) await page.waitForTimeout(delay.formState)
        await route.fulfill({ response })
        at('form-state answered')
      })
      await page.route('**/api/race-probes**', async (route) => {
        if (!isSave(route.request())) return route.continue()
        const response = await route.fetch()
        if (delay.save) await page.waitForTimeout(delay.save)
        await route.fulfill({ response })
        at(`save answered ${response.status()}`)
      })
      await page.goto(CREATE, { timeout: 180_000 })
      await page.locator('form[data-form-ready="true"]').first().waitFor({ timeout: 180_000 })
      const t0 = Date.now()
      log.length = 0
      return {
        page,
        at,
        hold: () => (held.armed = true),
        delay,
        captured: async () => {
          while (!held.route) await page.waitForTimeout(25)
        },
        deliver: async () => {
          at('HELD form-state answer delivered')
          await held.route.fulfill({ response: held.response })
        },
        timeline: async () => {
          const events = (await page.evaluate(() => window.__events))
            .filter(([time]) => time >= t0)
            .map(([time, key, visible]) => [time, `${key} ${visible ? 'SHOWN' : 'hidden'}`])
          return [...log, ...events]
            .sort((a, b) => a[0] - b[0])
            .map(([time, msg]) => `${time - t0}: ${msg}`)
            .join(' | ')
        },
        state: async () => ({
          custom: await page.locator('#field-type-custom').isChecked(),
          error: await page.locator('.field-type.error:has(input[name="title"])').count() > 0,
          url: await page.locator('input[name="url"]').isVisible(),
        }),
      }
    }
    
    const scenarios = {
      async B0(f) {
        await f.page.locator('input[name="title"]').fill('not ok')
        await f.page.waitForTimeout(1_500)
        await f.page.locator('#action-save').click()
      },
      async A(f) {
        f.hold()
        await f.page.locator('input[name="title"]').fill('ok title')
        await f.captured()
        await f.page.locator('label[for="field-type-custom"]').click()
        await f.deliver()
      },
      async A2(f) {
        f.hold()
        await f.page.locator('input[name="title"]').fill('not ok')
        await f.captured()
        await f.page.locator('#action-save').click()
        f.at('save clicked')
        await f.page.locator('label[for="field-type-custom"]').click()
        f.at('custom clicked')
        await f.page.waitForTimeout(600) // past the 250 ms debounce of the radio change
        await f.deliver()
      },
      async B(f) {
        f.hold()
        await f.page.locator('input[name="title"]').fill('not ok')
        await f.captured()
        await f.page.locator('#action-save').click()
        f.at('save clicked')
        await f.page.waitForTimeout(1_500)
        await f.deliver()
      },
      async B2(f) {
        f.delay.save = 1_000
        await f.page.locator('input[name="title"]').fill('not ok')
        f.at('typed')
        await f.page.locator('#action-save').click()
        f.at('save clicked')
      },
      async B3(f) {
        f.delay.save = 1_000
        f.delay.formState = 1_500
        await f.page.locator('input[name="title"]').fill('not ok')
        f.at('typed')
        await f.page.locator('#action-save').click()
        f.at('save clicked')
      },
    }
    
    const names = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(scenarios)
    for (const name of names) {
      for (let run = 1; run <= RUNS; run++) {
        const f = await open()
        await scenarios[name](f)
        await f.page.waitForTimeout(5_000)
        const s = await f.state()
        console.log(
          `${name.padEnd(2)} #${run} END: custom=${s.custom} url=${s.url ? 'SHOWN' : 'hidden'} titleError=${s.error ? 'SHOWN' : 'hidden'}`,
        )
        console.log(`      ${await f.timeline()}`)
        await f.page.close()
      }
    }
    
    await browser.close()
    Timelines, run 1 of 3 (ms since the create view was ready; END = state 5 s after the last step)
    Without #17455 (5a6d003)
    A   END: custom=true url=hidden titleError=hidden
        338: form-state sent | 402: HELD form-state answer delivered
    B   END: custom=false url=hidden titleError=hidden
        328: form-state sent | 396: save sent | 398: save clicked | 410: save answered 400 | 432: error SHOWN | 1899: HELD form-state answer delivered | 1921: error hidden
    A2  END: custom=true url=SHOWN titleError=SHOWN
        321: form-state sent | 386: save sent | 387: save clicked | 401: save answered 400 | 423: error SHOWN | 513: custom clicked | 1113: HELD form-state answer delivered | 1134: error hidden | 1135: form-state sent | 1157: form-state answered | 1166: error SHOWN | 1166: url SHOWN
    B2  END: custom=false url=hidden titleError=SHOWN
        54: typed | 90: save sent | 92: save clicked | 328: form-state sent | 348: form-state answered | 1105: save answered 400 | 1126: error SHOWN
    B3  END: custom=false url=hidden titleError=hidden
        47: typed | 89: save sent | 90: save clicked | 318: form-state sent | 1101: save answered 400 | 1125: error SHOWN | 1841: form-state answered | 1861: error hidden
    
    With #17455 (5e1c2c5)
    A   END: custom=true url=SHOWN titleError=hidden
        335: form-state sent | 395: HELD form-state answer delivered | 648: form-state sent | 669: form-state answered | 678: url SHOWN
    B   END: custom=false url=hidden titleError=SHOWN
        327: form-state sent | 373: save clicked | 1874: HELD form-state answer delivered | 1879: save sent | 1893: save answered 400 | 1918: error SHOWN
    A2  END: custom=true url=hidden titleError=SHOWN
        331: form-state sent | 393: save clicked | 429: custom clicked | 1030: HELD form-state answer delivered | 1035: save sent | 1049: save answered 400 | 1076: error SHOWN
    B2  END: custom=false url=hidden titleError=hidden
        53: typed | 100: save sent | 101: save clicked | 1113: save answered 400 | 1137: error SHOWN | 1220: form-state sent | 1244: form-state answered | 1265: error hidden
    B3  END: custom=false url=hidden titleError=hidden
        81: typed | 120: save sent | 122: save clicked | 1137: save answered 400 | 1161: error SHOWN | 1244: form-state sent | 2774: form-state answered | 2795: error hidden
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions