Skip to content

Commit a5d9388

Browse files
authored
fix(ui): stale data modal incorrectly shown when user saves their own document (#15933)
# Overview Fixes a race condition where the "Document Modified" modal incorrectly appears after a user types and saves their own document. ## Key Changes - Added `saveCounterRef` to track in-flight saves, incremented in the Form's `onSubmit` callback - Added a failing e2e test that reliably reproduces the race condition ## Design Decisions The stale data check compares `originalUpdatedAt` (sent with the form-state request) against the current DB `updatedAt`. If a save happened while a form-state request was in-flight, the DB timestamp advances and `isStale` returns true — correctly for *another user's* save, but incorrectly for *your own*. The fix captures the save counter at the start of each form-state request and skips the stale modal if a save was initiated during that window. The counter is incremented in `onSubmit`, which fires synchronously before the PATCH is sent. This guarantees the counter is already incremented before any form-state response could see our own save's `updatedAt` as stale — closing the race window entirely. The two-user scenario is unaffected: the counter is per-instance, so another user's save doesn't increment the local counter and the modal still appears correctly.
1 parent 03b20d0 commit a5d9388

File tree

2 files changed

+57
-3
lines changed

2 files changed

+57
-3
lines changed

packages/ui/src/views/Edit/index.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ export function DefaultEditView({
185185

186186
const hasCheckedForStaleDataRef = useRef(false)
187187
const originalUpdatedAtRef = useRef(data?.updatedAt)
188+
const saveCounterRef = useRef(0)
188189

189190
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
190191
const isLockExpired = Date.now() > lockExpiryTime
@@ -466,6 +467,10 @@ export function DefaultEditView({
466467
async ({ formState: prevFormState, submitted }) => {
467468
const controller = handleAbortRef(abortOnChangeRef)
468469

470+
// Capture save counter before the async form-state request so we can detect
471+
// if a save was triggered while this request was in-flight
472+
const saveCounterAtStart = saveCounterRef.current
473+
469474
// Sync originalUpdatedAt with current data if it's NEWER (e.g., after router.refresh())
470475
if (data?.updatedAt && data.updatedAt > originalUpdatedAtRef.current) {
471476
originalUpdatedAtRef.current = data.updatedAt
@@ -525,8 +530,10 @@ export function DefaultEditView({
525530
handleDocumentLocking(lockedState)
526531
}
527532

528-
// Handle stale data detection
529-
if (staleDataState?.isStale) {
533+
// Handle stale data detection.
534+
// Skip if a save was triggered after this request was initiated — the newer
535+
// updatedAt the server sees is from our OWN save, not an external modification.
536+
if (staleDataState?.isStale && saveCounterRef.current === saveCounterAtStart) {
530537
setShowStaleDataModal(true)
531538
}
532539

@@ -618,6 +625,9 @@ export function DefaultEditView({
618625
key={`${isLocked}`}
619626
method={id ? 'PATCH' : 'POST'}
620627
onChange={[onChange]}
628+
onSubmit={() => {
629+
saveCounterRef.current += 1
630+
}}
621631
onSuccess={onSave}
622632
>
623633
{isInDrawer && (

test/locked-documents/e2e.spec.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { BrowserContext, Page } from '@playwright/test'
1+
import type { BrowserContext, Locator, Page } from '@playwright/test'
22

33
import { expect, test } from '@playwright/test'
44
import * as path from 'path'
@@ -1790,6 +1790,50 @@ describe('Locked Documents', () => {
17901790
})
17911791
}
17921792
})
1793+
1794+
test('should not show stale data modal when user types and immediately saves (race condition)', async () => {
1795+
await page.goto(simpleUrl.edit(simpleDoc.id))
1796+
1797+
const fieldA = page.locator('#field-fieldA')
1798+
const editUrl = simpleUrl.edit(simpleDoc.id)
1799+
const modalContainer = page.locator('.payload__modal-container')
1800+
1801+
// Delay only the first POST (form-state from typing) by 3s to simulate the race:
1802+
// type → form-state starts (delayed) → save → DB updatedAt advances → delayed
1803+
// form-state reaches server and sees newer updatedAt → would incorrectly show modal.
1804+
// The second POST (post-save form-state from onSave) is not delayed so the toast works.
1805+
let firstPostDelayed = false
1806+
await page.route(editUrl, async (route) => {
1807+
if (route.request().method() === 'POST' && !firstPostDelayed) {
1808+
firstPostDelayed = true
1809+
// eslint-disable-next-line payload/no-wait-function
1810+
await wait(3000)
1811+
}
1812+
try {
1813+
await route.continue()
1814+
} catch (_e) {
1815+
// route may have already been handled (e.g. after page.unroute)
1816+
}
1817+
})
1818+
1819+
// Wait for the form-state POST to be in-flight before saving — if the save
1820+
// completes first, modified is reset and the POST never fires at all.
1821+
const formStateInFlight = page.waitForRequest(
1822+
(req) => req.method() === 'POST' && req.url() === editUrl,
1823+
{ timeout: 2000 },
1824+
)
1825+
await fieldA.fill('Race condition test')
1826+
await formStateInFlight
1827+
1828+
await page.click('#action-save')
1829+
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
1830+
1831+
await page.unroute(editUrl)
1832+
// eslint-disable-next-line payload/no-wait-function
1833+
await wait(4000)
1834+
1835+
await expect(modalContainer).toBeHidden()
1836+
})
17931837
})
17941838

17951839
describe('globals', () => {

0 commit comments

Comments
 (0)