From dd143c8aca98022e59ba583880cc68cab9786fa5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 20:50:11 +0200 Subject: [PATCH] fix(server-storage): a re-read keeps writes another tab had unconfirmed when it went out A re-read checked other tabs' unconfirmed entries only after its answer arrived. A tab whose write was unconfirmed when the read went out (its edit already in the snapshot) and confirmed before the old answer came back matched neither check, so the stale answer replaced its saved value in the shared cache; that tab's next edit, holding the newer tag, then wrote the stale list and deleted the item on the server. The entries unconfirmed when the read is sent are kept too. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 5 +- .../server-user-data-adopt.spec.ts | 70 +++++++++++++++++++ .../server-user-data-storage.ts | 11 ++- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 5936f001a..69b3ee581 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -272,8 +272,9 @@ settings and guards read them in every build. playlist's lists (`UserDataStorage.flushed`); a refused one stays parked. Replayed edits keep recently viewed lists newest first, as the app cuts them at their limit from the end. Also, re-reads run one at a time and keep entries edited while they were in - flight or not yet saved, and entries another tab saved meanwhile (the - answer predates them; their next write here conflicts and replays). A response body cut off after its headers counts + flight or not yet saved, and entries another tab saved meanwhile or had + unconfirmed when the read went out (the answer predates them; their next + write here conflicts and replays). A response body cut off after its headers counts as offline. Each write is sent on the tag of the entry it was made against (`UserDataWriteQueue`); after a `412` the edit (before → after) is replayed on the server's current value, so another device's change to the diff --git a/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts b/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts index 26269874d..ada385f6d 100644 --- a/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-adopt.spec.ts @@ -397,4 +397,74 @@ describe('ServerUserDataStorage taking over other page loads', () => { expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); }); + + it('keeps a write another tab confirmed while this read travelled', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + let land = (): void => undefined; + const a = new ServerUserDataStorage( + { + ...server.api, + // Tab A's write is still on its way when B asks. + sendJson: jest.fn( + (...args: Parameters) => + new Promise((resolve) => { + land = () => resolve(send(...args)); + }) + ), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + await a.hydrate(); + a.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + const sending = a.flush(); + let answer = (): void => undefined; + const b = new ServerUserDataStorage( + { + ...server.api, + getJson: jest.fn(() => { + const old = server.api.getJson(); + return new Promise((resolve) => { + answer = () => resolve(old); + }); + }), + } as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const reading = b.hydrate(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + // A's write lands (its record is cleared), then B's old answer. + land(); + await sending; + answer(); + await reading; + + // Tab A removes S from the list as it reads it now. + const read = JSON.parse(a.getItem('xtream-favorites') ?? '{}'); + a.setItem( + 'xtream-favorites', + JSON.stringify({ p1: read.p1.filter((id: string) => id !== 'S') }) + ); + land = () => undefined; + const removing = a.flush(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + land(); + await removing; + + expect(server.state['xtream-favorites']['p1']).toEqual(['X']); + }); }); diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.ts b/libs/services/src/lib/server-storage/server-user-data-storage.ts index e4844dc49..1e4481b04 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.ts @@ -341,15 +341,20 @@ export class ServerUserDataStorage implements UserDataStorage { const deletesBefore = this.deleted.length; await this.writes.flush(); const sent = this.cache.snapshot(); + const foreign = () => + othersUnconfirmed(this.local, this.instanceId, this.tabId); + const foreignBefore = foreign(); const { entries, tags } = withoutDeleted( await this.api.getJson('/user-state'), this.deleted.slice(deletesBefore) ); const unsaved = this.writes.unsavedSince(editsBefore); - // In the shared cache: other tabs' unconfirmed writes, and what a - // tab saved while this read travelled (newer than its answer). + // In the shared cache: other tabs' writes unconfirmed when this read + // went out or now (one confirmed meanwhile is newer than its answer), + // and what a tab saved while it travelled. for (const key of [ - ...othersUnconfirmed(this.local, this.instanceId, this.tabId), + ...foreignBefore, + ...foreign(), ...this.cache.changedSince(sent), ]) { unsaved.add(key);