Skip to content

Commit c9c43bd

Browse files
committed
perf: enhance collection indexing and selection
1 parent eb08ffd commit c9c43bd

File tree

2 files changed

+220
-145
lines changed

2 files changed

+220
-145
lines changed

src/library-authoring/common/context/ComponentPickerContext.tsx

Lines changed: 169 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
useMemo,
66
useState,
77
} from 'react';
8+
import { ContentHit } from 'search-manager';
9+
import { useSearchContext } from 'search-manager/SearchManager';
810

911
export interface SelectedComponent {
1012
usageKey: string;
@@ -19,9 +21,14 @@ export interface SelectedCollection {
1921
status: CollectionStatus;
2022
}
2123

24+
export interface CollectionData {
25+
components: SelectedComponent[];
26+
affectedCollectionSizes: Map<string, number>;
27+
}
28+
2229
export type ComponentSelectedEvent = (
2330
selectedComponent: SelectedComponent,
24-
collectionComponents?: SelectedComponent[] | number
31+
collectionComponents?: CollectionData | Map<string, number>
2532
) => void;
2633
export type ComponentSelectionChangedEvent = (selectedComponents: SelectedComponent[]) => void;
2734

@@ -88,6 +95,88 @@ type ComponentPickerProviderProps = {
8895
children?: React.ReactNode;
8996
} & ComponentPickerProps;
9097

98+
/**
99+
* Pre-computed collection indexing data for O(1) lookups
100+
*/
101+
export interface CollectionIndexData {
102+
/** Map: collectionKey → components in that collection */
103+
collectionToComponents: Map<string, SelectedComponent[]>;
104+
/** Map: componentUsageKey → collection keys it belongs to */
105+
componentToCollections: Map<string, string[]>;
106+
/** Map: collectionKey → Map of all affected collections with their sizes */
107+
collectionToAffectedSizes: Map<string, Map<string, number>>;
108+
/** Map: collectionKey → total component count (for quick size lookup) */
109+
collectionSizes: Map<string, number>;
110+
}
111+
112+
/**
113+
* Hook to build indexing maps for collections and components.
114+
* Pre-computes all relationships for O(1) lookups during selection operations.
115+
* @param hits - Search hits from which to build the indexes
116+
* @returns Pre-computed collection index data
117+
*/
118+
export const useCollectionIndexing = (
119+
hits: ReturnType<typeof useSearchContext>['hits'],
120+
): CollectionIndexData => useMemo(() => {
121+
const collectionToComponents = new Map<string, SelectedComponent[]>();
122+
const componentToCollections = new Map<string, string[]>();
123+
const collectionSizes = new Map<string, number>();
124+
125+
// First pass: build basic indexes
126+
hits.forEach((hit) => {
127+
if (hit.type === 'library_block') {
128+
const collectionKeys = (hit as ContentHit).collections?.key ?? [];
129+
130+
// Index component → collections mapping
131+
if (hit.usageKey) {
132+
componentToCollections.set(hit.usageKey, collectionKeys);
133+
}
134+
135+
// Index collection → components mapping
136+
collectionKeys.forEach((collectionKey: string) => {
137+
if (!collectionToComponents.has(collectionKey)) {
138+
collectionToComponents.set(collectionKey, []);
139+
}
140+
collectionToComponents.get(collectionKey)!.push({
141+
usageKey: hit.usageKey,
142+
blockType: hit.blockType,
143+
collectionKeys,
144+
});
145+
});
146+
}
147+
});
148+
149+
// Second pass: compute collection sizes
150+
collectionToComponents.forEach((components, collectionKey) => {
151+
collectionSizes.set(collectionKey, components.length);
152+
});
153+
154+
// Third pass: pre-compute affected collections for each collection
155+
// This avoids O(c * k) computation in each AddComponentWidget
156+
const collectionToAffectedSizes = new Map<string, Map<string, number>>();
157+
collectionToComponents.forEach((components, collectionKey) => {
158+
const affectedSizes = new Map<string, number>();
159+
160+
// For each component in this collection, find all collections it belongs to
161+
components.forEach((component) => {
162+
component.collectionKeys?.forEach((affectedKey) => {
163+
if (!affectedSizes.has(affectedKey)) {
164+
affectedSizes.set(affectedKey, collectionSizes.get(affectedKey) ?? 0);
165+
}
166+
});
167+
});
168+
169+
collectionToAffectedSizes.set(collectionKey, affectedSizes);
170+
});
171+
172+
return {
173+
collectionToComponents,
174+
componentToCollections,
175+
collectionToAffectedSizes,
176+
collectionSizes,
177+
};
178+
}, [hits]);
179+
91180
/**
92181
* React component to provide `ComponentPickerContext`
93182
*/
@@ -101,59 +190,13 @@ export const ComponentPickerProvider = ({
101190
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
102191
const [selectedCollections, setSelectedCollections] = useState<SelectedCollection[]>([]);
103192

104-
/**
105-
* Updates the selectedCollections state based on how many components are selected.
106-
* @param collectionKey - The key of the collection to update
107-
* @param selectedCount - Number of components currently selected in the collection
108-
* @param totalCount - Total number of components in the collection
109-
*/
110-
const updateCollectionStatus = useCallback((
111-
collectionKey: string,
112-
selectedCount: number,
113-
totalCount: number,
114-
) => {
115-
setSelectedCollections((prevSelectedCollections) => {
116-
const filteredCollections = prevSelectedCollections.filter(
117-
(collection) => collection.key !== collectionKey,
118-
);
119-
120-
if (selectedCount === 0) {
121-
return filteredCollections;
122-
}
123-
if (selectedCount >= totalCount) {
124-
return [...filteredCollections, { key: collectionKey, status: 'selected' as CollectionStatus }];
125-
}
126-
return [...filteredCollections, { key: collectionKey, status: 'indeterminate' as CollectionStatus }];
127-
});
128-
}, []);
129-
130-
/**
131-
* Finds the common collection key between a component and selected components.
132-
*/
133-
const findCommonCollectionKey = useCallback((
134-
componentKeys: string[] | undefined,
135-
components: SelectedComponent[],
136-
): string | undefined => {
137-
if (!componentKeys?.length || !components.length) {
138-
return undefined;
139-
}
140-
141-
for (const component of components) {
142-
const commonKey = component.collectionKeys?.find((key) => componentKeys.includes(key));
143-
if (commonKey) {
144-
return commonKey;
145-
}
146-
}
147-
148-
return undefined;
149-
}, []);
150-
151193
const addComponentToSelectedComponents = useCallback<ComponentSelectedEvent>((
152194
selectedComponent: SelectedComponent,
153-
collectionComponents?: SelectedComponent[] | number,
195+
collectionComponents?: CollectionData | Map<string, number>,
154196
) => {
155-
const componentsToAdd = Array.isArray(collectionComponents) && collectionComponents.length
156-
? collectionComponents
197+
const isCollectionSelection = collectionComponents && 'components' in collectionComponents;
198+
const componentsToAdd = isCollectionSelection
199+
? collectionComponents.components
157200
: [selectedComponent];
158201

159202
setSelectedComponents((prevSelectedComponents) => {
@@ -166,49 +209,55 @@ export const ComponentPickerProvider = ({
166209

167210
const newSelectedComponents = [...prevSelectedComponents, ...newComponents];
168211

169-
// Handle collection selection (when selecting entire collection)
170-
if (Array.isArray(collectionComponents) && collectionComponents.length) {
171-
updateCollectionStatus(
172-
selectedComponent.usageKey,
173-
collectionComponents.length,
174-
collectionComponents.length,
175-
);
176-
}
177-
178-
// Handle individual component selection (with total count)
179-
if (typeof collectionComponents === 'number') {
180-
const componentCollectionKeys = selectedComponent.collectionKeys;
181-
const selectedCollectionComponents = newSelectedComponents.filter(
182-
(component) => component.collectionKeys?.some(
183-
(key) => componentCollectionKeys?.includes(key),
184-
),
185-
);
186-
187-
const collectionKey = findCommonCollectionKey(
188-
componentCollectionKeys,
189-
selectedCollectionComponents,
190-
);
191-
192-
if (collectionKey) {
193-
updateCollectionStatus(
194-
collectionKey,
195-
selectedCollectionComponents.length,
196-
collectionComponents,
212+
const collectionSizes = isCollectionSelection
213+
? collectionComponents.affectedCollectionSizes
214+
: collectionComponents;
215+
216+
if (collectionSizes instanceof Map && collectionSizes.size > 0) {
217+
const selectedByCollection = new Map<string, number>();
218+
219+
newSelectedComponents.forEach((component) => {
220+
component.collectionKeys?.forEach((key) => {
221+
if (collectionSizes.has(key)) {
222+
selectedByCollection.set(key, (selectedByCollection.get(key) ?? 0) + 1);
223+
}
224+
});
225+
});
226+
227+
// Batch update all collection statuses
228+
setSelectedCollections((prevSelectedCollections) => {
229+
const collectionMap = new Map(
230+
prevSelectedCollections.map((c) => [c.key, c]),
197231
);
198-
}
232+
233+
collectionSizes.forEach((totalCount, collectionKey) => {
234+
const selectedCount = selectedByCollection.get(collectionKey) ?? 0;
235+
236+
if (selectedCount === 0) {
237+
collectionMap.delete(collectionKey);
238+
} else if (selectedCount >= totalCount) {
239+
collectionMap.set(collectionKey, { key: collectionKey, status: 'selected' });
240+
} else {
241+
collectionMap.set(collectionKey, { key: collectionKey, status: 'indeterminate' });
242+
}
243+
});
244+
245+
return Array.from(collectionMap.values());
246+
});
199247
}
200248

201249
onChangeComponentSelection?.(newSelectedComponents);
202250
return newSelectedComponents;
203251
});
204-
}, []);
252+
}, [onChangeComponentSelection]);
205253

206254
const removeComponentFromSelectedComponents = useCallback<ComponentSelectedEvent>((
207255
selectedComponent: SelectedComponent,
208-
collectionComponents?: SelectedComponent[] | number,
256+
collectionComponents?: CollectionData | Map<string, number>,
209257
) => {
210-
const componentsToRemove = Array.isArray(collectionComponents) && collectionComponents.length
211-
? collectionComponents
258+
const isCollectionSelection = collectionComponents && 'components' in collectionComponents;
259+
const componentsToRemove = isCollectionSelection
260+
? collectionComponents.components
212261
: [selectedComponent];
213262
const usageKeysToRemove = new Set(componentsToRemove.map((c) => c.usageKey));
214263

@@ -217,33 +266,48 @@ export const ComponentPickerProvider = ({
217266
(component) => !usageKeysToRemove.has(component.usageKey),
218267
);
219268

220-
if (typeof collectionComponents === 'number') {
221-
const componentCollectionKeys = selectedComponent.collectionKeys;
222-
const collectionKey = findCommonCollectionKey(componentCollectionKeys, componentsToRemove);
223-
224-
if (collectionKey) {
225-
const remainingCollectionComponents = newSelectedComponents.filter(
226-
(component) => component.collectionKeys?.includes(collectionKey),
269+
const collectionSizes = isCollectionSelection
270+
? collectionComponents.affectedCollectionSizes
271+
: collectionComponents;
272+
273+
if (collectionSizes instanceof Map && collectionSizes.size > 0) {
274+
const selectedByCollection = new Map<string, number>();
275+
276+
// Only count components for collections we care about
277+
newSelectedComponents.forEach((component) => {
278+
component.collectionKeys?.forEach((key) => {
279+
if (collectionSizes.has(key)) {
280+
selectedByCollection.set(key, (selectedByCollection.get(key) ?? 0) + 1);
281+
}
282+
});
283+
});
284+
285+
// Batch update all collection statuses
286+
setSelectedCollections((prevSelectedCollections) => {
287+
const collectionMap = new Map(
288+
prevSelectedCollections.map((c) => [c.key, c]),
227289
);
228-
updateCollectionStatus(
229-
collectionKey,
230-
remainingCollectionComponents.length,
231-
collectionComponents,
232-
);
233-
}
234-
} else {
235-
// Fallback: remove collections that have no remaining components
236-
setSelectedCollections((prevSelectedCollections) => prevSelectedCollections.filter(
237-
(collection) => newSelectedComponents.some(
238-
(component) => component.collectionKeys?.includes(collection.key),
239-
),
240-
));
290+
291+
collectionSizes.forEach((totalCount, collectionKey) => {
292+
const selectedCount = selectedByCollection.get(collectionKey) ?? 0;
293+
294+
if (selectedCount === 0) {
295+
collectionMap.delete(collectionKey);
296+
} else if (selectedCount >= totalCount) {
297+
collectionMap.set(collectionKey, { key: collectionKey, status: 'selected' });
298+
} else {
299+
collectionMap.set(collectionKey, { key: collectionKey, status: 'indeterminate' });
300+
}
301+
});
302+
303+
return Array.from(collectionMap.values());
304+
});
241305
}
242306

243307
onChangeComponentSelection?.(newSelectedComponents);
244308
return newSelectedComponents;
245309
});
246-
}, []);
310+
}, [onChangeComponentSelection]);
247311

248312
const context = useMemo<ComponentPickerContextData>(() => {
249313
switch (componentPickerMode) {

0 commit comments

Comments
 (0)