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
    
  3. OksentukVlad commented on Oct 16, 2026

    @OksentukVlad

    We found another consequence of this race, in Lexical BlocksFeature blocks: 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 onChange handler 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, then pnpm dev, then BASE=http://localhost:3000 node repro.mjs. The script:

    1. Delays every block form-state request by 700 ms before route.continue(); payloads and responses are unchanged.
    2. Inserts the block and fills A, waits, fills B, waits, then changes B again.
    3. Changes A about 600 ms after that request is intercepted, so its reply lands inside A's 250 ms debounce.
    4. Waits, saves, and reads the document back.

    On 3.91.0 it lost the edit in 3/3 runs: the input showed A latest, no block request carried it, and fieldA was saved as A 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:

    if (prevStateRef && dequal(state || {}, prevStateRef.current || {})) {
      prevStateRef.current = newState
    }

    The merge and return stay the same, and dequal is 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 to collections in payload.config.ts)
    import type { CollectionConfig } from 'payload'
    
    import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
    
    // Repro for payloadcms/payload#18425 in Lexical blocks: one block with two text fields.
    export const Posts: CollectionConfig = {
      slug: 'posts',
      fields: [
        { name: 'title', type: 'text' },
        {
          name: 'content',
          type: 'richText',
          editor: lexicalEditor({
            features: ({ defaultFeatures }) => [
              ...defaultFeatures,
              BlocksFeature({
                blocks: [
                  {
                    slug: 'pair',
                    fields: [
                      { name: 'fieldA', type: 'text' },
                      { name: 'fieldB', type: 'text' },
                    ],
                  },
                ],
              }),
            ],
          }),
        },
      ],
    }
    repro.mjs
    // repro.mjs: a Lexical block edit is lost when the block's previous form-state reply lands
    // within 250 ms of the edit (payloadcms/payload#18425). Blank template + the `posts`
    // collection with one `pair` block (text fields fieldA and fieldB), against `pnpm dev`:
    //   BASE=http://localhost:3000 node repro.mjs
    // Optional: RUNS=3, CHROMIUM_PATH=/path/to/chromium for a different browser build.
    import { chromium } from '@playwright/test'
    
    const BASE = process.env.BASE ?? 'http://localhost:3000'
    const RUNS = Number(process.env.RUNS ?? 3)
    const user = { email: 'race@example.test', password: 'race-repro-password' }
    
    const isServerAction = (req) => req.method() === 'POST' && 'next-action' in req.headers()
    // The block's own form-state requests carry its schema path.
    const isBlockFormState = (req) =>
      isServerAction(req) && (req.postData() ?? '').includes('lexical_internal_feature')
    
    const paragraph = (text) => ({
      type: 'paragraph',
      children: [{ type: 'text', detail: 0, format: 0, mode: 'normal', style: '', text, version: 1 }],
      direction: 'ltr',
      format: '',
      indent: 0,
      textFormat: 0,
      textStyle: '',
      version: 1,
    })
    
    const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH })
    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 })
    
    let lost = 0
    for (let run = 1; run <= RUNS; run++) {
      const created = await context.request.post('/api/posts', {
        data: {
          content: {
            root: { type: 'root', children: [paragraph('Text.')], direction: 'ltr', format: '', indent: 0, version: 1 },
          },
          title: `race ${run}`,
        },
      })
      const { id } = (await created.json()).doc
      const page = await context.newPage()
    
      // Every block form-state request is delayed by 700 ms before it goes out; payloads and
      // responses are unchanged. `nextBlockRequest()` resolves when the next one is intercepted.
      let onBlockRequest = null
      const nextBlockRequest = () => new Promise((resolve) => (onBlockRequest = resolve))
      await page.route('**/admin/**', async (route) => {
        if (!isBlockFormState(route.request())) return route.continue()
        onBlockRequest?.()
        onBlockRequest = null
        await new Promise((resolve) => setTimeout(resolve, 700))
        await route.continue()
      })
      const blockBodies = []
      const actionTimes = []
      page.on('request', (req) => {
        if (isServerAction(req)) actionTimes.push(Date.now())
        if (isBlockFormState(req)) blockBodies.push(req.postData() ?? '')
      })
    
      await page.goto(`/admin/collections/posts/${id}`, { timeout: 120_000 })
      await page.locator('form[data-form-ready="true"]').first().waitFor({ timeout: 120_000 })
    
      // Insert the block through the slash menu.
      await page.locator('[data-lexical-editor="true"] p').last().click()
      await page.keyboard.press('End')
      await page.keyboard.press('Enter')
      await page.keyboard.type('/pair')
      await page.waitForTimeout(800)
      await page.keyboard.press('Enter')
    
      const fieldA = page.locator('input[name="fieldA"]').first()
      const fieldB = page.locator('input[name="fieldB"]').first()
      await fieldA.waitFor({ timeout: 20_000 })
      await fieldA.fill('A first')
      await page.waitForTimeout(2500)
      await fieldB.fill('B first')
      await page.waitForTimeout(2500)
    
      // Edit B, then edit A ~100 ms before the reply for B lands (inside A's 250 ms debounce).
      const intercepted = nextBlockRequest()
      await fieldB.fill('B second')
      await intercepted
      await page.waitForTimeout(600)
      await fieldA.fill('A latest')
      await page.waitForTimeout(3000)
    
      const idleFrom = Date.now()
      await page.waitForTimeout(2500)
      const idleRequests = actionTimes.filter((t) => t >= idleFrom).length
      const shown = await fieldA.inputValue()
      const sentLatest = blockBodies.some((body) => body.includes('A latest'))
    
      await page.locator('#action-save').click()
      await page.waitForTimeout(3000)
      const doc = await (await context.request.get(`/api/posts/${id}?depth=0`)).json()
      const stored = doc.content.root.children.find((node) => node.type === 'block')?.fields?.fieldA
      if (stored !== 'A latest') lost++
      console.log(
        `run ${run}: input shows "${shown}", a block request carried "A latest": ${sentLatest}, ` +
          `requests in 2.5 s idle: ${idleRequests}, saved fieldA: "${stored}" -> ${stored === 'A latest' ? 'kept' : 'LOST'}`,
      )
      await page.close()
    }
    console.log(`${lost}/${RUNS} runs lost the edit`)
    await browser.close()
    process.exitCode = lost ? 1 : 0
  4. trivium-united commented on Nov 1, 2026

    @trivium-united

    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. addFieldRow dispatches ADD_ROW, which adds the row with isLoading: true and 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_STATE sets prevStateRef.current to the merged state, which already contains the new row (local state is kept). When the debounced effect then runs, formState equals prevFormState.current, so no request is sent for the row. ArrayRow keeps rendering ShimmerEffect until 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 espera ms later:

    const exp = async ({ name, button, espera, retardo = 1500 }) => {
      const orig = window.fetch, t0 = performance.now(), T = () => Math.round(performance.now() - t0), log = []
      window.fetch = async (...a) => {
        const u = String(a[0]?.url ?? a[0]), e = { t: T(), u: u.slice(-20) }
        log.push(e)
        const r = await orig(...a)
        if (/\/admin\/collections\//.test(u)) await new Promise((s) => setTimeout(s, retardo))
        e.done = T()
        return r
      }
      const inp = document.querySelector(`input[name="${name}"]`)
      Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(inp, String(Date.now() % 100))
      inp.dispatchEvent(new Event('input', { bubbles: true }))
      await new Promise((s) => setTimeout(s, espera))
      ;[...document.querySelectorAll('button')].find((b) => b.textContent.trim() === button).click()
      log.push({ t: T(), click: button })
      await new Promise((s) => setTimeout(s, retardo + 3000))
      window.fetch = orig
      return { shimmer: document.querySelectorAll('.shimmer-effect').length, log }
    }
    await exp({ name: 'yourNumberField', button: 'Add Row', espera: 1650 })

    The request for the typing goes out at ~260 ms and its answer lands at ~1800 ms:

    Click before the answer lands Another form-state request? Row
    1409 ms yes renders
    342 ms yes renders
    155 ms no stays a skeleton

    The same happens with an array whose rows have only a select and two text fields (no date, no admin.condition), so it doesn't depend on the row's field types. Without the artificial delay we hit it right after next dev started, 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.

  5. emmpauldev commented on Nov 3, 2026

    @emmpauldev

    Same race, measured on 3.92.0 with a plain required: true field and no custom validate,
    in a document drawer (create). The code is unchanged on the current 3.x head
    (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 the
    save's 400 has been handled, then released with route.fulfill()):

    1. form-state request sent after the pick, with skipValidation: true
    2. Save clicked about 100 ms later
    3. POST /api/<collection> answers 400, data.errors naming the date field
    4. the field is marked (error class and message present)
    5. the held form-state answer (200) is released
    6. the mark is gone; no further form-state request is sent

    With 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 main be95e1a; 3.x 49b3c0b line numbers in brackets):

    Minimal reproduction. Blank template, any collection with a required: true text field
    plus 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 route holding its answer). The toast names the field,
    the field is not marked. The B scenario of the script in this issue shows the same with a custom
    validate.

    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 (submitted is still false), and its
    answer, 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 modified before the debounce fires.

    The fix we carry as a pnpm patch on 3.92.0. A save counter in Form that moves at BOTH ends
    of submit, so that an answer whose request was queued before or during the latest save is
    dropped instead of merged:

    const saveCountRef = useRef(0)
    
    // in submit, once the disabled check has passed:
    saveCountRef.current++
    try {
      // … the whole rest of submit, unchanged …
    } finally {
      saveCountRef.current++ // the save ended: success, refusal or thrown error
    }
    
    const executeOnChange = useEffectEvent(() => {
      const saveCountAtQueue = saveCountRef.current
      queueTask(async () => {
        if (Array.isArray(onChange)) {
          let serverState: FormState
          for (const onChangeFn of onChange) {
            serverState = await onChangeFn({ formState: /* … */, submitted })
          }
          if (saveCountAtQueue !== saveCountRef.current) {
            return // queued before the latest save ended: stale
          }
          React.startTransition(() => {
            dispatchFields({ type: 'MERGE_SERVER_STATE', prevStateRef: prevFormState, serverState })
          })
        }
      })
    })

    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, submitted being 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 submitted is now true. After an accepted save,
    onSuccess merges a newer state anyway. An edit made while the save is in flight gets no server
    merge until the next edit, which only delays validation results and server-computed data. The Edit
    view already keeps a similar
    saveCounterRef, incremented in onSubmit,
    but uses it only for the
    stale-data modal.

    Questions:

  6. sam9191 commented on Nov 4, 2026

    @sam9191
    Author

    Does it cover a save clicked within the 250 ms debounce (B2 and B3 above)?

    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 256060a on #19203 makes a queued form-state request read submitted when 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.

    Is #17455 meant to land on 3.x, or only on 4.0?

    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.

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