Skip to content

Banner: absolutize root-relative hrefs in wp-admin (Odyssey) - #113168

Merged
dognose24 merged 3 commits into
trunkfrom
stats-397-odyssey-make-upsellnudge-banner-default-hrefs-absolute
Aug 10, 2026
Merged

Banner: absolutize root-relative hrefs in wp-admin (Odyssey)#113168
dognose24 merged 3 commits into
trunkfrom
stats-397-odyssey-make-upsellnudge-banner-default-hrefs-absolute

Conversation

@dognose24

Copy link
Copy Markdown
Contributor

Fixes STATS-397 — generalizes #113144's fix, as suggested in its review.

Proposed Changes

  • Root-relative hrefs on Banner / UpsellNudge are Calypso routes, but wp-admin (Odyssey Stats) resolves them against the site's own domain — every upgrade nudge rendered in Odyssey linked to a 404. SEO preview nudge: link to wordpress.com absolutely in wp-admin #113144 patched one call site (the SEO preview nudge); stats-no-content-banner's /post/<slug> link is still broken today, and any future nudge would be too.
  • Fix at the Banner chokepoint, where every variant converges: computed default hrefs (/plans/… with feature/plan/customerType), caller-provided hrefs (covers UpsellNudge's computed href and explicit ones like stats-no-content-banner), and the secondary CTA all pass through a new exported toCalypsoHref() — which prefixes getCalypsoUrl() when is_odyssey is enabled and the href is root-relative.
  • Deliberately narrow trigger: only hrefs starting with / (not //, not absolute, not admin.php?…) and only in Odyssey. Calypso proper and Jetpack Cloud are byte-for-byte unaffected (the helper is a no-op there). No Odyssey surface passes site-relative /wp-admin/… paths to Banner (wp-admin links are built absolute via getSiteAdminUrl by convention — audited).
  • Once this merges, SEO preview nudge: link to wordpress.com absolutely in wp-admin #113144's call-site helper (getUpgradeNudgeHref) becomes redundant and can be removed in a small cleanup.

Why are these changes being made?

Testing Instructions

  1. yarn test-client client/components/banner/test/ client/blocks/upsell-nudge/test/ — 36 tests green, including new toCalypsoHref cases: absolutizes root-relative routes in Odyssey; leaves absolute / protocol-relative / non-rooted URLs untouched; passes empty values through; no-op outside wp-admin.
  2. In wp-admin Odyssey Stats on a Simple site below Business: post detail → View PostSearch & Social → the upgrade banner links to https://wordpress.com/plans/… (previously via SEO preview nudge: link to wordpress.com absolutely in wp-admin #113144's call-site fix; now via the chokepoint).
  3. On a Simple site with no posts, the Stats "no content" banner's Start writing action now opens https://wordpress.com/post/<site> instead of a 404.
  4. On Calypso proper (wordpress.com), banners/nudges keep their relative links — unchanged.

Pre-merge Checklist

  • Has the general commit checklist been followed? (PCYsg-hS-p2)
  • Have you written new tests for your changes?
  • Have you tested the feature in Simple (P9HQHe-k8-p2), Atomic (P9HQHe-jW-p2), and self-hosted Jetpack sites (PCYsg-g6b-p2)?
  • Have you checked for TypeScript, React or other console errors?
  • For UI changes, have you tested the affected components in dark mode?
  • Have you tested accessibility for your changes? Ensure the feature remains usable with various user agents (e.g., browsers), interfaces (e.g., keyboard navigation), and assistive technologies (e.g., screen readers) (PCYsg-S3g-p2).
  • Have you used memoizing on expensive computations? More info in Memoizing with create-selector and Using memoizing selectors and Our Approach to Data
  • Have we added the "[Status] String Freeze" label as soon as any new strings were ready for translation (p4TIVU-5Jq-p2)?
    • For UI changes, have we tested the change in various languages (for example, ES, PT, FR, or DE)? The length of text and words vary significantly between languages.
  • For changes affecting Jetpack: Have we added the "[Status] Needs Privacy Updates" label if this pull request changes what data or activity we track or use (p4TIVU-aUh-p2)?

🤖 Generated with Claude Code

Root-relative hrefs on Banner/UpsellNudge are Calypso routes, but
wp-admin resolves them against the site's own domain - every upgrade
nudge rendered in Odyssey Stats linked to a 404 (the SEO preview nudge
was patched at its call site in #113144; stats-no-content-banner's
/post link is still broken today). Absolutize at the Banner chokepoint
- computed defaults, caller-provided hrefs, and the secondary CTA all
pass through it - via getCalypsoUrl(), a no-op outside wp-admin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dognose24 dognose24 added Bug When a feature is broken and / or not performing as intended [Feature] Stats Everything related to our analytics product at /stats/ Odyssey Stats Calypso Stats in Jetpack labels Jul 31, 2026
@dognose24 dognose24 added the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Jul 31, 2026
@dognose24
dognose24 marked this pull request as ready for review July 31, 2026 17:39
@dognose24 dognose24 self-assigned this Aug 3, 2026
@dognose24
dognose24 requested review from a team, chihsuan, kangzj and rcrdortiz August 3, 2026 17:55

@chihsuan chihsuan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up! @dognose24

The Banner chokepoint is the right call, and the /-only trigger keeps Calypso and Jetpack Cloud untouched.

A couple of things worth addressing before merge:

  • The wiring itself isn't covered by tests. I removed toCalypsoHref from getHref() and from secondaryHref locally, and the whole components/banner/test/ suite still passed.

The rest are optional cleanups and shouldn't block.

Comment thread client/components/banner/index.jsx Outdated
return getCalypsoUrl() + href;
}
return href;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would client/lib/url/ be a better home for this? It's not really a Banner concept, and pulling it in from here drags the whole component.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting there are already ~4 versions of this in the tree (post-likes, mini-carousel, stats-upsell, plus getUpgradeNudgeHref) and they've genuinely drifted. Not asking you to unify them here, just thinking a shared home makes that possible later. What do you think?

Comment thread client/components/banner/index.jsx Outdated
! href.startsWith( '//' ) &&
config.isEnabled( 'is_odyssey' )
) {
return getCalypsoUrl() + href;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: getCalypsoUrl() already takes a path and strips the leading slash, so could this just be getCalypsoUrl( href )?

Comment thread client/components/banner/index.jsx Outdated
if ( ! href && siteSlug && canUserUpgrade ) {
if ( customerType ) {
return `/plans/${ siteSlug }?customerType=${ customerType }`;
return toCalypsoHref( `/plans/${ siteSlug }?customerType=${ customerType }` );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be worth wrapping once at the boundary rather than at each return?

Comment thread client/components/banner/index.jsx Outdated
// wp-admin. Everywhere else (Calypso proper, Jetpack Cloud) this is a no-op.
export function toCalypsoHref( href ) {
if (
href &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny robustness thought, could we make this typeof href === 'string' &&?

@kangzj kangzj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested locally and reviewed — LGTM. :shipit:

Testing

  • Unit tests: yarn test-client client/components/banner/test/ client/blocks/upsell-nudge/test/ — 36/36 pass as described.
  • Live check, Calypso proper: on /stats/day/<simple-site> from the local dev server, the "Free domain available" UpsellNudge CTA renders href="/domains/add/<site>" — relative, unchanged. No regression.
  • Live check, Odyssey branch: reloading the same page with ?flags=is_odyssey (flips the real config flag in the dev bundle), the same banner renders href="https://wordpress.com/domains/add/<site>". Both branches verified in the actual bundle, not just Jest.
  • Odyssey bundle: apps/odyssey-stats yarn build && yarn test:size passes — 526.97 kB gzipped against the 583.68 kB limit, so the new @automattic/calypso-url import costs nothing meaningful.
  • Lint clean on both changed files; all CI checks green.

Review notes

  • Trigger condition is exactly right (/ but not //, Odyssey-only), and it composes safely with #113144 already on trunk: getUpgradeNudgeHref produces an already-absolute URL, which passes through toCalypsoHref untouched — no double-prefixing after merge. Note this branch forked just before #113144 landed, so a rebase before merge would make CI reflect the trunk it will actually land in.
  • Audited stats surfaces for hrefs that should not leave wp-admin: the only Banner/UpsellNudge call site with an explicit href is stats-no-content-banner's /post/<slug>, a genuine Calypso destination. No internal Odyssey route flows through Banner, so nothing that relied on hashbang navigation changes behavior.
  • One imprecision in the description: per apps/odyssey-stats/README.md, the stats-admin package intercepts anchor clicks whose href doesn't start with http and rewrites them to hashbang routes — so the pre-fix failure mode is likely a mangled #!/post/… route inside wp-admin rather than a domain 404. Either way the link is broken and the fix is correct; if anything the interceptor strengthens it, since absolutized https:// links are exactly what escapes interception. Worth one sanity click in a real Simple-site wp-admin before merge since local simulation can't reproduce the interceptor.
  • One non-blocking suggestion inline about the helper's location.

Comment thread client/components/banner/index.jsx Outdated
// Calypso absolutely there; getCalypsoUrl() falls back to
// https://wordpress.com when the current origin isn't a Calypso one, as in
// wp-admin. Everywhere else (Calypso proper, Jetpack Cloud) this is a no-op.
export function toCalypsoHref( href ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: a component file is a slightly odd home for a URL helper, and the same bug exists on Odyssey-rendered links that don't go through Banner — e.g. client/my-sites/stats/promo-cards/index.jsx (/advertising/…) and client/my-sites/stats/stats-email-detail/index.jsx (/post/… via CoreButton). Those call sites can't reasonably import from calypso/components/banner, so moving this to calypso/lib/url (or @automattic/calypso-url) would let the planned #113144 cleanup and those other surfaces share it. Fine as a follow-up.

dognose24 and others added 2 commits August 6, 2026 12:57
Review feedback on #113168:

- toCalypsoHref isn't a Banner concept, and call sites that can't import
  from a component (promo-cards, stats-email-detail, and #113144's
  getUpgradeNudgeHref) need it too. Move it to calypso/lib/url so the
  ~4 drifted copies in the tree have somewhere to converge.
- The wiring itself was untested: cutting toCalypsoHref out of getHref()
  and secondaryHref left the suite green. Add render tests covering every
  href Banner emits — computed plans hrefs, caller-provided hrefs, the CTA
  button, and the secondary CTA — which now fail if the wiring is removed.
- getCalypsoUrl() already strips the leading slash, so pass the href
  straight through instead of concatenating.
- Guard on typeof href === 'string' rather than truthiness.
- Wrap once where getHref() returns instead of at each branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ke-upsellnudge-banner-default-hrefs-absolute
@dognose24

Copy link
Copy Markdown
Contributor Author

Thanks both — addressed the blocker and took the lib/url suggestion rather than leaving it as a follow-up.

Wiring coverage. You were right that the suite passed with toCalypsoHref cut out. Added client/components/banner/test/odyssey-href.jsx, which renders Banner with is_odyssey enabled and asserts the emitted href on every path: the three computed /plans/… shapes (bare, feature/plan, customerType), a caller-provided href, the CTA button, and the secondary CTA — plus an absolute href to pin the no-op. Repeating your experiment now fails 6 tests.

Moved to calypso/lib/url. toCalypsoHref lives in client/lib/url/to-calypso-href.ts and is exported from the barrel; its unit test moved alongside it. This gives the drifted copies (post-likes, mini-carousel, stats-upsell, getUpgradeNudgeHref) somewhere to converge, and the non-Banner Odyssey surfaces @kangzj found — stats/promo-cards and stats/stats-email-detail — can import it directly. Banner no longer pulls in calypso-config or calypso-url at all.

Nits. getCalypsoUrl( href ) instead of concatenating (it strips the leading slash itself); guard is now typeof href === 'string'; and getHref() computes into a local and wraps once at the single return instead of at each branch.

@dognose24
dognose24 requested review from chihsuan and kangzj August 7, 2026 07:07

@kangzj kangzj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the chokepoint approach and the new toCalypsoHref() helper — branch precedence in getHref() is preserved by the refactor, the guard correctly skips absolute and protocol-relative hrefs, and there's no double-absolutization with the getUpgradeNudgeHref fix from #113144. The new banner/test/odyssey-href.jsx covers the wiring gap I flagged earlier.

One optional nit: the JSDoc in client/lib/url/to-calypso-href.ts says getCalypsoUrl() "falls back to https://wordpress.com when the current origin isn't a Calypso one", but it never reads window.location.origin — it always defaults to wordpress.com and only overrides via an allow-listed calypso_origin query arg. The no-op-outside-Odyssey behavior comes from the is_odyssey guard instead. Not a blocker.

LGTM.

@chihsuan chihsuan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @dognose24 LGTM! 🚢

@dognose24
dognose24 merged commit 17ce31d into trunk Aug 10, 2026
16 of 17 checks passed
@dognose24
dognose24 deleted the stats-397-odyssey-make-upsellnudge-banner-default-hrefs-absolute branch August 10, 2026 03:49
@github-actions github-actions Bot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug When a feature is broken and / or not performing as intended [Feature] Stats Everything related to our analytics product at /stats/ Odyssey Stats Calypso Stats in Jetpack

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants