From 5c62af7f2cdc7c57073ebf0a1301b7d639249729 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 14:46:07 -0300 Subject: [PATCH 01/17] Protect notices: back the notice component with the @wordpress/ui Notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- .../changelog/update-protect-notice-wpds | 4 ++ .../src/js/components/notice/index.jsx | 55 +++++++---------- .../js/components/notice/styles.module.scss | 61 ++----------------- 3 files changed, 30 insertions(+), 90 deletions(-) create mode 100644 projects/plugins/protect/changelog/update-protect-notice-wpds diff --git a/projects/plugins/protect/changelog/update-protect-notice-wpds b/projects/plugins/protect/changelog/update-protect-notice-wpds new file mode 100644 index 000000000000..03007d940cff --- /dev/null +++ b/projects/plugins/protect/changelog/update-protect-notice-wpds @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Notices: restyle notices to match the WordPress design system. diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index 176c4d04e16c..196717c30469 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -1,9 +1,16 @@ -import { __ } from '@wordpress/i18n'; -import { check, close, info, cautionFilled as warning, Icon } from '@wordpress/icons'; +import { Notice as WPNotice } from '@wordpress/ui'; +import clsx from 'clsx'; import { useCallback, useEffect } from 'react'; import useNotices from '../../hooks/use-notices'; import styles from './styles.module.scss'; +const INTENTS = { + success: 'success', + error: 'error', + info: 'info', + warning: 'warning', +}; + const Notice = ( { dismissable = false, duration = null, @@ -13,19 +20,6 @@ const Notice = ( { } ) => { const { clearNotice } = useNotices(); - let icon; - switch ( type ) { - case 'success': - icon = check; - break; - case 'error': - icon = warning; - break; - case 'info': - default: - icon = info; - } - const onClose = useCallback( () => { clearNotice(); }, [ clearNotice ] ); @@ -44,25 +38,20 @@ const Notice = ( { }, [ clearNotice, duration, message ] ); return ( -
-
- -
-
{ message }
- { dismissable && ( - + + // Only the floating toast appears without a focus change, so only it needs + // announcing. The other two sit in a modal that is read when it opens. + spokenMessage={ floating ? message : null } + > + { message } + { dismissable && } + ); }; diff --git a/projects/plugins/protect/src/js/components/notice/styles.module.scss b/projects/plugins/protect/src/js/components/notice/styles.module.scss index ec6f6909c2d6..0971d9360a7f 100644 --- a/projects/plugins/protect/src/js/components/notice/styles.module.scss +++ b/projects/plugins/protect/src/js/components/notice/styles.module.scss @@ -1,13 +1,7 @@ .notice { - background-color: var(--jp-gray-90); - color: var(--jp-white); - display: flex; - border-radius: var(--jp-border-radius); // 4px - overflow: hidden; z-index: 1; &.notice--info { - border-left: 4px solid var(--jp-yellow-20); margin-bottom: calc(var(--spacing-base) * 3); // 24px } @@ -17,59 +11,12 @@ right: calc(var(--spacing-base) * 3); // 24px margin-left: calc(var(--spacing-base) * 3); // 24px + // The notice sets `container-type: inline-size`, so it reports no width to + // this shrink-to-fit fixed box and collapses to a sliver. + container-type: normal; + @media ( max-width: 782px ) { top: calc(var(--spacing-base) * 8); // 72px } } - - a, - a:link, - a:hover, - a:visited, - a:active { - color: var(--jp-white); - } -} - -.notice__icon { - background-color: var(--jp-yellow-30); - fill: var(--jp-white); - display: flex; - align-items: center; - justify-content: center; - padding: calc(var(--spacing-base) * 1.5); // 12px - - .notice--success & { - background-color: var(--jp-green-50); - } - - .notice--error & { - background-color: var(--jp-red); - } - - .notice--info & { - fill: var(--jp-yellow-50); - background-color: var(--jp-yellow-5); - } -} - -.notice__message { - font-size: 14px; - padding: calc(var(--spacing-base) * 1.75); // 14px - - .notice--info & { - color: var(--jp-gray-90); - background-color: var(--jp-yellow-5); - } -} - -.notice__close { - fill: var(--jp-gray); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: calc(var(--spacing-base) * 1.5); // 12px - background: transparent; - border: none; } From 5582d048f106254b6c3bd59a2af79e6d20b37c8d Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 16:00:10 -0300 Subject: [PATCH 02/17] Protect notices: keep the notice silent and its dismiss label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- .../protect/src/js/components/notice/index.jsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index 196717c30469..c94e89286eee 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -1,3 +1,4 @@ +import { __ } from '@wordpress/i18n'; import { Notice as WPNotice } from '@wordpress/ui'; import clsx from 'clsx'; import { useCallback, useEffect } from 'react'; @@ -45,12 +46,17 @@ const Notice = ( { styles[ `notice--${ type }` ], floating && styles[ 'notice--floating' ] ) } - // Only the floating toast appears without a focus change, so only it needs - // announcing. The other two sit in a modal that is read when it opens. - spokenMessage={ floating ? message : null } + // The legacy notice never announced, and none of the three call sites + // sits in a live region. Announcing is a separate change. + spokenMessage={ null } > { message } - { dismissable && } + { dismissable && ( + + ) } ); }; From a1778e265cadb04d9172dcad8ad04589a602566a Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 16:21:34 -0300 Subject: [PATCH 03/17] Protect notices: let the toast announce, and follow the three review notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- .../plugins/protect/changelog/update-protect-notice-wpds | 2 +- projects/plugins/protect/src/js/components/notice/README.md | 2 +- projects/plugins/protect/src/js/components/notice/index.jsx | 6 +++--- projects/plugins/protect/tests/e2e/specs/start.test.ts | 4 +++- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/projects/plugins/protect/changelog/update-protect-notice-wpds b/projects/plugins/protect/changelog/update-protect-notice-wpds index 03007d940cff..62cdba682889 100644 --- a/projects/plugins/protect/changelog/update-protect-notice-wpds +++ b/projects/plugins/protect/changelog/update-protect-notice-wpds @@ -1,4 +1,4 @@ Significance: patch Type: changed -Notices: restyle notices to match the WordPress design system. +Notices: Restyle to match the WordPress design system. diff --git a/projects/plugins/protect/src/js/components/notice/README.md b/projects/plugins/protect/src/js/components/notice/README.md index dcb3c4935936..60e3f8582494 100644 --- a/projects/plugins/protect/src/js/components/notice/README.md +++ b/projects/plugins/protect/src/js/components/notice/README.md @@ -13,4 +13,4 @@ A simple notice component for displaying alerts and messages to the user. ## Props -Supported `type` values are `info`, `success`, and `error`. \ No newline at end of file +Supported `type` values are `info`, `success`, `warning`, and `error`. \ No newline at end of file diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index c94e89286eee..fd4317532453 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -46,9 +46,9 @@ const Notice = ( { styles[ `notice--${ type }` ], floating && styles[ 'notice--floating' ] ) } - // The legacy notice never announced, and none of the three call sites - // sits in a live region. Announcing is a separate change. - spokenMessage={ null } + // Only the toast reports an async result with no focus change, so only it + // announces. The modal notices are read when their dialog opens. + spokenMessage={ floating ? message : null } > { message } { dismissable && ( diff --git a/projects/plugins/protect/tests/e2e/specs/start.test.ts b/projects/plugins/protect/tests/e2e/specs/start.test.ts index 5a4317daa130..84c5f0120634 100644 --- a/projects/plugins/protect/tests/e2e/specs/start.test.ts +++ b/projects/plugins/protect/tests/e2e/specs/start.test.ts @@ -7,7 +7,9 @@ import type { Locator, Page } from '@playwright/test'; * @param {Page} page - Playwright page object */ async function closeChangesSavedNotice( page: Page ) { - await expect( page.getByText( 'Changes saved' ) ).toBeVisible(); + // The toast announces, so `speak()` copies its text into `#a11y-speak-polite` + // at the end of the body. The notice itself is the earlier match. + await expect( page.getByText( 'Changes saved' ).first() ).toBeVisible(); await page.getByRole( 'button', { name: 'Dismiss notice.' } ).click(); } From 3cbca291ee15b361d914a863bcb88f2d862b7562 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 17:05:37 -0300 Subject: [PATCH 04/17] Protect notices: never hand a JSX message to the announcer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- .../plugins/protect/src/js/components/notice/index.jsx | 7 ++++--- projects/plugins/protect/src/js/hooks/use-notices.tsx | 2 +- projects/plugins/protect/tests/e2e/specs/start.test.ts | 8 +++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index fd4317532453..5d7bcb440cc9 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -46,9 +46,10 @@ const Notice = ( { styles[ `notice--${ type }` ], floating && styles[ 'notice--floating' ] ) } - // Only the toast reports an async result with no focus change, so only it - // announces. The modal notices are read when their dialog opens. - spokenMessage={ floating ? message : null } + // 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 } > { message } { dismissable && ( diff --git a/projects/plugins/protect/src/js/hooks/use-notices.tsx b/projects/plugins/protect/src/js/hooks/use-notices.tsx index 28dbed49ba10..e37304239106 100644 --- a/projects/plugins/protect/src/js/hooks/use-notices.tsx +++ b/projects/plugins/protect/src/js/hooks/use-notices.tsx @@ -10,7 +10,7 @@ interface NoticeState { message?: string | JSX.Element; dismissable?: boolean; duration?: number; - type?: 'success' | 'info' | 'error'; + type?: 'success' | 'info' | 'warning' | 'error'; } interface NoticeContextValue { diff --git a/projects/plugins/protect/tests/e2e/specs/start.test.ts b/projects/plugins/protect/tests/e2e/specs/start.test.ts index 84c5f0120634..450c38372fc4 100644 --- a/projects/plugins/protect/tests/e2e/specs/start.test.ts +++ b/projects/plugins/protect/tests/e2e/specs/start.test.ts @@ -7,9 +7,11 @@ import type { Locator, Page } from '@playwright/test'; * @param {Page} page - Playwright page object */ async function closeChangesSavedNotice( page: Page ) { - // The toast announces, so `speak()` copies its text into `#a11y-speak-polite` - // at the end of the body. The notice itself is the earlier match. - await expect( page.getByText( 'Changes saved' ).first() ).toBeVisible(); + // Scoped to the app root: the toast announces, and `speak()` leaves its text in + // `#a11y-speak-polite` after the notice goes, which would match on later calls. + await expect( + page.locator( '#jetpack-protect-root' ).getByText( 'Changes saved' ) + ).toBeVisible(); await page.getByRole( 'button', { name: 'Dismiss notice.' } ).click(); } From b7982ffedf308a48724f2c9243f32303a27cb70b Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 17:42:15 -0300 Subject: [PATCH 05/17] Protect notices: bundle the two packages the close control pulls in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- projects/plugins/protect/webpack.config.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/projects/plugins/protect/webpack.config.js b/projects/plugins/protect/webpack.config.js index 24741223ddd2..4f62c7e1ca7a 100644 --- a/projects/plugins/protect/webpack.config.js +++ b/projects/plugins/protect/webpack.config.js @@ -19,7 +19,19 @@ module.exports = [ ...jetpackWebpackConfig.resolve, }, node: false, - plugins: [ ...jetpackWebpackConfig.StandardPlugins() ], + plugins: [ + ...jetpackWebpackConfig.StandardPlugins( { + DependencyExtractionPlugin: { + // `@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 }, + }, + }, + } ), + ], module: { strictExportPresence: true, rules: [ From d1f50270a5afa1838c44e21468a315accd71e843 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 18:26:39 -0300 Subject: [PATCH 06/17] Protect notices: correct a comment the e2e scoping outdated 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- projects/plugins/protect/changelog/update-protect-notice-wpds | 2 +- projects/plugins/protect/tests/e2e/specs/start.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/plugins/protect/changelog/update-protect-notice-wpds b/projects/plugins/protect/changelog/update-protect-notice-wpds index 62cdba682889..ad5ddf81027c 100644 --- a/projects/plugins/protect/changelog/update-protect-notice-wpds +++ b/projects/plugins/protect/changelog/update-protect-notice-wpds @@ -1,4 +1,4 @@ -Significance: patch +Significance: minor Type: changed Notices: Restyle to match the WordPress design system. diff --git a/projects/plugins/protect/tests/e2e/specs/start.test.ts b/projects/plugins/protect/tests/e2e/specs/start.test.ts index 450c38372fc4..1587f5af5150 100644 --- a/projects/plugins/protect/tests/e2e/specs/start.test.ts +++ b/projects/plugins/protect/tests/e2e/specs/start.test.ts @@ -73,8 +73,8 @@ test.describe( 'Jetpack Protect Plugin', () => { // react-router , the matched FirewallRoute can appear in two panels at // once. Scoping to the Firewall panel keeps each locator matching a single // element instead of tripping Playwright strict mode. `exact` avoids matching the - // "Automatic firewall is on" heading variant. The "Changes saved" notice is left - // page-scoped on purpose: it renders at the app level, outside the tab panels. + // "Automatic firewall is on" heading variant. The "Changes saved" notice renders + // at the app level, so its helper scopes to the app root rather than a panel. const firewallPanel = page.getByRole( 'tabpanel', { name: 'Firewall', exact: true } ); await test.step( 'Navigate to firewall page', async () => { From f1c6b65ecba5613832b75a214e149d3091e6df6b Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 19:14:18 -0300 Subject: [PATCH 07/17] Protect notices: announce error notices to screen readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- .../changelog/update-protect-notice-announce-errors | 4 ++++ .../protect/src/js/components/notice/index.jsx | 11 +++++++---- projects/plugins/protect/src/js/hooks/use-notices.tsx | 2 ++ 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 projects/plugins/protect/changelog/update-protect-notice-announce-errors diff --git a/projects/plugins/protect/changelog/update-protect-notice-announce-errors b/projects/plugins/protect/changelog/update-protect-notice-announce-errors new file mode 100644 index 000000000000..78cca1c3f8bf --- /dev/null +++ b/projects/plugins/protect/changelog/update-protect-notice-announce-errors @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Notices: Announce errors to screen readers. diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index 5d7bcb440cc9..fc8654d53a8a 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -17,6 +17,7 @@ const Notice = ( { duration = null, floating = false, message, + spokenMessage, type = 'success', } ) => { const { clearNotice } = useNotices(); @@ -25,6 +26,8 @@ const Notice = ( { clearNotice(); }, [ clearNotice ] ); + const spoken = spokenMessage ?? ( 'string' === typeof message ? message : null ); + /** * Clears the notice automatically after {duration} milliseconds. */ @@ -46,10 +49,10 @@ const Notice = ( { styles[ `notice--${ type }` ], floating && styles[ 'notice--floating' ] ) } - // 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 } + // Only the toast announces: the modal notices are read when their dialog opens. + // The value must be null, never undefined — `Notice.Root` defaults it to the + // children and renders those mid-render, corrupting hook order on a JSX message. + spokenMessage={ floating ? spoken : null } > { message } { dismissable && ( diff --git a/projects/plugins/protect/src/js/hooks/use-notices.tsx b/projects/plugins/protect/src/js/hooks/use-notices.tsx index e37304239106..ee8bd10e0535 100644 --- a/projects/plugins/protect/src/js/hooks/use-notices.tsx +++ b/projects/plugins/protect/src/js/hooks/use-notices.tsx @@ -8,6 +8,7 @@ import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'; interface NoticeState { message?: string | JSX.Element; + spokenMessage?: string; dismissable?: boolean; duration?: number; type?: 'success' | 'info' | 'warning' | 'error'; @@ -69,6 +70,7 @@ export default function useNotices() { setNotice( { type: 'error', dismissable: true, + spokenMessage: message || __( 'An error occurred.', 'jetpack-protect' ), message: ( <> { message || __( 'An error occurred.', 'jetpack-protect' ) }{ ' ' } From 3957b874af25ded6648654dc680fa3544f5c4041 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 19:57:25 -0300 Subject: [PATCH 08/17] Protect notices: speak the whole error, and speak it every time 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- .../src/js/components/protect-app/index.jsx | 4 +- .../protect/src/js/hooks/use-notices.tsx | 40 +++++++++++-------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/projects/plugins/protect/src/js/components/protect-app/index.jsx b/projects/plugins/protect/src/js/components/protect-app/index.jsx index 5c173467e90e..d380452f6252 100644 --- a/projects/plugins/protect/src/js/components/protect-app/index.jsx +++ b/projects/plugins/protect/src/js/components/protect-app/index.jsx @@ -92,7 +92,9 @@ const ProtectApp = () => { title={ 'Protect' /** "Protect" is a product name, do not translate. */ } subTitle={ __( 'Automated malware scanning and firewall protection.', 'jetpack-protect' ) } > - { notice && } + { notice && ( + + ) }
diff --git a/projects/plugins/protect/src/js/hooks/use-notices.tsx b/projects/plugins/protect/src/js/hooks/use-notices.tsx index ee8bd10e0535..2191e3d89133 100644 --- a/projects/plugins/protect/src/js/hooks/use-notices.tsx +++ b/projects/plugins/protect/src/js/hooks/use-notices.tsx @@ -9,6 +9,8 @@ import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'; interface NoticeState { message?: string | JSX.Element; spokenMessage?: string; + // Changes on every notice, so an identical repeat still remounts and re-announces. + id?: number; dismissable?: boolean; duration?: number; type?: 'success' | 'info' | 'warning' | 'error'; @@ -19,6 +21,8 @@ interface NoticeContextValue { setNotice: Dispatch< SetStateAction< NoticeState > >; } +let noticeId = 0; + const NoticeContext = createContext< NoticeContextValue | undefined >( undefined ); export const NoticeProvider: FC< { children: ReactNode } > = ( { children } ) => { @@ -45,6 +49,7 @@ export default function useNotices() { const showSuccessNotice = useCallback( ( message: string ) => { setNotice( { + id: ++noticeId, type: 'success', dismissable: true, duration: 7_500, @@ -57,6 +62,7 @@ export default function useNotices() { const showSavingNotice = useCallback( ( message?: string ) => { setNotice( { + id: ++noticeId, type: 'info', dismissable: false, message: message || __( 'Saving Changes…', 'jetpack-protect' ), @@ -67,28 +73,30 @@ export default function useNotices() { const showErrorNotice = useCallback( ( message: string ) => { + const error = message || __( 'An error occurred.', 'jetpack-protect' ); + const advice = __( + 'Please try again or contact support.', + 'jetpack-protect' + ); + setNotice( { + id: ++noticeId, type: 'error', dismissable: true, - spokenMessage: message || __( 'An error occurred.', 'jetpack-protect' ), + // The same translated string, with the interpolation tags stripped. + spokenMessage: `${ error } ${ advice.replace( /<\/?supportLink>/g, '' ) }`, message: ( <> - { message || __( 'An error occurred.', 'jetpack-protect' ) }{ ' ' } - { createInterpolateElement( - __( - 'Please try again or contact support.', - 'jetpack-protect' + { error }{ ' ' } + { createInterpolateElement( advice, { + supportLink: ( + ), - { - supportLink: ( - - ), - } - ) } + } ) } ), } ); From d7fbcf29fe91e59f72a4710fbdb5054881e818ae Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 20:50:03 -0300 Subject: [PATCH 09/17] Protect notices: re-announce without stealing focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- .../protect/src/js/components/notice/index.jsx | 7 ++++++- .../src/js/components/protect-app/index.jsx | 4 +--- .../protect/src/js/hooks/use-notices.tsx | 18 +++++++++++------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index fc8654d53a8a..27af53bb98e6 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -16,6 +16,7 @@ const Notice = ( { dismissable = false, duration = null, floating = false, + id = 0, message, spokenMessage, type = 'success', @@ -28,6 +29,10 @@ const Notice = ( { const spoken = spokenMessage ?? ( 'string' === typeof message ? message : null ); + // An identical repeat has to re-run the announcing effect, and alternating a + // trailing non-breaking space is what `@wordpress/a11y` itself does for that. + const announced = spoken && id % 2 ? `${ spoken }\u00a0` : spoken; + /** * Clears the notice automatically after {duration} milliseconds. */ @@ -52,7 +57,7 @@ const Notice = ( { // Only the toast announces: the modal notices are read when their dialog opens. // The value must be null, never undefined — `Notice.Root` defaults it to the // children and renders those mid-render, corrupting hook order on a JSX message. - spokenMessage={ floating ? spoken : null } + spokenMessage={ floating ? announced : null } > { message } { dismissable && ( diff --git a/projects/plugins/protect/src/js/components/protect-app/index.jsx b/projects/plugins/protect/src/js/components/protect-app/index.jsx index d380452f6252..5c173467e90e 100644 --- a/projects/plugins/protect/src/js/components/protect-app/index.jsx +++ b/projects/plugins/protect/src/js/components/protect-app/index.jsx @@ -92,9 +92,7 @@ const ProtectApp = () => { title={ 'Protect' /** "Protect" is a product name, do not translate. */ } subTitle={ __( 'Automated malware scanning and firewall protection.', 'jetpack-protect' ) } > - { notice && ( - - ) } + { notice && }
diff --git a/projects/plugins/protect/src/js/hooks/use-notices.tsx b/projects/plugins/protect/src/js/hooks/use-notices.tsx index 2191e3d89133..59ae2dbceb55 100644 --- a/projects/plugins/protect/src/js/hooks/use-notices.tsx +++ b/projects/plugins/protect/src/js/hooks/use-notices.tsx @@ -9,7 +9,7 @@ import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'; interface NoticeState { message?: string | JSX.Element; spokenMessage?: string; - // Changes on every notice, so an identical repeat still remounts and re-announces. + // Stamped by the provider on every notice, so an identical repeat still announces. id?: number; dismissable?: boolean; duration?: number; @@ -26,7 +26,14 @@ 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 => { + setNoticeState( previous => { + const next = 'function' === typeof value ? value( previous ) : value; + return next ? { ...next, id: ++noticeId } : next; + } ); + }, [] ); return ( { children } @@ -49,7 +56,6 @@ export default function useNotices() { const showSuccessNotice = useCallback( ( message: string ) => { setNotice( { - id: ++noticeId, type: 'success', dismissable: true, duration: 7_500, @@ -62,7 +68,6 @@ export default function useNotices() { const showSavingNotice = useCallback( ( message?: string ) => { setNotice( { - id: ++noticeId, type: 'info', dismissable: false, message: message || __( 'Saving Changes…', 'jetpack-protect' ), @@ -80,11 +85,10 @@ export default function useNotices() { ); setNotice( { - id: ++noticeId, type: 'error', dismissable: true, - // The same translated string, with the interpolation tags stripped. - spokenMessage: `${ error } ${ advice.replace( /<\/?supportLink>/g, '' ) }`, + // The same translated string, with its interpolation tags stripped. + spokenMessage: `${ error } ${ advice.replace( /<\/?[^>]+>/g, '' ) }`, message: ( <> { error }{ ' ' } From f5cbff92749b3588a38dfef3298506835d8fdca1 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 21:33:31 -0300 Subject: [PATCH 10/17] Protect notices: announce per notice, not per distinct message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- pnpm-lock.yaml | 3 +++ projects/plugins/protect/package.json | 1 + .../src/js/components/notice/README.md | 4 +++- .../src/js/components/notice/index.jsx | 19 ++++++++++++------- .../protect/src/js/hooks/use-notices.tsx | 4 +++- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8fdfd8286c0..f490f268ca84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6366,6 +6366,9 @@ importers: '@tanstack/react-query': specifier: 5.101.2 version: 5.101.2(react@18.3.1) + '@wordpress/a11y': + specifier: 4.54.0 + version: 4.54.0 '@wordpress/api-fetch': specifier: 7.54.0 version: 7.54.0 diff --git a/projects/plugins/protect/package.json b/projects/plugins/protect/package.json index 46268cfda23d..0c1d3fb222d6 100644 --- a/projects/plugins/protect/package.json +++ b/projects/plugins/protect/package.json @@ -35,6 +35,7 @@ "@wordpress/api-fetch": "7.54.0", "@wordpress/components": "40.0.0", "@wordpress/compose": "8.7.0", + "@wordpress/a11y": "4.54.0", "@wordpress/data": "10.54.0", "@wordpress/date": "5.54.0", "@wordpress/element": "8.6.0", diff --git a/projects/plugins/protect/src/js/components/notice/README.md b/projects/plugins/protect/src/js/components/notice/README.md index 60e3f8582494..963e2e5eedfd 100644 --- a/projects/plugins/protect/src/js/components/notice/README.md +++ b/projects/plugins/protect/src/js/components/notice/README.md @@ -13,4 +13,6 @@ A simple notice component for displaying alerts and messages to the user. ## Props -Supported `type` values are `info`, `success`, `warning`, and `error`. \ No newline at end of file +Supported `type` values are `info`, `success`, `warning`, and `error`. + +`spokenMessage` is the plain-text announcement for screen readers, used when `message` carries markup. `id` identifies the notice, so an identical repeat announces again. \ No newline at end of file diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index 27af53bb98e6..24e18bcf7453 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -1,3 +1,4 @@ +import { speak } from '@wordpress/a11y'; import { __ } from '@wordpress/i18n'; import { Notice as WPNotice } from '@wordpress/ui'; import clsx from 'clsx'; @@ -29,9 +30,14 @@ const Notice = ( { const spoken = spokenMessage ?? ( 'string' === typeof message ? message : null ); - // An identical repeat has to re-run the announcing effect, and alternating a - // trailing non-breaking space is what `@wordpress/a11y` itself does for that. - const announced = spoken && id % 2 ? `${ spoken }\u00a0` : spoken; + // Announced here, keyed on the notice, rather than by `Notice.Root`: it diffs the + // value, so an identical repeat would never announce twice. `speak()` handles the + // repeat itself, and a JSX message never reaches its serializer this way. + useEffect( () => { + if ( floating && 'string' === typeof spoken ) { + speak( spoken, 'error' === type ? 'assertive' : 'polite' ); + } + }, [ id, floating, spoken, type ] ); /** * Clears the notice automatically after {duration} milliseconds. @@ -54,10 +60,9 @@ const Notice = ( { styles[ `notice--${ type }` ], floating && styles[ 'notice--floating' ] ) } - // Only the toast announces: the modal notices are read when their dialog opens. - // The value must be null, never undefined — `Notice.Root` defaults it to the - // children and renders those mid-render, corrupting hook order on a JSX message. - spokenMessage={ floating ? announced : null } + // Always null, never undefined: the default is the children, which + // `Notice.Root` renders mid-render, corrupting hook order on a JSX message. + spokenMessage={ null } > { message } { dismissable && ( diff --git a/projects/plugins/protect/src/js/hooks/use-notices.tsx b/projects/plugins/protect/src/js/hooks/use-notices.tsx index 59ae2dbceb55..e70251c88724 100644 --- a/projects/plugins/protect/src/js/hooks/use-notices.tsx +++ b/projects/plugins/protect/src/js/hooks/use-notices.tsx @@ -29,9 +29,11 @@ export const NoticeProvider: FC< { children: ReactNode } > = ( { children } ) => const [ notice, setNoticeState ] = useState< NoticeState >( null ); const setNotice: Dispatch< SetStateAction< NoticeState > > = useCallback( value => { + const id = ++noticeId; + setNoticeState( previous => { const next = 'function' === typeof value ? value( previous ) : value; - return next ? { ...next, id: ++noticeId } : next; + return next ? { ...next, id } : next; } ); }, [] ); From 7f85c8bacd2be53d3ddec23bd1f3c49844880c63 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Wed, 9 Sep 2026 22:11:25 -0300 Subject: [PATCH 11/17] Protect notices: tie the dismiss timer to the notice as well `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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- projects/plugins/protect/package.json | 2 +- .../plugins/protect/src/js/components/notice/index.jsx | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/projects/plugins/protect/package.json b/projects/plugins/protect/package.json index 0c1d3fb222d6..7bd8ec33243e 100644 --- a/projects/plugins/protect/package.json +++ b/projects/plugins/protect/package.json @@ -32,10 +32,10 @@ "@automattic/jetpack-connection": "workspace:*", "@automattic/jetpack-scan": "workspace:*", "@tanstack/react-query": "5.101.2", + "@wordpress/a11y": "4.54.0", "@wordpress/api-fetch": "7.54.0", "@wordpress/components": "40.0.0", "@wordpress/compose": "8.7.0", - "@wordpress/a11y": "4.54.0", "@wordpress/data": "10.54.0", "@wordpress/date": "5.54.0", "@wordpress/element": "8.6.0", diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index 24e18bcf7453..e9ca29ed759a 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -30,9 +30,8 @@ const Notice = ( { const spoken = spokenMessage ?? ( 'string' === typeof message ? message : null ); - // Announced here, keyed on the notice, rather than by `Notice.Root`: it diffs the - // value, so an identical repeat would never announce twice. `speak()` handles the - // repeat itself, and a JSX message never reaches its serializer this way. + // 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. useEffect( () => { if ( floating && 'string' === typeof spoken ) { speak( spoken, 'error' === type ? 'assertive' : 'polite' ); @@ -50,7 +49,7 @@ const Notice = ( { } return () => clearTimeout( timeout ); - }, [ clearNotice, duration, message ] ); + }, [ clearNotice, duration, id ] ); return ( Date: Thu, 10 Sep 2026 12:39:36 -0300 Subject: [PATCH 12/17] Protect notices: drop a comment's claim of a nonexistent dialog 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 `
` 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- projects/plugins/protect/src/js/components/notice/index.jsx | 5 ++--- projects/plugins/protect/webpack.config.js | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index 5d7bcb440cc9..0e60c7e57330 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -46,9 +46,8 @@ const Notice = ( { styles[ `notice--${ type }` ], floating && styles[ 'notice--floating' ] ) } - // 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. + // `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. spokenMessage={ floating && 'string' === typeof message ? message : null } > { message } diff --git a/projects/plugins/protect/webpack.config.js b/projects/plugins/protect/webpack.config.js index 4f62c7e1ca7a..b52247ab826a 100644 --- a/projects/plugins/protect/webpack.config.js +++ b/projects/plugins/protect/webpack.config.js @@ -22,9 +22,9 @@ module.exports = [ plugins: [ ...jetpackWebpackConfig.StandardPlugins( { DependencyExtractionPlugin: { - // `@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. + // `@wordpress/theme` and `@wordpress/private-apis` are bundled, not + // externalized: this page registers neither handle, and an unmet + // handle stops the whole bundle enqueuing. requestMap: { '@wordpress/theme': { external: false }, '@wordpress/private-apis': { external: false }, From 8e1dc643f008f80038f11c3aca7789c08ca4fe73 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Thu, 10 Sep 2026 15:33:50 -0300 Subject: [PATCH 13/17] Protect notices: keep the joint-bundling constraint on the requestMap 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- projects/plugins/protect/webpack.config.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/projects/plugins/protect/webpack.config.js b/projects/plugins/protect/webpack.config.js index b52247ab826a..fde4e70273c0 100644 --- a/projects/plugins/protect/webpack.config.js +++ b/projects/plugins/protect/webpack.config.js @@ -22,9 +22,10 @@ module.exports = [ plugins: [ ...jetpackWebpackConfig.StandardPlugins( { DependencyExtractionPlugin: { - // `@wordpress/theme` and `@wordpress/private-apis` are bundled, not - // externalized: this page registers neither handle, and an unmet - // handle stops the whole bundle enqueuing. + // Bundled, not externalized: this page registers neither script + // handle, and an unmet handle stops the whole bundle enqueuing. + // The two must go together, or `@wordpress/theme`'s module-init + // `lock()` lands on a different consent map. See #48173. requestMap: { '@wordpress/theme': { external: false }, '@wordpress/private-apis': { external: false }, From b053998a0534b7f04dc19ff8ca50c4c235f4eb08 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Thu, 10 Sep 2026 16:09:29 -0300 Subject: [PATCH 14/17] Protect notices: point at the requestMap rationale instead of restating 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- projects/plugins/protect/webpack.config.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/projects/plugins/protect/webpack.config.js b/projects/plugins/protect/webpack.config.js index fde4e70273c0..e236cfe34c7e 100644 --- a/projects/plugins/protect/webpack.config.js +++ b/projects/plugins/protect/webpack.config.js @@ -22,10 +22,7 @@ module.exports = [ plugins: [ ...jetpackWebpackConfig.StandardPlugins( { DependencyExtractionPlugin: { - // Bundled, not externalized: this page registers neither script - // handle, and an unmet handle stops the whole bundle enqueuing. - // The two must go together, or `@wordpress/theme`'s module-init - // `lock()` lands on a different consent map. See #48173. + // Bundled, never one without the other — see #48173. requestMap: { '@wordpress/theme': { external: false }, '@wordpress/private-apis': { external: false }, From b24acf22a0b044e9df5eab57043ecafeeb72588d Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Thu, 10 Sep 2026 16:33:50 -0300 Subject: [PATCH 15/17] Protect notices: name the tag the spoken message strips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --- projects/plugins/protect/src/js/hooks/use-notices.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/projects/plugins/protect/src/js/hooks/use-notices.tsx b/projects/plugins/protect/src/js/hooks/use-notices.tsx index e70251c88724..b25ed4641dde 100644 --- a/projects/plugins/protect/src/js/hooks/use-notices.tsx +++ b/projects/plugins/protect/src/js/hooks/use-notices.tsx @@ -89,8 +89,10 @@ export default function useNotices() { setNotice( { type: 'error', dismissable: true, - // The same translated string, with its interpolation tags stripped. - spokenMessage: `${ error } ${ advice.replace( /<\/?[^>]+>/g, '' ) }`, + // The same translated string, minus the one interpolation tag it carries. + // Named rather than generic: a `<[^>]+>` strip reads as HTML sanitization + // and CodeQL flags it, though nothing here ever reaches innerHTML. + spokenMessage: `${ error } ${ advice.replace( /<\/?supportLink>/g, '' ) }`, message: ( <> { error }{ ' ' } From 62214f7116b45f856bb0e6542d8fea5eb2061046 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 11 Sep 2026 19:01:50 -0300 Subject: [PATCH 16/17] Protect notices: answer the review on the announcement 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) Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6 --- .../protect/src/js/components/notice/README.md | 7 ++----- .../protect/src/js/components/notice/index.jsx | 14 +++++++------- .../plugins/protect/src/js/hooks/use-notices.tsx | 11 +++++------ 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/projects/plugins/protect/src/js/components/notice/README.md b/projects/plugins/protect/src/js/components/notice/README.md index af8bcf709c8c..e44cf6cc881c 100644 --- a/projects/plugins/protect/src/js/components/notice/README.md +++ b/projects/plugins/protect/src/js/components/notice/README.md @@ -5,14 +5,11 @@ A simple notice component for displaying alerts and messages to the user. ## Usage ```jsx - + ``` ## Props Supported `type` values are `info`, `success`, `warning`, and `error`. -`spokenMessage` is the plain-text announcement for screen readers, used when `message` carries markup. `id` identifies the notice, so an identical repeat announces again. +`spokenMessage` is the plain-text announcement for screen readers, used when `message` carries markup. Only `floating` notices are announced. `noticeId` changes on every notice, so an identical repeat announces again. diff --git a/projects/plugins/protect/src/js/components/notice/index.jsx b/projects/plugins/protect/src/js/components/notice/index.jsx index e9ca29ed759a..cedf877b1f0c 100644 --- a/projects/plugins/protect/src/js/components/notice/index.jsx +++ b/projects/plugins/protect/src/js/components/notice/index.jsx @@ -17,8 +17,8 @@ const Notice = ( { dismissable = false, duration = null, floating = false, - id = 0, message, + noticeId = 0, spokenMessage, type = 'success', } ) => { @@ -30,13 +30,13 @@ const Notice = ( { const spoken = spokenMessage ?? ( 'string' === typeof message ? message : null ); - // 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. + // Keyed on `noticeId`: effect deps compare by value, so an identical repeat would not re-fire. useEffect( () => { + // Toast only: notices rendered inside a modal are not announced today. if ( floating && 'string' === typeof spoken ) { speak( spoken, 'error' === type ? 'assertive' : 'polite' ); } - }, [ id, floating, spoken, type ] ); + }, [ noticeId, floating, spoken, type ] ); /** * Clears the notice automatically after {duration} milliseconds. @@ -49,7 +49,7 @@ const Notice = ( { } return () => clearTimeout( timeout ); - }, [ clearNotice, duration, id ] ); + }, [ clearNotice, duration, noticeId ] ); return ( { message } diff --git a/projects/plugins/protect/src/js/hooks/use-notices.tsx b/projects/plugins/protect/src/js/hooks/use-notices.tsx index b25ed4641dde..dba65efe55c5 100644 --- a/projects/plugins/protect/src/js/hooks/use-notices.tsx +++ b/projects/plugins/protect/src/js/hooks/use-notices.tsx @@ -10,7 +10,7 @@ interface NoticeState { message?: string | JSX.Element; spokenMessage?: string; // Stamped by the provider on every notice, so an identical repeat still announces. - id?: number; + noticeId?: number; dismissable?: boolean; duration?: number; type?: 'success' | 'info' | 'warning' | 'error'; @@ -21,7 +21,7 @@ interface NoticeContextValue { setNotice: Dispatch< SetStateAction< NoticeState > >; } -let noticeId = 0; +let lastNoticeId = 0; const NoticeContext = createContext< NoticeContextValue | undefined >( undefined ); @@ -29,11 +29,11 @@ export const NoticeProvider: FC< { children: ReactNode } > = ( { children } ) => const [ notice, setNoticeState ] = useState< NoticeState >( null ); const setNotice: Dispatch< SetStateAction< NoticeState > > = useCallback( value => { - const id = ++noticeId; + const noticeId = ++lastNoticeId; setNoticeState( previous => { const next = 'function' === typeof value ? value( previous ) : value; - return next ? { ...next, id } : next; + return next ? { ...next, noticeId } : next; } ); }, [] ); @@ -90,8 +90,7 @@ export default function useNotices() { type: 'error', dismissable: true, // The same translated string, minus the one interpolation tag it carries. - // Named rather than generic: a `<[^>]+>` strip reads as HTML sanitization - // and CodeQL flags it, though nothing here ever reaches innerHTML. + // Strip it by name: a generic tag strip trips CodeQL's sanitization rule. spokenMessage: `${ error } ${ advice.replace( /<\/?supportLink>/g, '' ) }`, message: ( <> From cdbb966976cae22602368d3f371bab70b819907a Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 11 Sep 2026 19:02:22 -0300 Subject: [PATCH 17/17] Protect notices: announce a failing fixers poll once per streak 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) Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6 --- .../plugins/protect/src/js/data/scan/use-fixers-query.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts b/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts index f737355afe62..784b7d5bfeb1 100644 --- a/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts +++ b/projects/plugins/protect/src/js/data/scan/use-fixers-query.ts @@ -144,14 +144,15 @@ export default function useFixersQuery( { enabled: isRegistered, } ); - // Handle error if present in the query result + // Once per error streak, not per failed poll: each failure is a new Error object, and + // polling one every 5s would interrupt the screen reader with the same message each time. useEffect( () => { - if ( fixersQuery.isError && fixersQuery.error ) { + if ( fixersQuery.isError ) { // Reset the query data to the initial state queryClient.setQueryData( [ QUERY_FIXERS_KEY ], initialData ); showErrorNotice( __( 'An error occurred while fetching fixers status.', 'jetpack-protect' ) ); } - }, [ fixersQuery.isError, fixersQuery.error, queryClient, showErrorNotice ] ); + }, [ fixersQuery.isError, queryClient, showErrorNotice ] ); return fixersQuery; }