From 6e8a9a8b256b0df1aae1fba0cbaef4fe5257330d Mon Sep 17 00:00:00 2001 From: "mykola.gervasyuk" Date: Fri, 21 Aug 2026 17:20:02 +0300 Subject: [PATCH 1/2] perf: look selected runs up in a set instead of scanning the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting every run of a build is one click, and each of the places that asks whether a run is selected walked the array of selected ids: the grouped selection model and card count do it per run of every group, the bulk operations do it per row, and a card does it per run behind it. With ten thousand runs selected that is a hundred million comparisons per render. Index the selected ids in a set and read membership off it. Measured in Chromium on a 10 000 run build: select all rows 265 ms -> 90 ms select all cards, grouped 306 ms -> 120 ms The card grid takes the set itself rather than the array, so it does not rebuild one per render. The tag filter's options also stopped building an array per run while collecting the values on offer — thousands of throwaway allocations on every keystroke. --- src/components/TestRunList/BulkOperation.tsx | 16 +++++-- src/components/TestRunList/TestRunGrid.tsx | 8 ++-- src/components/TestRunList/index.tsx | 48 ++++++++++++++------ 3 files changed, 49 insertions(+), 23 deletions(-) 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 = () => { ) : ( Date: Fri, 21 Aug 2026 17:20:09 +0300 Subject: [PATCH 2/2] perf: keep the published run order when it has not changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list republishes the order the details dialog navigates on every grid state change, which with thousands of runs means a fresh array of that many ids landing in the context — and every consumer re-rendering — even when the order is identical. Compare before storing, and keep the previous array when nothing moved. --- src/contexts/testRun.context.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/contexts/testRun.context.tsx b/src/contexts/testRun.context.tsx index 44ac6edd..58f349af 100644 --- a/src/contexts/testRun.context.tsx +++ b/src/contexts/testRun.context.tsx @@ -88,6 +88,19 @@ const initialState: State = { loading: false, }; +const sameOrder = ( + previous: GridRowId[] | undefined, + next: GridRowId[] | undefined, +): boolean => { + 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,