Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/ui/src/views/Edit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -618,6 +625,9 @@ export function DefaultEditView({
key={`${isLocked}`}
method={id ? 'PATCH' : 'POST'}
onChange={[onChange]}
onSubmit={() => {
saveCounterRef.current += 1
}}
onSuccess={onSave}
>
{isInDrawer && (
Expand Down
46 changes: 45 additions & 1 deletion test/locked-documents/e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading