From 7cf45db6b2bfd4854d959976a92a3a5c4926f7ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 7 Nov 2025 12:04:28 +0000 Subject: [PATCH 01/58] add new slice for pull and push states --- src/hooks/sync-sites/sync-sites-context.tsx | 41 +++----- src/hooks/sync-sites/use-sync-pull.ts | 46 ++++++--- src/hooks/sync-sites/use-sync-push.ts | 52 +++++++--- src/hooks/use-sync-states-progress-info.ts | 13 ++- src/stores/index.ts | 3 + src/stores/sync/index.ts | 5 + src/stores/sync/sync-operations-slice.ts | 104 ++++++++++++++++++++ 7 files changed, 210 insertions(+), 54 deletions(-) create mode 100644 src/stores/sync/sync-operations-slice.ts diff --git a/src/hooks/sync-sites/sync-sites-context.tsx b/src/hooks/sync-sites/sync-sites-context.tsx index 9a88072662..991d5eab74 100644 --- a/src/hooks/sync-sites/sync-sites-context.tsx +++ b/src/hooks/sync-sites/sync-sites-context.tsx @@ -1,10 +1,9 @@ import { __, sprintf } from '@wordpress/i18n'; -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useContext, useEffect } from 'react'; import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; -import { PullStates, UseSyncPull, useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; +import { UseSyncPull, useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; import { - PushStates, UseSyncPush, useSyncPush, mapImportResponseToPushState, @@ -14,7 +13,7 @@ import { useFormatLocalizedTimestamps } from 'src/hooks/use-format-localized-tim import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useAppDispatch } from 'src/stores'; -import { useConnectedSitesData, useSyncSitesData, connectedSitesActions } from 'src/stores/sync'; +import { useConnectedSitesData, useSyncSitesData, connectedSitesActions, syncOperationsActions } from 'src/stores/sync'; import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string; @@ -34,7 +33,6 @@ const SyncSitesContext = createContext< SyncSitesContextType | undefined >( unde export function SyncSitesProvider( { children }: { children: React.ReactNode } ) { const { formatRelativeTime } = useFormatLocalizedTimestamps(); - const [ pullStates, setPullStates ] = useState< PullStates >( {} ); const getLastSyncTimeText = useCallback< GetLastSyncTimeText >( ( timestamp, type ) => { @@ -90,17 +88,12 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) const { pullSite, isAnySitePulling, isSiteIdPulling, clearPullState, getPullState, cancelPull } = useSyncPull( { - pullStates, - setPullStates, onPullSuccess: ( remoteSiteId, localSiteId ) => updateSiteTimestamp( remoteSiteId, localSiteId, 'pull' ), } ); - const [ pushStates, setPushStates ] = useState< PushStates >( {} ); const { pushSite, isAnySitePushing, isSiteIdPushing, clearPushState, getPushState, cancelPush } = useSyncPush( { - pushStates, - setPushStates, onPushSuccess: ( remoteSiteId, localSiteId ) => updateSiteTimestamp( remoteSiteId, localSiteId, 'push' ), } ); @@ -121,8 +114,6 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) const allSites = await getIpcApi().getSiteDetails(); const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); - const restoredStates: PushStates = {}; - for ( const connectedSite of allConnectedSites ) { try { const localSite = allSites.find( ( site ) => site.id === connectedSite.localSiteId ); @@ -132,25 +123,29 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) continue; } - const response = await client.req.get< ImportResponse >( + const response = await client.req.get( `/sites/${ connectedSite.id }/studio-app/sync/import`, { apiNamespace: 'wpcom/v2', } - ); + ) as ImportResponse; const status = mapImportResponseToPushState( response, pushStatesProgressInfo ); // Only restore the pushStates if the operation is still in progress if ( status ) { - const stateId = generateStateId( connectedSite.localSiteId, connectedSite.id ); - restoredStates[ stateId ] = { + dispatch( syncOperationsActions.updatePushState( { + selectedSiteId: connectedSite.localSiteId, remoteSiteId: connectedSite.id, - status, - selectedSite: localSite, - remoteSiteUrl: connectedSite.url, - }; + state: { + remoteSiteId: connectedSite.id, + status, + selectedSite: localSite, + remoteSiteUrl: connectedSite.url, + }, + } ) ); + const stateId = generateStateId( connectedSite.localSiteId, connectedSite.id ); getIpcApi().addSyncOperation( stateId, status ); } } catch ( error ) { @@ -158,17 +153,13 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) console.error( `Failed to check push progress for site ${ connectedSite.id }:`, error ); } } - - if ( Object.keys( restoredStates ).length > 0 ) { - setPushStates( ( prev ) => ( { ...prev, ...restoredStates } ) ); - } }; initializePushStates().catch( ( error ) => { // Initialization is not critical to app functionality, but log the error console.error( 'Failed to initialize push states from server:', error ); } ); - }, [ client, pushStatesProgressInfo ] ); + }, [ client, pushStatesProgressInfo, dispatch ] ); return ( boolean; type UseSyncPullProps = { - pullStates: PullStates; - setPullStates: React.Dispatch< React.SetStateAction< PullStates > >; onPullSuccess?: OnPullSuccess; }; @@ -65,10 +64,8 @@ export type UseSyncPull = { }; export function useSyncPull( { - pullStates, - setPullStates, onPullSuccess, -}: UseSyncPullProps ): UseSyncPull { +}: UseSyncPullProps = {} ): UseSyncPull { const { __ } = useI18n(); const { client } = useAuth(); const { importFile, clearImportState } = useImportExport(); @@ -80,11 +77,38 @@ export function useSyncPull( { isKeyCancelled, getBackupStatusWithProgress, } = useSyncStatesProgressInfo(); - const { - updateState, - getState: getPullState, - clearState, - } = usePullPushStates< SyncBackupState >( pullStates, setPullStates ); + + const dispatch = useAppDispatch(); + const pullStates = useRootSelector( syncOperationsSelectors.selectPullStates ); + + const updateState = useCallback< UpdateState< SyncBackupState > >( + ( selectedSiteId, remoteSiteId, state ) => { + dispatch( syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state, + } ) ); + }, + [ dispatch ] + ); + + const getPullState = useCallback< GetState< SyncBackupState > >( + ( selectedSiteId, remoteSiteId ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + return pullStates[ stateId ]; + }, + [ pullStates ] + ); + + const clearState = useCallback< ClearState >( + ( selectedSiteId, remoteSiteId ) => { + dispatch( syncOperationsActions.clearPullState( { + selectedSiteId, + remoteSiteId, + } ) ); + }, + [ dispatch ] + ); const updatePullState = useCallback< UpdateState< SyncBackupState > >( ( selectedSiteId, remoteSiteId, state ) => { diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 2b2c1c9632..21989b985a 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -8,7 +8,6 @@ import { generateStateId, GetState, UpdateState, - usePullPushStates, } from 'src/hooks/sync-sites/use-pull-push-states'; import { useAuth } from 'src/hooks/use-auth'; import { @@ -17,6 +16,8 @@ import { } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { syncOperationsActions, syncOperationsSelectors } from 'src/stores/sync'; import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; import type { SyncOption } from 'src/types'; @@ -43,8 +44,6 @@ type PushSite = ( type IsSiteIdPushing = ( selectedSiteId: string, remoteSiteId?: number ) => boolean; type UseSyncPushProps = { - pushStates: PushStates; - setPushStates: React.Dispatch< React.SetStateAction< PushStates > >; onPushSuccess?: OnPushSuccess; }; @@ -83,18 +82,45 @@ export function mapImportResponseToPushState( return null; } -export function useSyncPush( { - pushStates, - setPushStates, - onPushSuccess, -}: UseSyncPushProps ): UseSyncPush { +export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSyncPush { const { __ } = useI18n(); const { client } = useAuth(); - const { - updateState, - getState: getPushState, - clearState, - } = usePullPushStates< SyncPushState >( pushStates, setPushStates ); + + const dispatch = useAppDispatch(); + const pushStates = useRootSelector( syncOperationsSelectors.selectPushStates ); + + const updateState = useCallback< UpdateState< SyncPushState > >( + ( selectedSiteId, remoteSiteId, state ) => { + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId, + remoteSiteId, + state, + } ) + ); + }, + [ dispatch ] + ); + + const getPushState = useCallback< GetState< SyncPushState > >( + ( selectedSiteId, remoteSiteId ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + return pushStates[ stateId ]; + }, + [ pushStates ] + ); + + const clearState = useCallback< ClearState >( + ( selectedSiteId, remoteSiteId ) => { + dispatch( + syncOperationsActions.clearPushState( { + selectedSiteId, + remoteSiteId, + } ) + ); + }, + [ dispatch ] + ); const { pushStatesProgressInfo, isKeyPushing, diff --git a/src/hooks/use-sync-states-progress-info.ts b/src/hooks/use-sync-states-progress-info.ts index 9ac5d50947..71f23c0822 100644 --- a/src/hooks/use-sync-states-progress-info.ts +++ b/src/hooks/use-sync-states-progress-info.ts @@ -207,11 +207,14 @@ export function useSyncStatesProgressInfo() { : response.status; let newProgressInfo: PullStateProgressInfo | null = null; if ( response.status === 'in-progress' ) { - newProgressInfo = pullStatesProgressInfo[ frontendStatus ]; - // Update progress from the initial value to the new step proportionally to the response.progress - // on every update of the response.progress - newProgressInfo.progress = - IN_PROGRESS_INITIAL_VALUE + IN_PROGRESS_TO_DOWNLOADING_STEP * ( response.percent / 100 ); + newProgressInfo = { + ...pullStatesProgressInfo[ frontendStatus ], + // Update progress from the initial value to the new step proportionally to the response.progress + // on every update of the response.progress + progress: + IN_PROGRESS_INITIAL_VALUE + + IN_PROGRESS_TO_DOWNLOADING_STEP * ( response.percent / 100 ), + }; } const statusWithProgress = newProgressInfo || pullStatesProgressInfo[ frontendStatus ]; diff --git a/src/stores/index.ts b/src/stores/index.ts index a3b9140a5e..b5774a82bf 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -28,6 +28,7 @@ import { connectedSitesReducer, loadAllConnectedSites, } from 'src/stores/sync/connected-sites-slice'; +import { syncOperationsReducer } from 'src/stores/sync/sync-operations-slice'; import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from 'common/lib/locale'; @@ -42,6 +43,7 @@ export type RootState = { snapshot: ReturnType< typeof snapshotReducer >; sync: ReturnType< typeof syncReducer >; connectedSites: ReturnType< typeof connectedSitesReducer >; + syncOperations: ReturnType< typeof syncOperationsReducer >; wordpressVersionsApi: ReturnType< typeof wordpressVersionsApi.reducer >; wpcomApi: ReturnType< typeof wpcomApi.reducer >; wpcomPublicApi: ReturnType< typeof wpcomPublicApi.reducer >; @@ -98,6 +100,7 @@ export const rootReducer = combineReducers( { snapshot: snapshotReducer, sync: syncReducer, connectedSites: connectedSitesReducer, + syncOperations: syncOperationsReducer, wordpressVersionsApi: wordpressVersionsApi.reducer, wpcomApi: wpcomApi.reducer, wpcomPublicApi: wpcomPublicApi.reducer, diff --git a/src/stores/sync/index.ts b/src/stores/sync/index.ts index c462f4f349..da3cb0bdbf 100644 --- a/src/stores/sync/index.ts +++ b/src/stores/sync/index.ts @@ -14,4 +14,9 @@ export { useSyncSitesData, useConnectedSitesOperations, } from './connected-sites-hooks'; +export { + syncOperationsReducer, + syncOperationsActions, + syncOperationsSelectors, +} from './sync-operations-slice'; export * from './sync-types'; diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts new file mode 100644 index 0000000000..efd803d5dd --- /dev/null +++ b/src/stores/sync/sync-operations-slice.ts @@ -0,0 +1,104 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; +import type { SyncBackupState, PullStates } from 'src/hooks/sync-sites/use-sync-pull'; +import type { SyncPushState, PushStates } from 'src/hooks/sync-sites/use-sync-push'; + +interface SyncOperationsState { + pullStates: PullStates; + pushStates: PushStates; +} + +const initialState: SyncOperationsState = { + pullStates: {}, + pushStates: {}, +}; + +type UpdatePullStatePayload = { + selectedSiteId: string; + remoteSiteId: number; + state: Partial< SyncBackupState >; +}; + +type UpdatePushStatePayload = { + selectedSiteId: string; + remoteSiteId: number; + state: Partial< SyncPushState >; +}; + +type ClearStatePayload = { + selectedSiteId: string; + remoteSiteId: number; +}; + +const syncOperationsSlice = createSlice( { + name: 'syncOperations', + initialState, + reducers: { + updatePullState: ( state, action: PayloadAction< UpdatePullStatePayload > ) => { + const { selectedSiteId, remoteSiteId, state: updateState } = action.payload; + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + + state.pullStates[ stateId ] = { + ...state.pullStates[ stateId ], + ...updateState, + } as SyncBackupState; + }, + + clearPullState: ( state, action: PayloadAction< ClearStatePayload > ) => { + const { selectedSiteId, remoteSiteId } = action.payload; + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + delete state.pullStates[ stateId ]; + }, + + updatePushState: ( state, action: PayloadAction< UpdatePushStatePayload > ) => { + const { selectedSiteId, remoteSiteId, state: updateState } = action.payload; + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + + state.pushStates[ stateId ] = { + ...state.pushStates[ stateId ], + ...updateState, + } as SyncPushState; + }, + + clearPushState: ( state, action: PayloadAction< ClearStatePayload > ) => { + const { selectedSiteId, remoteSiteId } = action.payload; + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + delete state.pushStates[ stateId ]; + }, + + setPullStates: ( state, action: PayloadAction< PullStates > ) => { + state.pullStates = action.payload; + }, + + setPushStates: ( state, action: PayloadAction< PushStates > ) => { + state.pushStates = action.payload; + }, + + clearAllStates: ( state ) => { + state.pullStates = {}; + state.pushStates = {}; + }, + }, +} ); + +export const syncOperationsActions = syncOperationsSlice.actions; +export const syncOperationsReducer = syncOperationsSlice.reducer; + +export const syncOperationsSelectors = { + selectPullStates: ( state: { syncOperations: SyncOperationsState } ) => + state.syncOperations.pullStates, + selectPushStates: ( state: { syncOperations: SyncOperationsState } ) => + state.syncOperations.pushStates, + selectPullState: + ( selectedSiteId: string, remoteSiteId: number ) => + ( state: { syncOperations: SyncOperationsState } ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + return state.syncOperations.pullStates[ stateId ]; + }, + selectPushState: + ( selectedSiteId: string, remoteSiteId: number ) => + ( state: { syncOperations: SyncOperationsState } ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + return state.syncOperations.pushStates[ stateId ]; + }, +}; From 7d2d1b05a6cbc1c8c93e7d5e1cdd8dfc7ff20dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Thu, 11 Dec 2025 17:14:45 +0000 Subject: [PATCH 02/58] update useSyncPull and useSyncPush hooks --- src/hooks/sync-sites/use-sync-pull.ts | 32 +++++++++++++++++---- src/hooks/sync-sites/use-sync-push.ts | 40 +++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index 06753c5452..799ee0da67 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/electron/renderer'; import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { SYNC_PUSH_SIZE_LIMIT_GB, SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; import { ClearState, @@ -19,7 +19,7 @@ import { } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; -import { useAppDispatch, useRootSelector } from 'src/stores'; +import { useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsActions, syncOperationsSelectors } from 'src/stores/sync'; import type { SyncSite } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; @@ -77,10 +77,27 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync } = useSyncStatesProgressInfo(); const dispatch = useAppDispatch(); - const pullStates = useRootSelector( syncOperationsSelectors.selectPullStates ); + const pullStates = useRootSelector( + syncOperationsSelectors.selectPullStates as ( state: RootState ) => PullStates + ); + const pullStatesRef = useRef( pullStates ); + + // Keep ref in sync with Redux state + useEffect( () => { + pullStatesRef.current = pullStates; + }, [ pullStates ] ); const updateState = useCallback< UpdateState< SyncBackupState > >( ( selectedSiteId, remoteSiteId, state ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + // Immediately update the ref so getPullState returns the latest value + pullStatesRef.current = { + ...pullStatesRef.current, + [ stateId ]: { + ...pullStatesRef.current[ stateId ], + ...state, + } as SyncBackupState, + }; dispatch( syncOperationsActions.updatePullState( { selectedSiteId, @@ -95,13 +112,18 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync const getPullState = useCallback< GetState< SyncBackupState > >( ( selectedSiteId, remoteSiteId ) => { const stateId = generateStateId( selectedSiteId, remoteSiteId ); - return pullStates[ stateId ]; + return pullStatesRef.current[ stateId ]; }, - [ pullStates ] + [] ); const clearState = useCallback< ClearState >( ( selectedSiteId, remoteSiteId ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + // Immediately update the ref so getPullState returns undefined right away + const newStates = { ...pullStatesRef.current }; + delete newStates[ stateId ]; + pullStatesRef.current = newStates; dispatch( syncOperationsActions.clearPullState( { selectedSiteId, diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 836d3d8ef7..cf33911f40 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/electron/renderer'; import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; import { ClearState, @@ -16,7 +16,7 @@ import { } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; -import { useAppDispatch, useRootSelector } from 'src/stores'; +import { useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsActions, syncOperationsSelectors } from 'src/stores/sync'; import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; import type { SyncSite } from 'src/modules/sync/types'; @@ -87,10 +87,27 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync const { client } = useAuth(); const dispatch = useAppDispatch(); - const pushStates = useRootSelector( syncOperationsSelectors.selectPushStates ); + const pushStates = useRootSelector( + syncOperationsSelectors.selectPushStates as ( state: RootState ) => PushStates + ); + const pushStatesRef = useRef( pushStates ); + + // Keep ref in sync with Redux state + useEffect( () => { + pushStatesRef.current = pushStates; + }, [ pushStates ] ); const updateState = useCallback< UpdateState< SyncPushState > >( ( selectedSiteId, remoteSiteId, state ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + // Immediately update the ref so getPushState returns the latest value + pushStatesRef.current = { + ...pushStatesRef.current, + [ stateId ]: { + ...pushStatesRef.current[ stateId ], + ...state, + } as SyncPushState, + }; dispatch( syncOperationsActions.updatePushState( { selectedSiteId, @@ -105,13 +122,18 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync const getPushState = useCallback< GetState< SyncPushState > >( ( selectedSiteId, remoteSiteId ) => { const stateId = generateStateId( selectedSiteId, remoteSiteId ); - return pushStates[ stateId ]; + return pushStatesRef.current[ stateId ]; }, - [ pushStates ] + [] ); const clearState = useCallback< ClearState >( ( selectedSiteId, remoteSiteId ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + // Immediately update the ref so getPushState returns undefined right away + const newStates = { ...pushStatesRef.current }; + delete newStates[ stateId ]; + pushStatesRef.current = newStates; dispatch( syncOperationsActions.clearPushState( { selectedSiteId, @@ -348,7 +370,14 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync if ( response.success ) { updatePushState( selectedSite.id, remoteSiteId, { status: pushStatesProgressInfo.creatingRemoteBackup, + selectedSite, + remoteSiteUrl, } ); + // Immediately start polling for push progress after upload completes + const stateAfterStatusUpdate = getPushState( selectedSite.id, remoteSiteId ); + if ( stateAfterStatusUpdate ) { + void getPushProgressInfo( remoteSiteId, stateAfterStatusUpdate ); + } } else { throw response; } @@ -374,6 +403,7 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync updatePushState, getErrorFromResponse, isKeyCancelled, + getPushProgressInfo, ] ); From fe07c5e24f7e8618b88bc2b4a1bcde997f264966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 12 Dec 2025 13:34:35 +0000 Subject: [PATCH 03/58] add selectors from hook to slice --- src/hooks/sync-sites/use-sync-pull.ts | 4 +- src/hooks/sync-sites/use-sync-push.ts | 18 ++++--- src/stores/sync/sync-operations-slice.ts | 65 ++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index 799ee0da67..45ac9f8a2d 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -436,9 +436,7 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync }; }, [ pullStates, fetchAndUpdateBackup, isKeyCancelled ] ); - const isAnySitePulling = useMemo< boolean >( () => { - return Object.values( pullStates ).some( ( state ) => isKeyPulling( state.status.key ) ); - }, [ pullStates, isKeyPulling ] ); + const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); const isSiteIdPulling = useCallback< IsSiteIdPulling >( ( selectedSiteId, remoteSiteId ) => { diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index cf33911f40..e7c18b54fd 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/electron/renderer'; import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; import { ClearState, @@ -374,10 +374,14 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync remoteSiteUrl, } ); // Immediately start polling for push progress after upload completes - const stateAfterStatusUpdate = getPushState( selectedSite.id, remoteSiteId ); - if ( stateAfterStatusUpdate ) { - void getPushProgressInfo( remoteSiteId, stateAfterStatusUpdate ); - } + // Construct state directly instead of reading from Redux to avoid stale reads + const stateForPolling: SyncPushState = { + remoteSiteId, + status: pushStatesProgressInfo.creatingRemoteBackup, + selectedSite, + remoteSiteUrl, + }; + void getPushProgressInfo( remoteSiteId, stateForPolling ); } else { throw response; } @@ -434,9 +438,7 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync isKeyCancelled, ] ); - const isAnySitePushing = useMemo< boolean >( () => { - return Object.values( pushStates ).some( ( state ) => isKeyPushing( state.status.key ) ); - }, [ pushStates, isKeyPushing ] ); + const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const isSiteIdPushing = useCallback< IsSiteIdPushing >( ( selectedSiteId, remoteSiteId ) => { diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index efd803d5dd..ad83babbb8 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -84,6 +84,29 @@ const syncOperationsSlice = createSlice( { export const syncOperationsActions = syncOperationsSlice.actions; export const syncOperationsReducer = syncOperationsSlice.reducer; +// Helper functions for checking state keys (matching useSyncStatesProgressInfo logic) +const isKeyPulling = ( key: string | undefined ): boolean => { + if ( ! key ) { + return false; + } + const pullingStateKeys = [ 'in-progress', 'downloading', 'importing' ]; + return pullingStateKeys.includes( key ); +}; + +const isKeyPushing = ( key: string | undefined ): boolean => { + if ( ! key ) { + return false; + } + const pushingStateKeys = [ + 'creatingBackup', + 'uploading', + 'creatingRemoteBackup', + 'applyingChanges', + 'finishing', + ]; + return pushingStateKeys.includes( key ); +}; + export const syncOperationsSelectors = { selectPullStates: ( state: { syncOperations: SyncOperationsState } ) => state.syncOperations.pullStates, @@ -101,4 +124,46 @@ export const syncOperationsSelectors = { const stateId = generateStateId( selectedSiteId, remoteSiteId ); return state.syncOperations.pushStates[ stateId ]; }, + selectIsAnySitePulling: ( state: { syncOperations: SyncOperationsState } ): boolean => { + return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => + isKeyPulling( pullState.status.key ) + ); + }, + selectIsSiteIdPulling: + ( selectedSiteId: string, remoteSiteId?: number ) => + ( state: { syncOperations: SyncOperationsState } ): boolean => { + return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => { + if ( ! pullState.selectedSite ) { + return false; + } + if ( pullState.selectedSite.id !== selectedSiteId ) { + return false; + } + if ( remoteSiteId !== undefined ) { + return isKeyPulling( pullState.status.key ) && pullState.remoteSiteId === remoteSiteId; + } + return isKeyPulling( pullState.status.key ); + } ); + }, + selectIsAnySitePushing: ( state: { syncOperations: SyncOperationsState } ): boolean => { + return Object.values( state.syncOperations.pushStates ).some( ( pushState ) => + isKeyPushing( pushState.status.key ) + ); + }, + selectIsSiteIdPushing: + ( 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; + } + return isKeyPushing( pushState.status.key ); + } ); + }, }; From a98979887d4dcff99aad91b4d4a49918f57801a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 12 Dec 2025 13:48:43 +0000 Subject: [PATCH 04/58] move clear/cancel operations to thunks --- src/hooks/sync-sites/use-sync-pull.ts | 64 +++++--------- src/hooks/sync-sites/use-sync-push.ts | 38 +++++---- src/stores/sync/index.ts | 1 + src/stores/sync/sync-operations-slice.ts | 103 ++++++++++++++++++++++- 4 files changed, 148 insertions(+), 58 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index 45ac9f8a2d..a734140ab9 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/electron/renderer'; import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { SYNC_PUSH_SIZE_LIMIT_GB, SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; import { ClearState, @@ -20,7 +20,11 @@ import { import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; import { useAppDispatch, useRootSelector, type RootState } from 'src/stores'; -import { syncOperationsActions, syncOperationsSelectors } from 'src/stores/sync'; +import { + syncOperationsActions, + syncOperationsSelectors, + syncOperationsThunks, +} from 'src/stores/sync'; import type { SyncSite } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; @@ -117,23 +121,6 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync [] ); - const clearState = useCallback< ClearState >( - ( selectedSiteId, remoteSiteId ) => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - // Immediately update the ref so getPullState returns undefined right away - const newStates = { ...pullStatesRef.current }; - delete newStates[ stateId ]; - pullStatesRef.current = newStates; - dispatch( - syncOperationsActions.clearPullState( { - selectedSiteId, - remoteSiteId, - } ) - ); - }, - [ dispatch ] - ); - const updatePullState = useCallback< UpdateState< SyncBackupState > >( ( selectedSiteId, remoteSiteId, state ) => { updateState( selectedSiteId, remoteSiteId, state ); @@ -150,10 +137,16 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync const clearPullState = useCallback< ClearState >( ( selectedSiteId, remoteSiteId ) => { - clearState( selectedSiteId, remoteSiteId ); - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + // Immediately update the ref so getPullState returns undefined right away + const newStates = { ...pullStatesRef.current }; + delete newStates[ stateId ]; + pullStatesRef.current = newStates; + // Dispatch both the action and the thunk + dispatch( syncOperationsActions.clearPullState( { selectedSiteId, remoteSiteId } ) ); + void dispatch( syncOperationsThunks.clearPullState( { selectedSiteId, remoteSiteId } ) ); }, - [ clearState ] + [ dispatch ] ); const { startServer } = useSiteDetails(); @@ -458,26 +451,15 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync const cancelPull = useCallback< CancelPull >( async ( selectedSiteId, remoteSiteId ) => { - const operationId = generateStateId( selectedSiteId, remoteSiteId ); - - getIpcApi().cancelSyncOperation( operationId ); - - updatePullState( selectedSiteId, remoteSiteId, { - status: pullStatesProgressInfo.cancelled, - } ); - - getIpcApi() - .removeSyncBackup( remoteSiteId ) - .catch( () => { - // Ignore errors if file doesn't exist - } ); - - getIpcApi().showNotification( { - title: __( 'Pull cancelled' ), - body: __( 'The pull operation has been cancelled.' ), - } ); + void dispatch( + syncOperationsThunks.cancelPull( { + selectedSiteId, + remoteSiteId, + cancelledStatus: pullStatesProgressInfo.cancelled, + } ) + ); }, - [ __, pullStatesProgressInfo.cancelled, updatePullState ] + [ dispatch, pullStatesProgressInfo.cancelled ] ); return { diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index e7c18b54fd..0d9d4c64b4 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -17,7 +17,11 @@ import { import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; import { useAppDispatch, useRootSelector, type RootState } from 'src/stores'; -import { syncOperationsActions, syncOperationsSelectors } from 'src/stores/sync'; +import { + syncOperationsActions, + syncOperationsSelectors, + syncOperationsThunks, +} from 'src/stores/sync'; import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; import type { SyncSite } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; @@ -172,10 +176,16 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync const clearPushState = useCallback< ClearState >( ( selectedSiteId, remoteSiteId ) => { - clearState( selectedSiteId, remoteSiteId ); - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + // Immediately update the ref so getPushState returns undefined right away + const newStates = { ...pushStatesRef.current }; + delete newStates[ stateId ]; + pushStatesRef.current = newStates; + // Dispatch both the action and the thunk + dispatch( syncOperationsActions.clearPushState( { selectedSiteId, remoteSiteId } ) ); + void dispatch( syncOperationsThunks.clearPushState( { selectedSiteId, remoteSiteId } ) ); }, - [ clearState ] + [ dispatch ] ); const getPushProgressInfo = useCallback( @@ -460,19 +470,15 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync const cancelPush = useCallback< CancelPush >( async ( selectedSiteId, remoteSiteId ) => { - const operationId = generateStateId( selectedSiteId, remoteSiteId ); - getIpcApi().cancelSyncOperation( operationId ); - - updatePushState( selectedSiteId, remoteSiteId, { - status: pushStatesProgressInfo.cancelled, - } ); - - getIpcApi().showNotification( { - title: __( 'Push cancelled' ), - body: __( 'The push operation has been cancelled.' ), - } ); + void dispatch( + syncOperationsThunks.cancelPush( { + selectedSiteId, + remoteSiteId, + cancelledStatus: pushStatesProgressInfo.cancelled, + } ) + ); }, - [ __, pushStatesProgressInfo.cancelled, updatePushState ] + [ dispatch, pushStatesProgressInfo.cancelled ] ); return { diff --git a/src/stores/sync/index.ts b/src/stores/sync/index.ts index aef2503690..1624d22585 100644 --- a/src/stores/sync/index.ts +++ b/src/stores/sync/index.ts @@ -5,5 +5,6 @@ export { syncOperationsReducer, syncOperationsActions, syncOperationsSelectors, + syncOperationsThunks, } from './sync-operations-slice'; export * from './sync-types'; diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index ad83babbb8..18abb8ca57 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -1,7 +1,14 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import { __ } from '@wordpress/i18n'; import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; +import { getIpcApi } from 'src/lib/get-ipc-api'; import type { SyncBackupState, PullStates } from 'src/hooks/sync-sites/use-sync-pull'; import type { SyncPushState, PushStates } from 'src/hooks/sync-sites/use-sync-push'; +import type { + PullStateProgressInfo, + PushStateProgressInfo, +} from 'src/hooks/use-sync-states-progress-info'; +import type { AppDispatch, RootState } from 'src/stores'; interface SyncOperationsState { pullStates: PullStates; @@ -84,6 +91,100 @@ const syncOperationsSlice = createSlice( { export const syncOperationsActions = syncOperationsSlice.actions; export const syncOperationsReducer = syncOperationsSlice.reducer; +// Create typed async thunk helper +const createTypedAsyncThunk = createAsyncThunk.withTypes< { + state: RootState; + dispatch: AppDispatch; +} >(); + +// Thunks for clear operations +export const clearPushStateThunk = createTypedAsyncThunk( + 'syncOperations/clearPushState', + async ( { selectedSiteId, remoteSiteId }: ClearStatePayload ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + getIpcApi().clearSyncOperation( stateId ); + return { selectedSiteId, remoteSiteId }; + } +); + +export const clearPullStateThunk = createTypedAsyncThunk( + 'syncOperations/clearPullState', + async ( { selectedSiteId, remoteSiteId }: ClearStatePayload ) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + getIpcApi().clearSyncOperation( stateId ); + return { selectedSiteId, remoteSiteId }; + } +); + +// Thunks for cancel operations +type CancelPushPayload = { + selectedSiteId: string; + remoteSiteId: number; + cancelledStatus: PushStateProgressInfo; +}; + +type CancelPullPayload = { + selectedSiteId: string; + remoteSiteId: number; + cancelledStatus: PullStateProgressInfo; +}; + +export const cancelPushThunk = createTypedAsyncThunk( + 'syncOperations/cancelPush', + async ( { selectedSiteId, remoteSiteId, cancelledStatus }: CancelPushPayload, { dispatch } ) => { + const operationId = generateStateId( selectedSiteId, remoteSiteId ); + getIpcApi().cancelSyncOperation( operationId ); + + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId, + remoteSiteId, + state: { status: cancelledStatus }, + } ) + ); + + getIpcApi().showNotification( { + title: __( 'Push cancelled' ), + body: __( 'The push operation has been cancelled.' ), + } ); + } +); + +export const cancelPullThunk = createTypedAsyncThunk( + 'syncOperations/cancelPull', + async ( { selectedSiteId, remoteSiteId, cancelledStatus }: CancelPullPayload, { dispatch } ) => { + const operationId = generateStateId( selectedSiteId, remoteSiteId ); + getIpcApi().cancelSyncOperation( operationId ); + + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { status: cancelledStatus }, + } ) + ); + + getIpcApi() + .removeSyncBackup( remoteSiteId ) + .catch( () => { + // Ignore errors if file doesn't exist + } ); + + getIpcApi().showNotification( { + title: __( 'Pull cancelled' ), + body: __( 'The pull operation has been cancelled.' ), + } ); + } +); + +// Export thunks object for convenience +export const syncOperationsThunks = { + clearPushState: clearPushStateThunk, + clearPullState: clearPullStateThunk, + cancelPush: cancelPushThunk, + cancelPull: cancelPullThunk, +}; + // Helper functions for checking state keys (matching useSyncStatesProgressInfo logic) const isKeyPulling = ( key: string | undefined ): boolean => { if ( ! key ) { From 5640cbbec323f354b9ef6b3530265883b3efede2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 12 Dec 2025 14:05:52 +0000 Subject: [PATCH 05/58] move pushsite to thunk --- src/hooks/sync-sites/use-sync-push.ts | 163 +++----------- src/stores/sync/sync-operations-slice.ts | 257 ++++++++++++++++++++++- 2 files changed, 288 insertions(+), 132 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 0d9d4c64b4..cc4774a5b9 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -1,8 +1,6 @@ -import * as Sentry from '@sentry/electron/renderer'; import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useEffect, useRef } from 'react'; -import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; import { ClearState, generateStateId, @@ -16,7 +14,7 @@ import { } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; -import { useAppDispatch, useRootSelector, type RootState } from 'src/stores'; +import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsActions, syncOperationsSelectors, @@ -276,149 +274,52 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync ] ); - const getErrorFromResponse = useCallback( - ( error: unknown ): string => { - if ( - typeof error === 'object' && - error !== null && - 'error' in error && - typeof ( error as { error: unknown } ).error === 'string' - ) { - return ( error as { error: string } ).error; - } - - return __( 'Studio was unable to connect to WordPress.com. Please try again.' ); - }, - [ __ ] - ); - const pushSite = useCallback< PushSite >( async ( connectedSite, selectedSite, options ) => { if ( ! client ) { return; } - const remoteSiteId = connectedSite.id; - const remoteSiteUrl = connectedSite.url; - const operationId = generateStateId( selectedSite.id, remoteSiteId ); - - clearPushState( selectedSite.id, remoteSiteId ); - updatePushState( selectedSite.id, remoteSiteId, { - remoteSiteId, - status: pushStatesProgressInfo.creatingBackup, - selectedSite, - remoteSiteUrl, - } ); - - let archivePath: string, archiveSizeInBytes: number; try { - const result = await getIpcApi().exportSiteForPush( selectedSite.id, operationId, { - optionsToSync: options?.optionsToSync, - specificSelectionPaths: options?.specificSelectionPaths, - } ); - ( { archivePath, archiveSizeInBytes } = result ); - } catch ( error ) { - if ( error instanceof Error && error.message === 'Export aborted' ) { - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.cancelled, - } ); - return; - } - - Sentry.captureException( error ); - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: __( - 'An error occurred while pushing the site. If this problem persists, please contact support.' - ), - error, - showOpenLogs: true, - } ); - return; - } - - if ( archiveSizeInBytes > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: __( - 'The site is too large to push. Please reduce the size of the site and try again.' - ), - } ); - await getIpcApi().removeExportedSiteTmpFile( archivePath ); - return; - } - - const stateBeforeUpload = getPushState( selectedSite.id, remoteSiteId ); - - if ( ! stateBeforeUpload || isKeyCancelled( stateBeforeUpload?.status.key ) ) { - return; - } - - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.uploading, - } ); - - try { - const response = await getIpcApi().pushArchive( - remoteSiteId, - archivePath, - options?.optionsToSync, - options?.specificSelectionPaths - ); - const stateAfterUpload = getPushState( selectedSite.id, remoteSiteId ); - - if ( isKeyCancelled( stateAfterUpload?.status.key ) ) { - return; - } - - if ( response.success ) { - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.creatingRemoteBackup, + const result = await dispatch( + syncOperationsThunks.pushSite( { + connectedSite, selectedSite, - remoteSiteUrl, - } ); - // Immediately start polling for push progress after upload completes - // Construct state directly instead of reading from Redux to avoid stale reads + options, + pushStatesProgressInfo, + } ) + ).unwrap(); + + // Sync ref with latest Redux state immediately after thunk completes + // This ensures getPushState returns the latest value without waiting for re-render + const currentState = store.getState(); + const latestPushStates = syncOperationsSelectors.selectPushStates( currentState ); + pushStatesRef.current = latestPushStates; + + // If thunk completed successfully and returned polling info, start polling + if ( result.shouldStartPolling ) { const stateForPolling: SyncPushState = { - remoteSiteId, + remoteSiteId: result.remoteSiteId, status: pushStatesProgressInfo.creatingRemoteBackup, - selectedSite, - remoteSiteUrl, + selectedSite: result.selectedSite, + remoteSiteUrl: result.remoteSiteUrl, }; - void getPushProgressInfo( remoteSiteId, stateForPolling ); - } else { - throw response; + void getPushProgressInfo( result.remoteSiteId, stateForPolling ); } } catch ( error ) { - Sentry.captureException( error ); - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: getErrorFromResponse( error ), - } ); - } finally { - await getIpcApi().removeExportedSiteTmpFile( archivePath ); + // Sync ref even on error to ensure state is up to date + const currentState = store.getState(); + const latestPushStates = syncOperationsSelectors.selectPushStates( currentState ); + pushStatesRef.current = latestPushStates; + + // Errors are already handled in the thunk (state updates, error messages) + // Just log if it's an unexpected error + if ( ! ( error instanceof Error && error.message === 'Export aborted' ) ) { + // Other errors are already handled in thunk + } } }, - [ - __, - clearPushState, - client, - getPushState, - pushStatesProgressInfo, - updatePushState, - getErrorFromResponse, - isKeyCancelled, - getPushProgressInfo, - ] + [ client, dispatch, pushStatesProgressInfo, getPushProgressInfo ] ); useEffect( () => { diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 18abb8ca57..73028b0f4d 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -1,5 +1,7 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; -import { __ } from '@wordpress/i18n'; +import * as Sentry from '@sentry/electron/renderer'; +import { __, sprintf } from '@wordpress/i18n'; +import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; import { getIpcApi } from 'src/lib/get-ipc-api'; import type { SyncBackupState, PullStates } from 'src/hooks/sync-sites/use-sync-pull'; @@ -8,7 +10,9 @@ import type { PullStateProgressInfo, PushStateProgressInfo, } from 'src/hooks/use-sync-states-progress-info'; +import type { SyncSite } from 'src/modules/sync/types'; import type { AppDispatch, RootState } from 'src/stores'; +import type { SyncOption } from 'src/types'; interface SyncOperationsState { pullStates: PullStates; @@ -91,6 +95,58 @@ const syncOperationsSlice = createSlice( { export const syncOperationsActions = syncOperationsSlice.actions; export const syncOperationsReducer = syncOperationsSlice.reducer; +// Helper functions for push operations +const isKeyCancelled = ( key: string | undefined ): boolean => { + return key === 'cancelled'; +}; + +const isKeyFailed = ( key: string | undefined ): boolean => { + return key === 'failed'; +}; + +const isKeyFinished = ( key: string | undefined ): boolean => { + return key === 'finished'; +}; + +const getErrorFromResponse = ( error: unknown ): string => { + if ( + typeof error === 'object' && + error !== null && + 'error' in error && + typeof ( error as { error: unknown } ).error === 'string' + ) { + return ( error as { error: string } ).error; + } + return __( 'Studio was unable to connect to WordPress.com. Please try again.' ); +}; + +// Helper to update push state and sync with IPC (matching updatePushState logic) +const updatePushStateWithIpc = ( + dispatch: AppDispatch, + selectedSiteId: string, + remoteSiteId: number, + state: Partial< SyncPushState >, + isKeyFailedFn: ( key: string | undefined ) => boolean, + isKeyFinishedFn: ( key: string | undefined ) => boolean +) => { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + const statusKey = state.status?.key; + + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId, + remoteSiteId, + state, + } ) + ); + + if ( isKeyFailedFn( statusKey ) || isKeyFinishedFn( statusKey ) || isKeyCancelled( statusKey ) ) { + getIpcApi().clearSyncOperation( stateId ); + } else if ( state.status ) { + getIpcApi().addSyncOperation( stateId, state.status ); + } +}; + // Create typed async thunk helper const createTypedAsyncThunk = createAsyncThunk.withTypes< { state: RootState; @@ -177,12 +233,211 @@ export const cancelPullThunk = createTypedAsyncThunk( } ); +// Thunk for push operation +type PushSitePayload = { + connectedSite: SyncSite; + selectedSite: SiteDetails; + options?: { + optionsToSync?: SyncOption[]; + specificSelectionPaths?: string[]; + }; + pushStatesProgressInfo: Record< PushStateProgressInfo[ 'key' ], PushStateProgressInfo >; +}; + +type PushSiteResult = { + shouldStartPolling: boolean; + remoteSiteId: number; + selectedSite: SiteDetails; + remoteSiteUrl: string; +}; + +export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayload >( + 'syncOperations/pushSite', + async ( + { connectedSite, selectedSite, options, pushStatesProgressInfo }, + { dispatch, getState } + ) => { + const remoteSiteId = connectedSite.id; + const remoteSiteUrl = connectedSite.url; + const operationId = generateStateId( selectedSite.id, remoteSiteId ); + + // Clear existing state + dispatch( + syncOperationsActions.clearPushState( { selectedSiteId: selectedSite.id, remoteSiteId } ) + ); + void dispatch( + syncOperationsThunks.clearPushState( { selectedSiteId: selectedSite.id, remoteSiteId } ) + ); + + // Initialize push state + updatePushStateWithIpc( + dispatch, + selectedSite.id, + remoteSiteId, + { + remoteSiteId, + status: pushStatesProgressInfo.creatingBackup, + selectedSite, + remoteSiteUrl, + }, + isKeyFailed, + isKeyFinished + ); + + let archivePath: string, archiveSizeInBytes: number; + + try { + const result = await getIpcApi().exportSiteForPush( selectedSite.id, operationId, { + optionsToSync: options?.optionsToSync, + specificSelectionPaths: options?.specificSelectionPaths, + } ); + ( { archivePath, archiveSizeInBytes } = result ); + } catch ( error ) { + if ( error instanceof Error && error.message === 'Export aborted' ) { + updatePushStateWithIpc( + dispatch, + selectedSite.id, + remoteSiteId, + { status: pushStatesProgressInfo.cancelled }, + isKeyFailed, + isKeyFinished + ); + throw error; // Signal cancellation + } + + Sentry.captureException( error ); + updatePushStateWithIpc( + dispatch, + selectedSite.id, + remoteSiteId, + { status: pushStatesProgressInfo.failed }, + isKeyFailed, + isKeyFinished + ); + getIpcApi().showErrorMessageBox( { + title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), + message: __( + 'An error occurred while pushing the site. If this problem persists, please contact support.' + ), + error, + showOpenLogs: true, + } ); + throw error; + } + + // Check file size + if ( archiveSizeInBytes > SYNC_PUSH_SIZE_LIMIT_BYTES ) { + updatePushStateWithIpc( + dispatch, + selectedSite.id, + remoteSiteId, + { status: pushStatesProgressInfo.failed }, + isKeyFailed, + isKeyFinished + ); + getIpcApi().showErrorMessageBox( { + title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), + message: __( + 'The site is too large to push. Please reduce the size of the site and try again.' + ), + } ); + await getIpcApi().removeExportedSiteTmpFile( archivePath ); + throw new Error( 'Site too large' ); + } + + // Check if cancelled before upload + const state = getState(); + const currentPushState = syncOperationsSelectors.selectPushState( + selectedSite.id, + remoteSiteId + )( state ); + if ( ! currentPushState || isKeyCancelled( currentPushState.status.key ) ) { + await getIpcApi().removeExportedSiteTmpFile( archivePath ); + throw new Error( 'Push cancelled' ); + } + + // Update to uploading + updatePushStateWithIpc( + dispatch, + selectedSite.id, + remoteSiteId, + { status: pushStatesProgressInfo.uploading }, + isKeyFailed, + isKeyFinished + ); + + try { + const response = await getIpcApi().pushArchive( + remoteSiteId, + archivePath, + options?.optionsToSync, + options?.specificSelectionPaths + ); + + // Check if cancelled after upload + const stateAfterUpload = getState(); + const pushStateAfterUpload = syncOperationsSelectors.selectPushState( + selectedSite.id, + remoteSiteId + )( stateAfterUpload ); + + if ( isKeyCancelled( pushStateAfterUpload?.status.key ) ) { + await getIpcApi().removeExportedSiteTmpFile( archivePath ); + throw new Error( 'Push cancelled' ); + } + + if ( response.success ) { + updatePushStateWithIpc( + dispatch, + selectedSite.id, + remoteSiteId, + { + status: pushStatesProgressInfo.creatingRemoteBackup, + selectedSite, + remoteSiteUrl, + }, + isKeyFailed, + isKeyFinished + ); + + // Return info needed for polling + return { + shouldStartPolling: true, + remoteSiteId, + selectedSite, + remoteSiteUrl, + }; + } else { + throw response; + } + } catch ( error ) { + Sentry.captureException( error ); + updatePushStateWithIpc( + dispatch, + selectedSite.id, + remoteSiteId, + { status: pushStatesProgressInfo.failed }, + isKeyFailed, + isKeyFinished + ); + getIpcApi().showErrorMessageBox( { + title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), + message: getErrorFromResponse( error ), + } ); + throw error; + } finally { + await getIpcApi().removeExportedSiteTmpFile( archivePath ); + } + } +); + // Export thunks object for convenience export const syncOperationsThunks = { clearPushState: clearPushStateThunk, clearPullState: clearPullStateThunk, cancelPush: cancelPushThunk, cancelPull: cancelPullThunk, + pushSite: pushSiteThunk, }; // Helper functions for checking state keys (matching useSyncStatesProgressInfo logic) From dbf3f4fb01685fee26a89b2a5b5f2646483926b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 12 Dec 2025 14:17:56 +0000 Subject: [PATCH 06/58] move pullsite to thunk --- src/hooks/sync-sites/use-sync-pull.ts | 70 +++++-------- src/stores/sync/sync-operations-slice.ts | 121 ++++++++++++++++++++++- 2 files changed, 142 insertions(+), 49 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index a734140ab9..dd0e174c9d 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -19,7 +19,7 @@ import { } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; -import { useAppDispatch, useRootSelector, type RootState } from 'src/stores'; +import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsActions, syncOperationsSelectors, @@ -157,58 +157,32 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync return; } - const remoteSiteId = connectedSite.id; - const remoteSiteUrl = connectedSite.url; - - clearPullState( selectedSite.id, remoteSiteId ); - updatePullState( selectedSite.id, remoteSiteId, { - backupId: null, - status: pullStatesProgressInfo[ 'in-progress' ], - downloadUrl: null, - remoteSiteId, - remoteSiteUrl, - selectedSite, - } ); - try { - // Initializing backup on remote - const requestBody: { - options: SyncOption[]; - include_path_list: PullSiteOptions[ 'include_path_list' ]; - } = { - options: options.optionsToSync, - include_path_list: options.include_path_list, - }; - - const response = await client.req.post< { success: boolean; backup_id: string } >( { - path: `/sites/${ remoteSiteId }/studio-app/sync/backup`, - apiNamespace: 'wpcom/v2', - body: requestBody, - } ); - - if ( response.success ) { - updatePullState( selectedSite.id, remoteSiteId, { - backupId: response.backup_id, - } ); - } else { - console.error( response ); - throw new Error( 'Pull request failed' ); - } + await dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite, + selectedSite, + options, + pullStatesProgressInfo, + } ) + ).unwrap(); + + // Sync ref with latest Redux state immediately after thunk completes + // This ensures getPullState returns the latest value without waiting for re-render + const currentState = store.getState(); + const latestPullStates = syncOperationsSelectors.selectPullStates( currentState ); + pullStatesRef.current = latestPullStates; } catch ( error ) { - console.error( 'Pull request failed:', error ); + // Sync ref even on error to ensure state is up to date + const currentState = store.getState(); + const latestPullStates = syncOperationsSelectors.selectPullStates( currentState ); + pullStatesRef.current = latestPullStates; - Sentry.captureException( error ); - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.failed, - } ); - - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pulling from %s' ), connectedSite.name ), - message: __( 'Studio was unable to connect to WordPress.com. Please try again.' ), - } ); + // Errors are already handled in the thunk (state updates, error messages) } }, - [ __, clearPullState, client, pullStatesProgressInfo, updatePullState ] + [ client, dispatch, pullStatesProgressInfo ] ); const checkBackupFileSize = async ( downloadUrl: string ): Promise< number > => { diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 73028b0f4d..460ec51166 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -1,10 +1,15 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import * as Sentry from '@sentry/electron/renderer'; import { __, sprintf } from '@wordpress/i18n'; +import { WPCOM } from 'wpcom/types'; import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; import { getIpcApi } from 'src/lib/get-ipc-api'; -import type { SyncBackupState, PullStates } from 'src/hooks/sync-sites/use-sync-pull'; +import type { + PullSiteOptions, + SyncBackupState, + PullStates, +} from 'src/hooks/sync-sites/use-sync-pull'; import type { SyncPushState, PushStates } from 'src/hooks/sync-sites/use-sync-push'; import type { PullStateProgressInfo, @@ -431,6 +436,119 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl } ); +// Thunk for pull operation +type PullSitePayload = { + client: WPCOM; + connectedSite: SyncSite; + selectedSite: SiteDetails; + options: { + optionsToSync: SyncOption[]; + include_path_list?: string[]; + }; + pullStatesProgressInfo: Record< PullStateProgressInfo[ 'key' ], PullStateProgressInfo >; +}; + +type PullSiteResult = { + backupId: string; + remoteSiteId: number; +}; + +export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayload >( + 'syncOperations/pullSite', + async ( + { client, connectedSite, selectedSite, options, pullStatesProgressInfo }, + { dispatch } + ) => { + const remoteSiteId = connectedSite.id; + const remoteSiteUrl = connectedSite.url; + + // Clear existing state + dispatch( + syncOperationsActions.clearPullState( { selectedSiteId: selectedSite.id, remoteSiteId } ) + ); + void dispatch( + syncOperationsThunks.clearPullState( { selectedSiteId: selectedSite.id, remoteSiteId } ) + ); + + // Initialize pull state + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { + backupId: null, + status: pullStatesProgressInfo[ 'in-progress' ], + downloadUrl: null, + remoteSiteId, + remoteSiteUrl, + selectedSite, + }, + } ) + ); + + // Add sync operation for tracking + const stateId = generateStateId( selectedSite.id, remoteSiteId ); + getIpcApi().addSyncOperation( stateId ); + + try { + // Initializing backup on remote + const requestBody: { + options: SyncOption[]; + include_path_list?: string[]; + } = { + options: options.optionsToSync, + include_path_list: options.include_path_list, + }; + + const response = await client.req.post< { success: boolean; backup_id: string } >( { + path: `/sites/${ remoteSiteId }/studio-app/sync/backup`, + apiNamespace: 'wpcom/v2', + body: requestBody, + } ); + + if ( response.success ) { + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { + backupId: response.backup_id, + }, + } ) + ); + + return { + backupId: response.backup_id, + remoteSiteId, + }; + } else { + console.error( response ); + throw new Error( 'Pull request failed' ); + } + } catch ( error ) { + console.error( 'Pull request failed:', error ); + + Sentry.captureException( error ); + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { + status: pullStatesProgressInfo.failed, + }, + } ) + ); + + getIpcApi().showErrorMessageBox( { + title: sprintf( __( 'Error pulling from %s' ), connectedSite.name ), + message: __( 'Studio was unable to connect to WordPress.com. Please try again.' ), + } ); + + throw error; + } + } +); + // Export thunks object for convenience export const syncOperationsThunks = { clearPushState: clearPushStateThunk, @@ -438,6 +556,7 @@ export const syncOperationsThunks = { cancelPush: cancelPushThunk, cancelPull: cancelPullThunk, pushSite: pushSiteThunk, + pullSite: pullSiteThunk, }; // Helper functions for checking state keys (matching useSyncStatesProgressInfo logic) From 6b8f4c0d0f5248c498b86f2bedc8aa4bb8e70afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 12 Dec 2025 16:17:58 +0000 Subject: [PATCH 07/58] extract polling logic to separate hook --- src/hooks/sync-sites/use-sync-polling.ts | 33 ++++++++++++++++++++ src/hooks/sync-sites/use-sync-pull.ts | 36 +++++++++++----------- src/hooks/sync-sites/use-sync-push.ts | 39 +++++++++--------------- 3 files changed, 67 insertions(+), 41 deletions(-) create mode 100644 src/hooks/sync-sites/use-sync-polling.ts diff --git a/src/hooks/sync-sites/use-sync-polling.ts b/src/hooks/sync-sites/use-sync-polling.ts new file mode 100644 index 0000000000..a45176fdc1 --- /dev/null +++ b/src/hooks/sync-sites/use-sync-polling.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; + +/** + * Generic polling hook for sync operations. + * Sets up polling intervals for states that match the condition. + * + * @param states - Record of states to check for polling + * @param shouldPoll - Function to determine if a state should be polled + * @param pollFunction - Function to call when polling (receives state key and state value) + * @param pollInterval - Interval in milliseconds (default: 2000) + */ +export function useSyncPolling< T >( + states: Record< string, T >, + shouldPoll: ( state: T, key: string ) => boolean, + pollFunction: ( key: string, state: T ) => void | Promise< void >, + pollInterval: number = 2000 +) { + useEffect( () => { + const intervals: Record< string, NodeJS.Timeout > = {}; + + Object.entries( states ).forEach( ( [ key, state ] ) => { + if ( shouldPoll( state, key ) ) { + intervals[ key ] = setTimeout( () => { + void pollFunction( key, state ); + }, pollInterval ); + } + } ); + + return () => { + Object.values( intervals ).forEach( clearTimeout ); + }; + }, [ states, shouldPoll, pollFunction, pollInterval ] ); +} diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index dd0e174c9d..9121f1594b 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -2,6 +2,7 @@ import * as Sentry from '@sentry/electron/renderer'; import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useEffect, useRef } from 'react'; +import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { SYNC_PUSH_SIZE_LIMIT_GB, SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; import { ClearState, @@ -383,25 +384,26 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync ] ); - useEffect( () => { - const intervals: Record< string, NodeJS.Timeout > = {}; - - Object.entries( pullStates ).forEach( ( [ key, state ] ) => { - if ( isKeyCancelled( state.status.key ) ) { - return; - } + // Poll for backup status when states have backupId and are in-progress + const shouldPollPull = useCallback( + ( state: SyncBackupState ) => { + return ( + ! isKeyCancelled( state.status.key ) && + !! state.backupId && + state.status.key === 'in-progress' + ); + }, + [ isKeyCancelled ] + ); - if ( state.backupId && state.status.key === 'in-progress' ) { - intervals[ key ] = setTimeout( () => { - void fetchAndUpdateBackup( state.remoteSiteId, state.selectedSite.id ); - }, 2000 ); - } - } ); + const pollBackupStatus = useCallback( + ( _key: string, state: SyncBackupState ) => { + void fetchAndUpdateBackup( state.remoteSiteId, state.selectedSite.id ); + }, + [ fetchAndUpdateBackup ] + ); - return () => { - Object.values( intervals ).forEach( clearTimeout ); - }; - }, [ pullStates, fetchAndUpdateBackup, isKeyCancelled ] ); + useSyncPolling( pullStates, shouldPollPull, pollBackupStatus, 2000 ); const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index cc4774a5b9..b0c756c143 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -1,6 +1,7 @@ import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useEffect, useRef } from 'react'; +import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { ClearState, generateStateId, @@ -322,32 +323,22 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync [ client, dispatch, pushStatesProgressInfo, getPushProgressInfo ] ); - useEffect( () => { - const intervals: Record< string, NodeJS.Timeout > = {}; - - Object.entries( pushStates ).forEach( ( [ key, state ] ) => { - if ( isKeyCancelled( state.status.key ) ) { - return; - } + // Poll for push progress when states are in importing status + const shouldPollPush = useCallback( + ( state: SyncPushState ) => { + return ! isKeyCancelled( state.status.key ) && isKeyImporting( state.status.key ); + }, + [ isKeyCancelled, isKeyImporting ] + ); - if ( isKeyImporting( state.status.key ) ) { - intervals[ key ] = setTimeout( () => { - void getPushProgressInfo( state.remoteSiteId, state ); - }, 2000 ); - } - } ); + const pollPushProgress = useCallback( + ( _key: string, state: SyncPushState ) => { + void getPushProgressInfo( state.remoteSiteId, state ); + }, + [ getPushProgressInfo ] + ); - return () => { - Object.values( intervals ).forEach( clearTimeout ); - }; - }, [ - pushStates, - getPushProgressInfo, - pushStatesProgressInfo.creatingBackup.key, - pushStatesProgressInfo.applyingChanges.key, - isKeyImporting, - isKeyCancelled, - ] ); + useSyncPolling( pushStates, shouldPollPush, pollPushProgress, 2000 ); const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); From f55dc4813e8ecda118e8289b504831de08d90948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 22 Dec 2025 12:29:51 +0000 Subject: [PATCH 08/58] Remove SyncSitesProvider context, use hooks directly Remove the SyncSitesProvider React context layer and migrate all components to use useSyncPull and useSyncPush hooks directly. Changes: - Extract getLastSyncTimeText to useLastSyncTimeText hook - Extract initialization logic to useInitializeSyncStates hook - Move useListenDeepLinkConnection and initialization to App component - Update all components to use hooks directly instead of context - Remove sync-sites-context.tsx --- src/components/app.tsx | 6 + src/components/content-tab-import-export.tsx | 6 +- src/components/publish-site-button.tsx | 6 +- src/components/root.tsx | 5 +- src/components/site-management-actions.tsx | 4 +- src/components/site-menu.tsx | 4 +- src/hooks/sync-sites/index.ts | 5 +- src/hooks/sync-sites/sync-sites-context.tsx | 158 ------------------ .../sync-sites/use-initialize-sync-states.ts | 77 +++++++++ .../sync-sites/use-last-sync-time-text.ts | 30 ++++ src/hooks/sync-sites/use-sync-push.ts | 11 +- src/hooks/use-add-site.ts | 4 +- .../sync/components/sync-connected-sites.tsx | 23 ++- src/modules/sync/index.tsx | 6 +- 14 files changed, 151 insertions(+), 194 deletions(-) delete mode 100644 src/hooks/sync-sites/sync-sites-context.tsx create mode 100644 src/hooks/sync-sites/use-initialize-sync-states.ts create mode 100644 src/hooks/sync-sites/use-last-sync-time-text.ts diff --git a/src/components/app.tsx b/src/components/app.tsx index 86430fa575..3bc1cfcdea 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -9,6 +9,8 @@ import { NoStudioSites } from 'src/components/no-studio-sites'; import { SiteContentTabs } from 'src/components/site-content-tabs'; import TopBar from 'src/components/top-bar'; import WindowsTitlebar from 'src/components/windows-titlebar'; +import { useInitializeSyncStates } from 'src/hooks/sync-sites/use-initialize-sync-states'; +import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; import { useLocalizationSupport } from 'src/hooks/use-localization-support'; import { useSidebarVisibility } from 'src/hooks/use-sidebar-visibility'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -32,6 +34,10 @@ export default function App() { const { sites: localSites, loadingSites } = useSiteDetails(); const isEmpty = ! loadingSites && ! localSites.length; + // Initialize sync states and listen for deep link connections + useInitializeSyncStates(); + useListenDeepLinkConnection(); + useEffect( () => { void getIpcApi().setupAppMenu( { needsOnboarding } ); }, [ needsOnboarding ] ); diff --git a/src/components/content-tab-import-export.tsx b/src/components/content-tab-import-export.tsx index ce64e8d1c7..db3f6f18e1 100644 --- a/src/components/content-tab-import-export.tsx +++ b/src/components/content-tab-import-export.tsx @@ -11,7 +11,8 @@ import { ErrorIcon } from 'src/components/error-icon'; import ProgressBar from 'src/components/progress-bar'; import { Tooltip } from 'src/components/tooltip'; import { ACCEPTED_IMPORT_FILE_TYPES } from 'src/constants'; -import { useSyncSites } from 'src/hooks/sync-sites/sync-sites-context'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; +import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog'; import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file'; @@ -352,7 +353,8 @@ const ImportSite = ( { export function ContentTabImportExport( { selectedSite }: ContentTabImportExportProps ) { const { __ } = useI18n(); const [ isSupported, setIsSupported ] = useState< boolean | null >( null ); - const { isSiteIdPulling, isSiteIdPushing } = useSyncSites(); + const { isSiteIdPulling } = useSyncPull(); + const { isSiteIdPushing } = useSyncPush(); const { user } = useAuth(); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, diff --git a/src/components/publish-site-button.tsx b/src/components/publish-site-button.tsx index 6551c9d340..7804437a1a 100644 --- a/src/components/publish-site-button.tsx +++ b/src/components/publish-site-button.tsx @@ -1,7 +1,8 @@ import { cloudUpload } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback } from 'react'; -import { useSyncSites } from 'src/hooks/sync-sites'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; +import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -22,7 +23,8 @@ export const PublishSiteButton = () => { localSiteId: selectedSite?.id, userId: user?.id, } ); - const { isAnySitePulling, isAnySitePushing } = useSyncSites(); + const { isAnySitePulling } = useSyncPull(); + const { isAnySitePushing } = useSyncPush(); const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; const handlePublishClick = useCallback( () => { diff --git a/src/components/root.tsx b/src/components/root.tsx index a8bbddd1fb..5a02fc29e4 100644 --- a/src/components/root.tsx +++ b/src/components/root.tsx @@ -9,7 +9,6 @@ import AuthProvider from 'src/components/auth-provider'; import CrashTester from 'src/components/crash-tester'; import ErrorBoundary from 'src/components/error-boundary'; import { WordPressStyles } from 'src/components/wordpress-styles'; -import { SyncSitesProvider } from 'src/hooks/sync-sites/sync-sites-context'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { FeatureFlagsProvider } from 'src/hooks/use-feature-flags'; import { ImportExportProvider } from 'src/hooks/use-import-export'; @@ -43,9 +42,7 @@ const Root = () => { - - - + diff --git a/src/components/site-management-actions.tsx b/src/components/site-management-actions.tsx index 1329a29b22..73602a468d 100644 --- a/src/components/site-management-actions.tsx +++ b/src/components/site-management-actions.tsx @@ -3,7 +3,7 @@ import { useI18n } from '@wordpress/react-i18n'; import { ActionButton } from 'src/components/action-button'; import { PublishSiteButton } from 'src/components/publish-site-button'; import { Tooltip } from 'src/components/tooltip'; -import { useSyncSites } from 'src/hooks/sync-sites'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; import { useImportExport } from 'src/hooks/use-import-export'; export interface SiteManagementActionProps { @@ -21,7 +21,7 @@ export const SiteManagementActions = ( { }: SiteManagementActionProps ) => { const { __ } = useI18n(); const { isSiteImporting } = useImportExport(); - const { isSiteIdPulling } = useSyncSites(); + const { isSiteIdPulling } = useSyncPull(); if ( ! selectedSite ) { return null; diff --git a/src/components/site-menu.tsx b/src/components/site-menu.tsx index 9776ba25c8..49e8d1b44a 100644 --- a/src/components/site-menu.tsx +++ b/src/components/site-menu.tsx @@ -4,7 +4,7 @@ import { Spinner } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { useEffect } from 'react'; import { Tooltip } from 'src/components/tooltip'; -import { useSyncSites } from 'src/hooks/sync-sites'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useDeleteSite } from 'src/hooks/use-delete-site'; import { useImportExport } from 'src/hooks/use-import-export'; @@ -118,7 +118,7 @@ function SiteItem( { site }: { site: SiteDetails } ) { const { selectedSite, setSelectedSiteId, loadingServer } = useSiteDetails(); const isSelected = site === selectedSite; const { isSiteImporting, isSiteExporting } = useImportExport(); - const { isSiteIdPulling } = useSyncSites(); + const { isSiteIdPulling } = useSyncPull(); const { data: editor } = useGetUserEditorQuery(); const { data: terminal } = useGetUserTerminalQuery(); const isImporting = isSiteImporting( site.id ); diff --git a/src/hooks/sync-sites/index.ts b/src/hooks/sync-sites/index.ts index 180e3e1832..476730492a 100644 --- a/src/hooks/sync-sites/index.ts +++ b/src/hooks/sync-sites/index.ts @@ -1,2 +1,5 @@ -export * from './sync-sites-context'; export type { SyncBackupState } from './use-sync-pull'; +export { useSyncPull } from './use-sync-pull'; +export { useSyncPush } from './use-sync-push'; +export { useLastSyncTimeText } from './use-last-sync-time-text'; +export type { GetLastSyncTimeText } from './use-last-sync-time-text'; diff --git a/src/hooks/sync-sites/sync-sites-context.tsx b/src/hooks/sync-sites/sync-sites-context.tsx deleted file mode 100644 index 0212ee2532..0000000000 --- a/src/hooks/sync-sites/sync-sites-context.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { __, sprintf } from '@wordpress/i18n'; -import React, { createContext, useCallback, useContext, useEffect } from 'react'; -import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; -import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; -import { UseSyncPull, useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; -import { - UseSyncPush, - useSyncPush, - mapImportResponseToPushState, -} from 'src/hooks/sync-sites/use-sync-push'; -import { useAuth } from 'src/hooks/use-auth'; -import { useFormatLocalizedTimestamps } from 'src/hooks/use-format-localized-timestamps'; -import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useAppDispatch } from 'src/stores'; -import { syncOperationsActions } from 'src/stores/sync'; -import { useUpdateSiteTimestampMutation } from 'src/stores/sync/connected-sites'; -import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; - -type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string; - -export type SyncSitesContextType = Omit< UseSyncPull, 'pullStates' > & - Omit< UseSyncPush, 'pushStates' > & { - getLastSyncTimeText: GetLastSyncTimeText; - }; - -const SyncSitesContext = createContext< SyncSitesContextType | undefined >( undefined ); - -export function SyncSitesProvider( { children }: { children: React.ReactNode } ) { - const { formatRelativeTime } = useFormatLocalizedTimestamps(); - - const getLastSyncTimeText = useCallback< GetLastSyncTimeText >( - ( timestamp, type ) => { - if ( ! timestamp ) { - return type === 'pull' - ? __( 'You have not pulled this site yet.' ) - : __( 'You have not pushed this site yet.' ); - } - - return sprintf( - type === 'pull' - ? __( 'You pulled this site %s ago.' ) - : __( 'You pushed this site %s ago.' ), - formatRelativeTime( timestamp ) - ); - }, - [ formatRelativeTime ] - ); - - const dispatch = useAppDispatch(); - const [ updateSiteTimestamp ] = useUpdateSiteTimestampMutation(); - - const { pullSite, isAnySitePulling, isSiteIdPulling, clearPullState, getPullState, cancelPull } = - useSyncPull( { - onPullSuccess: ( remoteSiteId, localSiteId ) => - updateSiteTimestamp( { siteId: remoteSiteId, localSiteId, type: 'pull' } ), - } ); - - const { pushSite, isAnySitePushing, isSiteIdPushing, clearPushState, getPushState, cancelPush } = - useSyncPush( { - onPushSuccess: ( remoteSiteId, localSiteId ) => - updateSiteTimestamp( { siteId: remoteSiteId, localSiteId, type: 'push' } ), - } ); - - useListenDeepLinkConnection(); - - const { client } = useAuth(); - const { pushStatesProgressInfo } = useSyncStatesProgressInfo(); - - // Initialize push states from in-progress server operations on mount - useEffect( () => { - if ( ! client ) { - return; - } - - const initializePushStates = async () => { - const allSites = await getIpcApi().getSiteDetails(); - const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); - - for ( const connectedSite of allConnectedSites ) { - try { - const localSite = allSites.find( ( site ) => site.id === connectedSite.localSiteId ); - const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected'; - - if ( ! localSite || hasConnectionErrors ) { - continue; - } - - const response = ( await client.req.get( - `/sites/${ connectedSite.id }/studio-app/sync/import`, - { - apiNamespace: 'wpcom/v2', - } - ) ) as ImportResponse; - - const status = mapImportResponseToPushState( response, pushStatesProgressInfo ); - - // Only restore the pushStates if the operation is still in progress - if ( status ) { - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId: connectedSite.localSiteId, - remoteSiteId: connectedSite.id, - state: { - remoteSiteId: connectedSite.id, - status, - selectedSite: localSite, - remoteSiteUrl: connectedSite.url, - }, - } ) - ); - - const stateId = generateStateId( connectedSite.localSiteId, connectedSite.id ); - getIpcApi().addSyncOperation( stateId, status ); - } - } catch ( error ) { - // Continue checking other sites even if one fails - console.error( `Failed to check push progress for site ${ connectedSite.id }:`, error ); - } - } - }; - - initializePushStates().catch( ( error ) => { - // Initialization is not critical to app functionality, but log the error - console.error( 'Failed to initialize push states from server:', error ); - } ); - }, [ client, pushStatesProgressInfo, dispatch ] ); - - return ( - - { children } - - ); -} - -export function useSyncSites() { - const context = useContext( SyncSitesContext ); - if ( context === undefined ) { - throw new Error( 'useSyncSites must be used within a SyncSitesProvider' ); - } - return context; -} diff --git a/src/hooks/sync-sites/use-initialize-sync-states.ts b/src/hooks/sync-sites/use-initialize-sync-states.ts new file mode 100644 index 0000000000..63b0ddeb5b --- /dev/null +++ b/src/hooks/sync-sites/use-initialize-sync-states.ts @@ -0,0 +1,77 @@ +import { useEffect } from 'react'; +import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; +import { mapImportResponseToPushState } from 'src/hooks/sync-sites/use-sync-push'; +import { useAuth } from 'src/hooks/use-auth'; +import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useAppDispatch } from 'src/stores'; +import { syncOperationsActions } from 'src/stores/sync'; +import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; + +/** + * Hook to initialize push states from in-progress server operations on mount. + * This restores push state for any operations that were in progress when the app was closed. + */ +export function useInitializeSyncStates() { + const { client } = useAuth(); + const { pushStatesProgressInfo } = useSyncStatesProgressInfo(); + const dispatch = useAppDispatch(); + + useEffect( () => { + if ( ! client ) { + return; + } + + const initializePushStates = async () => { + const allSites = await getIpcApi().getSiteDetails(); + const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); + + for ( const connectedSite of allConnectedSites ) { + try { + const localSite = allSites.find( ( site ) => site.id === connectedSite.localSiteId ); + const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected'; + + if ( ! localSite || hasConnectionErrors ) { + continue; + } + + const response = ( await client.req.get( + `/sites/${ connectedSite.id }/studio-app/sync/import`, + { + apiNamespace: 'wpcom/v2', + } + ) ) as ImportResponse; + + const status = mapImportResponseToPushState( response, pushStatesProgressInfo ); + + // Only restore the pushStates if the operation is still in progress + if ( status ) { + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: connectedSite.localSiteId, + remoteSiteId: connectedSite.id, + state: { + remoteSiteId: connectedSite.id, + status, + selectedSite: localSite, + remoteSiteUrl: connectedSite.url, + }, + } ) + ); + + const stateId = generateStateId( connectedSite.localSiteId, connectedSite.id ); + getIpcApi().addSyncOperation( stateId, status ); + } + } catch ( error ) { + // Continue checking other sites even if one fails + console.error( `Failed to check push progress for site ${ connectedSite.id }:`, error ); + } + } + }; + + initializePushStates().catch( ( error ) => { + // Initialization is not critical to app functionality, but log the error + console.error( 'Failed to initialize push states from server:', error ); + } ); + }, [ client, pushStatesProgressInfo, dispatch ] ); +} diff --git a/src/hooks/sync-sites/use-last-sync-time-text.ts b/src/hooks/sync-sites/use-last-sync-time-text.ts new file mode 100644 index 0000000000..3dac586efa --- /dev/null +++ b/src/hooks/sync-sites/use-last-sync-time-text.ts @@ -0,0 +1,30 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { useCallback } from 'react'; +import { useFormatLocalizedTimestamps } from 'src/hooks/use-format-localized-timestamps'; + +export type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string; + +/** + * Hook that returns a function to format the last sync time text. + */ +export function useLastSyncTimeText(): GetLastSyncTimeText { + const { formatRelativeTime } = useFormatLocalizedTimestamps(); + + return useCallback< GetLastSyncTimeText >( + ( timestamp, type ) => { + if ( ! timestamp ) { + return type === 'pull' + ? __( 'You have not pulled this site yet.' ) + : __( 'You have not pushed this site yet.' ); + } + + return sprintf( + type === 'pull' + ? __( 'You pulled this site %s ago.' ) + : __( 'You pushed this site %s ago.' ), + formatRelativeTime( timestamp ) + ); + }, + [ formatRelativeTime ] + ); +} diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index b0c756c143..1318c4d5e1 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -1,13 +1,13 @@ import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useEffect, useRef } from 'react'; -import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { ClearState, generateStateId, GetState, UpdateState, } from 'src/hooks/sync-sites/use-pull-push-states'; +import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { useAuth } from 'src/hooks/use-auth'; import { useSyncStatesProgressInfo, @@ -291,11 +291,10 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync } ) ).unwrap(); - // Sync ref with latest Redux state immediately after thunk completes - // This ensures getPushState returns the latest value without waiting for re-render - const currentState = store.getState(); - const latestPushStates = syncOperationsSelectors.selectPushStates( currentState ); - pushStatesRef.current = latestPushStates; + // Sync ref again after thunk completes to ensure we have the final state + const finalState = store.getState(); + const finalPushStates = syncOperationsSelectors.selectPushStates( finalState ); + pushStatesRef.current = finalPushStates; // If thunk completed successfully and returned polling info, start polling if ( result.shouldStartPolling ) { diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts index 53afe155ed..704d604434 100644 --- a/src/hooks/use-add-site.ts +++ b/src/hooks/use-add-site.ts @@ -3,7 +3,7 @@ import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo, useState } from 'react'; import { BlueprintValidationWarning } from 'common/lib/blueprint-validation'; import { generateCustomDomainFromSiteName, getDomainNameValidationError } from 'common/lib/domains'; -import { useSyncSites } from 'src/hooks/sync-sites'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -30,7 +30,7 @@ export function useAddSite( options: UseAddSiteOptions = {} ) { const { createSite, sites, startServer } = useSiteDetails(); const { importFile, clearImportState, importState } = useImportExport(); const [ connectSite ] = useConnectSiteMutation(); - const { pullSite } = useSyncSites(); + const { pullSite } = useSyncPull(); const { setSelectedTab } = useContentTabs(); const defaultPhpVersion = useRootSelector( selectDefaultPhpVersion ); const defaultWordPressVersion = useRootSelector( selectDefaultWordPressVersion ); diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index fd2cb10de3..b9ca9ac9bb 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -13,7 +13,9 @@ import { PressableLogo } from 'src/components/pressable-logo'; import ProgressBar from 'src/components/progress-bar'; import { Tooltip, DynamicTooltip } from 'src/components/tooltip'; import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; -import { useSyncSites } from 'src/hooks/sync-sites'; +import { useLastSyncTimeText } from 'src/hooks/sync-sites/use-last-sync-time-text'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; +import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { useImportExport } from 'src/hooks/use-import-export'; import { useOffline } from 'src/hooks/use-offline'; @@ -50,15 +52,9 @@ const SyncConnectedSiteControls = ( { const { __ } = useI18n(); const isOffline = useOffline(); const [ syncDialogType, setSyncDialogType ] = useState< 'pull' | 'push' | null >( null ); - const { - pullSite, - isAnySitePulling, - isAnySitePushing, - pushSite, - isSiteIdPulling, - isSiteIdPushing, - getLastSyncTimeText, - } = useSyncSites(); + const { pullSite, isAnySitePulling, isSiteIdPulling } = useSyncPull(); + const { pushSite, isAnySitePushing, isSiteIdPushing } = useSyncPush(); + const getLastSyncTimeText = useLastSyncTimeText(); const { user } = useAuth(); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, @@ -189,8 +185,8 @@ const SyncConnectedSitesSectionItem = ( { connectedSite, }: SyncConnectedSitesListProps ) => { const { __ } = useI18n(); - const { clearPullState, getPullState, getPushState, clearPushState, cancelPull, cancelPush } = - useSyncSites(); + const { clearPullState, getPullState, cancelPull } = useSyncPull(); + const { getPushState, clearPushState, cancelPush } = useSyncPush(); const { importState } = useImportExport(); const { isKeyPulling, @@ -375,7 +371,8 @@ const SyncConnectedSiteSection = ( { const { __ } = useI18n(); const dispatch = useAppDispatch(); const locale = useI18nLocale(); - const { clearPullState, isSiteIdPulling, isSiteIdPushing } = useSyncSites(); + const { clearPullState, isSiteIdPulling } = useSyncPull(); + const { isSiteIdPushing } = useSyncPush(); const isOffline = useOffline(); const handleDisconnectSite = async () => { diff --git a/src/modules/sync/index.tsx b/src/modules/sync/index.tsx index 5334c660fd..28fef5831c 100644 --- a/src/modules/sync/index.tsx +++ b/src/modules/sync/index.tsx @@ -5,7 +5,8 @@ import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; -import { useSyncSites } from 'src/hooks/sync-sites'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; +import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; @@ -135,7 +136,8 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } } ); const [ connectSite ] = useConnectSiteMutation(); const [ disconnectSite ] = useDisconnectSiteMutation(); - const { pushSite, pullSite } = useSyncSites(); + const { pullSite } = useSyncPull(); + const { pushSite } = useSyncPush(); const connectedSiteIds = connectedSites.map( ( { id } ) => id ); const { data: syncSites = [] } = useGetWpComSitesQuery( { From a284a771bf884b5d06079ae8a8f35d99b9850c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 22 Dec 2025 15:25:07 +0000 Subject: [PATCH 09/58] move polling logic to thunk --- src/hooks/sync-sites/use-sync-pull.ts | 349 ++---------------- src/hooks/sync-sites/use-sync-push.ts | 225 ++---------- src/stores/sync/sync-operations-slice.ts | 447 ++++++++++++++++++++++- 3 files changed, 499 insertions(+), 522 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index 9121f1594b..818c26aa76 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -1,25 +1,11 @@ -import * as Sentry from '@sentry/electron/renderer'; -import { sprintf } from '@wordpress/i18n'; -import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback } from 'react'; import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; -import { SYNC_PUSH_SIZE_LIMIT_GB, SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; -import { - ClearState, - generateStateId, - GetState, - UpdateState, -} from 'src/hooks/sync-sites/use-pull-push-states'; +import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; import { useAuth } from 'src/hooks/use-auth'; -import { useImportExport } from 'src/hooks/use-import-export'; -import { useSiteDetails } from 'src/hooks/use-site-details'; import { PullStateProgressInfo, - SyncBackupResponse, useSyncStatesProgressInfo, } from 'src/hooks/use-sync-states-progress-info'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { getHostnameFromUrl } from 'src/lib/url-utils'; import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsActions, @@ -69,80 +55,24 @@ export type UseSyncPull = { }; export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSyncPull { - const { __ } = useI18n(); const { client } = useAuth(); - const { importFile, clearImportState } = useImportExport(); - const { - pullStatesProgressInfo, - isKeyPulling, - isKeyFinished, - isKeyFailed, - isKeyCancelled, - getBackupStatusWithProgress, - } = useSyncStatesProgressInfo(); + const { pullStatesProgressInfo } = useSyncStatesProgressInfo(); const dispatch = useAppDispatch(); const pullStates = useRootSelector( syncOperationsSelectors.selectPullStates as ( state: RootState ) => PullStates ); - const pullStatesRef = useRef( pullStates ); - - // Keep ref in sync with Redux state - useEffect( () => { - pullStatesRef.current = pullStates; - }, [ pullStates ] ); - - const updateState = useCallback< UpdateState< SyncBackupState > >( - ( selectedSiteId, remoteSiteId, state ) => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - // Immediately update the ref so getPullState returns the latest value - pullStatesRef.current = { - ...pullStatesRef.current, - [ stateId ]: { - ...pullStatesRef.current[ stateId ], - ...state, - } as SyncBackupState, - }; - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId, - remoteSiteId, - state, - } ) - ); - }, - [ dispatch ] - ); const getPullState = useCallback< GetState< SyncBackupState > >( ( selectedSiteId, remoteSiteId ) => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - return pullStatesRef.current[ stateId ]; + const state = store.getState(); + return syncOperationsSelectors.selectPullState( selectedSiteId, remoteSiteId )( state ); }, [] ); - const updatePullState = useCallback< UpdateState< SyncBackupState > >( - ( selectedSiteId, remoteSiteId, state ) => { - updateState( selectedSiteId, remoteSiteId, state ); - const statusKey = state.status?.key; - - if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) || isKeyCancelled( statusKey ) ) { - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } else { - getIpcApi().addSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } - }, - [ isKeyFailed, isKeyFinished, isKeyCancelled, updateState ] - ); - const clearPullState = useCallback< ClearState >( ( selectedSiteId, remoteSiteId ) => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - // Immediately update the ref so getPullState returns undefined right away - const newStates = { ...pullStatesRef.current }; - delete newStates[ stateId ]; - pullStatesRef.current = newStates; // Dispatch both the action and the thunk dispatch( syncOperationsActions.clearPullState( { selectedSiteId, remoteSiteId } ) ); void dispatch( syncOperationsThunks.clearPullState( { selectedSiteId, remoteSiteId } ) ); @@ -150,7 +80,22 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync [ dispatch ] ); - const { startServer } = useSiteDetails(); + const fetchAndUpdateBackup = useCallback( + async ( remoteSiteId: number, selectedSiteId: string ) => { + if ( ! client ) { + return; + } + void dispatch( + syncOperationsThunks.pollPullBackup( { + client, + selectedSiteId, + remoteSiteId, + pullStatesProgressInfo, + } ) + ); + }, + [ client, dispatch, pullStatesProgressInfo ] + ); const pullSite = useCallback< PullSite >( async ( connectedSite, selectedSite, options ) => { @@ -159,7 +104,7 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync } try { - await dispatch( + const result = await dispatch( syncOperationsThunks.pullSite( { client, connectedSite, @@ -169,232 +114,23 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync } ) ).unwrap(); - // Sync ref with latest Redux state immediately after thunk completes - // This ensures getPullState returns the latest value without waiting for re-render - const currentState = store.getState(); - const latestPullStates = syncOperationsSelectors.selectPullStates( currentState ); - pullStatesRef.current = latestPullStates; - } catch ( error ) { - // Sync ref even on error to ensure state is up to date - const currentState = store.getState(); - const latestPullStates = syncOperationsSelectors.selectPullStates( currentState ); - pullStatesRef.current = latestPullStates; - - // Errors are already handled in the thunk (state updates, error messages) - } - }, - [ client, dispatch, pullStatesProgressInfo ] - ); - - const checkBackupFileSize = async ( downloadUrl: string ): Promise< number > => { - try { - return await getIpcApi().checkSyncBackupSize( downloadUrl ); - } catch ( error ) { - console.error( 'Failed to check backup file size', error ); - Sentry.captureException( error ); - throw new Error( 'Failed to check backup file size' ); - } - }; - - const onBackupCompleted = useCallback( - async ( remoteSiteId: number, backupState: SyncBackupState & { downloadUrl: string } ) => { - const { downloadUrl, selectedSite, remoteSiteUrl } = backupState; - - try { - const fileSize = await checkBackupFileSize( downloadUrl ); - - if ( fileSize > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - const CANCEL_ID = 1; - - const { response: userChoice } = await getIpcApi().showMessageBox( { - type: 'warning', - message: __( "Large site's backup" ), - detail: sprintf( - __( - "Your site's backup exceeds %d GB. Pulling it will prevent you from pushing the site back.\n\nDo you want to continue?" - ), - SYNC_PUSH_SIZE_LIMIT_GB - ), - buttons: [ __( 'Continue' ), __( 'Cancel' ) ], - defaultId: 0, - cancelId: CANCEL_ID, - } ); - - if ( userChoice === CANCEL_ID ) { - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.cancelled, - } ); - clearPullState( selectedSite.id, remoteSiteId ); - return; - } - } - - // Initiating backup file download - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.downloading, - downloadUrl, - } ); - - const operationId = generateStateId( selectedSite.id, remoteSiteId ); - const filePath = await getIpcApi().downloadSyncBackup( - remoteSiteId, - downloadUrl, - operationId - ); - - const stateAfterDownload = getPullState( selectedSite.id, remoteSiteId ); - if ( ! stateAfterDownload || isKeyCancelled( stateAfterDownload?.status.key ) ) { - return; - } - - // Starting import process - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.importing, - } ); - - await importFile( - { - path: filePath, - type: 'application/tar+gzip', - }, - selectedSite, - { showImportNotification: false } - ); - - await getIpcApi().removeSyncBackup( remoteSiteId ); - - await startServer( selectedSite.id ); - - clearImportState( selectedSite.id ); - - // Sync pull operation completed successfully - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.finished, - } ); - - getIpcApi().showNotification( { - title: selectedSite.name, - body: sprintf( - // translators: %s is the site url without the protocol. - __( 'Studio site has been updated from %s' ), - getHostnameFromUrl( remoteSiteUrl ) - ), - } ); - - onPullSuccess?.( remoteSiteId, selectedSite.id ); - } catch ( error ) { - console.error( 'Backup completion failed:', error ); - - const currentState = getPullState( selectedSite.id, remoteSiteId ); - if ( currentState && isKeyCancelled( currentState?.status.key ) ) { - return; - } - - Sentry.captureException( error ); - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pulling from %s' ), selectedSite.name ), - message: __( 'Failed to check backup file size. Please try again.' ), - } ); - } - }, - [ - __, - clearImportState, - clearPullState, - getPullState, - importFile, - onPullSuccess, - isKeyCancelled, - pullStatesProgressInfo.cancelled, - pullStatesProgressInfo.downloading, - pullStatesProgressInfo.failed, - pullStatesProgressInfo.finished, - pullStatesProgressInfo.importing, - startServer, - updatePullState, - ] - ); - - const fetchAndUpdateBackup = useCallback( - async ( remoteSiteId: number, selectedSiteId: string ) => { - if ( ! client ) { - return; - } - - const currentState = getPullState( selectedSiteId, remoteSiteId ); - if ( currentState && isKeyCancelled( currentState.status.key ) ) { - return; - } - - const backupId = currentState?.backupId; - if ( ! backupId ) { - console.error( 'No backup ID found' ); - return; - } - - try { - const response = await client.req.get< SyncBackupResponse >( - `/sites/${ remoteSiteId }/studio-app/sync/backup`, - { - apiNamespace: 'wpcom/v2', - backup_id: backupId, - } - ); - - const hasBackupCompleted = response.status === 'finished'; - const downloadUrl = hasBackupCompleted ? response.download_url : null; - - if ( downloadUrl ) { - // Replacing the 'in-progress' status will stop the active listening for the backup completion - const backupState = getPullState( selectedSiteId, remoteSiteId ); - if ( backupState ) { - await onBackupCompleted( remoteSiteId, { - ...backupState, - downloadUrl, - } ); - } - } else { - const statusWithProgress = getBackupStatusWithProgress( - hasBackupCompleted, - pullStatesProgressInfo, - response - ); - - updatePullState( selectedSiteId, remoteSiteId, { - status: statusWithProgress, - downloadUrl, - } ); + // Start polling once backupId is set + if ( result.backupId ) { + void fetchAndUpdateBackup( result.remoteSiteId, selectedSite.id ); } } catch ( error ) { - console.error( 'Failed to fetch backup status:', error ); - throw error; + // Errors are already handled in the thunk (state updates, error messages) } }, - [ - client, - getBackupStatusWithProgress, - getPullState, - onBackupCompleted, - pullStatesProgressInfo, - updatePullState, - isKeyCancelled, - ] + [ client, dispatch, pullStatesProgressInfo, fetchAndUpdateBackup ] ); // Poll for backup status when states have backupId and are in-progress - const shouldPollPull = useCallback( - ( state: SyncBackupState ) => { - return ( - ! isKeyCancelled( state.status.key ) && - !! state.backupId && - state.status.key === 'in-progress' - ); - }, - [ isKeyCancelled ] - ); + const shouldPollPull = useCallback( ( state: SyncBackupState ) => { + return ( + state.status.key !== 'cancelled' && !! state.backupId && state.status.key === 'in-progress' + ); + }, [] ); const pollBackupStatus = useCallback( ( _key: string, state: SyncBackupState ) => { @@ -407,23 +143,10 @@ export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSync const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); - const isSiteIdPulling = useCallback< IsSiteIdPulling >( - ( selectedSiteId, remoteSiteId ) => { - return Object.values( pullStates ).some( ( state ) => { - if ( ! state.selectedSite ) { - return false; - } - if ( state.selectedSite.id !== selectedSiteId ) { - return false; - } - if ( remoteSiteId !== undefined ) { - return isKeyPulling( state.status.key ) && state.remoteSiteId === remoteSiteId; - } - return isKeyPulling( state.status.key ); - } ); - }, - [ pullStates, isKeyPulling ] - ); + const isSiteIdPulling = useCallback< IsSiteIdPulling >( ( selectedSiteId, remoteSiteId ) => { + const state = store.getState(); + return syncOperationsSelectors.selectIsSiteIdPulling( selectedSiteId, remoteSiteId )( state ); + }, [] ); const cancelPull = useCallback< CancelPull >( async ( selectedSiteId, remoteSiteId ) => { diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 1318c4d5e1..8b2a5f6ec9 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -1,27 +1,19 @@ -import { sprintf } from '@wordpress/i18n'; -import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useRef } from 'react'; -import { - ClearState, - generateStateId, - GetState, - UpdateState, -} from 'src/hooks/sync-sites/use-pull-push-states'; +import { useCallback } from 'react'; +import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { useAuth } from 'src/hooks/use-auth'; import { + ImportResponse, useSyncStatesProgressInfo, PushStateProgressInfo, } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; -import { getHostnameFromUrl } from 'src/lib/url-utils'; import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsActions, syncOperationsSelectors, syncOperationsThunks, } from 'src/stores/sync'; -import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; import type { SyncSite } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; @@ -86,100 +78,24 @@ export function mapImportResponseToPushState( } export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSyncPush { - const { __ } = useI18n(); const { client } = useAuth(); const dispatch = useAppDispatch(); const pushStates = useRootSelector( syncOperationsSelectors.selectPushStates as ( state: RootState ) => PushStates ); - const pushStatesRef = useRef( pushStates ); - - // Keep ref in sync with Redux state - useEffect( () => { - pushStatesRef.current = pushStates; - }, [ pushStates ] ); - - const updateState = useCallback< UpdateState< SyncPushState > >( - ( selectedSiteId, remoteSiteId, state ) => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - // Immediately update the ref so getPushState returns the latest value - pushStatesRef.current = { - ...pushStatesRef.current, - [ stateId ]: { - ...pushStatesRef.current[ stateId ], - ...state, - } as SyncPushState, - }; - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId, - remoteSiteId, - state, - } ) - ); - }, - [ dispatch ] - ); + const { pushStatesProgressInfo } = useSyncStatesProgressInfo(); const getPushState = useCallback< GetState< SyncPushState > >( ( selectedSiteId, remoteSiteId ) => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - return pushStatesRef.current[ stateId ]; + const state = store.getState(); + return syncOperationsSelectors.selectPushState( selectedSiteId, remoteSiteId )( state ); }, [] ); - const clearState = useCallback< ClearState >( - ( selectedSiteId, remoteSiteId ) => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - // Immediately update the ref so getPushState returns undefined right away - const newStates = { ...pushStatesRef.current }; - delete newStates[ stateId ]; - pushStatesRef.current = newStates; - dispatch( - syncOperationsActions.clearPushState( { - selectedSiteId, - remoteSiteId, - } ) - ); - }, - [ dispatch ] - ); - const { - pushStatesProgressInfo, - isKeyPushing, - isKeyImporting, - isKeyFinished, - isKeyFailed, - isKeyCancelled, - getPushStatusWithProgress, - } = useSyncStatesProgressInfo(); - - const updatePushState = useCallback< UpdateState< SyncPushState > >( - ( selectedSiteId, remoteSiteId, state ) => { - updateState( selectedSiteId, remoteSiteId, state ); - const statusKey = state.status?.key; - - if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) || isKeyCancelled( statusKey ) ) { - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } else if ( state.status ) { - getIpcApi().addSyncOperation( - generateStateId( selectedSiteId, remoteSiteId ), - state.status - ); - } - }, - [ isKeyFailed, isKeyFinished, isKeyCancelled, updateState ] - ); - const clearPushState = useCallback< ClearState >( ( selectedSiteId, remoteSiteId ) => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - // Immediately update the ref so getPushState returns undefined right away - const newStates = { ...pushStatesRef.current }; - delete newStates[ stateId ]; - pushStatesRef.current = newStates; // Dispatch both the action and the thunk dispatch( syncOperationsActions.clearPushState( { selectedSiteId, remoteSiteId } ) ); void dispatch( syncOperationsThunks.clearPushState( { selectedSiteId, remoteSiteId } ) ); @@ -192,87 +108,16 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync if ( ! client ) { return; } - const currentState = getPushState( syncPushState.selectedSite.id, remoteSiteId ); - - if ( ! currentState || isKeyCancelled( currentState?.status.key ) ) { - return; - } - - const response = await client.req.get< ImportResponse >( - `/sites/${ remoteSiteId }/studio-app/sync/import`, - { - apiNamespace: 'wpcom/v2', - } + void dispatch( + syncOperationsThunks.pollPushProgress( { + client, + selectedSiteId: syncPushState.selectedSite.id, + remoteSiteId, + pushStatesProgressInfo, + } ) ); - - let status: PushStateProgressInfo = pushStatesProgressInfo.creatingRemoteBackup; - if ( response.success && response.status === 'finished' ) { - status = pushStatesProgressInfo.finished; - onPushSuccess?.( remoteSiteId, syncPushState.selectedSite.id ); - getIpcApi().showNotification( { - title: syncPushState.selectedSite.name, - body: sprintf( - // translators: %s is the site url without the protocol. - __( '%s has been updated' ), - getHostnameFromUrl( syncPushState.remoteSiteUrl ) - ), - } ); - } else if ( response.success && response.status === 'failed' ) { - status = pushStatesProgressInfo.failed; - console.error( 'Push import failed:', { - remoteSiteId: syncPushState.remoteSiteId, - error: response.error, - error_data: response.error_data, - } ); - // If the impport fails due to a SQL import error, show a more specific message - const restoreMessage = response.error_data?.vp_restore_message || ''; - const isSqlImportFailure = /importing sql dump/i.test( restoreMessage ); - const isImportTimedOut = response.error === 'Import timed out'; - let message: string; - if ( isSqlImportFailure ) { - message = __( - 'Database import failed on the remote site. Please review your database and try again or contact support and provide details from the logs below.' - ); - } else if ( isImportTimedOut ) { - message = __( - "A timeout error occurred while pushing the site, likely due to its large size. Please try reducing the site's content or files and try again. If this problem persists, please contact support." - ); - } else { - message = __( - 'An error occurred while pushing the site. If this problem persists, please contact support.' - ); - } - - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), syncPushState.selectedSite.name ), - message, - showOpenLogs: true, - } ); - } else if ( response.success && response.status === 'archive_import_started' ) { - status = pushStatesProgressInfo.applyingChanges; - } else if ( response.success && response.status === 'archive_import_finished' ) { - status = pushStatesProgressInfo.finishing; - } - status = getPushStatusWithProgress( status, response ); - // Update state in any case to keep polling push state - updatePushState( syncPushState.selectedSite.id, syncPushState.remoteSiteId, { - status, - } ); }, - [ - __, - client, - getPushState, - getPushStatusWithProgress, - onPushSuccess, - pushStatesProgressInfo.applyingChanges, - pushStatesProgressInfo.creatingRemoteBackup, - pushStatesProgressInfo.finishing, - pushStatesProgressInfo.failed, - pushStatesProgressInfo.finished, - updatePushState, - isKeyCancelled, - ] + [ client, dispatch, pushStatesProgressInfo ] ); const pushSite = useCallback< PushSite >( @@ -291,11 +136,6 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync } ) ).unwrap(); - // Sync ref again after thunk completes to ensure we have the final state - const finalState = store.getState(); - const finalPushStates = syncOperationsSelectors.selectPushStates( finalState ); - pushStatesRef.current = finalPushStates; - // If thunk completed successfully and returned polling info, start polling if ( result.shouldStartPolling ) { const stateForPolling: SyncPushState = { @@ -307,11 +147,6 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync void getPushProgressInfo( result.remoteSiteId, stateForPolling ); } } catch ( error ) { - // Sync ref even on error to ensure state is up to date - const currentState = store.getState(); - const latestPushStates = syncOperationsSelectors.selectPushStates( currentState ); - pushStatesRef.current = latestPushStates; - // Errors are already handled in the thunk (state updates, error messages) // Just log if it's an unexpected error if ( ! ( error instanceof Error && error.message === 'Export aborted' ) ) { @@ -323,12 +158,11 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync ); // Poll for push progress when states are in importing status - const shouldPollPush = useCallback( - ( state: SyncPushState ) => { - return ! isKeyCancelled( state.status.key ) && isKeyImporting( state.status.key ); - }, - [ isKeyCancelled, isKeyImporting ] - ); + // Importing keys: creatingRemoteBackup, applyingChanges, finishing + const shouldPollPush = useCallback( ( state: SyncPushState ) => { + const importingKeys = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; + return state.status.key !== 'cancelled' && importingKeys.includes( state.status.key ); + }, [] ); const pollPushProgress = useCallback( ( _key: string, state: SyncPushState ) => { @@ -341,23 +175,10 @@ export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSync const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); - const isSiteIdPushing = useCallback< IsSiteIdPushing >( - ( selectedSiteId, remoteSiteId ) => { - return Object.values( pushStates ).some( ( state ) => { - if ( ! state.selectedSite ) { - return false; - } - if ( state.selectedSite.id !== selectedSiteId ) { - return false; - } - if ( remoteSiteId !== undefined ) { - return isKeyPushing( state.status.key ) && state.remoteSiteId === remoteSiteId; - } - return isKeyPushing( state.status.key ); - } ); - }, - [ pushStates, isKeyPushing ] - ); + const isSiteIdPushing = useCallback< IsSiteIdPushing >( ( selectedSiteId, remoteSiteId ) => { + const state = store.getState(); + return syncOperationsSelectors.selectIsSiteIdPushing( selectedSiteId, remoteSiteId )( state ); + }, [] ); const cancelPush = useCallback< CancelPush >( async ( selectedSiteId, remoteSiteId ) => { diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 460ec51166..23bae07eac 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -2,18 +2,18 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import * as Sentry from '@sentry/electron/renderer'; import { __, sprintf } from '@wordpress/i18n'; import { WPCOM } from 'wpcom/types'; -import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; +import { SYNC_PUSH_SIZE_LIMIT_BYTES, SYNC_PUSH_SIZE_LIMIT_GB } from 'src/constants'; import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; import { getIpcApi } from 'src/lib/get-ipc-api'; -import type { - PullSiteOptions, - SyncBackupState, - PullStates, -} from 'src/hooks/sync-sites/use-sync-pull'; +import { getHostnameFromUrl } from 'src/lib/url-utils'; +import { connectedSitesApi } from 'src/stores/sync/connected-sites'; +import type { SyncBackupState, PullStates } from 'src/hooks/sync-sites/use-sync-pull'; import type { SyncPushState, PushStates } from 'src/hooks/sync-sites/use-sync-push'; import type { + ImportResponse, PullStateProgressInfo, PushStateProgressInfo, + SyncBackupResponse, } from 'src/hooks/use-sync-states-progress-info'; import type { SyncSite } from 'src/modules/sync/types'; import type { AppDispatch, RootState } from 'src/stores'; @@ -549,7 +549,437 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl } ); -// Export thunks object for convenience +// Helper function to calculate push status with progress (inlined from useSyncStatesProgressInfo) +const getPushStatusWithProgress = ( + status: PushStateProgressInfo, + response: ImportResponse, + pushStatesProgressInfo: Record< PushStateProgressInfo[ 'key' ], PushStateProgressInfo > +): PushStateProgressInfo => { + if ( status.key === pushStatesProgressInfo.creatingRemoteBackup.key ) { + const progressRange = + pushStatesProgressInfo.applyingChanges.progress - + pushStatesProgressInfo.creatingRemoteBackup.progress; + + // This step will increase the progress to the next step progressively based on the backup_progress + return { + ...status, + progress: + pushStatesProgressInfo.creatingRemoteBackup.progress + + progressRange * ( response.backup_progress / 100 ), + }; + } + + // This step will increase the progress to the next step progressively based on the import_progress + if ( + status.key === pushStatesProgressInfo.applyingChanges.key && + response.import_progress < 100 + ) { + const progressRange = + pushStatesProgressInfo.finishing.progress - pushStatesProgressInfo.applyingChanges.progress; + return { + ...status, + progress: + pushStatesProgressInfo.applyingChanges.progress + + progressRange * ( response.import_progress / 100 ), + }; + } + return status; +}; + +// Thunk for polling push progress +type PollPushProgressPayload = { + client: WPCOM; + selectedSiteId: string; + remoteSiteId: number; + pushStatesProgressInfo: Record< PushStateProgressInfo[ 'key' ], PushStateProgressInfo >; +}; + +export const pollPushProgressThunk = createTypedAsyncThunk( + 'syncOperations/pollPushProgress', + async ( + { client, selectedSiteId, remoteSiteId, pushStatesProgressInfo }: PollPushProgressPayload, + { dispatch, getState } + ) => { + // Check if state exists and is not cancelled + const state = getState(); + const currentPushState = syncOperationsSelectors.selectPushState( + selectedSiteId, + remoteSiteId + )( state ); + + if ( ! currentPushState || isKeyCancelled( currentPushState.status.key ) ) { + return; + } + + const response = await client.req.get< ImportResponse >( + `/sites/${ remoteSiteId }/studio-app/sync/import`, + { + apiNamespace: 'wpcom/v2', + } + ); + + let status: PushStateProgressInfo = pushStatesProgressInfo.creatingRemoteBackup; + if ( response.success && response.status === 'finished' ) { + status = pushStatesProgressInfo.finished; + // Update site timestamp + void dispatch( + connectedSitesApi.endpoints.updateSiteTimestamp.initiate( { + siteId: remoteSiteId, + localSiteId: selectedSiteId, + type: 'push', + } ) + ); + getIpcApi().showNotification( { + title: currentPushState.selectedSite.name, + body: sprintf( + // translators: %s is the site url without the protocol. + __( '%s has been updated' ), + getHostnameFromUrl( currentPushState.remoteSiteUrl ) + ), + } ); + } else if ( response.success && response.status === 'failed' ) { + status = pushStatesProgressInfo.failed; + console.error( 'Push import failed:', { + remoteSiteId: currentPushState.remoteSiteId, + error: response.error, + error_data: response.error_data, + } ); + // If the import fails due to a SQL import error, show a more specific message + const restoreMessage = response.error_data?.vp_restore_message || ''; + const isSqlImportFailure = /importing sql dump/i.test( restoreMessage ); + const isImportTimedOut = response.error === 'Import timed out'; + let message: string; + if ( isSqlImportFailure ) { + message = __( + 'Database import failed on the remote site. Please review your database and try again or contact support and provide details from the logs below.' + ); + } else if ( isImportTimedOut ) { + message = __( + "A timeout error occurred while pushing the site, likely due to its large size. Please try reducing the site's content or files and try again. If this problem persists, please contact support." + ); + } else { + message = __( + 'An error occurred while pushing the site. If this problem persists, please contact support.' + ); + } + + getIpcApi().showErrorMessageBox( { + title: sprintf( __( 'Error pushing to %s' ), currentPushState.selectedSite.name ), + message, + showOpenLogs: true, + } ); + } else if ( response.success && response.status === 'archive_import_started' ) { + status = pushStatesProgressInfo.applyingChanges; + } else if ( response.success && response.status === 'archive_import_finished' ) { + status = pushStatesProgressInfo.finishing; + } + status = getPushStatusWithProgress( status, response, pushStatesProgressInfo ); + // Update state in any case to keep polling push state + updatePushStateWithIpc( + dispatch, + selectedSiteId, + remoteSiteId, + { status }, + isKeyFailed, + isKeyFinished + ); + } +); + +// Constants for pull progress calculation (from useSyncStatesProgressInfo) +const IN_PROGRESS_INITIAL_VALUE = 30; +const DOWNLOADING_INITIAL_VALUE = 60; +const IN_PROGRESS_TO_DOWNLOADING_STEP = DOWNLOADING_INITIAL_VALUE - IN_PROGRESS_INITIAL_VALUE; + +// Helper function to calculate backup status with progress (inlined from useSyncStatesProgressInfo) +const getBackupStatusWithProgress = ( + hasBackupCompleted: boolean, + pullStatesProgressInfo: Record< PullStateProgressInfo[ 'key' ], PullStateProgressInfo >, + response: SyncBackupResponse +): PullStateProgressInfo => { + const frontendStatus = hasBackupCompleted + ? pullStatesProgressInfo.downloading.key + : response.status; + let newProgressInfo: PullStateProgressInfo | null = null; + if ( response.status === 'in-progress' ) { + newProgressInfo = { + ...pullStatesProgressInfo[ frontendStatus ], + // Update progress from the initial value to the new step proportionally to the response.progress + // on every update of the response.progress + progress: + IN_PROGRESS_INITIAL_VALUE + IN_PROGRESS_TO_DOWNLOADING_STEP * ( response.percent / 100 ), + }; + } + const statusWithProgress = newProgressInfo || pullStatesProgressInfo[ frontendStatus ]; + + return statusWithProgress; +}; + +// Thunk for polling pull backup status +type PollPullBackupPayload = { + client: WPCOM; + selectedSiteId: string; + remoteSiteId: number; + pullStatesProgressInfo: Record< PullStateProgressInfo[ 'key' ], PullStateProgressInfo >; +}; + +export const pollPullBackupThunk = createTypedAsyncThunk( + 'syncOperations/pollPullBackup', + async ( + { client, selectedSiteId, remoteSiteId, pullStatesProgressInfo }: PollPullBackupPayload, + { dispatch, getState } + ) => { + // Check if state exists and is not cancelled + const state = getState(); + const currentPullState = syncOperationsSelectors.selectPullState( + selectedSiteId, + remoteSiteId + )( state ); + + if ( ! currentPullState || isKeyCancelled( currentPullState.status.key ) ) { + return; + } + + const backupId = currentPullState.backupId; + if ( ! backupId ) { + console.error( 'No backup ID found' ); + return; + } + + try { + const response = await client.req.get< SyncBackupResponse >( + `/sites/${ remoteSiteId }/studio-app/sync/backup`, + { + apiNamespace: 'wpcom/v2', + backup_id: backupId, + } + ); + + const hasBackupCompleted = response.status === 'finished'; + const downloadUrl = hasBackupCompleted ? response.download_url : null; + + if ( downloadUrl ) { + // Backup completed, trigger completion thunk + await dispatch( + syncOperationsThunks.completePull( { + selectedSiteId, + remoteSiteId, + downloadUrl, + pullStatesProgressInfo, + } ) + ).unwrap(); + } else { + // Update status with progress + const statusWithProgress = getBackupStatusWithProgress( + hasBackupCompleted, + pullStatesProgressInfo, + response + ); + + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { + status: statusWithProgress, + downloadUrl, + }, + } ) + ); + + // Update IPC sync operation + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + getIpcApi().addSyncOperation( stateId ); + } + } catch ( error ) { + console.error( 'Failed to fetch backup status:', error ); + throw error; + } + } +); + +// Thunk for completing pull operation (handles download, import, server start) +type CompletePullPayload = { + selectedSiteId: string; + remoteSiteId: number; + downloadUrl: string; + pullStatesProgressInfo: Record< PullStateProgressInfo[ 'key' ], PullStateProgressInfo >; +}; + +export const completePullThunk = createTypedAsyncThunk( + 'syncOperations/completePull', + async ( + { selectedSiteId, remoteSiteId, downloadUrl, pullStatesProgressInfo }: CompletePullPayload, + { dispatch, getState } + ) => { + // Check if cancelled + const state = getState(); + const currentPullState = syncOperationsSelectors.selectPullState( + selectedSiteId, + remoteSiteId + )( state ); + + if ( ! currentPullState || isKeyCancelled( currentPullState.status.key ) ) { + return; + } + + const { selectedSite, remoteSiteUrl } = currentPullState; + + try { + // Check file size + const fileSize = await getIpcApi().checkSyncBackupSize( downloadUrl ); + + if ( fileSize > SYNC_PUSH_SIZE_LIMIT_BYTES ) { + const CANCEL_ID = 1; + + const { response: userChoice } = await getIpcApi().showMessageBox( { + type: 'warning', + message: __( "Large site's backup" ), + detail: sprintf( + __( + "Your site's backup exceeds %d GB. Pulling it will prevent you from pushing the site back.\n\nDo you want to continue?" + ), + SYNC_PUSH_SIZE_LIMIT_GB + ), + buttons: [ __( 'Continue' ), __( 'Cancel' ) ], + defaultId: 0, + cancelId: CANCEL_ID, + } ); + + if ( userChoice === CANCEL_ID ) { + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { + status: pullStatesProgressInfo.cancelled, + }, + } ) + ); + void dispatch( syncOperationsThunks.clearPullState( { selectedSiteId, remoteSiteId } ) ); + return; + } + } + + // Update to downloading + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { + status: pullStatesProgressInfo.downloading, + downloadUrl, + }, + } ) + ); + + // Download backup + const operationId = generateStateId( selectedSiteId, remoteSiteId ); + const filePath = await getIpcApi().downloadSyncBackup( + remoteSiteId, + downloadUrl, + operationId + ); + + // Check if cancelled after download + const stateAfterDownload = getState(); + const pullStateAfterDownload = syncOperationsSelectors.selectPullState( + selectedSiteId, + remoteSiteId + )( stateAfterDownload ); + + if ( ! pullStateAfterDownload || isKeyCancelled( pullStateAfterDownload.status.key ) ) { + return; + } + + // Update to importing + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { + status: pullStatesProgressInfo.importing, + }, + } ) + ); + + // Stop server, import, then start server + await getIpcApi().stopServer( selectedSiteId ); + await getIpcApi().importSite( { + id: selectedSiteId, + backupFile: { + path: filePath, + type: 'application/tar+gzip', + }, + } ); + await getIpcApi().startServer( selectedSiteId ); + + // Clean up + await getIpcApi().removeSyncBackup( remoteSiteId ); + + // Update site timestamp + void dispatch( + connectedSitesApi.endpoints.updateSiteTimestamp.initiate( { + siteId: remoteSiteId, + localSiteId: selectedSiteId, + type: 'pull', + } ) + ); + + // Mark as finished + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { + status: pullStatesProgressInfo.finished, + }, + } ) + ); + + // Show notification + getIpcApi().showNotification( { + title: selectedSite.name, + body: sprintf( + // translators: %s is the site url without the protocol. + __( 'Studio site has been updated from %s' ), + getHostnameFromUrl( remoteSiteUrl ) + ), + } ); + } catch ( error ) { + console.error( 'Backup completion failed:', error ); + + // Check if cancelled + const errorState = getState(); + const pullStateOnError = syncOperationsSelectors.selectPullState( + selectedSiteId, + remoteSiteId + )( errorState ); + + if ( pullStateOnError && isKeyCancelled( pullStateOnError.status.key ) ) { + return; + } + + Sentry.captureException( error ); + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { + status: pullStatesProgressInfo.failed, + }, + } ) + ); + getIpcApi().showErrorMessageBox( { + title: sprintf( __( 'Error pulling from %s' ), selectedSite.name ), + message: __( 'Failed to check backup file size. Please try again.' ), + } ); + throw error; + } + } +); + +// Export thunks object for convenience (must be after all thunk declarations) export const syncOperationsThunks = { clearPushState: clearPushStateThunk, clearPullState: clearPullStateThunk, @@ -557,6 +987,9 @@ export const syncOperationsThunks = { cancelPull: cancelPullThunk, pushSite: pushSiteThunk, pullSite: pullSiteThunk, + pollPushProgress: pollPushProgressThunk, + pollPullBackup: pollPullBackupThunk, + completePull: completePullThunk, }; // Helper functions for checking state keys (matching useSyncStatesProgressInfo logic) From be60973d02bd08567fa4696d38155a658caea4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 22 Dec 2025 17:07:02 +0000 Subject: [PATCH 10/58] remove unused onPullSuccess/onPushSuccess props --- src/hooks/sync-sites/use-sync-pull.ts | 7 +------ src/hooks/sync-sites/use-sync-push.ts | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index 818c26aa76..a6615197b3 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -30,7 +30,6 @@ export type PullSiteOptions = { }; export type PullStates = Record< string, SyncBackupState >; -type OnPullSuccess = ( siteId: number, localSiteId: string ) => void; type PullSite = ( connectedSite: SyncSite, selectedSite: SiteDetails, @@ -38,10 +37,6 @@ type PullSite = ( ) => void; type IsSiteIdPulling = ( selectedSiteId: string, remoteSiteId?: number ) => boolean; -type UseSyncPullProps = { - onPullSuccess?: OnPullSuccess; -}; - type CancelPull = ( selectedSiteId: string, remoteSiteId: number ) => void; export type UseSyncPull = { @@ -54,7 +49,7 @@ export type UseSyncPull = { cancelPull: CancelPull; }; -export function useSyncPull( { onPullSuccess }: UseSyncPullProps = {} ): UseSyncPull { +export function useSyncPull(): UseSyncPull { const { client } = useAuth(); const { pullStatesProgressInfo } = useSyncStatesProgressInfo(); diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 8b2a5f6ec9..ecfcf3cc64 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -30,7 +30,6 @@ type PushSiteOptions = { }; export type PushStates = Record< string, SyncPushState >; -type OnPushSuccess = ( siteId: number, localSiteId: string ) => void; type PushSite = ( connectedSite: SyncSite, selectedSite: SiteDetails, @@ -38,10 +37,6 @@ type PushSite = ( ) => Promise< void >; type IsSiteIdPushing = ( selectedSiteId: string, remoteSiteId?: number ) => boolean; -type UseSyncPushProps = { - onPushSuccess?: OnPushSuccess; -}; - type CancelPush = ( selectedSiteId: string, remoteSiteId: number ) => void; export type UseSyncPush = { @@ -77,7 +72,7 @@ export function mapImportResponseToPushState( return null; } -export function useSyncPush( { onPushSuccess }: UseSyncPushProps = {} ): UseSyncPush { +export function useSyncPush(): UseSyncPush { const { client } = useAuth(); const dispatch = useAppDispatch(); From ff2184bc65445e2e2d84a3694f89460e2272952f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 22 Dec 2025 17:12:46 +0000 Subject: [PATCH 11/58] remove unused actions --- src/stores/sync/sync-operations-slice.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 23bae07eac..92ad511727 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -81,19 +81,6 @@ const syncOperationsSlice = createSlice( { const stateId = generateStateId( selectedSiteId, remoteSiteId ); delete state.pushStates[ stateId ]; }, - - setPullStates: ( state, action: PayloadAction< PullStates > ) => { - state.pullStates = action.payload; - }, - - setPushStates: ( state, action: PayloadAction< PushStates > ) => { - state.pushStates = action.payload; - }, - - clearAllStates: ( state ) => { - state.pullStates = {}; - state.pushStates = {}; - }, }, } ); From ec936a0a4e4b38f5b42233b45f22e5c1a108b696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 22 Dec 2025 17:19:05 +0000 Subject: [PATCH 12/58] fix unused reference after merge --- src/components/delete-site.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/delete-site.tsx b/src/components/delete-site.tsx index 1cb6ea2c97..ecc3cbcabf 100644 --- a/src/components/delete-site.tsx +++ b/src/components/delete-site.tsx @@ -1,7 +1,8 @@ import { MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; -import { useSyncSites } from 'src/hooks/sync-sites/sync-sites-context'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; +import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useDeleteSite } from 'src/hooks/use-delete-site'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -13,7 +14,8 @@ const DeleteSite = ( { onClose }: DeleteSiteProps ) => { const { __ } = useI18n(); const { selectedSite, isDeleting } = useSiteDetails(); const { handleDeleteSite } = useDeleteSite(); - const { isSiteIdPulling, isSiteIdPushing } = useSyncSites(); + const { isSiteIdPulling } = useSyncPull(); + const { isSiteIdPushing } = useSyncPush(); const isThisSiteSyncing = isSiteIdPulling( selectedSite?.id ?? '' ) || isSiteIdPushing( selectedSite?.id ?? '' ); From f60e93b8c69ee3815300ae96dd9ad3a9778fec9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 22 Dec 2025 17:19:16 +0000 Subject: [PATCH 13/58] update tests --- src/components/tests/app.test.tsx | 5 +- .../tests/content-tab-import-export.test.tsx | 5 +- src/components/tests/header.test.tsx | 5 +- src/components/tests/main-sidebar.test.tsx | 5 +- .../tests/site-content-tabs.test.tsx | 5 +- .../tests/site-management-actions.test.tsx | 5 +- src/hooks/tests/use-add-site.test.tsx | 16 +++-- src/modules/add-site/tests/add-site.test.tsx | 4 +- src/modules/sync/tests/index.test.tsx | 63 ++++++++++--------- 9 files changed, 51 insertions(+), 62 deletions(-) diff --git a/src/components/tests/app.test.tsx b/src/components/tests/app.test.tsx index 09e46ea0b9..5ba87b6356 100644 --- a/src/components/tests/app.test.tsx +++ b/src/components/tests/app.test.tsx @@ -2,7 +2,6 @@ import { configureStore } from '@reduxjs/toolkit'; import { render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import App from 'src/components/app'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { useOnboarding } from 'src/modules/onboarding/hooks/use-onboarding'; @@ -107,9 +106,7 @@ describe( 'App', () => { ); return render( - - { component } - + { component } ); }; diff --git a/src/components/tests/content-tab-import-export.test.tsx b/src/components/tests/content-tab-import-export.test.tsx index f64662cc02..ef243cc6d9 100644 --- a/src/components/tests/content-tab-import-export.test.tsx +++ b/src/components/tests/content-tab-import-export.test.tsx @@ -3,7 +3,6 @@ import { userEvent } from '@testing-library/user-event'; import { act } from 'react'; import { Provider } from 'react-redux'; import { ContentTabImportExport } from 'src/components/content-tab-import-export'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -48,9 +47,7 @@ beforeEach( () => { const renderWithProvider = ( children: React.ReactElement ) => { return render( - - { children } - + { children } ); }; diff --git a/src/components/tests/header.test.tsx b/src/components/tests/header.test.tsx index 44cf56894f..be291082e5 100644 --- a/src/components/tests/header.test.tsx +++ b/src/components/tests/header.test.tsx @@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import Header from 'src/components/header'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { SiteDetailsProvider } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; @@ -46,9 +45,7 @@ const renderWithProvider = ( children: React.ReactElement ) => { return render( - - { children } - + { children } ); diff --git a/src/components/tests/main-sidebar.test.tsx b/src/components/tests/main-sidebar.test.tsx index 98f4b9e7a4..f07b8a9e6a 100644 --- a/src/components/tests/main-sidebar.test.tsx +++ b/src/components/tests/main-sidebar.test.tsx @@ -2,7 +2,6 @@ import { render, act, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import MainSidebar from 'src/components/main-sidebar'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { store } from 'src/stores'; @@ -100,9 +99,7 @@ jest.mock( 'src/hooks/use-site-details', () => ( { const renderWithProvider = ( children: React.ReactElement ) => { return render( - - { children } - + { children } ); }; diff --git a/src/components/tests/site-content-tabs.test.tsx b/src/components/tests/site-content-tabs.test.tsx index 979f2ee797..694f1326ef 100644 --- a/src/components/tests/site-content-tabs.test.tsx +++ b/src/components/tests/site-content-tabs.test.tsx @@ -1,7 +1,6 @@ import { act, render, screen } from '@testing-library/react'; import { Provider } from 'react-redux'; import { SiteContentTabs } from 'src/components/site-content-tabs'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { store } from 'src/stores'; @@ -73,9 +72,7 @@ describe( 'SiteContentTabs', () => { const renderWithProvider = ( component: React.ReactElement ) => { return render( - - { component } - + { component } ); }; diff --git a/src/components/tests/site-management-actions.test.tsx b/src/components/tests/site-management-actions.test.tsx index 00f1cabb5f..472cf58404 100644 --- a/src/components/tests/site-management-actions.test.tsx +++ b/src/components/tests/site-management-actions.test.tsx @@ -5,7 +5,6 @@ import { SiteManagementActionProps, SiteManagementActions, } from 'src/components/site-management-actions'; -import { SyncSitesProvider } from 'src/hooks/sync-sites'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { store } from 'src/stores'; import { connectedSitesApi } from 'src/stores/sync/connected-sites'; @@ -54,9 +53,7 @@ describe( 'SiteManagementActions', () => { const renderWithProvider = ( children: React.ReactElement ) => { return render( - - { children } - + { children } ); }; diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx index c56433daf6..e0dd388132 100644 --- a/src/hooks/tests/use-add-site.test.tsx +++ b/src/hooks/tests/use-add-site.test.tsx @@ -2,7 +2,7 @@ import { renderHook, act } from '@testing-library/react'; import nock from 'nock'; import { Provider } from 'react-redux'; -import { useSyncSites } from 'src/hooks/sync-sites'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; import { useAddSite } from 'src/hooks/use-add-site'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -13,7 +13,7 @@ import type { SyncSite } from 'src/modules/sync/types'; jest.mock( 'src/hooks/use-site-details' ); jest.mock( 'src/hooks/use-feature-flags' ); -jest.mock( 'src/hooks/sync-sites' ); +jest.mock( 'src/hooks/sync-sites/use-sync-pull' ); jest.mock( 'src/hooks/use-content-tabs' ); jest.mock( 'src/hooks/use-import-export', () => ( { useImportExport: () => ( { @@ -73,11 +73,15 @@ describe( 'useAddSite', () => { } ); mockPullSite.mockReset(); - ( useSyncSites as jest.Mock ).mockReturnValue( { + ( useSyncPull as jest.Mock ).mockReturnValue( { pullSite: mockPullSite, - syncSites: [], - refetchSites: jest.fn(), - isFetching: false, + pullStates: {}, + getPullState: jest.fn(), + isAnySitePulling: false, + isSiteIdPulling: jest.fn(), + clearPullState: jest.fn(), + cancelPull: jest.fn(), + } ); isAnySitePulling: false, isSiteIdPulling: jest.fn(), clearPullState: jest.fn(), diff --git a/src/modules/add-site/tests/add-site.test.tsx b/src/modules/add-site/tests/add-site.test.tsx index df356f45f0..647380f722 100644 --- a/src/modules/add-site/tests/add-site.test.tsx +++ b/src/modules/add-site/tests/add-site.test.tsx @@ -64,8 +64,8 @@ jest.mock( 'src/lib/get-ipc-api', () => ( { } ), } ) ); -jest.mock( 'src/hooks/sync-sites', () => ( { - useSyncSites: () => mockUseSyncSites(), +jest.mock( 'src/hooks/sync-sites/use-sync-pull', () => ( { + useSyncPull: () => mockUseSyncSites(), } ) ); jest.mock( 'src/hooks/use-import-export', () => ( { diff --git a/src/modules/sync/tests/index.test.tsx b/src/modules/sync/tests/index.test.tsx index ebe10fb30c..de359bb84d 100644 --- a/src/modules/sync/tests/index.test.tsx +++ b/src/modules/sync/tests/index.test.tsx @@ -1,7 +1,8 @@ // To run tests, execute `npm run test -- src/modules/sync/tests/index.test.tsx` from the root directory import { render, screen, fireEvent } from '@testing-library/react'; import { Provider } from 'react-redux'; -import { SyncSitesProvider, useSyncSites } from 'src/hooks/sync-sites'; +import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; +import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { SyncPushState } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; @@ -24,10 +25,8 @@ jest.mock( 'src/stores/sync/wpcom-sites', () => ( { useGetWpComSitesQuery: jest.fn(), } ) ); jest.mock( 'src/hooks/use-feature-flags' ); -jest.mock( 'src/hooks/sync-sites/sync-sites-context', () => ( { - ...jest.requireActual( '../../../hooks/sync-sites/sync-sites-context' ), - useSyncSites: jest.fn(), -} ) ); +jest.mock( 'src/hooks/sync-sites/use-sync-pull' ); +jest.mock( 'src/hooks/sync-sites/use-sync-push' ); jest.mock( 'src/stores/sync', () => ( { ...jest.requireActual( 'src/stores/sync' ), @@ -111,18 +110,24 @@ const fakeSyncSite: SyncSite = { }; describe( 'ContentTabSync', () => { - const mockSyncSites = { + const mockSyncPull = { pullSite: jest.fn(), - pushSite: jest.fn(), - isAnySitePulling: false, - isAnySitePushing: false, + pullStates: {}, getPullState: jest.fn(), - getPushState: jest.fn(), - updateTimestamp: jest.fn(), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), + isAnySitePulling: false, isSiteIdPulling: jest.fn().mockReturnValue( false ), + clearPullState: jest.fn(), + cancelPull: jest.fn(), + }; + + const mockSyncPush = { + pushSite: jest.fn(), + pushStates: {}, + getPushState: jest.fn(), + isAnySitePushing: false, isSiteIdPushing: jest.fn().mockReturnValue( false ), - clearTimeout: jest.fn(), + clearPushState: jest.fn(), + cancelPush: jest.fn(), }; const setupConnectedSitesMocks = ( @@ -187,7 +192,8 @@ describe( 'ContentTabSync', () => { isPushSelectionOverLimit: false, isLoading: false, } ); - ( useSyncSites as jest.Mock ).mockReturnValue( mockSyncSites ); + ( useSyncPull as jest.Mock ).mockReturnValue( mockSyncPull ); + ( useSyncPush as jest.Mock ).mockReturnValue( mockSyncPush ); ( useLatestRewindId as jest.Mock ).mockReturnValue( { rewindId: '1704067200', isLoading: false, @@ -247,9 +253,7 @@ describe( 'ContentTabSync', () => { const renderWithProvider = ( children: React.ReactElement ) => { return render( - - { children } - + { children } ); }; @@ -383,8 +387,8 @@ describe( 'ContentTabSync', () => { it( 'displays the progress bar when the site is being pushed', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); - ( useSyncSites as jest.Mock ).mockReturnValue( { - ...mockSyncSites, + ( useSyncPush as jest.Mock ).mockReturnValue( { + ...mockSyncPush, getPushState: jest.fn().mockReturnValue( inProgressPushState ), isSiteIdPushing: jest.fn().mockReturnValue( true ), } ); @@ -402,9 +406,8 @@ describe( 'ContentTabSync', () => { environmentType: 'development', }; setupConnectedSitesMocks( [ fakeDevelopmentSyncSite ], [ fakeDevelopmentSyncSite ] ); - ( useSyncSites as jest.Mock ).mockReturnValue( { - ...mockSyncSites, - syncSites: [ fakeDevelopmentSyncSite ], + ( useSyncPull as jest.Mock ).mockReturnValue( { + ...mockSyncPull, pullSite: mockPullSite, } ); @@ -429,8 +432,8 @@ describe( 'ContentTabSync', () => { environmentType: 'non-supported-environment-example-or-sandbox', }; setupConnectedSitesMocks( [ fakeDevelopmentSyncSite ], [ fakeDevelopmentSyncSite ] ); - ( useSyncSites as jest.Mock ).mockReturnValue( { - ...mockSyncSites, + ( useSyncPull as jest.Mock ).mockReturnValue( { + ...mockSyncPull, pullSite: mockPullSite, } ); @@ -450,8 +453,8 @@ describe( 'ContentTabSync', () => { const mockPullSite = jest.fn(); ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); - ( useSyncSites as jest.Mock ).mockReturnValue( { - ...mockSyncSites, + ( useSyncPull as jest.Mock ).mockReturnValue( { + ...mockSyncPull, pullSite: mockPullSite, } ); @@ -480,8 +483,8 @@ describe( 'ContentTabSync', () => { const mockPullSite = jest.fn(); ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); - ( useSyncSites as jest.Mock ).mockReturnValue( { - ...mockSyncSites, + ( useSyncPull as jest.Mock ).mockReturnValue( { + ...mockSyncPull, pullSite: mockPullSite, } ); @@ -574,8 +577,8 @@ describe( 'ContentTabSync', () => { } ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); - ( useSyncSites as jest.Mock ).mockReturnValue( { - ...mockSyncSites, + ( useSyncPull as jest.Mock ).mockReturnValue( { + ...mockSyncPull, pullSite: mockPullSite, } ); From c5750d0c1a0eef1e1f422ac8d4ded258cb939d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Mon, 22 Dec 2025 17:24:59 +0000 Subject: [PATCH 14/58] add null checks --- src/stores/sync/sync-operations-slice.ts | 60 ++++++++++++++++++------ 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 92ad511727..01a800b9cb 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -343,7 +343,11 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl selectedSite.id, remoteSiteId )( state ); - if ( ! currentPushState || isKeyCancelled( currentPushState.status.key ) ) { + if ( + ! currentPushState || + ! currentPushState.status || + isKeyCancelled( currentPushState.status.key ) + ) { await getIpcApi().removeExportedSiteTmpFile( archivePath ); throw new Error( 'Push cancelled' ); } @@ -594,7 +598,11 @@ export const pollPushProgressThunk = createTypedAsyncThunk( remoteSiteId )( state ); - if ( ! currentPushState || isKeyCancelled( currentPushState.status.key ) ) { + if ( + ! currentPushState || + ! currentPushState.status || + isKeyCancelled( currentPushState.status.key ) + ) { return; } @@ -723,7 +731,11 @@ export const pollPullBackupThunk = createTypedAsyncThunk( remoteSiteId )( state ); - if ( ! currentPullState || isKeyCancelled( currentPullState.status.key ) ) { + if ( + ! currentPullState || + ! currentPullState.status || + isKeyCancelled( currentPullState.status.key ) + ) { return; } @@ -806,7 +818,11 @@ export const completePullThunk = createTypedAsyncThunk( remoteSiteId )( state ); - if ( ! currentPullState || isKeyCancelled( currentPullState.status.key ) ) { + if ( + ! currentPullState || + ! currentPullState.status || + isKeyCancelled( currentPullState.status.key ) + ) { return; } @@ -875,7 +891,11 @@ export const completePullThunk = createTypedAsyncThunk( remoteSiteId )( stateAfterDownload ); - if ( ! pullStateAfterDownload || isKeyCancelled( pullStateAfterDownload.status.key ) ) { + if ( + ! pullStateAfterDownload || + ! pullStateAfterDownload.status || + isKeyCancelled( pullStateAfterDownload.status.key ) + ) { return; } @@ -943,7 +963,11 @@ export const completePullThunk = createTypedAsyncThunk( remoteSiteId )( errorState ); - if ( pullStateOnError && isKeyCancelled( pullStateOnError.status.key ) ) { + if ( + pullStateOnError && + pullStateOnError.status && + isKeyCancelled( pullStateOnError.status.key ) + ) { return; } @@ -1020,8 +1044,8 @@ export const syncOperationsSelectors = { return state.syncOperations.pushStates[ stateId ]; }, selectIsAnySitePulling: ( state: { syncOperations: SyncOperationsState } ): boolean => { - return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => - isKeyPulling( pullState.status.key ) + return Object.values( state.syncOperations.pullStates ).some( + ( pullState ) => pullState.status && isKeyPulling( pullState.status.key ) ); }, selectIsSiteIdPulling: @@ -1035,14 +1059,18 @@ export const syncOperationsSelectors = { return false; } if ( remoteSiteId !== undefined ) { - return isKeyPulling( pullState.status.key ) && pullState.remoteSiteId === remoteSiteId; + return ( + pullState.status && + isKeyPulling( pullState.status.key ) && + pullState.remoteSiteId === remoteSiteId + ); } - return isKeyPulling( pullState.status.key ); + return pullState.status && isKeyPulling( pullState.status.key ); } ); }, selectIsAnySitePushing: ( state: { syncOperations: SyncOperationsState } ): boolean => { - return Object.values( state.syncOperations.pushStates ).some( ( pushState ) => - isKeyPushing( pushState.status.key ) + return Object.values( state.syncOperations.pushStates ).some( + ( pushState ) => pushState.status && isKeyPushing( pushState.status.key ) ); }, selectIsSiteIdPushing: @@ -1056,9 +1084,13 @@ export const syncOperationsSelectors = { return false; } if ( remoteSiteId !== undefined ) { - return isKeyPushing( pushState.status.key ) && pushState.remoteSiteId === remoteSiteId; + return ( + pushState.status && + isKeyPushing( pushState.status.key ) && + pushState.remoteSiteId === remoteSiteId + ); } - return isKeyPushing( pushState.status.key ); + return pushState.status && isKeyPushing( pushState.status.key ); } ); }, }; From d2781050a3580492a2deeb99845e6ff4f7b09609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 23 Dec 2025 14:11:24 +0000 Subject: [PATCH 15/58] fix linter errors --- src/hooks/sync-sites/use-sync-pull.ts | 2 +- src/hooks/sync-sites/use-sync-push.ts | 1 - src/hooks/tests/use-add-site.test.tsx | 12 ------------ src/modules/sync/tests/index.test.tsx | 3 +-- src/stores/index.ts | 1 - 5 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index a6615197b3..be2f583181 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; +import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { useAuth } from 'src/hooks/use-auth'; import { PullStateProgressInfo, diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index ecfcf3cc64..20382b4c01 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -7,7 +7,6 @@ import { useSyncStatesProgressInfo, PushStateProgressInfo, } from 'src/hooks/use-sync-states-progress-info'; -import { getIpcApi } from 'src/lib/get-ipc-api'; import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsActions, diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx index e0dd388132..650f21e04f 100644 --- a/src/hooks/tests/use-add-site.test.tsx +++ b/src/hooks/tests/use-add-site.test.tsx @@ -82,18 +82,6 @@ describe( 'useAddSite', () => { clearPullState: jest.fn(), cancelPull: jest.fn(), } ); - isAnySitePulling: false, - isSiteIdPulling: jest.fn(), - clearPullState: jest.fn(), - cancelPull: jest.fn(), - getPullState: jest.fn(), - pushSite: jest.fn(), - isAnySitePushing: false, - isSiteIdPushing: jest.fn(), - clearPushState: jest.fn(), - getPushState: jest.fn(), - getLastSyncTimeText: jest.fn(), - } ); mockSetSelectedTab.mockReset(); ( useContentTabs as jest.Mock ).mockReturnValue( { diff --git a/src/modules/sync/tests/index.test.tsx b/src/modules/sync/tests/index.test.tsx index de359bb84d..6846b9f006 100644 --- a/src/modules/sync/tests/index.test.tsx +++ b/src/modules/sync/tests/index.test.tsx @@ -2,8 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { Provider } from 'react-redux'; import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; -import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; -import { SyncPushState } from 'src/hooks/sync-sites/use-sync-push'; +import { useSyncPush, SyncPushState } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useFeatureFlags } from 'src/hooks/use-feature-flags'; diff --git a/src/stores/index.ts b/src/stores/index.ts index 05f91c42e6..9528f19778 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -102,7 +102,6 @@ export const rootReducer = combineReducers( { providerConstants: providerConstantsReducer, snapshot: snapshotReducer, sync: syncReducer, - connectedSites: connectedSitesReducer, syncOperations: syncOperationsReducer, wordpressVersionsApi: wordpressVersionsApi.reducer, wpcomApi: wpcomApi.reducer, From f82a3012b76fd2d4c034108696855a0e6bdb0903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 23 Dec 2025 14:51:00 +0000 Subject: [PATCH 16/58] add runtime checks --- src/hooks/sync-sites/use-sync-pull.ts | 5 ++++- src/hooks/sync-sites/use-sync-push.ts | 4 +++- .../sync/components/sync-connected-sites.tsx | 16 ++++++++-------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index be2f583181..a91cc32551 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -123,7 +123,10 @@ export function useSyncPull(): UseSyncPull { // Poll for backup status when states have backupId and are in-progress const shouldPollPull = useCallback( ( state: SyncBackupState ) => { return ( - state.status.key !== 'cancelled' && !! state.backupId && state.status.key === 'in-progress' + state.status && + state.status.key !== 'cancelled' && + !! state.backupId && + state.status.key === 'in-progress' ); }, [] ); diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 20382b4c01..7c3185252c 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -155,7 +155,9 @@ export function useSyncPush(): UseSyncPush { // Importing keys: creatingRemoteBackup, applyingChanges, finishing const shouldPollPush = useCallback( ( state: SyncPushState ) => { const importingKeys = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; - return state.status.key !== 'cancelled' && importingKeys.includes( state.status.key ); + return ( + state.status && state.status.key !== 'cancelled' && importingKeys.includes( state.status.key ) + ); }, [] ); const pollPushProgress = useCallback( diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index b9ca9ac9bb..e90f646861 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -198,18 +198,18 @@ const SyncConnectedSitesSectionItem = ( { } = useSyncStatesProgressInfo(); const sitePullState = getPullState( selectedSite.id, connectedSite.id ); - const isPulling = sitePullState && isKeyPulling( sitePullState.status.key ); - const isPullError = sitePullState && isKeyFailed( sitePullState.status.key ); - const hasPullFinished = sitePullState && isKeyFinished( sitePullState.status.key ); - const hasPullCancelled = sitePullState && isKeyCancelled( sitePullState.status.key ); + const isPulling = sitePullState?.status && isKeyPulling( sitePullState.status.key ); + const isPullError = sitePullState?.status && isKeyFailed( sitePullState.status.key ); + const hasPullFinished = sitePullState?.status && isKeyFinished( sitePullState.status.key ); + const hasPullCancelled = sitePullState?.status && isKeyCancelled( sitePullState.status.key ); const { message: sitePullStatusMessage, progress: sitePullStatusProgress } = getPullStatusWithProgress( sitePullState?.status, importState[ connectedSite.localSiteId ] ); const pushState = getPushState( selectedSite.id, connectedSite.id ); - const isPushing = pushState && isKeyPushing( pushState.status.key ); - const isPushError = pushState && isKeyFailed( pushState.status.key ); - const hasPushFinished = pushState && isKeyFinished( pushState.status.key ); - const hasPushCancelled = pushState && isKeyCancelled( pushState.status.key ); + const isPushing = pushState?.status && isKeyPushing( pushState.status.key ); + const isPushError = pushState?.status && isKeyFailed( pushState.status.key ); + const hasPushFinished = pushState?.status && isKeyFinished( pushState.status.key ); + const hasPushCancelled = pushState?.status && isKeyCancelled( pushState.status.key ); return (
From 4872b64aeec38441202af2d7b786bc065956c915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 23 Dec 2025 14:56:06 +0000 Subject: [PATCH 17/58] add optional chaining to progress data checks --- src/hooks/use-import-export.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hooks/use-import-export.tsx b/src/hooks/use-import-export.tsx index 086b4367d1..3ccb0843b3 100644 --- a/src/hooks/use-import-export.tsx +++ b/src/hooks/use-import-export.tsx @@ -191,8 +191,8 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode let statusMessage: string = __( 'Extracting backup files…' ); if ( - progressData.processedFiles != null && - progressData.totalFiles != null && + progressData?.processedFiles != null && + progressData?.totalFiles != null && progressData.totalFiles > 0 ) { const percentage = Math.round( @@ -239,8 +239,8 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode let statusMessage: string = __( 'Importing database…' ); if ( - progressData.processedFiles != null && - progressData.totalFiles != null && + progressData?.processedFiles != null && + progressData?.totalFiles != null && progressData.totalFiles > 0 ) { const percentage = Math.round( @@ -249,7 +249,7 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode statusMessage = sprintf( __( 'Importing database… (%d%%)' ), percentage ); } - const progressIncrement = progressData.totalFiles + const progressIncrement = progressData?.totalFiles ? ( ( progressData.processedFiles || 0 ) / progressData.totalFiles ) * 20 : 0; From 55b1c33d956c38c9bf10846c0894a7da8f1e5d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 6 Jan 2026 14:11:10 +0000 Subject: [PATCH 18/58] Merge trunk and resolve conflicts --- RELEASE-NOTES.txt | 17 ++ cli/commands/site/status.ts | 10 +- cli/commands/site/tests/status.test.ts | 26 +- common/translations/studio-ar.jed.json | 2 +- common/translations/studio-de.jed.json | 2 +- common/translations/studio-it.jed.json | 2 +- common/translations/studio-nl.jed.json | 2 +- common/translations/studio-tr.jed.json | 2 +- common/translations/studio-uk.jed.json | 2 +- package-lock.json | 231 +++++++++--------- package.json | 4 +- scripts/download-wp-server-files.ts | 72 +++++- src/components/content-tab-settings.tsx | 13 +- src/components/learn-more.tsx | 23 +- src/components/tree-view.tsx | 2 +- src/hooks/sync-sites/use-sync-push.ts | 62 ++++- src/hooks/tests/use-add-site.test.tsx | 69 +++++- src/hooks/use-add-site.ts | 7 + src/hooks/use-sync-states-progress-info.ts | 171 ++++++++----- src/ipc-handlers.ts | 10 + src/ipc-utils.ts | 1 + src/lib/get-localized-link.ts | 3 + .../export/exporters/default-exporter.ts | 45 +++- .../export/exporters/default-exporter.test.ts | 152 ++++++++++++ .../add-site/components/create-site-form.tsx | 15 +- .../cli/lib/macos-installation-manager.ts | 2 +- .../cli/lib/windows-installation-manager.ts | 2 +- .../site-settings/edit-site-details.tsx | 14 +- .../sync/components/sync-connected-sites.tsx | 9 +- src/modules/sync/components/sync-dialog.tsx | 15 +- src/modules/sync/lib/ipc-handlers.ts | 10 +- src/stores/sync/sync-operations-slice.ts | 1 + 32 files changed, 717 insertions(+), 281 deletions(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index f930172940..8d57ced0aa 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,23 @@ Unreleased ========== +1.6.8 +===== +* Made sync uploads resumable #2212 +* Fixed Windows auto-updater so that ARM64 installations receive the correct installer architecture #2280 +* Improved update dialogs with clearer messaging and safer ESC key handling #2257 +* Improved the Publish site button behavior #2264, #2288 +* Ensured auto-update customizations for a site are overridden by Studio setting #2262 +* Ensured the site can't be deleted when syncing #2254 +* Improved how errors are displayed in sync modal #2271 +* Fixed appearance of Query Manager plugin in wp-admin #2311 +* Disabled add site import from WordPress.com if Studio is offline #2284 +* Updated What's new modal to not display if no sites have been created yet #2320 +* Added banner with information about Telex to Assistant tab #2306 +* Fixed a bug where some files would be mistakenly excluded from site exports #2349 +* Updated multiple dependencies, including Electron, Vite, and WordPress Components #2248, #2258 +* Misc UI improvements #2277, #2283, #2279, #2305, #2318, #2333, #2331, #2312, #2321 + 1.6.7 ===== * Fixed auto-updates #2234 diff --git a/cli/commands/site/status.ts b/cli/commands/site/status.ts index c6000e27ac..d3ed4df631 100644 --- a/cli/commands/site/status.ts +++ b/cli/commands/site/status.ts @@ -36,17 +36,17 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) }[] = [ { key: __( 'Site URL' ), value: new URL( siteUrl ).toString(), type: 'url' }, { - key: __( 'Auto Login URL' ), + key: __( 'Auto-login URL' ), value: autoLoginUrl.toString(), type: 'url', hidden: ! isOnline, }, { key: __( 'Site Path' ), value: sitePath }, { key: __( 'Status' ), value: status }, - { key: __( 'PHP Version' ), value: site.phpVersion }, - { key: __( 'WP Version' ), value: wpVersion }, - { key: __( 'Admin Username' ), value: 'admin' }, - { key: __( 'Admin Password' ), value: site.adminPassword }, + { key: __( 'PHP version' ), value: site.phpVersion }, + { key: __( 'WP version' ), value: wpVersion }, + { key: __( 'Admin username' ), value: 'admin' }, + { key: __( 'Admin password' ), value: site.adminPassword }, ].filter( ( { value, hidden } ) => value && ! hidden ); if ( format === 'table' ) { diff --git a/cli/commands/site/tests/status.test.ts b/cli/commands/site/tests/status.test.ts index 32334bfd46..cbba0548c9 100644 --- a/cli/commands/site/tests/status.test.ts +++ b/cli/commands/site/tests/status.test.ts @@ -81,10 +81,10 @@ describe( 'CLI: studio site status', () => { 'Site URL': 'http://localhost:8080/', 'Site Path': '/path/to/site', Status: '🔴 Offline', - 'PHP Version': '8.0', - 'WP Version': '6.4', - 'Admin Username': 'admin', - 'Admin Password': 'password123', + 'PHP version': '8.0', + 'WP version': '6.4', + 'Admin username': 'admin', + 'Admin password': 'password123', }, null, 2 @@ -107,13 +107,13 @@ describe( 'CLI: studio site status', () => { JSON.stringify( { 'Site URL': 'http://localhost:8080/', - 'Auto Login URL': 'http://localhost:8080/studio-auto-login?redirect_to=%2Fwp-admin%2F', + 'Auto-login URL': 'http://localhost:8080/studio-auto-login?redirect_to=%2Fwp-admin%2F', 'Site Path': '/path/to/site', Status: '🟢 Online', - 'PHP Version': '8.0', - 'WP Version': '6.4', - 'Admin Username': 'admin', - 'Admin Password': 'password123', + 'PHP version': '8.0', + 'WP version': '6.4', + 'Admin username': 'admin', + 'Admin password': 'password123', }, null, 2 @@ -154,10 +154,10 @@ describe( 'CLI: studio site status', () => { 'Site URL': 'http://localhost:8080/', 'Site Path': '/path/to/site', Status: '🔴 Offline', - 'PHP Version': undefined, - 'WP Version': '6.4', - 'Admin Username': 'admin', - 'Admin Password': undefined, + 'PHP version': undefined, + 'WP version': '6.4', + 'Admin username': 'admin', + 'Admin password': undefined, }, null, 2 diff --git a/common/translations/studio-ar.jed.json b/common/translations/studio-ar.jed.json index c25526ef21..f97907cab3 100644 --- a/common/translations/studio-ar.jed.json +++ b/common/translations/studio-ar.jed.json @@ -1 +1 @@ -{"translation-revision-date":"2025-12-30 09:23:53+0000","generator":"GlotPress\/2.4.0-alpha","domain":"messages","locale_data":{"messages":{"":{"domain":"messages","plural-forms":"nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;","lang":"ar"},"Enable the %s command in the terminal. %s":[],"Create a new site from a featured Blueprint on your own. ":[],"Powered by experimental AI. ":[],"Could not load files. Please close and reopen this dialog to try again.":[],"The site uploading has been paused due to an internet connection issue. We will retry automatically in a few seconds.":[],"Enable PHP debugging with Xdebug. Only one site can have Xdebug enabled at a time. Note that Xdebug may slow down site performance. ":[],"Enable Xdebug":[],"Xdebug is currently enabled for \"%s\" site. Disable it there first to enable it for this site.":[],"Starting from a Blueprint requires an internet connection.":[],"Enabling Xdebug support":[],"Uploading paused":[],"Xdebug":[],"Dismiss":["\u062a\u062c\u0627\u0647\u0644"],"Build blocks with +
) } diff --git a/src/components/learn-more.tsx b/src/components/learn-more.tsx index f3d8d909aa..457f9cf940 100644 --- a/src/components/learn-more.tsx +++ b/src/components/learn-more.tsx @@ -4,13 +4,12 @@ import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink, type DocsLinkKey } from 'src/lib/get-localized-link'; import { useI18nLocale } from 'src/stores'; -export function LearnMoreLink( { - docsLinksKey, - className, -}: { +interface LinkProps { docsLinksKey: DocsLinkKey; className?: string; -} ) { +} + +function MoreLink( { docsLinksKey, className, label }: LinkProps & { label: string } ) { const { __ } = useI18n(); const locale = useI18nLocale(); @@ -24,7 +23,19 @@ export function LearnMoreLink( { } } variant="link" > - { __( 'Learn more' ) } + { label } ); } + +export function LearnMoreLink( props: LinkProps ) { + const { __ } = useI18n(); + + return ; +} + +export function LearnHowLink( props: LinkProps ) { + const { __ } = useI18n(); + + return ; +} diff --git a/src/components/tree-view.tsx b/src/components/tree-view.tsx index b78722db0f..a888c1d852 100644 --- a/src/components/tree-view.tsx +++ b/src/components/tree-view.tsx @@ -167,7 +167,7 @@ const TreeItem = ( { className={ cx( 'ps-6', isFirstLevel && 'border border-gray-300 rounded-sm py-2' ) } > { node.children.length === 0 ? ( - renderEmptyContent && renderEmptyContent( node.id ) ? ( + renderEmptyContent ? ( renderEmptyContent( node.id ) ) : (
diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index bfbf08347f..111df192ba 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -22,6 +22,7 @@ export type SyncPushState = { status: PushStateProgressInfo; selectedSite: SiteDetails; remoteSiteUrl: string; + uploadProgress?: number; }; type PushSiteOptions = { @@ -79,7 +80,8 @@ export function useSyncPush(): UseSyncPush { const pushStates = useRootSelector( syncOperationsSelectors.selectPushStates as ( state: RootState ) => PushStates ); - const { pushStatesProgressInfo } = useSyncStatesProgressInfo(); + const { pushStatesProgressInfo, isKeyUploading, mapUploadProgressToOverallProgress } = + useSyncStatesProgressInfo(); const getPushState = useCallback< GetState< SyncPushState > >( ( selectedSiteId, remoteSiteId ) => { @@ -138,6 +140,7 @@ export function useSyncPush(): UseSyncPush { status: pushStatesProgressInfo.creatingRemoteBackup, selectedSite: result.selectedSite, remoteSiteUrl: result.remoteSiteUrl, + uploadProgress: undefined, // Clear upload progress when transitioning to next state }; void getPushProgressInfo( result.remoteSiteId, stateForPolling ); } @@ -170,6 +173,63 @@ export function useSyncPush(): UseSyncPush { useSyncPolling( pushStates, shouldPollPush, pollPushProgress, 2000 ); + // IPC listeners for upload progress tracking + useIpcListener( + 'sync-upload-paused', + ( _event, payload: { selectedSiteId: string; remoteSiteId: number; error: string } ) => { + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: payload.selectedSiteId, + remoteSiteId: payload.remoteSiteId, + state: { + status: pushStatesProgressInfo.uploadingPaused, + }, + } ) + ); + } + ); + + useIpcListener( + 'sync-upload-resumed', + ( _event, payload: { selectedSiteId: string; remoteSiteId: number } ) => { + const currentState = getPushState( payload.selectedSiteId, payload.remoteSiteId ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: payload.selectedSiteId, + remoteSiteId: payload.remoteSiteId, + state: { + status: pushStatesProgressInfo.uploading, + uploadProgress: currentState?.uploadProgress, + }, + } ) + ); + } + ); + + useIpcListener( + 'sync-upload-progress', + ( _event, payload: { selectedSiteId: string; remoteSiteId: number; progress: number } ) => { + const currentState = getPushState( payload.selectedSiteId, payload.remoteSiteId ); + if ( currentState && isKeyUploading( currentState.status.key ) ) { + const mappedProgress = mapUploadProgressToOverallProgress( payload.progress ); + + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: payload.selectedSiteId, + remoteSiteId: payload.remoteSiteId, + state: { + status: { + ...currentState.status, + progress: mappedProgress, + }, + uploadProgress: payload.progress, + }, + } ) + ); + } + } + ); + const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const isSiteIdPushing = useCallback< IsSiteIdPushing >( ( selectedSiteId, remoteSiteId ) => { diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx index 650f21e04f..3e6cfd5317 100644 --- a/src/hooks/tests/use-add-site.test.tsx +++ b/src/hooks/tests/use-add-site.test.tsx @@ -23,18 +23,23 @@ jest.mock( 'src/hooks/use-import-export', () => ( { } ) ); const mockConnectWpcomSites = jest.fn().mockResolvedValue( undefined ); +const mockGenerateProposedSitePath = jest.fn().mockResolvedValue( { + path: '/default/path', + name: 'Default Site', + isEmpty: true, + isWordPress: false, +} ); + +const mockComparePaths = jest.fn().mockResolvedValue( false ); + jest.mock( 'src/lib/get-ipc-api', () => ( { getIpcApi: () => ( { - generateProposedSitePath: jest.fn().mockResolvedValue( { - path: '/default/path', - name: 'Default Site', - isEmpty: true, - isWordPress: false, - } ), + generateProposedSitePath: mockGenerateProposedSitePath, showNotification: jest.fn(), getAllCustomDomains: jest.fn().mockResolvedValue( [] ), connectWpcomSites: mockConnectWpcomSites, getConnectedWpcomSites: jest.fn().mockResolvedValue( [] ), + comparePaths: mockComparePaths, } ), } ) ); @@ -238,4 +243,56 @@ describe( 'useAddSite', () => { } ); expect( mockSetSelectedTab ).toHaveBeenCalledWith( 'sync' ); } ); + + describe( 'handleSiteNameChange', () => { + beforeEach( () => { + mockGenerateProposedSitePath.mockReset(); + mockGenerateProposedSitePath.mockResolvedValue( { + path: '/default/path', + name: 'Default Site', + isEmpty: true, + isWordPress: false, + } ); + mockComparePaths.mockReset(); + mockComparePaths.mockResolvedValue( false ); + } ); + + it( 'should set user-friendly error when site name causes ENAMETOOLONG error', async () => { + mockGenerateProposedSitePath.mockResolvedValueOnce( { + path: '/default/path/very-long-name', + name: 'a'.repeat( 300 ), + isEmpty: false, + isWordPress: false, + isNameTooLong: true, + } ); + + const { result } = renderHookWithProvider( () => useAddSite() ); + + await act( async () => { + await result.current.handleSiteNameChange( 'a'.repeat( 300 ) ); + } ); + + expect( result.current.error ).toBe( + 'The site name is too long. Please choose a shorter site name.' + ); + } ); + + it( 'should successfully update site name when path is valid', async () => { + mockGenerateProposedSitePath.mockResolvedValueOnce( { + path: '/default/path/my-site', + name: 'my-site', + isEmpty: true, + isWordPress: false, + } ); + + const { result } = renderHookWithProvider( () => useAddSite() ); + + await act( async () => { + await result.current.handleSiteNameChange( 'my-site' ); + } ); + + expect( result.current.siteName ).toBe( 'my-site' ); + expect( result.current.error ).toBe( '' ); + } ); + } ); } ); diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts index 704d604434..923881b21a 100644 --- a/src/hooks/use-add-site.ts +++ b/src/hooks/use-add-site.ts @@ -244,13 +244,20 @@ export function useAddSite( options: UseAddSiteOptions = {} ) { return; } setError( '' ); + const { path: proposedPath, isEmpty, isWordPress, + isNameTooLong, } = await getIpcApi().generateProposedSitePath( name ); setProposedSitePath( proposedPath ); + if ( isNameTooLong ) { + setError( __( 'The site name is too long. Please choose a shorter site name.' ) ); + return; + } + if ( await siteWithPathAlreadyExists( proposedPath ) ) { setError( __( diff --git a/src/hooks/use-sync-states-progress-info.ts b/src/hooks/use-sync-states-progress-info.ts index 515d235d05..9e89a34957 100644 --- a/src/hooks/use-sync-states-progress-info.ts +++ b/src/hooks/use-sync-states-progress-info.ts @@ -1,3 +1,4 @@ +import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo } from 'react'; import { ImportProgressState } from './use-import-export'; @@ -56,6 +57,80 @@ const DOWNLOADING_INITIAL_VALUE = 60; const IN_PROGRESS_TO_DOWNLOADING_STEP = DOWNLOADING_INITIAL_VALUE - IN_PROGRESS_INITIAL_VALUE; const PULL_IMPORTING_INITIAL_VALUE = 80; +function isKeyPulling( key: PullStateProgressInfo[ 'key' ] | undefined ): boolean { + const pullingStateKeys: PullStateProgressInfo[ 'key' ][] = [ + 'in-progress', + 'downloading', + 'importing', + ]; + if ( ! key ) { + return false; + } + return pullingStateKeys.includes( key ); +} + +function isKeyPushing( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { + const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ + 'creatingBackup', + 'uploading', + 'creatingRemoteBackup', + 'applyingChanges', + 'finishing', + ]; + if ( ! key ) { + return false; + } + return pushingStateKeys.includes( key ); +} + +function isKeyUploadingPaused( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { + return key === 'uploadingPaused'; +} + +function isKeyUploading( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { + return key === 'uploading'; +} + +function isKeyImporting( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { + const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ + 'creatingRemoteBackup', + 'applyingChanges', + 'finishing', + ]; + if ( ! key ) { + return false; + } + return pushingStateKeys.includes( key ); +} + +function isKeyFinished( + key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined +): boolean { + return key === 'finished'; +} + +function isKeyFailed( + key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined +): boolean { + return key === 'failed'; +} + +function isKeyCancelled( + key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined +): boolean { + return key === 'cancelled'; +} + +function getPushUploadPercentage( + statusKey: PushStateProgressInfo[ 'key' ] | undefined, + uploadProgress: number | undefined +): number | null { + if ( isKeyUploading( statusKey ) && uploadProgress !== undefined ) { + return Math.round( uploadProgress ); + } + return null; +} + export function useSyncStatesProgressInfo() { const { __ } = useI18n(); const pullStatesProgressInfo = useMemo( () => { @@ -104,7 +179,7 @@ export function useSyncStatesProgressInfo() { uploading: { key: 'uploading', progress: 40, - message: __( 'Uploading Studio site…' ), + message: __( 'Uploading site…' ), }, uploadingPaused: { key: 'uploadingPaused', @@ -144,67 +219,7 @@ export function useSyncStatesProgressInfo() { } as const satisfies PushStateProgressInfoValues; }, [ __ ] ); - const isKeyPulling = ( key: PullStateProgressInfo[ 'key' ] | undefined ) => { - const pullingStateKeys: PullStateProgressInfo[ 'key' ][] = [ - 'in-progress', - 'downloading', - 'importing', - ]; - if ( ! key ) { - return false; - } - return pullingStateKeys.includes( key ); - }; - - const isKeyPushing = ( key: PushStateProgressInfo[ 'key' ] | undefined ) => { - const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ - 'creatingBackup', - 'uploading', - 'creatingRemoteBackup', - 'applyingChanges', - 'finishing', - ]; - if ( ! key ) { - return false; - } - return pushingStateKeys.includes( key ); - }; - - const isKeyUploadingPaused = ( key: PushStateProgressInfo[ 'key' ] | undefined ) => { - return key === 'uploadingPaused'; - }; - - const isKeyImporting = ( key: PushStateProgressInfo[ 'key' ] | undefined ) => { - const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ - 'creatingRemoteBackup', - 'applyingChanges', - 'finishing', - ]; - if ( ! key ) { - return false; - } - return pushingStateKeys.includes( key ); - }; - const isKeyFinished = useCallback( - ( key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined ) => { - return key === 'finished'; - }, - [] - ); - - const isKeyFailed = useCallback( - ( key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined ) => { - return key === 'failed'; - }, - [] - ); - - const isKeyCancelled = useCallback( - ( key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined ) => { - return key === 'cancelled'; - }, - [] - ); + const uploadingProgressMessageTemplate = useMemo( () => __( 'Uploading site (%d%%)…' ), [ __ ] ); const getBackupStatusWithProgress = useCallback( ( @@ -297,6 +312,34 @@ export function useSyncStatesProgressInfo() { ] ); + const getPushUploadMessage = useCallback( + ( message: string, uploadPercentage: number | null ): string => { + if ( uploadPercentage !== null ) { + // translators: %d is the upload progress percentage + return sprintf( uploadingProgressMessageTemplate, uploadPercentage ); + } + return message; + }, + [ uploadingProgressMessageTemplate ] + ); + + const mapUploadProgressToOverallProgress = useCallback( + ( uploadProgress: number ): number => { + // Map upload progress (0-100%) to the uploading state range (40-50%) + const uploadingProgressRange = + pushStatesProgressInfo.creatingRemoteBackup.progress - + pushStatesProgressInfo.uploading.progress; + return ( + pushStatesProgressInfo.uploading.progress + + ( uploadProgress / 100 ) * uploadingProgressRange + ); + }, + [ + pushStatesProgressInfo.creatingRemoteBackup.progress, + pushStatesProgressInfo.uploading.progress, + ] + ); + return { pullStatesProgressInfo, pushStatesProgressInfo, @@ -306,9 +349,13 @@ export function useSyncStatesProgressInfo() { isKeyFinished, isKeyFailed, isKeyCancelled, + isKeyUploading, getBackupStatusWithProgress, getPullStatusWithProgress, getPushStatusWithProgress, + getPushUploadPercentage, + getPushUploadMessage, + mapUploadProgressToOverallProgress, isKeyUploadingPaused, }; } diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index a9a4ebdcf3..69f7a9f038 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -450,6 +450,7 @@ export interface FolderDialogResponse { name: string; isEmpty: boolean; isWordPress: boolean; + isNameTooLong?: boolean; } export async function showSaveAsDialog( event: IpcMainInvokeEvent, options: SaveDialogOptions ) { @@ -713,6 +714,15 @@ export async function generateProposedSitePath( isWordPress: false, }; } + if ( isErrnoException( err ) && err.code === 'ENAMETOOLONG' ) { + return { + path, + name: siteName, + isEmpty: false, + isWordPress: false, + isNameTooLong: true, + }; + } throw err; } } diff --git a/src/ipc-utils.ts b/src/ipc-utils.ts index 9893ac9934..e5e8de413b 100644 --- a/src/ipc-utils.ts +++ b/src/ipc-utils.ts @@ -40,6 +40,7 @@ export interface IpcEvents { 'site-context-menu-action': [ { action: string; siteId: string } ]; 'sync-upload-paused': [ { error: string; selectedSiteId: string; remoteSiteId: number } ]; 'sync-upload-resumed': [ { selectedSiteId: string; remoteSiteId: number } ]; + 'sync-upload-progress': [ { selectedSiteId: string; remoteSiteId: number; progress: number } ]; 'snapshot-error': [ { operationId: crypto.UUID; data: SnapshotEventData } ]; 'snapshot-fatal-error': [ { operationId: crypto.UUID; data: { message: string } } ]; 'snapshot-output': [ { operationId: crypto.UUID; data: SnapshotEventData } ]; diff --git a/src/lib/get-localized-link.ts b/src/lib/get-localized-link.ts index 918581a2c4..7ed1c5a7b5 100644 --- a/src/lib/get-localized-link.ts +++ b/src/lib/get-localized-link.ts @@ -34,6 +34,9 @@ const DOCS_LINKS = { docsXdebug: { en: 'https://developer.wordpress.com/docs/developer-tools/studio/xdebug/', }, + docsSslInStudio: { + en: 'https://developer.wordpress.com/docs/developer-tools/studio/ssl-in-studio/', + }, } satisfies Record< `docs${ string }`, TranslatedLink >; const BLOG_LINKS = { diff --git a/src/lib/import-export/export/exporters/default-exporter.ts b/src/lib/import-export/export/exporters/default-exporter.ts index 15eeadc653..2a187e7a70 100644 --- a/src/lib/import-export/export/exporters/default-exporter.ts +++ b/src/lib/import-export/export/exporters/default-exporter.ts @@ -27,8 +27,8 @@ export class DefaultExporter extends EventEmitter implements Exporter { private backup: BackupContents; private readonly options: ExportOptions; - private isExcludedPath( pathToCheck: string ) { - const pathsToExclude = [ + isExactPathExcluded( pathToCheck: string ) { + const PATHS_TO_EXCLUDE = [ 'wp-content/mu-plugins/sqlite-database-integration', 'wp-content/database', 'wp-content/db.php', @@ -44,11 +44,35 @@ export class DefaultExporter extends EventEmitter implements Exporter { 'wp-content/mu-plugins/0-https-for-reverse-proxy.php', 'wp-content/mu-plugins/0-sqlite-command.php', ]; - return pathsToExclude.some( ( pathToExclude ) => + + return PATHS_TO_EXCLUDE.some( ( pathToExclude ) => pathToCheck.startsWith( path.normalize( pathToExclude ) ) ); } + // Look for disallowed directory names in a given path. If found, determine whether that part of + // the path is a directory or not. + isPathExcludedByPattern( pathToCheck: string ) { + const DIRECTORY_NAMES_TO_EXCLUDE = [ '.git', 'node_modules', 'cache' ]; + const pathParts = pathToCheck.split( path.sep ); + + for ( const directoryName of DIRECTORY_NAMES_TO_EXCLUDE ) { + if ( ! pathParts.includes( directoryName ) ) { + continue; + } + const offenderIndex = pathToCheck.lastIndexOf( directoryName ); + const offenderPath = pathToCheck.substring( 0, offenderIndex + directoryName.length ); + try { + const stat = fs.statSync( offenderPath ); + return stat.isDirectory(); + } catch ( error ) { + return false; + } + } + + return false; + } + constructor( options: ExportOptions ) { super(); this.options = options; @@ -57,6 +81,7 @@ export class DefaultExporter extends EventEmitter implements Exporter { sqlFiles: [], }; } + async canHandle(): Promise< boolean > { const supportedExtension = [ 'tar.gz', 'tzg', 'zip' ].find( ( ext ) => this.options.backupFile.endsWith( ext ) @@ -185,19 +210,21 @@ export class DefaultExporter extends EventEmitter implements Exporter { const stat = await fsPromises.stat( fullPath ); if ( stat.isDirectory() ) { this.archiveBuilder.directory( fullPath, archivePath, ( entry ) => { - const fullArchivePath = path.join( archivePath, entry.name ); + const entryPathRelativeToArchiveRoot = path.join( archivePath, entry.name ); + const fullEntryPathOnDisk = path.join( + this.options.site.path, + entryPathRelativeToArchiveRoot + ); if ( - this.isExcludedPath( fullArchivePath ) || - entry.name.includes( '.git' ) || - entry.name.includes( 'node_modules' ) || - entry.name.includes( 'cache' ) + this.isExactPathExcluded( entryPathRelativeToArchiveRoot ) || + this.isPathExcludedByPattern( fullEntryPathOnDisk ) ) { return false; } return entry; } ); } else { - if ( this.isExcludedPath( archivePath ) ) { + if ( this.isExactPathExcluded( archivePath ) ) { continue; } this.archiveBuilder.file( fullPath, { name: archivePath } ); diff --git a/src/lib/import-export/tests/export/exporters/default-exporter.test.ts b/src/lib/import-export/tests/export/exporters/default-exporter.test.ts index 303be179c0..d94bead799 100644 --- a/src/lib/import-export/tests/export/exporters/default-exporter.test.ts +++ b/src/lib/import-export/tests/export/exporters/default-exporter.test.ts @@ -180,6 +180,20 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { ( fs.existsSync as jest.Mock ).mockImplementation( pathExistsMockImplementation ); + ( fs.statSync as jest.Mock ).mockImplementation( ( filePath: string ) => { + const normalizedPath = normalize( filePath ); + if ( + mockFiles.some( + ( file ) => normalizedPath === normalize( path.join( file.path, file.name ) ) + ) + ) { + return { isDirectory: () => false, isFile: () => true }; + } else if ( pathExistsMockImplementation( normalizedPath ) ) { + return { isDirectory: () => true, isFile: () => false }; + } + throw new Error( `File not found: ${ normalizedPath }` ); + } ); + mockBackup = { backupFile: normalize( '/path/to/backup.tar.gz' ), sqlFiles: [ normalize( '/tmp/studio_export_123/file.sql' ) ], @@ -639,4 +653,142 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { { name: 'wp-content/mu-plugins/sqlite-database-integration/example-load.php' } ); } ); + + describe( 'isExactPathExcluded', () => { + it( 'should exclude exact paths from PATHS_TO_EXCLUDE list', () => { + const exporter = new DefaultExporter( mockOptions ); + + expect( exporter.isExactPathExcluded( normalize( 'wp-content/database' ) ) ).toBe( true ); + expect( exporter.isExactPathExcluded( normalize( 'wp-content/db.php' ) ) ).toBe( true ); + expect( exporter.isExactPathExcluded( normalize( 'wp-content/debug.log' ) ) ).toBe( true ); + expect( + exporter.isExactPathExcluded( + normalize( 'wp-content/mu-plugins/sqlite-database-integration' ) + ) + ).toBe( true ); + expect( + exporter.isExactPathExcluded( + normalize( 'wp-content/mu-plugins/0-allowed-redirect-hosts.php' ) + ) + ).toBe( true ); + } ); + + it( 'should return false for paths not in the exclusion list', () => { + const exporter = new DefaultExporter( mockOptions ); + + expect( exporter.isExactPathExcluded( normalize( 'wp-content/plugins' ) ) ).toBe( false ); + expect( exporter.isExactPathExcluded( normalize( 'wp-content/themes' ) ) ).toBe( false ); + expect( exporter.isExactPathExcluded( normalize( 'wp-content/uploads' ) ) ).toBe( false ); + expect( exporter.isExactPathExcluded( normalize( 'wp-config.php' ) ) ).toBe( false ); + } ); + + it( 'should match paths that start with excluded prefixes', () => { + const exporter = new DefaultExporter( mockOptions ); + + expect( + exporter.isExactPathExcluded( normalize( 'wp-content/database/something.sql' ) ) + ).toBe( true ); + expect( + exporter.isExactPathExcluded( + normalize( 'wp-content/mu-plugins/sqlite-database-integration/load.php' ) + ) + ).toBe( true ); + } ); + } ); + + describe( 'isPathExcludedByPattern', () => { + it( 'should exclude disallowed directories based on their names', () => { + ( fs.statSync as jest.Mock ).mockReturnValue( { + isDirectory: () => true, + isFile: () => false, + } ); + + const exporter = new DefaultExporter( mockOptions ); + + expect( + exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/.git' ) ) + ).toBe( true ); + expect( + exporter.isPathExcludedByPattern( + normalize( '/path/to/site/wp-content/node_modules/hello' ) + ) + ).toBe( true ); + expect( + exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/cache' ) ) + ).toBe( true ); + expect( + exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/my-cache' ) ) + ).toBe( false ); + } ); + + it( 'should return false for non-excluded directories', () => { + ( fs.statSync as jest.Mock ).mockReturnValue( { + isDirectory: () => true, + isFile: () => false, + } ); + + const exporter = new DefaultExporter( mockOptions ); + + expect( + exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/uploads' ) ) + ).toBe( false ); + expect( + exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/plugins' ) ) + ).toBe( false ); + expect( + exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/themes' ) ) + ).toBe( false ); + } ); + + it( 'should return false for non-existent paths (stat fails)', () => { + const exporter = new DefaultExporter( mockOptions ); + + // Paths that don't exist in mockFiles will cause statSync to throw, returning false + expect( + exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/nonexistent' ) ) + ).toBe( false ); + expect( + exporter.isPathExcludedByPattern( normalize( '/path/to/site/nonexistent/.git' ) ) + ).toBe( false ); + } ); + + it( 'should return false for files (not directories)', () => { + ( fs.statSync as jest.Mock ).mockReturnValue( { + isDirectory: () => false, + isFile: () => true, + } ); + + const exporter = new DefaultExporter( mockOptions ); + + expect( + exporter.isPathExcludedByPattern( + normalize( '/path/to/site/wp-content/uploads/file1.jpg' ) + ) + ).toBe( false ); + expect( exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-config.php' ) ) ).toBe( + false + ); + expect( exporter.isPathExcludedByPattern( normalize( '/path/to/site/node_modules' ) ) ).toBe( + false + ); + } ); + + it( 'should handle directory names found at any position in the path', () => { + ( fs.statSync as jest.Mock ).mockReturnValue( { + isDirectory: () => true, + isFile: () => false, + } ); + + const exporter = new DefaultExporter( mockOptions ); + + expect( + exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/.git' ) ) + ).toBe( true ); + expect( + exporter.isPathExcludedByPattern( + normalize( '/path/to/site/wp-content/plugins/akismet/node_modules/webpack/index.js' ) + ) + ).toBe( true ); + } ); + } ); } ); diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index 5752eef141..64dd3a9271 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -7,11 +7,10 @@ import { FormEvent, useState, useEffect } from 'react'; import { generateCustomDomainFromSiteName } from 'common/lib/domains'; import Button from 'src/components/button'; import FolderIcon from 'src/components/folder-icon'; -import { LearnMoreLink } from 'src/components/learn-more'; +import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; import TextControlComponent from 'src/components/text-control'; import { WPVersionSelector } from 'src/components/wp-version-selector'; import { cx } from 'src/lib/cx'; -import { getIpcApi } from 'src/lib/get-ipc-api'; import { AllowedPHPVersion } from 'src/lib/wordpress-provider/constants'; import { useRootSelector } from 'src/stores'; import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api'; @@ -380,17 +379,7 @@ export const CreateSiteForm = ( { { __( 'You need to manually add the Studio root certificate authority to your keychain and trust it to enable HTTPS.' ) }{ ' ' } - +
) } diff --git a/src/modules/cli/lib/macos-installation-manager.ts b/src/modules/cli/lib/macos-installation-manager.ts index 18ff077722..2cca3adf6c 100644 --- a/src/modules/cli/lib/macos-installation-manager.ts +++ b/src/modules/cli/lib/macos-installation-manager.ts @@ -101,7 +101,7 @@ export class MacOSCliInstallationManager implements StudioCliInstallationManager const mainWindow = await getMainWindow(); await dialog.showMessageBox( mainWindow, { type: 'info', - title: __( 'CLI Uninstalled' ), + title: __( 'CLI uninstalled' ), message: __( 'The CLI has been uninstalled successfully.' ), } ); } catch ( error ) { diff --git a/src/modules/cli/lib/windows-installation-manager.ts b/src/modules/cli/lib/windows-installation-manager.ts index 2620a8da0e..5ebcdeaa94 100644 --- a/src/modules/cli/lib/windows-installation-manager.ts +++ b/src/modules/cli/lib/windows-installation-manager.ts @@ -93,7 +93,7 @@ export class WindowsCliInstallationManager implements StudioCliInstallationManag const mainWindow = await getMainWindow(); await dialog.showMessageBox( mainWindow, { type: 'info', - title: __( 'CLI Uninstalled' ), + title: __( 'CLI uninstalled' ), message: __( 'The CLI has been uninstalled successfully.' ), } ); } catch ( error ) { diff --git a/src/modules/site-settings/edit-site-details.tsx b/src/modules/site-settings/edit-site-details.tsx index 10103cb05f..c5e8acd0ea 100644 --- a/src/modules/site-settings/edit-site-details.tsx +++ b/src/modules/site-settings/edit-site-details.tsx @@ -8,7 +8,7 @@ import { generateCustomDomainFromSiteName, getDomainNameValidationError } from ' import { getWordPressVersionUrl } from 'common/lib/wordpress-version-utils'; import Button from 'src/components/button'; import { ErrorInformation } from 'src/components/error-information'; -import { LearnMoreLink } from 'src/components/learn-more'; +import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; import Modal from 'src/components/modal'; import TextControlComponent from 'src/components/text-control'; import { Tooltip } from 'src/components/tooltip'; @@ -328,17 +328,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = { __( 'You need to manually add the Studio certificate authority to your keychain and trust it.' ) }{ ' ' } - + ) } diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index 6aac5ac0e6..23360fedc4 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -195,6 +195,8 @@ const SyncConnectedSitesSectionItem = ( { isKeyFailed, isKeyCancelled, getPullStatusWithProgress, + getPushUploadPercentage, + getPushUploadMessage, isKeyUploadingPaused, } = useSyncStatesProgressInfo(); @@ -213,6 +215,11 @@ const SyncConnectedSitesSectionItem = ( { const hasPushFinished = pushState?.status && isKeyFinished( pushState.status.key ); const hasPushCancelled = pushState?.status && isKeyCancelled( pushState.status.key ); + const uploadPercentage = getPushUploadPercentage( + pushState?.status.key, + pushState?.uploadProgress + ); + return (
- { pushState.status.message } + { getPushUploadMessage( pushState.status.message, uploadPercentage ) }
diff --git a/src/modules/sync/components/sync-dialog.tsx b/src/modules/sync/components/sync-dialog.tsx index c6d8d9c907..72392f3450 100644 --- a/src/modules/sync/components/sync-dialog.tsx +++ b/src/modules/sync/components/sync-dialog.tsx @@ -117,13 +117,6 @@ const useDynamicTreeState = ( localSiteId, ] ); - // Handle local file tree errors by clearing children to show custom error message - useEffect( () => { - if ( type === 'push' && localFileTreeError ) { - setTreeState( ( treeState ) => updateNodeById( treeState, 'wp-content', { children: [] } ) ); - } - }, [ type, localFileTreeError, setTreeState ] ); - return { rewindId, fetchChildren, @@ -378,7 +371,11 @@ export function SyncDialog( {
); } - return null; + return ( +
+ { __( 'Empty' ) } +
+ ); } } /> @@ -395,6 +392,8 @@ export function SyncDialog( { showLabels valueLabel={ formattedSize } limitLabel={ formattedLimit } + // translators: %s is a filesize string, e.g. "1.3GB". This label is displayed if a sync + // archive is larger than a given limit. overLimitLabel={ sprintf( __( '%s over' ), formattedOverAmount ) } /> diff --git a/src/modules/sync/lib/ipc-handlers.ts b/src/modules/sync/lib/ipc-handlers.ts index d38a936187..f30c5fb90a 100644 --- a/src/modules/sync/lib/ipc-handlers.ts +++ b/src/modules/sync/lib/ipc-handlers.ts @@ -194,7 +194,7 @@ export async function pushArchive( console.error( '[TUS] Upload error', error ); reject( error ); }, - onProgress: () => { + onProgress: ( bytesSent: number, bytesTotal: number ) => { if ( isUploadingPaused ) { isUploadingPaused = false; void sendIpcEventToRenderer( 'sync-upload-resumed', { @@ -207,6 +207,14 @@ export async function pushArchive( if ( ! hasUploadStarted ) { hasUploadStarted = true; } + + // Calculate upload progress percentage (0-100) + const uploadProgress = bytesTotal > 0 ? ( bytesSent / bytesTotal ) * 100 : 0; + void sendIpcEventToRenderer( 'sync-upload-progress', { + selectedSiteId: selectedSiteId, + remoteSiteId: remoteSiteId, + progress: uploadProgress, + } ); }, onSuccess: ( payload ) => { if ( ! payload.lastResponse ) { diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 8a7793bede..31d1a06781 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -392,6 +392,7 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl status: pushStatesProgressInfo.creatingRemoteBackup, selectedSite, remoteSiteUrl, + uploadProgress: undefined, // Clear upload progress when transitioning to next state }, isKeyFailed, isKeyFinished From 3ceedf4cd3aa30f300cd579bc1c26d74f0e7f748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 6 Jan 2026 16:20:59 +0000 Subject: [PATCH 19/58] Revert "Merge trunk and resolve conflicts" This reverts commit 55b1c33d956c38c9bf10846c0894a7da8f1e5d7e. --- RELEASE-NOTES.txt | 17 -- cli/commands/site/status.ts | 10 +- cli/commands/site/tests/status.test.ts | 26 +- common/translations/studio-ar.jed.json | 2 +- common/translations/studio-de.jed.json | 2 +- common/translations/studio-it.jed.json | 2 +- common/translations/studio-nl.jed.json | 2 +- common/translations/studio-tr.jed.json | 2 +- common/translations/studio-uk.jed.json | 2 +- package-lock.json | 231 +++++++++--------- package.json | 4 +- scripts/download-wp-server-files.ts | 72 +----- src/components/content-tab-settings.tsx | 13 +- src/components/learn-more.tsx | 23 +- src/components/tree-view.tsx | 2 +- src/hooks/sync-sites/use-sync-push.ts | 62 +---- src/hooks/tests/use-add-site.test.tsx | 69 +----- src/hooks/use-add-site.ts | 7 - src/hooks/use-sync-states-progress-info.ts | 171 +++++-------- src/ipc-handlers.ts | 10 - src/ipc-utils.ts | 1 - src/lib/get-localized-link.ts | 3 - .../export/exporters/default-exporter.ts | 45 +--- .../export/exporters/default-exporter.test.ts | 152 ------------ .../add-site/components/create-site-form.tsx | 15 +- .../cli/lib/macos-installation-manager.ts | 2 +- .../cli/lib/windows-installation-manager.ts | 2 +- .../site-settings/edit-site-details.tsx | 14 +- .../sync/components/sync-connected-sites.tsx | 9 +- src/modules/sync/components/sync-dialog.tsx | 15 +- src/modules/sync/lib/ipc-handlers.ts | 10 +- src/stores/sync/sync-operations-slice.ts | 1 - 32 files changed, 281 insertions(+), 717 deletions(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 8d57ced0aa..f930172940 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,23 +1,6 @@ Unreleased ========== -1.6.8 -===== -* Made sync uploads resumable #2212 -* Fixed Windows auto-updater so that ARM64 installations receive the correct installer architecture #2280 -* Improved update dialogs with clearer messaging and safer ESC key handling #2257 -* Improved the Publish site button behavior #2264, #2288 -* Ensured auto-update customizations for a site are overridden by Studio setting #2262 -* Ensured the site can't be deleted when syncing #2254 -* Improved how errors are displayed in sync modal #2271 -* Fixed appearance of Query Manager plugin in wp-admin #2311 -* Disabled add site import from WordPress.com if Studio is offline #2284 -* Updated What's new modal to not display if no sites have been created yet #2320 -* Added banner with information about Telex to Assistant tab #2306 -* Fixed a bug where some files would be mistakenly excluded from site exports #2349 -* Updated multiple dependencies, including Electron, Vite, and WordPress Components #2248, #2258 -* Misc UI improvements #2277, #2283, #2279, #2305, #2318, #2333, #2331, #2312, #2321 - 1.6.7 ===== * Fixed auto-updates #2234 diff --git a/cli/commands/site/status.ts b/cli/commands/site/status.ts index d3ed4df631..c6000e27ac 100644 --- a/cli/commands/site/status.ts +++ b/cli/commands/site/status.ts @@ -36,17 +36,17 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) }[] = [ { key: __( 'Site URL' ), value: new URL( siteUrl ).toString(), type: 'url' }, { - key: __( 'Auto-login URL' ), + key: __( 'Auto Login URL' ), value: autoLoginUrl.toString(), type: 'url', hidden: ! isOnline, }, { key: __( 'Site Path' ), value: sitePath }, { key: __( 'Status' ), value: status }, - { key: __( 'PHP version' ), value: site.phpVersion }, - { key: __( 'WP version' ), value: wpVersion }, - { key: __( 'Admin username' ), value: 'admin' }, - { key: __( 'Admin password' ), value: site.adminPassword }, + { key: __( 'PHP Version' ), value: site.phpVersion }, + { key: __( 'WP Version' ), value: wpVersion }, + { key: __( 'Admin Username' ), value: 'admin' }, + { key: __( 'Admin Password' ), value: site.adminPassword }, ].filter( ( { value, hidden } ) => value && ! hidden ); if ( format === 'table' ) { diff --git a/cli/commands/site/tests/status.test.ts b/cli/commands/site/tests/status.test.ts index cbba0548c9..32334bfd46 100644 --- a/cli/commands/site/tests/status.test.ts +++ b/cli/commands/site/tests/status.test.ts @@ -81,10 +81,10 @@ describe( 'CLI: studio site status', () => { 'Site URL': 'http://localhost:8080/', 'Site Path': '/path/to/site', Status: '🔴 Offline', - 'PHP version': '8.0', - 'WP version': '6.4', - 'Admin username': 'admin', - 'Admin password': 'password123', + 'PHP Version': '8.0', + 'WP Version': '6.4', + 'Admin Username': 'admin', + 'Admin Password': 'password123', }, null, 2 @@ -107,13 +107,13 @@ describe( 'CLI: studio site status', () => { JSON.stringify( { 'Site URL': 'http://localhost:8080/', - 'Auto-login URL': 'http://localhost:8080/studio-auto-login?redirect_to=%2Fwp-admin%2F', + 'Auto Login URL': 'http://localhost:8080/studio-auto-login?redirect_to=%2Fwp-admin%2F', 'Site Path': '/path/to/site', Status: '🟢 Online', - 'PHP version': '8.0', - 'WP version': '6.4', - 'Admin username': 'admin', - 'Admin password': 'password123', + 'PHP Version': '8.0', + 'WP Version': '6.4', + 'Admin Username': 'admin', + 'Admin Password': 'password123', }, null, 2 @@ -154,10 +154,10 @@ describe( 'CLI: studio site status', () => { 'Site URL': 'http://localhost:8080/', 'Site Path': '/path/to/site', Status: '🔴 Offline', - 'PHP version': undefined, - 'WP version': '6.4', - 'Admin username': 'admin', - 'Admin password': undefined, + 'PHP Version': undefined, + 'WP Version': '6.4', + 'Admin Username': 'admin', + 'Admin Password': undefined, }, null, 2 diff --git a/common/translations/studio-ar.jed.json b/common/translations/studio-ar.jed.json index f97907cab3..c25526ef21 100644 --- a/common/translations/studio-ar.jed.json +++ b/common/translations/studio-ar.jed.json @@ -1 +1 @@ -{"translation-revision-date":"2026-01-05 15:54:03+0000","generator":"GlotPress\/2.4.0-alpha","domain":"messages","locale_data":{"messages":{"":{"domain":"messages","plural-forms":"nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;","lang":"ar"},"Enable the %s command in the terminal. %s":["\u062a\u0645\u0643\u064a\u0646 \u0627\u0644\u0623\u0645\u0631 %s \u0641\u064a \u0627\u0644\u0648\u062d\u062f\u0629 \u0627\u0644\u0637\u0631\u0641\u064a\u0629. %s"],"Create a new site from a featured Blueprint on your own. ":["\u0623\u0646\u0634\u0626 \u0645\u0648\u0642\u0639\u064b\u0627 \u062c\u062f\u064a\u062f\u064b\u0627 \u0645\u0646 \u0645\u062e\u0637\u0637 \u0645\u0645\u064a\u0632 \u0628\u0646\u0641\u0633\u0643. "],"Powered by experimental AI. ":["\u0645\u062f\u0639\u0648\u0645 \u0645\u0646 \u0627\u0644\u0630\u0643\u0627\u0621 \u0627\u0644\u0627\u0635\u0637\u0646\u0627\u0639\u064a \u0627\u0644\u062a\u062c\u0631\u064a\u0628\u064a. "],"Could not load files. Please close and reopen this dialog to try again.":["\u064a\u062a\u0639\u0630\u0631 \u062a\u062d\u0645\u064a\u0644 \u0627\u0644\u0645\u0644\u0641\u0627\u062a. \u064a\u064f\u0631\u062c\u0649 \u0625\u063a\u0644\u0627\u0642 \u0645\u0631\u0628\u0639 \u0627\u0644\u062d\u0648\u0627\u0631 \u0647\u0630\u0627 \u0648\u0625\u0639\u0627\u062f\u0629 \u0641\u062a\u062d\u0647 \u0644\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649."],"The site uploading has been paused due to an internet connection issue. We will retry automatically in a few seconds.":["\u062a\u0648\u0642\u0641 \u0631\u0641\u0639 \u0627\u0644\u0645\u0648\u0642\u0639 \u0645\u0624\u0642\u062a\u0627 \u0628\u0633\u0628\u0628 \u0645\u0634\u0643\u0644\u0629 \u0641\u064a \u0627\u0644\u0627\u062a\u0635\u0627\u0644 \u0628\u0627\u0644\u0625\u0646\u062a\u0631\u0646\u062a. \u0633\u0646\u0639\u064a\u062f \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u062a\u0644\u0642\u0627\u0626\u064a\u0627 \u0641\u064a \u063a\u0636\u0648\u0646 \u0628\u0636\u0639 \u062b\u0648\u0627\u0646."],"Enable PHP debugging with Xdebug. Only one site can have Xdebug enabled at a time. Note that Xdebug may slow down site performance. ":["\u062a\u0645\u0643\u064a\u0646 \u062a\u0635\u062d\u064a\u062d PHP \u0628\u0627\u0633\u062a\u062e\u062f\u0627\u0645 Xdebug. \u064a\u0645\u0643\u0646 \u062a\u0645\u0643\u064a\u0646 Xdebug \u0648\u0627\u062d\u062f \u0641\u0642\u0637 \u0644\u0643\u0644 \u0645\u0648\u0642\u0639 \u0641\u064a \u0643\u0644 \u0645\u0631\u0629. \u0644\u0627\u062d\u0638 \u0623\u0646 Xdebug \u0642\u062f \u064a\u0624\u062f\u064a \u0625\u0644\u0649 \u0625\u0628\u0637\u0627\u0621 \u0623\u062f\u0627\u0621 \u0627\u0644\u0645\u0648\u0642\u0639. "],"Enable Xdebug":["\u062a\u0645\u0643\u064a\u0646 Xdebug"],"Xdebug is currently enabled for \"%s\" site. Disable it there first to enable it for this site.":["\u062a\u0645 \u062a\u0645\u0643\u064a\u0646 Xdebug \u062d\u0627\u0644\u064a\u0627 \u0644\u0644\u0645\u0648\u0642\u0639 \"%s\". \u0639\u0637\u0651\u0644\u0647 \u0647\u0646\u0627\u0643 \u0623\u0648\u0644\u0627\u064b \u0644\u062a\u0645\u0643\u064a\u0646\u0647 \u0644\u0647\u0630\u0627 \u0627\u0644\u0645\u0648\u0642\u0639."],"Starting from a Blueprint requires an internet connection.":["\u064a\u062a\u0637\u0644\u0628 \u0627\u0644\u0628\u062f\u0621 \u0645\u0646 \u0645\u062e\u0637\u0637 \u0627\u0644\u0627\u062a\u0635\u0627\u0644 \u0628\u0627\u0644\u0625\u0646\u062a\u0631\u0646\u062a."],"Enabling Xdebug support":["\u062a\u0645\u0643\u064a\u0646 \u062f\u0639\u0645 Xdebug"],"Uploading paused":["\u062a\u0648\u0642\u0641 \u0627\u0644\u0631\u0641\u0639 \u0645\u0624\u0642\u062a\u064b\u0627"],"Xdebug":["\u062a\u0635\u062d\u064a\u062d \u0627\u0644\u0623\u062e\u0637\u0627\u0621"],"Dismiss":["\u062a\u062c\u0627\u0647\u0644"],"Build blocks with
) } diff --git a/src/components/learn-more.tsx b/src/components/learn-more.tsx index 457f9cf940..f3d8d909aa 100644 --- a/src/components/learn-more.tsx +++ b/src/components/learn-more.tsx @@ -4,12 +4,13 @@ import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink, type DocsLinkKey } from 'src/lib/get-localized-link'; import { useI18nLocale } from 'src/stores'; -interface LinkProps { +export function LearnMoreLink( { + docsLinksKey, + className, +}: { docsLinksKey: DocsLinkKey; className?: string; -} - -function MoreLink( { docsLinksKey, className, label }: LinkProps & { label: string } ) { +} ) { const { __ } = useI18n(); const locale = useI18nLocale(); @@ -23,19 +24,7 @@ function MoreLink( { docsLinksKey, className, label }: LinkProps & { label: stri } } variant="link" > - { label } + { __( 'Learn more' ) } ); } - -export function LearnMoreLink( props: LinkProps ) { - const { __ } = useI18n(); - - return ; -} - -export function LearnHowLink( props: LinkProps ) { - const { __ } = useI18n(); - - return ; -} diff --git a/src/components/tree-view.tsx b/src/components/tree-view.tsx index a888c1d852..b78722db0f 100644 --- a/src/components/tree-view.tsx +++ b/src/components/tree-view.tsx @@ -167,7 +167,7 @@ const TreeItem = ( { className={ cx( 'ps-6', isFirstLevel && 'border border-gray-300 rounded-sm py-2' ) } > { node.children.length === 0 ? ( - renderEmptyContent ? ( + renderEmptyContent && renderEmptyContent( node.id ) ? ( renderEmptyContent( node.id ) ) : (
diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 111df192ba..bfbf08347f 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -22,7 +22,6 @@ export type SyncPushState = { status: PushStateProgressInfo; selectedSite: SiteDetails; remoteSiteUrl: string; - uploadProgress?: number; }; type PushSiteOptions = { @@ -80,8 +79,7 @@ export function useSyncPush(): UseSyncPush { const pushStates = useRootSelector( syncOperationsSelectors.selectPushStates as ( state: RootState ) => PushStates ); - const { pushStatesProgressInfo, isKeyUploading, mapUploadProgressToOverallProgress } = - useSyncStatesProgressInfo(); + const { pushStatesProgressInfo } = useSyncStatesProgressInfo(); const getPushState = useCallback< GetState< SyncPushState > >( ( selectedSiteId, remoteSiteId ) => { @@ -140,7 +138,6 @@ export function useSyncPush(): UseSyncPush { status: pushStatesProgressInfo.creatingRemoteBackup, selectedSite: result.selectedSite, remoteSiteUrl: result.remoteSiteUrl, - uploadProgress: undefined, // Clear upload progress when transitioning to next state }; void getPushProgressInfo( result.remoteSiteId, stateForPolling ); } @@ -173,63 +170,6 @@ export function useSyncPush(): UseSyncPush { useSyncPolling( pushStates, shouldPollPush, pollPushProgress, 2000 ); - // IPC listeners for upload progress tracking - useIpcListener( - 'sync-upload-paused', - ( _event, payload: { selectedSiteId: string; remoteSiteId: number; error: string } ) => { - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId: payload.selectedSiteId, - remoteSiteId: payload.remoteSiteId, - state: { - status: pushStatesProgressInfo.uploadingPaused, - }, - } ) - ); - } - ); - - useIpcListener( - 'sync-upload-resumed', - ( _event, payload: { selectedSiteId: string; remoteSiteId: number } ) => { - const currentState = getPushState( payload.selectedSiteId, payload.remoteSiteId ); - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId: payload.selectedSiteId, - remoteSiteId: payload.remoteSiteId, - state: { - status: pushStatesProgressInfo.uploading, - uploadProgress: currentState?.uploadProgress, - }, - } ) - ); - } - ); - - useIpcListener( - 'sync-upload-progress', - ( _event, payload: { selectedSiteId: string; remoteSiteId: number; progress: number } ) => { - const currentState = getPushState( payload.selectedSiteId, payload.remoteSiteId ); - if ( currentState && isKeyUploading( currentState.status.key ) ) { - const mappedProgress = mapUploadProgressToOverallProgress( payload.progress ); - - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId: payload.selectedSiteId, - remoteSiteId: payload.remoteSiteId, - state: { - status: { - ...currentState.status, - progress: mappedProgress, - }, - uploadProgress: payload.progress, - }, - } ) - ); - } - } - ); - const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const isSiteIdPushing = useCallback< IsSiteIdPushing >( ( selectedSiteId, remoteSiteId ) => { diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx index 3e6cfd5317..650f21e04f 100644 --- a/src/hooks/tests/use-add-site.test.tsx +++ b/src/hooks/tests/use-add-site.test.tsx @@ -23,23 +23,18 @@ jest.mock( 'src/hooks/use-import-export', () => ( { } ) ); const mockConnectWpcomSites = jest.fn().mockResolvedValue( undefined ); -const mockGenerateProposedSitePath = jest.fn().mockResolvedValue( { - path: '/default/path', - name: 'Default Site', - isEmpty: true, - isWordPress: false, -} ); - -const mockComparePaths = jest.fn().mockResolvedValue( false ); - jest.mock( 'src/lib/get-ipc-api', () => ( { getIpcApi: () => ( { - generateProposedSitePath: mockGenerateProposedSitePath, + generateProposedSitePath: jest.fn().mockResolvedValue( { + path: '/default/path', + name: 'Default Site', + isEmpty: true, + isWordPress: false, + } ), showNotification: jest.fn(), getAllCustomDomains: jest.fn().mockResolvedValue( [] ), connectWpcomSites: mockConnectWpcomSites, getConnectedWpcomSites: jest.fn().mockResolvedValue( [] ), - comparePaths: mockComparePaths, } ), } ) ); @@ -243,56 +238,4 @@ describe( 'useAddSite', () => { } ); expect( mockSetSelectedTab ).toHaveBeenCalledWith( 'sync' ); } ); - - describe( 'handleSiteNameChange', () => { - beforeEach( () => { - mockGenerateProposedSitePath.mockReset(); - mockGenerateProposedSitePath.mockResolvedValue( { - path: '/default/path', - name: 'Default Site', - isEmpty: true, - isWordPress: false, - } ); - mockComparePaths.mockReset(); - mockComparePaths.mockResolvedValue( false ); - } ); - - it( 'should set user-friendly error when site name causes ENAMETOOLONG error', async () => { - mockGenerateProposedSitePath.mockResolvedValueOnce( { - path: '/default/path/very-long-name', - name: 'a'.repeat( 300 ), - isEmpty: false, - isWordPress: false, - isNameTooLong: true, - } ); - - const { result } = renderHookWithProvider( () => useAddSite() ); - - await act( async () => { - await result.current.handleSiteNameChange( 'a'.repeat( 300 ) ); - } ); - - expect( result.current.error ).toBe( - 'The site name is too long. Please choose a shorter site name.' - ); - } ); - - it( 'should successfully update site name when path is valid', async () => { - mockGenerateProposedSitePath.mockResolvedValueOnce( { - path: '/default/path/my-site', - name: 'my-site', - isEmpty: true, - isWordPress: false, - } ); - - const { result } = renderHookWithProvider( () => useAddSite() ); - - await act( async () => { - await result.current.handleSiteNameChange( 'my-site' ); - } ); - - expect( result.current.siteName ).toBe( 'my-site' ); - expect( result.current.error ).toBe( '' ); - } ); - } ); } ); diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts index 923881b21a..704d604434 100644 --- a/src/hooks/use-add-site.ts +++ b/src/hooks/use-add-site.ts @@ -244,20 +244,13 @@ export function useAddSite( options: UseAddSiteOptions = {} ) { return; } setError( '' ); - const { path: proposedPath, isEmpty, isWordPress, - isNameTooLong, } = await getIpcApi().generateProposedSitePath( name ); setProposedSitePath( proposedPath ); - if ( isNameTooLong ) { - setError( __( 'The site name is too long. Please choose a shorter site name.' ) ); - return; - } - if ( await siteWithPathAlreadyExists( proposedPath ) ) { setError( __( diff --git a/src/hooks/use-sync-states-progress-info.ts b/src/hooks/use-sync-states-progress-info.ts index 9e89a34957..515d235d05 100644 --- a/src/hooks/use-sync-states-progress-info.ts +++ b/src/hooks/use-sync-states-progress-info.ts @@ -1,4 +1,3 @@ -import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo } from 'react'; import { ImportProgressState } from './use-import-export'; @@ -57,80 +56,6 @@ const DOWNLOADING_INITIAL_VALUE = 60; const IN_PROGRESS_TO_DOWNLOADING_STEP = DOWNLOADING_INITIAL_VALUE - IN_PROGRESS_INITIAL_VALUE; const PULL_IMPORTING_INITIAL_VALUE = 80; -function isKeyPulling( key: PullStateProgressInfo[ 'key' ] | undefined ): boolean { - const pullingStateKeys: PullStateProgressInfo[ 'key' ][] = [ - 'in-progress', - 'downloading', - 'importing', - ]; - if ( ! key ) { - return false; - } - return pullingStateKeys.includes( key ); -} - -function isKeyPushing( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ - 'creatingBackup', - 'uploading', - 'creatingRemoteBackup', - 'applyingChanges', - 'finishing', - ]; - if ( ! key ) { - return false; - } - return pushingStateKeys.includes( key ); -} - -function isKeyUploadingPaused( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - return key === 'uploadingPaused'; -} - -function isKeyUploading( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - return key === 'uploading'; -} - -function isKeyImporting( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ - 'creatingRemoteBackup', - 'applyingChanges', - 'finishing', - ]; - if ( ! key ) { - return false; - } - return pushingStateKeys.includes( key ); -} - -function isKeyFinished( - key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined -): boolean { - return key === 'finished'; -} - -function isKeyFailed( - key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined -): boolean { - return key === 'failed'; -} - -function isKeyCancelled( - key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined -): boolean { - return key === 'cancelled'; -} - -function getPushUploadPercentage( - statusKey: PushStateProgressInfo[ 'key' ] | undefined, - uploadProgress: number | undefined -): number | null { - if ( isKeyUploading( statusKey ) && uploadProgress !== undefined ) { - return Math.round( uploadProgress ); - } - return null; -} - export function useSyncStatesProgressInfo() { const { __ } = useI18n(); const pullStatesProgressInfo = useMemo( () => { @@ -179,7 +104,7 @@ export function useSyncStatesProgressInfo() { uploading: { key: 'uploading', progress: 40, - message: __( 'Uploading site…' ), + message: __( 'Uploading Studio site…' ), }, uploadingPaused: { key: 'uploadingPaused', @@ -219,7 +144,67 @@ export function useSyncStatesProgressInfo() { } as const satisfies PushStateProgressInfoValues; }, [ __ ] ); - const uploadingProgressMessageTemplate = useMemo( () => __( 'Uploading site (%d%%)…' ), [ __ ] ); + const isKeyPulling = ( key: PullStateProgressInfo[ 'key' ] | undefined ) => { + const pullingStateKeys: PullStateProgressInfo[ 'key' ][] = [ + 'in-progress', + 'downloading', + 'importing', + ]; + if ( ! key ) { + return false; + } + return pullingStateKeys.includes( key ); + }; + + const isKeyPushing = ( key: PushStateProgressInfo[ 'key' ] | undefined ) => { + const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ + 'creatingBackup', + 'uploading', + 'creatingRemoteBackup', + 'applyingChanges', + 'finishing', + ]; + if ( ! key ) { + return false; + } + return pushingStateKeys.includes( key ); + }; + + const isKeyUploadingPaused = ( key: PushStateProgressInfo[ 'key' ] | undefined ) => { + return key === 'uploadingPaused'; + }; + + const isKeyImporting = ( key: PushStateProgressInfo[ 'key' ] | undefined ) => { + const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ + 'creatingRemoteBackup', + 'applyingChanges', + 'finishing', + ]; + if ( ! key ) { + return false; + } + return pushingStateKeys.includes( key ); + }; + const isKeyFinished = useCallback( + ( key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined ) => { + return key === 'finished'; + }, + [] + ); + + const isKeyFailed = useCallback( + ( key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined ) => { + return key === 'failed'; + }, + [] + ); + + const isKeyCancelled = useCallback( + ( key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined ) => { + return key === 'cancelled'; + }, + [] + ); const getBackupStatusWithProgress = useCallback( ( @@ -312,34 +297,6 @@ export function useSyncStatesProgressInfo() { ] ); - const getPushUploadMessage = useCallback( - ( message: string, uploadPercentage: number | null ): string => { - if ( uploadPercentage !== null ) { - // translators: %d is the upload progress percentage - return sprintf( uploadingProgressMessageTemplate, uploadPercentage ); - } - return message; - }, - [ uploadingProgressMessageTemplate ] - ); - - const mapUploadProgressToOverallProgress = useCallback( - ( uploadProgress: number ): number => { - // Map upload progress (0-100%) to the uploading state range (40-50%) - const uploadingProgressRange = - pushStatesProgressInfo.creatingRemoteBackup.progress - - pushStatesProgressInfo.uploading.progress; - return ( - pushStatesProgressInfo.uploading.progress + - ( uploadProgress / 100 ) * uploadingProgressRange - ); - }, - [ - pushStatesProgressInfo.creatingRemoteBackup.progress, - pushStatesProgressInfo.uploading.progress, - ] - ); - return { pullStatesProgressInfo, pushStatesProgressInfo, @@ -349,13 +306,9 @@ export function useSyncStatesProgressInfo() { isKeyFinished, isKeyFailed, isKeyCancelled, - isKeyUploading, getBackupStatusWithProgress, getPullStatusWithProgress, getPushStatusWithProgress, - getPushUploadPercentage, - getPushUploadMessage, - mapUploadProgressToOverallProgress, isKeyUploadingPaused, }; } diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 69f7a9f038..a9a4ebdcf3 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -450,7 +450,6 @@ export interface FolderDialogResponse { name: string; isEmpty: boolean; isWordPress: boolean; - isNameTooLong?: boolean; } export async function showSaveAsDialog( event: IpcMainInvokeEvent, options: SaveDialogOptions ) { @@ -714,15 +713,6 @@ export async function generateProposedSitePath( isWordPress: false, }; } - if ( isErrnoException( err ) && err.code === 'ENAMETOOLONG' ) { - return { - path, - name: siteName, - isEmpty: false, - isWordPress: false, - isNameTooLong: true, - }; - } throw err; } } diff --git a/src/ipc-utils.ts b/src/ipc-utils.ts index e5e8de413b..9893ac9934 100644 --- a/src/ipc-utils.ts +++ b/src/ipc-utils.ts @@ -40,7 +40,6 @@ export interface IpcEvents { 'site-context-menu-action': [ { action: string; siteId: string } ]; 'sync-upload-paused': [ { error: string; selectedSiteId: string; remoteSiteId: number } ]; 'sync-upload-resumed': [ { selectedSiteId: string; remoteSiteId: number } ]; - 'sync-upload-progress': [ { selectedSiteId: string; remoteSiteId: number; progress: number } ]; 'snapshot-error': [ { operationId: crypto.UUID; data: SnapshotEventData } ]; 'snapshot-fatal-error': [ { operationId: crypto.UUID; data: { message: string } } ]; 'snapshot-output': [ { operationId: crypto.UUID; data: SnapshotEventData } ]; diff --git a/src/lib/get-localized-link.ts b/src/lib/get-localized-link.ts index 7ed1c5a7b5..918581a2c4 100644 --- a/src/lib/get-localized-link.ts +++ b/src/lib/get-localized-link.ts @@ -34,9 +34,6 @@ const DOCS_LINKS = { docsXdebug: { en: 'https://developer.wordpress.com/docs/developer-tools/studio/xdebug/', }, - docsSslInStudio: { - en: 'https://developer.wordpress.com/docs/developer-tools/studio/ssl-in-studio/', - }, } satisfies Record< `docs${ string }`, TranslatedLink >; const BLOG_LINKS = { diff --git a/src/lib/import-export/export/exporters/default-exporter.ts b/src/lib/import-export/export/exporters/default-exporter.ts index 2a187e7a70..15eeadc653 100644 --- a/src/lib/import-export/export/exporters/default-exporter.ts +++ b/src/lib/import-export/export/exporters/default-exporter.ts @@ -27,8 +27,8 @@ export class DefaultExporter extends EventEmitter implements Exporter { private backup: BackupContents; private readonly options: ExportOptions; - isExactPathExcluded( pathToCheck: string ) { - const PATHS_TO_EXCLUDE = [ + private isExcludedPath( pathToCheck: string ) { + const pathsToExclude = [ 'wp-content/mu-plugins/sqlite-database-integration', 'wp-content/database', 'wp-content/db.php', @@ -44,35 +44,11 @@ export class DefaultExporter extends EventEmitter implements Exporter { 'wp-content/mu-plugins/0-https-for-reverse-proxy.php', 'wp-content/mu-plugins/0-sqlite-command.php', ]; - - return PATHS_TO_EXCLUDE.some( ( pathToExclude ) => + return pathsToExclude.some( ( pathToExclude ) => pathToCheck.startsWith( path.normalize( pathToExclude ) ) ); } - // Look for disallowed directory names in a given path. If found, determine whether that part of - // the path is a directory or not. - isPathExcludedByPattern( pathToCheck: string ) { - const DIRECTORY_NAMES_TO_EXCLUDE = [ '.git', 'node_modules', 'cache' ]; - const pathParts = pathToCheck.split( path.sep ); - - for ( const directoryName of DIRECTORY_NAMES_TO_EXCLUDE ) { - if ( ! pathParts.includes( directoryName ) ) { - continue; - } - const offenderIndex = pathToCheck.lastIndexOf( directoryName ); - const offenderPath = pathToCheck.substring( 0, offenderIndex + directoryName.length ); - try { - const stat = fs.statSync( offenderPath ); - return stat.isDirectory(); - } catch ( error ) { - return false; - } - } - - return false; - } - constructor( options: ExportOptions ) { super(); this.options = options; @@ -81,7 +57,6 @@ export class DefaultExporter extends EventEmitter implements Exporter { sqlFiles: [], }; } - async canHandle(): Promise< boolean > { const supportedExtension = [ 'tar.gz', 'tzg', 'zip' ].find( ( ext ) => this.options.backupFile.endsWith( ext ) @@ -210,21 +185,19 @@ export class DefaultExporter extends EventEmitter implements Exporter { const stat = await fsPromises.stat( fullPath ); if ( stat.isDirectory() ) { this.archiveBuilder.directory( fullPath, archivePath, ( entry ) => { - const entryPathRelativeToArchiveRoot = path.join( archivePath, entry.name ); - const fullEntryPathOnDisk = path.join( - this.options.site.path, - entryPathRelativeToArchiveRoot - ); + const fullArchivePath = path.join( archivePath, entry.name ); if ( - this.isExactPathExcluded( entryPathRelativeToArchiveRoot ) || - this.isPathExcludedByPattern( fullEntryPathOnDisk ) + this.isExcludedPath( fullArchivePath ) || + entry.name.includes( '.git' ) || + entry.name.includes( 'node_modules' ) || + entry.name.includes( 'cache' ) ) { return false; } return entry; } ); } else { - if ( this.isExactPathExcluded( archivePath ) ) { + if ( this.isExcludedPath( archivePath ) ) { continue; } this.archiveBuilder.file( fullPath, { name: archivePath } ); diff --git a/src/lib/import-export/tests/export/exporters/default-exporter.test.ts b/src/lib/import-export/tests/export/exporters/default-exporter.test.ts index d94bead799..303be179c0 100644 --- a/src/lib/import-export/tests/export/exporters/default-exporter.test.ts +++ b/src/lib/import-export/tests/export/exporters/default-exporter.test.ts @@ -180,20 +180,6 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { ( fs.existsSync as jest.Mock ).mockImplementation( pathExistsMockImplementation ); - ( fs.statSync as jest.Mock ).mockImplementation( ( filePath: string ) => { - const normalizedPath = normalize( filePath ); - if ( - mockFiles.some( - ( file ) => normalizedPath === normalize( path.join( file.path, file.name ) ) - ) - ) { - return { isDirectory: () => false, isFile: () => true }; - } else if ( pathExistsMockImplementation( normalizedPath ) ) { - return { isDirectory: () => true, isFile: () => false }; - } - throw new Error( `File not found: ${ normalizedPath }` ); - } ); - mockBackup = { backupFile: normalize( '/path/to/backup.tar.gz' ), sqlFiles: [ normalize( '/tmp/studio_export_123/file.sql' ) ], @@ -653,142 +639,4 @@ platformTestSuite( 'DefaultExporter', ( { normalize } ) => { { name: 'wp-content/mu-plugins/sqlite-database-integration/example-load.php' } ); } ); - - describe( 'isExactPathExcluded', () => { - it( 'should exclude exact paths from PATHS_TO_EXCLUDE list', () => { - const exporter = new DefaultExporter( mockOptions ); - - expect( exporter.isExactPathExcluded( normalize( 'wp-content/database' ) ) ).toBe( true ); - expect( exporter.isExactPathExcluded( normalize( 'wp-content/db.php' ) ) ).toBe( true ); - expect( exporter.isExactPathExcluded( normalize( 'wp-content/debug.log' ) ) ).toBe( true ); - expect( - exporter.isExactPathExcluded( - normalize( 'wp-content/mu-plugins/sqlite-database-integration' ) - ) - ).toBe( true ); - expect( - exporter.isExactPathExcluded( - normalize( 'wp-content/mu-plugins/0-allowed-redirect-hosts.php' ) - ) - ).toBe( true ); - } ); - - it( 'should return false for paths not in the exclusion list', () => { - const exporter = new DefaultExporter( mockOptions ); - - expect( exporter.isExactPathExcluded( normalize( 'wp-content/plugins' ) ) ).toBe( false ); - expect( exporter.isExactPathExcluded( normalize( 'wp-content/themes' ) ) ).toBe( false ); - expect( exporter.isExactPathExcluded( normalize( 'wp-content/uploads' ) ) ).toBe( false ); - expect( exporter.isExactPathExcluded( normalize( 'wp-config.php' ) ) ).toBe( false ); - } ); - - it( 'should match paths that start with excluded prefixes', () => { - const exporter = new DefaultExporter( mockOptions ); - - expect( - exporter.isExactPathExcluded( normalize( 'wp-content/database/something.sql' ) ) - ).toBe( true ); - expect( - exporter.isExactPathExcluded( - normalize( 'wp-content/mu-plugins/sqlite-database-integration/load.php' ) - ) - ).toBe( true ); - } ); - } ); - - describe( 'isPathExcludedByPattern', () => { - it( 'should exclude disallowed directories based on their names', () => { - ( fs.statSync as jest.Mock ).mockReturnValue( { - isDirectory: () => true, - isFile: () => false, - } ); - - const exporter = new DefaultExporter( mockOptions ); - - expect( - exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/.git' ) ) - ).toBe( true ); - expect( - exporter.isPathExcludedByPattern( - normalize( '/path/to/site/wp-content/node_modules/hello' ) - ) - ).toBe( true ); - expect( - exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/cache' ) ) - ).toBe( true ); - expect( - exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/my-cache' ) ) - ).toBe( false ); - } ); - - it( 'should return false for non-excluded directories', () => { - ( fs.statSync as jest.Mock ).mockReturnValue( { - isDirectory: () => true, - isFile: () => false, - } ); - - const exporter = new DefaultExporter( mockOptions ); - - expect( - exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/uploads' ) ) - ).toBe( false ); - expect( - exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/plugins' ) ) - ).toBe( false ); - expect( - exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/themes' ) ) - ).toBe( false ); - } ); - - it( 'should return false for non-existent paths (stat fails)', () => { - const exporter = new DefaultExporter( mockOptions ); - - // Paths that don't exist in mockFiles will cause statSync to throw, returning false - expect( - exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/nonexistent' ) ) - ).toBe( false ); - expect( - exporter.isPathExcludedByPattern( normalize( '/path/to/site/nonexistent/.git' ) ) - ).toBe( false ); - } ); - - it( 'should return false for files (not directories)', () => { - ( fs.statSync as jest.Mock ).mockReturnValue( { - isDirectory: () => false, - isFile: () => true, - } ); - - const exporter = new DefaultExporter( mockOptions ); - - expect( - exporter.isPathExcludedByPattern( - normalize( '/path/to/site/wp-content/uploads/file1.jpg' ) - ) - ).toBe( false ); - expect( exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-config.php' ) ) ).toBe( - false - ); - expect( exporter.isPathExcludedByPattern( normalize( '/path/to/site/node_modules' ) ) ).toBe( - false - ); - } ); - - it( 'should handle directory names found at any position in the path', () => { - ( fs.statSync as jest.Mock ).mockReturnValue( { - isDirectory: () => true, - isFile: () => false, - } ); - - const exporter = new DefaultExporter( mockOptions ); - - expect( - exporter.isPathExcludedByPattern( normalize( '/path/to/site/wp-content/.git' ) ) - ).toBe( true ); - expect( - exporter.isPathExcludedByPattern( - normalize( '/path/to/site/wp-content/plugins/akismet/node_modules/webpack/index.js' ) - ) - ).toBe( true ); - } ); - } ); } ); diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index 64dd3a9271..5752eef141 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -7,10 +7,11 @@ import { FormEvent, useState, useEffect } from 'react'; import { generateCustomDomainFromSiteName } from 'common/lib/domains'; import Button from 'src/components/button'; import FolderIcon from 'src/components/folder-icon'; -import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; +import { LearnMoreLink } from 'src/components/learn-more'; import TextControlComponent from 'src/components/text-control'; import { WPVersionSelector } from 'src/components/wp-version-selector'; import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; import { AllowedPHPVersion } from 'src/lib/wordpress-provider/constants'; import { useRootSelector } from 'src/stores'; import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api'; @@ -379,7 +380,17 @@ export const CreateSiteForm = ( { { __( 'You need to manually add the Studio root certificate authority to your keychain and trust it to enable HTTPS.' ) }{ ' ' } - +
) } diff --git a/src/modules/cli/lib/macos-installation-manager.ts b/src/modules/cli/lib/macos-installation-manager.ts index 2cca3adf6c..18ff077722 100644 --- a/src/modules/cli/lib/macos-installation-manager.ts +++ b/src/modules/cli/lib/macos-installation-manager.ts @@ -101,7 +101,7 @@ export class MacOSCliInstallationManager implements StudioCliInstallationManager const mainWindow = await getMainWindow(); await dialog.showMessageBox( mainWindow, { type: 'info', - title: __( 'CLI uninstalled' ), + title: __( 'CLI Uninstalled' ), message: __( 'The CLI has been uninstalled successfully.' ), } ); } catch ( error ) { diff --git a/src/modules/cli/lib/windows-installation-manager.ts b/src/modules/cli/lib/windows-installation-manager.ts index 5ebcdeaa94..2620a8da0e 100644 --- a/src/modules/cli/lib/windows-installation-manager.ts +++ b/src/modules/cli/lib/windows-installation-manager.ts @@ -93,7 +93,7 @@ export class WindowsCliInstallationManager implements StudioCliInstallationManag const mainWindow = await getMainWindow(); await dialog.showMessageBox( mainWindow, { type: 'info', - title: __( 'CLI uninstalled' ), + title: __( 'CLI Uninstalled' ), message: __( 'The CLI has been uninstalled successfully.' ), } ); } catch ( error ) { diff --git a/src/modules/site-settings/edit-site-details.tsx b/src/modules/site-settings/edit-site-details.tsx index c5e8acd0ea..10103cb05f 100644 --- a/src/modules/site-settings/edit-site-details.tsx +++ b/src/modules/site-settings/edit-site-details.tsx @@ -8,7 +8,7 @@ import { generateCustomDomainFromSiteName, getDomainNameValidationError } from ' import { getWordPressVersionUrl } from 'common/lib/wordpress-version-utils'; import Button from 'src/components/button'; import { ErrorInformation } from 'src/components/error-information'; -import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; +import { LearnMoreLink } from 'src/components/learn-more'; import Modal from 'src/components/modal'; import TextControlComponent from 'src/components/text-control'; import { Tooltip } from 'src/components/tooltip'; @@ -328,7 +328,17 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = { __( 'You need to manually add the Studio certificate authority to your keychain and trust it.' ) }{ ' ' } - + ) } diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index 23360fedc4..6aac5ac0e6 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -195,8 +195,6 @@ const SyncConnectedSitesSectionItem = ( { isKeyFailed, isKeyCancelled, getPullStatusWithProgress, - getPushUploadPercentage, - getPushUploadMessage, isKeyUploadingPaused, } = useSyncStatesProgressInfo(); @@ -215,11 +213,6 @@ const SyncConnectedSitesSectionItem = ( { const hasPushFinished = pushState?.status && isKeyFinished( pushState.status.key ); const hasPushCancelled = pushState?.status && isKeyCancelled( pushState.status.key ); - const uploadPercentage = getPushUploadPercentage( - pushState?.status.key, - pushState?.uploadProgress - ); - return (
- { getPushUploadMessage( pushState.status.message, uploadPercentage ) } + { pushState.status.message }
diff --git a/src/modules/sync/components/sync-dialog.tsx b/src/modules/sync/components/sync-dialog.tsx index 72392f3450..c6d8d9c907 100644 --- a/src/modules/sync/components/sync-dialog.tsx +++ b/src/modules/sync/components/sync-dialog.tsx @@ -117,6 +117,13 @@ const useDynamicTreeState = ( localSiteId, ] ); + // Handle local file tree errors by clearing children to show custom error message + useEffect( () => { + if ( type === 'push' && localFileTreeError ) { + setTreeState( ( treeState ) => updateNodeById( treeState, 'wp-content', { children: [] } ) ); + } + }, [ type, localFileTreeError, setTreeState ] ); + return { rewindId, fetchChildren, @@ -371,11 +378,7 @@ export function SyncDialog( {
); } - return ( -
- { __( 'Empty' ) } -
- ); + return null; } } /> @@ -392,8 +395,6 @@ export function SyncDialog( { showLabels valueLabel={ formattedSize } limitLabel={ formattedLimit } - // translators: %s is a filesize string, e.g. "1.3GB". This label is displayed if a sync - // archive is larger than a given limit. overLimitLabel={ sprintf( __( '%s over' ), formattedOverAmount ) } /> diff --git a/src/modules/sync/lib/ipc-handlers.ts b/src/modules/sync/lib/ipc-handlers.ts index f30c5fb90a..d38a936187 100644 --- a/src/modules/sync/lib/ipc-handlers.ts +++ b/src/modules/sync/lib/ipc-handlers.ts @@ -194,7 +194,7 @@ export async function pushArchive( console.error( '[TUS] Upload error', error ); reject( error ); }, - onProgress: ( bytesSent: number, bytesTotal: number ) => { + onProgress: () => { if ( isUploadingPaused ) { isUploadingPaused = false; void sendIpcEventToRenderer( 'sync-upload-resumed', { @@ -207,14 +207,6 @@ export async function pushArchive( if ( ! hasUploadStarted ) { hasUploadStarted = true; } - - // Calculate upload progress percentage (0-100) - const uploadProgress = bytesTotal > 0 ? ( bytesSent / bytesTotal ) * 100 : 0; - void sendIpcEventToRenderer( 'sync-upload-progress', { - selectedSiteId: selectedSiteId, - remoteSiteId: remoteSiteId, - progress: uploadProgress, - } ); }, onSuccess: ( payload ) => { if ( ! payload.lastResponse ) { diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 31d1a06781..8a7793bede 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -392,7 +392,6 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl status: pushStatesProgressInfo.creatingRemoteBackup, selectedSite, remoteSiteUrl, - uploadProgress: undefined, // Clear upload progress when transitioning to next state }, isKeyFailed, isKeyFinished From fb2546e8fbf02643cefa504f61eaf35e668fabb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 14 Jan 2026 15:24:57 +0000 Subject: [PATCH 20/58] Add back upload progress indicator Migrate the changes of PR #2332 --- src/stores/sync/sync-operations-slice.ts | 88 ++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 8a7793bede..dad77f7c2b 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -6,6 +6,7 @@ import { SYNC_PUSH_SIZE_LIMIT_BYTES, SYNC_PUSH_SIZE_LIMIT_GB } from 'src/constan 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 { connectedSitesApi } from 'src/stores/sync/connected-sites'; import type { SyncBackupState, PullStates } from 'src/hooks/sync-sites/use-sync-pull'; import type { SyncPushState, PushStates } from 'src/hooks/sync-sites/use-sync-push'; @@ -87,6 +88,93 @@ const syncOperationsSlice = createSlice( { export const syncOperationsActions = syncOperationsSlice.actions; export const syncOperationsReducer = syncOperationsSlice.reducer; +/** + * Keep upload progress in sync with the renderer store. + * + * The main process emits upload progress via IPC while streaming the push backup + * to WordPress.com (TUS). The UI expects `pushState.uploadProgress` to be updated + * so it can render "Uploading site (%d%)…" and, optionally, a smoother progress + * bar during the upload phase. + */ +const UPLOADING_BASE_PROGRESS = 40; +const CREATING_REMOTE_BACKUP_PROGRESS = 50; + +function isUploadPhaseKey( key: SyncPushState[ 'status' ][ 'key' ] | undefined ) { + return key === 'creatingBackup' || key === 'uploading' || key === 'uploadingPaused'; +} + +window.ipcListener.subscribe( 'sync-upload-progress', ( _event, payload ) => { + const stateId = generateStateId( payload.selectedSiteId, payload.remoteSiteId ); + const existing = store.getState().syncOperations.pushStates[ stateId ]; + if ( ! existing || ! isUploadPhaseKey( existing.status?.key ) ) { + return; + } + + const uploadProgress = Math.max( 0, Math.min( 100, payload.progress ) ); + const uploadRange = CREATING_REMOTE_BACKUP_PROGRESS - UPLOADING_BASE_PROGRESS; // 10 + const overallProgress = UPLOADING_BASE_PROGRESS + ( uploadProgress / 100 ) * uploadRange; + + store.dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: payload.selectedSiteId, + remoteSiteId: payload.remoteSiteId, + state: { + uploadProgress, + status: { + ...existing.status, + key: 'uploading', + progress: overallProgress, + }, + }, + } ) + ); +} ); + +window.ipcListener.subscribe( 'sync-upload-paused', ( _event, payload ) => { + const stateId = generateStateId( payload.selectedSiteId, payload.remoteSiteId ); + const existing = store.getState().syncOperations.pushStates[ stateId ]; + if ( ! existing || ! isUploadPhaseKey( existing.status?.key ) ) { + return; + } + + store.dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: payload.selectedSiteId, + remoteSiteId: payload.remoteSiteId, + state: { + status: { + ...existing.status, + key: 'uploadingPaused', + progress: 45, + message: __( 'Uploading paused' ), + }, + }, + } ) + ); +} ); + +window.ipcListener.subscribe( 'sync-upload-resumed', ( _event, payload ) => { + const stateId = generateStateId( payload.selectedSiteId, payload.remoteSiteId ); + const existing = store.getState().syncOperations.pushStates[ stateId ]; + if ( ! existing || ! isUploadPhaseKey( existing.status?.key ) ) { + return; + } + + store.dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: payload.selectedSiteId, + remoteSiteId: payload.remoteSiteId, + state: { + status: { + ...existing.status, + key: 'uploading', + message: __( 'Uploading site…' ), + }, + }, + } ) + ); +} ); + // Helper functions for push operations const isKeyCancelled = ( key: string | undefined ): boolean => { return key === 'cancelled'; From 30cc5da82b428a09488ca7ee72135c3bfb1ee647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 27 Jan 2026 13:34:07 +0000 Subject: [PATCH 21/58] Compare status keys directly, remove unecessary helpers --- src/stores/sync/sync-operations-slice.ts | 131 ++++++----------------- 1 file changed, 35 insertions(+), 96 deletions(-) diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index dad77f7c2b..14b3aed532 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -175,19 +175,6 @@ window.ipcListener.subscribe( 'sync-upload-resumed', ( _event, payload ) => { ); } ); -// Helper functions for push operations -const isKeyCancelled = ( key: string | undefined ): boolean => { - return key === 'cancelled'; -}; - -const isKeyFailed = ( key: string | undefined ): boolean => { - return key === 'failed'; -}; - -const isKeyFinished = ( key: string | undefined ): boolean => { - return key === 'finished'; -}; - const getErrorFromResponse = ( error: unknown ): string => { if ( typeof error === 'object' && @@ -205,9 +192,7 @@ const updatePushStateWithIpc = ( dispatch: AppDispatch, selectedSiteId: string, remoteSiteId: number, - state: Partial< SyncPushState >, - isKeyFailedFn: ( key: string | undefined ) => boolean, - isKeyFinishedFn: ( key: string | undefined ) => boolean + state: Partial< SyncPushState > ) => { const stateId = generateStateId( selectedSiteId, remoteSiteId ); const statusKey = state.status?.key; @@ -220,7 +205,7 @@ const updatePushStateWithIpc = ( } ) ); - if ( isKeyFailedFn( statusKey ) || isKeyFinishedFn( statusKey ) || isKeyCancelled( statusKey ) ) { + if ( statusKey === 'failed' || statusKey === 'finished' || statusKey === 'cancelled' ) { getIpcApi().clearSyncOperation( stateId ); } else if ( state.status ) { getIpcApi().addSyncOperation( stateId, state.status ); @@ -350,19 +335,12 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl ); // Initialize push state - updatePushStateWithIpc( - dispatch, - selectedSite.id, + updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { remoteSiteId, - { - remoteSiteId, - status: pushStatesProgressInfo.creatingBackup, - selectedSite, - remoteSiteUrl, - }, - isKeyFailed, - isKeyFinished - ); + status: pushStatesProgressInfo.creatingBackup, + selectedSite, + remoteSiteUrl, + } ); let archivePath: string, archiveSizeInBytes: number; @@ -374,26 +352,16 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl ( { archivePath, archiveSizeInBytes } = result ); } catch ( error ) { if ( error instanceof Error && error.message === 'Export aborted' ) { - updatePushStateWithIpc( - dispatch, - selectedSite.id, - remoteSiteId, - { status: pushStatesProgressInfo.cancelled }, - isKeyFailed, - isKeyFinished - ); + updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { + status: pushStatesProgressInfo.cancelled, + } ); throw error; // Signal cancellation } Sentry.captureException( error ); - updatePushStateWithIpc( - dispatch, - selectedSite.id, - remoteSiteId, - { status: pushStatesProgressInfo.failed }, - isKeyFailed, - isKeyFinished - ); + updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { + status: pushStatesProgressInfo.failed, + } ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), message: __( @@ -407,14 +375,9 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl // Check file size if ( archiveSizeInBytes > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - updatePushStateWithIpc( - dispatch, - selectedSite.id, - remoteSiteId, - { status: pushStatesProgressInfo.failed }, - isKeyFailed, - isKeyFinished - ); + updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { + status: pushStatesProgressInfo.failed, + } ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), message: __( @@ -434,21 +397,16 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl if ( ! currentPushState || ! currentPushState.status || - isKeyCancelled( currentPushState.status.key ) + currentPushState.status.key === 'cancelled' ) { await getIpcApi().removeExportedSiteTmpFile( archivePath ); throw new Error( 'Push cancelled' ); } // Update to uploading - updatePushStateWithIpc( - dispatch, - selectedSite.id, - remoteSiteId, - { status: pushStatesProgressInfo.uploading }, - isKeyFailed, - isKeyFinished - ); + updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { + status: pushStatesProgressInfo.uploading, + } ); try { const response = await getIpcApi().pushArchive( @@ -466,24 +424,17 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl remoteSiteId )( stateAfterUpload ); - if ( isKeyCancelled( pushStateAfterUpload?.status.key ) ) { + if ( pushStateAfterUpload?.status.key === 'cancelled' ) { await getIpcApi().removeExportedSiteTmpFile( archivePath ); throw new Error( 'Push cancelled' ); } if ( response.success ) { - updatePushStateWithIpc( - dispatch, - selectedSite.id, - remoteSiteId, - { - status: pushStatesProgressInfo.creatingRemoteBackup, - selectedSite, - remoteSiteUrl, - }, - isKeyFailed, - isKeyFinished - ); + updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { + status: pushStatesProgressInfo.creatingRemoteBackup, + selectedSite, + remoteSiteUrl, + } ); // Return info needed for polling return { @@ -497,14 +448,9 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl } } catch ( error ) { Sentry.captureException( error ); - updatePushStateWithIpc( - dispatch, - selectedSite.id, - remoteSiteId, - { status: pushStatesProgressInfo.failed }, - isKeyFailed, - isKeyFinished - ); + updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { + status: pushStatesProgressInfo.failed, + } ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), message: getErrorFromResponse( error ), @@ -690,7 +636,7 @@ export const pollPushProgressThunk = createTypedAsyncThunk( if ( ! currentPushState || ! currentPushState.status || - isKeyCancelled( currentPushState.status.key ) + currentPushState.status.key === 'cancelled' ) { return; } @@ -759,14 +705,7 @@ export const pollPushProgressThunk = createTypedAsyncThunk( } status = getPushStatusWithProgress( status, response, pushStatesProgressInfo ); // Update state in any case to keep polling push state - updatePushStateWithIpc( - dispatch, - selectedSiteId, - remoteSiteId, - { status }, - isKeyFailed, - isKeyFinished - ); + updatePushStateWithIpc( dispatch, selectedSiteId, remoteSiteId, { status } ); } ); @@ -823,7 +762,7 @@ export const pollPullBackupThunk = createTypedAsyncThunk( if ( ! currentPullState || ! currentPullState.status || - isKeyCancelled( currentPullState.status.key ) + currentPullState.status.key === 'cancelled' ) { return; } @@ -910,7 +849,7 @@ export const completePullThunk = createTypedAsyncThunk( if ( ! currentPullState || ! currentPullState.status || - isKeyCancelled( currentPullState.status.key ) + currentPullState.status.key === 'cancelled' ) { return; } @@ -983,7 +922,7 @@ export const completePullThunk = createTypedAsyncThunk( if ( ! pullStateAfterDownload || ! pullStateAfterDownload.status || - isKeyCancelled( pullStateAfterDownload.status.key ) + pullStateAfterDownload.status.key === 'cancelled' ) { return; } @@ -1055,7 +994,7 @@ export const completePullThunk = createTypedAsyncThunk( if ( pullStateOnError && pullStateOnError.status && - isKeyCancelled( pullStateOnError.status.key ) + pullStateOnError.status.key === 'cancelled' ) { return; } From 74802a7808da02d3af6a99c7955909a337e92286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 27 Jan 2026 16:33:13 +0000 Subject: [PATCH 22/58] make clearPullState and clearPushState thunks responsible for clearing the state as well --- src/hooks/sync-sites/use-sync-pull.ts | 2 -- src/hooks/sync-sites/use-sync-push.ts | 2 -- src/stores/sync/sync-operations-slice.ts | 16 ++++++++-------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index a91cc32551..ca96fd6f8f 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -68,8 +68,6 @@ export function useSyncPull(): UseSyncPull { const clearPullState = useCallback< ClearState >( ( selectedSiteId, remoteSiteId ) => { - // Dispatch both the action and the thunk - dispatch( syncOperationsActions.clearPullState( { selectedSiteId, remoteSiteId } ) ); void dispatch( syncOperationsThunks.clearPullState( { selectedSiteId, remoteSiteId } ) ); }, [ dispatch ] diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 87ba2b2097..26f76df78d 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -91,8 +91,6 @@ export function useSyncPush(): UseSyncPush { const clearPushState = useCallback< ClearState >( ( selectedSiteId, remoteSiteId ) => { - // Dispatch both the action and the thunk - dispatch( syncOperationsActions.clearPushState( { selectedSiteId, remoteSiteId } ) ); void dispatch( syncOperationsThunks.clearPushState( { selectedSiteId, remoteSiteId } ) ); }, [ dispatch ] diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 14b3aed532..83d576d6ca 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -221,7 +221,10 @@ const createTypedAsyncThunk = createAsyncThunk.withTypes< { // Thunks for clear operations export const clearPushStateThunk = createTypedAsyncThunk( 'syncOperations/clearPushState', - async ( { selectedSiteId, remoteSiteId }: ClearStatePayload ) => { + async ( { selectedSiteId, remoteSiteId }: ClearStatePayload, { dispatch } ) => { + // Update Redux state + dispatch( syncOperationsActions.clearPushState( { selectedSiteId, remoteSiteId } ) ); + // Clear IPC operation const stateId = generateStateId( selectedSiteId, remoteSiteId ); getIpcApi().clearSyncOperation( stateId ); return { selectedSiteId, remoteSiteId }; @@ -230,7 +233,10 @@ export const clearPushStateThunk = createTypedAsyncThunk( export const clearPullStateThunk = createTypedAsyncThunk( 'syncOperations/clearPullState', - async ( { selectedSiteId, remoteSiteId }: ClearStatePayload ) => { + async ( { selectedSiteId, remoteSiteId }: ClearStatePayload, { dispatch } ) => { + // Update Redux state + dispatch( syncOperationsActions.clearPullState( { selectedSiteId, remoteSiteId } ) ); + // Clear IPC operation const stateId = generateStateId( selectedSiteId, remoteSiteId ); getIpcApi().clearSyncOperation( stateId ); return { selectedSiteId, remoteSiteId }; @@ -327,9 +333,6 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl const operationId = generateStateId( selectedSite.id, remoteSiteId ); // Clear existing state - dispatch( - syncOperationsActions.clearPushState( { selectedSiteId: selectedSite.id, remoteSiteId } ) - ); void dispatch( syncOperationsThunks.clearPushState( { selectedSiteId: selectedSite.id, remoteSiteId } ) ); @@ -489,9 +492,6 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl const remoteSiteUrl = connectedSite.url; // Clear existing state - dispatch( - syncOperationsActions.clearPullState( { selectedSiteId: selectedSite.id, remoteSiteId } ) - ); void dispatch( syncOperationsThunks.clearPullState( { selectedSiteId: selectedSite.id, remoteSiteId } ) ); From 6cf80f8199be40bc7b7f06549fcad2e5cc865043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Fri, 6 Feb 2026 11:51:22 +0000 Subject: [PATCH 23/58] Inline single-use helper functions into their sole callers Inline getPushStatusWithProgress, getBackupStatusWithProgress, and completePullThunk directly into their only call sites within pollPushProgressThunk and pollPullBackupThunk. Remove the now-unused copies of getPushStatusWithProgress and getBackupStatusWithProgress from useSyncStatesProgressInfo. --- src/hooks/use-sync-states-progress-info.ts | 72 ---- src/stores/sync/sync-operations-slice.ts | 397 +++++++++------------ 2 files changed, 161 insertions(+), 308 deletions(-) diff --git a/src/hooks/use-sync-states-progress-info.ts b/src/hooks/use-sync-states-progress-info.ts index 9e89a34957..b446d2c897 100644 --- a/src/hooks/use-sync-states-progress-info.ts +++ b/src/hooks/use-sync-states-progress-info.ts @@ -54,7 +54,6 @@ export type ImportResponse = { const IN_PROGRESS_INITIAL_VALUE = 30; const DOWNLOADING_INITIAL_VALUE = 60; -const IN_PROGRESS_TO_DOWNLOADING_STEP = DOWNLOADING_INITIAL_VALUE - IN_PROGRESS_INITIAL_VALUE; const PULL_IMPORTING_INITIAL_VALUE = 80; function isKeyPulling( key: PullStateProgressInfo[ 'key' ] | undefined ): boolean { @@ -221,33 +220,6 @@ export function useSyncStatesProgressInfo() { const uploadingProgressMessageTemplate = useMemo( () => __( 'Uploading site (%d%%)…' ), [ __ ] ); - const getBackupStatusWithProgress = useCallback( - ( - hasBackupCompleted: boolean, - pullStatesProgressInfo: PullStateProgressInfoValues, - response: SyncBackupResponse - ) => { - const frontendStatus = hasBackupCompleted - ? pullStatesProgressInfo.downloading.key - : response.status; - let newProgressInfo: PullStateProgressInfo | null = null; - if ( response.status === 'in-progress' ) { - newProgressInfo = { - ...pullStatesProgressInfo[ frontendStatus ], - // Update progress from the initial value to the new step proportionally to the response.progress - // on every update of the response.progress - progress: - IN_PROGRESS_INITIAL_VALUE + - IN_PROGRESS_TO_DOWNLOADING_STEP * ( response.percent / 100 ), - }; - } - const statusWithProgress = newProgressInfo || pullStatesProgressInfo[ frontendStatus ]; - - return statusWithProgress; - }, - [] - ); - const getPullStatusWithProgress = useCallback( ( sitePullState?: PullStateProgressInfo, importState?: ImportProgressState[ string ] ) => { if ( importState ) { @@ -270,48 +242,6 @@ export function useSyncStatesProgressInfo() { [ __ ] ); - const getPushStatusWithProgress = useCallback( - ( status: PushStateProgressInfo, response: ImportResponse ) => { - if ( status.key === pushStatesProgressInfo.creatingRemoteBackup.key ) { - const progressRange = - pushStatesProgressInfo.applyingChanges.progress - - pushStatesProgressInfo.creatingRemoteBackup.progress; - - // This step will increase the progress to the next step progressively based on the backup_progress - return { - ...status, - progress: - pushStatesProgressInfo.creatingRemoteBackup.progress + - progressRange * ( response.backup_progress / 100 ), - }; - } - - // This step will increase the progress to the next step progressively based on the import_progress - if ( - status.key === pushStatesProgressInfo.applyingChanges.key && - response.import_progress < 100 - ) { - const progressRange = - pushStatesProgressInfo.finishing.progress - - pushStatesProgressInfo.applyingChanges.progress; - return { - ...status, - progress: - pushStatesProgressInfo.applyingChanges.progress + - progressRange * ( response.import_progress / 100 ), - }; - } - return status; - }, - [ - pushStatesProgressInfo.applyingChanges.key, - pushStatesProgressInfo.applyingChanges.progress, - pushStatesProgressInfo.creatingRemoteBackup.key, - pushStatesProgressInfo.creatingRemoteBackup.progress, - pushStatesProgressInfo.finishing.progress, - ] - ); - const getPushUploadMessage = useCallback( ( message: string, uploadPercentage: number | null ): string => { if ( uploadPercentage !== null ) { @@ -350,9 +280,7 @@ export function useSyncStatesProgressInfo() { isKeyFailed, isKeyCancelled, isKeyUploading, - getBackupStatusWithProgress, getPullStatusWithProgress, - getPushStatusWithProgress, getPushUploadPercentage, getPushUploadMessage, mapUploadProgressToOverallProgress, diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 83d576d6ca..e5c431db6d 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -575,43 +575,6 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl } ); -// Helper function to calculate push status with progress (inlined from useSyncStatesProgressInfo) -const getPushStatusWithProgress = ( - status: PushStateProgressInfo, - response: ImportResponse, - pushStatesProgressInfo: Record< PushStateProgressInfo[ 'key' ], PushStateProgressInfo > -): PushStateProgressInfo => { - if ( status.key === pushStatesProgressInfo.creatingRemoteBackup.key ) { - const progressRange = - pushStatesProgressInfo.applyingChanges.progress - - pushStatesProgressInfo.creatingRemoteBackup.progress; - - // This step will increase the progress to the next step progressively based on the backup_progress - return { - ...status, - progress: - pushStatesProgressInfo.creatingRemoteBackup.progress + - progressRange * ( response.backup_progress / 100 ), - }; - } - - // This step will increase the progress to the next step progressively based on the import_progress - if ( - status.key === pushStatesProgressInfo.applyingChanges.key && - response.import_progress < 100 - ) { - const progressRange = - pushStatesProgressInfo.finishing.progress - pushStatesProgressInfo.applyingChanges.progress; - return { - ...status, - progress: - pushStatesProgressInfo.applyingChanges.progress + - progressRange * ( response.import_progress / 100 ), - }; - } - return status; -}; - // Thunk for polling push progress type PollPushProgressPayload = { client: WPCOM; @@ -703,7 +666,30 @@ export const pollPushProgressThunk = createTypedAsyncThunk( } else if ( response.success && response.status === 'archive_import_finished' ) { status = pushStatesProgressInfo.finishing; } - status = getPushStatusWithProgress( status, response, pushStatesProgressInfo ); + // Calculate push status with progress + if ( status.key === pushStatesProgressInfo.creatingRemoteBackup.key ) { + const progressRange = + pushStatesProgressInfo.applyingChanges.progress - + pushStatesProgressInfo.creatingRemoteBackup.progress; + status = { + ...status, + progress: + pushStatesProgressInfo.creatingRemoteBackup.progress + + progressRange * ( response.backup_progress / 100 ), + }; + } else if ( + status.key === pushStatesProgressInfo.applyingChanges.key && + response.import_progress < 100 + ) { + const progressRange = + pushStatesProgressInfo.finishing.progress - pushStatesProgressInfo.applyingChanges.progress; + status = { + ...status, + progress: + pushStatesProgressInfo.applyingChanges.progress + + progressRange * ( response.import_progress / 100 ), + }; + } // Update state in any case to keep polling push state updatePushStateWithIpc( dispatch, selectedSiteId, remoteSiteId, { status } ); } @@ -714,30 +700,6 @@ const IN_PROGRESS_INITIAL_VALUE = 30; const DOWNLOADING_INITIAL_VALUE = 60; const IN_PROGRESS_TO_DOWNLOADING_STEP = DOWNLOADING_INITIAL_VALUE - IN_PROGRESS_INITIAL_VALUE; -// Helper function to calculate backup status with progress (inlined from useSyncStatesProgressInfo) -const getBackupStatusWithProgress = ( - hasBackupCompleted: boolean, - pullStatesProgressInfo: Record< PullStateProgressInfo[ 'key' ], PullStateProgressInfo >, - response: SyncBackupResponse -): PullStateProgressInfo => { - const frontendStatus = hasBackupCompleted - ? pullStatesProgressInfo.downloading.key - : response.status; - let newProgressInfo: PullStateProgressInfo | null = null; - if ( response.status === 'in-progress' ) { - newProgressInfo = { - ...pullStatesProgressInfo[ frontendStatus ], - // Update progress from the initial value to the new step proportionally to the response.progress - // on every update of the response.progress - progress: - IN_PROGRESS_INITIAL_VALUE + IN_PROGRESS_TO_DOWNLOADING_STEP * ( response.percent / 100 ), - }; - } - const statusWithProgress = newProgressInfo || pullStatesProgressInfo[ frontendStatus ]; - - return statusWithProgress; -}; - // Thunk for polling pull backup status type PollPullBackupPayload = { client: WPCOM; @@ -786,203 +748,167 @@ export const pollPullBackupThunk = createTypedAsyncThunk( const downloadUrl = hasBackupCompleted ? response.download_url : null; if ( downloadUrl ) { - // Backup completed, trigger completion thunk - await dispatch( - syncOperationsThunks.completePull( { + // Backup completed, handle download and import + const { selectedSite, remoteSiteUrl } = currentPullState; + + // Check file size + const fileSize = await getIpcApi().checkSyncBackupSize( downloadUrl ); + + if ( fileSize > SYNC_PUSH_SIZE_LIMIT_BYTES ) { + const CANCEL_ID = 1; + + const { response: userChoice } = await getIpcApi().showMessageBox( { + type: 'warning', + message: __( "Large site's backup" ), + detail: sprintf( + __( + "Your site's backup exceeds %d GB. Pulling it will prevent you from pushing the site back.\n\nDo you want to continue?" + ), + SYNC_PUSH_SIZE_LIMIT_GB + ), + buttons: [ __( 'Continue' ), __( 'Cancel' ) ], + defaultId: 0, + cancelId: CANCEL_ID, + } ); + + if ( userChoice === CANCEL_ID ) { + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { + status: pullStatesProgressInfo.cancelled, + }, + } ) + ); + void dispatch( + syncOperationsThunks.clearPullState( { selectedSiteId, remoteSiteId } ) + ); + return; + } + } + + // Update to downloading + dispatch( + syncOperationsActions.updatePullState( { selectedSiteId, remoteSiteId, - downloadUrl, - pullStatesProgressInfo, + state: { + status: pullStatesProgressInfo.downloading, + downloadUrl, + }, } ) - ).unwrap(); - } else { - // Update status with progress - const statusWithProgress = getBackupStatusWithProgress( - hasBackupCompleted, - pullStatesProgressInfo, - response ); + // Download backup + const operationId = generateStateId( selectedSiteId, remoteSiteId ); + const filePath = await getIpcApi().downloadSyncBackup( + remoteSiteId, + downloadUrl, + operationId + ); + + // Check if cancelled after download + const stateAfterDownload = getState(); + const pullStateAfterDownload = syncOperationsSelectors.selectPullState( + selectedSiteId, + remoteSiteId + )( stateAfterDownload ); + + if ( + ! pullStateAfterDownload || + ! pullStateAfterDownload.status || + pullStateAfterDownload.status.key === 'cancelled' + ) { + return; + } + + // Update to importing dispatch( syncOperationsActions.updatePullState( { selectedSiteId, remoteSiteId, state: { - status: statusWithProgress, - downloadUrl, + status: pullStatesProgressInfo.importing, }, } ) ); - // Update IPC sync operation - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - getIpcApi().addSyncOperation( stateId ); - } - } catch ( error ) { - console.error( 'Failed to fetch backup status:', error ); - throw error; - } - } -); - -// Thunk for completing pull operation (handles download, import, server start) -type CompletePullPayload = { - selectedSiteId: string; - remoteSiteId: number; - downloadUrl: string; - pullStatesProgressInfo: Record< PullStateProgressInfo[ 'key' ], PullStateProgressInfo >; -}; + // Stop server, import, then start server + await getIpcApi().stopServer( selectedSiteId ); + await getIpcApi().importSite( { + id: selectedSiteId, + backupFile: { + path: filePath, + type: 'application/tar+gzip', + }, + } ); + await getIpcApi().startServer( selectedSiteId ); -export const completePullThunk = createTypedAsyncThunk( - 'syncOperations/completePull', - async ( - { selectedSiteId, remoteSiteId, downloadUrl, pullStatesProgressInfo }: CompletePullPayload, - { dispatch, getState } - ) => { - // Check if cancelled - const state = getState(); - const currentPullState = syncOperationsSelectors.selectPullState( - selectedSiteId, - remoteSiteId - )( state ); + // Clean up + await getIpcApi().removeSyncBackup( remoteSiteId ); - if ( - ! currentPullState || - ! currentPullState.status || - currentPullState.status.key === 'cancelled' - ) { - return; - } + // Update site timestamp + void dispatch( + connectedSitesApi.endpoints.updateSiteTimestamp.initiate( { + siteId: remoteSiteId, + localSiteId: selectedSiteId, + type: 'pull', + } ) + ); - const { selectedSite, remoteSiteUrl } = currentPullState; + // Mark as finished + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { + status: pullStatesProgressInfo.finished, + }, + } ) + ); - try { - // Check file size - const fileSize = await getIpcApi().checkSyncBackupSize( downloadUrl ); - - if ( fileSize > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - const CANCEL_ID = 1; - - const { response: userChoice } = await getIpcApi().showMessageBox( { - type: 'warning', - message: __( "Large site's backup" ), - detail: sprintf( - __( - "Your site's backup exceeds %d GB. Pulling it will prevent you from pushing the site back.\n\nDo you want to continue?" - ), - SYNC_PUSH_SIZE_LIMIT_GB + // Show notification + getIpcApi().showNotification( { + title: selectedSite.name, + body: sprintf( + // translators: %s is the site url without the protocol. + __( 'Studio site has been updated from %s' ), + getHostnameFromUrl( remoteSiteUrl ) ), - buttons: [ __( 'Continue' ), __( 'Cancel' ) ], - defaultId: 0, - cancelId: CANCEL_ID, } ); - - if ( userChoice === CANCEL_ID ) { - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId, - remoteSiteId, - state: { - status: pullStatesProgressInfo.cancelled, - }, - } ) - ); - void dispatch( syncOperationsThunks.clearPullState( { selectedSiteId, remoteSiteId } ) ); - return; + } else { + // Calculate backup status with progress + const frontendStatus = hasBackupCompleted + ? pullStatesProgressInfo.downloading.key + : response.status; + let statusWithProgress: PullStateProgressInfo = pullStatesProgressInfo[ frontendStatus ]; + if ( response.status === 'in-progress' ) { + statusWithProgress = { + ...pullStatesProgressInfo[ frontendStatus ], + progress: + IN_PROGRESS_INITIAL_VALUE + + IN_PROGRESS_TO_DOWNLOADING_STEP * ( response.percent / 100 ), + }; } - } - - // Update to downloading - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId, - remoteSiteId, - state: { - status: pullStatesProgressInfo.downloading, - downloadUrl, - }, - } ) - ); - - // Download backup - const operationId = generateStateId( selectedSiteId, remoteSiteId ); - const filePath = await getIpcApi().downloadSyncBackup( - remoteSiteId, - downloadUrl, - operationId - ); - // Check if cancelled after download - const stateAfterDownload = getState(); - const pullStateAfterDownload = syncOperationsSelectors.selectPullState( - selectedSiteId, - remoteSiteId - )( stateAfterDownload ); + dispatch( + syncOperationsActions.updatePullState( { + selectedSiteId, + remoteSiteId, + state: { + status: statusWithProgress, + downloadUrl, + }, + } ) + ); - if ( - ! pullStateAfterDownload || - ! pullStateAfterDownload.status || - pullStateAfterDownload.status.key === 'cancelled' - ) { - return; + // Update IPC sync operation + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + getIpcApi().addSyncOperation( stateId ); } - - // Update to importing - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId, - remoteSiteId, - state: { - status: pullStatesProgressInfo.importing, - }, - } ) - ); - - // Stop server, import, then start server - await getIpcApi().stopServer( selectedSiteId ); - await getIpcApi().importSite( { - id: selectedSiteId, - backupFile: { - path: filePath, - type: 'application/tar+gzip', - }, - } ); - await getIpcApi().startServer( selectedSiteId ); - - // Clean up - await getIpcApi().removeSyncBackup( remoteSiteId ); - - // Update site timestamp - void dispatch( - connectedSitesApi.endpoints.updateSiteTimestamp.initiate( { - siteId: remoteSiteId, - localSiteId: selectedSiteId, - type: 'pull', - } ) - ); - - // Mark as finished - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId, - remoteSiteId, - state: { - status: pullStatesProgressInfo.finished, - }, - } ) - ); - - // Show notification - getIpcApi().showNotification( { - title: selectedSite.name, - body: sprintf( - // translators: %s is the site url without the protocol. - __( 'Studio site has been updated from %s' ), - getHostnameFromUrl( remoteSiteUrl ) - ), - } ); } catch ( error ) { - console.error( 'Backup completion failed:', error ); + console.error( 'Pull backup polling/completion failed:', error ); // Check if cancelled const errorState = getState(); @@ -1010,7 +936,7 @@ export const completePullThunk = createTypedAsyncThunk( } ) ); getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pulling from %s' ), selectedSite.name ), + title: sprintf( __( 'Error pulling from %s' ), currentPullState.selectedSite.name ), message: __( 'Failed to check backup file size. Please try again.' ), } ); throw error; @@ -1028,7 +954,6 @@ export const syncOperationsThunks = { pullSite: pullSiteThunk, pollPushProgress: pollPushProgressThunk, pollPullBackup: pollPullBackupThunk, - completePull: completePullThunk, }; // Helper functions for checking state keys (matching useSyncStatesProgressInfo logic) From cb963d92ab7c9f27f810b2af0d7be6059b935ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 10 Feb 2026 16:26:07 +0000 Subject: [PATCH 24/58] Remove redundant remoteSiteId from updatePushState/updatePullState call sites The reducers now auto-set remoteSiteId from the top-level payload, so callers no longer need to pass it in the nested state object. --- src/hooks/sync-sites/use-initialize-sync-states.ts | 1 - src/stores/sync/sync-operations-slice.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/hooks/sync-sites/use-initialize-sync-states.ts b/src/hooks/sync-sites/use-initialize-sync-states.ts index 63b0ddeb5b..19818215f6 100644 --- a/src/hooks/sync-sites/use-initialize-sync-states.ts +++ b/src/hooks/sync-sites/use-initialize-sync-states.ts @@ -51,7 +51,6 @@ export function useInitializeSyncStates() { selectedSiteId: connectedSite.localSiteId, remoteSiteId: connectedSite.id, state: { - remoteSiteId: connectedSite.id, status, selectedSite: localSite, remoteSiteUrl: connectedSite.url, diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index e5c431db6d..780f8963f4 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -58,6 +58,7 @@ const syncOperationsSlice = createSlice( { state.pullStates[ stateId ] = { ...state.pullStates[ stateId ], ...updateState, + remoteSiteId, } as SyncBackupState; }, @@ -74,6 +75,7 @@ const syncOperationsSlice = createSlice( { state.pushStates[ stateId ] = { ...state.pushStates[ stateId ], ...updateState, + remoteSiteId, } as SyncPushState; }, @@ -339,7 +341,6 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl // Initialize push state updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { - remoteSiteId, status: pushStatesProgressInfo.creatingBackup, selectedSite, remoteSiteUrl, @@ -505,7 +506,6 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl backupId: null, status: pullStatesProgressInfo[ 'in-progress' ], downloadUrl: null, - remoteSiteId, remoteSiteUrl, selectedSite, }, From 0cc9914979f2fa50bf95df779e8bda3ede0eb8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 10 Feb 2026 17:15:20 +0000 Subject: [PATCH 25/58] Remove updatePushStateWithIpc, move IPC sync to listener middleware The calls to addSyncOperation/clearSyncOperation are now an effect of Redux state updates via listener middleware, rather than being manually managed at each call site. This removes the coupling between state updates and IPC side effects. --- .../sync-sites/use-initialize-sync-states.ts | 4 - src/stores/index.ts | 42 +++++- src/stores/sync/sync-operations-slice.ts | 131 +++++++++--------- 3 files changed, 105 insertions(+), 72 deletions(-) diff --git a/src/hooks/sync-sites/use-initialize-sync-states.ts b/src/hooks/sync-sites/use-initialize-sync-states.ts index 19818215f6..12a6d130fd 100644 --- a/src/hooks/sync-sites/use-initialize-sync-states.ts +++ b/src/hooks/sync-sites/use-initialize-sync-states.ts @@ -1,5 +1,4 @@ import { useEffect } from 'react'; -import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; import { mapImportResponseToPushState } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; @@ -57,9 +56,6 @@ export function useInitializeSyncStates() { }, } ) ); - - const stateId = generateStateId( connectedSite.localSiteId, connectedSite.id ); - getIpcApi().addSyncOperation( stateId, status ); } } catch ( error ) { // Continue checking other sites even if one fails diff --git a/src/stores/index.ts b/src/stores/index.ts index b050f3414a..048e4e5f3c 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -7,6 +7,7 @@ import { import { setupListeners } from '@reduxjs/toolkit/query'; import { useDispatch, useSelector } from 'react-redux'; import { LOCAL_STORAGE_CHAT_API_IDS_KEY, LOCAL_STORAGE_CHAT_MESSAGES_KEY } from 'src/constants'; +import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { appVersionApi } from 'src/stores/app-version-api'; import { betaFeaturesReducer, loadBetaFeatures } from 'src/stores/beta-features-slice'; @@ -21,7 +22,7 @@ import { updateSnapshotLocally, snapshotActions, } from 'src/stores/snapshot-slice'; -import { syncReducer } from 'src/stores/sync'; +import { syncReducer, syncOperationsActions } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; import { syncOperationsReducer } from 'src/stores/sync/sync-operations-slice'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; @@ -29,6 +30,10 @@ import uiReducer from 'src/stores/ui-slice'; import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from 'common/lib/locale'; +import type { + PullStateProgressInfo, + PushStateProgressInfo, +} from 'src/hooks/use-sync-states-progress-info'; export type RootState = { appVersionApi: ReturnType< typeof appVersionApi.reducer >; @@ -90,6 +95,41 @@ listenerMiddleware.startListening( { }, } ); +const TERMINAL_STATUS_KEYS = [ 'failed', 'finished', 'cancelled' ]; + +// Sync push/pull state updates to IPC (addSyncOperation / clearSyncOperation) +listenerMiddleware.startListening( { + matcher: isAnyOf( syncOperationsActions.updatePushState, syncOperationsActions.updatePullState ), + effect( action ) { + const { selectedSiteId, remoteSiteId, state } = action.payload as { + selectedSiteId: string; + remoteSiteId: number; + state: { status?: PullStateProgressInfo | PushStateProgressInfo }; + }; + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + const statusKey = state.status?.key; + + if ( statusKey && TERMINAL_STATUS_KEYS.includes( statusKey ) ) { + getIpcApi().clearSyncOperation( stateId ); + } else if ( state.status ) { + getIpcApi().addSyncOperation( stateId, state.status ); + } + }, +} ); + +// Sync push/pull state clears to IPC (clearSyncOperation) +listenerMiddleware.startListening( { + matcher: isAnyOf( syncOperationsActions.clearPushState, syncOperationsActions.clearPullState ), + effect( action ) { + const { selectedSiteId, remoteSiteId } = action.payload as { + selectedSiteId: string; + remoteSiteId: number; + }; + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + getIpcApi().clearSyncOperation( stateId ); + }, +} ); + export const rootReducer = combineReducers( { appVersionApi: appVersionApi.reducer, betaFeatures: betaFeaturesReducer, diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 780f8963f4..d58319d697 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -189,31 +189,6 @@ const getErrorFromResponse = ( error: unknown ): string => { return __( 'Studio was unable to connect to WordPress.com. Please try again.' ); }; -// Helper to update push state and sync with IPC (matching updatePushState logic) -const updatePushStateWithIpc = ( - dispatch: AppDispatch, - selectedSiteId: string, - remoteSiteId: number, - state: Partial< SyncPushState > -) => { - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - const statusKey = state.status?.key; - - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId, - remoteSiteId, - state, - } ) - ); - - if ( statusKey === 'failed' || statusKey === 'finished' || statusKey === 'cancelled' ) { - getIpcApi().clearSyncOperation( stateId ); - } else if ( state.status ) { - getIpcApi().addSyncOperation( stateId, state.status ); - } -}; - // Create typed async thunk helper const createTypedAsyncThunk = createAsyncThunk.withTypes< { state: RootState; @@ -224,11 +199,7 @@ const createTypedAsyncThunk = createAsyncThunk.withTypes< { export const clearPushStateThunk = createTypedAsyncThunk( 'syncOperations/clearPushState', async ( { selectedSiteId, remoteSiteId }: ClearStatePayload, { dispatch } ) => { - // Update Redux state dispatch( syncOperationsActions.clearPushState( { selectedSiteId, remoteSiteId } ) ); - // Clear IPC operation - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - getIpcApi().clearSyncOperation( stateId ); return { selectedSiteId, remoteSiteId }; } ); @@ -236,11 +207,7 @@ export const clearPushStateThunk = createTypedAsyncThunk( export const clearPullStateThunk = createTypedAsyncThunk( 'syncOperations/clearPullState', async ( { selectedSiteId, remoteSiteId }: ClearStatePayload, { dispatch } ) => { - // Update Redux state dispatch( syncOperationsActions.clearPullState( { selectedSiteId, remoteSiteId } ) ); - // Clear IPC operation - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - getIpcApi().clearSyncOperation( stateId ); return { selectedSiteId, remoteSiteId }; } ); @@ -340,11 +307,17 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl ); // Initialize push state - updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.creatingBackup, - selectedSite, - remoteSiteUrl, - } ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { + status: pushStatesProgressInfo.creatingBackup, + selectedSite, + remoteSiteUrl, + }, + } ) + ); let archivePath: string, archiveSizeInBytes: number; @@ -356,16 +329,24 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl ( { archivePath, archiveSizeInBytes } = result ); } catch ( error ) { if ( error instanceof Error && error.message === 'Export aborted' ) { - updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.cancelled, - } ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { status: pushStatesProgressInfo.cancelled }, + } ) + ); throw error; // Signal cancellation } Sentry.captureException( error ); - updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { status: pushStatesProgressInfo.failed }, + } ) + ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), message: __( @@ -379,9 +360,13 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl // Check file size if ( archiveSizeInBytes > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { status: pushStatesProgressInfo.failed }, + } ) + ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), message: __( @@ -408,9 +393,13 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl } // Update to uploading - updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.uploading, - } ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { status: pushStatesProgressInfo.uploading }, + } ) + ); try { const response = await getIpcApi().pushArchive( @@ -434,11 +423,17 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl } if ( response.success ) { - updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.creatingRemoteBackup, - selectedSite, - remoteSiteUrl, - } ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { + status: pushStatesProgressInfo.creatingRemoteBackup, + selectedSite, + remoteSiteUrl, + }, + } ) + ); // Return info needed for polling return { @@ -452,9 +447,13 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl } } catch ( error ) { Sentry.captureException( error ); - updatePushStateWithIpc( dispatch, selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId, + state: { status: pushStatesProgressInfo.failed }, + } ) + ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), message: getErrorFromResponse( error ), @@ -512,10 +511,6 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl } ) ); - // Add sync operation for tracking - const stateId = generateStateId( selectedSite.id, remoteSiteId ); - getIpcApi().addSyncOperation( stateId ); - try { // Initializing backup on remote const requestBody: { @@ -691,7 +686,13 @@ export const pollPushProgressThunk = createTypedAsyncThunk( }; } // Update state in any case to keep polling push state - updatePushStateWithIpc( dispatch, selectedSiteId, remoteSiteId, { status } ); + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId, + remoteSiteId, + state: { status }, + } ) + ); } ); @@ -902,10 +903,6 @@ export const pollPullBackupThunk = createTypedAsyncThunk( }, } ) ); - - // Update IPC sync operation - const stateId = generateStateId( selectedSiteId, remoteSiteId ); - getIpcApi().addSyncOperation( stateId ); } } catch ( error ) { console.error( 'Pull backup polling/completion failed:', error ); From e31e3518e684c03a25e951a2d6c7422c8affc31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 10 Feb 2026 17:26:47 +0000 Subject: [PATCH 26/58] Guard against missing status in backup polling response The backup API can return error responses (e.g. job_not_found) without a status field. Without this guard, response.status is undefined, which silently breaks the pull state progression. --- src/stores/sync/sync-operations-slice.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index d58319d697..32bb948b2a 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -745,6 +745,10 @@ export const pollPullBackupThunk = createTypedAsyncThunk( } ); + if ( ! response.status ) { + throw new Error( 'Unexpected backup response: missing status' ); + } + const hasBackupCompleted = response.status === 'finished'; const downloadUrl = hasBackupCompleted ? response.download_url : null; From 25fbcebf4ef53a99a8c7570a759eee119b742a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Feb 2026 12:00:41 +0000 Subject: [PATCH 27/58] Replace if-else chain with switch statement in pollPushProgressThunk, remove getPullStatusWithProgress --- src/hooks/use-sync-states-progress-info.ts | 24 ---- .../sync/components/sync-connected-sites.tsx | 19 ++- src/stores/sync/sync-operations-slice.ts | 118 ++++++++++-------- 3 files changed, 82 insertions(+), 79 deletions(-) diff --git a/src/hooks/use-sync-states-progress-info.ts b/src/hooks/use-sync-states-progress-info.ts index aa0f348e9f..263005d0a4 100644 --- a/src/hooks/use-sync-states-progress-info.ts +++ b/src/hooks/use-sync-states-progress-info.ts @@ -1,7 +1,6 @@ import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo } from 'react'; -import { ImportProgressState } from './use-import-export'; export type PullStateProgressInfo = { key: 'in-progress' | 'downloading' | 'importing' | 'finished' | 'failed' | 'cancelled'; @@ -230,28 +229,6 @@ export function useSyncStatesProgressInfo() { const uploadingProgressMessageTemplate = useMemo( () => __( 'Uploading site (%d%%)…' ), [ __ ] ); - const getPullStatusWithProgress = useCallback( - ( sitePullState?: PullStateProgressInfo, importState?: ImportProgressState[ string ] ) => { - if ( importState ) { - if ( importState.progress === 100 ) { - return { message: __( 'Applying final details…' ), progress: 99 }; - } - const stepToProgress = 100 - PULL_IMPORTING_INITIAL_VALUE; - return { - message: importState.statusMessage, - // Update progress from the initial value to the new step proportionally to the importState.progress - // on every update of the importState.progress - progress: PULL_IMPORTING_INITIAL_VALUE + stepToProgress * ( importState.progress / 100 ), - }; - } - if ( sitePullState ) { - return { message: sitePullState.message, progress: sitePullState.progress }; - } - return { message: '', progress: 0 }; - }, - [ __ ] - ); - const getPushUploadMessage = useCallback( ( message: string, uploadPercentage: number | null ): string => { if ( uploadPercentage !== null ) { @@ -290,7 +267,6 @@ export function useSyncStatesProgressInfo() { isKeyFailed, isKeyCancelled, isKeyUploading, - getPullStatusWithProgress, getPushUploadPercentage, getPushUploadMessage, mapUploadProgressToOverallProgress, diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index 1c8c6c1c07..c218a84b37 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -195,7 +195,6 @@ const SyncConnectedSitesSectionItem = ( { isKeyFinished, isKeyFailed, isKeyCancelled, - getPullStatusWithProgress, getPushUploadPercentage, getPushUploadMessage, isKeyUploadingPaused, @@ -206,8 +205,22 @@ const SyncConnectedSitesSectionItem = ( { const isPullError = sitePullState?.status && isKeyFailed( sitePullState.status.key ); const hasPullFinished = sitePullState?.status && isKeyFinished( sitePullState.status.key ); const hasPullCancelled = sitePullState?.status && isKeyCancelled( sitePullState.status.key ); - const { message: sitePullStatusMessage, progress: sitePullStatusProgress } = - getPullStatusWithProgress( sitePullState?.status, importState[ connectedSite.localSiteId ] ); + const pullImportState = importState[ connectedSite.localSiteId ]; + let sitePullStatusMessage = ''; + let sitePullStatusProgress = 0; + if ( pullImportState ) { + if ( pullImportState.progress === 100 ) { + sitePullStatusMessage = __( 'Applying final details…' ); + sitePullStatusProgress = 99; + } else { + sitePullStatusMessage = pullImportState.statusMessage; + // Map import progress (0-100%) to the pull importing range (80-100%) + sitePullStatusProgress = 80 + 20 * ( pullImportState.progress / 100 ); + } + } else if ( sitePullState?.status ) { + sitePullStatusMessage = sitePullState.status.message; + sitePullStatusProgress = sitePullState.status.progress; + } const pushState = getPushState( selectedSite.id, connectedSite.id ); const isPushing = pushState?.status && isKeyPushing( pushState.status.key ); diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 32bb948b2a..112e871595 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -606,60 +606,74 @@ export const pollPushProgressThunk = createTypedAsyncThunk( } ); - let status: PushStateProgressInfo = pushStatesProgressInfo.creatingRemoteBackup; - if ( response.success && response.status === 'finished' ) { - status = pushStatesProgressInfo.finished; - // Update site timestamp - void dispatch( - connectedSitesApi.endpoints.updateSiteTimestamp.initiate( { - siteId: remoteSiteId, - localSiteId: selectedSiteId, - type: 'push', - } ) - ); - getIpcApi().showNotification( { - title: currentPushState.selectedSite.name, - body: sprintf( - // translators: %s is the site url without the protocol. - __( '%s has been updated' ), - getHostnameFromUrl( currentPushState.remoteSiteUrl ) - ), - } ); - } else if ( response.success && response.status === 'failed' ) { - status = pushStatesProgressInfo.failed; - console.error( 'Push import failed:', { - remoteSiteId: currentPushState.remoteSiteId, - error: response.error, - error_data: response.error_data, - } ); - // If the import fails due to a SQL import error, show a more specific message - const restoreMessage = response.error_data?.vp_restore_message || ''; - const isSqlImportFailure = /importing sql dump/i.test( restoreMessage ); - const isImportTimedOut = response.error === 'Import timed out'; - let message: string; - if ( isSqlImportFailure ) { - message = __( - 'Database import failed on the remote site. Please review your database and try again or contact support and provide details from the logs below.' - ); - } else if ( isImportTimedOut ) { - message = __( - "A timeout error occurred while pushing the site, likely due to its large size. Please try reducing the site's content or files and try again. If this problem persists, please contact support." - ); - } else { - message = __( - 'An error occurred while pushing the site. If this problem persists, please contact support.' + if ( ! response.success ) { + throw new Error( response.error || 'Push import failed' ); + } + + let status: PushStateProgressInfo; + switch ( response.status ) { + case 'finished': + status = pushStatesProgressInfo.finished; + // Update site timestamp + void dispatch( + connectedSitesApi.endpoints.updateSiteTimestamp.initiate( { + siteId: remoteSiteId, + localSiteId: selectedSiteId, + type: 'push', + } ) ); - } + getIpcApi().showNotification( { + title: currentPushState.selectedSite.name, + body: sprintf( + // translators: %s is the site url without the protocol. + __( '%s has been updated' ), + getHostnameFromUrl( currentPushState.remoteSiteUrl ) + ), + } ); + break; + case 'failed': + status = pushStatesProgressInfo.failed; + console.error( 'Push import failed:', { + remoteSiteId: currentPushState.remoteSiteId, + error: response.error, + error_data: response.error_data, + } ); + // If the import fails due to a SQL import error, show a more specific message + { + const restoreMessage = response.error_data?.vp_restore_message || ''; + const isSqlImportFailure = /importing sql dump/i.test( restoreMessage ); + const isImportTimedOut = response.error === 'Import timed out'; + let message: string; + if ( isSqlImportFailure ) { + message = __( + 'Database import failed on the remote site. Please review your database and try again or contact support and provide details from the logs below.' + ); + } else if ( isImportTimedOut ) { + message = __( + "A timeout error occurred while pushing the site, likely due to its large size. Please try reducing the site's content or files and try again. If this problem persists, please contact support." + ); + } else { + message = __( + 'An error occurred while pushing the site. If this problem persists, please contact support.' + ); + } - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), currentPushState.selectedSite.name ), - message, - showOpenLogs: true, - } ); - } else if ( response.success && response.status === 'archive_import_started' ) { - status = pushStatesProgressInfo.applyingChanges; - } else if ( response.success && response.status === 'archive_import_finished' ) { - status = pushStatesProgressInfo.finishing; + getIpcApi().showErrorMessageBox( { + title: sprintf( __( 'Error pushing to %s' ), currentPushState.selectedSite.name ), + message, + showOpenLogs: true, + } ); + } + break; + case 'initial_backup_started': + status = pushStatesProgressInfo.creatingRemoteBackup; + break; + case 'archive_import_started': + status = pushStatesProgressInfo.applyingChanges; + break; + case 'archive_import_finished': + status = pushStatesProgressInfo.finishing; + break; } // Calculate push status with progress if ( status.key === pushStatesProgressInfo.creatingRemoteBackup.key ) { From 80bd3bf4bdc06b7ee7bbc772e751981661f205d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Feb 2026 12:55:55 +0000 Subject: [PATCH 28/58] Move error state handling to extraReducers, use rejectWithValue in thunks --- src/stores/sync/sync-operations-slice.ts | 153 +++++++++++++++-------- 1 file changed, 103 insertions(+), 50 deletions(-) diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 112e871595..7b6dedfa3e 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -85,6 +85,42 @@ const syncOperationsSlice = createSlice( { delete state.pushStates[ stateId ]; }, }, + extraReducers: ( builder ) => { + // Handle push thunk rejections (pushSiteThunk) + builder.addMatcher( + ( action ): action is PayloadAction< RejectedSyncPayload > => + action.type === 'syncOperations/pushSite/rejected' && action.payload != null, + ( state, action ) => { + const { selectedSiteId, remoteSiteId } = action.payload; + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + if ( state.pushStates[ stateId ] ) { + state.pushStates[ stateId ].status = { + key: 'failed', + progress: 100, + message: __( 'Error pushing changes' ), + }; + } + } + ); + // Handle pull thunk rejections (pullSiteThunk, pollPullBackupThunk) + builder.addMatcher( + ( action ): action is PayloadAction< RejectedSyncPayload > => + [ 'syncOperations/pullSite/rejected', 'syncOperations/pollPullBackup/rejected' ].includes( + action.type + ) && action.payload != null, + ( state, action ) => { + const { selectedSiteId, remoteSiteId } = action.payload; + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + if ( state.pullStates[ stateId ] ) { + state.pullStates[ stateId ].status = { + key: 'failed', + progress: 100, + message: __( 'Error pulling changes' ), + }; + } + } + ); + }, } ); export const syncOperationsActions = syncOperationsSlice.actions; @@ -189,10 +225,23 @@ const getErrorFromResponse = ( error: unknown ): string => { return __( 'Studio was unable to connect to WordPress.com. Please try again.' ); }; +// Payload type for thunk rejections handled by extraReducers +type RejectedSyncPayload = { + selectedSiteId: string; + remoteSiteId: number; + errorInfo?: { + title: string; + message: string; + showOpenLogs?: boolean; + error?: unknown; + }; +}; + // Create typed async thunk helper const createTypedAsyncThunk = createAsyncThunk.withTypes< { state: RootState; dispatch: AppDispatch; + rejectValue: RejectedSyncPayload; } >(); // Thunks for clear operations @@ -295,7 +344,7 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl 'syncOperations/pushSite', async ( { connectedSite, selectedSite, options, pushStatesProgressInfo }, - { dispatch, getState } + { dispatch, getState, rejectWithValue } ) => { const remoteSiteId = connectedSite.id; const remoteSiteUrl = connectedSite.url; @@ -340,13 +389,6 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl } Sentry.captureException( error ); - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId: selectedSite.id, - remoteSiteId, - state: { status: pushStatesProgressInfo.failed }, - } ) - ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), message: __( @@ -355,18 +397,22 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl error, showOpenLogs: true, } ); - throw error; + return rejectWithValue( { + selectedSiteId: selectedSite.id, + remoteSiteId, + errorInfo: { + title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), + message: __( + 'An error occurred while pushing the site. If this problem persists, please contact support.' + ), + showOpenLogs: true, + error, + }, + } ); } // Check file size if ( archiveSizeInBytes > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId: selectedSite.id, - remoteSiteId, - state: { status: pushStatesProgressInfo.failed }, - } ) - ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), message: __( @@ -374,7 +420,16 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl ), } ); await getIpcApi().removeExportedSiteTmpFile( archivePath ); - throw new Error( 'Site too large' ); + return rejectWithValue( { + selectedSiteId: selectedSite.id, + remoteSiteId, + errorInfo: { + title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), + message: __( + 'The site is too large to push. Please reduce the size of the site and try again.' + ), + }, + } ); } // Check if cancelled before upload @@ -418,7 +473,6 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl )( stateAfterUpload ); if ( pushStateAfterUpload?.status.key === 'cancelled' ) { - await getIpcApi().removeExportedSiteTmpFile( archivePath ); throw new Error( 'Push cancelled' ); } @@ -443,22 +497,26 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl remoteSiteUrl, }; } else { - throw response; + throw new Error( response.error ); } } catch ( error ) { + // Don't override cancelled state + if ( error instanceof Error && error.message === 'Push cancelled' ) { + throw error; + } Sentry.captureException( error ); - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId: selectedSite.id, - remoteSiteId, - state: { status: pushStatesProgressInfo.failed }, - } ) - ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), message: getErrorFromResponse( error ), } ); - throw error; + return rejectWithValue( { + selectedSiteId: selectedSite.id, + remoteSiteId, + errorInfo: { + title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), + message: getErrorFromResponse( error ), + }, + } ); } finally { await getIpcApi().removeExportedSiteTmpFile( archivePath ); } @@ -486,7 +544,7 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl 'syncOperations/pullSite', async ( { client, connectedSite, selectedSite, options, pullStatesProgressInfo }, - { dispatch } + { dispatch, rejectWithValue } ) => { const remoteSiteId = connectedSite.id; const remoteSiteUrl = connectedSite.url; @@ -550,22 +608,19 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl console.error( 'Pull request failed:', error ); Sentry.captureException( error ); - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId: selectedSite.id, - remoteSiteId, - state: { - status: pullStatesProgressInfo.failed, - }, - } ) - ); - getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pulling from %s' ), connectedSite.name ), message: __( 'Studio was unable to connect to WordPress.com. Please try again.' ), } ); - throw error; + return rejectWithValue( { + selectedSiteId: selectedSite.id, + remoteSiteId, + errorInfo: { + title: sprintf( __( 'Error pulling from %s' ), connectedSite.name ), + message: __( 'Studio was unable to connect to WordPress.com. Please try again.' ), + }, + } ); } } ); @@ -727,7 +782,7 @@ export const pollPullBackupThunk = createTypedAsyncThunk( 'syncOperations/pollPullBackup', async ( { client, selectedSiteId, remoteSiteId, pullStatesProgressInfo }: PollPullBackupPayload, - { dispatch, getState } + { dispatch, getState, rejectWithValue } ) => { // Check if state exists and is not cancelled const state = getState(); @@ -941,20 +996,18 @@ export const pollPullBackupThunk = createTypedAsyncThunk( } Sentry.captureException( error ); - dispatch( - syncOperationsActions.updatePullState( { - selectedSiteId, - remoteSiteId, - state: { - status: pullStatesProgressInfo.failed, - }, - } ) - ); getIpcApi().showErrorMessageBox( { title: sprintf( __( 'Error pulling from %s' ), currentPullState.selectedSite.name ), message: __( 'Failed to check backup file size. Please try again.' ), } ); - throw error; + return rejectWithValue( { + selectedSiteId, + remoteSiteId, + errorInfo: { + title: sprintf( __( 'Error pulling from %s' ), currentPullState.selectedSite.name ), + message: __( 'Failed to check backup file size. Please try again.' ), + }, + } ); } } ); From 5670c5c6850aa6e0ba48fbb2c23b7ceb5e0d530b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Feb 2026 13:23:44 +0000 Subject: [PATCH 29/58] Move error modals to listener middleware, remove showErrorMessageBox from thunks --- src/stores/index.ts | 26 ++++++- src/stores/sync/sync-operations-slice.ts | 98 ++++++++++-------------- 2 files changed, 66 insertions(+), 58 deletions(-) diff --git a/src/stores/index.ts b/src/stores/index.ts index b257638332..b56f629387 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -24,7 +24,13 @@ import { } from 'src/stores/snapshot-slice'; import { syncReducer, syncOperationsActions } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; -import { syncOperationsReducer } from 'src/stores/sync/sync-operations-slice'; +import { + syncOperationsReducer, + pushSiteThunk, + pullSiteThunk, + pollPushProgressThunk, + pollPullBackupThunk, +} from 'src/stores/sync/sync-operations-slice'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import uiReducer from 'src/stores/ui-slice'; import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; @@ -130,6 +136,24 @@ listenerMiddleware.startListening( { }, } ); +// Show error modals for rejected sync thunks +listenerMiddleware.startListening( { + matcher: isAnyOf( + pushSiteThunk.rejected, + pullSiteThunk.rejected, + pollPushProgressThunk.rejected, + pollPullBackupThunk.rejected + ), + effect( action ) { + const payload = action.payload as + | { errorInfo?: { title: string; message: string; showOpenLogs?: boolean; error?: unknown } } + | undefined; + if ( payload?.errorInfo ) { + getIpcApi().showErrorMessageBox( payload.errorInfo ); + } + }, +} ); + export const rootReducer = combineReducers( { appVersionApi: appVersionApi.reducer, betaFeatures: betaFeaturesReducer, diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 7b6dedfa3e..d4ea2c3cf1 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -86,10 +86,12 @@ const syncOperationsSlice = createSlice( { }, }, extraReducers: ( builder ) => { - // Handle push thunk rejections (pushSiteThunk) + // Handle push thunk rejections (pushSiteThunk, pollPushProgressThunk) builder.addMatcher( ( action ): action is PayloadAction< RejectedSyncPayload > => - action.type === 'syncOperations/pushSite/rejected' && action.payload != null, + [ 'syncOperations/pushSite/rejected', 'syncOperations/pollPushProgress/rejected' ].includes( + action.type + ) && action.payload != null, ( state, action ) => { const { selectedSiteId, remoteSiteId } = action.payload; const stateId = generateStateId( selectedSiteId, remoteSiteId ); @@ -226,7 +228,7 @@ const getErrorFromResponse = ( error: unknown ): string => { }; // Payload type for thunk rejections handled by extraReducers -type RejectedSyncPayload = { +export type RejectedSyncPayload = { selectedSiteId: string; remoteSiteId: number; errorInfo?: { @@ -389,14 +391,6 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl } Sentry.captureException( error ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: __( - 'An error occurred while pushing the site. If this problem persists, please contact support.' - ), - error, - showOpenLogs: true, - } ); return rejectWithValue( { selectedSiteId: selectedSite.id, remoteSiteId, @@ -413,12 +407,6 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl // Check file size if ( archiveSizeInBytes > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: __( - 'The site is too large to push. Please reduce the size of the site and try again.' - ), - } ); await getIpcApi().removeExportedSiteTmpFile( archivePath ); return rejectWithValue( { selectedSiteId: selectedSite.id, @@ -505,10 +493,6 @@ export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayl throw error; } Sentry.captureException( error ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: getErrorFromResponse( error ), - } ); return rejectWithValue( { selectedSiteId: selectedSite.id, remoteSiteId, @@ -608,11 +592,6 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl console.error( 'Pull request failed:', error ); Sentry.captureException( error ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pulling from %s' ), connectedSite.name ), - message: __( 'Studio was unable to connect to WordPress.com. Please try again.' ), - } ); - return rejectWithValue( { selectedSiteId: selectedSite.id, remoteSiteId, @@ -637,7 +616,7 @@ export const pollPushProgressThunk = createTypedAsyncThunk( 'syncOperations/pollPushProgress', async ( { client, selectedSiteId, remoteSiteId, pushStatesProgressInfo }: PollPushProgressPayload, - { dispatch, getState } + { dispatch, getState, rejectWithValue } ) => { // Check if state exists and is not cancelled const state = getState(); @@ -662,7 +641,16 @@ export const pollPushProgressThunk = createTypedAsyncThunk( ); if ( ! response.success ) { - throw new Error( response.error || 'Push import failed' ); + return rejectWithValue( { + selectedSiteId, + remoteSiteId, + errorInfo: { + title: sprintf( __( 'Error pushing to %s' ), currentPushState.selectedSite.name ), + message: __( + 'An error occurred while pushing the site. If this problem persists, please contact support.' + ), + }, + } ); } let status: PushStateProgressInfo; @@ -686,40 +674,40 @@ export const pollPushProgressThunk = createTypedAsyncThunk( ), } ); break; - case 'failed': - status = pushStatesProgressInfo.failed; + case 'failed': { console.error( 'Push import failed:', { remoteSiteId: currentPushState.remoteSiteId, error: response.error, error_data: response.error_data, } ); // If the import fails due to a SQL import error, show a more specific message - { - const restoreMessage = response.error_data?.vp_restore_message || ''; - const isSqlImportFailure = /importing sql dump/i.test( restoreMessage ); - const isImportTimedOut = response.error === 'Import timed out'; - let message: string; - if ( isSqlImportFailure ) { - message = __( - 'Database import failed on the remote site. Please review your database and try again or contact support and provide details from the logs below.' - ); - } else if ( isImportTimedOut ) { - message = __( - "A timeout error occurred while pushing the site, likely due to its large size. Please try reducing the site's content or files and try again. If this problem persists, please contact support." - ); - } else { - message = __( - 'An error occurred while pushing the site. If this problem persists, please contact support.' - ); - } - - getIpcApi().showErrorMessageBox( { + const restoreMessage = response.error_data?.vp_restore_message || ''; + const isSqlImportFailure = /importing sql dump/i.test( restoreMessage ); + const isImportTimedOut = response.error === 'Import timed out'; + let message: string; + if ( isSqlImportFailure ) { + message = __( + 'Database import failed on the remote site. Please review your database and try again or contact support and provide details from the logs below.' + ); + } else if ( isImportTimedOut ) { + message = __( + "A timeout error occurred while pushing the site, likely due to its large size. Please try reducing the site's content or files and try again. If this problem persists, please contact support." + ); + } else { + message = __( + 'An error occurred while pushing the site. If this problem persists, please contact support.' + ); + } + return rejectWithValue( { + selectedSiteId, + remoteSiteId, + errorInfo: { title: sprintf( __( 'Error pushing to %s' ), currentPushState.selectedSite.name ), message, showOpenLogs: true, - } ); - } - break; + }, + } ); + } case 'initial_backup_started': status = pushStatesProgressInfo.creatingRemoteBackup; break; @@ -996,10 +984,6 @@ export const pollPullBackupThunk = createTypedAsyncThunk( } Sentry.captureException( error ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pulling from %s' ), currentPullState.selectedSite.name ), - message: __( 'Failed to check backup file size. Please try again.' ), - } ); return rejectWithValue( { selectedSiteId, remoteSiteId, From 88af0da374f34c981272d3623edbfcb7bf20d10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Feb 2026 13:36:46 +0000 Subject: [PATCH 30/58] Add createAsyncThunk condition to polling thunks, remove manual cancellation guards --- src/stores/sync/sync-operations-slice.ts | 44 ++++++++++++------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index d4ea2c3cf1..15680eb33b 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -618,20 +618,11 @@ export const pollPushProgressThunk = createTypedAsyncThunk( { client, selectedSiteId, remoteSiteId, pushStatesProgressInfo }: PollPushProgressPayload, { dispatch, getState, rejectWithValue } ) => { - // Check if state exists and is not cancelled - const state = getState(); + // condition guarantees currentPushState exists and is not cancelled const currentPushState = syncOperationsSelectors.selectPushState( selectedSiteId, remoteSiteId - )( state ); - - if ( - ! currentPushState || - ! currentPushState.status || - currentPushState.status.key === 'cancelled' - ) { - return; - } + )( getState() )!; const response = await client.req.get< ImportResponse >( `/sites/${ remoteSiteId }/studio-app/sync/import`, @@ -750,6 +741,15 @@ export const pollPushProgressThunk = createTypedAsyncThunk( state: { status }, } ) ); + }, + { + condition: ( { selectedSiteId, remoteSiteId }, { getState } ) => { + const pushState = syncOperationsSelectors.selectPushState( + selectedSiteId, + remoteSiteId + )( getState() ); + return !! pushState?.status && pushState.status.key !== 'cancelled'; + }, } ); @@ -772,20 +772,11 @@ export const pollPullBackupThunk = createTypedAsyncThunk( { client, selectedSiteId, remoteSiteId, pullStatesProgressInfo }: PollPullBackupPayload, { dispatch, getState, rejectWithValue } ) => { - // Check if state exists and is not cancelled - const state = getState(); + // condition guarantees currentPullState exists and is not cancelled const currentPullState = syncOperationsSelectors.selectPullState( selectedSiteId, remoteSiteId - )( state ); - - if ( - ! currentPullState || - ! currentPullState.status || - currentPullState.status.key === 'cancelled' - ) { - return; - } + )( getState() )!; const backupId = currentPullState.backupId; if ( ! backupId ) { @@ -993,6 +984,15 @@ export const pollPullBackupThunk = createTypedAsyncThunk( }, } ); } + }, + { + condition: ( { selectedSiteId, remoteSiteId }, { getState } ) => { + const pullState = syncOperationsSelectors.selectPullState( + selectedSiteId, + remoteSiteId + )( getState() ); + return !! pullState?.status && pullState.status.key !== 'cancelled'; + }, } ); From 3fd06a0b6dac5984e18f6d52d4e481c4131e3de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Feb 2026 15:02:39 +0000 Subject: [PATCH 31/58] Remove isKey* helpers, replace with direct comparisons in sync-connected-sites --- src/hooks/use-sync-states-progress-info.ts | 79 +------------------ .../sync/components/sync-connected-sites.tsx | 39 ++++----- 2 files changed, 21 insertions(+), 97 deletions(-) diff --git a/src/hooks/use-sync-states-progress-info.ts b/src/hooks/use-sync-states-progress-info.ts index 263005d0a4..18abb59f16 100644 --- a/src/hooks/use-sync-states-progress-info.ts +++ b/src/hooks/use-sync-states-progress-info.ts @@ -56,79 +56,11 @@ const IN_PROGRESS_INITIAL_VALUE = 30; const DOWNLOADING_INITIAL_VALUE = 60; const PULL_IMPORTING_INITIAL_VALUE = 80; -function isKeyPulling( key: PullStateProgressInfo[ 'key' ] | undefined ): boolean { - const pullingStateKeys: PullStateProgressInfo[ 'key' ][] = [ - 'in-progress', - 'downloading', - 'importing', - ]; - if ( ! key ) { - return false; - } - return pullingStateKeys.includes( key ); -} - -function isKeyPushing( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ - 'creatingBackup', - 'uploading', - 'creatingRemoteBackup', - 'applyingChanges', - 'finishing', - ]; - if ( ! key ) { - return false; - } - return pushingStateKeys.includes( key ); -} - -function isKeyUploadingPaused( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - return key === 'uploadingPaused'; -} - -function isKeyUploadingManuallyPaused( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - return key === 'uploadingManuallyPaused'; -} - -function isKeyUploading( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - return key === 'uploading'; -} - -function isKeyImporting( key: PushStateProgressInfo[ 'key' ] | undefined ): boolean { - const pushingStateKeys: PushStateProgressInfo[ 'key' ][] = [ - 'creatingRemoteBackup', - 'applyingChanges', - 'finishing', - ]; - if ( ! key ) { - return false; - } - return pushingStateKeys.includes( key ); -} - -function isKeyFinished( - key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined -): boolean { - return key === 'finished'; -} - -function isKeyFailed( - key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined -): boolean { - return key === 'failed'; -} - -function isKeyCancelled( - key: PullStateProgressInfo[ 'key' ] | PushStateProgressInfo[ 'key' ] | undefined -): boolean { - return key === 'cancelled'; -} - function getPushUploadPercentage( statusKey: PushStateProgressInfo[ 'key' ] | undefined, uploadProgress: number | undefined ): number | null { - if ( isKeyUploading( statusKey ) && uploadProgress !== undefined ) { + if ( statusKey === 'uploading' && uploadProgress !== undefined ) { return Math.round( uploadProgress ); } return null; @@ -260,17 +192,8 @@ export function useSyncStatesProgressInfo() { return { pullStatesProgressInfo, pushStatesProgressInfo, - isKeyPulling, - isKeyPushing, - isKeyImporting, - isKeyFinished, - isKeyFailed, - isKeyCancelled, - isKeyUploading, getPushUploadPercentage, getPushUploadMessage, mapUploadProgressToOverallProgress, - isKeyUploadingPaused, - isKeyUploadingManuallyPaused, }; } diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index c218a84b37..15a8b323b5 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -189,22 +189,15 @@ const SyncConnectedSitesSectionItem = ( { const { getPushState, clearPushState, cancelPush } = useSyncPush(); const isOffline = useOffline(); const { importState } = useImportExport(); - const { - isKeyPulling, - isKeyPushing, - isKeyFinished, - isKeyFailed, - isKeyCancelled, - getPushUploadPercentage, - getPushUploadMessage, - isKeyUploadingPaused, - } = useSyncStatesProgressInfo(); + const { getPushUploadPercentage, getPushUploadMessage } = useSyncStatesProgressInfo(); const sitePullState = getPullState( selectedSite.id, connectedSite.id ); - const isPulling = sitePullState?.status && isKeyPulling( sitePullState.status.key ); - const isPullError = sitePullState?.status && isKeyFailed( sitePullState.status.key ); - const hasPullFinished = sitePullState?.status && isKeyFinished( sitePullState.status.key ); - const hasPullCancelled = sitePullState?.status && isKeyCancelled( sitePullState.status.key ); + const isPulling = + sitePullState?.status && + [ 'in-progress', 'downloading', 'importing' ].includes( sitePullState.status.key ); + const isPullError = sitePullState?.status && sitePullState.status.key === 'failed'; + const hasPullFinished = sitePullState?.status && sitePullState.status.key === 'finished'; + const hasPullCancelled = sitePullState?.status && sitePullState.status.key === 'cancelled'; const pullImportState = importState[ connectedSite.localSiteId ]; let sitePullStatusMessage = ''; let sitePullStatusProgress = 0; @@ -223,11 +216,19 @@ const SyncConnectedSitesSectionItem = ( { } const pushState = getPushState( selectedSite.id, connectedSite.id ); - const isPushing = pushState?.status && isKeyPushing( pushState.status.key ); - const isUploadingPaused = pushState?.status && isKeyUploadingPaused( pushState.status.key ); - const isPushError = pushState?.status && isKeyFailed( pushState.status.key ); - const hasPushFinished = pushState?.status && isKeyFinished( pushState.status.key ); - const hasPushCancelled = pushState?.status && isKeyCancelled( pushState.status.key ); + const isPushing = + pushState?.status && + [ + 'creatingBackup', + 'uploading', + 'creatingRemoteBackup', + 'applyingChanges', + 'finishing', + ].includes( pushState.status.key ); + const isUploadingPaused = pushState?.status && pushState.status.key === 'uploadingPaused'; + const isPushError = pushState?.status && pushState.status.key === 'failed'; + const hasPushFinished = pushState?.status && pushState.status.key === 'finished'; + const hasPushCancelled = pushState?.status && pushState.status.key === 'cancelled'; const uploadPercentage = getPushUploadPercentage( pushState?.status.key, From 087179a5e6678c774fd56d97d69b2d14c5a893f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Feb 2026 15:46:08 +0000 Subject: [PATCH 32/58] Move progress info definitions into slice, remove from thunk payloads --- src/hooks/sync-sites/use-sync-pull.ts | 15 ++--- src/hooks/sync-sites/use-sync-push.ts | 20 ++---- src/stores/sync/sync-operations-slice.ts | 77 ++++++++++++++++++------ 3 files changed, 67 insertions(+), 45 deletions(-) diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index cdf80f2770..de529c9b93 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -2,10 +2,7 @@ import { useCallback } from 'react'; import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { useAuth } from 'src/hooks/use-auth'; -import { - PullStateProgressInfo, - useSyncStatesProgressInfo, -} from 'src/hooks/use-sync-states-progress-info'; +import { PullStateProgressInfo } from 'src/hooks/use-sync-states-progress-info'; import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsSelectors, syncOperationsThunks } from 'src/stores/sync'; import type { SyncSite } from 'src/modules/sync/types'; @@ -47,7 +44,6 @@ export type UseSyncPull = { export function useSyncPull(): UseSyncPull { const { client } = useAuth(); - const { pullStatesProgressInfo } = useSyncStatesProgressInfo(); const dispatch = useAppDispatch(); const pullStates = useRootSelector( @@ -79,11 +75,10 @@ export function useSyncPull(): UseSyncPull { client, selectedSiteId, remoteSiteId, - pullStatesProgressInfo, } ) ); }, - [ client, dispatch, pullStatesProgressInfo ] + [ client, dispatch ] ); const pullSite = useCallback< PullSite >( @@ -99,7 +94,6 @@ export function useSyncPull(): UseSyncPull { connectedSite, selectedSite, options, - pullStatesProgressInfo, } ) ).unwrap(); @@ -111,7 +105,7 @@ export function useSyncPull(): UseSyncPull { // Errors are already handled in the thunk (state updates, error messages) } }, - [ client, dispatch, pullStatesProgressInfo, fetchAndUpdateBackup ] + [ client, dispatch, fetchAndUpdateBackup ] ); // Poll for backup status when states have backupId and are in-progress @@ -146,11 +140,10 @@ export function useSyncPull(): UseSyncPull { syncOperationsThunks.cancelPull( { selectedSiteId, remoteSiteId, - cancelledStatus: pullStatesProgressInfo.cancelled, } ) ); }, - [ dispatch, pullStatesProgressInfo.cancelled ] + [ dispatch ] ); return { diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 7207271330..b980ca9077 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -2,13 +2,10 @@ import { useCallback } from 'react'; import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { useAuth } from 'src/hooks/use-auth'; -import { - ImportResponse, - useSyncStatesProgressInfo, - PushStateProgressInfo, -} from 'src/hooks/use-sync-states-progress-info'; +import { ImportResponse, PushStateProgressInfo } from 'src/hooks/use-sync-states-progress-info'; import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsSelectors, syncOperationsThunks } from 'src/stores/sync'; +import { getPushStatesProgressInfo } from 'src/stores/sync/sync-operations-slice'; import type { SyncSite } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; @@ -75,8 +72,6 @@ export function useSyncPush(): UseSyncPush { const pushStates = useRootSelector( syncOperationsSelectors.selectPushStates as ( state: RootState ) => PushStates ); - const { pushStatesProgressInfo } = useSyncStatesProgressInfo(); - const getPushState = useCallback< GetState< SyncPushState > >( ( selectedSiteId, remoteSiteId ) => { const state = store.getState(); @@ -102,11 +97,10 @@ export function useSyncPush(): UseSyncPush { client, selectedSiteId: syncPushState.selectedSite.id, remoteSiteId, - pushStatesProgressInfo, } ) ); }, - [ client, dispatch, pushStatesProgressInfo ] + [ client, dispatch ] ); const pushSite = useCallback< PushSite >( @@ -121,7 +115,6 @@ export function useSyncPush(): UseSyncPush { connectedSite, selectedSite, options, - pushStatesProgressInfo, } ) ).unwrap(); @@ -129,7 +122,7 @@ export function useSyncPush(): UseSyncPush { if ( result.shouldStartPolling ) { const stateForPolling: SyncPushState = { remoteSiteId: result.remoteSiteId, - status: pushStatesProgressInfo.creatingRemoteBackup, + status: getPushStatesProgressInfo().creatingRemoteBackup, selectedSite: result.selectedSite, remoteSiteUrl: result.remoteSiteUrl, }; @@ -143,7 +136,7 @@ export function useSyncPush(): UseSyncPush { } } }, - [ client, dispatch, pushStatesProgressInfo, getPushProgressInfo ] + [ client, dispatch, getPushProgressInfo ] ); // Poll for push progress when states are in importing status @@ -177,11 +170,10 @@ export function useSyncPush(): UseSyncPush { syncOperationsThunks.cancelPush( { selectedSiteId, remoteSiteId, - cancelledStatus: pushStatesProgressInfo.cancelled, } ) ); }, - [ dispatch, pushStatesProgressInfo.cancelled ] + [ dispatch ] ); return { diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 15680eb33b..7f8d895c85 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -20,6 +20,51 @@ import type { SyncSite } from 'src/modules/sync/types'; import type { AppDispatch, RootState } from 'src/stores'; import type { SyncOption } from 'src/types'; +// Factory functions for progress info (canonical definitions, also used by useSyncStatesProgressInfo hook) +export function getPushStatesProgressInfo(): Record< + PushStateProgressInfo[ 'key' ], + PushStateProgressInfo +> { + return { + creatingBackup: { key: 'creatingBackup', progress: 20, message: __( 'Creating backup…' ) }, + uploading: { key: 'uploading', progress: 40, message: __( 'Uploading site…' ) }, + uploadingPaused: { key: 'uploadingPaused', progress: 45, message: __( 'Uploading paused' ) }, + uploadingManuallyPaused: { + key: 'uploadingManuallyPaused', + progress: 45, + message: __( 'Uploading paused' ), + }, + creatingRemoteBackup: { + key: 'creatingRemoteBackup', + progress: 50, + message: __( 'Backing up remote site…' ), + }, + applyingChanges: { key: 'applyingChanges', progress: 60, message: __( 'Applying changes…' ) }, + finishing: { key: 'finishing', progress: 99, message: __( 'Almost there…' ) }, + finished: { key: 'finished', progress: 100, message: __( 'Push complete' ) }, + failed: { key: 'failed', progress: 100, message: __( 'Error pushing changes' ) }, + cancelled: { key: 'cancelled', progress: 0, message: __( 'Cancelled' ) }, + }; +} + +export function getPullStatesProgressInfo(): Record< + PullStateProgressInfo[ 'key' ], + PullStateProgressInfo +> { + return { + 'in-progress': { + key: 'in-progress', + progress: 30, + message: __( 'Initializing remote backup…' ), + }, + downloading: { key: 'downloading', progress: 60, message: __( 'Downloading backup…' ) }, + importing: { key: 'importing', progress: 80, message: __( 'Importing backup…' ) }, + finished: { key: 'finished', progress: 100, message: __( 'Pull complete' ) }, + failed: { key: 'failed', progress: 100, message: __( 'Error pulling changes' ) }, + cancelled: { key: 'cancelled', progress: 0, message: __( 'Cancelled' ) }, + }; +} + interface SyncOperationsState { pullStates: PullStates; pushStates: PushStates; @@ -267,18 +312,16 @@ export const clearPullStateThunk = createTypedAsyncThunk( type CancelPushPayload = { selectedSiteId: string; remoteSiteId: number; - cancelledStatus: PushStateProgressInfo; }; type CancelPullPayload = { selectedSiteId: string; remoteSiteId: number; - cancelledStatus: PullStateProgressInfo; }; export const cancelPushThunk = createTypedAsyncThunk( 'syncOperations/cancelPush', - async ( { selectedSiteId, remoteSiteId, cancelledStatus }: CancelPushPayload, { dispatch } ) => { + async ( { selectedSiteId, remoteSiteId }: CancelPushPayload, { dispatch } ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); getIpcApi().cancelSyncOperation( operationId ); @@ -286,7 +329,7 @@ export const cancelPushThunk = createTypedAsyncThunk( syncOperationsActions.updatePushState( { selectedSiteId, remoteSiteId, - state: { status: cancelledStatus }, + state: { status: getPushStatesProgressInfo().cancelled }, } ) ); @@ -299,7 +342,7 @@ export const cancelPushThunk = createTypedAsyncThunk( export const cancelPullThunk = createTypedAsyncThunk( 'syncOperations/cancelPull', - async ( { selectedSiteId, remoteSiteId, cancelledStatus }: CancelPullPayload, { dispatch } ) => { + async ( { selectedSiteId, remoteSiteId }: CancelPullPayload, { dispatch } ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); getIpcApi().cancelSyncOperation( operationId ); @@ -307,7 +350,7 @@ export const cancelPullThunk = createTypedAsyncThunk( syncOperationsActions.updatePullState( { selectedSiteId, remoteSiteId, - state: { status: cancelledStatus }, + state: { status: getPullStatesProgressInfo().cancelled }, } ) ); @@ -332,7 +375,6 @@ type PushSitePayload = { optionsToSync?: SyncOption[]; specificSelectionPaths?: string[]; }; - pushStatesProgressInfo: Record< PushStateProgressInfo[ 'key' ], PushStateProgressInfo >; }; type PushSiteResult = { @@ -344,10 +386,8 @@ type PushSiteResult = { export const pushSiteThunk = createTypedAsyncThunk< PushSiteResult, PushSitePayload >( 'syncOperations/pushSite', - async ( - { connectedSite, selectedSite, options, pushStatesProgressInfo }, - { dispatch, getState, rejectWithValue } - ) => { + async ( { connectedSite, selectedSite, options }, { dispatch, getState, rejectWithValue } ) => { + const pushStatesProgressInfo = getPushStatesProgressInfo(); const remoteSiteId = connectedSite.id; const remoteSiteUrl = connectedSite.url; const operationId = generateStateId( selectedSite.id, remoteSiteId ); @@ -516,7 +556,6 @@ type PullSitePayload = { optionsToSync: SyncOption[]; include_path_list?: string[]; }; - pullStatesProgressInfo: Record< PullStateProgressInfo[ 'key' ], PullStateProgressInfo >; }; type PullSiteResult = { @@ -526,10 +565,8 @@ type PullSiteResult = { export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayload >( 'syncOperations/pullSite', - async ( - { client, connectedSite, selectedSite, options, pullStatesProgressInfo }, - { dispatch, rejectWithValue } - ) => { + async ( { client, connectedSite, selectedSite, options }, { dispatch, rejectWithValue } ) => { + const pullStatesProgressInfo = getPullStatesProgressInfo(); const remoteSiteId = connectedSite.id; const remoteSiteUrl = connectedSite.url; @@ -609,15 +646,15 @@ type PollPushProgressPayload = { client: WPCOM; selectedSiteId: string; remoteSiteId: number; - pushStatesProgressInfo: Record< PushStateProgressInfo[ 'key' ], PushStateProgressInfo >; }; export const pollPushProgressThunk = createTypedAsyncThunk( 'syncOperations/pollPushProgress', async ( - { client, selectedSiteId, remoteSiteId, pushStatesProgressInfo }: PollPushProgressPayload, + { client, selectedSiteId, remoteSiteId }: PollPushProgressPayload, { dispatch, getState, rejectWithValue } ) => { + const pushStatesProgressInfo = getPushStatesProgressInfo(); // condition guarantees currentPushState exists and is not cancelled const currentPushState = syncOperationsSelectors.selectPushState( selectedSiteId, @@ -763,15 +800,15 @@ type PollPullBackupPayload = { client: WPCOM; selectedSiteId: string; remoteSiteId: number; - pullStatesProgressInfo: Record< PullStateProgressInfo[ 'key' ], PullStateProgressInfo >; }; export const pollPullBackupThunk = createTypedAsyncThunk( 'syncOperations/pollPullBackup', async ( - { client, selectedSiteId, remoteSiteId, pullStatesProgressInfo }: PollPullBackupPayload, + { client, selectedSiteId, remoteSiteId }: PollPullBackupPayload, { dispatch, getState, rejectWithValue } ) => { + const pullStatesProgressInfo = getPullStatesProgressInfo(); // condition guarantees currentPullState exists and is not cancelled const currentPullState = syncOperationsSelectors.selectPullState( selectedSiteId, From ec81009f9f4ba1cde69f529c6bf3756f98207cbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Feb 2026 16:07:04 +0000 Subject: [PATCH 33/58] Move polling to listener middleware, delete use-sync-polling hook --- src/hooks/sync-sites/use-sync-polling.ts | 33 -------------- src/hooks/sync-sites/use-sync-pull.ts | 47 ++----------------- src/hooks/sync-sites/use-sync-push.ts | 57 ++---------------------- src/stores/index.ts | 57 +++++++++++++++++++++++- 4 files changed, 63 insertions(+), 131 deletions(-) delete mode 100644 src/hooks/sync-sites/use-sync-polling.ts diff --git a/src/hooks/sync-sites/use-sync-polling.ts b/src/hooks/sync-sites/use-sync-polling.ts deleted file mode 100644 index a45176fdc1..0000000000 --- a/src/hooks/sync-sites/use-sync-polling.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect } from 'react'; - -/** - * Generic polling hook for sync operations. - * Sets up polling intervals for states that match the condition. - * - * @param states - Record of states to check for polling - * @param shouldPoll - Function to determine if a state should be polled - * @param pollFunction - Function to call when polling (receives state key and state value) - * @param pollInterval - Interval in milliseconds (default: 2000) - */ -export function useSyncPolling< T >( - states: Record< string, T >, - shouldPoll: ( state: T, key: string ) => boolean, - pollFunction: ( key: string, state: T ) => void | Promise< void >, - pollInterval: number = 2000 -) { - useEffect( () => { - const intervals: Record< string, NodeJS.Timeout > = {}; - - Object.entries( states ).forEach( ( [ key, state ] ) => { - if ( shouldPoll( state, key ) ) { - intervals[ key ] = setTimeout( () => { - void pollFunction( key, state ); - }, pollInterval ); - } - } ); - - return () => { - Object.values( intervals ).forEach( clearTimeout ); - }; - }, [ states, shouldPoll, pollFunction, pollInterval ] ); -} diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index de529c9b93..b05c8f5045 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -1,6 +1,5 @@ import { useCallback } from 'react'; import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; -import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { useAuth } from 'src/hooks/use-auth'; import { PullStateProgressInfo } from 'src/hooks/use-sync-states-progress-info'; import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; @@ -65,22 +64,6 @@ export function useSyncPull(): UseSyncPull { [ dispatch ] ); - const fetchAndUpdateBackup = useCallback( - async ( remoteSiteId: number, selectedSiteId: string ) => { - if ( ! client ) { - return; - } - void dispatch( - syncOperationsThunks.pollPullBackup( { - client, - selectedSiteId, - remoteSiteId, - } ) - ); - }, - [ client, dispatch ] - ); - const pullSite = useCallback< PullSite >( async ( connectedSite, selectedSite, options ) => { if ( ! client ) { @@ -88,7 +71,9 @@ export function useSyncPull(): UseSyncPull { } try { - const result = await dispatch( + // Polling is triggered automatically by listener middleware + // when state has backupId and status is in-progress + await dispatch( syncOperationsThunks.pullSite( { client, connectedSite, @@ -96,37 +81,13 @@ export function useSyncPull(): UseSyncPull { options, } ) ).unwrap(); - - // Start polling once backupId is set - if ( result.backupId ) { - void fetchAndUpdateBackup( result.remoteSiteId, selectedSite.id ); - } } catch ( error ) { // Errors are already handled in the thunk (state updates, error messages) } }, - [ client, dispatch, fetchAndUpdateBackup ] - ); - - // Poll for backup status when states have backupId and are in-progress - const shouldPollPull = useCallback( ( state: SyncBackupState ) => { - return ( - state.status && - state.status.key !== 'cancelled' && - !! state.backupId && - state.status.key === 'in-progress' - ); - }, [] ); - - const pollBackupStatus = useCallback( - ( _key: string, state: SyncBackupState ) => { - void fetchAndUpdateBackup( state.remoteSiteId, state.selectedSite.id ); - }, - [ fetchAndUpdateBackup ] + [ client, dispatch ] ); - useSyncPolling( pullStates, shouldPollPull, pollBackupStatus, 2000 ); - const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); const isSiteIdPulling = useCallback< IsSiteIdPulling >( ( selectedSiteId, remoteSiteId ) => { diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index b980ca9077..35945714ac 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -1,11 +1,9 @@ import { useCallback } from 'react'; import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; -import { useSyncPolling } from 'src/hooks/sync-sites/use-sync-polling'; import { useAuth } from 'src/hooks/use-auth'; import { ImportResponse, PushStateProgressInfo } from 'src/hooks/use-sync-states-progress-info'; import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsSelectors, syncOperationsThunks } from 'src/stores/sync'; -import { getPushStatesProgressInfo } from 'src/stores/sync/sync-operations-slice'; import type { SyncSite } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; @@ -87,22 +85,6 @@ export function useSyncPush(): UseSyncPush { [ dispatch ] ); - const getPushProgressInfo = useCallback( - async ( remoteSiteId: number, syncPushState: SyncPushState ) => { - if ( ! client ) { - return; - } - void dispatch( - syncOperationsThunks.pollPushProgress( { - client, - selectedSiteId: syncPushState.selectedSite.id, - remoteSiteId, - } ) - ); - }, - [ client, dispatch ] - ); - const pushSite = useCallback< PushSite >( async ( connectedSite, selectedSite, options ) => { if ( ! client ) { @@ -110,53 +92,22 @@ export function useSyncPush(): UseSyncPush { } try { - const result = await dispatch( + // Polling is triggered automatically by listener middleware + // when state enters creatingRemoteBackup/applyingChanges/finishing + await dispatch( syncOperationsThunks.pushSite( { connectedSite, selectedSite, options, } ) ).unwrap(); - - // If thunk completed successfully and returned polling info, start polling - if ( result.shouldStartPolling ) { - const stateForPolling: SyncPushState = { - remoteSiteId: result.remoteSiteId, - status: getPushStatesProgressInfo().creatingRemoteBackup, - selectedSite: result.selectedSite, - remoteSiteUrl: result.remoteSiteUrl, - }; - void getPushProgressInfo( result.remoteSiteId, stateForPolling ); - } } catch ( error ) { // Errors are already handled in the thunk (state updates, error messages) - // Just log if it's an unexpected error - if ( ! ( error instanceof Error && error.message === 'Export aborted' ) ) { - // Other errors are already handled in thunk - } } }, - [ client, dispatch, getPushProgressInfo ] - ); - - // Poll for push progress when states are in importing status - // Importing keys: creatingRemoteBackup, applyingChanges, finishing - const shouldPollPush = useCallback( ( state: SyncPushState ) => { - const importingKeys = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; - return ( - state.status && state.status.key !== 'cancelled' && importingKeys.includes( state.status.key ) - ); - }, [] ); - - const pollPushProgress = useCallback( - ( _key: string, state: SyncPushState ) => { - void getPushProgressInfo( state.remoteSiteId, state ); - }, - [ getPushProgressInfo ] + [ client, dispatch ] ); - useSyncPolling( pushStates, shouldPollPush, pollPushProgress, 2000 ); - const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const isSiteIdPushing = useCallback< IsSiteIdPushing >( ( selectedSiteId, remoteSiteId ) => { diff --git a/src/stores/index.ts b/src/stores/index.ts index b56f629387..9dd7e2311b 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -22,7 +22,7 @@ import { updateSnapshotLocally, snapshotActions, } from 'src/stores/snapshot-slice'; -import { syncReducer, syncOperationsActions } from 'src/stores/sync'; +import { syncReducer, syncOperationsActions, syncOperationsSelectors } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; import { syncOperationsReducer, @@ -33,7 +33,7 @@ import { } from 'src/stores/sync/sync-operations-slice'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import uiReducer from 'src/stores/ui-slice'; -import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; +import { getWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from 'common/lib/locale'; import type { @@ -154,6 +154,59 @@ listenerMiddleware.startListening( { }, } ); +const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; +const PUSH_POLLING_INTERVAL = 2000; + +// Poll push progress when state enters a pollable status +listenerMiddleware.startListening( { + actionCreator: syncOperationsActions.updatePushState, + async effect( action, listenerApi ) { + const { selectedSiteId, remoteSiteId } = action.payload; + const pushState = syncOperationsSelectors.selectPushState( + selectedSiteId, + remoteSiteId + )( listenerApi.getState() ); + if ( ! pushState?.status || ! PUSH_POLLING_KEYS.includes( pushState.status.key ) ) { + return; + } + + await listenerApi.delay( PUSH_POLLING_INTERVAL ); + + const client = getWpcomClient(); + if ( ! client ) { + return; + } + + void listenerApi.dispatch( pollPushProgressThunk( { client, selectedSiteId, remoteSiteId } ) ); + }, +} ); + +const PULL_POLLING_INTERVAL = 2000; + +// Poll pull backup when state has a backupId and is in-progress +listenerMiddleware.startListening( { + actionCreator: syncOperationsActions.updatePullState, + async effect( action, listenerApi ) { + const { selectedSiteId, remoteSiteId } = action.payload; + const pullState = syncOperationsSelectors.selectPullState( + selectedSiteId, + remoteSiteId + )( listenerApi.getState() ); + if ( ! pullState?.status || pullState.status.key !== 'in-progress' || ! pullState.backupId ) { + return; + } + + await listenerApi.delay( PULL_POLLING_INTERVAL ); + + const client = getWpcomClient(); + if ( ! client ) { + return; + } + + void listenerApi.dispatch( pollPullBackupThunk( { client, selectedSiteId, remoteSiteId } ) ); + }, +} ); + export const rootReducer = combineReducers( { appVersionApi: appVersionApi.reducer, betaFeatures: betaFeaturesReducer, From 3f912212116266ac14fcde54c2b497e455a84c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Feb 2026 16:21:25 +0000 Subject: [PATCH 34/58] Move useInitializeSyncStates to initializeSyncStatesThunk, move mapImportResponseToPushState to slice --- src/components/app.tsx | 16 +++-- .../sync-sites/use-initialize-sync-states.ts | 72 ------------------- src/hooks/sync-sites/use-sync-push.ts | 25 +------ src/stores/sync/sync-operations-slice.ts | 72 +++++++++++++++++++ 4 files changed, 85 insertions(+), 100 deletions(-) delete mode 100644 src/hooks/sync-sites/use-initialize-sync-states.ts diff --git a/src/components/app.tsx b/src/components/app.tsx index 2a53a93cdb..6683acda5c 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -9,8 +9,8 @@ import { NoStudioSites } from 'src/components/no-studio-sites'; import { SiteContentTabs } from 'src/components/site-content-tabs'; import TopBar from 'src/components/top-bar'; import WindowsTitlebar from 'src/components/windows-titlebar'; -import { useInitializeSyncStates } from 'src/hooks/sync-sites/use-initialize-sync-states'; import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; +import { useAuth } from 'src/hooks/use-auth'; import { useLocalizationSupport } from 'src/hooks/use-localization-support'; import { useSidebarVisibility } from 'src/hooks/use-sidebar-visibility'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -21,8 +21,9 @@ import { Onboarding } from 'src/modules/onboarding'; import { useOnboarding } from 'src/modules/onboarding/hooks/use-onboarding'; import { UserSettings } from 'src/modules/user-settings'; import { WhatsNewModal, useWhatsNew } from 'src/modules/whats-new'; -import { useRootSelector } from 'src/stores'; +import { useAppDispatch, useRootSelector } from 'src/stores'; import { selectOnboardingLoading } from 'src/stores/onboarding-slice'; +import { syncOperationsThunks } from 'src/stores/sync'; import 'src/index.css'; export default function App() { @@ -34,9 +35,16 @@ export default function App() { const { sites: localSites, loadingSites } = useSiteDetails(); const isEmpty = ! loadingSites && ! localSites.length; const shouldShowWhatsNew = showWhatsNew && ! isEmpty; + const { client } = useAuth(); + const dispatch = useAppDispatch(); + + // Initialize sync states from in-progress server operations + useEffect( () => { + if ( client ) { + void dispatch( syncOperationsThunks.initializeSyncStates( { client } ) ); + } + }, [ client, dispatch ] ); - // Initialize sync states and listen for deep link connections - useInitializeSyncStates(); useListenDeepLinkConnection(); useEffect( () => { diff --git a/src/hooks/sync-sites/use-initialize-sync-states.ts b/src/hooks/sync-sites/use-initialize-sync-states.ts deleted file mode 100644 index 12a6d130fd..0000000000 --- a/src/hooks/sync-sites/use-initialize-sync-states.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect } from 'react'; -import { mapImportResponseToPushState } from 'src/hooks/sync-sites/use-sync-push'; -import { useAuth } from 'src/hooks/use-auth'; -import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useAppDispatch } from 'src/stores'; -import { syncOperationsActions } from 'src/stores/sync'; -import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; - -/** - * Hook to initialize push states from in-progress server operations on mount. - * This restores push state for any operations that were in progress when the app was closed. - */ -export function useInitializeSyncStates() { - const { client } = useAuth(); - const { pushStatesProgressInfo } = useSyncStatesProgressInfo(); - const dispatch = useAppDispatch(); - - useEffect( () => { - if ( ! client ) { - return; - } - - const initializePushStates = async () => { - const allSites = await getIpcApi().getSiteDetails(); - const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); - - for ( const connectedSite of allConnectedSites ) { - try { - const localSite = allSites.find( ( site ) => site.id === connectedSite.localSiteId ); - const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected'; - - if ( ! localSite || hasConnectionErrors ) { - continue; - } - - const response = ( await client.req.get( - `/sites/${ connectedSite.id }/studio-app/sync/import`, - { - apiNamespace: 'wpcom/v2', - } - ) ) as ImportResponse; - - const status = mapImportResponseToPushState( response, pushStatesProgressInfo ); - - // Only restore the pushStates if the operation is still in progress - if ( status ) { - dispatch( - syncOperationsActions.updatePushState( { - selectedSiteId: connectedSite.localSiteId, - remoteSiteId: connectedSite.id, - state: { - status, - selectedSite: localSite, - remoteSiteUrl: connectedSite.url, - }, - } ) - ); - } - } catch ( error ) { - // Continue checking other sites even if one fails - console.error( `Failed to check push progress for site ${ connectedSite.id }:`, error ); - } - } - }; - - initializePushStates().catch( ( error ) => { - // Initialization is not critical to app functionality, but log the error - console.error( 'Failed to initialize push states from server:', error ); - } ); - }, [ client, pushStatesProgressInfo, dispatch ] ); -} diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 35945714ac..e38e522a86 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; import { useAuth } from 'src/hooks/use-auth'; -import { ImportResponse, PushStateProgressInfo } from 'src/hooks/use-sync-states-progress-info'; +import { PushStateProgressInfo } from 'src/hooks/use-sync-states-progress-info'; import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; import { syncOperationsSelectors, syncOperationsThunks } from 'src/stores/sync'; import type { SyncSite } from 'src/modules/sync/types'; @@ -40,29 +40,6 @@ export type UseSyncPush = { cancelPush: CancelPush; }; -/** - * Maps an ImportResponse status to a PushStateProgressInfo object. - * Returns null if the operation is not in progress or unknown. - */ -export function mapImportResponseToPushState( - response: ImportResponse, - pushStatesProgressInfo: Record< PushStateProgressInfo[ 'key' ], PushStateProgressInfo > -): PushStateProgressInfo | null { - if ( response.status === 'initial_backup_started' ) { - return pushStatesProgressInfo.creatingRemoteBackup; - } - - if ( response.status === 'archive_import_started' ) { - return pushStatesProgressInfo.applyingChanges; - } - - if ( response.status === 'archive_import_finished' ) { - return pushStatesProgressInfo.finishing; - } - - return null; -} - export function useSyncPush(): UseSyncPush { const { client } = useAuth(); diff --git a/src/stores/sync/sync-operations-slice.ts b/src/stores/sync/sync-operations-slice.ts index 7f8d895c85..e3b01724cf 100644 --- a/src/stores/sync/sync-operations-slice.ts +++ b/src/stores/sync/sync-operations-slice.ts @@ -1033,6 +1033,77 @@ export const pollPullBackupThunk = createTypedAsyncThunk( } ); +/** + * Maps an ImportResponse status to a PushStateProgressInfo object. + * Returns null if the operation is not in progress or unknown. + */ +export function mapImportResponseToPushState( + response: ImportResponse +): PushStateProgressInfo | null { + const pushStatesProgressInfo = getPushStatesProgressInfo(); + switch ( response.status ) { + case 'initial_backup_started': + return pushStatesProgressInfo.creatingRemoteBackup; + case 'archive_import_started': + return pushStatesProgressInfo.applyingChanges; + case 'archive_import_finished': + return pushStatesProgressInfo.finishing; + default: + return null; + } +} + +// Thunk to initialize push states from in-progress server operations on mount +type InitializeSyncStatesPayload = { + client: WPCOM; +}; + +export const initializeSyncStatesThunk = createTypedAsyncThunk( + 'syncOperations/initializeSyncStates', + async ( { client }: InitializeSyncStatesPayload, { dispatch } ) => { + const allSites = await getIpcApi().getSiteDetails(); + const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); + + for ( const connectedSite of allConnectedSites ) { + try { + const localSite = allSites.find( ( site ) => site.id === connectedSite.localSiteId ); + const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected'; + + if ( ! localSite || hasConnectionErrors ) { + continue; + } + + const response = ( await client.req.get( + `/sites/${ connectedSite.id }/studio-app/sync/import`, + { + apiNamespace: 'wpcom/v2', + } + ) ) as ImportResponse; + + const status = mapImportResponseToPushState( response ); + + // Only restore the pushStates if the operation is still in progress + if ( status ) { + dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: connectedSite.localSiteId, + remoteSiteId: connectedSite.id, + state: { + status, + selectedSite: localSite, + remoteSiteUrl: connectedSite.url, + }, + } ) + ); + } + } catch ( error ) { + // Continue checking other sites even if one fails + console.error( `Failed to check push progress for site ${ connectedSite.id }:`, error ); + } + } + } +); + // Export thunks object for convenience (must be after all thunk declarations) export const syncOperationsThunks = { clearPushState: clearPushStateThunk, @@ -1043,6 +1114,7 @@ export const syncOperationsThunks = { pullSite: pullSiteThunk, pollPushProgress: pollPushProgressThunk, pollPullBackup: pollPullBackupThunk, + initializeSyncStates: initializeSyncStatesThunk, }; // Helper functions for checking state keys (matching useSyncStatesProgressInfo logic) From 5d97f47a3075a89a7ebf87d9e43db2cd3821731f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Tue, 17 Feb 2026 17:25:30 +0000 Subject: [PATCH 35/58] Remove useSyncPull and useSyncPush hooks, replace with direct Redux access Delete the wrapper hooks and update all consumers to use useRootSelector + syncOperationsSelectors for reactive state and dispatch + syncOperationsThunks for actions. Move shared types (SyncBackupState, PullSiteOptions, PullStates, SyncPushState, PushStates) into the slice and re-export from stores/sync. --- src/components/content-tab-import-export.tsx | 14 +- src/components/delete-site.tsx | 9 +- src/components/publish-site-button.tsx | 8 +- src/components/site-management-actions.tsx | 8 +- src/components/site-menu.tsx | 10 +- src/hooks/sync-sites/index.ts | 3 - src/hooks/sync-sites/use-sync-pull.ts | 119 -------------- src/hooks/sync-sites/use-sync-push.ts | 116 -------------- src/hooks/tests/use-add-site.test.tsx | 38 +++-- src/hooks/use-add-site.ts | 24 ++- src/modules/add-site/tests/add-site.test.tsx | 16 -- .../sync/components/sync-connected-sites.tsx | 148 +++++++++++++++--- src/modules/sync/index.tsx | 26 ++- .../sync/lib/convert-tree-to-sync-options.ts | 2 +- src/modules/sync/tests/index.test.tsx | 114 +------------- src/stores/sync/index.ts | 7 + src/stores/sync/sync-operations-slice.ts | 28 +++- 17 files changed, 249 insertions(+), 441 deletions(-) delete mode 100644 src/hooks/sync-sites/use-sync-pull.ts delete mode 100644 src/hooks/sync-sites/use-sync-push.ts diff --git a/src/components/content-tab-import-export.tsx b/src/components/content-tab-import-export.tsx index 663a5e9099..8ac914d8b3 100644 --- a/src/components/content-tab-import-export.tsx +++ b/src/components/content-tab-import-export.tsx @@ -12,8 +12,6 @@ import { LearnMoreLink } from 'src/components/learn-more'; import ProgressBar from 'src/components/progress-bar'; import { Tooltip } from 'src/components/tooltip'; import { ACCEPTED_IMPORT_FILE_TYPES } from 'src/constants'; -import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; -import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog'; import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file'; @@ -21,6 +19,8 @@ import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { store } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync'; import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites'; interface ContentTabImportExportProps { @@ -344,15 +344,17 @@ const ImportSite = ( { export function ContentTabImportExport( { selectedSite }: ContentTabImportExportProps ) { const { __ } = useI18n(); const [ isSupported, setIsSupported ] = useState< boolean | null >( null ); - const { isSiteIdPulling } = useSyncPull(); - const { isSiteIdPushing } = useSyncPush(); const { user } = useAuth(); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, userId: user?.id, } ); - const isPulling = connectedSites.some( ( site ) => isSiteIdPulling( selectedSite.id, site.id ) ); - const isPushing = connectedSites.some( ( site ) => isSiteIdPushing( selectedSite.id, site.id ) ); + const isPulling = connectedSites.some( ( site ) => + syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( store.getState() ) + ); + const isPushing = connectedSites.some( ( site ) => + syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( store.getState() ) + ); const isThisSiteSyncing = isPulling || isPushing; useEffect( () => { diff --git a/src/components/delete-site.tsx b/src/components/delete-site.tsx index ecc3cbcabf..e7502ece55 100644 --- a/src/components/delete-site.tsx +++ b/src/components/delete-site.tsx @@ -1,10 +1,10 @@ import { MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; -import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; -import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useDeleteSite } from 'src/hooks/use-delete-site'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { store } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync'; type DeleteSiteProps = { onClose: () => void; @@ -14,10 +14,9 @@ const DeleteSite = ( { onClose }: DeleteSiteProps ) => { const { __ } = useI18n(); const { selectedSite, isDeleting } = useSiteDetails(); const { handleDeleteSite } = useDeleteSite(); - const { isSiteIdPulling } = useSyncPull(); - const { isSiteIdPushing } = useSyncPush(); const isThisSiteSyncing = - isSiteIdPulling( selectedSite?.id ?? '' ) || isSiteIdPushing( selectedSite?.id ?? '' ); + syncOperationsSelectors.selectIsSiteIdPulling( selectedSite?.id ?? '' )( store.getState() ) || + syncOperationsSelectors.selectIsSiteIdPushing( selectedSite?.id ?? '' )( store.getState() ); const isSiteDeletionDisabled = ! selectedSite || isDeleting || isThisSiteSyncing; diff --git a/src/components/publish-site-button.tsx b/src/components/publish-site-button.tsx index 9a9ce35142..0f049f42df 100644 --- a/src/components/publish-site-button.tsx +++ b/src/components/publish-site-button.tsx @@ -1,13 +1,13 @@ import { cloudUpload } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback } from 'react'; -import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; -import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { useSiteDetails } from 'src/hooks/use-site-details'; 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'; +import { useRootSelector } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync'; import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites'; export const PublishSiteButton = () => { @@ -18,8 +18,8 @@ export const PublishSiteButton = () => { localSiteId: selectedSite?.id, userId: user?.id, } ); - const { isAnySitePulling } = useSyncPull(); - const { isAnySitePushing } = useSyncPush(); + const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); + const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; const handlePublishClick = useCallback( () => { diff --git a/src/components/site-management-actions.tsx b/src/components/site-management-actions.tsx index a9c75d8f88..117d19ea49 100644 --- a/src/components/site-management-actions.tsx +++ b/src/components/site-management-actions.tsx @@ -3,8 +3,9 @@ import { useI18n } from '@wordpress/react-i18n'; import { ActionButton } from 'src/components/action-button'; import { PublishSiteButton } from 'src/components/publish-site-button'; import { Tooltip } from 'src/components/tooltip'; -import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; import { useImportExport } from 'src/hooks/use-import-export'; +import { store } from 'src/stores'; +import { syncOperationsSelectors } from 'src/stores/sync'; export interface SiteManagementActionProps { onStop: ( id: string ) => Promise< void >; @@ -21,14 +22,15 @@ export const SiteManagementActions = ( { }: SiteManagementActionProps ) => { const { __ } = useI18n(); const { isSiteImporting } = useImportExport(); - const { isSiteIdPulling } = useSyncPull(); if ( ! selectedSite ) { return null; } const isImporting = isSiteImporting( selectedSite.id ); - const isPulling = isSiteIdPulling( selectedSite.id ); + const isPulling = syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id )( + store.getState() + ); const disabled = isImporting || isPulling; let buttonLabelOnDisabled: string = __( 'Importing…' ); diff --git a/src/components/site-menu.tsx b/src/components/site-menu.tsx index be72f671c7..06ff31d291 100644 --- a/src/components/site-menu.tsx +++ b/src/components/site-menu.tsx @@ -5,8 +5,6 @@ import { __, sprintf } from '@wordpress/i18n'; import { useEffect } from 'react'; import { XDebugIcon } from 'src/components/icons/xdebug-icon'; import { Tooltip } from 'src/components/tooltip'; -import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; -import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useDeleteSite } from 'src/hooks/use-delete-site'; import { useImportExport } from 'src/hooks/use-import-export'; @@ -16,7 +14,9 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; import { getTerminalName } from 'src/modules/user-settings/lib/terminal'; +import { store } from 'src/stores'; import { useGetUserEditorQuery, useGetUserTerminalQuery } from 'src/stores/installed-apps-api'; +import { syncOperationsSelectors } from 'src/stores/sync'; interface SiteMenuProps { className?: string; @@ -133,14 +133,12 @@ function SiteItem( { site }: { site: SiteDetails } ) { const { selectedSite, setSelectedSiteId, loadingServer, isSiteDeleting } = useSiteDetails(); const isSelected = site === selectedSite; const { isSiteImporting, isSiteExporting } = useImportExport(); - const { isSiteIdPulling } = useSyncPull(); - const { isSiteIdPushing } = useSyncPush(); const { data: editor } = useGetUserEditorQuery(); const { data: terminal } = useGetUserTerminalQuery(); const isImporting = isSiteImporting( site.id ); const isExporting = isSiteExporting( site.id ); - const isPulling = isSiteIdPulling( site.id ); - const isPushing = isSiteIdPushing( site.id ); + const isPulling = syncOperationsSelectors.selectIsSiteIdPulling( site.id )( store.getState() ); + const isPushing = syncOperationsSelectors.selectIsSiteIdPushing( site.id )( store.getState() ); const isSyncing = isPulling || isPushing; const isDeleting = isSiteDeleting( site.id ); const showSpinner = diff --git a/src/hooks/sync-sites/index.ts b/src/hooks/sync-sites/index.ts index 476730492a..7196b0a333 100644 --- a/src/hooks/sync-sites/index.ts +++ b/src/hooks/sync-sites/index.ts @@ -1,5 +1,2 @@ -export type { SyncBackupState } from './use-sync-pull'; -export { useSyncPull } from './use-sync-pull'; -export { useSyncPush } from './use-sync-push'; export { useLastSyncTimeText } from './use-last-sync-time-text'; export type { GetLastSyncTimeText } from './use-last-sync-time-text'; diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts deleted file mode 100644 index b05c8f5045..0000000000 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { useCallback } from 'react'; -import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; -import { useAuth } from 'src/hooks/use-auth'; -import { PullStateProgressInfo } from 'src/hooks/use-sync-states-progress-info'; -import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; -import { syncOperationsSelectors, syncOperationsThunks } from 'src/stores/sync'; -import type { SyncSite } from 'src/modules/sync/types'; -import type { SyncOption } from 'src/types'; - -export type SyncBackupState = { - remoteSiteId: number; - backupId: string | null; - status: PullStateProgressInfo; - downloadUrl: string | null; - selectedSite: SiteDetails; - remoteSiteUrl: string; -}; - -export type PullSiteOptions = { - optionsToSync: SyncOption[]; - include_path_list?: string[]; -}; - -export type PullStates = Record< string, SyncBackupState >; -type PullSite = ( - connectedSite: SyncSite, - selectedSite: SiteDetails, - options: PullSiteOptions -) => void; -type IsSiteIdPulling = ( selectedSiteId: string, remoteSiteId?: number ) => boolean; - -type CancelPull = ( selectedSiteId: string, remoteSiteId: number ) => void; - -export type UseSyncPull = { - pullStates: PullStates; - getPullState: GetState< SyncBackupState >; - pullSite: PullSite; - isAnySitePulling: boolean; - isSiteIdPulling: IsSiteIdPulling; - clearPullState: ClearState; - cancelPull: CancelPull; -}; - -export function useSyncPull(): UseSyncPull { - const { client } = useAuth(); - - const dispatch = useAppDispatch(); - const pullStates = useRootSelector( - syncOperationsSelectors.selectPullStates as ( state: RootState ) => PullStates - ); - - const getPullState = useCallback< GetState< SyncBackupState > >( - ( selectedSiteId, remoteSiteId ) => { - const state = store.getState(); - return syncOperationsSelectors.selectPullState( selectedSiteId, remoteSiteId )( state ); - }, - [] - ); - - const clearPullState = useCallback< ClearState >( - ( selectedSiteId, remoteSiteId ) => { - void dispatch( syncOperationsThunks.clearPullState( { selectedSiteId, remoteSiteId } ) ); - }, - [ dispatch ] - ); - - const pullSite = useCallback< PullSite >( - async ( connectedSite, selectedSite, options ) => { - if ( ! client ) { - return; - } - - try { - // Polling is triggered automatically by listener middleware - // when state has backupId and status is in-progress - await dispatch( - syncOperationsThunks.pullSite( { - client, - connectedSite, - selectedSite, - options, - } ) - ).unwrap(); - } catch ( error ) { - // Errors are already handled in the thunk (state updates, error messages) - } - }, - [ client, dispatch ] - ); - - const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); - - const isSiteIdPulling = useCallback< IsSiteIdPulling >( ( selectedSiteId, remoteSiteId ) => { - const state = store.getState(); - return syncOperationsSelectors.selectIsSiteIdPulling( selectedSiteId, remoteSiteId )( state ); - }, [] ); - - const cancelPull = useCallback< CancelPull >( - async ( selectedSiteId, remoteSiteId ) => { - void dispatch( - syncOperationsThunks.cancelPull( { - selectedSiteId, - remoteSiteId, - } ) - ); - }, - [ dispatch ] - ); - - return { - pullStates, - getPullState, - pullSite, - isAnySitePulling, - isSiteIdPulling, - clearPullState, - cancelPull, - }; -} diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts deleted file mode 100644 index e38e522a86..0000000000 --- a/src/hooks/sync-sites/use-sync-push.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { useCallback } from 'react'; -import { ClearState, GetState } from 'src/hooks/sync-sites/use-pull-push-states'; -import { useAuth } from 'src/hooks/use-auth'; -import { PushStateProgressInfo } from 'src/hooks/use-sync-states-progress-info'; -import { store, useAppDispatch, useRootSelector, type RootState } from 'src/stores'; -import { syncOperationsSelectors, syncOperationsThunks } from 'src/stores/sync'; -import type { SyncSite } from 'src/modules/sync/types'; -import type { SyncOption } from 'src/types'; - -export type SyncPushState = { - remoteSiteId: number; - status: PushStateProgressInfo; - selectedSite: SiteDetails; - remoteSiteUrl: string; - uploadProgress?: number; -}; - -type PushSiteOptions = { - optionsToSync?: SyncOption[]; - specificSelectionPaths?: string[]; -}; - -export type PushStates = Record< string, SyncPushState >; -type PushSite = ( - connectedSite: SyncSite, - selectedSite: SiteDetails, - options?: PushSiteOptions -) => Promise< void >; -type IsSiteIdPushing = ( selectedSiteId: string, remoteSiteId?: number ) => boolean; - -type CancelPush = ( selectedSiteId: string, remoteSiteId: number ) => void; - -export type UseSyncPush = { - pushStates: PushStates; - getPushState: GetState< SyncPushState >; - pushSite: PushSite; - isAnySitePushing: boolean; - isSiteIdPushing: IsSiteIdPushing; - clearPushState: ClearState; - cancelPush: CancelPush; -}; - -export function useSyncPush(): UseSyncPush { - const { client } = useAuth(); - - const dispatch = useAppDispatch(); - const pushStates = useRootSelector( - syncOperationsSelectors.selectPushStates as ( state: RootState ) => PushStates - ); - const getPushState = useCallback< GetState< SyncPushState > >( - ( selectedSiteId, remoteSiteId ) => { - const state = store.getState(); - return syncOperationsSelectors.selectPushState( selectedSiteId, remoteSiteId )( state ); - }, - [] - ); - - const clearPushState = useCallback< ClearState >( - ( selectedSiteId, remoteSiteId ) => { - void dispatch( syncOperationsThunks.clearPushState( { selectedSiteId, remoteSiteId } ) ); - }, - [ dispatch ] - ); - - const pushSite = useCallback< PushSite >( - async ( connectedSite, selectedSite, options ) => { - if ( ! client ) { - return; - } - - try { - // Polling is triggered automatically by listener middleware - // when state enters creatingRemoteBackup/applyingChanges/finishing - await dispatch( - syncOperationsThunks.pushSite( { - connectedSite, - selectedSite, - options, - } ) - ).unwrap(); - } catch ( error ) { - // Errors are already handled in the thunk (state updates, error messages) - } - }, - [ client, dispatch ] - ); - - const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); - - const isSiteIdPushing = useCallback< IsSiteIdPushing >( ( selectedSiteId, remoteSiteId ) => { - const state = store.getState(); - return syncOperationsSelectors.selectIsSiteIdPushing( selectedSiteId, remoteSiteId )( state ); - }, [] ); - - const cancelPush = useCallback< CancelPush >( - async ( selectedSiteId, remoteSiteId ) => { - void dispatch( - syncOperationsThunks.cancelPush( { - selectedSiteId, - remoteSiteId, - } ) - ); - }, - [ dispatch ] - ); - - return { - pushStates, - getPushState, - pushSite, - isAnySitePushing, - isSiteIdPushing, - clearPushState, - cancelPush, - }; -} diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx index 93787a0ec9..1b1d73861a 100644 --- a/src/hooks/tests/use-add-site.test.tsx +++ b/src/hooks/tests/use-add-site.test.tsx @@ -3,8 +3,8 @@ import { renderHook, act } from '@testing-library/react'; import nock from 'nock'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; -import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; import { useAddSite, CreateSiteFormValues } from 'src/hooks/use-add-site'; +import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { store } from 'src/stores'; @@ -13,8 +13,22 @@ import type { SyncSite } from 'src/modules/sync/types'; vi.mock( 'src/hooks/use-site-details' ); vi.mock( 'src/hooks/use-feature-flags' ); -vi.mock( 'src/hooks/sync-sites/use-sync-pull' ); +vi.mock( 'src/hooks/use-auth' ); vi.mock( 'src/hooks/use-content-tabs' ); + +const { mockPullSiteThunk } = vi.hoisted( () => ( { + mockPullSiteThunk: vi.fn().mockReturnValue( { type: 'test/pullSite' } ), +} ) ); +vi.mock( 'src/stores/sync', async () => { + const actual = await vi.importActual( 'src/stores/sync' ); + return { + ...actual, + syncOperationsThunks: { + ...( actual as Record< string, unknown > ).syncOperationsThunks, + pullSite: mockPullSiteThunk, + }, + }; +} ); vi.mock( 'src/hooks/use-import-export', () => ( { useImportExport: () => ( { importFile: vi.fn(), @@ -55,7 +69,7 @@ describe( 'useAddSite', () => { const mockCreateSite = vi.fn(); const mockUpdateSite = vi.fn(); const mockStartServer = vi.fn(); - const mockPullSite = vi.fn(); + const mockClient = { req: { get: vi.fn(), post: vi.fn() } }; const mockSetSelectedTab = vi.fn(); beforeEach( () => { @@ -86,15 +100,8 @@ describe( 'useAddSite', () => { startServer: mockStartServer, } ); - mockPullSite.mockReset(); - vi.mocked( useSyncPull, { partial: true } ).mockReturnValue( { - pullSite: mockPullSite, - pullStates: {}, - getPullState: vi.fn(), - isAnySitePulling: false, - isSiteIdPulling: vi.fn(), - clearPullState: vi.fn(), - cancelPull: vi.fn(), + vi.mocked( useAuth, { partial: true } ).mockReturnValue( { + client: mockClient, } ); mockSetSelectedTab.mockReset(); @@ -264,8 +271,11 @@ describe( 'useAddSite', () => { localSiteId: createdSite.id, }, ] ); - expect( mockPullSite ).toHaveBeenCalledWith( remoteSite, createdSite, { - optionsToSync: [ 'all' ], + expect( mockPullSiteThunk ).toHaveBeenCalledWith( { + client: mockClient, + connectedSite: remoteSite, + selectedSite: createdSite, + options: { optionsToSync: [ 'all' ] }, } ); expect( mockSetSelectedTab ).toHaveBeenCalledWith( 'sync' ); } ); diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts index e4950b64b9..6a2003f478 100644 --- a/src/hooks/use-add-site.ts +++ b/src/hooks/use-add-site.ts @@ -4,16 +4,17 @@ import { useCallback, useMemo, useState } from 'react'; import { updateBlueprintWithFormValues } from 'common/lib/blueprint-settings'; import { BlueprintValidationWarning } from 'common/lib/blueprint-validation'; import { generateCustomDomainFromSiteName } from 'common/lib/domains'; -import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; +import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useRootSelector } from 'src/stores'; +import { useAppDispatch, useRootSelector } from 'src/stores'; import { selectDefaultPhpVersion, selectDefaultWordPressVersion, } from 'src/stores/provider-constants-slice'; +import { syncOperationsThunks } from 'src/stores/sync'; import { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; import { Blueprint } from 'src/stores/wpcom-api'; import type { BlueprintPreferredVersions } from 'common/lib/blueprint-validation'; @@ -50,7 +51,8 @@ export function useAddSite() { const { createSite, sites } = useSiteDetails(); const { importFile, clearImportState, importState } = useImportExport(); const [ connectSite ] = useConnectSiteMutation(); - const { pullSite } = useSyncPull(); + const { client } = useAuth(); + const dispatch = useAppDispatch(); const { setSelectedTab } = useContentTabs(); const defaultPhpVersion = useRootSelector( selectDefaultPhpVersion ); const defaultWordPressVersion = useRootSelector( selectDefaultWordPressVersion ); @@ -276,12 +278,17 @@ export function useAddSite() { title: newSite.name, body: __( 'Your new site was imported' ), } ); - } else if ( selectedRemoteSite ) { + } else if ( selectedRemoteSite && client ) { await connectSite( { site: selectedRemoteSite, localSiteId: newSite.id } ); const pullOptions: SyncOption[] = [ 'all' ]; - pullSite( selectedRemoteSite, newSite, { - optionsToSync: pullOptions, - } ); + void dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite: selectedRemoteSite, + selectedSite: newSite, + options: { optionsToSync: pullOptions }, + } ) + ); setSelectedTab( 'sync' ); } else { getIpcApi().showNotification( { @@ -299,12 +306,13 @@ export function useAddSite() { [ __, clearImportState, + client, createSite, + dispatch, fileForImport, importFile, selectedBlueprint, selectedRemoteSite, - pullSite, connectSite, setSelectedTab, ] diff --git a/src/modules/add-site/tests/add-site.test.tsx b/src/modules/add-site/tests/add-site.test.tsx index c79e5e40b8..7ef726aea9 100644 --- a/src/modules/add-site/tests/add-site.test.tsx +++ b/src/modules/add-site/tests/add-site.test.tsx @@ -46,8 +46,6 @@ const mockShowOpenFolderDialog = const mockGenerateProposedSitePath = vi.fn< ( siteName: string ) => Promise< FolderDialogResponse > >(); const mockGetAllCustomDomains = vi.fn< () => Promise< string[] > >().mockResolvedValue( [] ); -const mockPullSite = vi.fn(); -const mockUseSyncPull = vi.fn(); const mockSetSelectedTab = vi.fn(); vi.mock( 'src/lib/get-ipc-api', () => ( { @@ -63,10 +61,6 @@ vi.mock( 'src/lib/get-ipc-api', () => ( { } ), } ) ); -vi.mock( 'src/hooks/sync-sites/use-sync-pull', () => ( { - useSyncPull: () => mockUseSyncPull(), -} ) ); - vi.mock( 'src/hooks/use-import-export', () => ( { useImportExport: () => ( { importState: {}, @@ -119,16 +113,6 @@ const renderWithProvider = ( children: React.ReactElement ) => { beforeEach( () => { vi.clearAllMocks(); - mockPullSite.mockReset(); - mockUseSyncPull.mockReturnValue( { - pullSite: mockPullSite, - pullStates: {}, - getPullState: vi.fn(), - isAnySitePulling: false, - isSiteIdPulling: vi.fn(), - clearPullState: vi.fn(), - cancelPull: vi.fn(), - } ); mockSetSelectedTab.mockReset(); mockShowOpenFolderDialog.mockResolvedValue( { diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index 15a8b323b5..bdcfa27b08 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -14,8 +14,6 @@ import ProgressBar from 'src/components/progress-bar'; import { Tooltip, DynamicTooltip } from 'src/components/tooltip'; import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; import { useLastSyncTimeText } from 'src/hooks/sync-sites/use-last-sync-time-text'; -import { useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; -import { useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; import { useImportExport } from 'src/hooks/use-import-export'; import { useOffline } from 'src/hooks/use-offline'; @@ -35,7 +33,8 @@ import { convertTreeToPushOptions, } from 'src/modules/sync/lib/convert-tree-to-sync-options'; import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils'; -import { useAppDispatch, useI18nLocale } from 'src/stores'; +import { store, useAppDispatch, useI18nLocale, useRootSelector } from 'src/stores'; +import { syncOperationsSelectors, syncOperationsThunks } from 'src/stores/sync'; import { connectedSitesActions, useGetConnectedSitesForLocalSiteQuery, @@ -51,18 +50,23 @@ const SyncConnectedSiteControls = ( { } ) => { const { __ } = useI18n(); const isOffline = useOffline(); + const dispatch = useAppDispatch(); const [ syncDialogType, setSyncDialogType ] = useState< 'pull' | 'push' | null >( null ); - const { pullSite, isAnySitePulling, isSiteIdPulling } = useSyncPull(); - const { pushSite, isAnySitePushing, isSiteIdPushing } = useSyncPush(); + const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); + const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const getLastSyncTimeText = useLastSyncTimeText(); - const { user } = useAuth(); + const { user, client } = useAuth(); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, userId: user?.id, } ); const isAnyConnectedSiteSyncing = connectedSites.some( ( site ) => - isSiteIdPulling( selectedSite.id, site.id ) || isSiteIdPushing( selectedSite.id, site.id ) + syncOperationsSelectors.selectIsSiteIdPulling( + selectedSite.id, + site.id + )( store.getState() ) || + syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( store.getState() ) ); const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; @@ -161,11 +165,27 @@ const SyncConnectedSiteControls = ( { remoteSite={ connectedSite } onPush={ ( tree ) => { const pushOptions = convertTreeToPushOptions( tree ); - void pushSite( connectedSite, selectedSite, pushOptions ); + void dispatch( + syncOperationsThunks.pushSite( { + connectedSite, + selectedSite, + options: pushOptions, + } ) + ); } } onPull={ ( tree ) => { + if ( ! client ) { + return; + } const pullOptions = convertTreeToPullOptions( tree ); - pullSite( connectedSite, selectedSite, pullOptions ); + void dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite, + selectedSite, + options: pullOptions, + } ) + ); } } onRequestClose={ () => setSyncDialogType( null ) } /> @@ -185,13 +205,14 @@ const SyncConnectedSitesSectionItem = ( { connectedSite, }: SyncConnectedSitesListProps ) => { const { __ } = useI18n(); - const { clearPullState, getPullState, cancelPull } = useSyncPull(); - const { getPushState, clearPushState, cancelPush } = useSyncPush(); + const dispatch = useAppDispatch(); const isOffline = useOffline(); const { importState } = useImportExport(); const { getPushUploadPercentage, getPushUploadMessage } = useSyncStatesProgressInfo(); - const sitePullState = getPullState( selectedSite.id, connectedSite.id ); + const sitePullState = useRootSelector( + syncOperationsSelectors.selectPullState( selectedSite.id, connectedSite.id ) + ); const isPulling = sitePullState?.status && [ 'in-progress', 'downloading', 'importing' ].includes( sitePullState.status.key ); @@ -215,7 +236,9 @@ const SyncConnectedSitesSectionItem = ( { sitePullStatusProgress = sitePullState.status.progress; } - const pushState = getPushState( selectedSite.id, connectedSite.id ); + const pushState = useRootSelector( + syncOperationsSelectors.selectPushState( selectedSite.id, connectedSite.id ) + ); const isPushing = pushState?.status && [ @@ -287,7 +310,14 @@ const SyncConnectedSitesSectionItem = ( { > -
+
{ isPulling && ( -
+
{ sitePullStatusMessage }
@@ -325,56 +335,48 @@ const SyncConnectedSitesSectionItem = ( { ) } disabled={ ! canCancelPull( sitePullState?.status.key ) } - className="!p-0 flex-shrink-0" + className="flex-shrink-0 transition-all duration-300 ease-in-out" > - + + + { ' ' }
) } { sitePullState?.status && hasPullCancelled && ( - - dispatch( - syncOperationsActions.clearPullState( { - selectedSiteId: selectedSite.id, - remoteSiteId: connectedSite.id, - } ) - ) - } - > - { __( 'Pull cancelled' ) } - +
+ clearPullState( selectedSite.id, connectedSite.id ) }> + { __( 'Pull cancelled' ) } + +
) } { isPullError && ( - - dispatch( - syncOperationsActions.clearPullState( { - selectedSiteId: selectedSite.id, - remoteSiteId: connectedSite.id, - } ) - ) - } - isError - > - { __( 'Error pulling changes' ) } - +
+ clearPullState( selectedSite.id, connectedSite.id ) } + isError + > + { __( 'Error pulling changes' ) } + +
) } { isPushError && ( - - dispatch( - syncOperationsActions.clearPushState( { - selectedSiteId: selectedSite.id, - remoteSiteId: connectedSite.id, - } ) - ) - } - isError - > - { __( 'Error pushing changes' ) } - +
+ + dispatch( + syncOperationsActions.clearPushState( { + selectedSiteId: selectedSite.id, + remoteSiteId: connectedSite.id, + } ) + ) + } + isError + > + { __( 'Error pushing changes' ) } + +
) } { hasPullFinished && (
@@ -384,16 +386,7 @@ const SyncConnectedSitesSectionItem = ( { } placement="top-start" > - - dispatch( - syncOperationsActions.clearPullState( { - selectedSiteId: selectedSite.id, - remoteSiteId: connectedSite.id, - } ) - ) - } - > + clearPullState( selectedSite.id, connectedSite.id ) }> { __( 'Pull complete' ) } @@ -413,7 +406,7 @@ const SyncConnectedSitesSectionItem = ( { ) } { pushState?.status && isPushing && ( -
+
@@ -446,28 +439,31 @@ const SyncConnectedSitesSectionItem = ( { ) } disabled={ ! canCancelPush( pushState?.status.key ) } - className="!p-0 flex-shrink-0" + className="flex-shrink-0 transition-all duration-300 ease-in-out" > - + + +
) } { pushState?.status && hasPushCancelled && ( - - dispatch( - syncOperationsActions.clearPushState( { - selectedSiteId: selectedSite.id, - remoteSiteId: connectedSite.id, - } ) - ) - } - > - { __( 'Push cancelled' ) } - +
+ + dispatch( + syncOperationsActions.clearPushState( { + selectedSiteId: selectedSite.id, + remoteSiteId: connectedSite.id, + } ) + ) + } + > + { __( 'Push cancelled' ) } + +
) } - { pushState?.status && hasPushFinished && (
+
+ +
) }
@@ -581,7 +579,7 @@ const SyncConnectedSiteSection = ( { return (
-
+
{ logo }
{ connectedSite.name } From a0d4540ccefb55b3ec99ecba10a98386518923ae Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 10:48:42 +0100 Subject: [PATCH 43/58] zod schemas --- .../src/stores/sync/sync-operations-slice.ts | 102 ++++++++++-------- 1 file changed, 58 insertions(+), 44 deletions(-) diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index 741839e338..f6e4e52cb7 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -2,6 +2,7 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import * as Sentry from '@sentry/electron/renderer'; import { __, sprintf } from '@wordpress/i18n'; import { WPCOM } from 'wpcom/types'; +import { z } from 'zod'; import { SYNC_PUSH_SIZE_LIMIT_BYTES, SYNC_PUSH_SIZE_LIMIT_GB } from 'src/constants'; import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; import { getIpcApi } from 'src/lib/get-ipc-api'; @@ -18,7 +19,7 @@ import type { SyncOption } from 'src/types'; export type SyncBackupState = { remoteSiteId: number; - backupId: string | null; + backupId: number | null; status: PullStateProgressInfo; downloadUrl: string | null; selectedSite: SiteDetails; @@ -426,10 +427,43 @@ type PullSitePayload = { }; type PullSiteResult = { - backupId: string; + backupId: number; remoteSiteId: number; }; +const pullSiteResponseSchema = z.object( { + success: z.boolean(), + backup_id: z.number(), +} ); + +const importResponseSchema = z.object( { + status: z.enum( [ + 'finished', + 'failed', + 'initial_backup_started', + 'archive_import_started', + 'archive_import_finished', + ] ), + success: z.boolean(), + backup_progress: z.number().nullable(), + import_progress: z.number().nullable(), + error: z.string(), + error_data: z + .object( { + vp_restore_status: z.string().nullable(), + vp_restore_message: z.string().nullable(), + vp_rewind_id: z.string().nullable(), + } ) + .nullable() + .optional(), +} ); + +const syncBackupResponseSchema = z.object( { + status: z.enum( [ 'in-progress', 'finished', 'failed' ] ), + download_url: z.string().nullable().optional(), + percent: z.number(), +} ); + export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayload >( 'syncOperations/pullSite', async ( { client, connectedSite, selectedSite, options }, { dispatch, rejectWithValue } ) => { @@ -461,11 +495,12 @@ export const pullSiteThunk = createTypedAsyncThunk< PullSiteResult, PullSitePayl include_path_list: options.include_path_list, }; - const response = await client.req.post< { success: boolean; backup_id: string } >( { + const rawResponse = await client.req.post( { path: `/sites/${ remoteSiteId }/studio-app/sync/backup`, apiNamespace: 'wpcom/v2', body: requestBody, } ); + const response = pullSiteResponseSchema.parse( rawResponse ); if ( response.success ) { dispatch( @@ -503,25 +538,7 @@ type PollPushProgressPayload = { remoteSiteId: number; }; -type RestoreErrorData = { - vp_restore_status?: string; - vp_restore_message?: string; - vp_rewind_id?: string | null; -}; - -type ImportResponse = { - status: - | 'finished' - | 'failed' - | 'initial_backup_started' - | 'archive_import_started' - | 'archive_import_finished'; - success: boolean; - backup_progress: number; - import_progress: number; - error?: string; - error_data?: RestoreErrorData | null; -}; +type ImportResponse = z.infer< typeof importResponseSchema >; const pollPushProgressThunk = createTypedAsyncThunk( 'syncOperations/pollPushProgress', @@ -537,10 +554,10 @@ const pollPushProgressThunk = createTypedAsyncThunk( )( getState() ); try { - const response = await client.req.get< ImportResponse >( - `/sites/${ remoteSiteId }/studio-app/sync/import`, - { apiNamespace: 'wpcom/v2' } - ); + const rawResponse = await client.req.get( `/sites/${ remoteSiteId }/studio-app/sync/import`, { + apiNamespace: 'wpcom/v2', + } ); + const response = importResponseSchema.parse( rawResponse ); signal.throwIfAborted(); @@ -599,14 +616,18 @@ const pollPushProgressThunk = createTypedAsyncThunk( } case 'initial_backup_started': { status = pushStatesProgressInfo.creatingRemoteBackup; - const progressRange = pushStatesProgressInfo.applyingChanges.progress - status.progress; - status.progress = status.progress + progressRange * ( response.backup_progress / 100 ); + if ( response.backup_progress ) { + const progressRange = pushStatesProgressInfo.applyingChanges.progress - status.progress; + status.progress = status.progress + progressRange * ( response.backup_progress / 100 ); + } break; } case 'archive_import_started': { status = pushStatesProgressInfo.applyingChanges; - const progressRange = pushStatesProgressInfo.finishing.progress - status.progress; - status.progress = status.progress + progressRange * ( response.import_progress / 100 ); + if ( response.import_progress ) { + const progressRange = pushStatesProgressInfo.finishing.progress - status.progress; + status.progress = status.progress + progressRange * ( response.import_progress / 100 ); + } break; } case 'archive_import_finished': @@ -648,12 +669,6 @@ type PollPullBackupPayload = { remoteSiteId: number; }; -type SyncBackupResponse = { - status: 'in-progress' | 'finished' | 'failed'; - download_url: string; - percent: number; -}; - const pollPullBackupThunk = createTypedAsyncThunk( 'syncOperations/pollPullBackup', async ( @@ -673,13 +688,11 @@ const pollPullBackupThunk = createTypedAsyncThunk( } try { - const response = await client.req.get< SyncBackupResponse >( - `/sites/${ remoteSiteId }/studio-app/sync/backup`, - { - apiNamespace: 'wpcom/v2', - backup_id: backupId, - } - ); + const rawResponse = await client.req.get( `/sites/${ remoteSiteId }/studio-app/sync/backup`, { + apiNamespace: 'wpcom/v2', + backup_id: backupId, + } ); + const response = syncBackupResponseSchema.parse( rawResponse ); signal.throwIfAborted(); @@ -868,10 +881,11 @@ export const initializeSyncStatesThunk = createTypedAsyncThunk( continue; } - const response = await client.req.get< ImportResponse >( + const rawResponse = await client.req.get( `/sites/${ connectedSite.id }/studio-app/sync/import`, { apiNamespace: 'wpcom/v2' } ); + const response = importResponseSchema.parse( rawResponse ); const status = mapImportResponseToPushState( response ); From 94833751433dec62e896c2dcc1bb62d237fc5dde Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 11:07:01 +0100 Subject: [PATCH 44/58] Correctness and simplification --- .../sync/components/sync-connected-sites.tsx | 46 +++++++++---------- .../src/stores/sync/sync-operations-slice.ts | 15 ++++-- 2 files changed, 33 insertions(+), 28 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 f53356a968..f822a5101c 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -220,11 +220,12 @@ const SyncConnectedSitesSectionItem = ( { syncOperationsSelectors.selectPullState( selectedSite.id, connectedSite.id ) ); const isPulling = - sitePullState?.status && - [ 'in-progress', 'downloading', 'importing' ].includes( sitePullState.status.key ); - const isPullError = sitePullState?.status && sitePullState.status.key === 'failed'; - const hasPullFinished = sitePullState?.status && sitePullState.status.key === 'finished'; - const hasPullCancelled = sitePullState?.status && sitePullState.status.key === 'cancelled'; + sitePullState?.status.key === 'in-progress' || + sitePullState?.status.key === 'downloading' || + sitePullState?.status.key === 'importing'; + const isPullError = sitePullState?.status.key === 'failed'; + const hasPullFinished = sitePullState?.status.key === 'finished'; + const hasPullCancelled = sitePullState?.status.key === 'cancelled'; const pullImportState = importState[ connectedSite.localSiteId ]; let sitePullStatusMessage = ''; let sitePullStatusProgress = 0; @@ -246,18 +247,15 @@ const SyncConnectedSitesSectionItem = ( { syncOperationsSelectors.selectPushState( selectedSite.id, connectedSite.id ) ); const isPushing = - pushState?.status && - [ - 'creatingBackup', - 'uploading', - 'creatingRemoteBackup', - 'applyingChanges', - 'finishing', - ].includes( pushState.status.key ); - const isUploadingPaused = pushState?.status && pushState.status.key === 'uploadingPaused'; - const isPushError = pushState?.status && pushState.status.key === 'failed'; - const hasPushFinished = pushState?.status && pushState.status.key === 'finished'; - const hasPushCancelled = pushState?.status && pushState.status.key === 'cancelled'; + pushState?.status.key === 'creatingBackup' || + pushState?.status.key === 'uploading' || + pushState?.status.key === 'creatingRemoteBackup' || + pushState?.status.key === 'applyingChanges' || + pushState?.status.key === 'finishing'; + const isUploadingPaused = pushState?.status.key === 'uploadingPaused'; + const isPushError = pushState?.status.key === 'failed'; + const hasPushFinished = pushState?.status.key === 'finished'; + const hasPushCancelled = pushState?.status.key === 'cancelled'; const uploadPercentage = getPushUploadPercentage( pushState?.status.key, @@ -344,7 +342,7 @@ const SyncConnectedSitesSectionItem = ( {
) } - { sitePullState?.status && hasPullCancelled && ( + { hasPullCancelled && (
clearPullState( selectedSite.id, connectedSite.id ) }> { __( 'Pull cancelled' ) } @@ -392,7 +390,7 @@ const SyncConnectedSitesSectionItem = ( {
) } - { pushState?.status && isUploadingPaused && ( + { isUploadingPaused && ( ) } - { pushState?.status && isPushing && ( + { isPushing && (
@@ -422,7 +420,7 @@ const SyncConnectedSitesSectionItem = ( { @@ -448,7 +446,7 @@ const SyncConnectedSitesSectionItem = ( {
) } - { pushState?.status && hasPushCancelled && ( + { hasPushCancelled && (
@@ -464,7 +462,7 @@ const SyncConnectedSitesSectionItem = ( {
) } - { pushState?.status && hasPushFinished && ( + { hasPushFinished && (
diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index f6e4e52cb7..ffb35bff5c 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -552,6 +552,9 @@ const pollPushProgressThunk = createTypedAsyncThunk( selectedSiteId, remoteSiteId )( getState() ); + if ( ! currentPushState ) { + return; + } try { const rawResponse = await client.req.get( `/sites/${ remoteSiteId }/studio-app/sync/import`, { @@ -681,6 +684,10 @@ const pollPullBackupThunk = createTypedAsyncThunk( remoteSiteId )( getState() ); + if ( ! currentPullState ) { + return; + } + const backupId = currentPullState.backupId; if ( ! backupId ) { console.error( 'No backup ID found' ); @@ -952,15 +959,15 @@ export const syncOperationsSelectors = { state.syncOperations.pushStates, selectPullState: ( selectedSiteId: string, remoteSiteId: number ) => - ( state: { syncOperations: SyncOperationsState } ) => { + ( state: { syncOperations: SyncOperationsState } ): SyncBackupState | undefined => { const stateId = generateStateId( selectedSiteId, remoteSiteId ); - return state.syncOperations.pullStates[ stateId ]; + return state.syncOperations.pullStates[ stateId ] as SyncBackupState | undefined; }, selectPushState: ( selectedSiteId: string, remoteSiteId: number ) => - ( state: { syncOperations: SyncOperationsState } ) => { + ( state: { syncOperations: SyncOperationsState } ): SyncPushState | undefined => { const stateId = generateStateId( selectedSiteId, remoteSiteId ); - return state.syncOperations.pushStates[ stateId ]; + return state.syncOperations.pushStates[ stateId ] as SyncPushState | undefined; }, selectIsAnySitePulling: ( state: { syncOperations: SyncOperationsState } ): boolean => { return Object.values( state.syncOperations.pullStates ).some( ( pullState ) => From 6ed7d2fb4118789164b1bfb8d2273f3b7f501b87 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 11:18:29 +0100 Subject: [PATCH 45/58] Fix schema --- .../src/stores/sync/sync-operations-slice.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index ffb35bff5c..9e0c11d7f0 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -436,17 +436,9 @@ const pullSiteResponseSchema = z.object( { backup_id: z.number(), } ); -const importResponseSchema = z.object( { - status: z.enum( [ - 'finished', - 'failed', - 'initial_backup_started', - 'archive_import_started', - 'archive_import_finished', - ] ), +const importFailedResponseSchema = z.object( { + status: z.literal( 'failed' ), success: z.boolean(), - backup_progress: z.number().nullable(), - import_progress: z.number().nullable(), error: z.string(), error_data: z .object( { @@ -454,10 +446,28 @@ const importResponseSchema = z.object( { vp_restore_message: z.string().nullable(), vp_rewind_id: z.string().nullable(), } ) - .nullable() - .optional(), + .nullable(), +} ); + +const importWorkingResponseSchema = z.object( { + status: z.enum( [ + 'started', + 'initial_backup_started', + 'initial_backup_finished', + 'archive_import_started', + 'archive_import_finished', + 'finished', + ] ), + success: z.boolean(), + backup_progress: z.number().nullable(), + import_progress: z.number().nullable(), } ); +const importResponseSchema = z.discriminatedUnion( 'status', [ + importWorkingResponseSchema, + importFailedResponseSchema, +] ); + const syncBackupResponseSchema = z.object( { status: z.enum( [ 'in-progress', 'finished', 'failed' ] ), download_url: z.string().nullable().optional(), @@ -617,22 +627,22 @@ const pollPushProgressThunk = createTypedAsyncThunk( showOpenLogs: true, } ); } - case 'initial_backup_started': { + case 'started': + case 'initial_backup_started': + case 'initial_backup_finished': status = pushStatesProgressInfo.creatingRemoteBackup; if ( response.backup_progress ) { const progressRange = pushStatesProgressInfo.applyingChanges.progress - status.progress; status.progress = status.progress + progressRange * ( response.backup_progress / 100 ); } break; - } - case 'archive_import_started': { + case 'archive_import_started': status = pushStatesProgressInfo.applyingChanges; if ( response.import_progress ) { const progressRange = pushStatesProgressInfo.finishing.progress - status.progress; status.progress = status.progress + progressRange * ( response.import_progress / 100 ); } break; - } case 'archive_import_finished': status = pushStatesProgressInfo.finishing; break; From d04c867eff0a3a56948435d8dbaa3739d0e64eb1 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 11:21:48 +0100 Subject: [PATCH 46/58] Fix more merge issues --- .../src/modules/sync/components/sync-connected-sites.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 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 f822a5101c..b9bba62f94 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -323,7 +323,7 @@ const SyncConnectedSitesSectionItem = ( { placement="top-start" >
@@ -427,7 +428,7 @@ const SyncConnectedSitesSectionItem = ( { placement="top-start" >
) } - { isUploadingPaused && ( - - - + { isUploadingNetworkPaused && ( +
+ + + +
+ ) } + { isUploadingManuallyPaused && ( +
+ +
+
+ + { pushState.status.message } +
+ +
+
+ + + + + + +
) } { isPushing && (
@@ -419,6 +476,29 @@ const SyncConnectedSitesSectionItem = ( {
+
= 100 ) + ? 'opacity-0 pointer-events-none' + : 'opacity-100' + ) } + > + + + +
{ + store.dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: payload.selectedSiteId, + remoteSiteId: payload.remoteSiteId, + state: { + status: getPushStatesProgressInfo().uploadingPaused, + }, + } ) + ); +} ); + +window.ipcListener.subscribe( 'sync-upload-manually-paused', ( _event, payload ) => { + store.dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: payload.selectedSiteId, + remoteSiteId: payload.remoteSiteId, + state: { + status: getPushStatesProgressInfo().uploadingManuallyPaused, + }, + } ) + ); +} ); + window.ipcListener.subscribe( 'sync-upload-progress', ( _event, payload ) => { const uploadProgress = Math.max( 0, Math.min( 100, payload.progress ) ); const uploadRange = CREATING_REMOTE_BACKUP_PROGRESS - UPLOADING_BASE_PROGRESS; // 10 From 67437ac505d0de003e13fed3f1a4aa57576cf052 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 11:41:01 +0100 Subject: [PATCH 48/58] Re-enable skipped tests --- .../src/modules/sync/tests/index.test.tsx | 100 ++++++++++++++++-- 1 file changed, 89 insertions(+), 11 deletions(-) diff --git a/apps/studio/src/modules/sync/tests/index.test.tsx b/apps/studio/src/modules/sync/tests/index.test.tsx index 5882ca42d6..28142cf3bd 100644 --- a/apps/studio/src/modules/sync/tests/index.test.tsx +++ b/apps/studio/src/modules/sync/tests/index.test.tsx @@ -1,7 +1,8 @@ // To run tests, execute `npm run test -- src/modules/sync/tests/index.test.tsx` from the root directory -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; +import { SYNC_OPTIONS } from 'src/constants'; import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useFeatureFlags } from 'src/hooks/use-feature-flags'; @@ -10,12 +11,15 @@ import { ContentTabSync } from 'src/modules/sync'; import { useSelectedItemsPushSize } from 'src/modules/sync/hooks/use-selected-items-push-size'; import { SyncSite } from 'src/modules/sync/types'; import { store } from 'src/stores'; -import { useLatestRewindId, useRemoteFileTree } from 'src/stores/sync'; +import { syncOperationsActions, useLatestRewindId, useRemoteFileTree } from 'src/stores/sync'; import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; store.replaceReducer( testReducer ); +const mockPullSiteThunk = vi.hoisted( () => vi.fn() ); +const mockPushSiteThunk = vi.hoisted( () => vi.fn() ); + vi.mock( 'src/lib/get-ipc-api' ); vi.mock( 'src/hooks/use-auth' ); vi.mock( 'src/stores/sync/wpcom-sites', async () => { @@ -52,6 +56,11 @@ vi.mock( 'src/stores/sync', async () => { return { type: 'connectedSites/closeModal' }; } ), }, + syncOperationsThunks: { + ...actual.syncOperationsThunks, + pullSite: mockPullSiteThunk, + pushSite: mockPushSiteThunk, + }, }; } ); @@ -76,6 +85,7 @@ const createAuthMock = ( isAuthenticated: boolean = false ) => ( { isAuthenticated, authenticate: vi.fn(), user: isAuthenticated ? { id: 123, email: 'user@example.com', displayName: 'user' } : undefined, + client: isAuthenticated ? ( {} as never ) : undefined, } ); const selectedSite: SiteDetails = { @@ -118,6 +128,14 @@ describe( 'ContentTabSync', () => { beforeEach( () => { vi.resetAllMocks(); + mockPullSiteThunk.mockImplementation( ( payload ) => ( { + type: 'syncOperations/pullSite', + payload, + } ) ); + mockPushSiteThunk.mockImplementation( ( payload ) => ( { + type: 'syncOperations/pushSite', + payload, + } ) ); store.dispatch( testActions.resetState() ); store.dispatch( { type: 'connectedSitesApi/resetApiState' } ); vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( false ) ); @@ -130,6 +148,8 @@ describe( 'ContentTabSync', () => { openURL: vi.fn(), showMessageBox: vi.fn(), updateConnectedWpcomSites: vi.fn(), + addSyncOperation: vi.fn(), + removeSyncOperation: vi.fn(), getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ), getDirectorySize: vi.fn().mockResolvedValue( 0 ), connectWpcomSites: vi.fn(), @@ -230,6 +250,8 @@ describe( 'ContentTabSync', () => { }; it( 'renders the sync title and login buttons', () => { + const authMock = createAuthMock( false ); + vi.mocked( useAuth, { partial: true } ).mockReturnValue( authMock ); renderWithProvider( ); expect( screen.getByText( 'Sync with WordPress.com or Pressable' ) ).toBeInTheDocument(); @@ -237,7 +259,7 @@ describe( 'ContentTabSync', () => { expect( loginButton ).toBeInTheDocument(); fireEvent.click( loginButton ); - expect( useAuth().authenticate ).toHaveBeenCalled(); + expect( authMock.authenticate ).toHaveBeenCalled(); const freeAccountButton = screen.getByRole( 'button', { name: /Create a free account/i } ); expect( freeAccountButton ).toBeInTheDocument(); @@ -355,10 +377,24 @@ describe( 'ContentTabSync', () => { expect( screen.getByText( 'Staging' ) ).toBeInTheDocument(); expect( screen.getByText( 'Development' ) ).toBeInTheDocument(); } ); - it.skip( 'displays the progress bar when the site is being pushed', async () => { - // TODO: Needs Redux store state setup instead of hook mocks (STU-711) + it( 'displays the progress bar when the site is being pushed', async () => { vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); + store.dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId: fakeSyncSite.id, + state: { + status: { + key: 'uploading', + progress: 40, + message: 'Uploading…', + }, + selectedSite, + remoteSiteUrl: fakeSyncSite.url, + }, + } ) + ); renderWithProvider( ); await screen.findByRole( 'progressbar' ); @@ -406,8 +442,7 @@ describe( 'ContentTabSync', () => { ); } ); - it.skip( 'calls pullSite with correct optionsToSync when all options are selected', async () => { - // TODO: Needs thunk mock instead of hook mock (STU-711) + it( 'calls pullSite with correct optionsToSync when all options are selected', async () => { vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); @@ -426,10 +461,19 @@ describe( 'ContentTabSync', () => { const dialogPullButton = await screen.findByTestId( 'sync-dialog-pull-button' ); fireEvent.click( dialogPullButton ); + + await waitFor( () => { + expect( mockPullSiteThunk ).toHaveBeenCalledWith( + expect.objectContaining( { + options: { + optionsToSync: [ SYNC_OPTIONS.all ], + }, + } ) + ); + } ); } ); - it.skip( 'calls pullSite with correct optionsToSync when only database is selected', async () => { - // TODO: Needs thunk mock instead of hook mock (STU-711) + it( 'calls pullSite with correct optionsToSync when only database is selected', async () => { vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); @@ -446,10 +490,19 @@ describe( 'ContentTabSync', () => { const dialogPullButton = await screen.findByTestId( 'sync-dialog-pull-button' ); fireEvent.click( dialogPullButton ); + + await waitFor( () => { + expect( mockPullSiteThunk ).toHaveBeenCalledWith( + expect.objectContaining( { + options: { + optionsToSync: [ SYNC_OPTIONS.sqls ], + }, + } ) + ); + } ); } ); - it.skip( 'calls pullSite with correct optionsToSync when options partially are selected', async () => { - // TODO: Needs thunk mock instead of hook mock (STU-711) + it( 'calls pullSite with correct optionsToSync when options partially are selected', async () => { vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); vi.mocked( useRemoteFileTree, { partial: true } ).mockReturnValue( { fetchChildren: vi.fn().mockResolvedValue( [ @@ -520,6 +573,31 @@ describe( 'ContentTabSync', () => { setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); + + const pullButton = await screen.findByTestId( 'sync-list-pull-button' ); + fireEvent.click( pullButton ); + + await screen.findByText( 'Pull from Production' ); + + const select = screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ); + fireEvent.change( select, { target: { value: 'true' } } ); + + const pluginsCheckbox = screen.getByRole( 'checkbox', { name: 'plugins' } ); + fireEvent.click( pluginsCheckbox ); + + const dialogPullButton = await screen.findByTestId( 'sync-dialog-pull-button' ); + fireEvent.click( dialogPullButton ); + + await waitFor( () => { + expect( mockPullSiteThunk ).toHaveBeenCalledWith( + expect.objectContaining( { + options: { + optionsToSync: [ SYNC_OPTIONS.paths ], + include_path_list: [ 'cjI6,ZjI6Lw==' ], + }, + } ) + ); + } ); } ); it( 'disables the pull button when all checkboxes are unchecked, which is the initial state', async () => { From 6fb173f715b4244daa12ce9682c5525daaa8a251 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 11:47:06 +0100 Subject: [PATCH 49/58] Restore more of the tests --- .../src/modules/sync/tests/index.test.tsx | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/studio/src/modules/sync/tests/index.test.tsx b/apps/studio/src/modules/sync/tests/index.test.tsx index 28142cf3bd..f31103c616 100644 --- a/apps/studio/src/modules/sync/tests/index.test.tsx +++ b/apps/studio/src/modules/sync/tests/index.test.tsx @@ -575,29 +575,35 @@ describe( 'ContentTabSync', () => { renderWithProvider( ); const pullButton = await screen.findByTestId( 'sync-list-pull-button' ); + expect( pullButton ).toBeInTheDocument(); fireEvent.click( pullButton ); await screen.findByText( 'Pull from Production' ); + const databaseCheckbox = screen.getByRole( 'checkbox', { name: 'Database' } ); + fireEvent.click( databaseCheckbox ); + + // Open specific files and folders selector const select = screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ); fireEvent.change( select, { target: { value: 'true' } } ); + // Check plugins and uploads const pluginsCheckbox = screen.getByRole( 'checkbox', { name: 'plugins' } ); fireEvent.click( pluginsCheckbox ); + const uploadsCheckbox = screen.getByRole( 'checkbox', { name: 'uploads' } ); + fireEvent.click( uploadsCheckbox ); const dialogPullButton = await screen.findByTestId( 'sync-dialog-pull-button' ); fireEvent.click( dialogPullButton ); - await waitFor( () => { - expect( mockPullSiteThunk ).toHaveBeenCalledWith( - expect.objectContaining( { - options: { - optionsToSync: [ SYNC_OPTIONS.paths ], - include_path_list: [ 'cjI6,ZjI6Lw==' ], - }, - } ) - ); - } ); + expect( mockPullSiteThunk ).toHaveBeenCalledWith( + expect.objectContaining( { + options: { + optionsToSync: [ SYNC_OPTIONS.paths, SYNC_OPTIONS.sqls ], + include_path_list: [ 'cjI6,ZjI6Lw==', 'ZjM6Lw==' ], + }, + } ) + ); } ); it( 'disables the pull button when all checkboxes are unchecked, which is the initial state', async () => { From 7e4bfe589efd27b3c761e7d32c0ed3c5237410d8 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 11:49:14 +0100 Subject: [PATCH 50/58] Restore even more of the tests --- .../src/modules/sync/tests/index.test.tsx | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/apps/studio/src/modules/sync/tests/index.test.tsx b/apps/studio/src/modules/sync/tests/index.test.tsx index f31103c616..e9e24023bd 100644 --- a/apps/studio/src/modules/sync/tests/index.test.tsx +++ b/apps/studio/src/modules/sync/tests/index.test.tsx @@ -1,5 +1,5 @@ // To run tests, execute `npm run test -- src/modules/sync/tests/index.test.tsx` from the root directory -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import { SYNC_OPTIONS } from 'src/constants'; @@ -462,15 +462,13 @@ describe( 'ContentTabSync', () => { const dialogPullButton = await screen.findByTestId( 'sync-dialog-pull-button' ); fireEvent.click( dialogPullButton ); - await waitFor( () => { - expect( mockPullSiteThunk ).toHaveBeenCalledWith( - expect.objectContaining( { - options: { - optionsToSync: [ SYNC_OPTIONS.all ], - }, - } ) - ); - } ); + expect( mockPullSiteThunk ).toHaveBeenCalledWith( + expect.objectContaining( { + options: { + optionsToSync: [ SYNC_OPTIONS.all ], + }, + } ) + ); } ); it( 'calls pullSite with correct optionsToSync when only database is selected', async () => { @@ -491,15 +489,13 @@ describe( 'ContentTabSync', () => { const dialogPullButton = await screen.findByTestId( 'sync-dialog-pull-button' ); fireEvent.click( dialogPullButton ); - await waitFor( () => { - expect( mockPullSiteThunk ).toHaveBeenCalledWith( - expect.objectContaining( { - options: { - optionsToSync: [ SYNC_OPTIONS.sqls ], - }, - } ) - ); - } ); + expect( mockPullSiteThunk ).toHaveBeenCalledWith( + expect.objectContaining( { + options: { + optionsToSync: [ SYNC_OPTIONS.sqls ], + }, + } ) + ); } ); it( 'calls pullSite with correct optionsToSync when options partially are selected', async () => { From ccb562aab4f86f26dee8cab8c2366e6c85c50196 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 11:50:39 +0100 Subject: [PATCH 51/58] Fix diff --- apps/studio/src/hooks/use-import-export.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/studio/src/hooks/use-import-export.tsx b/apps/studio/src/hooks/use-import-export.tsx index fbe268e398..af7c321726 100644 --- a/apps/studio/src/hooks/use-import-export.tsx +++ b/apps/studio/src/hooks/use-import-export.tsx @@ -191,8 +191,8 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode let statusMessage: string = __( 'Extracting backup files…' ); if ( - progressData?.processedFiles != null && - progressData?.totalFiles != null && + progressData.processedFiles != null && + progressData.totalFiles != null && progressData.totalFiles > 0 ) { const percentage = Math.round( @@ -239,8 +239,8 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode let statusMessage: string = __( 'Importing database…' ); if ( - progressData?.processedFiles != null && - progressData?.totalFiles != null && + progressData.processedFiles != null && + progressData.totalFiles != null && progressData.totalFiles > 0 ) { const percentage = Math.round( @@ -249,7 +249,7 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode statusMessage = sprintf( __( 'Importing database… (%d%%)' ), percentage ); } - const progressIncrement = progressData?.totalFiles + const progressIncrement = progressData.totalFiles ? ( ( progressData.processedFiles || 0 ) / progressData.totalFiles ) * 20 : 0; From e440f5cde7c6d750b9e1a7d6667baa0f7ea9c216 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 11:55:05 +0100 Subject: [PATCH 52/58] Minor tweaks --- apps/studio/src/hooks/tests/use-add-site.test.tsx | 7 +++---- apps/studio/src/modules/sync/lib/ipc-handlers.ts | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/studio/src/hooks/tests/use-add-site.test.tsx b/apps/studio/src/hooks/tests/use-add-site.test.tsx index 1b1d73861a..5ff2263e74 100644 --- a/apps/studio/src/hooks/tests/use-add-site.test.tsx +++ b/apps/studio/src/hooks/tests/use-add-site.test.tsx @@ -16,15 +16,14 @@ vi.mock( 'src/hooks/use-feature-flags' ); vi.mock( 'src/hooks/use-auth' ); vi.mock( 'src/hooks/use-content-tabs' ); -const { mockPullSiteThunk } = vi.hoisted( () => ( { - mockPullSiteThunk: vi.fn().mockReturnValue( { type: 'test/pullSite' } ), -} ) ); +const mockPullSiteThunk = vi.hoisted( () => vi.fn() ); + vi.mock( 'src/stores/sync', async () => { const actual = await vi.importActual( 'src/stores/sync' ); return { ...actual, syncOperationsThunks: { - ...( actual as Record< string, unknown > ).syncOperationsThunks, + ...actual.syncOperationsThunks, pullSite: mockPullSiteThunk, }, }; diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts index aca90a4dcb..e993647362 100644 --- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts @@ -162,7 +162,7 @@ export async function exportSiteForPush( try { if ( abortController.signal.aborted ) { - throw new Error( 'PUSH_CANCELLED' ); + throw new Error( 'Export aborted' ); } await keepSqliteIntegrationUpdated( site.details.path ); @@ -199,7 +199,7 @@ export async function exportSiteForPush( await fsPromises.unlink( archivePath ).catch( () => { // Ignore cleanup errors } ); - throw new Error( 'PUSH_CANCELLED' ); + throw new Error( 'Export aborted' ); } const stats = fs.statSync( archivePath ); @@ -338,7 +338,7 @@ export async function pushArchive( abortController.signal.addEventListener( 'abort', () => { void upload.abort(); - reject( new Error( 'PUSH_CANCELLED' ) ); + reject( new Error( 'Export aborted' ) ); } ); const existingUploadState = SYNC_TUS_UPLOADS.get( uploadKey ); From bb73d26a0b7c6988101efd7f38fca2323a6175a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 25 Feb 2026 11:50:47 +0000 Subject: [PATCH 53/58] remove unused export --- apps/studio/src/stores/sync/sync-operations-slice.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index 249f10abd5..25bc312bcd 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -886,9 +886,7 @@ const pollPullBackupThunk = createTypedAsyncThunk( * Maps an ImportResponse status to a PushStateProgressInfo object. * Returns null if the operation is not in progress or unknown. */ -export function mapImportResponseToPushState( - response: ImportResponse -): PushStateProgressInfo | null { +function mapImportResponseToPushState( response: ImportResponse ): PushStateProgressInfo | null { const pushStatesProgressInfo = getPushStatesProgressInfo(); switch ( response.status ) { case 'initial_backup_started': From a2c25d2bdf10a38bcda0ef86660a79ef0df11f21 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 25 Feb 2026 13:22:28 +0100 Subject: [PATCH 54/58] Fix tests --- apps/studio/src/hooks/tests/use-add-site.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/studio/src/hooks/tests/use-add-site.test.tsx b/apps/studio/src/hooks/tests/use-add-site.test.tsx index 5ff2263e74..d83ea3df51 100644 --- a/apps/studio/src/hooks/tests/use-add-site.test.tsx +++ b/apps/studio/src/hooks/tests/use-add-site.test.tsx @@ -73,6 +73,9 @@ describe( 'useAddSite', () => { beforeEach( () => { vi.clearAllMocks(); + mockPullSiteThunk.mockImplementation( () => ( { + type: 'syncOperations/pullSite', + } ) ); // Prepopulate store with provider constants store.dispatch( From e041e31c08071d6b8978f46117e94177614b58a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 25 Feb 2026 14:57:05 +0000 Subject: [PATCH 55/58] Update listeners to react to state updates rather than action types, and to cancel other active listeners --- apps/studio/src/stores/index.ts | 36 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 7611e94bdc..cce0ecf949 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -183,7 +183,9 @@ const SYNC_POLLING_INTERVAL = 3000; // Poll push progress when state enters a pollable status startAppListening( { - actionCreator: syncOperationsActions.updatePushState, + predicate( action, currentState, previousState ) { + return currentState.syncOperations.pushStates !== previousState.syncOperations.pushStates; + }, async effect( action, listenerApi ) { const client = getWpcomClient(); if ( ! client ) { @@ -194,26 +196,26 @@ startAppListening( { await listenerApi.delay( SYNC_POLLING_INTERVAL ); const state = listenerApi.getState(); - const promises = Object.values( state.syncOperations.pushStates ) - .filter( ( pushState ) => PUSH_POLLING_KEYS.includes( pushState.status.key ) ) - .map( ( pushState ) => - listenerApi.dispatch( + for ( const pushState of Object.values( state.syncOperations.pushStates ) ) { + if ( PUSH_POLLING_KEYS.includes( pushState.status.key ) ) { + void listenerApi.dispatch( syncOperationsThunks.pollPushProgress( { client, signal: listenerApi.signal, selectedSiteId: pushState.selectedSite.id, remoteSiteId: pushState.remoteSiteId, } ) - ) - ); - - await Promise.all( promises ); + ); + } + } }, } ); // Poll pull backup when state has a backupId and is in-progress startAppListening( { - actionCreator: syncOperationsActions.updatePullState, + predicate( action, currentState, previousState ) { + return currentState.syncOperations.pullStates !== previousState.syncOperations.pullStates; + }, async effect( action, listenerApi ) { const client = getWpcomClient(); if ( ! client ) { @@ -224,20 +226,18 @@ startAppListening( { await listenerApi.delay( SYNC_POLLING_INTERVAL ); const state = listenerApi.getState(); - const promises = Object.values( state.syncOperations.pullStates ) - .filter( ( pullState ) => pullState.status.key === 'in-progress' ) - .map( ( pullState ) => - listenerApi.dispatch( + for ( const pullState of Object.values( state.syncOperations.pullStates ) ) { + if ( pullState.status.key === 'in-progress' ) { + void listenerApi.dispatch( syncOperationsThunks.pollPullBackup( { client, signal: listenerApi.signal, selectedSiteId: pullState.selectedSite.id, remoteSiteId: pullState.remoteSiteId, } ) - ) - ); - - await Promise.all( promises ); + ); + } + } }, } ); From 1cd26590d0516bd4230770d2787f9e56dde1ee47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20Cs=C3=A9csey?= Date: Wed, 25 Feb 2026 16:21:45 +0000 Subject: [PATCH 56/58] Revert "Update listeners to react to state updates rather than action types, and to cancel other active listeners" This reverts commit e041e31c08071d6b8978f46117e94177614b58a3. --- apps/studio/src/stores/index.ts | 36 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index cce0ecf949..7611e94bdc 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -183,9 +183,7 @@ const SYNC_POLLING_INTERVAL = 3000; // Poll push progress when state enters a pollable status startAppListening( { - predicate( action, currentState, previousState ) { - return currentState.syncOperations.pushStates !== previousState.syncOperations.pushStates; - }, + actionCreator: syncOperationsActions.updatePushState, async effect( action, listenerApi ) { const client = getWpcomClient(); if ( ! client ) { @@ -196,26 +194,26 @@ startAppListening( { await listenerApi.delay( SYNC_POLLING_INTERVAL ); const state = listenerApi.getState(); - for ( const pushState of Object.values( state.syncOperations.pushStates ) ) { - if ( PUSH_POLLING_KEYS.includes( pushState.status.key ) ) { - void listenerApi.dispatch( + const promises = Object.values( state.syncOperations.pushStates ) + .filter( ( pushState ) => PUSH_POLLING_KEYS.includes( pushState.status.key ) ) + .map( ( pushState ) => + listenerApi.dispatch( syncOperationsThunks.pollPushProgress( { client, signal: listenerApi.signal, selectedSiteId: pushState.selectedSite.id, remoteSiteId: pushState.remoteSiteId, } ) - ); - } - } + ) + ); + + await Promise.all( promises ); }, } ); // Poll pull backup when state has a backupId and is in-progress startAppListening( { - predicate( action, currentState, previousState ) { - return currentState.syncOperations.pullStates !== previousState.syncOperations.pullStates; - }, + actionCreator: syncOperationsActions.updatePullState, async effect( action, listenerApi ) { const client = getWpcomClient(); if ( ! client ) { @@ -226,18 +224,20 @@ startAppListening( { await listenerApi.delay( SYNC_POLLING_INTERVAL ); const state = listenerApi.getState(); - for ( const pullState of Object.values( state.syncOperations.pullStates ) ) { - if ( pullState.status.key === 'in-progress' ) { - void listenerApi.dispatch( + const promises = Object.values( state.syncOperations.pullStates ) + .filter( ( pullState ) => pullState.status.key === 'in-progress' ) + .map( ( pullState ) => + listenerApi.dispatch( syncOperationsThunks.pollPullBackup( { client, signal: listenerApi.signal, selectedSiteId: pullState.selectedSite.id, remoteSiteId: pullState.remoteSiteId, } ) - ); - } - } + ) + ); + + await Promise.all( promises ); }, } ); From 02b204e72c0f99db07712eb950a0ace17ff141a1 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 27 Feb 2026 10:54:03 +0100 Subject: [PATCH 57/58] Simplify polling setup --- apps/studio/src/stores/chat-slice.ts | 4 +- apps/studio/src/stores/index.ts | 173 ++++++++++++++++++++------- 2 files changed, 129 insertions(+), 48 deletions(-) diff --git a/apps/studio/src/stores/chat-slice.ts b/apps/studio/src/stores/chat-slice.ts index 99d5cd3497..bc15bb037b 100644 --- a/apps/studio/src/stores/chat-slice.ts +++ b/apps/studio/src/stores/chat-slice.ts @@ -233,7 +233,7 @@ const sendFeedback = createTypedAsyncThunk( const EMPTY_MESSAGES: readonly Message[] = Object.freeze( [] ); -interface ChatState { +type ChatState = { currentURL: string; pluginListDict: Record< string, string[] >; themeListDict: Record< string, string[] >; @@ -250,7 +250,7 @@ interface ChatState { chatInputBySite: { [ key: string ]: string }; isLoadingDict: Record< string, boolean >; isLoadingUpdateFromSiteDict: Record< string, boolean >; -} +}; const getInitialState = (): ChatState => { const storedMessages = localStorage.getItem( LOCAL_STORAGE_CHAT_MESSAGES_KEY ); diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 7611e94bdc..1c23ef38ae 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -28,7 +28,11 @@ import { } from 'src/stores/snapshot-slice'; import { syncReducer, syncOperationsActions } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; -import { syncOperationsReducer, syncOperationsThunks } from 'src/stores/sync/sync-operations-slice'; +import { + syncOperationsReducer, + syncOperationsSelectors, + syncOperationsThunks, +} from 'src/stores/sync/sync-operations-slice'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import uiReducer from 'src/stores/ui-slice'; import { getWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; @@ -132,6 +136,7 @@ startAppListening( { effect( action ) { const { selectedSiteId, remoteSiteId } = action.payload; const stateId = generateStateId( selectedSiteId, remoteSiteId ); + stopPushPoller( stateId ); getIpcApi().clearSyncOperation( stateId ); }, } ); @@ -140,6 +145,7 @@ startAppListening( { effect( action ) { const { selectedSiteId, remoteSiteId } = action.payload; const stateId = generateStateId( selectedSiteId, remoteSiteId ); + stopPullPoller( stateId ); getIpcApi().clearSyncOperation( stateId ); }, } ); @@ -181,63 +187,138 @@ startAppListening( { const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; const SYNC_POLLING_INTERVAL = 3000; -// Poll push progress when state enters a pollable status -startAppListening( { - actionCreator: syncOperationsActions.updatePushState, - async effect( action, listenerApi ) { - const client = getWpcomClient(); - if ( ! client ) { - return; +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, + 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; +} + +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 ) ) { + 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 ); } + } +} - listenerApi.cancelActiveListeners(); - await listenerApi.delay( SYNC_POLLING_INTERVAL ); - const state = listenerApi.getState(); +async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) { + const stateId = generateStateId( selectedSiteId, remoteSiteId ); + if ( PULL_POLLERS.has( stateId ) ) { + return; + } - const promises = Object.values( state.syncOperations.pushStates ) - .filter( ( pushState ) => PUSH_POLLING_KEYS.includes( pushState.status.key ) ) - .map( ( pushState ) => - listenerApi.dispatch( - syncOperationsThunks.pollPushProgress( { - client, - signal: listenerApi.signal, - selectedSiteId: pushState.selectedSite.id, - remoteSiteId: pushState.remoteSiteId, - } ) - ) + 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, + } ) ); - await Promise.all( promises ); + 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, - async effect( action, listenerApi ) { - const client = getWpcomClient(); - if ( ! client ) { - return; - } - - listenerApi.cancelActiveListeners(); - await listenerApi.delay( SYNC_POLLING_INTERVAL ); - const state = listenerApi.getState(); - - const promises = Object.values( state.syncOperations.pullStates ) - .filter( ( pullState ) => pullState.status.key === 'in-progress' ) - .map( ( pullState ) => - listenerApi.dispatch( - syncOperationsThunks.pollPullBackup( { - client, - signal: listenerApi.signal, - selectedSiteId: pullState.selectedSite.id, - remoteSiteId: pullState.remoteSiteId, - } ) - ) - ); + effect( action ) { + const { selectedSiteId, remoteSiteId } = action.payload; + const stateId = generateStateId( selectedSiteId, remoteSiteId ); - await Promise.all( promises ); + if ( isPullPollable( selectedSiteId, remoteSiteId ) ) { + void startPullPoller( selectedSiteId, remoteSiteId ); + } else { + stopPullPoller( stateId ); + } }, } ); From 15cba356dc49d4ec458ee30e2152542070378569 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 27 Feb 2026 11:33:20 +0100 Subject: [PATCH 58/58] Fix bug If I start a push and cancel it before it starts uploading, then quickly start a new push, the second operation will fail. That's because it generates an identical archive path for every push, and the abortion of the first push call is async, meaning the file isn't deleted when the operation is aborted, but only after the first export finishes. --- apps/studio/src/ipc-handlers.ts | 1 - .../src/modules/sync/lib/ipc-handlers.ts | 29 ++++--------------- apps/studio/src/preload.ts | 1 - 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index ea040162dc..0b07c0a255 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -101,7 +101,6 @@ export { getConnectedWpcomSites, pauseSyncUpload, pushArchive, - removeExportedSiteTmpFile, removeSyncBackup, resumeSyncUpload, updateConnectedWpcomSites, diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts index e993647362..277aaa935d 100644 --- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts @@ -1,9 +1,8 @@ import { app, IpcMainInvokeEvent } from 'electron'; import fs from 'fs'; import fsPromises from 'fs/promises'; +import { randomUUID } from 'node:crypto'; import path from 'node:path'; -import * as Sentry from '@sentry/electron/main'; -import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import wpcomFactory from '@studio/common/lib/wpcom-factory'; import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; import { Upload } from 'tus-js-client'; @@ -25,12 +24,6 @@ import { SiteServer } from 'src/site-server'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; import { SyncOption } from 'src/types'; -const TEMP_DIR = path.join( app.getPath( 'temp' ), 'com.wordpress.studio' ); - -if ( ! fs.existsSync( TEMP_DIR ) ) { - fs.mkdirSync( TEMP_DIR ); -} - /** * Registry to store AbortControllers for ongoing sync operations (push/pull). * Key format: `${selectedSiteId}-${remoteSiteId}` @@ -154,8 +147,10 @@ export async function exportSiteForPush( if ( ! site ) { throw new Error( 'Site not found.' ); } - const extension = 'tar.gz'; - const archivePath = path.join( TEMP_DIR, `site_${ id }.${ extension }` ); + + const tempDir = path.join( app.getPath( 'temp' ), 'com.wordpress.studio', randomUUID() ); + fs.mkdirSync( tempDir, { recursive: true } ); + const archivePath = path.join( tempDir, `site_${ id }.tar.gz` ); const abortController = new AbortController(); SYNC_ABORT_CONTROLLERS.set( operationId, abortController ); @@ -209,20 +204,6 @@ export async function exportSiteForPush( } } -export function removeExportedSiteTmpFile( event: IpcMainInvokeEvent, path: string ) { - if ( ! path.includes( TEMP_DIR ) ) { - throw new Error( 'The given path is not a temporary file' ); - } - try { - fs.unlinkSync( path ); - } catch ( error ) { - if ( isErrnoException( error ) && error.code === 'ENOENT' ) { - // Silently ignore if the temporary file doesn't exist - Sentry.captureException( error ); - } - } -} - export async function pushArchive( event: IpcMainInvokeEvent, selectedSiteId: string, diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index 9e30c143bb..e80fd5c82c 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -82,7 +82,6 @@ const api: IpcApi = { stopAllServers: () => ipcRendererInvoke( 'stopAllServers' ), copyText: ( text ) => ipcRendererInvoke( 'copyText', text ), getAppGlobals: () => ipcRendererInvoke( 'getAppGlobals' ), - removeExportedSiteTmpFile: ( path ) => ipcRendererInvoke( 'removeExportedSiteTmpFile', path ), getWpVersion: ( id ) => ipcRendererInvoke( 'getWpVersion', id ), generateProposedSitePath: ( siteName ) => ipcRendererInvoke( 'generateProposedSitePath', siteName ),