fix(offline): a tab waits for the flush lock instead of skipping its flush

The cross-tab lock was taken with ifAvailable, so a tab that found another
tab replaying returned from flush() at once. Its callers read that as "the
queue is sent": the online trigger then re-seeds every trip from the server
and rehydrates the open ones, and the socket refetches after its pre-reconnect
flush. All tabs see the online event together, so the tabs that lost the race
laid the server's rows from before the replay over the shared Dexie, and over
the optimistic edits still on their way, while the winning tab was mid-flush.

The lock is now requested without ifAvailable. A tab that finds it held waits,
then runs its own pass, which finds nothing or what the holder left behind,
so flush() resolves only after the replay and the re-seed chained onto it
reads the state after it. The holder's requests are bounded by the client
timeout, so the wait is too.
This commit is contained in:
Maurice
2026-10-08 11:38:05 +02:00
parent f5339307b2
commit e132d1529f
3 changed files with 58 additions and 35 deletions
+45 -17
View File
@@ -716,42 +716,70 @@ describe('mutationQueue: writes to one entity keep their order around a parked o
});
describe('mutationQueue.flush: one tab at a time', () => {
/** A Web Locks stand-in: one holder per name, `ifAvailable` answers null when taken. */
/** A Web Locks stand-in: one holder per name, later requests wait their turn. */
function fakeLocks() {
const held = new Set<string>();
const names: string[] = [];
const request = vi.fn(async (name: string, _opts: { ifAvailable?: boolean }, cb: (lock: { name: string } | null) => unknown) => {
names.push(name);
if (held.has(name)) return cb(null);
held.add(name);
try { return await cb({ name }); } finally { held.delete(name); }
const tails = new Map<string, Promise<unknown>>();
const request = vi.fn((name: string, cb: (lock: { name: string }) => unknown) => {
const run = (tails.get(name) ?? Promise.resolve()).then(() => cb({ name }));
tails.set(name, run.catch(() => {}));
return run;
});
Object.defineProperty(navigator, 'locks', { value: { request }, configurable: true });
return { held, names, request };
/** Another tab takes the lock and keeps it until the returned function runs. */
const holdElsewhere = () => {
let release = () => {};
void request(`trek-mutation-flush:${offlineDb.name}`, () => new Promise<void>(r => { release = r; }));
return () => release();
};
return { request, holdElsewhere };
}
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
afterEach(() => {
Reflect.deleteProperty(navigator, 'locks');
});
it('leaves the queue to the tab that holds the lock', async () => {
it('waits for the tab that holds the lock, so what the caller does next sees its replay', async () => {
const locks = fakeLocks();
locks.held.add(`trek-mutation-flush:${offlineDb.name}`);
const releaseOther = locks.holdElsewhere();
const id = generateUUID();
await mutationQueue.enqueue(makeMutation({ id }));
let sent = 0;
server.use(http.post('/api/trips/1/places', () => { sent++; return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 61 }) }); }));
await mutationQueue.flush();
let done = false;
const flushed = mutationQueue.flush().then(() => { done = true; });
await settle();
// Still waiting: a caller chaining a re-seed onto this flush must not run yet.
expect(done).toBe(false);
expect(sent).toBe(0);
expect(await offlineDb.mutationQueue.get(id)).toMatchObject({ status: 'pending' });
// The other tab is done: the next trigger here takes the lock and sends it.
locks.held.clear();
await mutationQueue.flush();
// The other tab sends the row and lets go of the lock.
await offlineDb.mutationQueue.delete(id);
releaseOther();
await flushed;
expect(done).toBe(true);
expect(sent).toBe(0);
expect(locks.request).toHaveBeenCalledWith(`trek-mutation-flush:${offlineDb.name}`, expect.any(Function));
});
it('sends what the other tab left once it has the lock', async () => {
const locks = fakeLocks();
const releaseOther = locks.holdElsewhere();
const id = generateUUID();
await mutationQueue.enqueue(makeMutation({ id }));
let sent = 0;
server.use(http.post('/api/trips/1/places', () => { sent++; return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 63 }) }); }));
const flushed = mutationQueue.flush();
await settle();
releaseOther();
await flushed;
expect(sent).toBe(1);
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined();
expect(locks.held.size).toBe(0);
});
it('flushes under the flag alone when the lock request is refused', async () => {