diff --git a/src/library-authoring/common/context/ComponentPickerContext.tsx b/src/library-authoring/common/context/ComponentPickerContext.tsx index 514df45027..33906586b0 100644 --- a/src/library-authoring/common/context/ComponentPickerContext.tsx +++ b/src/library-authoring/common/context/ComponentPickerContext.tsx @@ -88,6 +88,46 @@ type ComponentPickerProviderProps = { children?: React.ReactNode; } & ComponentPickerProps; +/** + * Builds indexed maps for efficient collection lookups. + * This creates two maps: + * 1. collectionToComponents: Maps collection keys to their component arrays + * 2. componentToCollections: Maps component usage keys to their collection keys + * + * Complexity: O(n) once per hits update instead of O(n × m) per widget + */ +export const useCollectionIndexing = ( + hits: any[], // Type should match ReturnType['hits'] +) => useMemo(() => { + const collectionToComponents = new Map(); + const componentToCollections = new Map(); + + hits.forEach((hit) => { + if (hit.type === 'library_block') { + const collectionKeys = (hit as any).collections?.key ?? []; + + // Index component → collections mapping + if (hit.usageKey) { + componentToCollections.set(hit.usageKey, collectionKeys); + } + + // Index collection → components mapping + collectionKeys.forEach((collectionKey: string) => { + if (!collectionToComponents.has(collectionKey)) { + collectionToComponents.set(collectionKey, []); + } + collectionToComponents.get(collectionKey)!.push({ + usageKey: hit.usageKey, + blockType: hit.blockType, + collectionKeys, + }); + }); + } + }); + + return { collectionToComponents, componentToCollections }; +}, [hits]); + /** * React component to provide `ComponentPickerContext` */ @@ -129,6 +169,7 @@ export const ComponentPickerProvider = ({ /** * Finds the common collection key between a component and selected components. + * Optimized with Set for O(n) instead of O(n*m) complexity. */ const findCommonCollectionKey = useCallback(( componentKeys: string[] | undefined, @@ -138,8 +179,11 @@ export const ComponentPickerProvider = ({ return undefined; } + // Convert to Set for O(1) lookups instead of O(m) for each includes() + const componentKeySet = new Set(componentKeys); + for (const component of components) { - const commonKey = component.collectionKeys?.find((key) => componentKeys.includes(key)); + const commonKey = component.collectionKeys?.find((key) => componentKeySet.has(key)); if (commonKey) { return commonKey; } diff --git a/src/library-authoring/components/AddComponentWidget.tsx b/src/library-authoring/components/AddComponentWidget.tsx index 197d0c1a02..1423e2f606 100644 --- a/src/library-authoring/components/AddComponentWidget.tsx +++ b/src/library-authoring/components/AddComponentWidget.tsx @@ -8,8 +8,12 @@ import { CheckBoxOutlineBlank, } from '@openedx/paragon/icons'; -import { ContentHit, useSearchContext } from '../../search-manager'; -import { SelectedComponent, useComponentPickerContext } from '../common/context/ComponentPickerContext'; +import { useSearchContext } from '../../search-manager'; +import { + SelectedComponent, + useComponentPickerContext, + useCollectionIndexing, +} from '../common/context/ComponentPickerContext'; import messages from './messages'; interface AddComponentWidgetProps { @@ -19,35 +23,6 @@ interface AddComponentWidgetProps { isCollection?: boolean; } -/** - * Builds an array of SelectedComponent from collection hits. - */ -const buildCollectionComponents = ( - hits: ReturnType['hits'], - collectionUsageKey: string, -): SelectedComponent[] => hits - .filter((hit) => hit.type === 'library_block' && hit.collections?.key?.includes(collectionUsageKey)) - .map((hit: ContentHit) => ({ - usageKey: hit.usageKey, - blockType: hit.blockType, - collectionKeys: (hit as ContentHit).collections?.key, - })); - -/** - * Counts the number of hits that share a collection key with the given component. - */ -const countCollectionHits = ( - hits: ReturnType['hits'], - componentCollectionKey: string[] | undefined, -): number => { - if (!componentCollectionKey?.length) { - return 0; - } - return hits.filter( - (hit) => (hit as ContentHit).collections?.key?.some((key) => componentCollectionKey.includes(key)), - ).length; -}; - const AddComponentWidget = ({ usageKey, blockType, collectionKeys, isCollection, }: AddComponentWidgetProps) => { @@ -64,16 +39,30 @@ const AddComponentWidget = ({ const { hits } = useSearchContext(); + // Use indexed lookup for O(1) performance instead of O(n) filtering + const { collectionToComponents, componentToCollections } = useCollectionIndexing(hits); + const collectionData = useMemo(() => { - // When selecting a collection: retrieve all its components to enable bulk selection + // When selecting a collection: O(1) lookup instead of O(n) filter if (isCollection) { - return buildCollectionComponents(hits, usageKey); + return collectionToComponents.get(usageKey) ?? []; } - // When selecting an individual component: get the total count of components in its collection - // This count is used to determine if the entire collection should be marked as selected - const componentCollectionKey = (hits.find((hit) => hit.usageKey === usageKey) as ContentHit)?.collections?.key; - return countCollectionHits(hits, componentCollectionKey); - }, [hits, usageKey, isCollection]); + + // When selecting an individual component: O(1) lookup + O(m) count + const componentCollectionKeys = componentToCollections.get(usageKey); + if (!componentCollectionKeys?.length) { + return 0; + } + + // Count total components across all collections this component belongs to + const componentSet = new Set(); + componentCollectionKeys.forEach((collectionKey) => { + const components = collectionToComponents.get(collectionKey) ?? []; + components.forEach((comp) => componentSet.add(comp.usageKey)); + }); + + return componentSet.size; + }, [collectionToComponents, componentToCollections, usageKey, isCollection]); // istanbul ignore if: this should never happen if (!usageKey) {