feat(app-expo): replay brand splash on logout and route to login

After sign-out or data purge, clear session state reliably, remount the
splash overlay above navigation, and navigate to login instead of tabs so
users no longer briefly land on the chat home screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-19 14:31:32 +08:00
parent 95856ca11a
commit b22f1cd4c4
14 changed files with 225 additions and 46 deletions

View File

@@ -1,11 +1,18 @@
import { Redirect, Stack } from 'expo-router';
import React from 'react';
import { BrandBootstrapLoading } from '@/components/animated-icon';
import { useSplashReplayActive } from '@/hooks/use-splash-replay-active';
import { useSession } from '@/features/auth/hooks';
export default function AuthLayout() {
const splashReplay = useSplashReplayActive();
const { status } = useSession();
if (splashReplay || status === 'loading') {
return <BrandBootstrapLoading />;
}
if (status === 'authenticated') {
return <Redirect href="/(tabs)" />;
}

View File

@@ -1,18 +1,16 @@
import { Redirect, Stack } from 'expo-router';
import React from 'react';
import { ActivityIndicator, View } from 'react-native';
import { BrandBootstrapLoading } from '@/components/animated-icon';
import { useSplashReplayActive } from '@/hooks/use-splash-replay-active';
import { useSession } from '@/features/auth/hooks';
export default function MainLayout() {
const splashReplay = useSplashReplayActive();
const { status } = useSession();
if (status === 'loading') {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
if (splashReplay || status === 'loading') {
return <BrandBootstrapLoading />;
}
if (status === 'unauthenticated') {

View File

@@ -1,6 +1,8 @@
import { Redirect, Tabs } from 'expo-router';
import React from 'react';
import { ActivityIndicator, View } from 'react-native';
import { BrandBootstrapLoading } from '@/components/animated-icon';
import { useSplashReplayActive } from '@/hooks/use-splash-replay-active';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useTranslation } from 'react-i18next';
@@ -27,6 +29,7 @@ const TAB_BAR_HEIGHT = 72;
const TAB_BAR_PADDING_HORIZONTAL = 12;
export default function TabsLayout() {
const splashReplay = useSplashReplayActive();
const { status } = useSession();
const typography = useTypography();
const { colorScheme } = useColorScheme();
@@ -35,12 +38,8 @@ export default function TabsLayout() {
const tabColors = TAB_BAR_COLORS[isDark ? 'dark' : 'light'];
const { t } = useTranslation('app');
if (status === 'loading') {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
if (splashReplay || status === 'loading') {
return <BrandBootstrapLoading />;
}
if (status === 'unauthenticated') {

View File

@@ -1,7 +1,8 @@
import { PortalHost } from '@rn-primitives/portal';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react';
import React, { useEffect, useSyncExternalStore } from 'react';
import { StyleSheet, View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import {
@@ -10,6 +11,10 @@ import {
} from 'react-native-safe-area-context';
import '@/global.css';
import { AnimatedSplashOverlay } from '@/components/animated-icon';
import {
getSplashGeneration,
subscribeSplashGeneration,
} from '@/core/splash-replay';
import { AppProviders } from '@/core/providers';
import { NavigationThemeProvider } from '@/core/navigation-theme-provider';
import { ThemeVariablesProvider } from '@/core/theme-variables-provider';
@@ -18,6 +23,31 @@ import { getAppLanguage, getDarkMode } from '@/core/settings/app-settings';
import { setLanguageResolver, startLocaleSync } from '@/i18n';
import { useColorScheme } from '@/hooks/use-color-scheme';
/** Remount AnimatedSplashOverlay when {@link requestSplashReplay} runs (post-logout). */
function ReplayableSplashOverlay() {
const generation = useSyncExternalStore(
subscribeSplashGeneration,
getSplashGeneration,
getSplashGeneration,
);
return (
<View style={styles.splashHost} pointerEvents="box-none">
<AnimatedSplashOverlay key={generation} />
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
},
splashHost: {
...StyleSheet.absoluteFillObject,
zIndex: 9999,
elevation: 9999,
},
});
export default function RootLayout() {
const { colorScheme, setColorScheme } = useColorScheme();
const resolved = colorScheme === 'dark' ? 'dark' : 'light';
@@ -53,13 +83,15 @@ export default function RootLayout() {
<ThemeVariablesProvider>
<NavigationThemeProvider>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<AnimatedSplashOverlay />
<View style={styles.root}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" />
<Stack.Screen name="legal" />
</Stack>
<ReplayableSplashOverlay />
</View>
<PortalHost />
</NavigationThemeProvider>
</ThemeVariablesProvider>

View File

@@ -4,9 +4,14 @@ import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, { Easing, Keyframe } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { completeSplashReplay } from '@/core/splash-replay';
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
const DURATION = 600;
/** Brand gate background (matches native splash / overlay). */
export const BRAND_BOOTSTRAP_BG = '#208AEF';
export function AnimatedSplashOverlay() {
const [visible, setVisible] = useState(true);
@@ -37,10 +42,22 @@ export function AnimatedSplashOverlay() {
'worklet';
if (finished) {
scheduleOnRN(setVisible, false);
scheduleOnRN(completeSplashReplay);
}
})}
style={styles.backgroundSolidColor}
/>
>
<AnimatedIcon />
</Animated.View>
);
}
/** Full-screen logo gate while session / token check is in progress (after splash). */
export function BrandBootstrapLoading() {
return (
<View style={styles.brandBootstrapRoot}>
<AnimatedIcon />
</View>
);
}
@@ -141,7 +158,15 @@ const styles = StyleSheet.create({
},
backgroundSolidColor: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#208AEF',
backgroundColor: BRAND_BOOTSTRAP_BG,
zIndex: 1000,
justifyContent: 'center',
alignItems: 'center',
},
brandBootstrapRoot: {
flex: 1,
backgroundColor: BRAND_BOOTSTRAP_BG,
justifyContent: 'center',
alignItems: 'center',
},
});

View File

@@ -5,10 +5,22 @@ import Animated, { Keyframe, Easing } from 'react-native-reanimated';
import classes from './animated-icon.module.css';
const DURATION = 300;
/** Brand gate background (matches native splash / overlay). */
export const BRAND_BOOTSTRAP_BG = '#208AEF';
export function AnimatedSplashOverlay() {
return null;
}
/** Full-screen logo gate while session / token check is in progress (after splash). */
export function BrandBootstrapLoading() {
return (
<View style={styles.brandBootstrapRoot}>
<AnimatedIcon />
</View>
);
}
const keyframe = new Keyframe({
0: {
transform: [{ scale: 0 }],
@@ -88,6 +100,12 @@ export function AnimatedIcon() {
}
const styles = StyleSheet.create({
brandBootstrapRoot: {
flex: 1,
backgroundColor: BRAND_BOOTSTRAP_BG,
justifyContent: 'center',
alignItems: 'center',
},
container: {
alignItems: 'center',
width: '100%',

View File

@@ -6,6 +6,7 @@ import { MemoirReadingSettingsProvider } from '@/core/memoir-reading-settings-co
import { NetworkError } from '@/core/api/types';
import { tokenManager } from '@/core/auth/token-manager';
import { config } from '@/core/config';
import { authKeys } from '@/features/auth/auth-query-keys';
import { AppQueryProvider, queryClient } from '@/core/query';
/**
@@ -49,8 +50,8 @@ async function refreshTokens(): Promise<boolean> {
* since the cache flip is the authoritative signal.
*/
function onAuthFailure() {
queryClient.setQueryData(['auth', 'token-check'], false);
queryClient.setQueryData(['session'], null);
queryClient.setQueryData(authKeys.tokenCheck, false);
queryClient.setQueryData(authKeys.session, null);
tokenManager.clearTokens();
}

View File

@@ -0,0 +1,50 @@
/**
* Signals the root AnimatedSplashOverlay to remount so the cold-start splash
* animation runs again — used after explicit logout-like flows (not passive /me failures).
*/
let splashGeneration = 0;
let splashReplayActive = false;
const generationListeners = new Set<() => void>();
const activeListeners = new Set<() => void>();
function notifyGeneration() {
generationListeners.forEach((listener) => listener());
}
function notifyActive() {
activeListeners.forEach((listener) => listener());
}
export function requestSplashReplay() {
splashGeneration += 1;
splashReplayActive = true;
notifyGeneration();
notifyActive();
}
/** Called when the replay overlay animation finishes (or unmounts). */
export function completeSplashReplay() {
if (!splashReplayActive) return;
splashReplayActive = false;
notifyActive();
}
export function getSplashGeneration(): number {
return splashGeneration;
}
export function getSplashReplayActive(): boolean {
return splashReplayActive;
}
export function subscribeSplashGeneration(onStoreChange: () => void): () => void {
generationListeners.add(onStoreChange);
return () => generationListeners.delete(onStoreChange);
}
export function subscribeSplashReplayActive(onStoreChange: () => void): () => void {
activeListeners.add(onStoreChange);
return () => activeListeners.delete(onStoreChange);
}

View File

@@ -0,0 +1,5 @@
/** Shared TanStack keys for bootstrap session — avoids circular imports with hooks. */
export const authKeys = {
session: ['session'] as const,
tokenCheck: ['auth', 'token-check'] as const,
} as const;

View File

@@ -0,0 +1,42 @@
import { type QueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { tokenManager } from '@/core/auth/token-manager';
import { requestSplashReplay } from '@/core/splash-replay';
import { authKeys } from '@/features/auth/auth-query-keys';
import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool';
/** Matches `useSession` token bootstrap — keep options aligned for cache coherence. */
const TOKEN_CHECK_FETCH_OPTIONS = {
queryKey: authKeys.tokenCheck,
queryFn: () => tokenManager.hasTokens(),
staleTime: Infinity,
gcTime: Infinity,
} as const;
/**
* Used after deliberate sign-out flows (manual logout, data purge…):
* wipes local credentials + TanStack cache, replays splash on top, then routes to login.
*/
export async function clearLocalSessionAndReplayEntry(
queryClient: QueryClient,
): Promise<void> {
requestSplashReplay();
disposeAllBackgroundConversationWs();
await tokenManager.clearTokens();
await queryClient.cancelQueries({ queryKey: authKeys.session });
await queryClient.cancelQueries({ queryKey: authKeys.tokenCheck });
queryClient.removeQueries({ queryKey: authKeys.session });
queryClient.clear();
queryClient.setQueryData(authKeys.tokenCheck, false);
await queryClient.fetchQuery({
...TOKEN_CHECK_FETCH_OPTIONS,
});
router.replace('/(auth)/login');
}

View File

@@ -1,13 +1,13 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { useCallback } from 'react';
import { AuthError } from '@/core/api/types';
import { tokenManager } from '@/core/auth/token-manager';
import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool';
import { clearLocalSessionAndReplayEntry } from '@/features/auth/clear-local-session-and-replay-entry';
import { getDeviceLanguage } from '@/i18n';
import { authApi } from './api';
import { authKeys } from './auth-query-keys';
import type {
LanguagePreference,
LoginRequest,
@@ -34,10 +34,7 @@ function withDeviceLanguage<T extends { language?: LanguagePreference }>(
// ─── Query keys ───
export const authKeys = {
session: ['session'] as const,
tokenCheck: ['auth', 'token-check'] as const,
};
export { authKeys };
const PROFILE_QUERY_PREFIX = ['profile'] as const;
@@ -251,11 +248,7 @@ export function useLogout() {
}
},
onSettled: async () => {
disposeAllBackgroundConversationWs();
await tokenManager.clearTokens();
queryClient.clear();
queryClient.setQueryData(authKeys.tokenCheck, false);
router.replace('/(auth)/login');
await clearLocalSessionAndReplayEntry(queryClient);
},
});
}

View File

@@ -1,9 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { tokenManager } from '@/core/auth/token-manager';
import { disposeAllBackgroundConversationWs } from '@/features/conversation/conversation-ws-background-pool';
import { authKeys } from '@/features/auth/hooks';
import { clearLocalSessionAndReplayEntry } from '@/features/auth/clear-local-session-and-replay-entry';
import { profileApi } from './api';
import type {
@@ -98,11 +95,7 @@ export function usePurgeUserData() {
return useMutation({
mutationFn: (body: PurgeUserDataRequest) => profileApi.purgeUserData(body),
onSuccess: async () => {
disposeAllBackgroundConversationWs();
await tokenManager.clearTokens();
queryClient.clear();
queryClient.setQueryData(authKeys.tokenCheck, false);
router.replace('/(auth)/login');
await clearLocalSessionAndReplayEntry(queryClient);
},
});
}

View File

@@ -0,0 +1,14 @@
import { useSyncExternalStore } from 'react';
import {
getSplashReplayActive,
subscribeSplashReplayActive,
} from '@/core/splash-replay';
export function useSplashReplayActive(): boolean {
return useSyncExternalStore(
subscribeSplashReplayActive,
getSplashReplayActive,
() => false,
);
}

View File

@@ -226,6 +226,7 @@ describe('useLogout', () => {
test('reads refresh token first, calls API, then clears local state', async () => {
mockGetRefreshToken.mockResolvedValue('my-refresh');
mockLogout.mockResolvedValue({ message: 'ok' });
mockHasTokens.mockResolvedValue(false);
const wrapper = createWrapper();
@@ -251,6 +252,7 @@ describe('useLogout', () => {
test('clears local state even if server logout fails', async () => {
mockGetRefreshToken.mockResolvedValue('my-refresh');
mockLogout.mockRejectedValue(new Error('Network down'));
mockHasTokens.mockResolvedValue(false);
const wrapper = createWrapper();