From 856c4cd4dcea347a7bd1b1f10e4aa16e3dbe9d74 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 22 Apr 2026 15:16:24 +0800 Subject: [PATCH 1/7] fix(desktop): prevent discovery view renderer crash --- index.html | 5 --- src/components/DiscoveryView.tsx | 21 ++++++---- src/store/useAppStore.ts | 71 ++++++++++++++++++++++++++++++-- tailwind.config.js | 4 +- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/index.html b/index.html index 121382a1..04123025 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,6 @@ GitHub Stars Manager - AI-Powered Repository Management - - - - -
diff --git a/src/components/DiscoveryView.tsx b/src/components/DiscoveryView.tsx index ec771daa..0ae4fe34 100644 --- a/src/components/DiscoveryView.tsx +++ b/src/components/DiscoveryView.tsx @@ -604,6 +604,10 @@ export const DiscoveryView: React.FC = React.memo(() => { const discoveryScrollPositionsRef = useRef>({}); const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); + const safeDiscoveryChannels = useMemo( + () => Array.isArray(discoveryChannels) ? discoveryChannels.filter(Boolean) : [], + [discoveryChannels] + ); const ITEMS_PER_PAGE = 20; @@ -637,7 +641,8 @@ export const DiscoveryView: React.FC = React.memo(() => { const currentIsLoading = discoveryIsLoading?.[selectedDiscoveryChannel] ?? false; const currentHasMore = discoveryHasMore?.[selectedDiscoveryChannel] ?? false; const currentNextPage = discoveryNextPage?.[selectedDiscoveryChannel] ?? 1; - const currentChannelIcon = discoveryChannels.find(ch => ch.id === selectedDiscoveryChannel)?.icon || 'trending'; + const currentChannel = safeDiscoveryChannels.find(ch => ch.id === selectedDiscoveryChannel); + const currentChannelIcon = currentChannel?.icon || 'trending'; const currentChannelStyle = discoveryChannelStyleMap[currentChannelIcon] || discoveryChannelStyleMap.trending; const currentChannelIconNode = discoveryChannelIconMap[currentChannelIcon] || discoveryChannelIconMap.trending; @@ -926,20 +931,20 @@ export const DiscoveryView: React.FC = React.memo(() => { }, [allRepos.length, currentHasMore, currentIsLoading, currentNextPage, refreshChannel, selectedDiscoveryChannel]); const refreshAll = useCallback(async () => { - const enabledChannels = discoveryChannels.filter(ch => ch.enabled); + const enabledChannels = safeDiscoveryChannels.filter(ch => ch.enabled); for (const channel of enabledChannels) { await refreshChannel(channel.id, 1, false); } - }, [discoveryChannels, refreshChannel]); + }, [safeDiscoveryChannels, refreshChannel]); const mobileChannels = useMemo(() => { - return discoveryChannels + return safeDiscoveryChannels .filter(ch => ch.enabled) .map(ch => ({ ...ch, icon: discoveryChannelIconMap[ch.icon] || , })); - }, [discoveryChannels]); + }, [safeDiscoveryChannels]); return (
@@ -963,7 +968,7 @@ export const DiscoveryView: React.FC = React.memo(() => {
{ // 保存当前频道的滚动位置到 ref 和 state @@ -1000,8 +1005,8 @@ export const DiscoveryView: React.FC = React.memo(() => {

{language === 'zh' - ? discoveryChannels.find(ch => ch.id === selectedDiscoveryChannel)?.name - : discoveryChannels.find(ch => ch.id === selectedDiscoveryChannel)?.nameEn} + ? currentChannel?.name + : currentChannel?.nameEn}

{currentLastRefresh && (

diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 50a88f70..f7e2111e 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -232,6 +232,7 @@ const normalizePersistedState = ( currentState: AppState & AppActions ): Partial => { const safePersisted = persisted ?? {}; + const defaultDiscoveryChannelIds = new Set(defaultDiscoveryChannels.map((channel) => channel.id)); const repositories = Array.isArray(safePersisted.repositories) ? safePersisted.repositories : []; const releases = Array.isArray(safePersisted.releases) ? safePersisted.releases : []; @@ -273,27 +274,71 @@ const normalizePersistedState = ( releaseViewMode: safePersisted.releaseViewMode || 'timeline', releaseSelectedFilters: Array.isArray(safePersisted.releaseSelectedFilters) ? safePersisted.releaseSelectedFilters : [], releaseSearchQuery: typeof safePersisted.releaseSearchQuery === 'string' ? safePersisted.releaseSearchQuery : '', + discoveryChannels: (() => { + const persisted = (safePersisted as Record).discoveryChannels; + if (!Array.isArray(persisted)) return defaultDiscoveryChannels; + + return defaultDiscoveryChannels.map((defaultChannel) => { + const persistedChannel = persisted.find((channel: unknown) => { + return (channel as Record)?.id === defaultChannel.id; + }) as Record | undefined; + + if (!persistedChannel) { + return defaultChannel; + } + + return { + ...defaultChannel, + ...(persistedChannel as Partial), + enabled: persistedChannel.enabled !== false, + }; + }); + })(), discoveryRepos: (() => { const persisted = (safePersisted as Record).discoveryRepos; if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { - return persisted as Record; + return { + 'trending': [], + 'hot-release': [], + 'most-popular': [], + 'topic': [], + 'search': [], + ...(persisted as Record), + }; } return { 'trending': [], 'hot-release': [], 'most-popular': [], 'topic': [], 'search': [] } as Record; })(), discoveryLastRefresh: (() => { const persisted = (safePersisted as Record).discoveryLastRefresh; if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { - return persisted as Record; + return { + 'trending': null, + 'hot-release': null, + 'most-popular': null, + 'topic': null, + 'search': null, + ...(persisted as Record), + }; } return { 'trending': null, 'hot-release': null, 'most-popular': null, 'topic': null, 'search': null }; })(), discoveryTotalCount: (() => { const persisted = (safePersisted as Record).discoveryTotalCount; if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { - return persisted as Record; + return { + 'trending': 0, + 'hot-release': 0, + 'most-popular': 0, + 'topic': 0, + 'search': 0, + ...(persisted as Record), + }; } return { 'trending': 0, 'hot-release': 0, 'most-popular': 0, 'topic': 0, 'search': 0 }; })(), + selectedDiscoveryChannel: defaultDiscoveryChannelIds.has(safePersisted.selectedDiscoveryChannel as DiscoveryChannelId) + ? safePersisted.selectedDiscoveryChannel as DiscoveryChannelId + : 'trending', // 确保 subscription 相关状态包含 trending 键 subscriptionRepos: { 'most-stars': [], @@ -1221,6 +1266,26 @@ export const useAppStore = create()( if (state && !state.selectedDiscoveryChannel) { state.selectedDiscoveryChannel = 'trending'; } + if (state && (!state.discoveryChannels || !Array.isArray(state.discoveryChannels))) { + state.discoveryChannels = defaultDiscoveryChannels; + } else if (state && Array.isArray(state.discoveryChannels)) { + const persistedChannels = state.discoveryChannels as unknown[]; + state.discoveryChannels = defaultDiscoveryChannels.map((defaultChannel) => { + const persistedChannel = persistedChannels.find((channel) => { + return (channel as Record)?.id === defaultChannel.id; + }) as Record | undefined; + + if (!persistedChannel) { + return defaultChannel; + } + + return { + ...defaultChannel, + ...(persistedChannel as Partial), + enabled: persistedChannel.enabled !== false, + }; + }); + } // 迁移订阅频道(版本 4→5:daily-dev → most-dev,新增 trending,补全 nameEn) const defaultChannelsMap = new Map(defaultSubscriptionChannels.map(ch => [ch.id, ch])); if (state && !Array.isArray(state.subscriptionChannels)) { diff --git a/tailwind.config.js b/tailwind.config.js index 85705cff..25ea1414 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -12,7 +12,7 @@ export default { }, extend: { fontFamily: { - sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'sans-serif'], + sans: ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'sans-serif'], }, colors: { primary: { @@ -104,4 +104,4 @@ export default { }, }, plugins: [], -}; \ No newline at end of file +}; From 80cc3d9c0bc9ce69aa3211c717f05bfa7713e50c Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 22 Apr 2026 16:39:41 +0800 Subject: [PATCH 2/7] fix: resolve mac desktop white screen when switching top tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes fixed: 1. normalizePersistedState was missing defensive handling for 4 discovery runtime state fields (discoveryIsLoading, discoveryHasMore, discoveryNextPage, discoveryScrollPositions). If old persisted data had these fields in a wrong format (e.g. array instead of object), spreading them in store actions like { ...state.discoveryIsLoading, [channel]: loading } would produce corrupted state and cause React render errors → white screen. 2. migrate() function also lacked a reset for discoveryIsLoading / discoveryScrollPositions, so old-format data survived into the merge step. 3. DiscoveryView had no local ErrorBoundary. Any render error inside it propagated all the way to the root boundary (main.tsx), wiping the entire UI. Wrapping it in a scoped ErrorBoundary now shows a recovery UI instead of a blank page when switching to the discovery tab. Changes: - src/store/useAppStore.ts: add normalizePersistedState entries for discoveryIsLoading (reset to false), discoveryHasMore (safe object merge), discoveryNextPage (safe object merge), discoveryScrollPositions (reset to 0); add migrate() block to reset discoveryIsLoading and discoveryScrollPositions. - src/App.tsx: wrap in for scoped recovery. --- src/App.tsx | 7 ++++++- src/store/useAppStore.ts | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index be525f00..931f730c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { ReleaseTimeline } from './components/ReleaseTimeline'; import { SettingsPanel } from './components/SettingsPanel'; import { DiscoveryView } from './components/DiscoveryView'; import { BackToTop } from './components/BackToTop'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { useAppStore } from './store/useAppStore'; import { useAutoUpdateCheck } from './components/UpdateChecker'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; @@ -116,7 +117,11 @@ function App() { case 'releases': return ; case 'subscription': - return ; + return ( + + + + ); case 'settings': return ; default: diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index f7e2111e..1f6410a3 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -339,6 +339,40 @@ const normalizePersistedState = ( selectedDiscoveryChannel: defaultDiscoveryChannelIds.has(safePersisted.selectedDiscoveryChannel as DiscoveryChannelId) ? safePersisted.selectedDiscoveryChannel as DiscoveryChannelId : 'trending', + // discoveryIsLoading 不持久化,始终重置为 false(防止旧数据格式异常) + discoveryIsLoading: { 'trending': false, 'hot-release': false, 'most-popular': false, 'topic': false, 'search': false }, + // discoveryHasMore 从持久化恢复,确保对象格式 + discoveryHasMore: (() => { + const persisted = (safePersisted as Record).discoveryHasMore; + if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { + return { + 'trending': false, + 'hot-release': false, + 'most-popular': false, + 'topic': false, + 'search': false, + ...(persisted as Record), + }; + } + return { 'trending': false, 'hot-release': false, 'most-popular': false, 'topic': false, 'search': false }; + })(), + // discoveryNextPage 从持久化恢复,确保对象格式 + discoveryNextPage: (() => { + const persisted = (safePersisted as Record).discoveryNextPage; + if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { + return { + 'trending': 1, + 'hot-release': 1, + 'most-popular': 1, + 'topic': 1, + 'search': 1, + ...(persisted as Record), + }; + } + return { 'trending': 1, 'hot-release': 1, 'most-popular': 1, 'topic': 1, 'search': 1 }; + })(), + // discoveryScrollPositions 不持久化,始终重置为 0 + discoveryScrollPositions: { 'trending': 0, 'hot-release': 0, 'most-popular': 0, 'topic': 0, 'search': 0 }, // 确保 subscription 相关状态包含 trending 键 subscriptionRepos: { 'most-stars': [], @@ -1335,6 +1369,16 @@ export const useAppStore = create()( if (state && !state.discoverySortOrder) { state.discoverySortOrder = 'Descending'; } + // discoveryIsLoading 不应持久化,migrate 时始终重置防止旧数据格式异常导致 spread 崩溃 + if (state) { + (state as Record).discoveryIsLoading = { + 'trending': false, 'hot-release': false, 'most-popular': false, 'topic': false, 'search': false, + }; + // discoveryScrollPositions 同样不应持久化,重置以避免 stale 滚动位置 + (state as Record).discoveryScrollPositions = { + 'trending': 0, 'hot-release': 0, 'most-popular': 0, 'topic': 0, 'search': 0, + }; + } return state as PersistedAppState; }, From 3ddadbce33956a9ce85c7245bcde7bd0dbec1f67 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 22 Apr 2026 17:05:02 +0800 Subject: [PATCH 3/7] fix(desktop): disable persistent storage for massive discoveryRepos object --- src/components/DiscoveryView.tsx | 53 ++++++++++++++++++++++++-------- src/store/useAppStore.ts | 14 ++++++--- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/components/DiscoveryView.tsx b/src/components/DiscoveryView.tsx index 0ae4fe34..0d3fe979 100644 --- a/src/components/DiscoveryView.tsx +++ b/src/components/DiscoveryView.tsx @@ -604,6 +604,10 @@ export const DiscoveryView: React.FC = React.memo(() => { const discoveryScrollPositionsRef = useRef>({}); const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); + const isDesktopSafeMode = useMemo(() => { + if (typeof window === 'undefined') return false; + return window.location.protocol === 'file:' || navigator.userAgent.includes('Electron'); + }, []); const safeDiscoveryChannels = useMemo( () => Array.isArray(discoveryChannels) ? discoveryChannels.filter(Boolean) : [], [discoveryChannels] @@ -646,7 +650,7 @@ export const DiscoveryView: React.FC = React.memo(() => { const currentChannelStyle = discoveryChannelStyleMap[currentChannelIcon] || discoveryChannelStyleMap.trending; const currentChannelIconNode = discoveryChannelIconMap[currentChannelIcon] || discoveryChannelIconMap.trending; - // 切换频道时重置页码并恢复滚动位置(只依赖 selectedDiscoveryChannel,不订阅 discoveryScrollPositions) + // 切换频道时重置页码、恢复滚动位置,并自动加载空数据 useEffect(() => { setCurrentPage(1); // 恢复当前频道的滚动位置(从 ref 读取最新值,避免订阅整个 map) @@ -654,7 +658,14 @@ export const DiscoveryView: React.FC = React.memo(() => { const savedPosition = discoveryScrollPositionsRef.current[selectedDiscoveryChannel] || 0; scrollContainerRef.current.scrollTop = savedPosition; } - }, [selectedDiscoveryChannel]); + + // 取消持久化后,首次打开或切换到空频道时自动加载 + const hasRepos = useAppStore.getState().discoveryRepos[selectedDiscoveryChannel]?.length > 0; + const isLoading = useAppStore.getState().discoveryIsLoading[selectedDiscoveryChannel]; + if (!hasRepos && !isLoading) { + refreshChannel(selectedDiscoveryChannel, 1, false); + } + }, [selectedDiscoveryChannel, refreshChannel]); // 主题改变时刷新数据 useEffect(() => { @@ -1119,10 +1130,12 @@ export const DiscoveryView: React.FC = React.memo(() => {

{selectedDiscoveryChannel === 'search' && ( -
+
@@ -1138,7 +1151,9 @@ export const DiscoveryView: React.FC = React.memo(() => {
)} -
+
{currentPageRepos.map(repo => ( - + ))}
{/* Page Info */} {allRepos.length > 0 && ( -
+
@@ -1286,7 +1311,9 @@ export const DiscoveryView: React.FC = React.memo(() => {