From b8dabf6a364d0b6a2624ecf03d9ae184c890d642 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 8 Jul 2026 13:05:34 +0900 Subject: [PATCH 1/8] Tabs: Add editor e2e tests and fix active tab switching from a stale selection Select the tab list synchronously in the tab-list block when switching the active tab, so a block still selected in the previously active panel is deselected in the same update and its sync effect can't revert the active tab. Add editor e2e tests covering keyboard navigation between tabs, switching the active tab while an inner block is selected, and adding/removing tabs. Grouped under an "Editor functionality" describe to leave room for future frontend tests. Co-Authored-By: Claude --- packages/block-library/src/tab-list/edit.js | 11 +- test/e2e/specs/editor/blocks/tabs.spec.js | 183 ++++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 test/e2e/specs/editor/blocks/tabs.spec.js diff --git a/packages/block-library/src/tab-list/edit.js b/packages/block-library/src/tab-list/edit.js index 30b93e919a9466..9947258a301d9b 100644 --- a/packages/block-library/src/tab-list/edit.js +++ b/packages/block-library/src/tab-list/edit.js @@ -58,8 +58,11 @@ function Edit( { ); const { isBlockSelected, hasSelectedInnerBlock } = useSelect( blockEditorStore ); - const { updateBlockAttributes, __unstableMarkNextChangeAsNotPersistent } = - useDispatch( blockEditorStore ); + const { + updateBlockAttributes, + selectBlock, + __unstableMarkNextChangeAsNotPersistent, + } = useDispatch( blockEditorStore ); const { insertTab, removeTab } = useTabActions( tabsClientId ); const effectiveActiveIndex = editorActiveTabIndex ?? activeTabIndex; @@ -74,6 +77,10 @@ function Edit( { function selectTabPanel( tabIndex ) { if ( tabsClientId && tabIndex !== effectiveActiveIndex ) { + // Select the tab list synchronously to deselect any block in the + // previously active panel, so its sync effect doesn't revert + // editorActiveTabIndex from a stale inner-block selection. + selectBlock( clientId ); __unstableMarkNextChangeAsNotPersistent(); updateBlockAttributes( tabsClientId, { editorActiveTabIndex: tabIndex, diff --git a/test/e2e/specs/editor/blocks/tabs.spec.js b/test/e2e/specs/editor/blocks/tabs.spec.js new file mode 100644 index 00000000000000..0d7dccbb792300 --- /dev/null +++ b/test/e2e/specs/editor/blocks/tabs.spec.js @@ -0,0 +1,183 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Tabs', () => { + test.describe( 'Editor functionality', () => { + test.beforeEach( async ( { admin, editor } ) => { + await admin.createNewPost(); + await editor.insertBlock( { + name: 'core/tabs', + innerBlocks: [ + { name: 'core/tab-list' }, + { + name: 'core/tab-panels', + innerBlocks: [ + { + name: 'core/tab-panel', + attributes: { label: 'Tab 1' }, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'Panel 1' }, + }, + ], + }, + { + name: 'core/tab-panel', + attributes: { label: 'Tab 2' }, + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'Panel 2' }, + }, + ], + }, + ], + }, + ], + } ); + } ); + + test( 'activates the next tab when the caret moves into its label with the right arrow key', async ( { + editor, + page, + } ) => { + const tab1 = editor.canvas.getByRole( 'tab', { name: 'Tab 1' } ); + const tab2 = editor.canvas.getByRole( 'tab', { name: 'Tab 2' } ); + + await tab1.click(); + await expect( tab1 ).toHaveAttribute( 'aria-selected', 'true' ); + await expect( tab2 ).toHaveAttribute( 'aria-selected', 'false' ); + await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeVisible(); + await expect( editor.canvas.getByText( 'Panel 2' ) ).toBeHidden(); + + await page.keyboard.press( 'End' ); + await page.keyboard.press( 'ArrowRight' ); + + await expect( tab1 ).toHaveAttribute( 'aria-selected', 'false' ); + await expect( tab2 ).toHaveAttribute( 'aria-selected', 'true' ); + await expect( + tab2.locator( '[contenteditable="true"]' ) + ).toBeFocused(); + await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeHidden(); + await expect( editor.canvas.getByText( 'Panel 2' ) ).toBeVisible(); + } ); + + test( 'activates the previous tab when the caret moves into its label with the left arrow key', async ( { + editor, + page, + } ) => { + const tab1 = editor.canvas.getByRole( 'tab', { name: 'Tab 1' } ); + const tab2 = editor.canvas.getByRole( 'tab', { name: 'Tab 2' } ); + + await tab2.click(); + await expect( tab1 ).toHaveAttribute( 'aria-selected', 'false' ); + await expect( tab2 ).toHaveAttribute( 'aria-selected', 'true' ); + await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeHidden(); + await expect( editor.canvas.getByText( 'Panel 2' ) ).toBeVisible(); + + await page.keyboard.press( 'Home' ); + await page.keyboard.press( 'ArrowLeft' ); + + await expect( tab1 ).toHaveAttribute( 'aria-selected', 'true' ); + await expect( tab2 ).toHaveAttribute( 'aria-selected', 'false' ); + await expect( + tab1.locator( '[contenteditable="true"]' ) + ).toBeFocused(); + await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeVisible(); + await expect( editor.canvas.getByText( 'Panel 2' ) ).toBeHidden(); + } ); + + test( 'switches the active tab and selects the tab list when a tab is clicked while a block in another panel is selected', async ( { + editor, + } ) => { + const tab1 = editor.canvas.getByRole( 'tab', { name: 'Tab 1' } ); + const tab2 = editor.canvas.getByRole( 'tab', { name: 'Tab 2' } ); + + // Select the paragraph inside the first (active) panel. + await editor.canvas.getByText( 'Panel 1' ).click(); + await expect( tab1 ).toHaveAttribute( 'aria-selected', 'true' ); + await expect( tab2 ).toHaveAttribute( 'aria-selected', 'false' ); + + await tab2.click(); + + await expect( tab1 ).toHaveAttribute( 'aria-selected', 'false' ); + await expect( tab2 ).toHaveAttribute( 'aria-selected', 'true' ); + await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeHidden(); + await expect( editor.canvas.getByText( 'Panel 2' ) ).toBeVisible(); + + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Tab List', + } ) + ).toHaveClass( /is-selected/ ); + } ); + + test( 'adds and activates a new tab when pressing Enter at the end of a tab label', async ( { + editor, + page, + } ) => { + const tab2 = editor.canvas.getByRole( 'tab', { name: 'Tab 2' } ); + await tab2.click(); + await expect( tab2 ).toHaveAttribute( 'aria-selected', 'true' ); + + await page.keyboard.press( 'End' ); + await page.keyboard.press( 'Enter' ); + + const tabs = editor.canvas.getByRole( 'tab' ); + await expect( tabs ).toHaveCount( 3 ); + + await expect( tabs.nth( 0 ) ).toHaveAttribute( + 'aria-selected', + 'false' + ); + await expect( tabs.nth( 1 ) ).toHaveAttribute( + 'aria-selected', + 'false' + ); + + const newTab = tabs.nth( 2 ); + await expect( newTab ).toHaveAttribute( 'aria-selected', 'true' ); + await expect( + newTab.locator( '[contenteditable="true"]' ) + ).toBeFocused(); + + // The new tab's panel is the active one and is visible. + const panels = editor.canvas.getByRole( 'document', { + name: 'Block: Tab Panel', + } ); + await expect( panels ).toHaveCount( 3 ); + await expect( panels.nth( 2 ) ).toBeVisible(); + } ); + + test( 'removes the tab and activates the previous one when pressing Delete on an empty tab label', async ( { + editor, + page, + pageUtils, + } ) => { + const tab2 = editor.canvas.getByRole( 'tab', { name: 'Tab 2' } ); + await tab2.click(); + await expect( tab2 ).toHaveAttribute( 'aria-selected', 'true' ); + + // Empty the tab label, then delete again to remove the tab. + await pageUtils.pressKeys( 'primary+a' ); + await page.keyboard.press( 'Delete' ); + await page.keyboard.press( 'Delete' ); + + const tabs = editor.canvas.getByRole( 'tab' ); + await expect( tabs ).toHaveCount( 1 ); + + const tab1 = editor.canvas.getByRole( 'tab', { name: 'Tab 1' } ); + await expect( tab1 ).toHaveAttribute( 'aria-selected', 'true' ); + await expect( + tab1.locator( '[contenteditable="true"]' ) + ).toBeFocused(); + await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeVisible(); + } ); + } ); + + // TODO: Add a `Frontend functionality` describe block for front-end + // interaction tests (e.g. switching tabs on the published post). +} ); From 3497467e9d34cfc8a9e9e4cda21481921268be86 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 8 Jul 2026 18:48:06 +0900 Subject: [PATCH 2/8] Tabs: Enable block experiments flag in editor e2e tests The Tabs blocks are experimental and are only registered in the editor when the `gutenberg-block-experiments` flag is enabled. Without it, the inserted blocks fall back to the unregistered type and no tab buttons render, so every test times out clicking a tab. Enable the flag in `beforeAll` and reset it in `afterAll`. Co-Authored-By: Claude --- test/e2e/specs/editor/blocks/tabs.spec.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/e2e/specs/editor/blocks/tabs.spec.js b/test/e2e/specs/editor/blocks/tabs.spec.js index 0d7dccbb792300..44a3598ddc9a5d 100644 --- a/test/e2e/specs/editor/blocks/tabs.spec.js +++ b/test/e2e/specs/editor/blocks/tabs.spec.js @@ -4,6 +4,16 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); test.describe( 'Tabs', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.setGutenbergExperiments( [ + 'gutenberg-block-experiments', + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.setGutenbergExperiments( [] ); + } ); + test.describe( 'Editor functionality', () => { test.beforeEach( async ( { admin, editor } ) => { await admin.createNewPost(); From 09fa2ce990e5ea8e5fca340211558e8f539daf6f Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 8 Jul 2026 19:03:57 +0900 Subject: [PATCH 3/8] Tabs: Batch active-tab selection and index update in tab list When switching the active tab, the tab list dispatches a block selection followed by an editorActiveTabIndex update. Dispatched separately, the previously active panel's sync effect can observe the new index while it still holds a stale inner-block selection and revert the switch. Wrap both dispatches in registry.batch so they are notified together, keeping the update atomic. The non-persistent mark is retained so the change stays out of undo history. Co-Authored-By: Claude --- packages/block-library/src/tab-list/edit.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/block-library/src/tab-list/edit.js b/packages/block-library/src/tab-list/edit.js index 9947258a301d9b..6bb3c122f559d8 100644 --- a/packages/block-library/src/tab-list/edit.js +++ b/packages/block-library/src/tab-list/edit.js @@ -15,7 +15,7 @@ import { __experimentalUseColorProps as useColorProps, __experimentalGetSpacingClassesAndStyles as getSpacingClassesAndStyles, } from '@wordpress/block-editor'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; import { useEffect, useMemo, useRef } from '@wordpress/element'; /** @@ -56,6 +56,7 @@ function Edit( { }, [ clientId ] ); + const registry = useRegistry(); const { isBlockSelected, hasSelectedInnerBlock } = useSelect( blockEditorStore ); const { @@ -77,13 +78,13 @@ function Edit( { function selectTabPanel( tabIndex ) { if ( tabsClientId && tabIndex !== effectiveActiveIndex ) { - // Select the tab list synchronously to deselect any block in the - // previously active panel, so its sync effect doesn't revert - // editorActiveTabIndex from a stale inner-block selection. - selectBlock( clientId ); - __unstableMarkNextChangeAsNotPersistent(); - updateBlockAttributes( tabsClientId, { - editorActiveTabIndex: tabIndex, + // Batch so the selection and the index update are observed together. + registry.batch( () => { + selectBlock( clientId ); + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( tabsClientId, { + editorActiveTabIndex: tabIndex, + } ); } ); } } From cfaaab1e913b99ced84d76cfe5f0d249eaaffa89 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 8 Jul 2026 19:11:25 +0900 Subject: [PATCH 4/8] Tabs: Clarify why the active-tab update is batched Expand the comment on the batched selection/index update in the tab list to explain that batching keeps the tab-panel sync effect from reverting the switch from a stale inner-block selection. Co-Authored-By: Claude --- packages/block-library/src/tab-list/edit.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/block-library/src/tab-list/edit.js b/packages/block-library/src/tab-list/edit.js index 6bb3c122f559d8..23c51fe780b564 100644 --- a/packages/block-library/src/tab-list/edit.js +++ b/packages/block-library/src/tab-list/edit.js @@ -78,7 +78,9 @@ function Edit( { function selectTabPanel( tabIndex ) { if ( tabsClientId && tabIndex !== effectiveActiveIndex ) { - // Batch so the selection and the index update are observed together. + // Batch the selection and index update so the sync effect in + // the tab-panel block can't revert the switch from a stale + // inner-block selection in the previously active panel. registry.batch( () => { selectBlock( clientId ); __unstableMarkNextChangeAsNotPersistent(); From f6a361789774ded3e3201cc721a96d3c666d3704 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 8 Jul 2026 19:53:38 +0900 Subject: [PATCH 5/8] Tabs: Assert undo behavior in editor e2e tests Add undo assertions so the tests lock in how tab interactions interact with the undo stack: - Tab switches (arrow keys and clicking) are non-persistent, so a single undo clears the inserted block rather than only reverting the switch. - Adding and removing a tab are persistent structural changes, so undo restores the previous tab count. The add/remove cases also document, via TODO, that focus should return to the affected second tab rather than the first after undo. Co-Authored-By: Claude --- test/e2e/specs/editor/blocks/tabs.spec.js | 49 +++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/e2e/specs/editor/blocks/tabs.spec.js b/test/e2e/specs/editor/blocks/tabs.spec.js index 44a3598ddc9a5d..5bd512d7299bd7 100644 --- a/test/e2e/specs/editor/blocks/tabs.spec.js +++ b/test/e2e/specs/editor/blocks/tabs.spec.js @@ -53,6 +53,7 @@ test.describe( 'Tabs', () => { test( 'activates the next tab when the caret moves into its label with the right arrow key', async ( { editor, page, + pageUtils, } ) => { const tab1 = editor.canvas.getByRole( 'tab', { name: 'Tab 1' } ); const tab2 = editor.canvas.getByRole( 'tab', { name: 'Tab 2' } ); @@ -73,11 +74,17 @@ test.describe( 'Tabs', () => { ).toBeFocused(); await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeHidden(); await expect( editor.canvas.getByText( 'Panel 2' ) ).toBeVisible(); + + // Switching the active tab is non-persistent, so undo clears the + // inserted block instead of only reverting the tab switch. + await pageUtils.pressKeys( 'primary+z' ); + await expect.poll( editor.getBlocks ).toEqual( [] ); } ); test( 'activates the previous tab when the caret moves into its label with the left arrow key', async ( { editor, page, + pageUtils, } ) => { const tab1 = editor.canvas.getByRole( 'tab', { name: 'Tab 1' } ); const tab2 = editor.canvas.getByRole( 'tab', { name: 'Tab 2' } ); @@ -98,10 +105,16 @@ test.describe( 'Tabs', () => { ).toBeFocused(); await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeVisible(); await expect( editor.canvas.getByText( 'Panel 2' ) ).toBeHidden(); + + // Switching the active tab is non-persistent, so undo clears the + // inserted block instead of only reverting the tab switch. + await pageUtils.pressKeys( 'primary+z' ); + await expect.poll( editor.getBlocks ).toEqual( [] ); } ); test( 'switches the active tab and selects the tab list when a tab is clicked while a block in another panel is selected', async ( { editor, + pageUtils, } ) => { const tab1 = editor.canvas.getByRole( 'tab', { name: 'Tab 1' } ); const tab2 = editor.canvas.getByRole( 'tab', { name: 'Tab 2' } ); @@ -123,11 +136,17 @@ test.describe( 'Tabs', () => { name: 'Block: Tab List', } ) ).toHaveClass( /is-selected/ ); + + // Switching the active tab is non-persistent, so undo clears the + // inserted block instead of only reverting the tab switch. + await pageUtils.pressKeys( 'primary+z' ); + await expect.poll( editor.getBlocks ).toEqual( [] ); } ); test( 'adds and activates a new tab when pressing Enter at the end of a tab label', async ( { editor, page, + pageUtils, } ) => { const tab2 = editor.canvas.getByRole( 'tab', { name: 'Tab 2' } ); await tab2.click(); @@ -160,6 +179,21 @@ test.describe( 'Tabs', () => { } ); await expect( panels ).toHaveCount( 3 ); await expect( panels.nth( 2 ) ).toBeVisible(); + + // The tab insertion is persistent, so undo removes the new tab + await pageUtils.pressKeys( 'primary+z' ); + await expect( tabs ).toHaveCount( 2 ); + + // TODO: This is not the intended behavior and should be fixed. + // After undo the second tab (not the first) should be the active, + // focused one. + await expect( tabs.nth( 0 ) ).toHaveAttribute( + 'aria-selected', + 'true' + ); + await expect( + tabs.nth( 0 ).locator( '[contenteditable="true"]' ) + ).toBeFocused(); } ); test( 'removes the tab and activates the previous one when pressing Delete on an empty tab label', async ( { @@ -185,6 +219,21 @@ test.describe( 'Tabs', () => { tab1.locator( '[contenteditable="true"]' ) ).toBeFocused(); await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeVisible(); + + // The tab removal is persistent, so undo brings the tab back. + await pageUtils.pressKeys( 'primary+z' ); + await expect( tabs ).toHaveCount( 2 ); + + // TODO: This is not the intended behavior and should be fixed. + // After undo the restored second tab (not the first) should be the + // active, focused one. + await expect( tabs.nth( 0 ) ).toHaveAttribute( + 'aria-selected', + 'true' + ); + await expect( + tabs.nth( 0 ).locator( '[contenteditable="true"]' ) + ).toBeFocused(); } ); } ); From 4efb5625ac1b77456f2a6abcb6c4ca5a27eae4dc Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 8 Jul 2026 20:59:18 +0900 Subject: [PATCH 6/8] Tabs: Use Backspace to remove an empty tab label in e2e test Removing an emptied tab label requires a backward deletion. Delete (forward) does not remove the empty label, so the tab removal step never triggered. Co-Authored-By: Claude --- test/e2e/specs/editor/blocks/tabs.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/specs/editor/blocks/tabs.spec.js b/test/e2e/specs/editor/blocks/tabs.spec.js index 5bd512d7299bd7..2dcf55e3ddeadf 100644 --- a/test/e2e/specs/editor/blocks/tabs.spec.js +++ b/test/e2e/specs/editor/blocks/tabs.spec.js @@ -208,7 +208,7 @@ test.describe( 'Tabs', () => { // Empty the tab label, then delete again to remove the tab. await pageUtils.pressKeys( 'primary+a' ); await page.keyboard.press( 'Delete' ); - await page.keyboard.press( 'Delete' ); + await page.keyboard.press( 'Backspace' ); const tabs = editor.canvas.getByRole( 'tab' ); await expect( tabs ).toHaveCount( 1 ); From 2849f361985cce613187154e1967b96ca0db884d Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 8 Jul 2026 22:07:54 +0900 Subject: [PATCH 7/8] Tabs: Activate the right tab after undoing a tab add or remove The editor-only active tab index is a non-persistent attribute, so it is not restored on undo/redo and focus fell back to the first tab. Derive the tab to activate from the structural change instead: activate a reappearing tab, or the tab adjacent to a removed one. Co-Authored-By: Claude --- packages/block-library/src/tab-list/edit.js | 41 ++++++++++++++++++--- test/e2e/specs/editor/blocks/tabs.spec.js | 21 +++++------ 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/block-library/src/tab-list/edit.js b/packages/block-library/src/tab-list/edit.js index 23c51fe780b564..9d1f72505e6f74 100644 --- a/packages/block-library/src/tab-list/edit.js +++ b/packages/block-library/src/tab-list/edit.js @@ -99,14 +99,16 @@ function Edit( { } const menuRef = useRef(); - const prevTabCountRef = useRef( tabsList.length ); + const prevTabIdsRef = useRef( tabsList.map( ( tab ) => tab.clientId ) ); // When tabs are added or removed, focus the appropriate button. useEffect( () => { - const prevCount = prevTabCountRef.current; - prevTabCountRef.current = tabsList.length; + const prevTabIds = prevTabIdsRef.current; + const tabIds = tabsList.map( ( tab ) => tab.clientId ); + prevTabIdsRef.current = tabIds; - if ( ! menuRef.current || tabsList.length === prevCount ) { + // Only react when a tab was actually added or removed. + if ( ! menuRef.current || tabIds.length === prevTabIds.length ) { return; } @@ -128,13 +130,40 @@ function Edit( { } ); }; - focusButtonAt( effectiveActiveIndex ); + // The editor-only active index is a non-persistent attribute, so it + // isn't restored on undo/redo. Derive the tab to activate from the + // structural change. + const addedTabIndex = tabIds.findIndex( + ( id ) => ! prevTabIds.includes( id ) + ); + let targetTabIndex; + if ( addedTabIndex !== -1 ) { + // A tab appeared (a new tab or undoing a removal): activate it. + targetTabIndex = addedTabIndex; + } else { + // A tab disappeared (undoing an insertion or a removal): activate + // the tab adjacent to the removed one. + const removedTabIndex = prevTabIds.findIndex( + ( id ) => ! tabIds.includes( id ) + ); + targetTabIndex = Math.min( removedTabIndex, tabIds.length - 1 ); + } + + if ( targetTabIndex !== effectiveActiveIndex ) { + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( tabsClientId, { + editorActiveTabIndex: targetTabIndex, + } ); + } + focusButtonAt( targetTabIndex ); }, [ effectiveActiveIndex, hasSelectedInnerBlock, isBlockSelected, tabsClientId, - tabsList.length, + tabsList, + updateBlockAttributes, + __unstableMarkNextChangeAsNotPersistent, ] ); const blockProps = useBlockProps( { diff --git a/test/e2e/specs/editor/blocks/tabs.spec.js b/test/e2e/specs/editor/blocks/tabs.spec.js index 2dcf55e3ddeadf..859ff949d6672b 100644 --- a/test/e2e/specs/editor/blocks/tabs.spec.js +++ b/test/e2e/specs/editor/blocks/tabs.spec.js @@ -180,19 +180,18 @@ test.describe( 'Tabs', () => { await expect( panels ).toHaveCount( 3 ); await expect( panels.nth( 2 ) ).toBeVisible(); - // The tab insertion is persistent, so undo removes the new tab + // The tab insertion is persistent, so undo removes the new tab. The + // tab adjacent to the removed one (the second) becomes the active, + // focused one. await pageUtils.pressKeys( 'primary+z' ); await expect( tabs ).toHaveCount( 2 ); - // TODO: This is not the intended behavior and should be fixed. - // After undo the second tab (not the first) should be the active, - // focused one. - await expect( tabs.nth( 0 ) ).toHaveAttribute( + await expect( tabs.nth( 1 ) ).toHaveAttribute( 'aria-selected', 'true' ); await expect( - tabs.nth( 0 ).locator( '[contenteditable="true"]' ) + tabs.nth( 1 ).locator( '[contenteditable="true"]' ) ).toBeFocused(); } ); @@ -220,19 +219,17 @@ test.describe( 'Tabs', () => { ).toBeFocused(); await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeVisible(); - // The tab removal is persistent, so undo brings the tab back. + // The tab removal is persistent, so undo brings the tab back. The + // restored (second) tab becomes the active, focused one. await pageUtils.pressKeys( 'primary+z' ); await expect( tabs ).toHaveCount( 2 ); - // TODO: This is not the intended behavior and should be fixed. - // After undo the restored second tab (not the first) should be the - // active, focused one. - await expect( tabs.nth( 0 ) ).toHaveAttribute( + await expect( tabs.nth( 1 ) ).toHaveAttribute( 'aria-selected', 'true' ); await expect( - tabs.nth( 0 ).locator( '[contenteditable="true"]' ) + tabs.nth( 1 ).locator( '[contenteditable="true"]' ) ).toBeFocused(); } ); } ); From 546a8e89a2414668dacf64b0d8d031bdd555ded0 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Thu, 9 Jul 2026 00:34:37 +0900 Subject: [PATCH 8/8] Tabs: Revert undo focus handling and drop related e2e assertions Manually tracking the active tab on undo/redo proved brittle, so revert 2849f361985 and remove the TODO-marked undo focus assertions in the e2e tests. The active-tab tracking is better addressed in a separate effort. Co-Authored-By: Claude --- packages/block-library/src/tab-list/edit.js | 41 +++------------------ test/e2e/specs/editor/blocks/tabs.spec.js | 23 +----------- 2 files changed, 8 insertions(+), 56 deletions(-) diff --git a/packages/block-library/src/tab-list/edit.js b/packages/block-library/src/tab-list/edit.js index 9d1f72505e6f74..23c51fe780b564 100644 --- a/packages/block-library/src/tab-list/edit.js +++ b/packages/block-library/src/tab-list/edit.js @@ -99,16 +99,14 @@ function Edit( { } const menuRef = useRef(); - const prevTabIdsRef = useRef( tabsList.map( ( tab ) => tab.clientId ) ); + const prevTabCountRef = useRef( tabsList.length ); // When tabs are added or removed, focus the appropriate button. useEffect( () => { - const prevTabIds = prevTabIdsRef.current; - const tabIds = tabsList.map( ( tab ) => tab.clientId ); - prevTabIdsRef.current = tabIds; + const prevCount = prevTabCountRef.current; + prevTabCountRef.current = tabsList.length; - // Only react when a tab was actually added or removed. - if ( ! menuRef.current || tabIds.length === prevTabIds.length ) { + if ( ! menuRef.current || tabsList.length === prevCount ) { return; } @@ -130,40 +128,13 @@ function Edit( { } ); }; - // The editor-only active index is a non-persistent attribute, so it - // isn't restored on undo/redo. Derive the tab to activate from the - // structural change. - const addedTabIndex = tabIds.findIndex( - ( id ) => ! prevTabIds.includes( id ) - ); - let targetTabIndex; - if ( addedTabIndex !== -1 ) { - // A tab appeared (a new tab or undoing a removal): activate it. - targetTabIndex = addedTabIndex; - } else { - // A tab disappeared (undoing an insertion or a removal): activate - // the tab adjacent to the removed one. - const removedTabIndex = prevTabIds.findIndex( - ( id ) => ! tabIds.includes( id ) - ); - targetTabIndex = Math.min( removedTabIndex, tabIds.length - 1 ); - } - - if ( targetTabIndex !== effectiveActiveIndex ) { - __unstableMarkNextChangeAsNotPersistent(); - updateBlockAttributes( tabsClientId, { - editorActiveTabIndex: targetTabIndex, - } ); - } - focusButtonAt( targetTabIndex ); + focusButtonAt( effectiveActiveIndex ); }, [ effectiveActiveIndex, hasSelectedInnerBlock, isBlockSelected, tabsClientId, - tabsList, - updateBlockAttributes, - __unstableMarkNextChangeAsNotPersistent, + tabsList.length, ] ); const blockProps = useBlockProps( { diff --git a/test/e2e/specs/editor/blocks/tabs.spec.js b/test/e2e/specs/editor/blocks/tabs.spec.js index 859ff949d6672b..962189b9b39baf 100644 --- a/test/e2e/specs/editor/blocks/tabs.spec.js +++ b/test/e2e/specs/editor/blocks/tabs.spec.js @@ -180,19 +180,9 @@ test.describe( 'Tabs', () => { await expect( panels ).toHaveCount( 3 ); await expect( panels.nth( 2 ) ).toBeVisible(); - // The tab insertion is persistent, so undo removes the new tab. The - // tab adjacent to the removed one (the second) becomes the active, - // focused one. + // The tab insertion is persistent, so undo removes the new tab await pageUtils.pressKeys( 'primary+z' ); await expect( tabs ).toHaveCount( 2 ); - - await expect( tabs.nth( 1 ) ).toHaveAttribute( - 'aria-selected', - 'true' - ); - await expect( - tabs.nth( 1 ).locator( '[contenteditable="true"]' ) - ).toBeFocused(); } ); test( 'removes the tab and activates the previous one when pressing Delete on an empty tab label', async ( { @@ -219,18 +209,9 @@ test.describe( 'Tabs', () => { ).toBeFocused(); await expect( editor.canvas.getByText( 'Panel 1' ) ).toBeVisible(); - // The tab removal is persistent, so undo brings the tab back. The - // restored (second) tab becomes the active, focused one. + // The tab removal is persistent, so undo brings the tab back. await pageUtils.pressKeys( 'primary+z' ); await expect( tabs ).toHaveCount( 2 ); - - await expect( tabs.nth( 1 ) ).toHaveAttribute( - 'aria-selected', - 'true' - ); - await expect( - tabs.nth( 1 ).locator( '[contenteditable="true"]' ) - ).toBeFocused(); } ); } );