From cfa95e1dfd0f267f1f28828fa7f09a2a8fc0d40e Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 5 Jun 2026 14:18:00 +0200 Subject: [PATCH 1/5] Pattern creator: resolve react-hooks/exhaustive-deps lint warnings Clear the eslint-plugin-react-hooks warnings CI surfaces in the pattern-creator workspace: - Add genuinely-missing dependencies read from a prop or store inside the hook: `postId` (editor useSelect, url-controller useEffect), `feature` (feature-toggle useSelect), `onSelect`/`onClose` (openverse grid useCallback). - Add stable dispatchers/setters to satisfy the rule without changing behavior: `createInfoNotice`, `enableComplementaryArea`, `setDebouncedSearchTerm`. - Give the four pattern-settings `useCallback`s a dependency array; they previously had none and were not memoizing at all. - Document and silence two intentional omissions with eslint-disable-next-line: the pattern-categories-control `useMemo` (avoids reordering the list on selection) and the submission-modal `useEffect` (including `meta` would loop, `editPost` is stable). Add regression tests for the two behavior-affecting fixes (url-controller `postId`, feature-toggle `feature`); each was verified to fail without the fix. Tests render through the React copy `@wordpress/element` bundles to avoid the repo's dual-React invalid-hook-call issue. Co-Authored-By: Claude Opus 4.8 --- .../src/components/editor/index.js | 27 +++-- .../components/header/feature-toggle/index.js | 4 +- .../header/feature-toggle/test/index.js | 111 ++++++++++++++++++ .../src/components/openverse/grid.js | 4 +- .../pattern-categories-control/index.js | 5 +- .../src/components/sidebar/index.js | 2 +- .../sidebar/pattern-settings/index.js | 38 ++++-- .../src/components/submission-modal/index.js | 4 + .../src/components/url-controller/index.js | 2 +- .../components/url-controller/test/index.js | 98 ++++++++++++++++ 10 files changed, 261 insertions(+), 34 deletions(-) create mode 100644 public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/test/index.js create mode 100644 public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/editor/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/editor/index.js index 29ca43386..052b8558e 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/editor/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/editor/index.js @@ -45,18 +45,21 @@ const interfaceLabels = { }; function Editor( { onError, postId } ) { - const { isInserterOpen, isListViewOpen, post, sidebarIsOpened, settings } = useSelect( ( select ) => { - const { isInserterOpened, isListViewOpened, getSettings } = select( patternStore ); - const { getEntityRecord } = select( coreStore ); + const { isInserterOpen, isListViewOpen, post, sidebarIsOpened, settings } = useSelect( + ( select ) => { + const { isInserterOpened, isListViewOpened, getSettings } = select( patternStore ); + const { getEntityRecord } = select( coreStore ); - return { - isInserterOpen: isInserterOpened(), - isListViewOpen: isListViewOpened(), - post: getEntityRecord( 'postType', POST_TYPE, postId ), - sidebarIsOpened: !! select( interfaceStore ).getActiveComplementaryArea( STORE_NAME ), - settings: getSettings(), - }; - }, [] ); + return { + isInserterOpen: isInserterOpened(), + isListViewOpen: isListViewOpened(), + post: getEntityRecord( 'postType', POST_TYPE, postId ), + sidebarIsOpened: !! select( interfaceStore ).getActiveComplementaryArea( STORE_NAME ), + settings: getSettings(), + }; + }, + [ postId ] + ); const { setIsInserterOpened } = useDispatch( patternStore ); const { createInfoNotice } = useDispatch( noticesStore ); const [ isEntitiesSavedStatesOpen, setIsEntitiesSavedStatesOpen ] = useState( false ); @@ -73,7 +76,7 @@ function Editor( { onError, postId } ) { isDismissible: false, } ); } - }, [] ); + }, [ createInfoNotice ] ); // Don't render the Editor until the settings are set and loaded if ( ! settings?.siteUrl || ! post ) { diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/index.js index 96b893d88..c97b29df0 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/index.js @@ -26,9 +26,7 @@ export default function FeatureToggle( { feature, label, info, messageActivated, } }; - const isActive = useSelect( ( select ) => { - return select( patternStore ).isFeatureActive( feature ); - }, [] ); + const isActive = useSelect( ( select ) => select( patternStore ).isFeatureActive( feature ), [ feature ] ); const { toggleFeature } = useDispatch( patternStore ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/test/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/test/index.js new file mode 100644 index 000000000..9c12b897c --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/test/index.js @@ -0,0 +1,111 @@ +/** + * WordPress dependencies + */ +import { createElement, createRoot } from '@wordpress/element'; +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as patternStore } from '../../../../store'; + +/* + * `@wordpress/element` renders with the React copy it bundles, which is a + * different instance than the one at the repo root (kept for forward-compat + * work). Pull `act` from that same copy so the render and its hooks share a + * single React dispatcher, otherwise React throws an invalid-hook-call error. + */ +const { act } = jest.requireActual( + require.resolve( 'react', { paths: [ require.resolve( '@wordpress/element' ) ] } ) +); + +/* + * Replace the real store with a minimal stand-in that mirrors `isFeatureActive` + * / `toggleFeature`. This keeps `useSelect` (the unit under test) real while + * avoiding the heavy `@wordpress/core-data` graph the real store pulls in. + */ +jest.mock( '../../../../store', () => { + const { createReduxStore, register } = jest.requireActual( '@wordpress/data' ); + const store = createReduxStore( 'test/feature-toggle', { + reducer: ( state = {}, action ) => + action.type === 'TOGGLE_FEATURE' ? { ...state, [ action.feature ]: ! state[ action.feature ] } : state, + actions: { + toggleFeature: ( feature ) => ( { type: 'TOGGLE_FEATURE', feature } ), + }, + selectors: { + isFeatureActive: ( state, feature ) => !! state[ feature ], + }, + } ); + register( store ); + return { store }; +} ); + +/* + * The unit under test is the `useSelect` dependency array that derives the + * selected state, not the `MenuItem` chrome. Stub it so the markup exposes that + * state directly. + */ +jest.mock( '@wordpress/components', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + return { + MenuItem: ( { isSelected, children } ) => + el( 'button', { type: 'button', 'aria-checked': String( !! isSelected ) }, children ), + }; +} ); + +describe( 'FeatureToggle', () => { + let FeatureToggle; + let container; + let root; + + beforeAll( () => { + global.IS_REACT_ACT_ENVIRONMENT = true; + FeatureToggle = require( '../index' ).default; + } ); + + beforeEach( () => { + container = document.createElement( 'div' ); + document.body.appendChild( container ); + root = createRoot( container ); + } ); + + afterEach( () => { + act( () => root.unmount() ); + container.remove(); + } ); + + const render = ( feature ) => { + act( () => { + root.render( createElement( FeatureToggle, { feature, label: 'Toggle' } ) ); + } ); + }; + + const isActive = () => container.querySelector( 'button' ).getAttribute( 'aria-checked' ); + + it( 'reflects the active state of the feature it is given', () => { + dispatch( patternStore ).toggleFeature( 'reflects-active' ); + + render( 'reflects-active' ); + + expect( isActive() ).toBe( 'true' ); + } ); + + it( 'reports an untouched feature as inactive', () => { + render( 'never-toggled' ); + + expect( isActive() ).toBe( 'false' ); + } ); + + it( 'updates when the feature prop changes', () => { + dispatch( patternStore ).toggleFeature( 'prop-change-on' ); + + render( 'prop-change-on' ); + expect( isActive() ).toBe( 'true' ); + + // Re-render with a different, inactive feature. Without `feature` in the + // `useSelect` dependency array, the stale closure keeps reporting the + // first feature's state. + render( 'prop-change-off' ); + expect( isActive() ).toBe( 'false' ); + } ); +} ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/openverse/grid.js b/public_html/wp-content/plugins/pattern-creator/src/components/openverse/grid.js index 21d6fd035..b644909bd 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/openverse/grid.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/openverse/grid.js @@ -52,7 +52,7 @@ export default function OpenverseGrid( { searchTerm, onClose, onSelect, multiple // Set up a debounced search term, so we don't query constantly while someone is typing. useEffect( () => { setDebouncedSearchTerm( searchTerm ); - }, [ searchTerm ] ); + }, [ searchTerm, setDebouncedSearchTerm ] ); // When the search changes, reset back to page 1, and trigger a search. useEffect( () => { @@ -91,7 +91,7 @@ export default function OpenverseGrid( { searchTerm, onClose, onSelect, multiple } onClose(); - }, [ selected, multiple ] ); + }, [ selected, multiple, onSelect, onClose ] ); const onClick = useCallback( ( newValue ) => { diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js index dbb27b701..7104d152a 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js @@ -131,8 +131,9 @@ function PatternCategoriesControl( { selectedTerms = EMPTY_ARRAY, setTerms } ) { const availableTermsTree = useMemo( () => sortBySelected( availableTerms, selectedTerms ), - // Remove `terms` from the dependency list to avoid reordering every time - // checking or unchecking a term. + // `selectedTerms` is intentionally omitted from the dependency list to + // avoid reordering the list every time a term is checked or unchecked. + // eslint-disable-next-line react-hooks/exhaustive-deps [ availableTerms ] ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/index.js index c0fbf9831..317bdc5c3 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/index.js @@ -42,7 +42,7 @@ export function SidebarComplementaryAreaFills() { } else { enableComplementaryArea( STORE_NAME, SIDEBAR_PATTERN ); } - }, [ hasBlockSelection, isEditorSidebarOpened ] ); + }, [ hasBlockSelection, isEditorSidebarOpened, enableComplementaryArea ] ); let sidebarName = sidebar; if ( ! isEditorSidebarOpened ) { diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/pattern-settings/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/pattern-settings/index.js index 724af1318..2f86fee65 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/pattern-settings/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/pattern-settings/index.js @@ -39,19 +39,31 @@ function PatternSettings() { } ); const { editPost } = useDispatch( editorStore ); - const setTitle = useCallback( ( value ) => { - editPost( { title: value } ); - } ); - const setDescription = useCallback( ( value ) => { - editPost( { meta: { ...meta, wpop_description: value } } ); - } ); - const setCategories = useCallback( ( value ) => { - editPost( { 'pattern-categories': value } ); - } ); - const setKeywords = useCallback( ( value ) => { - const keywordsString = value.join( ', ' ); - editPost( { meta: { ...meta, [ KEYWORD_SLUG ]: keywordsString } } ); - } ); + const setTitle = useCallback( + ( value ) => { + editPost( { title: value } ); + }, + [ editPost ] + ); + const setDescription = useCallback( + ( value ) => { + editPost( { meta: { ...meta, wpop_description: value } } ); + }, + [ editPost, meta ] + ); + const setCategories = useCallback( + ( value ) => { + editPost( { 'pattern-categories': value } ); + }, + [ editPost ] + ); + const setKeywords = useCallback( + ( value ) => { + const keywordsString = value.join( ', ' ); + editPost( { meta: { ...meta, [ KEYWORD_SLUG ]: keywordsString } } ); + }, + [ editPost, meta ] + ); return ( <> diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js index 4b8161c30..ba9d70c4a 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js @@ -79,6 +79,10 @@ export default function SubmissionModal( { onClose, onSubmit, status } ) { title, 'pattern-categories': selectedCategories, } ); + // Only sync the editor when the local form fields change. `meta` is + // intentionally omitted: this effect edits `meta`, so including it would + // loop, and `editPost` is a stable dispatcher. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ title, description, selectedCategories ] ); const goBack = () => { diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/index.js index 634da1c92..4a8c72fde 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/index.js @@ -18,7 +18,7 @@ export default function UrlController( { postId } ) { const newUrl = `${ BASE_URL }/pattern/${ postId }/edit/`; window.history.replaceState( {}, '', newUrl ); } - }, [ post.status ] ); + }, [ post.status, postId ] ); return null; } diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js new file mode 100644 index 000000000..cd735a780 --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js @@ -0,0 +1,98 @@ +/** + * WordPress dependencies + */ +import { createElement, createRoot } from '@wordpress/element'; + +/* + * `@wordpress/element` renders with the React copy it bundles, which is a + * different instance than the one at the repo root (kept for forward-compat + * work). Pull `act` from that same copy so the render and its hooks share a + * single React dispatcher, otherwise React throws an invalid-hook-call error. + */ +const { act } = jest.requireActual( + require.resolve( 'react', { paths: [ require.resolve( '@wordpress/element' ) ] } ) +); + +/* + * Mock the data and store layers so the test can drive the `post` value + * directly. `useSelect` is replaced, but `useEffect` (from `@wordpress/element`) + * is left intact so the test exercises the real effect dependency array. + */ +let mockPost; +jest.mock( '@wordpress/data', () => ( { + useSelect: () => mockPost, +} ) ); +jest.mock( '@wordpress/core-data', () => ( { + store: 'core', +} ) ); +jest.mock( '../../../store', () => ( { + POST_TYPE: 'wporg-pattern', +} ) ); + +describe( 'UrlController', () => { + let UrlController; + let container; + let root; + + beforeAll( () => { + global.IS_REACT_ACT_ENVIRONMENT = true; + global.wporgBlockPattern = { siteUrl: 'https://example.com' }; + // `BASE_URL` is read at module load, so require after the global is set. + UrlController = require( '../index' ).default; + } ); + + beforeEach( () => { + container = document.createElement( 'div' ); + document.body.appendChild( container ); + root = createRoot( container ); + window.history.replaceState = jest.fn(); + } ); + + afterEach( () => { + act( () => root.unmount() ); + container.remove(); + } ); + + const render = ( postId ) => { + act( () => { + root.render( createElement( UrlController, { postId } ) ); + } ); + }; + + it( 'updates the URL once the post is no longer an auto-draft', () => { + mockPost = { status: 'publish' }; + + render( 1 ); + + expect( window.history.replaceState ).toHaveBeenCalledWith( + {}, + '', + 'https://example.com/pattern/1/edit/' + ); + } ); + + it( 'leaves the URL alone while the post is still an auto-draft', () => { + mockPost = { status: 'auto-draft' }; + + render( 1 ); + + expect( window.history.replaceState ).not.toHaveBeenCalled(); + } ); + + it( 'updates the URL when only the postId changes', () => { + mockPost = { status: 'publish' }; + + render( 1 ); + window.history.replaceState.mockClear(); + + // The status is unchanged; only `postId` changes. Without `postId` in the + // effect's dependency array the URL would keep the stale id. + render( 2 ); + + expect( window.history.replaceState ).toHaveBeenCalledWith( + {}, + '', + 'https://example.com/pattern/2/edit/' + ); + } ); +} ); From 8a265acc065ae2378c08334fd9f4265e75c624f5 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 5 Jun 2026 14:24:26 +0200 Subject: [PATCH 2/5] Pattern creator: add regression tests for editor and openverse grid dep fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the two remaining behavior-affecting dependency fixes: - editor: re-renders with a new `postId` now look up the new post. The editor short-circuits to `null` without `siteUrl`, so the stores are stubbed with minimal stand-ins and the heavy children are mocked — the `useSelect` mapping (the unit under test) still runs. - openverse grid: `onCommitSelected` now calls the latest `onSelect` / `onClose` props after they change, rather than the props captured on first render. Both tests were verified to fail with the dependency arrays reverted. Co-Authored-By: Claude Opus 4.8 --- .../src/components/editor/test/index.js | 142 +++++++++++++++++ .../src/components/openverse/test/grid.js | 147 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js create mode 100644 public_html/wp-content/plugins/pattern-creator/src/components/openverse/test/grid.js diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js new file mode 100644 index 000000000..e58f7f034 --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js @@ -0,0 +1,142 @@ +/** + * WordPress dependencies + */ +import { createElement, createRoot } from '@wordpress/element'; + +/* + * `@wordpress/element` renders with the React copy it bundles, which is a + * different instance than the one at the repo root (kept for forward-compat + * work). Pull `act` from that same copy so the render and its hooks share a + * single React dispatcher, otherwise React throws an invalid-hook-call error. + */ +const { act } = jest.requireActual( + require.resolve( 'react', { paths: [ require.resolve( '@wordpress/element' ) ] } ) +); + +const mockGetEntityRecord = jest.fn(); +// A stable reference: `useSelect` warns in dev if the mapping returns a new +// object for the same state, and the editor short-circuits when `siteUrl` is +// absent regardless. +const mockSettings = {}; + +/* + * Replace the stores with minimal stand-ins so `useSelect` (the unit under + * test) stays real while `@wordpress/core-data` and friends are not pulled in. + * `getSettings` returns no `siteUrl`, so the editor short-circuits to `null` + * before mounting its heavy render tree — but the `useSelect` mapping still + * runs, which is what reads `postId`. + */ +const makeStore = ( name, selectors, actions = {} ) => { + const { createReduxStore, register } = jest.requireActual( '@wordpress/data' ); + const store = createReduxStore( name, { reducer: ( state = {} ) => state, selectors, actions } ); + register( store ); + return store; +}; + +jest.mock( '../../../store', () => ( { + POST_TYPE: 'wporg-pattern', + store: makeStore( + 'test/editor-pattern', + { + isInserterOpened: () => false, + isListViewOpened: () => false, + getSettings: () => mockSettings, + }, + { setIsInserterOpened: () => ( { type: 'NOOP' } ) } + ), +} ) ); +jest.mock( '@wordpress/core-data', () => ( { + store: makeStore( 'test/editor-core', { + getEntityRecord: ( state, kind, postType, id ) => mockGetEntityRecord( kind, postType, id ), + } ), +} ) ); +jest.mock( '@wordpress/notices', () => ( { + store: makeStore( 'test/editor-notices', {}, { createInfoNotice: () => ( { type: 'NOOP' } ) } ), +} ) ); +jest.mock( '@wordpress/interface', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + const Noop = () => null; + return { + store: makeStore( 'test/editor-interface', { getActiveComplementaryArea: () => undefined } ), + ComplementaryArea: Object.assign( Noop, { Slot: Noop } ), + FullscreenMode: Noop, + InterfaceSkeleton: () => el( 'div', null ), + }; +} ); + +/* Stub the heavy components so importing the editor module stays cheap. */ +const Noop = () => null; +jest.mock( '@wordpress/editor', () => ( { + EditorNotices: Noop, + EditorProvider: Noop, + EditorSnackbars: Noop, + ErrorBoundary: Noop, + UnsavedChangesWarning: Noop, +} ) ); +jest.mock( '@wordpress/components', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + const N = () => null; + return { + Notice: N, + Popover: Object.assign( N, { Slot: N } ), + SlotFillProvider: ( { children } ) => el( 'div', null, children ), + }; +} ); +jest.mock( '@wordpress/block-editor', () => ( { BlockBreadcrumb: Noop } ) ); +jest.mock( '@wordpress/keyboard-shortcuts', () => ( { ShortcutProvider: () => null } ) ); +jest.mock( '../../block-editor', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../header', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../secondary-sidebar/inserter-sidebar', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../secondary-sidebar/list-view-sidebar', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../keyboard-shortcuts', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../sidebar', () => ( { SidebarComplementaryAreaFills: Noop } ) ); +jest.mock( '../../url-controller', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../welcome-guide', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../save-sidebar', () => ( { __esModule: true, default: Noop } ) ); + +describe( 'Editor', () => { + let Editor; + let container; + let root; + + beforeAll( () => { + global.IS_REACT_ACT_ENVIRONMENT = true; + global.wporgLocale = { id: 'en_US' }; + Editor = require( '../index' ).default; + } ); + + beforeEach( () => { + container = document.createElement( 'div' ); + document.body.appendChild( container ); + root = createRoot( container ); + mockGetEntityRecord.mockReset(); + } ); + + afterEach( () => { + act( () => root.unmount() ); + container.remove(); + } ); + + const render = ( postId ) => { + act( () => { + root.render( createElement( Editor, { postId, onError: () => {} } ) ); + } ); + }; + + it( 'looks up the post for the given postId', () => { + render( 1 ); + + expect( mockGetEntityRecord ).toHaveBeenCalledWith( 'postType', 'wporg-pattern', 1 ); + } ); + + it( 'looks up the new post when postId changes', () => { + render( 1 ); + mockGetEntityRecord.mockClear(); + + // Re-render with a different postId. Without `postId` in the `useSelect` + // dependency array, the memoized selector keeps reading the first id. + render( 2 ); + + expect( mockGetEntityRecord ).toHaveBeenCalledWith( 'postType', 'wporg-pattern', 2 ); + } ); +} ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/openverse/test/grid.js b/public_html/wp-content/plugins/pattern-creator/src/components/openverse/test/grid.js new file mode 100644 index 000000000..a2ca718f7 --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/components/openverse/test/grid.js @@ -0,0 +1,147 @@ +/** + * WordPress dependencies + */ +import { createElement, createRoot } from '@wordpress/element'; + +/* + * `@wordpress/element` renders with the React copy it bundles, which is a + * different instance than the one at the repo root (kept for forward-compat + * work). Pull `act` from that same copy so the render and its hooks share a + * single React dispatcher, otherwise React throws an invalid-hook-call error. + */ +const { act } = jest.requireActual( + require.resolve( 'react', { paths: [ require.resolve( '@wordpress/element' ) ] } ) +); + +const mockFetchImages = jest.fn(); +jest.mock( '../utils', () => ( { + fetchImages: ( ...args ) => mockFetchImages( ...args ), +} ) ); + +// Make the search debounce synchronous so the fetch fires within `act`. +jest.mock( '@wordpress/compose', () => ( { + useDebounce: ( fn ) => fn, +} ) ); + +/* + * Stub the chrome so the test drives selection and commits directly. The unit + * under test is `onCommitSelected`'s dependency array, not the grid layout. + */ +jest.mock( '@wordpress/components', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + return { + Button: ( { onClick, children } ) => el( 'button', { onClick }, children ), + Notice: ( { children } ) => el( 'div', null, children ), + Spinner: () => el( 'span', null, 'Loading' ), + }; +} ); +jest.mock( '../grid-items', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + return { + __esModule: true, + default: ( { items, onSelect } ) => + el( + 'div', + null, + items.map( ( item ) => + el( 'button', { key: item.id, onClick: () => onSelect( item ) }, `select-${ item.id }` ) + ) + ), + }; +} ); +jest.mock( '../grid-actions', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + return { + __esModule: true, + default: ( { actions } ) => el( 'div', null, actions ), + }; +} ); +jest.mock( '../pagination', () => ( { + __esModule: true, + default: () => null, +} ) ); + +describe( 'OpenverseGrid', () => { + let OpenverseGrid; + let container; + let root; + + beforeAll( () => { + global.IS_REACT_ACT_ENVIRONMENT = true; + OpenverseGrid = require( '../grid' ).default; + } ); + + beforeEach( () => { + mockFetchImages.mockResolvedValue( { + results: [ { id: 1, url: 'https://example.com/1.jpg', title: 'Cat' } ], + total: 1, + totalPages: 1, + } ); + container = document.createElement( 'div' ); + document.body.appendChild( container ); + root = createRoot( container ); + } ); + + afterEach( () => { + act( () => root.unmount() ); + container.remove(); + mockFetchImages.mockReset(); + } ); + + const renderGrid = async ( props ) => { + await act( async () => { + root.render( createElement( OpenverseGrid, { searchTerm: 'cat', multiple: false, ...props } ) ); + } ); + // Flush the resolved fetch and the follow-up render it triggers. + await act( async () => {} ); + }; + + const button = ( text ) => + [ ...container.querySelectorAll( 'button' ) ].find( ( el ) => el.textContent === text ); + + const click = ( el ) => act( () => el.click() ); + + it( 'commits the selected image to the current onSelect / onClose props', async () => { + const onSelect = jest.fn(); + const onClose = jest.fn(); + + await renderGrid( { onSelect, onClose } ); + click( button( 'select-1' ) ); + click( button( 'Add media' ) ); + + expect( onSelect ).toHaveBeenCalledWith( + expect.objectContaining( { url: 'https://example.com/1.jpg', caption: 'Cat' } ) + ); + expect( onClose ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'commits to the latest props after they change', async () => { + const firstSelect = jest.fn(); + const firstClose = jest.fn(); + const latestSelect = jest.fn(); + const latestClose = jest.fn(); + + await renderGrid( { onSelect: firstSelect, onClose: firstClose } ); + click( button( 'select-1' ) ); + + // Swap the callbacks while a selection is held. Without `onSelect` / + // `onClose` in the dependency array, the memoized handler keeps calling + // the stale props. + await act( async () => { + root.render( + createElement( OpenverseGrid, { + searchTerm: 'cat', + multiple: false, + onSelect: latestSelect, + onClose: latestClose, + } ) + ); + } ); + click( button( 'Add media' ) ); + + expect( latestSelect ).toHaveBeenCalledTimes( 1 ); + expect( latestClose ).toHaveBeenCalledTimes( 1 ); + expect( firstSelect ).not.toHaveBeenCalled(); + expect( firstClose ).not.toHaveBeenCalled(); + } ); +} ); From 0edba613ba5344decb84fcfb25c40e26b52180b0 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 5 Jun 2026 14:33:20 +0200 Subject: [PATCH 3/5] Restore window.history.replaceState spy in url-controller test Replacing the built-in with a bare jest.fn() left it overwritten after the suite ran. Use jest.spyOn with mockImplementation and restore it in afterEach so the mock can't leak into other tests in the same worker. Co-Authored-By: Claude Opus 4.8 --- .../src/components/url-controller/test/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js index cd735a780..6b750add7 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js @@ -45,12 +45,13 @@ describe( 'UrlController', () => { container = document.createElement( 'div' ); document.body.appendChild( container ); root = createRoot( container ); - window.history.replaceState = jest.fn(); + jest.spyOn( window.history, 'replaceState' ).mockImplementation( () => {} ); } ); afterEach( () => { act( () => root.unmount() ); container.remove(); + jest.restoreAllMocks(); } ); const render = ( postId ) => { From c34430bd726c6ddf6524ac3b7fc9189318a98fef Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 5 Jun 2026 14:40:39 +0200 Subject: [PATCH 4/5] Rename editor test store helper to a hoist-safe name `jest.mock()` factories are hoisted, and babel-plugin-jest-hoist only permits out-of-scope references that are clearly mock-only (prefixed `mock`). Rename the `makeStore` helper to `mockMakeStore` so the factory references stay within that guard and don't risk a hoist-time failure on stricter plugin versions. Co-Authored-By: Claude Opus 4.8 --- .../src/components/editor/test/index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js index e58f7f034..dbcb8e643 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js @@ -26,7 +26,7 @@ const mockSettings = {}; * before mounting its heavy render tree — but the `useSelect` mapping still * runs, which is what reads `postId`. */ -const makeStore = ( name, selectors, actions = {} ) => { +const mockMakeStore = ( name, selectors, actions = {} ) => { const { createReduxStore, register } = jest.requireActual( '@wordpress/data' ); const store = createReduxStore( name, { reducer: ( state = {} ) => state, selectors, actions } ); register( store ); @@ -35,7 +35,7 @@ const makeStore = ( name, selectors, actions = {} ) => { jest.mock( '../../../store', () => ( { POST_TYPE: 'wporg-pattern', - store: makeStore( + store: mockMakeStore( 'test/editor-pattern', { isInserterOpened: () => false, @@ -46,18 +46,18 @@ jest.mock( '../../../store', () => ( { ), } ) ); jest.mock( '@wordpress/core-data', () => ( { - store: makeStore( 'test/editor-core', { + store: mockMakeStore( 'test/editor-core', { getEntityRecord: ( state, kind, postType, id ) => mockGetEntityRecord( kind, postType, id ), } ), } ) ); jest.mock( '@wordpress/notices', () => ( { - store: makeStore( 'test/editor-notices', {}, { createInfoNotice: () => ( { type: 'NOOP' } ) } ), + store: mockMakeStore( 'test/editor-notices', {}, { createInfoNotice: () => ( { type: 'NOOP' } ) } ), } ) ); jest.mock( '@wordpress/interface', () => { const { createElement: el } = jest.requireActual( '@wordpress/element' ); const Noop = () => null; return { - store: makeStore( 'test/editor-interface', { getActiveComplementaryArea: () => undefined } ), + store: mockMakeStore( 'test/editor-interface', { getActiveComplementaryArea: () => undefined } ), ComplementaryArea: Object.assign( Noop, { Slot: Noop } ), FullscreenMode: Noop, InterfaceSkeleton: () => el( 'div', null ), From cfc7db9132389e7e94aec114ee3021dc5f8183c0 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 5 Jun 2026 14:50:02 +0200 Subject: [PATCH 5/5] Fail JS linting on warnings across all workspaces Add `--max-warnings=0` to every workspace's `lint:js` script so any eslint warning (e.g. react-hooks/exhaustive-deps) fails the lint step locally and in CI, preventing regressions of the warnings just cleared from the pattern-creator. Also clear the one pre-existing warning this surfaces: the unlist-button notice effect was missing its `createNotice` / `removeNotice` deps. Both are stable `useDispatch` handles, so listing them is behavior-neutral. Co-Authored-By: Claude Opus 4.8 --- public_html/wp-content/plugins/pattern-creator/package.json | 2 +- public_html/wp-content/plugins/pattern-directory/package.json | 2 +- .../src/pattern-post-type/unlist-button/notice.js | 2 +- .../wp-content/themes/wporg-pattern-directory-2024/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public_html/wp-content/plugins/pattern-creator/package.json b/public_html/wp-content/plugins/pattern-creator/package.json index abfd604b1..f084ac127 100644 --- a/public_html/wp-content/plugins/pattern-creator/package.json +++ b/public_html/wp-content/plugins/pattern-creator/package.json @@ -9,7 +9,7 @@ "dev": "NODE_ENV=development wp-scripts build", "format:js": "wp-scripts format src -- --config=../../../../.prettierrc.js", "lint:css": "wp-scripts lint-style 'style.css' 'src/**/*.scss'", - "lint:js": "wp-scripts lint-js src", + "lint:js": "wp-scripts lint-js src --max-warnings=0", "start": "wp-scripts start", "test:unit": "wp-scripts test-unit-js" }, diff --git a/public_html/wp-content/plugins/pattern-directory/package.json b/public_html/wp-content/plugins/pattern-directory/package.json index b1615e2ef..0e123b181 100644 --- a/public_html/wp-content/plugins/pattern-directory/package.json +++ b/public_html/wp-content/plugins/pattern-directory/package.json @@ -9,7 +9,7 @@ "dev": "NODE_ENV=development wp-scripts build pattern-post-type=./src/pattern-post-type.js", "format:js": "wp-scripts format src -- --config=../../../../.prettierrc.js", "lint:css": "wp-scripts lint-style 'src/**/*.scss'", - "lint:js": "wp-scripts lint-js src", + "lint:js": "wp-scripts lint-js src --max-warnings=0", "start": "wp-scripts start pattern-post-type=./src/pattern-post-type.js", "test:unit": "echo \"No JS tests.\"" }, diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/notice.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/notice.js index a1e28ec8d..7e433eda2 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/notice.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/notice.js @@ -33,7 +33,7 @@ const UnlistNotice = () => { } else { removeNotice( NOTICE_ID ); } - }, [ status ] ); + }, [ status, createNotice, removeNotice ] ); return null; }; diff --git a/public_html/wp-content/themes/wporg-pattern-directory-2024/package.json b/public_html/wp-content/themes/wporg-pattern-directory-2024/package.json index 77ae3b2aa..1f6adf46d 100644 --- a/public_html/wp-content/themes/wporg-pattern-directory-2024/package.json +++ b/public_html/wp-content/themes/wporg-pattern-directory-2024/package.json @@ -27,7 +27,7 @@ "scripts": { "build": "wp-scripts build --experimental-modules", "start": "wp-scripts start --experimental-modules", - "lint:js": "wp-scripts lint-js src", + "lint:js": "wp-scripts lint-js src --max-warnings=0", "lint:css": "wp-scripts lint-style src/**/*.scss", "format": "wp-scripts format src -- --config=../../../../.prettierrc.js" }