+`;
+
+store( 'directive-each', {
+ actions: {
+ navigate() {
+ return navigate( window.location, {
+ force: true,
+ html,
+ } );
+ },
+ }
+} );
+
diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md
index b37fff1bbb5c4c..252b200f9d4d01 100644
--- a/packages/interactivity/CHANGELOG.md
+++ b/packages/interactivity/CHANGELOG.md
@@ -12,6 +12,7 @@
- Add the `data-wp-run` directive along with the `useInit` and `useWatch` hooks. ([#57805](https://github.com/WordPress/gutenberg/pull/57805))
- Add `wp-data-on-window` and `wp-data-on-document` directives. ([#57931](https://github.com/WordPress/gutenberg/pull/57931))
+- Add the `data-wp-each` directive to render lists of items using a template. ([57859](https://github.com/WordPress/gutenberg/pull/57859))
### Breaking Changes
diff --git a/packages/interactivity/docs/2-api-reference.md b/packages/interactivity/docs/2-api-reference.md
index 922662c10a8e2b..bae15e9a7fcf2f 100644
--- a/packages/interactivity/docs/2-api-reference.md
+++ b/packages/interactivity/docs/2-api-reference.md
@@ -26,6 +26,7 @@ DOM elements are connected to data stored in the state and context through direc
- [`wp-init`](#wp-init) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
- [`wp-run`](#wp-run) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
- [`wp-key`](#wp-key) ![](https://img.shields.io/badge/TEMPLATING-afd2e3.svg)
+ - [`wp-each`](#wp-each) ![](https://img.shields.io/badge/TEMPLATING-afd2e3.svg)
- [Values of directives are references to store properties](#values-of-directives-are-references-to-store-properties)
- [The store](#the-store)
- [Elements of the store](#elements-of-the-store)
@@ -620,6 +621,78 @@ But it can also be used on other elements:
When the list is re-rendered, the Interactivity API will match elements by their keys to determine if an item was added/removed/reordered. Elements without keys might be recreated unnecessarily.
+
+#### `wp-each`
+
+The `wp-each` directive is intended to render a list of elements. The directive can be used in `` tags, being the value a path to an array stored in the global state or the context. The content inside the `` tag is the template used to render each of the items.
+
+Each item is included in the context under the `item` name by default, so directives inside the template can access the current item.
+
+For example, let's consider the following HTML.
+
+```html
+
+
+
+
+
+```
+
+It would generate the following output:
+
+```html
+
+
hello
+
hola
+
olá
+
+```
+
+The prop that holds the item in the context can be changed by passing a suffix to the directive name. In the following example, we change the default prop `item` to `greeting`.
+
+```html
+
+
+
+
+
+```
+
+By default, it uses each element as the key for the rendered nodes, but you can also specify a path to retrieve the key if necessary, e.g., when the list contains objects.
+
+For that, you must use `data-wp-each-key` in the `` tag and not `data-wp-key` inside the template content. This is because `data-wp-each` creates
+a context provider wrapper around each rendered item, and those wrappers are the ones that need the `key` property.
+
+```html
+
+
+
+
+
+```
+
+For server-side rendered lists, another directive called `data-wp-each-child` ensures hydration works as expected. This directive is added automatically when the directive is processed on the server.
+
+```html
+
+
+
+
+
hello
+
hola
+
olá
+
+```
+
### Values of directives are references to store properties
The value assigned to a directive is a string pointing to a specific state, action, or side effect.
diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js
index 21ab2da29cf275..c1879af1fe19a6 100644
--- a/packages/interactivity/src/directives.js
+++ b/packages/interactivity/src/directives.js
@@ -9,7 +9,7 @@ import { deepSignal, peek } from 'deepsignal';
*/
import { createPortal } from './portals';
import { useWatch, useInit } from './utils';
-import { directive } from './hooks';
+import { directive, getScope, getEvaluate } from './hooks';
const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
@@ -313,4 +313,49 @@ export default () => {
directive( 'run', ( { directives: { run }, evaluate } ) => {
run.forEach( ( entry ) => evaluate( entry ) );
} );
+
+ // data-wp-each--[item]
+ directive(
+ 'each',
+ ( {
+ directives: { each, 'each-key': eachKey },
+ context: inheritedContext,
+ element,
+ evaluate,
+ } ) => {
+ if ( element.type !== 'template' ) return;
+
+ const { Provider } = inheritedContext;
+ const inheritedValue = useContext( inheritedContext );
+
+ const [ entry ] = each;
+ const { namespace, suffix } = entry;
+
+ const list = evaluate( entry );
+ return list.map( ( item ) => {
+ const mergedContext = deepSignal( {} );
+
+ const itemProp = suffix === 'default' ? 'item' : suffix;
+ const newValue = deepSignal( {
+ [ namespace ]: { [ itemProp ]: item },
+ } );
+ mergeDeepSignals( newValue, inheritedValue );
+ mergeDeepSignals( mergedContext, newValue, true );
+
+ const scope = { ...getScope(), context: mergedContext };
+ const key = eachKey
+ ? getEvaluate( { scope } )( eachKey[ 0 ] )
+ : item;
+
+ return (
+
+ { element.props.content }
+
+ );
+ } );
+ },
+ { priority: 20 }
+ );
+
+ directive( 'each-child', () => null );
};
diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx
index 4309f3f0bea7a7..c133eb9981880b 100644
--- a/packages/interactivity/src/hooks.tsx
+++ b/packages/interactivity/src/hooks.tsx
@@ -261,7 +261,7 @@ const resolve = ( path, namespace ) => {
};
// Generate the evaluate function.
-const getEvaluate: GetEvaluate =
+export const getEvaluate: GetEvaluate =
( { scope } ) =>
( entry, ...args ) => {
let { value: path, namespace } = entry;
diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js
index 860a3149e6ffd6..4a7cfff9f9d0df 100644
--- a/packages/interactivity/src/vdom.js
+++ b/packages/interactivity/src/vdom.js
@@ -43,7 +43,7 @@ export function toVdom( root ) {
);
function walk( node ) {
- const { attributes, nodeType } = node;
+ const { attributes, nodeType, localName } = node;
if ( nodeType === 3 ) return [ node.data ];
if ( nodeType === 4 ) {
@@ -93,7 +93,7 @@ export function toVdom( root ) {
if ( ignore && ! island )
return [
- h( node.localName, {
+ h( localName, {
...props,
innerHTML: node.innerHTML,
__directives: { ignore: true },
@@ -118,20 +118,26 @@ export function toVdom( root ) {
);
}
- let child = treeWalker.firstChild();
- if ( child ) {
- while ( child ) {
- const [ vnode, nextChild ] = walk( child );
- if ( vnode ) children.push( vnode );
- child = nextChild || treeWalker.nextSibling();
+ if ( localName === 'template' ) {
+ props.content = [ ...node.content.childNodes ].map( ( childNode ) =>
+ toVdom( childNode )
+ );
+ } else {
+ let child = treeWalker.firstChild();
+ if ( child ) {
+ while ( child ) {
+ const [ vnode, nextChild ] = walk( child );
+ if ( vnode ) children.push( vnode );
+ child = nextChild || treeWalker.nextSibling();
+ }
+ treeWalker.parentNode();
}
- treeWalker.parentNode();
}
// Restore previous namespace.
if ( island ) namespaces.pop();
- return [ h( node.localName, props, children ) ];
+ return [ h( localName, props, children ) ];
}
return walk( treeWalker.currentNode );
diff --git a/test/e2e/specs/interactivity/directive-each.spec.ts b/test/e2e/specs/interactivity/directive-each.spec.ts
new file mode 100644
index 00000000000000..4f024b1f828a95
--- /dev/null
+++ b/test/e2e/specs/interactivity/directive-each.spec.ts
@@ -0,0 +1,486 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'data-wp-each', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ await utils.addPostWithBlock( 'test/directive-each' );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'test/directive-each' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'should use `item` as the defaul item name in the context', async ( {
+ page,
+ } ) => {
+ const elements = page.getByTestId( 'letters' ).getByTestId( 'item' );
+ await expect( elements ).toHaveText( [ 'A', 'B', 'C' ] );
+ } );
+
+ test( 'should use the specified item name in the context', async ( {
+ page,
+ } ) => {
+ const elements = page.getByTestId( 'fruits' ).getByTestId( 'item' );
+ await expect( elements ).toHaveText( [
+ 'avocado',
+ 'banana',
+ 'cherimoya',
+ ] );
+ } );
+
+ test.describe( 'without `wp-each-key`', () => {
+ test.beforeEach( async ( { page } ) => {
+ const elements = page.getByTestId( 'fruits' ).getByTestId( 'item' );
+
+ // These tags are included to check that the elements are not unmounted
+ // and mounted again. If an element remounts, its tag should be missing.
+ await elements.evaluateAll( ( refs ) =>
+ refs.forEach( ( ref, index ) => {
+ if ( ref instanceof HTMLElement ) {
+ ref.dataset.tag = `${ index }`;
+ }
+ } )
+ );
+ } );
+
+ test( 'should preserve elements on deletion', async ( { page } ) => {
+ const elements = page.getByTestId( 'fruits' ).getByTestId( 'item' );
+
+ // An item is removed when clicked.
+ await elements.first().click();
+
+ await expect( elements ).toHaveText( [ 'banana', 'cherimoya' ] );
+ await expect( elements.getByText( 'avocado' ) ).toBeHidden();
+
+ // Get the tags. They should not have disappeared.
+ const [ banana, cherimoya ] = await elements.all();
+ await expect( banana ).toHaveAttribute( 'data-tag', '1' );
+ await expect( cherimoya ).toHaveAttribute( 'data-tag', '2' );
+ } );
+
+ test( 'should preserve elements on reordering', async ( { page } ) => {
+ const elements = page.getByTestId( 'fruits' ).getByTestId( 'item' );
+
+ await page.getByTestId( 'fruits' ).getByTestId( 'rotate' ).click();
+
+ await expect( elements ).toHaveText( [
+ 'cherimoya',
+ 'avocado',
+ 'banana',
+ ] );
+
+ // Get the tags. They should not have disappeared or changed.
+ const [ cherimoya, avocado, banana ] = await elements.all();
+ await expect( cherimoya ).toHaveAttribute( 'data-tag', '2' );
+ await expect( avocado ).toHaveAttribute( 'data-tag', '0' );
+ await expect( banana ).toHaveAttribute( 'data-tag', '1' );
+ } );
+
+ test( 'should preserve elements on addition', async ( { page } ) => {
+ const elements = page.getByTestId( 'fruits' ).getByTestId( 'item' );
+
+ await page.getByTestId( 'fruits' ).getByTestId( 'add' ).click();
+
+ await expect( elements ).toHaveText( [
+ 'ananas',
+ 'avocado',
+ 'banana',
+ 'cherimoya',
+ ] );
+
+ // Get the tags. They should not have disappeared or changed,
+ // except for the newly created element.
+ const [ ananas, avocado, banana, cherimoya ] = await elements.all();
+ await expect( ananas ).not.toHaveAttribute( 'data-tag' );
+ await expect( avocado ).toHaveAttribute( 'data-tag', '0' );
+ await expect( banana ).toHaveAttribute( 'data-tag', '1' );
+ await expect( cherimoya ).toHaveAttribute( 'data-tag', '2' );
+ } );
+
+ test( 'should preserve elements on replacement', async ( { page } ) => {
+ const elements = page.getByTestId( 'fruits' ).getByTestId( 'item' );
+
+ await page.getByTestId( 'fruits' ).getByTestId( 'replace' ).click();
+
+ await expect( elements ).toHaveText( [
+ 'ananas',
+ 'banana',
+ 'cherimoya',
+ ] );
+
+ // Get the tags. They should not have disappeared or changed,
+ // except for the newly created element.
+ const [ ananas, banana, cherimoya ] = await elements.all();
+ await expect( ananas ).not.toHaveAttribute( 'data-tag' );
+ await expect( banana ).toHaveAttribute( 'data-tag', '1' );
+ await expect( cherimoya ).toHaveAttribute( 'data-tag', '2' );
+ } );
+ } );
+
+ test.describe( 'with `wp-each-key`', () => {
+ test.beforeEach( async ( { page } ) => {
+ const elements = page.getByTestId( 'books' ).getByTestId( 'item' );
+
+ // These tags are included to check that the elements are not unmounted
+ // and mounted again. If an element remounts, its tag should be missing.
+ await elements.evaluateAll( ( refs ) =>
+ refs.forEach( ( ref, index ) => {
+ if ( ref instanceof HTMLElement ) {
+ ref.dataset.tag = `${ index }`;
+ }
+ } )
+ );
+ } );
+
+ test( 'should preserve elements on deletion', async ( { page } ) => {
+ const elements = page.getByTestId( 'books' ).getByTestId( 'item' );
+
+ await expect( elements ).toHaveText( [
+ 'A Game of Thrones',
+ 'A Clash of Kings',
+ 'A Storm of Swords',
+ ] );
+
+ // An item is removed when clicked.
+ await elements.first().click();
+
+ await expect( elements ).toHaveText( [
+ 'A Clash of Kings',
+ 'A Storm of Swords',
+ ] );
+
+ // Get the tags. They should not have disappeared.
+ const [ acok, asos ] = await elements.all();
+ await expect( acok ).toHaveAttribute( 'data-tag', '1' );
+ await expect( asos ).toHaveAttribute( 'data-tag', '2' );
+ } );
+
+ test( 'should preserve elements on reordering', async ( { page } ) => {
+ const elements = page.getByTestId( 'books' ).getByTestId( 'item' );
+
+ await page.getByTestId( 'books' ).getByTestId( 'rotate' ).click();
+
+ await expect( elements ).toHaveText( [
+ 'A Storm of Swords',
+ 'A Game of Thrones',
+ 'A Clash of Kings',
+ ] );
+
+ // Get the tags. They should not have disappeared or changed.
+ const [ asos, agot, acok ] = await elements.all();
+ await expect( asos ).toHaveAttribute( 'data-tag', '2' );
+ await expect( agot ).toHaveAttribute( 'data-tag', '0' );
+ await expect( acok ).toHaveAttribute( 'data-tag', '1' );
+ } );
+
+ test( 'should preserve elements on addition', async ( { page } ) => {
+ const elements = page.getByTestId( 'books' ).getByTestId( 'item' );
+
+ await page.getByTestId( 'books' ).getByTestId( 'add' ).click();
+
+ await expect( elements ).toHaveText( [
+ 'A Feast for Crows',
+ 'A Game of Thrones',
+ 'A Clash of Kings',
+ 'A Storm of Swords',
+ ] );
+
+ // Get the tags. They should not have disappeared or changed,
+ // except for the newly created element.
+ const [ affc, agot, acok, asos ] = await elements.all();
+ await expect( affc ).not.toHaveAttribute( 'data-tag' );
+ await expect( agot ).toHaveAttribute( 'data-tag', '0' );
+ await expect( acok ).toHaveAttribute( 'data-tag', '1' );
+ await expect( asos ).toHaveAttribute( 'data-tag', '2' );
+ } );
+
+ test( 'should preserve elements on replacement', async ( { page } ) => {
+ const elements = page.getByTestId( 'books' ).getByTestId( 'item' );
+
+ await page.getByTestId( 'books' ).getByTestId( 'replace' ).click();
+
+ await expect( elements ).toHaveText( [
+ 'A Feast for Crows',
+ 'A Clash of Kings',
+ 'A Storm of Swords',
+ ] );
+
+ // Get the tags. They should not have disappeared or changed,
+ // except for the newly created element.
+ const [ affc, acok, asos ] = await elements.all();
+ await expect( affc ).not.toHaveAttribute( 'data-tag' );
+ await expect( acok ).toHaveAttribute( 'data-tag', '1' );
+ await expect( asos ).toHaveAttribute( 'data-tag', '2' );
+ } );
+
+ test( 'should preserve elements on modification', async ( {
+ page,
+ } ) => {
+ const elements = page.getByTestId( 'books' ).getByTestId( 'item' );
+
+ await page.getByTestId( 'books' ).getByTestId( 'modify' ).click();
+
+ await expect( elements ).toHaveText( [
+ 'A GAME OF THRONES',
+ 'A Clash of Kings',
+ 'A Storm of Swords',
+ ] );
+
+ // Get the tags. They should not have disappeared or changed.
+ const [ agot, acok, asos ] = await elements.all();
+ await expect( agot ).toHaveAttribute( 'data-tag', '0' );
+ await expect( acok ).toHaveAttribute( 'data-tag', '1' );
+ await expect( asos ).toHaveAttribute( 'data-tag', '2' );
+ } );
+ } );
+
+ test( 'should respect elements after', async ( { page } ) => {
+ const elements = page.getByTestId( 'numbers' ).getByTestId( 'item' );
+ await expect( elements ).toHaveText( [ '1', '2', '3', '4' ] );
+ await page.getByTestId( 'numbers' ).getByTestId( 'shift' ).click();
+ await expect( elements ).toHaveText( [ '2', '3', '4' ] );
+ await page
+ .getByTestId( 'numbers' )
+ .getByTestId( 'unshift' )
+ .click( { clickCount: 2 } );
+ await expect( elements ).toHaveText( [ '0', '1', '2', '3', '4' ] );
+ } );
+
+ test( 'should support initial empty lists', async ( { page } ) => {
+ const elements = page.getByTestId( 'empty' ).getByTestId( 'item' );
+ await expect( elements ).toHaveText( [ 'item X' ] );
+ await page
+ .getByTestId( 'empty' )
+ .getByTestId( 'add' )
+ .click( { clickCount: 2 } );
+
+ await expect( elements ).toHaveText( [ 'item 0', 'item 1', 'item X' ] );
+ } );
+
+ test( 'should support multiple siblings inside the template', async ( {
+ page,
+ } ) => {
+ const elements = page.getByTestId( 'siblings' ).getByTestId( 'item' );
+ await expect( elements ).toHaveText( [
+ 'two',
+ '2',
+ 'three',
+ '3',
+ 'four',
+ '4',
+ ] );
+ await page.getByTestId( 'siblings' ).getByTestId( 'unshift' ).click();
+ await expect( elements ).toHaveText( [
+ 'one',
+ '1',
+ 'two',
+ '2',
+ 'three',
+ '3',
+ 'four',
+ '4',
+ ] );
+ } );
+
+ test( 'should work on navigation', async ( { page } ) => {
+ const elements = page
+ .getByTestId( 'navigation-updated list' )
+ .getByTestId( 'item' );
+
+ // These tags are included to check that the elements are not unmounted
+ // and mounted again. If an element remounts, its tag should be missing.
+ await elements.evaluateAll( ( refs ) =>
+ refs.forEach( ( ref, index ) => {
+ if ( ref instanceof HTMLElement ) {
+ ref.dataset.tag = `${ index }`;
+ }
+ } )
+ );
+
+ await expect( elements ).toHaveText( [ 'beta', 'gamma', 'delta' ] );
+
+ await page
+ .getByTestId( 'navigation-updated list' )
+ .getByTestId( 'navigate' )
+ .click();
+
+ await expect( elements ).toHaveText( [
+ 'alpha',
+ 'beta',
+ 'gamma',
+ 'delta',
+ ] );
+
+ // Get the tags. They should not have disappeared or changed,
+ // except for the newly created element.
+ const [ alpha, beta, gamma, delta ] = await elements.all();
+ await expect( alpha ).not.toHaveAttribute( 'data-tag' );
+ await expect( beta ).toHaveAttribute( 'data-tag', '0' );
+ await expect( gamma ).toHaveAttribute( 'data-tag', '1' );
+ await expect( delta ).toHaveAttribute( 'data-tag', '2' );
+ } );
+
+ test( 'should work with nested lists', async ( { page } ) => {
+ const mainElement = page.getByTestId( 'nested' );
+
+ // These tags are included to check that the elements are not unmounted
+ // and mounted again. If an element remounts, its tag should be missing.
+ const listItems = mainElement.getByRole( 'listitem' );
+ await listItems.evaluateAll( ( refs ) =>
+ refs.forEach( ( ref, index ) => {
+ if ( ref instanceof HTMLElement ) {
+ ref.dataset.tag = `${ index }`;
+ }
+ } )
+ );
+
+ const animals = mainElement.getByTestId( 'animal' );
+
+ {
+ // Ensure it hydrates correctly.
+ const [ dog, cat ] = await animals.all();
+ await expect( dog.getByTestId( 'name' ) ).toHaveText( 'Dog' );
+ await expect( dog.getByRole( 'listitem' ) ).toHaveText( [
+ 'chihuahua',
+ 'rottweiler',
+ ] );
+ await expect( cat.getByTestId( 'name' ) ).toHaveText( 'Cat' );
+ await expect( cat.getByRole( 'listitem' ) ).toHaveText( [
+ 'sphynx',
+ 'siamese',
+ ] );
+ }
+
+ await mainElement.getByTestId( 'add animal' ).click();
+
+ {
+ // Ensure it works when the top list is modified.
+ const [ rat, dog, cat ] = await animals.all();
+ await expect( rat.getByTestId( 'name' ) ).toHaveText( 'Rat' );
+ await expect( rat.getByRole( 'listitem' ) ).toHaveText( [
+ 'dumbo',
+ 'rex',
+ ] );
+ await expect( dog.getByTestId( 'name' ) ).toHaveText( 'Dog' );
+ await expect( dog.getByRole( 'listitem' ) ).toHaveText( [
+ 'chihuahua',
+ 'rottweiler',
+ ] );
+ await expect( cat.getByTestId( 'name' ) ).toHaveText( 'Cat' );
+ await expect( cat.getByRole( 'listitem' ) ).toHaveText( [
+ 'sphynx',
+ 'siamese',
+ ] );
+ await expect( rat ).not.toHaveAttribute( 'data-tag' );
+ const [ d1, d2 ] = await dog.getByRole( 'listitem' ).all();
+ await expect( dog ).toHaveAttribute( 'data-tag', '0' );
+ await expect( d1 ).toHaveAttribute( 'data-tag', '1' );
+ await expect( d2 ).toHaveAttribute( 'data-tag', '2' );
+ const [ c1, c2 ] = await cat.getByRole( 'listitem' ).all();
+ await expect( cat ).toHaveAttribute( 'data-tag', '3' );
+ await expect( c1 ).toHaveAttribute( 'data-tag', '4' );
+ await expect( c2 ).toHaveAttribute( 'data-tag', '5' );
+ }
+
+ // Reset tags so the added elements have one.
+ await listItems.evaluateAll( ( refs ) =>
+ refs.forEach( ( ref, index ) => {
+ if ( ref instanceof HTMLElement ) {
+ ref.dataset.tag = `${ index }`;
+ }
+ } )
+ );
+
+ await mainElement.getByTestId( 'add breeds' ).click();
+
+ {
+ // Ensure it works when the top list is modified.
+ const [ rat, dog, cat ] = await animals.all();
+ await expect( rat.getByTestId( 'name' ) ).toHaveText( 'Rat' );
+ await expect( rat.getByRole( 'listitem' ) ).toHaveText( [
+ 'satin',
+ 'dumbo',
+ 'rex',
+ ] );
+ await expect( dog.getByTestId( 'name' ) ).toHaveText( 'Dog' );
+ await expect( dog.getByRole( 'listitem' ) ).toHaveText( [
+ 'german shepherd',
+ 'chihuahua',
+ 'rottweiler',
+ ] );
+ await expect( cat.getByTestId( 'name' ) ).toHaveText( 'Cat' );
+ await expect( cat.getByRole( 'listitem' ) ).toHaveText( [
+ 'maine coon',
+ 'sphynx',
+ 'siamese',
+ ] );
+ const [ r1, r2, r3 ] = await rat.getByRole( 'listitem' ).all();
+ await expect( rat ).toHaveAttribute( 'data-tag', '0' );
+ await expect( r1 ).not.toHaveAttribute( 'data-tag' );
+ await expect( r2 ).toHaveAttribute( 'data-tag', '1' );
+ await expect( r3 ).toHaveAttribute( 'data-tag', '2' );
+ const [ d1, d2, d3 ] = await dog.getByRole( 'listitem' ).all();
+ await expect( dog ).toHaveAttribute( 'data-tag', '3' );
+ await expect( d1 ).not.toHaveAttribute( 'data-tag' );
+ await expect( d2 ).toHaveAttribute( 'data-tag', '4' );
+ await expect( d3 ).toHaveAttribute( 'data-tag', '5' );
+ const [ c1, c2, c3 ] = await cat.getByRole( 'listitem' ).all();
+ await expect( cat ).toHaveAttribute( 'data-tag', '6' );
+ await expect( c1 ).not.toHaveAttribute( 'data-tag' );
+ await expect( c2 ).toHaveAttribute( 'data-tag', '7' );
+ await expect( c3 ).toHaveAttribute( 'data-tag', '8' );
+ }
+ } );
+
+ test( 'should do nothing when used on non-template elements', async ( {
+ page,
+ } ) => {
+ const elements = page
+ .getByTestId( 'invalid tag' )
+ .getByTestId( 'item' );
+
+ await expect( elements ).toHaveCount( 1 );
+ await expect( elements ).toBeEmpty();
+ } );
+
+ test( 'should work with derived state as keys', async ( { page } ) => {
+ const elements = page
+ .getByTestId( 'derived state' )
+ .getByTestId( 'item' );
+
+ // These tags are included to check that the elements are not unmounted
+ // and mounted again. If an element remounts, its tag should be missing.
+ await elements.evaluateAll( ( refs ) =>
+ refs.forEach( ( ref, index ) => {
+ if ( ref instanceof HTMLElement ) {
+ ref.dataset.tag = `${ index }`;
+ }
+ } )
+ );
+
+ await page
+ .getByTestId( 'derived state' )
+ .getByTestId( 'rotate' )
+ .click();
+
+ await expect( elements ).toHaveText( [
+ 'cherimoya',
+ 'avocado',
+ 'banana',
+ ] );
+
+ // Get the tags. They should not have disappeared or changed.
+ const [ cherimoya, avocado, banana ] = await elements.all();
+ await expect( cherimoya ).toHaveAttribute( 'data-tag', '2' );
+ await expect( avocado ).toHaveAttribute( 'data-tag', '0' );
+ await expect( banana ).toHaveAttribute( 'data-tag', '1' );
+ } );
+} );