Implement cache invalidation improvements

This commit is contained in:
gpt-engineer-app[bot]
2025-10-31 01:23:29 +00:00
parent 875d189881
commit 179d9e674c
5 changed files with 115 additions and 13 deletions

View File

@@ -1,11 +1,17 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient, QueryCache } from '@tanstack/react-query';
interface InvalidationEvent {
timestamp: number;
queryKey: readonly unknown[];
reason: string;
}
/** /**
* CacheMonitor Component (Dev Only) * CacheMonitor Component (Dev Only)
* *
* Real-time cache performance monitoring for development. * 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. * Only renders in development mode.
*/ */
export function CacheMonitor() { export function CacheMonitor() {
@@ -16,7 +22,10 @@ export function CacheMonitor() {
fetchingQueries: 0, fetchingQueries: 0,
cacheSize: 0, cacheSize: 0,
}); });
const [recentInvalidations, setRecentInvalidations] = useState<InvalidationEvent[]>([]);
const invalidationsRef = useRef<InvalidationEvent[]>([]);
// Monitor cache stats
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
const cache = queryClient.getQueryCache(); const cache = queryClient.getQueryCache();
@@ -28,22 +37,85 @@ export function CacheMonitor() {
fetchingQueries: queries.filter(q => q.state.fetchStatus === 'fetching').length, fetchingQueries: queries.filter(q => q.state.fetchStatus === 'fetching').length,
cacheSize: JSON.stringify(queries).length, cacheSize: JSON.stringify(queries).length,
}); });
// Update invalidations display
setRecentInvalidations([...invalidationsRef.current]);
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [queryClient]); }, [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; 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 ( return (
<div className="fixed bottom-4 right-4 bg-black/80 text-white p-4 rounded-lg text-xs font-mono z-50 shadow-xl"> <div className="fixed bottom-4 right-4 bg-black/90 text-white p-4 rounded-lg text-xs font-mono z-50 shadow-xl max-w-sm">
<h3 className="font-bold mb-2 text-primary">Cache Monitor</h3> <h3 className="font-bold mb-3 text-primary border-b border-primary/30 pb-2">Cache Monitor</h3>
<div className="space-y-1">
<div>Total Queries: <span className="text-green-400">{stats.totalQueries}</span></div> <div className="space-y-1 mb-3 pb-3 border-b border-white/10">
<div>Stale: <span className="text-yellow-400">{stats.staleQueries}</span></div> <div className="flex justify-between">
<div>Fetching: <span className="text-blue-400">{stats.fetchingQueries}</span></div> <span>Total Queries:</span>
<div>Size: <span className="text-purple-400">{(stats.cacheSize / 1024).toFixed(1)} KB</span></div> <span className="text-green-400 font-bold">{stats.totalQueries}</span>
</div>
<div className="flex justify-between">
<span>Stale:</span>
<span className="text-yellow-400 font-bold">{stats.staleQueries}</span>
</div>
<div className="flex justify-between">
<span>Fetching:</span>
<span className="text-blue-400 font-bold">{stats.fetchingQueries}</span>
</div>
<div className="flex justify-between">
<span>Size:</span>
<span className="text-purple-400 font-bold">{(stats.cacheSize / 1024).toFixed(1)} KB</span>
</div>
</div> </div>
{recentInvalidations.length > 0 && (
<div>
<h4 className="font-bold mb-2 text-accent">Recent Invalidations</h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{recentInvalidations.map((inv, i) => (
<div key={i} className="text-[10px] opacity-80 border-l-2 border-accent/50 pl-2">
<div className="text-muted-foreground">{formatTime(inv.timestamp)}</div>
<div className="truncate">{formatQueryKey(inv.queryKey)}</div>
</div>
))}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -17,6 +17,8 @@ import { StarRating } from './StarRating';
import { toDateOnly } from '@/lib/dateUtils'; import { toDateOnly } from '@/lib/dateUtils';
import { getErrorMessage } from '@/lib/errorHandler'; import { getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
const reviewSchema = z.object({ const reviewSchema = z.object({
rating: z.number().min(0.5).max(5).multipleOf(0.5), rating: z.number().min(0.5).max(5).multipleOf(0.5),
title: z.string().optional(), title: z.string().optional(),
@@ -41,6 +43,7 @@ export function ReviewForm({
const { const {
user user
} = useAuth(); } = useAuth();
const { invalidateEntityReviews } = useQueryInvalidation();
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [photos, setPhotos] = useState<string[]>([]); const [photos, setPhotos] = useState<string[]>([]);
@@ -118,6 +121,10 @@ export function ReviewForm({
title: "Review Submitted!", title: "Review Submitted!",
description: "Thank you for your review. It will be published after moderation." description: "Thank you for your review. It will be published after moderation."
}); });
// Invalidate review cache for instant UI update
invalidateEntityReviews(entityType, entityId);
reset(); reset();
setRating(0); setRating(0);
setPhotos([]); setPhotos([]);

View File

@@ -1,6 +1,8 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { handleError, handleSuccess } from '@/lib/errorHandler'; import { handleError, handleSuccess } from '@/lib/errorHandler';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
import { useAuth } from '@/hooks/useAuth';
export type AvatarUploadState = { export type AvatarUploadState = {
url: string; url: string;
@@ -13,6 +15,8 @@ export const useAvatarUpload = (
initialImageId: string = '', initialImageId: string = '',
username: string username: string
) => { ) => {
const { user } = useAuth();
const { invalidateUserProfile } = useQueryInvalidation();
const [state, setState] = useState<AvatarUploadState>({ const [state, setState] = useState<AvatarUploadState>({
url: initialUrl, url: initialUrl,
imageId: initialImageId, imageId: initialImageId,
@@ -48,6 +52,11 @@ export const useAvatarUpload = (
setState(prev => ({ ...prev, isUploading: false })); setState(prev => ({ ...prev, isUploading: false }));
handleSuccess('Avatar updated', 'Your avatar has been successfully updated.'); 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 }; return { success: true };
} catch (error: unknown) { } catch (error: unknown) {
// Rollback on error // Rollback on error
@@ -64,7 +73,7 @@ export const useAvatarUpload = (
return { success: false, error }; return { success: false, error };
} }
}, [username, initialUrl, initialImageId]); }, [username, initialUrl, initialImageId, user?.id, invalidateUserProfile]);
const resetAvatar = useCallback(() => { const resetAvatar = useCallback(() => {
setState({ setState({

View File

@@ -324,5 +324,14 @@ export function useQueryInvalidation() {
queryKey: queryKeys.entities.name(entityType, entityId) queryKey: queryKeys.entities.name(entityType, entityId)
}); });
}, },
/**
* Invalidate user profile cache
*/
invalidateUserProfile: (userId: string) => {
queryClient.invalidateQueries({
queryKey: ['profiles', userId]
});
},
}; };
} }

View File

@@ -13,9 +13,11 @@ import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer'; import { Footer } from '@/components/layout/Footer';
import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { useOpenGraph } from '@/hooks/useOpenGraph'; import { useOpenGraph } from '@/hooks/useOpenGraph';
import { useQueryInvalidation } from '@/lib/queryInvalidation';
export default function BlogPost() { export default function BlogPost() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { invalidateBlogPost } = useQueryInvalidation();
const { data: post, isLoading } = useBlogPost(slug); const { data: post, isLoading } = useBlogPost(slug);
@@ -34,9 +36,12 @@ export default function BlogPost() {
useEffect(() => { useEffect(() => {
if (slug) { 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) { if (isLoading) {
return ( return (