From 186c9f0e1e1d2fa626cc9c90c4a45e7db390a836 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 16 Apr 2026 20:23:05 +0300 Subject: [PATCH] fix: share auth via React Context to prevent getSession() timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the cascading warnings: [useAuth] getSession() timed out after 3s, clearing loading state [useAuth] getSession() timed out after 3s, clearing loading state [useAuth] getSession() timed out after 3s, clearing loading state The warning appeared 3x per page load because 3 components each called useAuth() independently (DashboardLayout, Sidebar, Header), each firing their own supabase.auth.getSession() call. The Supabase client has internal mutex locks around session reads, so concurrent calls from multiple components queue up and timeout. Fix: introduce an AuthProvider React Context that calls getSession() ONCE for the whole subtree. useAuth() now reads from context instead of making its own call. Also fixes the Settings page stuck on loading: WhatsAppConfig, TemplateManager, and TagManager were making their own getSession() calls in fetch functions. They now read the user from useAuth() context — no redundant session calls. Changes: - src/hooks/use-auth.ts -> use-auth.tsx (JSX for the Provider) Added AuthProvider component and converted useAuth() to a context consumer. Renamed because the file now exports JSX. - src/app/(dashboard)/layout.tsx — wraps children in with a DashboardShell inner component that consumes the context. - src/components/settings/whatsapp-config.tsx — uses useAuth() for user instead of calling getSession() itself. - src/components/settings/template-manager.tsx — same. - src/components/settings/tag-manager.tsx — same. - Profile fetch is now fire-and-forget (doesn't block setLoading), so the UI renders as soon as the session is known. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/(dashboard)/layout.tsx | 24 +++--- src/components/settings/tag-manager.tsx | 21 +++-- src/components/settings/template-manager.tsx | 21 +++-- src/components/settings/whatsapp-config.tsx | 21 +++-- src/hooks/{use-auth.ts => use-auth.tsx} | 85 ++++++++++++++------ 5 files changed, 114 insertions(+), 58 deletions(-) rename src/hooks/{use-auth.ts => use-auth.tsx} (52%) diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index a7d0ee4..c70f00a 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -2,15 +2,11 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/use-auth"; +import { AuthProvider, useAuth } from "@/hooks/use-auth"; import { Sidebar } from "@/components/layout/sidebar"; import { Header } from "@/components/layout/header"; -export default function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { +function DashboardShell({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); const router = useRouter(); @@ -31,9 +27,7 @@ export default function DashboardLayout({ ); } - if (!user) { - return null; - } + if (!user) return null; return (
@@ -45,3 +39,15 @@ export default function DashboardLayout({
); } + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/settings/tag-manager.tsx b/src/components/settings/tag-manager.tsx index 9318775..db67d14 100644 --- a/src/components/settings/tag-manager.tsx +++ b/src/components/settings/tag-manager.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { Plus, X, Loader2 } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -31,6 +32,7 @@ const PRESET_COLORS = [ export function TagManager() { const supabase = createClient(); + const { user, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [tags, setTags] = useState([]); @@ -43,19 +45,23 @@ export function TagManager() { const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[3].value); useEffect(() => { - fetchTags(); - }, []); + if (authLoading) return; + if (!user) { + setLoading(false); + return; + } + fetchTags(user.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, user?.id]); - async function fetchTags() { + async function fetchTags(userId: string) { try { setLoading(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; - if (!user) return; const { data, error } = await supabase .from('tags') .select('*') - .eq('user_id', user.id) + .eq('user_id', userId) .order('created_at', { ascending: true }); if (error) throw error; @@ -76,7 +82,6 @@ export function TagManager() { try { setSaving(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; if (!user) { toast.error('Not authenticated'); return; @@ -96,7 +101,7 @@ export function TagManager() { setDialogOpen(false); setNewTagName(''); setSelectedColor(PRESET_COLORS[3].value); - await fetchTags(); + if (user) await fetchTags(user.id); } catch (err) { console.error('Create error:', err); toast.error('Failed to create tag'); diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index 89945a8..70d9a77 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { Plus, Trash2, Loader2 } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -63,6 +64,7 @@ const emptyForm: TemplateFormData = { export function TemplateManager() { const supabase = createClient(); + const { user, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [templates, setTemplates] = useState([]); @@ -71,19 +73,23 @@ export function TemplateManager() { const [form, setForm] = useState(emptyForm); useEffect(() => { - fetchTemplates(); - }, []); + if (authLoading) return; + if (!user) { + setLoading(false); + return; + } + fetchTemplates(user.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, user?.id]); - async function fetchTemplates() { + async function fetchTemplates(userId: string) { try { setLoading(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; - if (!user) return; const { data, error } = await supabase .from('message_templates') .select('*') - .eq('user_id', user.id) + .eq('user_id', userId) .order('created_at', { ascending: false }); if (error) throw error; @@ -108,7 +114,6 @@ export function TemplateManager() { try { setSaving(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; if (!user) { toast.error('Not authenticated'); return; @@ -134,7 +139,7 @@ export function TemplateManager() { toast.success('Template created successfully'); setDialogOpen(false); setForm(emptyForm); - await fetchTemplates(); + if (user) await fetchTemplates(user.id); } catch (err) { console.error('Save error:', err); toast.error('Failed to create template'); diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx index 9b7a8ad..fc63253 100644 --- a/src/components/settings/whatsapp-config.tsx +++ b/src/components/settings/whatsapp-config.tsx @@ -13,6 +13,7 @@ import { Zap, } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -30,6 +31,7 @@ const MASKED_TOKEN = '••••••••••••••••'; export function WhatsAppConfig() { const supabase = createClient(); + const { user, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -50,19 +52,23 @@ export function WhatsAppConfig() { : ''; useEffect(() => { - fetchConfig(); - }, []); + if (authLoading) return; + if (!user) { + setLoading(false); + return; + } + fetchConfig(user.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, user?.id]); - async function fetchConfig() { + async function fetchConfig(userId: string) { try { setLoading(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; - if (!user) return; const { data, error } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('user_id', userId) .single(); if (error && error.code !== 'PGRST116') { @@ -97,7 +103,6 @@ export function WhatsAppConfig() { try { setSaving(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; if (!user) { toast.error('Not authenticated'); return; @@ -134,7 +139,7 @@ export function WhatsAppConfig() { } toast.success('Configuration saved successfully'); - await fetchConfig(); + if (user) await fetchConfig(user.id); } catch (err) { console.error('Save error:', err); toast.error('Failed to save configuration'); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.tsx similarity index 52% rename from src/hooks/use-auth.ts rename to src/hooks/use-auth.tsx index 8d353ae..5085b04 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.tsx @@ -1,6 +1,13 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { + createContext, + useContext, + useEffect, + useState, + useCallback, + type ReactNode, +} from "react"; import { createClient } from "@/lib/supabase/client"; import type { User } from "@supabase/supabase-js"; @@ -11,7 +18,21 @@ interface Profile { avatar_url: string | null; } -export function useAuth() { +interface AuthContextValue { + user: User | null; + profile: Profile | null; + loading: boolean; + signOut: () => Promise; +} + +const AuthContext = createContext(null); + +/** + * AuthProvider — wrap this around the dashboard layout. + * Makes ONE getSession() call for the whole tree instead of one per + * component, avoiding internal lock contention in the Supabase client. + */ +export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); @@ -20,18 +41,15 @@ export function useAuth() { const supabase = createClient(); let mounted = true; - // Safety net: 3s is plenty for localStorage reads. If it hangs here, - // something else is wrong (likely storage permissions in an iframe). const safetyTimer = setTimeout(() => { if (mounted) { - console.warn("[useAuth] getSession() timed out after 3s, clearing loading state"); + console.warn("[AuthProvider] getSession() timed out after 3s"); setLoading(false); } }, 3000); 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") @@ -39,7 +57,7 @@ export function useAuth() { .maybeSingle(); if (error) { - console.error("[useAuth] fetchProfile error:", { + console.error("[AuthProvider] fetchProfile error:", { message: error.message, details: error.details, hint: error.hint, @@ -48,38 +66,32 @@ export function useAuth() { return; } - if (data && mounted) { - setProfile(data); - } + if (data && mounted) setProfile(data); } catch (err) { - console.error("[useAuth] fetchProfile threw:", err); + console.error("[AuthProvider] fetchProfile threw:", err); } }; const init = async () => { try { - // getSession() reads from localStorage — instant, no network call. - // The middleware already validates the JWT server-side with getUser() - // on every request, and RLS enforces authorization at the DB level, - // so the client can trust the local session for UI purposes. const { data: { session }, error, } = await supabase.auth.getSession(); - if (error) { - console.error("[useAuth] getSession error:", error.message); - } + if (error) console.error("[AuthProvider] getSession error:", error.message); if (!mounted) return; const currentUser = session?.user ?? null; setUser(currentUser); if (currentUser) { - await fetchProfile(currentUser.id); + // Don't block loading on profile fetch — let the UI render + // with the user info we already have, profile enriches async. + fetchProfile(currentUser.id); } } catch (err) { - console.error("[useAuth] init threw:", err); + console.error("[AuthProvider] init threw:", err); } finally { if (mounted) setLoading(false); clearTimeout(safetyTimer); @@ -90,13 +102,13 @@ export function useAuth() { const { data: { subscription }, - } = supabase.auth.onAuthStateChange(async (_event, session) => { + } = supabase.auth.onAuthStateChange((_event, session) => { if (!mounted) return; const currentUser = session?.user ?? null; setUser(currentUser); if (currentUser) { - await fetchProfile(currentUser.id); + fetchProfile(currentUser.id); } else { setProfile(null); } @@ -109,8 +121,6 @@ export function useAuth() { clearTimeout(safetyTimer); subscription.unsubscribe(); }; - // Intentionally run once on mount — createClient() is a singleton - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const signOut = useCallback(async () => { @@ -121,5 +131,30 @@ export function useAuth() { window.location.href = "/login"; }, []); - return { user, profile, loading, signOut }; + return ( + + {children} + + ); +} + +/** + * useAuth — read the shared auth state from context. + * Must be used inside an . + */ +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + // Fallback for components rendered outside the provider (shouldn't + // happen in normal flow, but don't crash the page). + return { + user: null, + profile: null, + loading: false, + signOut: async () => { + window.location.href = "/login"; + }, + }; + } + return ctx; }