Skip to content

Charts: stop pre-bundling @wordpress/ui so IconButton works in Script Module consumers - #51016

Open
adamwoodnz wants to merge 8 commits into
trunkfrom
charts-247-charts-stop-pre-bundling-wordpressui-so-iconbutton-and
Open

Charts: stop pre-bundling @wordpress/ui so IconButton works in Script Module consumers#51016
adamwoodnz wants to merge 8 commits into
trunkfrom
charts-247-charts-stop-pre-bundling-wordpressui-so-iconbutton-and

Conversation

@adamwoodnz

@adamwoodnz adamwoodnz commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fixes CHARTS-247

Why

PR #50721 converted the chart zoom-reset control to @wordpress/ui's IconButton. That broke every wp-admin screen consuming charts/dist as a WordPress Script Module — Premium Analytics, Social, VideoPress, Podcasts — and was live for 5 hours before being reverted. The fix that shipped in #50796 swapped IconButton for Button + a title attribute.

That was a stopgap, and review feedback was clear it should not be the end state, on two counts.

It was solving the problem in the wrong place. Charts is the only package in the monorepo that pre-bundles @wordpress/ui. Everywhere else — including publicize, one of the four packages this incident broke — imports IconButton and Tooltip directly and builds fine under both webpack and @wordpress/build.

title is not an equivalent substitute for a tooltip. It is never shown to keyboard users, it cannot be dismissed (failing WCAG 2.2 SC 1.4.13), and pairing it with aria-label causes double announcements in some screen readers. An icon-only button with no visible or tooltip label is a "mystery meat" button — which is the exact problem IconButton exists to solve.

Root cause

tsdown.config.ts inlined @wordpress/ui via deps.alwaysBundle. With react external, Rolldown cannot rewrite a transitive CommonJS require("react") (IconButton → tooltip → @base-ui/reactuse-sync-external-store/shim) into a static ESM import, so it emitted a shim that throws. @wordpress/build then re-bundled that shim opaquely, so its vendor-external:react plugin never saw the require to rewrite, and it threw during evaluation of the shared widgets-toolkit module — failing every widget at once.

The reason alwaysBundle existed no longer holds. It was added in #47004 (Feb 2026) because @wordpress/build externalised all @wordpress/* to window.wp.*, and window.wp.ui does not exist. @wordpress/build now externalises only packages declaring wpScriptModuleExports; @wordpress/ui ships wpScript: false and none, so wp-build bundles it itself. Verified on both lines in use here — 0.18.0 (publicize, podcast, videopress) and 0.19.1-next (premium-analytics).

Note the consequence, which is recorded in the package's AGENTS.md: because @wordpress/ui is now external in dist, assert-no-dynamic-require can no longer detect this class of bug — dist is clean by construction. If a future @wordpress/build reverts to externalising all @wordpress/*, every build stays green and consumers break at runtime instead. A major bump should be checked by loading a charts screen in wp-admin.

Proposed changes

  • Remove @wordpress/ui from deps.alwaysBundle in the charts tsdown.config.ts, so each consumer's bundler resolves it. It stays in dependencies, so npm consumers still get it transitively; webpack consumers are unaffected either way because they resolve charts through the jetpack:src export condition.
  • Restore ZoomResetButton to IconButton with a real label, removing the title + aria-label pair.
  • Delete the three --wp-ui-button-* custom properties from x-zoom.module.scss. They were a verbatim copy of IconButton's own composition layer, added only because IconButton could not be used.
  • Replace the title unit assertion with coverage for tooltip-on-focus and Escape-dismissal.
  • Document the dist vs jetpack:src consumption split in the package's AGENTS.md, and record that alwaysBundle must not be used.

tools/assert-no-dynamic-require.ts (added in #50796) is unchanged and still guards every build.

Result

Before After
dist/index.js 433,019 B 350,627 B
dist/index.css 22,198 B 22,198 B (unchanged)
@wordpress/ui in output inlined external import
Dynamic-require shim 0 0

Screenshots

Line Chart → Zoomable story. Captured with Playwright against a real browser.

No reset control until you zoom — full domain, Feb–Dec.

zoom-reset-01-before-zoom

After dragging to zoom — the domain narrows to Apr 7–Jul 21 and the square reset control appears top-right, opaque so the gridlines don't show through.

zoom-reset-02-zoomed-button-visible

Tooltip on hover.

zoom-reset-03-tooltip-on-hover

Tooltip on keyboard focus — the behaviour title could never provide, and the reason for this change. Note the focus ring alongside the tooltip. Escape dismisses it while focus stays put.

zoom-reset-04-tooltip-on-keyboard-focus

Related product discussion/links

Does this pull request change what data or activity we track or use?

No.

Testing instructions

Storybook — the accessibility behaviour

  • Run Storybook and open JS Packages/Charts Library/Charts/Line Chart → Zoomable.
  • Drag horizontally across the plot to zoom. The reset button appears top-right.
  • Confirm it is square, and opaque (chart gridlines must not show through it).
  • Hover it — a "Reset zoom" tooltip appears.
  • Tab to it — the tooltip appears on keyboard focus. This is the behaviour title could never provide.
  • Press Escape — the tooltip dismisses and focus stays on the button (WCAG 2.2 SC 1.4.13).
  • Press Enter — the zoom resets and the button disappears.

The build guard

  • pnpm run build:prod in projects/js-packages/charts. It must succeed — assert-no-dynamic-require fails the build if any dynamic-require shim reaches the ESM output.
  • grep -o 'from *"@wordpress/ui"' dist/index.js should now match; it did not before.

Script Module consumers — the regression this prevents

Build and load each of these in wp-admin, confirming widgets render rather than showing "This widget encountered an error" or "Dynamic require of react is not supported":

  • pnpm jetpack build packages/premium-analytics — Premium Analytics dashboard
  • pnpm jetpack build packages/publicize — Jetpack Social
  • pnpm jetpack build packages/podcast — Podcasts
  • pnpm jetpack build packages/videopress — VideoPress
  • pnpm jetpack build packages/my-jetpack — webpack control; should be unaffected

To check the artefacts directly, the meaningful test is what __require is called with, not whether the string Dynamic require of appears — esbuild always emits that helper text, including on trunk:

grep -rho '__require([^)]*)' projects/packages/*/build --include='*.js' | sort -u

Expected: only __require("jetpackConfig") and __require("@wordpress/boot"), both pre-existing. Any __require("react") means the regression is back.

Verification already performed

  • Storybook, real browser via Playwright: 12/12 checks pass, covering every step above.
  • All five consumer packages built; no __require("react") in any output; charts code confirmed present (a8ccharts- classes) so this is not a tree-shaken false pass.
  • Unit tests: 53 suites / 1054 tests. The two new tooltip tests were mutation-checked — removing the focus step or the Escape step makes them fail, so they are not vacuous.

Note for whoever releases this

woocommerce-analytics consumes the published npm dist and was the original driver for #47004. Please confirm its @wordpress/build version externalises @wordpress/ui the same way 0.18.0 does before this goes out. That could not be checked from this repo.

adamwoodnz and others added 4 commits August 4, 2026 08:55
@wordpress/build 0.18.0 only externalizes @wordpress/* packages that
declare wpScriptModuleExports. @wordpress/ui declares wpScript: false
and none, so wp-build bundles it into charts' own output instead of
treating it as an external import.

The CHARTS-163 premise that window.wp.ui would be missing at runtime
no longer holds, so there's no reason to keep pre-bundling it. Worse,
pre-bundling is what forces Rolldown to emit a throwing dynamic
require() shim for a transitive CommonJS dependency, which breaks as
soon as anything reachable from charts source imports from
@wordpress/ui.

Removing '@wordpress/ui' from deps.alwaysBundle in tsdown.config.ts
lets it be externalized as a normal ESM import: dist/index.js drops
from ~433KB to ~351KB, dist/index.css is unchanged, and the dynamic
require shim is gone.
The zoom-reset button used a plain Button with aria-label + title as
a stopgap, because IconButton's Base UI tooltip pulled in a CommonJS
dependency that made Rolldown emit a dynamic require() into dist,
which threw in WordPress Script Module consumers.

title is invisible to keyboard users and can't be dismissed, failing
WCAG 2.2 SC 1.4.13, and pairs badly with aria-label in some screen
readers. A prior task removed @wordpress/ui from tsdown's
deps.alwaysBundle, so it's now an external ESM import instead of
being inlined, which removes the dynamic-require path entirely and
makes IconButton safe to use again. Its tooltip appears on focus and
dismisses on Escape.

Tests were rewritten first to assert the new tooltip behaviour and
observed failing against the old Button implementation before this
swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGjYD4EWfBUvwUZJ9ZF7yi
These three custom properties (--wp-ui-button-aspect-ratio,
--wp-ui-button-padding-inline, --wp-ui-button-min-width) were a
verbatim copy of the composition layer that IconButton itself applies.

They were added as a workaround because IconButton could not be used
in this component — its Base UI tooltip dependency included CommonJS
code that Rolldown would emit as a dynamic require(), breaking Script
Module consumers.

Task 2 replaced Button with @wordpress/ui's IconButton, which resolved
that blocker. These properties are now dead weight that would silently
drift from upstream if not removed.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code
The @wordpress/ui shim incident (CHARTS-247) happened partly because
this split was not widely known: @wordpress/build apps (premium-
analytics, publicize, podcast, videopress) consume the Rolldown
dist/ output as a WordPress Script Module, where require does not
exist, while webpack apps like My Jetpack resolve source through the
jetpack:src export condition instead. A charts-only change did not
look capable of breaking Jetpack Social because the two paths
weren't documented anywhere.

Record both the split and the no-alwaysBundle rule that caused the
incident so the next dist-only change gets evaluated against the
right blast radius.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGjYD4EWfBUvwUZJ9ZF7yi
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack or WordPress.com Site Helper), and enable the charts-247-charts-stop-pre-bundling-wordpressui-so-iconbutton-and branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack charts-247-charts-stop-pre-bundling-wordpressui-so-iconbutton-and
bin/jetpack-downloader test jetpack-mu-wpcom-plugin charts-247-charts-stop-pre-bundling-wordpressui-so-iconbutton-and

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@adamwoodnz

Copy link
Copy Markdown
Contributor Author

@copilot review this PR. Leave your feedback as review comments only — do NOT push commits, apply suggestions, or modify the branch in any way. I will make all code changes myself.

@jp-launch-control

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 1 file.

File Coverage Δ% Δ Uncovered
projects/js-packages/charts/src/charts/private/x-zoom/x-zoom.tsx 36/39 (92.31%) -0.19% 0 💚

Full summary · PHP report · JS report

@adamwoodnz

Copy link
Copy Markdown
Contributor Author

@claude please review this PR.

@claude

This comment was marked as resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGjYD4EWfBUvwUZJ9ZF7yi
@adamwoodnz
adamwoodnz force-pushed the charts-247-charts-stop-pre-bundling-wordpressui-so-iconbutton-and branch from d157ab1 to f539228 Compare August 4, 2026 00:19
Externalising @wordpress/ui removes the dynamic-require shim, but it does
not remove the failure mode — it moves it out of reach of the build. With
the package external, dist stays clean by construction, so
assert-no-dynamic-require can no longer detect this class of bug at all.
Correctness now rests on @wordpress/build choosing to bundle
@wordpress/ui rather than externalise it to window.wp.ui, which is what it
used to do and what CHARTS-163 worked around. If that reverts, every build
still passes and Script Module consumers break at runtime instead.

A PR description is not read at the next @wordpress/build bump, so record
it in AGENTS.md with the versions actually verified.

Also narrow the alwaysBundle rule to what is really true. It read as a
blanket prohibition while the config one line away still bundles
fast-deep-equal, which invites a reader to discount the whole rule. The
danger is specific to pre-bundling a package that transitively requires an
external; a dependency-free package cannot produce a shim.

Document the tooltip in both zoom docs sections, which disagreed with each
other and described neither, and cover the one path that could strand a
portaled tooltip: activation unmounts the button while its tooltip is open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGjYD4EWfBUvwUZJ9ZF7yi
@adamwoodnz

This comment was marked as resolved.

@adamwoodnz adamwoodnz added [Status] Needs Review This PR is ready for review. and removed [Status] In Progress labels Aug 4, 2026
@adamwoodnz
adamwoodnz marked this pull request as ready for review August 4, 2026 00:52
@adamwoodnz

This comment was marked as resolved.

Comment thread projects/js-packages/charts/src/charts/private/x-zoom/x-zoom.module.scss Outdated
@adamwoodnz

Copy link
Copy Markdown
Contributor Author

Verified in wp-admin

The final review noted that real-browser behaviour couldn't be verified from the diff — the Storybook pass proves the tooltip's open/dismiss logic, not that the Script Module consumers actually evaluate. Closing that gap: all four screens loaded on a local WordPress install, against freshly built packages.

Screen Result
Premium Analytics 36 widgets, 0 widget errors, 33 a8ccharts- elements, 87 SVGs
Jetpack Social loads, heading "Social", no error state
Podcasts loads, heading "Podcast", no error state
VideoPress loads, heading "VideoPress", no error state

No "critical error", no "Something went wrong", and no "Dynamic require of" text on any of them. These are the same four screens that rendered the error state during the incident, so a clean load is the regression signal.

Builds confirmed fresh rather than cached — every JS file in all four packages was rebuilt: premium-analytics 343/343, publicize 12/12, podcast 5/5, videopress 32/32.

Dynamic-require targets across the four built outputs, which is the check that actually matters:

premium-analytics                __require("@wordpress/boot")   pre-existing, build/scripts/init
publicize, podcast, videopress   __require("jetpackConfig")     pre-existing

No __require("react") in any of them.

@adamwoodnz

Copy link
Copy Markdown
Contributor Author

Follow-up on the woocommerce-analytics release question

I checked a clone. It is three separate pnpm workspaces, each with its own lockfile, and they differ in ways that matter here:

Workspace @automattic/charts Built with @wordpress/build? Exposed to this change?
root ^0.56.1 → 0.56.4 no No — ^0.56.1 cannot resolve to 1.x
standalone/ none yes, 0.7.1-next.v.0 No — it does not depend on charts at all
next-woocommerce-analytics/ ^1.0.0 → 1.0.0 yes (wp-build build) Yes

So the concern narrows to one workspace. standalone/ is the one pinned to a very old @wordpress/build (0.7.1-next), which is the version most likely to still externalise every @wordpress/* to window.wp.* — but it carries no charts dependency, so it cannot hit this.

next-woocommerce-analytics/ is the real consumer: it builds with wp-build and its catalog range ^1.0.0 will accept the release containing this change. It already declares @wordpress/ui in its own catalog, so the package is available to it — the failure mode would only be its @wordpress/build externalising @wordpress/ui to window.wp.ui rather than bundling it.

What I could not determine: that workspace's actual @wordpress/build version. It is absent from its lockfile and its pnpm-workspace.yaml pulls packages from sibling repositories (../../next-admin/...) that are not in the clone, and node_modules is not installed. So the one version that decides this is the one I cannot read from here.

Concretely, before or shortly after release someone with that workspace installed should check:

pnpm why @wordpress/build     # in next-woocommerce-analytics/

If it bundles @wordpress/ui the way 0.18.0+ does, there is nothing to do. If it still externalises @wordpress/*, that workspace must not take charts past this release until it is bumped. Nothing blocks merging here — that workspace is on charts 1.0.0 today and picks up new versions deliberately, not automatically.

The package is past 1.0, so minor promises consumers on ^1.x that this is
safe to take automatically. It is not universally safe: dist now imports
@wordpress/ui at runtime, and any bundler that externalizes @wordpress/*
to window.wp.* resolves that to an undefined window.wp.ui.

That is not a hypothetical. CHARTS-163 added the pre-bundling this PR
removes precisely because externalized @wordpress/ui broke WooCommerce
Analytics that way, and the failure is a blank screen at runtime rather
than a build error.

Also state the requirement in the entry itself, so the release notes tell
a consumer what to check rather than only what changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGjYD4EWfBUvwUZJ9ZF7yi
@@ -0,0 +1,4 @@
Significance: major

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

major rather than minor, because this changes the published dist contract rather than an exported API.

Until now dist carried its own copy of @wordpress/ui. After this it imports it, so the host has to resolve it. That is fine in a plain bundler — it stays a dependency and resolves from node_modules — but it breaks any consumer that externalizes @wordpress/* to window.wp.*, because window.wp.ui does not exist.

That is not hypothetical. #47004 added the alwaysBundle this PR removes, precisely because externalized @wordpress/ui broke WooCommerce Analytics that way. Undoing it re-exposes the same surface, and the failure is a blank screen at runtime rather than a build error.

On the known consumer: the only woocommerce-analytics workspace that pairs @automattic/charts with @wordpress/build is next-woocommerce-analytics, and that one is abandoned — it became Jetpack Premium Analytics. Its other two workspaces are not exposed (standalone/ builds with @wordpress/build but has no charts dependency; the root has charts ^0.56.1, which cannot resolve to 1.x).

So in practice nothing live is likely to break. The major is not about today's consumer list though — charts ships to npm, so consumers are not enumerable, and minor would tell everyone on ^1.x this is safe to take unattended. Worth noting it makes the release 2.0.0 rather than 1.12.0.

@manzoorwanijk manzoorwanijk 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.

This change looks a bit unusual to me but if you are confident that this is the only solution, then that is fine.

Comment on lines +36 to +42
- **Two consumption paths — this changes what a charts change can break.**
`@wordpress/build` apps (premium-analytics, publicize, podcast, videopress)
consume the Rolldown output in `dist/` and load it as a **WordPress Script
Module** — native browser ESM, where `require` does not exist. Webpack apps
(My Jetpack and friends) resolve source through the `jetpack:src` export
condition instead. A change that only affects `dist/` can therefore break
four packages this one does not import.

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.

Hard-wrap makes it difficult to navigate or read for people which rely on screen readers.

'.png': 'asset',
},
deps: {
alwaysBundle: [ '@wordpress/ui', /^fast-deep-equal/ ],

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.

But wp build tools expect this to be bundled? How will this externalization deal with private APIs mismatch? Did we test this change with older versions of other plugins that consume the same package?

@Nikschavan Nikschavan 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.

I reviewed the dependency resolution and checked the current consumers. This approach looks good: @wordpress/ui remains a normal Charts dependency, so it is installed automatically, and supported build tools will bundle it instead of looking for window.wp.ui. The current Jetpack consumers build and run correctly. We should announce this internally so consumers with older build tooling know they may need to update it.

I also experimented with esmExternalRequirePlugin from rolldown/plugins to rewrite the React require() calls. That works, but it keeps @wordpress/ui bundled in Charts and could duplicate the consumer’s copy, so I think this approach is better.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants