Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
181 lines
5.7 KiB
TypeScript
181 lines
5.7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { trpc } from '@/lib/trpc';
|
|
import type { SubscriptionEvent } from '@codewalk-district/shared';
|
|
|
|
interface UseSubscriptionWithErrorHandlingOptions {
|
|
/** Called when subscription receives data */
|
|
onData?: (data: SubscriptionEvent) => void;
|
|
/** Called when subscription encounters an error */
|
|
onError?: (error: Error) => void;
|
|
/** Called when subscription starts */
|
|
onStarted?: () => void;
|
|
/** Called when subscription stops */
|
|
onStopped?: () => void;
|
|
/** Whether to automatically reconnect on errors (default: true) */
|
|
autoReconnect?: boolean;
|
|
/** Delay before attempting reconnection in ms (default: 1000) */
|
|
reconnectDelay?: number;
|
|
/** Maximum number of reconnection attempts (default: 5) */
|
|
maxReconnectAttempts?: number;
|
|
/** Whether the subscription is enabled (default: true) */
|
|
enabled?: boolean;
|
|
}
|
|
|
|
interface SubscriptionState {
|
|
isConnected: boolean;
|
|
isConnecting: boolean;
|
|
error: Error | null;
|
|
reconnectAttempts: number;
|
|
lastEventId: string | null;
|
|
}
|
|
|
|
/**
|
|
* Hook for managing tRPC subscriptions with error handling, reconnection, and cleanup.
|
|
*
|
|
* Provides automatic reconnection on connection failures, tracks connection state,
|
|
* and ensures proper cleanup on unmount.
|
|
*/
|
|
export function useSubscriptionWithErrorHandling(
|
|
subscription: () => ReturnType<typeof trpc.onEvent.useSubscription>,
|
|
options: UseSubscriptionWithErrorHandlingOptions = {}
|
|
) {
|
|
const {
|
|
autoReconnect = true,
|
|
reconnectDelay = 1000,
|
|
maxReconnectAttempts = 5,
|
|
enabled = true,
|
|
} = options;
|
|
|
|
const [state, setState] = useState<SubscriptionState>({
|
|
isConnected: false,
|
|
isConnecting: false,
|
|
error: null,
|
|
reconnectAttempts: 0,
|
|
lastEventId: null,
|
|
});
|
|
|
|
// Store callbacks in refs so they never appear in effect deps.
|
|
// Callers pass inline arrows that change identity every render —
|
|
// putting them in deps causes setState → re-render → new callback → effect re-fire → infinite loop.
|
|
const callbacksRef = useRef(options);
|
|
callbacksRef.current = options;
|
|
|
|
const reconnectAttemptsRef = useRef(0);
|
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const mountedRef = useRef(true);
|
|
|
|
// Clear reconnect timeout on unmount
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
callbacksRef.current.onStopped?.();
|
|
};
|
|
}, []);
|
|
|
|
const scheduleReconnect = useCallback(() => {
|
|
if (!autoReconnect || reconnectAttemptsRef.current >= maxReconnectAttempts || !mountedRef.current) {
|
|
return;
|
|
}
|
|
|
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
if (mountedRef.current) {
|
|
reconnectAttemptsRef.current += 1;
|
|
setState(prev => ({
|
|
...prev,
|
|
isConnecting: true,
|
|
reconnectAttempts: reconnectAttemptsRef.current,
|
|
}));
|
|
}
|
|
}, reconnectDelay);
|
|
}, [autoReconnect, maxReconnectAttempts, reconnectDelay]);
|
|
|
|
const subscriptionResult = subscription();
|
|
|
|
// Handle subscription state changes.
|
|
// Only depends on primitive/stable values — never on caller callbacks.
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
setState(prev => {
|
|
if (!prev.isConnected && !prev.isConnecting && prev.error === null) return prev;
|
|
return { ...prev, isConnected: false, isConnecting: false, error: null };
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (subscriptionResult.status === 'pending') {
|
|
setState(prev => {
|
|
if (prev.isConnecting && prev.error === null) return prev;
|
|
return { ...prev, isConnecting: true, error: null };
|
|
});
|
|
callbacksRef.current.onStarted?.();
|
|
} else if (subscriptionResult.status === 'error') {
|
|
const error = subscriptionResult.error instanceof Error
|
|
? subscriptionResult.error
|
|
: new Error('Subscription error');
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
isConnected: false,
|
|
isConnecting: false,
|
|
error,
|
|
}));
|
|
|
|
callbacksRef.current.onError?.(error);
|
|
scheduleReconnect();
|
|
} else if (subscriptionResult.status === 'success') {
|
|
reconnectAttemptsRef.current = 0;
|
|
setState(prev => {
|
|
if (prev.isConnected && !prev.isConnecting && prev.error === null && prev.reconnectAttempts === 0) return prev;
|
|
return { ...prev, isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0 };
|
|
});
|
|
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
reconnectTimeoutRef.current = null;
|
|
}
|
|
}
|
|
}, [enabled, subscriptionResult.status, subscriptionResult.error, scheduleReconnect]);
|
|
|
|
// Handle incoming data
|
|
useEffect(() => {
|
|
if (subscriptionResult.data) {
|
|
setState(prev => ({ ...prev, lastEventId: subscriptionResult.data.id }));
|
|
callbacksRef.current.onData?.(subscriptionResult.data);
|
|
}
|
|
}, [subscriptionResult.data]);
|
|
|
|
return {
|
|
...state,
|
|
/** Manually trigger a reconnection attempt */
|
|
reconnect: () => {
|
|
if (mountedRef.current) {
|
|
reconnectAttemptsRef.current = 0;
|
|
setState(prev => ({
|
|
...prev,
|
|
isConnecting: true,
|
|
error: null,
|
|
reconnectAttempts: 0,
|
|
}));
|
|
}
|
|
},
|
|
/** Reset error state and reconnection attempts */
|
|
reset: () => {
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
reconnectTimeoutRef.current = null;
|
|
}
|
|
reconnectAttemptsRef.current = 0;
|
|
setState(prev => ({
|
|
...prev,
|
|
error: null,
|
|
reconnectAttempts: 0,
|
|
isConnecting: false,
|
|
}));
|
|
},
|
|
};
|
|
}
|