From d47341d442b347fe379c1bee621ef1c0ae61bcbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 4 Mar 2026 16:34:00 +0000 Subject: [PATCH 01/21] Clear sync-related Redux state when user logs out Dispatch a shared userLoggedOut action on logout and invalid token detection that resets sync slices, cancels active operations, stops pollers, and clears authenticated RTK Query caches. --- apps/studio/src/components/auth-provider.tsx | 10 +++-- apps/studio/src/stores/auth-actions.ts | 3 ++ apps/studio/src/stores/index.ts | 37 +++++++++++++++++++ .../studio/src/stores/sync/connected-sites.ts | 4 ++ .../src/stores/sync/sync-operations-slice.ts | 2 + apps/studio/src/stores/sync/sync-slice.ts | 2 + 6 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 apps/studio/src/stores/auth-actions.ts diff --git a/apps/studio/src/components/auth-provider.tsx b/apps/studio/src/components/auth-provider.tsx index 1c92f0c48c..015fabc67b 100644 --- a/apps/studio/src/components/auth-provider.tsx +++ b/apps/studio/src/components/auth-provider.tsx @@ -8,7 +8,8 @@ import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error'; -import { useI18nLocale } from 'src/stores'; +import { useAppDispatch, useI18nLocale } from 'src/stores'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { setWpcomClient } from 'src/stores/wpcom-api'; export interface AuthContextType { @@ -45,11 +46,13 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { const { __ } = useI18n(); const isOffline = useOffline(); + const dispatch = useAppDispatch(); const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); const handleInvalidToken = useCallback( async () => { try { void getIpcApi().logRendererMessage( 'info', 'Detected invalid token. Logging out.' ); + dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); setClient( undefined ); @@ -59,7 +62,7 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { console.error( 'Failed to handle invalid token:', err ); Sentry.captureException( err ); } - }, [] ); + }, [ dispatch ] ); useIpcListener( 'auth-updated', ( _event, payload ) => { if ( 'error' in payload ) { @@ -110,6 +113,7 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { } try { + dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); setClient( undefined ); @@ -119,7 +123,7 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { console.error( err ); Sentry.captureException( err ); } - }, [ client, isOffline ] ); + }, [ client, dispatch, isOffline ] ); useEffect( () => { async function run() { diff --git a/apps/studio/src/stores/auth-actions.ts b/apps/studio/src/stores/auth-actions.ts new file mode 100644 index 0000000000..bb746957ae --- /dev/null +++ b/apps/studio/src/stores/auth-actions.ts @@ -0,0 +1,3 @@ +import { createAction } from '@reduxjs/toolkit'; + +export const userLoggedOut = createAction( 'auth/userLoggedOut' ); diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 1c23ef38ae..05e994511a 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -14,6 +14,7 @@ import { } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { appVersionApi } from 'src/stores/app-version-api'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { betaFeaturesReducer, loadBetaFeatures } from 'src/stores/beta-features-slice'; import { certificateTrustApi } from 'src/stores/certificate-trust-api'; import { reducer as chatReducer } from 'src/stores/chat-slice'; @@ -184,6 +185,42 @@ startAppListening( { }, } ); +// Clear all sync state when user logs out +startAppListening( { + actionCreator: userLoggedOut, + effect( action, listenerApi ) { + const state = listenerApi.getState(); + + // Collect all operation IDs from state and pollers + const allStateIds = new Set( [ + ...Object.keys( state.syncOperations.pushStates ), + ...Object.keys( state.syncOperations.pullStates ), + ...PUSH_POLLERS.keys(), + ...PULL_POLLERS.keys(), + ] ); + + // Cancel main process operations + for ( const stateId of allStateIds ) { + getIpcApi().cancelSyncOperation( stateId ); + } + + // Stop renderer-side pollers + for ( const controller of PUSH_POLLERS.values() ) { + controller.abort(); + } + PUSH_POLLERS.clear(); + for ( const controller of PULL_POLLERS.values() ) { + controller.abort(); + } + PULL_POLLERS.clear(); + + // Reset authenticated RTK Query caches + listenerApi.dispatch( connectedSitesApi.util.resetApiState() ); + listenerApi.dispatch( wpcomSitesApi.util.resetApiState() ); + listenerApi.dispatch( wpcomApi.util.resetApiState() ); + }, +} ); + const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; const SYNC_POLLING_INTERVAL = 3000; diff --git a/apps/studio/src/stores/sync/connected-sites.ts b/apps/studio/src/stores/sync/connected-sites.ts index 6b2796eba1..8640ea14ec 100644 --- a/apps/studio/src/stores/sync/connected-sites.ts +++ b/apps/studio/src/stores/sync/connected-sites.ts @@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { RootState } from 'src/stores'; +import { userLoggedOut } from 'src/stores/auth-actions'; import type { SyncSite, SyncModalMode } from 'src/modules/sync/types'; type ConnectedSitesState = { @@ -42,6 +43,9 @@ const connectedSitesSlice = createSlice( { state.selectedRemoteSiteId = null; }, }, + extraReducers: ( builder ) => { + builder.addCase( userLoggedOut, () => getInitialState() ); + }, } ); export const connectedSitesActions = connectedSitesSlice.actions; diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index 25bc312bcd..8b93341006 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -8,6 +8,7 @@ import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; import { store } from 'src/stores'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { connectedSitesApi } from 'src/stores/sync/connected-sites'; import type { PullStateProgressInfo, @@ -155,6 +156,7 @@ const syncOperationsSlice = createSlice( { }, extraReducers: ( builder ) => { builder + .addCase( userLoggedOut, () => initialState ) .addCase( pushSiteThunk.rejected, ( state, action ) => { const { connectedSite, selectedSite } = action.meta.arg; const stateId = generateStateId( selectedSite.id, connectedSite.id ); diff --git a/apps/studio/src/stores/sync/sync-slice.ts b/apps/studio/src/stores/sync/sync-slice.ts index cc450453c8..7abe56e033 100644 --- a/apps/studio/src/stores/sync/sync-slice.ts +++ b/apps/studio/src/stores/sync/sync-slice.ts @@ -1,5 +1,6 @@ import { createSlice } from '@reduxjs/toolkit'; import { TreeNode } from 'src/components/tree-view'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { fetchRemoteFileTree } from './sync-api'; type RemoteFileTreeState = { @@ -33,6 +34,7 @@ const syncSlice = createSlice( { }, extraReducers: ( builder ) => { builder + .addCase( userLoggedOut, () => initialState ) .addCase( fetchRemoteFileTree.pending, ( state ) => { state.remoteFileTrees.loading = true; state.remoteFileTrees.error = null; From c680af74f2c6cc43eba1ef8fbfad7620d8ac360a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 13 Mar 2026 12:42:30 +0000 Subject: [PATCH 02/21] Treat failed import during sync pull as a pull error When importSite fails during a sync pull, the pull Redux state stays at 'importing' while the import context fires an error event. Since canCancelPull only allows cancellation for 'in-progress' and 'downloading' states, the user gets stuck with a disabled dismiss button. Add an isError flag to ImportProgressState and set it on import errors. In the sync UI, detect when a pull is in 'importing' state but the import has failed, and treat it as a pull error so the user can dismiss it and retry. --- apps/studio/src/hooks/use-import-export.tsx | 2 ++ .../modules/sync/components/sync-connected-sites.tsx | 12 ++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/studio/src/hooks/use-import-export.tsx b/apps/studio/src/hooks/use-import-export.tsx index af7c321726..75f9e89a1a 100644 --- a/apps/studio/src/hooks/use-import-export.tsx +++ b/apps/studio/src/hooks/use-import-export.tsx @@ -26,6 +26,7 @@ export type ImportProgressState = { statusMessage: string; progress: number; isNewSite?: boolean; + isError?: boolean; }; }; @@ -339,6 +340,7 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode [ siteId ]: { ...currentProgress, statusMessage: __( 'Import failed. Please try again.' ), + isError: true, }, } ) ); break; diff --git a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx index 69c58ecca6..9c0dc98c88 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -221,11 +221,15 @@ const SyncConnectedSitesSectionItem = ( { const sitePullState = useRootSelector( syncOperationsSelectors.selectPullState( selectedSite.id, connectedSite.id ) ); + const pullImportFailed = + sitePullState?.status.key === 'importing' && + importState[ connectedSite.localSiteId ]?.isError === true; const isPulling = - sitePullState?.status.key === 'in-progress' || - sitePullState?.status.key === 'downloading' || - sitePullState?.status.key === 'importing'; - const isPullError = sitePullState?.status.key === 'failed'; + ( sitePullState?.status.key === 'in-progress' || + sitePullState?.status.key === 'downloading' || + sitePullState?.status.key === 'importing' ) && + ! pullImportFailed; + const isPullError = sitePullState?.status.key === 'failed' || pullImportFailed; const hasPullFinished = sitePullState?.status.key === 'finished'; const hasPullCancelled = sitePullState?.status.key === 'cancelled'; const pullImportState = importState[ connectedSite.localSiteId ]; From e667920b78cb0a30bdf1662a731255393c028cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 13 Mar 2026 12:45:48 +0000 Subject: [PATCH 03/21] Simplify logout cleanup in ImportExportProvider to use direct isAuthenticated check --- apps/studio/src/hooks/use-import-export.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/hooks/use-import-export.tsx b/apps/studio/src/hooks/use-import-export.tsx index 75f9e89a1a..7fc57eacf5 100644 --- a/apps/studio/src/hooks/use-import-export.tsx +++ b/apps/studio/src/hooks/use-import-export.tsx @@ -1,7 +1,8 @@ import * as Sentry from '@sentry/electron/renderer'; import { __, sprintf } from '@wordpress/i18n'; -import { createContext, useMemo, useState, useCallback, useContext } from 'react'; +import { createContext, useEffect, useMemo, useState, useCallback, useContext } from 'react'; import { WP_CLI_IMPORT_EXPORT_RESPONSE_TIMEOUT_IN_HRS } from 'src/constants'; +import { useAuth } from 'src/hooks/use-auth'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { simplifyErrorForDisplay } from 'src/lib/error-formatting'; @@ -77,6 +78,14 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode const [ importState, setImportState ] = useState< ImportProgressState >( {} ); const [ exportState, setExportState ] = useState< ExportProgressState >( {} ); const { startServer, stopServer, updateSite } = useSiteDetails(); + const { isAuthenticated } = useAuth(); + + useEffect( () => { + if ( ! isAuthenticated ) { + setImportState( {} ); + setExportState( {} ); + } + }, [ isAuthenticated ] ); const importFile = useCallback( async ( From f8169954e29b265873997540411b0fbecb21d53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 16 Mar 2026 11:56:52 +0000 Subject: [PATCH 04/21] Use cancel thunks for sync cleanup on logout instead of manual cancellation --- apps/studio/src/stores/index.ts | 35 +++++++++---------- .../src/stores/sync/sync-operations-slice.ts | 31 ++++++++++------ 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 05e994511a..c3e3f9841a 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -191,28 +191,25 @@ startAppListening( { effect( action, listenerApi ) { const state = listenerApi.getState(); - // Collect all operation IDs from state and pollers - const allStateIds = new Set( [ - ...Object.keys( state.syncOperations.pushStates ), - ...Object.keys( state.syncOperations.pullStates ), - ...PUSH_POLLERS.keys(), - ...PULL_POLLERS.keys(), - ] ); - - // Cancel main process operations - for ( const stateId of allStateIds ) { - getIpcApi().cancelSyncOperation( stateId ); + for ( const pullState of Object.values( state.syncOperations.pullStates ) ) { + void store.dispatch( + syncOperationsThunks.cancelPull( { + selectedSiteId: pullState.selectedSite.id, + remoteSiteId: pullState.remoteSiteId, + displayNotification: false, + } ) + ); } - // Stop renderer-side pollers - for ( const controller of PUSH_POLLERS.values() ) { - controller.abort(); - } - PUSH_POLLERS.clear(); - for ( const controller of PULL_POLLERS.values() ) { - controller.abort(); + for ( const pushState of Object.values( state.syncOperations.pushStates ) ) { + void store.dispatch( + syncOperationsThunks.cancelPush( { + selectedSiteId: pushState.selectedSite.id, + remoteSiteId: pushState.remoteSiteId, + displayNotification: false, + } ) + ); } - PULL_POLLERS.clear(); // Reset authenticated RTK Query caches listenerApi.dispatch( connectedSitesApi.util.resetApiState() ); diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index e5c1c4efda..6a4d021663 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -272,11 +272,15 @@ const createTypedAsyncThunk = createAsyncThunk.withTypes< { type CancelOperationPayload = { selectedSiteId: string; remoteSiteId: number; + displayNotification?: boolean; }; const cancelPushThunk = createTypedAsyncThunk( 'syncOperations/cancelPush', - async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => { + async ( + { selectedSiteId, remoteSiteId, displayNotification = true }: CancelOperationPayload, + { dispatch } + ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); const abortCallback = PUSH_SITE_ABORT_CALLBACKS.get( operationId ); @@ -291,16 +295,21 @@ const cancelPushThunk = createTypedAsyncThunk( } ) ); - getIpcApi().showNotification( { - title: __( 'Push cancelled' ), - body: __( 'The push operation has been cancelled.' ), - } ); + if ( displayNotification ) { + getIpcApi().showNotification( { + title: __( 'Push cancelled' ), + body: __( 'The push operation has been cancelled.' ), + } ); + } } ); const cancelPullThunk = createTypedAsyncThunk( 'syncOperations/cancelPull', - async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => { + async ( + { selectedSiteId, remoteSiteId, displayNotification = true }: CancelOperationPayload, + { dispatch } + ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); getIpcApi().cancelSyncOperation( operationId ); @@ -318,10 +327,12 @@ const cancelPullThunk = createTypedAsyncThunk( // Ignore errors if file doesn't exist } ); - getIpcApi().showNotification( { - title: __( 'Pull cancelled' ), - body: __( 'The pull operation has been cancelled.' ), - } ); + if ( displayNotification ) { + getIpcApi().showNotification( { + title: __( 'Pull cancelled' ), + body: __( 'The pull operation has been cancelled.' ), + } ); + } } ); From 625fc86ec076d30af7e42cd060ca6391243128bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Thu, 19 Mar 2026 13:46:51 +0000 Subject: [PATCH 05/21] Use getOriginalState() in userLoggedOut listener to fix cancel thunksnot firing --- apps/studio/src/stores/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 81bed1f229..f54c1f91b7 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -187,7 +187,8 @@ startAppListening( { startAppListening( { actionCreator: userLoggedOut, effect( action, listenerApi ) { - const state = listenerApi.getState(); + // Use getOriginalState() to read state BEFORE the reducer cleared pullStates/pushStates + const state = listenerApi.getOriginalState(); for ( const pullState of Object.values( state.syncOperations.pullStates ) ) { void store.dispatch( From 677d49d9756d3407a989d806eccf195d34d85299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Thu, 19 Mar 2026 13:46:55 +0000 Subject: [PATCH 06/21] Clear wpcomClient before dispatching userLoggedOut to prevent error modal --- apps/studio/src/components/auth-provider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/components/auth-provider.tsx b/apps/studio/src/components/auth-provider.tsx index 30c5c30dc5..1a4ecdcd04 100644 --- a/apps/studio/src/components/auth-provider.tsx +++ b/apps/studio/src/components/auth-provider.tsx @@ -52,11 +52,11 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { const handleInvalidToken = useCallback( async () => { try { void getIpcApi().logRendererMessage( 'info', 'Detected invalid token. Logging out.' ); + setWpcomClient( undefined ); dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); setClient( undefined ); - setWpcomClient( undefined ); setUser( undefined ); } catch ( err ) { console.error( 'Failed to handle invalid token:', err ); @@ -113,11 +113,11 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { } try { + setWpcomClient( undefined ); dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); setClient( undefined ); - setWpcomClient( undefined ); setUser( undefined ); } catch ( err ) { console.error( err ); From 34bd922df9817ad702b56f1fbe6d5bbc30a734d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Thu, 19 Mar 2026 14:08:37 +0000 Subject: [PATCH 07/21] keep connected sites cache on logout --- apps/studio/src/stores/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index f54c1f91b7..c7fe37aadf 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -211,7 +211,6 @@ startAppListening( { } // Reset authenticated RTK Query caches - listenerApi.dispatch( connectedSitesApi.util.resetApiState() ); listenerApi.dispatch( wpcomSitesApi.util.resetApiState() ); listenerApi.dispatch( wpcomApi.util.resetApiState() ); }, From 58e5ecf7b73ee74f77654584278f3ebe03856b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Thu, 19 Mar 2026 14:31:41 +0000 Subject: [PATCH 08/21] Abort poller signals in cancel thunks to prevent error modals during logout Extracted PUSH_POLLERS/PULL_POLLERS maps and stop functions into a shared sync-pollers module. Cancel thunks now call stopPushPoller/stopPullPoller directly, so in-flight poll requests see signal.aborted and exit silently instead of showing error modals. --- apps/studio/src/stores/index.ts | 19 ++++++------------ .../src/stores/sync/sync-operations-slice.ts | 3 +++ apps/studio/src/stores/sync/sync-pollers.ts | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 apps/studio/src/stores/sync/sync-pollers.ts diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index c7fe37aadf..a26045a842 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -33,6 +33,12 @@ import { syncOperationsSelectors, syncOperationsThunks, } from 'src/stores/sync/sync-operations-slice'; +import { + PUSH_POLLERS, + PULL_POLLERS, + stopPushPoller, + stopPullPoller, +} from 'src/stores/sync/sync-pollers'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import uiReducer from 'src/stores/ui-slice'; import { getWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; @@ -219,9 +225,6 @@ startAppListening( { const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; const SYNC_POLLING_INTERVAL = 3000; -const PUSH_POLLERS = new Map< string, AbortController >(); -const PULL_POLLERS = new Map< string, AbortController >(); - function isPushPollable( selectedSiteId: string, remoteSiteId: number ) { const pushState = syncOperationsSelectors.selectPushState( selectedSiteId, @@ -238,16 +241,6 @@ function isPullPollable( selectedSiteId: string, remoteSiteId: number ) { return pullState?.status.key === 'in-progress' && !! pullState.backupId; } -function stopPushPoller( stateId: string ) { - PUSH_POLLERS.get( stateId )?.abort(); - PUSH_POLLERS.delete( stateId ); -} - -function stopPullPoller( stateId: string ) { - PULL_POLLERS.get( stateId )?.abort(); - PULL_POLLERS.delete( stateId ); -} - async function startPushPoller( selectedSiteId: string, remoteSiteId: number ) { const stateId = generateStateId( selectedSiteId, remoteSiteId ); if ( PUSH_POLLERS.has( stateId ) ) { diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index 40601bb71e..84fc4a3b2e 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -9,6 +9,7 @@ import { getHostnameFromUrl } from 'src/lib/url-utils'; import { store } from 'src/stores'; import { userLoggedOut } from 'src/stores/auth-actions'; import { connectedSitesApi } from 'src/stores/sync/connected-sites'; +import { stopPullPoller, stopPushPoller } from 'src/stores/sync/sync-pollers'; import type { PullStateProgressInfo, PushStateProgressInfo, @@ -311,6 +312,7 @@ const cancelPushThunk = createTypedAsyncThunk( const operationId = generateStateId( selectedSiteId, remoteSiteId ); const abortCallback = PUSH_SITE_ABORT_CALLBACKS.get( operationId ); + stopPushPoller( operationId ); abortCallback?.(); getIpcApi().cancelSyncOperation( operationId ); @@ -338,6 +340,7 @@ const cancelPullThunk = createTypedAsyncThunk( { dispatch } ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); + stopPullPoller( operationId ); getIpcApi().cancelSyncOperation( operationId ); dispatch( diff --git a/apps/studio/src/stores/sync/sync-pollers.ts b/apps/studio/src/stores/sync/sync-pollers.ts new file mode 100644 index 0000000000..b924ccf181 --- /dev/null +++ b/apps/studio/src/stores/sync/sync-pollers.ts @@ -0,0 +1,20 @@ +/** + * Shared poller abort controllers for sync push/pull operations. + * + * Extracted into its own module so both the poller loops (stores/index.ts) + * and the cancel thunks (sync-operations-slice.ts) can abort in-flight + * poll requests without circular imports. + */ + +export const PUSH_POLLERS = new Map< string, AbortController >(); +export const PULL_POLLERS = new Map< string, AbortController >(); + +export function stopPushPoller( stateId: string ) { + PUSH_POLLERS.get( stateId )?.abort(); + PUSH_POLLERS.delete( stateId ); +} + +export function stopPullPoller( stateId: string ) { + PULL_POLLERS.get( stateId )?.abort(); + PULL_POLLERS.delete( stateId ); +} From e09fa0291f16134ef552448cf8d22422a9b9658c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 20 Mar 2026 16:02:43 +0000 Subject: [PATCH 09/21] Move setWpcomClient into userLoggedOut listener --- apps/studio/src/components/auth-provider.tsx | 2 -- apps/studio/src/stores/index.ts | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/studio/src/components/auth-provider.tsx b/apps/studio/src/components/auth-provider.tsx index 1a4ecdcd04..4f614630e9 100644 --- a/apps/studio/src/components/auth-provider.tsx +++ b/apps/studio/src/components/auth-provider.tsx @@ -52,7 +52,6 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { const handleInvalidToken = useCallback( async () => { try { void getIpcApi().logRendererMessage( 'info', 'Detected invalid token. Logging out.' ); - setWpcomClient( undefined ); dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); @@ -113,7 +112,6 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { } try { - setWpcomClient( undefined ); dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index a26045a842..0eee72d75f 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -41,7 +41,7 @@ import { } from 'src/stores/sync/sync-pollers'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import uiReducer from 'src/stores/ui-slice'; -import { getWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; +import { getWpcomClient, setWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from '@studio/common/lib/locale'; @@ -193,6 +193,8 @@ startAppListening( { startAppListening( { actionCreator: userLoggedOut, effect( action, listenerApi ) { + setWpcomClient( undefined ); + // Use getOriginalState() to read state BEFORE the reducer cleared pullStates/pushStates const state = listenerApi.getOriginalState(); From ad7bc86f091a123fa101870823d773f998d77613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Thu, 9 Apr 2026 13:57:06 +0100 Subject: [PATCH 10/21] Move sync poller logic from stores/index.ts into sync-operations-slice.ts Consolidates all poller infrastructure (AbortController maps, start/stop functions, pollable checks) into the slice alongside the thunks that use them. Pollers are now started explicitly from pushSiteThunk, pullSiteThunk, and initializeSyncStatesThunk instead of reactively via listener middleware. Removes sync-pollers.ts and ~130 lines from stores/index.ts. --- apps/studio/src/stores/index.ts | 136 +----------------- .../src/stores/sync/sync-operations-slice.ts | 114 ++++++++++++++- apps/studio/src/stores/sync/sync-pollers.ts | 20 --- 3 files changed, 116 insertions(+), 154 deletions(-) delete mode 100644 apps/studio/src/stores/sync/sync-pollers.ts diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 28e6b78532..60a1c48de0 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -24,19 +24,14 @@ import { import { syncReducer, syncOperationsActions } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; import { + stopPullPoller, + stopPushPoller, syncOperationsReducer, - syncOperationsSelectors, syncOperationsThunks, } from 'src/stores/sync/sync-operations-slice'; -import { - PUSH_POLLERS, - PULL_POLLERS, - stopPushPoller, - stopPullPoller, -} from 'src/stores/sync/sync-pollers'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import uiReducer from 'src/stores/ui-slice'; -import { getWpcomClient, setWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; +import { setWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from '@studio/common/lib/locale'; @@ -225,131 +220,6 @@ startAppListening( { }, } ); -const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; -const SYNC_POLLING_INTERVAL = 3000; - -function isPushPollable( selectedSiteId: string, remoteSiteId: number ) { - const pushState = syncOperationsSelectors.selectPushState( - selectedSiteId, - remoteSiteId - )( store.getState() ); - return pushState && PUSH_POLLING_KEYS.includes( pushState.status.key ); -} - -function isPullPollable( selectedSiteId: string, remoteSiteId: number ) { - const pullState = syncOperationsSelectors.selectPullState( - selectedSiteId, - remoteSiteId - )( store.getState() ); - return pullState?.status.key === 'in-progress' && !! pullState.backupId; -} - -async function startPushPoller( selectedSiteId: string, remoteSiteId: number ) { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - if ( PUSH_POLLERS.has( stateId ) ) { - return; - } - - const controller = new AbortController(); - PUSH_POLLERS.set( stateId, controller ); - - try { - while ( ! controller.signal.aborted ) { - const client = getWpcomClient(); - if ( ! client ) { - break; - } - - await store.dispatch( - syncOperationsThunks.pollPushProgress( { - client, - signal: controller.signal, - selectedSiteId, - remoteSiteId, - } ) - ); - - if ( controller.signal.aborted || ! isPushPollable( selectedSiteId, remoteSiteId ) ) { - break; - } - - await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) ); - } - } finally { - if ( PUSH_POLLERS.get( stateId ) === controller ) { - PUSH_POLLERS.delete( stateId ); - } - } -} - -async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - if ( PULL_POLLERS.has( stateId ) ) { - return; - } - - const controller = new AbortController(); - PULL_POLLERS.set( stateId, controller ); - - try { - while ( ! controller.signal.aborted ) { - const client = getWpcomClient(); - if ( ! client ) { - break; - } - - await store.dispatch( - syncOperationsThunks.pollPullBackup( { - client, - signal: controller.signal, - selectedSiteId, - remoteSiteId, - } ) - ); - - if ( controller.signal.aborted || ! isPullPollable( selectedSiteId, remoteSiteId ) ) { - break; - } - - await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) ); - } - } finally { - if ( PULL_POLLERS.get( stateId ) === controller ) { - PULL_POLLERS.delete( stateId ); - } - } -} - -// Poll push progress when state enters a pollable status -startAppListening( { - actionCreator: syncOperationsActions.updatePushState, - effect( action ) { - const { selectedSiteId, remoteSiteId } = action.payload; - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - - if ( isPushPollable( selectedSiteId, remoteSiteId ) ) { - void startPushPoller( selectedSiteId, remoteSiteId ); - } else { - stopPushPoller( stateId ); - } - }, -} ); - -// Poll pull backup when state has a backupId and is in-progress -startAppListening( { - actionCreator: syncOperationsActions.updatePullState, - effect( action ) { - const { selectedSiteId, remoteSiteId } = action.payload; - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - - if ( isPullPollable( selectedSiteId, remoteSiteId ) ) { - void startPullPoller( selectedSiteId, remoteSiteId ); - } else { - stopPullPoller( stateId ); - } - }, -} ); - export const rootReducer = combineReducers( { appVersionApi: appVersionApi.reducer, betaFeatures: betaFeaturesReducer, diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index c2f7b72c15..5f516760f7 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -16,7 +16,7 @@ import { getHostnameFromUrl } from 'src/lib/url-utils'; import { store } from 'src/stores'; import { userLoggedOut } from 'src/stores/auth-actions'; import { connectedSitesApi } from 'src/stores/sync/connected-sites'; -import { stopPullPoller, stopPushPoller } from 'src/stores/sync/sync-pollers'; +import { getWpcomClient } from 'src/stores/wpcom-api'; import type { ImportResponse, SyncSite } from '@studio/common/types/sync'; import type { PullStateProgressInfo, @@ -293,6 +293,115 @@ window.ipcListener.subscribe( 'sync-upload-resumed', ( _event, payload ) => { ); } ); +// Poller infrastructure — manages AbortControllers for push/pull polling loops +const PUSH_POLLERS = new Map< string, AbortController >(); +const PULL_POLLERS = new Map< string, AbortController >(); + +export function stopPushPoller( stateId: string ) { + PUSH_POLLERS.get( stateId )?.abort(); + PUSH_POLLERS.delete( stateId ); +} + +export function stopPullPoller( stateId: string ) { + PULL_POLLERS.get( stateId )?.abort(); + PULL_POLLERS.delete( stateId ); +} + +const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; +const SYNC_POLLING_INTERVAL = 3000; + +function isPushPollable( selectedSiteId: string, remoteSiteId: number ) { + const pushState = syncOperationsSelectors.selectPushState( + selectedSiteId, + remoteSiteId + )( store.getState() ); + return pushState && PUSH_POLLING_KEYS.includes( pushState.status.key ); +} + +function isPullPollable( selectedSiteId: string, remoteSiteId: number ) { + const pullState = syncOperationsSelectors.selectPullState( + selectedSiteId, + remoteSiteId + )( store.getState() ); + return pullState?.status.key === 'in-progress' && !! pullState.backupId; +} + +async function startPushPoller( selectedSiteId: string, remoteSiteId: number ) { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + if ( PUSH_POLLERS.has( stateId ) ) { + return; + } + + const controller = new AbortController(); + PUSH_POLLERS.set( stateId, controller ); + + try { + while ( ! controller.signal.aborted ) { + const client = getWpcomClient(); + if ( ! client ) { + break; + } + + await store.dispatch( + pollPushProgressThunk( { + client, + signal: controller.signal, + selectedSiteId, + remoteSiteId, + } ) + ); + + if ( controller.signal.aborted || ! isPushPollable( selectedSiteId, remoteSiteId ) ) { + break; + } + + await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) ); + } + } finally { + if ( PUSH_POLLERS.get( stateId ) === controller ) { + PUSH_POLLERS.delete( stateId ); + } + } +} + +async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + if ( PULL_POLLERS.has( stateId ) ) { + return; + } + + const controller = new AbortController(); + PULL_POLLERS.set( stateId, controller ); + + try { + while ( ! controller.signal.aborted ) { + const client = getWpcomClient(); + if ( ! client ) { + break; + } + + await store.dispatch( + pollPullBackupThunk( { + client, + signal: controller.signal, + selectedSiteId, + remoteSiteId, + } ) + ); + + if ( controller.signal.aborted || ! isPullPollable( selectedSiteId, remoteSiteId ) ) { + break; + } + + await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) ); + } + } finally { + if ( PULL_POLLERS.get( stateId ) === controller ) { + PULL_POLLERS.delete( stateId ); + } + } +} + const createTypedAsyncThunk = createAsyncThunk.withTypes< { state: RootState; dispatch: AppDispatch; @@ -468,6 +577,7 @@ const pushSiteThunk = createTypedAsyncThunk< void, PushSitePayload >( }, } ) ); + void startPushPoller( selectedSite.id, remoteSiteId ); } else { return rejectWithValue( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), @@ -553,6 +663,7 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl }, } ) ); + void startPullPoller( selectedSite.id, remoteSiteId ); return { backupId: response.backup_id, @@ -961,6 +1072,7 @@ export const initializeSyncStatesThunk = createTypedAsyncThunk( }, } ) ); + void startPushPoller( connectedSite.localSiteId, connectedSite.id ); } } catch ( error ) { // Continue checking other sites even if one fails diff --git a/apps/studio/src/stores/sync/sync-pollers.ts b/apps/studio/src/stores/sync/sync-pollers.ts deleted file mode 100644 index b924ccf181..0000000000 --- a/apps/studio/src/stores/sync/sync-pollers.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Shared poller abort controllers for sync push/pull operations. - * - * Extracted into its own module so both the poller loops (stores/index.ts) - * and the cancel thunks (sync-operations-slice.ts) can abort in-flight - * poll requests without circular imports. - */ - -export const PUSH_POLLERS = new Map< string, AbortController >(); -export const PULL_POLLERS = new Map< string, AbortController >(); - -export function stopPushPoller( stateId: string ) { - PUSH_POLLERS.get( stateId )?.abort(); - PUSH_POLLERS.delete( stateId ); -} - -export function stopPullPoller( stateId: string ) { - PULL_POLLERS.get( stateId )?.abort(); - PULL_POLLERS.delete( stateId ); -} From 8e1a594167e2ca7bdb639bed241f30f7ccc7cc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 15 Apr 2026 16:15:05 +0100 Subject: [PATCH 11/21] Avoid setting cancelled state when logging out --- .../src/stores/sync/sync-operations-slice.ts | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index 5f516760f7..8003edd208 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -432,15 +432,17 @@ const cancelPushThunk = createTypedAsyncThunk( abortCallback?.(); getIpcApi().cancelSyncOperation( operationId ); - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId, - remoteSiteId, - state: { status: getPushStatesProgressInfo().cancelled }, - } ) - ); - + // Treat `displayNotification: false` as a "silent cancel" (e.g. logout cleanup): + // abort the underlying operation but avoid creating a persisted "cancelled" UI state. if ( displayNotification ) { + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId, + remoteSiteId, + state: { status: getPushStatesProgressInfo().cancelled }, + } ) + ); + getIpcApi().showNotification( { title: __( 'Push cancelled' ), body: __( 'The push operation has been cancelled.' ), @@ -459,13 +461,16 @@ const cancelPullThunk = createTypedAsyncThunk( stopPullPoller( operationId ); getIpcApi().cancelSyncOperation( operationId ); - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId, - remoteSiteId, - state: { status: getPullStatesProgressInfo().cancelled }, - } ) - ); + // See cancelPushThunk note: skip "cancelled" UI state for silent cancels (logout cleanup). + if ( displayNotification ) { + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { status: getPullStatesProgressInfo().cancelled }, + } ) + ); + } getIpcApi() .removeSyncBackup( remoteSiteId ) From e32c175230716cf156f54bd93f0436fb4477e6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 15 Apr 2026 16:35:36 +0100 Subject: [PATCH 12/21] Enable import/export while push is running remotely --- .../components/content-tab-import-export.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/studio/src/components/content-tab-import-export.tsx b/apps/studio/src/components/content-tab-import-export.tsx index 4d72fef792..51a6d4cabd 100644 --- a/apps/studio/src/components/content-tab-import-export.tsx +++ b/apps/studio/src/components/content-tab-import-export.tsx @@ -17,6 +17,7 @@ import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog'; import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { pushBackupIsUploading } from 'src/lib/active-sync-operations'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useRootSelector } from 'src/stores'; @@ -356,12 +357,18 @@ export function ContentTabImportExport( { selectedSite }: ContentTabImportExport syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state ) ) ); - const isPushing = useRootSelector( ( state ) => - connectedSites.some( ( site ) => - syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( state ) - ) + // Only block import/export while the local machine is actively involved in sync. + // After the backup upload completes, the push continues remotely and should not block import/export. + const isUploadingPushBackup = useRootSelector( ( state ) => + connectedSites.some( ( site ) => { + const pushState = syncOperationsSelectors.selectPushState( + selectedSite.id, + site.id + )( state ); + return pushBackupIsUploading( pushState?.status.key ); + } ) ); - const isThisSiteSyncing = isPulling || isPushing; + const isThisSiteSyncing = isPulling || isUploadingPushBackup; useEffect( () => { getIpcApi() From 1ea5e8f614cede1a2ffc1a00cf395671cafa9e11 Mon Sep 17 00:00:00 2001 From: Conductor Date: Fri, 17 Apr 2026 13:10:08 +0100 Subject: [PATCH 13/21] Saving uncommitted changes before archiving --- apps/studio/__mocks__/electron.ts | 1 + .../components/content-tab-import-export.tsx | 19 +++-- .../src/components/publish-site-button.tsx | 20 +++-- .../src/components/settings-site-menu.tsx | 32 ++++++-- .../components/site-management-actions.tsx | 18 +++-- apps/studio/src/index.ts | 10 ++- apps/studio/src/lib/active-sync-operations.ts | 16 ++++ .../sync/components/sync-connected-sites.tsx | 80 ++++++++++++------- apps/studio/src/tests/index.test.ts | 1 + 9 files changed, 139 insertions(+), 58 deletions(-) diff --git a/apps/studio/__mocks__/electron.ts b/apps/studio/__mocks__/electron.ts index fcd828fcc3..e3082c7878 100644 --- a/apps/studio/__mocks__/electron.ts +++ b/apps/studio/__mocks__/electron.ts @@ -19,6 +19,7 @@ export const app = { on: vi.fn(), off: vi.fn(), setAppLogsPath: vi.fn(), + removeAsDefaultProtocolClient: vi.fn(), setAsDefaultProtocolClient: vi.fn(), enableSandbox: vi.fn(), commandLine: { diff --git a/apps/studio/src/components/content-tab-import-export.tsx b/apps/studio/src/components/content-tab-import-export.tsx index 51a6d4cabd..c41914fb7a 100644 --- a/apps/studio/src/components/content-tab-import-export.tsx +++ b/apps/studio/src/components/content-tab-import-export.tsx @@ -17,7 +17,10 @@ import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog'; import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { pushBackupIsUploading } from 'src/lib/active-sync-operations'; +import { + pullBackupIsDownloadingOrImporting, + pushBackupIsUploading, +} from 'src/lib/active-sync-operations'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useRootSelector } from 'src/stores'; @@ -352,10 +355,14 @@ export function ContentTabImportExport( { selectedSite }: ContentTabImportExport localSiteId: selectedSite.id, userId: user?.id, } ); - const isPulling = useRootSelector( ( state ) => - connectedSites.some( ( site ) => - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state ) - ) + const isPullingLocally = useRootSelector( ( state ) => + connectedSites.some( ( site ) => { + const pullState = syncOperationsSelectors.selectPullState( + selectedSite.id, + site.id + )( state ); + return pullBackupIsDownloadingOrImporting( pullState?.status.key ); + } ) ); // Only block import/export while the local machine is actively involved in sync. // After the backup upload completes, the push continues remotely and should not block import/export. @@ -368,7 +375,7 @@ export function ContentTabImportExport( { selectedSite }: ContentTabImportExport return pushBackupIsUploading( pushState?.status.key ); } ) ); - const isThisSiteSyncing = isPulling || isUploadingPushBackup; + const isThisSiteSyncing = isPullingLocally || isUploadingPushBackup; useEffect( () => { getIpcApi() diff --git a/apps/studio/src/components/publish-site-button.tsx b/apps/studio/src/components/publish-site-button.tsx index 0f049f42df..482220dcf0 100644 --- a/apps/studio/src/components/publish-site-button.tsx +++ b/apps/studio/src/components/publish-site-button.tsx @@ -3,6 +3,10 @@ import { useI18n } from '@wordpress/react-i18n'; import { useCallback } from 'react'; import { useAuth } from 'src/hooks/use-auth'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { + pullBackupIsDownloadingOrImporting, + pushBackupIsUploading, +} from 'src/lib/active-sync-operations'; import { generateCheckoutUrl } from 'src/lib/generate-checkout-url'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ConnectButton } from 'src/modules/sync/components/connect-button'; @@ -18,9 +22,15 @@ export const PublishSiteButton = () => { localSiteId: selectedSite?.id, userId: user?.id, } ); - const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); - const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); - const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; + const isAnySiteDoingLocalSyncWork = useRootSelector( ( state ) => { + const anyPullLocal = Object.values( syncOperationsSelectors.selectPullStates( state ) ).some( + ( pullState ) => pullBackupIsDownloadingOrImporting( pullState.status.key ) + ); + const anyPushLocal = Object.values( syncOperationsSelectors.selectPushStates( state ) ).some( + ( pushState ) => pushBackupIsUploading( pushState.status.key ) + ); + return anyPullLocal || anyPushLocal; + } ); const handlePublishClick = useCallback( () => { if ( ! selectedSite ) return; @@ -36,9 +46,9 @@ export const PublishSiteButton = () => { variant="primary" icon={ cloudUpload } connectSite={ handlePublishClick } - disabled={ isAnySiteSyncing } + disabled={ isAnySiteDoingLocalSyncWork } tooltipText={ - isAnySiteSyncing + isAnySiteDoingLocalSyncWork ? __( 'Another site is syncing. Please wait for the sync to finish before you publish your site.' ) diff --git a/apps/studio/src/components/settings-site-menu.tsx b/apps/studio/src/components/settings-site-menu.tsx index 99a95edde0..3b3bcfe353 100644 --- a/apps/studio/src/components/settings-site-menu.tsx +++ b/apps/studio/src/components/settings-site-menu.tsx @@ -1,5 +1,9 @@ import { MenuItem } from '@wordpress/components'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { + pullBackupIsDownloadingOrImporting, + pushBackupIsUploading, +} from 'src/lib/active-sync-operations'; import { useRootSelector } from 'src/stores'; import { syncOperationsSelectors } from 'src/stores/sync'; @@ -15,18 +19,30 @@ export const SettingsMenuItem = ( { isDestructive = false, }: SettingsMenuItemProps ) => { const { isDeleting, sites, selectedSite } = useSiteDetails(); - const isPulling = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite?.id ?? '' ) - ); - const isPushing = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPushing( selectedSite?.id ?? '' ) - ); + const selectedSiteId = selectedSite?.id; + const isThisSiteDoingLocalSyncWork = useRootSelector( ( state ) => { + if ( ! selectedSiteId ) { + return false; + } + const pullStates = syncOperationsSelectors.selectPullStates( state ); + const pushStates = syncOperationsSelectors.selectPushStates( state ); + const anyPullLocal = Object.values( pullStates ).some( + ( pullState ) => + pullState.selectedSite?.id === selectedSiteId && + pullBackupIsDownloadingOrImporting( pullState.status.key ) + ); + const anyPushLocal = Object.values( pushStates ).some( + ( pushState ) => + pushState.selectedSite?.id === selectedSiteId && + pushBackupIsUploading( pushState.status.key ) + ); + return anyPullLocal || anyPushLocal; + } ); if ( ! selectedSite ) { return null; } - const isThisSiteSyncing = isPulling || isPushing; const isAddingSite = sites.some( ( site ) => site.isAddingSite ); - const isDisabled = isDeleting || isThisSiteSyncing || isAddingSite; + const isDisabled = isDeleting || isThisSiteDoingLocalSyncWork || isAddingSite; return ( { const { __ } = useI18n(); const { isSiteImporting } = useImportExport(); - const isPulling = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite?.id ?? '' ) - ); + const isPullingLocally = useRootSelector( ( state ) => { + if ( ! selectedSite ) { + return false; + } + return Object.values( syncOperationsSelectors.selectPullStates( state ) ).some( + ( pullState ) => + pullState.selectedSite?.id === selectedSite.id && + pullBackupIsDownloadingOrImporting( pullState.status.key ) + ); + } ); if ( ! selectedSite ) { return null; } const isImporting = isSiteImporting( selectedSite.id ); - const disabled = isImporting || isPulling; + const disabled = isImporting || isPullingLocally; let buttonLabelOnDisabled: string = __( 'Importing…' ); - if ( isPulling ) { + if ( isPullingLocally ) { buttonLabelOnDisabled = __( 'Pulling…' ); } diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 810e1810da..23a5140209 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -143,13 +143,15 @@ async function appBoot() { setupUpdates(); + const appArgs = process.argv.length >= 2 ? [ path.resolve( process.argv[ 1 ] ) ] : []; + if ( process.defaultApp ) { - if ( process.argv.length >= 2 ) { - app.setAsDefaultProtocolClient( PROTOCOL_PREFIX, process.execPath, [ - path.resolve( process.argv[ 1 ] ), - ] ); + if ( appArgs.length > 0 ) { + app.removeAsDefaultProtocolClient( PROTOCOL_PREFIX, process.execPath, appArgs ); + app.setAsDefaultProtocolClient( PROTOCOL_PREFIX, process.execPath, appArgs ); } } else { + app.removeAsDefaultProtocolClient( PROTOCOL_PREFIX ); app.setAsDefaultProtocolClient( PROTOCOL_PREFIX ); } diff --git a/apps/studio/src/lib/active-sync-operations.ts b/apps/studio/src/lib/active-sync-operations.ts index bab4f0a352..99008dacf2 100644 --- a/apps/studio/src/lib/active-sync-operations.ts +++ b/apps/studio/src/lib/active-sync-operations.ts @@ -59,6 +59,22 @@ export function pushBackupIsUploading( key: PushStateProgressInfo[ 'key' ] | und return uploadingStateKeys.includes( key ); } +/** + * Check if a pull operation is doing local work (download/import). + * + * The initial "in-progress" phase is remote backup creation and should not be treated as local work + * when deciding whether to block unrelated local operations. + */ +export function pullBackupIsDownloadingOrImporting( + key: PullStateProgressInfo[ 'key' ] | undefined +): boolean { + const localPullKeys: PullStateProgressInfo[ 'key' ][] = [ 'downloading', 'importing' ]; + if ( ! key ) { + return false; + } + return localPullKeys.includes( key ); +} + export function hasUploadingPushOperations(): boolean { // Iterate over all the sites and check if any operation is cancelable let result = false; diff --git a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx index d9a228f7ba..74c4f7b64c 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -22,6 +22,7 @@ import { useOffline } from 'src/hooks/use-offline'; import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; import { pushBackupIsUploading, + pullBackupIsDownloadingOrImporting, canCancelPull, canCancelPush, } from 'src/lib/active-sync-operations'; @@ -59,22 +60,37 @@ const SyncConnectedSiteControls = ( { const isOffline = useOffline(); const dispatch = useAppDispatch(); const [ syncDialogType, setSyncDialogType ] = useState< 'pull' | 'push' | null >( null ); - const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); - const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const getLastSyncTimeText = useLastSyncTimeText(); const { user, client } = useAuth(); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, userId: user?.id, } ); - const isAnyConnectedSiteSyncing = useRootSelector( ( state ) => - connectedSites.some( - ( site ) => - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state ) || - syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( state ) - ) + const isAnyConnectedSiteDoingLocalSyncWork = useRootSelector( ( state ) => + connectedSites.some( ( site ) => { + const pullState = syncOperationsSelectors.selectPullState( + selectedSite.id, + site.id + )( state ); + const pushState = syncOperationsSelectors.selectPushState( + selectedSite.id, + site.id + )( state ); + return ( + pullBackupIsDownloadingOrImporting( pullState?.status.key ) || + pushBackupIsUploading( pushState?.status.key ) + ); + } ) ); - const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; + const isAnySiteDoingLocalSyncWork = useRootSelector( ( state ) => { + const anyPullLocal = Object.values( syncOperationsSelectors.selectPullStates( state ) ).some( + ( pullState ) => pullBackupIsDownloadingOrImporting( pullState.status.key ) + ); + const anyPushLocal = Object.values( syncOperationsSelectors.selectPushStates( state ) ).some( + ( pushState ) => pushBackupIsUploading( pushState.status.key ) + ); + return anyPullLocal || anyPushLocal; + } ); return (
- { isAnySiteSyncing ? ( + { isAnySiteDoingLocalSyncWork ? ( setSyncDialogType( 'pull' ) } - disabled={ isAnySiteSyncing || isOffline } + disabled={ isAnySiteDoingLocalSyncWork || isOffline } data-testid="sync-list-pull-button" > @@ -125,10 +140,10 @@ const SyncConnectedSiteControls = ( { ) } - { isAnySiteSyncing ? ( + { isAnySiteDoingLocalSyncWork ? ( setSyncDialogType( 'push' ) } - disabled={ isAnySiteSyncing || isOffline } + disabled={ isAnySiteDoingLocalSyncWork || isOffline } data-testid="sync-list-push-button" > @@ -670,12 +684,20 @@ const SyncConnectedSiteSection = ( { connectedSitesSelectors.selectIsLoadingSiteId( connectedSite.id ) ); const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected'; - const isPulling = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, connectedSite.id ) - ); - const isPushing = useRootSelector( - syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, connectedSite.id ) - ); + const isDoingLocalSyncWork = useRootSelector( ( state ) => { + const pullState = syncOperationsSelectors.selectPullState( + selectedSite.id, + connectedSite.id + )( state ); + const pushState = syncOperationsSelectors.selectPushState( + selectedSite.id, + connectedSite.id + )( state ); + return ( + pullBackupIsDownloadingOrImporting( pullState?.status.key ) || + pushBackupIsUploading( pushState?.status.key ) + ); + } ); let logo = ; if ( isSiteLoading ) { @@ -702,18 +724,16 @@ const SyncConnectedSiteSection = ( { text={ __( 'This site is syncing. Please wait for the sync to finish before you can disconnect it.' ) } - disabled={ ! ( isPulling || isPushing ) || isOffline } + disabled={ ! isDoingLocalSyncWork || isOffline } placement="top-start" > diff --git a/apps/studio/src/tests/index.test.ts b/apps/studio/src/tests/index.test.ts index 23d8d529d2..367bea478d 100644 --- a/apps/studio/src/tests/index.test.ts +++ b/apps/studio/src/tests/index.test.ts @@ -102,6 +102,7 @@ function mockElectron() { quit: vi.fn(), exit: vi.fn(), setName: vi.fn(), + removeAsDefaultProtocolClient: vi.fn(), setAsDefaultProtocolClient: vi.fn(), enableSandbox: vi.fn(), setAppLogsPath: vi.fn(), From 8a04a4b905ff103591791f33e0b406498301815b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 6 May 2026 23:18:52 +0100 Subject: [PATCH 14/21] Abort sync operations directly from userLoggedOut listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-operation cancel thunk dispatches with a single abortAllSyncOperations() helper. The slice's addCase(userLoggedOut) already resets pullStates/pushStates, so the listener only needs to abort in-flight side effects (pollers, push upload aborts, main-process operations) — no state dispatches required. This removes the displayNotification param from cancelPush/cancelPull, which was overloaded to gate both the toast and the post-logout state re-population. The thunks are now purely user-initiated cancel paths. --- apps/studio/src/stores/index.ts | 30 ++----- .../src/stores/sync/sync-operations-slice.ts | 87 ++++++++++--------- 2 files changed, 51 insertions(+), 66 deletions(-) diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 60a1c48de0..03626a09f6 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -24,6 +24,7 @@ import { import { syncReducer, syncOperationsActions } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; import { + abortAllSyncOperations, stopPullPoller, stopPushPoller, syncOperationsReducer, @@ -185,36 +186,17 @@ startAppListening( { }, } ); -// Clear all sync state when user logs out +// Clear all sync state when user logs out. Slice state is reset via +// addCase(userLoggedOut) in sync-operations-slice; here we only abort the +// in-flight side effects (pollers, upload aborts, main-process operations) so +// they don't surface error UI after logout. startAppListening( { actionCreator: userLoggedOut, effect( action, listenerApi ) { setWpcomClient( undefined ); - // Use getOriginalState() to read state BEFORE the reducer cleared pullStates/pushStates - const state = listenerApi.getOriginalState(); + abortAllSyncOperations(); - for ( const pullState of Object.values( state.syncOperations.pullStates ) ) { - void store.dispatch( - syncOperationsThunks.cancelPull( { - selectedSiteId: pullState.selectedSite.id, - remoteSiteId: pullState.remoteSiteId, - displayNotification: false, - } ) - ); - } - - for ( const pushState of Object.values( state.syncOperations.pushStates ) ) { - void store.dispatch( - syncOperationsThunks.cancelPush( { - selectedSiteId: pushState.selectedSite.id, - remoteSiteId: pushState.remoteSiteId, - displayNotification: false, - } ) - ); - } - - // Reset authenticated RTK Query caches listenerApi.dispatch( wpcomSitesApi.util.resetApiState() ); listenerApi.dispatch( wpcomApi.util.resetApiState() ); }, diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index 535c63c6bc..24cec0624d 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -307,6 +307,26 @@ export function stopPullPoller( stateId: string ) { PULL_POLLERS.delete( stateId ); } +// Aborts every in-flight push/pull (pollers, push upload abort callbacks, and +// the corresponding main-process operations) without dispatching any state +// updates. Use from the userLoggedOut listener; rely on the slice's +// addCase(userLoggedOut) for state cleanup. +export function abortAllSyncOperations() { + const operationIds = new Set< string >( [ + ...PUSH_POLLERS.keys(), + ...PULL_POLLERS.keys(), + ...PUSH_SITE_ABORT_CALLBACKS.keys(), + ] ); + + for ( const operationId of operationIds ) { + stopPushPoller( operationId ); + stopPullPoller( operationId ); + PUSH_SITE_ABORT_CALLBACKS.get( operationId )?.(); + getIpcApi().cancelSyncOperation( operationId ); + } + PUSH_SITE_ABORT_CALLBACKS.clear(); +} + const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; const SYNC_POLLING_INTERVAL = 3000; @@ -416,61 +436,46 @@ const createTypedAsyncThunk = createAsyncThunk.withTypes< { type CancelOperationPayload = { selectedSiteId: string; remoteSiteId: number; - displayNotification?: boolean; }; const cancelPushThunk = createTypedAsyncThunk( 'syncOperations/cancelPush', - async ( - { selectedSiteId, remoteSiteId, displayNotification = true }: CancelOperationPayload, - { dispatch } - ) => { + async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); - const abortCallback = PUSH_SITE_ABORT_CALLBACKS.get( operationId ); stopPushPoller( operationId ); - abortCallback?.(); + PUSH_SITE_ABORT_CALLBACKS.get( operationId )?.(); getIpcApi().cancelSyncOperation( operationId ); - // Treat `displayNotification: false` as a "silent cancel" (e.g. logout cleanup): - // abort the underlying operation but avoid creating a persisted "cancelled" UI state. - if ( displayNotification ) { - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId, - remoteSiteId, - state: { status: getPushStatesProgressInfo().cancelled }, - } ) - ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId, + remoteSiteId, + state: { status: getPushStatesProgressInfo().cancelled }, + } ) + ); - getIpcApi().showNotification( { - title: __( 'Push cancelled' ), - body: __( 'The push operation has been cancelled.' ), - } ); - } + getIpcApi().showNotification( { + title: __( 'Push cancelled' ), + body: __( 'The push operation has been cancelled.' ), + } ); } ); const cancelPullThunk = createTypedAsyncThunk( 'syncOperations/cancelPull', - async ( - { selectedSiteId, remoteSiteId, displayNotification = true }: CancelOperationPayload, - { dispatch } - ) => { + async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); stopPullPoller( operationId ); getIpcApi().cancelSyncOperation( operationId ); - // See cancelPushThunk note: skip "cancelled" UI state for silent cancels (logout cleanup). - if ( displayNotification ) { - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId, - remoteSiteId, - state: { status: getPullStatesProgressInfo().cancelled }, - } ) - ); - } + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { status: getPullStatesProgressInfo().cancelled }, + } ) + ); getIpcApi() .removeSyncBackup( remoteSiteId ) @@ -478,12 +483,10 @@ const cancelPullThunk = createTypedAsyncThunk( // Ignore errors if file doesn't exist } ); - if ( displayNotification ) { - getIpcApi().showNotification( { - title: __( 'Pull cancelled' ), - body: __( 'The pull operation has been cancelled.' ), - } ); - } + getIpcApi().showNotification( { + title: __( 'Pull cancelled' ), + body: __( 'The pull operation has been cancelled.' ), + } ); } ); From 2b4a33e1f4a85b3dc8a1efdd1d1a46ab9394392a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 6 May 2026 23:26:33 +0100 Subject: [PATCH 15/21] Skip import/export reset on logout if a pull is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An active pull's import phase is driven by main-process events that keep emitting after logout, so resetting importState here was futile — it would just get repopulated. Snapshot the pre-logout pull-active flag via a ref (pullStates is reset synchronously on userLoggedOut, so the post-logout selector value is already false), and only clear when no pull was active. The hook now reads from the Redux store, so the test wrapper needs a Provider too. --- .../hooks/tests/use-import-export.test.tsx | 6 ++++- apps/studio/src/hooks/use-import-export.tsx | 27 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/studio/src/hooks/tests/use-import-export.test.tsx b/apps/studio/src/hooks/tests/use-import-export.test.tsx index 7a117246ea..b483b6ec48 100644 --- a/apps/studio/src/hooks/tests/use-import-export.test.tsx +++ b/apps/studio/src/hooks/tests/use-import-export.test.tsx @@ -1,4 +1,5 @@ import { renderHook, act } from '@testing-library/react'; +import { Provider } from 'react-redux'; import { vi } from 'vitest'; import { ImportExportProvider, useImportExport } from 'src/hooks/use-import-export'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; @@ -6,6 +7,7 @@ import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ExportEventType, ExportEvents } from 'src/lib/import-export/export/events'; import { ImportEventType, ImportEvents } from 'src/lib/import-export/import/events'; +import { store } from 'src/stores'; const mockShowSaveAsDialog = vi.fn(); const mockShowErrorMessageBox = vi.fn(); @@ -49,7 +51,9 @@ const selectedSite: SiteDetails = { }; const wrapper = ( { children }: { children: React.ReactNode } ) => ( - { children } + + { children } + ); beforeEach( () => { diff --git a/apps/studio/src/hooks/use-import-export.tsx b/apps/studio/src/hooks/use-import-export.tsx index 28a07346db..81d9b21591 100644 --- a/apps/studio/src/hooks/use-import-export.tsx +++ b/apps/studio/src/hooks/use-import-export.tsx @@ -1,6 +1,14 @@ import * as Sentry from '@sentry/electron/renderer'; import { __, sprintf } from '@wordpress/i18n'; -import { createContext, useEffect, useMemo, useState, useCallback, useContext } from 'react'; +import { + createContext, + useEffect, + useMemo, + useRef, + useState, + useCallback, + useContext, +} from 'react'; import { WP_CLI_IMPORT_EXPORT_RESPONSE_TIMEOUT_IN_HRS } from 'src/constants'; import { useAuth } from 'src/hooks/use-auth'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; @@ -21,6 +29,8 @@ import { ImportDatabaseProgressEventData, ImportWpContentProgressEventData, } from 'src/lib/import-export/import/types'; +import { useRootSelector } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync/sync-operations-slice'; export type ImportProgressState = { [ siteId: string ]: { @@ -80,9 +90,22 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode const [ exportState, setExportState ] = useState< ExportProgressState >( {} ); const { startServer, stopServer, updateSite } = useSiteDetails(); const { isAuthenticated } = useAuth(); + const isAnyPullActive = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); + + // Snapshot the latest pre-logout pull-active value while still authenticated. + // pullStates is reset synchronously on userLoggedOut, so by the time the effect + // below sees `isAuthenticated === false`, isAnyPullActive is already false. + const hadActivePullRef = useRef( false ); + if ( isAuthenticated ) { + hadActivePullRef.current = isAnyPullActive; + } useEffect( () => { - if ( ! isAuthenticated ) { + // On logout, only clear import/export state if no pull was in flight. + // An active pull's import phase is driven by main-process events that + // would just repopulate this state, so leave it alone and let the + // import finish. + if ( ! isAuthenticated && ! hadActivePullRef.current ) { setImportState( {} ); setExportState( {} ); } From 337d9e384496fa124a5c0491f99bdf8febf314c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 1 Jun 2026 16:34:22 +0100 Subject: [PATCH 16/21] Remove protocol-handler leftovers accidentally swept in by Conductor archive --- apps/studio/__mocks__/electron.ts | 1 - apps/studio/src/index.ts | 10 ++++------ apps/studio/src/tests/index.test.ts | 1 - 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/studio/__mocks__/electron.ts b/apps/studio/__mocks__/electron.ts index 6f48e505bf..7def34fb29 100644 --- a/apps/studio/__mocks__/electron.ts +++ b/apps/studio/__mocks__/electron.ts @@ -19,7 +19,6 @@ export const app = { on: vi.fn(), off: vi.fn(), setAppLogsPath: vi.fn(), - removeAsDefaultProtocolClient: vi.fn(), setAsDefaultProtocolClient: vi.fn(), enableSandbox: vi.fn(), commandLine: { diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 268d45d0c8..cbf7d8e875 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -174,15 +174,13 @@ async function appBoot() { setupUpdates(); - const appArgs = process.argv.length >= 2 ? [ path.resolve( process.argv[ 1 ] ) ] : []; - if ( process.defaultApp ) { - if ( appArgs.length > 0 ) { - app.removeAsDefaultProtocolClient( PROTOCOL_PREFIX, process.execPath, appArgs ); - app.setAsDefaultProtocolClient( PROTOCOL_PREFIX, process.execPath, appArgs ); + if ( process.argv.length >= 2 ) { + app.setAsDefaultProtocolClient( PROTOCOL_PREFIX, process.execPath, [ + path.resolve( process.argv[ 1 ] ), + ] ); } } else { - app.removeAsDefaultProtocolClient( PROTOCOL_PREFIX ); app.setAsDefaultProtocolClient( PROTOCOL_PREFIX ); } diff --git a/apps/studio/src/tests/index.test.ts b/apps/studio/src/tests/index.test.ts index 3253c97c75..e4c934ea4d 100644 --- a/apps/studio/src/tests/index.test.ts +++ b/apps/studio/src/tests/index.test.ts @@ -122,7 +122,6 @@ function mockElectron() { quit: vi.fn(), exit: vi.fn(), setName: vi.fn(), - removeAsDefaultProtocolClient: vi.fn(), setAsDefaultProtocolClient: vi.fn(), enableSandbox: vi.fn(), setAppLogsPath: vi.fn(), From f57a08e37ac037b478fb24281ffc92c112e6be9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 1 Jun 2026 16:57:54 +0100 Subject: [PATCH 17/21] Move local-sync-work selectors into syncOperationsSelectors --- .../sync/components/sync-connected-sites.tsx | 47 ++++--------------- .../src/stores/sync/sync-operations-slice.ts | 29 ++++++++++++ 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx index 4af006fd31..4bd6d3789c 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -22,7 +22,6 @@ import { useOffline } from 'src/hooks/use-offline'; import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; import { pushBackupIsUploading, - pullBackupIsDownloadingOrImporting, canCancelPull, canCancelPush, } from 'src/lib/active-sync-operations'; @@ -67,30 +66,13 @@ const SyncConnectedSiteControls = ( { userId: user?.id, } ); const isAnyConnectedSiteDoingLocalSyncWork = useRootSelector( ( state ) => - connectedSites.some( ( site ) => { - const pullState = syncOperationsSelectors.selectPullState( - selectedSite.id, - site.id - )( state ); - const pushState = syncOperationsSelectors.selectPushState( - selectedSite.id, - site.id - )( state ); - return ( - pullBackupIsDownloadingOrImporting( pullState?.status.key ) || - pushBackupIsUploading( pushState?.status.key ) - ); - } ) + connectedSites.some( ( site ) => + syncOperationsSelectors.selectIsSiteDoingLocalSyncWork( selectedSite.id, site.id )( state ) + ) + ); + const isAnySiteDoingLocalSyncWork = useRootSelector( + syncOperationsSelectors.selectIsAnySiteDoingLocalSyncWork ); - const isAnySiteDoingLocalSyncWork = useRootSelector( ( state ) => { - const anyPullLocal = Object.values( syncOperationsSelectors.selectPullStates( state ) ).some( - ( pullState ) => pullBackupIsDownloadingOrImporting( pullState.status.key ) - ); - const anyPushLocal = Object.values( syncOperationsSelectors.selectPushStates( state ) ).some( - ( pushState ) => pushBackupIsUploading( pushState.status.key ) - ); - return anyPullLocal || anyPushLocal; - } ); return ( { - const pullState = syncOperationsSelectors.selectPullState( - selectedSite.id, - connectedSite.id - )( state ); - const pushState = syncOperationsSelectors.selectPushState( - selectedSite.id, - connectedSite.id - )( state ); - return ( - pullBackupIsDownloadingOrImporting( pullState?.status.key ) || - pushBackupIsUploading( pushState?.status.key ) - ); - } ); + const isDoingLocalSyncWork = useRootSelector( + syncOperationsSelectors.selectIsSiteDoingLocalSyncWork( selectedSite.id, connectedSite.id ) + ); let logo = ; if ( isSiteLoading ) { diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index e94068248d..a49c0421e7 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -11,6 +11,10 @@ import { } from '@studio/common/types/sync'; import { __, sprintf } from '@wordpress/i18n'; import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; +import { + pullBackupIsDownloadingOrImporting, + pushBackupIsUploading, +} from 'src/lib/active-sync-operations'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; import { store } from 'src/stores'; @@ -1180,4 +1184,29 @@ export const syncOperationsSelectors = { return isKeyPushing( pushState.status.key ); } ); }, + // "Local sync work" = the phases the local machine is actively involved in (pull + // downloading/importing, push backup uploading). We can only block on these; the + // server-bound phases of a sync continue regardless. + selectIsSiteDoingLocalSyncWork: + ( selectedSiteId: string, remoteSiteId: number ) => + ( state: { syncOperations: SyncOperationsState } ): boolean => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + const pullState = state.syncOperations.pullStates[ stateId ]; + const pushState = state.syncOperations.pushStates[ stateId ]; + return ( + pullBackupIsDownloadingOrImporting( pullState?.status.key ) || + pushBackupIsUploading( pushState?.status.key ) + ); + }, + selectIsAnySiteDoingLocalSyncWork: ( state: { + syncOperations: SyncOperationsState; + } ): boolean => { + const anyPullLocal = Object.values( state.syncOperations.pullStates ).some( ( pullState ) => + pullBackupIsDownloadingOrImporting( pullState.status.key ) + ); + const anyPushLocal = Object.values( state.syncOperations.pushStates ).some( ( pushState ) => + pushBackupIsUploading( pushState.status.key ) + ); + return anyPullLocal || anyPushLocal; + }, }; From 912d7897da95629210ff9eaa955953fb297a9972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 2 Jun 2026 16:31:20 +0100 Subject: [PATCH 18/21] Handle logout during a sync pull cleanly (no error dialog, no orphaned pull) --- .../src/modules/sync/lib/ipc-handlers.ts | 22 +++++-- .../src/stores/sync/sync-operations-slice.ts | 61 +++++++++++++------ 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts index cd51336d66..f33ff10efa 100644 --- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts @@ -10,6 +10,7 @@ import { removeConnectedWpcomSite, updateConnectedWpcomSites as updateConnectedWpcomSitesShared, } from '@studio/common/lib/connected-sites'; +import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { getCurrentUserId } from '@studio/common/lib/shared-config'; import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api'; import wpcomFactory from '@studio/common/lib/wpcom-factory'; @@ -409,11 +410,13 @@ export async function downloadSyncBackup( await download( downloadUrl, filePath, false, '', abortController.signal ); return filePath; } catch ( error ) { - if ( error instanceof Error && error.name === 'AbortError' ) { - // Download was cancelled, throw the error - } else { - console.error( `[Download] Download failed for operation: ${ operationId }`, error ); + // A cancelled operation (user cancel or logout cleanup) aborts this signal. That's an + // intentional stop, not a failure — return without logging or throwing so it doesn't + // surface as an error, and let the caller treat the missing path as "stopped". + if ( abortController.signal.aborted ) { + return undefined; } + console.error( `[Download] Download failed for operation: ${ operationId }`, error ); throw error; } finally { SYNC_ABORT_CONTROLLERS.delete( operationId ); @@ -422,7 +425,16 @@ export async function downloadSyncBackup( export async function removeSyncBackup( event: IpcMainInvokeEvent, remoteSiteId: number ) { const filePath = getSyncBackupTempPath( remoteSiteId ); - await fsPromises.unlink( filePath ); + try { + await fsPromises.unlink( filePath ); + } catch ( error ) { + // The backup file may never have been created — e.g. cancelling a pull that was still + // initializing the remote backup, before anything was downloaded. A missing file is + // not an error here, so only rethrow unexpected failures. + if ( ! isErrnoException( error ) || error.code !== 'ENOENT' ) { + throw error; + } + } } type WpcomSitesToConnect = { sites: SyncSite[]; localSiteId: string }[]; diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index a49c0421e7..b26a9d6b13 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -631,7 +631,10 @@ type PullSiteResult = { export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayload >( 'syncOperations/pullSite', - async ( { client, connectedSite, selectedSite, options }, { dispatch, rejectWithValue } ) => { + async ( + { client, connectedSite, selectedSite, options }, + { dispatch, getState, rejectWithValue } + ) => { const pullStatesProgressInfo = getPullStatesProgressInfo(); const remoteSiteId = connectedSite.id; const remoteSiteUrl = connectedSite.url; @@ -668,16 +671,28 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl const response = pullSiteResponseSchema.parse( rawResponse ); if ( response.success ) { - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId: selectedSite.id, - remoteSiteId, - state: { - backupId: response.backup_id, - }, - } ) - ); - void startPullPoller( selectedSite.id, remoteSiteId ); + // Creating the remote backup can take a while. If the user logged out (slice + // reset) or cancelled the pull while this request was in flight, don't resurrect + // the pull state or start a poller — that would leave an orphaned pull stuck at + // "Initializing remote backup…" after logout. + const currentState = syncOperationsSelectors.selectPullState( + selectedSite.id, + remoteSiteId + )( getState() ); + const isStillActive = !! currentState && currentState.status.key !== 'cancelled'; + + if ( isStillActive ) { + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { + backupId: response.backup_id, + }, + } ) + ); + void startPullPoller( selectedSite.id, remoteSiteId ); + } return { backupId: response.backup_id, @@ -936,6 +951,12 @@ const pollPullBackupThunk = createTypedAsyncThunk( operationId ); + // downloadSyncBackup resolves without a path when it was cancelled (e.g. the user + // logged out mid-download). Stop silently instead of importing a missing backup. + if ( signal.aborted || ! filePath ) { + return; + } + dispatch( syncOperationsActions.updatePullState( { selectedSiteId, @@ -953,6 +974,13 @@ const pollPullBackupThunk = createTypedAsyncThunk( showNotification: false, } ); + // The import runs locally and is intentionally not aborted on logout, but if the + // user logged out / cancelled while it finished, don't surface post-logout + // "finished" UI (notification + re-populated sync state). + if ( signal.aborted ) { + return; + } + await updateSiteTimestamp( { siteId: remoteSiteId, localSiteId: selectedSiteId, @@ -1004,12 +1032,11 @@ const pollPullBackupThunk = createTypedAsyncThunk( ); } } catch ( error ) { - const currentState = syncOperationsSelectors.selectPullState( - selectedSiteId, - remoteSiteId - )( getState() ); - - if ( signal.aborted && currentState?.status.key === 'cancelled' ) { + // The poller signal is aborted on user cancel and on logout cleanup — both are + // intentional stops, so don't capture the error or show the "Error pulling" modal. + // (On logout the slice is reset, so we can't rely on the state still being + // 'cancelled' here — the aborted signal is the reliable signal.) + if ( signal.aborted ) { return; } From e852ab8a7a0d46c2f1d8e736e42b5e56054430c7 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 3 Jun 2026 10:38:49 +0200 Subject: [PATCH 19/21] Move logic into Redux slice selectors --- .../components/content-tab-import-export.tsx | 15 +--- .../src/components/publish-site-button.tsx | 16 +--- .../src/components/settings-site-menu.tsx | 26 +------ .../components/site-management-actions.tsx | 10 +-- apps/studio/src/lib/active-sync-operations.ts | 16 ---- .../src/stores/sync/sync-operations-slice.ts | 76 ++++++++++++------- 6 files changed, 61 insertions(+), 98 deletions(-) diff --git a/apps/studio/src/components/content-tab-import-export.tsx b/apps/studio/src/components/content-tab-import-export.tsx index 3c502a5dac..2bf269a98d 100644 --- a/apps/studio/src/components/content-tab-import-export.tsx +++ b/apps/studio/src/components/content-tab-import-export.tsx @@ -17,10 +17,7 @@ import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog'; import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { - pullBackupIsDownloadingOrImporting, - pushBackupIsUploading, -} from 'src/lib/active-sync-operations'; +import { pushBackupIsUploading } from 'src/lib/active-sync-operations'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useRootSelector } from 'src/stores'; @@ -349,13 +346,9 @@ export function ContentTabImportExport( { selectedSite }: ContentTabImportExport userId: user?.id, } ); const isPullingLocally = useRootSelector( ( state ) => - connectedSites.some( ( site ) => { - const pullState = syncOperationsSelectors.selectPullState( - selectedSite.id, - site.id - )( state ); - return pullBackupIsDownloadingOrImporting( pullState?.status.key ); - } ) + connectedSites.some( ( site ) => + syncOperationsSelectors.selectIsSiteIdPullingLocally( selectedSite.id, site.id )( state ) + ) ); // Only block import/export while the local machine is actively involved in sync. // After the backup upload completes, the push continues remotely and should not block import/export. diff --git a/apps/studio/src/components/publish-site-button.tsx b/apps/studio/src/components/publish-site-button.tsx index 482220dcf0..94fcea8735 100644 --- a/apps/studio/src/components/publish-site-button.tsx +++ b/apps/studio/src/components/publish-site-button.tsx @@ -3,10 +3,6 @@ import { useI18n } from '@wordpress/react-i18n'; import { useCallback } from 'react'; import { useAuth } from 'src/hooks/use-auth'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { - pullBackupIsDownloadingOrImporting, - pushBackupIsUploading, -} from 'src/lib/active-sync-operations'; import { generateCheckoutUrl } from 'src/lib/generate-checkout-url'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ConnectButton } from 'src/modules/sync/components/connect-button'; @@ -22,15 +18,9 @@ export const PublishSiteButton = () => { localSiteId: selectedSite?.id, userId: user?.id, } ); - const isAnySiteDoingLocalSyncWork = useRootSelector( ( state ) => { - const anyPullLocal = Object.values( syncOperationsSelectors.selectPullStates( state ) ).some( - ( pullState ) => pullBackupIsDownloadingOrImporting( pullState.status.key ) - ); - const anyPushLocal = Object.values( syncOperationsSelectors.selectPushStates( state ) ).some( - ( pushState ) => pushBackupIsUploading( pushState.status.key ) - ); - return anyPullLocal || anyPushLocal; - } ); + const isAnySiteDoingLocalSyncWork = useRootSelector( + syncOperationsSelectors.selectIsAnySitePullingLocally + ); const handlePublishClick = useCallback( () => { if ( ! selectedSite ) return; diff --git a/apps/studio/src/components/settings-site-menu.tsx b/apps/studio/src/components/settings-site-menu.tsx index 3b3bcfe353..d47d703a5c 100644 --- a/apps/studio/src/components/settings-site-menu.tsx +++ b/apps/studio/src/components/settings-site-menu.tsx @@ -1,9 +1,5 @@ import { MenuItem } from '@wordpress/components'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { - pullBackupIsDownloadingOrImporting, - pushBackupIsUploading, -} from 'src/lib/active-sync-operations'; import { useRootSelector } from 'src/stores'; import { syncOperationsSelectors } from 'src/stores/sync'; @@ -19,25 +15,9 @@ export const SettingsMenuItem = ( { isDestructive = false, }: SettingsMenuItemProps ) => { const { isDeleting, sites, selectedSite } = useSiteDetails(); - const selectedSiteId = selectedSite?.id; - const isThisSiteDoingLocalSyncWork = useRootSelector( ( state ) => { - if ( ! selectedSiteId ) { - return false; - } - const pullStates = syncOperationsSelectors.selectPullStates( state ); - const pushStates = syncOperationsSelectors.selectPushStates( state ); - const anyPullLocal = Object.values( pullStates ).some( - ( pullState ) => - pullState.selectedSite?.id === selectedSiteId && - pullBackupIsDownloadingOrImporting( pullState.status.key ) - ); - const anyPushLocal = Object.values( pushStates ).some( - ( pushState ) => - pushState.selectedSite?.id === selectedSiteId && - pushBackupIsUploading( pushState.status.key ) - ); - return anyPullLocal || anyPushLocal; - } ); + const isThisSiteDoingLocalSyncWork = useRootSelector( + syncOperationsSelectors.selectIsSiteDoingLocalSyncWork( selectedSite?.id ) + ); if ( ! selectedSite ) { return null; } diff --git a/apps/studio/src/components/site-management-actions.tsx b/apps/studio/src/components/site-management-actions.tsx index d13118ab07..e9a7637ef8 100644 --- a/apps/studio/src/components/site-management-actions.tsx +++ b/apps/studio/src/components/site-management-actions.tsx @@ -4,7 +4,6 @@ import { ActionButton } from 'src/components/action-button'; import { PublishSiteButton } from 'src/components/publish-site-button'; import { Tooltip } from 'src/components/tooltip'; import { useImportExport } from 'src/hooks/use-import-export'; -import { pullBackupIsDownloadingOrImporting } from 'src/lib/active-sync-operations'; import { useRootSelector } from 'src/stores'; import { syncOperationsSelectors } from 'src/stores/sync'; @@ -24,14 +23,7 @@ export const SiteManagementActions = ( { const { __ } = useI18n(); const { isSiteImporting } = useImportExport(); const isPullingLocally = useRootSelector( ( state ) => { - if ( ! selectedSite ) { - return false; - } - return Object.values( syncOperationsSelectors.selectPullStates( state ) ).some( - ( pullState ) => - pullState.selectedSite?.id === selectedSite.id && - pullBackupIsDownloadingOrImporting( pullState.status.key ) - ); + syncOperationsSelectors.selectIsSiteIdPullingLocally( selectedSite?.id )( state ); } ); if ( ! selectedSite ) { diff --git a/apps/studio/src/lib/active-sync-operations.ts b/apps/studio/src/lib/active-sync-operations.ts index 99008dacf2..bab4f0a352 100644 --- a/apps/studio/src/lib/active-sync-operations.ts +++ b/apps/studio/src/lib/active-sync-operations.ts @@ -59,22 +59,6 @@ export function pushBackupIsUploading( key: PushStateProgressInfo[ 'key' ] | und return uploadingStateKeys.includes( key ); } -/** - * Check if a pull operation is doing local work (download/import). - * - * The initial "in-progress" phase is remote backup creation and should not be treated as local work - * when deciding whether to block unrelated local operations. - */ -export function pullBackupIsDownloadingOrImporting( - key: PullStateProgressInfo[ 'key' ] | undefined -): boolean { - const localPullKeys: PullStateProgressInfo[ 'key' ][] = [ 'downloading', 'importing' ]; - if ( ! key ) { - return false; - } - return localPullKeys.includes( key ); -} - export function hasUploadingPushOperations(): boolean { // Iterate over all the sites and check if any operation is cancelable let result = false; diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index b26a9d6b13..c216bfd0eb 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -11,10 +11,6 @@ import { } from '@studio/common/types/sync'; import { __, sprintf } from '@wordpress/i18n'; import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; -import { - pullBackupIsDownloadingOrImporting, - pushBackupIsUploading, -} from 'src/lib/active-sync-operations'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; import { store } from 'src/stores'; @@ -1134,22 +1130,38 @@ const isKeyPulling = ( key?: PullStateProgressInfo[ 'key' ] ): boolean => { if ( ! key ) { return false; } - const pullingStateKeys = [ 'in-progress', 'downloading', 'importing' ]; - return pullingStateKeys.includes( key ); + const keys = [ 'in-progress', 'downloading', 'importing' ]; + return keys.includes( key ); +}; + +const isKeyPullingLocally = ( key?: PullStateProgressInfo[ 'key' ] ): boolean => { + if ( ! key ) { + return false; + } + const keys = [ 'downloading', 'importing' ]; + return keys.includes( key ); +}; + +const isKeyUploading = ( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean => { + if ( ! key ) { + return false; + } + const keys = [ 'creatingBackup', 'uploading', 'uploadingManuallyPaused' ]; + return keys.includes( key ); }; const isKeyPushing = ( key?: PushStateProgressInfo[ 'key' ] ): boolean => { if ( ! key ) { return false; } - const pushingStateKeys = [ + const keys = [ 'creatingBackup', 'uploading', 'creatingRemoteBackup', 'applyingChanges', 'finishing', ]; - return pushingStateKeys.includes( key ); + return keys.includes( key ); }; export const syncOperationsSelectors = { @@ -1174,20 +1186,35 @@ export const syncOperationsSelectors = { isKeyPulling( pullState.status.key ) ); }, + selectIsAnySitePullingLocally: ( state: { syncOperations: SyncOperationsState } ): boolean => { + return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => + isKeyPullingLocally( pullState.status.key ) + ); + }, selectIsSiteIdPulling: ( selectedSiteId: string, remoteSiteId?: number ) => ( state: { syncOperations: SyncOperationsState } ): boolean => { return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => { - if ( ! pullState.selectedSite ) { + if ( pullState.selectedSite.id !== selectedSiteId ) { return false; } + if ( pullState.remoteSiteId === remoteSiteId ) { + return isKeyPulling( pullState.status.key ); + } + return pullState.status && isKeyPulling( pullState.status.key ); + } ); + }, + selectIsSiteIdPullingLocally: + ( selectedSiteId?: string, remoteSiteId?: number ) => + ( state: { syncOperations: SyncOperationsState } ): boolean => { + return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => { if ( pullState.selectedSite.id !== selectedSiteId ) { return false; } - if ( remoteSiteId !== undefined ) { - return isKeyPulling( pullState.status.key ) && pullState.remoteSiteId === remoteSiteId; + if ( pullState.remoteSiteId === remoteSiteId ) { + return isKeyPullingLocally( pullState.status.key ); } - return pullState.status && isKeyPulling( pullState.status.key ); + return pullState.status && isKeyPullingLocally( pullState.status.key ); } ); }, selectIsAnySitePushing: ( state: { syncOperations: SyncOperationsState } ): boolean => { @@ -1196,17 +1223,14 @@ export const syncOperationsSelectors = { ); }, selectIsSiteIdPushing: - ( selectedSiteId: string, remoteSiteId?: number ) => + ( selectedSiteId?: string, remoteSiteId?: number ) => ( state: { syncOperations: SyncOperationsState } ): boolean => { return Object.values( state.syncOperations.pushStates ).some( ( pushState ) => { - if ( ! pushState.selectedSite ) { - return false; - } if ( pushState.selectedSite.id !== selectedSiteId ) { return false; } - if ( remoteSiteId !== undefined ) { - return isKeyPushing( pushState.status.key ) && pushState.remoteSiteId === remoteSiteId; + if ( pushState.remoteSiteId === remoteSiteId ) { + return isKeyPushing( pushState.status.key ); } return isKeyPushing( pushState.status.key ); } ); @@ -1215,24 +1239,24 @@ export const syncOperationsSelectors = { // downloading/importing, push backup uploading). We can only block on these; the // server-bound phases of a sync continue regardless. selectIsSiteDoingLocalSyncWork: - ( selectedSiteId: string, remoteSiteId: number ) => + ( selectedSiteId?: string, remoteSiteId?: number ) => ( state: { syncOperations: SyncOperationsState } ): boolean => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - const pullState = state.syncOperations.pullStates[ stateId ]; - const pushState = state.syncOperations.pushStates[ stateId ]; return ( - pullBackupIsDownloadingOrImporting( pullState?.status.key ) || - pushBackupIsUploading( pushState?.status.key ) + syncOperationsSelectors.selectIsSiteIdPullingLocally( + selectedSiteId, + remoteSiteId + )( state ) || + syncOperationsSelectors.selectIsSiteIdPushing( selectedSiteId, remoteSiteId )( state ) ); }, selectIsAnySiteDoingLocalSyncWork: ( state: { syncOperations: SyncOperationsState; } ): boolean => { const anyPullLocal = Object.values( state.syncOperations.pullStates ).some( ( pullState ) => - pullBackupIsDownloadingOrImporting( pullState.status.key ) + isKeyPullingLocally( pullState.status.key ) ); const anyPushLocal = Object.values( state.syncOperations.pushStates ).some( ( pushState ) => - pushBackupIsUploading( pushState.status.key ) + isKeyUploading( pushState.status.key ) ); return anyPullLocal || anyPushLocal; }, From 76977ecc88f3dc40c2a2d7dce78e08108746fd31 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 3 Jun 2026 10:44:07 +0200 Subject: [PATCH 20/21] Corrections --- .../components/content-tab-import-export.tsx | 11 +++------ .../src/components/publish-site-button.tsx | 2 +- .../components/site-management-actions.tsx | 6 ++--- .../src/stores/sync/sync-operations-slice.ts | 23 ++++++++++++++----- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/apps/studio/src/components/content-tab-import-export.tsx b/apps/studio/src/components/content-tab-import-export.tsx index 2bf269a98d..8ed14fec6c 100644 --- a/apps/studio/src/components/content-tab-import-export.tsx +++ b/apps/studio/src/components/content-tab-import-export.tsx @@ -17,7 +17,6 @@ import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog'; import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { pushBackupIsUploading } from 'src/lib/active-sync-operations'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useRootSelector } from 'src/stores'; @@ -353,13 +352,9 @@ export function ContentTabImportExport( { selectedSite }: ContentTabImportExport // Only block import/export while the local machine is actively involved in sync. // After the backup upload completes, the push continues remotely and should not block import/export. const isUploadingPushBackup = useRootSelector( ( state ) => - connectedSites.some( ( site ) => { - const pushState = syncOperationsSelectors.selectPushState( - selectedSite.id, - site.id - )( state ); - return pushBackupIsUploading( pushState?.status.key ); - } ) + connectedSites.some( ( site ) => + syncOperationsSelectors.selectIsSiteIdPushingLocally( selectedSite.id, site.id )( state ) + ) ); const isThisSiteSyncing = isPullingLocally || isUploadingPushBackup; diff --git a/apps/studio/src/components/publish-site-button.tsx b/apps/studio/src/components/publish-site-button.tsx index 94fcea8735..cdb2f023dd 100644 --- a/apps/studio/src/components/publish-site-button.tsx +++ b/apps/studio/src/components/publish-site-button.tsx @@ -19,7 +19,7 @@ export const PublishSiteButton = () => { userId: user?.id, } ); const isAnySiteDoingLocalSyncWork = useRootSelector( - syncOperationsSelectors.selectIsAnySitePullingLocally + syncOperationsSelectors.selectIsAnySiteDoingLocalSyncWork ); const handlePublishClick = useCallback( () => { diff --git a/apps/studio/src/components/site-management-actions.tsx b/apps/studio/src/components/site-management-actions.tsx index e9a7637ef8..ee6b2bf88a 100644 --- a/apps/studio/src/components/site-management-actions.tsx +++ b/apps/studio/src/components/site-management-actions.tsx @@ -22,9 +22,9 @@ export const SiteManagementActions = ( { }: SiteManagementActionProps ) => { const { __ } = useI18n(); const { isSiteImporting } = useImportExport(); - const isPullingLocally = useRootSelector( ( state ) => { - syncOperationsSelectors.selectIsSiteIdPullingLocally( selectedSite?.id )( state ); - } ); + const isPullingLocally = useRootSelector( ( state ) => + syncOperationsSelectors.selectIsSiteIdPullingLocally( selectedSite?.id )( state ) + ); if ( ! selectedSite ) { return null; diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index c216bfd0eb..ff01847d7f 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -1186,11 +1186,6 @@ export const syncOperationsSelectors = { isKeyPulling( pullState.status.key ) ); }, - selectIsAnySitePullingLocally: ( state: { syncOperations: SyncOperationsState } ): boolean => { - return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => - isKeyPullingLocally( pullState.status.key ) - ); - }, selectIsSiteIdPulling: ( selectedSiteId: string, remoteSiteId?: number ) => ( state: { syncOperations: SyncOperationsState } ): boolean => { @@ -1235,6 +1230,19 @@ export const syncOperationsSelectors = { return isKeyPushing( pushState.status.key ); } ); }, + selectIsSiteIdPushingLocally: + ( selectedSiteId?: string, remoteSiteId?: number ) => + ( state: { syncOperations: SyncOperationsState } ): boolean => { + return Object.values( state.syncOperations.pushStates ).some( ( pushState ) => { + if ( pushState.selectedSite.id !== selectedSiteId ) { + return false; + } + if ( pushState.remoteSiteId === remoteSiteId ) { + return isKeyUploading( pushState.status.key ); + } + return isKeyUploading( pushState.status.key ); + } ); + }, // "Local sync work" = the phases the local machine is actively involved in (pull // downloading/importing, push backup uploading). We can only block on these; the // server-bound phases of a sync continue regardless. @@ -1246,7 +1254,10 @@ export const syncOperationsSelectors = { selectedSiteId, remoteSiteId )( state ) || - syncOperationsSelectors.selectIsSiteIdPushing( selectedSiteId, remoteSiteId )( state ) + syncOperationsSelectors.selectIsSiteIdPushingLocally( + selectedSiteId, + remoteSiteId + )( state ) ); }, selectIsAnySiteDoingLocalSyncWork: ( state: { From 831efb6ff137e8dcc8292ec474015734c69aea65 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 3 Jun 2026 10:54:11 +0200 Subject: [PATCH 21/21] Correction --- .../src/stores/sync/sync-operations-slice.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index ff01847d7f..7ed1551b5e 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -1193,10 +1193,10 @@ export const syncOperationsSelectors = { if ( pullState.selectedSite.id !== selectedSiteId ) { return false; } - if ( pullState.remoteSiteId === remoteSiteId ) { - return isKeyPulling( pullState.status.key ); + if ( remoteSiteId !== undefined && pullState.remoteSiteId !== remoteSiteId ) { + return false; } - return pullState.status && isKeyPulling( pullState.status.key ); + return isKeyPulling( pullState.status.key ); } ); }, selectIsSiteIdPullingLocally: @@ -1206,10 +1206,10 @@ export const syncOperationsSelectors = { if ( pullState.selectedSite.id !== selectedSiteId ) { return false; } - if ( pullState.remoteSiteId === remoteSiteId ) { - return isKeyPullingLocally( pullState.status.key ); + if ( remoteSiteId !== undefined && pullState.remoteSiteId !== remoteSiteId ) { + return false; } - return pullState.status && isKeyPullingLocally( pullState.status.key ); + return isKeyPullingLocally( pullState.status.key ); } ); }, selectIsAnySitePushing: ( state: { syncOperations: SyncOperationsState } ): boolean => { @@ -1224,8 +1224,8 @@ export const syncOperationsSelectors = { if ( pushState.selectedSite.id !== selectedSiteId ) { return false; } - if ( pushState.remoteSiteId === remoteSiteId ) { - return isKeyPushing( pushState.status.key ); + if ( remoteSiteId !== undefined && pushState.remoteSiteId !== remoteSiteId ) { + return false; } return isKeyPushing( pushState.status.key ); } ); @@ -1237,8 +1237,8 @@ export const syncOperationsSelectors = { if ( pushState.selectedSite.id !== selectedSiteId ) { return false; } - if ( pushState.remoteSiteId === remoteSiteId ) { - return isKeyUploading( pushState.status.key ); + if ( remoteSiteId !== undefined && pushState.remoteSiteId !== remoteSiteId ) { + return false; } return isKeyUploading( pushState.status.key ); } );