Skip to content

Commit ee3db52

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

File tree

2 files changed

+219
-145
lines changed

2 files changed

+219
-145
lines changed

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

Lines changed: 168 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,87 @@ 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+
const collectionToAffectedSizes = new Map<string, Map<string, number>>();
156+
collectionToComponents.forEach((components, collectionKey) => {
157+
const affectedSizes = new Map<string, number>();
158+
159+
// For each component in this collection, find all collections it belongs to
160+
components.forEach((component) => {
161+
component.collectionKeys?.forEach((affectedKey) => {
162+
if (!affectedSizes.has(affectedKey)) {
163+
affectedSizes.set(affectedKey, collectionSizes.get(affectedKey) ?? 0);
164+
}
165+
});
166+
});
167+
168+
collectionToAffectedSizes.set(collectionKey, affectedSizes);
169+
});
170+
171+
return {
172+
collectionToComponents,
173+
componentToCollections,
174+
collectionToAffectedSizes,
175+
collectionSizes,
176+
};
177+
}, [hits]);
178+
91179
/**
92180
* React component to provide `ComponentPickerContext`
93181
*/
@@ -101,59 +189,13 @@ export const ComponentPickerProvider = ({
101189
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
102190
const [selectedCollections, setSelectedCollections] = useState<SelectedCollection[]>([]);
103191

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-
151192
const addComponentToSelectedComponents = useCallback<ComponentSelectedEvent>((
152193
selectedComponent: SelectedComponent,
153-
collectionComponents?: SelectedComponent[] | number,
194+
collectionComponents?: CollectionData | Map<string, number>,
154195
) => {
155-
const componentsToAdd = Array.isArray(collectionComponents) && collectionComponents.length
156-
? collectionComponents
196+
const isCollectionSelection = collectionComponents && 'components' in collectionComponents;
197+
const componentsToAdd = isCollectionSelection
198+
? collectionComponents.components
157199
: [selectedComponent];
158200

159201
setSelectedComponents((prevSelectedComponents) => {
@@ -166,49 +208,55 @@ export const ComponentPickerProvider = ({
166208

167209
const newSelectedComponents = [...prevSelectedComponents, ...newComponents];
168210

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,
211+
const collectionSizes = isCollectionSelection
212+
? collectionComponents.affectedCollectionSizes
213+
: collectionComponents;
214+
215+
if (collectionSizes instanceof Map && collectionSizes.size > 0) {
216+
const selectedByCollection = new Map<string, number>();
217+
218+
newSelectedComponents.forEach((component) => {
219+
component.collectionKeys?.forEach((key) => {
220+
if (collectionSizes.has(key)) {
221+
selectedByCollection.set(key, (selectedByCollection.get(key) ?? 0) + 1);
222+
}
223+
});
224+
});
225+
226+
// Batch update all collection statuses
227+
setSelectedCollections((prevSelectedCollections) => {
228+
const collectionMap = new Map(
229+
prevSelectedCollections.map((c) => [c.key, c]),
197230
);
198-
}
231+
232+
collectionSizes.forEach((totalCount, collectionKey) => {
233+
const selectedCount = selectedByCollection.get(collectionKey) ?? 0;
234+
235+
if (selectedCount === 0) {
236+
collectionMap.delete(collectionKey);
237+
} else if (selectedCount >= totalCount) {
238+
collectionMap.set(collectionKey, { key: collectionKey, status: 'selected' });
239+
} else {
240+
collectionMap.set(collectionKey, { key: collectionKey, status: 'indeterminate' });
241+
}
242+
});
243+
244+
return Array.from(collectionMap.values());
245+
});
199246
}
200247

201248
onChangeComponentSelection?.(newSelectedComponents);
202249
return newSelectedComponents;
203250
});
204-
}, []);
251+
}, [onChangeComponentSelection]);
205252

206253
const removeComponentFromSelectedComponents = useCallback<ComponentSelectedEvent>((
207254
selectedComponent: SelectedComponent,
208-
collectionComponents?: SelectedComponent[] | number,
255+
collectionComponents?: CollectionData | Map<string, number>,
209256
) => {
210-
const componentsToRemove = Array.isArray(collectionComponents) && collectionComponents.length
211-
? collectionComponents
257+
const isCollectionSelection = collectionComponents && 'components' in collectionComponents;
258+
const componentsToRemove = isCollectionSelection
259+
? collectionComponents.components
212260
: [selectedComponent];
213261
const usageKeysToRemove = new Set(componentsToRemove.map((c) => c.usageKey));
214262

@@ -217,33 +265,48 @@ export const ComponentPickerProvider = ({
217265
(component) => !usageKeysToRemove.has(component.usageKey),
218266
);
219267

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),
268+
const collectionSizes = isCollectionSelection
269+
? collectionComponents.affectedCollectionSizes
270+
: collectionComponents;
271+
272+
if (collectionSizes instanceof Map && collectionSizes.size > 0) {
273+
const selectedByCollection = new Map<string, number>();
274+
275+
// Only count components for collections we care about
276+
newSelectedComponents.forEach((component) => {
277+
component.collectionKeys?.forEach((key) => {
278+
if (collectionSizes.has(key)) {
279+
selectedByCollection.set(key, (selectedByCollection.get(key) ?? 0) + 1);
280+
}
281+
});
282+
});
283+
284+
// Batch update all collection statuses
285+
setSelectedCollections((prevSelectedCollections) => {
286+
const collectionMap = new Map(
287+
prevSelectedCollections.map((c) => [c.key, c]),
227288
);
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-
));
289+
290+
collectionSizes.forEach((totalCount, collectionKey) => {
291+
const selectedCount = selectedByCollection.get(collectionKey) ?? 0;
292+
293+
if (selectedCount === 0) {
294+
collectionMap.delete(collectionKey);
295+
} else if (selectedCount >= totalCount) {
296+
collectionMap.set(collectionKey, { key: collectionKey, status: 'selected' });
297+
} else {
298+
collectionMap.set(collectionKey, { key: collectionKey, status: 'indeterminate' });
299+
}
300+
});
301+
302+
return Array.from(collectionMap.values());
303+
});
241304
}
242305

243306
onChangeComponentSelection?.(newSelectedComponents);
244307
return newSelectedComponents;
245308
});
246-
}, []);
309+
}, [onChangeComponentSelection]);
247310

248311
const context = useMemo<ComponentPickerContextData>(() => {
249312
switch (componentPickerMode) {

0 commit comments

Comments
 (0)