Commit 56f8a8560b7 for woocommerce

commit 56f8a8560b734148c3d7a4229fa4f0038549b79d
Author: Thilina Pituwala <thilina.hasantha@gmail.com>
Date:   Tue Sep 15 01:05:07 2026 +1000

    Surface auto-update status for installed products in My Subscriptions (#68410)

    * Surface plugin auto-update status in My Subscriptions

    The "Installed on this store" table shows when a subscription expires and which
    version is installed, but nothing about whether the product will update itself.
    A merchant with auto-updates off can sit several releases behind, including on
    security releases, with no signal on the screen they use to manage the product.

    Report the state from WC_Helper::get_plugin_auto_update_data(), which follows
    the same rules as Core's Plugins screen: AUTOMATIC_UPDATER_DISABLED and the
    plugins_auto_update_enabled filter switch the feature off entirely, a non-null
    auto_update_plugin filter forces a state that can't be changed, and otherwise the
    auto_update_plugins site option decides. Rows with auto-updates on render nothing.

    Enabling writes the same auto_update_plugins option Core's Plugins screen writes,
    through a new wc/v3/marketplace/subscriptions/auto-update route, and the row
    refreshes in place with the change announced to assistive technology.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Warn when auto-updates are on but the extension still won't update

    The auto-update setting being on is only half the story. A product with the
    setting on still won't update if automatic updates are off site-wide, if the
    WooCommerce.com Update Manager is inactive, if there is no subscription for it,
    if that subscription has expired, or if it is not connected to this store. The
    row said nothing about any of it.

    Add a second state to the same slot, "Won't auto-update", whose tooltip lists
    the reasons in point form and offers no action, since none of them can be fixed
    from that row.

    This also splits the site-wide switch out of the per-plugin flag. auto_update
    now reports only what the plugin's own setting says, so the screen can report a
    setting that is on alongside the site-wide block that makes it moot;
    auto_update_manageable still goes false, because there is nothing to toggle.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Call the blocked auto-update state red, and name it "Auto-updates blocked"

    "Won't auto-update" described the outcome without saying what kind of problem it
    was. "Auto-updates blocked" reads as a sibling of "Auto-updates are off" -- same
    subject, different cause -- and "blocked" says something outside the row is
    stopping it. It also avoids implying manual updates are unavailable; the Update
    link in the Version column still works.

    Raise it to StatusLevel.Error so it renders red like "Expired". The setting being
    off stays amber: that is a choice the merchant can correct from the row, while
    this is a state the row cannot fix.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Stop the site-wide auto-update switch from blocking a row

    Whether WordPress runs automatic updates at all is a site-wide decision, and
    repeating it on every row says nothing about the product. Drop it from the
    blocker list, along with the pluginAutoUpdatesEnabled setting it read and the
    require_once that setting needed.

    auto_update_manageable still consults the switch: that governs whether there is
    a toggle to offer, not what the row says about the product.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Fix enabling auto-updates for products distributed through WordPress.org

    Enabling returned HTTP 400 for Facebook for WooCommerce and any other
    WooCommerce.com product shipped from WordPress.org. The setter resolved the
    plugin file through _get_local_from_product_id(), which only sees plugins
    carrying a Woo header; .org builds do not carry one, so it returned false and
    the request threw "This subscription has no installed plugin to update".

    The row itself is built from get_subscription_local_data(), which matches
    zip_slug against every installed plugin, so the button appeared on rows the
    setter could not act on. Resolve through the same data the row uses.

    The test fixture now leaves the Woo header empty, which is what a WordPress.org
    build looks like, so this path stays covered.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Keep auto-update labels out of the "Available to use" table

    The label was rendered from nameAndStatus(), which both tables share. Filtering
    on local.installed was not enough: is_subscription_installed() marks only one
    subscription per product as the installed one, so a store holding several keys
    for the same product lists the spares under "Available to use" with
    local.installed still true. Those rows are not the copy running on this store,
    so their auto-update state says nothing useful.

    Pass the table through, as the status, version and action columns already do,
    and render the label only for "Installed on this store".

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Extend auto-update status to themes

    Themes were skipped when this was first built, so a WooCommerce.com theme showed
    nothing at all, even with auto-updates off or a disconnected subscription.

    Read the state from auto_update_themes and the auto_update_theme filter, with
    update_themes as the capability, mirroring core's Themes screen the way the
    plugin path mirrors its Plugins screen. The shared logic moves into
    get_auto_update_data(), with get_plugin_auto_update_data() and
    get_theme_auto_update_data() as the entry points, and the filter payload switches
    shape per type because core passes different keys for each.

    set_subscription_plugin_auto_update() becomes set_subscription_auto_update() and
    writes whichever option matches the product; themes are keyed by stylesheet, not
    by a file path. It was added on this branch and has never shipped, so the rename
    carries no compatibility cost.

    The subscription-level blockers already applied to both, and the copy is now
    type-neutral so one set of strings covers plugins and themes.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Show the endpoint's reason when enabling auto-updates fails

    The endpoint refuses with wp_send_json_error(), which nests the reason under
    data.message, but the row read a top-level message property that is never
    set. Every refusal, including the one for installed products that have no
    subscription, collapsed into "Auto-updates could not be enabled."

    The notice now leads with that sentence and appends the endpoint's reason,
    and the no-subscription refusal reads "There is no subscription for this
    product." so the two sentences make sense together.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Stop blocking auto-updates for copies installed from WordPress.org

    Every reason in the "Auto-updates blocked" list is about WooCommerce.com
    delivering the update: the Update Manager being inactive, or the subscription
    being expired or disconnected. The updater only handles products carrying a
    Woo header, though. A copy installed from WordPress.org has none, so core
    updates it on its own and none of those reasons apply. The row still showed
    the red label for it.

    Local data now reports whether the installed copy carries a Woo header, read
    from the same lists the updater iterates, and the blockers apply only then.
    The "Auto-updates are off" warning is unchanged, since that is a core setting
    and enabling it works for either kind of copy.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Check only the matching Woo list when reading a row's update source

    The Woo header check ran for every row and scanned both the plugin and the
    theme lists. The theme scan reads a theme_info.txt file for each theme without
    a header, so every plugin row paid for file reads it did not need. The check
    now takes the row's type and scans only that list.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Move auto-update controls into an "Automatic updates" column

    The auto-update state sat next to the product name as a warning label, and
    only offered to turn the setting on. It now has its own column in the
    "Installed on this store" table, between Version and Actions.

    The cell offers to enable or disable the setting. When the setting is on but
    WooCommerce.com cannot deliver the update, it shows "Blocked" with the reasons
    on hover instead, since none of them can be fixed here. A setting that cannot
    be changed from here reads "On" or "Off", with the reason on hover.

    "Available to use" keeps its columns: those rows are not the copy running on
    this store, so their auto-update state says nothing useful. A new test renders
    both tables and checks every row has one cell per header.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Update changelog entry for the automatic updates column

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Show a progress label while auto-updates are being enabled or disabled

    The link gave no sign that anything was happening between the click and the
    row refreshing. It now reads "Enabling…" or "Disabling…" with a spinning
    refresh icon, the same asset the Refresh button uses, until the request
    settles. The spin stops for users who prefer reduced motion.

    The button is disabled through aria-disabled rather than the disabled
    attribute, so keyboard focus stays on it for the whole round trip instead of
    dropping to the page. It keeps the link colour, since the state is progress
    rather than a dead control.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Report a failed refresh separately from a failed auto-update toggle

    The toggle request and the list refresh that follows shared one catch. If the
    toggle succeeded and only the refresh failed, the notice said auto-updates
    could not be enabled, which was false, and the row kept showing the old link.

    The success announcement now fires as soon as the toggle resolves, and the
    refresh carries its own notice saying the setting was saved but the list could
    not be refreshed. The outer catch only sees toggle failures.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Pass the real theme update offer to the auto_update_theme filter

    The plugin payload already came from the update transient, but the theme
    payload was always the empty defaults, so a filter deciding on new_version,
    package, or the requirements saw different data here than on the Themes
    screen, despite the docblock promising parity.

    The theme payload now comes from the update_themes transient when the theme
    has an entry, accepting the array shape core writes and the object shape
    theme update checkers write. Otherwise it is built from the theme headers,
    as wp_prepare_themes_for_js() does.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Query the auto-update pills by role in the component tests

    The Blocked, On, and Off pills are buttons, but the tests found them by
    visible text, which would keep passing if the pill lost its button semantics
    and its keyboard access with it. They are now queried by role and accessible
    name, matching the link controls in the same file and the admin client's
    query-order guideline. The exact name also proves the info icon stays
    decorative.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Add per-request cache to local Woo plugin and theme scans

    get_local_woo_plugins() and get_local_woo_themes() rescan every installed
    plugin and theme on each call, and the My Subscriptions list calls them
    once per row. Cache the result in static properties for the rest of the
    request behind a new $use_cache parameter that defaults to true.

    Passing false rescans and refreshes the cache, which the activated-plugin
    and connect-theme handlers use since the product may have been installed
    earlier in the same request. The upgrader-complete handler flushes the
    cache for the same reason, and the helper tests flush it in setUp.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Fix setProductAutoUpdate JSDoc to cover themes

    The endpoint it calls toggles auto-updates for the plugin or theme backing
    the subscription, writing auto_update_themes for themes, but the comment
    described it as plugin-only.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Make the auto-update cell read-only for products without a subscription

    Rows for installed products the account has no subscription for were
    offered the "Enable auto-updates" link, and saving always failed because
    the endpoint resolves the product through its subscription.

    Nothing delivers updates for such a product, so the cell now shows "Off"
    with a hint to subscribe when the setting is off, and the existing
    "Blocked" pill with the no-subscription reason when it is on. The
    no-subscription blocker applies regardless of where the product updates
    from, so a row without a subscription never offers a toggle.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Co-authored-by: Akeda Bagus <akeda.bagus@automattic.com>

diff --git a/plugins/woocommerce/changelog/add-wccom-2873-auto-update-status b/plugins/woocommerce/changelog/add-wccom-2873-auto-update-status
new file mode 100644
index 00000000000..d0fafcd79b9
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-wccom-2873-auto-update-status
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an "Automatic updates" column to the My Subscriptions screen that turns auto-updates on or off for installed extensions and themes, and shows "Blocked" when they are on but something else stops the product updating
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 190abbee563..664804c0080 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
@@ -206,6 +206,11 @@
 	white-space: nowrap;
 	font-size: 12px;

+	svg {
+		fill: currentColor;
+		margin-inline-end: $grid-unit-05;
+	}
+
 	&--error {
 		color: var(--wp-red-red-70, #8a2424);
 		background: var(--wp-red-red-0, #fcf0f1);
@@ -228,6 +233,24 @@
 		a {
 			text-decoration: none;
 		}
+
+		p {
+			margin: 0 0 $grid-unit-10;
+		}
+	}
+}
+
+.woocommerce-marketplace__my-subscriptions__auto-update-blockers {
+	list-style: disc;
+	margin: 0;
+	padding-inline-start: $grid-unit-20;
+
+	li {
+		margin-bottom: $grid-unit-05;
+
+		&:last-child {
+			margin-bottom: 0;
+		}
 	}
 }

@@ -238,11 +261,43 @@
 .woocommerce-marketplace__my-subscriptions-version {
 	padding: 6px 12px;
 }
-.woocommerce-marketplace__my-subscriptions__table__header--version > span {
+.woocommerce-marketplace__my-subscriptions__table__header--version > span,
+.woocommerce-marketplace__my-subscriptions__table__header--auto-updates > span {
 	display: inline-block;
 	padding: 0 12px;
 }

+// Lines up with the link button, which carries its own 6px 12px padding.
+.woocommerce-marketplace__my-subscriptions__auto-updates {
+	.woocommerce-marketplace__my-subscriptions__product-status {
+		margin: 6px 12px;
+	}
+
+	// A disabled link button greys out; the saving state is progress, not a dead control.
+	.components-button.is-link:disabled,
+	.components-button.is-link[aria-disabled="true"] {
+		color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
+		opacity: 1;
+	}
+}
+
+.woocommerce-marketplace__my-subscriptions__auto-updates-saving-icon {
+	width: 16px;
+	height: 16px;
+	margin-inline-end: $grid-unit-05;
+	animation: woocommerce-marketplace-auto-updates-spin 1s linear infinite;
+
+	@media (prefers-reduced-motion: reduce) {
+		animation: none;
+	}
+}
+
+@keyframes woocommerce-marketplace-auto-updates-spin {
+	to {
+		transform: rotate(360deg);
+	}
+}
+
 .woocommerce-marketplace__my-subscriptions {
 	.woocommerce-table__empty-item,
 	.woocommerce-table__header,
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/auto-update-status.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/auto-update-status.tsx
new file mode 100644
index 00000000000..48aae7c0c69
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/auto-update-status.tsx
@@ -0,0 +1,245 @@
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { useContext, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { speak } from '@wordpress/a11y';
+import { Icon, info } from '@wordpress/icons';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import RefreshIcon from '../../../../assets/images/refresh.svg';
+import { SubscriptionsContext } from '../../../../contexts/subscriptions-context';
+import { NoticeStatus } from '../../../../contexts/types';
+import {
+	addNotice,
+	removeNotice,
+	setProductAutoUpdate,
+} from '../../../../utils/functions';
+import { StatusLevel, Subscription } from '../../types';
+import StatusPopover from './status-popover';
+import { getAdminSetting } from '../../../../../utils/admin-settings';
+
+/**
+ * Reasons a product won't auto-update even though the plugin's own auto-update setting is on.
+ *
+ * A product without a subscription is always blocked. The other reasons are about
+ * WooCommerce.com delivering the update, so none of them applies to a copy installed from
+ * WordPress.org: core updates that one on its own.
+ *
+ * Whether WordPress runs automatic updates at all is deliberately not one of them: that is a
+ * site-wide decision, and repeating it on every row says nothing about the product.
+ */
+export function getAutoUpdateBlockers( subscription: Subscription ): string[] {
+	const blockers: string[] = [];
+
+	if ( subscription.product_key === '' ) {
+		blockers.push(
+			__( 'There is no subscription for it.', 'woocommerce' )
+		);
+
+		return blockers;
+	}
+
+	if ( ! subscription.local?.updates_from_wccom ) {
+		return blockers;
+	}
+
+	const wccomSettings = getAdminSetting( 'wccomHelper', {} );
+
+	if ( ! wccomSettings?.wooUpdateManagerActive ) {
+		blockers.push(
+			__(
+				'WooCommerce.com Update Manager is not active, and it delivers these updates.',
+				'woocommerce'
+			)
+		);
+	}
+
+	if ( subscription.expired && ! subscription.lifetime ) {
+		blockers.push( __( 'The subscription has expired.', 'woocommerce' ) );
+	}
+
+	if ( ! subscription.active ) {
+		blockers.push(
+			__(
+				'The subscription is not connected to this store.',
+				'woocommerce'
+			)
+		);
+	}
+
+	return blockers;
+}
+
+/**
+ * The "Automatic updates" cell of an installed row.
+ *
+ * Offers to turn the plugin's or theme's auto-update setting on or off. When the setting is on
+ * but something else holds the update back, shows "Blocked" with the reasons instead, since none
+ * of them can be fixed here. A setting that can't be changed from here is shown as plain text,
+ * and so is a product without a subscription, since nothing would deliver its updates.
+ */
+export default function AutoUpdateStatus( props: {
+	subscription: Subscription;
+} ): React.JSX.Element | null {
+	const { subscription } = props;
+	const { loadSubscriptions } = useContext( SubscriptionsContext );
+	const [ isSaving, setIsSaving ] = useState( false );
+
+	const local = subscription.local;
+
+	if ( ! local?.installed ) {
+		return null;
+	}
+
+	function setAutoUpdate( enabled: boolean ) {
+		recordEvent(
+			enabled
+				? 'marketplace_enable_auto_update_clicked'
+				: 'marketplace_disable_auto_update_clicked',
+			{
+				product_id: subscription.product_id,
+				product_zip_slug: subscription.zip_slug,
+			}
+		);
+
+		setIsSaving( true );
+		removeNotice( subscription.product_key );
+
+		setProductAutoUpdate( subscription, enabled )
+			.then( () => {
+				const announcement = enabled
+					? /* translators: %s is the product name. */
+					  __( 'Auto-updates enabled for %s.', 'woocommerce' )
+					: /* translators: %s is the product name. */
+					  __( 'Auto-updates disabled for %s.', 'woocommerce' );
+
+				speak( sprintf( announcement, subscription.product_name ) );
+
+				// The setting is saved by now. A failed refresh only leaves the row stale, so it
+				// gets its own notice rather than the toggle failure below.
+				return loadSubscriptions( false ).catch( () =>
+					addNotice(
+						subscription.product_key,
+						__(
+							'The setting was saved, but the list could not be refreshed. Reload the page to see the change.',
+							'woocommerce'
+						),
+						NoticeStatus.Error
+					)
+				);
+			} )
+			.catch( ( error: { data?: { message?: string } } ) => {
+				const failure = enabled
+					? __( 'Auto-updates could not be enabled.', 'woocommerce' )
+					: __(
+							'Auto-updates could not be disabled.',
+							'woocommerce'
+					  );
+
+				// The endpoint answers with wp_send_json_error(), which nests the reason under data.
+				const reason = error?.data?.message;
+
+				addNotice(
+					subscription.product_key,
+					reason ? `${ failure } ${ reason }` : failure,
+					NoticeStatus.Error
+				);
+			} )
+			.finally( () => setIsSaving( false ) );
+	}
+
+	const blockers = local.auto_update
+		? getAutoUpdateBlockers( subscription )
+		: [];
+
+	if ( blockers.length > 0 ) {
+		return (
+			<StatusPopover
+				icon={ <Icon icon={ info } size={ 16 } /> }
+				text={ __( 'Blocked', 'woocommerce' ) }
+				level={ StatusLevel.Error }
+				explanation={
+					<>
+						<p>
+							{ __(
+								'Auto-updates are on, but it will not update because:',
+								'woocommerce'
+							) }
+						</p>
+						<ul className="woocommerce-marketplace__my-subscriptions__auto-update-blockers">
+							{ blockers.map( ( blocker ) => (
+								<li key={ blocker }>{ blocker }</li>
+							) ) }
+						</ul>
+					</>
+				}
+				explanationOnHover
+			/>
+		);
+	}
+
+	if ( ! local.auto_update_manageable ) {
+		return (
+			<StatusPopover
+				text={
+					local.auto_update
+						? __( 'On', 'woocommerce' )
+						: __( 'Off', 'woocommerce' )
+				}
+				level={ StatusLevel.Info }
+				explanation={ __(
+					'Auto-updates for this product are controlled outside this screen.',
+					'woocommerce'
+				) }
+				explanationOnHover
+			/>
+		);
+	}
+
+	// Nothing delivers updates for a product without a subscription, so there is nothing to
+	// enable. Once the setting is on, the Blocked pill above says the same thing.
+	if ( subscription.product_key === '' && ! local.auto_update ) {
+		return (
+			<StatusPopover
+				text={ __( 'Off', 'woocommerce' ) }
+				level={ StatusLevel.Info }
+				explanation={ __(
+					'Subscribe to enable auto-updates for this product.',
+					'woocommerce'
+				) }
+				explanationOnHover
+			/>
+		);
+	}
+
+	if ( isSaving ) {
+		return (
+			<Button variant="link" disabled accessibleWhenDisabled>
+				<img
+					src={ RefreshIcon }
+					alt=""
+					className="woocommerce-marketplace__my-subscriptions__auto-updates-saving-icon"
+				/>
+				{ local.auto_update
+					? __( 'Disabling…', 'woocommerce' )
+					: __( 'Enabling…', 'woocommerce' ) }
+			</Button>
+		);
+	}
+
+	return (
+		<Button
+			variant="link"
+			onClick={ () => setAutoUpdate( ! local.auto_update ) }
+		>
+			{ local.auto_update
+				? __( 'Disable auto-updates', 'woocommerce' )
+				: __( 'Enable auto-updates', 'woocommerce' ) }
+		</Button>
+	);
+}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/functions.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/functions.tsx
index 597d2ae10f1..93cd691693d 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/functions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/functions.tsx
@@ -18,6 +18,7 @@ import AutoRenewButton from '../actions/auto-renew-button';
 import SubscribeButton from '../actions/subscribe-button';
 import Update from '../actions/update';
 import StatusPopover from './status-popover';
+import AutoUpdateStatus from './auto-update-status';
 import ActionsDropdownMenu from './actions-dropdown-menu';
 import Version from './version';
 import {
@@ -423,6 +424,16 @@ export function version(
 	};
 }

+export function autoUpdates( subscription: Subscription ): TableRow {
+	return {
+		display: (
+			<div className="woocommerce-marketplace__my-subscriptions__auto-updates">
+				<AutoUpdateStatus subscription={ subscription } />
+			</div>
+		),
+	};
+}
+
 export function actions( subscription: Subscription ): TableRow {
 	let actionButton = null;
 	if ( subscription.product_key === '' ) {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/status-popover.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/status-popover.tsx
index 337c1fd4367..8eba3c9c6e5 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/status-popover.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/status-popover.tsx
@@ -15,6 +15,7 @@ export default function StatusPopover( props: {
 	level: StatusLevel;
 	explanation: string | React.JSX.Element;
 	explanationOnHover?: boolean;
+	icon?: React.JSX.Element;
 } ) {
 	const [ isHovered, setIsHovered ] = useState( false );
 	const [ isClicked, setIsClicked ] = useState( false );
@@ -76,6 +77,7 @@ export default function StatusPopover( props: {
 				`woocommerce-marketplace__my-subscriptions__product-status--${ props.level }`
 			) }
 		>
+			{ props.icon }
 			{ props.text }
 			{ shouldShowExplanation() && (
 				<Popover
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/test/auto-update-status.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/test/auto-update-status.test.tsx
new file mode 100644
index 00000000000..3929f420612
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/rows/test/auto-update-status.test.tsx
@@ -0,0 +1,459 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import React from 'react';
+
+jest.mock( '@woocommerce/tracks', () => ( {
+	recordEvent: jest.fn(),
+	queueRecordEvent: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/a11y', () => ( {
+	speak: jest.fn(),
+} ) );
+
+jest.mock( '../../../../../../utils/admin-settings', () => ( {
+	getAdminSetting: jest.fn(),
+} ) );
+
+jest.mock( '../../../../../utils/functions', () => ( {
+	setProductAutoUpdate: jest.fn( () => Promise.resolve() ),
+	addNotice: jest.fn(),
+	removeNotice: jest.fn(),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { speak } from '@wordpress/a11y';
+import {
+	setProductAutoUpdate,
+	addNotice,
+} from '../../../../../utils/functions';
+import { getAdminSetting } from '../../../../../../utils/admin-settings';
+import AutoUpdateStatus from '../auto-update-status';
+import { SubscriptionsContext } from '../../../../../contexts/subscriptions-context';
+import { SubscriptionsContextType } from '../../../../../contexts/types';
+import { Subscription, SubscriptionLocal } from '../../../types';
+
+const loadSubscriptions = jest.fn( () => Promise.resolve() );
+
+function subscriptionWith(
+	local: Partial< SubscriptionLocal >,
+	subscription: Partial< Subscription > = {}
+): Subscription {
+	return {
+		product_key: 'test-key',
+		product_id: 123,
+		product_name: 'Test Extension',
+		zip_slug: 'test-extension',
+		expired: false,
+		lifetime: false,
+		active: true,
+		...subscription,
+		local: {
+			installed: true,
+			installable: true,
+			active: true,
+			version: '1.0.0',
+			type: 'plugin',
+			slug: 'test-extension',
+			path: 'test-extension/test-extension.php',
+			auto_update: false,
+			auto_update_manageable: true,
+			updates_from_wccom: true,
+			...local,
+		},
+	} as Subscription;
+}
+
+function renderStatus( subscription: Subscription ) {
+	return render(
+		<SubscriptionsContext.Provider
+			value={
+				{ loadSubscriptions } as unknown as SubscriptionsContextType
+			}
+		>
+			<AutoUpdateStatus subscription={ subscription } />
+		</SubscriptionsContext.Provider>
+	);
+}
+
+/**
+ * Stand in the site-level settings, healthy unless a test says otherwise.
+ */
+function setSiteSettings( settings: Record< string, boolean > = {} ) {
+	( getAdminSetting as jest.Mock ).mockReturnValue( {
+		wooUpdateManagerActive: true,
+		...settings,
+	} );
+}
+
+describe( 'AutoUpdateStatus', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		setSiteSettings();
+	} );
+
+	it( 'renders nothing when the product is not installed', () => {
+		const { container } = renderStatus(
+			subscriptionWith( { installed: false } )
+		);
+
+		expect( container ).toBeEmptyDOMElement();
+	} );
+
+	it( 'offers to enable auto-updates when they are off', () => {
+		renderStatus( subscriptionWith( {} ) );
+
+		expect(
+			screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+		).toBeInTheDocument();
+	} );
+
+	it( 'offers to disable auto-updates when they are on and nothing blocks them', () => {
+		renderStatus( subscriptionWith( { auto_update: true } ) );
+
+		expect(
+			screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+		).toBeInTheDocument();
+		expect(
+			screen.queryByRole( 'button', { name: 'Blocked' } )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'shows the setting as text when it cannot be changed from here', async () => {
+		renderStatus(
+			subscriptionWith( {
+				auto_update: true,
+				auto_update_manageable: false,
+			} )
+		);
+
+		expect(
+			screen.queryByRole( 'button', { name: /auto-updates/ } )
+		).not.toBeInTheDocument();
+		fireEvent.click( screen.getByRole( 'button', { name: 'On' } ) );
+
+		expect(
+			await screen.findByText(
+				'Auto-updates for this product are controlled outside this screen.'
+			)
+		).toBeInTheDocument();
+	} );
+
+	it( 'shows off as text when it cannot be changed from here', () => {
+		renderStatus( subscriptionWith( { auto_update_manageable: false } ) );
+
+		expect(
+			screen.getByRole( 'button', { name: 'Off' } )
+		).toBeInTheDocument();
+	} );
+
+	it( 'does not offer to enable auto-updates without a subscription', async () => {
+		renderStatus(
+			subscriptionWith(
+				{},
+				{ product_key: '', expired: true, active: false }
+			)
+		);
+
+		expect(
+			screen.queryByRole( 'button', { name: /auto-updates/ } )
+		).not.toBeInTheDocument();
+		fireEvent.click( screen.getByRole( 'button', { name: 'Off' } ) );
+
+		expect(
+			await screen.findByText(
+				'Subscribe to enable auto-updates for this product.'
+			)
+		).toBeInTheDocument();
+	} );
+
+	it( 'treats a theme like a plugin', () => {
+		renderStatus(
+			subscriptionWith( { type: 'theme', auto_update: true } )
+		);
+
+		expect(
+			screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+		).toBeInTheDocument();
+	} );
+
+	it( 'never blocks a copy installed from WordPress.org', () => {
+		setSiteSettings( { wooUpdateManagerActive: false } );
+		renderStatus(
+			subscriptionWith(
+				{ auto_update: true, updates_from_wccom: false },
+				{ expired: true, active: false }
+			)
+		);
+
+		expect(
+			screen.queryByRole( 'button', { name: 'Blocked' } )
+		).not.toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+		).toBeInTheDocument();
+	} );
+
+	it( 'blocks a product without a subscription even when it does not update from WooCommerce.com', async () => {
+		renderStatus(
+			subscriptionWith(
+				{ auto_update: true, updates_from_wccom: false },
+				{ product_key: '' }
+			)
+		);
+
+		expect(
+			screen.queryByRole( 'button', { name: 'Disable auto-updates' } )
+		).not.toBeInTheDocument();
+		fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+		expect(
+			await screen.findByText( 'There is no subscription for it.' )
+		).toBeInTheDocument();
+	} );
+
+	it( 'shows blocked when the Update Manager is not active', async () => {
+		setSiteSettings( { wooUpdateManagerActive: false } );
+		renderStatus( subscriptionWith( { auto_update: true } ) );
+
+		fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+		expect(
+			await screen.findByText(
+				'WooCommerce.com Update Manager is not active, and it delivers these updates.'
+			)
+		).toBeInTheDocument();
+	} );
+
+	it( 'shows blocked for a theme for the same reasons as a plugin', async () => {
+		setSiteSettings( { wooUpdateManagerActive: false } );
+		renderStatus(
+			subscriptionWith( { type: 'theme', auto_update: true } )
+		);
+
+		fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+		expect(
+			await screen.findByText(
+				'WooCommerce.com Update Manager is not active, and it delivers these updates.'
+			)
+		).toBeInTheDocument();
+	} );
+
+	it( 'does not show blocked when auto-updates are off', () => {
+		setSiteSettings( { wooUpdateManagerActive: false } );
+		renderStatus( subscriptionWith( {} ) );
+
+		expect(
+			screen.queryByRole( 'button', { name: 'Blocked' } )
+		).not.toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+		).toBeInTheDocument();
+	} );
+
+	it( 'reports a missing subscription and stops there', async () => {
+		renderStatus(
+			subscriptionWith(
+				{ auto_update: true },
+				{ product_key: '', expired: true, active: false }
+			)
+		);
+
+		fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+		expect(
+			await screen.findByText( 'There is no subscription for it.' )
+		).toBeInTheDocument();
+		expect(
+			screen.queryByText( 'The subscription has expired.' )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'lists an expired subscription and a disconnected one together', async () => {
+		renderStatus(
+			subscriptionWith(
+				{ auto_update: true },
+				{ expired: true, active: false }
+			)
+		);
+
+		fireEvent.click( screen.getByRole( 'button', { name: 'Blocked' } ) );
+
+		expect(
+			await screen.findByText( 'The subscription has expired.' )
+		).toBeInTheDocument();
+		expect(
+			screen.getByText(
+				'The subscription is not connected to this store.'
+			)
+		).toBeInTheDocument();
+	} );
+
+	it( 'does not treat a lifetime subscription as expired', () => {
+		renderStatus(
+			subscriptionWith(
+				{ auto_update: true },
+				{ expired: true, lifetime: true }
+			)
+		);
+
+		expect(
+			screen.queryByRole( 'button', { name: 'Blocked' } )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'enables auto-updates, refreshes the row and announces the change', async () => {
+		const subscription = subscriptionWith( {} );
+		renderStatus( subscription );
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+		);
+
+		await waitFor( () =>
+			expect( setProductAutoUpdate ).toHaveBeenCalledWith(
+				subscription,
+				true
+			)
+		);
+		await waitFor( () =>
+			expect( loadSubscriptions ).toHaveBeenCalledWith( false )
+		);
+		await waitFor( () =>
+			expect( speak ).toHaveBeenCalledWith(
+				'Auto-updates enabled for Test Extension.'
+			)
+		);
+	} );
+
+	it( 'disables auto-updates and announces the change', async () => {
+		const subscription = subscriptionWith( { auto_update: true } );
+		renderStatus( subscription );
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+		);
+
+		await waitFor( () =>
+			expect( setProductAutoUpdate ).toHaveBeenCalledWith(
+				subscription,
+				false
+			)
+		);
+		await waitFor( () =>
+			expect( speak ).toHaveBeenCalledWith(
+				'Auto-updates disabled for Test Extension.'
+			)
+		);
+	} );
+
+	it( 'reports a failed refresh without calling the saved toggle a failure', async () => {
+		loadSubscriptions.mockRejectedValueOnce( new Error( 'offline' ) );
+		renderStatus( subscriptionWith( {} ) );
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+		);
+
+		await waitFor( () =>
+			expect( addNotice ).toHaveBeenCalledWith(
+				'test-key',
+				'The setting was saved, but the list could not be refreshed. Reload the page to see the change.',
+				'error'
+			)
+		);
+		expect( speak ).toHaveBeenCalledWith(
+			'Auto-updates enabled for Test Extension.'
+		);
+		expect( addNotice ).not.toHaveBeenCalledWith(
+			'test-key',
+			expect.stringContaining( 'could not be enabled' ),
+			'error'
+		);
+	} );
+
+	it( 'shows a progress label while enabling', async () => {
+		let finish: () => void = () => {};
+		( setProductAutoUpdate as jest.Mock ).mockReturnValueOnce(
+			new Promise< void >( ( resolve ) => {
+				finish = resolve;
+			} )
+		);
+		renderStatus( subscriptionWith( {} ) );
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+		);
+
+		// Disabled through aria-disabled, so focus stays on the control while it works.
+		expect(
+			await screen.findByRole( 'button', { name: 'Enabling…' } )
+		).toHaveAttribute( 'aria-disabled', 'true' );
+
+		finish();
+
+		expect(
+			await screen.findByRole( 'button', { name: 'Enable auto-updates' } )
+		).not.toHaveAttribute( 'aria-disabled' );
+	} );
+
+	it( 'shows a progress label while disabling', async () => {
+		( setProductAutoUpdate as jest.Mock ).mockReturnValueOnce(
+			new Promise( () => {} )
+		);
+		renderStatus( subscriptionWith( { auto_update: true } ) );
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+		);
+
+		expect(
+			await screen.findByRole( 'button', { name: 'Disabling…' } )
+		).toHaveAttribute( 'aria-disabled', 'true' );
+	} );
+
+	it( "surfaces the endpoint's reason when enabling fails", async () => {
+		// The shape wp_send_json_error() produces.
+		( setProductAutoUpdate as jest.Mock ).mockRejectedValueOnce( {
+			success: false,
+			data: { message: 'There is no subscription for this product.' },
+		} );
+		renderStatus( subscriptionWith( {} ) );
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+		);
+
+		await waitFor( () =>
+			expect( addNotice ).toHaveBeenCalledWith(
+				'test-key',
+				'Auto-updates could not be enabled. There is no subscription for this product.',
+				'error'
+			)
+		);
+	} );
+
+	it( 'falls back to a generic notice when disabling fails without a reason', async () => {
+		( setProductAutoUpdate as jest.Mock ).mockRejectedValueOnce( {
+			code: 'invalid_json',
+		} );
+		renderStatus( subscriptionWith( { auto_update: true } ) );
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: 'Disable auto-updates' } )
+		);
+
+		await waitFor( () =>
+			expect( addNotice ).toHaveBeenCalledWith(
+				'test-key',
+				'Auto-updates could not be disabled.',
+				'error'
+			)
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table-rows.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table-rows.tsx
index a2407427ba0..bc01e325ed2 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table-rows.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table-rows.tsx
@@ -8,6 +8,7 @@ import type { TableRow } from '@woocommerce/components/build-types/table/types';
 import { Subscription, MySubscriptionsTable } from '../types';
 import {
 	actions,
+	autoUpdates,
 	subscriptionStatus,
 	expiry,
 	nameAndStatus,
@@ -18,11 +19,20 @@ export function subscriptionRow(
 	item: Subscription,
 	table: MySubscriptionsTable
 ): TableRow[] {
-	return [
+	const row = [
 		nameAndStatus( item ),
 		expiry( item ),
 		subscriptionStatus( item, table ),
 		version( item, table ),
-		actions( item ),
 	];
+
+	// Rows in "Available to use" are not the copy running on this store, so their auto-update
+	// state says nothing useful.
+	if ( table === 'installed' ) {
+		row.push( autoUpdates( item ) );
+	}
+
+	row.push( actions( item ) );
+
+	return row;
 }
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table.tsx
index c52d1a23bf1..18eb7fd37ab 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/table.tsx
@@ -72,6 +72,10 @@ export function InstalledSubscriptionsTable( props: {
 } ) {
 	const headers = [
 		...tableHeadersDefault,
+		{
+			key: 'auto-updates',
+			label: __( 'Automatic updates', 'woocommerce' ),
+		},
 		{
 			key: 'actions',
 			label: __( 'Actions', 'woocommerce' ),
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/test/table-rows.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/test/table-rows.test.tsx
new file mode 100644
index 00000000000..11c275f3cf1
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/test/table-rows.test.tsx
@@ -0,0 +1,92 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+jest.mock( '@woocommerce/tracks', () => ( {
+	recordEvent: jest.fn(),
+	queueRecordEvent: jest.fn(),
+} ) );
+
+jest.mock( '../../../../../utils/admin-settings', () => ( {
+	getAdminSetting: jest.fn( () => ( { wooUpdateManagerActive: true } ) ),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { subscriptionRow } from '../table-rows';
+import {
+	AvailableSubscriptionsTable,
+	InstalledSubscriptionsTable,
+} from '../table';
+import { Subscription } from '../../types';
+
+const subscription = {
+	product_key: 'test-key',
+	product_id: 123,
+	product_name: 'Test Extension',
+	product_url: 'https://woocommerce.com/products/test-extension/',
+	zip_slug: 'test-extension',
+	expires: 0,
+	expired: false,
+	expiring: false,
+	lifetime: false,
+	autorenew: true,
+	active: true,
+	is_shared: false,
+	version: '1.0.0',
+	local: {
+		installed: true,
+		installable: true,
+		active: true,
+		version: '1.0.0',
+		type: 'plugin',
+		slug: 'test-extension',
+		path: 'test-extension/test-extension.php',
+		auto_update: false,
+		auto_update_manageable: true,
+		updates_from_wccom: true,
+	},
+} as unknown as Subscription;
+
+describe( 'subscriptionRow', () => {
+	it( 'gives installed rows one cell per header, including automatic updates', () => {
+		render(
+			<InstalledSubscriptionsTable
+				rows={ [ subscriptionRow( subscription, 'installed' ) ] }
+				isLoading={ false }
+			/>
+		);
+
+		expect( screen.getAllByRole( 'cell' ) ).toHaveLength(
+			screen.getAllByRole( 'columnheader' ).length
+		);
+		expect(
+			screen.getByRole( 'columnheader', { name: 'Automatic updates' } )
+		).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', { name: 'Enable auto-updates' } )
+		).toBeInTheDocument();
+	} );
+
+	it( 'gives available rows one cell per header, without automatic updates', () => {
+		render(
+			<AvailableSubscriptionsTable
+				rows={ [ subscriptionRow( subscription, 'available' ) ] }
+				isLoading={ false }
+			/>
+		);
+
+		expect( screen.getAllByRole( 'cell' ) ).toHaveLength(
+			screen.getAllByRole( 'columnheader' ).length
+		);
+		expect(
+			screen.queryByRole( 'columnheader', { name: 'Automatic updates' } )
+		).not.toBeInTheDocument();
+		expect(
+			screen.queryByRole( 'button', { name: 'Enable auto-updates' } )
+		).not.toBeInTheDocument();
+	} );
+} );
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 5a36c796412..1b411725f2e 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
@@ -46,6 +46,12 @@ export interface SubscriptionLocal {
 	type: string;
 	slug: string;
 	path: string;
+	/** Whether WordPress will auto-update this plugin, mirroring the Plugins screen. */
+	auto_update: boolean;
+	/** Whether auto-updates can be changed from here: not forced, not disabled site-wide, and the user may update plugins. */
+	auto_update_manageable: boolean;
+	/** Whether the installed copy carries a Woo header, so WooCommerce.com delivers its updates rather than WordPress.org. */
+	updates_from_wccom: boolean;
 }

 export interface SubscriptionShare {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
index 622d03fa495..ddb85bb8752 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
@@ -309,6 +309,30 @@ function activateProductPlugin( subscription: Subscription ): Promise< void > {
 	} );
 }

+/**
+ * Turn auto-updates on or off for the plugin or theme backing a subscription.
+ *
+ * Writes the same `auto_update_plugins` / `auto_update_themes` option the
+ * WordPress Plugins and Themes screens write.
+ */
+function setProductAutoUpdate(
+	subscription: Subscription,
+	enabled: boolean
+): Promise< void > {
+	const data = new URLSearchParams();
+	data.append( 'product_key', subscription.product_key );
+	data.append( 'enabled', enabled ? '1' : '0' );
+
+	return apiFetch( {
+		path: '/wc/v3/marketplace/subscriptions/auto-update',
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/x-www-form-urlencoded',
+		},
+		body: data,
+	} );
+}
+
 function disconnectProduct( subscription: Subscription ): Promise< void > {
 	if ( subscription.active === false ) {
 		return Promise.resolve();
@@ -602,6 +626,7 @@ export {
 	appendURLParams,
 	connectProduct,
 	activateProductPlugin,
+	setProductAutoUpdate,
 	enableAutorenewalUrl,
 	fetchCategories,
 	fetchDiscoverPageData,
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
index a73e4460848..5d3b497da3c 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
@@ -110,6 +110,26 @@ class WC_Helper_Subscriptions_API {
 			)
 		);

+		register_rest_route(
+			'wc/v3',
+			'/marketplace/subscriptions/auto-update',
+			array(
+				'methods'             => 'POST',
+				'callback'            => array( __CLASS__, 'set_auto_update' ),
+				'permission_callback' => array( __CLASS__, 'get_permission' ),
+				'args'                => array(
+					'product_key' => array(
+						'required' => true,
+						'type'     => 'string',
+					),
+					'enabled'     => array(
+						'required' => true,
+						'type'     => 'boolean',
+					),
+				),
+			)
+		);
+
 		register_rest_route(
 			'wc/v3',
 			'/marketplace/subscriptions/install-url',
@@ -273,6 +293,43 @@ class WC_Helper_Subscriptions_API {
 		}
 	}

+	/**
+	 * Turn auto-updates on or off for a subscription's installed plugin or theme.
+	 *
+	 * @param WP_REST_Request<array<string, mixed>> $request Request object.
+	 *
+	 * @return void
+	 */
+	public static function set_auto_update( $request ): void {
+		if ( ! current_user_can( 'update_plugins' ) && ! current_user_can( 'update_themes' ) ) {
+			wp_send_json_error(
+				array(
+					'message' => __( 'Sorry, you are not allowed to modify plugins or themes.', 'woocommerce' ),
+				),
+				403
+			);
+		}
+
+		try {
+			WC_Helper::set_subscription_auto_update( $request->get_param( 'product_key' ), (bool) $request->get_param( 'enabled' ) );
+		} catch ( Exception $e ) {
+			wp_send_json_error(
+				array(
+					'message' => $e->getMessage(),
+				),
+				400
+			);
+		}
+
+		wp_send_json_success(
+			array(
+				'message' => $request->get_param( 'enabled' )
+					? __( 'Auto-updates are now on for this product.', 'woocommerce' )
+					: __( 'Auto-updates are now off for this product.', 'woocommerce' ),
+			)
+		);
+	}
+
 	/**
 	 * Disconnect a WooCommerce.com subscription.
 	 *
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
index 0117ebdc95f..5014be6630c 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -924,6 +924,7 @@ class WC_Helper_Updater {
 	 */
 	public static function upgrader_process_complete() {
 		delete_transient( '_woocommerce_helper_updates_count' );
+		WC_Helper::flush_local_woo_products_cache();
 	}

 	/**
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index 45e66bfb46e..9b7dfae98d4 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -28,6 +28,20 @@ class WC_Helper {
 	 */
 	public static $log;

+	/**
+	 * Per-request cache of get_local_woo_plugins(), keyed by plugin file.
+	 *
+	 * @var array|null
+	 */
+	private static $local_woo_plugins_cache = null;
+
+	/**
+	 * Per-request cache of get_local_woo_themes(), keyed by stylesheet path.
+	 *
+	 * @var array|null
+	 */
+	private static $local_woo_themes_cache = null;
+
 	private const CACHE_KEY_CONNECTION_DATA = '_woocommerce_helper_connection_data';

 	/**
@@ -1767,9 +1781,20 @@ class WC_Helper {
 	/**
 	 * Obtain a list of data about locally installed Woo extensions.
 	 *
+	 * Scanning every installed plugin is expensive, so the result is cached for the rest of the
+	 * request. Pass false after installing or removing a plugin to rescan and refresh the cache.
+	 *
+	 * @since 11.2.0 Added the `$use_cache` parameter.
+	 *
+	 * @param bool $use_cache Whether to return the cached list when one exists.
+	 *
 	 * @return array
 	 */
-	public static function get_local_woo_plugins() {
+	public static function get_local_woo_plugins( bool $use_cache = true ) {
+		if ( $use_cache && null !== self::$local_woo_plugins_cache ) {
+			return self::$local_woo_plugins_cache;
+		}
+
 		if ( ! function_exists( 'get_plugins' ) ) {
 			require_once ABSPATH . 'wp-admin/includes/plugin.php';
 		}
@@ -1826,15 +1851,28 @@ class WC_Helper {
 			$woo_plugins[ $filename ] = $data;
 		}

+		self::$local_woo_plugins_cache = $woo_plugins;
+
 		return $woo_plugins;
 	}

 	/**
 	 * Get locally installed Woo themes.
 	 *
+	 * Scanning every installed theme is expensive, so the result is cached for the rest of the
+	 * request. Pass false after installing or removing a theme to rescan and refresh the cache.
+	 *
+	 * @since 11.2.0 Added the `$use_cache` parameter.
+	 *
+	 * @param bool $use_cache Whether to return the cached list when one exists.
+	 *
 	 * @return array
 	 */
-	public static function get_local_woo_themes() {
+	public static function get_local_woo_themes( bool $use_cache = true ) {
+		if ( $use_cache && null !== self::$local_woo_themes_cache ) {
+			return self::$local_woo_themes_cache;
+		}
+
 		$themes     = wp_get_themes();
 		$woo_themes = array();

@@ -1878,6 +1916,8 @@ class WC_Helper {
 			$woo_themes[ $data['_filename'] ] = $data;
 		}

+		self::$local_woo_themes_cache = $woo_themes;
+
 		return $woo_themes;
 	}

@@ -2422,22 +2462,30 @@ class WC_Helper {

 		if ( empty( $installed_product ) ) {
 			return array(
-				'installed' => false,
-				'active'    => false,
-				'version'   => null,
-				'type'      => null,
-				'slug'      => null,
-				'path'      => null,
+				'installed'              => false,
+				'active'                 => false,
+				'version'                => null,
+				'type'                   => null,
+				'slug'                   => null,
+				'path'                   => null,
+				'auto_update'            => false,
+				'auto_update_manageable' => false,
+				'updates_from_wccom'     => false,
 			);
 		}

 		$local_data = array(
-			'installed' => true,
-			'active'    => false,
-			'version'   => $installed_product['Version'],
-			'type'      => $installed_product['_type'],
-			'slug'      => null,
-			'path'      => $installed_product['_filename'],
+			'installed'              => true,
+			'active'                 => false,
+			'version'                => $installed_product['Version'],
+			'type'                   => $installed_product['_type'],
+			'slug'                   => null,
+			'path'                   => $installed_product['_filename'],
+			'auto_update'            => false,
+			'auto_update_manageable' => false,
+			// The updater only handles products carrying a Woo header. Without one, this copy
+			// updates from WordPress.org, and nothing WooCommerce.com-related can hold it back.
+			'updates_from_wccom'     => self::has_woo_header( $installed_product['_type'], $installed_product['_filename'] ),
 		);

 		if ( 'plugin' === $installed_product['_type'] ) {
@@ -2447,16 +2495,249 @@ class WC_Helper {
 			} elseif ( is_multisite() && is_plugin_active_for_network( $installed_product['_filename'] ) ) {
 				$local_data['active'] = true;
 			}
+
+			$local_data = array_merge( $local_data, self::get_plugin_auto_update_data( $installed_product['_filename'] ) );
 		} elseif ( 'theme' === $installed_product['_type'] ) {
 			$local_data['slug'] = $installed_product['_stylesheet'];
 			if ( in_array( $installed_product['_stylesheet'], array( get_stylesheet(), get_template() ), true ) ) {
 				$local_data['active'] = true;
 			}
+
+			$local_data = array_merge( $local_data, self::get_theme_auto_update_data( $installed_product['_stylesheet'] ) );
 		}

 		return $local_data;
 	}

+	/**
+	 * Whether an installed plugin or theme carries a Woo header, so the updater handles it.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $type     'plugin' or 'theme'.
+	 * @param string $filename Plugin file, or a theme's stylesheet followed by /style.css.
+	 *
+	 * @return bool
+	 */
+	private static function has_woo_header( string $type, string $filename ): bool {
+		$woo_products = 'theme' === $type ? self::get_local_woo_themes() : self::get_local_woo_plugins();
+
+		return isset( $woo_products[ $filename ] );
+	}
+
+	/**
+	 * Turn auto-updates on or off for the plugin or theme backing a subscription.
+	 *
+	 * Writes the same auto_update_plugins / auto_update_themes site options WordPress core's
+	 * Plugins and Themes screens write, so the change is identical to making it there.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $product_key Subscription product key.
+	 * @param bool   $enabled     Whether auto-updates should be on.
+	 *
+	 * @throws Exception If the subscription, or the product backing it, can't be acted on.
+	 *
+	 * @return void
+	 */
+	public static function set_subscription_auto_update( string $product_key, bool $enabled ): void {
+		$subscription = self::get_subscription( $product_key );
+		if ( ! is_array( $subscription ) ) {
+			throw new Exception( esc_html__( 'There is no subscription for this product.', 'woocommerce' ) );
+		}
+
+		// Resolved the same way the row itself is, by zip_slug against every installed product.
+		// _get_local_from_product_id() only sees products carrying a Woo header, which misses the
+		// WooCommerce.com products distributed through WordPress.org.
+		$local = self::get_subscription_local_data( $subscription );
+		if ( empty( $local['installed'] ) ) {
+			throw new Exception( esc_html__( 'This subscription has no installed product to update.', 'woocommerce' ) );
+		}
+
+		$is_theme = 'theme' === $local['type'];
+
+		// auto_update_themes is keyed by stylesheet, auto_update_plugins by plugin file.
+		$item_key = $is_theme ? (string) $local['slug'] : (string) $local['path'];
+		if ( '' === $item_key ) {
+			throw new Exception( esc_html__( 'This subscription has no installed product to update.', 'woocommerce' ) );
+		}
+
+		$auto_update = $is_theme
+			? self::get_theme_auto_update_data( $item_key )
+			: self::get_plugin_auto_update_data( $item_key );
+
+		if ( ! $auto_update['auto_update_manageable'] ) {
+			throw new Exception( esc_html__( "Auto-updates for this product can't be changed from here.", 'woocommerce' ) );
+		}
+
+		$all_items = $is_theme ? self::get_all_theme_keys() : self::get_all_plugin_keys();
+		if ( ! in_array( $item_key, $all_items, true ) ) {
+			throw new Exception( esc_html__( 'The product for this subscription is no longer installed.', 'woocommerce' ) );
+		}
+
+		$option       = $is_theme ? 'auto_update_themes' : 'auto_update_plugins';
+		$auto_updates = (array) get_site_option( $option, array() );
+
+		if ( $enabled ) {
+			$auto_updates[] = $item_key;
+			$auto_updates   = array_unique( $auto_updates );
+		} else {
+			$auto_updates = array_diff( $auto_updates, array( $item_key ) );
+		}
+
+		// Drop entries for products deleted since the option was last written, as core does.
+		$auto_updates = array_intersect( $auto_updates, $all_items );
+
+		update_site_option( $option, array_values( $auto_updates ) );
+	}
+
+	/**
+	 * Keys of every installed plugin, as auto_update_plugins stores them.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @return string[]
+	 */
+	private static function get_all_plugin_keys(): array {
+		require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+		// Core's own filter, documented in wp-admin/includes/class-wp-plugins-list-table.php; applied here so this
+		// path sees the same plugin list core's auto-update toggle does.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment, WooCommerce.Commenting.CommentHooks.MissingSinceComment -- Not a WooCommerce hook.
+		return array_keys( (array) apply_filters( 'all_plugins', get_plugins() ) );
+	}
+
+	/**
+	 * Stylesheets of every installed theme, as auto_update_themes stores them.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @return string[]
+	 */
+	private static function get_all_theme_keys(): array {
+		return array_keys( wp_get_themes() );
+	}
+
+	/**
+	 * Auto-update state of an installed plugin, following the same rules as WordPress core's
+	 * Plugins screen so the two screens never describe the same plugin differently.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+	 *
+	 * @return array{auto_update: bool, auto_update_manageable: bool}
+	 */
+	public static function get_plugin_auto_update_data( string $plugin_file ): array {
+		return self::get_auto_update_data( 'plugin', $plugin_file );
+	}
+
+	/**
+	 * Auto-update state of an installed theme, following the same rules as WordPress core's
+	 * Themes screen.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $stylesheet Directory name of the theme.
+	 *
+	 * @return array{auto_update: bool, auto_update_manageable: bool}
+	 */
+	public static function get_theme_auto_update_data( string $stylesheet ): array {
+		return self::get_auto_update_data( 'theme', $stylesheet );
+	}
+
+	/**
+	 * Shared auto-update state for a plugin or a theme.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param 'plugin'|'theme' $type      The product type.
+	 * @param string           $item_key Plugin file, or theme stylesheet.
+	 *
+	 * @return array{auto_update: bool, auto_update_manageable: bool}
+	 */
+	private static function get_auto_update_data( string $type, string $item_key ): array {
+		require_once ABSPATH . 'wp-admin/includes/update.php';
+
+		$forced = wp_is_auto_update_forced_for_item( $type, null, self::get_auto_update_filter_payload( $type, $item_key ) );
+		if ( ! is_null( $forced ) ) {
+			return array(
+				'auto_update'            => (bool) $forced,
+				'auto_update_manageable' => false,
+			);
+		}
+
+		$option     = 'theme' === $type ? 'auto_update_themes' : 'auto_update_plugins';
+		$capability = 'theme' === $type ? 'update_themes' : 'update_plugins';
+
+		return array(
+			// The product's own setting. AUTOMATIC_UPDATER_DISABLED and the auto-update-enabled
+			// filters switch the feature off for the whole site without changing it, which the
+			// screen deliberately does not report per row.
+			'auto_update'            => in_array( $item_key, (array) get_site_option( $option, array() ), true ),
+			// These options are network options, and core only offers the toggle from network admin
+			// on multisite. My Subscriptions is a site-level screen, so it reads only there.
+			'auto_update_manageable' => wp_is_auto_update_enabled_for_type( $type )
+				&& ! is_multisite()
+				&& current_user_can( $capability ),
+		);
+	}
+
+	/**
+	 * Payload for the auto_update_plugin / auto_update_theme filters, matching what core's list
+	 * tables pass so third-party callbacks see the same shape on both screens.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param 'plugin'|'theme' $type      The product type.
+	 * @param string           $item_key Plugin file, or theme stylesheet.
+	 *
+	 * @return object
+	 */
+	private static function get_auto_update_filter_payload( string $type, string $item_key ) {
+		if ( 'theme' === $type ) {
+			// Core stores theme entries as arrays; extensions that add their own sometimes use objects.
+			$updates     = get_site_transient( 'update_themes' );
+			$update_item = $updates->response[ $item_key ] ?? $updates->no_update[ $item_key ] ?? null;
+			if ( ! empty( $update_item ) ) {
+				return (object) $update_item;
+			}
+
+			// Same fallback as wp_prepare_themes_for_js() when the theme is not in the transient.
+			$theme = wp_get_theme( $item_key );
+
+			return (object) array(
+				'theme'        => $item_key,
+				'new_version'  => $theme->get( 'Version' ),
+				'url'          => '',
+				'package'      => '',
+				'requires'     => $theme->get( 'RequiresWP' ),
+				'requires_php' => $theme->get( 'RequiresPHP' ),
+			);
+		}
+
+		$defaults = array(
+			'id'            => $item_key,
+			'slug'          => '',
+			'plugin'        => $item_key,
+			'new_version'   => '',
+			'url'           => '',
+			'package'       => '',
+			'icons'         => array(),
+			'banners'       => array(),
+			'banners_rtl'   => array(),
+			'tested'        => '',
+			'requires_php'  => '',
+			'compatibility' => new stdClass(),
+		);
+
+		$updates     = get_site_transient( 'update_plugins' );
+		$update_item = $updates->response[ $item_key ] ?? $updates->no_update[ $item_key ] ?? null;
+		$plugin_data = is_object( $update_item ) ? (array) $update_item : array();
+
+		return (object) wp_parse_args( $plugin_data, $defaults );
+	}
+
 	/**
 	 * Runs when any plugin is activated.
 	 *
@@ -2469,7 +2750,8 @@ class WC_Helper {
 	 * @return void
 	 */
 	public static function activated_plugin( $filename ) {
-		$plugins = self::get_local_woo_plugins();
+		// The plugin may have been installed earlier in this request, after the list was cached.
+		$plugins = self::get_local_woo_plugins( false );

 		// Not a local woo plugin.
 		if ( empty( $plugins[ $filename ] ) ) {
@@ -2542,7 +2824,7 @@ class WC_Helper {
 		}

 		wp_clean_themes_cache( false );
-		$themes = self::get_local_woo_themes();
+		$themes = self::get_local_woo_themes( false );

 		$themes = array_filter(
 			$themes,
@@ -2774,6 +3056,19 @@ class WC_Helper {
 		delete_transient( '_woocommerce_helper_subscriptions' );
 	}

+	/**
+	 * Drop the per-request caches of get_local_woo_plugins() and get_local_woo_themes(),
+	 * so the next call rescans the installed products.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @return void
+	 */
+	public static function flush_local_woo_products_cache(): void {
+		self::$local_woo_plugins_cache = null;
+		self::$local_woo_themes_cache  = null;
+	}
+
 	/**
 	 * Flush product-usage-notice-rules cache.
 	 *
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 a8edcff698d..3beeb84f212 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
@@ -14,6 +14,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();
 		$this->cleanup_helper_transients();
+		WC_Helper::flush_local_woo_products_cache();
 	}

 	/**
@@ -21,10 +22,63 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
 	 */
 	public function tearDown(): void {
 		$this->cleanup_helper_transients();
+		$this->cleanup_auto_update_state();
 		unset( $_GET['page'] );
 		parent::tearDown();
 	}

+	/**
+	 * Clean up the state the auto-update tests stand in.
+	 */
+	private function cleanup_auto_update_state(): void {
+		delete_site_option( 'auto_update_plugins' );
+		delete_site_option( 'auto_update_themes' );
+		delete_site_transient( 'update_themes' );
+		wp_cache_delete( 'plugins', 'plugins' );
+		wp_set_current_user( 0 );
+		remove_all_filters( 'auto_update_plugin' );
+		remove_all_filters( 'plugins_auto_update_enabled' );
+		remove_all_filters( 'themes_auto_update_enabled' );
+		remove_all_filters( 'auto_update_theme' );
+	}
+
+	/**
+	 * Stand in a plugin list, which get_plugins() reads from the 'plugins' cache group, and log in
+	 * a user who may update plugins.
+	 *
+	 * @return void
+	 */
+	private function prepare_auto_update_env(): void {
+		require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+		// The test suite runs with background updates switched off; these tests are about what the
+		// screen reports on a site where the feature is available.
+		add_filter( 'plugins_auto_update_enabled', '__return_true' );
+		add_filter( 'themes_auto_update_enabled', '__return_true' );
+
+		$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$admin    = new WP_User( $admin_id );
+		$admin->add_cap( 'update_plugins' );
+		wp_set_current_user( $admin_id );
+
+		wp_cache_set(
+			'plugins',
+			array(
+				'' => array(
+					'test-woo-extension/test-woo-extension.php' => array(
+						'Name'    => 'Test Woo Extension',
+						'Version' => '1.0.0',
+						// Empty, as it is for a WooCommerce.com product distributed through
+						// WordPress.org. The key itself has to be present or get_local_woo_plugins()
+						// discards this stand-in and re-reads the real plugin list.
+						'Woo'     => '',
+					),
+				),
+			),
+			'plugins'
+		);
+	}
+
 	/**
 	 * Clean up transients used by WC_Helper.
 	 */
@@ -778,6 +832,44 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
 		$this->assertArrayHasKey( $woocommerce_key, $woo_plugins );
 	}

+	/**
+	 * @testdox The local Woo plugin list is cached for the request until a caller asks for a rescan.
+	 */
+	public function test_get_local_woo_plugins_is_cached_until_a_rescan_is_requested(): void {
+		$first  = array(
+			'first-woo-plugin/first-woo-plugin.php' => array(
+				'Name'    => 'First',
+				'Version' => '1.0.0',
+				'Woo'     => '111:aaa',
+			),
+		);
+		$second = array(
+			'second-woo-plugin/second-woo-plugin.php' => array(
+				'Name'    => 'Second',
+				'Version' => '1.0.0',
+				'Woo'     => '222:bbb',
+			),
+		);
+
+		wp_cache_set( 'plugins', array( '' => $first ), 'plugins' );
+		$this->assertSame( array_keys( $first ), array_keys( WC_Helper::get_local_woo_plugins() ) );
+
+		// A plugin installed after the first scan is invisible to cached calls.
+		wp_cache_set( 'plugins', array( '' => $first + $second ), 'plugins' );
+		$this->assertSame( array_keys( $first ), array_keys( WC_Helper::get_local_woo_plugins() ) );
+
+		// Asking for a rescan returns the new list and refreshes the cache for later callers.
+		$this->assertSame( array_keys( $first + $second ), array_keys( WC_Helper::get_local_woo_plugins( false ) ) );
+		$this->assertSame( array_keys( $first + $second ), array_keys( WC_Helper::get_local_woo_plugins() ) );
+
+		// Flushing drops the cache, so the next default call scans again.
+		wp_cache_set( 'plugins', array( '' => $second ), 'plugins' );
+		WC_Helper::flush_local_woo_products_cache();
+		$this->assertSame( array_keys( $second ), array_keys( WC_Helper::get_local_woo_plugins() ) );
+
+		wp_clean_plugins_cache( false );
+	}
+
 	/**
 	 * Invoke the private static WC_Helper::get_subscriptions_url().
 	 *
@@ -811,4 +903,346 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
 			$this->get_subscriptions_url()
 		);
 	}
+
+	/**
+	 * @testdox Auto-update data reports off for a plugin absent from the auto_update_plugins option.
+	 */
+	public function test_plugin_auto_update_data_reports_off_by_default(): void {
+		$this->prepare_auto_update_env();
+
+		$data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+		$this->assertFalse( $data['auto_update'], 'A plugin not listed in the option does not auto-update.' );
+		$this->assertTrue( $data['auto_update_manageable'], 'An administrator on single site can turn it on.' );
+	}
+
+	/**
+	 * @testdox Auto-update data reports on for a plugin listed in the auto_update_plugins option.
+	 */
+	public function test_plugin_auto_update_data_reports_on_when_listed(): void {
+		$this->prepare_auto_update_env();
+		update_site_option( 'auto_update_plugins', array( 'test-woo-extension/test-woo-extension.php' ) );
+
+		$data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+		$this->assertTrue( $data['auto_update'] );
+	}
+
+	/**
+	 * @testdox A forced auto-update state is reported but not offered as an action.
+	 *
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $forced The state the auto_update_plugin filter forces.
+	 */
+	public function test_plugin_auto_update_data_reports_forced_state_without_an_action( bool $forced ): void {
+		$this->prepare_auto_update_env();
+		add_filter(
+			'auto_update_plugin',
+			function () use ( $forced ) {
+				return $forced;
+			}
+		);
+
+		$data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+		$this->assertSame( $forced, $data['auto_update'], 'The forced state is what actually happens.' );
+		$this->assertFalse( $data['auto_update_manageable'], 'A forced state cannot be changed from here.' );
+	}
+
+	/**
+	 * @testdox The site-wide switch leaves the plugin's own setting alone but blocks changing it.
+	 */
+	public function test_plugin_auto_update_data_follows_the_site_wide_switch(): void {
+		$this->prepare_auto_update_env();
+		update_site_option( 'auto_update_plugins', array( 'test-woo-extension/test-woo-extension.php' ) );
+		add_filter( 'plugins_auto_update_enabled', '__return_false' );
+
+		$data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+		$this->assertTrue( $data['auto_update'], "The plugin's own setting is untouched; the screen reports the site-wide block separately." );
+		$this->assertFalse( $data['auto_update_manageable'], 'There is nothing to toggle while the feature is off site-wide.' );
+	}
+
+	/**
+	 * @testdox Auto-updates are not manageable without the update_plugins capability.
+	 */
+	public function test_plugin_auto_update_data_requires_the_update_plugins_capability(): void {
+		$this->prepare_auto_update_env();
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+		$data = WC_Helper::get_plugin_auto_update_data( 'test-woo-extension/test-woo-extension.php' );
+
+		$this->assertFalse( $data['auto_update_manageable'] );
+	}
+
+	/**
+	 * @testdox Enabling auto-updates writes the plugin into the auto_update_plugins option.
+	 */
+	public function test_set_subscription_auto_update_enables(): void {
+		$this->prepare_auto_update_env();
+		$this->set_subscription_for_local_plugin();
+
+		WC_Helper::set_subscription_auto_update( 'test-key', true );
+
+		$this->assertSame(
+			array( 'test-woo-extension/test-woo-extension.php' ),
+			(array) get_site_option( 'auto_update_plugins' )
+		);
+	}
+
+	/**
+	 * @testdox Disabling auto-updates removes the plugin from the auto_update_plugins option.
+	 */
+	public function test_set_subscription_auto_update_disables(): void {
+		$this->prepare_auto_update_env();
+		$this->set_subscription_for_local_plugin();
+		update_site_option( 'auto_update_plugins', array( 'test-woo-extension/test-woo-extension.php', 'other/other.php' ) );
+
+		WC_Helper::set_subscription_auto_update( 'test-key', false );
+
+		$this->assertSame( array(), (array) get_site_option( 'auto_update_plugins' ), 'Plugins no longer installed are dropped too.' );
+	}
+
+	/**
+	 * @testdox Setting auto-updates is refused when the state cannot be changed from here.
+	 */
+	public function test_set_subscription_auto_update_refuses_a_forced_state(): void {
+		$this->prepare_auto_update_env();
+		$this->set_subscription_for_local_plugin();
+		add_filter( 'auto_update_plugin', '__return_false' );
+
+		$this->expectException( Exception::class );
+
+		WC_Helper::set_subscription_auto_update( 'test-key', true );
+	}
+
+	/**
+	 * @testdox Auto-updates can be set for a product distributed through WordPress.org.
+	 */
+	public function test_set_subscription_auto_update_handles_a_wporg_product(): void {
+		$this->prepare_auto_update_env();
+		$this->set_subscription_for_local_plugin();
+
+		// The fixture plugin carries no Woo header, which is what a WordPress.org build looks like.
+		WC_Helper::set_subscription_auto_update( 'test-key', true );
+
+		$this->assertContains(
+			'test-woo-extension/test-woo-extension.php',
+			(array) get_site_option( 'auto_update_plugins' ),
+			'Resolution has to go through the same local data the row is built from.'
+		);
+	}
+
+	/**
+	 * @testdox Local data reports whether the installed copy carries a Woo header.
+	 */
+	public function test_local_data_reports_where_updates_come_from(): void {
+		$this->prepare_auto_update_env();
+
+		$plugins = wp_cache_get( 'plugins', 'plugins' );
+
+		$plugins['']['wccom-extension/wccom-extension.php'] = array(
+			'Name'    => 'WooCommerce.com Extension',
+			'Version' => '1.0.0',
+			'Woo'     => '456:abcdef',
+		);
+		wp_cache_set( 'plugins', $plugins, 'plugins' );
+
+		// The fixture plugin has an empty Woo header, as a WordPress.org build does.
+		$wporg = WC_Helper::get_subscription_local_data( array( 'zip_slug' => 'test-woo-extension' ) );
+		$wccom = WC_Helper::get_subscription_local_data( array( 'zip_slug' => 'wccom-extension' ) );
+		$none  = WC_Helper::get_subscription_local_data( array( 'zip_slug' => 'not-installed' ) );
+
+		$this->assertFalse( $wporg['updates_from_wccom'], 'Without a Woo header, core updates the plugin from WordPress.org.' );
+		$this->assertTrue( $wccom['updates_from_wccom'] );
+		$this->assertFalse( $none['updates_from_wccom'] );
+	}
+
+	/**
+	 * @testdox Auto-update data for a theme reads the auto_update_themes option.
+	 */
+	public function test_theme_auto_update_data_reads_the_theme_option(): void {
+		$this->prepare_auto_update_env();
+		$stylesheet = get_stylesheet();
+
+		$off = WC_Helper::get_theme_auto_update_data( $stylesheet );
+		update_site_option( 'auto_update_themes', array( $stylesheet ) );
+		$on = WC_Helper::get_theme_auto_update_data( $stylesheet );
+
+		$this->assertFalse( $off['auto_update'], 'A theme not listed in the option does not auto-update.' );
+		$this->assertTrue( $on['auto_update'] );
+		$this->assertTrue( $on['auto_update_manageable'], 'An administrator on single site can change it.' );
+	}
+
+	/**
+	 * @testdox The auto_update_theme filter receives the installed version when the theme has no update entry.
+	 */
+	public function test_theme_auto_update_filter_receives_the_installed_version(): void {
+		$this->prepare_auto_update_env();
+		$stylesheet = get_stylesheet();
+		$version    = wp_get_theme( $stylesheet )->get( 'Version' );
+		$seen       = null;
+
+		// Blocks only when the payload carries the installed version, as core's fallback does.
+		add_filter(
+			'auto_update_theme',
+			function ( $update, $item ) use ( &$seen, $version ) {
+				$seen = $item;
+				return $item->new_version === $version ? false : $update;
+			},
+			10,
+			2
+		);
+
+		$data = WC_Helper::get_theme_auto_update_data( $stylesheet );
+
+		$this->assertSame( $version, $seen->new_version, 'The fallback carries the same fields core passes.' );
+		$this->assertFalse( $data['auto_update_manageable'], 'The filter could decide on that field, so the state is forced.' );
+	}
+
+	/**
+	 * @testdox The auto_update_theme filter receives the update offer when the theme has one.
+	 */
+	public function test_theme_auto_update_filter_receives_the_update_offer(): void {
+		$this->prepare_auto_update_env();
+		$stylesheet = get_stylesheet();
+		$seen       = null;
+
+		set_site_transient(
+			'update_themes',
+			(object) array(
+				'response' => array(
+					$stylesheet => array(
+						'theme'       => $stylesheet,
+						'new_version' => '99.0.0',
+						'package'     => 'https://example.com/theme.zip',
+					),
+				),
+			)
+		);
+		add_filter(
+			'auto_update_theme',
+			function ( $update, $item ) use ( &$seen ) {
+				$seen = $item;
+				return $update;
+			},
+			10,
+			2
+		);
+
+		WC_Helper::get_theme_auto_update_data( $stylesheet );
+
+		$this->assertSame( '99.0.0', $seen->new_version );
+		$this->assertSame( 'https://example.com/theme.zip', $seen->package );
+	}
+
+	/**
+	 * @testdox Theme auto-updates are not manageable without the update_themes capability.
+	 */
+	public function test_theme_auto_update_data_requires_the_update_themes_capability(): void {
+		$this->prepare_auto_update_env();
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+		$data = WC_Helper::get_theme_auto_update_data( get_stylesheet() );
+
+		$this->assertFalse( $data['auto_update_manageable'] );
+	}
+
+	/**
+	 * @testdox Enabling auto-updates for a theme writes the stylesheet into auto_update_themes.
+	 */
+	public function test_set_subscription_auto_update_enables_a_theme(): void {
+		$this->prepare_auto_update_env();
+		$stylesheet = get_stylesheet();
+		$this->set_subscription_for_local_theme( $stylesheet );
+
+		WC_Helper::set_subscription_auto_update( 'test-theme-key', true );
+
+		$this->assertContains(
+			$stylesheet,
+			(array) get_site_option( 'auto_update_themes' ),
+			'Themes are keyed by stylesheet, not by a file path.'
+		);
+	}
+
+	/**
+	 * @testdox Disabling auto-updates for a theme removes the stylesheet from auto_update_themes.
+	 */
+	public function test_set_subscription_auto_update_disables_a_theme(): void {
+		$this->prepare_auto_update_env();
+		$stylesheet = get_stylesheet();
+		$this->set_subscription_for_local_theme( $stylesheet );
+		update_site_option( 'auto_update_themes', array( $stylesheet ) );
+
+		WC_Helper::set_subscription_auto_update( 'test-theme-key', false );
+
+		$this->assertNotContains( $stylesheet, (array) get_site_option( 'auto_update_themes' ) );
+	}
+
+	/**
+	 * Stand in a subscription whose product is the active theme.
+	 *
+	 * @param string $stylesheet Directory name of the theme.
+	 * @return void
+	 */
+	private function set_subscription_for_local_theme( string $stylesheet ): void {
+		set_transient(
+			'_woocommerce_helper_subscriptions',
+			array(
+				array(
+					'product_key'  => 'test-theme-key',
+					'product_id'   => 456,
+					'product_name' => 'Test Theme',
+					'zip_slug'     => $stylesheet,
+					'connections'  => array(),
+					'expired'      => false,
+					'expiring'     => false,
+					'lifetime'     => false,
+					'autorenew'    => true,
+					'expires'      => time() + DAY_IN_SECONDS,
+				),
+			),
+			HOUR_IN_SECONDS
+		);
+	}
+
+	/**
+	 * @testdox Setting auto-updates is refused for an unknown subscription.
+	 */
+	public function test_set_subscription_auto_update_refuses_an_unknown_subscription(): void {
+		$this->prepare_auto_update_env();
+		$this->set_subscription_for_local_plugin();
+
+		$this->expectException( Exception::class );
+
+		WC_Helper::set_subscription_auto_update( 'no-such-key', true );
+	}
+
+	/**
+	 * Stand in a subscription whose product is the plugin prepare_auto_update_env() installs.
+	 *
+	 * @return void
+	 */
+	private function set_subscription_for_local_plugin(): void {
+		set_transient(
+			'_woocommerce_helper_subscriptions',
+			array(
+				array(
+					'product_key'  => 'test-key',
+					'product_id'   => 123,
+					'product_name' => 'Test Woo Extension',
+					'zip_slug'     => 'test-woo-extension',
+					'connections'  => array(),
+					'expired'      => false,
+					'expiring'     => false,
+					'lifetime'     => false,
+					'autorenew'    => true,
+					'expires'      => time() + DAY_IN_SECONDS,
+				),
+			),
+			HOUR_IN_SECONDS
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
index b7778655dd2..d115090056a 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
@@ -47,6 +47,7 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
 		parent::setUp();

 		$this->cleanup_transients();
+		WC_Helper::flush_local_woo_products_cache();
 	}

 	/**