feat: Implement Phase 5 optimization and best practices

This commit is contained in:
gpt-engineer-app[bot]
2025-10-13 22:56:47 +00:00
parent 68a2572c23
commit 3e520e1520
14 changed files with 341 additions and 165 deletions

View File

@@ -1,5 +1,7 @@
import { useRef, useCallback, useMemo } from 'react';
import { useRef, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { logger } from '@/lib/logger';
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
/**
* Entity types supported by the cache
@@ -61,10 +63,21 @@ export function useEntityCache() {
}, []);
/**
* Set a cached entity
* Set a cached entity with LRU eviction
*/
const setCached = useCallback((type: EntityType, id: string, data: any): void => {
cacheRef.current[type].set(id, data);
const cache = cacheRef.current[type];
// LRU eviction: remove oldest entry if cache is full
if (cache.size >= MODERATION_CONSTANTS.MAX_ENTITY_CACHE_SIZE) {
const firstKey = cache.keys().next().value;
if (firstKey) {
cache.delete(firstKey);
logger.log(`♻️ [EntityCache] Evicted ${type}/${firstKey} (LRU)`);
}
}
cache.set(id, data);
}, []);
/**
@@ -119,7 +132,7 @@ export function useEntityCache() {
.in('id', uncachedIds);
if (error) {
console.error(`Error fetching ${type}:`, error);
logger.error(`Error fetching ${type}:`, error);
return [];
}
@@ -132,7 +145,7 @@ export function useEntityCache() {
return data || [];
} catch (error) {
console.error(`Failed to bulk fetch ${type}:`, error);
logger.error(`Failed to bulk fetch ${type}:`, error);
return [];
}
}, [getCached, setCached, getUncachedIds]);
@@ -221,7 +234,8 @@ export function useEntityCache() {
*/
const getCacheRef = useCallback(() => cacheRef.current, []);
return useMemo(() => ({
// Return without useMemo wrapper (OPTIMIZED)
return {
getCached,
has,
setCached,
@@ -233,17 +247,5 @@ export function useEntityCache() {
getSize,
getTotalSize,
getCacheRef,
}), [
getCached,
has,
setCached,
getUncachedIds,
bulkFetch,
fetchRelatedEntities,
clear,
clearAll,
getSize,
getTotalSize,
getCacheRef,
]);
};
}

View File

@@ -8,8 +8,10 @@
* - Filter persistence and clearing
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { logger } from '@/lib/logger';
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
import type { EntityFilter, StatusFilter, QueueTab, SortConfig, SortField } from '@/types/moderation';
export interface ModerationFiltersConfig {
@@ -109,7 +111,7 @@ export function useModerationFilters(config: ModerationFiltersConfig = {}): Mode
initialEntityFilter = 'all',
initialStatusFilter = 'pending',
initialTab = 'mainQueue',
debounceDelay = 300,
debounceDelay = MODERATION_CONSTANTS.FILTER_DEBOUNCE_MS,
persist = true,
storageKey = 'moderationQueue_filters',
initialSortConfig = { field: 'created_at', direction: 'asc' },
@@ -204,25 +206,25 @@ export function useModerationFilters(config: ModerationFiltersConfig = {}): Mode
// Set entity filter with logging
const setEntityFilter = useCallback((filter: EntityFilter) => {
console.log('🔍 Entity filter changed:', filter);
logger.log('🔍 Entity filter changed:', filter);
setEntityFilterState(filter);
}, []);
// Set status filter with logging
const setStatusFilter = useCallback((filter: StatusFilter) => {
console.log('🔍 Status filter changed:', filter);
logger.log('🔍 Status filter changed:', filter);
setStatusFilterState(filter);
}, []);
// Set active tab with logging
const setActiveTab = useCallback((tab: QueueTab) => {
console.log('🔍 Tab changed:', tab);
logger.log('🔍 Tab changed:', tab);
setActiveTabState(tab);
}, []);
// Sort callbacks
const setSortConfig = useCallback((config: SortConfig) => {
console.log('📝 [SORT] Sort config changed:', config);
logger.log('📝 [SORT] Sort config changed:', config);
setSortConfigState(config);
}, []);
@@ -248,7 +250,7 @@ export function useModerationFilters(config: ModerationFiltersConfig = {}): Mode
// Clear all filters
const clearFilters = useCallback(() => {
console.log('🔍 Filters cleared');
logger.log('🔍 Filters cleared');
setEntityFilterState(initialEntityFilter);
setStatusFilterState(initialStatusFilter);
setActiveTabState(initialTab);
@@ -263,7 +265,8 @@ export function useModerationFilters(config: ModerationFiltersConfig = {}): Mode
sortConfig.field !== initialSortConfig.field ||
sortConfig.direction !== initialSortConfig.direction;
return useMemo(() => ({
// Return without useMemo wrapper (OPTIMIZED)
return {
entityFilter,
statusFilter,
activeTab,
@@ -280,22 +283,5 @@ export function useModerationFilters(config: ModerationFiltersConfig = {}): Mode
sortBy,
toggleSortDirection,
resetSort,
}), [
entityFilter,
statusFilter,
activeTab,
debouncedEntityFilter,
debouncedStatusFilter,
setEntityFilter,
setStatusFilter,
setActiveTab,
clearFilters,
hasActiveFilters,
sortConfig,
debouncedSortConfig,
setSortConfig,
sortBy,
toggleSortDirection,
resetSort,
]);
};
}

View File

@@ -1,6 +1,8 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { logger } from "@/lib/logger";
import { MODERATION_CONSTANTS } from "@/lib/moderation/constants";
import type { User } from "@supabase/supabase-js";
import {
useEntityCache,
@@ -71,7 +73,7 @@ export interface ModerationQueueManager {
* Consolidates all queue-related logic into a single hook
*/
export function useModerationQueueManager(config: ModerationQueueManagerConfig): ModerationQueueManager {
console.log('🚀 [QUEUE MANAGER] Hook mounting/rendering', {
logger.log('🚀 [QUEUE MANAGER] Hook mounting/rendering', {
hasUser: !!config.user,
isAdmin: config.isAdmin,
timestamp: new Date().toISOString()
@@ -169,7 +171,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
useEffect(() => {
if (queueQuery.items) {
setItems(queueQuery.items);
console.log('✅ Queue items updated from TanStack Query:', queueQuery.items.length);
logger.log('✅ Queue items updated from TanStack Query:', queueQuery.items.length);
}
}, [queueQuery.items]);
@@ -187,7 +189,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
// Show error toast when query fails
useEffect(() => {
if (queueQuery.error) {
console.error('❌ Queue query error:', queueQuery.error);
logger.error('❌ Queue query error:', queueQuery.error);
toast({
variant: 'destructive',
title: 'Failed to Load Queue',
@@ -205,7 +207,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
useEffect(() => {
if (!queueQuery.isLoading && !initialFetchCompleteRef.current) {
initialFetchCompleteRef.current = true;
console.log('✅ Initial queue fetch complete');
logger.log('✅ Initial queue fetch complete');
}
}, [queueQuery.isLoading]);
@@ -213,7 +215,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
* Manual refresh function
*/
const refresh = useCallback(async () => {
console.log('🔄 Manual refresh triggered');
logger.log('🔄 Manual refresh triggered');
await queueQuery.refetch();
}, [queueQuery]);
@@ -221,7 +223,7 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
* Show pending new items by invalidating query
*/
const showNewItems = useCallback(async () => {
console.log('✅ Showing new items via query invalidation');
logger.log('✅ Showing new items via query invalidation');
await queueQuery.invalidate();
setPendingNewItems([]);
setNewItemsCount(0);
@@ -521,16 +523,25 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
[filters.statusFilter, toast],
);
// Extract stable callbacks for dependencies
const invalidateQuery = useCallback(() => {
queueQuery.invalidate();
}, [queueQuery.invalidate]);
const resetPagination = useCallback(() => {
pagination.reset();
}, [pagination.reset]);
// Mark initial fetch as complete when query loads
useEffect(() => {
if (!queueQuery.isLoading && !initialFetchCompleteRef.current) {
initialFetchCompleteRef.current = true;
isMountingRef.current = false;
console.log('✅ Initial queue fetch complete');
logger.log('✅ Initial queue fetch complete');
}
}, [queueQuery.isLoading]);
// Invalidate query when filters or sort changes
// Invalidate query when filters or sort changes (OPTIMIZED)
useEffect(() => {
if (
!user ||
@@ -538,36 +549,41 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
isMountingRef.current
) return;
console.log('🔄 Filters/sort changed, invalidating query');
pagination.reset();
queueQuery.invalidate();
logger.log('🔄 Filters/sort changed, invalidating query');
resetPagination();
invalidateQuery();
}, [
filters.debouncedEntityFilter,
filters.debouncedStatusFilter,
filters.debouncedSortConfig.field,
filters.debouncedSortConfig.direction,
user,
queueQuery,
pagination
invalidateQuery,
resetPagination
]);
// Polling effect (when realtime disabled)
// Polling effect (when realtime disabled) - MUTUALLY EXCLUSIVE
useEffect(() => {
if (!user || settings.refreshMode !== "auto" || loadingState === "initial" || settings.useRealtimeQueue) {
const shouldPoll = settings.refreshMode === 'auto'
&& !settings.useRealtimeQueue
&& loadingState !== 'initial'
&& !!user;
if (!shouldPoll) {
return;
}
console.log("⚠️ Polling ENABLED - interval:", settings.pollInterval);
logger.log("⚠️ Polling ENABLED - interval:", settings.pollInterval);
const interval = setInterval(() => {
console.log("🔄 Polling refresh triggered");
logger.log("🔄 Polling refresh triggered");
queueQuery.refetch();
}, settings.pollInterval);
return () => {
clearInterval(interval);
console.log("🛑 Polling stopped");
logger.log("🛑 Polling stopped");
};
}, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, queueQuery]);
}, [user, settings.refreshMode, settings.pollInterval, loadingState, settings.useRealtimeQueue, queueQuery.refetch]);
// Initialize realtime subscriptions
useRealtimeSubscriptions({
@@ -591,21 +607,18 @@ export function useModerationQueueManager(config: ModerationQueueManagerConfig):
if (recentlyRemovedRef.current.has(item.id)) return;
if (interactingWith.has(item.id)) return;
if (shouldRemove) {
setItems((prev) => prev.filter((i) => i.id !== item.id));
} else {
setItems((prev) => {
const exists = prev.some((i) => i.id === item.id);
if (exists) {
return prev.map((i) => (i.id === item.id ? item : i));
} else {
return [item, ...prev];
}
});
// Only track removals for optimistic update protection
if (shouldRemove && !recentlyRemovedRef.current.has(item.id)) {
recentlyRemovedRef.current.add(item.id);
setTimeout(() => recentlyRemovedRef.current.delete(item.id), MODERATION_CONSTANTS.REALTIME_OPTIMISTIC_REMOVAL_TIMEOUT);
}
// TanStack Query handles actual state updates via invalidation
},
onItemRemoved: (itemId: string) => {
setItems((prev) => prev.filter((i) => i.id !== itemId));
// Track for optimistic update protection
recentlyRemovedRef.current.add(itemId);
setTimeout(() => recentlyRemovedRef.current.delete(itemId), MODERATION_CONSTANTS.REALTIME_OPTIMISTIC_REMOVAL_TIMEOUT);
// TanStack Query handles removal via invalidation
},
entityCache,
profileCache,

View File

@@ -5,6 +5,7 @@
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
export interface PaginationConfig {
/** Initial page number (1-indexed) */
@@ -104,7 +105,7 @@ export interface PaginationState {
export function usePagination(config: PaginationConfig = {}): PaginationState {
const {
initialPage = 1,
initialPageSize = 25,
initialPageSize = MODERATION_CONSTANTS.DEFAULT_PAGE_SIZE,
persist = false,
storageKey = 'pagination_state',
onPageChange,
@@ -231,6 +232,7 @@ export function usePagination(config: PaginationConfig = {}): PaginationState {
[currentPage, totalPages]
);
// Return without useMemo wrapper (OPTIMIZED)
return {
currentPage,
pageSize,

View File

@@ -1,5 +1,7 @@
import { useRef, useCallback, useMemo } from 'react';
import { useRef, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { logger } from '@/lib/logger';
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
/**
* Profile data structure returned from the database
@@ -55,10 +57,21 @@ export function useProfileCache() {
}, []);
/**
* Set a cached profile
* Set a cached profile with LRU eviction
*/
const setCached = useCallback((userId: string, profile: CachedProfile): void => {
cacheRef.current.set(userId, profile);
const cache = cacheRef.current;
// LRU eviction
if (cache.size >= MODERATION_CONSTANTS.MAX_PROFILE_CACHE_SIZE) {
const firstKey = cache.keys().next().value;
if (firstKey) {
cache.delete(firstKey);
logger.log(`♻️ [ProfileCache] Evicted ${firstKey} (LRU)`);
}
}
cache.set(userId, profile);
}, []);
/**
@@ -92,7 +105,7 @@ export function useProfileCache() {
.in('user_id', uncachedIds);
if (error) {
console.error('Error fetching profiles:', error);
logger.error('Error fetching profiles:', error);
return [];
}
@@ -105,7 +118,7 @@ export function useProfileCache() {
return data || [];
} catch (error) {
console.error('Failed to bulk fetch profiles:', error);
logger.error('Failed to bulk fetch profiles:', error);
return [];
}
}, [getCached, setCached, getUncachedIds]);
@@ -181,7 +194,8 @@ export function useProfileCache() {
*/
const getCacheRef = useCallback(() => cacheRef.current, []);
return useMemo(() => ({
// Return without useMemo wrapper (OPTIMIZED)
return {
getCached,
has,
setCached,
@@ -195,19 +209,5 @@ export function useProfileCache() {
getSize,
getAllCachedIds,
getCacheRef,
}), [
getCached,
has,
setCached,
getUncachedIds,
bulkFetch,
fetchAsMap,
fetchForSubmissions,
getDisplayName,
invalidate,
clear,
getSize,
getAllCachedIds,
getCacheRef,
]);
};
}

View File

@@ -8,6 +8,8 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchSubmissions, type QueryConfig } from '@/lib/moderation/queries';
import { supabase } from '@/integrations/supabase/client';
import { logger } from '@/lib/logger';
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
import type {
ModerationItem,
EntityFilter,
@@ -116,22 +118,22 @@ export function useQueueQuery(config: UseQueueQueryConfig): UseQueueQueryReturn
const query = useQuery({
queryKey,
queryFn: async () => {
console.log('🔍 [TanStack Query] Fetching queue data:', queryKey);
logger.log('🔍 [TanStack Query] Fetching queue data:', queryKey);
const result = await fetchSubmissions(supabase, queryConfig);
if (result.error) {
console.error('❌ [TanStack Query] Error:', result.error);
logger.error('❌ [TanStack Query] Error:', result.error);
throw result.error;
}
console.log('✅ [TanStack Query] Fetched', result.submissions.length, 'items');
logger.log('✅ [TanStack Query] Fetched', result.submissions.length, 'items');
return result;
},
enabled: config.enabled !== false && !!config.userId,
staleTime: 30000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
retry: 2, // Retry failed requests up to 2 times
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
staleTime: MODERATION_CONSTANTS.QUERY_STALE_TIME,
gcTime: MODERATION_CONSTANTS.QUERY_GC_TIME,
retry: MODERATION_CONSTANTS.QUERY_RETRY_COUNT,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Invalidate helper

View File

@@ -8,6 +8,8 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { logger } from '@/lib/logger';
import { MODERATION_CONSTANTS } from '@/lib/moderation/constants';
import type { RealtimeChannel } from '@supabase/supabase-js';
import type { ModerationItem, EntityFilter, StatusFilter } from '@/types/moderation';
import type { useEntityCache } from './useEntityCache';
@@ -47,7 +49,7 @@ export interface RealtimeSubscriptionConfig {
/** Pause subscriptions when tab is hidden (default: true) */
pauseWhenHidden?: boolean;
/** Debounce delay for UPDATE events in milliseconds (default: 500) */
/** Debounce delay for UPDATE events in milliseconds */
debounceMs?: number;
/** Entity cache for resolving entity names */
@@ -95,7 +97,7 @@ export function useRealtimeSubscriptions(
onUpdateItem,
onItemRemoved,
pauseWhenHidden = true,
debounceMs = 500,
debounceMs = MODERATION_CONSTANTS.REALTIME_DEBOUNCE_MS,
entityCache,
profileCache,
recentlyRemovedIds,
@@ -151,7 +153,7 @@ export function useRealtimeSubscriptions(
.single();
if (error || !submission) {
console.error('Error fetching submission details:', error);
logger.error('Error fetching submission details:', error);
return null;
}
@@ -243,17 +245,17 @@ export function useRealtimeSubscriptions(
const handleInsert = useCallback(async (payload: any) => {
const newSubmission = payload.new as any;
console.log('🆕 Realtime INSERT:', newSubmission.id);
logger.log('🆕 Realtime INSERT:', newSubmission.id);
// Queue updates if tab is hidden
if (pauseWhenHidden && document.hidden) {
console.log('📴 Realtime event received while hidden - queuing for later');
logger.log('📴 Realtime event received while hidden - queuing for later');
return;
}
// Ignore if recently removed (optimistic update)
if (recentlyRemovedIds.has(newSubmission.id)) {
console.log('⏭️ Ignoring INSERT for recently removed submission:', newSubmission.id);
logger.log('⏭️ Ignoring INSERT for recently removed submission:', newSubmission.id);
return;
}
@@ -271,7 +273,7 @@ export function useRealtimeSubscriptions(
return;
}
console.log('✅ NEW submission matches filters, invalidating query:', newSubmission.id);
logger.log('✅ NEW submission matches filters, invalidating query:', newSubmission.id);
// Invalidate the query to trigger background refetch
await queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
@@ -296,7 +298,7 @@ export function useRealtimeSubscriptions(
onNewItem(fullItem);
} catch (error) {
console.error('Error building new item notification:', error);
logger.error('Error building new item notification:', error);
}
}, [
filters,
@@ -316,23 +318,23 @@ export function useRealtimeSubscriptions(
const updatedSubmission = payload.new as any;
const oldSubmission = payload.old as any;
console.log('🔄 Realtime UPDATE:', updatedSubmission.id);
logger.log('🔄 Realtime UPDATE:', updatedSubmission.id);
// Queue updates if tab is hidden
if (pauseWhenHidden && document.hidden) {
console.log('📴 Realtime UPDATE received while hidden - queuing for later');
logger.log('📴 Realtime UPDATE received while hidden - queuing for later');
return;
}
// Ignore if recently removed (optimistic update in progress)
if (recentlyRemovedIds.has(updatedSubmission.id)) {
console.log('⏭️ Ignoring UPDATE for recently removed submission:', updatedSubmission.id);
logger.log('⏭️ Ignoring UPDATE for recently removed submission:', updatedSubmission.id);
return;
}
// Ignore if currently being interacted with
if (interactingWithIds.has(updatedSubmission.id)) {
console.log('⏭️ Ignoring UPDATE for interacting submission:', updatedSubmission.id);
logger.log('⏭️ Ignoring UPDATE for interacting submission:', updatedSubmission.id);
return;
}
@@ -340,7 +342,7 @@ export function useRealtimeSubscriptions(
const isStatusChange = oldSubmission?.status !== updatedSubmission.status;
if (isStatusChange) {
console.log('⚡ Status change detected, invalidating immediately');
logger.log('⚡ Status change detected, invalidating immediately');
await queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
const matchesEntity = matchesEntityFilter(updatedSubmission, filters.entityFilter);
@@ -355,7 +357,7 @@ export function useRealtimeSubscriptions(
// Use debounce for non-critical updates
debouncedUpdate(updatedSubmission.id, async () => {
console.log('🔄 Invalidating query due to UPDATE:', updatedSubmission.id);
logger.log('🔄 Invalidating query due to UPDATE:', updatedSubmission.id);
// Simply invalidate the query - TanStack Query handles the rest
await queryClient.invalidateQueries({ queryKey: ['moderation-queue'] });
@@ -388,7 +390,7 @@ export function useRealtimeSubscriptions(
return;
}
console.log('📡 Setting up INSERT subscription');
logger.log('📡 Setting up INSERT subscription');
const channel = supabase
.channel('moderation-new-submissions')
@@ -402,7 +404,7 @@ export function useRealtimeSubscriptions(
handleInsert
)
.subscribe((status) => {
console.log('INSERT subscription status:', status);
logger.log('INSERT subscription status:', status);
if (status === 'SUBSCRIBED') {
setChannelStatus('connected');
} else if (status === 'CHANNEL_ERROR') {
@@ -413,7 +415,7 @@ export function useRealtimeSubscriptions(
insertChannelRef.current = channel;
return () => {
console.log('🛑 Cleaning up INSERT subscription');
logger.log('🛑 Cleaning up INSERT subscription');
supabase.removeChannel(channel);
insertChannelRef.current = null;
};
@@ -425,7 +427,7 @@ export function useRealtimeSubscriptions(
useEffect(() => {
if (!enabled) return;
console.log('📡 Setting up UPDATE subscription');
logger.log('📡 Setting up UPDATE subscription');
const channel = supabase
.channel('moderation-updated-submissions')
@@ -439,7 +441,7 @@ export function useRealtimeSubscriptions(
handleUpdate
)
.subscribe((status) => {
console.log('UPDATE subscription status:', status);
logger.log('UPDATE subscription status:', status);
if (status === 'SUBSCRIBED') {
setChannelStatus('connected');
} else if (status === 'CHANNEL_ERROR') {
@@ -450,7 +452,7 @@ export function useRealtimeSubscriptions(
updateChannelRef.current = channel;
return () => {
console.log('🛑 Cleaning up UPDATE subscription');
logger.log('🛑 Cleaning up UPDATE subscription');
supabase.removeChannel(channel);
updateChannelRef.current = null;
};
@@ -470,7 +472,7 @@ export function useRealtimeSubscriptions(
* Manual reconnect function
*/
const reconnect = useCallback(() => {
console.log('🔄 Manually reconnecting subscriptions...');
logger.log('🔄 Manually reconnecting subscriptions...');
setReconnectTrigger(prev => prev + 1);
}, []);