diff --git a/src/components/dev/CacheMonitor.tsx b/src/components/dev/CacheMonitor.tsx index d2cc97d5..e2dff29d 100644 --- a/src/components/dev/CacheMonitor.tsx +++ b/src/components/dev/CacheMonitor.tsx @@ -1,11 +1,17 @@ -import { useState, useEffect } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect, useRef } from 'react'; +import { useQueryClient, QueryCache } from '@tanstack/react-query'; + +interface InvalidationEvent { + timestamp: number; + queryKey: readonly unknown[]; + reason: string; +} /** * CacheMonitor Component (Dev Only) * * Real-time cache performance monitoring for development. - * Displays total queries, stale queries, fetching queries, and cache size. + * Displays total queries, stale queries, fetching queries, cache size, and invalidations. * Only renders in development mode. */ export function CacheMonitor() { @@ -16,7 +22,10 @@ export function CacheMonitor() { fetchingQueries: 0, cacheSize: 0, }); + const [recentInvalidations, setRecentInvalidations] = useState([]); + const invalidationsRef = useRef([]); + // Monitor cache stats useEffect(() => { const interval = setInterval(() => { const cache = queryClient.getQueryCache(); @@ -28,22 +37,85 @@ export function CacheMonitor() { fetchingQueries: queries.filter(q => q.state.fetchStatus === 'fetching').length, cacheSize: JSON.stringify(queries).length, }); + + // Update invalidations display + setRecentInvalidations([...invalidationsRef.current]); }, 1000); return () => clearInterval(interval); }, [queryClient]); + // Track invalidations + useEffect(() => { + const cache = queryClient.getQueryCache(); + + const unsubscribe = cache.subscribe((event) => { + if (event?.type === 'removed' || event?.type === 'updated') { + const query = event.query; + if (query && query.state.fetchStatus === 'idle' && query.isStale()) { + const invalidation: InvalidationEvent = { + timestamp: Date.now(), + queryKey: query.queryKey, + reason: event.type, + }; + + invalidationsRef.current = [invalidation, ...invalidationsRef.current].slice(0, 5); + } + } + }); + + return () => unsubscribe(); + }, [queryClient]); + if (!import.meta.env.DEV) return null; + const formatQueryKey = (key: readonly unknown[]): string => { + return JSON.stringify(key).slice(0, 40) + '...'; + }; + + const formatTime = (timestamp: number): string => { + const diff = Date.now() - timestamp; + if (diff < 1000) return 'just now'; + if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`; + return `${Math.floor(diff / 60000)}m ago`; + }; + return ( -
-

Cache Monitor

-
-
Total Queries: {stats.totalQueries}
-
Stale: {stats.staleQueries}
-
Fetching: {stats.fetchingQueries}
-
Size: {(stats.cacheSize / 1024).toFixed(1)} KB
+
+

Cache Monitor

+ +
+
+ Total Queries: + {stats.totalQueries} +
+
+ Stale: + {stats.staleQueries} +
+
+ Fetching: + {stats.fetchingQueries} +
+
+ Size: + {(stats.cacheSize / 1024).toFixed(1)} KB +
+ + {recentInvalidations.length > 0 && ( +
+

Recent Invalidations

+
+ {recentInvalidations.map((inv, i) => ( +
+
{formatTime(inv.timestamp)}
+
{formatQueryKey(inv.queryKey)}
+
+ ))} +
+
+ )}
); } diff --git a/src/components/reviews/ReviewForm.tsx b/src/components/reviews/ReviewForm.tsx index 14e291b1..99196bf5 100644 --- a/src/components/reviews/ReviewForm.tsx +++ b/src/components/reviews/ReviewForm.tsx @@ -17,6 +17,8 @@ import { StarRating } from './StarRating'; import { toDateOnly } from '@/lib/dateUtils'; import { getErrorMessage } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; +import { useQueryInvalidation } from '@/lib/queryInvalidation'; + const reviewSchema = z.object({ rating: z.number().min(0.5).max(5).multipleOf(0.5), title: z.string().optional(), @@ -41,6 +43,7 @@ export function ReviewForm({ const { user } = useAuth(); + const { invalidateEntityReviews } = useQueryInvalidation(); const [rating, setRating] = useState(0); const [submitting, setSubmitting] = useState(false); const [photos, setPhotos] = useState([]); @@ -118,6 +121,10 @@ export function ReviewForm({ title: "Review Submitted!", description: "Thank you for your review. It will be published after moderation." }); + + // Invalidate review cache for instant UI update + invalidateEntityReviews(entityType, entityId); + reset(); setRating(0); setPhotos([]); diff --git a/src/hooks/useAvatarUpload.ts b/src/hooks/useAvatarUpload.ts index b6467065..a5048722 100644 --- a/src/hooks/useAvatarUpload.ts +++ b/src/hooks/useAvatarUpload.ts @@ -1,6 +1,8 @@ import { useState, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { handleError, handleSuccess } from '@/lib/errorHandler'; +import { useQueryInvalidation } from '@/lib/queryInvalidation'; +import { useAuth } from '@/hooks/useAuth'; export type AvatarUploadState = { url: string; @@ -13,6 +15,8 @@ export const useAvatarUpload = ( initialImageId: string = '', username: string ) => { + const { user } = useAuth(); + const { invalidateUserProfile } = useQueryInvalidation(); const [state, setState] = useState({ url: initialUrl, imageId: initialImageId, @@ -48,6 +52,11 @@ export const useAvatarUpload = ( setState(prev => ({ ...prev, isUploading: false })); handleSuccess('Avatar updated', 'Your avatar has been successfully updated.'); + // Invalidate user profile cache for instant UI update + if (user?.id) { + invalidateUserProfile(user.id); + } + return { success: true }; } catch (error: unknown) { // Rollback on error @@ -64,7 +73,7 @@ export const useAvatarUpload = ( return { success: false, error }; } - }, [username, initialUrl, initialImageId]); + }, [username, initialUrl, initialImageId, user?.id, invalidateUserProfile]); const resetAvatar = useCallback(() => { setState({ diff --git a/src/lib/queryInvalidation.ts b/src/lib/queryInvalidation.ts index 81421445..2125fcc4 100644 --- a/src/lib/queryInvalidation.ts +++ b/src/lib/queryInvalidation.ts @@ -324,5 +324,14 @@ export function useQueryInvalidation() { queryKey: queryKeys.entities.name(entityType, entityId) }); }, + + /** + * Invalidate user profile cache + */ + invalidateUserProfile: (userId: string) => { + queryClient.invalidateQueries({ + queryKey: ['profiles', userId] + }); + }, }; } diff --git a/src/pages/BlogPost.tsx b/src/pages/BlogPost.tsx index ddd575e2..cf14501d 100644 --- a/src/pages/BlogPost.tsx +++ b/src/pages/BlogPost.tsx @@ -13,9 +13,11 @@ import { Header } from '@/components/layout/Header'; import { Footer } from '@/components/layout/Footer'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useOpenGraph } from '@/hooks/useOpenGraph'; +import { useQueryInvalidation } from '@/lib/queryInvalidation'; export default function BlogPost() { const { slug } = useParams<{ slug: string }>(); + const { invalidateBlogPost } = useQueryInvalidation(); const { data: post, isLoading } = useBlogPost(slug); @@ -34,9 +36,12 @@ export default function BlogPost() { useEffect(() => { if (slug) { - supabase.rpc('increment_blog_view_count', { post_slug: slug }); + supabase.rpc('increment_blog_view_count', { post_slug: slug }).then(() => { + // Invalidate blog post cache to update view count + invalidateBlogPost(slug); + }); } - }, [slug]); + }, [slug, invalidateBlogPost]); if (isLoading) { return (