diff --git a/app-expo/src/app/(auth)/_layout.tsx b/app-expo/src/app/(auth)/_layout.tsx
index 9eb6260..383591d 100644
--- a/app-expo/src/app/(auth)/_layout.tsx
+++ b/app-expo/src/app/(auth)/_layout.tsx
@@ -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 ;
+ }
+
if (status === 'authenticated') {
return ;
}
diff --git a/app-expo/src/app/(main)/_layout.tsx b/app-expo/src/app/(main)/_layout.tsx
index e02c618..9281aac 100644
--- a/app-expo/src/app/(main)/_layout.tsx
+++ b/app-expo/src/app/(main)/_layout.tsx
@@ -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 (
-
-
-
- );
+ if (splashReplay || status === 'loading') {
+ return ;
}
if (status === 'unauthenticated') {
diff --git a/app-expo/src/app/(tabs)/_layout.tsx b/app-expo/src/app/(tabs)/_layout.tsx
index a5c7d7e..6b9a0a0 100644
--- a/app-expo/src/app/(tabs)/_layout.tsx
+++ b/app-expo/src/app/(tabs)/_layout.tsx
@@ -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 (
-
-
-
- );
+ if (splashReplay || status === 'loading') {
+ return ;
}
if (status === 'unauthenticated') {
diff --git a/app-expo/src/app/_layout.tsx b/app-expo/src/app/_layout.tsx
index f1814d4..3436905 100644
--- a/app-expo/src/app/_layout.tsx
+++ b/app-expo/src/app/_layout.tsx
@@ -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 (
+
+
+
+ );
+}
+
+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() {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/app-expo/src/components/animated-icon.tsx b/app-expo/src/components/animated-icon.tsx
index df1f04b..b757571 100644
--- a/app-expo/src/components/animated-icon.tsx
+++ b/app-expo/src/components/animated-icon.tsx
@@ -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}
- />
+ >
+
+
+ );
+}
+
+/** Full-screen logo gate while session / token check is in progress (after splash). */
+export function BrandBootstrapLoading() {
+ return (
+
+
+
);
}
@@ -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',
},
});
diff --git a/app-expo/src/components/animated-icon.web.tsx b/app-expo/src/components/animated-icon.web.tsx
index adaabbb..ce443a5 100644
--- a/app-expo/src/components/animated-icon.web.tsx
+++ b/app-expo/src/components/animated-icon.web.tsx
@@ -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 (
+
+
+
+ );
+}
+
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%',
diff --git a/app-expo/src/core/providers.tsx b/app-expo/src/core/providers.tsx
index 8357e04..674c7e6 100644
--- a/app-expo/src/core/providers.tsx
+++ b/app-expo/src/core/providers.tsx
@@ -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 {
* 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();
}
diff --git a/app-expo/src/core/splash-replay.ts b/app-expo/src/core/splash-replay.ts
new file mode 100644
index 0000000..1befbc5
--- /dev/null
+++ b/app-expo/src/core/splash-replay.ts
@@ -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);
+}
diff --git a/app-expo/src/features/auth/auth-query-keys.ts b/app-expo/src/features/auth/auth-query-keys.ts
new file mode 100644
index 0000000..628712a
--- /dev/null
+++ b/app-expo/src/features/auth/auth-query-keys.ts
@@ -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;
diff --git a/app-expo/src/features/auth/clear-local-session-and-replay-entry.ts b/app-expo/src/features/auth/clear-local-session-and-replay-entry.ts
new file mode 100644
index 0000000..1315e23
--- /dev/null
+++ b/app-expo/src/features/auth/clear-local-session-and-replay-entry.ts
@@ -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 {
+ 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');
+}
diff --git a/app-expo/src/features/auth/hooks.ts b/app-expo/src/features/auth/hooks.ts
index ca30ab9..b6cfefc 100644
--- a/app-expo/src/features/auth/hooks.ts
+++ b/app-expo/src/features/auth/hooks.ts
@@ -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(
// ─── 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);
},
});
}
diff --git a/app-expo/src/features/profile/hooks.ts b/app-expo/src/features/profile/hooks.ts
index 0954b75..b7b478d 100644
--- a/app-expo/src/features/profile/hooks.ts
+++ b/app-expo/src/features/profile/hooks.ts
@@ -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);
},
});
}
diff --git a/app-expo/src/hooks/use-splash-replay-active.ts b/app-expo/src/hooks/use-splash-replay-active.ts
new file mode 100644
index 0000000..5ae35f0
--- /dev/null
+++ b/app-expo/src/hooks/use-splash-replay-active.ts
@@ -0,0 +1,14 @@
+import { useSyncExternalStore } from 'react';
+
+import {
+ getSplashReplayActive,
+ subscribeSplashReplayActive,
+} from '@/core/splash-replay';
+
+export function useSplashReplayActive(): boolean {
+ return useSyncExternalStore(
+ subscribeSplashReplayActive,
+ getSplashReplayActive,
+ () => false,
+ );
+}
diff --git a/app-expo/tests/features/auth/hooks.test.tsx b/app-expo/tests/features/auth/hooks.test.tsx
index 013c51d..031c267 100644
--- a/app-expo/tests/features/auth/hooks.test.tsx
+++ b/app-expo/tests/features/auth/hooks.test.tsx
@@ -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();