From 0dbcea0b4e8620d986b2352444ef5d5a0ffa3695 Mon Sep 17 00:00:00 2001 From: Vic liu <8925514+iamvicliu@users.noreply.github.com> Date: Wed, 13 May 2026 16:03:54 +0800 Subject: [PATCH 1/2] fix: persist GitHub star sync immediately and guard against empty-backend overwrite Two related data-loss bugs when using the backend sync feature: 1. src/components/Header.tsx After fetching starred repos from GitHub, the store update was only flushed to the backend after a 2-second debounce. If the user refreshed the page within that window the debounce timer was destroyed, the PUT never fired, and the data was lost on the next load (when syncFromBackend overwrites the local store with the still-empty backend). Fix: call forceSyncToBackend() immediately after setRepositories() so the data reaches the backend before the user can navigate away. 2. src/services/autoSync.ts (syncFromBackend) On every page load, syncFromBackend fetches from the backend and overwrites the Zustand store. If the backend has zero repositories (e.g. first-time setup, backend was reset, or the debounce race above occurred), it silently clears whatever was cached in localStorage. Fix: skip the setRepositories call when the backend returns an empty list but the local store already has repositories, so locally-cached data is never replaced with an empty response. Together these two changes make starred-repo data survive a page refresh even in scenarios where the backend has not yet received a successful sync. Co-Authored-By: Claude Sonnet 4.6 --- src/components/Header.tsx | 4 ++++ src/services/autoSync.ts | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 852b40b0..ba85b9da 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -3,6 +3,7 @@ import { Settings, Calendar, Search, Moon, Sun, LogOut, RefreshCw, TrendingUp, G import { useAppStore } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; import { useDialog } from '../hooks/useDialog'; +import { forceSyncToBackend } from '../services/autoSync'; export const Header: React.FC = () => { const { @@ -101,6 +102,9 @@ export const Header: React.FC = () => { setRepositories(mergedRepositories); + // Force-push to backend immediately (bypass 2s debounce) so data survives a page refresh + void forceSyncToBackend(); + // Note: Release fetching is now handled by the Refresh button in Release Timeline // Header sync only syncs the starred repos list diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index cb10f5e9..50c7f3e5 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -128,8 +128,15 @@ export async function syncFromBackend(): Promise { // Update store then commit hash — hash only changes if setter succeeds if (changed.repos && reposResult.status === 'fulfilled') { - state.setRepositories(reposResult.value.repositories); - _lastHash.repos = hashes.repos; + const backendRepos = reposResult.value.repositories; + const localRepos = state.repositories; + // Don't overwrite a non-empty local store with an empty backend response. + // This prevents the initial syncFromBackend call from wiping locally-cached + // repos before the user has had a chance to push them to the backend. + if (backendRepos.length > 0 || localRepos.length === 0) { + state.setRepositories(backendRepos); + _lastHash.repos = hashes.repos; + } } if (changed.releases && releasesResult.status === 'fulfilled') { state.setReleases(releasesResult.value.releases); From 9d139d6a656c204ecfb31c648ec6212b2600de5f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 13 May 2026 16:42:56 +0800 Subject: [PATCH 2/2] fix: refine empty-backend guard and handle sync errors - Differentiate bootstrap-empty from authoritative-empty backend: only preserve local cache on first-ever sync (_lastHash.repos === ''), accept empty backend on subsequent syncs so cross-device deletes converge. (addresses CodeRabbit review) - Replace void forceSyncToBackend() with .catch(console.error) so sync failures are logged instead of silently swallowed. --- src/components/Header.tsx | 2 +- src/services/autoSync.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ba85b9da..952c0b84 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -103,7 +103,7 @@ export const Header: React.FC = () => { setRepositories(mergedRepositories); // Force-push to backend immediately (bypass 2s debounce) so data survives a page refresh - void forceSyncToBackend(); + forceSyncToBackend().catch(console.error); // Note: Release fetching is now handled by the Refresh button in Release Timeline // Header sync only syncs the starred repos list diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 50c7f3e5..611bdbc2 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -130,10 +130,15 @@ export async function syncFromBackend(): Promise { if (changed.repos && reposResult.status === 'fulfilled') { const backendRepos = reposResult.value.repositories; const localRepos = state.repositories; - // Don't overwrite a non-empty local store with an empty backend response. - // This prevents the initial syncFromBackend call from wiping locally-cached - // repos before the user has had a chance to push them to the backend. - if (backendRepos.length > 0 || localRepos.length === 0) { + // Distinguish first-ever sync (bootstrap) from an authoritative empty backend. + // On bootstrap the hash is still '' — preserve local cache and push it to backend. + // On subsequent syncs, accept the backend state even if empty (e.g. user cleared + // stars from another device). + const isBootstrapEmpty = + backendRepos.length === 0 && localRepos.length > 0 && _lastHash.repos === ''; + if (isBootstrapEmpty) { + _hasPendingPush = true; + } else { state.setRepositories(backendRepos); _lastHash.repos = hashes.repos; }