diff --git a/src/__tests__/use-collection.test.tsx b/src/__tests__/use-collection.test.tsx index 0b02484..fdd29d9 100644 --- a/src/__tests__/use-collection.test.tsx +++ b/src/__tests__/use-collection.test.tsx @@ -480,6 +480,25 @@ describe('Property filtering', () => { expect(container?.textContent?.split(',')).toEqual(['a']); }); + test('should return precomputed filteringOptions as-is when provided', () => { + const precomputed = [ + { propertyKey: 'id', value: 'custom-1' }, + { propertyKey: 'id', value: 'custom-2' }, + ]; + function App() { + const items: Item[] = [{ id: 'one' }, { id: 'two' }]; + const result = useCollection(items, { + propertyFiltering: { + filteringProperties: [{ key: 'id', groupValuesLabel: 'Id values', propertyLabel: 'Id' }], + filteringOptions: precomputed, + }, + }); + return <>{result.propertyFilterProps.filteringOptions.map(({ value }) => value).join(',')}; + } + const { container } = testRender(); + expect(container?.textContent?.split(',')).toEqual(['custom-1', 'custom-2']); + }); + test('should not generate filtering options for "falsy" values except boolean false and number zero', () => { const MixedOptions = () => { const propertyFiltering = { diff --git a/src/interfaces.ts b/src/interfaces.ts index 5294bd0..4c727de 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -47,6 +47,16 @@ export interface UseCollectionOptions { empty?: React.ReactNode; noMatch?: React.ReactNode; filteringProperties: readonly PropertyFilterProperty[]; + /** + * Pre-computed filtering options to use instead of scanning all items. + * + * When provided, `useCollection` skips its O(items × filteringProperties) scan + * and returns this list directly from `propertyFilterProps.filteringOptions`. + * + * Use this when you already know the full set of valid filter values. + * The caller is responsible for keeping this list up-to-date and for deduplication. + */ + filteringOptions?: readonly PropertyFilterOption[]; // custom filtering function filteringFunction?: (item: T, query: PropertyFilterQuery) => boolean; defaultQuery?: PropertyFilterQuery; diff --git a/src/use-collection.ts b/src/use-collection.ts index 8979ce1..5a038ad 100644 --- a/src/use-collection.ts +++ b/src/use-collection.ts @@ -1,9 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { useRef } from 'react'; +import { useRef, useMemo } from 'react'; import { processItems, processSelectedItems, itemsAreEqual } from './operations/index.js'; import { UseCollectionOptions, UseCollectionResult, CollectionRef } from './interfaces'; -import { createSyncProps } from './utils.js'; +import { createSyncProps, computeFilteringOptions } from './utils.js'; import { useCollectionState } from './use-collection-state.js'; export function useCollection(allItems: ReadonlyArray, options: UseCollectionOptions): UseCollectionResult { @@ -61,6 +61,19 @@ export function useCollection(allItems: ReadonlyArray, options: UseCollect } } + // Memoize `computeFilteringOptions` scan so it only runs when `allItems` or + // `filteringProperties` change. When the caller supplies + // `options.propertyFiltering.filteringOptions`, the scan is skipped entirely + // and the pre-computed list is returned directly. + const filteringProperties = options.propertyFiltering?.filteringProperties; + const precomputedOptions = options.propertyFiltering?.filteringOptions; + const filteringOptions = useMemo( + () => computeFilteringOptions(allItems, filteringProperties, precomputedOptions), + // filteringProperties and precomputedOptions are typically stable references (defined outside render or + // wrapped in useMemo by the caller), so array-identity comparison is correct here. + [allItems, filteringProperties, precomputedOptions] + ); + // When normal selection is used, the selectedItems are taken from state. // When group selection is used, the selectedItems are derived from group selection state. const extendedState = selectedItems ? { ...state, selectedItems } : state; @@ -75,6 +88,7 @@ export function useCollection(allItems: ReadonlyArray, options: UseCollect allItems, totalItemsCount, expandableRows, + filteringOptions, }), }; } diff --git a/src/utils.ts b/src/utils.ts index 5aa01ac..a9775c8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -9,6 +9,7 @@ import { CollectionRef, PropertyFilterQuery, PropertyFilterOption, + PropertyFilterProperty, CollectionActions, GroupSelectionState, ExpandableRowsResultBase, @@ -119,6 +120,44 @@ export function createActions({ }; } +/** + * Scans all items for each filterable property and collects unique values. + * + * Complexity: O(items × filteringProperties). Callers should memoize the result + * so it is only recomputed when `allItems` or `filteringProperties` change. + * + * When `options.propertyFiltering.filteringOptions` is provided by the caller, + * this scan is skipped entirely and the caller-supplied list is returned as-is. + */ +export function computeFilteringOptions( + allItems: readonly T[], + filteringProperties: readonly PropertyFilterProperty[] | undefined, + precomputedOptions: readonly PropertyFilterOption[] | undefined +): PropertyFilterOption[] { + if (!filteringProperties) { + return []; + } + if (precomputedOptions !== undefined) { + return precomputedOptions as PropertyFilterOption[]; + } + return filteringProperties.reduce((acc, property) => { + Object.keys( + allItems.reduce<{ [key in string]: boolean }>((acc, item) => { + acc['' + fixupFalsyValues(item[property.key as keyof T])] = true; + return acc; + }, {}) + ).forEach(value => { + if (value !== '') { + acc.push({ + propertyKey: property.key, + value, + }); + } + }); + return acc; + }, []); +} + export function createSyncProps( options: UseCollectionOptions, { @@ -138,12 +177,14 @@ export function createSyncProps( allItems, totalItemsCount, expandableRows, + filteringOptions, }: { pagesCount?: number; actualPageIndex?: number; allItems: readonly T[]; totalItemsCount: number; expandableRows?: ExpandableRowsResultBase; + filteringOptions: readonly PropertyFilterOption[]; } ): Pick, 'collectionProps' | 'filterProps' | 'paginationProps' | 'propertyFilterProps'> { let empty: ReactNode | null = options.filtering @@ -156,24 +197,6 @@ export function createSyncProps( ? options.propertyFiltering.noMatch : options.propertyFiltering.empty : empty; - const filteringOptions = options.propertyFiltering - ? options.propertyFiltering.filteringProperties.reduce((acc, property) => { - Object.keys( - allItems.reduce<{ [key in string]: boolean }>((acc, item) => { - acc['' + fixupFalsyValues(item[property.key as keyof T])] = true; - return acc; - }, {}) - ).forEach(value => { - if (value !== '') { - acc.push({ - propertyKey: property.key, - value, - }); - } - }); - return acc; - }, []) - : []; return { collectionProps: {