fix: never write from a read another editor's row landed during

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 19:01:44 +02:00
co-authored by Claude Opus 5
parent 03b62f46db
commit 47afd85bcc
2 changed files with 35 additions and 1 deletions
+8 -1
View File
@@ -283,6 +283,9 @@ class Entry<V> {
reconcile(): void {
const key = this.key
if (this.retired || !this.loaded || this.origin === 'new' || isTemporaryPath(key.path)) return
// Known to be behind the server: writing from here would put that back over whatever is
// actually there. A reload is what clears it.
if (this.stale) return
const desired = this.dirty ? (serialize(this.value) ?? null) : null
this.ports.hint(key, desired !== null)
if (desired === this.row) return
@@ -411,6 +414,11 @@ class Entry<V> {
// nothing can send it, so a read nobody asked for must not put the server's value in
// its place, nor answer the conflict on the user's behalf. Checked here rather than
// before the read, because the flush that precedes one is itself a way to find out.
// A row was handed over while this read was out, so its response predates it. The value
// is still shown, for want of a better one, but writing from it would post it back
// over that row on the baseline the row advanced: the newer write lost to an older
// read. Nothing goes out until a reload settles which is which.
this.stale = this.ports.rowMark(key) !== rowsAtRead
const standOff = unasked && this.ports.conflicted(key)
// Whatever landed since the read was asked for — an external write, an edit — is
// newer than what it read, so the read only moves the deployed side under it.
@@ -418,7 +426,6 @@ class Entry<V> {
standOff || this.edits !== at.edits || this.externals !== at.externals
this.meta = res.meta
this.error = undefined
this.stale = false
if (isTemporaryPath(key.path)) {
this.template = snapshot(res.template)
this.origin = 'new'
+27
View File
@@ -843,6 +843,33 @@ describe('item store: origins', () => {
expect(item.value?.description).toBe('typed during the deploy')
})
it('does not post a read back over a row another editor landed during it', async () => {
const rows = fakeRows()
const read = deferred<ItemLoad<Res>>()
const store = createItemStore(rows.port)
const older = { ...deployedRes, description: 'draft A, as the GET found it' }
const { handle: item } = store.acquire(
{ workspace: 'w', kind: 'resource', path: 'u/me/r' },
{ workspace: 'w', path: 'u/me/r' },
adapter(() => read.promise)
)
await settle()
// Another editor of the same item saves draft B through the syncer directly and it lands,
// leaving nothing pending. Only the row mark records that it happened.
rows.handExternally('u/me/r')
const writesBefore = rows.writes.length
read.resolve({ deployed: deployedRes, draft: older, draftSavedAt: 'T0' })
await settle()
// Posting A now would overwrite B, on the baseline B itself advanced.
expect(rows.writes.slice(writesBefore)).toEqual([])
expect(rows.seeds).toEqual([])
// And nothing may be written from this view until it is reloaded.
expect(item.canSave).toBe(false)
item.value = { ...deployedRes, description: 'typed on a stale view' }
expect(rows.writes.slice(writesBefore)).toEqual([])
})
it('tells a caller its post-deploy re-read failed, apart from never having the item', async () => {
const rows = fakeRows()
const store = createItemStore(rows.port)