diff --git a/projects/packages/newsletter/_inc/subscribers/components/modals/comp-modal.tsx b/projects/packages/newsletter/_inc/subscribers/components/modals/comp-modal.tsx
index e366eb5f537..cd1f26132ac 100644
--- a/projects/packages/newsletter/_inc/subscribers/components/modals/comp-modal.tsx
+++ b/projects/packages/newsletter/_inc/subscribers/components/modals/comp-modal.tsx
@@ -225,16 +225,6 @@ export default function CompModal( { subscriber, onClose }: Props ): JSX.Element
) : null }
- { ! productsQuery.isLoading && products.length === 0 && ! productsQuery.isError ? (
-
-
- { __(
- 'You don’t have any paid newsletter plans configured on this site yet.',
- 'jetpack-newsletter'
- ) }
-
-
- ) : null }
{ allComped ? (
diff --git a/projects/packages/newsletter/_inc/subscribers/components/subscribers-data-views.tsx b/projects/packages/newsletter/_inc/subscribers/components/subscribers-data-views.tsx
index 4cd4eacf0a9..0995ebc9286 100644
--- a/projects/packages/newsletter/_inc/subscribers/components/subscribers-data-views.tsx
+++ b/projects/packages/newsletter/_inc/subscribers/components/subscribers-data-views.tsx
@@ -2,6 +2,7 @@ import { DataViews } from '@wordpress/dataviews';
import { useCallback, useMemo, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Notice } from '@wordpress/ui';
+import { useMembershipsProducts } from '../data/use-memberships-products';
import { useSubscriberRemoveMutation } from '../data/use-subscriber-remove-mutation';
import { useSubscribers } from '../data/use-subscribers';
import {
@@ -92,6 +93,20 @@ export default function SubscribersDataViews( {
const { data, isLoading, error } = useSubscribers( queryParams );
const removeMutation = useSubscriberRemoveMutation();
+ // Fetch the site's paid products once for the whole table (not per row) so the "Comp a
+ // subscription" action can be hidden when there's nothing to comp onto — otherwise it opens a
+ // dead-end modal that only reports "no paid plans". The Subscribers tab is already gated behind
+ // a WordPress.com connection, so the proxied request is safe to fire eagerly.
+ const { data: membershipsProducts, isError: membershipsProductsError } =
+ useMembershipsProducts( true );
+ const hasPaidProducts = ( membershipsProducts?.length ?? 0 ) > 0;
+ // Offer the action when we know there's a paid product to comp onto, OR when we couldn't
+ // determine it because the products request errored. Failing open on error preserves the
+ // capability and lets the modal surface the fetch error, rather than silently removing the
+ // action on a transient failure. It stays hidden only while the request is still loading and
+ // when the site genuinely has zero paid products (the dead-end case this fix targets).
+ const canShowCompAction = hasPaidProducts || membershipsProductsError;
+
// Fired off `onChangeView` rather than per-control handlers because DataViews owns
// the controls — we diff the previous view against the next to mirror Calypso's
// per-interaction Tracks events.
@@ -216,11 +231,13 @@ export default function SubscribersDataViews( {
{
id: 'comp',
label: __( 'Comp a subscription', 'jetpack-newsletter' ),
- // We need a wpcom user id to attach the comp to (Calypso's
- // `hasUncompedPlans` also checks the plans list, but that requires the site's
- // products to be loaded — we let the modal handle the "all comped" /
- // "no paid plans" edge cases instead).
- isEligible: ( subscriber: Subscriber ) => !! subscriber.user_id,
+ // Needs a wpcom user id to attach the comp to, plus a paid product to comp onto —
+ // otherwise the modal is a dead-end that only reports "no paid plans".
+ // `canShowCompAction` comes from a single table-level fetch (see above: true when
+ // products exist or the fetch errored, false while loading or on a genuinely empty
+ // site), so this stays cheap per row. The modal still handles the per-subscriber
+ // "already comped on every plan" edge case.
+ isEligible: ( subscriber: Subscriber ) => !! subscriber.user_id && canShowCompAction,
callback: ( items: Subscriber[] ) => {
const target = items[ 0 ];
if ( ! target ) {
@@ -261,7 +278,7 @@ export default function SubscribersDataViews( {
},
},
],
- [ onViewSubscriber ]
+ [ onViewSubscriber, canShowCompAction ]
);
const handleConfirmRemoval = useCallback( async () => {
diff --git a/projects/packages/newsletter/_inc/subscribers/data/use-memberships-products.ts b/projects/packages/newsletter/_inc/subscribers/data/use-memberships-products.ts
index b5ba6b9a559..5a0c43c93db 100644
--- a/projects/packages/newsletter/_inc/subscribers/data/use-memberships-products.ts
+++ b/projects/packages/newsletter/_inc/subscribers/data/use-memberships-products.ts
@@ -4,9 +4,10 @@ import type { MembershipsProduct } from './api';
/**
* Fetch the paid newsletter / membership products configured on this site. Lazy — only runs
- * when the consumer asks for it (the Comp modal needs it; nothing else does).
+ * when the consumer asks for it. The Subscribers table fetches it to decide whether to offer the
+ * "Comp a subscription" action; the Comp modal reuses the same cached result.
*
- * @param enabled - Whether the request should run (typically `true` once the modal is open).
+ * @param enabled - Whether the request should run.
* @return React Query handle.
*/
export function useMembershipsProducts( enabled: boolean ) {
diff --git a/projects/packages/newsletter/changelog/change-newsletter-comp-gate-paid-plans b/projects/packages/newsletter/changelog/change-newsletter-comp-gate-paid-plans
new file mode 100644
index 00000000000..9329d6478ba
--- /dev/null
+++ b/projects/packages/newsletter/changelog/change-newsletter-comp-gate-paid-plans
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Subscribers: Hide the "Comp a subscription" action when the site has no paid newsletter plans.