Skip to content

Commit 6aff717

Browse files
authored
fix(ui): prevent false positive stale data modal on autosave-enabled documents (#15817)
## Summary Temporarily disable stale data checking for autosave-enabled collections and globals to prevent false positives from race conditions. ## Problem When autosave is enabled (collections or globals), rapid edits trigger a race condition between autosave completion and onChange debounce, causing the "Document Modified" modal to incorrectly appear for the same user. ## Approaches Considered Several approaches were explored to distinguish between a user's own autosaves and another user's changes: 1. **Track autosave queue status**: Add an `autosaveQueued` flag to skip stale checks when autosave is processing. This fixes the false positive (same user seeing their own changes as stale), but creates false negatives in multi-user scenarios. When User 2 starts typing, their own autosave could queue before onChange fires, causing the check to skip even when User 1 has legitimately modified the document. 2. **Grace period after page load**: Skip stale checks for X seconds after opening a document. This is an arbitrary time-based workaround that doesn't address the root cause and has unreliable edge cases (what if autosave takes longer than the grace period?). 3. **Shared queue between Form and Autosave**: Have both components use the same task queue to coordinate timing. While architecturally sound, this requires a major refactor and still doesn't solve the fundamental issue that autosave's potentially shorter debounce causes it to queue before onChange. 4. **Immediate stale check without debounce**: Perform the stale data check directly in the Form component on every keystroke instead of waiting for the debounced onChange. This could work but duplicates onChange logic, requires watching formState changes without debounce, and could cause performance issues. 5. **Combined approaches**: Mix multiple approaches (e.g., autosaveQueued + grace period). This adds significant complexity without fully solving the problem - still vulnerable to edge cases and race conditions. All approaches were flawed because they tried to work around the timing issue rather than addressing the fundamental problem: we can't reliably distinguish between a user's own autosaves and another user's changes without tracking who made each change. ## Solution Disable stale data checking when: - Collections: `versions.drafts.autosave` is configured - Globals: `versions.drafts.autosave` is configured Autosave-enabled documents already have frequent saves for conflict resolution. ## Future Work (v4.0) Implement proper `updatedBy` tracking to compare current user vs last editor. This is the correct solution but requires breaking schema changes. ## Changes - Skip stale check when `hasAutosaveEnabled(docConfig)` returns true Fixes #15803
1 parent e899182 commit 6aff717

7 files changed

Lines changed: 228 additions & 4 deletions

File tree

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ export function DefaultEditView({
309309
void refreshCookieAsync()
310310
}
311311

312-
setLastUpdateTime(updatedAt)
312+
setLastUpdateTime(new Date(updatedAt).getTime())
313313

314314
// Update stale data check refs after successful save
315315
// This allows detecting if another user modifies the document after this save
@@ -483,8 +483,13 @@ export function DefaultEditView({
483483
}
484484

485485
// Check for stale data on first edit only
486+
// Skip this check entirely for autosave-enabled collections/globals to prevent
487+
// false positives from the user's own autosaves
486488
const checkForStaleData =
487-
!hasCheckedForStaleDataRef.current && originalUpdatedAtRef.current && operation === 'update'
489+
!hasCheckedForStaleDataRef.current &&
490+
originalUpdatedAtRef.current &&
491+
operation === 'update' &&
492+
!autosaveEnabled
488493

489494
if (checkForStaleData) {
490495
hasCheckedForStaleDataRef.current = true
@@ -542,6 +547,7 @@ export function DefaultEditView({
542547
operation,
543548
schemaPathSegments,
544549
handleDocumentLocking,
550+
autosaveEnabled,
545551
],
546552
)
547553

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { autosaveSlug } from '../../slugs.js'
4+
5+
export const AutosaveCollection: CollectionConfig = {
6+
slug: autosaveSlug,
7+
admin: {
8+
useAsTitle: 'fieldA',
9+
},
10+
fields: [
11+
{
12+
name: 'fieldA',
13+
type: 'text',
14+
},
15+
{
16+
name: 'fieldB',
17+
type: 'text',
18+
},
19+
],
20+
versions: {
21+
drafts: {
22+
autosave: {
23+
interval: 100,
24+
},
25+
},
26+
},
27+
}

test/locked-documents/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'
22
import path from 'path'
33

44
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
5+
import { AutosaveCollection } from './collections/Autosave/index.js'
56
import { PagesCollection } from './collections/Pages/index.js'
67
import { PostsCollection } from './collections/Posts/index.js'
78
import { ServerComponentsCollection } from './collections/ServerComponents/index.js'
@@ -10,6 +11,7 @@ import { SimpleWithVersionsCollection } from './collections/SimpleWithVersions/i
1011
import { TestsCollection } from './collections/Tests/index.js'
1112
import { Users } from './collections/Users/index.js'
1213
import { AdminGlobal } from './globals/Admin/index.js'
14+
import { AutosaveGlobal } from './globals/AutosaveGlobal/index.js'
1315
import { GlobalWithVersions } from './globals/GlobalWithVersions/index.js'
1416
import { MenuGlobal } from './globals/Menu/index.js'
1517
import { seed } from './seed.js'
@@ -24,6 +26,7 @@ export default buildConfigWithDefaults({
2426
},
2527
},
2628
collections: [
29+
AutosaveCollection,
2730
PagesCollection,
2831
PostsCollection,
2932
ServerComponentsCollection,
@@ -32,7 +35,7 @@ export default buildConfigWithDefaults({
3235
TestsCollection,
3336
Users,
3437
],
35-
globals: [AdminGlobal, GlobalWithVersions, MenuGlobal],
38+
globals: [AdminGlobal, AutosaveGlobal, GlobalWithVersions, MenuGlobal],
3639
onInit: async (payload) => {
3740
if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') {
3841
await seed(payload)

test/locked-documents/e2e.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'url'
88

99
import type { PayloadTestSDK } from '../__helpers/shared/sdk/index.js'
1010
import type {
11+
Autosave,
1112
Config,
1213
Page as PageType,
1314
PayloadLockedDocument,
@@ -46,6 +47,7 @@ let serverComponentsUrl: AdminUrlUtil
4647
let testsUrl: AdminUrlUtil
4748
let simpleUrl: AdminUrlUtil
4849
let simpleWithVersionsUrl: AdminUrlUtil
50+
let autosaveUrl: AdminUrlUtil
4951
let payload: PayloadTestSDK<Config>
5052
let serverURL: string
5153

@@ -61,6 +63,7 @@ describe('Locked Documents', () => {
6163
testsUrl = new AdminUrlUtil(serverURL, 'tests')
6264
simpleUrl = new AdminUrlUtil(serverURL, 'simple')
6365
simpleWithVersionsUrl = new AdminUrlUtil(serverURL, 'simple-with-versions')
66+
autosaveUrl = new AdminUrlUtil(serverURL, 'autosave')
6467

6568
const context = await browser.newContext()
6669
page = await context.newPage()
@@ -1733,6 +1736,60 @@ describe('Locked Documents', () => {
17331736
// Modal should still NOT appear
17341737
await expect(modalContainer).toBeHidden()
17351738
})
1739+
1740+
test('should not show stale data modal after autosave for same user with rapid edits', async () => {
1741+
const createdAutosaveIDs: string[] = []
1742+
1743+
// Create an autosave document
1744+
const autosaveDoc = (await payload.create({
1745+
collection: 'autosave',
1746+
data: {
1747+
fieldA: 'Initial Value',
1748+
fieldB: 'Initial Value B',
1749+
},
1750+
})) as unknown as Autosave
1751+
1752+
createdAutosaveIDs.push(autosaveDoc.id)
1753+
1754+
await page.goto(autosaveUrl.edit(autosaveDoc.id))
1755+
1756+
// Simulate very slow CPU to create reliable race condition
1757+
const client = await page.context().newCDPSession(page)
1758+
await client.send('Emulation.setCPUThrottlingRate', { rate: 50 })
1759+
1760+
const fieldA = page.locator('#field-fieldA')
1761+
const modalContainer = page.locator('.payload__modal-container')
1762+
1763+
// Make many rapid edits to create multiple queued autosaves
1764+
for (let i = 1; i <= 10; i++) {
1765+
await fieldA.fill(`Edit ${i}`)
1766+
// eslint-disable-next-line payload/no-wait-function
1767+
await wait(30)
1768+
}
1769+
1770+
// Wait for all autosaves to process
1771+
// eslint-disable-next-line payload/no-wait-function
1772+
await wait(2000)
1773+
1774+
// Make one more edit to trigger stale data check
1775+
await fieldA.fill('Final Edit')
1776+
// eslint-disable-next-line payload/no-wait-function
1777+
await wait(500)
1778+
1779+
// Modal should NOT appear because it's the same user
1780+
await expect(modalContainer).toBeHidden()
1781+
1782+
// Clean up
1783+
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 })
1784+
await client.detach()
1785+
1786+
// Clean up created autosave document
1787+
for (const id of createdAutosaveIDs) {
1788+
await payload.delete({ collection: 'autosave', id }).catch(() => {
1789+
// Ignore deletion errors (document might already be deleted)
1790+
})
1791+
}
1792+
})
17361793
})
17371794

17381795
describe('globals', () => {
@@ -2071,6 +2128,40 @@ describe('Locked Documents', () => {
20712128
modalContainer = page.locator('.payload__modal-container')
20722129
await expect(modalContainer).toBeVisible()
20732130
})
2131+
2132+
test('should not show stale data modal for autosave-enabled global with rapid edits', async () => {
2133+
await page.goto(globalUrl.global('autosave-global'))
2134+
2135+
// Simulate very slow CPU to create reliable race condition
2136+
const client = await page.context().newCDPSession(page)
2137+
await client.send('Emulation.setCPUThrottlingRate', { rate: 50 })
2138+
2139+
const textField = page.locator('#field-text')
2140+
const modalContainer = page.locator('.payload__modal-container')
2141+
2142+
// Make many rapid edits to create multiple queued autosaves
2143+
for (let i = 1; i <= 10; i++) {
2144+
await textField.fill(`Edit ${i}`)
2145+
// eslint-disable-next-line payload/no-wait-function
2146+
await wait(30)
2147+
}
2148+
2149+
// Wait for all autosaves to process
2150+
// eslint-disable-next-line payload/no-wait-function
2151+
await wait(2000)
2152+
2153+
// Make one more edit to trigger stale data check
2154+
await textField.fill('Final Edit')
2155+
// eslint-disable-next-line payload/no-wait-function
2156+
await wait(500)
2157+
2158+
// Modal should NOT appear because stale check is disabled for autosave-enabled globals
2159+
await expect(modalContainer).toBeHidden()
2160+
2161+
// Clean up
2162+
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 })
2163+
await client.detach()
2164+
})
20742165
})
20752166
})
20762167
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { GlobalConfig } from 'payload'
2+
3+
export const autosaveGlobalSlug = 'autosave-global'
4+
5+
export const AutosaveGlobal: GlobalConfig = {
6+
slug: autosaveGlobalSlug,
7+
fields: [
8+
{
9+
name: 'text',
10+
type: 'text',
11+
},
12+
],
13+
versions: {
14+
drafts: {
15+
autosave: {
16+
interval: 100,
17+
},
18+
},
19+
},
20+
}

test/locked-documents/payload-types.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface Config {
6767
};
6868
blocks: {};
6969
collections: {
70+
autosave: Autosave;
7071
pages: Page;
7172
posts: Post;
7273
'server-components': ServerComponent;
@@ -81,6 +82,7 @@ export interface Config {
8182
};
8283
collectionsJoins: {};
8384
collectionsSelect: {
85+
autosave: AutosaveSelect<false> | AutosaveSelect<true>;
8486
pages: PagesSelect<false> | PagesSelect<true>;
8587
posts: PostsSelect<false> | PostsSelect<true>;
8688
'server-components': ServerComponentsSelect<false> | ServerComponentsSelect<true>;
@@ -99,15 +101,20 @@ export interface Config {
99101
fallbackLocale: null;
100102
globals: {
101103
admin: Admin;
104+
'autosave-global': AutosaveGlobal;
102105
'global-with-versions': GlobalWithVersion;
103106
menu: Menu;
104107
};
105108
globalsSelect: {
106109
admin: AdminSelect<false> | AdminSelect<true>;
110+
'autosave-global': AutosaveGlobalSelect<false> | AutosaveGlobalSelect<true>;
107111
'global-with-versions': GlobalWithVersionsSelect<false> | GlobalWithVersionsSelect<true>;
108112
menu: MenuSelect<false> | MenuSelect<true>;
109113
};
110114
locale: null;
115+
widgets: {
116+
collections: CollectionsWidget;
117+
};
111118
user: User;
112119
jobs: {
113120
tasks: unknown;
@@ -132,6 +139,18 @@ export interface UserAuthOperations {
132139
password: string;
133140
};
134141
}
142+
/**
143+
* This interface was referenced by `Config`'s JSON-Schema
144+
* via the `definition` "autosave".
145+
*/
146+
export interface Autosave {
147+
id: string;
148+
fieldA?: string | null;
149+
fieldB?: string | null;
150+
updatedAt: string;
151+
createdAt: string;
152+
_status?: ('draft' | 'published') | null;
153+
}
135154
/**
136155
* This interface was referenced by `Config`'s JSON-Schema
137156
* via the `definition` "pages".
@@ -280,6 +299,10 @@ export interface PayloadKv {
280299
export interface PayloadLockedDocument {
281300
id: string;
282301
document?:
302+
| ({
303+
relationTo: 'autosave';
304+
value: string | Autosave;
305+
} | null)
283306
| ({
284307
relationTo: 'posts';
285308
value: string | Post;
@@ -346,6 +369,17 @@ export interface PayloadMigration {
346369
updatedAt: string;
347370
createdAt: string;
348371
}
372+
/**
373+
* This interface was referenced by `Config`'s JSON-Schema
374+
* via the `definition` "autosave_select".
375+
*/
376+
export interface AutosaveSelect<T extends boolean = true> {
377+
fieldA?: T;
378+
fieldB?: T;
379+
updatedAt?: T;
380+
createdAt?: T;
381+
_status?: T;
382+
}
349383
/**
350384
* This interface was referenced by `Config`'s JSON-Schema
351385
* via the `definition` "pages_select".
@@ -483,6 +517,17 @@ export interface Admin {
483517
updatedAt?: string | null;
484518
createdAt?: string | null;
485519
}
520+
/**
521+
* This interface was referenced by `Config`'s JSON-Schema
522+
* via the `definition` "autosave-global".
523+
*/
524+
export interface AutosaveGlobal {
525+
id: string;
526+
text?: string | null;
527+
_status?: ('draft' | 'published') | null;
528+
updatedAt?: string | null;
529+
createdAt?: string | null;
530+
}
486531
/**
487532
* This interface was referenced by `Config`'s JSON-Schema
488533
* via the `definition` "global-with-versions".
@@ -514,6 +559,17 @@ export interface AdminSelect<T extends boolean = true> {
514559
createdAt?: T;
515560
globalType?: T;
516561
}
562+
/**
563+
* This interface was referenced by `Config`'s JSON-Schema
564+
* via the `definition` "autosave-global_select".
565+
*/
566+
export interface AutosaveGlobalSelect<T extends boolean = true> {
567+
text?: T;
568+
_status?: T;
569+
updatedAt?: T;
570+
createdAt?: T;
571+
globalType?: T;
572+
}
517573
/**
518574
* This interface was referenced by `Config`'s JSON-Schema
519575
* via the `definition` "global-with-versions_select".
@@ -535,6 +591,16 @@ export interface MenuSelect<T extends boolean = true> {
535591
createdAt?: T;
536592
globalType?: T;
537593
}
594+
/**
595+
* This interface was referenced by `Config`'s JSON-Schema
596+
* via the `definition` "collections_widget".
597+
*/
598+
export interface CollectionsWidget {
599+
data?: {
600+
[k: string]: unknown;
601+
};
602+
width: 'full';
603+
}
538604
/**
539605
* This interface was referenced by `Config`'s JSON-Schema
540606
* via the `definition` "auth".

test/locked-documents/slugs.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,15 @@ export const usersSlug = 'users'
1010

1111
export const globalWithVersionsSlug = 'global-with-versions'
1212

13-
export const collectionSlugs = [pagesSlug, postsSlug, simpleSlug, simpleWithVersionsSlug, usersSlug]
13+
export const autosaveSlug = 'autosave'
14+
15+
export const autosaveGlobalSlug = 'autosave-global'
16+
17+
export const collectionSlugs = [
18+
autosaveSlug,
19+
pagesSlug,
20+
postsSlug,
21+
simpleSlug,
22+
simpleWithVersionsSlug,
23+
usersSlug,
24+
]

0 commit comments

Comments
 (0)