diff --git a/src/components/RepositoryCard.tsx b/src/components/RepositoryCard.tsx index 8e71837..aeade32 100644 --- a/src/components/RepositoryCard.tsx +++ b/src/components/RepositoryCard.tsx @@ -13,6 +13,7 @@ import { ReadmeModal } from './ReadmeModal'; import { FloatingTooltip } from './FloatingTooltip'; import { shallow } from 'zustand/shallow'; import { useDialog } from '../hooks/useDialog'; +import { logger } from '../services/logger'; // Selection-aware button component to centralize selectionMode disable logic interface SelectionAwareButtonProps extends React.ButtonHTMLAttributes { @@ -89,13 +90,18 @@ const RepositoryCardComponent: React.FC = ({ allCategories }) => { const repoId = repository.id; - + + const isSubscribed = useAppStore( + useCallback((state) => state.releaseSubscriptions.has(repoId), [repoId]) + ); + const isStoreAnalyzing = useAppStore( + useCallback((state) => state.analyzingRepositoryIds.has(repoId), [repoId]) + ); + const { - isSubscribed, toggleReleaseSubscription, githubToken, activeAIConfig, - analyzingRepositoryIds, setAnalyzingRepository, language, updateRepository, @@ -103,11 +109,9 @@ const RepositoryCardComponent: React.FC = ({ } = useAppStore( useCallback( (state) => ({ - isSubscribed: state.releaseSubscriptions.has(repoId), toggleReleaseSubscription: state.toggleReleaseSubscription, githubToken: state.githubToken, activeAIConfig: state.activeAIConfig, - analyzingRepositoryIds: state.analyzingRepositoryIds, setAnalyzingRepository: state.setAnalyzingRepository, language: state.language, updateRepository: state.updateRepository, @@ -118,8 +122,6 @@ const RepositoryCardComponent: React.FC = ({ shallow ); - const isAnalyzing = analyzingRepositoryIds.has(repoId); - const abortControllerRef = useRef(null); useEffect(() => { @@ -141,6 +143,8 @@ const RepositoryCardComponent: React.FC = ({ const [unstarring, setUnstarring] = useState(false); const [showDragHint, setShowDragHint] = useState(false); const dragHintTimeoutRef = useRef | null>(null); + const [isLocallyAnalyzing, setIsLocallyAnalyzing] = useState(false); + const isAnalyzing = isLocallyAnalyzing || isStoreAnalyzing; // 高亮搜索关键词的工具函数 - 使用缓存优化 const highlightSearchTerm = useCallback((text: string, searchTerm: string): React.ReactNode => { @@ -295,6 +299,15 @@ const RepositoryCardComponent: React.FC = ({ const controller = new AbortController(); abortControllerRef.current = controller; + const analysisStartedAt = performance.now(); + setIsLocallyAnalyzing(true); + requestAnimationFrame(() => { + logger.info('ai.performance', 'Repository card AI spinner painted', { + repoId, + fullName: repository.full_name, + elapsedMs: Math.round(performance.now() - analysisStartedAt), + }); + }); setAnalyzingRepository(repoId, true); try { const result = await analyzeRepository({ @@ -303,8 +316,21 @@ const RepositoryCardComponent: React.FC = ({ aiConfig: activeConfig, language, categories: allCategories, + onProgress: (status) => { + logger.info('ai.performance', 'Repository card AI analysis step', { + repoId, + fullName: repository.full_name, + status, + elapsedMs: Math.round(performance.now() - analysisStartedAt), + }); + }, signal: controller.signal, }); + logger.info('ai.performance', 'Repository card AI request completed', { + repoId, + fullName: repository.full_name, + elapsedMs: Math.round(performance.now() - analysisStartedAt), + }); if (controller.signal.aborted) return; @@ -320,7 +346,14 @@ const RepositoryCardComponent: React.FC = ({ analysis_error: undefined, }; + const updateStartedAt = performance.now(); updateRepository(updatedRepo); + logger.info('ai.performance', 'Repository card AI result stored', { + repoId, + fullName: repository.full_name, + updateMs: Math.round(performance.now() - updateStartedAt), + elapsedMs: Math.round(performance.now() - analysisStartedAt), + }); const successMessage = repository.analyzed_at ? (language === 'zh' ? 'AI重新分析完成!' : 'AI re-analysis completed!') @@ -342,11 +375,19 @@ const RepositoryCardComponent: React.FC = ({ analysis_error: failedResult.analysis_error, }; + const updateStartedAt = performance.now(); updateRepository(failedRepo); + logger.info('ai.performance', 'Repository card AI failure stored', { + repoId, + fullName: repository.full_name, + updateMs: Math.round(performance.now() - updateStartedAt), + elapsedMs: Math.round(performance.now() - analysisStartedAt), + }); toast(language === 'zh' ? 'AI分析失败,请检查AI配置和网络连接。' : 'AI analysis failed. Please check AI configuration and network connection.', 'error'); } } finally { + setIsLocallyAnalyzing(false); if (!controller.signal.aborted) { setAnalyzingRepository(repoId, false); } diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx index 8f6ee89..ef5adea 100644 --- a/src/components/RepositoryList.tsx +++ b/src/components/RepositoryList.tsx @@ -75,6 +75,7 @@ export const RepositoryList: React.FC = ({ const selectedCategoryObj = allCategories.find(cat => cat.id === selectedCategory); if (!selectedCategoryObj) return []; + const selectedCategoryKeywords = selectedCategoryObj.keywords.map(keyword => keyword.toLowerCase()); return repositories.filter(repo => { if (repo.custom_category !== undefined) { @@ -87,12 +88,13 @@ export const RepositoryList: React.FC = ({ // 如果没有自定义分类,使用AI标签和关键词匹配 // 优先使用AI标签进行匹配 if (repo.ai_tags && repo.ai_tags.length > 0) { - return repo.ai_tags.some(tag => - selectedCategoryObj.keywords.some(keyword => - tag.toLowerCase().includes(keyword.toLowerCase()) || - keyword.toLowerCase().includes(tag.toLowerCase()) + return repo.ai_tags.some(tag => { + const tagLower = tag.toLowerCase(); + return selectedCategoryKeywords.some(keyword => + tagLower.includes(keyword) || + keyword.includes(tagLower) ) - ); + }); } // 如果没有AI标签,使用传统方式匹配 @@ -104,8 +106,8 @@ export const RepositoryList: React.FC = ({ repo.ai_summary || '' ].join(' ').toLowerCase(); - return selectedCategoryObj.keywords.some(keyword => - repoText.includes(keyword.toLowerCase()) + return selectedCategoryKeywords.some(keyword => + repoText.includes(keyword) ); }); }, [repositories, selectedCategory, allCategories]); @@ -164,7 +166,7 @@ export const RepositoryList: React.FC = ({ return { unanalyzedCount, analyzedCount, failedCount }; }, [filteredRepositories]); - const filterResetKey = useMemo(() => JSON.stringify({ + const filterResetKey = useMemo(() => ({ selectedCategory, query: searchFilters.query, languages: searchFilters.languages, diff --git a/src/services/indexedDbStorage.ts b/src/services/indexedDbStorage.ts index 1214018..0525b55 100644 --- a/src/services/indexedDbStorage.ts +++ b/src/services/indexedDbStorage.ts @@ -21,11 +21,13 @@ const safeLocalStorageGet = (key: string): string | null => { } }; -const safeLocalStorageSet = (key: string, value: string): void => { +const safeLocalStorageSet = (key: string, value: string): boolean => { try { window.localStorage.setItem(key, value); + return true; } catch { - // Quota/security errors are expected in some environments; ignore. + // Quota/security errors are expected in some environments; report failure to caller. + return false; } }; @@ -105,10 +107,12 @@ const idbDelete = async (key: string): Promise => { }; /** - * IndexedDB-backed Zustand persist storage with seamless migration + dual write: + * IndexedDB-backed Zustand persist storage with seamless migration: * - First read from IndexedDB - * - If empty, fall back to existing localStorage snapshot and migrate to IndexedDB - * - Every write goes to IndexedDB and localStorage (backward compatibility window) + * - If empty, migrate an existing localStorage snapshot to IndexedDB and then remove it + * - Normal writes go to IndexedDB and clear any legacy localStorage snapshot. + * - localStorage is only kept as the current snapshot when IndexedDB is unavailable or a write fails. + * This avoids stale fallback rollbacks while preserving persistence in constrained environments. */ export const indexedDBStorage: StateStorage = { getItem: async (name: string): Promise => { @@ -127,6 +131,7 @@ export const indexedDBStorage: StateStorage = { const legacyValue = safeLocalStorageGet(name); if (legacyValue !== null) { await withTimeout(idbSet(name, legacyValue)); + safeLocalStorageRemove(name); console.info('[storage] migrated state from localStorage to IndexedDB'); } return legacyValue; @@ -143,13 +148,16 @@ export const indexedDBStorage: StateStorage = { if (canUseIndexedDB()) { try { await withTimeout(idbSet(name, value)); + safeLocalStorageRemove(name); + return; } catch (error) { - console.warn('[storage] IndexedDB set failed:', error); + console.warn('[storage] IndexedDB set failed, fallback to localStorage:', error); } } - // Secondary compatibility backup (best effort only) - safeLocalStorageSet(name, value); + if (!safeLocalStorageSet(name, value)) { + throw new Error('[storage] localStorage fallback write failed'); + } }, removeItem: async (name: string): Promise => { diff --git a/src/store/useAppStore.test.ts b/src/store/useAppStore.test.ts new file mode 100644 index 0000000..7eb519d --- /dev/null +++ b/src/store/useAppStore.test.ts @@ -0,0 +1,88 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Repository } from '../types'; + +let useAppStore: typeof import('./useAppStore').useAppStore; + +beforeAll(async () => { + const { indexedDBStorage } = await vi.importActual('../services/indexedDbStorage'); + window.localStorage?.removeItem?.('github-stars-manager'); + await indexedDBStorage.removeItem('github-stars-manager'); + ({ useAppStore } = await vi.importActual('./useAppStore')); +}); + +const createRepository = (id: number, overrides: Partial = {}): Repository => ({ + id, + name: `repo-${id}`, + full_name: `owner/repo-${id}`, + description: 'A test repository', + html_url: `https://github.com/owner/repo-${id}`, + stargazers_count: 10, + forks_count: 1, + forks: 1, + language: 'TypeScript', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', + pushed_at: '2026-01-03T00:00:00.000Z', + owner: { + login: 'owner', + avatar_url: 'https://github.com/avatar.png', + }, + topics: ['test'], + ...overrides, +}); + +describe('useAppStore repository performance guards', () => { + beforeEach(() => { + useAppStore.setState({ + repositories: [], + searchResults: [], + analyzingRepositoryIds: new Set(), + }); + }); + + it('does not notify subscribers when updateRepository receives an equivalent repository', () => { + const repo = createRepository(1); + useAppStore.setState({ repositories: [repo], searchResults: [repo] }); + + const previousRepositories = useAppStore.getState().repositories; + const previousSearchResults = useAppStore.getState().searchResults; + let notifications = 0; + const unsubscribe = useAppStore.subscribe(() => { + notifications++; + }); + + useAppStore.getState().updateRepository({ ...repo }); + unsubscribe(); + + expect(notifications).toBe(0); + expect(useAppStore.getState().repositories).toBe(previousRepositories); + expect(useAppStore.getState().searchResults).toBe(previousSearchResults); + }); + + it('updates only lists that contain the repository', () => { + const repo = createRepository(1); + useAppStore.setState({ repositories: [repo], searchResults: [] }); + + const previousSearchResults = useAppStore.getState().searchResults; + useAppStore.getState().updateRepository({ ...repo, ai_summary: 'Updated summary' }); + + expect(useAppStore.getState().repositories[0].ai_summary).toBe('Updated summary'); + expect(useAppStore.getState().searchResults).toBe(previousSearchResults); + }); + + it('does not notify subscribers when analyzing state is unchanged', () => { + useAppStore.setState({ analyzingRepositoryIds: new Set([1]) }); + + const previousAnalyzingIds = useAppStore.getState().analyzingRepositoryIds; + let notifications = 0; + const unsubscribe = useAppStore.subscribe(() => { + notifications++; + }); + + useAppStore.getState().setAnalyzingRepository(1, true); + unsubscribe(); + + expect(notifications).toBe(0); + expect(useAppStore.getState().analyzingRepositoryIds).toBe(previousAnalyzingIds); + }); +}); diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index f92ad10..c7001a1 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -33,6 +33,115 @@ import { PRESET_FILTERS } from '../constants/presetFilters'; const BACKEND_SECRET_SESSION_KEY = 'github-stars-manager-backend-secret'; +const scheduleIdleTask = (callback: () => void): number => { + if (typeof window === 'undefined') { + return setTimeout(callback, 0) as unknown as number; + } + + if ('requestIdleCallback' in window) { + return window.requestIdleCallback(callback, { timeout: 3000 }); + } + + return window.setTimeout(callback, 0); +}; + +const cancelIdleTask = (id: number): void => { + if (typeof window !== 'undefined' && 'cancelIdleCallback' in window) { + window.cancelIdleCallback(id); + return; + } + + clearTimeout(id); +}; + +let persistTimeoutId: ReturnType | null = null; +let persistIdleTaskId: number | null = null; +let latestPersistName: string | null = null; +let latestPersistValue: StorageValue | null = null; +let persistWriteVersion = 0; +let persistFlushListenersRegistered = false; + +const cancelPendingPersistTasks = (): void => { + if (persistTimeoutId) { + clearTimeout(persistTimeoutId); + persistTimeoutId = null; + } + + if (persistIdleTaskId !== null) { + cancelIdleTask(persistIdleTaskId); + persistIdleTaskId = null; + } +}; + +const writePersistSnapshot = ( + name: string, + value: StorageValue, + version: number, + source: 'idle' | 'flush' +): void => { + if (latestPersistValue === null || latestPersistName !== name || persistWriteVersion !== version) { + return; + } + + const startedAt = performance.now(); + try { + const str = JSON.stringify(value); + const stringifyMs = Math.round(performance.now() - startedAt); + const writeStartedAt = performance.now(); + void indexedDBStorage.setItem(name, str) + .then(() => { + const writeMs = Math.round(performance.now() - writeStartedAt); + if (writeMs > 50) { + logger.warn('store.persist', 'Large state IndexedDB write completed', { + source, + writeMs, + bytes: str.length, + }); + } + }) + .catch((error) => { + const writeMs = Math.round(performance.now() - writeStartedAt); + logger.errorFromError('store.persist', 'IndexedDB write failed', error, { + source, + writeMs, + bytes: str.length, + }); + }); + if (stringifyMs > 50) { + logger.warn('store.persist', 'Large state stringify completed', { + source, + stringifyMs, + bytes: str.length, + }); + } + } catch (e) { + logger.errorFromError('store.persist', 'Failed to stringify state for persistence', e); + } +}; + +const flushPendingPersistSnapshot = (): void => { + if (latestPersistName === null || latestPersistValue === null) return; + + cancelPendingPersistTasks(); + writePersistSnapshot(latestPersistName, latestPersistValue, persistWriteVersion, 'flush'); +}; + +const registerPersistFlushListeners = (): void => { + if (persistFlushListenersRegistered || typeof window === 'undefined') return; + persistFlushListenersRegistered = true; + + window.addEventListener('pagehide', flushPendingPersistSnapshot); + window.addEventListener('beforeunload', flushPendingPersistSnapshot); + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + flushPendingPersistSnapshot(); + } + }); + } +}; + // Create a debounced storage to avoid frequent JSON.stringify calls on large state objects // which causes V8 JIT assertion failures (EXC_BREAKPOINT) on macOS ARM64. const debouncedPersistStorage: PersistStorage = { @@ -45,24 +154,30 @@ const debouncedPersistStorage: PersistStorage = { return null; } }, - setItem: (() => { - let timeoutId: ReturnType | null = null; - let latestValue: StorageValue | null = null; - return (name: string, value: StorageValue) => { - latestValue = value; - if (timeoutId) clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - try { - const str = JSON.stringify(latestValue); - indexedDBStorage.setItem(name, str); - } catch (e) { - logger.errorFromError('store.persist', 'Failed to stringify state for persistence', e); - } - }, 1000); - }; - })(), + setItem: (name: string, value: StorageValue) => { + registerPersistFlushListeners(); + latestPersistName = name; + latestPersistValue = value; + persistWriteVersion++; + const scheduledVersion = persistWriteVersion; + + cancelPendingPersistTasks(); + persistTimeoutId = setTimeout(() => { + persistTimeoutId = null; + persistIdleTaskId = scheduleIdleTask(() => { + persistIdleTaskId = null; + writePersistSnapshot(name, value, scheduledVersion, 'idle'); + }); + }, 1000); + }, removeItem: (name) => { - indexedDBStorage.removeItem(name); + latestPersistName = null; + latestPersistValue = null; + persistWriteVersion++; + cancelPendingPersistTasks(); + void indexedDBStorage.removeItem(name).catch((error) => { + logger.errorFromError('store.persist', 'Failed to remove persisted state snapshot', error); + }); } }; @@ -80,6 +195,40 @@ const writeSessionBackendSecret = (secret: string | null): void => { } }; +const areRepositoryRecordsEqual = (a: Repository, b: Repository): boolean => { + if (a === b) return true; + + const aRecord = a as Record; + const bRecord = b as Record; + const keys = new Set([...Object.keys(aRecord), ...Object.keys(bRecord)]); + + for (const key of keys) { + if (!Object.is(aRecord[key], bRecord[key])) { + return false; + } + } + + return true; +}; + +const replaceRepositoryInList = ( + repositories: Repository[], + repo: Repository +): { repositories: Repository[]; changed: boolean; found: boolean } => { + const index = repositories.findIndex((item) => item.id === repo.id); + if (index === -1) { + return { repositories, changed: false, found: false }; + } + + if (areRepositoryRecordsEqual(repositories[index], repo)) { + return { repositories, changed: false, found: true }; + } + + const nextRepositories = repositories.slice(); + nextRepositories[index] = repo; + return { repositories: nextRepositories, changed: true, found: true }; +}; + interface AppActions { // Auth actions setUser: (user: GitHubUser | null) => void; @@ -827,10 +976,18 @@ export const useAppStore = create()( // Repository actions setRepositories: (repositories) => set({ repositories, searchResults: repositories }), updateRepository: (repo) => set((state) => { - const updatedRepositories = state.repositories.map(r => r.id === repo.id ? repo : r); + const repositoriesResult = replaceRepositoryInList(state.repositories, repo); + const searchResultsResult = state.searchResults === state.repositories + ? repositoriesResult + : replaceRepositoryInList(state.searchResults, repo); + + if (!repositoriesResult.changed && !searchResultsResult.changed) { + return state; + } + return { - repositories: updatedRepositories, - searchResults: state.searchResults.map(r => r.id === repo.id ? repo : r) + repositories: repositoriesResult.repositories, + searchResults: searchResultsResult.repositories }; }), addRepository: (repo) => set((state) => { @@ -890,6 +1047,11 @@ export const useAppStore = create()( }; }), setAnalyzingRepository: (repoId, isAnalyzing) => set((state) => { + const alreadyAnalyzing = state.analyzingRepositoryIds.has(repoId); + if (alreadyAnalyzing === isAnalyzing) { + return state; + } + const nextAnalyzingIds = new Set(state.analyzingRepositoryIds); if (isAnalyzing) { nextAnalyzingIds.add(repoId);