fix(server-storage): forget the token for another server, listen before sign-in, adopt only stale records

- A server that answers as another backend URL or instance id than the
  remembered one makes the browser forget the stored token, so server A's
  token never reaches server B.
- The session installs its page-hide and heartbeat listeners before it
  returns for sign-in, so a page holding another page load's writes still
  marks itself closed on unload.
- Closed tabs are found by stale records only again: saved writes without
  a record may belong to a running tab whose record storage refused, and
  are left to that tab's own reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 20:08:33 +02:00
1 parent cf715a7573
commit 4908803ceb
7 files changed
+94 -41

No files matched your search

@@ -91,6 +91,16 @@ describe('ServerStorageSession', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it('marks the page closed on page hide also while sign-in is pending', async () => {
const { session } = setup('token');
const close = jest.spyOn(session.userData, 'close');
await session.start();
window.dispatchEvent(new Event('pagehide'));
expect(close).toHaveBeenCalled();
});
it('starts with a stored token', async () => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't');
const { session, status } = setup('token');
@@ -76,6 +76,9 @@ export class ServerStorageSession {
async start(): Promise<void> {
this.backend.useServerStore(this.documentStore);
this.status.activate(this.config.instance, () => void this.refresh());
// Also while sign-in is pending: this page may already hold another
// page load's writes, so it must mark itself closed on page hide.
this.listen();
if (this.config.instance.auth === 'token' && !readServerAccessToken()) {
this.status.reportUnauthorized();
return;
@@ -86,7 +89,6 @@ export class ServerStorageSession {
setTimeout(resolve, STARTUP_HYDRATE_WAIT_MS)
),
]);
this.listen();
}
/** Re-reads synced user data; also the offline retry. */
@@ -120,8 +120,9 @@ export function swapPage(
}
/**
* Page loads with saved writes or a record but no fresh record: closed
* tabs nobody sends for (also when their record was lost).
* Page loads whose record went stale: closed tabs nobody sends for. Saved
* writes without any record are left alone: a tab that could save them but
* not its record may still run, and only its own reload takes them over.
*/
export function orphanedPages(
local: UserDataStorage,
@@ -129,20 +130,13 @@ export function orphanedPages(
page: string,
now = Date.now()
): string[] {
const registry = readRegistry(local, instanceId);
const pages = new Set<string>();
for (const prefix of PAGE_PREFIXES) {
for (const owner of pagesUnder(local, `${prefix}:${instanceId}:`)) {
pages.add(owner);
}
}
return [...pages].filter((owner) => {
const record = registry[owner];
return (
owner !== page &&
!(record && now - Number(record.at) < UNCONFIRMED_TTL_MS)
);
});
return Object.entries(readRegistry(local, instanceId))
.filter(
([owner, record]) =>
owner !== page &&
!(now - Number(record.at) < UNCONFIRMED_TTL_MS)
)
.map(([owner]) => owner);
}
/** Forgets a page load's saved writes, early edits and record. */
@@ -1284,43 +1284,54 @@ describe('ServerUserDataStorage with other devices', () => {
);
});
it("sends a closed tab's saved writes whose record was lost", async () => {
it('leaves saved writes without a record to the tab that saved them', async () => {
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
const shared = memoryStorage();
const closed = new ServerUserDataStorage(
{
...server.api,
sendJson: jest
.fn()
.mockRejectedValue(
new ServerApiError(0, 'network', 'offline')
),
} as never,
const tabStore = memoryStorage();
const offline = {
...server.api,
sendJson: jest
.fn()
.mockRejectedValue(new ServerApiError(0, 'network', 'offline')),
};
const open = new ServerUserDataStorage(
offline as never,
shared,
jest.fn(),
'default',
memoryStorage()
tabStore
);
await closed.hydrate();
closed.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
await closed.flush();
// Another tab's save once replaced the shared record: now it is gone.
await open.hydrate();
open.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
await open.flush();
// Storage refused the record but kept the saved writes.
for (const key of [...shared.values.keys()]) {
if (key.startsWith('iptvnator-server:unconfirmed:')) {
shared.removeItem(key);
}
}
const next = new ServerUserDataStorage(
const other = new ServerUserDataStorage(
server.api as never,
shared,
jest.fn(),
'default',
memoryStorage()
);
await next.hydrate();
await next.flush();
await other.hydrate();
await other.flush();
expect(server.state['xtream-favorites']['p1']).toEqual(['S']);
// The tab's own reload takes them over.
const reloaded = new ServerUserDataStorage(
server.api as never,
shared,
jest.fn(),
'default',
tabStore
);
await reloaded.hydrate();
await reloaded.flush();
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
});
});