diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index ef1238c..620c773 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -229,7 +229,12 @@ async function startAnalyticsProcessor( } function startHeartBeatProcessor(track: (params: TrackEventParams) => void) { + // Browser-only, like the other automatic events here (initialization, session + // duration, visibility). Outside a browser this timer fired a `me()` every + // interval for the lifetime of a long-lived server-side client, and kept the + // Node event loop alive. Explicit `analytics.track()` calls still work. if ( + typeof window === "undefined" || analyticsSharedState.isHeartBeatProcessing || (analyticsSharedState.config.heartBeatInterval ?? 0) < 10 ) { @@ -307,6 +312,22 @@ function transformEventDataToApiRequestData(sessionContext: SessionContext) { } let sessionContextPromise: Promise | null = null; + +/** + * Clears the memoized analytics session context. + * + * The context holds the `user_id` resolved by `auth.me()` and is reused for the + * lifetime of the session, so it has to be dropped whenever the identity + * changes. Without this, a visitor who loads a page anonymously and then logs in + * keeps reporting `user_id: null` on every subsequent event. + * + * @internal + */ +export function resetAnalyticsSessionContext() { + analyticsSharedState.sessionContext = null; + sessionContextPromise = null; +} + async function getSessionContext( userAuthModule: AuthModule ): Promise { diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 13c35d7..f1e4873 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -2,10 +2,12 @@ import { AxiosInstance } from "axios"; import { AuthModule, AuthModuleOptions, + User, VerifyOtpParams, ChangePasswordParams, ResetPasswordParams, } from "./auth.types"; +import { resetAnalyticsSessionContext } from "./analytics.js"; function isInsideIframe(): boolean { if (typeof window === "undefined") return false; @@ -91,10 +93,31 @@ export function createAuthModule( appId: string, options: AuthModuleOptions ): AuthModule { + // In-flight `me()` request, shared by concurrent callers. The analytics + // module resolves its session context through `me()` at client construction, + // at the same moment most apps issue their own `me()`. Browsers serialize the + // two identical GETs, so the second pays the first's full latency on every + // cold load. + // + // This shares the pending promise only — it is cleared as soon as the request + // settles, so no resolved user is ever retained. Caching the user across + // requests would leave the app rendering a stale identity after logout or a + // session swap. + let pendingMe: Promise | null = null; + const clearPendingMe = () => { + pendingMe = null; + }; + return { // Get current user information async me() { - return axios.get(`/apps/${appId}/entities/User/me`); + const request = + pendingMe ?? + axios + .get(`/apps/${appId}/entities/User/me`) + .finally(clearPendingMe); + pendingMe = request; + return request; }, // Update current user data @@ -158,6 +181,11 @@ export function createAuthModule( // Remove token from axios headers (always do this) delete axios.defaults.headers.common["Authorization"]; + // Drop identity resolved under the previous session: a `me()` already in + // flight would otherwise resolve into callers that run after the logout. + clearPendingMe(); + resetAnalyticsSessionContext(); + // Only do the rest if in a browser environment if (typeof window !== "undefined") { // Remove token from localStorage @@ -184,6 +212,11 @@ export function createAuthModule( setToken(token: string, saveToStorage = true) { if (!token) return; + // Same reasoning as in `logout`: the identity changes here, so anything + // resolved for the previous one must not be handed to later callers. + clearPendingMe(); + resetAnalyticsSessionContext(); + // handle token change for axios clients axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; functionsAxiosClient.defaults.headers.common[ diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 4c0f647..bc691e0 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -6,6 +6,7 @@ import { TrackEventData, } from "../../src/index.ts"; import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; +import { resetAnalyticsSessionContext } from "../../src/modules/analytics.ts"; import { User } from "../../src/modules/auth.types.ts"; import { AxiosInstance } from "axios"; @@ -82,6 +83,25 @@ describe("Analytics Module", () => { }); }); + test("should clear the memoized session context on reset", () => { + expect(sharedState?.sessionContext).toEqual({ user_id: "test-user-id" }); + + resetAnalyticsSessionContext(); + + // Called on every identity change. Without it, a visitor who loads + // anonymously and then logs in keeps reporting the pre-login identity. + expect(sharedState?.sessionContext).toBeNull(); + }); + + test("should not start the heartbeat outside a browser", () => { + const heartBeatState = sharedState as unknown as { + isHeartBeatProcessing: boolean; + }; + + expect(typeof window).toBe("undefined"); + expect(heartBeatState.isHeartBeatProcessing).toBeFalsy(); + }); + test("should track multiple events", async () => { vi.useFakeTimers(); diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index c31f7a1..38f168f 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -1,6 +1,7 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import nock from 'nock'; import { createClient } from '../../src/index.ts'; +import { getSharedInstance } from '../../src/utils/sharedInstance.ts'; describe('Auth Module', () => { let base44; @@ -90,8 +91,75 @@ describe('Auth Module', () => { // Verify all mocks were called expect(scope.isDone()).toBe(true); }); + + test('shares one in-flight request between concurrent callers', async () => { + const mockUser = { id: 'user-123', email: 'test@example.com' }; + + // A single interceptor: a second GET would hit disableNetConnect and throw. + scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, mockUser); + + const [first, second] = await Promise.all([ + base44.auth.me(), + base44.auth.me(), + ]); + + expect(first).toEqual(mockUser); + expect(second).toEqual(mockUser); + expect(scope.isDone()).toBe(true); + }); + + test('does not reuse a resolved user across separate calls', async () => { + scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: 'user-1' }); + scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: 'user-2' }); + + const first = await base44.auth.me(); + const second = await base44.auth.me(); + + // Sharing is limited to the in-flight window; identity is never cached. + expect(first.id).toBe('user-1'); + expect(second.id).toBe('user-2'); + expect(scope.isDone()).toBe(true); + }); + + test('does not retain a rejected request', async () => { + const mockUser = { id: 'user-123' }; + scope.get(`/api/apps/${appId}/entities/User/me`).reply(401, { detail: 'Unauthorized' }); + scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, mockUser); + + await expect(base44.auth.me()).rejects.toThrow(); + await expect(base44.auth.me()).resolves.toEqual(mockUser); + + expect(scope.isDone()).toBe(true); + }); + + test('setToken() drops an in-flight request from the previous identity', async () => { + scope + .get(`/api/apps/${appId}/entities/User/me`) + .delay(50) + .reply(200, { id: 'anonymous' }); + scope.get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: 'logged-in' }); + + const beforeLogin = base44.auth.me(); + base44.auth.setToken('new-access-token', false); + const afterLogin = await base44.auth.me(); + + // The call made after the identity change must not resolve into the + // request that was already in flight for the anonymous one. + expect(afterLogin.id).toBe('logged-in'); + await expect(beforeLogin).resolves.toEqual({ id: 'anonymous' }); + expect(scope.isDone()).toBe(true); + }); + + test('setToken() clears the analytics session context', () => { + const analyticsState = getSharedInstance('analytics', () => ({})); + analyticsState.sessionContext = { user_id: 'anonymous-user', session_id: 's1' }; + + base44.auth.setToken('new-access-token', false); + + expect(analyticsState.sessionContext).toBeNull(); + }); }); - + describe('updateMe()', () => { test('should update current user data', async () => { const updateData = {