Skip to content

Commit 6eaaf7c

Browse files
committed
perf: cut ~800ms off dashboard first paint
Three changes that target the auth waterfall before bootstrap fires: 1. auth-provider: drop the unconditional currentUser.reload() and stop forcing getIdToken(true) on every onAuthStateChanged. Both were extra round-trips to Firebase on every page load. The cached token is still server-verifiable and emailVerified is persisted in the SDK's local store. The "I changed something, pick it up" path (refreshUser) still passes forceRefresh: true so the email- verification flow keeps working. 2. auth-provider: expose firebaseUser on the context immediately when onAuthStateChanged fires — before /api/user/profile is even called. Consumers can now key off that to start their own authenticated fetches in parallel with profile. 3. dashboard-container: key the bootstrap effect on firebaseUser?.uid instead of user?.uid. Bootstrap now fires the moment Firebase confirms a session, in parallel with the auth-provider's profile fetch. The dashboard's render gate still waits on both (user + isLoading), so wall-clock load = max(profile, bootstrap) instead of profile + bootstrap. Drops the redirect-to-login gate to only fire after authLoading actually completes, so the parallel kick- off doesn't accidentally bounce the user mid-init. Combined savings on dashboard cold load: ~600-1000ms depending on Firebase / network. No change to security posture — every API call still verifies the ID token server-side.
1 parent d0785b9 commit 6eaaf7c

2 files changed

Lines changed: 51 additions & 23 deletions

File tree

components/providers/auth-provider.tsx

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ interface UserProfile {
3333

3434
interface AuthContextType {
3535
user: UserProfile | null;
36+
// The raw Firebase user, exposed so consumers (like the dashboard) can
37+
// start their own authenticated fetches as soon as Firebase confirms the
38+
// session — without waiting for /api/user/profile to also come back.
39+
firebaseUser: FirebaseUser | null;
3640
loading: boolean;
3741
login: (email: string, password: string) => Promise<void>;
3842
register: (formData: FormData) => Promise<void>;
@@ -45,6 +49,7 @@ interface AuthContextType {
4549

4650
const AuthContext = createContext<AuthContextType>({
4751
user: null,
52+
firebaseUser: null,
4853
loading: true,
4954
login: async () => { },
5055
register: async () => { },
@@ -59,15 +64,28 @@ import { useToast } from "@/hooks/use-toast";
5964

6065
export function AuthProvider({ children }: { children: React.ReactNode }) {
6166
const [user, setUser] = useState<UserProfile | null>(null);
67+
const [firebaseUser, setFirebaseUser] = useState<FirebaseUser | null>(null);
6268
const [loading, setLoading] = useState(true);
6369
const router = useRouter();
6470
const { toast } = useToast();
6571

66-
const fetchUserProfile = async (currentUser: FirebaseUser) => {
72+
// forceRefresh: when true, force-reloads the Firebase user (network call)
73+
// and force-refreshes the ID token (another network call). Use this only
74+
// when something has actually changed server-side that we need to pick
75+
// up — e.g. after email verification (emailVerified flag flipped) or an
76+
// explicit "refresh" action.
77+
// Default false: skip both extra round-trips. The cached token is still
78+
// valid (Firebase auto-refreshes ~5min before expiry) and emailVerified
79+
// is persisted in the SDK's local store. Saves ~600-800ms per call.
80+
const fetchUserProfile = async (
81+
currentUser: FirebaseUser,
82+
forceRefresh: boolean = false,
83+
) => {
6784
try {
68-
// Force reload user to get latest emailVerified status
69-
await currentUser.reload();
70-
const token = await currentUser.getIdToken(true);
85+
if (forceRefresh) {
86+
await currentUser.reload();
87+
}
88+
const token = await currentUser.getIdToken(forceRefresh);
7189
const response = await fetch(API_ENDPOINTS.userProfile, {
7290
headers: {
7391
Authorization: `Bearer ${token}`,
@@ -112,9 +130,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
112130
};
113131

114132
useEffect(() => {
115-
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
116-
if (firebaseUser) {
117-
await fetchUserProfile(firebaseUser);
133+
const unsubscribe = onAuthStateChanged(auth, async (fbUser) => {
134+
// Publish the Firebase user IMMEDIATELY (before profile fetch) so
135+
// consumers can fire their own authenticated calls in parallel
136+
// with /api/user/profile instead of waiting in series.
137+
setFirebaseUser(fbUser);
138+
if (fbUser) {
139+
await fetchUserProfile(fbUser);
118140
} else {
119141
setUser(null);
120142
}
@@ -248,10 +270,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
248270

249271
const refreshUser = useCallback(async () => {
250272
if (auth.currentUser) {
251-
// Force token refresh to update emailVerified claim in the token if needed
252-
await auth.currentUser.reload();
253273
setLoading(true);
254-
await fetchUserProfile(auth.currentUser);
274+
// Force reload + token refresh — this is the explicit "I changed
275+
// something server-side, pick it up" path (e.g. after email
276+
// verification). fetchUserProfile handles both internally.
277+
await fetchUserProfile(auth.currentUser, true);
255278
setLoading(false);
256279
}
257280
}, []);
@@ -292,6 +315,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
292315

293316
const contextValue = useMemo(() => ({
294317
user,
318+
firebaseUser,
295319
loading,
296320
login,
297321
register,
@@ -300,7 +324,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
300324
getToken,
301325
sendVerificationEmail,
302326
resetPassword
303-
}), [user, loading, login, register, logout, refreshUser, getToken, sendVerificationEmail, resetPassword]);
327+
}), [user, firebaseUser, loading, login, register, logout, refreshUser, getToken, sendVerificationEmail, resetPassword]);
304328

305329
return (
306330
<AuthContext.Provider value={contextValue}>

components/registration/dashboard-container.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ function StatusStrip({
253253
}
254254

255255
export function DashboardContainer() {
256-
const { user, isAuthenticated, isLoading: authLoading, getToken } = useAuth();
256+
const { user, firebaseUser, isLoading: authLoading, getToken } = useAuth();
257257
const router = useRouter();
258258
const { toast } = useToast();
259259
const [team, setTeam] = useState<Team | null>(null);
@@ -351,11 +351,15 @@ export function DashboardContainer() {
351351
}, [user]);
352352

353353
useEffect(() => {
354-
// Wait for the auth provider to finish initialising before deciding
355-
// whether to redirect or kick off the data fetch.
356-
if (authLoading) return;
357-
if (!isAuthenticated || !user) {
358-
router.push("/login");
354+
// We kick off /api/me/bootstrap the moment Firebase confirms a session,
355+
// in parallel with the auth-provider's /api/user/profile fetch — they're
356+
// independent, and waiting for profile to finish before starting bootstrap
357+
// adds an unnecessary serial RTT to the dashboard's first paint.
358+
if (!firebaseUser) {
359+
// No Firebase session. Only redirect once the auth-provider has actually
360+
// finished initialising — before that, firebaseUser==null just means
361+
// "we haven't heard back from onAuthStateChanged yet".
362+
if (!authLoading) router.push("/login");
359363
return;
360364
}
361365

@@ -447,13 +451,13 @@ export function DashboardContainer() {
447451
};
448452

449453
fetchData();
450-
// Depend on user?.uid (stable string) rather than user (object reference
451-
// that the auth-provider recreates on every emailVerified refresh / Strict
452-
// Mode double-mount). Without this, bootstrap re-fires whenever the user
453-
// object identity changes — even though the actual uid hasn't — and the
454-
// dashboard flashes back into its skeleton state.
454+
// Key on firebaseUser?.uid (stable string available as soon as Firebase
455+
// confirms the session) rather than user?.uid (set later, after profile
456+
// fetch). This lets bootstrap run in parallel with /api/user/profile
457+
// instead of in series. Strict Mode / token rotations don't change uid,
458+
// so the effect doesn't re-fire and flash the skeleton.
455459
// eslint-disable-next-line react-hooks/exhaustive-deps
456-
}, [user?.uid, isAuthenticated, authLoading, router, refreshTrigger]);
460+
}, [firebaseUser?.uid, authLoading, router, refreshTrigger]);
457461

458462
const getTeamStatus = ():
459463
| "none"

0 commit comments

Comments
 (0)