Skip to content

Commit 33989f0

Browse files
authored
feat(frontend): supabase + zustand for speed (#11333)
### Changes 🏗️ This change uses [Zustand](https://github.com/pmndrs/zustand) (a lightweight state management library) to centralize authentication state across the app. Previously, each component mounting `useSupabase()` would create its own local state, causing duplicate API calls and inconsistent user data. Now, user state is cached globally with Zustand - when multiple components need auth data, they share the same cached state instead of each fetching separately. This reduces server load and improves app responsiveness. **File structure:** ``` src/lib/supabase/hooks/ ├── useSupabase.ts # React hook interface (modified) ├── useSupabaseStore.ts # Zustand state management (new) └── helpers.ts # Pure business logic (new) ``` **What was extracted to helpers:** - `ensureSupabaseClient()` - Singleton client initialization - `fetchUser()` - User fetching with error handling - `validateSession()` - Session validation logic - `refreshSession()` - Session refresh logic - `handleStorageEvent()` - Cross-tab logout handling ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verified no TypeScript errors in modified files - [x] Tested login flow works correctly - [x] Tested logout flow works correctly - [x] Verified session validation on tab focus/visibility - [x] Tested cross-tab logout synchronization - [x] Confirmed WebSocket disconnection on logout
1 parent d6ee402 commit 33989f0

File tree

3 files changed

+497
-168
lines changed

3 files changed

+497
-168
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import type BackendAPI from "@/lib/autogpt-server-api/client";
2+
import { environment } from "@/services/environment";
3+
import { createBrowserClient } from "@supabase/ssr";
4+
import type { SupabaseClient, User } from "@supabase/supabase-js";
5+
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
6+
import {
7+
getCurrentUser,
8+
refreshSession as refreshSessionAction,
9+
validateSession as validateSessionAction,
10+
} from "../actions";
11+
import {
12+
clearWebSocketDisconnectIntent,
13+
getRedirectPath,
14+
isLogoutEvent,
15+
setWebSocketDisconnectIntent,
16+
} from "../helpers";
17+
18+
let supabaseSingleton: SupabaseClient | null = null;
19+
20+
export function ensureSupabaseClient(): SupabaseClient | null {
21+
if (supabaseSingleton) return supabaseSingleton;
22+
23+
const supabaseUrl = environment.getSupabaseUrl();
24+
const supabaseKey = environment.getSupabaseAnonKey();
25+
26+
if (!supabaseUrl || !supabaseKey) return null;
27+
28+
try {
29+
supabaseSingleton = createBrowserClient(supabaseUrl, supabaseKey, {
30+
isSingleton: true,
31+
auth: {
32+
persistSession: false,
33+
},
34+
});
35+
} catch (error) {
36+
console.error("Error creating Supabase client", error);
37+
supabaseSingleton = null;
38+
}
39+
40+
return supabaseSingleton;
41+
}
42+
43+
interface FetchUserResult {
44+
user: User | null;
45+
hasLoadedUser: boolean;
46+
isUserLoading: boolean;
47+
}
48+
49+
export async function fetchUser(): Promise<FetchUserResult> {
50+
try {
51+
const { user, error } = await getCurrentUser();
52+
53+
if (error || !user) {
54+
return {
55+
user: null,
56+
hasLoadedUser: true,
57+
isUserLoading: false,
58+
};
59+
}
60+
61+
clearWebSocketDisconnectIntent();
62+
return {
63+
user,
64+
hasLoadedUser: true,
65+
isUserLoading: false,
66+
};
67+
} catch (error) {
68+
console.error("Get user error:", error);
69+
return {
70+
user: null,
71+
hasLoadedUser: true,
72+
isUserLoading: false,
73+
};
74+
}
75+
}
76+
77+
interface ValidateSessionParams {
78+
pathname: string;
79+
currentUser: User | null;
80+
}
81+
82+
interface ValidateSessionResult {
83+
isValid: boolean;
84+
user?: User | null;
85+
redirectPath?: string | null;
86+
shouldUpdateUser: boolean;
87+
}
88+
89+
export async function validateSession(
90+
params: ValidateSessionParams,
91+
): Promise<ValidateSessionResult> {
92+
try {
93+
const result = await validateSessionAction(params.pathname);
94+
95+
if (!result.isValid) {
96+
return {
97+
isValid: false,
98+
redirectPath: result.redirectPath,
99+
shouldUpdateUser: true,
100+
};
101+
}
102+
103+
if (result.user) {
104+
const shouldUpdateUser = params.currentUser?.id !== result.user.id;
105+
clearWebSocketDisconnectIntent();
106+
return {
107+
isValid: true,
108+
user: result.user,
109+
shouldUpdateUser,
110+
};
111+
}
112+
113+
return {
114+
isValid: true,
115+
shouldUpdateUser: false,
116+
};
117+
} catch (error) {
118+
console.error("Session validation error:", error);
119+
const redirectPath = getRedirectPath(params.pathname);
120+
return {
121+
isValid: false,
122+
redirectPath,
123+
shouldUpdateUser: true,
124+
};
125+
}
126+
}
127+
128+
interface RefreshSessionResult {
129+
user?: User | null;
130+
error?: string;
131+
}
132+
133+
export async function refreshSession(): Promise<RefreshSessionResult> {
134+
const result = await refreshSessionAction();
135+
136+
if (result.user) {
137+
clearWebSocketDisconnectIntent();
138+
}
139+
140+
return result;
141+
}
142+
143+
interface StorageEventHandlerParams {
144+
event: StorageEvent;
145+
api: BackendAPI | null;
146+
router: AppRouterInstance | null;
147+
pathname: string;
148+
}
149+
150+
interface StorageEventHandlerResult {
151+
shouldLogout: boolean;
152+
redirectPath?: string | null;
153+
}
154+
155+
export function handleStorageEvent(
156+
params: StorageEventHandlerParams,
157+
): StorageEventHandlerResult {
158+
if (!isLogoutEvent(params.event)) {
159+
return { shouldLogout: false };
160+
}
161+
162+
setWebSocketDisconnectIntent();
163+
164+
if (params.api) {
165+
params.api.disconnectWebSocket();
166+
}
167+
168+
const redirectPath = getRedirectPath(params.pathname);
169+
170+
return {
171+
shouldLogout: true,
172+
redirectPath,
173+
};
174+
}

0 commit comments

Comments
 (0)