Files
Netcatty/application/state/useAutoSync.ts
陈大猫 36e5779d94 perf(terminal): reduce terminal tab-switch and layout jank (#1321)
* perf(terminal): smooth layout drags and faster tab switching

Defer xterm refit during split, sidebar, and host-tree drags while keeping pane containers in sync with live layout measurements. Refactor TerminalLayer into focused sections with TabBridge/memo optimizations and add the terminal host tree sidebar.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): keep side panels alive and guard session attach races

Prevent terminal boot unmount from leaking backend sessions, keep SFTP/scripts/theme/AI state when switching side tabs, and defer heavy SFTP UI mount so first entry stays responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf(terminal): reduce tab switch jank

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 03:35:03 +08:00

966 lines
40 KiB
TypeScript

/**
* useAutoSync - Auto-sync Hook for Cloud Sync
*
* Provides automatic sync capabilities:
* - Sync when data changes (hosts, keys, snippets, port forwarding rules)
* - Check remote version on app startup
* - Debounced sync to avoid too frequent API calls
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { mergeSyncPayloads } from '../../domain/syncMerge';
import { resolveCloudSyncConflictAction } from '../../domain/syncStrategy';
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectSyncableSettings,
getEffectivePortForwardingRulesForSync,
hasMeaningfulCloudSyncData,
shouldPromptCloudVaultRecovery,
} from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { notify } from '../notification';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
interface AutoSyncConfig {
// Data to sync
hosts: SyncPayload['hosts'];
keys: SyncPayload['keys'];
identities?: SyncPayload['identities'];
proxyProfiles?: SyncPayload['proxyProfiles'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
startupReady?: boolean;
// Callbacks
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
}
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
const SYNCABLE_SETTING_STORAGE_KEY_SET = new Set<string>(SYNCABLE_SETTING_STORAGE_KEYS);
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
// in the future means a restore is applying in some window and auto-sync
// must not push concurrently. The writer (`withRestoreBarrier`) heartbeats
// the deadline to keep it alive; a crashed window naturally expires within
// ~RESTORE_BARRIER_HOLD_MS. We still defend against two degenerate cases:
// (1) a stale deadline sitting in the past — harmless but pollutes debug
// state, so we opportunistically clear it; (2) a deadline absurdly far
// in the future (clock skew between windows, pathological holdMs, or a
// tampered value) — would otherwise lock auto-sync indefinitely, so we
// clear it and treat the barrier as inactive.
const RESTORE_BARRIER_SANITY_MAX_MS = 10 * 60 * 1000; // 10 minutes
const isRestoreInProgress = (): boolean => {
const raw = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL);
if (typeof raw !== 'number' || raw <= 0) return false;
const now = Date.now();
if (raw <= now) {
// Deadline is in the past — either a clean finish that failed to
// overwrite the key, or a crashed heartbeat. Clear so subsequent
// reads are cheap and the key doesn't linger forever.
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
return false;
}
if (raw - now > RESTORE_BARRIER_SANITY_MAX_MS) {
console.warn(
'[useAutoSync] Restore barrier deadline is absurdly far in the future; treating as corrupt and clearing.',
{ deadline: raw, now },
);
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
return false;
}
return true;
};
type SyncTrigger = 'auto' | 'manual';
interface SyncNowOptions {
trigger?: SyncTrigger;
}
interface RemoteVersionCheckOptions {
force?: boolean;
notifyOnFailure?: boolean;
}
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const sync = useCloudSync();
const { onApplyPayload } = config;
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
const inspectFailureToastShownRef = useRef(false);
/** True once checkRemoteVersion has completed (success or failure). Until
* this is set, the debounced auto-sync effect will not fire, preventing
* an empty local vault from racing ahead and overwriting a non-empty
* cloud vault before the startup pull has run. See #679. */
const remoteCheckDoneRef = useRef(false);
const isInitializedRef = useRef(false);
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
// State for the empty-vault-vs-cloud confirmation dialog (Fix D).
// When checkRemoteVersion detects that the local vault is empty but
// the cloud has data, it pauses and exposes this state so the root
// component can render a confirmation dialog.
const [emptyVaultConflict, setEmptyVaultConflict] = useState<{
remotePayload: SyncPayload;
hostCount: number;
keyCount: number;
proxyProfileCount: number;
snippetCount: number;
} | null>(null);
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
// Listen for SFTP bookmark changes to trigger auto-sync
const [bookmarksVersion, setBookmarksVersion] = useState(0);
useEffect(() => {
const handler = () => setBookmarksVersion((v) => v + 1);
window.addEventListener('sftp-bookmarks-changed', handler);
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
useEffect(() => {
const bumpIfSyncableSetting = (key: string | null | undefined) => {
if (!key || !SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)) return;
setSyncableSettingsStorageVersion((v) => v + 1);
};
const handleStorage = (event: StorageEvent) => {
bumpIfSyncableSetting(event.key);
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
bumpIfSyncableSetting(key);
};
window.addEventListener('storage', handleStorage);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, []);
const getSyncSnapshot = useCallback(() => {
return {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
proxyProfiles: config.proxyProfiles,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
groupConfigs: config.groupConfigs,
};
}, [
config.hosts,
config.keys,
config.identities,
config.proxyProfiles,
config.snippets,
config.customGroups,
config.snippetPackages,
config.portForwardingRules,
config.groupConfigs,
]);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
return {
...getSyncSnapshot(),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
}, [getSyncSnapshot]);
// Create a hash of current data for comparison (includes settings)
const getDataHash = useCallback(() => {
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
}, [getSyncSnapshot]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {
const trigger: SyncTrigger = options?.trigger ?? 'auto';
isSyncRunningRef.current = true;
try {
// Get fresh state directly from CloudSyncManager singleton
let state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const syncing = state.syncState === 'SYNCING';
if (!hasProvider) {
throw new Error(t('sync.autoSync.noProvider'));
}
if (syncing) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping overlapping auto-sync because another sync is already running.');
return;
}
throw new Error(t('sync.autoSync.alreadySyncing'));
}
// Cross-window guard: another window may be in the middle of
// applying a local vault restore. If we push right now we'd upload
// the pre-restore snapshot (the main window's React state hasn't
// observed the localStorage writes yet), clobbering the just-
// restored cloud copy. Skip silently on auto triggers and fail
// loudly on manual ones so the user understands why their click
// did nothing.
//
// Pairs with `withRestoreBarrier` in application/localVaultBackups.ts
// (the writer) and with the matching early-return in the
// debounced-sync effect below (the other reader, which prevents
// scheduling a push while the barrier is held).
if (isRestoreInProgress()) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping: a vault restore is in progress in another window.');
return;
}
throw new Error(t('sync.autoSync.restoreInProgress'));
}
// Refuse to auto-push when a previous apply crashed mid-way and
// left the vault in a partial state. `applyProtectedSyncPayload`
// sets a sentinel before its non-atomic localStorage writes and
// clears it on successful completion; the sentinel's presence
// here means the renderer crashed between a first write and the
// clean-up, so the in-memory payload is a mix of pre-apply and
// post-apply entries. Pushing that would silently overwrite an
// intact cloud copy with corrupted data.
//
// Manual triggers surface a user-visible error that points the
// user at the Restore UI; auto triggers return quietly (the
// next startup toast below flags the state).
const interruptedApply = readInterruptedVaultApply();
if (interruptedApply) {
if (trigger === 'auto') {
console.warn(
'[AutoSync] Skipping: previous apply was interrupted — refusing to push partial state.',
interruptedApply,
);
return;
}
throw new Error(t('sync.autoSync.interruptedApplyMessage'));
}
// If another window unlocked, reuse the in-memory session password from main process.
if (state.securityState !== 'UNLOCKED') {
const bridge = netcattyBridge.get();
const sessionPassword = await bridge?.cloudSyncGetSessionPassword?.();
if (sessionPassword) {
const ok = await sync.unlock(sessionPassword);
if (!ok) {
void bridge?.cloudSyncClearSessionPassword?.();
}
}
}
// Re-check after unlock attempt
state = manager.getState();
if (state.securityState !== 'UNLOCKED') {
throw new Error(t('sync.autoSync.vaultLocked'));
}
const dataHash = getDataHash();
const payload = buildPayload();
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length > 0) {
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
throw new Error(t('sync.credentialsUnavailable'));
}
// Refuse to push an empty vault to cloud. This is almost always
// a sign that the local state was lost (update, import failure,
// storage corruption) rather than a deliberate "delete everything".
// Both auto and manual triggers are blocked; the user can still
// use Force Push from the SyncBlocked banner if they genuinely
// want to wipe the cloud.
//
// This pairs with the inspect-failure "fail open" behavior in
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulCloudSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
}
throw new Error(t('sync.autoSync.emptyVaultManual'));
}
const results = await sync.syncNow(payload);
// Apply merged payloads first (before checking for failures) so local
// state gets updated even when some providers failed
const resultList = Array.from(results.values());
const allProvidersSynced = resultList.length > 0
&& resultList.every((result) => result.success);
for (const result of resultList) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
recordDownload: true,
});
}
skipNextSyncRef.current = allProvidersSynced;
if (!allProvidersSynced) {
console.warn('[AutoSync] Remote payload applied locally, but not every provider synced; leaving next auto-sync enabled for retry.');
}
break; // All providers share the same merged payload
}
}
for (const result of resultList) {
if (!result.success) {
if (result.conflictDetected) {
throw new Error(t('sync.autoSync.conflictDetected'));
}
throw new Error(result.error || t('sync.autoSync.syncFailed'));
}
}
lastSyncedDataRef.current = dataHash;
// Successful sync implies a successful per-provider
// `checkProviderConflict` (which inspects remote) — equivalent
// to a successful startup reconciliation from the auto-sync
// gate's point of view. Opening the gate here is the escape
// hatch when a network outage exhausted the startup retry
// timer: a user-triggered manual sync (or any first successful
// auto sync that somehow ran anyway) resumes auto-sync for the
// rest of the session. Without this, a degraded-startup session
// would require the user to manually sync after every edit.
hasCheckedRemoteRef.current = true;
remoteCheckDoneRef.current = true;
} catch (error) {
if (trigger === 'manual') {
throw error;
}
console.error('[AutoSync] Sync failed:', error);
notify.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
} finally {
isSyncRunningRef.current = false;
}
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
// One-shot toast per mount when a previous apply was interrupted, so the
// user understands why auto-sync is silently paused and where to go to
// recover. `applyProtectedSyncPayload` clears the sentinel on a clean
// apply, so this only fires once per genuine crash and naturally stops
// after the user completes a recovery.
const interruptedApplyNotifiedRef = useRef(false);
useEffect(() => {
if (interruptedApplyNotifiedRef.current) return;
if (!sync.isUnlocked) return;
const interrupted = readInterruptedVaultApply();
if (!interrupted) return;
interruptedApplyNotifiedRef.current = true;
notify.error(
t('sync.autoSync.interruptedApplyMessage'),
t('sync.autoSync.interruptedApplyTitle'),
);
}, [sync.isUnlocked, t]);
// Stabilize the fields `checkRemoteVersion` reads from `config`.
// AutoSyncConfig is a fresh object literal on every App render, so a
// naive `config` dep would rebuild `checkRemoteVersion`'s identity on
// every unrelated state change — re-firing the retry effect with
// `attempt=0` and spawning overlapping in-flight inspections. The
// refs below let `checkRemoteVersion` read the latest callback and
// readiness flag without pulling the object identity into deps.
const onApplyPayloadRef = useRef(config.onApplyPayload);
useEffect(() => {
onApplyPayloadRef.current = config.onApplyPayload;
}, [config.onApplyPayload]);
const startupReadyRef = useRef(config.startupReady);
useEffect(() => {
startupReadyRef.current = config.startupReady;
}, [config.startupReady]);
// `buildPayload` closes over live React state so its identity flips
// on every vault edit; route it through a ref so `checkRemoteVersion`
// can read the latest builder without churning its memo identity.
const buildPayloadRef = useRef(buildPayload);
useEffect(() => {
buildPayloadRef.current = buildPayload;
}, [buildPayload]);
const getDataHashRef = useRef(getDataHash);
useEffect(() => {
getDataHashRef.current = getDataHash;
}, [getDataHash]);
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
// could both write-then-clear the apply-in-progress sentinel around
// interleaved applies, and both could push post-merge snapshots to
// remote. The cross-window `withRestoreBarrier` protects other
// windows but does NOT serialize same-window re-entry, so this
// in-flight guard closes that gap at the top of the call.
const checkRemoteInFlightRef = useRef(false);
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
if (checkRemoteInFlightRef.current) {
return;
}
const force = options?.force === true;
const notifyOnFailure = options?.notifyOnFailure !== false;
const state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
return;
}
// Find connected provider BEFORE acquiring the in-flight lock so the
// "nothing to check" early return doesn't leak the lock and wedge
// the retry timer. Any path that takes the lock MUST reach the
// finally-release below.
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
isProviderReadyForSync(state.providers[provider]),
) ?? null;
if (!connectedProvider) {
// Nothing to check — mark as done so the auto-sync gate opens.
remoteCheckDoneRef.current = true;
return;
}
checkRemoteInFlightRef.current = true;
// Track whether the startup path completed in a state where the anchor/base
// are consistent with the local vault. Only then should we latch
// hasCheckedRemoteRef so that transient failures are retryable.
let startupConsistent = false;
let markCurrentDataSynced = true;
try {
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
const base = await manager.loadSyncBase(connectedProvider);
const inspection = await manager.inspectProviderRemote(connectedProvider);
if (!inspection.payload || !inspection.remoteChanged || !inspection.remoteFile) {
// Remote unchanged (or empty) — no local mutation needed; anchor/base
// are already in sync with remote from a previous run.
startupConsistent = true;
return;
}
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const localPayload = buildPayloadRef.current();
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).
// Pause and ask the user what to do instead of silently merging.
if (shouldPromptCloudVaultRecovery(localPayload, remotePayload)) {
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
emptyVaultResolveRef.current = resolve;
setEmptyVaultConflict({
remotePayload,
hostCount: remotePayload.hosts?.length ?? 0,
keyCount: remotePayload.keys?.length ?? 0,
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
});
});
setEmptyVaultConflict(null);
emptyVaultResolveRef.current = null;
if (userAction === 'restore') {
// Apply remote FIRST; only commit anchor/base after the UI-side
// state has accepted the remote payload, otherwise a failure
// between commit and apply would leave the anchor pointing at
// remote while local is still empty — the exact overwrite window
// we're trying to close.
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload, {
recordDownload: true,
});
skipNextSyncRef.current = true;
startupConsistent = true;
notify.success(tRef.current('sync.autoSync.restoredMessage'), tRef.current('sync.autoSync.restoredTitle'));
} else {
// User chose to keep the empty vault. Deliberately do NOT advance
// the anchor or base — the next sync must still treat remote as
// "unseen" so the empty-vault-push guard (`hasMeaningfulSyncData`)
// keeps protecting the cloud copy. startupConsistent stays false
// so hasCheckedRemoteRef is not latched and the next startup will
// re-prompt if the user still has not added anything.
notify.info(tRef.current('sync.autoSync.keptLocalMessage'), tRef.current('sync.autoSync.keptLocalTitle'));
}
return;
}
const conflictAction = resolveCloudSyncConflictAction(state.syncStrategy, {
hasConflict: inspection.remoteChanged,
hasRemoteFile: Boolean(inspection.remoteFile),
});
if (conflictAction === 'download-remote') {
// Apply remote FIRST; only commit anchor/base after the UI-side
// state has accepted the remote payload, matching the empty-vault
// restore ordering above.
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload, {
recordDownload: true,
});
startupConsistent = true;
markCurrentDataSynced = false;
const roundTripResults = await manager.syncAllProviders(remotePayload, {
conflictActionOverride: 'upload-local',
});
const roundTripResultList = Array.from(roundTripResults.values());
const wasShrinkBlocked = roundTripResultList.some((result) => result.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
if (wasShrinkBlocked) {
console.warn('[AutoSync] Cloud-wins round-trip was shrink-blocked; cloud data applied locally, leaving sync blocked for user review.');
} else if (!roundTripFullySynced) {
console.warn('[AutoSync] Cloud-wins round-trip did not update every provider; leaving next auto-sync enabled for retry.');
}
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
return;
}
if (conflictAction === 'upload-local') {
const pushResults = await manager.syncAllProviders(localPayload);
const results = Array.from(pushResults.values());
const allProvidersSynced = results.length > 0
&& results.every((result) => result.success);
const wasShrinkBlocked = results.some((result) => result.shrinkBlocked === true);
if (allProvidersSynced) {
startupConsistent = true;
return;
}
if (wasShrinkBlocked) {
return;
}
throw new Error('Startup local-wins sync failed for one or more providers');
}
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
// Apply merged payload to local state BEFORE committing. If the apply
// throws, the next startup will re-run the merge with fresh data.
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
// stores remotePayload as the base so the next diff is computed
// against what the cloud actually has, not against the merged
// local-only state.
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
startupConsistent = true;
markCurrentDataSynced = false;
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
// If the three-way merge introduced any local-only additions that the
// remote does not yet have, we MUST round-trip those to the cloud.
// Previously this branch stopped after applying merge locally, so the
// merged-in additions lived only on the device that ran the merge
// until the user's next edit.
//
// We push the merged payload *directly* through the manager rather
// than going through the React-state-driven `syncNow`. syncNow
// rebuilds the payload from hooks state, which may not yet reflect
// the onApplyPayload we awaited above (React commit phase is async
// relative to the awaited promise resolution). Passing mergeResult
// in explicitly removes the race entirely and avoids a setTimeout(0)
// that only approximated the correct ordering.
if (mergeResult.payload) {
try {
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
const roundTripResultList = Array.from(roundTripResults.values());
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
if (wasShrinkBlocked) {
// The merged payload is already applied locally and is the source of truth
// for THIS device. The blocking only prevents pushing it to cloud, which
// is acceptable here — the next user-edit-triggered sync will re-check
// (and the user can also force-push from the Settings banner if they
// navigate there). Reset syncState so we don't leave the manager wedged
// in BLOCKED with no banner visible.
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
manager.clearShrinkBlockedState();
} else if (!roundTripFullySynced) {
console.warn('[AutoSync] Post-merge round-trip did not update every provider; leaving next auto-sync enabled for retry.');
}
// Suppress the debounced follow-up tick that otherwise fires
// once React commits the applied state, since we've just
// already pushed that exact payload upstream. If some provider
// failed, allow the follow-up tick to retry the applied payload.
skipNextSyncRef.current = roundTripFullySynced || wasShrinkBlocked;
markCurrentDataSynced = roundTripFullySynced || wasShrinkBlocked;
} catch (error) {
// Non-fatal: the next user edit will drive another sync cycle.
console.warn('[AutoSync] Post-merge round-trip push failed:', error);
}
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
if (notifyOnFailure && !inspectFailureToastShownRef.current) {
// Surface a degraded-sync hint once per session. Retries and
// incidental re-triggers (e.g. effect restarts) must not spam toasts.
inspectFailureToastShownRef.current = true;
notify.error(
tRef.current('sync.autoSync.inspectFailedMessage'),
tRef.current('sync.autoSync.inspectFailedTitle'),
);
}
// Leave hasCheckedRemoteRef=false so the next startup (or the next
// provider/unlock transition) can retry.
} finally {
if (startupConsistent) {
if (!isInitializedRef.current) {
isInitializedRef.current = true;
}
if (markCurrentDataSynced) {
lastSyncedDataRef.current = getDataHashRef.current();
} else {
lastSyncedDataRef.current = '';
}
hasCheckedRemoteRef.current = true;
// Only open the auto-sync gate when the inspect actually
// validated the remote state. Leaving the gate closed on
// inspect failure is intentional: an edit made during a
// degraded startup must not race ahead and push a partially-
// hydrated vault over an intact remote. The retry effect
// below re-fires checkRemoteVersion on the next provider/
// unlock/startupReady transition, and a manual sync from
// Settings remains available as an escape hatch.
remoteCheckDoneRef.current = true;
}
checkRemoteInFlightRef.current = false;
}
// Intentionally minimal deps: `buildPayload`, `config.onApplyPayload`,
// and `config.startupReady` are read through refs above so their
// identity flips (every vault edit produces a fresh `buildPayload`
// and a fresh AutoSyncConfig literal) cannot re-memoize this
// callback and restart the retry-timer's exponential backoff.
// `t` is read through tRef so locale updates don't rebuild this
// callback and re-fire the startup retry effect on unrelated renders.
}, []);
const checkRemoteVersionRef = useRef(checkRemoteVersion);
useEffect(() => {
checkRemoteVersionRef.current = checkRemoteVersion;
}, [checkRemoteVersion]);
// Debounced auto-sync when data changes
useEffect(() => {
// Skip if not ready
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
// Don't auto-sync until the startup remote check has completed.
// Without this gate, an empty local vault can push to the cloud
// before checkRemoteVersion even runs, overwriting a non-empty
// remote vault — the exact bug described in #679.
if (!remoteCheckDoneRef.current) {
return;
}
// Skip initial render
if (!isInitializedRef.current) {
isInitializedRef.current = true;
lastSyncedDataRef.current = getDataHash();
return;
}
const currentHash = getDataHash();
// After a merge, onApplyPayload changes local state which triggers
// this effect. Skip that cycle and just update the hash baseline.
if (skipNextSyncRef.current) {
skipNextSyncRef.current = false;
lastSyncedDataRef.current = currentHash;
return;
}
// Skip if data hasn't changed
if (currentHash === lastSyncedDataRef.current) {
return;
}
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}
// Hold off on scheduling a new push while another window is applying
// a restore — the restore is about to land via localStorage and the
// debounce-fired syncNow would otherwise race it. The next data-
// change tick after the restore barrier clears will re-enter here.
if (isRestoreInProgress()) {
return;
}
// Don't even schedule a push while the apply-in-progress sentinel
// is held. The syncNow path re-checks and refuses too, but dropping
// the debounced schedule here avoids spinning a 3-second timer for
// every keystroke while the user is in the Restore UI working
// through recovery.
if (readInterruptedVaultApply()) {
return;
}
// Clear existing timeout
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// Debounce sync by 3 seconds
syncTimeoutRef.current = setTimeout(() => {
syncNow();
}, 3000);
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
}, [
sync.hasAnyConnectedProvider,
sync.autoSyncEnabled,
sync.isUnlocked,
sync.isSyncing,
getDataHash,
syncNow,
config.settingsVersion,
bookmarksVersion,
syncableSettingsStorageVersion,
]);
// Check remote version on startup/unlock, then retry with backoff
// while the inspect keeps failing. Without the timer-based retry,
// a failure that doesn't coincide with a dep change would wedge the
// auto-sync gate closed until the user restarts or manually triggers
// sync from Settings — the 30s/60s/90s cadence below lets a short
// outage (network blip, provider rate-limit) self-heal.
useEffect(() => {
if (
!sync.hasAnyConnectedProvider ||
!sync.isUnlocked ||
hasCheckedRemoteRef.current ||
config.startupReady === false
) {
return;
}
let cancelled = false;
let attempt = 0;
let timerId: NodeJS.Timeout | null = null;
const tick = () => {
if (cancelled) return;
void (async () => {
const notifyOnFailure = attempt === 0;
await checkRemoteVersionRef.current(
notifyOnFailure ? undefined : { notifyOnFailure: false },
);
if (cancelled || hasCheckedRemoteRef.current) return;
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
// persistent failure beyond that is almost certainly a
// misconfiguration that needs user action rather than more
// auto-retries.
//
// When retries exhaust we deliberately leave the auto-sync gate
// CLOSED. Opening it here would allow a partially-lost local
// vault to silently clobber an unchanged remote: anchor still
// matches, `checkProviderConflict` sees no remote change,
// `hasMeaningfulSyncData` doesn't flag non-empty-but-partial
// local, and the empty-vault prompt never fires.
//
// Escape hatch: a successful manual sync from Settings opens
// the gate via `syncNow`'s success path. That path runs the
// same per-provider inspect we use here, so a successful
// manual sync is equivalent to a successful startup inspect
// from the gate's point of view — the user's explicit click
// authorizes both the push and the subsequent auto-sync
// resumption. Until then, auto-sync stays paused and the
// "sync paused" toast is the user's signal to act.
if (attempt >= 4) return;
const delayMs = Math.min(240_000, 30_000 * 2 ** attempt);
attempt += 1;
timerId = setTimeout(tick, delayMs);
})();
};
tick();
return () => {
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
const now = Date.now();
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
if (!shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
isUnlocked: sync.isUnlocked,
startupRemoteCheckDone: remoteCheckDoneRef.current,
isSyncing: sync.isSyncing,
isSyncRunning: isSyncRunningRef.current,
remoteCheckInFlight: checkRemoteInFlightRef.current,
force: options?.force === true,
now,
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
minIntervalMs,
})) {
return;
}
lastRuntimeRemoteCheckAtRef.current = now;
await checkRemoteVersion({ force: true, notifyOnFailure: false });
}, [
checkRemoteVersion,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isSyncing,
sync.isUnlocked,
]);
// Keep checking the cloud while the app is open. This closes the gap where
// another device uploads changes after our startup inspection but before
// this device edits anything locally.
useEffect(() => {
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
const timerId = window.setInterval(() => {
void runRuntimeRemoteCheck();
}, intervalMs);
return () => window.clearInterval(timerId);
}, [
runRuntimeRemoteCheck,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isUnlocked,
]);
// Also re-check when the user returns to the app or the network comes back.
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void runRuntimeRemoteCheck({ force: true });
}
};
const handleOnline = () => {
void runRuntimeRemoteCheck({ force: true });
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('online', handleOnline);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('online', handleOnline);
};
}, [runRuntimeRemoteCheck]);
// Reset check flags when provider disconnects
useEffect(() => {
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
lastRuntimeRemoteCheckAtRef.current = null;
}
}, [sync.hasAnyConnectedProvider]);
// On unmount, release any pending empty-vault confirmation. Without
// this, an unmount mid-dialog (window close, workspace switch) leaves
// the resolver promise dangling forever and the `checkRemoteVersion`
// finally block never sets remoteCheckDoneRef — in practice React
// tears down the hook first, but leaking the resolve callback and
// referenced remotePayload keeps them pinned by the awaiter until
// the next reload. Resolving with 'keep-empty' is the safe default:
// it mirrors the "don't touch remote" choice and leaves the version
// stamp untouched so the next mount re-prompts.
useEffect(() => {
return () => {
const resolve = emptyVaultResolveRef.current;
if (resolve) {
emptyVaultResolveRef.current = null;
resolve('keep-empty');
}
};
}, []);
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
// Guard: resolve only once (prevents double-click from entering an
// inconsistent state). The ref is nulled immediately so subsequent
// calls are no-ops.
const resolve = emptyVaultResolveRef.current;
if (!resolve) return;
emptyVaultResolveRef.current = null;
resolve(action);
}, []);
return {
syncNow,
buildPayload,
isSyncing: sync.isSyncing,
isConnected: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
emptyVaultConflict,
resolveEmptyVaultConflict,
};
};
export default useAutoSync;