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 { 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<InvalidationEvent[]>([]);
const invalidationsRef = useRef<InvalidationEvent[]>([]);
// 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 (
<div className="fixed bottom-4 right-4 bg-black/80 text-white p-4 rounded-lg text-xs font-mono z-50 shadow-xl">
<h3 className="font-bold mb-2 text-primary">Cache Monitor</h3>
<div className="space-y-1">
<div>Total Queries: <span className="text-green-400">{stats.totalQueries}</span></div>
<div>Stale: <span className="text-yellow-400">{stats.staleQueries}</span></div>
<div>Fetching: <span className="text-blue-400">{stats.fetchingQueries}</span></div>
<div>Size: <span className="text-purple-400">{(stats.cacheSize / 1024).toFixed(1)} KB</span></div>
<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-3 text-primary border-b border-primary/30 pb-2">Cache Monitor</h3>
<div className="space-y-1 mb-3 pb-3 border-b border-white/10">
<div className="flex justify-between">
<span>Total Queries:</span>
<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>
{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>
);
}

View File

@@ -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<string[]>([]);
@@ -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([]);

View File

@@ -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<AvatarUploadState>({
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({

View File

@@ -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]
});
},
};
}

View File

@@ -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 (