Skip to content

Protect notices: announce errors to screen readers - #52167

Merged
dhasilva merged 21 commits into
trunkfrom
update/protect-notice-announce-errors
Sep 11, 2026
Merged

dhasilva merged 21 commits into
trunkfrom
update/protect-notice-announce-errors

Conversation

@CGastrell

@CGastrell CGastrell commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • Notice announces its own message with speak(), in an effect keyed on the notice, and hands Notice.Root null unconditionally.
  • NoticeState gains a plain-text spokenMessage and an id. showErrorNotice sets 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.Root renders whatever it is handed for spokenMessage to a string during its own render. Every error message interpolates a support Link, and rendering a hook-using component that way appends its hooks to Notice.Root's hook list — the useEffect on 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.Root also announces only when the value changes, so two identical errors in a row would announce once. data/scan/use-fixers-query.ts:153 reaches 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:

  • The announcement carries the whole sentence, including "Please try again or contact support." Stripping the interpolation tags off the already-translated string costs no new translation.
  • Errors announce assertively, everything else politely. That register is chosen in Notice, not by the design system.
  • @wordpress/a11y is newly declared, but costs nothing: it externalizes to the wp-a11y script 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 MutationObserver rather than reading their text afterwards — a region keeps its previous content, so reading it cannot tell a fresh announcement from a leftover one:

for ( const id of [ 'a11y-speak-polite', 'a11y-speak-assertive' ] ) {
	new MutationObserver( ms => ms.forEach( m => {
		const t = m.type === 'characterData' ? m.target.textContent : [ ...m.addedNodes ].map( n => n.textContent ).join( '' );
		if ( t ) console.log( id, JSON.stringify( t ) );
	} ) ).observe( document.getElementById( id ), { childList: true, characterData: true, subtree: true } );
}
  • Open Jetpack Protect → Settings and toggle Account protection. a11y-speak-polite should be written with Saving Changes… and then Changes saved.
  • Now make the requests fail — force a 500 on /wp-json/jetpack-protect/** — and toggle again. The red error toast should appear, and a11y-speak-assertive should be written with the error text, ending "Please try again or contact support."
  • The repeat case. Trigger the same failure twice with no other notice in between — the Enable Firewall button on the Firewall tab is the path, since it raises no "Saving Changes…" first. Both failures must produce a write; the second carries a trailing non-breaking space, which is how speak() marks a repeat.
  • Confirm the dashboard survives it: document.getElementById( 'jetpack-protect-root' ).innerHTML.length must stay non-zero, and the console must show no TypeError. That is the crash Protect notices: back the notice component with the @wordpress/ui Notice #52160 fixed, and this change must not reintroduce it.
  • With a screen reader on, the failed save should be spoken. Before this change it was silent.
  • Put focus on the toast's dismiss button and trigger another failure from Enable Firewall. Focus must stay on that button — an earlier revision remounted the notice and dropped focus to the body. Use that path, not the Settings toggle: its "Saving Changes…" notice is not dismissable, so the button legitimately goes away there.

Automated: ESLint and tsgo --noEmit are clean, jetpack build plugins/protect succeeds, and build/index.asset.php still carries neither wp-theme nor wp-private-apis.

followup-error-announce

CGastrell and others added 7 commits September 9, 2026 14:46
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
@CGastrell CGastrell added the [Status] Needs Review This PR is ready for review. label Sep 9, 2026
@CGastrell CGastrell self-assigned this Sep 9, 2026
@github-actions github-actions Bot added the [Plugin] Protect A plugin with features to protect a site: brute force protection, security scanning, and a WAF. label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 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!


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.

@jp-launch-control

jp-launch-control Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Code Coverage Summary

This 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. 🤷

Full summary · PHP report · JS report

CGastrell and others added 3 commits September 9, 2026 19:57
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
@github-actions

github-actions Bot commented Sep 10, 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 update/protect-notice-announce-errors branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack update/protect-notice-announce-errors
bin/jetpack-downloader test jetpack-mu-wpcom-plugin update/protect-notice-announce-errors

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 added the Docs label Sep 10, 2026
`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 dhasilva 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 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, under domReady. Protect's built build/index.asset.php already lists wp-a11y on 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.Root deliberately renders no role (its own comment: "no role attribute needed as it would cause double announcements") and announces solely through useSpokenMessage. Passing spokenMessage={ null } makes safeRenderToString return undefined, so that effect no-ops. Exactly one speak() per notice.
  • null vs omitting — the comment on that line is load-bearing and correct. RootProps destructures spokenMessage = children, and a destructuring default fires only on undefined, so omitting the prop would feed the JSX message to renderToString during Root's own render. Keep it.
  • Politeness reproduces @wordpress/ui's own getDefaultPoliteness() exactly, so the register stays consistent with the design system even though it's now chosen locally.
  • Repeat announcements genuinely work: filterMessage appends \u00A0 when the message matches the previous one, and clear() empties the region before the write. The id key 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 ." — your replace( /<\/?[^>]+>/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.

Comment on lines +33 to +34
// 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.

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.

[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.

Comment on lines +35 to +39
useEffect( () => {
if ( floating && 'string' === typeof spoken ) {
speak( spoken, 'error' === type ? 'assertive' : 'polite' );
}
}, [ id, floating, spoken, type ] );

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.

[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.

Comment on lines +24 to +32
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;

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.

[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
@CGastrell
CGastrell force-pushed the update/protect-notice-wpds branch from 9c2c4d4 to 43abf94 Compare September 10, 2026 16:06
@CGastrell
CGastrell force-pushed the update/protect-notice-announce-errors branch from 13c2e2b to 2ca4225 Compare September 10, 2026 16:06
…announce-errors

# Conflicts:
#	projects/plugins/protect/src/js/components/notice/index.jsx
@CGastrell
CGastrell force-pushed the update/protect-notice-announce-errors branch from 2ca4225 to 0a5adf3 Compare September 10, 2026 16:07
CGastrell and others added 2 commits September 10, 2026 15:33
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
@simison

simison commented Sep 10, 2026

Copy link
Copy Markdown
Member

Notice announces its own message with speak(), in an effect keyed on the notice, and hands Notice.Root null unconditionally.

Note that we're considering removing this:

CGastrell and others added 2 commits September 10, 2026 16:09
…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
Base automatically changed from update/protect-notice-wpds to trunk September 10, 2026 19:21
…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
Comment thread projects/plugins/protect/src/js/hooks/use-notices.tsx Fixed
CGastrell and others added 3 commits September 10, 2026 16:33
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
@dhasilva
dhasilva merged commit d121e06 into trunk Sep 11, 2026
165 of 167 checks passed
@dhasilva
dhasilva deleted the update/protect-notice-announce-errors branch September 11, 2026 22:38
@github-actions github-actions Bot removed the [Status] Needs Review This PR is ready for review. label Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Docs E2E Tests [Plugin] Protect A plugin with features to protect a site: brute force protection, security scanning, and a WAF. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants