Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d47341d
Clear sync-related Redux state when user logs out
gcsecsey Mar 4, 2026
c680af7
Treat failed import during sync pull as a pull error
gcsecsey Mar 13, 2026
e667920
Simplify logout cleanup in ImportExportProvider to use direct isAuthe…
gcsecsey Mar 13, 2026
99884e0
Merge branch 'trunk' of github.com:Automattic/studio into gcsecsey/st…
gcsecsey Mar 13, 2026
7cf6787
Merge branch 'trunk' into gcsecsey/stu-166-clear-sync-on-logout
gcsecsey Mar 13, 2026
f816995
Use cancel thunks for sync cleanup on logout instead of manual cancel…
gcsecsey Mar 16, 2026
0c6e92f
Merge branch 'trunk' into gcsecsey/stu-166-clear-sync-on-logout
gcsecsey Mar 16, 2026
0d93adc
Merge branch 'trunk' into gcsecsey/stu-166-clear-sync-on-logout
gcsecsey Mar 17, 2026
625fc86
Use getOriginalState() in userLoggedOut listener to fix cancel thunks…
gcsecsey Mar 19, 2026
677d49d
Clear wpcomClient before dispatching userLoggedOut to prevent error m…
gcsecsey Mar 19, 2026
20bde45
Merge branch 'trunk' into gcsecsey/stu-166-clear-sync-on-logout
gcsecsey Mar 19, 2026
34bd922
keep connected sites cache on logout
gcsecsey Mar 19, 2026
11a1a05
Merge branch 'gcsecsey/stu-166-clear-sync-on-logout' of github.com:Au…
gcsecsey Mar 19, 2026
58e5ecf
Abort poller signals in cancel thunks to prevent error modals during …
gcsecsey Mar 19, 2026
e09fa02
Move setWpcomClient into userLoggedOut listener
gcsecsey Mar 20, 2026
42fcee6
Merge branch 'trunk' of github.com:Automattic/studio into gcsecsey/st…
gcsecsey Apr 9, 2026
86cb91a
Merge branch 'trunk' of github.com:Automattic/studio into gcsecsey/st…
gcsecsey Apr 9, 2026
ad7bc86
Move sync poller logic from stores/index.ts into sync-operations-slic…
gcsecsey Apr 9, 2026
a833912
Merge branch 'trunk' into gcsecsey/stu-166-clear-sync-on-logout
gcsecsey Apr 10, 2026
d94ad64
Merge branch 'trunk' of github.com:Automattic/studio into gcsecsey/st…
gcsecsey Apr 10, 2026
b4603f5
Merge branch 'gcsecsey/stu-166-clear-sync-on-logout' of github.com:Au…
gcsecsey Apr 10, 2026
8e1a594
Avoid setting cancelled state when logging out
gcsecsey Apr 15, 2026
e32c175
Enable import/export while push is running remotely
gcsecsey Apr 15, 2026
e5f06f8
Merge branch 'trunk' into gcsecsey/stu-166-clear-sync-on-logout
gcsecsey Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions apps/studio/src/components/auth-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { useOffline } from 'src/hooks/use-offline';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error';
import { setSentryWpcomUserIdRenderer } from 'src/lib/renderer-sentry-utils';
import { useI18nLocale } from 'src/stores';
import { useAppDispatch, useI18nLocale } from 'src/stores';
import { userLoggedOut } from 'src/stores/auth-actions';
import { setWpcomClient } from 'src/stores/wpcom-api';
import type { WPCOM } from 'wpcom/types';

Expand Down Expand Up @@ -46,21 +47,22 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => {
const { __ } = useI18n();
const isOffline = useOffline();

const dispatch = useAppDispatch();
const authenticate = useCallback( () => getIpcApi().authenticate(), [] );

const handleInvalidToken = useCallback( async () => {
try {
void getIpcApi().logRendererMessage( 'info', 'Detected invalid token. Logging out.' );
dispatch( userLoggedOut() );
await getIpcApi().clearAuthenticationToken();
setIsAuthenticated( false );
setClient( undefined );
setWpcomClient( undefined );
setUser( undefined );
setSentryWpcomUserIdRenderer( undefined );
} catch ( err ) {
console.error( 'Failed to handle invalid token:', err );
}
}, [] );
}, [ dispatch ] );

useIpcListener( 'auth-updated', ( _event, payload ) => {
if ( 'error' in payload ) {
Expand Down Expand Up @@ -121,16 +123,16 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => {
}

try {
dispatch( userLoggedOut() );
await getIpcApi().clearAuthenticationToken();
setIsAuthenticated( false );
setClient( undefined );
setWpcomClient( undefined );
setUser( undefined );
setSentryWpcomUserIdRenderer( undefined );
} catch ( err ) {
console.error( err );
}
}, [ client, isOffline ] );
}, [ client, dispatch, isOffline ] );

useEffect( () => {
async function run() {
Expand Down
17 changes: 12 additions & 5 deletions apps/studio/src/components/content-tab-import-export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog';
import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file';
import { useImportExport } from 'src/hooks/use-import-export';
import { useSiteDetails } from 'src/hooks/use-site-details';
import { pushBackupIsUploading } from 'src/lib/active-sync-operations';
import { cx } from 'src/lib/cx';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { useRootSelector } from 'src/stores';
Expand Down Expand Up @@ -356,12 +357,18 @@ export function ContentTabImportExport( { selectedSite }: ContentTabImportExport
syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state )
)
);
const isPushing = useRootSelector( ( state ) =>
connectedSites.some( ( site ) =>
syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( state )
)
// Only block import/export while the local machine is actively involved in sync.
// After the backup upload completes, the push continues remotely and should not block import/export.
const isUploadingPushBackup = useRootSelector( ( state ) =>
connectedSites.some( ( site ) => {
const pushState = syncOperationsSelectors.selectPushState(
selectedSite.id,
site.id
)( state );
return pushBackupIsUploading( pushState?.status.key );
} )
);
const isThisSiteSyncing = isPulling || isPushing;
const isThisSiteSyncing = isPulling || isUploadingPushBackup;

useEffect( () => {
getIpcApi()
Expand Down
13 changes: 12 additions & 1 deletion apps/studio/src/hooks/use-import-export.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as Sentry from '@sentry/electron/renderer';
import { __, sprintf } from '@wordpress/i18n';
import { createContext, useMemo, useState, useCallback, useContext } from 'react';
import { createContext, useEffect, useMemo, useState, useCallback, useContext } from 'react';
import { WP_CLI_IMPORT_EXPORT_RESPONSE_TIMEOUT_IN_HRS } from 'src/constants';
import { useAuth } from 'src/hooks/use-auth';
import { useIpcListener } from 'src/hooks/use-ipc-listener';
import { useSiteDetails } from 'src/hooks/use-site-details';
import { simplifyErrorForDisplay } from 'src/lib/error-formatting';
Expand All @@ -26,6 +27,7 @@ export type ImportProgressState = {
statusMessage: string;
progress: number;
isNewSite?: boolean;
isError?: boolean;
};
};

Expand Down Expand Up @@ -77,6 +79,14 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode
const [ importState, setImportState ] = useState< ImportProgressState >( {} );
const [ exportState, setExportState ] = useState< ExportProgressState >( {} );
const { startServer, stopServer, updateSite } = useSiteDetails();
const { isAuthenticated } = useAuth();

useEffect( () => {
if ( ! isAuthenticated ) {
setImportState( {} );
setExportState( {} );
}
}, [ isAuthenticated ] );

const importFile = useCallback(
async (
Expand Down Expand Up @@ -340,6 +350,7 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode
[ siteId ]: {
...currentProgress,
statusMessage: __( 'Import failed. Please try again.' ),
isError: true,
},
} ) );
break;
Expand Down
12 changes: 8 additions & 4 deletions apps/studio/src/modules/sync/components/sync-connected-sites.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,15 @@ const SyncConnectedSitesSectionItem = ( {
const sitePullState = useRootSelector(
syncOperationsSelectors.selectPullState( selectedSite.id, connectedSite.id )
);
const pullImportFailed =
sitePullState?.status.key === 'importing' &&
importState[ connectedSite.localSiteId ]?.isError === true;
const isPulling =
sitePullState?.status.key === 'in-progress' ||
sitePullState?.status.key === 'downloading' ||
sitePullState?.status.key === 'importing';
const isPullError = sitePullState?.status.key === 'failed';
( sitePullState?.status.key === 'in-progress' ||
sitePullState?.status.key === 'downloading' ||
sitePullState?.status.key === 'importing' ) &&
! pullImportFailed;
const isPullError = sitePullState?.status.key === 'failed' || pullImportFailed;
const hasPullFinished = sitePullState?.status.key === 'finished';
const hasPullCancelled = sitePullState?.status.key === 'cancelled';
const pullImportState = importState[ connectedSite.localSiteId ];
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/stores/auth-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createAction } from '@reduxjs/toolkit';

export const userLoggedOut = createAction( 'auth/userLoggedOut' );
153 changes: 26 additions & 127 deletions apps/studio/src/stores/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from 'src/hooks/use-sync-states-progress-info';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { appVersionApi } from 'src/stores/app-version-api';
import { userLoggedOut } from 'src/stores/auth-actions';
import { betaFeaturesReducer, loadBetaFeatures } from 'src/stores/beta-features-slice';
import { certificateTrustApi } from 'src/stores/certificate-trust-api';
import { reducer as chatReducer } from 'src/stores/chat-slice';
Expand All @@ -23,13 +24,14 @@ import {
import { syncReducer, syncOperationsActions } from 'src/stores/sync';
import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites';
import {
stopPullPoller,
stopPushPoller,
syncOperationsReducer,
syncOperationsSelectors,
syncOperationsThunks,
} from 'src/stores/sync/sync-operations-slice';
import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites';
import uiReducer from 'src/stores/ui-slice';
import { getWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api';
import { setWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api';
import { wordpressVersionsApi } from './wordpress-versions-api';
import type { SupportedLocale } from '@studio/common/lib/locale';

Expand Down Expand Up @@ -183,141 +185,38 @@ startAppListening( {
},
} );

const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ];
const SYNC_POLLING_INTERVAL = 3000;

const PUSH_POLLERS = new Map< string, AbortController >();
const PULL_POLLERS = new Map< string, AbortController >();

function isPushPollable( selectedSiteId: string, remoteSiteId: number ) {
const pushState = syncOperationsSelectors.selectPushState(
selectedSiteId,
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 );
// Clear all sync state when user logs out
startAppListening( {
actionCreator: userLoggedOut,
effect( action, listenerApi ) {
setWpcomClient( undefined );

try {
while ( ! controller.signal.aborted ) {
const client = getWpcomClient();
if ( ! client ) {
break;
}
// Use getOriginalState() to read state BEFORE the reducer cleared pullStates/pushStates
const state = listenerApi.getOriginalState();

await store.dispatch(
syncOperationsThunks.pollPushProgress( {
client,
signal: controller.signal,
selectedSiteId,
remoteSiteId,
for ( const pullState of Object.values( state.syncOperations.pullStates ) ) {
void store.dispatch(
syncOperationsThunks.cancelPull( {
selectedSiteId: pullState.selectedSite.id,
remoteSiteId: pullState.remoteSiteId,
displayNotification: false,
} )
);

if ( controller.signal.aborted || ! isPushPollable( selectedSiteId, remoteSiteId ) ) {
break;
}

await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) );
}
} finally {
if ( PUSH_POLLERS.get( stateId ) === controller ) {
PUSH_POLLERS.delete( stateId );
}
}
}

async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) {
const stateId = generateStateId( selectedSiteId, remoteSiteId );
if ( PULL_POLLERS.has( stateId ) ) {
return;
}

const controller = new AbortController();
PULL_POLLERS.set( stateId, controller );

try {
while ( ! controller.signal.aborted ) {
const client = getWpcomClient();
if ( ! client ) {
break;
}

await store.dispatch(
syncOperationsThunks.pollPullBackup( {
client,
signal: controller.signal,
selectedSiteId,
remoteSiteId,
for ( const pushState of Object.values( state.syncOperations.pushStates ) ) {
void store.dispatch(
syncOperationsThunks.cancelPush( {
selectedSiteId: pushState.selectedSite.id,
remoteSiteId: pushState.remoteSiteId,
displayNotification: false,
} )
);

if ( controller.signal.aborted || ! isPullPollable( selectedSiteId, remoteSiteId ) ) {
break;
}

await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLLING_INTERVAL ) );
}
} finally {
if ( PULL_POLLERS.get( stateId ) === controller ) {
PULL_POLLERS.delete( stateId );
}
}
}

// Poll push progress when state enters a pollable status
startAppListening( {
actionCreator: syncOperationsActions.updatePushState,
effect( action ) {
const { selectedSiteId, remoteSiteId } = action.payload;
const stateId = generateStateId( selectedSiteId, remoteSiteId );

if ( isPushPollable( selectedSiteId, remoteSiteId ) ) {
void startPushPoller( selectedSiteId, remoteSiteId );
} else {
stopPushPoller( stateId );
}
},
} );

// Poll pull backup when state has a backupId and is in-progress
startAppListening( {
actionCreator: syncOperationsActions.updatePullState,
effect( action ) {
const { selectedSiteId, remoteSiteId } = action.payload;
const stateId = generateStateId( selectedSiteId, remoteSiteId );

if ( isPullPollable( selectedSiteId, remoteSiteId ) ) {
void startPullPoller( selectedSiteId, remoteSiteId );
} else {
stopPullPoller( stateId );
}
// Reset authenticated RTK Query caches
listenerApi.dispatch( wpcomSitesApi.util.resetApiState() );
listenerApi.dispatch( wpcomApi.util.resetApiState() );
},
} );

Expand Down
4 changes: 4 additions & 0 deletions apps/studio/src/stores/sync/connected-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { RootState } from 'src/stores';
import { userLoggedOut } from 'src/stores/auth-actions';
import type { SyncSite } from '@studio/common/types/sync';
import type { SyncModalMode } from 'src/modules/sync/types';

Expand Down Expand Up @@ -61,6 +62,9 @@ const connectedSitesSlice = createSlice( {
delete state.loadingSiteIds[ action.payload ];
},
},
extraReducers: ( builder ) => {
builder.addCase( userLoggedOut, () => getInitialState() );
},
} );

export const connectedSitesActions = connectedSitesSlice.actions;
Expand Down
Loading
Loading