Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
1ea5e8f
Saving uncommitted changes before archiving
Apr 17, 2026
e5f06f8
Merge branch 'trunk' into gcsecsey/stu-166-clear-sync-on-logout
gcsecsey Apr 22, 2026
9490492
Merge branch 'gcsecsey/stu-166-clear-sync-on-logout' of github.com:Au…
gcsecsey May 6, 2026
8a04a4b
Abort sync operations directly from userLoggedOut listener
gcsecsey May 6, 2026
2b4a33e
Skip import/export reset on logout if a pull is in flight
gcsecsey May 6, 2026
d0ffd83
Merge branch 'trunk' into gcsecsey/stu-166-clear-sync-on-logout
gcsecsey May 6, 2026
3149b6b
Merge branch 'trunk' of github.com:Automattic/studio into gcsecsey/st…
gcsecsey Jun 1, 2026
337d9e3
Remove protocol-handler leftovers accidentally swept in by Conductor …
gcsecsey Jun 1, 2026
f57a08e
Move local-sync-work selectors into syncOperationsSelectors
gcsecsey Jun 1, 2026
dceb859
Merge branch 'trunk' of github.com:Automattic/studio into gcsecsey/st…
gcsecsey Jun 2, 2026
912d789
Handle logout during a sync pull cleanly (no error dialog, no orphane…
gcsecsey Jun 2, 2026
e852ab8
Move logic into Redux slice selectors
fredrikekelund Jun 3, 2026
76977ec
Corrections
fredrikekelund Jun 3, 2026
831efb6
Correction
fredrikekelund Jun 3, 2026
a73a48e
Merge branch 'trunk' into gcsecsey/stu-166-clear-sync-on-logout
fredrikekelund Jun 3, 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
12 changes: 7 additions & 5 deletions apps/studio/src/components/content-tab-import-export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -344,17 +344,19 @@ export function ContentTabImportExport( { selectedSite }: ContentTabImportExport
localSiteId: selectedSite.id,
userId: user?.id,
} );
const isPulling = useRootSelector( ( state ) =>
const isPullingLocally = useRootSelector( ( state ) =>
connectedSites.some( ( site ) =>
syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state )
syncOperationsSelectors.selectIsSiteIdPullingLocally( selectedSite.id, site.id )( state )
)
);
const isPushing = useRootSelector( ( 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 ) =>
syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( state )
syncOperationsSelectors.selectIsSiteIdPushingLocally( selectedSite.id, site.id )( state )
)
);
const isThisSiteSyncing = isPulling || isPushing;
const isThisSiteSyncing = isPullingLocally || isUploadingPushBackup;

useEffect( () => {
getIpcApi()
Expand Down
10 changes: 5 additions & 5 deletions apps/studio/src/components/publish-site-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ export const PublishSiteButton = () => {
localSiteId: selectedSite?.id,
userId: user?.id,
} );
const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling );
const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing );
const isAnySiteSyncing = isAnySitePulling || isAnySitePushing;
const isAnySiteDoingLocalSyncWork = useRootSelector(
syncOperationsSelectors.selectIsAnySiteDoingLocalSyncWork
);

const handlePublishClick = useCallback( () => {
if ( ! selectedSite ) return;
Expand All @@ -36,9 +36,9 @@ export const PublishSiteButton = () => {
variant="primary"
icon={ cloudUpload }
connectSite={ handlePublishClick }
disabled={ isAnySiteSyncing }
disabled={ isAnySiteDoingLocalSyncWork }
tooltipText={
isAnySiteSyncing
isAnySiteDoingLocalSyncWork
? __(
'Another site is syncing. Please wait for the sync to finish before you publish your site.'
)
Expand Down
10 changes: 3 additions & 7 deletions apps/studio/src/components/settings-site-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,14 @@ export const SettingsMenuItem = ( {
isDestructive = false,
}: SettingsMenuItemProps ) => {
const { isDeleting, sites, selectedSite } = useSiteDetails();
const isPulling = useRootSelector(
syncOperationsSelectors.selectIsSiteIdPulling( selectedSite?.id ?? '' )
);
const isPushing = useRootSelector(
syncOperationsSelectors.selectIsSiteIdPushing( selectedSite?.id ?? '' )
const isThisSiteDoingLocalSyncWork = useRootSelector(
syncOperationsSelectors.selectIsSiteDoingLocalSyncWork( selectedSite?.id )
);
if ( ! selectedSite ) {
return null;
}
const isThisSiteSyncing = isPulling || isPushing;
const isAddingSite = sites.some( ( site ) => site.isAddingSite );
const isDisabled = isDeleting || isThisSiteSyncing || isAddingSite;
const isDisabled = isDeleting || isThisSiteDoingLocalSyncWork || isAddingSite;

return (
<MenuItem
Expand Down
8 changes: 4 additions & 4 deletions apps/studio/src/components/site-management-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,19 @@ export const SiteManagementActions = ( {
}: SiteManagementActionProps ) => {
const { __ } = useI18n();
const { isSiteImporting } = useImportExport();
const isPulling = useRootSelector(
syncOperationsSelectors.selectIsSiteIdPulling( selectedSite?.id ?? '' )
const isPullingLocally = useRootSelector( ( state ) =>
syncOperationsSelectors.selectIsSiteIdPullingLocally( selectedSite?.id )( state )
);

if ( ! selectedSite ) {
return null;
}

const isImporting = isSiteImporting( selectedSite.id );
const disabled = isImporting || isPulling;
const disabled = isImporting || isPullingLocally;

let buttonLabelOnDisabled: string = __( 'Importing…' );
if ( isPulling ) {
if ( isPullingLocally ) {
buttonLabelOnDisabled = __( 'Pulling…' );
}

Expand Down
34 changes: 33 additions & 1 deletion apps/studio/src/hooks/use-import-export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@ import {
ValidatorEvents,
} from '@studio/common/lib/import-export-events';
import { __, sprintf } from '@wordpress/i18n';
import { createContext, useMemo, useState, useCallback, useContext } from 'react';
import {
createContext,
useEffect,
useMemo,
useRef,
useState,
useCallback,
useContext,
} from 'react';
import { useAuth } from 'src/hooks/use-auth';
import { useIpcListener } from 'src/hooks/use-ipc-listener';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { useRootSelector } from 'src/stores';
import { syncOperationsSelectors } from 'src/stores/sync/sync-operations-slice';

export type ImportProgressState = {
[ siteId: string ]: {
Expand Down Expand Up @@ -65,6 +76,27 @@ const WP_CONTENT_TYPE_LABELS: Record< string, string > = {
export const ImportExportProvider = ( { children }: { children: React.ReactNode } ) => {
const [ importState, setImportState ] = useState< ImportProgressState >( {} );
const [ exportState, setExportState ] = useState< ExportProgressState >( {} );
const { isAuthenticated } = useAuth();
const isAnyPullActive = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling );

// Snapshot the latest pre-logout pull-active value while still authenticated.
// pullStates is reset synchronously on userLoggedOut, so by the time the effect
// below sees `isAuthenticated === false`, isAnyPullActive is already false.
const hadActivePullRef = useRef( false );
if ( isAuthenticated ) {
hadActivePullRef.current = isAnyPullActive;
}

useEffect( () => {
// On logout, only clear import/export state if no pull was in flight.
// An active pull's import phase is driven by main-process events that
// would just repopulate this state, so leave it alone and let the
// import finish.
if ( ! isAuthenticated && ! hadActivePullRef.current ) {
setImportState( {} );
setExportState( {} );
}
}, [ isAuthenticated ] );
Comment thread
gcsecsey marked this conversation as resolved.

const importFile = useCallback(
async (
Expand Down
47 changes: 19 additions & 28 deletions apps/studio/src/modules/sync/components/sync-connected-sites.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,20 @@ const SyncConnectedSiteControls = ( {
const isOffline = useOffline();
const dispatch = useAppDispatch();
const [ syncDialogType, setSyncDialogType ] = useState< 'pull' | 'push' | null >( null );
const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling );
const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing );
const getLastSyncTimeText = useLastSyncTimeText();
const { user, client } = useAuth();
const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( {
localSiteId: selectedSite.id,
userId: user?.id,
} );
const isAnyConnectedSiteSyncing = useRootSelector( ( state ) =>
connectedSites.some(
( site ) =>
syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, site.id )( state ) ||
syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, site.id )( state )
const isAnyConnectedSiteDoingLocalSyncWork = useRootSelector( ( state ) =>
connectedSites.some( ( site ) =>
syncOperationsSelectors.selectIsSiteDoingLocalSyncWork( selectedSite.id, site.id )( state )
)
);
const isAnySiteSyncing = isAnySitePulling || isAnySitePushing;
const isAnySiteDoingLocalSyncWork = useRootSelector(
syncOperationsSelectors.selectIsAnySiteDoingLocalSyncWork
);

return (
<Tooltip
Expand All @@ -84,10 +82,10 @@ const SyncConnectedSiteControls = ( {
placement="top-start"
>
<div className="flex gap-2 h-5">
{ isAnySiteSyncing ? (
{ isAnySiteDoingLocalSyncWork ? (
<Tooltip
text={
isAnyConnectedSiteSyncing
isAnyConnectedSiteDoingLocalSyncWork
? __(
'This Studio site is syncing. Please wait for the sync to finish before you pull it.'
)
Expand All @@ -112,23 +110,22 @@ const SyncConnectedSiteControls = ( {
variant="link"
className={ cx(
! isOffline &&
! isAnySitePulling &&
! isAnySitePushing &&
! isAnySiteDoingLocalSyncWork &&
'!text-frame-text hover:!text-frame-theme'
) }
onClick={ () => setSyncDialogType( 'pull' ) }
disabled={ isAnySiteSyncing || isOffline }
disabled={ isAnySiteDoingLocalSyncWork || isOffline }
data-testid="sync-list-pull-button"
>
<Icon icon={ cloudDownload } />
{ __( 'Pull' ) }
</Button>
</DynamicTooltip>
) }
{ isAnySiteSyncing ? (
{ isAnySiteDoingLocalSyncWork ? (
<Tooltip
text={
isAnyConnectedSiteSyncing
isAnyConnectedSiteDoingLocalSyncWork
? __(
'This Studio site is syncing. Please wait for the sync to finish before you push it.'
)
Expand All @@ -153,12 +150,11 @@ const SyncConnectedSiteControls = ( {
variant="link"
className={ cx(
! isOffline &&
! isAnySitePulling &&
! isAnySitePushing &&
! isAnySiteDoingLocalSyncWork &&
'!text-frame-text hover:!text-frame-theme'
) }
onClick={ () => setSyncDialogType( 'push' ) }
disabled={ isAnySiteSyncing || isOffline }
disabled={ isAnySiteDoingLocalSyncWork || isOffline }
data-testid="sync-list-push-button"
>
<Icon icon={ cloudUpload } />
Expand Down Expand Up @@ -655,11 +651,8 @@ const SyncConnectedSiteSection = ( {
connectedSitesSelectors.selectIsLoadingSiteId( connectedSite.id )
);
const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected';
const isPulling = useRootSelector(
syncOperationsSelectors.selectIsSiteIdPulling( selectedSite.id, connectedSite.id )
);
const isPushing = useRootSelector(
syncOperationsSelectors.selectIsSiteIdPushing( selectedSite.id, connectedSite.id )
const isDoingLocalSyncWork = useRootSelector(
syncOperationsSelectors.selectIsSiteDoingLocalSyncWork( selectedSite.id, connectedSite.id )
);

let logo = <WordPressLogoCircle />;
Expand Down Expand Up @@ -690,18 +683,16 @@ const SyncConnectedSiteSection = ( {
text={ __(
'This site is syncing. Please wait for the sync to finish before you can disconnect it.'
) }
disabled={ ! ( isPulling || isPushing ) || isOffline }
disabled={ ! isDoingLocalSyncWork || isOffline }
placement="top-start"
>
<Button
variant="link"
className={ cx(
! isPulling && ! isPushing
? '!text-frame-text-secondary hover:!text-a8c-red-50'
: ''
! isDoingLocalSyncWork ? '!text-frame-text-secondary hover:!text-a8c-red-50' : ''
) }
onClick={ handleDisconnectSite }
disabled={ isPulling || isPushing }
disabled={ isDoingLocalSyncWork }
>
{ __( 'Disconnect' ) }
</Button>
Expand Down
22 changes: 17 additions & 5 deletions apps/studio/src/modules/sync/lib/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
removeConnectedWpcomSite,
updateConnectedWpcomSites as updateConnectedWpcomSitesShared,
} from '@studio/common/lib/connected-sites';
import { isErrnoException } from '@studio/common/lib/is-errno-exception';
import { getCurrentUserId } from '@studio/common/lib/shared-config';
import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api';
import wpcomFactory from '@studio/common/lib/wpcom-factory';
Expand Down Expand Up @@ -409,11 +410,13 @@ export async function downloadSyncBackup(
await download( downloadUrl, filePath, false, '', abortController.signal );
return filePath;
} catch ( error ) {
if ( error instanceof Error && error.name === 'AbortError' ) {
// Download was cancelled, throw the error
} else {
console.error( `[Download] Download failed for operation: ${ operationId }`, error );
// A cancelled operation (user cancel or logout cleanup) aborts this signal. That's an
// intentional stop, not a failure — return without logging or throwing so it doesn't
// surface as an error, and let the caller treat the missing path as "stopped".
if ( abortController.signal.aborted ) {
return undefined;
}
console.error( `[Download] Download failed for operation: ${ operationId }`, error );
throw error;
} finally {
SYNC_ABORT_CONTROLLERS.delete( operationId );
Expand All @@ -422,7 +425,16 @@ export async function downloadSyncBackup(

export async function removeSyncBackup( event: IpcMainInvokeEvent, remoteSiteId: number ) {
const filePath = getSyncBackupTempPath( remoteSiteId );
await fsPromises.unlink( filePath );
try {
await fsPromises.unlink( filePath );
} catch ( error ) {
// The backup file may never have been created — e.g. cancelling a pull that was still
// initializing the remote backup, before anything was downloaded. A missing file is
// not an error here, so only rethrow unexpected failures.
if ( ! isErrnoException( error ) || error.code !== 'ENOENT' ) {
throw error;
}
}
}

type WpcomSitesToConnect = { sites: SyncSite[]; localSiteId: string }[];
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' );
Loading