diff --git a/src/app/(app)/__tests__/init-session-generation.test.tsx b/src/app/(app)/__tests__/init-session-generation.test.tsx new file mode 100644 index 00000000..28589ee8 --- /dev/null +++ b/src/app/(app)/__tests__/init-session-generation.test.tsx @@ -0,0 +1,140 @@ +/** + * Signing out while app initialization is still awaiting must retire that run: a stale + * invocation may not mark the app initialized, connect the chat hub, or restart location + * tracking that the sign-out cleanup just stopped. + * + * The layout itself pulls in Mapbox, Novu, push notifications and the whole store graph, + * so the guard protocol is exercised through the same generation-token shape the layout + * uses rather than by rendering it. + */ +import { act, renderHook } from '@testing-library/react-native'; +import React from 'react'; + +interface Deferred { + promise: Promise; + resolve: () => void; +} + +function deferred(): Deferred { + let resolve: () => void = () => undefined; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** Mirrors the layout's initializeApp guard: generation captured at start, checked after each await. */ +function useInitGuard(gate: Deferred, effects: { connectHub: jest.Mock; startLocation: jest.Mock; markInitialized: jest.Mock }) { + const initGeneration = React.useRef(0); + const isInitializing = React.useRef(false); + + const initialize = React.useCallback(async () => { + if (isInitializing.current) return; + isInitializing.current = true; + const generation = (initGeneration.current += 1); + const isCurrentRun = () => initGeneration.current === generation; + + try { + await gate.promise; + if (!isCurrentRun()) return; + + effects.connectHub(); + if (!isCurrentRun()) return; + + effects.markInitialized(); + if (!isCurrentRun()) return; + + effects.startLocation(); + } finally { + if (isCurrentRun()) { + isInitializing.current = false; + } + } + }, [gate, effects]); + + const signOut = React.useCallback(() => { + initGeneration.current += 1; + isInitializing.current = false; + }, []); + + return { initialize, signOut, isInitializing }; +} + +describe('app initialization session generation', () => { + const effects = { connectHub: jest.fn(), startLocation: jest.fn(), markInitialized: jest.fn() }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('abandons an in-flight run when the session ends mid-initialization', async () => { + const gate = deferred(); + const { result } = renderHook(() => useInitGuard(gate, effects)); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.initialize(); + }); + + // Sign-out lands while initialization is still awaiting its first step. + act(() => { + result.current.signOut(); + }); + + await act(async () => { + gate.resolve(); + await pending; + }); + + expect(effects.connectHub).not.toHaveBeenCalled(); + expect(effects.markInitialized).not.toHaveBeenCalled(); + expect(effects.startLocation).not.toHaveBeenCalled(); + }); + + it('completes normally when the session survives', async () => { + const gate = deferred(); + const { result } = renderHook(() => useInitGuard(gate, effects)); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.initialize(); + }); + + await act(async () => { + gate.resolve(); + await pending; + }); + + expect(effects.connectHub).toHaveBeenCalledTimes(1); + expect(effects.markInitialized).toHaveBeenCalledTimes(1); + expect(effects.startLocation).toHaveBeenCalledTimes(1); + }); + + it('frees the in-progress guard so the next sign-in can initialize', async () => { + const first = deferred(); + const { result } = renderHook(() => useInitGuard(first, effects)); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.initialize(); + }); + act(() => { + result.current.signOut(); + }); + + // The new session starts before the retired run has settled. + let second: Promise = Promise.resolve(); + act(() => { + second = result.current.initialize(); + }); + + await act(async () => { + first.resolve(); + await Promise.all([pending, second]); + }); + + // Exactly one run reached the effects: the current one. + expect(effects.markInitialized).toHaveBeenCalledTimes(1); + expect(effects.startLocation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 42168733..ea3289ff 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -83,6 +83,10 @@ export default function TabLayout() { // Refs to track initialization state const hasInitialized = useRef(false); const isInitializing = useRef(false); + // Bumped on every initialization start and whenever the session ends. An in-flight run + // compares its captured value after each await, so a run belonging to a session that is + // over can no longer connect hubs or mark the app initialized. + const initGeneration = useRef(0); const hasHiddenSplash = useRef(false); const parentRef = useRef(null); @@ -151,6 +155,8 @@ export default function TabLayout() { } isInitializing.current = true; + const generation = (initGeneration.current += 1); + const isCurrentRun = () => initGeneration.current === generation; logger.info({ message: 'Starting app initialization', context: { @@ -167,10 +173,14 @@ export default function TabLayout() { // time-to-interactive (previously 8+ serial network hops). await Promise.all([useRolesStore.getState().init(), useCallsStore.getState().init(), useWeatherAlertsStore.getState().init(), securityStore.getState().getRights(), featureFlagsStore.getState().fetchFlags()]); + if (!isCurrentRun()) return; + // SignalR needs config (EventingUrl) and rights (DepartmentId) — both // available now. The two hub connects are independent. await Promise.all([useSignalRStore.getState().connectUpdateHub(), useSignalRStore.getState().connectGeolocationHub()]); + if (!isCurrentRun()) return; + // Connect the realtime chat hub only when the Chat.System feature flag is on for // this department; when it is off every chat surface stays hidden. if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { @@ -189,6 +199,8 @@ export default function TabLayout() { }); } + if (!isCurrentRun()) return; + hasInitialized.current = true; // Evict expired/capped API cache entries once per cold start. @@ -223,12 +235,20 @@ export default function TabLayout() { context: { error }, }); } + // A run whose session already ended must not burn the retry budget or clobber + // state a newer run has since established. + if (!isCurrentRun()) return; + // Reset initialization state on error so it can be retried hasInitialized.current = false; setInitRetryCount((c) => c + 1); } finally { - isInitializing.current = false; - setIsInitComplete(true); + // Only the current run owns the guard; a superseded run clearing it would let two + // initializations overlap. + if (isCurrentRun()) { + isInitializing.current = false; + setIsInitComplete(true); + } } }, [status]); @@ -270,7 +290,15 @@ export default function TabLayout() { // Handle app initialization - simplified logic const MAX_INIT_RETRIES = 3; useEffect(() => { - if (status !== 'signedIn' && initRetryCount > 0) { + if (status === 'signedIn') return; + + // Leaving the signed-in state retires any initialization still in flight, and frees + // the guard it no longer owns so the next sign-in is not skipped as "already + // initializing". + initGeneration.current += 1; + isInitializing.current = false; + + if (initRetryCount > 0) { setInitRetryCount(0); } }, [status, initRetryCount]); diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 1905466b..51b21837 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -8,9 +8,7 @@ import { copyToClipboard } from '@/components/chat/chat-utils'; import { MessageActionsSheet } from '@/components/chat/message-actions-sheet'; import { MessageBubble } from '@/components/chat/message-bubble'; import { TypingDots } from '@/components/chat/typing-indicator'; -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; import { Center } from '@/components/ui/center'; import { FlatList } from '@/components/ui/flat-list'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; @@ -20,7 +18,6 @@ import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; import { Pressable } from '@/components/ui/pressable'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; -import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; @@ -40,8 +37,6 @@ export default function ChatbotScreen() { const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; const [text, setText] = useState(''); const [actionsMessage, setActionsMessage] = useState(null); - const [editMessage, setEditMessage] = useState(null); - const [editText, setEditText] = useState(''); useFocusEffect( useCallback(() => { @@ -72,7 +67,14 @@ export default function ChatbotScreen() { const renderItem = useCallback( ({ item }: { item: ChatMessageResultData }) => ( - undefined} /> + undefined} + /> ), [currentUserId] ); @@ -122,7 +124,9 @@ export default function ChatbotScreen() { ) : (