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-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/editor/test/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js
new file mode 100644
index 000000000..dbcb8e643
--- /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 mockMakeStore = ( 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: mockMakeStore(
+ 'test/editor-pattern',
+ {
+ isInserterOpened: () => false,
+ isListViewOpened: () => false,
+ getSettings: () => mockSettings,
+ },
+ { setIsInserterOpened: () => ( { type: 'NOOP' } ) }
+ ),
+} ) );
+jest.mock( '@wordpress/core-data', () => ( {
+ store: mockMakeStore( 'test/editor-core', {
+ getEntityRecord: ( state, kind, postType, id ) => mockGetEntityRecord( kind, postType, id ),
+ } ),
+} ) );
+jest.mock( '@wordpress/notices', () => ( {
+ store: mockMakeStore( 'test/editor-notices', {}, { createInfoNotice: () => ( { type: 'NOOP' } ) } ),
+} ) );
+jest.mock( '@wordpress/interface', () => {
+ const { createElement: el } = jest.requireActual( '@wordpress/element' );
+ const Noop = () => null;
+ return {
+ store: mockMakeStore( '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/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/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();
+ } );
+} );
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..6b750add7
--- /dev/null
+++ b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js
@@ -0,0 +1,99 @@
+/**
+ * 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 );
+ jest.spyOn( window.history, 'replaceState' ).mockImplementation( () => {} );
+ } );
+
+ afterEach( () => {
+ act( () => root.unmount() );
+ container.remove();
+ jest.restoreAllMocks();
+ } );
+
+ 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/'
+ );
+ } );
+} );
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"
}