feat(frontend): Add SignalR Context for real-time event management
Create comprehensive SignalR Context infrastructure to support real-time updates across the application.
Changes:
- Created SignalRContext.tsx with React Context API for SignalR connection management
- Implemented useSignalREvent and useSignalREvents hooks for simplified event subscription
- Updated Kanban page to use new SignalR hooks (reduced from 150+ lines to ~50 lines)
- Updated root layout to use new SignalRProvider from SignalRContext
- Fixed login page Suspense boundary issue for Next.js 16 compatibility
- Fixed Kanban type issue: made description optional to match API response
Features:
- Auto-connect when user is authenticated
- Auto-reconnect with configurable delays (0s, 2s, 5s, 10s, 30s)
- Toast notifications for connection status changes
- Event subscription management with automatic cleanup
- Support for multiple hub connections (PROJECT, NOTIFICATION)
- TypeScript type safety with proper interfaces
Usage:
```tsx
// Subscribe to single event
useSignalREvent('TaskCreated', (task) => {
console.log('Task created:', task);
});
// Subscribe to multiple events
useSignalREvents({
'TaskCreated': (task) => handleTaskCreated(task),
'TaskUpdated': (task) => handleTaskUpdated(task),
});
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
288
lib/signalr/SignalRContext.tsx
Normal file
288
lib/signalr/SignalRContext.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { HubConnection } from '@microsoft/signalr';
|
||||
import { SignalRConnectionManager, ConnectionState } from './ConnectionManager';
|
||||
import { SIGNALR_CONFIG } from './config';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ============================================
|
||||
// TYPE DEFINITIONS
|
||||
// ============================================
|
||||
|
||||
interface SignalRContextValue {
|
||||
// Connection management
|
||||
connection: HubConnection | null;
|
||||
connectionState: ConnectionState;
|
||||
isConnected: boolean;
|
||||
|
||||
// Event handlers registry
|
||||
service: SignalREventService | null;
|
||||
|
||||
// Manual connection control (optional)
|
||||
connect: () => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Event subscription service
|
||||
interface SignalREventService {
|
||||
subscribe: (eventName: string, handler: (...args: any[]) => void) => () => void;
|
||||
unsubscribe: (eventName: string, handler?: (...args: any[]) => void) => void;
|
||||
getEventHandlers: () => SignalREventService | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONTEXT CREATION
|
||||
// ============================================
|
||||
|
||||
const SignalRContext = createContext<SignalRContextValue | null>(null);
|
||||
|
||||
// ============================================
|
||||
// PROVIDER COMPONENT
|
||||
// ============================================
|
||||
|
||||
interface SignalRProviderProps {
|
||||
children: React.ReactNode;
|
||||
hubUrl?: string; // Optional: custom hub URL (defaults to PROJECT hub)
|
||||
autoConnect?: boolean; // Auto-connect when authenticated (default: true)
|
||||
showToasts?: boolean; // Show connection status toasts (default: true)
|
||||
}
|
||||
|
||||
export function SignalRProvider({
|
||||
children,
|
||||
hubUrl = SIGNALR_CONFIG.HUB_URLS.PROJECT,
|
||||
autoConnect = true,
|
||||
showToasts = true,
|
||||
}: SignalRProviderProps) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||
const managerRef = useRef<SignalRConnectionManager | null>(null);
|
||||
const eventHandlersRef = useRef<Map<string, Set<(...args: any[]) => void>>>(new Map());
|
||||
|
||||
// ============================================
|
||||
// EVENT SERVICE IMPLEMENTATION
|
||||
// ============================================
|
||||
|
||||
const eventService: SignalREventService = {
|
||||
subscribe: (eventName: string, handler: (...args: any[]) => void) => {
|
||||
// Register handler
|
||||
if (!eventHandlersRef.current.has(eventName)) {
|
||||
eventHandlersRef.current.set(eventName, new Set());
|
||||
}
|
||||
eventHandlersRef.current.get(eventName)?.add(handler);
|
||||
|
||||
// Subscribe to SignalR event
|
||||
if (managerRef.current) {
|
||||
managerRef.current.on(eventName, handler);
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
eventHandlersRef.current.get(eventName)?.delete(handler);
|
||||
if (managerRef.current) {
|
||||
managerRef.current.off(eventName, handler);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
unsubscribe: (eventName: string, handler?: (...args: any[]) => void) => {
|
||||
if (handler) {
|
||||
eventHandlersRef.current.get(eventName)?.delete(handler);
|
||||
if (managerRef.current) {
|
||||
managerRef.current.off(eventName, handler);
|
||||
}
|
||||
} else {
|
||||
// Unsubscribe all handlers for this event
|
||||
eventHandlersRef.current.delete(eventName);
|
||||
if (managerRef.current) {
|
||||
managerRef.current.off(eventName);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getEventHandlers: () => eventService,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// CONNECTION MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
console.warn('[SignalRContext] Cannot connect: user not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
if (managerRef.current?.state === 'connected') {
|
||||
console.log('[SignalRContext] Already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = new SignalRConnectionManager(hubUrl);
|
||||
managerRef.current = manager;
|
||||
|
||||
// Subscribe to state changes
|
||||
manager.onStateChange((state) => {
|
||||
setConnectionState(state);
|
||||
|
||||
// Show toast notifications
|
||||
if (showToasts) {
|
||||
if (state === 'connected') {
|
||||
toast.success('Connected to real-time updates');
|
||||
} else if (state === 'disconnected') {
|
||||
toast.error('Disconnected from real-time updates');
|
||||
} else if (state === 'reconnecting') {
|
||||
toast.info('Reconnecting...');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Re-subscribe all registered event handlers
|
||||
eventHandlersRef.current.forEach((handlers, eventName) => {
|
||||
handlers.forEach((handler) => {
|
||||
manager.on(eventName, handler);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.start();
|
||||
} catch (error) {
|
||||
console.error('[SignalRContext] Connection error:', error);
|
||||
if (showToasts) {
|
||||
toast.error('Failed to connect to real-time updates');
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, hubUrl, showToasts]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
if (managerRef.current) {
|
||||
await managerRef.current.stop();
|
||||
managerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// AUTO-CONNECT ON AUTHENTICATION
|
||||
// ============================================
|
||||
|
||||
useEffect(() => {
|
||||
if (autoConnect && isAuthenticated) {
|
||||
connect();
|
||||
} else if (!isAuthenticated) {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [isAuthenticated, autoConnect, connect, disconnect]);
|
||||
|
||||
// ============================================
|
||||
// CONTEXT VALUE
|
||||
// ============================================
|
||||
|
||||
const contextValue: SignalRContextValue = {
|
||||
connection: managerRef.current?.['connection'] ?? null,
|
||||
connectionState,
|
||||
isConnected: connectionState === 'connected',
|
||||
service: eventService,
|
||||
connect,
|
||||
disconnect,
|
||||
};
|
||||
|
||||
return (
|
||||
<SignalRContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SignalRContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOM HOOKS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Access SignalR context
|
||||
*/
|
||||
export function useSignalRContext(): SignalRContextValue {
|
||||
const context = useContext(SignalRContext);
|
||||
if (!context) {
|
||||
throw new Error('useSignalRContext must be used within SignalRProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a specific SignalR event (simplified hook)
|
||||
*
|
||||
* @example
|
||||
* useSignalREvent('TaskStatusChanged', (taskId, newStatus) => {
|
||||
* console.log('Task status changed:', taskId, newStatus);
|
||||
* });
|
||||
*/
|
||||
export function useSignalREvent(
|
||||
eventName: string,
|
||||
handler: (...args: any[]) => void,
|
||||
deps: React.DependencyList = []
|
||||
) {
|
||||
const { service, isConnected } = useSignalRContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !service) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = service.subscribe(eventName, handler);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventName, isConnected, service, ...deps]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to multiple SignalR events
|
||||
*
|
||||
* @example
|
||||
* useSignalREvents({
|
||||
* 'TaskCreated': (task) => console.log('Task created:', task),
|
||||
* 'TaskUpdated': (task) => console.log('Task updated:', task),
|
||||
* });
|
||||
*/
|
||||
export function useSignalREvents(
|
||||
events: Record<string, (...args: any[]) => void>,
|
||||
deps: React.DependencyList = []
|
||||
) {
|
||||
const { service, isConnected } = useSignalRContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !service) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribers = Object.entries(events).map(([eventName, handler]) =>
|
||||
service.subscribe(eventName, handler)
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isConnected, service, ...deps]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection state and status
|
||||
*/
|
||||
export function useSignalRConnection() {
|
||||
const { connectionState, isConnected, connect, disconnect } = useSignalRContext();
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
isConnected,
|
||||
connect,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user