fix(server-storage): take over a reload's writes once the previous page load stops looking live

When the close flag could not be saved (storage full), a reload took the
previous page load for a running duplicated tab and left its saved writes
unsent. The page now remembers that page load and its 30 s touch takes it
over as soon as its record is closed, stale or gone, so the session's
retry sends the writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 00:12:40 +02:00
1 parent 37c14d7d89
commit d45210c4f7
3 files changed
+77 -21

No files matched your search

+3 -1
View File
@@ -230,7 +230,9 @@ settings and guards read them in every build.
is saved, at the latest with this page's next saved copy). A page marks
its record closed on `pagehide` (not into the back-forward cache), and
only a closed, stale or missing record is taken over: a duplicated tab,
which copies `sessionStorage`, leaves the running tab its writes; the writes of a tab closed before it
which copies `sessionStorage`, leaves the running tab its writes. A page
load that still looked live (or whose close flag storage refused) is
checked again every 30 s and taken over once it does not; the writes of a tab closed before it
could send them are queued, ahead of its own, by the next re-read in
another tab once that tab's record has gone stale; the result replays the
closed tab's edits first and this tab's newer ones last. Tabs never clear each other's.
@@ -60,6 +60,8 @@ export class ServerUserDataStorage implements UserDataStorage {
*/
private readonly deferred = new Map<string, DeferredEdit>();
private hydrated = false;
/** The previous page load in this tab, while it still looks live. */
private awaited: string | null = null;
/** Closed tabs' page loads this one took over (once each). */
private readonly adopted = new Set<string>();
/** Page loads taken over whose copy stays until this one saves. */
@@ -99,11 +101,11 @@ export class ServerUserDataStorage implements UserDataStorage {
writeFailed: onWriteFailure,
});
claimCache(local, instanceId);
const previous = swapPage(tabStore, instanceId, this.tabId);
// A duplicated tab copies the page load of a tab that still runs.
if (previous && !pageIsLive(local, instanceId, previous)) {
this.takeOver(previous);
}
// A duplicated tab copies the page load of a tab that still runs;
// so does a reload whose close flag could not be saved: `touch`
// takes it over once it no longer looks live.
this.awaited = swapPage(tabStore, instanceId, this.tabId);
this.touch();
}
/** The page unloads: a reload of this tab may take its writes over. */
@@ -183,6 +185,11 @@ export class ServerUserDataStorage implements UserDataStorage {
/** Tells other tabs this one is alive and still sending its writes. */
touch(): void {
const page = this.awaited;
if (page && !pageIsLive(this.local, this.instanceId, page)) {
this.awaited = null;
this.takeOver(page);
}
if (this.hasUnsentWrites()) {
this.publishUnconfirmed();
}
@@ -208,30 +215,27 @@ export class ServerUserDataStorage implements UserDataStorage {
}
setItem(key: string, value: string): void {
if (isServerUserStateNamespace(key)) {
this.write(key, value);
} else {
this.local.setItem(key, value);
}
this.write(key, value);
}
removeItem(key: string): void {
if (isServerUserStateNamespace(key)) {
this.write(key, null);
} else {
this.local.removeItem(key);
}
this.write(key, null);
}
/**
* A caller's new map. When the cache changed since the caller read it
* (another tab, a re-read), only the entries it changed are applied to
* the cache as it is now; the rest stays.
* the cache as it is now; the rest stays. Other keys use plain storage.
*/
private write(
namespace: ServerUserStateNamespace,
value: string | null
): void {
private write(namespace: string, value: string | null): void {
if (!isServerUserStateNamespace(namespace)) {
if (value === null) {
this.local.removeItem(namespace);
} else {
this.local.setItem(namespace, value);
}
return;
}
const current = this.cache.read(namespace);
const before = parseMap(current ?? '{}');
const read = this.handedOut.has(namespace)
@@ -1017,4 +1017,54 @@ describe('ServerUserDataStorage with other devices', () => {
)
).toHaveLength(1);
});
it('takes over a reload whose close flag was lost once it goes quiet', async () => {
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
const local = memoryStorage();
const tabStore = memoryStorage();
const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000);
const before = new ServerUserDataStorage(
{
...server.api,
sendJson: jest
.fn()
.mockRejectedValue(
new ServerApiError(0, 'network', 'offline')
),
} as never,
local,
jest.fn(),
'default',
tabStore
);
await before.hydrate();
before.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
await before.flush();
// Storage is full when the page unloads: the close flag is lost.
const setItem = local.setItem;
local.setItem = () => {
throw new DOMException('full', 'QuotaExceededError');
};
before.close();
local.setItem = setItem;
const reloaded = new ServerUserDataStorage(
server.api as never,
local,
jest.fn(),
'default',
tabStore
);
// It looks like a duplicated tab of a running one: nothing taken.
expect(reloaded.hasUnsentWrites()).toBe(false);
clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1);
reloaded.touch();
expect(reloaded.hasUnsentWrites()).toBe(true);
await reloaded.hydrate();
await reloaded.flush();
clock.mockRestore();
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
});
});