From 49aa6ac989f878476d2e8e9b6ee5f6885f491846 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:54:22 +0000 Subject: [PATCH] fix(portal): prevent premature redirect with sessionAttempted flag Fixes E2E race condition where setSession and setInitComplete are batched by React concurrent rendering, causing redirect to fire before session is set. The sessionAttempted flag tracks "did we try" so redirect only fires when there was NO attempt, not when the state update is pending. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/portal/CustomerPortal.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index aa877f2..d725a2d 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -39,6 +39,9 @@ export function CustomerPortal() { const [sessionExtended, setSessionExtended] = useState(false); const [clientName, setClientName] = useState(""); const [initComplete, setInitComplete] = useState(false); + // Track whether we've attempted to fetch a session — used to prevent premature redirect + // when a session fetch is in-flight (E2E mocks resolve synchronously, batched with setInitComplete) + const [sessionAttempted, setSessionAttempted] = useState(false); const { branding } = useBranding(); const [searchParams, setSearchParams] = useSearchParams(); @@ -60,6 +63,7 @@ export function CustomerPortal() { .then((s) => { if (s && s.status === "active") { setSession(s); + setSessionAttempted(true); fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } }) .then(r => r.ok ? r.json() : null) .then(data => { if (data?.name) setClientName(data.name); }) @@ -68,6 +72,7 @@ export function CustomerPortal() { setSearchParams({}, { replace: true }); }) .catch(() => { + setSessionAttempted(true); setSearchParams({}, { replace: true }); }) .finally(() => setInitComplete(true)); @@ -89,10 +94,11 @@ export function CustomerPortal() { .then((s) => { if (s && s.id) { setSession(s); + setSessionAttempted(true); setClientName(devUser.name); } }) - .catch(() => {}) + .catch(() => { setSessionAttempted(true); }) .finally(() => setInitComplete(true)); } else { // No valid session: staff dev users and unauthenticated users fall through here @@ -175,8 +181,11 @@ export function CustomerPortal() { const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase(); // After init completes, redirect unauthenticated users to /login and staff to /admin - // The portal chrome must NEVER be visible to users without a valid client session - if (initComplete && !session) { + // The portal chrome must NEVER be visible to users without a valid client session. + // Only redirect if we have NOT attempted a session fetch yet — if a fetch is in-flight + // (E2E mock resolves synchronously, batched with setInitComplete), sessionAttempted + // is still false so we don't redirect prematurely. + if (initComplete && !session && !sessionAttempted) { const devUser = getDevUser(); if (devUser && devUser.type === "staff") { return ; @@ -251,7 +260,7 @@ export function CustomerPortal() {
{branding.logoBase64 && branding.logoMimeType ? (