mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
fix(native): route to the right screen when the session is lost
The session-loss effect never ran because sessionLost was computed in a render that also called setState, so the committed value was always false. Compare against a ref inside the effect instead, re-validate the session on 401s, stop retrying 4xx errors, and show the change-server link on OIDC-only login screens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+111
-45
No files matched your search
@@ -210,26 +210,26 @@ export default function LoginScreen() {
|
||||
</Link>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<Animated.View entering={FadeIn.duration(300).delay(500)} className="mt-8 items-center">
|
||||
<Link href="/(auth)/server-url" replace asChild>
|
||||
<Pressable
|
||||
disabled={busy}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: busy }}
|
||||
className="flex-row items-center gap-1.5"
|
||||
>
|
||||
<ScaledIcon icon={IconServer2} size={14} color={statusCompletedColor} />
|
||||
<Text className="text-muted-foreground font-sans text-xs">
|
||||
<Trans>
|
||||
Connected to <Text className="font-medium">{serverHost}</Text>. Tap to change.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Animated.View entering={FadeIn.duration(300).delay(500)} className="mt-8 items-center">
|
||||
<Link href="/(auth)/server-url" replace asChild>
|
||||
<Pressable
|
||||
disabled={busy}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: busy }}
|
||||
className="flex-row items-center gap-1.5"
|
||||
>
|
||||
<ScaledIcon icon={IconServer2} size={14} color={statusCompletedColor} />
|
||||
<Text className="text-muted-foreground font-sans text-xs">
|
||||
<Trans>
|
||||
Connected to <Text className="font-medium">{serverHost}</Text>. Tap to change.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</Animated.View>
|
||||
</AuthScreen>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { clearStorageScope, hasScopedStorage, setStorageScope } from "@/lib/mmkv";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
authClient,
|
||||
clearCachedSessionSeeded,
|
||||
consumeServerChangeRequest,
|
||||
consumeSessionRejected,
|
||||
ensureInstanceId,
|
||||
getCurrentInstanceId,
|
||||
hasStoredServerUrl,
|
||||
@@ -95,40 +96,39 @@ export function useServerConnection() {
|
||||
// confirmed by the server. Once confirmed, flip to false so explicit
|
||||
// sign-outs don't show a misleading "session expired" toast.
|
||||
const [hadOptimisticSession, setHadOptimisticSession] = useState(wasCachedSessionSeeded);
|
||||
const [prevSession, setPrevSession] = useState(session);
|
||||
|
||||
if (hadOptimisticSession && session && !isRefetching) {
|
||||
setHadOptimisticSession(false);
|
||||
}
|
||||
|
||||
// Detect session loss during render so the effect doesn't need to call
|
||||
// setPrevSession (which triggers the set-state-in-effect lint rule).
|
||||
let sessionLost = false;
|
||||
if (prevSession !== session) {
|
||||
if (prevSession && !session) {
|
||||
sessionLost = true;
|
||||
}
|
||||
setPrevSession(session);
|
||||
}
|
||||
|
||||
const { replace } = useRouter();
|
||||
const prevSessionRef = useRef(session);
|
||||
|
||||
// Navigate to auth when session is lost. Stack.Protected handles screen
|
||||
// availability, but enableFreeze can prevent the navigator from
|
||||
// transitioning on its own.
|
||||
// transitioning on its own. The previous-session comparison lives in the
|
||||
// effect (via a ref) because a render-phase setState discards that render's
|
||||
// locals, so a render-local "sessionLost" flag would never be committed.
|
||||
useEffect(() => {
|
||||
if (sessionLost) {
|
||||
const changingServer = consumeServerChangeRequest();
|
||||
replace(changingServer ? "/(auth)/server-url" : "/(auth)/login");
|
||||
|
||||
if (hadOptimisticSession) {
|
||||
toast.info(i18n._(msg`Session expired`), {
|
||||
description: i18n._(msg`Please sign in again.`),
|
||||
});
|
||||
clearCachedSessionSeeded();
|
||||
}
|
||||
const hadSession = prevSessionRef.current != null;
|
||||
prevSessionRef.current = session;
|
||||
if (session) {
|
||||
// A confirmed session clears a stale "rejected" flag (e.g. a 401 raced a sign-in).
|
||||
consumeSessionRejected();
|
||||
return;
|
||||
}
|
||||
}, [sessionLost, replace, hadOptimisticSession]);
|
||||
if (!hadSession) return;
|
||||
|
||||
const changingServer = consumeServerChangeRequest();
|
||||
replace(changingServer ? "/(auth)/server-url" : "/(auth)/login");
|
||||
|
||||
if (hadOptimisticSession || consumeSessionRejected()) {
|
||||
toast.info(i18n._(msg`Session expired`), {
|
||||
description: i18n._(msg`Please sign in again.`),
|
||||
});
|
||||
clearCachedSessionSeeded();
|
||||
}
|
||||
}, [session, replace, hadOptimisticSession]);
|
||||
|
||||
return { session, isPending, hasServerUrl, instanceId };
|
||||
}
|
||||
@@ -16,7 +16,22 @@ vi.mock("@lingui/core/macro", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { getAuthErrorMessage, getErrorMessage } = await import("./error-messages");
|
||||
const { getAuthErrorMessage, getErrorMessage, isClientError, isUnauthorizedError } =
|
||||
await import("./error-messages");
|
||||
|
||||
describe("error classification", () => {
|
||||
test("isUnauthorizedError matches only UNAUTHORIZED oRPC errors", () => {
|
||||
expect(isUnauthorizedError(new ORPCError("UNAUTHORIZED"))).toBe(true);
|
||||
expect(isUnauthorizedError(new ORPCError("FORBIDDEN"))).toBe(false);
|
||||
expect(isUnauthorizedError(new Error("boom"))).toBe(false);
|
||||
});
|
||||
|
||||
test("isClientError matches 4xx oRPC errors only", () => {
|
||||
expect(isClientError(new ORPCError("BAD_REQUEST"))).toBe(true);
|
||||
expect(isClientError(new ORPCError("INTERNAL_SERVER_ERROR"))).toBe(false);
|
||||
expect(isClientError(new Error("Network request failed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getErrorMessage", () => {
|
||||
test("returns the English message for a mapped app error code", () => {
|
||||
|
||||
@@ -35,6 +35,16 @@ const APP_ERROR_MESSAGES: Record<AppErrorCode, MessageDescriptor> = {
|
||||
EXPORT_FAILED: msg`Failed to export data`,
|
||||
};
|
||||
|
||||
/** True when an oRPC call failed because the server no longer accepts the session. */
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
return error instanceof ORPCError && (error.code === "UNAUTHORIZED" || error.status === 401);
|
||||
}
|
||||
|
||||
/** True for 4xx oRPC errors — retrying them can't succeed. */
|
||||
export function isClientError(error: unknown): boolean {
|
||||
return error instanceof ORPCError && error.status >= 400 && error.status < 500;
|
||||
}
|
||||
|
||||
/** Localized message for an oRPC error carrying an app error code, else `fallback`. */
|
||||
export function getErrorMessage(error: unknown, fallback?: string): string {
|
||||
const code = getAppErrorCode(error);
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { isClientError, isUnauthorizedError } from "@/lib/error-messages";
|
||||
import { posthog } from "@/lib/posthog";
|
||||
import { getIsReachable, isNetworkError } from "@/lib/server";
|
||||
import { authClient, getIsReachable, isNetworkError, markSessionRejected } from "@/lib/server";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { i18n } from "@sofa/i18n";
|
||||
|
||||
// The server rejected the session: re-validate it (at most once per 5s) so the
|
||||
// session-loss redirect runs instead of every screen erroring until foreground.
|
||||
let lastSessionCheck = 0;
|
||||
function handleUnauthorized() {
|
||||
const now = Date.now();
|
||||
if (now - lastSessionCheck < 5_000) return;
|
||||
lastSessionCheck = now;
|
||||
markSessionRejected();
|
||||
authClient.$store.atoms.session.get().refetch?.();
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -14,6 +26,8 @@ export const queryClient = new QueryClient({
|
||||
gcTime: 300_000,
|
||||
networkMode: "online",
|
||||
retry: (failureCount, error) => {
|
||||
// 4xx errors can't succeed on retry.
|
||||
if (isClientError(error)) return false;
|
||||
// Don't retry network errors when server is unreachable — the
|
||||
// banner already tells the user and retries will just spam toasts.
|
||||
if (!getIsReachable() && isNetworkError(error)) return false;
|
||||
@@ -26,6 +40,13 @@ export const queryClient = new QueryClient({
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
// Keep the 401 branch first: an expired session is an expected state,
|
||||
// not worth a toast or an error report.
|
||||
if (isUnauthorizedError(error)) {
|
||||
handleUnauthorized();
|
||||
return;
|
||||
}
|
||||
|
||||
// Suppress toasts for network errors when the server-unreachable
|
||||
// banner is already visible — avoids flooding native toasts.
|
||||
if (!getIsReachable() && isNetworkError(error)) return;
|
||||
@@ -35,4 +56,10 @@ export const queryClient = new QueryClient({
|
||||
Sentry.captureException(error, { tags: { source: "react-query" } });
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => {
|
||||
// Mutations already toast their own failures; only re-validate the session.
|
||||
if (isUnauthorizedError(error)) handleUnauthorized();
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -430,6 +430,20 @@ export function consumeServerChangeRequest(): boolean {
|
||||
return was;
|
||||
}
|
||||
|
||||
// Signals that the server rejected the current session (an API call returned 401), so the
|
||||
// session-loss redirect should explain why.
|
||||
let sessionRejected = false;
|
||||
|
||||
export function markSessionRejected(): void {
|
||||
sessionRejected = true;
|
||||
}
|
||||
|
||||
export function consumeSessionRejected(): boolean {
|
||||
const was = sessionRejected;
|
||||
sessionRejected = false;
|
||||
return was;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// serverManager — compound operations for server-url screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user