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:
jakeandClaude Opus 5.5 committed 2026-10-05 02:39:54 -04:00
1 parent e5209f80fe
commit 25cf4f1bc8
6 files changed
+111 -45

No files matched your search

+18 -18
View File
@@ -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>
);
}
+24 -24
View File
@@ -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 -1
View File
@@ -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", () => {
+10
View File
@@ -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);
+29 -2
View File
@@ -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();
},
}),
});
+14
View File
@@ -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
// ---------------------------------------------------------------------------