feat(kosync): defer push after resume (#3892)

* feat(kosync): defer push after resume

* PR feedback updates:
 * follow established patterns from other hooks
 * error handling
 * fix typos
This commit is contained in:
Zach Bean 2026-04-18 10:28:20 -05:00 committed by GitHub
parent 31e44d2e4d
commit 5c97b2e9d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 94 additions and 6 deletions

View file

@ -11,6 +11,8 @@ import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { getCFIFromXPointer, XCFI } from '@/utils/xcfi';
import { useWindowActiveChanged } from './useWindowActiveChanged';
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
export interface SyncDetails {
@ -252,53 +254,75 @@ export const useKOSync = (bookKey: string) => {
[bookKey, appService, kosyncClient, settings.kosync, progress],
);
// use a ref to track the current push/pull functions so they can change without triggering effects
const syncRefs = useRef({ pushProgress, pullProgress });
useEffect(() => {
syncRefs.current = { pushProgress, pullProgress };
}, [pushProgress, pullProgress]);
useEffect(() => {
const handlePushProgress = (event: CustomEvent) => {
const { pushProgress } = syncRefs.current;
if (event.detail.bookKey !== bookKey) return;
pushProgress();
pushProgress.flush();
};
const handleFlush = (event: CustomEvent) => {
const { pushProgress } = syncRefs.current;
if (event.detail.bookKey !== bookKey) return;
pushProgress.flush();
};
eventDispatcher.on('push-kosync', handlePushProgress);
eventDispatcher.on('flush-kosync', handleFlush);
return () => {
const { pushProgress } = syncRefs.current;
eventDispatcher.off('push-kosync', handlePushProgress);
eventDispatcher.off('flush-kosync', handleFlush);
pushProgress.flush();
};
}, [bookKey, pushProgress]);
}, [bookKey]);
useEffect(() => {
const handlePullProgress = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
const { pullProgress } = syncRefs.current;
pullProgress();
};
eventDispatcher.on('pull-kosync', handlePullProgress);
return () => {
eventDispatcher.off('pull-kosync', handlePullProgress);
};
}, [bookKey, pullProgress]);
}, [bookKey]);
// Pull: pull progress once when the book is opened
useEffect(() => {
if (!appService || !kosyncClient || !progress?.location) return;
if (hasPulledOnce.current) return;
pullProgress();
}, [appService, kosyncClient, progress?.location, pushProgress, pullProgress]);
syncRefs.current.pullProgress();
}, [appService, kosyncClient, progress?.location]);
// Push: auto-push progress when progress changes with a debounce
useEffect(() => {
if (syncState === 'synced' && progress) {
const { strategy, enabled } = settings.kosync;
if (strategy !== 'receive' && enabled) {
pushProgress();
syncRefs.current.pushProgress();
}
}
}, [progress, syncState, settings.kosync, pushProgress]);
}, [progress, syncState, settings.kosync]);
useWindowActiveChanged((isActive) => {
const { pushProgress, pullProgress } = syncRefs.current;
if (isActive) {
hasPulledOnce.current = false;
pullProgress();
} else {
pushProgress();
pushProgress.flush();
}
});
const resolveWithLocal = () => {
pushProgress();

View file

@ -0,0 +1,64 @@
// used to execute a callback when the "active" state of the current window changes.
// On web and mobile, "active" means "is visible". On desktop, the 'visibilitychange'
// event is unreliable, so "active" means "has focus".
import { useEffect, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
export type ActiveCallback = (isActive: boolean) => void;
type Cleanup = () => void;
async function activeChangedDesktop(onChange: ActiveCallback): Promise<Cleanup> {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const appWindow = getCurrentWindow();
const unlisten = await appWindow.onFocusChanged(({ payload: isActive }) => onChange(isActive));
return () => {
unlisten();
};
}
async function activeChangedOther(onChange: ActiveCallback): Promise<Cleanup> {
const handler = () => onChange(document.visibilityState === 'visible');
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
}
export function useWindowActiveChanged(callback: ActiveCallback) {
const onActiveChanged = useRef<ActiveCallback>(callback);
const { appService } = useEnv();
const subscribe = appService?.isDesktopApp ? activeChangedDesktop : activeChangedOther;
useEffect(() => {
onActiveChanged.current = callback;
}, [callback]);
useEffect(() => {
let isAlive = true;
let unsub: Cleanup | undefined;
const onChange = (isActive: boolean) => {
onActiveChanged.current?.(isActive);
};
subscribe(onChange)
.then((cleanup) => {
if (isAlive) {
unsub = cleanup;
} else {
// component was already unmounted, just clean up immediately
cleanup();
}
})
.catch((e) => {
console.error('Could not listen for window active changes', e);
});
return () => {
isAlive = false;
unsub?.();
};
}, [subscribe]);
}