Надёжная real-time доставка сообщений в чатах: SSE buffer, Last-Event-ID, polling fallback

This commit is contained in:
2026-07-08 21:15:40 +03:00
parent be3ee4e625
commit 7b830c658d
10 changed files with 232 additions and 51 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useAuth } from './useAuth';
import { sseManager } from '@/lib/sseManager';
@@ -21,6 +21,12 @@ export const useEvents = (options: UseEventsOptions = {}) => {
const { user } = useAuth();
const [isConnected, setIsConnected] = useState(false);
const [connectionId, setConnectionId] = useState<string | null>(null);
// Храним актуальные callbacks в ref, чтобы слушатели SSE всегда вызывали
// последние версии обработчиков, но не переподписывались на каждый рендер.
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
});
// Привязываем singleton к текущему пользователю
useEffect(() => {
@@ -35,22 +41,25 @@ export const useEvents = (options: UseEventsOptions = {}) => {
});
}, []);
// Подписываемся на события — каждый useEvents добавляет свои callbacks к одному EventSource
// Fixed: empty dependency array so we subscribe once on mount instead of
// unsubscribing/resubscribing on every render (which dropped SSE events).
// Подписываемся на события — каждый useEvents добавляет свои callbacks к одному EventSource.
// Регистрируем обёртки один раз; обёртки читают актуальные callbacks из ref.
useEffect(() => {
const unsubs: Array<() => void> = [];
if (options.onMessageRead) unsubs.push(sseManager.on('message_read', options.onMessageRead));
if (options.onMessageCreated) unsubs.push(sseManager.on('message_created', options.onMessageCreated));
if (options.onTaskUpdated) unsubs.push(sseManager.on('task_updated', options.onTaskUpdated));
if (options.onNotification) unsubs.push(sseManager.on('notification', options.onNotification));
if (options.onConvMessageCreated) unsubs.push(sseManager.on('conv_message_created', options.onConvMessageCreated));
if (options.onConvMessageUpdated) unsubs.push(sseManager.on('conv_message_updated', options.onConvMessageUpdated));
if (options.onConvMessageDeleted) unsubs.push(sseManager.on('conv_message_deleted', options.onConvMessageDeleted));
if (options.onConvRead) unsubs.push(sseManager.on('conv_read', options.onConvRead));
if (options.onConvCreated) unsubs.push(sseManager.on('conv_created', options.onConvCreated));
if (options.onReactionUpdated) unsubs.push(sseManager.on('reaction_updated', options.onReactionUpdated));
if (options.onPollUpdated) unsubs.push(sseManager.on('poll_updated', options.onPollUpdated));
const wrap = <K extends keyof UseEventsOptions>(name: K) => (data: any) => {
const cb = optionsRef.current[name] as any;
if (cb) cb(data);
};
if (options.onMessageRead) unsubs.push(sseManager.on('message_read', wrap('onMessageRead')));
if (options.onMessageCreated) unsubs.push(sseManager.on('message_created', wrap('onMessageCreated')));
if (options.onTaskUpdated) unsubs.push(sseManager.on('task_updated', wrap('onTaskUpdated')));
if (options.onNotification) unsubs.push(sseManager.on('notification', wrap('onNotification')));
if (options.onConvMessageCreated) unsubs.push(sseManager.on('conv_message_created', wrap('onConvMessageCreated')));
if (options.onConvMessageUpdated) unsubs.push(sseManager.on('conv_message_updated', wrap('onConvMessageUpdated')));
if (options.onConvMessageDeleted) unsubs.push(sseManager.on('conv_message_deleted', wrap('onConvMessageDeleted')));
if (options.onConvRead) unsubs.push(sseManager.on('conv_read', wrap('onConvRead')));
if (options.onConvCreated) unsubs.push(sseManager.on('conv_created', wrap('onConvCreated')));
if (options.onReactionUpdated) unsubs.push(sseManager.on('reaction_updated', wrap('onReactionUpdated')));
if (options.onPollUpdated) unsubs.push(sseManager.on('poll_updated', wrap('onPollUpdated')));
return () => { for (const u of unsubs) u(); };
}, []);