fix(sync): no conflicts with one's own text, one copy per conflict

- Leaving the tab sent pending text with keepalive but kept it queued;
  typing on folded it with newer keystrokes under the old base version,
  which then conflicted with the user's own text. The flushed batch now
  becomes the batch in flight.
- After a conflict, the focused field still showed the losing text and
  every further batch made another copy. Typing on goes into the one
  copy until the cursor leaves; on leaving, the field shows the block's
  current text, and entering a block bases edits on what it shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 11:30:08 +01:00
1 parent 08d35a4a81
commit c021946c7f
3 files changed
+118 -12

No files matched your search

@@ -358,6 +358,58 @@ describe('DocSyncEngine', () => {
expect(contents(engine)).toEqual(['Schreibe']);
});
it('typing on after leaving the tab causes no conflict with oneself', async () => {
const server = new FakeServer();
const engine = await startEngine(new FakeNet(server));
engine.focusBlock('b1');
edit(engine, (b) => [{ ...b[0], content: 'Start und' }]);
// Tab hidden (window switch, app switch on the phone): pending text goes out with keepalive
(engine as any).flushOnExit();
await new Promise((r) => setTimeout(r, 0));
expect(serverContents(server)).toEqual(['Start und']);
// Back in the tab, typing on in the same block
edit(engine, (b) => [{ ...b[0], content: 'Start und weiter' }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Start und weiter geht es' }]);
await engine.syncNow();
expect(engine.getState().conflicts).toEqual([]);
expect(serverContents(server)).toEqual(['Start und weiter geht es']);
});
it('typing on in a block after a conflict updates the one conflict copy', async () => {
const server = new FakeServer();
const a = await startEngine(new FakeNet(server));
const b = await startEngine(new FakeNet(server));
a.focusBlock('b1');
edit(b, (blocks) => [{ ...blocks[0], content: 'B hat geändert' }]);
await b.syncNow();
await a.syncNow(); // the focused field keeps showing A's own text
a.applyFlat([{ id: 'b1', type: 'bullet', content: 'Start + A', level: 0 }]);
await a.syncNow(); // conflict: A's text becomes a copy
// A keeps typing in the same field, which still shows A's text
a.applyFlat([{ ...flatOf(a)[0], content: 'Start + A tippt' }, flatOf(a)[1]]);
await a.syncNow();
a.applyFlat([{ ...flatOf(a)[0], content: 'Start + A tippt weiter' }, flatOf(a)[1]]);
await a.syncNow();
await b.syncNow();
expect(a.getState().conflicts).toHaveLength(1);
expect(serverContents(server)).toEqual(['B hat geändert', 'Start + A tippt weiter']);
expect(contents(b)).toEqual(['B hat geändert', 'Start + A tippt weiter']);
// Once the cursor left, the field shows B's version again: editing it changes the block itself
a.focusBlock(null);
a.focusBlock('b1');
edit(a, (blocks) => [{ ...blocks[0], content: 'B hat geändert, A ergänzt' }, blocks[1]]);
await a.syncNow();
expect(a.getState().conflicts).toHaveLength(1);
expect(serverContents(server)).toEqual(['B hat geändert, A ergänzt', 'Start + A tippt weiter']);
});
it('viewers cannot change anything', async () => {
const server = new FakeServer();
const viewer = await startEngine(new FakeNet(server, 'viewer'));