From 2fa773f3abba8f3abf3595557d6d86aa981ae0cc Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Thu, 12 Mar 2026 13:16:31 +0000 Subject: [PATCH 01/14] Handle sync when deleting and copying tabs --- packages/block-library/src/tabs/edit.js | 135 ++++++++++++++++++++---- 1 file changed, 117 insertions(+), 18 deletions(-) diff --git a/packages/block-library/src/tabs/edit.js b/packages/block-library/src/tabs/edit.js index a4b923e55a1db8..4b882071945e42 100644 --- a/packages/block-library/src/tabs/edit.js +++ b/packages/block-library/src/tabs/edit.js @@ -7,6 +7,7 @@ import { BlockContextProvider, store as blockEditorStore, } from '@wordpress/block-editor'; +import { createBlock } from '@wordpress/blocks'; import { useSelect, useDispatch } from '@wordpress/data'; import { useMemo, useEffect, useRef } from '@wordpress/element'; @@ -74,13 +75,13 @@ function Edit( { } }, [] ); // eslint-disable-line react-hooks/exhaustive-deps - const { removeBlock } = useDispatch( blockEditorStore ); + const { removeBlock, insertBlock } = useDispatch( blockEditorStore ); /** * Construct a list of core/tab blocks, used to create tabs-list context. * Also select menu items with their anchors for anchor-based deletion sync. */ - const { tabs, menuItems } = useSelect( + const { tabs, menuItems, tabPanelClientId, tabsMenuClientId } = useSelect( ( select ) => { const { getBlocks } = select( blockEditorStore ); const innerBlocks = getBlocks( clientId ); @@ -109,22 +110,23 @@ function Edit( { anchor: b.attributes.anchor ?? '', } ) ) : [], + tabPanelClientId: tabPanel?.clientId ?? null, + tabsMenuClientId: tabsMenu?.clientId ?? null, }; }, [ clientId ] ); /** - * Keep tabs and menu items in sync when either is deleted directly (e.g. - * via the Backspace key or List View). + * Keep tabs and menu items in sync when the lists change due to direct + * user actions (deletion, paste, duplicate, drag-and-drop). * - * TODO: This effect only handles deletions. The two lists can get out of - * sync in other cases: if a user pastes a core/tab block into the tab-panel - * (or duplicates one), no corresponding tabs-menu-item is created; if a - * user drags and drops a tabs-menu-item, the tab panel is not copied with - * it. We should extend this effect to handle insertions, detecting when - * tabs.length > menuItems.length and inserting the missing menu - * item(s) at the correct index. + * Deletion: when one side shrinks, remove the orphaned counterpart. + * Insertion: when one side grows without the other, insert the missing + * counterpart at the matching index. + * + * When both lists change simultaneously (e.g. the "Add Tab" toolbar button, + * which inserts both at once), no action is needed and the effect exits early. */ const prevSyncStateRef = useRef( null ); useEffect( () => { @@ -146,19 +148,29 @@ function Edit( { const tabsRemoved = currentTabs.length < prevTabs.length; const menuItemsRemoved = menuItems.length < prevMenuItems.length; + const tabsInserted = currentTabs.length > prevTabs.length; + const menuItemsInserted = menuItems.length > prevMenuItems.length; // Update snapshot to the current state. - // Snapshot is updated eagerly; post-removal mutations keep it consistent - // so the next effect invocation sees a stable baseline. prevSyncStateRef.current = { tabs: currentTabs, menuItems: [ ...menuItems ], }; - // Lists are in sync, nothing changed, or toolbar already removed both. + // Lists are already in sync. + if ( + ( tabsRemoved && menuItemsRemoved ) || + ( tabsInserted && menuItemsInserted ) + ) { + return; + } + + // Nothing changed. if ( - ( ! tabsRemoved && ! menuItemsRemoved ) || - ( tabsRemoved && menuItemsRemoved ) + ! tabsRemoved && + ! menuItemsRemoved && + ! tabsInserted && + ! menuItemsInserted ) { return; } @@ -169,6 +181,7 @@ function Edit( { ); if ( tabsRemoved ) { + // A tab was deleted — remove its corresponding menu item. prevTabs.forEach( ( prevTab ) => { if ( currentTabIds.has( prevTab.clientId ) ) { return; @@ -187,7 +200,8 @@ function Edit( { ); } } ); - } else { + } else if ( menuItemsRemoved ) { + // A menu item was deleted — remove its corresponding tab. prevMenuItems.forEach( ( prevItem ) => { if ( currentMenuItemIds.has( prevItem.clientId ) ) { return; @@ -206,8 +220,93 @@ function Edit( { ); } } ); + } else if ( tabsInserted ) { + // A tab was pasted or duplicated — insert a matching menu item. + if ( ! tabsMenuClientId ) { + return; + } + const prevTabIds = new Set( prevTabs.map( ( t ) => t.clientId ) ); + const existingMenuAnchors = new Set( + menuItems.map( ( m ) => m.anchor ) + ); + currentTabs.forEach( ( newTab, tabIndex ) => { + if ( prevTabIds.has( newTab.clientId ) ) { + return; + } + const expectedMenuAnchor = newTab.anchor + ? `${ newTab.anchor }-button` + : null; + if ( + expectedMenuAnchor && + existingMenuAnchors.has( expectedMenuAnchor ) + ) { + return; + } + const newMenuItemBlock = createBlock( + 'core/tabs-menu-item', + expectedMenuAnchor ? { anchor: expectedMenuAnchor } : {} + ); + insertBlock( + newMenuItemBlock, + tabIndex, + tabsMenuClientId, + false + ); + // Add a placeholder so the next render does not re-trigger insertion. + prevSyncStateRef.current.menuItems.splice( tabIndex, 0, { + clientId: newMenuItemBlock.clientId, + anchor: expectedMenuAnchor ?? '', + } ); + } ); + } else if ( menuItemsInserted ) { + // A menu item was pasted or dragged in — insert a matching tab. + if ( ! tabPanelClientId ) { + return; + } + const prevMenuItemIds = new Set( + prevMenuItems.map( ( m ) => m.clientId ) + ); + const existingTabAnchors = new Set( + currentTabs.map( ( t ) => t.anchor ) + ); + menuItems.forEach( ( newMenuItem, menuItemIndex ) => { + if ( prevMenuItemIds.has( newMenuItem.clientId ) ) { + return; + } + const expectedTabAnchor = newMenuItem.anchor + ? newMenuItem.anchor.replace( /-button$/, '' ) + : ''; + if ( + expectedTabAnchor && + existingTabAnchors.has( expectedTabAnchor ) + ) { + return; + } + const newTabBlock = createBlock( 'core/tab', { + anchor: expectedTabAnchor, + label: '', + } ); + insertBlock( + newTabBlock, + menuItemIndex, + tabPanelClientId, + false + ); + // Add a placeholder so the next render does not re-trigger insertion. + prevSyncStateRef.current.tabs.splice( menuItemIndex, 0, { + clientId: newTabBlock.clientId, + anchor: expectedTabAnchor, + } ); + } ); } - }, [ tabs, menuItems, removeBlock ] ); + }, [ + tabs, + menuItems, + removeBlock, + insertBlock, + tabsMenuClientId, + tabPanelClientId, + ] ); /** * Memoize context value to prevent unnecessary re-renders. From 995b8d4c999344143e7dd98ac803e490ceec205e Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Thu, 12 Mar 2026 14:11:54 +0000 Subject: [PATCH 02/14] Generate a new tab ID when a tab is duplicated --- packages/block-library/src/tabs/edit.js | 108 ++++++++++++++++++------ 1 file changed, 84 insertions(+), 24 deletions(-) diff --git a/packages/block-library/src/tabs/edit.js b/packages/block-library/src/tabs/edit.js index 4b882071945e42..023d36fe470755 100644 --- a/packages/block-library/src/tabs/edit.js +++ b/packages/block-library/src/tabs/edit.js @@ -75,7 +75,8 @@ function Edit( { } }, [] ); // eslint-disable-line react-hooks/exhaustive-deps - const { removeBlock, insertBlock } = useDispatch( blockEditorStore ); + const { removeBlock, insertBlock, updateBlockAttributes } = + useDispatch( blockEditorStore ); /** * Construct a list of core/tab blocks, used to create tabs-list context. @@ -222,10 +223,17 @@ function Edit( { } ); } else if ( tabsInserted ) { // A tab was pasted or duplicated — insert a matching menu item. + // If the tab's anchor conflicts with an existing menu item, generate + // a fresh unique anchor. if ( ! tabsMenuClientId ) { return; } const prevTabIds = new Set( prevTabs.map( ( t ) => t.clientId ) ); + // Track anchors in use across both tabs and menu items to generate + // collision-free anchors when multiple tabs are inserted at once. + const usedTabAnchors = new Set( + currentTabs.map( ( t ) => t.anchor ).filter( Boolean ) + ); const existingMenuAnchors = new Set( menuItems.map( ( m ) => m.anchor ) ); @@ -233,19 +241,34 @@ function Edit( { if ( prevTabIds.has( newTab.clientId ) ) { return; } - const expectedMenuAnchor = newTab.anchor - ? `${ newTab.anchor }-button` - : null; - if ( - expectedMenuAnchor && - existingMenuAnchors.has( expectedMenuAnchor ) - ) { - return; + let tabAnchor = newTab.anchor; + const menuAnchorConflicts = + tabAnchor && + existingMenuAnchors.has( `${ tabAnchor }-button` ); + + if ( ! tabAnchor || menuAnchorConflicts ) { + // Find the next free tab-N slot. + let tabNumber = currentTabs.length + 1; + while ( usedTabAnchors.has( `tab-${ tabNumber }` ) ) { + tabNumber++; + } + tabAnchor = `tab-${ tabNumber }`; + // Reserve this anchor so subsequent new tabs don't collide. + usedTabAnchors.add( tabAnchor ); + updateBlockAttributes( newTab.clientId, { + anchor: tabAnchor, + } ); + // Keep the snapshot in sync with the new anchor value. + prevSyncStateRef.current.tabs[ tabIndex ] = { + ...prevSyncStateRef.current.tabs[ tabIndex ], + anchor: tabAnchor, + }; } - const newMenuItemBlock = createBlock( - 'core/tabs-menu-item', - expectedMenuAnchor ? { anchor: expectedMenuAnchor } : {} - ); + + const menuAnchor = `${ tabAnchor }-button`; + const newMenuItemBlock = createBlock( 'core/tabs-menu-item', { + anchor: menuAnchor, + } ); insertBlock( newMenuItemBlock, tabIndex, @@ -255,17 +278,29 @@ function Edit( { // Add a placeholder so the next render does not re-trigger insertion. prevSyncStateRef.current.menuItems.splice( tabIndex, 0, { clientId: newMenuItemBlock.clientId, - anchor: expectedMenuAnchor ?? '', + anchor: menuAnchor, } ); + existingMenuAnchors.add( menuAnchor ); } ); } else if ( menuItemsInserted ) { - // A menu item was pasted or dragged in — insert a matching tab. + // A menu item was pasted or duplicated: insert a matching tab. + // If the menu item's anchor conflicts with an existing tab, generate + // a fresh unique anchor. if ( ! tabPanelClientId ) { return; } const prevMenuItemIds = new Set( prevMenuItems.map( ( m ) => m.clientId ) ); + // Track base anchors (the tab-N part) already in use across both + // tabs and menu items to generate collision-free anchors when + // multiple menu items are inserted at once. + const usedBaseAnchors = new Set( [ + ...currentTabs.map( ( t ) => t.anchor ).filter( Boolean ), + ...menuItems + .map( ( m ) => m.anchor.replace( /-button$/, '' ) ) + .filter( Boolean ), + ] ); const existingTabAnchors = new Set( currentTabs.map( ( t ) => t.anchor ) ); @@ -273,18 +308,41 @@ function Edit( { if ( prevMenuItemIds.has( newMenuItem.clientId ) ) { return; } - const expectedTabAnchor = newMenuItem.anchor + let baseAnchor = newMenuItem.anchor ? newMenuItem.anchor.replace( /-button$/, '' ) : ''; - if ( - expectedTabAnchor && - existingTabAnchors.has( expectedTabAnchor ) - ) { - return; + const tabAnchorConflicts = + baseAnchor && existingTabAnchors.has( baseAnchor ); + + // Grab the original tab's label before potentially reassigning + // baseAnchor, so we can copy it to the new tab. + const originalTab = tabs.find( + ( t ) => ( t.attributes.anchor ?? '' ) === baseAnchor + ); + const label = originalTab?.attributes?.label ?? ''; + + if ( ! baseAnchor || tabAnchorConflicts ) { + // Find the next free tab-N slot. + let tabNumber = menuItems.length + 1; + while ( usedBaseAnchors.has( `tab-${ tabNumber }` ) ) { + tabNumber++; + } + baseAnchor = `tab-${ tabNumber }`; + // Reserve this anchor so subsequent new items don't collide. + usedBaseAnchors.add( baseAnchor ); + updateBlockAttributes( newMenuItem.clientId, { + anchor: `${ baseAnchor }-button`, + } ); + // Keep the snapshot in sync with the new anchor value. + prevSyncStateRef.current.menuItems[ menuItemIndex ] = { + ...prevSyncStateRef.current.menuItems[ menuItemIndex ], + anchor: `${ baseAnchor }-button`, + }; } + const newTabBlock = createBlock( 'core/tab', { - anchor: expectedTabAnchor, - label: '', + anchor: baseAnchor, + label, } ); insertBlock( newTabBlock, @@ -295,8 +353,9 @@ function Edit( { // Add a placeholder so the next render does not re-trigger insertion. prevSyncStateRef.current.tabs.splice( menuItemIndex, 0, { clientId: newTabBlock.clientId, - anchor: expectedTabAnchor, + anchor: baseAnchor, } ); + existingTabAnchors.add( baseAnchor ); } ); } }, [ @@ -304,6 +363,7 @@ function Edit( { menuItems, removeBlock, insertBlock, + updateBlockAttributes, tabsMenuClientId, tabPanelClientId, ] ); From 9185efa30f448b9502747bbd843099820dfb14ce Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Thu, 12 Mar 2026 14:49:00 +0000 Subject: [PATCH 03/14] Extract sync logic to hook --- packages/block-library/src/tabs/edit.js | 259 +---------------- .../src/tabs/use-tab-menu-sync.js | 273 ++++++++++++++++++ 2 files changed, 277 insertions(+), 255 deletions(-) create mode 100644 packages/block-library/src/tabs/use-tab-menu-sync.js diff --git a/packages/block-library/src/tabs/edit.js b/packages/block-library/src/tabs/edit.js index 023d36fe470755..b9df41b677f60a 100644 --- a/packages/block-library/src/tabs/edit.js +++ b/packages/block-library/src/tabs/edit.js @@ -7,14 +7,14 @@ import { BlockContextProvider, store as blockEditorStore, } from '@wordpress/block-editor'; -import { createBlock } from '@wordpress/blocks'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { useMemo, useEffect, useRef } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { useMemo, useEffect } from '@wordpress/element'; /** * Internal dependencies */ import Controls from './controls'; +import useTabMenuSync from './use-tab-menu-sync'; const TABS_TEMPLATE = [ [ @@ -75,9 +75,6 @@ function Edit( { } }, [] ); // eslint-disable-line react-hooks/exhaustive-deps - const { removeBlock, insertBlock, updateBlockAttributes } = - useDispatch( blockEditorStore ); - /** * Construct a list of core/tab blocks, used to create tabs-list context. * Also select menu items with their anchors for anchor-based deletion sync. @@ -118,255 +115,7 @@ function Edit( { [ clientId ] ); - /** - * Keep tabs and menu items in sync when the lists change due to direct - * user actions (deletion, paste, duplicate, drag-and-drop). - * - * Deletion: when one side shrinks, remove the orphaned counterpart. - * Insertion: when one side grows without the other, insert the missing - * counterpart at the matching index. - * - * When both lists change simultaneously (e.g. the "Add Tab" toolbar button, - * which inserts both at once), no action is needed and the effect exits early. - */ - const prevSyncStateRef = useRef( null ); - useEffect( () => { - const currentTabs = tabs.map( ( tab ) => ( { - clientId: tab.clientId, - anchor: tab.attributes.anchor ?? '', - } ) ); - - if ( prevSyncStateRef.current === null ) { - prevSyncStateRef.current = { - tabs: currentTabs, - menuItems: [ ...menuItems ], - }; - return; - } - - const { tabs: prevTabs, menuItems: prevMenuItems } = - prevSyncStateRef.current; - - const tabsRemoved = currentTabs.length < prevTabs.length; - const menuItemsRemoved = menuItems.length < prevMenuItems.length; - const tabsInserted = currentTabs.length > prevTabs.length; - const menuItemsInserted = menuItems.length > prevMenuItems.length; - - // Update snapshot to the current state. - prevSyncStateRef.current = { - tabs: currentTabs, - menuItems: [ ...menuItems ], - }; - - // Lists are already in sync. - if ( - ( tabsRemoved && menuItemsRemoved ) || - ( tabsInserted && menuItemsInserted ) - ) { - return; - } - - // Nothing changed. - if ( - ! tabsRemoved && - ! menuItemsRemoved && - ! tabsInserted && - ! menuItemsInserted - ) { - return; - } - - const currentTabIds = new Set( currentTabs.map( ( t ) => t.clientId ) ); - const currentMenuItemIds = new Set( - menuItems.map( ( m ) => m.clientId ) - ); - - if ( tabsRemoved ) { - // A tab was deleted — remove its corresponding menu item. - prevTabs.forEach( ( prevTab ) => { - if ( currentTabIds.has( prevTab.clientId ) ) { - return; - } - const expectedMenuAnchor = prevTab.anchor - ? `${ prevTab.anchor }-button` - : null; - const menuItemToRemove = expectedMenuAnchor - ? menuItems.find( ( m ) => m.anchor === expectedMenuAnchor ) - : null; - if ( menuItemToRemove ) { - removeBlock( menuItemToRemove.clientId, false ); - prevSyncStateRef.current.menuItems = - prevSyncStateRef.current.menuItems.filter( - ( m ) => m.clientId !== menuItemToRemove.clientId - ); - } - } ); - } else if ( menuItemsRemoved ) { - // A menu item was deleted — remove its corresponding tab. - prevMenuItems.forEach( ( prevItem ) => { - if ( currentMenuItemIds.has( prevItem.clientId ) ) { - return; - } - const expectedTabAnchor = - prevItem.anchor?.replace( /-button$/, '' ) ?? ''; - const tabToRemove = tabs.find( - ( tab ) => - ( tab.attributes.anchor ?? '' ) === expectedTabAnchor - ); - if ( tabToRemove ) { - removeBlock( tabToRemove.clientId, false ); - prevSyncStateRef.current.tabs = - prevSyncStateRef.current.tabs.filter( - ( t ) => t.clientId !== tabToRemove.clientId - ); - } - } ); - } else if ( tabsInserted ) { - // A tab was pasted or duplicated — insert a matching menu item. - // If the tab's anchor conflicts with an existing menu item, generate - // a fresh unique anchor. - if ( ! tabsMenuClientId ) { - return; - } - const prevTabIds = new Set( prevTabs.map( ( t ) => t.clientId ) ); - // Track anchors in use across both tabs and menu items to generate - // collision-free anchors when multiple tabs are inserted at once. - const usedTabAnchors = new Set( - currentTabs.map( ( t ) => t.anchor ).filter( Boolean ) - ); - const existingMenuAnchors = new Set( - menuItems.map( ( m ) => m.anchor ) - ); - currentTabs.forEach( ( newTab, tabIndex ) => { - if ( prevTabIds.has( newTab.clientId ) ) { - return; - } - let tabAnchor = newTab.anchor; - const menuAnchorConflicts = - tabAnchor && - existingMenuAnchors.has( `${ tabAnchor }-button` ); - - if ( ! tabAnchor || menuAnchorConflicts ) { - // Find the next free tab-N slot. - let tabNumber = currentTabs.length + 1; - while ( usedTabAnchors.has( `tab-${ tabNumber }` ) ) { - tabNumber++; - } - tabAnchor = `tab-${ tabNumber }`; - // Reserve this anchor so subsequent new tabs don't collide. - usedTabAnchors.add( tabAnchor ); - updateBlockAttributes( newTab.clientId, { - anchor: tabAnchor, - } ); - // Keep the snapshot in sync with the new anchor value. - prevSyncStateRef.current.tabs[ tabIndex ] = { - ...prevSyncStateRef.current.tabs[ tabIndex ], - anchor: tabAnchor, - }; - } - - const menuAnchor = `${ tabAnchor }-button`; - const newMenuItemBlock = createBlock( 'core/tabs-menu-item', { - anchor: menuAnchor, - } ); - insertBlock( - newMenuItemBlock, - tabIndex, - tabsMenuClientId, - false - ); - // Add a placeholder so the next render does not re-trigger insertion. - prevSyncStateRef.current.menuItems.splice( tabIndex, 0, { - clientId: newMenuItemBlock.clientId, - anchor: menuAnchor, - } ); - existingMenuAnchors.add( menuAnchor ); - } ); - } else if ( menuItemsInserted ) { - // A menu item was pasted or duplicated: insert a matching tab. - // If the menu item's anchor conflicts with an existing tab, generate - // a fresh unique anchor. - if ( ! tabPanelClientId ) { - return; - } - const prevMenuItemIds = new Set( - prevMenuItems.map( ( m ) => m.clientId ) - ); - // Track base anchors (the tab-N part) already in use across both - // tabs and menu items to generate collision-free anchors when - // multiple menu items are inserted at once. - const usedBaseAnchors = new Set( [ - ...currentTabs.map( ( t ) => t.anchor ).filter( Boolean ), - ...menuItems - .map( ( m ) => m.anchor.replace( /-button$/, '' ) ) - .filter( Boolean ), - ] ); - const existingTabAnchors = new Set( - currentTabs.map( ( t ) => t.anchor ) - ); - menuItems.forEach( ( newMenuItem, menuItemIndex ) => { - if ( prevMenuItemIds.has( newMenuItem.clientId ) ) { - return; - } - let baseAnchor = newMenuItem.anchor - ? newMenuItem.anchor.replace( /-button$/, '' ) - : ''; - const tabAnchorConflicts = - baseAnchor && existingTabAnchors.has( baseAnchor ); - - // Grab the original tab's label before potentially reassigning - // baseAnchor, so we can copy it to the new tab. - const originalTab = tabs.find( - ( t ) => ( t.attributes.anchor ?? '' ) === baseAnchor - ); - const label = originalTab?.attributes?.label ?? ''; - - if ( ! baseAnchor || tabAnchorConflicts ) { - // Find the next free tab-N slot. - let tabNumber = menuItems.length + 1; - while ( usedBaseAnchors.has( `tab-${ tabNumber }` ) ) { - tabNumber++; - } - baseAnchor = `tab-${ tabNumber }`; - // Reserve this anchor so subsequent new items don't collide. - usedBaseAnchors.add( baseAnchor ); - updateBlockAttributes( newMenuItem.clientId, { - anchor: `${ baseAnchor }-button`, - } ); - // Keep the snapshot in sync with the new anchor value. - prevSyncStateRef.current.menuItems[ menuItemIndex ] = { - ...prevSyncStateRef.current.menuItems[ menuItemIndex ], - anchor: `${ baseAnchor }-button`, - }; - } - - const newTabBlock = createBlock( 'core/tab', { - anchor: baseAnchor, - label, - } ); - insertBlock( - newTabBlock, - menuItemIndex, - tabPanelClientId, - false - ); - // Add a placeholder so the next render does not re-trigger insertion. - prevSyncStateRef.current.tabs.splice( menuItemIndex, 0, { - clientId: newTabBlock.clientId, - anchor: baseAnchor, - } ); - existingTabAnchors.add( baseAnchor ); - } ); - } - }, [ - tabs, - menuItems, - removeBlock, - insertBlock, - updateBlockAttributes, - tabsMenuClientId, - tabPanelClientId, - ] ); + useTabMenuSync( { tabs, menuItems, tabPanelClientId, tabsMenuClientId } ); /** * Memoize context value to prevent unnecessary re-renders. diff --git a/packages/block-library/src/tabs/use-tab-menu-sync.js b/packages/block-library/src/tabs/use-tab-menu-sync.js new file mode 100644 index 00000000000000..e16dd10cf9c96b --- /dev/null +++ b/packages/block-library/src/tabs/use-tab-menu-sync.js @@ -0,0 +1,273 @@ +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useDispatch } from '@wordpress/data'; +import { useEffect, useRef } from '@wordpress/element'; + +/** + * Keep tabs and menu items in sync when the lists change due to direct + * user actions (deletion, paste, duplicate, drag-and-drop). + * + * Deletion: when one side shrinks, remove the orphaned counterpart. + * Insertion: when one side grows without the other, insert the missing + * counterpart at the matching index. + * + * When both lists change simultaneously (e.g. the "Add Tab" toolbar button, + * which inserts both at once), no action is needed and the effect exits early. + * + * @param {Object} props + * @param {Array} props.tabs Raw core/tab block objects. + * @param {Array} props.menuItems Menu item descriptors { clientId, anchor }. + * @param {string|null} props.tabPanelClientId Client ID of the core/tab-panel block. + * @param {string|null} props.tabsMenuClientId Client ID of the core/tabs-menu block. + */ +export default function useTabMenuSync( { + tabs, + menuItems, + tabPanelClientId, + tabsMenuClientId, +} ) { + const { removeBlock, insertBlock, updateBlockAttributes } = + useDispatch( blockEditorStore ); + + const prevSyncStateRef = useRef( null ); + useEffect( () => { + const currentTabs = tabs.map( ( tab ) => ( { + clientId: tab.clientId, + anchor: tab.attributes.anchor ?? '', + } ) ); + + if ( prevSyncStateRef.current === null ) { + prevSyncStateRef.current = { + tabs: currentTabs, + menuItems: [ ...menuItems ], + }; + return; + } + + const { tabs: prevTabs, menuItems: prevMenuItems } = + prevSyncStateRef.current; + + const tabsRemoved = currentTabs.length < prevTabs.length; + const menuItemsRemoved = menuItems.length < prevMenuItems.length; + const tabsInserted = currentTabs.length > prevTabs.length; + const menuItemsInserted = menuItems.length > prevMenuItems.length; + + // Update snapshot to the current state. + prevSyncStateRef.current = { + tabs: currentTabs, + menuItems: [ ...menuItems ], + }; + + // Lists are already in sync. + if ( + ( tabsRemoved && menuItemsRemoved ) || + ( tabsInserted && menuItemsInserted ) + ) { + return; + } + + // Nothing changed. + if ( + ! tabsRemoved && + ! menuItemsRemoved && + ! tabsInserted && + ! menuItemsInserted + ) { + return; + } + + const currentTabIds = new Set( currentTabs.map( ( t ) => t.clientId ) ); + const currentMenuItemIds = new Set( + menuItems.map( ( m ) => m.clientId ) + ); + + if ( tabsRemoved ) { + // A tab was deleted — remove its corresponding menu item. + prevTabs.forEach( ( prevTab ) => { + if ( currentTabIds.has( prevTab.clientId ) ) { + return; + } + const expectedMenuAnchor = prevTab.anchor + ? `${ prevTab.anchor }-button` + : null; + const menuItemToRemove = expectedMenuAnchor + ? menuItems.find( ( m ) => m.anchor === expectedMenuAnchor ) + : null; + if ( menuItemToRemove ) { + removeBlock( menuItemToRemove.clientId, false ); + prevSyncStateRef.current.menuItems = + prevSyncStateRef.current.menuItems.filter( + ( m ) => m.clientId !== menuItemToRemove.clientId + ); + } + } ); + } else if ( menuItemsRemoved ) { + // A menu item was deleted — remove its corresponding tab. + prevMenuItems.forEach( ( prevItem ) => { + if ( currentMenuItemIds.has( prevItem.clientId ) ) { + return; + } + const expectedTabAnchor = + prevItem.anchor?.replace( /-button$/, '' ) ?? ''; + const tabToRemove = tabs.find( + ( tab ) => + ( tab.attributes.anchor ?? '' ) === expectedTabAnchor + ); + if ( tabToRemove ) { + removeBlock( tabToRemove.clientId, false ); + prevSyncStateRef.current.tabs = + prevSyncStateRef.current.tabs.filter( + ( t ) => t.clientId !== tabToRemove.clientId + ); + } + } ); + } else if ( tabsInserted ) { + // A tab was pasted or duplicated — insert a matching menu item. + // If the tab's anchor conflicts with an existing menu item, generate + // a fresh unique anchor. + if ( ! tabsMenuClientId ) { + return; + } + const prevTabIds = new Set( prevTabs.map( ( t ) => t.clientId ) ); + // Track anchors in use across both tabs and menu items to generate + // collision-free anchors when multiple tabs are inserted at once. + const usedTabAnchors = new Set( + currentTabs.map( ( t ) => t.anchor ).filter( Boolean ) + ); + const existingMenuAnchors = new Set( + menuItems.map( ( m ) => m.anchor ) + ); + currentTabs.forEach( ( newTab, tabIndex ) => { + if ( prevTabIds.has( newTab.clientId ) ) { + return; + } + let tabAnchor = newTab.anchor; + const menuAnchorConflicts = + tabAnchor && + existingMenuAnchors.has( `${ tabAnchor }-button` ); + + if ( ! tabAnchor || menuAnchorConflicts ) { + // Find the next free tab-N slot. + let tabNumber = currentTabs.length + 1; + while ( usedTabAnchors.has( `tab-${ tabNumber }` ) ) { + tabNumber++; + } + tabAnchor = `tab-${ tabNumber }`; + // Reserve this anchor so subsequent new tabs don't collide. + usedTabAnchors.add( tabAnchor ); + updateBlockAttributes( newTab.clientId, { + anchor: tabAnchor, + } ); + // Keep the snapshot in sync with the new anchor value. + prevSyncStateRef.current.tabs[ tabIndex ] = { + ...prevSyncStateRef.current.tabs[ tabIndex ], + anchor: tabAnchor, + }; + } + + const menuAnchor = `${ tabAnchor }-button`; + const newMenuItemBlock = createBlock( 'core/tabs-menu-item', { + anchor: menuAnchor, + } ); + insertBlock( + newMenuItemBlock, + tabIndex, + tabsMenuClientId, + false + ); + // Add a placeholder so the next render does not re-trigger insertion. + prevSyncStateRef.current.menuItems.splice( tabIndex, 0, { + clientId: newMenuItemBlock.clientId, + anchor: menuAnchor, + } ); + existingMenuAnchors.add( menuAnchor ); + } ); + } else if ( menuItemsInserted ) { + // A menu item was pasted or duplicated — insert a matching tab. + // If the menu item's anchor conflicts with an existing tab, generate + // a fresh unique anchor. + if ( ! tabPanelClientId ) { + return; + } + const prevMenuItemIds = new Set( + prevMenuItems.map( ( m ) => m.clientId ) + ); + // Track base anchors (the tab-N part) already in use across both + // tabs and menu items to generate collision-free anchors when + // multiple menu items are inserted at once. + const usedBaseAnchors = new Set( [ + ...currentTabs.map( ( t ) => t.anchor ).filter( Boolean ), + ...menuItems + .map( ( m ) => m.anchor.replace( /-button$/, '' ) ) + .filter( Boolean ), + ] ); + const existingTabAnchors = new Set( + currentTabs.map( ( t ) => t.anchor ) + ); + menuItems.forEach( ( newMenuItem, menuItemIndex ) => { + if ( prevMenuItemIds.has( newMenuItem.clientId ) ) { + return; + } + let baseAnchor = newMenuItem.anchor + ? newMenuItem.anchor.replace( /-button$/, '' ) + : ''; + const tabAnchorConflicts = + baseAnchor && existingTabAnchors.has( baseAnchor ); + + // Grab the original tab's label before potentially reassigning + // baseAnchor, so we can copy it to the new tab. + const originalTab = tabs.find( + ( t ) => ( t.attributes.anchor ?? '' ) === baseAnchor + ); + const label = originalTab?.attributes?.label ?? ''; + + if ( ! baseAnchor || tabAnchorConflicts ) { + // Find the next free tab-N slot. + let tabNumber = menuItems.length + 1; + while ( usedBaseAnchors.has( `tab-${ tabNumber }` ) ) { + tabNumber++; + } + baseAnchor = `tab-${ tabNumber }`; + // Reserve this anchor so subsequent new items don't collide. + usedBaseAnchors.add( baseAnchor ); + updateBlockAttributes( newMenuItem.clientId, { + anchor: `${ baseAnchor }-button`, + } ); + // Keep the snapshot in sync with the new anchor value. + prevSyncStateRef.current.menuItems[ menuItemIndex ] = { + ...prevSyncStateRef.current.menuItems[ menuItemIndex ], + anchor: `${ baseAnchor }-button`, + }; + } + + const newTabBlock = createBlock( 'core/tab', { + anchor: baseAnchor, + label, + } ); + insertBlock( + newTabBlock, + menuItemIndex, + tabPanelClientId, + false + ); + // Add a placeholder so the next render does not re-trigger insertion. + prevSyncStateRef.current.tabs.splice( menuItemIndex, 0, { + clientId: newTabBlock.clientId, + anchor: baseAnchor, + } ); + existingTabAnchors.add( baseAnchor ); + } ); + } + }, [ + tabs, + menuItems, + removeBlock, + insertBlock, + updateBlockAttributes, + tabsMenuClientId, + tabPanelClientId, + ] ); +} From aee20210d757b73de94d4914e5ae607577fdb0be Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Thu, 12 Mar 2026 14:49:13 +0000 Subject: [PATCH 04/14] Add tests --- .../src/tabs/test/use-tab-menu-sync.js | 533 ++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 packages/block-library/src/tabs/test/use-tab-menu-sync.js diff --git a/packages/block-library/src/tabs/test/use-tab-menu-sync.js b/packages/block-library/src/tabs/test/use-tab-menu-sync.js new file mode 100644 index 00000000000000..a04c828eb28a28 --- /dev/null +++ b/packages/block-library/src/tabs/test/use-tab-menu-sync.js @@ -0,0 +1,533 @@ +/** + * @jest-environment jsdom + */ + +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react'; + +jest.mock( '@wordpress/block-editor', () => ( { + store: { name: 'core/block-editor' }, +} ) ); + +jest.mock( '@wordpress/blocks', () => ( { + createBlock: jest.fn( ( name, attributes ) => ( { + name, + attributes, + // Deterministic clientId so tests can assert on it. + clientId: `generated:${ name }:${ attributes.anchor ?? '' }`, + innerBlocks: [], + } ) ), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + useDispatch: jest.fn(), +} ) ); + +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import useTabMenuSync from '../use-tab-menu-sync'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTab( clientId, anchor, label = '' ) { + return { clientId, attributes: { anchor, label } }; +} + +function makeMenuItem( clientId, anchor ) { + return { clientId, anchor }; +} + +const PANEL = 'panel-client-id'; +const MENU = 'menu-client-id'; + +function renderSync( initialProps ) { + return renderHook( + ( { tabs, menuItems, tabPanelClientId, tabsMenuClientId } ) => + useTabMenuSync( { + tabs, + menuItems, + tabPanelClientId, + tabsMenuClientId, + } ), + { initialProps } + ); +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +let removeBlock; +let insertBlock; +let updateBlockAttributes; + +beforeEach( () => { + removeBlock = jest.fn(); + insertBlock = jest.fn(); + updateBlockAttributes = jest.fn(); + + useDispatch.mockReturnValue( { + removeBlock, + insertBlock, + updateBlockAttributes, + } ); + + createBlock.mockImplementation( ( name, attributes ) => ( { + name, + attributes, + clientId: `generated:${ name }:${ attributes.anchor ?? '' }`, + innerBlocks: [], + } ) ); +} ); + +afterEach( () => { + jest.clearAllMocks(); +} ); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe( 'useTabMenuSync', () => { + describe( 'initial mount', () => { + it( 'does nothing on first render', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( removeBlock ).not.toHaveBeenCalled(); + expect( insertBlock ).not.toHaveBeenCalled(); + expect( updateBlockAttributes ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing when re-rendered with the same data', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + const props = { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + }; + + const { rerender } = renderSync( props ); + rerender( props ); + + expect( removeBlock ).not.toHaveBeenCalled(); + expect( insertBlock ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'deletion', () => { + it( 'removes the menu item when a tab is deleted', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + rerender( { + tabs: [ makeTab( 't1', 'tab-1', 'Tab 1' ) ], + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( removeBlock ).toHaveBeenCalledTimes( 1 ); + expect( removeBlock ).toHaveBeenCalledWith( 'm2', false ); + expect( insertBlock ).not.toHaveBeenCalled(); + } ); + + it( 'removes the tab when a menu item is deleted', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + rerender( { + tabs, + menuItems: [ makeMenuItem( 'm1', 'tab-1-button' ) ], + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( removeBlock ).toHaveBeenCalledTimes( 1 ); + expect( removeBlock ).toHaveBeenCalledWith( 't2', false ); + expect( insertBlock ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing when both sides shrink simultaneously (toolbar removal)', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // Both lists shrink together — already in sync. + rerender( { + tabs: [ makeTab( 't1', 'tab-1', 'Tab 1' ) ], + menuItems: [ makeMenuItem( 'm1', 'tab-1-button' ) ], + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( removeBlock ).not.toHaveBeenCalled(); + expect( insertBlock ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'tab inserted', () => { + it( 'inserts a menu item when a tab with a fresh anchor is pasted', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // New tab 't3' has anchor 'tab-3' — no conflict. + rerender( { + tabs: [ ...tabs, makeTab( 't3', 'tab-3', 'Tab 3' ) ], + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( updateBlockAttributes ).not.toHaveBeenCalled(); + expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + expect( insertBlock ).toHaveBeenCalledWith( + expect.objectContaining( { + attributes: { anchor: 'tab-3-button' }, + } ), + 2, // index of the new tab + MENU, + false + ); + } ); + + it( 'generates a fresh anchor and updates the tab when a duplicate tab conflicts', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // 't1-dup' is a duplicate of t1, keeping its anchor 'tab-1'. + const dupTab = makeTab( 't1-dup', 'tab-1', 'Tab 1' ); + rerender( { + tabs: [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + dupTab, + makeTab( 't2', 'tab-2', 'Tab 2' ), + ], + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // Anchor on the duplicate tab must be updated to avoid conflict. + expect( updateBlockAttributes ).toHaveBeenCalledWith( 't1-dup', { + anchor: 'tab-4', // currentTabs.length(3) + 1 = 4 + } ); + + // A menu item with the new anchor should be inserted at index 1. + expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + expect( insertBlock ).toHaveBeenCalledWith( + expect.objectContaining( { + attributes: { anchor: 'tab-4-button' }, + } ), + 1, + MENU, + false + ); + } ); + + it( 'does nothing when tabs-menu clientId is missing', () => { + const tabs = [ makeTab( 't1', 'tab-1', 'Tab 1' ) ]; + const menuItems = []; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: null, + } ); + + rerender( { + tabs: [ ...tabs, makeTab( 't2', 'tab-2', 'Tab 2' ) ], + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: null, + } ); + + expect( insertBlock ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing when both sides grow simultaneously (Add Tab toolbar)', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // Both grow together — "Add Tab" already created both. + rerender( { + tabs: [ ...tabs, makeTab( 't3', 'tab-3', 'Tab 3' ) ], + menuItems: [ + ...menuItems, + makeMenuItem( 'm3', 'tab-3-button' ), + ], + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( insertBlock ).not.toHaveBeenCalled(); + expect( removeBlock ).not.toHaveBeenCalled(); + } ); + + it( 'generates distinct anchors when two tabs are duplicated at once', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // Two duplicates inserted — both keep conflicting anchor 'tab-1'. + const dup1 = makeTab( 'dup-a', 'tab-1', 'Tab 1' ); + const dup2 = makeTab( 'dup-b', 'tab-1', 'Tab 1' ); + rerender( { + tabs: [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + dup1, + dup2, + makeTab( 't2', 'tab-2', 'Tab 2' ), + ], + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + const assignedAnchors = updateBlockAttributes.mock.calls.map( + ( [ , attrs ] ) => attrs.anchor + ); + expect( assignedAnchors ).toHaveLength( 2 ); + // Each duplicate must receive a distinct anchor. + expect( new Set( assignedAnchors ).size ).toBe( 2 ); + // Both must be distinct from the originals. + expect( assignedAnchors ).not.toContain( 'tab-1' ); + expect( assignedAnchors ).not.toContain( 'tab-2' ); + } ); + } ); + + describe( 'menu item inserted', () => { + it( 'inserts a tab when a menu item with a fresh anchor is pasted', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // New menu item 'm3' has anchor 'tab-3-button' — no conflict. + rerender( { + tabs, + menuItems: [ + ...menuItems, + makeMenuItem( 'm3', 'tab-3-button' ), + ], + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( updateBlockAttributes ).not.toHaveBeenCalled(); + expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + expect( insertBlock ).toHaveBeenCalledWith( + expect.objectContaining( { + attributes: { anchor: 'tab-3', label: '' }, + } ), + 2, // index of the new menu item + PANEL, + false + ); + } ); + + it( 'generates a fresh anchor and copies the label when a menu item is duplicated', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // 'm1-dup' is a duplicate of m1, keeping its anchor 'tab-1-button'. + const dupItem = makeMenuItem( 'm1-dup', 'tab-1-button' ); + rerender( { + tabs, + menuItems: [ + makeMenuItem( 'm1', 'tab-1-button' ), + dupItem, + makeMenuItem( 'm2', 'tab-2-button' ), + ], + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // Anchor on the duplicate menu item must be updated. + expect( updateBlockAttributes ).toHaveBeenCalledWith( 'm1-dup', { + anchor: 'tab-4-button', // menuItems.length(3) + 1 = 4 + } ); + + // A tab with the new base anchor and the original label should be inserted. + expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + expect( insertBlock ).toHaveBeenCalledWith( + expect.objectContaining( { + attributes: { anchor: 'tab-4', label: 'Tab 1' }, + } ), + 1, // index of the duplicate in the menu items list + PANEL, + false + ); + } ); + + it( 'does nothing when tab-panel clientId is missing', () => { + const tabs = [ makeTab( 't1', 'tab-1', 'Tab 1' ) ]; + const menuItems = [ makeMenuItem( 'm1', 'tab-1-button' ) ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: null, + tabsMenuClientId: MENU, + } ); + + rerender( { + tabs, + menuItems: [ + ...menuItems, + makeMenuItem( 'm2', 'tab-2-button' ), + ], + tabPanelClientId: null, + tabsMenuClientId: MENU, + } ); + + expect( insertBlock ).not.toHaveBeenCalled(); + } ); + } ); +} ); From c21b46f53277e1532cdccd3317b295e90c29be7c Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Thu, 12 Mar 2026 15:52:03 +0000 Subject: [PATCH 05/14] Only update sync snapshot when necessary --- .../src/tabs/use-tab-menu-sync.js | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/block-library/src/tabs/use-tab-menu-sync.js b/packages/block-library/src/tabs/use-tab-menu-sync.js index e16dd10cf9c96b..17ec9ac831a0f4 100644 --- a/packages/block-library/src/tabs/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/use-tab-menu-sync.js @@ -55,17 +55,15 @@ export default function useTabMenuSync( { const tabsInserted = currentTabs.length > prevTabs.length; const menuItemsInserted = menuItems.length > prevMenuItems.length; - // Update snapshot to the current state. - prevSyncStateRef.current = { - tabs: currentTabs, - menuItems: [ ...menuItems ], - }; - // Lists are already in sync. if ( ( tabsRemoved && menuItemsRemoved ) || ( tabsInserted && menuItemsInserted ) ) { + prevSyncStateRef.current = { + tabs: currentTabs, + menuItems: [ ...menuItems ], + }; return; } @@ -76,9 +74,28 @@ export default function useTabMenuSync( { ! tabsInserted && ! menuItemsInserted ) { + prevSyncStateRef.current = { + tabs: currentTabs, + menuItems: [ ...menuItems ], + }; + return; + } + + // If the required container block isn't available yet, bail without + // updating the snapshot so the next render re-evaluates the same delta. + if ( tabsInserted && ! tabsMenuClientId ) { + return; + } + if ( menuItemsInserted && ! tabPanelClientId ) { return; } + // Update snapshot to the current state. + prevSyncStateRef.current = { + tabs: currentTabs, + menuItems: [ ...menuItems ], + }; + const currentTabIds = new Set( currentTabs.map( ( t ) => t.clientId ) ); const currentMenuItemIds = new Set( menuItems.map( ( m ) => m.clientId ) @@ -128,9 +145,6 @@ export default function useTabMenuSync( { // A tab was pasted or duplicated — insert a matching menu item. // If the tab's anchor conflicts with an existing menu item, generate // a fresh unique anchor. - if ( ! tabsMenuClientId ) { - return; - } const prevTabIds = new Set( prevTabs.map( ( t ) => t.clientId ) ); // Track anchors in use across both tabs and menu items to generate // collision-free anchors when multiple tabs are inserted at once. @@ -189,9 +203,6 @@ export default function useTabMenuSync( { // A menu item was pasted or duplicated — insert a matching tab. // If the menu item's anchor conflicts with an existing tab, generate // a fresh unique anchor. - if ( ! tabPanelClientId ) { - return; - } const prevMenuItemIds = new Set( prevMenuItems.map( ( m ) => m.clientId ) ); From c966b8b8f9dc640948cc9239454ab30052785663 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Thu, 12 Mar 2026 15:54:07 +0000 Subject: [PATCH 06/14] Add one-sided insertion test --- .../src/tabs/test/use-tab-menu-sync.js | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/packages/block-library/src/tabs/test/use-tab-menu-sync.js b/packages/block-library/src/tabs/test/use-tab-menu-sync.js index a04c828eb28a28..155b4c6eb6cc7a 100644 --- a/packages/block-library/src/tabs/test/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/test/use-tab-menu-sync.js @@ -342,6 +342,58 @@ describe( 'useTabMenuSync', () => { expect( insertBlock ).not.toHaveBeenCalled(); } ); + it( 'syncs once (no duplicates) when tabs-menu clientId becomes available after a one-sided insertion', () => { + const tabs = [ makeTab( 't1', 'tab-1', 'Tab 1' ) ]; + const menuItems = [ makeMenuItem( 'm1', 'tab-1-button' ) ]; + const tabsWithNew = [ ...tabs, makeTab( 't2', 'tab-2', 'Tab 2' ) ]; + + // Render 1 — initial snapshot. + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: null, + } ); + + // Render 2 — tab inserted but container unavailable; snapshot must NOT advance. + rerender( { + tabs: tabsWithNew, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: null, + } ); + + expect( insertBlock ).not.toHaveBeenCalled(); + + // Render 3 — container now available; hook should sync exactly once. + rerender( { + tabs: tabsWithNew, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + expect( insertBlock ).toHaveBeenCalledWith( + expect.objectContaining( { + attributes: { anchor: 'tab-2-button' }, + } ), + 1, + MENU, + false + ); + + // Render 4 — same props; no further insertions. + rerender( { + tabs: tabsWithNew, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'does nothing when both sides grow simultaneously (Add Tab toolbar)', () => { const tabs = [ makeTab( 't1', 'tab-1', 'Tab 1' ), @@ -529,5 +581,60 @@ describe( 'useTabMenuSync', () => { expect( insertBlock ).not.toHaveBeenCalled(); } ); + + it( 'syncs once (no duplicates) when tab-panel clientId becomes available after a one-sided insertion', () => { + const tabs = [ makeTab( 't1', 'tab-1', 'Tab 1' ) ]; + const menuItems = [ makeMenuItem( 'm1', 'tab-1-button' ) ]; + const menuItemsWithNew = [ + ...menuItems, + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + // Render 1 — initial snapshot. + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: null, + tabsMenuClientId: MENU, + } ); + + // Render 2 — menu item inserted but container unavailable; snapshot must NOT advance. + rerender( { + tabs, + menuItems: menuItemsWithNew, + tabPanelClientId: null, + tabsMenuClientId: MENU, + } ); + + expect( insertBlock ).not.toHaveBeenCalled(); + + // Render 3 — container now available; hook should sync exactly once. + rerender( { + tabs, + menuItems: menuItemsWithNew, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + expect( insertBlock ).toHaveBeenCalledWith( + expect.objectContaining( { + attributes: { anchor: 'tab-2', label: '' }, + } ), + 1, + PANEL, + false + ); + + // Render 4 — same props; no further insertions. + rerender( { + tabs, + menuItems: menuItemsWithNew, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + } ); } ); } ); From 98c06e187edeed53b3654e332d051cd70bfaecae Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Fri, 10 Apr 2026 13:26:02 +0100 Subject: [PATCH 07/14] Replace removed boolean flags in useTabMenuSync --- .../src/tabs/test/use-tab-menu-sync.js | 37 +++++++++++++++++++ .../src/tabs/use-tab-menu-sync.js | 30 +++++++-------- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/block-library/src/tabs/test/use-tab-menu-sync.js b/packages/block-library/src/tabs/test/use-tab-menu-sync.js index 155b4c6eb6cc7a..2b3e41cfe4448c 100644 --- a/packages/block-library/src/tabs/test/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/test/use-tab-menu-sync.js @@ -234,6 +234,43 @@ describe( 'useTabMenuSync', () => { expect( removeBlock ).not.toHaveBeenCalled(); expect( insertBlock ).not.toHaveBeenCalled(); } ); + + it( 'does nothing when both sides grow by different amounts', () => { + const tabs = [ + makeTab( 't1', 'tab-1', 'Tab 1' ), + makeTab( 't2', 'tab-2', 'Tab 2' ), + ]; + const menuItems = [ + makeMenuItem( 'm1', 'tab-1-button' ), + makeMenuItem( 'm2', 'tab-2-button' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // Tabs grew by 2, menu items grew by 1. + // Bail out. + rerender( { + tabs: [ + ...tabs, + makeTab( 't3', 'tab-3', 'Tab 3' ), + makeTab( 't4', 'tab-4', 'Tab 4' ), + ], + menuItems: [ + ...menuItems, + makeMenuItem( 'm3', 'tab-3-button' ), + ], + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( insertBlock ).not.toHaveBeenCalled(); + expect( removeBlock ).not.toHaveBeenCalled(); + } ); } ); describe( 'tab inserted', () => { diff --git a/packages/block-library/src/tabs/use-tab-menu-sync.js b/packages/block-library/src/tabs/use-tab-menu-sync.js index 17ec9ac831a0f4..cfcc4602d0c58f 100644 --- a/packages/block-library/src/tabs/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/use-tab-menu-sync.js @@ -50,16 +50,17 @@ export default function useTabMenuSync( { const { tabs: prevTabs, menuItems: prevMenuItems } = prevSyncStateRef.current; - const tabsRemoved = currentTabs.length < prevTabs.length; - const menuItemsRemoved = menuItems.length < prevMenuItems.length; - const tabsInserted = currentTabs.length > prevTabs.length; - const menuItemsInserted = menuItems.length > prevMenuItems.length; + const tabCountChange = currentTabs.length - prevTabs.length; + const menuItemCountChange = menuItems.length - prevMenuItems.length; - // Lists are already in sync. - if ( - ( tabsRemoved && menuItemsRemoved ) || - ( tabsInserted && menuItemsInserted ) - ) { + const tabsRemoved = tabCountChange < 0; + const menuItemsRemoved = menuItemCountChange < 0; + const tabsInserted = tabCountChange > 0; + const menuItemsInserted = menuItemCountChange > 0; + + // Both sides changed by the same amount. + // Covers: no-op re-renders, "Add Tab" toolbar, and toolbar-remove. + if ( tabCountChange === menuItemCountChange ) { prevSyncStateRef.current = { tabs: currentTabs, menuItems: [ ...menuItems ], @@ -67,12 +68,11 @@ export default function useTabMenuSync( { return; } - // Nothing changed. + // Both sides changed in the same direction but by different amounts. + // Bail without making a partial fix. if ( - ! tabsRemoved && - ! menuItemsRemoved && - ! tabsInserted && - ! menuItemsInserted + ( tabCountChange > 0 && menuItemCountChange > 0 ) || + ( tabCountChange < 0 && menuItemCountChange < 0 ) ) { prevSyncStateRef.current = { tabs: currentTabs, @@ -82,7 +82,7 @@ export default function useTabMenuSync( { } // If the required container block isn't available yet, bail without - // updating the snapshot so the next render re-evaluates the same delta. + // updating the snapshot so the next render re-evaluates the same count change. if ( tabsInserted && ! tabsMenuClientId ) { return; } From 5dc379d6c35c355687d818422052a5a69cd8ea7c Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Tue, 14 Apr 2026 12:42:49 +0100 Subject: [PATCH 08/14] Remove refs to anchors --- packages/block-library/src/tabs/edit.js | 22 +- .../src/tabs/test/use-tab-menu-sync.js | 317 ++++-------------- .../src/tabs/use-tab-menu-sync.js | 202 +++-------- 3 files changed, 127 insertions(+), 414 deletions(-) diff --git a/packages/block-library/src/tabs/edit.js b/packages/block-library/src/tabs/edit.js index 531caba65cc147..f4c3bd3116ffdd 100644 --- a/packages/block-library/src/tabs/edit.js +++ b/packages/block-library/src/tabs/edit.js @@ -75,12 +75,6 @@ function Edit( { } }, [] ); // eslint-disable-line react-hooks/exhaustive-deps - const { removeBlock, replaceInnerBlocks } = useDispatch( blockEditorStore ); - - /** - * Construct a list of core/tab blocks, used to create tabs-list context. - * Also select menu items with their anchors for anchor-based deletion sync. - */ const { tabs, tabPanelClientId, menuItems, tabsMenuClientId } = useSelect( ( select ) => { const { getBlocks } = select( blockEditorStore ); @@ -94,20 +88,10 @@ function Edit( { ); return { - tabs: tabPanel - ? tabPanel.innerBlocks.filter( - ( block ) => block.name === 'core/tab' - ) - : EMPTY_ARRAY, + tabs: tabPanel?.innerBlocks ?? EMPTY_ARRAY, tabPanelClientId: tabPanel?.clientId ?? null, - menuItems: tabsMenu - ? getBlocks( tabsMenu.clientId ) - .filter( ( b ) => b.name === 'core/tabs-menu-item' ) - .map( ( b ) => ( { - clientId: b.clientId, - anchor: b.attributes.anchor ?? '', - } ) ) - : EMPTY_ARRAY, + menuItems: tabsMenu?.innerBlocks ?? EMPTY_ARRAY, + tabsMenuClientId: tabsMenu?.clientId ?? null, }; }, [ clientId ] diff --git a/packages/block-library/src/tabs/test/use-tab-menu-sync.js b/packages/block-library/src/tabs/test/use-tab-menu-sync.js index 2b3e41cfe4448c..98843c4dd10e9b 100644 --- a/packages/block-library/src/tabs/test/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/test/use-tab-menu-sync.js @@ -15,8 +15,7 @@ jest.mock( '@wordpress/blocks', () => ( { createBlock: jest.fn( ( name, attributes ) => ( { name, attributes, - // Deterministic clientId so tests can assert on it. - clientId: `generated:${ name }:${ attributes.anchor ?? '' }`, + clientId: `generated:${ name }`, innerBlocks: [], } ) ), } ) ); @@ -40,12 +39,12 @@ import useTabMenuSync from '../use-tab-menu-sync'; // Helpers // --------------------------------------------------------------------------- -function makeTab( clientId, anchor, label = '' ) { - return { clientId, attributes: { anchor, label } }; +function makeTab( clientId, label = '' ) { + return { clientId, attributes: { label } }; } -function makeMenuItem( clientId, anchor ) { - return { clientId, anchor }; +function makeMenuItem( clientId ) { + return { clientId }; } const PANEL = 'panel-client-id'; @@ -70,23 +69,20 @@ function renderSync( initialProps ) { let removeBlock; let insertBlock; -let updateBlockAttributes; beforeEach( () => { removeBlock = jest.fn(); insertBlock = jest.fn(); - updateBlockAttributes = jest.fn(); useDispatch.mockReturnValue( { removeBlock, insertBlock, - updateBlockAttributes, } ); createBlock.mockImplementation( ( name, attributes ) => ( { name, attributes, - clientId: `generated:${ name }:${ attributes.anchor ?? '' }`, + clientId: `generated:${ name }`, innerBlocks: [], } ) ); } ); @@ -102,14 +98,8 @@ afterEach( () => { describe( 'useTabMenuSync', () => { describe( 'initial mount', () => { it( 'does nothing on first render', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; renderSync( { tabs, @@ -120,18 +110,11 @@ describe( 'useTabMenuSync', () => { expect( removeBlock ).not.toHaveBeenCalled(); expect( insertBlock ).not.toHaveBeenCalled(); - expect( updateBlockAttributes ).not.toHaveBeenCalled(); } ); it( 'does nothing when re-rendered with the same data', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; const props = { tabs, menuItems, @@ -148,15 +131,9 @@ describe( 'useTabMenuSync', () => { } ); describe( 'deletion', () => { - it( 'removes the menu item when a tab is deleted', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + it( 'removes the menu item at the same position when a tab is deleted', () => { + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; const { rerender } = renderSync( { tabs, @@ -165,8 +142,9 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); + // Tab at index 1 ('t2') is deleted. rerender( { - tabs: [ makeTab( 't1', 'tab-1', 'Tab 1' ) ], + tabs: [ makeTab( 't1', 'Tab 1' ) ], menuItems, tabPanelClientId: PANEL, tabsMenuClientId: MENU, @@ -177,15 +155,9 @@ describe( 'useTabMenuSync', () => { expect( insertBlock ).not.toHaveBeenCalled(); } ); - it( 'removes the tab when a menu item is deleted', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + it( 'removes the tab at the same position when a menu item is deleted', () => { + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; const { rerender } = renderSync( { tabs, @@ -194,9 +166,10 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); + // Menu item at index 1 ('m2') is deleted. rerender( { tabs, - menuItems: [ makeMenuItem( 'm1', 'tab-1-button' ) ], + menuItems: [ makeMenuItem( 'm1' ) ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -207,14 +180,8 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when both sides shrink simultaneously (toolbar removal)', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; const { rerender } = renderSync( { tabs, @@ -225,8 +192,8 @@ describe( 'useTabMenuSync', () => { // Both lists shrink together — already in sync. rerender( { - tabs: [ makeTab( 't1', 'tab-1', 'Tab 1' ) ], - menuItems: [ makeMenuItem( 'm1', 'tab-1-button' ) ], + tabs: [ makeTab( 't1', 'Tab 1' ) ], + menuItems: [ makeMenuItem( 'm1' ) ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -236,14 +203,8 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when both sides grow by different amounts', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; const { rerender } = renderSync( { tabs, @@ -257,13 +218,10 @@ describe( 'useTabMenuSync', () => { rerender( { tabs: [ ...tabs, - makeTab( 't3', 'tab-3', 'Tab 3' ), - makeTab( 't4', 'tab-4', 'Tab 4' ), - ], - menuItems: [ - ...menuItems, - makeMenuItem( 'm3', 'tab-3-button' ), + makeTab( 't3', 'Tab 3' ), + makeTab( 't4', 'Tab 4' ), ], + menuItems: [ ...menuItems, makeMenuItem( 'm3' ) ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -274,15 +232,9 @@ describe( 'useTabMenuSync', () => { } ); describe( 'tab inserted', () => { - it( 'inserts a menu item when a tab with a fresh anchor is pasted', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + it( 'inserts a menu item when a tab is pasted or duplicated', () => { + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; const { rerender } = renderSync( { tabs, @@ -291,19 +243,19 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - // New tab 't3' has anchor 'tab-3' — no conflict. + // New tab 't3' inserted at the end. rerender( { - tabs: [ ...tabs, makeTab( 't3', 'tab-3', 'Tab 3' ) ], + tabs: [ ...tabs, makeTab( 't3', 'Tab 3' ) ], menuItems, tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); - expect( updateBlockAttributes ).not.toHaveBeenCalled(); expect( insertBlock ).toHaveBeenCalledTimes( 1 ); expect( insertBlock ).toHaveBeenCalledWith( expect.objectContaining( { - attributes: { anchor: 'tab-3-button' }, + name: 'core/tabs-menu-item', + attributes: {}, } ), 2, // index of the new tab MENU, @@ -311,55 +263,8 @@ describe( 'useTabMenuSync', () => { ); } ); - it( 'generates a fresh anchor and updates the tab when a duplicate tab conflicts', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // 't1-dup' is a duplicate of t1, keeping its anchor 'tab-1'. - const dupTab = makeTab( 't1-dup', 'tab-1', 'Tab 1' ); - rerender( { - tabs: [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - dupTab, - makeTab( 't2', 'tab-2', 'Tab 2' ), - ], - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Anchor on the duplicate tab must be updated to avoid conflict. - expect( updateBlockAttributes ).toHaveBeenCalledWith( 't1-dup', { - anchor: 'tab-4', // currentTabs.length(3) + 1 = 4 - } ); - - // A menu item with the new anchor should be inserted at index 1. - expect( insertBlock ).toHaveBeenCalledTimes( 1 ); - expect( insertBlock ).toHaveBeenCalledWith( - expect.objectContaining( { - attributes: { anchor: 'tab-4-button' }, - } ), - 1, - MENU, - false - ); - } ); - it( 'does nothing when tabs-menu clientId is missing', () => { - const tabs = [ makeTab( 't1', 'tab-1', 'Tab 1' ) ]; + const tabs = [ makeTab( 't1', 'Tab 1' ) ]; const menuItems = []; const { rerender } = renderSync( { @@ -370,7 +275,7 @@ describe( 'useTabMenuSync', () => { } ); rerender( { - tabs: [ ...tabs, makeTab( 't2', 'tab-2', 'Tab 2' ) ], + tabs: [ ...tabs, makeTab( 't2', 'Tab 2' ) ], menuItems, tabPanelClientId: PANEL, tabsMenuClientId: null, @@ -380,9 +285,9 @@ describe( 'useTabMenuSync', () => { } ); it( 'syncs once (no duplicates) when tabs-menu clientId becomes available after a one-sided insertion', () => { - const tabs = [ makeTab( 't1', 'tab-1', 'Tab 1' ) ]; - const menuItems = [ makeMenuItem( 'm1', 'tab-1-button' ) ]; - const tabsWithNew = [ ...tabs, makeTab( 't2', 'tab-2', 'Tab 2' ) ]; + const tabs = [ makeTab( 't1', 'Tab 1' ) ]; + const menuItems = [ makeMenuItem( 'm1' ) ]; + const tabsWithNew = [ ...tabs, makeTab( 't2', 'Tab 2' ) ]; // Render 1 — initial snapshot. const { rerender } = renderSync( { @@ -413,7 +318,8 @@ describe( 'useTabMenuSync', () => { expect( insertBlock ).toHaveBeenCalledTimes( 1 ); expect( insertBlock ).toHaveBeenCalledWith( expect.objectContaining( { - attributes: { anchor: 'tab-2-button' }, + name: 'core/tabs-menu-item', + attributes: {}, } ), 1, MENU, @@ -432,14 +338,8 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when both sides grow simultaneously (Add Tab toolbar)', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; const { rerender } = renderSync( { tabs, @@ -450,11 +350,8 @@ describe( 'useTabMenuSync', () => { // Both grow together — "Add Tab" already created both. rerender( { - tabs: [ ...tabs, makeTab( 't3', 'tab-3', 'Tab 3' ) ], - menuItems: [ - ...menuItems, - makeMenuItem( 'm3', 'tab-3-button' ), - ], + tabs: [ ...tabs, makeTab( 't3', 'Tab 3' ) ], + menuItems: [ ...menuItems, makeMenuItem( 'm3' ) ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -462,61 +359,12 @@ describe( 'useTabMenuSync', () => { expect( insertBlock ).not.toHaveBeenCalled(); expect( removeBlock ).not.toHaveBeenCalled(); } ); - - it( 'generates distinct anchors when two tabs are duplicated at once', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Two duplicates inserted — both keep conflicting anchor 'tab-1'. - const dup1 = makeTab( 'dup-a', 'tab-1', 'Tab 1' ); - const dup2 = makeTab( 'dup-b', 'tab-1', 'Tab 1' ); - rerender( { - tabs: [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - dup1, - dup2, - makeTab( 't2', 'tab-2', 'Tab 2' ), - ], - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - const assignedAnchors = updateBlockAttributes.mock.calls.map( - ( [ , attrs ] ) => attrs.anchor - ); - expect( assignedAnchors ).toHaveLength( 2 ); - // Each duplicate must receive a distinct anchor. - expect( new Set( assignedAnchors ).size ).toBe( 2 ); - // Both must be distinct from the originals. - expect( assignedAnchors ).not.toContain( 'tab-1' ); - expect( assignedAnchors ).not.toContain( 'tab-2' ); - } ); } ); describe( 'menu item inserted', () => { - it( 'inserts a tab when a menu item with a fresh anchor is pasted', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + it( 'inserts a tab when a menu item is pasted or duplicated', () => { + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; const { rerender } = renderSync( { tabs, @@ -525,22 +373,19 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - // New menu item 'm3' has anchor 'tab-3-button' — no conflict. + // New menu item 'm3' appended at the end. rerender( { tabs, - menuItems: [ - ...menuItems, - makeMenuItem( 'm3', 'tab-3-button' ), - ], + menuItems: [ ...menuItems, makeMenuItem( 'm3' ) ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); - expect( updateBlockAttributes ).not.toHaveBeenCalled(); expect( insertBlock ).toHaveBeenCalledTimes( 1 ); expect( insertBlock ).toHaveBeenCalledWith( expect.objectContaining( { - attributes: { anchor: 'tab-3', label: '' }, + name: 'core/tab', + attributes: { label: 'Tab 2' }, // label copied from adjacent tab at index 1 } ), 2, // index of the new menu item PANEL, @@ -548,15 +393,9 @@ describe( 'useTabMenuSync', () => { ); } ); - it( 'generates a fresh anchor and copies the label when a menu item is duplicated', () => { - const tabs = [ - makeTab( 't1', 'tab-1', 'Tab 1' ), - makeTab( 't2', 'tab-2', 'Tab 2' ), - ]; - const menuItems = [ - makeMenuItem( 'm1', 'tab-1-button' ), - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + it( 'copies the label from the adjacent tab when a menu item is duplicated in the middle', () => { + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; const { rerender } = renderSync( { tabs, @@ -565,39 +404,34 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - // 'm1-dup' is a duplicate of m1, keeping its anchor 'tab-1-button'. - const dupItem = makeMenuItem( 'm1-dup', 'tab-1-button' ); + // 'm1-dup' is a duplicate of m1, inserted at index 1. rerender( { tabs, menuItems: [ - makeMenuItem( 'm1', 'tab-1-button' ), - dupItem, - makeMenuItem( 'm2', 'tab-2-button' ), + makeMenuItem( 'm1' ), + makeMenuItem( 'm1-dup' ), + makeMenuItem( 'm2' ), ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); - // Anchor on the duplicate menu item must be updated. - expect( updateBlockAttributes ).toHaveBeenCalledWith( 'm1-dup', { - anchor: 'tab-4-button', // menuItems.length(3) + 1 = 4 - } ); - - // A tab with the new base anchor and the original label should be inserted. + // Tab inserted at index 1 should copy label from t1 (index 0). expect( insertBlock ).toHaveBeenCalledTimes( 1 ); expect( insertBlock ).toHaveBeenCalledWith( expect.objectContaining( { - attributes: { anchor: 'tab-4', label: 'Tab 1' }, + name: 'core/tab', + attributes: { label: 'Tab 1' }, } ), - 1, // index of the duplicate in the menu items list + 1, PANEL, false ); } ); it( 'does nothing when tab-panel clientId is missing', () => { - const tabs = [ makeTab( 't1', 'tab-1', 'Tab 1' ) ]; - const menuItems = [ makeMenuItem( 'm1', 'tab-1-button' ) ]; + const tabs = [ makeTab( 't1', 'Tab 1' ) ]; + const menuItems = [ makeMenuItem( 'm1' ) ]; const { rerender } = renderSync( { tabs, @@ -608,10 +442,7 @@ describe( 'useTabMenuSync', () => { rerender( { tabs, - menuItems: [ - ...menuItems, - makeMenuItem( 'm2', 'tab-2-button' ), - ], + menuItems: [ ...menuItems, makeMenuItem( 'm2' ) ], tabPanelClientId: null, tabsMenuClientId: MENU, } ); @@ -620,12 +451,9 @@ describe( 'useTabMenuSync', () => { } ); it( 'syncs once (no duplicates) when tab-panel clientId becomes available after a one-sided insertion', () => { - const tabs = [ makeTab( 't1', 'tab-1', 'Tab 1' ) ]; - const menuItems = [ makeMenuItem( 'm1', 'tab-1-button' ) ]; - const menuItemsWithNew = [ - ...menuItems, - makeMenuItem( 'm2', 'tab-2-button' ), - ]; + const tabs = [ makeTab( 't1', 'Tab 1' ) ]; + const menuItems = [ makeMenuItem( 'm1' ) ]; + const menuItemsWithNew = [ ...menuItems, makeMenuItem( 'm2' ) ]; // Render 1 — initial snapshot. const { rerender } = renderSync( { @@ -656,7 +484,8 @@ describe( 'useTabMenuSync', () => { expect( insertBlock ).toHaveBeenCalledTimes( 1 ); expect( insertBlock ).toHaveBeenCalledWith( expect.objectContaining( { - attributes: { anchor: 'tab-2', label: '' }, + name: 'core/tab', + attributes: { label: 'Tab 1' }, } ), 1, PANEL, diff --git a/packages/block-library/src/tabs/use-tab-menu-sync.js b/packages/block-library/src/tabs/use-tab-menu-sync.js index cfcc4602d0c58f..bc90e1dc6d1d57 100644 --- a/packages/block-library/src/tabs/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/use-tab-menu-sync.js @@ -10,7 +10,7 @@ import { useEffect, useRef } from '@wordpress/element'; * Keep tabs and menu items in sync when the lists change due to direct * user actions (deletion, paste, duplicate, drag-and-drop). * - * Deletion: when one side shrinks, remove the orphaned counterpart. + * Deletion: when one side shrinks, remove the counterpart at the same index. * Insertion: when one side grows without the other, insert the missing * counterpart at the matching index. * @@ -19,7 +19,7 @@ import { useEffect, useRef } from '@wordpress/element'; * * @param {Object} props * @param {Array} props.tabs Raw core/tab block objects. - * @param {Array} props.menuItems Menu item descriptors { clientId, anchor }. + * @param {Array} props.menuItems Raw core/tabs-menu-item block objects. * @param {string|null} props.tabPanelClientId Client ID of the core/tab-panel block. * @param {string|null} props.tabsMenuClientId Client ID of the core/tabs-menu block. */ @@ -29,20 +29,21 @@ export default function useTabMenuSync( { tabPanelClientId, tabsMenuClientId, } ) { - const { removeBlock, insertBlock, updateBlockAttributes } = - useDispatch( blockEditorStore ); + const { removeBlock, insertBlock } = useDispatch( blockEditorStore ); const prevSyncStateRef = useRef( null ); useEffect( () => { const currentTabs = tabs.map( ( tab ) => ( { clientId: tab.clientId, - anchor: tab.attributes.anchor ?? '', + } ) ); + const currentMenuItems = menuItems.map( ( m ) => ( { + clientId: m.clientId, } ) ); if ( prevSyncStateRef.current === null ) { prevSyncStateRef.current = { tabs: currentTabs, - menuItems: [ ...menuItems ], + menuItems: currentMenuItems, }; return; } @@ -51,10 +52,9 @@ export default function useTabMenuSync( { prevSyncStateRef.current; const tabCountChange = currentTabs.length - prevTabs.length; - const menuItemCountChange = menuItems.length - prevMenuItems.length; + const menuItemCountChange = + currentMenuItems.length - prevMenuItems.length; - const tabsRemoved = tabCountChange < 0; - const menuItemsRemoved = menuItemCountChange < 0; const tabsInserted = tabCountChange > 0; const menuItemsInserted = menuItemCountChange > 0; @@ -63,7 +63,7 @@ export default function useTabMenuSync( { if ( tabCountChange === menuItemCountChange ) { prevSyncStateRef.current = { tabs: currentTabs, - menuItems: [ ...menuItems ], + menuItems: currentMenuItems, }; return; } @@ -76,7 +76,7 @@ export default function useTabMenuSync( { ) { prevSyncStateRef.current = { tabs: currentTabs, - menuItems: [ ...menuItems ], + menuItems: currentMenuItems, }; return; } @@ -93,183 +93,84 @@ export default function useTabMenuSync( { // Update snapshot to the current state. prevSyncStateRef.current = { tabs: currentTabs, - menuItems: [ ...menuItems ], + menuItems: currentMenuItems, }; const currentTabIds = new Set( currentTabs.map( ( t ) => t.clientId ) ); const currentMenuItemIds = new Set( - menuItems.map( ( m ) => m.clientId ) + currentMenuItems.map( ( m ) => m.clientId ) ); - if ( tabsRemoved ) { - // A tab was deleted — remove its corresponding menu item. - prevTabs.forEach( ( prevTab ) => { - if ( currentTabIds.has( prevTab.clientId ) ) { - return; - } - const expectedMenuAnchor = prevTab.anchor - ? `${ prevTab.anchor }-button` - : null; - const menuItemToRemove = expectedMenuAnchor - ? menuItems.find( ( m ) => m.anchor === expectedMenuAnchor ) - : null; - if ( menuItemToRemove ) { - removeBlock( menuItemToRemove.clientId, false ); - prevSyncStateRef.current.menuItems = - prevSyncStateRef.current.menuItems.filter( - ( m ) => m.clientId !== menuItemToRemove.clientId - ); - } - } ); - } else if ( menuItemsRemoved ) { - // A menu item was deleted — remove its corresponding tab. - prevMenuItems.forEach( ( prevItem ) => { - if ( currentMenuItemIds.has( prevItem.clientId ) ) { - return; - } - const expectedTabAnchor = - prevItem.anchor?.replace( /-button$/, '' ) ?? ''; - const tabToRemove = tabs.find( - ( tab ) => - ( tab.attributes.anchor ?? '' ) === expectedTabAnchor - ); - if ( tabToRemove ) { - removeBlock( tabToRemove.clientId, false ); - prevSyncStateRef.current.tabs = - prevSyncStateRef.current.tabs.filter( - ( t ) => t.clientId !== tabToRemove.clientId - ); - } - } ); - } else if ( tabsInserted ) { - // A tab was pasted or duplicated — insert a matching menu item. - // If the tab's anchor conflicts with an existing menu item, generate - // a fresh unique anchor. - const prevTabIds = new Set( prevTabs.map( ( t ) => t.clientId ) ); - // Track anchors in use across both tabs and menu items to generate - // collision-free anchors when multiple tabs are inserted at once. - const usedTabAnchors = new Set( - currentTabs.map( ( t ) => t.anchor ).filter( Boolean ) + if ( tabCountChange < 0 ) { + // Remove the menu item at the same position as the deleted tab. + const removedIndex = prevTabs.findIndex( + ( t ) => ! currentTabIds.has( t.clientId ) ); - const existingMenuAnchors = new Set( - menuItems.map( ( m ) => m.anchor ) + if ( removedIndex >= 0 && menuItems[ removedIndex ] ) { + removeBlock( menuItems[ removedIndex ].clientId, false ); + prevSyncStateRef.current.menuItems = + prevSyncStateRef.current.menuItems.filter( + ( _, i ) => i !== removedIndex + ); + } + } else if ( menuItemCountChange < 0 ) { + // Remove the tab at the same position as the deleted menu item. + const removedIndex = prevMenuItems.findIndex( + ( m ) => ! currentMenuItemIds.has( m.clientId ) ); + if ( removedIndex >= 0 && tabs[ removedIndex ] ) { + removeBlock( tabs[ removedIndex ].clientId, false ); + prevSyncStateRef.current.tabs = + prevSyncStateRef.current.tabs.filter( + ( _, i ) => i !== removedIndex + ); + } + } else if ( tabsInserted ) { + // A tab was pasted or duplicated — insert a matching menu item at + // the same position. + const prevTabIds = new Set( prevTabs.map( ( t ) => t.clientId ) ); currentTabs.forEach( ( newTab, tabIndex ) => { if ( prevTabIds.has( newTab.clientId ) ) { return; } - let tabAnchor = newTab.anchor; - const menuAnchorConflicts = - tabAnchor && - existingMenuAnchors.has( `${ tabAnchor }-button` ); - - if ( ! tabAnchor || menuAnchorConflicts ) { - // Find the next free tab-N slot. - let tabNumber = currentTabs.length + 1; - while ( usedTabAnchors.has( `tab-${ tabNumber }` ) ) { - tabNumber++; - } - tabAnchor = `tab-${ tabNumber }`; - // Reserve this anchor so subsequent new tabs don't collide. - usedTabAnchors.add( tabAnchor ); - updateBlockAttributes( newTab.clientId, { - anchor: tabAnchor, - } ); - // Keep the snapshot in sync with the new anchor value. - prevSyncStateRef.current.tabs[ tabIndex ] = { - ...prevSyncStateRef.current.tabs[ tabIndex ], - anchor: tabAnchor, - }; - } - - const menuAnchor = `${ tabAnchor }-button`; - const newMenuItemBlock = createBlock( 'core/tabs-menu-item', { - anchor: menuAnchor, - } ); + const newMenuItemBlock = createBlock( + 'core/tabs-menu-item', + {} + ); insertBlock( newMenuItemBlock, tabIndex, tabsMenuClientId, false ); - // Add a placeholder so the next render does not re-trigger insertion. prevSyncStateRef.current.menuItems.splice( tabIndex, 0, { clientId: newMenuItemBlock.clientId, - anchor: menuAnchor, } ); - existingMenuAnchors.add( menuAnchor ); } ); } else if ( menuItemsInserted ) { - // A menu item was pasted or duplicated — insert a matching tab. - // If the menu item's anchor conflicts with an existing tab, generate - // a fresh unique anchor. + // A menu item was pasted or duplicated — insert a matching tab at + // the same position, copying the label from the adjacent tab. const prevMenuItemIds = new Set( prevMenuItems.map( ( m ) => m.clientId ) ); - // Track base anchors (the tab-N part) already in use across both - // tabs and menu items to generate collision-free anchors when - // multiple menu items are inserted at once. - const usedBaseAnchors = new Set( [ - ...currentTabs.map( ( t ) => t.anchor ).filter( Boolean ), - ...menuItems - .map( ( m ) => m.anchor.replace( /-button$/, '' ) ) - .filter( Boolean ), - ] ); - const existingTabAnchors = new Set( - currentTabs.map( ( t ) => t.anchor ) - ); - menuItems.forEach( ( newMenuItem, menuItemIndex ) => { + currentMenuItems.forEach( ( newMenuItem, menuItemIndex ) => { if ( prevMenuItemIds.has( newMenuItem.clientId ) ) { return; } - let baseAnchor = newMenuItem.anchor - ? newMenuItem.anchor.replace( /-button$/, '' ) - : ''; - const tabAnchorConflicts = - baseAnchor && existingTabAnchors.has( baseAnchor ); - - // Grab the original tab's label before potentially reassigning - // baseAnchor, so we can copy it to the new tab. - const originalTab = tabs.find( - ( t ) => ( t.attributes.anchor ?? '' ) === baseAnchor - ); - const label = originalTab?.attributes?.label ?? ''; - - if ( ! baseAnchor || tabAnchorConflicts ) { - // Find the next free tab-N slot. - let tabNumber = menuItems.length + 1; - while ( usedBaseAnchors.has( `tab-${ tabNumber }` ) ) { - tabNumber++; - } - baseAnchor = `tab-${ tabNumber }`; - // Reserve this anchor so subsequent new items don't collide. - usedBaseAnchors.add( baseAnchor ); - updateBlockAttributes( newMenuItem.clientId, { - anchor: `${ baseAnchor }-button`, - } ); - // Keep the snapshot in sync with the new anchor value. - prevSyncStateRef.current.menuItems[ menuItemIndex ] = { - ...prevSyncStateRef.current.menuItems[ menuItemIndex ], - anchor: `${ baseAnchor }-button`, - }; - } - - const newTabBlock = createBlock( 'core/tab', { - anchor: baseAnchor, - label, - } ); + const label = + tabs[ menuItemIndex - 1 ]?.attributes?.label ?? + tabs[ menuItemIndex ]?.attributes?.label ?? + ''; + const newTabBlock = createBlock( 'core/tab', { label } ); insertBlock( newTabBlock, menuItemIndex, tabPanelClientId, false ); - // Add a placeholder so the next render does not re-trigger insertion. prevSyncStateRef.current.tabs.splice( menuItemIndex, 0, { clientId: newTabBlock.clientId, - anchor: baseAnchor, } ); - existingTabAnchors.add( baseAnchor ); } ); } }, [ @@ -277,7 +178,6 @@ export default function useTabMenuSync( { menuItems, removeBlock, insertBlock, - updateBlockAttributes, tabsMenuClientId, tabPanelClientId, ] ); From cc169c4df2d0238c9fd0d146bba7b8c30f6ea41f Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Tue, 14 Apr 2026 12:48:28 +0100 Subject: [PATCH 09/14] Fix reordering regression --- .../src/tabs/test/use-tab-menu-sync.js | 82 +++++++++++++++++++ .../src/tabs/use-tab-menu-sync.js | 30 ++++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/block-library/src/tabs/test/use-tab-menu-sync.js b/packages/block-library/src/tabs/test/use-tab-menu-sync.js index 98843c4dd10e9b..65082c949b6920 100644 --- a/packages/block-library/src/tabs/test/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/test/use-tab-menu-sync.js @@ -69,14 +69,17 @@ function renderSync( initialProps ) { let removeBlock; let insertBlock; +let replaceInnerBlocks; beforeEach( () => { removeBlock = jest.fn(); insertBlock = jest.fn(); + replaceInnerBlocks = jest.fn(); useDispatch.mockReturnValue( { removeBlock, insertBlock, + replaceInnerBlocks, } ); createBlock.mockImplementation( ( name, attributes ) => ( { @@ -231,6 +234,85 @@ describe( 'useTabMenuSync', () => { } ); } ); + describe( 'reordering', () => { + it( 'reorders tabs to match when menu items are dragged to a new position', () => { + const t1 = makeTab( 't1', 'Tab 1' ); + const t2 = makeTab( 't2', 'Tab 2' ); + const t3 = makeTab( 't3', 'Tab 3' ); + const tabs = [ t1, t2, t3 ]; + const menuItems = [ + makeMenuItem( 'm1' ), + makeMenuItem( 'm2' ), + makeMenuItem( 'm3' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // User drags m1 to the end: new order is m2, m3, m1. + rerender( { + tabs, + menuItems: [ + makeMenuItem( 'm2' ), + makeMenuItem( 'm3' ), + makeMenuItem( 'm1' ), + ], + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( replaceInnerBlocks ).toHaveBeenCalledTimes( 1 ); + expect( replaceInnerBlocks ).toHaveBeenCalledWith( + PANEL, + [ t2, t3, t1 ], // tabs reordered to match menu items + false + ); + expect( removeBlock ).not.toHaveBeenCalled(); + expect( insertBlock ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing when menu items are in the same order', () => { + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const props = { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + }; + + const { rerender } = renderSync( props ); + rerender( props ); + + expect( replaceInnerBlocks ).not.toHaveBeenCalled(); + } ); + + it( 'does nothing when tab-panel clientId is missing during reorder', () => { + const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; + const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: null, + tabsMenuClientId: MENU, + } ); + + rerender( { + tabs, + menuItems: [ makeMenuItem( 'm2' ), makeMenuItem( 'm1' ) ], + tabPanelClientId: null, + tabsMenuClientId: MENU, + } ); + + expect( replaceInnerBlocks ).not.toHaveBeenCalled(); + } ); + } ); + describe( 'tab inserted', () => { it( 'inserts a menu item when a tab is pasted or duplicated', () => { const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; diff --git a/packages/block-library/src/tabs/use-tab-menu-sync.js b/packages/block-library/src/tabs/use-tab-menu-sync.js index bc90e1dc6d1d57..cd0add622abb9b 100644 --- a/packages/block-library/src/tabs/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/use-tab-menu-sync.js @@ -29,7 +29,8 @@ export default function useTabMenuSync( { tabPanelClientId, tabsMenuClientId, } ) { - const { removeBlock, insertBlock } = useDispatch( blockEditorStore ); + const { removeBlock, insertBlock, replaceInnerBlocks } = + useDispatch( blockEditorStore ); const prevSyncStateRef = useRef( null ); useEffect( () => { @@ -60,7 +61,33 @@ export default function useTabMenuSync( { // Both sides changed by the same amount. // Covers: no-op re-renders, "Add Tab" toolbar, and toolbar-remove. + // Also handles drag-and-drop reordering of menu items. if ( tabCountChange === menuItemCountChange ) { + // When lengths are equal but order changed, the user reordered menu + // items via drag-and-drop. Reorder the tab content blocks to match. + if ( + tabCountChange === 0 && + tabPanelClientId && + currentMenuItems.some( + ( m, i ) => m.clientId !== prevMenuItems[ i ]?.clientId + ) + ) { + const reorderedTabs = currentMenuItems + .map( ( menuItem ) => { + const oldIndex = prevMenuItems.findIndex( + ( pm ) => pm.clientId === menuItem.clientId + ); + return oldIndex !== -1 ? tabs[ oldIndex ] : null; + } ) + .filter( Boolean ); + if ( reorderedTabs.length === tabs.length ) { + replaceInnerBlocks( + tabPanelClientId, + reorderedTabs, + false + ); + } + } prevSyncStateRef.current = { tabs: currentTabs, menuItems: currentMenuItems, @@ -178,6 +205,7 @@ export default function useTabMenuSync( { menuItems, removeBlock, insertBlock, + replaceInnerBlocks, tabsMenuClientId, tabPanelClientId, ] ); From f49f46dc3a2902c4b24d8fd37961bd1f005e1395 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Wed, 15 Apr 2026 16:28:50 +0100 Subject: [PATCH 10/14] Handle bulk deletions and suppress sync undo --- .../src/tabs/test/use-tab-menu-sync.js | 69 +++++++++++++++++++ .../src/tabs/use-tab-menu-sync.js | 68 +++++++++++------- 2 files changed, 113 insertions(+), 24 deletions(-) diff --git a/packages/block-library/src/tabs/test/use-tab-menu-sync.js b/packages/block-library/src/tabs/test/use-tab-menu-sync.js index 65082c949b6920..a759ef292654ed 100644 --- a/packages/block-library/src/tabs/test/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/test/use-tab-menu-sync.js @@ -70,16 +70,19 @@ function renderSync( initialProps ) { let removeBlock; let insertBlock; let replaceInnerBlocks; +let __unstableMarkNextChangeAsNotPersistent; beforeEach( () => { removeBlock = jest.fn(); insertBlock = jest.fn(); replaceInnerBlocks = jest.fn(); + __unstableMarkNextChangeAsNotPersistent = jest.fn(); useDispatch.mockReturnValue( { removeBlock, insertBlock, replaceInnerBlocks, + __unstableMarkNextChangeAsNotPersistent, } ); createBlock.mockImplementation( ( name, attributes ) => ( { @@ -182,6 +185,72 @@ describe( 'useTabMenuSync', () => { expect( insertBlock ).not.toHaveBeenCalled(); } ); + it( 'removes all orphaned menu items when multiple tabs are deleted at once', () => { + const tabs = [ + makeTab( 't1', 'Tab 1' ), + makeTab( 't2', 'Tab 2' ), + makeTab( 't3', 'Tab 3' ), + ]; + const menuItems = [ + makeMenuItem( 'm1' ), + makeMenuItem( 'm2' ), + makeMenuItem( 'm3' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // Tabs at index 0 and 1 ('t1', 't2') are deleted simultaneously. + rerender( { + tabs: [ makeTab( 't3', 'Tab 3' ) ], + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( removeBlock ).toHaveBeenCalledTimes( 2 ); + expect( removeBlock ).toHaveBeenCalledWith( 'm1', false ); + expect( removeBlock ).toHaveBeenCalledWith( 'm2', false ); + expect( insertBlock ).not.toHaveBeenCalled(); + } ); + + it( 'removes all orphaned tabs when multiple menu items are deleted at once', () => { + const tabs = [ + makeTab( 't1', 'Tab 1' ), + makeTab( 't2', 'Tab 2' ), + makeTab( 't3', 'Tab 3' ), + ]; + const menuItems = [ + makeMenuItem( 'm1' ), + makeMenuItem( 'm2' ), + makeMenuItem( 'm3' ), + ]; + + const { rerender } = renderSync( { + tabs, + menuItems, + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + // Menu items at index 0 and 1 ('m1', 'm2') are deleted simultaneously. + rerender( { + tabs, + menuItems: [ makeMenuItem( 'm3' ) ], + tabPanelClientId: PANEL, + tabsMenuClientId: MENU, + } ); + + expect( removeBlock ).toHaveBeenCalledTimes( 2 ); + expect( removeBlock ).toHaveBeenCalledWith( 't1', false ); + expect( removeBlock ).toHaveBeenCalledWith( 't2', false ); + expect( insertBlock ).not.toHaveBeenCalled(); + } ); + it( 'does nothing when both sides shrink simultaneously (toolbar removal)', () => { const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; diff --git a/packages/block-library/src/tabs/use-tab-menu-sync.js b/packages/block-library/src/tabs/use-tab-menu-sync.js index cd0add622abb9b..123dba136fd7ac 100644 --- a/packages/block-library/src/tabs/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/use-tab-menu-sync.js @@ -29,8 +29,12 @@ export default function useTabMenuSync( { tabPanelClientId, tabsMenuClientId, } ) { - const { removeBlock, insertBlock, replaceInnerBlocks } = - useDispatch( blockEditorStore ); + const { + removeBlock, + insertBlock, + replaceInnerBlocks, + __unstableMarkNextChangeAsNotPersistent, + } = useDispatch( blockEditorStore ); const prevSyncStateRef = useRef( null ); useEffect( () => { @@ -81,6 +85,7 @@ export default function useTabMenuSync( { } ) .filter( Boolean ); if ( reorderedTabs.length === tabs.length ) { + __unstableMarkNextChangeAsNotPersistent(); replaceInnerBlocks( tabPanelClientId, reorderedTabs, @@ -129,29 +134,41 @@ export default function useTabMenuSync( { ); if ( tabCountChange < 0 ) { - // Remove the menu item at the same position as the deleted tab. - const removedIndex = prevTabs.findIndex( - ( t ) => ! currentTabIds.has( t.clientId ) - ); - if ( removedIndex >= 0 && menuItems[ removedIndex ] ) { - removeBlock( menuItems[ removedIndex ].clientId, false ); - prevSyncStateRef.current.menuItems = - prevSyncStateRef.current.menuItems.filter( - ( _, i ) => i !== removedIndex - ); - } + // Remove the menu item at the same position as each deleted tab. + const removedIndices = prevTabs + .map( ( t, i ) => + ! currentTabIds.has( t.clientId ) ? i : -1 + ) + .filter( ( i ) => i !== -1 ); + const removedSet = new Set( removedIndices ); + removedIndices.forEach( ( removedIndex ) => { + if ( menuItems[ removedIndex ] ) { + __unstableMarkNextChangeAsNotPersistent(); + removeBlock( menuItems[ removedIndex ].clientId, false ); + } + } ); + prevSyncStateRef.current.menuItems = + prevSyncStateRef.current.menuItems.filter( + ( _, i ) => ! removedSet.has( i ) + ); } else if ( menuItemCountChange < 0 ) { - // Remove the tab at the same position as the deleted menu item. - const removedIndex = prevMenuItems.findIndex( - ( m ) => ! currentMenuItemIds.has( m.clientId ) - ); - if ( removedIndex >= 0 && tabs[ removedIndex ] ) { - removeBlock( tabs[ removedIndex ].clientId, false ); - prevSyncStateRef.current.tabs = - prevSyncStateRef.current.tabs.filter( - ( _, i ) => i !== removedIndex - ); - } + // Remove the tab at the same position as each deleted menu item. + const removedIndices = prevMenuItems + .map( ( m, i ) => + ! currentMenuItemIds.has( m.clientId ) ? i : -1 + ) + .filter( ( i ) => i !== -1 ); + const removedSet = new Set( removedIndices ); + removedIndices.forEach( ( removedIndex ) => { + if ( tabs[ removedIndex ] ) { + __unstableMarkNextChangeAsNotPersistent(); + removeBlock( tabs[ removedIndex ].clientId, false ); + } + } ); + prevSyncStateRef.current.tabs = + prevSyncStateRef.current.tabs.filter( + ( _, i ) => ! removedSet.has( i ) + ); } else if ( tabsInserted ) { // A tab was pasted or duplicated — insert a matching menu item at // the same position. @@ -164,6 +181,7 @@ export default function useTabMenuSync( { 'core/tabs-menu-item', {} ); + __unstableMarkNextChangeAsNotPersistent(); insertBlock( newMenuItemBlock, tabIndex, @@ -189,6 +207,7 @@ export default function useTabMenuSync( { tabs[ menuItemIndex ]?.attributes?.label ?? ''; const newTabBlock = createBlock( 'core/tab', { label } ); + __unstableMarkNextChangeAsNotPersistent(); insertBlock( newTabBlock, menuItemIndex, @@ -206,6 +225,7 @@ export default function useTabMenuSync( { removeBlock, insertBlock, replaceInnerBlocks, + __unstableMarkNextChangeAsNotPersistent, tabsMenuClientId, tabPanelClientId, ] ); From fb5c38025ce3e413cda05c6c9303029ead314c1f Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Wed, 15 Apr 2026 16:39:40 +0100 Subject: [PATCH 11/14] Replace insertBlock with insertBlocks --- .../src/tabs/test/use-tab-menu-sync.js | 108 ++++++++++-------- .../src/tabs/use-tab-menu-sync.js | 85 ++++++++------ 2 files changed, 109 insertions(+), 84 deletions(-) diff --git a/packages/block-library/src/tabs/test/use-tab-menu-sync.js b/packages/block-library/src/tabs/test/use-tab-menu-sync.js index a759ef292654ed..c510aae6903af8 100644 --- a/packages/block-library/src/tabs/test/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/test/use-tab-menu-sync.js @@ -68,19 +68,19 @@ function renderSync( initialProps ) { // --------------------------------------------------------------------------- let removeBlock; -let insertBlock; +let insertBlocks; let replaceInnerBlocks; let __unstableMarkNextChangeAsNotPersistent; beforeEach( () => { removeBlock = jest.fn(); - insertBlock = jest.fn(); + insertBlocks = jest.fn(); replaceInnerBlocks = jest.fn(); __unstableMarkNextChangeAsNotPersistent = jest.fn(); useDispatch.mockReturnValue( { removeBlock, - insertBlock, + insertBlocks, replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent, } ); @@ -115,7 +115,7 @@ describe( 'useTabMenuSync', () => { } ); expect( removeBlock ).not.toHaveBeenCalled(); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); it( 'does nothing when re-rendered with the same data', () => { @@ -132,7 +132,7 @@ describe( 'useTabMenuSync', () => { rerender( props ); expect( removeBlock ).not.toHaveBeenCalled(); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); } ); @@ -158,7 +158,7 @@ describe( 'useTabMenuSync', () => { expect( removeBlock ).toHaveBeenCalledTimes( 1 ); expect( removeBlock ).toHaveBeenCalledWith( 'm2', false ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); it( 'removes the tab at the same position when a menu item is deleted', () => { @@ -182,7 +182,7 @@ describe( 'useTabMenuSync', () => { expect( removeBlock ).toHaveBeenCalledTimes( 1 ); expect( removeBlock ).toHaveBeenCalledWith( 't2', false ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); it( 'removes all orphaned menu items when multiple tabs are deleted at once', () => { @@ -215,7 +215,7 @@ describe( 'useTabMenuSync', () => { expect( removeBlock ).toHaveBeenCalledTimes( 2 ); expect( removeBlock ).toHaveBeenCalledWith( 'm1', false ); expect( removeBlock ).toHaveBeenCalledWith( 'm2', false ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); it( 'removes all orphaned tabs when multiple menu items are deleted at once', () => { @@ -248,7 +248,7 @@ describe( 'useTabMenuSync', () => { expect( removeBlock ).toHaveBeenCalledTimes( 2 ); expect( removeBlock ).toHaveBeenCalledWith( 't1', false ); expect( removeBlock ).toHaveBeenCalledWith( 't2', false ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); it( 'does nothing when both sides shrink simultaneously (toolbar removal)', () => { @@ -271,7 +271,7 @@ describe( 'useTabMenuSync', () => { } ); expect( removeBlock ).not.toHaveBeenCalled(); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); it( 'does nothing when both sides grow by different amounts', () => { @@ -298,7 +298,7 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); expect( removeBlock ).not.toHaveBeenCalled(); } ); } ); @@ -341,7 +341,7 @@ describe( 'useTabMenuSync', () => { false ); expect( removeBlock ).not.toHaveBeenCalled(); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); it( 'does nothing when menu items are in the same order', () => { @@ -402,12 +402,14 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).toHaveBeenCalledTimes( 1 ); - expect( insertBlock ).toHaveBeenCalledWith( - expect.objectContaining( { - name: 'core/tabs-menu-item', - attributes: {}, - } ), + expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); + expect( insertBlocks ).toHaveBeenCalledWith( + [ + expect.objectContaining( { + name: 'core/tabs-menu-item', + attributes: {}, + } ), + ], 2, // index of the new tab MENU, false @@ -432,7 +434,7 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: null, } ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); it( 'syncs once (no duplicates) when tabs-menu clientId becomes available after a one-sided insertion', () => { @@ -456,7 +458,7 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: null, } ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); // Render 3 — container now available; hook should sync exactly once. rerender( { @@ -466,12 +468,14 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).toHaveBeenCalledTimes( 1 ); - expect( insertBlock ).toHaveBeenCalledWith( - expect.objectContaining( { - name: 'core/tabs-menu-item', - attributes: {}, - } ), + expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); + expect( insertBlocks ).toHaveBeenCalledWith( + [ + expect.objectContaining( { + name: 'core/tabs-menu-item', + attributes: {}, + } ), + ], 1, MENU, false @@ -485,7 +489,7 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); } ); it( 'does nothing when both sides grow simultaneously (Add Tab toolbar)', () => { @@ -507,7 +511,7 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); expect( removeBlock ).not.toHaveBeenCalled(); } ); } ); @@ -532,12 +536,14 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).toHaveBeenCalledTimes( 1 ); - expect( insertBlock ).toHaveBeenCalledWith( - expect.objectContaining( { - name: 'core/tab', - attributes: { label: 'Tab 2' }, // label copied from adjacent tab at index 1 - } ), + expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); + expect( insertBlocks ).toHaveBeenCalledWith( + [ + expect.objectContaining( { + name: 'core/tab', + attributes: { label: 'Tab 2' }, // label copied from adjacent tab at index 1 + } ), + ], 2, // index of the new menu item PANEL, false @@ -568,12 +574,14 @@ describe( 'useTabMenuSync', () => { } ); // Tab inserted at index 1 should copy label from t1 (index 0). - expect( insertBlock ).toHaveBeenCalledTimes( 1 ); - expect( insertBlock ).toHaveBeenCalledWith( - expect.objectContaining( { - name: 'core/tab', - attributes: { label: 'Tab 1' }, - } ), + expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); + expect( insertBlocks ).toHaveBeenCalledWith( + [ + expect.objectContaining( { + name: 'core/tab', + attributes: { label: 'Tab 1' }, + } ), + ], 1, PANEL, false @@ -598,7 +606,7 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); } ); it( 'syncs once (no duplicates) when tab-panel clientId becomes available after a one-sided insertion', () => { @@ -622,7 +630,7 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).not.toHaveBeenCalled(); + expect( insertBlocks ).not.toHaveBeenCalled(); // Render 3 — container now available; hook should sync exactly once. rerender( { @@ -632,12 +640,14 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).toHaveBeenCalledTimes( 1 ); - expect( insertBlock ).toHaveBeenCalledWith( - expect.objectContaining( { - name: 'core/tab', - attributes: { label: 'Tab 1' }, - } ), + expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); + expect( insertBlocks ).toHaveBeenCalledWith( + [ + expect.objectContaining( { + name: 'core/tab', + attributes: { label: 'Tab 1' }, + } ), + ], 1, PANEL, false @@ -651,7 +661,7 @@ describe( 'useTabMenuSync', () => { tabsMenuClientId: MENU, } ); - expect( insertBlock ).toHaveBeenCalledTimes( 1 ); + expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); } ); } ); } ); diff --git a/packages/block-library/src/tabs/use-tab-menu-sync.js b/packages/block-library/src/tabs/use-tab-menu-sync.js index 123dba136fd7ac..8e1d8d3ed90068 100644 --- a/packages/block-library/src/tabs/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/use-tab-menu-sync.js @@ -31,7 +31,7 @@ export default function useTabMenuSync( { } ) { const { removeBlock, - insertBlock, + insertBlocks, replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent, } = useDispatch( blockEditorStore ); @@ -170,60 +170,75 @@ export default function useTabMenuSync( { ( _, i ) => ! removedSet.has( i ) ); } else if ( tabsInserted ) { - // A tab was pasted or duplicated — insert a matching menu item at - // the same position. + // A tab was pasted or duplicated — insert matching menu items. const prevTabIds = new Set( prevTabs.map( ( t ) => t.clientId ) ); - currentTabs.forEach( ( newTab, tabIndex ) => { - if ( prevTabIds.has( newTab.clientId ) ) { - return; - } - const newMenuItemBlock = createBlock( - 'core/tabs-menu-item', - {} - ); + const newMenuItems = currentTabs + .map( ( tab, tabIndex ) => + ! prevTabIds.has( tab.clientId ) + ? { + tabIndex, + block: createBlock( 'core/tabs-menu-item', {} ), + } + : null + ) + .filter( Boolean ); + + if ( newMenuItems.length > 0 ) { __unstableMarkNextChangeAsNotPersistent(); - insertBlock( - newMenuItemBlock, - tabIndex, + insertBlocks( + newMenuItems.map( ( { block } ) => block ), + newMenuItems[ 0 ].tabIndex, tabsMenuClientId, false ); - prevSyncStateRef.current.menuItems.splice( tabIndex, 0, { - clientId: newMenuItemBlock.clientId, + newMenuItems.forEach( ( { tabIndex, block } ) => { + prevSyncStateRef.current.menuItems.splice( tabIndex, 0, { + clientId: block.clientId, + } ); } ); - } ); + } } else if ( menuItemsInserted ) { - // A menu item was pasted or duplicated — insert a matching tab at - // the same position, copying the label from the adjacent tab. + // A menu item was pasted or duplicated — insert matching tabs, + // copying the label from the adjacent tab. const prevMenuItemIds = new Set( prevMenuItems.map( ( m ) => m.clientId ) ); - currentMenuItems.forEach( ( newMenuItem, menuItemIndex ) => { - if ( prevMenuItemIds.has( newMenuItem.clientId ) ) { - return; - } - const label = - tabs[ menuItemIndex - 1 ]?.attributes?.label ?? - tabs[ menuItemIndex ]?.attributes?.label ?? - ''; - const newTabBlock = createBlock( 'core/tab', { label } ); + const newTabs = currentMenuItems + .map( ( menuItem, menuItemIndex ) => { + if ( prevMenuItemIds.has( menuItem.clientId ) ) { + return null; + } + const label = + tabs[ menuItemIndex - 1 ]?.attributes?.label ?? + tabs[ menuItemIndex ]?.attributes?.label ?? + ''; + return { + menuItemIndex, + block: createBlock( 'core/tab', { label } ), + }; + } ) + .filter( Boolean ); + + if ( newTabs.length > 0 ) { __unstableMarkNextChangeAsNotPersistent(); - insertBlock( - newTabBlock, - menuItemIndex, + insertBlocks( + newTabs.map( ( { block } ) => block ), + newTabs[ 0 ].menuItemIndex, tabPanelClientId, false ); - prevSyncStateRef.current.tabs.splice( menuItemIndex, 0, { - clientId: newTabBlock.clientId, + newTabs.forEach( ( { menuItemIndex, block } ) => { + prevSyncStateRef.current.tabs.splice( menuItemIndex, 0, { + clientId: block.clientId, + } ); } ); - } ); + } } }, [ tabs, menuItems, removeBlock, - insertBlock, + insertBlocks, replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent, tabsMenuClientId, From 036e03aff7534fcffb3c59e42c1ee6bae7729374 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Wed, 15 Apr 2026 16:42:14 +0100 Subject: [PATCH 12/14] Remove test helpers --- .../src/tabs/test/use-tab-menu-sync.js | 206 ++++++++++-------- 1 file changed, 121 insertions(+), 85 deletions(-) diff --git a/packages/block-library/src/tabs/test/use-tab-menu-sync.js b/packages/block-library/src/tabs/test/use-tab-menu-sync.js index c510aae6903af8..2e7c0c1887adc5 100644 --- a/packages/block-library/src/tabs/test/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/test/use-tab-menu-sync.js @@ -35,18 +35,6 @@ import { createBlock } from '@wordpress/blocks'; */ import useTabMenuSync from '../use-tab-menu-sync'; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeTab( clientId, label = '' ) { - return { clientId, attributes: { label } }; -} - -function makeMenuItem( clientId ) { - return { clientId }; -} - const PANEL = 'panel-client-id'; const MENU = 'menu-client-id'; @@ -104,8 +92,11 @@ afterEach( () => { describe( 'useTabMenuSync', () => { describe( 'initial mount', () => { it( 'does nothing on first render', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; renderSync( { tabs, @@ -119,8 +110,11 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when re-rendered with the same data', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const props = { tabs, menuItems, @@ -138,8 +132,11 @@ describe( 'useTabMenuSync', () => { describe( 'deletion', () => { it( 'removes the menu item at the same position when a tab is deleted', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const { rerender } = renderSync( { tabs, @@ -150,7 +147,7 @@ describe( 'useTabMenuSync', () => { // Tab at index 1 ('t2') is deleted. rerender( { - tabs: [ makeTab( 't1', 'Tab 1' ) ], + tabs: [ { clientId: 't1', attributes: { label: 'Tab 1' } } ], menuItems, tabPanelClientId: PANEL, tabsMenuClientId: MENU, @@ -162,8 +159,11 @@ describe( 'useTabMenuSync', () => { } ); it( 'removes the tab at the same position when a menu item is deleted', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const { rerender } = renderSync( { tabs, @@ -175,7 +175,7 @@ describe( 'useTabMenuSync', () => { // Menu item at index 1 ('m2') is deleted. rerender( { tabs, - menuItems: [ makeMenuItem( 'm1' ) ], + menuItems: [ { clientId: 'm1' } ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -187,14 +187,14 @@ describe( 'useTabMenuSync', () => { it( 'removes all orphaned menu items when multiple tabs are deleted at once', () => { const tabs = [ - makeTab( 't1', 'Tab 1' ), - makeTab( 't2', 'Tab 2' ), - makeTab( 't3', 'Tab 3' ), + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + { clientId: 't3', attributes: { label: 'Tab 3' } }, ]; const menuItems = [ - makeMenuItem( 'm1' ), - makeMenuItem( 'm2' ), - makeMenuItem( 'm3' ), + { clientId: 'm1' }, + { clientId: 'm2' }, + { clientId: 'm3' }, ]; const { rerender } = renderSync( { @@ -206,7 +206,7 @@ describe( 'useTabMenuSync', () => { // Tabs at index 0 and 1 ('t1', 't2') are deleted simultaneously. rerender( { - tabs: [ makeTab( 't3', 'Tab 3' ) ], + tabs: [ { clientId: 't3', attributes: { label: 'Tab 3' } } ], menuItems, tabPanelClientId: PANEL, tabsMenuClientId: MENU, @@ -220,14 +220,14 @@ describe( 'useTabMenuSync', () => { it( 'removes all orphaned tabs when multiple menu items are deleted at once', () => { const tabs = [ - makeTab( 't1', 'Tab 1' ), - makeTab( 't2', 'Tab 2' ), - makeTab( 't3', 'Tab 3' ), + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + { clientId: 't3', attributes: { label: 'Tab 3' } }, ]; const menuItems = [ - makeMenuItem( 'm1' ), - makeMenuItem( 'm2' ), - makeMenuItem( 'm3' ), + { clientId: 'm1' }, + { clientId: 'm2' }, + { clientId: 'm3' }, ]; const { rerender } = renderSync( { @@ -240,7 +240,7 @@ describe( 'useTabMenuSync', () => { // Menu items at index 0 and 1 ('m1', 'm2') are deleted simultaneously. rerender( { tabs, - menuItems: [ makeMenuItem( 'm3' ) ], + menuItems: [ { clientId: 'm3' } ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -252,8 +252,11 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when both sides shrink simultaneously (toolbar removal)', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const { rerender } = renderSync( { tabs, @@ -264,8 +267,8 @@ describe( 'useTabMenuSync', () => { // Both lists shrink together — already in sync. rerender( { - tabs: [ makeTab( 't1', 'Tab 1' ) ], - menuItems: [ makeMenuItem( 'm1' ) ], + tabs: [ { clientId: 't1', attributes: { label: 'Tab 1' } } ], + menuItems: [ { clientId: 'm1' } ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -275,8 +278,11 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when both sides grow by different amounts', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const { rerender } = renderSync( { tabs, @@ -290,10 +296,10 @@ describe( 'useTabMenuSync', () => { rerender( { tabs: [ ...tabs, - makeTab( 't3', 'Tab 3' ), - makeTab( 't4', 'Tab 4' ), + { clientId: 't3', attributes: { label: 'Tab 3' } }, + { clientId: 't4', attributes: { label: 'Tab 4' } }, ], - menuItems: [ ...menuItems, makeMenuItem( 'm3' ) ], + menuItems: [ ...menuItems, { clientId: 'm3' } ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -305,14 +311,14 @@ describe( 'useTabMenuSync', () => { describe( 'reordering', () => { it( 'reorders tabs to match when menu items are dragged to a new position', () => { - const t1 = makeTab( 't1', 'Tab 1' ); - const t2 = makeTab( 't2', 'Tab 2' ); - const t3 = makeTab( 't3', 'Tab 3' ); + const t1 = { clientId: 't1', attributes: { label: 'Tab 1' } }; + const t2 = { clientId: 't2', attributes: { label: 'Tab 2' } }; + const t3 = { clientId: 't3', attributes: { label: 'Tab 3' } }; const tabs = [ t1, t2, t3 ]; const menuItems = [ - makeMenuItem( 'm1' ), - makeMenuItem( 'm2' ), - makeMenuItem( 'm3' ), + { clientId: 'm1' }, + { clientId: 'm2' }, + { clientId: 'm3' }, ]; const { rerender } = renderSync( { @@ -326,9 +332,9 @@ describe( 'useTabMenuSync', () => { rerender( { tabs, menuItems: [ - makeMenuItem( 'm2' ), - makeMenuItem( 'm3' ), - makeMenuItem( 'm1' ), + { clientId: 'm2' }, + { clientId: 'm3' }, + { clientId: 'm1' }, ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, @@ -345,8 +351,11 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when menu items are in the same order', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const props = { tabs, menuItems, @@ -361,8 +370,11 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when tab-panel clientId is missing during reorder', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const { rerender } = renderSync( { tabs, @@ -373,7 +385,7 @@ describe( 'useTabMenuSync', () => { rerender( { tabs, - menuItems: [ makeMenuItem( 'm2' ), makeMenuItem( 'm1' ) ], + menuItems: [ { clientId: 'm2' }, { clientId: 'm1' } ], tabPanelClientId: null, tabsMenuClientId: MENU, } ); @@ -384,8 +396,11 @@ describe( 'useTabMenuSync', () => { describe( 'tab inserted', () => { it( 'inserts a menu item when a tab is pasted or duplicated', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const { rerender } = renderSync( { tabs, @@ -396,7 +411,10 @@ describe( 'useTabMenuSync', () => { // New tab 't3' inserted at the end. rerender( { - tabs: [ ...tabs, makeTab( 't3', 'Tab 3' ) ], + tabs: [ + ...tabs, + { clientId: 't3', attributes: { label: 'Tab 3' } }, + ], menuItems, tabPanelClientId: PANEL, tabsMenuClientId: MENU, @@ -417,7 +435,7 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when tabs-menu clientId is missing', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ) ]; + const tabs = [ { clientId: 't1', attributes: { label: 'Tab 1' } } ]; const menuItems = []; const { rerender } = renderSync( { @@ -428,7 +446,10 @@ describe( 'useTabMenuSync', () => { } ); rerender( { - tabs: [ ...tabs, makeTab( 't2', 'Tab 2' ) ], + tabs: [ + ...tabs, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ], menuItems, tabPanelClientId: PANEL, tabsMenuClientId: null, @@ -438,9 +459,12 @@ describe( 'useTabMenuSync', () => { } ); it( 'syncs once (no duplicates) when tabs-menu clientId becomes available after a one-sided insertion', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ) ]; - const menuItems = [ makeMenuItem( 'm1' ) ]; - const tabsWithNew = [ ...tabs, makeTab( 't2', 'Tab 2' ) ]; + const tabs = [ { clientId: 't1', attributes: { label: 'Tab 1' } } ]; + const menuItems = [ { clientId: 'm1' } ]; + const tabsWithNew = [ + ...tabs, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; // Render 1 — initial snapshot. const { rerender } = renderSync( { @@ -493,8 +517,11 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when both sides grow simultaneously (Add Tab toolbar)', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const { rerender } = renderSync( { tabs, @@ -505,8 +532,11 @@ describe( 'useTabMenuSync', () => { // Both grow together — "Add Tab" already created both. rerender( { - tabs: [ ...tabs, makeTab( 't3', 'Tab 3' ) ], - menuItems: [ ...menuItems, makeMenuItem( 'm3' ) ], + tabs: [ + ...tabs, + { clientId: 't3', attributes: { label: 'Tab 3' } }, + ], + menuItems: [ ...menuItems, { clientId: 'm3' } ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -518,8 +548,11 @@ describe( 'useTabMenuSync', () => { describe( 'menu item inserted', () => { it( 'inserts a tab when a menu item is pasted or duplicated', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const { rerender } = renderSync( { tabs, @@ -531,7 +564,7 @@ describe( 'useTabMenuSync', () => { // New menu item 'm3' appended at the end. rerender( { tabs, - menuItems: [ ...menuItems, makeMenuItem( 'm3' ) ], + menuItems: [ ...menuItems, { clientId: 'm3' } ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, } ); @@ -551,8 +584,11 @@ describe( 'useTabMenuSync', () => { } ); it( 'copies the label from the adjacent tab when a menu item is duplicated in the middle', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ), makeTab( 't2', 'Tab 2' ) ]; - const menuItems = [ makeMenuItem( 'm1' ), makeMenuItem( 'm2' ) ]; + const tabs = [ + { clientId: 't1', attributes: { label: 'Tab 1' } }, + { clientId: 't2', attributes: { label: 'Tab 2' } }, + ]; + const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; const { rerender } = renderSync( { tabs, @@ -565,9 +601,9 @@ describe( 'useTabMenuSync', () => { rerender( { tabs, menuItems: [ - makeMenuItem( 'm1' ), - makeMenuItem( 'm1-dup' ), - makeMenuItem( 'm2' ), + { clientId: 'm1' }, + { clientId: 'm1-dup' }, + { clientId: 'm2' }, ], tabPanelClientId: PANEL, tabsMenuClientId: MENU, @@ -589,8 +625,8 @@ describe( 'useTabMenuSync', () => { } ); it( 'does nothing when tab-panel clientId is missing', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ) ]; - const menuItems = [ makeMenuItem( 'm1' ) ]; + const tabs = [ { clientId: 't1', attributes: { label: 'Tab 1' } } ]; + const menuItems = [ { clientId: 'm1' } ]; const { rerender } = renderSync( { tabs, @@ -601,7 +637,7 @@ describe( 'useTabMenuSync', () => { rerender( { tabs, - menuItems: [ ...menuItems, makeMenuItem( 'm2' ) ], + menuItems: [ ...menuItems, { clientId: 'm2' } ], tabPanelClientId: null, tabsMenuClientId: MENU, } ); @@ -610,9 +646,9 @@ describe( 'useTabMenuSync', () => { } ); it( 'syncs once (no duplicates) when tab-panel clientId becomes available after a one-sided insertion', () => { - const tabs = [ makeTab( 't1', 'Tab 1' ) ]; - const menuItems = [ makeMenuItem( 'm1' ) ]; - const menuItemsWithNew = [ ...menuItems, makeMenuItem( 'm2' ) ]; + const tabs = [ { clientId: 't1', attributes: { label: 'Tab 1' } } ]; + const menuItems = [ { clientId: 'm1' } ]; + const menuItemsWithNew = [ ...menuItems, { clientId: 'm2' } ]; // Render 1 — initial snapshot. const { rerender } = renderSync( { From 00789522e5596626a5fd5c3abd61cf920185fb2b Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Thu, 16 Apr 2026 14:41:23 +0100 Subject: [PATCH 13/14] Replace map with spreads --- .../src/tabs/use-tab-menu-sync.js | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/block-library/src/tabs/use-tab-menu-sync.js b/packages/block-library/src/tabs/use-tab-menu-sync.js index 8e1d8d3ed90068..9473a2b9277a0f 100644 --- a/packages/block-library/src/tabs/use-tab-menu-sync.js +++ b/packages/block-library/src/tabs/use-tab-menu-sync.js @@ -38,17 +38,10 @@ export default function useTabMenuSync( { const prevSyncStateRef = useRef( null ); useEffect( () => { - const currentTabs = tabs.map( ( tab ) => ( { - clientId: tab.clientId, - } ) ); - const currentMenuItems = menuItems.map( ( m ) => ( { - clientId: m.clientId, - } ) ); - if ( prevSyncStateRef.current === null ) { prevSyncStateRef.current = { - tabs: currentTabs, - menuItems: currentMenuItems, + tabs: [ ...tabs ], + menuItems: [ ...menuItems ], }; return; } @@ -56,9 +49,8 @@ export default function useTabMenuSync( { const { tabs: prevTabs, menuItems: prevMenuItems } = prevSyncStateRef.current; - const tabCountChange = currentTabs.length - prevTabs.length; - const menuItemCountChange = - currentMenuItems.length - prevMenuItems.length; + const tabCountChange = tabs.length - prevTabs.length; + const menuItemCountChange = menuItems.length - prevMenuItems.length; const tabsInserted = tabCountChange > 0; const menuItemsInserted = menuItemCountChange > 0; @@ -72,11 +64,11 @@ export default function useTabMenuSync( { if ( tabCountChange === 0 && tabPanelClientId && - currentMenuItems.some( + menuItems.some( ( m, i ) => m.clientId !== prevMenuItems[ i ]?.clientId ) ) { - const reorderedTabs = currentMenuItems + const reorderedTabs = menuItems .map( ( menuItem ) => { const oldIndex = prevMenuItems.findIndex( ( pm ) => pm.clientId === menuItem.clientId @@ -94,8 +86,8 @@ export default function useTabMenuSync( { } } prevSyncStateRef.current = { - tabs: currentTabs, - menuItems: currentMenuItems, + tabs: [ ...tabs ], + menuItems: [ ...menuItems ], }; return; } @@ -107,8 +99,8 @@ export default function useTabMenuSync( { ( tabCountChange < 0 && menuItemCountChange < 0 ) ) { prevSyncStateRef.current = { - tabs: currentTabs, - menuItems: currentMenuItems, + tabs: [ ...tabs ], + menuItems: [ ...menuItems ], }; return; } @@ -124,13 +116,13 @@ export default function useTabMenuSync( { // Update snapshot to the current state. prevSyncStateRef.current = { - tabs: currentTabs, - menuItems: currentMenuItems, + tabs: [ ...tabs ], + menuItems: [ ...menuItems ], }; - const currentTabIds = new Set( currentTabs.map( ( t ) => t.clientId ) ); + const currentTabIds = new Set( tabs.map( ( t ) => t.clientId ) ); const currentMenuItemIds = new Set( - currentMenuItems.map( ( m ) => m.clientId ) + menuItems.map( ( m ) => m.clientId ) ); if ( tabCountChange < 0 ) { @@ -172,7 +164,7 @@ export default function useTabMenuSync( { } else if ( tabsInserted ) { // A tab was pasted or duplicated — insert matching menu items. const prevTabIds = new Set( prevTabs.map( ( t ) => t.clientId ) ); - const newMenuItems = currentTabs + const newMenuItems = tabs .map( ( tab, tabIndex ) => ! prevTabIds.has( tab.clientId ) ? { @@ -203,7 +195,7 @@ export default function useTabMenuSync( { const prevMenuItemIds = new Set( prevMenuItems.map( ( m ) => m.clientId ) ); - const newTabs = currentMenuItems + const newTabs = menuItems .map( ( menuItem, menuItemIndex ) => { if ( prevMenuItemIds.has( menuItem.clientId ) ) { return null; From f450557d264e216612d5c3880be91a46ee50b6a0 Mon Sep 17 00:00:00 2001 From: Sarah Norris Date: Thu, 16 Apr 2026 14:42:20 +0100 Subject: [PATCH 14/14] Remove unit tests for useTabMenuSync --- .../src/tabs/test/use-tab-menu-sync.js | 703 ------------------ 1 file changed, 703 deletions(-) delete mode 100644 packages/block-library/src/tabs/test/use-tab-menu-sync.js diff --git a/packages/block-library/src/tabs/test/use-tab-menu-sync.js b/packages/block-library/src/tabs/test/use-tab-menu-sync.js deleted file mode 100644 index 2e7c0c1887adc5..00000000000000 --- a/packages/block-library/src/tabs/test/use-tab-menu-sync.js +++ /dev/null @@ -1,703 +0,0 @@ -/** - * @jest-environment jsdom - */ - -/** - * External dependencies - */ -import { renderHook } from '@testing-library/react'; - -jest.mock( '@wordpress/block-editor', () => ( { - store: { name: 'core/block-editor' }, -} ) ); - -jest.mock( '@wordpress/blocks', () => ( { - createBlock: jest.fn( ( name, attributes ) => ( { - name, - attributes, - clientId: `generated:${ name }`, - innerBlocks: [], - } ) ), -} ) ); - -jest.mock( '@wordpress/data', () => ( { - useDispatch: jest.fn(), -} ) ); - -/** - * WordPress dependencies - */ -import { useDispatch } from '@wordpress/data'; -import { createBlock } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import useTabMenuSync from '../use-tab-menu-sync'; - -const PANEL = 'panel-client-id'; -const MENU = 'menu-client-id'; - -function renderSync( initialProps ) { - return renderHook( - ( { tabs, menuItems, tabPanelClientId, tabsMenuClientId } ) => - useTabMenuSync( { - tabs, - menuItems, - tabPanelClientId, - tabsMenuClientId, - } ), - { initialProps } - ); -} - -// --------------------------------------------------------------------------- -// Setup -// --------------------------------------------------------------------------- - -let removeBlock; -let insertBlocks; -let replaceInnerBlocks; -let __unstableMarkNextChangeAsNotPersistent; - -beforeEach( () => { - removeBlock = jest.fn(); - insertBlocks = jest.fn(); - replaceInnerBlocks = jest.fn(); - __unstableMarkNextChangeAsNotPersistent = jest.fn(); - - useDispatch.mockReturnValue( { - removeBlock, - insertBlocks, - replaceInnerBlocks, - __unstableMarkNextChangeAsNotPersistent, - } ); - - createBlock.mockImplementation( ( name, attributes ) => ( { - name, - attributes, - clientId: `generated:${ name }`, - innerBlocks: [], - } ) ); -} ); - -afterEach( () => { - jest.clearAllMocks(); -} ); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe( 'useTabMenuSync', () => { - describe( 'initial mount', () => { - it( 'does nothing on first render', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( removeBlock ).not.toHaveBeenCalled(); - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'does nothing when re-rendered with the same data', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - const props = { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - }; - - const { rerender } = renderSync( props ); - rerender( props ); - - expect( removeBlock ).not.toHaveBeenCalled(); - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - } ); - - describe( 'deletion', () => { - it( 'removes the menu item at the same position when a tab is deleted', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Tab at index 1 ('t2') is deleted. - rerender( { - tabs: [ { clientId: 't1', attributes: { label: 'Tab 1' } } ], - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( removeBlock ).toHaveBeenCalledTimes( 1 ); - expect( removeBlock ).toHaveBeenCalledWith( 'm2', false ); - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'removes the tab at the same position when a menu item is deleted', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Menu item at index 1 ('m2') is deleted. - rerender( { - tabs, - menuItems: [ { clientId: 'm1' } ], - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( removeBlock ).toHaveBeenCalledTimes( 1 ); - expect( removeBlock ).toHaveBeenCalledWith( 't2', false ); - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'removes all orphaned menu items when multiple tabs are deleted at once', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - { clientId: 't3', attributes: { label: 'Tab 3' } }, - ]; - const menuItems = [ - { clientId: 'm1' }, - { clientId: 'm2' }, - { clientId: 'm3' }, - ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Tabs at index 0 and 1 ('t1', 't2') are deleted simultaneously. - rerender( { - tabs: [ { clientId: 't3', attributes: { label: 'Tab 3' } } ], - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( removeBlock ).toHaveBeenCalledTimes( 2 ); - expect( removeBlock ).toHaveBeenCalledWith( 'm1', false ); - expect( removeBlock ).toHaveBeenCalledWith( 'm2', false ); - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'removes all orphaned tabs when multiple menu items are deleted at once', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - { clientId: 't3', attributes: { label: 'Tab 3' } }, - ]; - const menuItems = [ - { clientId: 'm1' }, - { clientId: 'm2' }, - { clientId: 'm3' }, - ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Menu items at index 0 and 1 ('m1', 'm2') are deleted simultaneously. - rerender( { - tabs, - menuItems: [ { clientId: 'm3' } ], - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( removeBlock ).toHaveBeenCalledTimes( 2 ); - expect( removeBlock ).toHaveBeenCalledWith( 't1', false ); - expect( removeBlock ).toHaveBeenCalledWith( 't2', false ); - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'does nothing when both sides shrink simultaneously (toolbar removal)', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Both lists shrink together — already in sync. - rerender( { - tabs: [ { clientId: 't1', attributes: { label: 'Tab 1' } } ], - menuItems: [ { clientId: 'm1' } ], - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( removeBlock ).not.toHaveBeenCalled(); - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'does nothing when both sides grow by different amounts', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Tabs grew by 2, menu items grew by 1. - // Bail out. - rerender( { - tabs: [ - ...tabs, - { clientId: 't3', attributes: { label: 'Tab 3' } }, - { clientId: 't4', attributes: { label: 'Tab 4' } }, - ], - menuItems: [ ...menuItems, { clientId: 'm3' } ], - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).not.toHaveBeenCalled(); - expect( removeBlock ).not.toHaveBeenCalled(); - } ); - } ); - - describe( 'reordering', () => { - it( 'reorders tabs to match when menu items are dragged to a new position', () => { - const t1 = { clientId: 't1', attributes: { label: 'Tab 1' } }; - const t2 = { clientId: 't2', attributes: { label: 'Tab 2' } }; - const t3 = { clientId: 't3', attributes: { label: 'Tab 3' } }; - const tabs = [ t1, t2, t3 ]; - const menuItems = [ - { clientId: 'm1' }, - { clientId: 'm2' }, - { clientId: 'm3' }, - ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // User drags m1 to the end: new order is m2, m3, m1. - rerender( { - tabs, - menuItems: [ - { clientId: 'm2' }, - { clientId: 'm3' }, - { clientId: 'm1' }, - ], - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( replaceInnerBlocks ).toHaveBeenCalledTimes( 1 ); - expect( replaceInnerBlocks ).toHaveBeenCalledWith( - PANEL, - [ t2, t3, t1 ], // tabs reordered to match menu items - false - ); - expect( removeBlock ).not.toHaveBeenCalled(); - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'does nothing when menu items are in the same order', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - const props = { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - }; - - const { rerender } = renderSync( props ); - rerender( props ); - - expect( replaceInnerBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'does nothing when tab-panel clientId is missing during reorder', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: null, - tabsMenuClientId: MENU, - } ); - - rerender( { - tabs, - menuItems: [ { clientId: 'm2' }, { clientId: 'm1' } ], - tabPanelClientId: null, - tabsMenuClientId: MENU, - } ); - - expect( replaceInnerBlocks ).not.toHaveBeenCalled(); - } ); - } ); - - describe( 'tab inserted', () => { - it( 'inserts a menu item when a tab is pasted or duplicated', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // New tab 't3' inserted at the end. - rerender( { - tabs: [ - ...tabs, - { clientId: 't3', attributes: { label: 'Tab 3' } }, - ], - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); - expect( insertBlocks ).toHaveBeenCalledWith( - [ - expect.objectContaining( { - name: 'core/tabs-menu-item', - attributes: {}, - } ), - ], - 2, // index of the new tab - MENU, - false - ); - } ); - - it( 'does nothing when tabs-menu clientId is missing', () => { - const tabs = [ { clientId: 't1', attributes: { label: 'Tab 1' } } ]; - const menuItems = []; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: null, - } ); - - rerender( { - tabs: [ - ...tabs, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ], - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: null, - } ); - - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'syncs once (no duplicates) when tabs-menu clientId becomes available after a one-sided insertion', () => { - const tabs = [ { clientId: 't1', attributes: { label: 'Tab 1' } } ]; - const menuItems = [ { clientId: 'm1' } ]; - const tabsWithNew = [ - ...tabs, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - - // Render 1 — initial snapshot. - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: null, - } ); - - // Render 2 — tab inserted but container unavailable; snapshot must NOT advance. - rerender( { - tabs: tabsWithNew, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: null, - } ); - - expect( insertBlocks ).not.toHaveBeenCalled(); - - // Render 3 — container now available; hook should sync exactly once. - rerender( { - tabs: tabsWithNew, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); - expect( insertBlocks ).toHaveBeenCalledWith( - [ - expect.objectContaining( { - name: 'core/tabs-menu-item', - attributes: {}, - } ), - ], - 1, - MENU, - false - ); - - // Render 4 — same props; no further insertions. - rerender( { - tabs: tabsWithNew, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'does nothing when both sides grow simultaneously (Add Tab toolbar)', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Both grow together — "Add Tab" already created both. - rerender( { - tabs: [ - ...tabs, - { clientId: 't3', attributes: { label: 'Tab 3' } }, - ], - menuItems: [ ...menuItems, { clientId: 'm3' } ], - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).not.toHaveBeenCalled(); - expect( removeBlock ).not.toHaveBeenCalled(); - } ); - } ); - - describe( 'menu item inserted', () => { - it( 'inserts a tab when a menu item is pasted or duplicated', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // New menu item 'm3' appended at the end. - rerender( { - tabs, - menuItems: [ ...menuItems, { clientId: 'm3' } ], - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); - expect( insertBlocks ).toHaveBeenCalledWith( - [ - expect.objectContaining( { - name: 'core/tab', - attributes: { label: 'Tab 2' }, // label copied from adjacent tab at index 1 - } ), - ], - 2, // index of the new menu item - PANEL, - false - ); - } ); - - it( 'copies the label from the adjacent tab when a menu item is duplicated in the middle', () => { - const tabs = [ - { clientId: 't1', attributes: { label: 'Tab 1' } }, - { clientId: 't2', attributes: { label: 'Tab 2' } }, - ]; - const menuItems = [ { clientId: 'm1' }, { clientId: 'm2' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // 'm1-dup' is a duplicate of m1, inserted at index 1. - rerender( { - tabs, - menuItems: [ - { clientId: 'm1' }, - { clientId: 'm1-dup' }, - { clientId: 'm2' }, - ], - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - // Tab inserted at index 1 should copy label from t1 (index 0). - expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); - expect( insertBlocks ).toHaveBeenCalledWith( - [ - expect.objectContaining( { - name: 'core/tab', - attributes: { label: 'Tab 1' }, - } ), - ], - 1, - PANEL, - false - ); - } ); - - it( 'does nothing when tab-panel clientId is missing', () => { - const tabs = [ { clientId: 't1', attributes: { label: 'Tab 1' } } ]; - const menuItems = [ { clientId: 'm1' } ]; - - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: null, - tabsMenuClientId: MENU, - } ); - - rerender( { - tabs, - menuItems: [ ...menuItems, { clientId: 'm2' } ], - tabPanelClientId: null, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).not.toHaveBeenCalled(); - } ); - - it( 'syncs once (no duplicates) when tab-panel clientId becomes available after a one-sided insertion', () => { - const tabs = [ { clientId: 't1', attributes: { label: 'Tab 1' } } ]; - const menuItems = [ { clientId: 'm1' } ]; - const menuItemsWithNew = [ ...menuItems, { clientId: 'm2' } ]; - - // Render 1 — initial snapshot. - const { rerender } = renderSync( { - tabs, - menuItems, - tabPanelClientId: null, - tabsMenuClientId: MENU, - } ); - - // Render 2 — menu item inserted but container unavailable; snapshot must NOT advance. - rerender( { - tabs, - menuItems: menuItemsWithNew, - tabPanelClientId: null, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).not.toHaveBeenCalled(); - - // Render 3 — container now available; hook should sync exactly once. - rerender( { - tabs, - menuItems: menuItemsWithNew, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); - expect( insertBlocks ).toHaveBeenCalledWith( - [ - expect.objectContaining( { - name: 'core/tab', - attributes: { label: 'Tab 1' }, - } ), - ], - 1, - PANEL, - false - ); - - // Render 4 — same props; no further insertions. - rerender( { - tabs, - menuItems: menuItemsWithNew, - tabPanelClientId: PANEL, - tabsMenuClientId: MENU, - } ); - - expect( insertBlocks ).toHaveBeenCalledTimes( 1 ); - } ); - } ); -} );