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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { PortalHost } from '@rn-primitives/portal'; import { PortalHost } from '@rn-primitives/portal';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; 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 { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller'; import { KeyboardProvider } from 'react-native-keyboard-controller';
import { import {
@@ -10,6 +11,10 @@ import {
} from 'react-native-safe-area-context'; } from 'react-native-safe-area-context';
import '@/global.css'; import '@/global.css';
import { AnimatedSplashOverlay } from '@/components/animated-icon'; import { AnimatedSplashOverlay } from '@/components/animated-icon';
import {
getSplashGeneration,
subscribeSplashGeneration,
} from '@/core/splash-replay';
import { AppProviders } from '@/core/providers'; import { AppProviders } from '@/core/providers';
import { NavigationThemeProvider } from '@/core/navigation-theme-provider'; import { NavigationThemeProvider } from '@/core/navigation-theme-provider';
import { ThemeVariablesProvider } from '@/core/theme-variables-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 { setLanguageResolver, startLocaleSync } from '@/i18n';
import { useColorScheme } from '@/hooks/use-color-scheme'; 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() { export default function RootLayout() {
const { colorScheme, setColorScheme } = useColorScheme(); const { colorScheme, setColorScheme } = useColorScheme();
const resolved = colorScheme === 'dark' ? 'dark' : 'light'; const resolved = colorScheme === 'dark' ? 'dark' : 'light';
@@ -53,13 +83,15 @@ export default function RootLayout() {
<ThemeVariablesProvider> <ThemeVariablesProvider>
<NavigationThemeProvider> <NavigationThemeProvider>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} /> <StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<AnimatedSplashOverlay /> <View style={styles.root}>
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" /> <Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" /> <Stack.Screen name="(main)" />
<Stack.Screen name="legal" /> <Stack.Screen name="legal" />
</Stack> </Stack>
<ReplayableSplashOverlay />
</View>
<PortalHost /> <PortalHost />
</NavigationThemeProvider> </NavigationThemeProvider>
</ThemeVariablesProvider> </ThemeVariablesProvider>

View File

@@ -4,9 +4,14 @@ import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, { Easing, Keyframe } from 'react-native-reanimated'; import Animated, { Easing, Keyframe } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets'; import { scheduleOnRN } from 'react-native-worklets';
import { completeSplashReplay } from '@/core/splash-replay';
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90; const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
const DURATION = 600; const DURATION = 600;
/** Brand gate background (matches native splash / overlay). */
export const BRAND_BOOTSTRAP_BG = '#208AEF';
export function AnimatedSplashOverlay() { export function AnimatedSplashOverlay() {
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
@@ -37,10 +42,22 @@ export function AnimatedSplashOverlay() {
'worklet'; 'worklet';
if (finished) { if (finished) {
scheduleOnRN(setVisible, false); scheduleOnRN(setVisible, false);
scheduleOnRN(completeSplashReplay);
} }
})} })}
style={styles.backgroundSolidColor} 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: { backgroundSolidColor: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: '#208AEF', backgroundColor: BRAND_BOOTSTRAP_BG,
zIndex: 1000, 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'; import classes from './animated-icon.module.css';
const DURATION = 300; const DURATION = 300;
/** Brand gate background (matches native splash / overlay). */
export const BRAND_BOOTSTRAP_BG = '#208AEF';
export function AnimatedSplashOverlay() { export function AnimatedSplashOverlay() {
return null; 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({ const keyframe = new Keyframe({
0: { 0: {
transform: [{ scale: 0 }], transform: [{ scale: 0 }],
@@ -88,6 +100,12 @@ export function AnimatedIcon() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
brandBootstrapRoot: {
flex: 1,
backgroundColor: BRAND_BOOTSTRAP_BG,
justifyContent: 'center',
alignItems: 'center',
},
container: { container: {
alignItems: 'center', alignItems: 'center',
width: '100%', width: '100%',

View File

@@ -6,6 +6,7 @@ import { MemoirReadingSettingsProvider } from '@/core/memoir-reading-settings-co
import { NetworkError } from '@/core/api/types'; import { NetworkError } from '@/core/api/types';
import { tokenManager } from '@/core/auth/token-manager'; import { tokenManager } from '@/core/auth/token-manager';
import { config } from '@/core/config'; import { config } from '@/core/config';
import { authKeys } from '@/features/auth/auth-query-keys';
import { AppQueryProvider, queryClient } from '@/core/query'; import { AppQueryProvider, queryClient } from '@/core/query';
/** /**
@@ -49,8 +50,8 @@ async function refreshTokens(): Promise<boolean> {
* since the cache flip is the authoritative signal. * since the cache flip is the authoritative signal.
*/ */
function onAuthFailure() { function onAuthFailure() {
queryClient.setQueryData(['auth', 'token-check'], false); queryClient.setQueryData(authKeys.tokenCheck, false);
queryClient.setQueryData(['session'], null); queryClient.setQueryData(authKeys.session, null);
tokenManager.clearTokens(); 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { AuthError } from '@/core/api/types'; import { AuthError } from '@/core/api/types';
import { tokenManager } from '@/core/auth/token-manager'; 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 { getDeviceLanguage } from '@/i18n';
import { authApi } from './api'; import { authApi } from './api';
import { authKeys } from './auth-query-keys';
import type { import type {
LanguagePreference, LanguagePreference,
LoginRequest, LoginRequest,
@@ -34,10 +34,7 @@ function withDeviceLanguage<T extends { language?: LanguagePreference }>(
// ─── Query keys ─── // ─── Query keys ───
export const authKeys = { export { authKeys };
session: ['session'] as const,
tokenCheck: ['auth', 'token-check'] as const,
};
const PROFILE_QUERY_PREFIX = ['profile'] as const; const PROFILE_QUERY_PREFIX = ['profile'] as const;
@@ -251,11 +248,7 @@ export function useLogout() {
} }
}, },
onSettled: async () => { onSettled: async () => {
disposeAllBackgroundConversationWs(); await clearLocalSessionAndReplayEntry(queryClient);
await tokenManager.clearTokens();
queryClient.clear();
queryClient.setQueryData(authKeys.tokenCheck, false);
router.replace('/(auth)/login');
}, },
}); });
} }

View File

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

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