From 52876da4a9ac6510287cedb81f0dd4c4b0db5715 Mon Sep 17 00:00:00 2001 From: bymyself Date: Wed, 29 Oct 2025 19:00:10 -0700 Subject: [PATCH 1/2] handle in flight on session creation --- src/platform/auth/session/useSessionCookie.ts | 110 ++++++++++---- .../auth/session/useSessionCookie.test.ts | 139 ++++++++++++++++++ 2 files changed, 222 insertions(+), 27 deletions(-) create mode 100644 tests-ui/platform/auth/session/useSessionCookie.test.ts diff --git a/src/platform/auth/session/useSessionCookie.ts b/src/platform/auth/session/useSessionCookie.ts index 49f6fec46e..a95c7d25e1 100644 --- a/src/platform/auth/session/useSessionCookie.ts +++ b/src/platform/auth/session/useSessionCookie.ts @@ -12,30 +12,57 @@ export const useSessionCookie = () => { * Called after login and on token refresh. */ const createSession = async (): Promise => { - if (!isCloud) return + if (!isCloud || logoutInProgress) return + + if (inFlightCreateSession) { + await inFlightCreateSession + return + } const authStore = useFirebaseAuthStore() - const authHeader = await authStore.getAuthHeader() - if (!authHeader) { - throw new Error('No auth header available for session creation') - } + let controller: AbortController | null = null + + const run = (async () => { + const authHeader = await authStore.getAuthHeader() - const response = await fetch(api.apiURL('/auth/session'), { - method: 'POST', - credentials: 'include', - headers: { - ...authHeader, - 'Content-Type': 'application/json' + if (!authHeader) { + throw new Error('No auth header available for session creation') } - }) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error( - `Failed to create session: ${errorData.message || response.statusText}` - ) - } + controller = new AbortController() + currentCreateController = controller + + const response = await fetch(api.apiURL('/auth/session'), { + method: 'POST', + credentials: 'include', + signal: controller.signal, + headers: { + ...authHeader, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + `Failed to create session: ${errorData.message || response.statusText}` + ) + } + })() + .catch((error: unknown) => { + if (isAbortError(error)) return + throw error + }) + .finally(() => { + if (currentCreateController === controller) { + currentCreateController = null + } + inFlightCreateSession = null + }) + + inFlightCreateSession = run + await run } /** @@ -45,16 +72,35 @@ export const useSessionCookie = () => { const deleteSession = async (): Promise => { if (!isCloud) return - const response = await fetch(api.apiURL('/auth/session'), { - method: 'DELETE', - credentials: 'include' - }) + logoutInProgress = true + + try { + if (inFlightCreateSession) { + currentCreateController?.abort() + try { + await inFlightCreateSession + } catch (error: unknown) { + if (!isAbortError(error)) { + throw error + } + } + } + + const response = await fetch(api.apiURL('/auth/session'), { + method: 'DELETE', + credentials: 'include' + }) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error( - `Failed to delete session: ${errorData.message || response.statusText}` - ) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + `Failed to delete session: ${ + errorData.message || response.statusText + }` + ) + } + } finally { + logoutInProgress = false } } @@ -63,3 +109,13 @@ export const useSessionCookie = () => { deleteSession } } + +let inFlightCreateSession: Promise | null = null +let currentCreateController: AbortController | null = null +let logoutInProgress = false + +const isAbortError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') return false + const name = 'name' in error ? (error as { name?: string }).name : undefined + return name === 'AbortError' +} diff --git a/tests-ui/platform/auth/session/useSessionCookie.test.ts b/tests-ui/platform/auth/session/useSessionCookie.test.ts new file mode 100644 index 0000000000..b25f1f64ed --- /dev/null +++ b/tests-ui/platform/auth/session/useSessionCookie.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest' + +const makeSuccessResponse = () => + new Response('{}', { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' } + }) + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (reason: unknown) => void +} + +const createDeferred = (): Deferred => { + let resolve: (value: T) => void + let reject: (reason: unknown) => void + + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + // @ts-expect-error initialized via closure assignments above + return { promise, resolve, reject } +} + +const mockModules = async () => { + vi.resetModules() + + const getAuthHeader = vi.fn(async () => ({ Authorization: 'Bearer token' })) + + vi.doMock('@/scripts/api', () => ({ + api: { + apiURL: vi.fn((path: string) => `/api${path}`) + } + })) + + vi.doMock('@/platform/distribution/types', () => ({ + isCloud: true + })) + + vi.doMock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: vi.fn(() => ({ + getAuthHeader + })) + })) + + const module = await import('@/platform/auth/session/useSessionCookie') + return { getAuthHeader, useSessionCookie: module.useSessionCookie } +} + +describe('useSessionCookie', () => { + it('deduplicates in-flight session creation', async () => { + const { useSessionCookie, getAuthHeader } = await mockModules() + + const postDeferred = createDeferred() + + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(() => postDeferred.promise) + + const { createSession } = useSessionCookie() + + const firstCall = createSession() + const secondCall = createSession() + + await Promise.resolve() + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(getAuthHeader).toHaveBeenCalledTimes(1) + + postDeferred.resolve(makeSuccessResponse()) + await expect(firstCall).resolves.toBeUndefined() + await expect(secondCall).resolves.toBeUndefined() + + fetchSpy.mockRestore() + }) + + it('aborts pending create on logout and skips new ones while logout is in progress', async () => { + const { useSessionCookie, getAuthHeader } = await mockModules() + + const firstPostDeferred = createDeferred() + const deleteDeferred = createDeferred() + + let capturedSignal: AbortSignal | undefined + + const fetchSpy = vi.spyOn(globalThis, 'fetch') + fetchSpy + .mockImplementationOnce((_, init?: RequestInit) => { + capturedSignal = init?.signal as AbortSignal | undefined + return firstPostDeferred.promise + }) + .mockImplementationOnce((_, init?: RequestInit) => { + expect(init?.method).toBe('DELETE') + return deleteDeferred.promise + }) + .mockImplementation((_, init?: RequestInit) => { + if (init?.method === 'POST') { + return Promise.resolve(makeSuccessResponse()) + } + return Promise.resolve(makeSuccessResponse()) + }) + + const { createSession, deleteSession } = useSessionCookie() + + const createPromise = createSession() + + await Promise.resolve() + + const logoutPromise = deleteSession() + + await Promise.resolve() + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(capturedSignal?.aborted).toBe(true) + + const abortError = new Error('aborted') + abortError.name = 'AbortError' + firstPostDeferred.reject(abortError) + await expect(createPromise).resolves.toBeUndefined() + + await Promise.resolve() + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(getAuthHeader).toHaveBeenCalledTimes(1) + + await expect(createSession()).resolves.toBeUndefined() + expect(fetchSpy).toHaveBeenCalledTimes(2) + + deleteDeferred.resolve(makeSuccessResponse()) + await expect(logoutPromise).resolves.toBeUndefined() + + await expect(createSession()).resolves.toBeUndefined() + expect(fetchSpy).toHaveBeenCalledTimes(3) + + fetchSpy.mockRestore() + }) +}) From 6a5fcc53572ac256c0b0d6b22981482f81045b45 Mon Sep 17 00:00:00 2001 From: bymyself Date: Wed, 29 Oct 2025 20:44:31 -0700 Subject: [PATCH 2/2] cancel inflight create session --- src/platform/auth/session/useSessionCookie.ts | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/src/platform/auth/session/useSessionCookie.ts b/src/platform/auth/session/useSessionCookie.ts index a95c7d25e1..1f1750f066 100644 --- a/src/platform/auth/session/useSessionCookie.ts +++ b/src/platform/auth/session/useSessionCookie.ts @@ -1,3 +1,5 @@ +import { createSingletonPromise } from '@vueuse/core' + import { api } from '@/scripts/api' import { isCloud } from '@/platform/distribution/types' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' @@ -14,55 +16,16 @@ export const useSessionCookie = () => { const createSession = async (): Promise => { if (!isCloud || logoutInProgress) return - if (inFlightCreateSession) { - await inFlightCreateSession - return - } - - const authStore = useFirebaseAuthStore() - - let controller: AbortController | null = null - - const run = (async () => { - const authHeader = await authStore.getAuthHeader() - - if (!authHeader) { - throw new Error('No auth header available for session creation') - } - - controller = new AbortController() - currentCreateController = controller - - const response = await fetch(api.apiURL('/auth/session'), { - method: 'POST', - credentials: 'include', - signal: controller.signal, - headers: { - ...authHeader, - 'Content-Type': 'application/json' - } - }) + const promise = createSessionSingleton() + inFlightCreateSession = promise - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error( - `Failed to create session: ${errorData.message || response.statusText}` - ) - } - })() - .catch((error: unknown) => { - if (isAbortError(error)) return - throw error - }) - .finally(() => { - if (currentCreateController === controller) { - currentCreateController = null - } + try { + await promise + } finally { + if (inFlightCreateSession === promise) { inFlightCreateSession = null - }) - - inFlightCreateSession = run - await run + } + } } /** @@ -101,6 +64,7 @@ export const useSessionCookie = () => { } } finally { logoutInProgress = false + await createSessionSingleton.reset() } } @@ -114,6 +78,45 @@ let inFlightCreateSession: Promise | null = null let currentCreateController: AbortController | null = null let logoutInProgress = false +const createSessionSingleton = createSingletonPromise(async () => { + const authStore = useFirebaseAuthStore() + const authHeader = await authStore.getAuthHeader() + + if (!authHeader) { + throw new Error('No auth header available for session creation') + } + + const controller = new AbortController() + currentCreateController = controller + + try { + const response = await fetch(api.apiURL('/auth/session'), { + method: 'POST', + credentials: 'include', + signal: controller.signal, + headers: { + ...authHeader, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + `Failed to create session: ${errorData.message || response.statusText}` + ) + } + } catch (error: unknown) { + if (!isAbortError(error)) { + throw error + } + } finally { + if (currentCreateController === controller) { + currentCreateController = null + } + } +}) + const isAbortError = (error: unknown): boolean => { if (!error || typeof error !== 'object') return false const name = 'name' in error ? (error as { name?: string }).name : undefined