Merge pull request #128 from ArnasDon/fix/flows-spurious-redirect

fix(flows): bounce-to-dashboard race when navigating into /flows
This commit is contained in:
Arnas Donauskas 2026-05-22 16:07:25 +03:00 committed by GitHub
commit 9febfae9b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 57 additions and 15 deletions

View File

@ -23,7 +23,7 @@ import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
export default function FlowEditorPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const { profile, loading: authLoading } = useAuth();
const { profile, loading: authLoading, profileLoading } = useAuth();
const [flow, setFlow] = useState<FlowRow | null>(null);
const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
@ -33,7 +33,11 @@ export default function FlowEditorPage() {
const allowed = isFlowsEnabled(profile);
useEffect(() => {
if (authLoading) return;
// Wait for BOTH session and profile — see the comment on the same
// gate in /flows/page.tsx. Without `profileLoading`, navigating
// here from the list shows the `{ loading: false, profile: null }`
// window and would bounce a legitimate beta user.
if (authLoading || profileLoading) return;
if (!allowed) {
router.replace("/dashboard");
return;
@ -68,9 +72,9 @@ export default function FlowEditorPage() {
return () => {
cancelled = true;
};
}, [allowed, authLoading, params.id, router]);
}, [allowed, authLoading, profileLoading, params.id, router]);
if (authLoading || (allowed && loading)) {
if (authLoading || profileLoading || (allowed && loading)) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />

View File

@ -98,7 +98,7 @@ const STATUS_META: Record<
export default function FlowRunsPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const { profile, loading: authLoading } = useAuth();
const { profile, loading: authLoading, profileLoading } = useAuth();
const [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
const [runs, setRuns] = useState<RunRow[]>([]);
@ -110,7 +110,8 @@ export default function FlowRunsPage() {
const allowed = isFlowsEnabled(profile);
useEffect(() => {
if (authLoading) return;
// Wait for BOTH session and profile — see /flows/page.tsx.
if (authLoading || profileLoading) return;
if (!allowed) {
router.replace("/dashboard");
return;
@ -147,7 +148,7 @@ export default function FlowRunsPage() {
return () => {
cancelled = true;
};
}, [allowed, authLoading, params.id, router]);
}, [allowed, authLoading, profileLoading, params.id, router]);
function toggle(runId: string) {
setExpanded((prev) => {
@ -158,7 +159,7 @@ export default function FlowRunsPage() {
});
}
if (authLoading || (allowed && loading)) {
if (authLoading || profileLoading || (allowed && loading)) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />

View File

@ -84,7 +84,7 @@ const TEMPLATE_ICONS = {
export default function FlowsPage() {
const router = useRouter();
const { profile, loading: authLoading } = useAuth();
const { profile, loading: authLoading, profileLoading } = useAuth();
const [flows, setFlows] = useState<FlowRow[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
@ -95,7 +95,12 @@ export default function FlowsPage() {
const flowsAccessAllowed = isFlowsEnabled(profile);
useEffect(() => {
if (authLoading) return;
// Wait for BOTH the session and the profile row. Session loads
// fast (cookie read) but profile crosses the network — during the
// gap `profile` is null and `isFlowsEnabled(null)` is false, which
// would bounce a legitimate beta user back to /dashboard on every
// navigation here.
if (authLoading || profileLoading) return;
if (!flowsAccessAllowed) {
// Bounce non-beta users — the API would 404 every call anyway.
router.replace("/dashboard");
@ -133,7 +138,7 @@ export default function FlowsPage() {
return () => {
cancelled = true;
};
}, [authLoading, flowsAccessAllowed, router]);
}, [authLoading, profileLoading, flowsAccessAllowed, router]);
async function handleCreate() {
if (!newName.trim()) return;
@ -200,7 +205,7 @@ export default function FlowsPage() {
}
}
if (authLoading || (flowsAccessAllowed && loading)) {
if (authLoading || profileLoading || (flowsAccessAllowed && loading)) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />

View File

@ -29,7 +29,21 @@ interface Profile {
interface AuthContextValue {
user: User | null;
profile: Profile | null;
/**
* Session-level loading. Flips to false as soon as we know whether
* a user is signed in, *without* waiting for the profile row. Use
* this for chrome (sidebar / header) that can render with just the
* user object.
*/
loading: boolean;
/**
* Profile-row loading. Stays true until `fetchProfile` settles
* (success, missing row, or error). Code that branches on
* `profile.beta_features` MUST gate on this — otherwise it sees the
* `{ loading: false, profile: null }` window during initial load
* and may take the "not opted in" branch incorrectly.
*/
profileLoading: boolean;
signOut: () => Promise<void>;
/** Re-fetch the current user's profile row — call after a save from
* the settings form so header/sidebar reflect the change without a
@ -48,12 +62,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
// Tracked separately from `loading`. The session settles fast (one
// local cookie read); the profile fetch crosses the network and
// settles later. Callers that gate on `profile.*` need to know which
// window they're in — see the type doc above.
const [profileLoading, setProfileLoading] = useState(true);
// Shared across init, auth-state-change listener, and the exposed
// refreshProfile() callback. Reads the current session's user id and
// pulls the matching profile row.
const fetchProfile = useCallback(async (userId: string) => {
const supabase = createClient();
setProfileLoading(true);
try {
const { data, error } = await supabase
.from("profiles")
@ -83,6 +103,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
} catch (err) {
console.error("[AuthProvider] fetchProfile threw:", err);
} finally {
setProfileLoading(false);
}
}, []);
@ -94,6 +116,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (mounted) {
console.warn("[AuthProvider] getSession() timed out after 3s");
setLoading(false);
setProfileLoading(false);
}
}, 3000);
@ -111,9 +134,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(currentUser);
if (currentUser) {
// Don't block loading on profile fetch — let the UI render
// with the user info we already have, profile enriches async.
// Don't block session loading on profile fetch — chrome
// (header, sidebar) can render from the user object alone,
// profile enriches async. Callers that need to branch on
// profile data gate on `profileLoading` instead.
fetchProfile(currentUser.id);
} else {
// No user → no profile to load. Flip profileLoading off so
// pages that gate on it don't wait forever on the logged-out
// path (the route guard or redirect should fire instead).
setProfileLoading(false);
}
} catch (err) {
console.error("[AuthProvider] init threw:", err);
@ -136,6 +166,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
fetchProfile(currentUser.id);
} else {
setProfile(null);
setProfileLoading(false);
}
setLoading(false);
@ -163,7 +194,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return (
<AuthContext.Provider
value={{ user, profile, loading, signOut, refreshProfile }}
value={{ user, profile, loading, profileLoading, signOut, refreshProfile }}
>
{children}
</AuthContext.Provider>
@ -183,6 +214,7 @@ export function useAuth(): AuthContextValue {
user: null,
profile: null,
loading: false,
profileLoading: false,
signOut: async () => {
window.location.href = "/login";
},