fix(client): let a flush called mid-pass resolve with that pass

flush() returned at once when this tab already had a flush going, and
that includes one still waiting for another tab's Web Lock. The socket's
reconnect hook chains a refetch onto flush(), so a reconnect landing in
that window rehydrated the trip while a replay was still on its way, the
race the lock wait was meant to close. sendSoon and the online, periodic
and visibility triggers make such an overlap common.

flush() now keeps the running flush as a promise. A second call still
asks it for one more pass and then gets that same promise back, so it
resolves only once every pass, including the one it asked for, is done.
The pass itself moves to _flushPass, and the repeat runs as a loop
instead of a nested flush() call. The client CLAUDE.md sync bullet now
says so, and loses its dash.
This commit is contained in:
Maurice
2026-10-08 13:47:54 +02:00
parent 72c284467b
commit df2768d30e
3 changed files with 95 additions and 23 deletions
@@ -810,6 +810,57 @@ describe('mutationQueue.flush: one tab at a time', () => {
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined();
});
it('a second flush while the first waits for the lock resolves only with it', 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: 64 }) }); }));
const first = mutationQueue.flush();
await settle();
// The socket's reconnect hook calls in here and refetches once this resolves.
let secondDone = false;
const second = mutationQueue.flush().then(() => { secondDone = true; });
await settle();
expect(secondDone).toBe(false);
releaseOther();
await Promise.all([first, second]);
expect(secondDone).toBe(true);
expect(sent).toBe(1);
// One lock request for the waiting pass and one for the extra pass it was asked for.
expect(locks.request).toHaveBeenCalledTimes(3);
});
it('a write queued during a pass goes out before the second caller resolves', async () => {
const first = generateUUID();
const later = generateUUID();
await mutationQueue.enqueue(makeMutation({ id: first }));
const sent: string[] = [];
let second: Promise<string[]> | undefined;
server.use(http.post('/api/trips/1/places', async ({ request }) => {
const { name } = await request.json() as { name: string };
sent.push(name);
if (name === 'Eiffel Tower') {
await mutationQueue.enqueue(makeMutation({ id: later, body: { name: 'Louvre' } }));
// What had been sent at the moment this second call resolved.
second = mutationQueue.flush().then(() => [...sent]);
}
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: sent.length + 70 }) });
}));
const firstRun = mutationQueue.flush();
await vi.waitFor(() => expect(second).toBeDefined());
// Resolved only after the extra pass sent the later write, not when it was asked for.
expect(await second).toEqual(['Eiffel Tower', 'Louvre']);
expect(await offlineDb.mutationQueue.count()).toBe(0);
await firstRun;
});
it('flushes under the flag alone when the lock request is refused', async () => {
Object.defineProperty(navigator, 'locks', {
value: { request: vi.fn().mockRejectedValue(new DOMException('denied', 'SecurityError')) },