Summary
src/app/admin/layout.tsx:20-38 runs an async admin-check inside useEffect. It captures user via closure, then awaits an async service call, then calls router.push('/') based on the result. This is the same shape that was reverted three times before in ProtectedRoute (6b4c13a, 2c97e67, 259b38d).
The current implementation includes a cancelled flag that prevents the post-unmount redirect — that's a partial mitigation. The latent risk is that user may change mid-async (token refresh, sign-out, sign-in-as-different-user) and the closure still holds the stale reference, sending the wrong user to / or skipping the redirect entirely.
Code under review
useEffect(() => {
if (authLoading) return;
if (!user) return; // ProtectedRoute handles unauthenticated redirect
let cancelled = false;
(async () => {
const service = new AdminAuthService(supabase);
const admin = await service.checkIsAdmin(user.id); // ← stale-closure risk
if (cancelled) return;
setIsAdmin(admin);
if (!admin && !wasAdmin.current) router.push('/'); // ← acts on stale user
})();
return () => { cancelled = true; };
}, [user, authLoading, router]);
Repro paths (manual; need automated regression cover)
- Mount admin page as admin → token refresh fires →
user ref is replaced → in-flight checkIsAdmin resolves against old user.id → setIsAdmin writes correct value (the value that goes with the OLD user). On unrelated re-renders this can also stash a wrong wasAdmin.current.
- Sign-out during the async call:
cancelled prevents the redirect, but the "if (!user) return" guard ran before cancelled could fire, and setIsAdmin may still race the unmount.
Plan
- Add a regression test first. Mount
<AdminLayout> with a controllable AuthContext, assert correct redirect under: (a) sign-out mid-async, (b) user-id change mid-async, (c) authLoading flipping back to true mid-async. Vitest + React Testing Library. Lives at tests/integration/auth/admin-layout.test.tsx.
- Pin the user reference for the duration of the effect: capture
const targetUserId = user.id before the async, then assert at the resolution point that userRef.current === targetUserId before calling setIsAdmin or router.push. The userRef is a useRef synced via a separate effect.
- Centralize the pattern. This is the same shape as
ProtectedRoute. Both should call into a shared useAuthGate({onAdmin, onNotAdmin}) hook so the next regression has one place to land. Live in src/hooks/useAuthGate.ts.
- Sweep the codebase for other
useEffect blocks that await on a captured user and then router.push — likely candidates: any other layout.tsx files under src/app/, plus MessagingProvider, OnTheAccount page.
Acceptance
- Regression test from step 1 passes
grep -rn 'router\.push.*user\.' src/app/ returns zero direct matches in async closures (use useAuthGate instead)
- No new revert of this shape for 30 days
Reference
Summary
src/app/admin/layout.tsx:20-38runs an async admin-check insideuseEffect. It capturesuservia closure, then awaits an async service call, then callsrouter.push('/')based on the result. This is the same shape that was reverted three times before inProtectedRoute(6b4c13a, 2c97e67, 259b38d).The current implementation includes a
cancelledflag that prevents the post-unmount redirect — that's a partial mitigation. The latent risk is thatusermay change mid-async (token refresh, sign-out, sign-in-as-different-user) and the closure still holds the stale reference, sending the wrong user to/or skipping the redirect entirely.Code under review
Repro paths (manual; need automated regression cover)
userref is replaced → in-flightcheckIsAdminresolves against olduser.id→setIsAdminwrites correct value (the value that goes with the OLD user). On unrelated re-renders this can also stash a wrongwasAdmin.current.cancelledprevents the redirect, but the "if (!user) return" guard ran beforecancelledcould fire, andsetIsAdminmay still race the unmount.Plan
<AdminLayout>with a controllable AuthContext, assert correct redirect under: (a) sign-out mid-async, (b) user-id change mid-async, (c)authLoadingflipping back totruemid-async. Vitest + React Testing Library. Lives attests/integration/auth/admin-layout.test.tsx.const targetUserId = user.idbefore the async, then assert at the resolution point thatuserRef.current === targetUserIdbefore callingsetIsAdminorrouter.push. TheuserRefis auseRefsynced via a separate effect.ProtectedRoute. Both should call into a shareduseAuthGate({onAdmin, onNotAdmin})hook so the next regression has one place to land. Live insrc/hooks/useAuthGate.ts.useEffectblocks that await on a captureduserand thenrouter.push— likely candidates: any otherlayout.tsxfiles undersrc/app/, plusMessagingProvider,OnTheAccountpage.Acceptance
grep -rn 'router\.push.*user\.' src/app/returns zero direct matches in async closures (useuseAuthGateinstead)Reference