fix(server-storage): a closed page record stays closed when a late write rewrites it

On pagehide the page flushes with keepalive and then marks its record
closed. When writes were held (offline), the keepalive write fails at
once and its requeue saved the journal again a microtask later, which
rewrote the record without `closed`: the reload took the previous page
load for live and left its favorites and progress unsent until the
record aged out (ten minutes). markUnconfirmed now keeps an existing
record's closed flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 15:58:08 +02:00
1 parent 37fac60d92
commit 5a90fd6c30
3 files changed
+56 -3

No files matched your search

+2 -1
View File
@@ -235,7 +235,8 @@ settings and guards read them in every build.
tab remembers its page load in `sessionStorage`, so a reload takes its
writes over and sends them first (the old copy goes only once the new one
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, where
its record closed on `pagehide`, and it stays closed when a page-hide
write failing afterwards rewrites it (not into the back-forward cache, where
its record goes stale, as in a background tab the browser suspends;
restored or shown again after another tab adopted it, the page reloads
rather than send those writes again, and until then it neither sends nor
@@ -406,7 +406,9 @@ export function pageIsLive(
/**
* Writes only this page load's record (none without keys); true when one
* now stands for it.
* now stands for it. A closed record stays closed: a page-hide write that
* fails after `pagehide` rewrites it, and the reload must still see a page
* that unloaded, not one that seems to run.
*/
export function markUnconfirmed(
local: UserDataStorage,
@@ -420,9 +422,14 @@ export function markUnconfirmed(
local.removeItem(recordKey(instanceId, tabId));
return false;
}
const closed = readRecord(local, instanceId, tabId)?.closed === true;
local.setItem(
recordKey(instanceId, tabId),
JSON.stringify({ at: now, keys: [...keys] })
JSON.stringify({
at: now,
keys: [...keys],
...(closed ? { closed } : {}),
})
);
return true;
} catch {
@@ -352,4 +352,49 @@ describe('ServerUserDataStorage taking over other page loads', () => {
await expect(tab.flushed()).resolves.toBe(false);
expect(server.state['xtream-favorites']['p1']).toEqual(['live:1']);
});
it('stays closed when a page-hide write fails after pagehide', async () => {
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
const local = memoryStorage();
const tabStore = memoryStorage();
const page = new ServerUserDataStorage(
{
...server.api,
// Offline: the page-hide write fails at once.
sendJson: jest.fn(() =>
Promise.reject(new ServerApiError(0, 'network', 'offline'))
),
sendKeepalive: jest.fn(async () => ({
status: 'failed' as const,
})),
} as never,
local,
jest.fn(),
'default',
tabStore
);
await page.hydrate();
page.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
// pagehide: flush, then close; the failure is answered after.
page.flushWithKeepalive();
page.close();
for (let turn = 0; turn < 10; turn += 1) {
await Promise.resolve();
}
// The reload takes its writes over at once, not after ten minutes.
const reloaded = new ServerUserDataStorage(
server.api as never,
local,
jest.fn(),
'default',
tabStore
);
await reloaded.hydrate();
await reloaded.flush();
await reloaded.flush();
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
});
});