Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Fix React hook dependency warnings in filter an All Products blocks #3285

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion assets/js/base/components/checkbox-list/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import './style.scss';
*
* @param {Object} props Incoming props for the component.
* @param {string} props.className CSS class used.
* @param {function():any} props.onChange Function called when inputs change.
* @param {function(string):any} props.onChange Function called when inputs change.
* @param {Array} props.options Options for list.
* @param {Array} props.checked Which items are checked.
* @param {boolean} props.isLoading If loading or not.
Expand Down Expand Up @@ -142,6 +142,7 @@ const CheckboxList = ( {
);
}, [
options,
onChange,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one was easy: missing dependency in a useMemo. onChange is not expected to change, but in order to fix the linting warning, it was needed.

checked,
showExpanded,
limit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* Validate a min and max value for a range slider against defined constraints (min, max, step).
*
* @param {Array} values Array containing min and max values.
* @param {number} min Min allowed value for the sliders.
* @param {number} max Max allowed value for the sliders.
* @param {number|null} min Min allowed value for the sliders.
* @param {number|null} max Max allowed value for the sliders.
* @param {number} step Step value for the sliders.
* @param {boolean} isMin Whether we're currently interacting with the min range slider or not, so we update the correct values.
* @return {Array} Validated and updated min/max values that fit within the range slider constraints.
Expand Down
19 changes: 9 additions & 10 deletions assets/js/base/components/price-slider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ const PriceSlider = ( {
minConstraint,
maxConstraint,
hasValidConstraints,
stepValue,
] );

/**
Expand Down Expand Up @@ -186,7 +185,14 @@ const PriceSlider = ( {
parseInt( values[ 1 ], 10 ),
] );
},
[ minPrice, maxPrice, minConstraint, maxConstraint, stepValue ]
[
onChange,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar as before, but in this case it's a useCallback().

minPrice,
maxPrice,
minConstraint,
maxConstraint,
stepValue,
]
);

/**
Expand Down Expand Up @@ -221,14 +227,7 @@ const PriceSlider = ( {
parseInt( values[ 1 ], 10 ),
] );
},
[
minConstraint,
maxConstraint,
stepValue,
minPriceInput,
maxPriceInput,
currency,
]
[ onChange, stepValue, minPriceInput, maxPriceInput ]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onChange was a missing dependency, while minConstraint, maxConstraint and currency were listed as a dependency, but they are not used inside the callback.

);

const classes = classnames(
Expand Down
48 changes: 27 additions & 21 deletions assets/js/base/components/product-list/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const generateQuery = ( { sortValue, currentPage, attributes } ) => {
const extractPaginationAndSortAttributes = ( query ) => {
/* eslint-disable-next-line no-unused-vars, camelcase */
const { order, orderby, page, per_page, ...totalQuery } = query;
return totalQuery;
return totalQuery || {};
};

const announceLoadingCompletion = ( totalProducts ) => {
Expand All @@ -96,6 +96,11 @@ const announceLoadingCompletion = ( totalProducts ) => {
}
};

const areQueryTotalsDifferent = (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I extracted that function outside of the functional component so it's not re-created on every render. This prevents usePrevious() repeating the validation unnecessarily on every render if the value didn't change. See the changes in use-previous.js here:

https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3285/files#diff-0fa9d6144ce10110cc712ea2ff1988cb3d7e9eba087ca2ee2a5deb8d709dbe7eL23

{ totalQuery: nextQuery, totalProducts: nextProducts },
{ totalQuery: currentQuery } = {}
) => ! isEqual( nextQuery, currentQuery ) && Number.isFinite( nextProducts );

const ProductList = ( {
attributes,
currentPage,
Expand Down Expand Up @@ -129,28 +134,27 @@ const ProductList = ( {
// the total number of products is a finite number.
const previousQueryTotals = usePrevious(
{ totalQuery, totalProducts },
(
{ totalQuery: nextQuery, totalProducts: nextProducts },
{ totalQuery: currentQuery } = {}
) =>
! isEqual( nextQuery, currentQuery ) &&
Number.isFinite( nextProducts )
areQueryTotalsDifferent
);
const isPreviousTotalQueryEqual =
typeof previousQueryTotals === 'object' &&
isEqual( totalQuery, previousQueryTotals.totalQuery );

// If query state (excluding pagination/sorting attributes) changed,
// reset pagination to the first page.
useEffect( () => {
// If query state (excluding pagination/sorting attributes) changed,
// reset pagination to the first page.
if ( ! isPreviousTotalQueryEqual ) {
onPageChange( 1 );

// Make sure there was a previous query, so we don't announce it on page load.
if ( previousQueryTotals ) {
announceLoadingCompletion( totalProducts );
}
if ( isEqual( totalQuery, previousQueryTotals?.totalQuery ) ) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as before.

return;
}
onPageChange( 1 );

// Make sure there was a previous query, so we don't announce it on page load.
if ( previousQueryTotals?.totalQuery ) {
announceLoadingCompletion( totalProducts );
}
}, [ queryState ] );
}, [
previousQueryTotals?.totalQuery,
totalProducts,
onPageChange,
totalQuery,
] );

const onPaginationChange = ( newPage ) => {
scrollToTop( { focusableSelector: 'a, button' } );
Expand All @@ -175,7 +179,9 @@ const ProductList = ( {
const { contentVisibility } = attributes;
const perPage = attributes.columns * attributes.rows;
const totalPages =
! Number.isFinite( totalProducts ) && isPreviousTotalQueryEqual
! Number.isFinite( totalProducts ) &&
Number.isFinite( previousQueryTotals?.totalProducts ) &&
isEqual( totalQuery, previousQueryTotals?.totalQuery )
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check that was named isPreviousTotalQueryEqual, has been moved here. Instead of checking that previousQueryTotals is an object, we directly check if it has the totalProducts property and it's a finite number.

? Math.ceil( previousQueryTotals.totalProducts / perPage )
: Math.ceil( totalProducts / perPage );
const listProducts = products.length
Expand Down
2 changes: 1 addition & 1 deletion assets/js/base/hooks/use-previous.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const usePrevious = ( value, validation ) => {
) {
ref.current = value;
}
}, [ value, ref.current ] );
}, [ value, validation ] );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing dep in a useEffect(). I needed to update calls to usePrevious() in other components to ensure validation doesn't change on each render (I commented it inline in those cases).


return ref.current;
};
38 changes: 28 additions & 10 deletions assets/js/base/hooks/use-query-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect, useDispatch } from '@wordpress/data';
import { useRef, useEffect, useCallback } from '@wordpress/element';
import { useQueryStateContext } from '@woocommerce/base-context';
import { usePrevious } from '@woocommerce/base-hooks';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { assign } from 'lodash';

/**
Expand Down Expand Up @@ -43,7 +45,7 @@ export const useQueryStateByContext = ( context ) => {
( value ) => {
setValueForQueryContext( context, value );
},
[ context ]
[ context, setValueForQueryContext ]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing dep in a useCallback(). It should be a safe change.

);

return [ queryState, setQueryState ];
Expand All @@ -56,11 +58,11 @@ export const useQueryStateByContext = ( context ) => {
* "Query State" is a wp.data store that keeps track of an arbitrary object of
* query keys and their values.
*
* @param {*} queryKey The specific query key to retrieve the value for.
* @param {*} defaultValue Default value if query does not exist.
* @param {string} [context] What context to retrieve the query state for. If
* not provided will attempt to use what is provided
* by query state context.
* @param {*} queryKey The specific query key to retrieve the value for.
* @param {*} [defaultValue] Default value if query does not exist.
* @param {string} [context] What context to retrieve the query state for. If
* not provided will attempt to use what is provided
* by query state context.
*
* @return {*} Whatever value is set at the query state index using the
* provided context and query key.
Expand All @@ -81,7 +83,7 @@ export const useQueryStateByKey = ( queryKey, defaultValue, context ) => {
( value ) => {
setQueryValue( context, queryKey, value );
},
[ context, queryKey ]
[ context, queryKey, setQueryValue ]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing dep in a useCallback(). It should be a safe change.

);

return [ queryValue, setQueryValueByKey ];
Expand Down Expand Up @@ -117,15 +119,31 @@ export const useSynchronizedQueryState = ( synchronizedQuery, context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const [ queryState, setQueryState ] = useQueryStateByContext( context );
const currentQueryState = useShallowEqual( queryState );
const currentSynchronizedQuery = useShallowEqual( synchronizedQuery );
const previousSynchronizedQuery = usePrevious( currentSynchronizedQuery );
// used to ensure we allow initial synchronization to occur before
// returning non-synced state.
const isInitialized = useRef( false );
// update queryState anytime incoming synchronizedQuery changes
useEffect( () => {
setQueryState( assign( {}, queryState, currentSynchronizedQuery ) );
isInitialized.current = true;
}, [ currentSynchronizedQuery ] );
if (
! isShallowEqual(
previousSynchronizedQuery,
currentSynchronizedQuery
)
) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only need to run this effect when currentSynchronizedQuery changes, but since we added new dependencies in the deps array, we need to manually check that currentSynchronizedQuery is not equal to the previous one, so we don't run this effect in some unnecessary cases.

setQueryState(
assign( {}, currentQueryState, currentSynchronizedQuery )
);
isInitialized.current = true;
}
}, [
currentQueryState,
currentSynchronizedQuery,
previousSynchronizedQuery,
setQueryState,
] );
return isInitialized.current
? [ queryState, setQueryState ]
: [ synchronizedQuery, setQueryState ];
Expand Down
10 changes: 8 additions & 2 deletions assets/js/blocks/active-filters/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ const ActiveFiltersBlock = ( {
},
displayStyle: blockAttributes.displayStyle,
} );
}, [ minPrice, maxPrice, formatPriceRange ] );
}, [
minPrice,
maxPrice,
blockAttributes.displayStyle,
setMinPrice,
setMaxPrice,
] );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing deps in a useMemo(), it should be safe.


const activeAttributeFilters = useMemo( () => {
return productAttributes.map( ( attribute ) => {
Expand All @@ -64,7 +70,7 @@ const ActiveFiltersBlock = ( {
/>
);
} );
}, [ productAttributes ] );
}, [ productAttributes, blockAttributes.displayStyle ] );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same. 🙂


const hasFilters = () => {
return (
Expand Down
67 changes: 36 additions & 31 deletions assets/js/blocks/attribute-filter/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useQueryStateByKey,
useQueryStateByContext,
useCollectionData,
usePrevious,
useShallowEqual,
} from '@woocommerce/base-hooks';
import {
Expand Down Expand Up @@ -113,7 +114,7 @@ const AttributeFilterBlock = ( {
* @param {string} termSlug The term of the slug to check.
*/
const isTermInQueryState = ( termSlug ) => {
if ( ! queryState || ! queryState.attributes ) {
if ( ! queryState?.attributes ) {
return false;
}
return queryState.attributes.some(
Expand Down Expand Up @@ -168,16 +169,11 @@ const AttributeFilterBlock = ( {
] );

// Track checked STATE changes - if state changes, update the query.
useEffect(
() => {
if ( ! blockAttributes.showFilterButton ) {
onSubmit();
}
},
// There is no need to add blockAttributes.showFilterButton as a dependency.
// It will only change in the editor and there we don't need to call onSubmit in any case.
[ checked, onSubmit ]
);
useEffect( () => {
if ( ! blockAttributes.showFilterButton ) {
onSubmit( checked );
}
}, [ blockAttributes.showFilterButton, checked, onSubmit ] );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing dep in effect. blockAttributes.showFilterButton is not expected to change during the lifecycle of the component, so it should be safe.


const checkedQuery = useMemo( () => {
return productAttributesQuery
Expand All @@ -188,17 +184,16 @@ const AttributeFilterBlock = ( {
}, [ productAttributesQuery, attributeObject.taxonomy ] );

const currentCheckedQuery = useShallowEqual( checkedQuery );

const previousCheckedQuery = usePrevious( currentCheckedQuery );
// Track ATTRIBUTES QUERY changes so the block reflects current filters.
useEffect(
() => {
if ( ! isShallowEqual( checked, checkedQuery ) ) {
setChecked( checkedQuery );
}
},
// We only want to apply this effect when the query changes, so we are intentionally leaving `checked` out of the dependencies.
[ currentCheckedQuery ]
);
useEffect( () => {
if (
! isShallowEqual( previousCheckedQuery, currentCheckedQuery ) && // checked query changed
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only want to run this effect when currentCheckedQuery changes, but since we added new deps, we need to ensure the currentCheckedQuery is not equal to the previous one.

! isShallowEqual( checked, currentCheckedQuery ) // checked query doesn't match the UI
) {
setChecked( currentCheckedQuery );
}
}, [ checked, currentCheckedQuery, previousCheckedQuery ] );

/**
* Returns an array of term objects that have been chosen via the checkboxes.
Expand All @@ -215,19 +210,29 @@ const AttributeFilterBlock = ( {
[ attributeTerms ]
);

const onSubmit = () => {
if ( isEditor ) {
return;
}
const onSubmit = useCallback(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since onSubmit is a dependency of some effects, I wrapped it in a useCallback().

( isChecked ) => {
if ( isEditor ) {
return;
}

updateAttributeFilter(
updateAttributeFilter(
productAttributesQuery,
setProductAttributesQuery,
attributeObject,
getSelectedTerms( isChecked ),
blockAttributes.queryType === 'or' ? 'in' : 'and'
);
},
[
isEditor,
productAttributesQuery,
setProductAttributesQuery,
attributeObject,
getSelectedTerms( checked ),
blockAttributes.queryType === 'or' ? 'in' : 'and'
);
};
getSelectedTerms,
blockAttributes.queryType,
]
);

const multiple =
blockAttributes.displayStyle !== 'dropdown' ||
Expand Down Expand Up @@ -357,7 +362,7 @@ const AttributeFilterBlock = ( {
<FilterSubmitButton
className="wc-block-attribute-filter__button"
disabled={ isLoading || isDisabled }
onClick={ onSubmit }
onClick={ () => onSubmit( checked ) }
/>
) }
</div>
Expand Down
Loading