Commit 2af72a7817d for woocommerce

commit 2af72a7817d073022dab83fecea910bf84bd15dd
Author: Anand Rajaram <anandrajaram21@gmail.com>
Date:   Thu Aug 20 08:43:02 2026 +0530

    Normalize search filter IDs and label handling (#67634)

    * Normalize search filter IDs and label handling

    * Add changefile(s) from automation for the following project(s): @woocommerce/components

    * Disable tabindex lint rule for search filter

    * Handle array values in search filters

    * Normalize search filter values and ignore stale labels

    * Normalize initial search filter values once

    * Handle null search filter values

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/packages/js/components/changelog/67634-35900-ip-search-bug b/packages/js/components/changelog/67634-35900-ip-search-bug
new file mode 100644
index 00000000000..dfe0597cd2e
--- /dev/null
+++ b/packages/js/components/changelog/67634-35900-ip-search-bug
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix removal of Advanced Filter selections that use string identifiers, such as download IP addresses.
diff --git a/packages/js/components/src/advanced-filters/search-filter.js b/packages/js/components/src/advanced-filters/search-filter.js
index efae4d61be6..e9fc6da8e86 100644
--- a/packages/js/components/src/advanced-filters/search-filter.js
+++ b/packages/js/components/src/advanced-filters/search-filter.js
@@ -3,7 +3,6 @@
  */
 import { createElement, Component, Fragment } from '@wordpress/element';
 import { SelectControl } from '@wordpress/components';
-import { getIdsFromQuery } from '@woocommerce/navigation';
 import { find, isEqual } from 'lodash';
 import PropTypes from 'prop-types';
 import clsx from 'clsx';
@@ -17,8 +16,15 @@ import {
 	textContent,
 } from './utils';

+const normalizeFilterValue = ( value ) => {
+	if ( Array.isArray( value ) ) {
+		return value.join( ',' );
+	}
+	return typeof value === 'string' ? value : '';
+};
+
 class SearchFilter extends Component {
-	constructor( { filter, config, query } ) {
+	constructor( { filter, query } ) {
 		super( ...arguments );
 		this.onSearchChange = this.onSearchChange.bind( this );
 		this.state = {
@@ -27,44 +33,57 @@ class SearchFilter extends Component {

 		this.updateLabels = this.updateLabels.bind( this );

-		if ( filter.value.length ) {
-			config.input
-				.getLabels( filter.value, query )
-				.then( ( selected ) => {
-					const selectedWithKeys = selected.map( ( s ) => ( {
-						key: s.id,
-						...s,
-					} ) );
-
-					this.updateLabels( selectedWithKeys );
-				} );
+		const filterValue = normalizeFilterValue( filter.value );
+		if ( filterValue.length ) {
+			this.loadLabels( filterValue, query );
 		}
 	}

 	componentDidUpdate( prevProps ) {
-		const { config, filter, query } = this.props;
+		const { filter, query } = this.props;
 		const { filter: prevFilter } = prevProps;
+		const filterValue = normalizeFilterValue( filter.value );

-		if ( filter.value.length && ! isEqual( prevFilter, filter ) ) {
+		if ( filterValue.length && ! isEqual( prevFilter, filter ) ) {
 			const { selected } = this.state;
-			const ids = selected.map( ( item ) => item.key );
-			const filterIds = getIdsFromQuery( filter.value );
-			const hasNewIds = filterIds.every( ( id ) => ! ids.includes( id ) );
-
-			if ( hasNewIds ) {
-				config.input
-					.getLabels( filter.value, query )
-					.then( this.updateLabels );
+			const selectedIds = selected.map( ( item ) => String( item.key ) );
+			const filterIds = filterValue
+				.split( ',' )
+				.filter( Boolean )
+				.map( String );
+			const haveIdsChanged =
+				filterIds.length !== selectedIds.length ||
+				filterIds.some( ( id ) => ! selectedIds.includes( id ) );
+
+			if ( haveIdsChanged ) {
+				this.loadLabels( filterValue, query );
 			}
 		}
 	}

+	loadLabels( filterValue, query ) {
+		this.props.config.input
+			.getLabels( filterValue, query )
+			.then( ( selected ) => {
+				if (
+					filterValue ===
+					normalizeFilterValue( this.props.filter.value )
+				) {
+					this.updateLabels( selected );
+				}
+			} );
+	}
+
 	updateLabels( selected ) {
+		const normalizedSelected = selected.map( ( item ) => ( {
+			...item,
+			key: item.key ?? item.id,
+		} ) );
 		const prevIds = this.state.selected.map( ( item ) => item.key );
-		const ids = selected.map( ( item ) => item.key );
+		const ids = normalizedSelected.map( ( item ) => item.key );

-		if ( ! isEqual( ids.sort(), prevIds.sort() ) ) {
-			this.setState( { selected } );
+		if ( ! isEqual( [ ...ids ].sort(), [ ...prevIds ].sort() ) ) {
+			this.setState( { selected: normalizedSelected } );
 		}
 	}

@@ -138,7 +157,6 @@ class SearchFilter extends Component {

 		const screenReaderText = this.getScreenReaderText( filter, config );

-		/*eslint-disable jsx-a11y/no-noninteractive-tabindex*/
 		return (
 			<fieldset
 				className="woocommerce-filters-advanced__line-item"
@@ -164,7 +182,6 @@ class SearchFilter extends Component {
 				) }
 			</fieldset>
 		);
-		/*eslint-enable jsx-a11y/no-noninteractive-tabindex*/
 	}
 }

@@ -187,7 +204,7 @@ SearchFilter.propTypes = {
 	filter: PropTypes.shape( {
 		key: PropTypes.string,
 		rule: PropTypes.string,
-		value: PropTypes.string,
+		value: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array ] ),
 	} ).isRequired,
 	/**
 	 * Function to be called on update.
diff --git a/packages/js/components/src/advanced-filters/test/search-filter.test.js b/packages/js/components/src/advanced-filters/test/search-filter.test.js
new file mode 100644
index 00000000000..50e8be7ea12
--- /dev/null
+++ b/packages/js/components/src/advanced-filters/test/search-filter.test.js
@@ -0,0 +1,285 @@
+/**
+ * @jest-environment jsdom
+ */
+/**
+ * External dependencies
+ */
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createElement, createRef } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import SearchFilter from '../search-filter';
+
+const getConfig = ( getLabels ) => ( {
+	labels: {
+		add: 'IP Address',
+		filter: 'Select IP addresses',
+		placeholder: 'Search IP address',
+		rule: 'Select an IP address filter match',
+		title: '<title>IP Address</title> <rule/> <filter/>',
+	},
+	rules: [ { value: 'includes', label: 'Includes' } ],
+	input: {
+		type: 'downloadIps',
+		getLabels,
+	},
+} );
+
+describe( 'SearchFilter', () => {
+	test( 'normalizes string identifiers when an external change reloads labels', async () => {
+		const getLabels = jest
+			.fn()
+			.mockResolvedValue( [ { id: '::1', label: '::1' } ] );
+		const onFilterChange = jest.fn();
+		const ref = createRef();
+		const props = {
+			config: getConfig( getLabels ),
+			filter: { key: 'ip_address', rule: 'includes', value: '' },
+			onFilterChange,
+			query: {},
+		};
+		const { rerender } = render(
+			<SearchFilter ref={ ref } { ...props } />
+		);
+
+		act( () => {
+			ref.current.onSearchChange( [
+				{ key: '127.0.0.1', label: '127.0.0.1' },
+			] );
+		} );
+		onFilterChange.mockClear();
+
+		rerender(
+			<SearchFilter
+				ref={ ref }
+				{ ...props }
+				filter={ { ...props.filter, value: '::1' } }
+			/>
+		);
+
+		await waitFor( () =>
+			expect( getLabels ).toHaveBeenCalledWith( '::1', {} )
+		);
+		expect( ref.current.state.selected ).toEqual( [
+			{ id: '::1', key: '::1', label: '::1' },
+		] );
+
+		await userEvent.click(
+			screen.getByRole( 'button', { name: 'Remove ::1' } )
+		);
+
+		expect(
+			screen.queryByRole( 'button', { name: 'Remove ::1' } )
+		).not.toBeInTheDocument();
+		expect( onFilterChange ).toHaveBeenCalledWith( {
+			property: 'value',
+			value: '',
+		} );
+	} );
+
+	test( 'does not reload a current string identifier with stale label data', async () => {
+		const getLabels = jest
+			.fn()
+			.mockResolvedValue( [ { id: '::1', label: '::1' } ] );
+		const onFilterChange = jest.fn();
+		const ref = createRef();
+		const props = {
+			config: getConfig( getLabels ),
+			filter: { key: 'ip_address', rule: 'includes', value: '' },
+			onFilterChange,
+			query: {},
+		};
+		const { rerender } = render(
+			<SearchFilter ref={ ref } { ...props } />
+		);
+
+		act( () => {
+			ref.current.onSearchChange( [ { key: '::1', label: '::1' } ] );
+		} );
+		onFilterChange.mockClear();
+		rerender(
+			<SearchFilter
+				ref={ ref }
+				{ ...props }
+				filter={ { ...props.filter, value: '::1' } }
+			/>
+		);
+
+		expect( getLabels ).not.toHaveBeenCalled();
+		expect( ref.current.state.selected ).toEqual( [
+			{ key: '::1', label: '::1' },
+		] );
+
+		await userEvent.click(
+			screen.getByRole( 'button', { name: 'Remove ::1' } )
+		);
+		expect( onFilterChange ).toHaveBeenCalledWith( {
+			property: 'value',
+			value: '',
+		} );
+	} );
+
+	test( 'keeps numeric identifiers without reloading their labels', () => {
+		const getLabels = jest
+			.fn()
+			.mockResolvedValue( [ { id: 60, label: '#60' } ] );
+		const ref = createRef();
+		const props = {
+			config: getConfig( getLabels ),
+			filter: { key: 'order', rule: 'includes', value: '' },
+			onFilterChange: jest.fn(),
+			query: {},
+		};
+		const { rerender } = render(
+			<SearchFilter ref={ ref } { ...props } />
+		);
+
+		act( () => {
+			ref.current.onSearchChange( [ { key: 60, label: '#60' } ] );
+		} );
+		rerender(
+			<SearchFilter
+				ref={ ref }
+				{ ...props }
+				filter={ { ...props.filter, value: '60' } }
+			/>
+		);
+
+		expect( getLabels ).not.toHaveBeenCalled();
+		expect( ref.current.state.selected ).toEqual( [
+			{ key: 60, label: '#60' },
+		] );
+	} );
+
+	test( 'does not reload labels when an array filter value matches the selection', () => {
+		const getLabels = jest.fn();
+		const ref = createRef();
+		const props = {
+			config: getConfig( getLabels ),
+			filter: { key: 'order', rule: 'includes', value: '' },
+			onFilterChange: jest.fn(),
+			query: {},
+		};
+		const { rerender } = render(
+			<SearchFilter ref={ ref } { ...props } />
+		);
+
+		act( () => {
+			ref.current.onSearchChange( [ { key: 60, label: '#60' } ] );
+		} );
+		rerender(
+			<SearchFilter
+				ref={ ref }
+				{ ...props }
+				filter={ { ...props.filter, value: [ 60 ] } }
+			/>
+		);
+
+		expect( getLabels ).not.toHaveBeenCalled();
+	} );
+
+	test( 'handles null filter values', () => {
+		const getLabels = jest.fn();
+		const props = {
+			config: getConfig( getLabels ),
+			filter: { key: 'order', rule: 'includes', value: null },
+			onFilterChange: jest.fn(),
+			query: {},
+		};
+		const { rerender } = render( <SearchFilter { ...props } /> );
+
+		rerender(
+			<SearchFilter
+				{ ...props }
+				filter={ { ...props.filter, value: '' } }
+			/>
+		);
+		rerender( <SearchFilter { ...props } /> );
+
+		expect( getLabels ).not.toHaveBeenCalled();
+	} );
+
+	test( 'passes changed array filter values to getLabels as strings', async () => {
+		const getLabels = jest
+			.fn()
+			.mockResolvedValue( [ { id: 60, label: '#60' } ] );
+		const ref = createRef();
+		const props = {
+			config: getConfig( getLabels ),
+			filter: { key: 'order', rule: 'includes', value: '' },
+			onFilterChange: jest.fn(),
+			query: {},
+		};
+		const { rerender } = render(
+			<SearchFilter ref={ ref } { ...props } />
+		);
+
+		rerender(
+			<SearchFilter
+				ref={ ref }
+				{ ...props }
+				filter={ { ...props.filter, value: [ 60 ] } }
+			/>
+		);
+
+		await waitFor( () =>
+			expect( getLabels ).toHaveBeenCalledWith( '60', {} )
+		);
+	} );
+
+	test( 'ignores stale label responses', async () => {
+		let resolveFirstRequest;
+		let resolveSecondRequest;
+		const firstRequest = new Promise( ( resolve ) => {
+			resolveFirstRequest = resolve;
+		} );
+		const secondRequest = new Promise( ( resolve ) => {
+			resolveSecondRequest = resolve;
+		} );
+		const getLabels = jest
+			.fn()
+			.mockReturnValueOnce( firstRequest )
+			.mockReturnValueOnce( secondRequest );
+		const ref = createRef();
+		const props = {
+			config: getConfig( getLabels ),
+			filter: { key: 'ip_address', rule: 'includes', value: '' },
+			onFilterChange: jest.fn(),
+			query: {},
+		};
+		const { rerender } = render(
+			<SearchFilter ref={ ref } { ...props } />
+		);
+
+		rerender(
+			<SearchFilter
+				ref={ ref }
+				{ ...props }
+				filter={ { ...props.filter, value: '::1' } }
+			/>
+		);
+		rerender(
+			<SearchFilter
+				ref={ ref }
+				{ ...props }
+				filter={ { ...props.filter, value: '127.0.0.1' } }
+			/>
+		);
+
+		await act( async () => {
+			resolveSecondRequest( [ { id: '127.0.0.1', label: '127.0.0.1' } ] );
+			await secondRequest;
+		} );
+		await act( async () => {
+			resolveFirstRequest( [ { id: '::1', label: '::1' } ] );
+			await firstRequest;
+		} );
+
+		expect( ref.current.state.selected ).toEqual( [
+			{ id: '127.0.0.1', key: '127.0.0.1', label: '127.0.0.1' },
+		] );
+	} );
+} );