Charts: stop pre-bundling @wordpress/ui so IconButton works in Script Module consumers - #51016
Conversation
@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
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
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:
If you have questions about anything, reach out in #jetpack-developers for guidance! |
|
@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. |
Code Coverage SummaryCoverage changed in 1 file.
|
|
@claude please review this PR. |
This comment was marked as resolved.
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
d157ab1 to
f539228
Compare
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
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
Verified in wp-adminThe 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.
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: No |
Follow-up on the
|
| 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 | |||
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
This change looks a bit unusual to me but if you are confident that this is the only solution, then that is fine.
| - **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. |
There was a problem hiding this comment.
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/ ], |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
Fixes CHARTS-247
Why
PR #50721 converted the chart zoom-reset control to
@wordpress/ui'sIconButton. That broke every wp-admin screen consumingcharts/distas a WordPress Script Module — Premium Analytics, Social, VideoPress, Podcasts — and was live for 5 hours before being reverted. The fix that shipped in #50796 swappedIconButtonforButton+ atitleattribute.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 — includingpublicize, one of the four packages this incident broke — importsIconButtonandTooltipdirectly and builds fine under both webpack and@wordpress/build.titleis 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 witharia-labelcauses 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 problemIconButtonexists to solve.Root cause
tsdown.config.tsinlined@wordpress/uiviadeps.alwaysBundle. Withreactexternal, Rolldown cannot rewrite a transitive CommonJSrequire("react")(IconButton→ tooltip →@base-ui/react→use-sync-external-store/shim) into a static ESM import, so it emitted a shim that throws.@wordpress/buildthen re-bundled that shim opaquely, so itsvendor-external:reactplugin never saw therequireto rewrite, and it threw during evaluation of the sharedwidgets-toolkitmodule — failing every widget at once.The reason
alwaysBundleexisted no longer holds. It was added in #47004 (Feb 2026) because@wordpress/buildexternalised all@wordpress/*towindow.wp.*, andwindow.wp.uidoes not exist.@wordpress/buildnow externalises only packages declaringwpScriptModuleExports;@wordpress/uishipswpScript: falseand none, so wp-build bundles it itself. Verified on both lines in use here —0.18.0(publicize, podcast, videopress) and0.19.1-next(premium-analytics).Note the consequence, which is recorded in the package's
AGENTS.md: because@wordpress/uiis now external indist,assert-no-dynamic-requirecan no longer detect this class of bug —distis clean by construction. If a future@wordpress/buildreverts 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
@wordpress/uifromdeps.alwaysBundlein the chartstsdown.config.ts, so each consumer's bundler resolves it. It stays independencies, so npm consumers still get it transitively; webpack consumers are unaffected either way because they resolve charts through thejetpack:srcexport condition.ZoomResetButtontoIconButtonwith a reallabel, removing thetitle+aria-labelpair.--wp-ui-button-*custom properties fromx-zoom.module.scss. They were a verbatim copy ofIconButton's own composition layer, added only becauseIconButtoncould not be used.titleunit assertion with coverage for tooltip-on-focus and Escape-dismissal.distvsjetpack:srcconsumption split in the package'sAGENTS.md, and record thatalwaysBundlemust not be used.tools/assert-no-dynamic-require.ts(added in #50796) is unchanged and still guards every build.Result
dist/index.jsdist/index.css@wordpress/uiin outputScreenshots
Line Chart → Zoomable story. Captured with Playwright against a real browser.
No reset control until you zoom — full domain, Feb–Dec.
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.
Tooltip on hover.
Tooltip on keyboard focus — the behaviour
titlecould never provide, and the reason for this change. Note the focus ring alongside the tooltip. Escape dismisses it while focus stays put.Related product discussion/links
Does this pull request change what data or activity we track or use?
No.
Testing instructions
Storybook — the accessibility behaviour
titlecould never provide.The build guard
pnpm run build:prodinprojects/js-packages/charts. It must succeed —assert-no-dynamic-requirefails the build if any dynamic-requireshim reaches the ESM output.grep -o 'from *"@wordpress/ui"' dist/index.jsshould 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 dashboardpnpm jetpack build packages/publicize— Jetpack Socialpnpm jetpack build packages/podcast— Podcastspnpm jetpack build packages/videopress— VideoPresspnpm jetpack build packages/my-jetpack— webpack control; should be unaffectedTo check the artefacts directly, the meaningful test is what
__requireis called with, not whether the stringDynamic require ofappears — esbuild always emits that helper text, including on trunk:Expected: only
__require("jetpackConfig")and__require("@wordpress/boot"), both pre-existing. Any__require("react")means the regression is back.Verification already performed
__require("react")in any output; charts code confirmed present (a8ccharts-classes) so this is not a tree-shaken false pass.Note for whoever releases this
woocommerce-analyticsconsumes the published npmdistand was the original driver for #47004. Please confirm its@wordpress/buildversion externalises@wordpress/uithe same way 0.18.0 does before this goes out. That could not be checked from this repo.