diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 08dbc7d115b..33ab178ee0e 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -185,6 +185,7 @@ export function DefaultEditView({ const hasCheckedForStaleDataRef = useRef(false) const originalUpdatedAtRef = useRef(data?.updatedAt) + const saveCounterRef = useRef(0) const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds const isLockExpired = Date.now() > lockExpiryTime @@ -466,6 +467,10 @@ export function DefaultEditView({ async ({ formState: prevFormState, submitted }) => { const controller = handleAbortRef(abortOnChangeRef) + // Capture save counter before the async form-state request so we can detect + // if a save was triggered while this request was in-flight + const saveCounterAtStart = saveCounterRef.current + // Sync originalUpdatedAt with current data if it's NEWER (e.g., after router.refresh()) if (data?.updatedAt && data.updatedAt > originalUpdatedAtRef.current) { originalUpdatedAtRef.current = data.updatedAt @@ -525,8 +530,10 @@ export function DefaultEditView({ handleDocumentLocking(lockedState) } - // Handle stale data detection - if (staleDataState?.isStale) { + // Handle stale data detection. + // Skip if a save was triggered after this request was initiated — the newer + // updatedAt the server sees is from our OWN save, not an external modification. + if (staleDataState?.isStale && saveCounterRef.current === saveCounterAtStart) { setShowStaleDataModal(true) } @@ -618,6 +625,9 @@ export function DefaultEditView({ key={`${isLocked}`} method={id ? 'PATCH' : 'POST'} onChange={[onChange]} + onSubmit={() => { + saveCounterRef.current += 1 + }} onSuccess={onSave} > {isInDrawer && ( diff --git a/test/locked-documents/e2e.spec.ts b/test/locked-documents/e2e.spec.ts index 925fb742bed..961be760cf9 100644 --- a/test/locked-documents/e2e.spec.ts +++ b/test/locked-documents/e2e.spec.ts @@ -1,4 +1,4 @@ -import type { BrowserContext, Page } from '@playwright/test' +import type { BrowserContext, Locator, Page } from '@playwright/test' import { expect, test } from '@playwright/test' import * as path from 'path' @@ -1790,6 +1790,50 @@ describe('Locked Documents', () => { }) } }) + + test('should not show stale data modal when user types and immediately saves (race condition)', async () => { + await page.goto(simpleUrl.edit(simpleDoc.id)) + + const fieldA = page.locator('#field-fieldA') + const editUrl = simpleUrl.edit(simpleDoc.id) + const modalContainer = page.locator('.payload__modal-container') + + // Delay only the first POST (form-state from typing) by 3s to simulate the race: + // type → form-state starts (delayed) → save → DB updatedAt advances → delayed + // form-state reaches server and sees newer updatedAt → would incorrectly show modal. + // The second POST (post-save form-state from onSave) is not delayed so the toast works. + let firstPostDelayed = false + await page.route(editUrl, async (route) => { + if (route.request().method() === 'POST' && !firstPostDelayed) { + firstPostDelayed = true + // eslint-disable-next-line payload/no-wait-function + await wait(3000) + } + try { + await route.continue() + } catch (_e) { + // route may have already been handled (e.g. after page.unroute) + } + }) + + // Wait for the form-state POST to be in-flight before saving — if the save + // completes first, modified is reset and the POST never fires at all. + const formStateInFlight = page.waitForRequest( + (req) => req.method() === 'POST' && req.url() === editUrl, + { timeout: 2000 }, + ) + await fieldA.fill('Race condition test') + await formStateInFlight + + await page.click('#action-save') + await expect(page.locator('.payload-toast-container')).toContainText('successfully') + + await page.unroute(editUrl) + // eslint-disable-next-line payload/no-wait-function + await wait(4000) + + await expect(modalContainer).toBeHidden() + }) }) describe('globals', () => {