fix: share auth via React Context to prevent getSession() timeouts

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 <AuthProvider>
  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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-16 20:23:05 +03:00
parent 7cc6a840d5
commit 186c9f0e1e
No known key found for this signature in database
5 changed files with 114 additions and 58 deletions

View File

@ -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 (
<div className="flex h-screen overflow-hidden bg-slate-950">
@ -45,3 +39,15 @@ export default function DashboardLayout({
</div>
);
}
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<DashboardShell>{children}</DashboardShell>
</AuthProvider>
);
}

View File

@ -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<Tag[]>([]);
@ -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');

View File

@ -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<MessageTemplate[]>([]);
@ -71,19 +73,23 @@ export function TemplateManager() {
const [form, setForm] = useState<TemplateFormData>(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');

View File

@ -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');

View File

@ -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<void>;
}
const AuthContext = createContext<AuthContextValue | null>(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<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(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 (
<AuthContext.Provider value={{ user, profile, loading, signOut }}>
{children}
</AuthContext.Provider>
);
}
/**
* useAuth — read the shared auth state from context.
* Must be used inside an <AuthProvider>.
*/
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;
}