Commit 5fed0e4b387 for woocommerce
commit 5fed0e4b38719d24c769900d98a43549835a85bf
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Thu Oct 15 15:46:50 2026 +0200
Show why extension updates fail on My Subscriptions (#69818)
* Explain WordPress and PHP requirement failures on My Subscriptions updates
Updating an extension from My Subscriptions showed only "<Product> couldn't
be updated." when the new version needed a newer WordPress or PHP. Core's
update AJAX response carries the reason in errorCode, which wpAjax() dropped
and update.tsx ignored.
wpAjax() now forwards errorCode, and update failures get a notice that says
which requirement wasn't met, linking to Dashboard > Updates (only for users
who can update core) or the Update PHP page. Other failures append core's
message and keep the "Try again" action.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Fix My Subscriptions notice actions reloading the page or leaving the admin
Actions with an empty url rendered as <a href="">, so "Try again" ran the
retry and reloaded the page before its result could show. Only url actions
now get a url, so onClick-only actions render as buttons. The Update PHP
"Learn more" link opens in a new tab with an "(opens in a new tab)" hint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Simplify My Subscriptions update error notices
Build the WordPress and PHP requirement notices from one lookup keyed by error code,
type what wpAjax() rejects with, and render the PHP "Learn more" action as a real
external link instead of a button calling window.open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep My Subscriptions notice actions on the right on WordPress 7.1
WordPress 7.1 moved Notice actions out of the content div, so the old rule
no longer matched and "Update WordPress" / "Try again" wrapped under the
message. Lay the notice out as a centred flex row and style the actions
wherever they sit. Also drop the compact height and the ExternalLink
underline so "Learn more" and core's link actions look the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review feedback on My Subscriptions update notices
Check the update's Requires PHP before updating, since core refuses
the update before download without an error code, and show the PHP
notice instead of a "Try again" that can't succeed. The subscriptions
list gains an update_requires_php_met flag for this.
Decode entities in core's appended message, only read string message
and code values from the rejection, add error_code to the
update-failed Tracks event, and swap the notice Map for a switch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Clear the helper updates transient between WC_Helper tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Don't fail the subscriptions list on a malformed requires_php
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Send the installed version, not a boolean, in update Tracks events
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wccom-3058-my-subscriptions-update-requirements-notice b/plugins/woocommerce/changelog/wccom-3058-my-subscriptions-update-requirements-notice
new file mode 100644
index 00000000000..fb71032f847
--- /dev/null
+++ b/plugins/woocommerce/changelog/wccom-3058-my-subscriptions-update-requirements-notice
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Explain My Subscriptions update failures caused by WordPress or PHP requirements, and keep Try again from reloading the page.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss
index 11020f14ecd..01dcaa107fb 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss
@@ -407,6 +407,8 @@
border: 1px solid var(--gutenberg-gray-100, #f0f0f0);
padding-right: $grid-unit-15;
position: relative;
+ display: flex;
+ align-items: center;
&::before {
content: "";
@@ -426,15 +428,26 @@
.components-notice__content {
display: flex;
+ flex-grow: 1;
align-items: center;
gap: $grid-unit-15;
+ }
- .components-notice__actions {
- flex-shrink: 0;
- margin-left: auto;
+ // Core renders the actions inside the content before WordPress 7.1, and after it from 7.1 on.
+ .components-notice__actions {
+ flex-shrink: 0;
+ margin-block-start: 0;
+ margin-inline-start: auto;
+
+ &:empty {
+ display: none;
}
}
+ .components-notice__content + .components-notice__actions {
+ margin-inline-start: $grid-unit-15;
+ }
+
.components-notice__dismiss.has-icon {
width: 24px;
min-width: 24px;
@@ -446,9 +459,16 @@
}
}
}
+ // WordPress 7.1 makes core's actions compact (32px tall), so match the text-height "Learn more" link.
.components-notice__action.components-button.is-link {
+ height: auto;
margin: 0;
padding: 0;
+
+ // Link buttons on this page aren't underlined, but ExternalLink underlines its text.
+ .components-external-link__contents {
+ text-decoration: none;
+ }
}
}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/notices.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/notices.tsx
index 6a8ff02e905..1fcc8e71851 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/notices.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/notices.tsx
@@ -2,18 +2,40 @@
* External dependencies
*/
import { ComponentProps } from 'react';
-import { Notice } from '@wordpress/components';
+import { ExternalLink, Notice } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import Alert from '../../assets/images/alert.svg';
-import { Notice as NoticeType } from '../../contexts/types';
+import { NoticeAction, Notice as NoticeType } from '../../contexts/types';
import { noticeStore } from '../../contexts/notice-store';
import { removeNotice } from '../../utils/functions';
-type NoticeActions = ComponentProps< typeof Notice >[ 'actions' ];
+type WPNoticeAction = NonNullable<
+ ComponentProps< typeof Notice >[ 'actions' ]
+>[ number ];
+
+// Notice renders any `url` it gets, even an empty one, as a link, so onClick-only actions must not pass one.
+const toNoticeAction = ( {
+ label,
+ url,
+ onClick,
+}: NoticeAction ): WPNoticeAction => {
+ const common = { label, variant: 'link', className: 'is-link' } as const;
+
+ if ( url ) {
+ return { ...common, url };
+ }
+
+ return { ...common, onClick: () => onClick?.() };
+};
+
+const opensInNewTab = (
+ action: NoticeAction
+): action is NoticeAction & { url: string } =>
+ !! action.url && !! action.openInNewTab;
export default function Notices() {
const notices: NoticeType[] = useSelect(
@@ -21,35 +43,36 @@ export default function Notices() {
[]
);
- const actions = ( notice: NoticeType ): NoticeActions => {
- if ( ! notice.options?.actions ) {
- return [];
- }
- // Marketplace notice actions carry both `url` and `onClick`, which
- // doesn't cleanly match either arm of @wordpress/components'
- // `NoticeActionWithOnClick | NoticeActionWithURL` discriminated union.
- // The underlying <Notice> picks `onClick` first, so the cast is safe.
- return notice.options?.actions.map( ( action ) => {
- return {
- ...action,
- variant: 'link',
- className: 'is-link',
- };
- } ) as NoticeActions;
- };
-
const errorNotices = [];
for ( const notice of notices ) {
+ const actions = notice.options?.actions ?? [];
+ const newTabActions = actions.filter( opensInNewTab );
+
errorNotices.push(
<Notice
className="woocommerce-marketplace__notice--error"
status={ notice.status }
onRemove={ () => removeNotice( notice.productKey ) }
key={ notice.productKey }
- actions={ actions( notice ) }
+ actions={ actions
+ .filter( ( action ) => ! opensInNewTab( action ) )
+ .map( toNoticeAction ) }
>
<img src={ Alert } alt="" width={ 24 } height={ 24 } />
{ notice.message }
+ { newTabActions.length > 0 && (
+ <div className="components-notice__actions">
+ { newTabActions.map( ( { label, url } ) => (
+ <ExternalLink
+ key={ label }
+ href={ url }
+ className="components-button components-notice__action is-link is-next-40px-default-size"
+ >
+ { label }
+ </ExternalLink>
+ ) ) }
+ </div>
+ ) }
</Notice>
);
}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/test/update.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/test/update.test.tsx
new file mode 100644
index 00000000000..3c1fc77c614
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/test/update.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import { recordEvent } from '@woocommerce/tracks';
+
+jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
+
+jest.mock( '~/utils/admin-settings', () => ( {
+ getAdminSetting: () => ( {
+ updatePhpUrl: 'https://wordpress.org/support/update-php/',
+ } ),
+} ) );
+
+jest.mock( '../../../../../utils/functions', () => ( {
+ ...jest.requireActual( '../../../../../utils/functions' ),
+ addNotice: jest.fn(),
+ removeNotice: jest.fn(),
+ updateProduct: jest.fn(),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import Update from '../update';
+import { Subscription } from '../../../types';
+import { addNotice, updateProduct } from '../../../../../utils/functions';
+
+const subscription = {
+ product_key: 'product-key',
+ product_name: 'Product Add-Ons',
+ product_id: 18618,
+ zip_slug: 'woocommerce-product-addons',
+ version: '8.0.0',
+ active: true,
+ update_requires_php_met: false,
+ local: {
+ installed: true,
+ slug: 'woocommerce-product-addons',
+ path: 'woocommerce-product-addons/woocommerce-product-addons.php',
+ },
+} as unknown as Subscription;
+
+describe( 'Update', () => {
+ it( 'explains the PHP requirement instead of updating when the site’s PHP is too old', () => {
+ render(
+ <Update subscription={ subscription } wooUpdateManagerActive />
+ );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Update to 8.0.0' } )
+ );
+
+ expect( updateProduct ).not.toHaveBeenCalled();
+ expect( addNotice ).toHaveBeenCalledWith(
+ 'product-key',
+ 'Product Add-Ons couldn’t be updated. This version requires a newer version of PHP.',
+ 'error',
+ {
+ actions: [
+ {
+ label: 'Learn more',
+ url: 'https://wordpress.org/support/update-php/',
+ openInNewTab: true,
+ },
+ ],
+ }
+ );
+ expect( recordEvent ).toHaveBeenCalledWith(
+ 'marketplace_product_update_failed',
+ expect.objectContaining( {
+ error_code: 'incompatible_php_required_version',
+ } )
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/update.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/update.tsx
index 72662dadd15..89a572933bf 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/update.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/update.tsx
@@ -16,6 +16,9 @@ import RenewModal from './renew-modal';
import SubscribeModal from './subscribe-modal';
import {
addNotice,
+ getUpdateErrorNotice,
+ getWpAjaxErrorData,
+ PHP_REQUIREMENT_ERROR_CODE,
removeNotice,
updateProduct,
} from '../../../../utils/functions';
@@ -39,11 +42,36 @@ export default function Update( props: UpdateProps ) {
props.subscription.local.path &&
props.wooUpdateManagerActive;
+ function showUpdateError( error: unknown, onRetry: () => void ) {
+ const { message, actions } = getUpdateErrorNotice(
+ props.subscription.product_name,
+ error,
+ onRetry
+ );
+ addNotice(
+ props.subscription.product_key,
+ message,
+ NoticeStatus.Error,
+ {
+ actions,
+ }
+ );
+ setIsUpdating( false );
+
+ recordEvent( 'marketplace_product_update_failed', {
+ product_zip_slug: props.subscription.zip_slug,
+ product_id: props.subscription.product_id,
+ product_installed_version: props.subscription.local.version,
+ product_current_version: props.subscription.version,
+ error_code: getWpAjaxErrorData( error ).code || '',
+ } );
+ }
+
function update() {
recordEvent( 'marketplace_product_update_button_clicked', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
- product_installed_version: props.subscription.local.installed,
+ product_installed_version: props.subscription.local.version,
product_current_version: props.subscription.version,
} );
@@ -52,6 +80,17 @@ export default function Update( props: UpdateProps ) {
return;
}
removeNotice( props.subscription.product_key );
+ // Core refuses this update before downloading it, with no error code to explain why.
+ if ( props.subscription.update_requires_php_met === false ) {
+ showUpdateError(
+ {
+ success: false,
+ data: { code: PHP_REQUIREMENT_ERROR_CODE },
+ },
+ update
+ );
+ return;
+ }
if ( ! window.wp.updates ) {
addNotice(
props.subscription.product_key,
@@ -71,7 +110,6 @@ export default function Update( props: UpdateProps ) {
onClick: () => {
window.location.reload();
},
- url: '',
},
],
}
@@ -99,40 +137,11 @@ export default function Update( props: UpdateProps ) {
recordEvent( 'marketplace_product_updated', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
- product_installed_version:
- props.subscription.local.installed,
+ product_installed_version: props.subscription.local.version,
product_current_version: props.subscription.version,
} );
} )
- .catch( () => {
- addNotice(
- props.subscription.product_key,
- sprintf(
- // translators: %s is the product name.
- __( '%s couldn’t be updated.', 'woocommerce' ),
- props.subscription.product_name
- ),
- NoticeStatus.Error,
- {
- actions: [
- {
- label: __( 'Try again', 'woocommerce' ),
- onClick: update,
- url: '',
- },
- ],
- }
- );
- setIsUpdating( false );
-
- recordEvent( 'marketplace_product_update_failed', {
- product_zip_slug: props.subscription.zip_slug,
- product_id: props.subscription.product_id,
- product_installed_version:
- props.subscription.local.installed,
- product_current_version: props.subscription.version,
- } );
- } );
+ .catch( ( error: unknown ) => showUpdateError( error, update ) );
}
const modal = () => {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/test/notices.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/test/notices.test.tsx
new file mode 100644
index 00000000000..3704c96117e
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/test/notices.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen, fireEvent } from '@testing-library/react';
+import { dispatch } from '@wordpress/data';
+
+jest.mock( '../../../utils/functions', () => ( {
+ removeNotice: jest.fn(),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import Notices from '../notices';
+import { noticeStore } from '../../../contexts/notice-store';
+import { NoticeAction, NoticeStatus } from '../../../contexts/types';
+
+const showNotice = ( action: NoticeAction ) =>
+ act( () => {
+ void dispatch( noticeStore ).addNotice(
+ 'product-key',
+ 'Product Add-Ons couldn’t be updated.',
+ NoticeStatus.Error,
+ { actions: [ action ] }
+ );
+ } );
+
+describe( 'Notices', () => {
+ afterEach( () => {
+ act( () => {
+ void dispatch( noticeStore ).removeNotice( 'product-key' );
+ } );
+ } );
+
+ it( 'renders an onClick-only action as a button, even with an empty url', () => {
+ const onClick = jest.fn();
+ render( <Notices /> );
+ showNotice( { label: 'Try again', onClick, url: '' } );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Try again' } ) );
+
+ expect( onClick ).toHaveBeenCalledTimes( 1 );
+ expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders a url action as a link in the same tab', () => {
+ render( <Notices /> );
+ showNotice( {
+ label: 'Update WordPress',
+ url: 'https://example.test/wp-admin/update-core.php',
+ } );
+
+ const link = screen.getByRole( 'link', { name: 'Update WordPress' } );
+ expect( link ).toHaveAttribute(
+ 'href',
+ 'https://example.test/wp-admin/update-core.php'
+ );
+ expect( link ).not.toHaveAttribute( 'target' );
+ } );
+
+ it( 'renders an openInNewTab action as a link that opens in a new tab', () => {
+ render( <Notices /> );
+ showNotice( {
+ label: 'Learn more',
+ url: 'https://wordpress.org/support/update-php/',
+ openInNewTab: true,
+ } );
+
+ const link = screen.getByRole( 'link', { name: /Learn more/ } );
+ expect( link ).toHaveAttribute(
+ 'href',
+ 'https://wordpress.org/support/update-php/'
+ );
+ expect( link ).toHaveAttribute( 'target', '_blank' );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/types.ts b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/types.ts
index 1b411725f2e..6b924c882e9 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/types.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/types.ts
@@ -27,6 +27,8 @@ export type Subscription = {
active: boolean;
local: SubscriptionLocal;
has_updates: boolean;
+ /** Whether this site's PHP meets the available update's `requires_php`. */
+ update_requires_php_met: boolean;
version: string;
subscription_installed: boolean;
subscription_available: boolean;
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
index 818b69bdc86..deee529f6fa 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
@@ -1,10 +1,18 @@
/**
* Internal dependencies
*/
-import type { NoticeOptions } from '~/lib/notices/types';
+import type {
+ NoticeAction as BaseNoticeAction,
+ NoticeOptions as BaseNoticeOptions,
+} from '~/lib/notices/types';
import { Subscription } from '../components/my-subscriptions/types';
-export type { NoticeAction, NoticeOptions } from '~/lib/notices/types';
+/** Notice can't set a link target, so `openInNewTab` actions are rendered separately as external links. */
+export type NoticeAction = BaseNoticeAction & { openInNewTab?: boolean };
+
+export type NoticeOptions = Omit< BaseNoticeOptions, 'actions' > & {
+ actions?: NoticeAction[];
+};
/**
* Notice key shared by everything that refreshes subscriptions, so a refresh
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
index bc199934e84..d63543e00d3 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
@@ -6,6 +6,7 @@ import { __, sprintf } from '@wordpress/i18n';
import { dispatch } from '@wordpress/data';
import { store as coreNoticesStore } from '@wordpress/notices';
import { Icon } from '@wordpress/components';
+import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
@@ -27,7 +28,7 @@ import {
SearchAPIJSONType,
SearchAPIProductType,
} from '../components/product-list/types';
-import { NoticeOptions, NoticeStatus } from '../contexts/types';
+import { NoticeAction, NoticeOptions, NoticeStatus } from '../contexts/types';
import { noticeStore } from '../contexts/notice-store';
interface ProductGroup {
@@ -356,10 +357,30 @@ type WpAjaxResponse = {
};
type WpAjaxResponseData = {
+ errorCode?: string;
errorMessage?: string;
activateUrl?: string;
};
+type WpAjaxError = {
+ success: false;
+ data: { message?: string; code?: string };
+};
+
+/** The message and error code of a rejected wpAjax() call, keeping only string values. */
+const getWpAjaxErrorData = ( error: unknown ): WpAjaxError[ 'data' ] => {
+ const data = ( error as { data?: { message?: unknown; code?: unknown } } )
+ ?.data;
+
+ return {
+ message: typeof data?.message === 'string' ? data.message : undefined,
+ code: typeof data?.code === 'string' ? data.code : undefined,
+ };
+};
+
+/** Core's error code for a package that needs a newer PHP than the site runs. */
+const PHP_REQUIREMENT_ERROR_CODE = 'incompatible_php_required_version';
+
function wpAjax(
action: string,
data: {
@@ -384,12 +405,14 @@ function wpAjax(
} );
},
error: ( error: WpAjaxResponseData ) => {
- reject( {
+ const rejection: WpAjaxError = {
success: false,
data: {
message: error.errorMessage,
+ code: error.errorCode,
},
- } );
+ };
+ reject( rejection );
},
} );
} );
@@ -526,6 +549,79 @@ const getRefreshErrorMessage = ( error: unknown ): string => {
return message ?? __( 'Unexpected error.', 'woocommerce' );
};
+/**
+ * Build the notice for a failed My Subscriptions update, explaining WordPress and PHP requirement failures by error code.
+ */
+const getUpdateErrorNotice = (
+ productName: string,
+ error: unknown,
+ onRetry: () => void
+): { message: string; actions: NoticeAction[] } => {
+ const { message: wpMessage, code } = getWpAjaxErrorData( error );
+ const wccomSettings = getAdminSetting( 'wccomHelper', {} );
+
+ switch ( code ) {
+ case 'incompatible_wp_required_version':
+ return {
+ message: sprintf(
+ // translators: %s is the product name.
+ __(
+ '%s couldn’t be updated. This version requires a newer version of WordPress.',
+ 'woocommerce'
+ ),
+ productName
+ ),
+ actions: wccomSettings.updateCoreUrl
+ ? [
+ {
+ label: __( 'Update WordPress', 'woocommerce' ),
+ url: wccomSettings.updateCoreUrl,
+ },
+ ]
+ : [],
+ };
+ case PHP_REQUIREMENT_ERROR_CODE:
+ return {
+ message: sprintf(
+ // translators: %s is the product name.
+ __(
+ '%s couldn’t be updated. This version requires a newer version of PHP.',
+ 'woocommerce'
+ ),
+ productName
+ ),
+ actions: wccomSettings.updatePhpUrl
+ ? [
+ {
+ label: __( 'Learn more', 'woocommerce' ),
+ url: wccomSettings.updatePhpUrl,
+ openInNewTab: true,
+ },
+ ]
+ : [],
+ };
+ }
+
+ let message = sprintf(
+ // translators: %s is the product name.
+ __( '%s couldn’t be updated.', 'woocommerce' ),
+ productName
+ );
+ if ( wpMessage ) {
+ message += ' ' + decodeEntities( wpMessage );
+ }
+
+ return {
+ message,
+ actions: [
+ {
+ label: __( 'Try again', 'woocommerce' ),
+ onClick: onRetry,
+ },
+ ],
+ };
+};
+
const removeNotice = ( productKey: string ) => {
void dispatch( noticeStore ).removeNotice( productKey );
};
@@ -641,6 +737,9 @@ export {
updateProduct,
addNotice,
getRefreshErrorMessage,
+ getUpdateErrorNotice,
+ getWpAjaxErrorData,
+ PHP_REQUIREMENT_ERROR_CODE,
removeNotice,
renewUrl,
subscribeUrl,
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/test/get-update-error-notice.test.ts b/plugins/woocommerce/client/admin/client/marketplace/utils/test/get-update-error-notice.test.ts
new file mode 100644
index 00000000000..e420f99aa7c
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/test/get-update-error-notice.test.ts
@@ -0,0 +1,130 @@
+/**
+ * Internal dependencies
+ */
+import { getUpdateErrorNotice } from '../functions';
+
+const mockSettings: Record< string, string > = {};
+
+jest.mock( '~/utils/admin-settings', () => ( {
+ getAdminSetting: () => mockSettings,
+} ) );
+
+const reject = ( code: string, message: string ) => ( {
+ success: false,
+ data: { code, message },
+} );
+
+describe( 'getUpdateErrorNotice', () => {
+ const onRetry = jest.fn();
+
+ beforeEach( () => {
+ mockSettings.updateCoreUrl =
+ 'https://example.test/wp-admin/update-core.php';
+ mockSettings.updatePhpUrl = 'https://wordpress.org/support/update-php/';
+ } );
+
+ it( 'links to WordPress updates when the new version needs a newer WordPress', () => {
+ expect(
+ getUpdateErrorNotice(
+ 'Product Add-Ons',
+ reject(
+ 'incompatible_wp_required_version',
+ 'The package could not be installed.'
+ ),
+ onRetry
+ )
+ ).toEqual( {
+ message:
+ 'Product Add-Ons couldn’t be updated. This version requires a newer version of WordPress.',
+ actions: [
+ {
+ label: 'Update WordPress',
+ url: 'https://example.test/wp-admin/update-core.php',
+ },
+ ],
+ } );
+ } );
+
+ it( 'shows no action for a newer WordPress when the user can’t update core', () => {
+ mockSettings.updateCoreUrl = '';
+
+ expect(
+ getUpdateErrorNotice(
+ 'Product Add-Ons',
+ reject(
+ 'incompatible_wp_required_version',
+ 'The package could not be installed.'
+ ),
+ onRetry
+ ).actions
+ ).toEqual( [] );
+ } );
+
+ it( 'links to the Update PHP page when the new version needs a newer PHP', () => {
+ expect(
+ getUpdateErrorNotice(
+ 'Product Add-Ons',
+ reject(
+ 'incompatible_php_required_version',
+ 'The package could not be installed.'
+ ),
+ onRetry
+ )
+ ).toEqual( {
+ message:
+ 'Product Add-Ons couldn’t be updated. This version requires a newer version of PHP.',
+ actions: [
+ {
+ label: 'Learn more',
+ url: 'https://wordpress.org/support/update-php/',
+ openInNewTab: true,
+ },
+ ],
+ } );
+ } );
+
+ it( 'appends core’s message and offers a retry for other errors', () => {
+ expect(
+ getUpdateErrorNotice(
+ 'Product Add-Ons',
+ reject( 'download_failed', 'Download failed.' ),
+ onRetry
+ )
+ ).toEqual( {
+ message: 'Product Add-Ons couldn’t be updated. Download failed.',
+ actions: [ { label: 'Try again', onClick: onRetry } ],
+ } );
+ } );
+
+ it( 'decodes HTML entities in core’s message', () => {
+ expect(
+ getUpdateErrorNotice(
+ 'Product Add-Ons',
+ reject( 'folder_exists', 'Can't replace the folder.' ),
+ onRetry
+ ).message
+ ).toBe(
+ "Product Add-Ons couldn’t be updated. Can't replace the folder."
+ );
+ } );
+
+ it( 'ignores a message that isn’t a string', () => {
+ expect(
+ getUpdateErrorNotice(
+ 'Product Add-Ons',
+ { success: false, data: { message: { text: 'Nope' } } },
+ onRetry
+ ).message
+ ).toBe( 'Product Add-Ons couldn’t be updated.' );
+ } );
+
+ it( 'falls back to the base sentence when there is no message', () => {
+ expect(
+ getUpdateErrorNotice(
+ 'Product Add-Ons',
+ 'Please reload and try again',
+ onRetry
+ ).message
+ ).toBe( 'Product Add-Ons couldn’t be updated.' );
+ } );
+} );
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
index ea01f5ba833..909730592d1 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
@@ -99,6 +99,8 @@ class WC_Helper_Admin {
$settings['wccomHelper']['wooUpdateCount'] = WC_Helper_Updater::get_updates_count_based_on_site_status();
$settings['wccomHelper']['connected_notice'] = PluginsHelper::get_wccom_connected_notice( $auth_user_email );
$settings['wccomHelper']['woocomConnectNoticeType'] = WC_Helper_Updater::get_woo_connect_notice_type();
+ $settings['wccomHelper']['updateCoreUrl'] = current_user_can( 'update_core' ) ? network_admin_url( 'update-core.php' ) : '';
+ $settings['wccomHelper']['updatePhpUrl'] = esc_url_raw( wp_get_update_php_url() );
if ( WC_Helper::is_site_connected() ) {
$settings['wccomHelper']['subscription_expired_notice'] = PluginsHelper::get_expired_subscription_notice( false );
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index 20b37135173..e90c6e2c310 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -2302,9 +2302,13 @@ class WC_Helper {
$subscription['local'] = self::get_subscription_local_data( $subscription );
- $subscription['has_update'] = false;
+ $subscription['has_update'] = false;
+ $subscription['update_requires_php_met'] = true;
if ( $subscription['local']['installed'] && ! empty( $updates[ $subscription['product_id'] ] ) ) {
$subscription['has_update'] = version_compare( $updates[ $subscription['product_id'] ]['version'], $subscription['local']['version'], '>' );
+ // Mirrors core's pre-download check (no error code); a malformed value counts as met, as before.
+ $requires_php = $updates[ $subscription['product_id'] ]['requires_php'] ?? '';
+ $subscription['update_requires_php_met'] = ! is_scalar( $requires_php ) || is_php_version_compatible( (string) $requires_php );
}
if ( ! empty( $updates[ $subscription['product_id'] ] ) ) {
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
index 14079da2c3c..6268a91d128 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
@@ -84,6 +84,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
*/
private function cleanup_helper_transients(): void {
delete_transient( '_woocommerce_helper_subscriptions' );
+ delete_transient( '_woocommerce_helper_updates' );
delete_transient( '_woocommerce_helper_product_usage_notice_rules' );
delete_transient( '_woocommerce_helper_notices' );
delete_transient( '_woocommerce_helper_connection_data' );
@@ -1036,6 +1037,95 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Subscription list data reports whether the site's PHP meets the available update's requirement.
+ */
+ public function test_subscription_list_data_reports_whether_update_requires_php_is_met(): void {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ wp_cache_set(
+ 'plugins',
+ array(
+ '' => array(
+ 'needs-new-php/needs-new-php.php' => array(
+ 'Name' => 'Needs New PHP',
+ 'Version' => '1.0.0',
+ 'Woo' => '900101:abc',
+ ),
+ 'any-php/any-php.php' => array(
+ 'Name' => 'Any PHP',
+ 'Version' => '1.0.0',
+ 'Woo' => '900102:def',
+ ),
+ ),
+ ),
+ 'plugins'
+ );
+ WC_Helper::flush_local_woo_products_cache();
+ delete_transient( '_woocommerce_helper_updates' );
+
+ $subscription = array(
+ 'product_key' => '',
+ 'product_name' => '',
+ 'connections' => array(),
+ 'expires' => 0,
+ 'expired' => false,
+ );
+ set_transient(
+ '_woocommerce_helper_subscriptions',
+ array(
+ array_merge(
+ $subscription,
+ array(
+ 'product_id' => 900101,
+ 'zip_slug' => 'needs-new-php',
+ )
+ ),
+ array_merge(
+ $subscription,
+ array(
+ 'product_id' => 900102,
+ 'zip_slug' => 'any-php',
+ )
+ ),
+ ),
+ HOUR_IN_SECONDS
+ );
+
+ $http_mock = static function ( $preempt, $args, $url ) {
+ if ( false === strpos( $url, 'update-check' ) ) {
+ return new WP_Error( 'test', 'Mocked error' );
+ }
+
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode(
+ array(
+ 900101 => array(
+ 'version' => '2.0.0',
+ 'requires_php' => '99.0',
+ ),
+ 900102 => array(
+ 'version' => '2.0.0',
+ 'requires_php' => array( '99.0' ),
+ ),
+ )
+ ),
+ );
+ };
+ add_filter( 'pre_http_request', $http_mock, 10, 3 );
+
+ try {
+ $result = array_column( WC_Helper::get_subscription_list_data(), null, 'product_id' );
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock );
+ }
+
+ $this->assertTrue( $result[900101]['has_update'] );
+ $this->assertFalse( $result[900101]['update_requires_php_met'], 'An update needing a newer PHP than this site runs is not installable.' );
+ $this->assertTrue( $result[900102]['has_update'] );
+ $this->assertTrue( $result[900102]['update_requires_php_met'], 'A malformed PHP requirement does not block the update.' );
+ }
+
/**
* @testdox Local data reports whether the installed copy carries a Woo header.
*/