From 461a63fc86641ad5c1898a6f5c1cb94c0fe41d9a Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 16:32:57 +0200 Subject: [PATCH] fix(auth): send the queued changes before a logout deletes the offline database Logging out deleted this user's offline database straight away, so a change made offline and not yet sent was lost without a word. With a connection, the logout now flushes the queue first, for at most five seconds. --- client/src/store/authStore.ts | 15 ++++++++++- client/tests/unit/stores/authStore.test.ts | 30 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 1970ed966..983f936f3 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -7,7 +7,8 @@ import { getApiErrorMessage } from '../types' import { tripSyncManager } from '../sync/tripSyncManager' import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb' import { setAuthed } from '../sync/authGate' -import { setForcedOffline } from '../sync/networkMode' +import { isEffectivelyOffline, setForcedOffline } from '../sync/networkMode' +import { mutationQueue } from '../sync/mutationQueue' import { registerSyncTriggers, unregisterSyncTriggers } from '../sync/syncTriggers' import { useSystemNoticeStore } from './systemNoticeStore.js' import { clearAppearanceSnapshot } from '../theme/applyAppearance' @@ -18,6 +19,9 @@ import { forgetResumeRoute } from '../utils/resumeRoute' import { markSignedOut, clearSignedOut } from '../utils/signedOut' import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush' +/** How long a logout waits for the queued changes to go out. */ +const LOGOUT_FLUSH_MS = 5000 + interface AuthResponse { user: User token: string @@ -238,6 +242,15 @@ export const useAuthStore = create()( }, logout: async () => { + // 0. Send what is still queued while the session can. Step 6 deletes this + // user's offline database, and an edit made offline and not yet sent would + // go with it. Bounded, so a slow server never holds the logout. + if (!isEffectivelyOffline()) { + await Promise.race([ + mutationQueue.flush().catch(() => {}), + new Promise(resolve => setTimeout(resolve, LOGOUT_FLUSH_MS)), + ]) + } // 1. Gate first so any in-flight flush/syncAll bails before we wipe the DB. setAuthed(false) // Flagged in the same update that drops the session: clearing isAuthenticated diff --git a/client/tests/unit/stores/authStore.test.ts b/client/tests/unit/stores/authStore.test.ts index 2df8f704c..305bebe3b 100644 --- a/client/tests/unit/stores/authStore.test.ts +++ b/client/tests/unit/stores/authStore.test.ts @@ -147,6 +147,36 @@ describe('authStore', () => { expect(state.user).toBeNull(); expect(state.isAuthenticated).toBe(false); }); + + it('sends the queued changes before the offline database is deleted', async () => { + const { mutationQueue } = await import('../../../src/sync/mutationQueue'); + useAuthStore.setState({ user: buildUser(), isAuthenticated: true }); + setAuthed(true); + let authedDuringFlush: boolean | null = null; + const flush = vi.spyOn(mutationQueue, 'flush').mockImplementation(async () => { authedDuringFlush = isAuthed(); }); + + await useAuthStore.getState().logout(); + + expect(flush).toHaveBeenCalledOnce(); + expect(authedDuringFlush).toBe(true); + expect(isAuthed()).toBe(false); + }); + + it('does not wait on a flush that hangs', async () => { + vi.useFakeTimers(); + try { + const { mutationQueue } = await import('../../../src/sync/mutationQueue'); + vi.spyOn(mutationQueue, 'flush').mockImplementation(() => new Promise(() => {})); + useAuthStore.setState({ user: buildUser(), isAuthenticated: true }); + const done = useAuthStore.getState().logout(); + await vi.advanceTimersByTimeAsync(5001); + await vi.runAllTimersAsync(); + await done; + expect(useAuthStore.getState().user).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); }); describe('FE-AUTH-007: Register success', () => {