diff --git a/client/dashboard/sites/settings-wordpress/beta-program-notice.tsx b/client/dashboard/sites/settings-wordpress/beta-program-notice.tsx
new file mode 100644
index 000000000000..ac22ce033f8a
--- /dev/null
+++ b/client/dashboard/sites/settings-wordpress/beta-program-notice.tsx
@@ -0,0 +1,47 @@
+import { Button } from '@wordpress/components';
+import { createInterpolateElement } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { useHelpCenter } from '../../app/help-center';
+import { Notice } from '../../components/notice';
+import { getBackupUrl } from '../../utils/site-backup';
+import type { Site } from '@automattic/api-core';
+
+interface BetaProgramNoticeProps {
+ site: Site;
+ wpVersion: string;
+}
+
+export function BetaProgramNotice( { site, wpVersion }: BetaProgramNoticeProps ) {
+ const backupUrl = getBackupUrl( site );
+ const { setShowHelpCenter } = useHelpCenter();
+
+ return (
+
+ { site.is_wpcom_staging_site
+ ? createInterpolateElement(
+ __(
+ 'If you notice anything unexpected, let us know. Your feedback helps shape WordPress. You can switch back to the stable release anytime.'
+ ),
+ {
+ support:
+ );
+}
diff --git a/client/dashboard/sites/settings-wordpress/index.tsx b/client/dashboard/sites/settings-wordpress/index.tsx
index df566961f2fe..38c9b1b3dd44 100644
--- a/client/dashboard/sites/settings-wordpress/index.tsx
+++ b/client/dashboard/sites/settings-wordpress/index.tsx
@@ -1,133 +1,79 @@
import {
siteBySlugQuery,
siteWordPressVersionQuery,
- siteWordPressVersionMutation,
wpOrgCoreVersionQuery,
} from '@automattic/api-queries';
-import { useQuery, useSuspenseQuery, useMutation } from '@tanstack/react-query';
-import {
- __experimentalVStack as VStack,
- __experimentalText as Text,
- Button,
-} from '@wordpress/components';
-import { DataForm } from '@wordpress/dataviews';
+import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
+import { __experimentalVStack as VStack, __experimentalText as Text } from '@wordpress/components';
import { createInterpolateElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
-import { useState } from 'react';
import Breadcrumbs from '../../app/breadcrumbs';
-import { NavigationBlocker } from '../../app/navigation-blocker';
-import { ButtonStack } from '../../components/button-stack';
-import { Card, CardBody } from '../../components/card';
import InlineSupportLink from '../../components/inline-support-link';
import Notice from '../../components/notice';
import { PageHeader } from '../../components/page-header';
import PageLayout from '../../components/page-layout';
-import { formatWordPressVersion, getFormattedWordPressVersion } from '../../utils/wp-version';
+import { getFormattedWordPressVersion } from '../../utils/wp-version';
import { canViewWordPressSettings } from '../features';
-import type { Field } from '@wordpress/dataviews';
+import { BetaProgramNotice } from './beta-program-notice';
+import { LatestVersionNotice } from './latest-version-notice';
+import { useVersionSwitch } from './use-version-switch';
+import { VersionForm } from './version-form';
+import { VersionSwitchNotice } from './version-switch-notice';
-export default function WordPressSettings( { siteSlug }: { siteSlug: string } ) {
+function WordPressSettingsForm( { siteSlug }: { siteSlug: string } ) {
const { data: site } = useSuspenseQuery( siteBySlugQuery( siteSlug ) );
- const canView = canViewWordPressSettings( site );
-
- const { data: currentVersion } = useQuery( {
- ...siteWordPressVersionQuery( site.ID ),
- enabled: canView,
- } );
-
- const { data: latestVersion } = useQuery( {
- ...wpOrgCoreVersionQuery(),
- enabled: canView,
- } );
- const { data: betaVersion } = useQuery( {
- ...wpOrgCoreVersionQuery( 'beta' ),
- enabled: canView,
- } );
-
- const mutation = useMutation( {
- ...siteWordPressVersionMutation( site.ID ),
- meta: {
- snackbar: {
- success: __( 'WordPress version saved.' ),
- error: __( 'Failed to save WordPress version.' ),
- },
- },
- } );
+ const { data: currentVersion } = useQuery( siteWordPressVersionQuery( site.ID ) );
+ const versionSwitch = useVersionSwitch( site );
+ const { isSwitching, switchedToBeta, switchedToLatest, backupState, targetVersion } =
+ versionSwitch;
- const [ formData, setFormData ] = useState< { version: string } >( {
- version: currentVersion ?? '',
- } );
+ // Resolve the target version tag (e.g. "beta") to a display string (e.g. "7.0-RC2").
+ const { data: latestVersion = '' } = useQuery( wpOrgCoreVersionQuery() );
+ const { data: betaVersion = '' } = useQuery( wpOrgCoreVersionQuery( 'beta' ) );
- const currentWpVersion = site.options?.software_version ?? '';
-
- const fields: Field< { version: string } >[] = [
- {
- id: 'version',
- label: __( 'WordPress version' ),
- Edit: 'select',
- elements: [
- {
- value: 'latest',
- label: formatWordPressVersion( latestVersion ?? currentWpVersion, 'latest', true ),
- },
- {
- value: 'beta',
- label: formatWordPressVersion( betaVersion ?? currentWpVersion, 'beta', true ),
- },
- ],
- },
- ];
-
- const form = {
- layout: { type: 'regular' as const },
- fields: [ 'version' ],
- };
+ let notice;
+ if ( isSwitching ) {
+ // Switching in progress — show backup/progress notices.
+ notice = (
+
+ );
+ } else if ( switchedToLatest ) {
+ // Just switched back to stable.
+ notice = ;
+ } else if ( switchedToBeta || currentVersion === 'beta' ) {
+ // Enrolled in beta — show program notice.
+ notice = ;
+ }
- const isDirty = formData.version !== currentVersion;
- const { isPending } = mutation;
+ return (
+ }
+ title="WordPress"
+ description={ __( 'Manage your WordPress version.' ) }
+ />
+ }
+ notices={ notice }
+ >
+
+
+ );
+}
- const handleSubmit = ( e: React.FormEvent ) => {
- e.preventDefault();
- mutation.mutate( formData.version );
- };
+export default function WordPressSettings( { siteSlug }: { siteSlug: string } ) {
+ const { data: site } = useSuspenseQuery( siteBySlugQuery( siteSlug ) );
- if ( ! canView ) {
- return (
- }
- title="WordPress"
- description={ __( 'Manage your WordPress version.' ) }
- />
- }
- >
-
-
-
- { sprintf(
- // translators: %s: WordPress version, e.g. 6.8
- __( 'Every WordPress.com site runs the latest WordPress version (%s).' ),
- getFormattedWordPressVersion( site )
- ) }
-
- { site.is_wpcom_atomic && (
-
- { createInterpolateElement(
- __(
- 'Switch to a staging site to test a beta version of the next WordPress release. '
- ),
- {
- learnMoreLink: ,
- }
- ) }
-
- ) }
-
-
-
- );
+ if ( canViewWordPressSettings( site ) ) {
+ return ;
}
return (
@@ -141,33 +87,29 @@ export default function WordPressSettings( { siteSlug }: { siteSlug: string } )
/>
}
>
-
-
-
-
-
+
+
+
+ { sprintf(
+ // translators: %s: WordPress version, e.g. 6.8
+ __( 'Every WordPress.com site runs the latest WordPress version (%s).' ),
+ getFormattedWordPressVersion( site )
+ ) }
+
+ { site.is_wpcom_atomic && (
+
+ { createInterpolateElement(
+ __(
+ 'Switch to a staging site to test a beta version of the next WordPress release. '
+ ),
+ {
+ learnMoreLink: ,
+ }
+ ) }
+
+ ) }
+
+
);
}
diff --git a/client/dashboard/sites/settings-wordpress/latest-version-notice.tsx b/client/dashboard/sites/settings-wordpress/latest-version-notice.tsx
new file mode 100644
index 000000000000..8ea82a0b1155
--- /dev/null
+++ b/client/dashboard/sites/settings-wordpress/latest-version-notice.tsx
@@ -0,0 +1,18 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { Notice } from '../../components/notice';
+
+interface LatestVersionNoticeProps {
+ wpVersion: string;
+}
+
+export function LatestVersionNotice( { wpVersion }: LatestVersionNoticeProps ) {
+ return (
+
+ { sprintf(
+ // translators: %s: WordPress version, e.g. "7.0-RC2"
+ __( 'Your site is now running WordPress %s.' ),
+ wpVersion
+ ) }
+
+ );
+}
diff --git a/client/dashboard/sites/settings-wordpress/test/index.test.tsx b/client/dashboard/sites/settings-wordpress/test/index.test.tsx
index b78eb62c4569..cdb303a0e8fa 100644
--- a/client/dashboard/sites/settings-wordpress/test/index.test.tsx
+++ b/client/dashboard/sites/settings-wordpress/test/index.test.tsx
@@ -27,24 +27,34 @@ const site = {
},
} as Site;
-function mockSite( mockedSite: Site ) {
+function mockApi() {
nock( 'https://public-api.wordpress.com' )
- .get( `/rest/v1.1/sites/${ mockedSite.slug }` )
+ .get( `/rest/v1.1/sites/${ site.slug }` )
.query( true )
- .reply( 200, mockedSite );
-}
+ .reply( 200, site );
-function mockWordPressVersion( version: string ) {
nock( 'https://public-api.wordpress.com' )
.get( `/wpcom/v2/sites/${ site.ID }/hosting/wp-version` )
.query( true )
- .reply( 200, version );
+ .reply( 200, 'latest' );
+
+ nock( 'https://public-api.wordpress.com' )
+ .persist()
+ .get( `/wpcom/v2/sites/${ site.ID }/hosting/wp-version/pending` )
+ .query( true )
+ .reply( 200, null as unknown as nock.Body );
+
+ nock( 'https://public-api.wordpress.com' )
+ .persist()
+ .get( `/wpcom/v2/sites/${ site.ID }/rewind/backups` )
+ .query( true )
+ .reply( 200, [] );
}
function mockWordPressVersionSaved( expectedVersion: string ) {
return nock( 'https://public-api.wordpress.com' )
.post( `/wpcom/v2/sites/${ site.ID }/hosting/wp-version`, ( body ) => {
- expect( body ).toEqual( { version: expectedVersion } );
+ expect( body ).toMatchObject( { version: expectedVersion } );
return true;
} )
.reply( 200 );
@@ -54,8 +64,7 @@ describe( '', () => {
test( 'renders and saves the form for a Business+ site', async () => {
const user = userEvent.setup();
- mockSite( site );
- mockWordPressVersion( 'latest' );
+ mockApi();
render( );
await screen.findByRole( 'heading', { name: 'WordPress' } );
diff --git a/client/dashboard/sites/settings-wordpress/use-version-switch.ts b/client/dashboard/sites/settings-wordpress/use-version-switch.ts
new file mode 100644
index 000000000000..74faef16b413
--- /dev/null
+++ b/client/dashboard/sites/settings-wordpress/use-version-switch.ts
@@ -0,0 +1,137 @@
+import {
+ queryClient,
+ siteBackupsQuery,
+ siteBySlugQuery,
+ sitePendingWordPressVersionQuery,
+ siteWordPressVersionQuery,
+ siteWordPressVersionMutation,
+} from '@automattic/api-queries';
+import { isEnabled } from '@automattic/calypso-config';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { usePrevious } from '@wordpress/compose';
+import { __ } from '@wordpress/i18n';
+import { useEffect, useReducer } from 'react';
+import { useBackupState } from '../backups/use-backup-state';
+import type { BackupState } from '../backups/use-backup-state';
+import type { Site } from '@automattic/api-core';
+
+export type Phase =
+ | { status: 'idle' }
+ | { status: 'submitting'; targetVersion: string }
+ | { status: 'switching'; targetVersion: string }
+ | { status: 'switched'; targetVersion: string };
+
+type Action =
+ | { type: 'VERSION_CHANGE_REQUESTED'; targetVersion: string }
+ | { type: 'SWITCH_STARTED'; targetVersion: string }
+ | { type: 'SWITCH_COMPLETED' };
+
+export function reducer( state: Phase, action: Action ): Phase {
+ switch ( action.type ) {
+ case 'VERSION_CHANGE_REQUESTED':
+ return { status: 'submitting', targetVersion: action.targetVersion };
+ case 'SWITCH_STARTED':
+ return { status: 'switching', targetVersion: action.targetVersion };
+ case 'SWITCH_COMPLETED':
+ if ( state.status !== 'switching' ) {
+ return state;
+ }
+ return { status: 'switched', targetVersion: state.targetVersion };
+ default:
+ return state;
+ }
+}
+
+export interface VersionSwitchState {
+ backupState: BackupState;
+ targetVersion: string;
+ pendingVersion: string | null | undefined;
+ isSwitching: boolean;
+ isSwitched: boolean;
+ switchedToBeta: boolean;
+ switchedToLatest: boolean;
+ switchVersion: ( version: string ) => void;
+ isSaving: boolean;
+}
+
+export function useVersionSwitch( site: Site ): VersionSwitchState {
+ const deferUntilBackupComplete =
+ isEnabled( 'dashboard/wp-beta-program' ) && ! site.is_wpcom_staging_site;
+
+ const backupState = useBackupState( site.ID );
+ const [ phase, dispatch ] = useReducer( reducer, { status: 'idle' } );
+
+ // Check if there's a pending version switch.
+ const { data: pendingVersion } = useQuery( {
+ ...sitePendingWordPressVersionQuery( site.ID ),
+ enabled: deferUntilBackupComplete,
+ } );
+ const hasPendingVersion = deferUntilBackupComplete && !! pendingVersion;
+ const hadPendingVersion = usePrevious( hasPendingVersion );
+
+ // Pending version appeared → switching. Also start backup tracking.
+ useEffect( () => {
+ if ( pendingVersion ) {
+ backupState.setEnqueued( true );
+ dispatch( { type: 'SWITCH_STARTED', targetVersion: pendingVersion } );
+ }
+ }, [ pendingVersion ] ); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Pending version cleared → switched.
+ useEffect( () => {
+ if ( hadPendingVersion && ! hasPendingVersion ) {
+ dispatch( { type: 'SWITCH_COMPLETED' } );
+ queryClient.invalidateQueries( siteWordPressVersionQuery( site.ID ) );
+ queryClient.invalidateQueries( siteBySlugQuery( site.slug ) );
+ queryClient.invalidateQueries( {
+ queryKey: [ 'site', site.ID, 'backup-activity-log' ],
+ } );
+ }
+ }, [ hadPendingVersion, hasPendingVersion, site.ID, site.slug ] );
+
+ // Poll backups while a version switch is in progress (including right after mutation fires).
+ const shouldPollBackups = phase.status === 'submitting' || hasPendingVersion;
+ useQuery( {
+ ...siteBackupsQuery( site.ID ),
+ refetchInterval: shouldPollBackups ? 3000 : false,
+ enabled: shouldPollBackups,
+ } );
+
+ // After backup completes, poll pending version until it clears.
+ useQuery( {
+ ...sitePendingWordPressVersionQuery( site.ID ),
+ refetchInterval: hasPendingVersion && backupState.hasRecentlyCompleted ? 5000 : false,
+ } );
+
+ const mutation = useMutation( {
+ ...siteWordPressVersionMutation( site.ID, { deferUntilBackupComplete } ),
+ meta: {
+ snackbar: {
+ ...( ! deferUntilBackupComplete && { success: __( 'WordPress version saved.' ) } ),
+ error: __( 'Failed to save WordPress version.' ),
+ },
+ },
+ } );
+
+ const switchVersion = ( version: string ) => {
+ if ( deferUntilBackupComplete ) {
+ dispatch( { type: 'VERSION_CHANGE_REQUESTED', targetVersion: version } );
+ }
+ mutation.mutate( version );
+ };
+
+ const targetVersion = phase.status !== 'idle' ? phase.targetVersion : '';
+ const isSwitched = phase.status === 'switched';
+
+ return {
+ backupState,
+ targetVersion,
+ pendingVersion,
+ isSwitching: phase.status === 'submitting' || phase.status === 'switching',
+ isSwitched,
+ switchedToBeta: isSwitched && phase.targetVersion === 'beta',
+ switchedToLatest: isSwitched && phase.targetVersion === 'latest',
+ switchVersion,
+ isSaving: mutation.isPending,
+ };
+}
diff --git a/client/dashboard/sites/settings-wordpress/version-form.tsx b/client/dashboard/sites/settings-wordpress/version-form.tsx
new file mode 100644
index 000000000000..3d375edf30ab
--- /dev/null
+++ b/client/dashboard/sites/settings-wordpress/version-form.tsx
@@ -0,0 +1,98 @@
+import { wpOrgCoreVersionQuery } from '@automattic/api-queries';
+import { useQuery } from '@tanstack/react-query';
+import { __experimentalVStack as VStack, Button } from '@wordpress/components';
+import { DataForm } from '@wordpress/dataviews';
+import { __ } from '@wordpress/i18n';
+import { useState } from 'react';
+import { NavigationBlocker } from '../../app/navigation-blocker';
+import { ButtonStack } from '../../components/button-stack';
+import { Card, CardBody } from '../../components/card';
+import { formatWordPressVersion } from '../../utils/wp-version';
+import type { VersionSwitchState } from './use-version-switch';
+import type { Site } from '@automattic/api-core';
+import type { Field } from '@wordpress/dataviews';
+
+interface VersionFormProps {
+ site: Site;
+ currentVersion: string | undefined;
+ versionSwitch: VersionSwitchState;
+}
+
+export function VersionForm( { site, currentVersion, versionSwitch }: VersionFormProps ) {
+ const { data: latestVersion } = useQuery( wpOrgCoreVersionQuery() );
+ const { data: betaVersion } = useQuery( wpOrgCoreVersionQuery( 'beta' ) );
+
+ const { isSwitching, pendingVersion, switchVersion, isSaving } = versionSwitch;
+
+ const [ formEdits, setFormEdits ] = useState< { version: string } >( {
+ version: '',
+ } );
+
+ const formData = {
+ version: formEdits.version || pendingVersion || currentVersion || '',
+ };
+
+ const currentWpVersion = site.options?.software_version ?? '';
+
+ const fields: Field< { version: string } >[] = [
+ {
+ id: 'version',
+ label: __( 'WordPress version' ),
+ Edit: 'select',
+ elements: [
+ {
+ value: 'latest',
+ label: formatWordPressVersion( latestVersion ?? currentWpVersion, 'latest', true ),
+ },
+ {
+ value: 'beta',
+ label: formatWordPressVersion( betaVersion ?? currentWpVersion, 'beta', true ),
+ },
+ ],
+ },
+ ];
+
+ const form = {
+ layout: { type: 'regular' as const },
+ fields: [ 'version' ],
+ };
+
+ const isDirty = formData.version !== currentVersion;
+
+ const handleSubmit = ( e: React.FormEvent ) => {
+ e.preventDefault();
+ switchVersion( formData.version );
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/client/dashboard/sites/settings-wordpress/version-switch-notice.tsx b/client/dashboard/sites/settings-wordpress/version-switch-notice.tsx
new file mode 100644
index 000000000000..f6a060d3b871
--- /dev/null
+++ b/client/dashboard/sites/settings-wordpress/version-switch-notice.tsx
@@ -0,0 +1,85 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { Notice } from '../../components/notice';
+import type { BackupState } from '../backups/use-backup-state';
+
+interface VersionSwitchNoticeProps {
+ backupState: BackupState;
+ targetVersion: string;
+}
+
+export function VersionSwitchNotice( { backupState, targetVersion }: VersionSwitchNoticeProps ) {
+ const { status, backup } = backupState;
+
+ if ( status === 'enqueued' ) {
+ return (
+
+ { __( 'Creating a backup of your site before switching.' ) }
+
+ );
+ }
+
+ if ( status === 'running' ) {
+ return (
+
+ { sprintf(
+ // translators: %s: backup progress percentage
+ __(
+ 'Generating backup… (%s%% progress). A backup is being created before switching. This may take a few minutes.'
+ ),
+ backup?.percent ?? '0'
+ ) }
+
+ );
+ }
+
+ if ( status === 'success' ) {
+ return (
+
+ { __( 'Backup completed. Now switching WordPress version…' ) }
+
+ );
+ }
+
+ if ( status === 'error' ) {
+ return (
+
+ { __( 'The backup could not be completed. Please try again or contact support.' ) }
+
+ );
+ }
+
+ // Fallback for 'idle' state — pending version exists but backup hasn't started yet.
+ return (
+
+ { __( 'Preparing to switch WordPress version…' ) }
+
+ );
+}
diff --git a/packages/api-core/src/site-hosting/fetchers.ts b/packages/api-core/src/site-hosting/fetchers.ts
index 809d5cc6c0f1..455fd8559ced 100644
--- a/packages/api-core/src/site-hosting/fetchers.ts
+++ b/packages/api-core/src/site-hosting/fetchers.ts
@@ -8,6 +8,13 @@ export async function fetchWordPressVersion( siteId: number ): Promise< string >
} );
}
+export async function fetchPendingWordPressVersion( siteId: number ): Promise< string | null > {
+ return wpcom.req.get( {
+ path: `/sites/${ siteId }/hosting/wp-version/pending`,
+ apiNamespace: 'wpcom/v2',
+ } );
+}
+
export async function fetchPHPVersion( siteId: number ): Promise< string > {
return wpcom.req.get( {
path: `/sites/${ siteId }/hosting/php-version`,
diff --git a/packages/api-core/src/site-hosting/mutators.ts b/packages/api-core/src/site-hosting/mutators.ts
index 582fae6c4ee5..92e97214bd9f 100644
--- a/packages/api-core/src/site-hosting/mutators.ts
+++ b/packages/api-core/src/site-hosting/mutators.ts
@@ -34,13 +34,20 @@ export async function restoreSitePlanSoftware( siteId: number ) {
} );
}
-export async function updateWordPressVersion( siteId: number, version: string ) {
+export async function updateWordPressVersion(
+ siteId: number,
+ version: string,
+ deferUntilBackupComplete?: boolean
+) {
return wpcom.req.post(
{
path: `/sites/${ siteId }/hosting/wp-version`,
apiNamespace: 'wpcom/v2',
},
- { version }
+ {
+ version,
+ ...( deferUntilBackupComplete && { defer_until_backup_complete: true } ),
+ }
);
}
diff --git a/packages/api-queries/src/site-wordpress-version.ts b/packages/api-queries/src/site-wordpress-version.ts
index 618e79a93ff8..9580c1397395 100644
--- a/packages/api-queries/src/site-wordpress-version.ts
+++ b/packages/api-queries/src/site-wordpress-version.ts
@@ -1,4 +1,8 @@
-import { fetchWordPressVersion, updateWordPressVersion } from '@automattic/api-core';
+import {
+ fetchWordPressVersion,
+ fetchPendingWordPressVersion,
+ updateWordPressVersion,
+} from '@automattic/api-core';
import { queryOptions, mutationOptions } from '@tanstack/react-query';
import { queryClient } from './query-client';
@@ -8,10 +12,21 @@ export const siteWordPressVersionQuery = ( siteId: number ) =>
queryFn: () => fetchWordPressVersion( siteId ),
} );
-export const siteWordPressVersionMutation = ( siteId: number ) =>
+export const sitePendingWordPressVersionQuery = ( siteId: number ) =>
+ queryOptions( {
+ queryKey: [ 'site', siteId, 'wp-version', 'pending' ],
+ queryFn: () => fetchPendingWordPressVersion( siteId ),
+ } );
+
+export const siteWordPressVersionMutation = (
+ siteId: number,
+ options?: { deferUntilBackupComplete?: boolean }
+) =>
mutationOptions( {
- mutationFn: ( version: string ) => updateWordPressVersion( siteId, version ),
+ mutationFn: ( version: string ) =>
+ updateWordPressVersion( siteId, version, options?.deferUntilBackupComplete ),
onSuccess: () => {
queryClient.invalidateQueries( siteWordPressVersionQuery( siteId ) );
+ queryClient.invalidateQueries( sitePendingWordPressVersionQuery( siteId ) );
},
} );