diff --git a/src/components/TestRunList/BulkOperation.tsx b/src/components/TestRunList/BulkOperation.tsx index bf8ea9d3..dff283c4 100644 --- a/src/components/TestRunList/BulkOperation.tsx +++ b/src/components/TestRunList/BulkOperation.tsx @@ -32,9 +32,15 @@ export const BulkOperation: React.FunctionComponent<{ const [isProcessing, setIsProcessing] = React.useState(false); + // thousands of rows against thousands of selected ids, on every render + const selectedIdSet = React.useMemo( + () => new Set(selectedIds), + [selectedIds], + ); + const isMerge: boolean = React.useMemo( - () => !!rows.find((row) => selectedIds.includes(row.id))?.merge, - [selectedIds, rows], + () => !!rows.find((row) => selectedIdSet.has(row.id))?.merge, + [selectedIdSet, rows], ); // The ids of those rows that have status "new" or "resolved" @@ -43,11 +49,11 @@ export const BulkOperation: React.FunctionComponent<{ rows .filter( (row) => - selectedIds.includes(row.id) && + selectedIdSet.has(row.id) && [TestStatus.new, TestStatus.unresolved].includes(row.status), ) .map((row) => row.id), - [selectedIds, rows], + [selectedIdSet, rows], ); const toggleApproveDialogOpen = () => { @@ -131,7 +137,7 @@ export const BulkOperation: React.FunctionComponent<{ if (downloadDialogOpen) { const images = testRuns - .filter((testRun) => selectedIds.includes(testRun.id)) + .filter((testRun) => selectedIdSet.has(testRun.id)) .map((item) => ({ filename: item.name, name: item.imageName, diff --git a/src/components/TestRunList/TestRunGrid.tsx b/src/components/TestRunList/TestRunGrid.tsx index ef4e47d4..f575b092 100644 --- a/src/components/TestRunList/TestRunGrid.tsx +++ b/src/components/TestRunList/TestRunGrid.tsx @@ -62,7 +62,9 @@ const clipToLines = (lines: number) => ({ export const TestRunGrid: React.FunctionComponent<{ groups: TestRunGroup[]; - selectedIds: string[]; + // a set rather than the array: a card looks its runs up on every render, and + // a build of thousands can have every one of them selected + selectedIdSet: Set; density: TestRunDensity; showDiff: boolean; activeTags: string[]; @@ -72,7 +74,7 @@ export const TestRunGrid: React.FunctionComponent<{ onOpen: (id: string) => void; }> = ({ groups, - selectedIds, + selectedIdSet, density, showDiff, activeTags, @@ -90,7 +92,7 @@ export const TestRunGrid: React.FunctionComponent<{ > {groups.map(({ key, runs, representative }) => { const ids = runs.map((run) => run.id); - const selectedCount = ids.filter((id) => selectedIds.includes(id)).length; + const selectedCount = ids.filter((id) => selectedIdSet.has(id)).length; const tags = tagValuesOf(representative, tagFieldsFor(runs.length)); return ( diff --git a/src/components/TestRunList/index.tsx b/src/components/TestRunList/index.tsx index 96a01cb0..28fd4b0f 100644 --- a/src/components/TestRunList/index.tsx +++ b/src/components/TestRunList/index.tsx @@ -228,23 +228,34 @@ const TestRunList: React.FunctionComponent = () => { enabled: view === "grid" && !selectedTestRun, }); + // A build holds thousands of runs and selecting them all is one click, so + // every membership test below reads a set rather than scanning the array. + const selectedIdSet = React.useMemo( + () => new Set(selectedIds), + [selectedIds], + ); + // every filtered run, not just the page: the table's header box does the same const toggleAll = React.useCallback( (ids: string[]) => - setSelectedIds((prev) => - ids.every((id) => prev.includes(id)) ? [] : ids, - ), + setSelectedIds((prev) => { + const previous = new Set(prev); + return ids.every((id) => previous.has(id)) ? [] : ids; + }), [], ); // a group is selected or cleared as a whole const toggleGroup = React.useCallback( (ids: string[]) => - setSelectedIds((prev) => - ids.every((id) => prev.includes(id)) - ? prev.filter((id) => !ids.includes(id)) - : Array.from(new Set([...prev, ...ids])), - ), + setSelectedIds((prev) => { + const previous = new Set(prev); + if (ids.every((id) => previous.has(id))) { + const dropped = new Set(ids); + return prev.filter((id) => !dropped.has(id)); + } + return Array.from(new Set([...prev, ...ids])); + }), [], ); @@ -376,11 +387,11 @@ const TestRunList: React.FunctionComponent = () => { groupVariations ? groups .filter((group) => - group.runs.every((run) => selectedIds.includes(run.id)), + group.runs.every((run) => selectedIdSet.has(run.id)), ) .map((group) => group.representative.id) : selectedIds, - [groupVariations, groups, selectedIds], + [groupVariations, groups, selectedIds, selectedIdSet], ); // the dialog walks the grid's runs in the grid's order, groups expanded, so @@ -397,9 +408,9 @@ const TestRunList: React.FunctionComponent = () => { const selectedCardCount = React.useMemo( () => groups.filter((group) => - group.runs.some((run) => selectedIds.includes(run.id)), + group.runs.some((run) => selectedIdSet.has(run.id)), ).length, - [groups, selectedIds], + [groups, selectedIdSet], ); // clamped rather than corrected in state: turning grouping off and on again @@ -430,7 +441,14 @@ const TestRunList: React.FunctionComponent = () => { const present = new Set(tagFilter); testRuns.forEach((run) => { if (matchesNameQuery(run, query) && matchesStatuses(run, statusFilter)) { - runTagValues(run).forEach((value) => present.add(value)); + // read the fields straight off the run: an array per run is thousands + // of throwaway allocations on every keystroke + TAG_FIELDS.forEach((field) => { + const value = run[field]; + if (typeof value === "string" && value) { + present.add(value); + } + }); } }); const fieldIndex = (value: string): number => { @@ -648,7 +666,7 @@ const TestRunList: React.FunctionComponent = () => { onSortChange={setGridSort} density={density} selectedCount={ - groupedRunIds.filter((id) => selectedIds.includes(id)).length + groupedRunIds.filter((id) => selectedIdSet.has(id)).length } totalCount={groupedRunIds.length} onToggleAll={() => toggleAll(groupedRunIds)} @@ -668,7 +686,7 @@ const TestRunList: React.FunctionComponent = () => { ) : ( { + if (previous === next) { + return true; + } + if (!previous || !next || previous.length !== next.length) { + return false; + } + return previous.every((id, index) => id === next[index]); +}; + function testRunReducer(state: State, action: IAction): State { switch (action.type) { case "select": @@ -98,6 +111,12 @@ function testRunReducer(state: State, action: IAction): State { }; case "filterSort": + // The list republishes its order on every grid state change, and with + // thousands of runs a fresh array re-renders everything reading this + // context for nothing. Keep the previous one when the order is the same. + if (sameOrder(state.filteredSortedTestRunIds, action.payload)) { + return state; + } return { ...state, filteredSortedTestRunIds: action.payload,