From edbff72463c54ae47611a8059ddb9e15448fbb9f Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 16 Aug 2026 22:22:29 -0700 Subject: [PATCH 1/2] RG-T131 Another bottom sheet softkeyboard fix --- src/__tests__/app/call/[id].security.test.tsx | 7 + src/app/call/[id].tsx | 18 +- src/app/call/[id]/command.tsx | 4 +- src/app/call/[id]/edit.tsx | 24 +- src/app/call/new/index.tsx | 36 +- .../dispatch-selection-basic.test.tsx | 17 + .../dispatch-selection-modal.test.tsx | 17 + .../calls/close-call-bottom-sheet.tsx | 12 +- .../calls/dispatch-selection-modal.tsx | 393 +++++++++--------- src/components/status/status-bottom-sheet.tsx | 14 +- src/hooks/use-keyboard-height.ts | 15 +- src/stores/signalr/signalr-store.ts | 94 ++++- 12 files changed, 376 insertions(+), 275 deletions(-) diff --git a/src/__tests__/app/call/[id].security.test.tsx b/src/__tests__/app/call/[id].security.test.tsx index f6557c6..0ee9ba4 100644 --- a/src/__tests__/app/call/[id].security.test.tsx +++ b/src/__tests__/app/call/[id].security.test.tsx @@ -241,6 +241,13 @@ jest.mock('../../../components/status/status-bottom-sheet', () => ({ StatusBottomSheet: () =>
Status Sheet
, })); +// The dispatch selection modal is now a gluestack Actionsheet; importing the real one +// pulls in @gluestack-ui overlay code that needs the full react-native StatusBar, which +// this suite's minimal react-native mock does not provide. Mock it like the other sheets. +jest.mock('../../../components/calls/dispatch-selection-modal', () => ({ + DispatchSelectionModal: () =>
Dispatch Selection
, +})); + jest.mock('@/components/incident-command/incident-command-tab', () => ({ IncidentCommandTab: () =>
Incident Command Tab
, })); diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 68605f0..b2ecf47 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -354,7 +354,7 @@ export default function CallDetail() { title: t('call_detail.tabs.info'), icon: , content: ( - + {t('call_detail.priority')} @@ -483,7 +483,7 @@ export default function CallDetail() { {callExtraData?.Protocols && callExtraData.Protocols.length > 0 ? ( {callExtraData.Protocols.map((protocol, index) => ( - + {protocol.Name} {protocol.Description} @@ -554,7 +554,7 @@ export default function CallDetail() { {callExtraData?.Dispatches && callExtraData.Dispatches.length > 0 ? ( {callExtraData.Dispatches.map((dispatched, index) => ( - + {dispatched.Name} @@ -655,9 +655,9 @@ export default function CallDetail() { headerBackTitle: '', }} /> - + {/* Header */} - + {call.Name} ({call.Number}) @@ -708,9 +708,9 @@ export default function CallDetail() { {/* Map (toggles between the call address and the destination POI when one exists) */} - + {hasDestinationLocation ? ( - + - + {t('calls.notify_cancelled_entities')} diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index 810ae6a..e479a6e 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -767,12 +767,12 @@ export default function NewCall() { }} /> - + {t('calls.create_new_call')} {/* Call Templates */} - + toggleSection('templates')} className="flex-row items-center justify-between p-4"> @@ -790,7 +790,7 @@ export default function NewCall() { ) : null} - + toggleSection('callName')} className="flex-row items-center justify-between p-4"> {t('calls.name')} {sectionsExpanded.callName ? : } @@ -817,7 +817,7 @@ export default function NewCall() { ) : null} - + toggleSection('nature')} className="flex-row items-center justify-between p-4"> {t('calls.nature')} {sectionsExpanded.nature ? : } @@ -844,7 +844,7 @@ export default function NewCall() { ) : null} - + toggleSection('priorityType')} className="flex-row items-center justify-between p-4"> {t('calls.priority_and_type', 'Priority & Type')} {sectionsExpanded.priorityType ? : } @@ -916,7 +916,7 @@ export default function NewCall() { {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? ( - + toggleSection('note')} className="flex-row items-center justify-between p-4"> {t('calls.note')} {sectionsExpanded.note ? : } @@ -940,7 +940,7 @@ export default function NewCall() { ) : null} {showLocationCard ? ( - + toggleSection('location')} className="flex-row items-center justify-between p-4"> {t('calls.call_location')} {sectionsExpanded.location ? : } @@ -1104,7 +1104,7 @@ export default function NewCall() { {/* One card holds both contact fields, so it shows when either is enabled. */} {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) || fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( - + toggleSection('contact')} className="flex-row items-center justify-between p-4"> @@ -1158,14 +1158,14 @@ export default function NewCall() { {/* Protocols */} {fieldPolicy.isVisible(NewCallFieldKeys.Protocols) ? ( - + toggleSection('protocols')} className="flex-row items-center justify-between p-4"> {t('calls.protocols.title', 'Protocols')} {selectedProtocols.length > 0 ? ( - - {selectedProtocols.length} + + {selectedProtocols.length} ) : null} @@ -1186,14 +1186,14 @@ export default function NewCall() { {/* Linked Call */} {fieldPolicy.isVisible(NewCallFieldKeys.LinkedCall) ? ( - + toggleSection('linkedCall')} className="flex-row items-center justify-between p-4"> {t('calls.linked_calls.title', 'Linked Call')} {linkedCall ? ( - - #{linkedCall.number} + + #{linkedCall.number} ) : null} @@ -1202,7 +1202,7 @@ export default function NewCall() { {sectionsExpanded.linkedCall ? ( {linkedCall ? ( - + #{linkedCall.number} — {linkedCall.name} @@ -1221,7 +1221,7 @@ export default function NewCall() { ) : null} {/* Additional Fields (UDF) */} - + toggleSection('additionalFields')} className="flex-row items-center justify-between p-4"> {t('calls.additional_fields', 'Additional Fields')} {sectionsExpanded.additionalFields ? ( @@ -1239,7 +1239,7 @@ export default function NewCall() { {/* Call Form */} {callForm ? ( - + toggleSection('callForm')} className="flex-row items-center justify-between p-4"> {callForm.Name || t('calls.form.title', 'Call Form')} {sectionsExpanded.callForm ? : } @@ -1253,7 +1253,7 @@ export default function NewCall() { ) : null} {fieldPolicy.isVisible(NewCallFieldKeys.DispatchList) ? ( - + toggleSection('dispatch')} className="flex-row items-center justify-between p-4"> {t('calls.dispatch_to')} {sectionsExpanded.dispatch ? : } diff --git a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx index 24ddc8c..2cd88f6 100644 --- a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx +++ b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx @@ -48,6 +48,23 @@ jest.mock('nativewind', () => ({ cssInterop: jest.fn(), })); +// The component is now a gluestack Actionsheet (renders inside a native Modal), +// so mock the actionsheet parts to render inline like the other sheet tests do. +jest.mock('@/components/ui/actionsheet', () => { + const { View } = require('react-native'); + return { + Actionsheet: ({ isOpen, children }: any) => (isOpen ? {children} : null), + ActionsheetBackdrop: ({ children }: any) => {children}, + ActionsheetContent: ({ children, style }: any) => ( + + {children} + + ), + ActionsheetDragIndicator: () => , + ActionsheetDragIndicatorWrapper: ({ children }: any) => {children}, + }; +}); + // Mock cssInterop globally (global as any).cssInterop = jest.fn(); diff --git a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx index a3276b0..657217d 100644 --- a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx +++ b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx @@ -51,6 +51,23 @@ jest.mock('nativewind', () => ({ cssInterop: jest.fn(), })); +// The component is now a gluestack Actionsheet (renders inside a native Modal), +// so mock the actionsheet parts to render inline like the other sheet tests do. +jest.mock('@/components/ui/actionsheet', () => { + const { View } = require('react-native'); + return { + Actionsheet: ({ isOpen, children }: any) => (isOpen ? {children} : null), + ActionsheetBackdrop: ({ children }: any) => {children}, + ActionsheetContent: ({ children, style }: any) => ( + + {children} + + ), + ActionsheetDragIndicator: () => , + ActionsheetDragIndicatorWrapper: ({ children }: any) => {children}, + }; +}); + // Mock translations jest.mock('react-i18next', () => ({ useTranslation: () => ({ diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx index 6a2d3d6..0c66cf7 100644 --- a/src/components/calls/close-call-bottom-sheet.tsx +++ b/src/components/calls/close-call-bottom-sheet.tsx @@ -1,8 +1,7 @@ import { useRouter } from 'expo-router'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Platform } from 'react-native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; +import { Platform, ScrollView } from 'react-native'; import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Button, ButtonText } from '@/components/ui/button'; @@ -108,10 +107,13 @@ export const CloseCallBottomSheet: React.FC = ({ isOp {t('call_detail.close_call')} - @@ -156,7 +158,7 @@ export const CloseCallBottomSheet: React.FC = ({ isOp - + diff --git a/src/components/calls/dispatch-selection-modal.tsx b/src/components/calls/dispatch-selection-modal.tsx index c0ac73b..2a768da 100644 --- a/src/components/calls/dispatch-selection-modal.tsx +++ b/src/components/calls/dispatch-selection-modal.tsx @@ -2,13 +2,15 @@ import { CheckIcon, SearchIcon, UsersIcon, X } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, TextInput, TouchableOpacity, View } from 'react-native'; +import { ScrollView, TouchableOpacity } from 'react-native'; import { Loading } from '@/components/common/loading'; +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { HStack } from '@/components/ui/hstack'; +import { Input, InputField } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type DispatchSelection, useDispatchStore } from '@/stores/dispatch/store'; @@ -70,225 +72,200 @@ export const DispatchSelectionModal: React.FC = ({ return selection.users.length + selection.groups.length + selection.roles.length + selection.units.length; }; - if (!isVisible) return null; - return ( - - {/* Header */} - - - - {t('calls.select_dispatch_recipients')} - - - - - + + + + + + - {/* Search */} - - - - + {/* Header */} + + + + + {t('calls.select_dispatch_recipients')} + + + + + - - {/* Content */} - {isLoading ? ( - - ) : error ? ( - - {error} + {/* Search */} + + + + + - ) : ( - - {/* Partial load warning — the sections that did load are still usable. */} - {hasLoadFailure && ( - - - {t('calls.dispatch_recipients_partial_load')} - fetchDispatchData(true)}> - {t('common.retry')} - - - - )} - {/* Everyone Option */} - - - - - {selection.everyone && } - - - {t('calls.everyone')} - {t('calls.dispatch_to_everyone')} - - - - - - {/* Users Section */} - {filteredData.users.length > 0 && ( - - - {t('calls.users')} ({filteredData.users.length}) - - {filteredData.users.map((user) => ( - - toggleUser(user.Id)}> - - - {selection.users.includes(user.Id) && } - - - {user.Name} - - + {/* Content */} + {isLoading ? ( + + + + ) : error ? ( + + {error} + + + ) : ( + + {/* Partial load warning — the sections that did load are still usable. */} + {hasLoadFailure && ( + + + {t('calls.dispatch_recipients_partial_load')} + fetchDispatchData(true)}> + {t('common.retry')} - - ))} - - )} + + + )} - {/* Groups Section */} - {filteredData.groups.length > 0 && ( - - - {t('calls.groups')} ({filteredData.groups.length}) - - {filteredData.groups.map((group) => ( - - toggleGroup(group.Id)}> - - - {selection.groups.includes(group.Id) && } - - - {group.Name} - - - - - ))} - - )} + {/* Everyone Option */} + + + + + {selection.everyone ? : null} + + + {t('calls.everyone')} + {t('calls.dispatch_to_everyone')} + + + + - {/* Roles Section */} - {filteredData.roles.length > 0 && ( - - - {t('calls.roles')} ({filteredData.roles.length}) - - {filteredData.roles.map((role) => ( - - toggleRole(role.Id)}> - - - {selection.roles.includes(role.Id) && } - - - {role.Name} - - - - - ))} - - )} + {/* Users Section */} + {filteredData.users.length > 0 && ( + + + {t('calls.users')} ({filteredData.users.length}) + + {filteredData.users.map((user) => ( + + toggleUser(user.Id)}> + + + {selection.users.includes(user.Id) ? : null} + + + {user.Name} + + + + + ))} + + )} - {/* Units Section */} - {filteredData.units.length > 0 && ( - - - {t('calls.units')} ({filteredData.units.length}) - - {filteredData.units.map((unit) => ( - - toggleUnit(unit.Id)}> - - - {selection.units.includes(unit.Id) && } - - - {unit.Name} - - - - - ))} - - )} + {/* Groups Section */} + {filteredData.groups.length > 0 && ( + + + {t('calls.groups')} ({filteredData.groups.length}) + + {filteredData.groups.map((group) => ( + + toggleGroup(group.Id)}> + + + {selection.groups.includes(group.Id) ? : null} + + + {group.Name} + + + + + ))} + + )} - {/* No Results */} - {searchQuery && filteredData.users.length === 0 && filteredData.groups.length === 0 && filteredData.roles.length === 0 && filteredData.units.length === 0 && ( - - {t('common.no_results_found')} - - )} + {/* Roles Section */} + {filteredData.roles.length > 0 && ( + + + {t('calls.roles')} ({filteredData.roles.length}) + + {filteredData.roles.map((role) => ( + + toggleRole(role.Id)}> + + + {selection.roles.includes(role.Id) ? : null} + + + {role.Name} + + + + + ))} + + )} - {/* Everything loaded and there is genuinely nothing to pick beyond Everyone. Say so, rather - than leaving the dispatcher staring at a single option wondering what broke. */} - {!searchQuery && !hasLoadFailure && data.users.length === 0 && data.groups.length === 0 && data.roles.length === 0 && data.units.length === 0 && ( - - {t('calls.dispatch_recipients_empty')} - - )} - - )} + {/* Units Section */} + {filteredData.units.length > 0 && ( + + + {t('calls.units')} ({filteredData.units.length}) + + {filteredData.units.map((unit) => ( + + toggleUnit(unit.Id)}> + + + {selection.units.includes(unit.Id) ? : null} + + + {unit.Name} + + + + + ))} + + )} - {/* Footer */} - - - {getSelectionCount()} {t('calls.selected')} - - - - - - - + {/* No Results */} + {searchQuery && filteredData.users.length === 0 && filteredData.groups.length === 0 && filteredData.roles.length === 0 && filteredData.units.length === 0 && ( + + {t('common.no_results_found')} + + )} + + {/* Everything loaded and there is genuinely nothing to pick beyond Everyone. Say so, rather + than leaving the dispatcher staring at a single option wondering what broke. */} + {!searchQuery && !hasLoadFailure && data.users.length === 0 && data.groups.length === 0 && data.roles.length === 0 && data.units.length === 0 && ( + + {t('calls.dispatch_recipients_empty')} + + )} + + )} + + {/* Footer */} + + + {getSelectionCount()} {t('calls.selected')} + + + + + + + + ); }; diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index b77a037..e98583a 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -3,7 +3,6 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { Platform, ScrollView, TouchableOpacity } from 'react-native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import { useKeyboardHeight } from '@/hooks/use-keyboard-height'; import { DestinationEntityType, type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities, getEnabledDestinationTabs } from '@/lib/destination-helpers'; @@ -738,12 +737,11 @@ export const StatusBottomSheet = () => { )} {currentStep === 'add-note' && ( - + /* Plain ScrollView on purpose: the sheet already slides above the keyboard via + the ActionsheetContent paddingBottom. KeyboardAwareScrollView also reacts to + the keyboard (its events are window-agnostic), so it compensated a second + time and pushed the note field out of the sheet's visible area. */ + {/* Selected Status */} @@ -781,7 +779,7 @@ export const StatusBottomSheet = () => { - + )} diff --git a/src/hooks/use-keyboard-height.ts b/src/hooks/use-keyboard-height.ts index 00ecac3..1cc6c6a 100644 --- a/src/hooks/use-keyboard-height.ts +++ b/src/hooks/use-keyboard-height.ts @@ -4,12 +4,15 @@ import { Keyboard, Platform } from 'react-native'; /** * Current soft-keyboard height in dp, or 0 when it is closed. * - * Bottom sheets render inside a native `Modal`, which owns its own window. - * react-native-keyboard-controller's inset animations are bound to the main window, - * so `KeyboardAvoidingView`/`KeyboardAwareScrollView` never move sheet content and the - * keyboard sits on top of it. React Native's own `Keyboard` events are dispatched - * regardless of which window is focused, so they still describe the keyboard correctly - * inside a sheet — use them to size the gap the sheet needs to leave. + * Bottom sheets render inside a native `Modal`, which owns its own window. The modal + * window itself never resizes or pans for the keyboard on either platform, so the sheet + * must leave the gap itself — pad `ActionsheetContent` by this height and the + * content-sized, bottom-anchored sheet slides up out from under the keyboard. + * + * IMPORTANT: this must be the ONLY keyboard compensation inside a sheet. Do NOT nest a + * `KeyboardAwareScrollView`/`KeyboardAvoidingView` in sheet content: keyboard events are + * window-agnostic on both platforms, so those components still fire inside the modal and + * shift the content a second keyboard-height, pushing it out of the visible sheet. * * iOS gets the `Will` events so the sheet moves with the keyboard animation; Android only * reports usable frames on `Did`. diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index b7f6cfa..468d95e 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -194,6 +194,40 @@ interface SignalRState { * These are defined at module scope to ensure they're the same reference * for both registering and unregistering */ +/** + * A call id is a non-empty string or a finite number and nothing else. Anything looser gets + * stringified into a plausible-looking id — an array of one becomes its element, an object becomes + * "[object Object]" — and would be treated as a real incident instead of falling through to the + * fallback path. + */ +function toCallId(value: unknown): string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return undefined; +} + +/** + * The affected incident's call id. Core sends it as a bare string — the eventing worker forwards the + * topic's ItemId, which is CallId.ToString() — with object payloads tolerated so a producer sending a + * richer message keeps working. + */ +function extractCommandCallId(message: unknown): string | undefined { + const scalar = toCallId(message); + if (scalar !== undefined) { + return scalar; + } + if (message !== null && typeof message === 'object') { + const m = message as { CallId?: unknown; callId?: unknown }; + return toCallId(m.CallId ?? m.callId); + } + return undefined; +} + interface EventHandlers { personnelStatusUpdated: ((data: unknown) => void) | null; personnelStaffingUpdated: ((data: unknown) => void) | null; @@ -225,6 +259,19 @@ let updateHubHandlers: EventHandlers = { onConnected: null, }; +// Rejoining the department group after an update-hub reconnect. +const UPDATE_REJOIN_RETRY_MS = 5000; +const UPDATE_REJOIN_MAX_ATTEMPTS = 3; +let updateRejoinTimer: ReturnType | null = null; +let updateRejoinAttempts = 0; + +function stopUpdateRejoinRetry(): void { + if (updateRejoinTimer) { + clearTimeout(updateRejoinTimer); + updateRejoinTimer = null; + } +} + /** * Lifecycle listeners for the update hub (reconnect/disconnect), kept apart from the method handlers * so they can be torn down without touching the event subscriptions. @@ -527,9 +574,9 @@ export const useSignalRStore = create((set, get) => ({ message: 'incidentCommandUpdated', context: { message }, }); - // Payload is the affected call id (string). It is a lightweight "something changed for this - // call" notification — the client refetches the affected command board. - const callId = extractAlertId(message); + // Payload is the affected call id. extractAlertId reads alert-shaped keys and accepts blanks, + // so the command feed gets its own strict reader. + const callId = extractCommandCallId(message); const now = Date.now(); set({ lastUpdateMessage: null, lastUpdateTimestamp: now, lastEventType: 'incidentCommandUpdated', lastIncidentCommandUpdateTimestamp: now }); if (callId) { @@ -557,7 +604,17 @@ export const useSignalRStore = create((set, get) => ({ unregisterUpdateHubLifecycleHandlers(); const updateReconnected = `${SignalRService.HUB_RECONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`; - const onUpdateReconnected = () => { + + /** + * Rejoin the department group, retrying a few times before giving up. + * + * A failed rejoin is silent and total: the socket is up, so nothing looks wrong, but the + * connection belongs to no group and no board change will ever arrive. Nothing else clears the + * connected flag either — an automatic reconnect never raises the disconnected event, only a + * close does — so it has to be cleared here or connectUpdateHub's already-connected guard + * would block every later repair. + */ + const rejoinDepartmentGroup = () => { const reconnectDepartmentId = Number(securityStore.getState().rights?.DepartmentId ?? '0'); if (!Number.isFinite(reconnectDepartmentId)) { return; @@ -565,6 +622,8 @@ export const useSignalRStore = create((set, get) => ({ signalRService .invoke(Env.CHANNEL_HUB_NAME, 'connect', reconnectDepartmentId) .then(() => { + stopUpdateRejoinRetry(); + updateRejoinAttempts = 0; set({ isUpdateHubConnected: true, error: null }); logger.info({ message: 'Re-announced to update hub after reconnect; reloading incident command', context: { departmentId: reconnectDepartmentId } }); // Lazy import to avoid circular dependency @@ -575,15 +634,36 @@ export const useSignalRStore = create((set, get) => ({ } }) .catch((error) => { - logger.warn({ message: 'Failed to re-announce to update hub after reconnect', context: { error } }); + updateRejoinAttempts += 1; + logger.warn({ message: 'Failed to re-announce to update hub after reconnect', context: { error, attempt: updateRejoinAttempts, maxAttempts: UPDATE_REJOIN_MAX_ATTEMPTS } }); + set({ isUpdateHubConnected: false }); + + if (updateRejoinAttempts < UPDATE_REJOIN_MAX_ATTEMPTS) { + stopUpdateRejoinRetry(); + updateRejoinTimer = setTimeout(() => { + updateRejoinTimer = null; + rejoinDepartmentGroup(); + }, UPDATE_REJOIN_RETRY_MS); + } else { + logger.error({ message: 'Giving up re-announcing to update hub; the next connectUpdateHub will rebuild the session', context: { attempts: updateRejoinAttempts } }); + } }); }; + + const onUpdateReconnected = () => { + stopUpdateRejoinRetry(); + updateRejoinAttempts = 0; + rejoinDepartmentGroup(); + }; updateHubLifecycleHandlers[updateReconnected] = onUpdateReconnected; signalRService.on(updateReconnected, onUpdateReconnected); const updateDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`; const onUpdateDisconnected = () => { - // Clearing the flag is what lets connectUpdateHub rebuild the session later. + // A dropped transport supersedes any rejoin still pending against the old connection, and + // clearing the flag is what lets connectUpdateHub rebuild the session later. + stopUpdateRejoinRetry(); + updateRejoinAttempts = 0; set({ isUpdateHubConnected: false }); }; updateHubLifecycleHandlers[updateDisconnected] = onUpdateDisconnected; @@ -609,6 +689,8 @@ export const useSignalRStore = create((set, get) => ({ disconnectUpdateHub: async () => { try { // Unregister all handlers BEFORE disconnecting to prevent memory leaks + stopUpdateRejoinRetry(); + updateRejoinAttempts = 0; unregisterUpdateHubHandlers(); unregisterUpdateHubLifecycleHandlers(); From ccc3c19fec6ea5dd813415e3d0bb79575b219a35 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 17 Aug 2026 07:48:19 -0700 Subject: [PATCH 2/2] RG-T131 PR#131 fixes --- src/stores/signalr/signalr-store.ts | 95 ++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 468d95e..4f774bf 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -264,6 +264,14 @@ const UPDATE_REJOIN_RETRY_MS = 5000; const UPDATE_REJOIN_MAX_ATTEMPTS = 3; let updateRejoinTimer: ReturnType | null = null; let updateRejoinAttempts = 0; +// Stamps each rejoin with the connection lifecycle that started it. Teardown (explicit +// disconnect or a dropped transport) bumps the generation, so an invoke still in flight +// against the old connection completes as a no-op instead of restoring the connected +// flag, reloading incident command, or scheduling retries after the connection is gone. +let updateConnectionGeneration = 0; +// The rejoin in flight for the current generation, so overlapping reconnect events share +// one announce instead of racing each other's retry budget. +let updateRejoinOperation: { generation: number; promise: Promise } | null = null; function stopUpdateRejoinRetry(): void { if (updateRejoinTimer) { @@ -614,40 +622,62 @@ export const useSignalRStore = create((set, get) => ({ * close does — so it has to be cleared here or connectUpdateHub's already-connected guard * would block every later repair. */ - const rejoinDepartmentGroup = () => { + const runUpdateRejoin = async (generation: number): Promise => { const reconnectDepartmentId = Number(securityStore.getState().rights?.DepartmentId ?? '0'); if (!Number.isFinite(reconnectDepartmentId)) { return; } - signalRService - .invoke(Env.CHANNEL_HUB_NAME, 'connect', reconnectDepartmentId) - .then(() => { + try { + await signalRService.invoke(Env.CHANNEL_HUB_NAME, 'connect', reconnectDepartmentId); + // A completion from a torn-down connection must not restore state or reload. + if (generation !== updateConnectionGeneration) { + return; + } + stopUpdateRejoinRetry(); + updateRejoinAttempts = 0; + set({ isUpdateHubConnected: true, error: null }); + logger.info({ message: 'Re-announced to update hub after reconnect; reloading incident command', context: { departmentId: reconnectDepartmentId } }); + // Lazy import to avoid circular dependency + const { useIncidentCommandStore } = require('../incident-command/store'); + const openCallId = useIncidentCommandStore.getState().callId; + if (openCallId) { + useIncidentCommandStore.getState().handleIncidentCommandUpdated(openCallId); + } + } catch (error) { + // A stale failure must not schedule retries against a connection that is gone. + if (generation !== updateConnectionGeneration) { + return; + } + updateRejoinAttempts += 1; + logger.warn({ message: 'Failed to re-announce to update hub after reconnect', context: { error, attempt: updateRejoinAttempts, maxAttempts: UPDATE_REJOIN_MAX_ATTEMPTS } }); + set({ isUpdateHubConnected: false }); + + if (updateRejoinAttempts < UPDATE_REJOIN_MAX_ATTEMPTS) { stopUpdateRejoinRetry(); - updateRejoinAttempts = 0; - set({ isUpdateHubConnected: true, error: null }); - logger.info({ message: 'Re-announced to update hub after reconnect; reloading incident command', context: { departmentId: reconnectDepartmentId } }); - // Lazy import to avoid circular dependency - const { useIncidentCommandStore } = require('../incident-command/store'); - const openCallId = useIncidentCommandStore.getState().callId; - if (openCallId) { - useIncidentCommandStore.getState().handleIncidentCommandUpdated(openCallId); - } - }) - .catch((error) => { - updateRejoinAttempts += 1; - logger.warn({ message: 'Failed to re-announce to update hub after reconnect', context: { error, attempt: updateRejoinAttempts, maxAttempts: UPDATE_REJOIN_MAX_ATTEMPTS } }); - set({ isUpdateHubConnected: false }); - - if (updateRejoinAttempts < UPDATE_REJOIN_MAX_ATTEMPTS) { - stopUpdateRejoinRetry(); - updateRejoinTimer = setTimeout(() => { - updateRejoinTimer = null; - rejoinDepartmentGroup(); - }, UPDATE_REJOIN_RETRY_MS); - } else { - logger.error({ message: 'Giving up re-announcing to update hub; the next connectUpdateHub will rebuild the session', context: { attempts: updateRejoinAttempts } }); - } - }); + updateRejoinTimer = setTimeout(() => { + updateRejoinTimer = null; + rejoinDepartmentGroup(); + }, UPDATE_REJOIN_RETRY_MS); + } else { + logger.error({ message: 'Giving up re-announcing to update hub; the next connectUpdateHub will rebuild the session', context: { attempts: updateRejoinAttempts } }); + } + } + }; + + const rejoinDepartmentGroup = () => { + const generation = updateConnectionGeneration; + // Reuse the in-flight rejoin only when it belongs to this connection; an operation + // left over from a previous generation is a dead announce that must not absorb the + // fresh connection's rejoin. + if (updateRejoinOperation && updateRejoinOperation.generation === generation) { + return; + } + const promise = runUpdateRejoin(generation).finally(() => { + if (updateRejoinOperation?.promise === promise) { + updateRejoinOperation = null; + } + }); + updateRejoinOperation = { generation, promise }; }; const onUpdateReconnected = () => { @@ -660,8 +690,10 @@ export const useSignalRStore = create((set, get) => ({ const updateDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`; const onUpdateDisconnected = () => { - // A dropped transport supersedes any rejoin still pending against the old connection, and + // A dropped transport supersedes any rejoin still pending against the old connection — + // bumping the generation turns an invoke already in flight into a no-op — and // clearing the flag is what lets connectUpdateHub rebuild the session later. + updateConnectionGeneration += 1; stopUpdateRejoinRetry(); updateRejoinAttempts = 0; set({ isUpdateHubConnected: false }); @@ -688,6 +720,9 @@ export const useSignalRStore = create((set, get) => ({ }, disconnectUpdateHub: async () => { try { + // Invalidate any rejoin still in flight so its completion can't restore the + // connected flag or reload incident command after this teardown. + updateConnectionGeneration += 1; // Unregister all handlers BEFORE disconnecting to prevent memory leaks stopUpdateRejoinRetry(); updateRejoinAttempts = 0;