Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .DS_Store
Binary file not shown.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ google-services.json
credentials.json
Gemfile.lock
Gemfile
*.ipa
*.apk
*.keystore

# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
Expand Down
Empty file added docs/layout-refactor.md
Empty file.
151 changes: 151 additions & 0 deletions docs/signalr-lifecycle-ios-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# SignalR Lifecycle iOS Hanging Issue - Fix Documentation

## Problem Summary

The `useSignalRLifecycle` hook was causing the React Native app to hang on iOS when built in release mode. This was particularly problematic during app state transitions (background/foreground).

## Root Causes Identified

### 1. **Race Conditions**
- Rapid app state changes could trigger multiple concurrent SignalR operations
- No protection against overlapping async operations
- Missing debouncing for rapid state transitions

### 2. **Unhandled Promise Rejections**
- Using `await` with multiple sequential promises that could fail
- One failing operation would block the entire chain
- Error handling was not preventing the hook from hanging

### 3. **Missing Operation Cancellation**
- No way to cancel pending operations when component unmounts
- No AbortController usage for async operations
- Potential memory leaks from uncanceled operations

### 4. **iOS-Specific Timing Issues**
- Release builds on iOS are more sensitive to timing issues
- JavaScript bridge optimizations in release mode can expose race conditions
- Missing debouncing made the app vulnerable to rapid state changes

## Solution Implementation

### 1. **Added Concurrency Protection**
```typescript
const isProcessing = useRef(false);
const pendingOperations = useRef<AbortController | null>(null);
```

### 2. **Implemented AbortController Pattern**
```typescript
// Cancel any pending operations
if (pendingOperations.current) {
pendingOperations.current.abort();
}

isProcessing.current = true;
const controller = new AbortController();
pendingOperations.current = controller;
```

### 3. **Used Promise.allSettled for Error Handling**
```typescript
// Use Promise.allSettled to prevent one failure from blocking the other
const results = await Promise.allSettled([
signalRStore.disconnectUpdateHub(),
signalRStore.disconnectGeolocationHub()
]);

// Log any failures without throwing
results.forEach((result, index) => {
if (result.status === 'rejected') {
const hubName = index === 0 ? 'UpdateHub' : 'GeolocationHub';
logger.error({
message: `Failed to disconnect ${hubName} on app background`,
context: { error: result.reason },
});
}
});
```

### 4. **Added Debouncing**
```typescript
// Handle app going to background
useEffect(() => {
if (!isActive && (appState === 'background' || appState === 'inactive') && hasInitialized) {
// Debounce rapid state changes
const timer = setTimeout(() => {
if (!isActive && (appState === 'background' || appState === 'inactive')) {
handleAppBackground();
}
}, 100);

return () => clearTimeout(timer);
}
}, [isActive, appState, hasInitialized, handleAppBackground]);
```

### 5. **Added Proper Cleanup**
```typescript
// Cleanup on unmount
useEffect(() => {
return () => {
if (pendingOperations.current) {
pendingOperations.current.abort();
pendingOperations.current = null;
}
isProcessing.current = false;
};
}, []);
```

### 6. **Enhanced State Validation**
```typescript
// Double-check state before reconnecting
if (isActive && appState === 'active') {
handleAppResume();
}
```

## Key Improvements

1. **Thread Safety**: Added concurrency protection to prevent multiple operations from running simultaneously
2. **Error Resilience**: Operations can fail individually without blocking others
3. **Memory Safety**: Proper cleanup prevents memory leaks
4. **Performance**: Debouncing reduces unnecessary operations
5. **iOS Compatibility**: Addresses iOS-specific timing sensitivities in release builds

## Testing

Added comprehensive tests covering:
- Basic disconnect/reconnect functionality
- Error handling scenarios
- Concurrency prevention
- Debouncing behavior
- Cleanup behavior

All tests pass and verify the robustness of the solution.

## Usage

The hook can now be safely enabled in the main app layout:

```typescript
// In _layout.tsx
useSignalRLifecycle({
isSignedIn: status === 'signedIn',
hasInitialized: hasInitialized.current,
});
```

## Impact

- ✅ Eliminates iOS hanging issues in release builds
- ✅ Improves app stability during state transitions
- ✅ Provides better error handling and logging
- ✅ Reduces unnecessary SignalR operations
- ✅ Maintains backward compatibility

## Future Considerations

1. Monitor app performance metrics to ensure the solution doesn't introduce new issues
2. Consider implementing similar patterns in other lifecycle-related hooks
3. Add telemetry to track SignalR connection health and state transition performance
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"@microsoft/signalr": "~8.0.7",
"@notifee/react-native": "^9.1.8",
"@novu/react-native": "~2.6.6",
"@react-native-community/netinfo": "^11.4.1",
"@rnmapbox/maps": "10.1.38",
"@semantic-release/git": "^10.0.1",
"@sentry/react-native": "~6.10.0",
Expand Down
11 changes: 11 additions & 0 deletions src/api/dispatch/dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createApiEndpoint } from '@/api/common/client';
import { type GetSetUnitStateResult } from '@/models/v4/dispatch/getSetUnitStateResult';

const getSetUnitStateApi = createApiEndpoint('/Dispatch/GetSetUnitState');

export const getSetUnitState = async (unitId: string) => {
const response = await getSetUnitStateApi.get<GetSetUnitStateResult>({
unitId: unitId,
});
return response.data;
};
38 changes: 12 additions & 26 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { size } from 'lodash';
import { Contact, ListTree, Map, Megaphone, Menu, Notebook, Settings } from 'lucide-react-native';
import React, { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform, StyleSheet, useWindowDimensions } from 'react-native';
import { StyleSheet, useWindowDimensions } from 'react-native';

import { NotificationButton } from '@/components/notifications/NotificationButton';
import { NotificationInbox } from '@/components/notifications/NotificationInbox';
Expand Down Expand Up @@ -39,7 +39,6 @@ export default function TabLayout() {
const [isFirstTime, _setIsFirstTime] = useIsFirstTime();
const [isOpen, setIsOpen] = React.useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = React.useState(false);
const [isNotificationSystemReady, setIsNotificationSystemReady] = React.useState(false);
const { width, height } = useWindowDimensions();
const isLandscape = width > height;
const { isActive, appState } = useAppLifecycle();
Expand Down Expand Up @@ -216,20 +215,6 @@ export default function TabLayout() {
const activeUnitId = useCoreStore((state) => state.activeUnitId);
const rights = securityStore((state) => state.rights);

// Manage notification system readiness
useEffect(() => {
const isReady = Boolean(activeUnitId && config && config.NovuApplicationId && config.NovuBackendApiUrl && config.NovuSocketUrl && rights?.DepartmentCode);

if (isReady && !isNotificationSystemReady) {
// Add a small delay to ensure the main UI is rendered first
setTimeout(() => {
setIsNotificationSystemReady(true);
}, 1000);
} else if (!isReady && isNotificationSystemReady) {
setIsNotificationSystemReady(false);
}
}, [activeUnitId, config, rights?.DepartmentCode, isNotificationSystemReady]);

if (isFirstTime) {
//setIsOnboarding();
return <Redirect href="/onboarding" />;
Expand All @@ -239,7 +224,7 @@ export default function TabLayout() {
}

const content = (
<View style={styles.container} pointerEvents="auto">
<View style={styles.container} pointerEvents="box-none">
<View className="flex-1 flex-row" ref={parentRef}>
{/* Drawer - conditionally rendered as permanent in landscape */}
{isLandscape ? (
Expand Down Expand Up @@ -281,8 +266,8 @@ export default function TabLayout() {
paddingTop: 5,
height: isLandscape ? 65 : 60,
elevation: 8, // Ensure tab bar is above other elements on Android
zIndex: 10, // Reduced z-index to prevent stacking issues
backgroundColor: undefined, // Let the tab bar use its default background
zIndex: 100, // Ensure tab bar is above other elements on iOS
backgroundColor: 'transparent', // Ensure proper touch event handling
},
}}
>
Expand Down Expand Up @@ -353,16 +338,16 @@ export default function TabLayout() {
</Tabs>

{/* NotificationInbox positioned within the tab content area */}
{isNotificationSystemReady && <NotificationInbox isOpen={isNotificationsOpen} onClose={() => setIsNotificationsOpen(false)} />}
{activeUnitId && config && rights?.DepartmentCode && <NotificationInbox isOpen={isNotificationsOpen} onClose={() => setIsNotificationsOpen(false)} />}
</View>
</View>
</View>
);

return (
<>
{isNotificationSystemReady ? (
<NovuProvider subscriberId={`${rights?.DepartmentCode}_Unit_${activeUnitId}`} applicationIdentifier={config!.NovuApplicationId} backendUrl={config!.NovuBackendApiUrl} socketUrl={config!.NovuSocketUrl}>
{activeUnitId && config && rights?.DepartmentCode ? (
<NovuProvider subscriberId={`${rights?.DepartmentCode}_Unit_${activeUnitId}`} applicationIdentifier={config.NovuApplicationId} backendUrl={config.NovuBackendApiUrl} socketUrl={config.NovuSocketUrl}>
{content}
</NovuProvider>
) : (
Expand Down Expand Up @@ -409,16 +394,17 @@ const CreateNotificationButton = ({
return null;
}

// Only render after notification system is ready to prevent timing issues
return <NotificationButton onPress={() => setIsNotificationsOpen(true)} />;
return (
<NovuProvider subscriberId={`${departmentCode}_Unit_${activeUnitId}`} applicationIdentifier={config.NovuApplicationId} backendUrl={config.NovuBackendApiUrl} socketUrl={config.NovuSocketUrl}>
<NotificationButton onPress={() => setIsNotificationsOpen(true)} />
</NovuProvider>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
height: '100%',
// Ensure proper touch event handling in iOS production builds
...(Platform.OS === 'ios' && { overflow: 'hidden' }),
},
});
2 changes: 2 additions & 0 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
import { APIProvider } from '@/api';
import { AptabaseProviderWrapper } from '@/components/common/aptabase-provider';
import { LiveKitBottomSheet } from '@/components/livekit';
import { PushNotificationModal } from '@/components/push-notification/push-notification-modal';
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider';
import { loadKeepAliveState } from '@/lib/hooks/use-keep-alive';
import { loadSelectedTheme } from '@/lib/hooks/use-selected-theme';
Expand Down Expand Up @@ -161,6 +162,7 @@ function Providers({ children }: { children: React.ReactNode }) {
<BottomSheetModalProvider>
{children}
<LiveKitBottomSheet />
<PushNotificationModal />
<FlashMessage position="top" />
</BottomSheetModalProvider>
</ThemeProvider>
Expand Down
Loading
Loading