From 608da07bc4ffe36907686aaaa11d815d28715f73 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 1 Mar 2026 19:58:09 +0100 Subject: [PATCH] Tweak browser extension sync queue --- .../background/VaultMessageHandler.ts | 61 +++++++++++---- .../entrypoints/popup/hooks/useVaultMutate.ts | 77 ++++++++++++++++--- .../entrypoints/popup/hooks/useVaultSync.ts | 1 - 3 files changed, 114 insertions(+), 25 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts index e019c5331..9a8ce106d 100644 --- a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts @@ -44,6 +44,13 @@ import { t } from '@/i18n/StandaloneI18n'; let cachedSqliteClient: SqliteClient | null = null; let cachedVaultBlob: string | null = null; +/** + * Global sync queue state. + * Prevents multiple simultaneous sync operations and ensures pending changes are synced. + */ +let isSyncInProgress = false; +let hasPendingSync = false; + /** * Check if the user is logged in and if the vault is locked, and also check for pending migrations. */ @@ -671,7 +678,15 @@ export async function handleUploadVault( }; } catch (error) { console.error('Failed to upload vault:', error); - // E-801: Upload failed + + // Check if error is UPLOAD_OUTDATED (E-802) - server has newer vault + const errorMessage = error instanceof Error ? error.message : ''; + if (errorMessage.includes('E-802')) { + // Return status 2 (Outdated) so caller can handle merge + return { success: false, status: 2, error: errorMessage }; + } + + // E-801: Upload failed for other reasons return { success: false, error: formatErrorWithCode(await t('common.errors.unknownError'), AppErrorCode.UPLOAD_FAILED) }; } } @@ -956,6 +971,7 @@ export async function handleGetSyncState(): Promise<{ isDirty: boolean; mutationSequence: number; serverRevision: number; + isSyncInProgress: boolean; }> { const [isDirty, mutationSequence, serverRevision] = await Promise.all([ storage.getItem('local:isDirty') as Promise, @@ -966,7 +982,8 @@ export async function handleGetSyncState(): Promise<{ return { isDirty: isDirty ?? false, mutationSequence: mutationSequence ?? 0, - serverRevision: serverRevision ?? 0 + serverRevision: serverRevision ?? 0, + isSyncInProgress }; } @@ -1094,19 +1111,20 @@ export async function handleCheckSyncStatus(): Promise { /** * Full vault sync orchestration that runs entirely in background context. * This ensures sync completes even if popup closes mid-operation. - * - * Sync logic: - * - If server has newer vault AND we have local changes (isDirty) → merge then upload - * - If server has newer vault AND no local changes → just download - * - If server has same revision AND we have local changes → upload - * - If offline → keep local changes, sync later - * - * Race detection: - * - Upload captures mutationSequence at start - * - After upload, only clears isDirty if sequence unchanged - * - If sequence changed during upload, stays dirty for next sync */ export async function handleFullVaultSync(): Promise { + // Check if sync is already in progress + if (isSyncInProgress) { + // Mark that we need to sync again after current sync completes + hasPendingSync = true; + console.info('[VaultSync] Sync already in progress, queued for retry after completion'); + return { success: true, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false }; + } + + // Mark sync as in progress + isSyncInProgress = true; + hasPendingSync = false; + const webApi = new WebApiService(); try { @@ -1380,6 +1398,23 @@ export async function handleFullVaultSync(): Promise { ? baseMessage : formatErrorWithCode(baseMessage, AppErrorCode.UNKNOWN_ERROR); return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false, error: errorMessage }; + } finally { + // Reset sync in progress flag + isSyncInProgress = false; + + /* + * Check if another sync is needed (mutations happened during this sync). + * Trigger follow-up sync asynchronously (don't await to avoid blocking). + * Popup will poll isDirty flag to detect when background sync completes. + */ + if (hasPendingSync) { + console.info('[VaultSync] Pending mutations detected, triggering follow-up sync'); + hasPendingSync = false; + + handleFullVaultSync().catch(err => { + console.error('[VaultSync] Follow-up sync failed:', err); + }); + } } } diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts index 112ecd543..86966a1a1 100644 --- a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { sendMessage } from 'webext-bridge/popup'; import type { FullVaultSyncResult } from '@/entrypoints/background/VaultMessageHandler'; @@ -26,6 +26,7 @@ export function useVaultMutate(): { executeVaultMutationAsync: (operation: () => Promise) => Promise; } { const dbContext = useDb(); + const pollIntervalRef = useRef(null); /** * Execute the provided operation and save locally. @@ -53,13 +54,66 @@ export function useVaultMutate(): { await dbContext.refreshSyncState(); }, [dbContext]); + /** + * Start polling to detect when background sync completes. + * Polls isDirty flag AND background sync state every 500ms. + * Only clears indicator when vault is clean AND background has no sync in progress. + */ + const startPollingForCompletion = useCallback((): void => { + /* Clear any existing poll interval */ + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + + console.info('[VaultMutate] Starting to poll for sync completion'); + + pollIntervalRef.current = setInterval(async () => { + try { + /* + * Get sync state from background - includes both isDirty flag + * and isSyncInProgress status from the background script + */ + const syncState = await sendMessage('GET_SYNC_STATE', {}, 'background') as { + isDirty: boolean; + isSyncInProgress: boolean; + mutationSequence: number; + serverRevision: number; + }; + + /* + * Only clear uploading indicator when: + * 1. Vault is not dirty (no pending changes) + * 2. Background has no sync in progress (no queued syncs running) + * + * This prevents clearing the indicator between queued syncs. + */ + if (!syncState.isDirty && !syncState.isSyncInProgress) { + console.info('[VaultMutate] Sync completed (isDirty=false, isSyncInProgress=false), clearing uploading indicator'); + dbContext.setIsUploading(false); + dbContext.setIsSyncing(false); + + /* Refresh React state to match storage */ + await dbContext.refreshSyncState(); + + /* Stop polling */ + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + } + } catch (error) { + console.error('[VaultMutate] Error polling sync state:', error); + } + }, 500); /* Poll every 500ms */ + }, [dbContext]); + /** * Trigger a sync in the background script. * This is fire-and-forget - the sync runs entirely in the background context * and continues even if the popup closes. * - * If a merge happened during sync (hasNewVault=true), reload the database - * so the popup shows the merged data. + * Always polls to detect completion since background sync may queue additional + * syncs that we cannot directly observe from the popup context. */ const triggerBackgroundSync = useCallback((): void => { dbContext.setIsUploading(true); @@ -68,20 +122,21 @@ export function useVaultMutate(): { * Fire-and-forget: send message to background without awaiting. * The background script will handle the full sync orchestration * and will re-sync if mutations happened during the sync. + * + * After sending message, we start polling to detect completion. */ - sendMessage('FULL_VAULT_SYNC', {}, 'background').then(async (result: FullVaultSyncResult) => { - // If a merge happened, reload the database to show merged data - if (result.hasNewVault) { + void sendMessage('FULL_VAULT_SYNC', {}, 'background').then(async (result) => { + const syncResult = result as FullVaultSyncResult; + if (syncResult.hasNewVault) { await dbContext.loadStoredDatabase(); } - // Refresh sync state if popup is still open - await dbContext.refreshSyncState(); }).catch((error) => { console.error('Background sync error:', error); - }).finally(() => { - dbContext.setIsUploading(false); }); - }, [dbContext]); + + // Start polling for completion + startPollingForCompletion(); + }, [dbContext, startPollingForCompletion]); /** * Execute a vault mutation asynchronously: save locally immediately, then diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts index 43da712f8..0f9ea6f51 100644 --- a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts @@ -124,7 +124,6 @@ export const useVaultSync = (): { syncVault: (options?: VaultSyncOptions) => Pro } finally { // Always clear syncing/uploading states when done dbContext.setIsSyncing(false); - dbContext.setIsUploading(false); } }, [app, dbContext, t]);