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
This commit is contained in:
Ubbe
2025-11-10 19:08:37 +07:00
committed by GitHub
parent d6ee402483
commit 33989f09d0
3 changed files with 497 additions and 168 deletions

View File

@@ -0,0 +1,174 @@
import type BackendAPI from "@/lib/autogpt-server-api/client";
import { environment } from "@/services/environment";
import { createBrowserClient } from "@supabase/ssr";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import {
getCurrentUser,
refreshSession as refreshSessionAction,
validateSession as validateSessionAction,
} from "../actions";
import {
clearWebSocketDisconnectIntent,
getRedirectPath,
isLogoutEvent,
setWebSocketDisconnectIntent,
} from "../helpers";
let supabaseSingleton: SupabaseClient | null = null;
export function ensureSupabaseClient(): SupabaseClient | null {
if (supabaseSingleton) return supabaseSingleton;
const supabaseUrl = environment.getSupabaseUrl();
const supabaseKey = environment.getSupabaseAnonKey();
if (!supabaseUrl || !supabaseKey) return null;
try {
supabaseSingleton = createBrowserClient(supabaseUrl, supabaseKey, {
isSingleton: true,
auth: {
persistSession: false,
},
});
} catch (error) {
console.error("Error creating Supabase client", error);
supabaseSingleton = null;
}
return supabaseSingleton;
}
interface FetchUserResult {
user: User | null;
hasLoadedUser: boolean;
isUserLoading: boolean;
}
export async function fetchUser(): Promise<FetchUserResult> {
try {
const { user, error } = await getCurrentUser();
if (error || !user) {
return {
user: null,
hasLoadedUser: true,
isUserLoading: false,
};
}
clearWebSocketDisconnectIntent();
return {
user,
hasLoadedUser: true,
isUserLoading: false,
};
} catch (error) {
console.error("Get user error:", error);
return {
user: null,
hasLoadedUser: true,
isUserLoading: false,
};
}
}
interface ValidateSessionParams {
pathname: string;
currentUser: User | null;
}
interface ValidateSessionResult {
isValid: boolean;
user?: User | null;
redirectPath?: string | null;
shouldUpdateUser: boolean;
}
export async function validateSession(
params: ValidateSessionParams,
): Promise<ValidateSessionResult> {
try {
const result = await validateSessionAction(params.pathname);
if (!result.isValid) {
return {
isValid: false,
redirectPath: result.redirectPath,
shouldUpdateUser: true,
};
}
if (result.user) {
const shouldUpdateUser = params.currentUser?.id !== result.user.id;
clearWebSocketDisconnectIntent();
return {
isValid: true,
user: result.user,
shouldUpdateUser,
};
}
return {
isValid: true,
shouldUpdateUser: false,
};
} catch (error) {
console.error("Session validation error:", error);
const redirectPath = getRedirectPath(params.pathname);
return {
isValid: false,
redirectPath,
shouldUpdateUser: true,
};
}
}
interface RefreshSessionResult {
user?: User | null;
error?: string;
}
export async function refreshSession(): Promise<RefreshSessionResult> {
const result = await refreshSessionAction();
if (result.user) {
clearWebSocketDisconnectIntent();
}
return result;
}
interface StorageEventHandlerParams {
event: StorageEvent;
api: BackendAPI | null;
router: AppRouterInstance | null;
pathname: string;
}
interface StorageEventHandlerResult {
shouldLogout: boolean;
redirectPath?: string | null;
}
export function handleStorageEvent(
params: StorageEventHandlerParams,
): StorageEventHandlerResult {
if (!isLogoutEvent(params.event)) {
return { shouldLogout: false };
}
setWebSocketDisconnectIntent();
if (params.api) {
params.api.disconnectWebSocket();
}
const redirectPath = getRedirectPath(params.pathname);
return {
shouldLogout: true,
redirectPath,
};
}

View File

@@ -1,188 +1,67 @@
"use client";
import { createBrowserClient } from "@supabase/ssr";
import { User } from "@supabase/supabase-js";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
getCurrentUser,
refreshSession,
serverLogout,
ServerLogoutOptions,
validateSession,
} from "../actions";
import {
broadcastLogout,
clearWebSocketDisconnectIntent,
getRedirectPath,
isLogoutEvent,
setWebSocketDisconnectIntent,
setupSessionEventListeners,
} from "../helpers";
import { environment } from "@/services/environment";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import type { ServerLogoutOptions } from "../actions";
import { useSupabaseStore } from "./useSupabaseStore";
export function useSupabase() {
const router = useRouter();
const pathname = usePathname();
const api = useBackendAPI();
const [user, setUser] = useState<User | null>(null);
const [isUserLoading, setIsUserLoading] = useState(true);
const lastValidationRef = useRef<number>(0);
const isValidatingRef = useRef(false);
const isLoggedIn = Boolean(user);
const supabase = useMemo(() => {
try {
return createBrowserClient(
environment.getSupabaseUrl(),
environment.getSupabaseAnonKey(),
{
isSingleton: true,
auth: {
persistSession: false, // Don't persist session on client with httpOnly cookies
},
},
);
} catch (error) {
console.error("Error creating Supabase client", error);
return null;
}
}, []);
async function logOut(options: ServerLogoutOptions = {}) {
setWebSocketDisconnectIntent();
api.disconnectWebSocket();
broadcastLogout();
try {
await serverLogout(options);
} catch (error) {
console.error("Error logging out:", error);
} finally {
setUser(null);
router.refresh();
}
}
async function validateSessionServer() {
// Prevent concurrent validation calls
if (isValidatingRef.current) return true;
// Simple debounce - only validate if 2 seconds have passed
const now = Date.now();
if (now - lastValidationRef.current < 2000) {
return true;
}
isValidatingRef.current = true;
lastValidationRef.current = now;
try {
const result = await validateSession(pathname);
if (!result.isValid) {
setUser(null);
if (result.redirectPath) {
router.push(result.redirectPath);
}
return false;
}
// Update local state with server user data
if (result.user) {
setUser((currentUser) => {
// Only update if user actually changed to prevent unnecessary re-renders
if (currentUser?.id !== result.user?.id) {
return result.user;
}
return currentUser;
});
clearWebSocketDisconnectIntent();
}
return true;
} catch (error) {
console.error("Session validation error:", error);
setUser(null);
const redirectPath = getRedirectPath(pathname);
if (redirectPath) {
router.push(redirectPath);
}
return false;
} finally {
isValidatingRef.current = false;
}
}
async function getUserFromServer() {
try {
const { user: serverUser, error } = await getCurrentUser();
if (error || !serverUser) {
setUser(null);
return null;
}
setUser(serverUser);
clearWebSocketDisconnectIntent();
return serverUser;
} catch (error) {
console.error("Get user error:", error);
setUser(null);
return null;
}
}
function handleCrossTabLogout(e: StorageEvent) {
if (!isLogoutEvent(e)) return;
setWebSocketDisconnectIntent();
api.disconnectWebSocket();
// Clear local state immediately
setUser(null);
router.refresh();
const redirectPath = getRedirectPath(pathname);
if (redirectPath) {
router.push(redirectPath);
}
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
validateSessionServer();
}
}
function handleFocus() {
validateSessionServer();
}
const {
user,
supabase,
isUserLoading,
initialize,
logOut,
validateSession,
refreshSession,
} = useSupabaseStore(
useShallow((state) => ({
user: state.user,
supabase: state.supabase,
isUserLoading: state.isUserLoading,
initialize: state.initialize,
logOut: state.logOut,
validateSession: state.validateSession,
refreshSession: state.refreshSession,
})),
);
useEffect(() => {
getUserFromServer().finally(() => {
setIsUserLoading(false);
void initialize({
api,
router,
pathname,
});
}, [api, initialize, pathname, router]);
// Set up event listeners for cross-tab logout, focus, and visibility change
const eventListeners = setupSessionEventListeners(
handleVisibilityChange,
handleFocus,
handleCrossTabLogout,
);
function handleLogout(options: ServerLogoutOptions = {}) {
return logOut({
options,
api,
router,
});
}
return () => {
eventListeners.cleanup();
};
}, []);
function handleValidateSession() {
return validateSession({
pathname,
router,
});
}
return {
user,
supabase, // Available for non-auth operations like real-time subscriptions
isLoggedIn,
supabase,
isLoggedIn: Boolean(user),
isUserLoading,
logOut,
validateSession: validateSessionServer,
logOut: handleLogout,
validateSession: handleValidateSession,
refreshSession,
};
}

View File

@@ -0,0 +1,276 @@
"use client";
import type BackendAPI from "@/lib/autogpt-server-api/client";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { create } from "zustand";
import { serverLogout, type ServerLogoutOptions } from "../actions";
import {
broadcastLogout,
setWebSocketDisconnectIntent,
setupSessionEventListeners,
} from "../helpers";
import {
ensureSupabaseClient,
fetchUser,
handleStorageEvent as handleStorageEventHelper,
refreshSession as refreshSessionHelper,
validateSession as validateSessionHelper,
} from "./helpers";
interface InitializeParams {
api: BackendAPI;
router: AppRouterInstance;
pathname: string;
}
interface LogOutParams {
api?: BackendAPI;
options?: ServerLogoutOptions;
router?: AppRouterInstance;
}
interface ValidateParams {
force?: boolean;
pathname?: string;
router?: AppRouterInstance;
}
interface SupabaseStoreState {
user: User | null;
supabase: SupabaseClient | null;
isUserLoading: boolean;
isValidating: boolean;
hasLoadedUser: boolean;
lastValidation: number;
initializationPromise: Promise<void> | null;
listenersCleanup: (() => void) | null;
routerRef: AppRouterInstance | null;
apiRef: BackendAPI | null;
currentPathname: string;
initialize: (params: InitializeParams) => Promise<void>;
logOut: (params?: LogOutParams) => Promise<void>;
validateSession: (params?: ValidateParams) => Promise<boolean>;
refreshSession: () => ReturnType<typeof refreshSessionHelper>;
cleanup: () => void;
}
export const useSupabaseStore = create<SupabaseStoreState>((set, get) => {
async function initialize(params: InitializeParams): Promise<void> {
set({
routerRef: params.router,
apiRef: params.api,
currentPathname: params.pathname,
});
const supabaseClient = ensureSupabaseClient();
if (supabaseClient !== get().supabase) {
set({ supabase: supabaseClient });
}
let initializationPromise = get().initializationPromise;
if (!initializationPromise) {
initializationPromise = (async () => {
if (!get().hasLoadedUser) {
set({ isUserLoading: true });
const result = await fetchUser();
set(result);
} else {
set({ isUserLoading: false });
}
const existingCleanup = get().listenersCleanup;
if (existingCleanup) {
existingCleanup();
}
const cleanup = setupSessionEventListeners(
handleVisibilityChange,
handleFocus,
handleStorageEventInternal,
);
set({ listenersCleanup: cleanup.cleanup });
})();
set({ initializationPromise });
}
try {
await initializationPromise;
} finally {
set({ initializationPromise: null });
}
}
async function logOut(params?: LogOutParams): Promise<void> {
const router = params?.router ?? get().routerRef;
const api = params?.api ?? get().apiRef;
const options = params?.options ?? {};
setWebSocketDisconnectIntent();
if (api) {
api.disconnectWebSocket();
}
const existingCleanup = get().listenersCleanup;
if (existingCleanup) {
existingCleanup();
set({ listenersCleanup: null });
}
broadcastLogout();
try {
await serverLogout(options);
} catch (error) {
console.error("Error logging out:", error);
} finally {
set({
user: null,
hasLoadedUser: false,
isUserLoading: false,
});
if (router) {
router.refresh();
}
}
}
async function validateSessionInternal(
params?: ValidateParams,
): Promise<boolean> {
const router = params?.router ?? get().routerRef;
const pathname = params?.pathname ?? get().currentPathname;
if (!router || !pathname) return true;
if (!params?.force && get().isValidating) return true;
const now = Date.now();
if (!params?.force && now - get().lastValidation < 2000) return true;
set({
isValidating: true,
lastValidation: now,
});
try {
const result = await validateSessionHelper({
pathname,
currentUser: get().user,
});
if (!result.isValid) {
set({
user: null,
hasLoadedUser: false,
isUserLoading: false,
});
if (result.redirectPath) {
router.push(result.redirectPath);
}
return false;
}
if (result.user && result.shouldUpdateUser) {
set({ user: result.user });
}
if (result.user) {
set({
hasLoadedUser: true,
isUserLoading: false,
});
}
return true;
} finally {
set({ isValidating: false });
}
}
function handleVisibilityChange(): void {
if (document.visibilityState !== "visible") return;
void validateSessionInternal();
}
function handleFocus(): void {
void validateSessionInternal();
}
function handleStorageEventInternal(event: StorageEvent): void {
const result = handleStorageEventHelper({
event,
api: get().apiRef,
router: get().routerRef,
pathname: get().currentPathname,
});
if (!result.shouldLogout) return;
set({
user: null,
hasLoadedUser: false,
isUserLoading: false,
});
const router = get().routerRef;
if (router) {
router.refresh();
if (result.redirectPath) {
router.push(result.redirectPath);
}
}
}
async function refreshSessionInternal() {
const result = await refreshSessionHelper();
if (result.user) {
set({
user: result.user,
hasLoadedUser: true,
isUserLoading: false,
});
} else if (result.error) {
set({
user: null,
hasLoadedUser: false,
isUserLoading: false,
});
}
return result;
}
function cleanup(): void {
const existingCleanup = get().listenersCleanup;
if (existingCleanup) {
existingCleanup();
set({ listenersCleanup: null });
}
}
return {
user: null,
supabase: null,
isUserLoading: true,
isValidating: false,
hasLoadedUser: false,
lastValidation: 0,
initializationPromise: null,
listenersCleanup: null,
routerRef: null,
apiRef: null,
currentPathname: "",
initialize,
logOut,
validateSession: validateSessionInternal,
refreshSession: refreshSessionInternal,
cleanup,
};
});