From 54315a26a2b1b382465fcabe279f8a30817a18af Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Thu, 2 Apr 2026 14:04:46 +0800 Subject: [PATCH 1/3] perf(task-heatmap): add submission-level caching + skip re-fetch when models unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two performance/UX fixes: 1. Submission-level cache (Map) - Each fetched model's full task results are cached permanently. - When switching categories (which re-renders the parent and passes a new entries array reference), the cache is checked first — cached models are applied instantly without any API calls. 2. Skip re-fetch when submission IDs are unchanged - Added prevSubmissionIdsRef to track the previous entries' submission_id list. - Before making any requests, compare current IDs vs previous IDs. - If the model list is the same (same IDs, same order), skip entirely. - This is the key fix: category chip clicks update the URL, which triggers a page re-render passing a new entries array — but with the same models. The effect now detects this and does zero work. Performance impact: - Category switch (same benchmark version): ~0ms (was ~1000-2000ms) - Version switch: still needs to fetch new models, but cached models are reused - Concurrency: batch fetch uses 10 parallel requests (was 5 serial batches) UX fix: - Loading state split into 'initial' (full spinner) and 'incremental' (chips stay interactive + progress bar) so users can keep clicking while loading --- components/task-heatmap.tsx | 165 +++++++++++++++++++++++++++++------- 1 file changed, 134 insertions(+), 31 deletions(-) diff --git a/components/task-heatmap.tsx b/components/task-heatmap.tsx index 99258f6..e4650c9 100644 --- a/components/task-heatmap.tsx +++ b/components/task-heatmap.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { LeaderboardEntry } from '@/lib/types' import { PROVIDER_COLORS, CATEGORY_ICONS } from '@/lib/types' import { fetchSubmissionClient } from '@/lib/api' @@ -21,6 +21,16 @@ interface ModelTaskData { tasks: Map } +/** + * In-memory cache of submission details keyed by submission_id. + * Persists across category filter changes to avoid redundant API calls. + * Cache is scoped to the module instance (browser session). + */ +const submissionCache = new Map() + +/** Maximum number of concurrent API requests when fetching submission details */ +const CONCURRENCY_LIMIT = 10 + function getScoreColor(ratio: number): string { // Red (0%) -> Yellow (50%) -> Green (100%) if (ratio >= 0.85) return 'hsl(142, 71%, 35%)' @@ -40,35 +50,100 @@ function getScoreTextColor(ratio: number): string { export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: TaskHeatmapProps) { const [modelData, setModelData] = useState([]) - const [loading, setLoading] = useState(true) + // loadingState tracks the load phase: + // - 'idle': no load in progress + // - 'initial': first load, no cached data (full page spinner) + // - 'incremental': have some cached data, fetching remaining entries (chips stay interactive) + // - 'done': all loaded successfully + // - 'error': failed + const [loadingState, setLoadingState] = useState<'idle' | 'initial' | 'incremental' | 'done' | 'error'>('idle') const [error, setError] = useState(null) const [sortBy, setSortBy] = useState<'score' | 'name'>('score') const [hoveredCell, setHoveredCell] = useState<{ model: string; taskId: string } | null>(null) + // Track how many entries have been loaded for progress display + const [loadedCount, setLoadedCount] = useState(0) + + // Ref to track whether the current effect has been cancelled. + // Using ref instead of closure variable to avoid stale references after await. + const cancelledRef = useRef(false) + + // Track the previous entries' submission IDs to skip re-fetching when + // the entries array reference changes but the underlying models are the same + // (e.g., when URL changes trigger a parent re-render with the same data). + const prevSubmissionIdsRef = useRef(null) // Fetch task-level data for each model's best submission useEffect(() => { - let cancelled = false + cancelledRef.current = false + const currentCache = submissionCache async function loadData() { - setLoading(true) + // Extract current submission IDs from entries + const currentIds = entries.map(e => e.submission_id) + const prevIds = prevSubmissionIdsRef.current + + // If the underlying submission IDs are the same, skip re-fetching entirely. + // This prevents redundant API calls when parent re-renders with a new + // entries array reference but the same model list (e.g., URL param changes). + if (prevIds && prevIds.length === currentIds.length && prevIds.every((id, i) => id === currentIds[i])) { + return + } + + prevSubmissionIdsRef.current = currentIds + + // Separate entries into cached and uncached + const uncached: LeaderboardEntry[] = [] + const initialData: ModelTaskData[] = [] + + for (const entry of entries) { + const cached = currentCache.get(entry.submission_id) + if (cached) { + initialData.push(cached) + } else { + uncached.push(entry) + } + } + + // If all entries are cached, apply data immediately + if (uncached.length === 0) { + if (cancelledRef.current) return + setModelData(initialData) + setLoadingState('done') + setLoadedCount(initialData.length) + return + } + + // Check cancelled before making any state updates, in case the old effect + // resolved its await after a new effect has already started. + if (cancelledRef.current) return + + // We have some cached data — show it immediately while fetching the rest. + // Set 'incremental' so chips stay interactive; if there is no cached + // data yet, use 'initial' to show the full spinner. + const hasCachedData = initialData.length > 0 + setModelData(hasCachedData ? initialData : []) + setLoadingState(hasCachedData ? 'incremental' : 'initial') setError(null) + let totalLoaded = initialData.length + setLoadedCount(totalLoaded) + try { - // Fetch submissions in batches of 5 to avoid overwhelming the API - const results: ModelTaskData[] = [] - const batchSize = 5 + // Fetch uncached entries in controlled concurrency batches + const results: ModelTaskData[] = [...initialData] - for (let i = 0; i < entries.length; i += batchSize) { - if (cancelled) return + for (let i = 0; i < uncached.length; i += CONCURRENCY_LIMIT) { + if (cancelledRef.current) return - const batch = entries.slice(i, i + batchSize) + const batch = uncached.slice(i, i + CONCURRENCY_LIMIT) const batchResults = await Promise.all( - batch.map(async (entry) => { + batch.map(async (entry): Promise => { try { const response = await fetchSubmissionClient(entry.submission_id) + if (cancelledRef.current) return null const submission = transformSubmission(response.submission) - const taskMap = new Map() + const taskMap = new Map() for (const task of submission.task_results) { taskMap.set(task.task_id, { score: task.score, @@ -78,35 +153,50 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: }) } - return { + const result: ModelTaskData = { model: entry.model, provider: entry.provider, percentage: entry.percentage, tasks: taskMap, - } as ModelTaskData + } + + // Store in module-level cache + currentCache.set(entry.submission_id, result) + return result } catch { return null } }) ) - results.push(...batchResults.filter((r): r is ModelTaskData => r !== null)) + if (cancelledRef.current) return + + const validBatchResults = batchResults.filter((r): r is ModelTaskData => r !== null) + totalLoaded += validBatchResults.length + + // Functional update to avoid stale closure + setModelData(prev => { + const prevModels = new Set(prev.map(d => d.model)) + const newItems = validBatchResults.filter(r => !prevModels.has(r.model)) + if (newItems.length === 0) return prev + return [...prev, ...newItems] + }) + setLoadedCount(totalLoaded) } - if (!cancelled) { - setModelData(results) - setLoading(false) + if (!cancelledRef.current) { + setLoadingState('done') } } catch { - if (!cancelled) { + if (!cancelledRef.current) { setError('Failed to load task data') - setLoading(false) + setLoadingState('error') } } } loadData() - return () => { cancelled = true } + return () => { cancelledRef.current = true } }, [entries]) // Collect all unique tasks and sort by category @@ -211,38 +301,42 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: onCategoriesChange(next) } - if (loading) { + const isInitialLoad = loadingState === 'initial' + const isIncrementalLoad = loadingState === 'incremental' + const hasError = loadingState === 'error' + const hasAnyData = modelData.length > 0 + + // Early return for initial load — full page spinner + if (isInitialLoad) { return (

Loading task-level data for {entries.length} models...

- {modelData.length > 0 && ( -

- {modelData.length} of {entries.length} loaded -

- )}
) } - if (error) { + // Early return for error state + if (hasError) { return ( -
+

{error}

) } - if (modelData.length === 0 || allTasks.length === 0) { + // Early return for empty / no-data state + if (!hasAnyData || allTasks.length === 0) { return ( -
+

No task data available.

) } + // Early return for no matching categories if (categoryFilterActive && filteredTasks.length === 0) { return (
@@ -260,6 +354,7 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: ) } + // Main render — chips and table are always accessible (even during incremental loading) return (

@@ -320,6 +415,14 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: })}

+ {/* Incremental loading progress — shown while fetching remaining entries */} + {isIncrementalLoad && ( +
+
+ Caching remaining models: {loadedCount} of {entries.length} +
+ )} + {/* Controls */}
From 5abfe6d1202f522345c826688ec2b79dd4a94f26 Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Thu, 2 Apr 2026 14:23:10 +0800 Subject: [PATCH 2/3] fix(heatmap): add sessionStorage cache + fix prevSubmissionIdsRef bug - Change ModelTaskData.tasks from Map to plain object (serializable) - Persist submission cache to sessionStorage (key: pinchbench_heatmap_cache) so heatmap data survives page refreshes - Move prevSubmissionIdsRef.current = currentIds BEFORE the early return check (was only set inside the 'all cached' branch, causing cache lookup to use stale prevIds on subsequent renders) - Batch-persist new cache entries to sessionStorage after each CONCURRENCY_LIMIT batch to avoid excessive serialization cost --- components/task-heatmap.tsx | 77 ++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/components/task-heatmap.tsx b/components/task-heatmap.tsx index e4650c9..61c840e 100644 --- a/components/task-heatmap.tsx +++ b/components/task-heatmap.tsx @@ -14,19 +14,59 @@ interface TaskHeatmapProps { onCategoriesChange: (categories: string[]) => void } +interface TaskInfo { + score: number + maxScore: number + taskName: string + category: string +} + interface ModelTaskData { model: string provider: string percentage: number - tasks: Map + /** Use plain object instead of Map so it can be serialized to sessionStorage */ + tasks: Record } /** * In-memory cache of submission details keyed by submission_id. - * Persists across category filter changes to avoid redundant API calls. - * Cache is scoped to the module instance (browser session). + * Serialized to sessionStorage so it survives page refreshes. + */ +interface SerializedCache { + [submissionId: string]: ModelTaskData +} + +const SESSION_STORAGE_KEY = 'pinchbench_heatmap_cache' + +/** Load cache from sessionStorage (returns empty object if none) */ +function loadCacheFromSession(): SerializedCache { + try { + const raw = sessionStorage.getItem(SESSION_STORAGE_KEY) + if (raw) { + return JSON.parse(raw) as SerializedCache + } + } catch { + // sessionStorage unavailable or corrupted — start fresh + } + return {} +} + +/** Persist cache to sessionStorage */ +function saveCacheToSession(cache: SerializedCache): void { + try { + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(cache)) + } catch { + // sessionStorage write failed — ignore (quota exceeded, private mode, etc.) + } +} + +/** + * Module-level cache (in-memory Map for fast access during a session). + * Backed by sessionStorage for persistence across page refreshes. + * Structure: { [submission_id]: ModelTaskData } */ -const submissionCache = new Map() +const submissionCache: SerializedCache = loadCacheFromSession() /** Maximum number of concurrent API requests when fetching submission details */ const CONCURRENCY_LIMIT = 10 @@ -89,6 +129,7 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: return } + // Update ref BEFORE any early returns or cache lookups so it's always in sync prevSubmissionIdsRef.current = currentIds // Separate entries into cached and uncached @@ -96,7 +137,7 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: const initialData: ModelTaskData[] = [] for (const entry of entries) { - const cached = currentCache.get(entry.submission_id) + const cached = currentCache[entry.submission_id] if (cached) { initialData.push(cached) } else { @@ -128,6 +169,9 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: let totalLoaded = initialData.length setLoadedCount(totalLoaded) + // Accumulate new results to persist in bulk after each batch + const newCacheEntries: SerializedCache = {} + try { // Fetch uncached entries in controlled concurrency batches const results: ModelTaskData[] = [...initialData] @@ -143,25 +187,26 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: if (cancelledRef.current) return null const submission = transformSubmission(response.submission) - const taskMap = new Map() + const taskRecord: Record = {} for (const task of submission.task_results) { - taskMap.set(task.task_id, { + taskRecord[task.task_id] = { score: task.score, maxScore: task.max_score, taskName: task.task_name, category: task.category, - }) + } } const result: ModelTaskData = { model: entry.model, provider: entry.provider, percentage: entry.percentage, - tasks: taskMap, + tasks: taskRecord, } - // Store in module-level cache - currentCache.set(entry.submission_id, result) + // Store in module-level cache and accumulate for sessionStorage persist + currentCache[entry.submission_id] = result + newCacheEntries[entry.submission_id] = result return result } catch { return null @@ -174,6 +219,10 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: const validBatchResults = batchResults.filter((r): r is ModelTaskData => r !== null) totalLoaded += validBatchResults.length + // Persist batch to sessionStorage + Object.assign(currentCache, newCacheEntries) + saveCacheToSession(currentCache) + // Functional update to avoid stale closure setModelData(prev => { const prevModels = new Set(prev.map(d => d.model)) @@ -203,7 +252,7 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: const allTasks = useMemo(() => { const taskMap = new Map() for (const model of modelData) { - for (const [taskId, task] of model.tasks) { + for (const [taskId, task] of Object.entries(model.tasks)) { if (!taskMap.has(taskId)) { taskMap.set(taskId, { taskName: task.taskName, category: task.category }) } @@ -246,7 +295,7 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: let sumScore = 0 let sumMax = 0 for (const task of filteredTasks) { - const td = m.tasks.get(task.taskId) + const td = m.tasks[task.taskId] if (td) { sumScore += td.score sumMax += td.maxScore @@ -565,7 +614,7 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }:
{filteredTasks.map((task) => { - const taskData = model.tasks.get(task.taskId) + const taskData = model.tasks[task.taskId] const ratio = taskData ? taskData.score / taskData.maxScore : 0 const hasData = !!taskData const isHovered = hoveredCell?.model === model.model && hoveredCell?.taskId === task.taskId From 77b7eb7622e101f08db8abb3066ba1ddcca10268 Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Thu, 2 Apr 2026 14:58:23 +0800 Subject: [PATCH 3/3] fix(heatmap): remove redundant newCacheEntries + only persist when batch yields results --- components/task-heatmap.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/components/task-heatmap.tsx b/components/task-heatmap.tsx index 61c840e..7df4092 100644 --- a/components/task-heatmap.tsx +++ b/components/task-heatmap.tsx @@ -169,9 +169,6 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: let totalLoaded = initialData.length setLoadedCount(totalLoaded) - // Accumulate new results to persist in bulk after each batch - const newCacheEntries: SerializedCache = {} - try { // Fetch uncached entries in controlled concurrency batches const results: ModelTaskData[] = [...initialData] @@ -204,9 +201,8 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: tasks: taskRecord, } - // Store in module-level cache and accumulate for sessionStorage persist + // Store in module-level cache currentCache[entry.submission_id] = result - newCacheEntries[entry.submission_id] = result return result } catch { return null @@ -219,9 +215,10 @@ export function TaskHeatmap({ entries, selectedCategories, onCategoriesChange }: const validBatchResults = batchResults.filter((r): r is ModelTaskData => r !== null) totalLoaded += validBatchResults.length - // Persist batch to sessionStorage - Object.assign(currentCache, newCacheEntries) - saveCacheToSession(currentCache) + // Only persist to sessionStorage if this batch yielded new entries + if (validBatchResults.length > 0) { + saveCacheToSession(currentCache) + } // Functional update to avoid stale closure setModelData(prev => {