From b7a450563473e0d1046bf95a1533b48fc6968151 Mon Sep 17 00:00:00 2001 From: Allison Levine Date: Wed, 8 Apr 2026 13:09:23 -0400 Subject: [PATCH] fix(newsletter): swallow editor email stats fetch errors and skip for drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Newsletter panel's getTotalEmailsSentCount resolver has a hard 5s timeout. On large sites the WPCOM stats/opens/emails endpoint can exceed that, and the resolver surfaced the error as a snackbar in the editor — flashing "cURL error 28" on every load. It also fired for brand new drafts that had never been sent. - Fail silently on fetch errors; open rate is informational and should never flip api state or raise an editor notice. Log to console.warn instead. - Only trigger the fetch for already-published posts (status === 'publish'). - Extend resolver tests to cover timeout/reject and WP_Error paths. Fixes NL-578. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fix-nl-578-editor-email-stats-timeout | 4 +++ .../memberships/subscribers-affirmation.js | 9 +++++-- .../store/membership-products/resolvers.js | 9 ++++--- .../test/resolvers-test.js | 26 +++++++++++++++++-- 4 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/fix-nl-578-editor-email-stats-timeout diff --git a/projects/plugins/jetpack/changelog/fix-nl-578-editor-email-stats-timeout b/projects/plugins/jetpack/changelog/fix-nl-578-editor-email-stats-timeout new file mode 100644 index 000000000000..c382f0b956b0 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-nl-578-editor-email-stats-timeout @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Newsletter: Fail silently on email stats fetch errors in the editor and skip the fetch for drafts so timeouts no longer flash as errors in Gutenberg. diff --git a/projects/plugins/jetpack/extensions/shared/memberships/subscribers-affirmation.js b/projects/plugins/jetpack/extensions/shared/memberships/subscribers-affirmation.js index b5a5336cdcd9..1ce582084599 100644 --- a/projects/plugins/jetpack/extensions/shared/memberships/subscribers-affirmation.js +++ b/projects/plugins/jetpack/extensions/shared/memberships/subscribers-affirmation.js @@ -350,7 +350,12 @@ function SubscribersAffirmation( { accessLevel, prePublish = false } ) { const _postEmailSentState = postId ? getPostEmailSentState( postId ) : null; const emailSentAt = _postEmailSentState?.email_sent_at ?? null; - const shouldFetchTotalEmails = postId && blogId && postEmailResolved && emailSentAt == null; + // Only fetch email open stats for already-published posts. Drafts, + // auto-drafts, pending, and scheduled posts have never been emailed, + // so the WPCOM stats/opens/emails request would be a guaranteed miss + // (and can time out on large sites). See NL-578. + const shouldFetchTotalEmails = + postId && blogId && postEmailResolved && emailSentAt == null && status === 'publish'; return { hasFinishedLoading: [ @@ -372,7 +377,7 @@ function SubscribersAffirmation( { accessLevel, prePublish = false } ) { : null, }; }, - [ postId, blogId ] + [ postId, blogId, status ] ); if ( ! hasFinishedLoading ) { diff --git a/projects/plugins/jetpack/extensions/store/membership-products/resolvers.js b/projects/plugins/jetpack/extensions/store/membership-products/resolvers.js index ff36e61f1897..0a4e13eb8a15 100644 --- a/projects/plugins/jetpack/extensions/store/membership-products/resolvers.js +++ b/projects/plugins/jetpack/extensions/store/membership-products/resolvers.js @@ -286,7 +286,7 @@ export const getSubscriberCounts = export const getTotalEmailsSentCount = ( blogId, postId ) => - async ( { dispatch, registry } ) => { + async ( { dispatch } ) => { await executionLock.blockExecution( TOTAL_EMAILS_SENT_COUNT_EXECUTION_KEY ); const lock = executionLock.acquire( TOTAL_EMAILS_SENT_COUNT_EXECUTION_KEY ); @@ -294,8 +294,11 @@ export const getTotalEmailsSentCount = const response = await fetchTotalEmailsSentCount( blogId, postId ); dispatch( setTotalEmailsSentCount( response?.total_sends ) ); } catch ( error ) { - dispatch( setApiState( API_STATE_NOTCONNECTED ) ); - onError( error.message, registry ); + // Email open stats are informational. Fail silently so a slow or + // failed WPCOM response (e.g. 5s timeout on stats/opens/emails) does + // not surface a snackbar error in the editor. See NL-578. + // eslint-disable-next-line no-console + console.warn( 'Failed to fetch total emails sent count:', error?.message ); } finally { executionLock.release( lock ); } diff --git a/projects/plugins/jetpack/extensions/store/membership-products/test/resolvers-test.js b/projects/plugins/jetpack/extensions/store/membership-products/test/resolvers-test.js index 197dcb12aab8..83f627ae3dd0 100644 --- a/projects/plugins/jetpack/extensions/store/membership-products/test/resolvers-test.js +++ b/projects/plugins/jetpack/extensions/store/membership-products/test/resolvers-test.js @@ -103,7 +103,10 @@ describe( 'Membership Products Resolvers', () => { expect( apiFetch ).not.toHaveBeenCalled(); } ); - test( 'WP_Error response: calls onError', async () => { + test( 'WP_Error response: fails silently (no onError, no dispatch)', async () => { + // Email stats are informational — errors should never flash in the + // editor (see NL-578). + const warnSpy = jest.spyOn( console, 'warn' ).mockImplementation( () => {} ); apiFetch.mockResolvedValue( { errors: { rest_forbidden: [ 'Sorry, you are not allowed.' ] }, } ); @@ -111,7 +114,26 @@ describe( 'Membership Products Resolvers', () => { const thunk = getTotalEmailsSentCount( 123, 456 ); await thunk( { dispatch: mockDispatch, registry: mockRegistry } ); - expect( utils.onError ).toHaveBeenCalled(); + expect( utils.onError ).not.toHaveBeenCalled(); + expect( mockDispatch ).not.toHaveBeenCalled(); + expect( warnSpy ).toHaveBeenCalled(); + warnSpy.mockRestore(); + } ); + + test( 'apiFetch rejects (e.g. timeout): fails silently', async () => { + const warnSpy = jest.spyOn( console, 'warn' ).mockImplementation( () => {} ); + apiFetch.mockRejectedValue( new Error( 'cURL error 28: Operation timed out' ) ); + + const thunk = getTotalEmailsSentCount( 123, 456 ); + await thunk( { dispatch: mockDispatch, registry: mockRegistry } ); + + expect( utils.onError ).not.toHaveBeenCalled(); + expect( mockDispatch ).not.toHaveBeenCalled(); + expect( warnSpy ).toHaveBeenCalledWith( + 'Failed to fetch total emails sent count:', + 'cURL error 28: Operation timed out' + ); + warnSpy.mockRestore(); } ); } ); } );