Repository navigation
Protect notices: back the notice component with the @wordpress/ui Notice - #52160
Conversation
The hand-rolled chassis — an icon switch, a message div and a dismiss button over a dark banner — becomes `Notice.Root` with a `Notice.Description` and a `Notice.CloseIcon`. The public props are unchanged, so all three call sites keep working: `type` picks the intent, `message` fills the description, `dismissable` draws the close control, and `duration` keeps its own timeout. `type="warning"` now reaches an intent of its own. The old switch had no case for it, so the fix-threat modal's inactive-extension warning fell through to the info icon over the default chassis. Only the floating toast passes a `spokenMessage`. It appears without a focus change and no call site wraps it in a live region; the other two sit inside a modal that is announced when it opens. The stylesheet keeps the floating placement, the info notice's bottom margin and the stacking context, and loses everything that painted the old chassis. `container-type: normal` is new: `Notice.Root` sets `container-type: inline-size`, and a size-contained child reports no width to the shrink-to-fit fixed box, which collapsed the toast to 26px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
|
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! Protect plugin: No scheduled milestone found for this plugin. If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. |
Code Coverage SummaryThis PR did not change code coverage! That could be good or bad, depending on the situation. Everything covered before, and still is? Great! Nothing was covered before? Not so great. 🤷 |
The Protect e2e suite failed on both counts, and both are this PR's doing. `spokenMessage` defaulted the toast into `speak()`, which copies the message into `#a11y-speak-polite` — so `getByText( 'Changes saved' )` matched two nodes and Playwright's strict mode rejected it. The legacy notice never announced and no call site sits in a live region, so this restores that. Announcing a toast is a real improvement, but it belongs in its own change, applied consistently. `Notice.CloseIcon`'s default label is "Dismiss", which renamed the control the suite clicks. The existing translated string is passed through instead, so the accessible name users already have survives the chassis swap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
|
LGTM with 3 nits. Verified: the
Not raised: — Tangerine |
…notes `speak()` keeps its own live region, so whether a call site sits in one was never the right test — the reason the previous commit gave was wrong. The toast reports an async result with no focus change, which is what `speak()` is for, so it announces again; the two modal notices stay quiet, since their dialog is read when it opens. That is what broke the e2e suite: `speak()` copies the message into `#a11y-speak-polite`, so `getByText( 'Changes saved' )` matches twice. Verified in a browser that the notice is the earlier of the two, and scoped the assertion to it. Also: capitalise the changelog entry after its prefix and drop the repeated word, and add `warning` to the component README's list of types, which this change makes a real intent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
|
All three actioned in a1778e2. 1. Worth recording why it was 2. Changelog. Now 3. README. — Terminator |
`Notice.Root` renders `spokenMessage` to a string during its own render. When the message holds a component with hooks — every error notice interpolates a `Link` — that component's hooks land in `Notice.Root`'s hook list, and the `useEffect` on the next line reads a mismatched slot and throws. React then unmounts the whole Protect dashboard, so any failed request blanked the page. `safeRenderToString`'s try/catch does not help: the throw comes from the hook after it. Only a plain string is announced now, which covers the saved and saving toasts. Announcing errors needs a spoken string carried alongside the JSX; that is worth doing separately. The same trap is recorded in `packages/newsletter/_inc/subscribers/components/modals/add-subscribers-modal.tsx`. Two follow-ups from the same review: the e2e assertion is scoped to the app root, because `speak()` leaves its text in the live region after the notice goes and the helper runs seven times; and `NoticeState.type` gains `warning`, which the component and README already offer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
`Notice.CloseIcon` reaches `@wordpress/theme` and `@wordpress/private-apis` through `IconButton` and its tooltip, so the build started externalising them to the `wp-theme` and `wp-private-apis` script handles. Protect registers no shim for those, and WordPress refuses to enqueue a bundle with an unmet dependency — the admin page would render empty wherever core does not supply them. They are bundled instead, which is what the Jetpack plugin's own admin bundles do for the same reason. Verified by rebuilding: both handles are gone from `build/index.asset.php`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
The Firewall block still said the "Changes saved" assertion stays page-scoped on purpose. It does not any more. The changelog entry becomes `minor`, matching the same migration for the Jetpack plugin in #52015: every Protect notice is restyled, one turns amber, and the toasts start announcing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
|
Error notices still do not announce. That is a follow-up PR, not this one. The guard here only announces a plain string, and The fix is to carry the announcement next to the message rather than derive it from it: add Two details for whoever picks it up:
— Terminator |
dhasilva
left a comment
There was a problem hiding this comment.
Reviewed with /jetpack-review-pr (standard depth — 147 lines, 1 project). No blockers; two suggestions inline.
The migration is careful. I checked the things that usually break on a @wordpress/components → @wordpress/ui swap and they are all handled:
- Every WPDS prop used exists on 0.21.0.
intent(notstatus),Notice.Description,Notice.CloseIconwithlabel. - The dismiss control keeps its accessible name.
Notice.CloseIconforwardslabeltoIconButton, which sets it asaria-labelon the underlyingButton, sogetByRole( 'button', { name: 'Dismiss notice.' } )still resolves — confirmed live by the e2e suite passing on both WP 7.0 and WP latest. - The
spokenMessageguard is complete and load-bearing.Notice.RootcallssafeRenderToString( spokenMessage )during its own render, before itsuseEffect, and@wordpress/element'srenderToStringinvokes function components directly (serialize.ts:616) and forwardRef ones viatype.render( props )(serialize.ts:633). Protect's error message is a fragment holding a WPDSLink, whoseuseRenderhook would land insideNotice.Root's hook list — and the count changes when the toast goes from a string message to a JSX one without unmounting. That is exactly "Rendered more hooks than during the previous render". Gating ontypeof === 'string'keeps the nested hook count at zero. - Passing
nullrather than omitting the prop is the right call, and worth keeping deliberate:Notice.RootdestructuresspokenMessage = children, and a destructuring default fires only onundefined— so omitting it would serialize the children instead. - Operator precedence on the
spokenMessageline is fine;&&binds tighter than?:.
RTL looks like a violation but isn't: .notice--floating uses physical right/margin-left, but Protect's build runs MiniCssWithRtlPlugin/WebpackRtlPlugin via StandardPlugins(), and Assets::register_script swaps in the generated .rtl.css when is_rtl() (packages/assets/src/class-assets.php:437-440). Net RTL improvement anyway — it deletes the border-left: 4px.
Test quality: the e2e change is necessary, not cosmetic, and the reason is worth writing down. speak() writes into #a11y-speak-polite, which @wordpress/a11y appends to document.body outside the app root, and that node is position:absolute; width:1px; height:1px; clip — which Playwright counts as visible. An unscoped getByText( 'Changes saved' ) would match two nodes and trip strict mode on the second of six calls. Scoping to #jetpack-protect-root fixes it.
Bundle size (~56KB gzip) is noted rather than filed — it isn't the deciding factor for WPDS adoption, and Boost, the Jetpack plugin and packages/search already pay the same cost.
Known a11y gap, deliberately not filed here: because only plain strings pass the gate, error toasts are silent to screen readers — and errors are exactly what would use assertive. That is the correct trade in isolation (the alternative is the hook-order crash above), and #52167 is stacked here to close it. Worth landing them close together.
One thing worth knowing but out of scope: the same renderToString-during-render hazard already applies to Protect's pre-existing Notice.Root call sites that pass ActionButton/ActionLink children with no spokenMessage (routes/settings/index.jsx:57,77, routes/firewall/index.jsx:218, components/upgrade-notice/index.tsx:56). They survive today only because their children's hook count happens not to change between renders. Upstream sharp edge, not this PR's to fix.
Changelog is valid, and no other plugin needs an entry — git grep confirms nothing outside projects/plugins/protect/src imports this component.
Verdict: minor issues — can merge after addressing. The first suggestion is a one-line fix I'd take before merge; the second is a follow-up.
Generated by Claude.
| // Only the toast announces: the modal notices are read when their dialog | ||
| // opens. A non-string message is never passed — `Notice.Root` renders it | ||
| // to a string mid-render, which corrupts hook order when it holds a Link. | ||
| spokenMessage={ floating && 'string' === typeof message ? message : null } |
There was a problem hiding this comment.
[suggestion] The gate is right and must stay, but the first sentence justifies it with accessibility coverage that doesn't exist.
// Only the toast announces: the modal notices are read when their dialog opens.
Protect has no dialog. components/modal/index.jsx is a plain <div className={ styles.modal }> wrapping a <div className={ styles.modal__window }> — no role="dialog", no aria-modal, no focus move into the window, no aria-describedby. git grep -n 'role="dialog"\|aria-modal\|Dialog\.' -- projects/plugins/protect/src returns nothing at all.
So neither non-floating call site (fix-threat-modal/index.jsx:62,78 and user-connection-needed-modal/index.jsx:27, the latter reached through UserConnectionGate in the same shell) is announced when it opens — a screen-reader user's focus stays on the trigger button. The comment is wrong in the direction that matters: it tells the next reader the modal notices are already handled.
The second and third lines are accurate and load-bearing. Suggest keeping the mechanism and dropping the false half:
// `Notice.Root` serializes `spokenMessage` mid-render, so a JSX message runs its
// hooks inside Root and corrupts hook order. Strings only, and only for the toast.(Related: #52167 removes this comment entirely when it moves the floating test into its new effect. Worth settling the true rationale here so that PR can carry it across rather than restoring the inaccurate version.)
Generated by Claude.
| // `@wordpress/ui`'s close control reaches `@wordpress/theme` and | ||
| // `@wordpress/private-apis`. This page registers no shim for those | ||
| // handles, and an unmet one stops the whole bundle enqueuing. | ||
| requestMap: { | ||
| '@wordpress/theme': { external: false }, | ||
| '@wordpress/private-apis': { external: false }, | ||
| }, |
There was a problem hiding this comment.
[suggestion] This is the fourth independent copy of this rationale, with the same two requestMap entries. It already lives in:
projects/plugins/boost/webpack.config.js:86-93projects/plugins/jetpack/tools/webpack.config.js:234-244projects/packages/search/tools/webpack.dashboard.config.js:40-48
AGENTS.md's tie-break is the shared helper over each of its callers, and the helper here is real: projects/js-packages/webpack-config/src/webpack.js:131, whose defaultRequestMap merges under every caller's requestMap and already carries three entries with the rationale stated once.
To be clear, the change is correct as written and the override is genuinely needed — DEWP 6.54.0 bundles @wordpress/ui (lib/util.js:2-13) but externalizes @wordpress/theme and @wordpress/private-apis to script handles, and the search comment's point about both having to be bundled jointly, so @wordpress/theme's lock() lands on the same consent map, is load-bearing.
So this is a follow-up rather than a change request. Either move the pair into defaultRequestMap (weighing that it flips the default for wp-build routes that do register the shim) and delete all four blocks, or cut this copy to a one-line pointer at whichever file owns the decision. Adding a fourth independent copy is the one option that guarantees they drift.
For the record, the two comments this PR adds that clearly earn their place are the container-type note in styles.module.scss and the e2e scoping note in start.test.ts — both state a live constraint and neither has an expiry date.
Generated by Claude.
The comment above `spokenMessage` said the modal notices are read when their dialog opens. Protect has no dialog: `components/modal/index.jsx` renders a plain `<div>` with no `role="dialog"`, no `aria-modal` and no focus move into the window, so nothing reads those notices. Keep the half that is true and load-bearing -- why the prop takes strings only -- and drop the rest. Also cut the `requestMap` comment in the plugin's webpack config to the live constraint. The mechanism is already spelled out in three other configs, and a fourth copy drifts. The code is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
9c2c4d4 to
43abf94
Compare
The pair has to be bundled together: `@wordpress/theme` opts in and calls `lock()` at module scope, and `@wordpress/private-apis` keeps its consent map per module instance. Bundle one and externalize the other and the two maps diverge, which surfaces at runtime as "Cannot unlock an object that was not locked before" or a duplicate opt-in, with nothing pointing back at the build config. The full account lives in #48173. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
…ng it Three other webpack configs already carry this reasoning in full. A fourth copy is the one outcome that guarantees they drift, so this leaves the constraint and the reference and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
Fixes JETPACK-2554
Proposed changes
Noticewith the@wordpress/uiNotice, so Protect's notices move to the design system without touching a call site.@wordpress/uiis already a direct dependency at 0.21.0.typemaps tointent,messagetoNotice.Description,dismissabletoNotice.CloseIcon. Thedurationauto-clear timer is unchanged. The hand-rolled@wordpress/iconsswitch, thearia-labelstring and the chassis CSS (background, colours, icon box, message typography, close-button chrome) all go — 90 lines out, 30 in.z-index, and themargin-bottomunder.notice--infothatuser-connection-needed-modaldepends on.Two things worth a reviewer's eye:
type="warning"now looks like a warning.fix-threat-modal/index.jsx:79passes it for an inactive extension. The old switch had nowarningcase, so it fell through to the info icon on the default gray chassis.@wordpress/uihas a real warning intent, so that notice is now amber. Intentional, but it is a visible change.container-typeis reset on the floating toast.Notice.Rootsetscontainer-type: inline-size; inside aposition: fixedcontainer that shrinks to fit, a size-contained child reports no width. Measured in a headless browser: 26×120px without the override, 249×48px with it. Same trap as the Jetpack plugin's toasts in Jetpack notices: back SimpleNotice with the @wordpress/ui Notice #52015.Four things the later commits added
Notice.RootrendersspokenMessageto a string during its own render. Every error notice interpolates aLink, whose hooks then land inNotice.Root's hook list, and theuseEffecton the next line throws — React unmounted the whole Protect dashboard on any failed request. Only a plain string is announced now, so the saved and saving toasts speak and errors stay silent. Announcing errors needs a spoken string carried alongside the JSX, which is worth doing separately.@wordpress/themeand@wordpress/private-apisare bundled, not externalized.Notice.CloseIconreaches both throughIconButtonand its tooltip, so the build started emitting thewp-themeandwp-private-apisscript handles. Protect registers no shim for those, and WordPress refuses to enqueue a bundle with an unmet dependency.webpack.config.jsnow bundles them, as the Jetpack plugin's and Boost's admin builds already do. That costs about 27KB gzip on top of the ~29KB the design system Notice itself adds.Notice.CloseIcondefaults to "Dismiss", which would have renamed the control the e2e suite clicks.speak()leaves its text in the live region after the notice goes and the helper runs six times.Related product discussion/links
SimpleNotice.useNoticesReact context rather than sharing a toast surface, which is JETPACK-2556's decision, not this PR's.Does this pull request change what data or activity we track or use?
No.
Testing instructions
On a connected site with Jetpack Protect active, running this branch.
container-typereset is not applying.delete, open the threat and click Fix: an error notice shows if the extension is active, a warning notice if it is not. And with the site connected but your user not, opening any threat's Fix or Ignore raises the user-connection modal, whose info notice should keep a clear gap above the paragraph below it.I verified the first path on a local site and captured before/after screenshots. I could not reach the modal notices — they need a paid Scan plan with a vulnerable-extension threat, or a disconnected user — so those two are unverified by me.
This ships without automated tests, deliberately.
projects/plugins/protecthas no jest harness at all: no config, notestscript, notest-jsincomposer.json, no testing dependencies. I wrote the tests and ran them out of tree — thetype→intentmap for every type, thatdismissablerenders a close control and firing it callsclearNotice, and that no control renders otherwise. Five assertions pass, and mutatingINTENTS.errorto'info'fails the right one. Landing them means adding ~5 devDependencies, a lock file change and a CI job for Protect, which is a call for the plugin's owners rather than something to fold into a notice migration. Happy to open that separately.Lint, stylelint, typecheck and the webpack build are all clean.
Note on the screenshots: pay no attention to the "fixture notice" on top, that was an injection. The notice shows on the toast in the upper right corner