Fix N+1 query and UI flashing

This commit is contained in:
gpt-engineer-app[bot]
2025-10-06 19:19:02 +00:00
parent cc3ec7a6e4
commit 71f497a001
2 changed files with 144 additions and 55 deletions

View File

@@ -79,6 +79,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null); const [selectedItemForAction, setSelectedItemForAction] = useState<ModerationItem | null>(null);
const [interactingWith, setInteractingWith] = useState<Set<string>>(new Set()); const [interactingWith, setInteractingWith] = useState<Set<string>>(new Set());
const [newItemsCount, setNewItemsCount] = useState(0); const [newItemsCount, setNewItemsCount] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole(); const { isAdmin, isSuperuser } = useUserRole();
const { user } = useAuth(); const { user } = useAuth();
@@ -108,12 +109,21 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return; return;
} }
// Prevent duplicate requests
if (isRefreshing && silent) {
console.log('⏭️ Skipping refresh - already in progress');
return;
}
try { try {
// Only show loading on initial load or filter change // Only show loading on initial load or filter change
if (!silent) { if (!silent) {
setLoading(true); setLoading(true);
setNewItemsCount(0);
} }
setIsRefreshing(true);
let reviewStatuses: string[] = []; let reviewStatuses: string[] = [];
let submissionStatuses: string[] = []; let submissionStatuses: string[] = [];
@@ -183,7 +193,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
reviews = reviewsData || []; reviews = reviewsData || [];
} }
// Fetch content submissions with entity data // Fetch content submissions with entity data (OPTIMIZED: Single query with all entity data)
let submissions = []; let submissions = [];
if ((entityFilter === 'all' || entityFilter === 'submissions' || entityFilter === 'photos') && submissionStatuses.length > 0) { if ((entityFilter === 'all' || entityFilter === 'submissions' || entityFilter === 'photos') && submissionStatuses.length > 0) {
let query = supabase let query = supabase
@@ -213,13 +223,16 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
if (submissionsError) throw submissionsError; if (submissionsError) throw submissionsError;
// Get entity data for photo submissions // Collect all entity IDs by type for batch fetching
let submissionsWithEntities = submissionsData || []; const rideIds = new Set<string>();
for (const submission of submissionsWithEntities) { const parkIds = new Set<string>();
const companyIds = new Set<string>();
// First pass: collect all entity IDs
for (const submission of submissionsData || []) {
if (submission.submission_type === 'photo' && submission.content && typeof submission.content === 'object') { if (submission.submission_type === 'photo' && submission.content && typeof submission.content === 'object') {
const contentObj = submission.content as any; const contentObj = submission.content as any;
// Handle both old format (context as object) and new format (context as string)
let contextType = null; let contextType = null;
let entityId = null; let entityId = null;
let rideId = null; let rideId = null;
@@ -227,12 +240,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
let companyId = null; let companyId = null;
if (typeof contentObj.context === 'object' && contentObj.context !== null) { if (typeof contentObj.context === 'object' && contentObj.context !== null) {
// OLD FORMAT: context is an object like {ride_id: "...", park_id: "..."}
rideId = contentObj.context.ride_id; rideId = contentObj.context.ride_id;
parkId = contentObj.context.park_id; parkId = contentObj.context.park_id;
contextType = rideId ? 'ride' : parkId ? 'park' : null; contextType = rideId ? 'ride' : parkId ? 'park' : null;
} else if (typeof contentObj.context === 'string') { } else if (typeof contentObj.context === 'string') {
// NEW FORMAT: context is a string, IDs are at top level
contextType = contentObj.context; contextType = contentObj.context;
entityId = contentObj.entity_id; entityId = contentObj.entity_id;
rideId = contentObj.ride_id; rideId = contentObj.ride_id;
@@ -240,54 +251,115 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
companyId = contentObj.company_id; companyId = contentObj.company_id;
} }
// Determine entity ID based on context type
if (!entityId) { if (!entityId) {
if (contextType === 'ride') entityId = rideId; if (contextType === 'ride') entityId = rideId;
else if (contextType === 'park') entityId = parkId; else if (contextType === 'park') entityId = parkId;
else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) entityId = companyId; else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) entityId = companyId;
} }
if (contextType === 'ride' && entityId) { // Collect IDs by type
const { data: rideData } = await supabase if (entityId) {
.from('rides') if (contextType === 'ride') rideIds.add(entityId);
.select(` else if (contextType === 'park') parkIds.add(entityId);
name, else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) companyIds.add(entityId);
parks:park_id (
name
)
`)
.eq('id', entityId)
.single();
if (rideData) {
(submission as any).entity_name = rideData.name;
(submission as any).park_name = rideData.parks?.name;
}
} else if (contextType === 'park' && entityId) {
const { data: parkData } = await supabase
.from('parks')
.select('name')
.eq('id', entityId)
.single();
if (parkData) {
(submission as any).entity_name = parkData.name;
}
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType) && entityId) {
const { data: companyData } = await supabase
.from('companies')
.select('name, company_type')
.eq('id', entityId)
.single();
if (companyData) {
(submission as any).entity_name = companyData.name;
(submission as any).company_type = companyData.company_type;
}
} }
} }
} }
// Batch fetch all entity data (3 queries instead of N queries)
const [ridesData, parksData, companiesData] = await Promise.all([
rideIds.size > 0 ? supabase
.from('rides')
.select(`
id,
name,
parks:park_id (
name
)
`)
.in('id', Array.from(rideIds))
.then(res => res.data || []) : Promise.resolve([]),
parkIds.size > 0 ? supabase
.from('parks')
.select('id, name')
.in('id', Array.from(parkIds))
.then(res => res.data || []) : Promise.resolve([]),
companyIds.size > 0 ? supabase
.from('companies')
.select('id, name, company_type')
.in('id', Array.from(companyIds))
.then(res => res.data || []) : Promise.resolve([])
]);
// Create lookup maps for O(1) access
const ridesMap = new Map(ridesData.map((r: any) => [r.id, r] as [string, any]));
const parksMap = new Map(parksData.map((p: any) => [p.id, p] as [string, any]));
const companiesMap = new Map(companiesData.map((c: any) => [c.id, c] as [string, any]));
// Second pass: attach entity data using maps
let submissionsWithEntities = (submissionsData || []).map(submission => {
if (submission.submission_type === 'photo' && submission.content && typeof submission.content === 'object') {
const contentObj = submission.content as any;
let contextType = null;
let entityId = null;
let rideId = null;
let parkId = null;
let companyId = null;
if (typeof contentObj.context === 'object' && contentObj.context !== null) {
rideId = contentObj.context.ride_id;
parkId = contentObj.context.park_id;
contextType = rideId ? 'ride' : parkId ? 'park' : null;
} else if (typeof contentObj.context === 'string') {
contextType = contentObj.context;
entityId = contentObj.entity_id;
rideId = contentObj.ride_id;
parkId = contentObj.park_id;
companyId = contentObj.company_id;
}
if (!entityId) {
if (contextType === 'ride') entityId = rideId;
else if (contextType === 'park') entityId = parkId;
else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType)) entityId = companyId;
}
// Attach entity data from maps
if (contextType === 'ride' && entityId) {
const rideData = ridesMap.get(entityId) as any;
if (rideData) {
return {
...submission,
entity_name: rideData.name,
park_name: rideData.parks?.name
};
}
} else if (contextType === 'park' && entityId) {
const parkData = parksMap.get(entityId) as any;
if (parkData) {
return {
...submission,
entity_name: parkData.name
};
}
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(contextType) && entityId) {
const companyData = companiesMap.get(entityId) as any;
if (companyData) {
return {
...submission,
entity_name: companyData.name,
company_type: companyData.company_type
};
}
}
}
return submission;
});
submissions = submissionsWithEntities; submissions = submissionsWithEntities;
} }
@@ -371,7 +443,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
// Use smart merging for silent refreshes if strategy is 'merge' // Use smart merging for silent refreshes if strategy is 'merge'
if (silent && refreshStrategy === 'merge') { if (silent && refreshStrategy === 'merge') {
const mergeResult = smartMergeArray(items, formattedItems, { const mergeResult = smartMergeArray(items, formattedItems, {
compareFields: ['status', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'content', 'submission_type'], compareFields: ['status', 'reviewed_at', 'reviewed_by', 'reviewer_notes', 'submission_type', 'entity_name', 'park_name'],
preserveOrder: true, preserveOrder: true,
addToTop: true, addToTop: true,
}); });
@@ -437,6 +509,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
if (isInitialLoad) { if (isInitialLoad) {
setIsInitialLoad(false); setIsInitialLoad(false);
} }
setIsRefreshing(false);
} }
}; };

View File

@@ -1,10 +1,27 @@
/** /**
* Smart State Update Utility * Utility functions for performing "smart" updates on arrays
*
* Provides intelligent array diffing and merging to prevent
* unnecessary re-renders and preserve user interaction state.
*/ */
/**
* Creates a stable content hash for comparison
*/
function hashContent(obj: any): string {
if (obj === null || obj === undefined) return 'null';
if (typeof obj !== 'object') return String(obj);
// Sort keys for stable hashing
const sortedKeys = Object.keys(obj).sort();
const parts = sortedKeys.map(key => `${key}:${hashContent(obj[key])}`);
return parts.join('|');
}
/**
* Checks if content has meaningfully changed (not just object reference)
*/
function hasContentChanged(current: any, next: any): boolean {
return hashContent(current) !== hashContent(next);
}
export interface SmartMergeOptions<T> { export interface SmartMergeOptions<T> {
compareFields?: (keyof T)[]; compareFields?: (keyof T)[];
preserveOrder?: boolean; preserveOrder?: boolean;
@@ -130,9 +147,8 @@ function hasItemChanged<T>(
compareFields?: (keyof T)[] compareFields?: (keyof T)[]
): boolean { ): boolean {
if (!compareFields || compareFields.length === 0) { if (!compareFields || compareFields.length === 0) {
// If no fields specified, assume no change (too sensitive to compare everything) // If no fields specified, use content hash comparison
// This prevents false positives from object reference changes return hasContentChanged(currentItem, newItem);
return false;
} }
// Compare only specified fields // Compare only specified fields
@@ -140,9 +156,9 @@ function hasItemChanged<T>(
const currentValue = currentItem[field]; const currentValue = currentItem[field];
const newValue = newItem[field]; const newValue = newItem[field];
// Handle nested objects/arrays // Handle nested objects/arrays with content hash
if (typeof currentValue === 'object' && typeof newValue === 'object') { if (typeof currentValue === 'object' && typeof newValue === 'object') {
if (JSON.stringify(currentValue) !== JSON.stringify(newValue)) { if (hasContentChanged(currentValue, newValue)) {
return true; return true;
} }
} else if (currentValue !== newValue) { } else if (currentValue !== newValue) {