diff --git a/app/api/notifications/count/route.ts b/app/api/notifications/count/route.ts index f769d359..bd13e57a 100644 --- a/app/api/notifications/count/route.ts +++ b/app/api/notifications/count/route.ts @@ -19,7 +19,10 @@ export async function GET(request: Request) { const notificationService = NotificationService.getInstance(); const counts = await notificationService.getNotificationCount(userId); - return NextResponse.json(counts); + // Add Cache-Control header to help with client-side caching + const response = NextResponse.json(counts); + response.headers.set('Cache-Control', 'private, max-age=10'); // Cache for 10 seconds on client + return response; } catch (error: any) { console.error('Error in notification count API:', error); return NextResponse.json( diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts index a9b405ad..b0f127bb 100644 --- a/app/api/notifications/route.ts +++ b/app/api/notifications/route.ts @@ -38,12 +38,15 @@ export async function GET(request: Request) { const notificationService = NotificationService.getInstance(); const notifications = await notificationService.getNotifications(userId, page, limit); - return NextResponse.json({ + // Add Cache-Control header to help with client-side caching + const response = NextResponse.json({ notifications, page, limit, total: notifications.length }); + response.headers.set('Cache-Control', 'private, max-age=30'); // Cache for 30 seconds on client + return response; } catch (error: any) { console.error('Error in notifications API:', error); return NextResponse.json( diff --git a/components/notification-badge.tsx b/components/notification-badge.tsx index cdffe3f0..d0ea63d9 100644 --- a/components/notification-badge.tsx +++ b/components/notification-badge.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import Link from 'next/link'; import { Bell } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; @@ -8,7 +8,8 @@ interface NotificationBadgeProps { className?: string; } -export function NotificationBadge({ className }: NotificationBadgeProps) { +// Use React.memo to prevent unnecessary re-renders +export const NotificationBadge = memo(function NotificationBadge({ className }: NotificationBadgeProps) { const { notificationCount } = useNotifications(); const hasUnread = notificationCount.unread > 0; @@ -32,4 +33,4 @@ export function NotificationBadge({ className }: NotificationBadgeProps) { ); -} \ No newline at end of file +}); \ No newline at end of file diff --git a/hooks/use-notifications.ts b/hooks/use-notifications.ts index e5e60b9e..7cc93f02 100644 --- a/hooks/use-notifications.ts +++ b/hooks/use-notifications.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useSession } from 'next-auth/react'; import { Notification, NotificationCount } from '@/lib/types/notification'; @@ -9,20 +9,47 @@ const defaultNotificationCount: NotificationCount = { sources: {} }; +// Debounce function to limit API calls +function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + + return function(...args: Parameters) { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} + export function useNotifications() { const { data: session, status } = useSession(); const [notifications, setNotifications] = useState([]); const [notificationCount, setNotificationCount] = useState(defaultNotificationCount); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [pollingInterval, setPollingInterval] = useState(null); + const pollingIntervalRef = useRef(null); + const lastFetchTimeRef = useRef(0); + const isMountedRef = useRef(false); + const isPollingRef = useRef(false); + + // Minimum time between fetches (in milliseconds) + const MIN_FETCH_INTERVAL = 5000; // 5 seconds + const POLLING_INTERVAL = 60000; // 1 minute - // Fetch notification count - const fetchNotificationCount = useCallback(async () => { - if (!session?.user) return; + // Fetch notification count with rate limiting + const fetchNotificationCount = useCallback(async (force = false) => { + if (!session?.user || !isMountedRef.current) return; + + const now = Date.now(); + if (!force && now - lastFetchTimeRef.current < MIN_FETCH_INTERVAL) { + console.log('Skipping notification count fetch - too soon'); + return; + } try { setError(null); + lastFetchTimeRef.current = now; const response = await fetch('/api/notifications/count'); @@ -34,19 +61,34 @@ export function useNotifications() { } const data = await response.json(); - setNotificationCount(data); + if (isMountedRef.current) { + setNotificationCount(data); + } } catch (err) { console.error('Error fetching notification count:', err); setError('Failed to fetch notification count'); } }, [session?.user]); + // Debounced version to prevent rapid successive calls + const debouncedFetchCount = useCallback( + debounce(fetchNotificationCount, 300), + [fetchNotificationCount] + ); + // Fetch notifications const fetchNotifications = useCallback(async (page = 1, limit = 20) => { - if (!session?.user) return; + if (!session?.user || !isMountedRef.current) return; + + const now = Date.now(); + if (now - lastFetchTimeRef.current < MIN_FETCH_INTERVAL) { + console.log('Skipping notifications fetch - too soon'); + return; + } setLoading(true); setError(null); + lastFetchTimeRef.current = now; try { const response = await fetch(`/api/notifications?page=${page}&limit=${limit}`); @@ -59,18 +101,22 @@ export function useNotifications() { } const data = await response.json(); - setNotifications(data.notifications); + if (isMountedRef.current) { + setNotifications(data.notifications); + } } catch (err) { console.error('Error fetching notifications:', err); setError('Failed to fetch notifications'); } finally { - setLoading(false); + if (isMountedRef.current) { + setLoading(false); + } } }, [session?.user]); // Mark notification as read const markAsRead = useCallback(async (notificationId: string) => { - if (!session?.user) return; + if (!session?.user) return false; try { const response = await fetch(`/api/notifications/${notificationId}/read`, { @@ -96,18 +142,18 @@ export function useNotifications() { ); // Refresh notification count - fetchNotificationCount(); + debouncedFetchCount(true); return true; } catch (err) { console.error('Error marking notification as read:', err); return false; } - }, [session?.user, fetchNotificationCount]); + }, [session?.user, debouncedFetchCount]); // Mark all notifications as read const markAllAsRead = useCallback(async () => { - if (!session?.user) return; + if (!session?.user) return false; try { const response = await fetch('/api/notifications/read-all', { @@ -129,55 +175,62 @@ export function useNotifications() { ); // Refresh notification count - fetchNotificationCount(); + debouncedFetchCount(true); return true; } catch (err) { console.error('Error marking all notifications as read:', err); return false; } - }, [session?.user, fetchNotificationCount]); + }, [session?.user, debouncedFetchCount]); // Start polling for notification count - const startPolling = useCallback((interval = 30000) => { - if (pollingInterval) { - clearInterval(pollingInterval); + const startPolling = useCallback(() => { + if (isPollingRef.current) return; + + isPollingRef.current = true; + + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); } - const id = setInterval(() => { - fetchNotificationCount(); - }, interval); + // Ensure we don't create multiple intervals + pollingIntervalRef.current = setInterval(() => { + if (isMountedRef.current) { + debouncedFetchCount(); + } + }, POLLING_INTERVAL); - setPollingInterval(id); - - return () => { - clearInterval(id); - setPollingInterval(null); - }; - }, [fetchNotificationCount, pollingInterval]); + return () => stopPolling(); + }, [debouncedFetchCount]); // Stop polling const stopPolling = useCallback(() => { - if (pollingInterval) { - clearInterval(pollingInterval); - setPollingInterval(null); + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; } - }, [pollingInterval]); + isPollingRef.current = false; + }, []); - // Initialize fetching on component mount + // Initialize fetching on component mount and cleanup on unmount useEffect(() => { + isMountedRef.current = true; + if (status === 'authenticated' && session?.user) { - fetchNotificationCount(); + // Initial fetches + fetchNotificationCount(true); fetchNotifications(); // Start polling - const cleanup = startPolling(); - - return () => { - cleanup(); - }; + startPolling(); } - }, [status, session?.user, fetchNotificationCount, fetchNotifications, startPolling]); + + return () => { + isMountedRef.current = false; + stopPolling(); + }; + }, [status, session?.user, fetchNotificationCount, fetchNotifications, startPolling, stopPolling]); return { notifications, @@ -185,10 +238,8 @@ export function useNotifications() { loading, error, fetchNotifications, - fetchNotificationCount, + fetchNotificationCount: () => debouncedFetchCount(true), markAsRead, - markAllAsRead, - startPolling, - stopPolling + markAllAsRead }; } \ No newline at end of file diff --git a/lib/services/notifications/notification-service.ts b/lib/services/notifications/notification-service.ts index 9d1f6225..83114fb3 100644 --- a/lib/services/notifications/notification-service.ts +++ b/lib/services/notifications/notification-service.ts @@ -278,8 +278,27 @@ export class NotificationService { // Use setTimeout to make this non-blocking setTimeout(async () => { try { + // Check if we've refreshed recently (within the last minute) + // to avoid excessive refreshes from multiple tabs/components + const refreshKey = `notifications:last_refresh:${userId}`; + const lastRefresh = await redis.get(refreshKey); + + if (lastRefresh) { + const lastRefreshTime = parseInt(lastRefresh, 10); + const now = Date.now(); + + // If refreshed in the last minute, skip + if (now - lastRefreshTime < 60000) { + console.log(`[NOTIFICATION_SERVICE] Skipping background refresh for user ${userId} - refreshed recently`); + return; + } + } + console.log(`[NOTIFICATION_SERVICE] Background refresh started for user ${userId}`); + // Set last refresh time + await redis.set(refreshKey, Date.now().toString(), 'EX', 120); // 2 minute TTL + // Refresh counts and notifications (for first page) await this.getNotificationCount(userId); await this.getNotifications(userId, 1, 20);