From 5ce9135eba0198161ff6852a45ee08ddede462fe Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 19 Jun 2026 22:40:58 +0200 Subject: [PATCH] fix(auth): only clear inFlightRefresh if it still points to the settling promise --- frontend/src/helpers/auth.test.ts | 30 ++++++++++++++++++++++++++++++ frontend/src/helpers/auth.ts | 12 +++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/frontend/src/helpers/auth.test.ts b/frontend/src/helpers/auth.test.ts index a663e8a8a..c57f896b8 100644 --- a/frontend/src/helpers/auth.test.ts +++ b/frontend/src/helpers/auth.test.ts @@ -120,4 +120,34 @@ describe('refreshToken in-flight dedup', () => { expect(localStorage.getItem('token')).toBeNull() }) + + it('an older refresh settling does not clobber a newer in-flight one', async () => { + // Refresh A starts and stays in flight. + const pA = refreshToken(true) + expect(postCallCount).toBe(1) + const resolveA = resolvePost + + // User logs out, which drops the in-flight reference to A. + removeToken() + + // Refresh B starts; it must claim the in-flight slot. + const pB = refreshToken(true) + expect(postCallCount).toBe(2) + const resolveB = resolvePost + + // A settles after B started. Its cleanup must NOT null the in-flight + // slot, since that slot now belongs to B. Without the `=== p` guard, + // A's .finally would clobber B and let a concurrent caller fire a + // second parallel POST. + resolveA?.({data: {token: FAKE_TOKEN}}) + await pA + + // A concurrent caller while B is still in flight must dedup to B — + // no third POST. + const pB2 = refreshToken(true) + expect(postCallCount).toBe(2) + + resolveB?.({data: {token: FAKE_TOKEN}}) + await Promise.all([pB, pB2]) + }) }) diff --git a/frontend/src/helpers/auth.ts b/frontend/src/helpers/auth.ts index 1ad9ef616..04ed29f2e 100644 --- a/frontend/src/helpers/auth.ts +++ b/frontend/src/helpers/auth.ts @@ -63,10 +63,16 @@ export async function refreshToken(persist: boolean): Promise { if (inFlightRefresh) { return inFlightRefresh } - inFlightRefresh = doRefresh(persist).finally(() => { - inFlightRefresh = null + const p = doRefresh(persist) + inFlightRefresh = p + // Only clear if it still points to this promise — a logout (or a newer + // refresh started after it) may have replaced inFlightRefresh meanwhile. + p.finally(() => { + if (inFlightRefresh === p) { + inFlightRefresh = null + } }) - return inFlightRefresh + return p } async function doRefresh(persist: boolean): Promise {