From 7d7deb5e0b87fe77c389abb225710bfe799e52e2 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 16 Apr 2026 19:52:04 +0300 Subject: [PATCH] fix: dashboard stuck on infinite 'Loading...' spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in src/hooks/use-auth.ts that caused the dashboard layout's auth loading state to never resolve: 1. fetchProfile queried profiles.id = userId, but profiles.id is the profile's own UUID. The correct column is profiles.user_id (FK to auth.users.id). This meant no profile was ever found and — combined with .single() throwing on 0 rows — could propagate errors. 2. No try/catch/finally around supabase.auth.getUser(). If getUser() threw (e.g. during auth lock contention), setLoading(false) never ran and the spinner showed forever with no visible error. 3. useEffect had supabase and fetchProfile in its deps. Since createClient() previously returned a new instance each call, both deps changed every render and the effect re-ran forever, subscribing to onAuthStateChange repeatedly and racing getUser() calls against the auth lock. Changes: - fetchProfile now uses .eq("user_id", userId) and .maybeSingle() so a missing profile returns { data: null } instead of throwing. - init() wrapped in try/catch/finally — setLoading(false) ALWAYS runs. - 10-second safety timer clears the loading state if anything hangs. - useEffect runs once on mount (createClient is a singleton; deps intentionally empty). - Logs Supabase error fields explicitly (message/details/hint/code) instead of the raw object which serializes as {}. - AuthSessionMissingError (normal for logged-out users) is suppressed from error logs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/use-auth.ts | 101 +++++++++++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 30 deletions(-) diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 0a630dd..f0e9ae7 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -16,43 +16,79 @@ export function useAuth() { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); - const supabase = createClient(); - - const fetchProfile = useCallback( - async (userId: string) => { - const { data } = await supabase - .from("profiles") - .select("id, full_name, email, avatar_url") - .eq("id", userId) - .single(); - - if (data) { - setProfile(data); - } - }, - [supabase] - ); - useEffect(() => { - const getUser = async () => { - const { - data: { user }, - } = await supabase.auth.getUser(); + const supabase = createClient(); + let mounted = true; - setUser(user); - - if (user) { - await fetchProfile(user.id); + // Safety net: if something hangs, surface it instead of an infinite spinner. + const safetyTimer = setTimeout(() => { + if (mounted) { + console.warn("[useAuth] getUser() timed out after 10s, clearing loading state"); + setLoading(false); } + }, 10000); - setLoading(false); + const fetchProfile = async (userId: string) => { + try { + // profiles.user_id (NOT profiles.id) references auth.users.id + const { data, error } = await supabase + .from("profiles") + .select("id, full_name, email, avatar_url") + .eq("user_id", userId) + .maybeSingle(); + + if (error) { + console.error("[useAuth] fetchProfile error:", { + message: error.message, + details: error.details, + hint: error.hint, + code: error.code, + }); + return; + } + + if (data && mounted) { + setProfile(data); + } + } catch (err) { + console.error("[useAuth] fetchProfile threw:", err); + } }; - getUser(); + const init = async () => { + try { + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error) { + // AuthSessionMissingError is expected when not logged in — don't log as error + if (error.name !== "AuthSessionMissingError") { + console.error("[useAuth] getUser error:", error.message); + } + } + + if (!mounted) return; + setUser(user); + + if (user) { + await fetchProfile(user.id); + } + } catch (err) { + console.error("[useAuth] init threw:", err); + } finally { + if (mounted) setLoading(false); + clearTimeout(safetyTimer); + } + }; + + init(); const { data: { subscription }, - } = supabase.auth.onAuthStateChange(async (event, session) => { + } = supabase.auth.onAuthStateChange(async (_event, session) => { + if (!mounted) return; const currentUser = session?.user ?? null; setUser(currentUser); @@ -66,16 +102,21 @@ export function useAuth() { }); return () => { + mounted = false; + clearTimeout(safetyTimer); subscription.unsubscribe(); }; - }, [supabase, fetchProfile]); + // Intentionally run once on mount — createClient() is a singleton + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const signOut = useCallback(async () => { + const supabase = createClient(); await supabase.auth.signOut(); setUser(null); setProfile(null); window.location.href = "/login"; - }, [supabase]); + }, []); return { user, profile, loading, signOut }; }