fix: dashboard stuck on infinite 'Loading...' spinner

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-16 19:52:04 +03:00
parent 658d070811
commit 7d7deb5e0b
No known key found for this signature in database

View File

@ -16,43 +16,79 @@ export function useAuth() {
const [profile, setProfile] = useState<Profile | null>(null); const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => {
const supabase = createClient(); const supabase = createClient();
let mounted = true;
const fetchProfile = useCallback( // Safety net: if something hangs, surface it instead of an infinite spinner.
async (userId: string) => { const safetyTimer = setTimeout(() => {
const { data } = await supabase if (mounted) {
console.warn("[useAuth] getUser() timed out after 10s, clearing loading state");
setLoading(false);
}
}, 10000);
const fetchProfile = async (userId: string) => {
try {
// profiles.user_id (NOT profiles.id) references auth.users.id
const { data, error } = await supabase
.from("profiles") .from("profiles")
.select("id, full_name, email, avatar_url") .select("id, full_name, email, avatar_url")
.eq("id", userId) .eq("user_id", userId)
.single(); .maybeSingle();
if (data) { 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); setProfile(data);
} }
}, } catch (err) {
[supabase] console.error("[useAuth] fetchProfile threw:", err);
); }
};
useEffect(() => { const init = async () => {
const getUser = async () => { try {
const { const {
data: { user }, data: { user },
error,
} = await supabase.auth.getUser(); } = 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); setUser(user);
if (user) { if (user) {
await fetchProfile(user.id); await fetchProfile(user.id);
} }
} catch (err) {
setLoading(false); console.error("[useAuth] init threw:", err);
} finally {
if (mounted) setLoading(false);
clearTimeout(safetyTimer);
}
}; };
getUser(); init();
const { const {
data: { subscription }, data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => { } = supabase.auth.onAuthStateChange(async (_event, session) => {
if (!mounted) return;
const currentUser = session?.user ?? null; const currentUser = session?.user ?? null;
setUser(currentUser); setUser(currentUser);
@ -66,16 +102,21 @@ export function useAuth() {
}); });
return () => { return () => {
mounted = false;
clearTimeout(safetyTimer);
subscription.unsubscribe(); 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 signOut = useCallback(async () => {
const supabase = createClient();
await supabase.auth.signOut(); await supabase.auth.signOut();
setUser(null); setUser(null);
setProfile(null); setProfile(null);
window.location.href = "/login"; window.location.href = "/login";
}, [supabase]); }, []);
return { user, profile, loading, signOut }; return { user, profile, loading, signOut };
} }