Stats post detail: show email tabs based on actual email stats - #113843
Conversation
|
WordPress.com
Automattic for Agencies
|
|
Here is how your PR affects size of JS and CSS bundles shipped to the user's browser: Async-loaded Components (~108 bytes added 📈 [gzipped]) Details
React components that are loaded lazily, when a certain part of UI is displayed for the first time. Legend What is parsed and gzip size?Parsed Size: Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory. |
The post detail page hid the Email opens / Email clicks tabs based on the _jetpack_dont_email_post_to_subs post meta and a publish-date cutoff, while the email detail page always shows them. Posts that have email stats but carry that meta lost the tabs when navigating from the email page to Post traffic. Gate on the email rate endpoint instead, so both pages agree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0fd453b to
f70c23a
Compare
There was a problem hiding this comment.
Pull request overview
This PR updates the Calypso Stats post detail page to decide whether to show the Post traffic / Email opens / Email clicks tab strip based on actual email stats availability, aligning behavior with the email detail page and avoiding incorrect heuristics based on post metadata and publish date cutoffs.
Changes:
- Add a new React Query hook (
usePostEmailStatsAvailabilityQuery) that checks the email “rate” stats endpoint to determine if a post has any email sends/opens. - Update the post detail page to show email tabs only when that hook reports email stats exist (and when subscriptions + email stats are supported).
- Remove the now-unused
dont_email_post_to_subsplumbing from the post object shape used by the highlights section.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| client/my-sites/stats/stats-post-detail/index.jsx | Switch tab availability logic to rely on hasEmailStats from the new query hook, and remove metadata/date heuristics. |
| client/my-sites/stats/post-detail-highlights-section/index.tsx | Remove the dont_email_post_to_subs field from the Post type since it’s no longer provided/used. |
| client/my-sites/stats/hooks/use-post-email-stats-availability-query.ts | Introduce a small React Query hook to fetch email rate stats and derive a boolean “has email stats” signal. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…n show Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A newsletter that is still being sent can briefly report zero sends, so only keep a positive answer fresh; a negative one is refetched on the next mount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Looks like one of the E2E tests has failed. You can fix them following these steps:
|
chihsuan
left a comment
There was a problem hiding this comment.
Thanks for working on this! @dognose24 Basing the tabs on the actual email stats reads much better than the metadata guess. 👍
I left a few inline comments, mostly around what happens when the /rate request fails.
Also, would a small unit test for hasEmailStats be worth adding? Thanks!
| return supportsEmailStats && subscriptionsEnabled; | ||
| } ); | ||
|
|
||
| const { data: hasEmailStats = false } = usePostEmailStatsAvailabilityQuery( |
There was a problem hiding this comment.
Should we surface isError here too? Reading only data makes a failed /rate request look identical to a post with no email stats, so the tabs quietly disappear. And with retryOnMount: false from the defaults, they stay hidden until a full page reload. Would failing open be safer?
There was a problem hiding this comment.
Fixed in 6760a25: the hook now sets retryOnMount: true (the shared defaults pin an errored query until a full reload), so navigating back retries instead of leaving the tabs hidden. The error still reads as unavailable rather than surfacing UI, which fails toward the pre-existing behavior of the page.
| isJetpackSite( state, siteId, { treatAtomicAsJetpackSite: false } ) | ||
| ); | ||
|
|
||
| const canHaveEmailStats = useSelector( ( state ) => { |
There was a problem hiding this comment.
I might not fully understand the wiring here — doesn't connect wrap this component, so supportsEmailStats, isSimple and isSubscriptionsModuleActive already arrive as props? Re-selecting them puts the same rule in three places in this file.
There was a problem hiding this comment.
You are right, connect wraps this component, I misread the composition. Fixed in 6760a25: the wrapper now reads the checks from props and computes the whole rule (canHaveEmailStats, the postId guard, and the query result) in one place, passing a single isEmailTabsAvailable down; the render-side recomputation is gone.
| } | ||
|
|
||
| function hasEmailStats( data?: EmailRateResponse ) { | ||
| return ( data?.total_sends ?? 0 ) > 0 || ( data?.total_opens ?? 0 ) > 0; |
There was a problem hiding this comment.
Just curious — is 0 always a real zero on this endpoint? I found a note on #110760 where the API side said unique_clicks: 0 means data unavailable. I don't know whether total_sends behaves the same way.
There was a problem hiding this comment.
Good instinct, and it is documented: total_sends is not always a real zero. STATS-446 records the same send reporting total_sends 0 on one endpoint and 1 on another for legacy sends. That is why this check also accepts total_opens > 0, and open tracking covers every send since the unique-tracking era. For the remaining untracked-era posts the behavior matches the date-cutoff heuristic this PR replaces (those tabs were already hidden by the 2023-05-30 guard), so a false negative here fails toward the existing state, and the email details page stays reachable from the Emails module.
| ) { | ||
| return useQuery( { | ||
| ...getDefaultQueryParams(), | ||
| queryKey: [ 'stats', 'emails', 'rate', siteId, postId ], |
There was a problem hiding this comment.
nit: Should the key carry opens too? The URL pins the stat type but the key doesn't, so a future clicks-rate query with the same shape would share this cache entry.
| queryKey: [ 'stats', 'emails', 'rate', siteId, postId ], | |
| queryKey: [ 'stats', 'emails', 'opens', 'rate', siteId, postId ], |
The whole email-tabs rule now lives in the wrapper, which already receives the environment checks as connect props; a failed availability request retries on remount instead of pinning the tabs hidden until a full reload; and the query key carries the stat type it pins in the URL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kangzj
left a comment
There was a problem hiding this comment.
Hi @dognose24, thanks for taking this one on 🙂 Basing the tabs on the actual email stats rather than the meta flag makes total sense to me, and it's nice that it takes the 2023-05-30 cutoff out at the same time.
The feature itself works well for me in the browser - but I'm requesting changes on one thing, sorry.
Blocker
canHaveEmailStatscan evaluate tonullrather thanfalse, and react-query v5 hard-throws on a non-booleanenabled. Because it throws during render the whole Stats screen goes to a white page, not just the tabs. Reproduced live on this branch, and trunk is fine with the same state - details inline.
Non-blocking
- No test for the new hook.
hasEmailStatsis nice pure logic - null / 0 / positive - and there's already aclient/my-sites/stats/hooks/testfolder, so it'd be pretty cheap to add one. Up to you. - Testing step 3 says the tabs stay when you click Post traffic from the Emails page. They don't quite - I measured a ~760ms window where the strip is gone. But trunk does the same thing (~640ms), so it's pre-existing and not yours to fix, I'm just flagging it so the note doesn't mislead whoever tests next.
- I could NOT check Odyssey - I didn't build it into a docker env. That's the one I'd most like a second pair of eyes on for the blocker above, because
isSimpleis false in wp-admin, so the whole thing then rests onactive_modulesbeing present in thejetpack/v4/sitepayload. Are you able to give it a spin there please?
Testing performed
Local Calypso dev env on this branch, logged in as me, mostly against en.blog.wordpress.com since it has real newsletter stats:
- post with email stats (76414 - 68,036 sends, 9,109 opens) → Post traffic / Email opens / Email clicks shown ✅
- post with no email stats (227, published 2005) → no tabs ✅
- home page entry (post id 0) → no tabs ✅
- Emails page → click Post traffic → tabs there afterwards ✅
- normal loads of the post detail page on Simple, Atomic and Jetpack sites → all render fine ✅
yarn typecheck-client→ no new errors in the touched files ✅- crash repro, this branch vs trunk → ❌ this branch white screens, trunk doesn't
I poked the endpoint directly too, couple of things worth knowing:
- it returns real numbers (
total_sends: 68036), not strings, sohasEmailStatsis fine as it is - a post that was never emailed comes back
200with all-null counters, so no error storm there - good - a post id that doesn't exist comes back
500though, so a stale post id in the URL costs two failed requests withretry: 1. Very minor
Evidence
| Tabs working on this branch (post 76414) | White screen after the enabled throw |
|---|---|
![]() |
![]() |
Console at the moment it goes white:
Uncaught Error: Expected enabled to be a boolean or a callback that returns a boolean
Thanks again - happy to re-review as soon as the !! is in 👍
| // `connect` wraps this component, so the environment checks arrive as props; | ||
| // the whole email-tabs rule lives here rather than being re-derived in render. | ||
| const { supportsEmailStats, isSimple, isSubscriptionsModuleActive, postId } = props; | ||
| const canHaveEmailStats = supportsEmailStats && ( isSimple || isSubscriptionsModuleActive ); |
There was a problem hiding this comment.
isJetpackModuleActive returns null (not false) when it can't tell - the jetpack modules state hasn't loaded yet and active_modules isn't around either - so this line can come out as null, and that goes straight into the hook's enabled. react-query v5 hard-throws on a non-boolean enabled, and since it throws during render it takes the whole Stats screen down to a white page rather than just dropping the tabs.
I reproduced it on this branch by putting the store into the state those selectors are documented to return (site missing from state.sites.items while the post detail route is mounted): white page, Uncaught Error: Expected enabled to be a boolean or a callback that returns a boolean. trunk survives the exact same state.
To be fair I couldn't pin down a natural navigation that gets there in Calypso - Simple sites short-circuit on isSimple, and for Jetpack/Atomic the /me/sites payload happens to carry active_modules - so right now it's only that which is holding it up. Since it's one character I'd rather just make it safe:
| const canHaveEmailStats = supportsEmailStats && ( isSimple || isSubscriptionsModuleActive ); | |
| const canHaveEmailStats = !! supportsEmailStats && !! ( isSimple || isSubscriptionsModuleActive ); |
There was a problem hiding this comment.
Good catch, thanks. Both coerced in bd81280.
| ...getDefaultQueryParams(), | ||
| queryKey: [ 'stats', 'emails', 'opens', 'rate', siteId, postId ], | ||
| queryFn: () => queryEmailRate( siteId as number, postId ), | ||
| enabled: enabled && !! siteId && postId > 0, |
There was a problem hiding this comment.
Same thing from the other side - this is a shared hook now, so it probably shouldn't trust the caller to hand it a real boolean:
| enabled: enabled && !! siteId && postId > 0, | |
| enabled: !! enabled && !! siteId && postId > 0, |
| enabled: enabled && !! siteId && postId > 0, | ||
| // A "no email stats" answer can be transient while a newsletter is still being sent, | ||
| // so only a positive result is kept for a while. | ||
| staleTime: ( query ) => ( hasEmailStats( query.state.data ) ? 1000 * 60 * 5 : 0 ), |
There was a problem hiding this comment.
Minor one - this resolves to 0 for the "no email stats" answer, and with refetchOnMount on by default that's a fresh request every time someone opens a post detail page, which is the common case. I checked the endpoint and a never-emailed post does return 200 with all-null counters, so at least there's no retry storm. Would the 30s from the shared defaults be enough here?
| staleTime: ( query ) => ( hasEmailStats( query.state.data ) ? 1000 * 60 * 5 : 0 ), | |
| staleTime: ( query ) => ( hasEmailStats( query.state.data ) ? 1000 * 60 * 5 : 1000 * 30 ), |
There was a problem hiding this comment.
Applied in bd81280: 30s for the negative result.
…e result briefly, add hook test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C5G7Fg8tpLg8Qe9EaSs79t
|
Thanks for the thorough review. Addressed in bd81280:
Odyssey check, on a local Jetpack docker site (wp-admin → Stats) with this branch's bundle built into
|
chihsuan
left a comment
There was a problem hiding this comment.
Thanks for the updates! @dognose24 This reads much cleaner now. 👍
I left a few small inline notes. The only one I feel is worth resolving before merge is the failed-request case, since retryOnMount only helps after a remount.
Just curious: the email tab already fetches the same /rate URL through emailStatsAlltime. Is a second cache for it worth it here? Thanks!
| const { data: hasEmailStats = false } = usePostEmailStatsAvailabilityQuery( | ||
| siteId, | ||
| postId, | ||
| canHaveEmailStats | ||
| ); |
There was a problem hiding this comment.
Should we read isError here too? A failed /rate request looks identical to a post that was never emailed. And retryOnMount only fires on remount, so the tabs may stay hidden for that whole visit.
| const { data: hasEmailStats = false } = usePostEmailStatsAvailabilityQuery( | |
| siteId, | |
| postId, | |
| canHaveEmailStats | |
| ); | |
| const { data, isError } = usePostEmailStatsAvailabilityQuery( | |
| siteId, | |
| postId, | |
| canHaveEmailStats | |
| ); | |
| const hasEmailStats = data ?? isError; |
There was a problem hiding this comment.
Agreed, applied in 2f79b81: hasEmailStats = data ?? isError, so a failed request shows the tabs rather than hiding them.
| // A failed request reads the same as "no email stats" and hides the tabs, so | ||
| // let a remount retry instead of pinning the error until a full reload | ||
| // (the shared defaults set retryOnMount: false). | ||
| retryOnMount: true, |
There was a problem hiding this comment.
nit: Should this opt out of persistence? shouldDehydrateQuery defaults to true, so a false answer gets written to localStorage and rendered on the next load before the 30s refetch corrects it.
| retryOnMount: true, | |
| retryOnMount: true, | |
| meta: { persist: false }, |
| return useQuery( { | ||
| ...getDefaultQueryParams(), | ||
| queryKey: [ 'stats', 'emails', 'opens', 'rate', siteId, postId ], | ||
| queryFn: () => queryEmailRate( siteId as number, postId ), |
There was a problem hiding this comment.
nit: Could queryEmailRate take number | null so this cast can go? The template literal is unchanged, and the non-null claim currently rests on enabled, which TypeScript can't see.
…stence Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C5G7Fg8tpLg8Qe9EaSs79t
|
Thanks! All three applied in 2f79b81. On the |


Fixes STATS-456
Proposed Changes
usePostEmailStatsAvailabilityQuery, a small react-query hook that reads/sites/:site/stats/opens/emails/:post/rateand reports whether the post has any email sends or opens._jetpack_dont_email_post_to_subspost meta and a 2023-05-30 publish-date cutoff.dont_email_post_to_subsplumbing fromgetPost()and the highlights section type.Why are these changes being made?
The post detail page and the email detail page share the same tab strip but decided differently whether to render it: the email page always did, while the post page relied on post metadata. A post that carries
_jetpack_dont_email_post_to_subsbut still has email stats (the case in STATS-456) showed all three tabs on the email page and none on the post page, so clicking Post traffic made the tabs disappear.Basing the decision on the email stats themselves keeps both pages consistent and also covers the old publish-date cutoff: posts from before newsletter stats existed simply have no sends and get no tabs. This supersedes the metadata check from #97739, which had the same intent (no tabs for posts that were never sent as an email) but relied on a flag that does not always match what was actually sent. In the reported case the email went out at publish time and the meta was only flipped to true by a later edit, so the flag reflects the editor's last-saved state rather than what was sent.
Testing Instructions
Finding a post that reproduces the bug. The tabs only went missing for posts that have email stats and carry the
_jetpack_dont_email_post_to_subsmeta. A newsletter sent through the normal flow does not carry it, so either:wp post meta update <post_id> _jetpack_dont_email_post_to_subs 1.Steps:
Also worth checking on a self-hosted Jetpack site with newsletters enabled, since the rate endpoint goes through the Jetpack API there.
Screenshots
Pre-merge Checklist
🤖 Generated with Claude Code