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:
Yaojia Wang
2025-11-05 13:21:10 +01:00
parent 71895f328d
commit 3fa43c5542
4 changed files with 358 additions and 160 deletions

View 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,
};
}