Repository navigation
Protect notices: announce errors to screen readers - #52167
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
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
…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
`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
The announcement was derived from the message, and only a plain string was ever handed to `Notice.Root` — anything else corrupts its hook order. `showErrorNotice` always builds JSX, because it interpolates a support link, so failed saves stayed silent while "Changes saved." did not. Carry the announcement in the notice state instead: `showErrorNotice` sets `spokenMessage` from the plain string it already receives, and the component prefers it over the message. It still resolves to null rather than undefined, so `Notice.Root` never falls back to rendering children. 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 announcement dropped the "Please try again or contact support." sentence because it only exists with interpolation tags. Stripping the tags off the translated string gives the plain sentence at no translation cost, so the announcement now carries what the notice says. Each notice also gets an identity, and the toast is keyed on it. Without that a repeat of the same error re-renders with identical props, the announcing effect never re-runs, and the second failure is silent. `use-fixers-query` is the path that reaches it: its polling error fires the same message with no other notice in between. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
Keying the toast on the notice id remounted it, which destroyed the node focus was in: a keyboard user reading the error, with focus on its support link or dismiss button, lost focus to the body when the next notice arrived. In an accessibility change that is not a fair trade. The announcement now varies instead of the element. A repeat alternates a trailing non-breaking space — the same device `@wordpress/a11y` uses for this — so the announcing effect re-runs while the element stays mounted. Two identical errors in a row take consecutive ids, so the parity always flips between them; anything that arrives in between announces on its own text. Two more from the same review: the provider stamps the id, so no future writer can forget it and silently break the repeat, and the tag strip no longer names `supportLink`, so renaming the tag cannot silently stop it working. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
The parity nonce was wrong. `Notice.Root` announces when the value it is handed changes, so the previous revision alternated a trailing space keyed on the notice id — but the id advances once per stored notice while the announcement happens once per committed render. Any event that stores an even number of notices in one commit leaves the parity where it was, and the repeat is silent again. `use-fixers-query` is exactly that shape: one instance per threat row plus the modals, all sharing a query, all raising the same error in one commit. So the announcement moves here, keyed on the notice, and `Notice.Root` is handed `null` unconditionally — it never renders a message to a string now, which is what the crash came from. `speak()` handles an identical repeat itself. Two more from the same review: the id is stamped outside the state updater, and the component README documents `spokenMessage` and `id`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
`duration` keyed its timer on the message, so a second identical success inherited whatever was left of the first one's 7.5 seconds instead of starting again. The notice's id is what says "this is a different notice", and the announcement already uses it. Also from review: the mid-render rationale is stated once, on the prop that carries it, and the new dependency is filed in alphabetical order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
dhasilva
left a comment
There was a problem hiding this comment.
Reviewed with /jetpack-review-pr (standard depth — 82 lines, 1 project). No blockers; four suggestions, three inline and one below.
Scope note: this targets update/protect-notice-wpds (#52160's head), not trunk, so I reviewed only the incremental diff and judged it against the tree as #52160 leaves it, reading origin/update/protect-notice-wpds whenever I needed the "before" state. Two things I deliberately did not file here because they belong to #52160: the @wordpress/ui migration itself, and the mid-render renderToString hook-order hazard it guards against — I only verified that this PR's spokenMessage={ null } still closes that hole, which it does, since RootProps defaults spokenMessage to children. That guard is a genuine prerequisite, so #52160 should land first.
The mechanism is correct on every axis I could check
Verified against the installed @wordpress/ui@0.21.0 and @wordpress/a11y@4.54.0 sources rather than from the description:
- The announcement actually reaches AT.
speak()only writes to#a11y-speak-polite/#a11y-speak-assertive; it doesn't create them.setup()does, underdomReady. Protect's builtbuild/index.asset.phpalready listswp-a11yon trunk, before this PR — so the regions exist long before any user action creates a notice. No inject-and-populate-in-one-render trap, and this also confirms your claim that the new dependency costs no new script handle. - No double announcement.
Notice.Rootdeliberately renders norole(its own comment: "no role attribute needed as it would cause double announcements") and announces solely throughuseSpokenMessage. PassingspokenMessage={ null }makessafeRenderToStringreturnundefined, so that effect no-ops. Exactly onespeak()per notice. nullvs omitting — the comment on that line is load-bearing and correct.RootPropsdestructuresspokenMessage = children, and a destructuring default fires only onundefined, so omitting the prop would feed the JSX message torenderToStringduring Root's own render. Keep it.- Politeness reproduces
@wordpress/ui's owngetDefaultPoliteness()exactly, so the register stays consistent with the design system even though it's now chosen locally. - Repeat announcements genuinely work:
filterMessageappends\u00A0when the message matches the previous one, andclear()empties the region before the write. Theidkey is what gets the effect to fire at all in that case — it's necessary, not belt-and-braces. - Tag stripping isn't redundant:
speak()strips tags internally but replaces each with a space, which would yield "contact support ." — yourreplace( /<\/?[^>]+>/g, '' )avoids that.
Also cleared: no announcement storms (the effect's deps are all primitives or strings); no remount, since protect-app/index.jsx:95 renders the notice at a static JSX position outside the router <Outlet />, so truthy→truthy is a prop update and focus inside the toast survives; the id stamp is computed outside the state updater, so React re-invoking the updater can't bump it twice; showErrorNotice() with no argument still works via the message || __( 'An error occurred.' ) fallback; id never leaks to the DOM; and there's no StrictMode to double-fire in dev.
The auto-dismiss dep change (message → id) is a small improvement rather than a regression — two identical success messages now restart the 7.5s timer where before they wouldn't. Both modal call sites pass no duration, so nothing changes for them.
Changelog is valid and correctly scoped: I diffed the changelog directory across trunk, update/protect-notice-wpds and this branch — update-protect-notice-wpds is #52160's and inherited, and this PR adds exactly one file. Dependency @wordpress/a11y@4.54.0 is in correct alphabetical position and matches the WordPress train the project is pinned to.
One suggestion that doesn't touch this diff
[suggestion] A failing fixers poll now interrupts the screen reader assertively every 5 seconds.
projects/plugins/protect/src/js/data/scan/use-fixers-query.ts:148-155 is the call site your description names as the motivating repeat case, so it's worth being deliberate about which half of it you want.
That effect fires on every change of fixersQuery.error, and each failed fetch produces a fresh Error — so each one calls showErrorNotice → a new id → a new assertive speak(). Meanwhile refetchInterval (L120-143) returns 5000, then 15000, for as long as any threat is in_progress and not stale. The reset on L152 writes to [ QUERY_FIXERS_KEY ] while the query's key is [ QUERY_FIXERS_KEY, threatIds ], so under TanStack v5's exact matching it doesn't clear the data that keeps the poll alive.
Visually this already re-renders an identical toast and reads as static. Announced assertively it becomes an interruption every 5 seconds, and assertive cuts off whatever the user was reading. Before this PR that path was silent, so the regression is introduced here even though the underlying loop isn't.
Worth separating the two cases: a repeat that follows a user action (the Enable Firewall path in your testing instructions) should announce; a repeat produced by a background poll shouldn't, or should be rate-limited. Simplest lever is at the call site — announce the polling failure once per error streak rather than per error object.
Test coverage
Protect has no JS test files and no jest wiring at all, so this PR couldn't add unit tests without first bootstrapping the infrastructure — pre-existing and not this change's fault. It does mean three subtle invariants (announce once per notice; assertive only for errors; an identical repeat announces again) are guarded only by the manual MutationObserver procedure in the description, which is the right way to test this. projects/js-packages/licensing/components/activation-screen-error/test/component.jsx is the nearest precedent in the monorepo for asserting on @wordpress/ui Notice announcements, if someone stands jest up here later.
Verdict: minor issues — can merge after addressing. The assertive-repeat item is the one with user-visible consequences and the one I'd want a decision on before merge; the other three are small.
Generated by Claude.
| // Keyed on the notice, not the message: `Notice.Root` announces only when the value | ||
| // it is handed changes, so an identical repeat would never announce twice. |
There was a problem hiding this comment.
[suggestion] This explains the alternative the PR rejected rather than the code that's here.
Notice.Root is handed spokenMessage={ null } twenty-five lines below and announces nothing, so the mechanism the comment cites isn't in play in this file at all. The reader has to work out for themselves that the actual constraint is React's own by-value comparison of the dep array — which applies to this effect regardless of what Notice.Root does.
It's the "present-tense contrast with an alternative that isn't in the tree" shape: once this merges, the delegated-announcement version is nowhere a reader can reach. Your PR description already argues it at length, which is where it belongs.
Keep the invariant, drop the comparison:
// Keyed on the id: effect deps compare by value, so an identical repeat would not re-fire.(The other new comment, on spokenMessage={ null }, is the opposite case — spokenMessage = children is a live default in RootProps that a reader can reach and would trip over. Keep that one as is.)
Generated by Claude.
| useEffect( () => { | ||
| if ( floating && 'string' === typeof spoken ) { | ||
| speak( spoken, 'error' === type ? 'assertive' : 'polite' ); | ||
| } | ||
| }, [ id, floating, spoken, type ] ); |
There was a problem hiding this comment.
[suggestion] The floating guard arrived here without its rationale.
#52160 carried the reason on the prop it guarded — // Only the toast announces: the modal notices are read when their dialog opens. This PR deletes that line and moves the floating test into the new effect without carrying anything across, so a reader now meets if ( floating && … ) with no explanation of why fix-threat-modal and user-connection-needed-modal are excluded. It also couples an announcement to what is otherwise a styling flag, which deserves a sentence.
One caveat before you restore the old wording: it isn't true. Reviewing #52160 I checked, and Protect has no dialog — components/modal/index.jsx is a plain <div>, and git grep -n 'role="dialog"\|aria-modal\|Dialog\.' -- projects/plugins/protect/src returns nothing. The modal notices aren't read when their dialog opens; they aren't read at all, and focus stays on the trigger button.
So the honest version of this comment says the modal notices are silent, which is really a behaviour question rather than a comment question — worth deciding which you want here.
The README compounds it either way: README.md:18 documents spokenMessage as "the plain-text announcement for screen readers" with no mention that nothing is announced at all unless floating is set, so a future caller will reasonably expect a modal notice to speak. Worth qualifying that sentence alongside whatever you put here.
Generated by Claude.
| let noticeId = 0; | ||
|
|
||
| const NoticeContext = createContext< NoticeContextValue | undefined >( undefined ); | ||
|
|
||
| export const NoticeProvider: FC< { children: ReactNode } > = ( { children } ) => { | ||
| const [ notice, setNotice ] = useState< NoticeState >( null ); | ||
| const [ notice, setNoticeState ] = useState< NoticeState >( null ); | ||
|
|
||
| const setNotice: Dispatch< SetStateAction< NoticeState > > = useCallback( value => { | ||
| const id = ++noticeId; |
There was a problem hiding this comment.
[suggestion] id is a sequence number, not an identity, and it collides with a DOM attribute name.
++noticeId runs on every setNotice call, so re-showing the same logical notice produces a different value — which is precisely the behaviour you want here, but not what "id" says. README.md states it as "id identifies the notice", which is the opposite of what it does.
It also lands on a component whose root renders a div, so the day someone adds { ...rest } to WPNotice.Root it silently becomes a DOM id attribute holding a bare integer.
noticeId or announcementKey says what it is and can't collide. Cheap to rename now while the only consumers are three files.
(Worth keeping the current shape otherwise — computing the stamp outside the setNoticeState updater is the careful version, since React can re-invoke an updater and would otherwise bump the counter twice.)
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
13c2e2b to
2ca4225
Compare
…announce-errors # Conflicts: # projects/plugins/protect/src/js/components/notice/index.jsx
2ca4225 to
0a5adf3
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
Note that we're considering removing this: |
…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
…e-announce-errors # Conflicts: # pnpm-lock.yaml # projects/plugins/protect/package.json # projects/plugins/protect/src/js/components/notice/README.md # projects/plugins/protect/src/js/components/notice/index.jsx
CodeQL reads a `<[^>]+>` strip as HTML sanitization and files it high severity (js/incomplete-multi-character-sanitization). Nothing here reaches innerHTML — the result goes to `speak()`, which writes textContent — but the generic form buys nothing over naming the one placeholder the string actually carries, and `createInterpolateElement` breaks just as loudly if that name ever changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
Rename the per-notice stamp from `id` to `noticeId`. It is a sequence number rather than an identity, and `id` would have become a real DOM attribute the day `Notice.Root` is handed rest props. Say why only the toast announces, and drop the comparison with `Notice.Root`'s own announcement: WordPress/gutenberg#82737 proposes removing that, so the comparison would describe a mechanism a reader cannot reach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6
Every failed poll produces a new Error object, so keying the effect on it re-announced the same message assertively every five seconds for as long as the poll kept failing. Key on `isError` instead: one announcement per streak, and another only once a success has come in between. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6
Follows #52160, and stacks on it — review that one first.
Proposed changes
Error notices in Jetpack Protect never reach screen readers. This makes them announce.
Noticeannounces its own message withspeak(), in an effect keyed on the notice, and handsNotice.Rootnullunconditionally.NoticeStategains a plain-textspokenMessageand anid.showErrorNoticesets the first from the error string it already receives; the provider stamps the second on every notice it stores.Why the component announces rather than
Notice.Root.Notice.Rootrenders whatever it is handed forspokenMessageto a string during its own render. Every error message interpolates a supportLink, and rendering a hook-using component that way appends its hooks toNotice.Root's hook list — theuseEffecton the next line then reads a mismatched slot, throws, and React unmounts the whole Protect dashboard. That was a live bug in #52160, guarded there by announcing plain strings only, which left errors silent.Notice.Rootalso announces only when the value changes, so two identical errors in a row would announce once.data/scan/use-fixers-query.ts:153reaches exactly that: its polling error fires the same message with no other notice in between.Announcing from the component settles both. Nothing is serialized mid-render, so the crash is gone by construction rather than by guard; the effect is keyed on the notice's id, so a repeat announces again;
speak()handles the repeated string itself; and the element is never remounted, so focus inside a notice survives.Three details worth knowing if you touch this again:
Notice, not by the design system.@wordpress/a11yis newly declared, but costs nothing: it externalizes to thewp-a11yscript handle the bundle already carried, and the build grows by 333 bytes.Related product discussion/links
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. I ran these steps on a local site; the readings below are what I measured.
Watch the live regions with a
MutationObserverrather than reading their text afterwards — a region keeps its previous content, so reading it cannot tell a fresh announcement from a leftover one:a11y-speak-politeshould be written withSaving Changes…and thenChanges saved./wp-json/jetpack-protect/**— and toggle again. The red error toast should appear, anda11y-speak-assertiveshould be written with the error text, ending "Please try again or contact support."speak()marks a repeat.document.getElementById( 'jetpack-protect-root' ).innerHTML.lengthmust stay non-zero, and the console must show noTypeError. That is the crash Protect notices: back the notice component with the @wordpress/ui Notice #52160 fixed, and this change must not reintroduce it.Automated: ESLint and
tsgo --noEmitare clean,jetpack build plugins/protectsucceeds, andbuild/index.asset.phpstill carries neitherwp-themenorwp-private-apis.