fix(notes): no self-conflicts when typing in a rhythm
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m51s

Several unsent text changes of one block were applied one by one to the
local view, numbering local versions ahead of the server, which folds them
into one change (formBatch). The focused field kept the inflated version as
base, so the next edit after a sync conflicted with the user's own text.
The local view now applies pending ops as they will be sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 21:13:02 +01:00
1 parent b55334b163
commit fbc404fea1
3 files changed
+30 -1

No files matched your search

+3
View File
@@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
### Fixed
- Notizen: Wer im Rhythmus tippte (kurze Pausen zwischen den Wörtern), bekam Konfliktkopien gegen sich selbst, auch in nicht geteilten Notizen. Mehrere ungesendete Änderungen an einem Punkt zählten lokal als mehrere Versionen, beim Server aber als eine.
## [0.6.0] - 2026-10-29 ## [0.6.0] - 2026-10-29
### Added ### Added
@@ -335,6 +335,29 @@ describe('DocSyncEngine', () => {
expect(contents(a)).toEqual(expect.arrayContaining(['B hat geändert', 'Start + A'])); expect(contents(a)).toEqual(expect.arrayContaining(['B hat geändert', 'Start + A']));
}); });
it('typing in a rhythm (several edits per sync) causes no conflict with oneself', async () => {
const server = new FakeServer();
const net = new FakeNet(server);
const engine = await startEngine(net);
engine.focusBlock('b1');
// Pauses longer than the field's debounce but shorter than the sync debounce: several edits per sync
for (const text of ['S', 'Sc', 'Sch']) edit(engine, (b) => [{ ...b[0], content: text }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Schr' }]);
edit(engine, (b) => [{ ...b[0], content: 'Schre' }]);
// More typing while the request is on the wire
net.duringRequest = () => edit(engine, (b) => [{ ...b[0], content: 'Schrei' }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Schreib' }]);
await engine.syncNow();
edit(engine, (b) => [{ ...b[0], content: 'Schreibe' }]);
await engine.syncNow();
expect(engine.getState().conflicts).toEqual([]);
expect(serverContents(server)).toEqual(['Schreibe']);
expect(contents(engine)).toEqual(['Schreibe']);
});
it('viewers cannot change anything', async () => { it('viewers cannot change anything', async () => {
const server = new FakeServer(); const server = new FakeServer();
const viewer = await startEngine(new FakeNet(server, 'viewer')); const viewer = await startEngine(new FakeNet(server, 'viewer'));
+4 -1
View File
@@ -462,7 +462,10 @@ export class DocSyncEngine {
private recompute(): void { private recompute(): void {
const v = cloneDoc(this.confirmed); const v = cloneDoc(this.confirmed);
for (const op of [...(this.inflight || []), ...this.pending]) { // Pending ops count the way they will be sent: formBatch folds all text changes of a block into one,
// so the server raises its version once. Applying every keystroke here would number local versions
// ahead of the server, and the next edit of the focused block would conflict with the user's own text.
for (const op of [...(this.inflight || []), ...formBatch(this.pending)]) {
const local = { ...op }; const local = { ...op };
if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0; if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0;
if (local.kind === 'set_title') local.baseVersion = v.titleVersion; if (local.kind === 'set_title') local.baseVersion = v.titleVersion;