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);