Skip to content

Commit dc98f0f

Browse files
authored
fix(ui): stale data modal shown when onChange starts while save is in-flight (#15960)
# 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` (incremented in `onSubmit`) and `isSavingRef` (set in `onSubmit`, cleared in `onSave`) to the Edit view - Both are captured at the start of each `onChange` and used to suppress the stale data modal when the newer `updatedAt` the server sees came from our own save - Added CPU throttling to an existing lexical e2e test to reliably reproduce the race in CI environments <img width="1790" height="1198" alt="Screenshot 2026-03-16 at 9 32 31 AM" src="https://github.com/user-attachments/assets/d95fffb0-fa5f-44aa-98ac-41a8564daff3" /> ## Design Decisions `saveCounterRef` handles form-state requests that start before `onSubmit` — the counter advances mid-flight, so `saveCounterAtStart` diverges and the modal is suppressed. `isSavingRef` handles the complementary case where a queued `onChange` starts after `onSubmit` (so the counter already matches) but before `onSave` has updated `originalUpdatedAtRef` with the new timestamp. Capturing it before the async call means the guard fires correctly even if the save completes before the response arrives. The two-user scenario is unaffected — both refs are per-instance, so another user's save doesn't influence the local state and the modal still appears correctly.
1 parent da212fd commit dc98f0f

File tree

4 files changed

+108
-68
lines changed

4 files changed

+108
-68
lines changed

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export function DefaultEditView({
186186
const hasCheckedForStaleDataRef = useRef(false)
187187
const originalUpdatedAtRef = useRef(data?.updatedAt)
188188
const saveCounterRef = useRef(0)
189+
const isSavingRef = useRef(false)
189190

190191
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
191192
const isLockExpired = Date.now() > lockExpiryTime
@@ -316,6 +317,7 @@ export function DefaultEditView({
316317
// This allows detecting if another user modifies the document after this save
317318
originalUpdatedAtRef.current = updatedAt
318319
hasCheckedForStaleDataRef.current = false
320+
isSavingRef.current = false
319321

320322
if (context?.incrementVersionCount !== false) {
321323
incrementVersionCount()
@@ -467,9 +469,10 @@ export function DefaultEditView({
467469
async ({ formState: prevFormState, submitted }) => {
468470
const controller = handleAbortRef(abortOnChangeRef)
469471

470-
// Capture save counter before the async form-state request so we can detect
472+
// Capture save state before the async form-state request so we can detect
471473
// if a save was triggered while this request was in-flight
472474
const saveCounterAtStart = saveCounterRef.current
475+
const isSavingAtStart = isSavingRef.current
473476

474477
// Sync originalUpdatedAt with current data if it's NEWER (e.g., after router.refresh())
475478
if (data?.updatedAt && data.updatedAt > originalUpdatedAtRef.current) {
@@ -531,9 +534,13 @@ export function DefaultEditView({
531534
}
532535

533536
// 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) {
537+
// Skip if a save was in-flight when this request started, or if the save counter
538+
// has advanced — either way the newer updatedAt is from our OWN save.
539+
if (
540+
staleDataState?.isStale &&
541+
!isSavingAtStart &&
542+
saveCounterRef.current === saveCounterAtStart
543+
) {
537544
setShowStaleDataModal(true)
538545
}
539546

@@ -627,6 +634,7 @@ export function DefaultEditView({
627634
onChange={[onChange]}
628635
onSubmit={() => {
629636
saveCounterRef.current += 1
637+
isSavingRef.current = true
630638
}}
631639
onSuccess={onSave}
632640
>

test/lexical/collections/Lexical/e2e/main/e2e.spec.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,46 @@ describe('lexicalMain', () => {
166166
await expect(page.locator('.leave-without-saving__content').first()).toBeHidden()
167167
})
168168

169+
test('should not show stale data modal after saving a lexical document with blocks (race condition)', async () => {
170+
// CPU throttling widens the race window enough to reproduce reliably:
171+
// the large block-based form state takes longer to process, so a queued
172+
// onChange can start after onSubmit (isSaving=true) but before onSave
173+
// updates originalUpdatedAtRef — causing the server to return isStale=true
174+
// from our own save.
175+
const client = await page.context().newCDPSession(page)
176+
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 })
177+
178+
try {
179+
await navigateToLexicalFields()
180+
await expect(
181+
page.locator('.rich-text-lexical').nth(2).locator('.LexicalEditorTheme__block'),
182+
).toHaveCount(10)
183+
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
184+
185+
const thirdBlock = page
186+
.locator('.rich-text-lexical')
187+
.nth(2)
188+
.locator('.LexicalEditorTheme__block')
189+
.nth(2)
190+
await thirdBlock.scrollIntoViewIfNeeded()
191+
192+
const spanInBlock = thirdBlock
193+
.locator('span')
194+
.getByText('Some text below relationship node 1')
195+
.first()
196+
await spanInBlock.scrollIntoViewIfNeeded()
197+
await spanInBlock.click()
198+
199+
await page.keyboard.type('moretext')
200+
await saveDocAndAssert(page)
201+
202+
await expect(page.locator('.payload__modal-container')).toBeHidden()
203+
} finally {
204+
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 })
205+
await client.detach()
206+
}
207+
})
208+
169209
test('should type and save typed text', async () => {
170210
await navigateToLexicalFields()
171211
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
@@ -456,8 +496,8 @@ describe('lexicalMain', () => {
456496
const popoverOption3BoundingBox = await popoverOption3.boundingBox()
457497
expect(popoverOption3BoundingBox).not.toBeNull()
458498
expect(popoverOption3BoundingBox).not.toBeUndefined()
459-
expect(popoverOption3BoundingBox.height).toBeGreaterThan(0)
460-
expect(popoverOption3BoundingBox.width).toBeGreaterThan(0)
499+
expect(popoverOption3BoundingBox?.height).toBeGreaterThan(0)
500+
expect(popoverOption3BoundingBox?.width).toBeGreaterThan(0)
461501

462502
// Now click the button to see if it actually works. Simulate an actual mouse click instead of using .click()
463503
// by using page.mouse and the correct coordinates
@@ -466,8 +506,8 @@ describe('lexicalMain', () => {
466506
// This is why we use page.mouse.click() here. It's the most effective way of detecting such a z-index issue
467507
// and usually the only method which works.
468508

469-
const x = popoverOption3BoundingBox.x
470-
const y = popoverOption3BoundingBox.y
509+
const x = popoverOption3BoundingBox?.x ?? 0
510+
const y = popoverOption3BoundingBox?.y ?? 0
471511

472512
await page.mouse.click(x, y, { button: 'left' })
473513
}).toPass({
@@ -1566,7 +1606,6 @@ describe('lexicalMain', () => {
15661606

15671607
const relationshipInput = page.locator('.drawer__content .rs__input').first()
15681608
await expect(relationshipInput).toBeVisible()
1569-
page.getByRole('heading', { name: 'Lexical Fields' })
15701609
await relationshipInput.click()
15711610
const user = page.getByRole('option', { name: 'User' })
15721611
await user.click()
@@ -1576,10 +1615,8 @@ describe('lexicalMain', () => {
15761615
.filter({ hasText: /^User$/ })
15771616
.first()
15781617
await expect(userListDrawer).toBeVisible()
1579-
page.getByRole('heading', { name: 'Users' })
15801618
const button = page.getByLabel('Add new User')
15811619
await button.click()
1582-
page.getByText('Creating new User')
15831620
})
15841621

15851622
test('ensure custom Description component is rendered only once', async () => {

test/lexical/payload-types.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,7 @@ export interface NestedBlock {
199199
* via the `definition` "blockWithBlockRef".
200200
*/
201201
export interface BlockWithBlockRef {
202-
nestedBlocks?:
203-
| {
204-
text?: string | null;
205-
id?: string | null;
206-
blockName?: string | null;
207-
blockType: 'nestedBlock';
208-
}[]
209-
| null;
202+
nestedBlocks?: NestedBlock[] | null;
210203
id?: string | null;
211204
blockName?: string | null;
212205
blockType: 'blockWithBlockRef';

test/locked-documents/e2e.spec.ts

Lines changed: 51 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1757,37 +1757,38 @@ describe('Locked Documents', () => {
17571757
const client = await page.context().newCDPSession(page)
17581758
await client.send('Emulation.setCPUThrottlingRate', { rate: 50 })
17591759

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)
1760+
try {
1761+
const fieldA = page.locator('#field-fieldA')
1762+
const modalContainer = page.locator('.payload__modal-container')
17731763

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()
1764+
// Make many rapid edits to create multiple queued autosaves
1765+
for (let i = 1; i <= 10; i++) {
1766+
await fieldA.fill(`Edit ${i}`)
1767+
// eslint-disable-next-line payload/no-wait-function
1768+
await wait(30)
1769+
}
17811770

1782-
// Clean up
1783-
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 })
1784-
await client.detach()
1771+
// Wait for all autosaves to process
1772+
// eslint-disable-next-line payload/no-wait-function
1773+
await wait(2000)
17851774

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-
})
1775+
// Make one more edit to trigger stale data check
1776+
await fieldA.fill('Final Edit')
1777+
// eslint-disable-next-line payload/no-wait-function
1778+
await wait(500)
1779+
1780+
// Modal should NOT appear because it's the same user
1781+
await expect(modalContainer).toBeHidden()
1782+
} finally {
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+
}
17911792
}
17921793
})
17931794

@@ -2180,31 +2181,32 @@ describe('Locked Documents', () => {
21802181
const client = await page.context().newCDPSession(page)
21812182
await client.send('Emulation.setCPUThrottlingRate', { rate: 50 })
21822183

2183-
const textField = page.locator('#field-text')
2184-
const modalContainer = page.locator('.payload__modal-container')
2184+
try {
2185+
const textField = page.locator('#field-text')
2186+
const modalContainer = page.locator('.payload__modal-container')
21852187

2186-
// Make many rapid edits to create multiple queued autosaves
2187-
for (let i = 1; i <= 10; i++) {
2188-
await textField.fill(`Edit ${i}`)
2189-
// eslint-disable-next-line payload/no-wait-function
2190-
await wait(30)
2191-
}
2192-
2193-
// Wait for all autosaves to process
2194-
// eslint-disable-next-line payload/no-wait-function
2195-
await wait(2000)
2188+
// Make many rapid edits to create multiple queued autosaves
2189+
for (let i = 1; i <= 10; i++) {
2190+
await textField.fill(`Edit ${i}`)
2191+
// eslint-disable-next-line payload/no-wait-function
2192+
await wait(30)
2193+
}
21962194

2197-
// Make one more edit to trigger stale data check
2198-
await textField.fill('Final Edit')
2199-
// eslint-disable-next-line payload/no-wait-function
2200-
await wait(500)
2195+
// Wait for all autosaves to process
2196+
// eslint-disable-next-line payload/no-wait-function
2197+
await wait(2000)
22012198

2202-
// Modal should NOT appear because stale check is disabled for autosave-enabled globals
2203-
await expect(modalContainer).toBeHidden()
2199+
// Make one more edit to trigger stale data check
2200+
await textField.fill('Final Edit')
2201+
// eslint-disable-next-line payload/no-wait-function
2202+
await wait(500)
22042203

2205-
// Clean up
2206-
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 })
2207-
await client.detach()
2204+
// Modal should NOT appear because stale check is disabled for autosave-enabled globals
2205+
await expect(modalContainer).toBeHidden()
2206+
} finally {
2207+
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 })
2208+
await client.detach()
2209+
}
22082210
})
22092211
})
22102212
})

0 commit comments

Comments
 (0)