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 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 [checkingMaintenance, setCheckingMaintenance] = useState(true);
@@ -75,6 +75,22 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<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 */}
<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 */}
@@ -350,11 +366,17 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
{/* Demo Switcher — only visible when logged in as a known demo account */}
{isDemoUser(userId) && (
<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
value={userId}
disabled={switching}
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.AI_COACH.id}>Rahul (AI Coach)</option>

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
export interface UserProfile {
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() {
const [userId, setUserIdState] = useState<string>(() => {
if (typeof window !== "undefined") {
@@ -110,16 +133,24 @@ export function useActiveUser() {
const [user, setUser] = useState<UserProfile | null>(null);
const [globalSettings, setGlobalSettings] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [switching, setSwitching] = useState(false);
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) => {
if (!isPoll) setLoading(true);
setError(null);
try {
const res = await fetch(`/api/users/${id}`);
const settingsRes = await fetch(`/api/admin/settings`, { cache: "no-store" });
// ── PERF FIX: run both fetches in PARALLEL instead of sequentially ──
const [res, settingsData] = await Promise.all([
fetch(`/api/users/${id}`),
fetchSettings(),
]);
if (!res.ok) {
if (!isPoll) {
setError("Failed to fetch user profile");
@@ -127,13 +158,13 @@ export function useActiveUser() {
}
return;
}
const data = await res.json();
if (settingsRes.ok) {
const settingsData = await settingsRes.json();
if (settingsData) {
setGlobalSettings(settingsData);
}
failCountRef.current = 0;
// Single Active Session Check
@@ -150,8 +181,7 @@ export function useActiveUser() {
setUser(data);
} catch (err: any) {
// Network error (backend down / restarting) — only surface on initial load
failCountRef.current = (failCountRef.current || 0) + 1;
failCountRef.current += 1;
if (!isPoll) {
console.warn("Backend unreachable, will retry:", err.message);
setError("Backend unreachable — retrying...");
@@ -162,55 +192,80 @@ export function useActiveUser() {
}
}, []);
// Initial fetch
// Initial fetch on mount
useEffect(() => {
if (typeof window !== "undefined") {
const storedId = localStorage.getItem("activeUserId") || DEFAULT_USER_ID;
activeUserIdRef.current = storedId;
setUserIdState(storedId);
fetchUserProfile(storedId);
}
}, [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(() => {
if (typeof window === "undefined") return;
const handleUpdate = () => fetchUserProfile(userId);
const handleUpdate = () => fetchUserProfile(activeUserIdRef.current);
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(() => {
fetchUserProfile(userId, true);
}, 30000);
fetchUserProfile(activeUserIdRef.current, true);
}, 30_000);
return () => {
window.removeEventListener("pragativeer-user-updated", handleUpdate);
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) => {
if (typeof window !== "undefined") {
document.cookie = `auth_token=${newId}; path=/; max-age=2592000; SameSite=Lax`;
const sessionToken = crypto.randomUUID();
localStorage.setItem("active_session_token", sessionToken);
try {
await fetch(`/api/users/${newId}/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken })
});
} catch (err) {
console.error("Failed to set session", err);
if (typeof window === "undefined" || newId === activeUserIdRef.current) return;
setSwitching(true);
// ── Update cookie + localStorage immediately (optimistic) ───────────────
document.cookie = `auth_token=${newId}; path=/; max-age=2592000; SameSite=Lax`;
const sessionToken = crypto.randomUUID();
localStorage.setItem("active_session_token", sessionToken);
localStorage.setItem("activeUserId", newId);
// 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);
}
localStorage.setItem("activeUserId", newId);
setUserIdState(newId);
window.location.reload();
} catch {
// Network error — fall back to full fetch
await fetchUserProfile(newId);
} finally {
setSwitching(false);
}
}, []);
// ── NO window.location.reload() — React state update is instant ─────────
}, [fetchUserProfile]);
const logout = useCallback(() => {
if (typeof window !== "undefined") {
@@ -223,11 +278,11 @@ export function useActiveUser() {
const hasAccessTo = useCallback((feature: string): boolean => {
if (!user) return false;
// Compute effective tier via trial logic
const { effectiveTier } = computeTrialInfo(user, globalSettings);
const tier = effectiveTier;
// Core restrictions mapping based on PRD feature matrix
switch (feature) {
case "topic-wise-microtests-unlimited":
@@ -241,7 +296,7 @@ export function useActiveUser() {
case "offline-mode":
case "pdf-export":
return tier === "Pro" || tier === "Al_Coach";
case "ai-coach-custom-schedules":
case "personalized-study-plan":
case "ai-tutor-doubt-resolution":
@@ -249,7 +304,7 @@ export function useActiveUser() {
case "ai-readiness-prediction":
case "ai-tutor-chat":
return tier === "Al_Coach";
default:
// Free tier features: DPS (1/day), basic dashboard, basic streak tracking, leaderboard
return true;
@@ -261,6 +316,7 @@ export function useActiveUser() {
user,
globalSettings,
loading,
switching, // expose switching state so layout can show a brief spinner
error,
switchUser,
logout,
@@ -268,7 +324,7 @@ export function useActiveUser() {
trialInfo: computeTrialInfo(user, globalSettings),
refreshUser: async () => {
await fetchUserProfile(userId);
window.dispatchEvent(new CustomEvent('pragativeer-user-updated'));
}
window.dispatchEvent(new CustomEvent("pragativeer-user-updated"));
},
};
}