From fbc404fea1ed48d0db8bfa23843baf58458d1e41 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 21:13:02 +0100 Subject: [PATCH 1/2] fix(notes): no self-conflicts when typing in a rhythm 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 --- CHANGELOG.md | 3 +++ .../src/lib/docSync/__tests__/engine.test.ts | 23 +++++++++++++++++++ frontend/src/lib/docSync/engine.ts | 5 +++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07beef6..dbf433f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [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 ### Added diff --git a/frontend/src/lib/docSync/__tests__/engine.test.ts b/frontend/src/lib/docSync/__tests__/engine.test.ts index 201667a..d69d83d 100644 --- a/frontend/src/lib/docSync/__tests__/engine.test.ts +++ b/frontend/src/lib/docSync/__tests__/engine.test.ts @@ -335,6 +335,29 @@ describe('DocSyncEngine', () => { 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 () => { const server = new FakeServer(); const viewer = await startEngine(new FakeNet(server, 'viewer')); diff --git a/frontend/src/lib/docSync/engine.ts b/frontend/src/lib/docSync/engine.ts index e3168e1..02dbc78 100644 --- a/frontend/src/lib/docSync/engine.ts +++ b/frontend/src/lib/docSync/engine.ts @@ -462,7 +462,10 @@ export class DocSyncEngine { private recompute(): void { 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 }; if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0; if (local.kind === 'set_title') local.baseVersion = v.titleVersion; From 6a5e82431f6b7391b0400685b98c181a72a3bd7e Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 21:20:00 +0100 Subject: [PATCH 2/2] chore(release): v0.6.1 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf433f..17ef0b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.6.1] - 2026-10-29 + ### 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. @@ -44,5 +46,6 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.1...dev +[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1 [0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e083d0c..1f01d4b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.6.0", + "version": "0.6.1", "license": "Unlicense", "dependencies": { "@types/prismjs": "^1.26.6", diff --git a/frontend/package.json b/frontend/package.json index f61af61..bbbc3c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.6.0", + "version": "0.6.1", "license": "Unlicense", "type": "module", "scripts": {