perf: eliminate demo account switch lag - no more page reload, parallel fetches, settings cache

This commit is contained in:
2026-09-20 12:15:33 +05:30
parent 896073ea2d
commit 0fab60b710
2 changed files with 124 additions and 46 deletions

View File

@@ -20,7 +20,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
const [dropdownOpen, setDropdownOpen] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
const { userId, user, globalSettings, switchUser, logout, hasAccessTo, loading, trialInfo } = useActiveUser(); const { userId, user, globalSettings, switchUser, logout, hasAccessTo, loading, switching, trialInfo } = useActiveUser();
const [maintenance, setMaintenance] = useState(false); const [maintenance, setMaintenance] = useState(false);
const [checkingMaintenance, setCheckingMaintenance] = useState(true); const [checkingMaintenance, setCheckingMaintenance] = useState(true);
@@ -75,6 +75,22 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="flex h-screen bg-background overflow-hidden text-foreground selection:bg-primary/20"> <div className="flex h-screen bg-background overflow-hidden text-foreground selection:bg-primary/20">
{/* Demo Account Switch — slim top progress bar, no full-page reload */}
{switching && (
<div className="fixed top-0 left-0 right-0 z-[9999] h-0.5 overflow-hidden">
<div
className="h-full bg-[#00F0FF] animate-[slideProgress_0.8s_ease-in-out_infinite]"
style={{ width: '60%', animation: 'slideProgress 0.9s ease-in-out infinite' }}
/>
<style>{`
@keyframes slideProgress {
0% { transform: translateX(-100%); width: 60%; }
50% { transform: translateX(70%); width: 80%; }
100% { transform: translateX(200%); width: 60%; }
}
`}</style>
</div>
)}
{/* Sidebar */} {/* Sidebar */}
<aside className="w-64 border-r border-border/60 bg-background/95 backdrop-blur-xl flex flex-col hidden md:flex relative"> <aside className="w-64 border-r border-border/60 bg-background/95 backdrop-blur-xl flex flex-col hidden md:flex relative">
{/* Subtle gradient overlay */} {/* Subtle gradient overlay */}
@@ -350,11 +366,17 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
{/* Demo Switcher — only visible when logged in as a known demo account */} {/* Demo Switcher — only visible when logged in as a known demo account */}
{isDemoUser(userId) && ( {isDemoUser(userId) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground font-bold hidden lg:inline">Demo:</span> {switching && (
<span className="text-xs text-[#00F0FF] font-bold animate-pulse hidden lg:inline">Switching…</span>
)}
{!switching && (
<span className="text-xs text-muted-foreground font-bold hidden lg:inline">Demo:</span>
)}
<select <select
value={userId} value={userId}
disabled={switching}
onChange={(e) => switchUser(e.target.value)} onChange={(e) => switchUser(e.target.value)}
className="bg-secondary border border-border text-foreground px-2.5 py-1.5 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#00F0FF] outline-none cursor-pointer hover:bg-secondary/80 transition-colors" className="bg-secondary border border-border text-foreground px-2.5 py-1.5 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#00F0FF] outline-none cursor-pointer hover:bg-secondary/80 transition-colors disabled:opacity-50 disabled:cursor-wait"
> >
<option value={DEMO_USERS.NEHA.id}>Neha Verma (AI Coach)</option> <option value={DEMO_USERS.NEHA.id}>Neha Verma (AI Coach)</option>
<option value={DEMO_USERS.AI_COACH.id}>Rahul (AI Coach)</option> <option value={DEMO_USERS.AI_COACH.id}>Rahul (AI Coach)</option>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
export interface UserProfile { export interface UserProfile {
id: string; id: string;
@@ -99,6 +99,29 @@ export function computeTrialInfo(user: UserProfile | null, globalSettings: any):
}; };
} }
// ── Global in-memory settings cache ─────────────────────────────────────────
// Settings rarely change; cache them for 60s so every user switch doesn't
// re-hit the backend. On a full page load the cache is cold — fine.
let cachedSettings: any = null;
let settingsCacheExpiry = 0;
async function fetchSettings(): Promise<any> {
const now = Date.now();
if (cachedSettings && now < settingsCacheExpiry) return cachedSettings;
try {
const res = await fetch(`/api/admin/settings`, { cache: "no-store" });
if (res.ok) {
cachedSettings = await res.json();
settingsCacheExpiry = now + 60_000; // cache for 60 seconds
return cachedSettings;
}
} catch {
// silently return stale cache if available
}
return cachedSettings ?? null;
}
export function useActiveUser() { export function useActiveUser() {
const [userId, setUserIdState] = useState<string>(() => { const [userId, setUserIdState] = useState<string>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -110,15 +133,23 @@ export function useActiveUser() {
const [user, setUser] = useState<UserProfile | null>(null); const [user, setUser] = useState<UserProfile | null>(null);
const [globalSettings, setGlobalSettings] = useState<any>(null); const [globalSettings, setGlobalSettings] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [switching, setSwitching] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const failCountRef = { current: 0 }; // Use a ref for fail count so it doesn't cause re-renders
const failCountRef = useRef(0);
// Track the active userId in a ref to avoid stale closures in poll interval
const activeUserIdRef = useRef(userId);
const fetchUserProfile = useCallback(async (id: string, isPoll: boolean = false) => { const fetchUserProfile = useCallback(async (id: string, isPoll: boolean = false) => {
if (!isPoll) setLoading(true); if (!isPoll) setLoading(true);
setError(null); setError(null);
try { try {
const res = await fetch(`/api/users/${id}`); // ── PERF FIX: run both fetches in PARALLEL instead of sequentially ──
const settingsRes = await fetch(`/api/admin/settings`, { cache: "no-store" }); const [res, settingsData] = await Promise.all([
fetch(`/api/users/${id}`),
fetchSettings(),
]);
if (!res.ok) { if (!res.ok) {
if (!isPoll) { if (!isPoll) {
@@ -129,8 +160,8 @@ export function useActiveUser() {
} }
const data = await res.json(); const data = await res.json();
if (settingsRes.ok) {
const settingsData = await settingsRes.json(); if (settingsData) {
setGlobalSettings(settingsData); setGlobalSettings(settingsData);
} }
@@ -150,8 +181,7 @@ export function useActiveUser() {
setUser(data); setUser(data);
} catch (err: any) { } catch (err: any) {
// Network error (backend down / restarting) — only surface on initial load failCountRef.current += 1;
failCountRef.current = (failCountRef.current || 0) + 1;
if (!isPoll) { if (!isPoll) {
console.warn("Backend unreachable, will retry:", err.message); console.warn("Backend unreachable, will retry:", err.message);
setError("Backend unreachable — retrying..."); setError("Backend unreachable — retrying...");
@@ -162,55 +192,80 @@ export function useActiveUser() {
} }
}, []); }, []);
// Initial fetch // Initial fetch on mount
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const storedId = localStorage.getItem("activeUserId") || DEFAULT_USER_ID; const storedId = localStorage.getItem("activeUserId") || DEFAULT_USER_ID;
activeUserIdRef.current = storedId;
setUserIdState(storedId); setUserIdState(storedId);
fetchUserProfile(storedId); fetchUserProfile(storedId);
} }
}, [fetchUserProfile]); }, [fetchUserProfile]);
// Session validity polling — only runs when we already have a user // Session validity polling — runs against the ref so switching userId
// doesn't restart the interval and cause an immediate double-fetch
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const handleUpdate = () => fetchUserProfile(userId); const handleUpdate = () => fetchUserProfile(activeUserIdRef.current);
window.addEventListener("pragativeer-user-updated", handleUpdate); window.addEventListener("pragativeer-user-updated", handleUpdate);
// Poll every 30s (was 10s — less aggressive, kinder on backend restarts) // Poll every 30s (less aggressive, kinder on backend)
const interval = setInterval(() => { const interval = setInterval(() => {
fetchUserProfile(userId, true); fetchUserProfile(activeUserIdRef.current, true);
}, 30000); }, 30_000);
return () => { return () => {
window.removeEventListener("pragativeer-user-updated", handleUpdate); window.removeEventListener("pragativeer-user-updated", handleUpdate);
clearInterval(interval); clearInterval(interval);
}; };
}, [userId, fetchUserProfile]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run once — ref keeps the latest userId
const switchUser = useCallback(async (newId: string) => { const switchUser = useCallback(async (newId: string) => {
if (typeof window !== "undefined") { if (typeof window === "undefined" || newId === activeUserIdRef.current) return;
document.cookie = `auth_token=${newId}; path=/; max-age=2592000; SameSite=Lax`;
const sessionToken = crypto.randomUUID();
localStorage.setItem("active_session_token", sessionToken);
try { setSwitching(true);
await fetch(`/api/users/${newId}/session`, {
method: 'POST', // ── Update cookie + localStorage immediately (optimistic) ───────────────
headers: { 'Content-Type': 'application/json' }, document.cookie = `auth_token=${newId}; path=/; max-age=2592000; SameSite=Lax`;
body: JSON.stringify({ session_token: sessionToken }) const sessionToken = crypto.randomUUID();
}); localStorage.setItem("active_session_token", sessionToken);
} catch (err) { localStorage.setItem("activeUserId", newId);
console.error("Failed to set session", err);
// Update refs and state before awaiting network
activeUserIdRef.current = newId;
setUserIdState(newId);
setUser(null); // Clear old user immediately so stale data isn't shown
try {
// ── Run session update + new user profile fetch IN PARALLEL ─────────
const [_, newUserRes] = await Promise.all([
// Fire-and-forget session token update
fetch(`/api/users/${newId}/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_token: sessionToken }),
}).catch(() => {}), // ignore failure — non-critical
// Fetch the new user's profile immediately
fetch(`/api/users/${newId}`),
]);
if (newUserRes && newUserRes.ok) {
const data = await newUserRes.json();
setUser(data);
} else {
// Fallback: use the regular fetch path
await fetchUserProfile(newId);
} }
} catch {
localStorage.setItem("activeUserId", newId); // Network error — fall back to full fetch
setUserIdState(newId); await fetchUserProfile(newId);
window.location.reload(); } finally {
setSwitching(false);
} }
}, []); // ── NO window.location.reload() — React state update is instant ─────────
}, [fetchUserProfile]);
const logout = useCallback(() => { const logout = useCallback(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -261,6 +316,7 @@ export function useActiveUser() {
user, user,
globalSettings, globalSettings,
loading, loading,
switching, // expose switching state so layout can show a brief spinner
error, error,
switchUser, switchUser,
logout, logout,
@@ -268,7 +324,7 @@ export function useActiveUser() {
trialInfo: computeTrialInfo(user, globalSettings), trialInfo: computeTrialInfo(user, globalSettings),
refreshUser: async () => { refreshUser: async () => {
await fetchUserProfile(userId); await fetchUserProfile(userId);
window.dispatchEvent(new CustomEvent('pragativeer-user-updated')); window.dispatchEvent(new CustomEvent("pragativeer-user-updated"));
} },
}; };
} }