Optimize memoization and search debouncing on /targets page (#16589)

Moving the debouncing of the search field to the parent component and then
memoizing the ScrapePoolsList component prevents a lot of superfluous
re-renders of the entire scrape pools list that previously got triggered
immediately when you typed in the search box or even just collapsed a pool.
(While the computation of what data to show was already memoized in the
ScrapePoolList component, the component itself still had to re-render a lot
with the same data.)

Discovered this problem + verified fix using react-scan.

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2025-05-12 10:39:58 +02:00 committed by GitHub
parent 8b0d33e5b2
commit 5c06804df8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 270 additions and 261 deletions

View File

@ -19,7 +19,7 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useSuspenseAPIQuery } from "../../api/api"; import { useSuspenseAPIQuery } from "../../api/api";
import { Target, TargetsResult } from "../../api/responseTypes/targets"; import { Target, TargetsResult } from "../../api/responseTypes/targets";
import React, { FC, useMemo } from "react"; import React, { FC, memo, useMemo } from "react";
import { import {
humanizeDurationRelative, humanizeDurationRelative,
humanizeDuration, humanizeDuration,
@ -37,7 +37,6 @@ import CustomInfiniteScroll from "../../components/CustomInfiniteScroll";
import badgeClasses from "../../Badge.module.css"; import badgeClasses from "../../Badge.module.css";
import panelClasses from "../../Panel.module.css"; import panelClasses from "../../Panel.module.css";
import TargetLabels from "./TargetLabels"; import TargetLabels from "./TargetLabels";
import { useDebouncedValue } from "@mantine/hooks";
import { targetPoolDisplayLimit } from "./TargetsPage"; import { targetPoolDisplayLimit } from "./TargetsPage";
import { badgeIconStyle } from "../../styles"; import { badgeIconStyle } from "../../styles";
@ -145,12 +144,8 @@ type ScrapePoolListProp = {
searchFilter: string; searchFilter: string;
}; };
const ScrapePoolList: FC<ScrapePoolListProp> = ({ const ScrapePoolList: FC<ScrapePoolListProp> = memo(
poolNames, ({ poolNames, selectedPool, healthFilter, searchFilter }) => {
selectedPool,
healthFilter,
searchFilter,
}) => {
// Based on the selected pool (if any), load the list of targets. // Based on the selected pool (if any), load the list of targets.
const { const {
data: { data: {
@ -174,17 +169,15 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
(state) => state.targetsPage (state) => state.targetsPage
); );
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250);
const allPools = useMemo( const allPools = useMemo(
() => () =>
buildPoolsData( buildPoolsData(
selectedPool ? [selectedPool] : poolNames, selectedPool ? [selectedPool] : poolNames,
activeTargets, activeTargets,
debouncedSearch, searchFilter,
healthFilter healthFilter
), ),
[selectedPool, poolNames, activeTargets, debouncedSearch, healthFilter] [selectedPool, poolNames, activeTargets, searchFilter, healthFilter]
); );
const allPoolNames = Object.keys(allPools); const allPoolNames = Object.keys(allPools);
@ -205,8 +198,8 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
title="Hiding pools with no matching targets" title="Hiding pools with no matching targets"
icon={<IconInfoCircle />} icon={<IconInfoCircle />}
> >
Hiding {allPoolNames.length - shownPoolNames.length} empty pools due Hiding {allPoolNames.length - shownPoolNames.length} empty pools
to filters or no targets. due to filters or no targets.
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyPools(true)}> <Anchor ml="md" fz="1em" onClick={() => setShowEmptyPools(true)}>
Show empty pools Show empty pools
</Anchor> </Anchor>
@ -287,9 +280,12 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
</Anchor> </Anchor>
</Alert> </Alert>
) : pool.targets.length === 0 ? ( ) : pool.targets.length === 0 ? (
<Alert title="No matching targets" icon={<IconInfoCircle />}> <Alert
No targets in this pool match your filter criteria (omitted{" "} title="No matching targets"
{pool.count} filtered targets). icon={<IconInfoCircle />}
>
No targets in this pool match your filter criteria
(omitted {pool.count} filtered targets).
<Anchor <Anchor
ml="md" ml="md"
fz="1em" fz="1em"
@ -348,7 +344,9 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
label: { textTransform: "none" }, label: { textTransform: "none" },
}} }}
leftSection={ leftSection={
<IconRefresh style={badgeIconStyle} /> <IconRefresh
style={badgeIconStyle}
/>
} }
> >
{humanizeDurationRelative( {humanizeDurationRelative(
@ -383,7 +381,9 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
</Table.Td> </Table.Td>
<Table.Td valign="top"> <Table.Td valign="top">
<Badge <Badge
className={healthBadgeClass(target.health)} className={healthBadgeClass(
target.health
)}
> >
{target.health} {target.health}
</Badge> </Badge>
@ -417,6 +417,7 @@ const ScrapePoolList: FC<ScrapePoolListProp> = ({
</Accordion> </Accordion>
</Stack> </Stack>
); );
}; }
);
export default ScrapePoolList; export default ScrapePoolList;

View File

@ -30,9 +30,16 @@ import ScrapePoolList from "./ScrapePoolsList";
import { useSuspenseAPIQuery } from "../../api/api"; import { useSuspenseAPIQuery } from "../../api/api";
import { ScrapePoolsResult } from "../../api/responseTypes/scrapePools"; import { ScrapePoolsResult } from "../../api/responseTypes/scrapePools";
import { expandIconStyle, inputIconStyle } from "../../styles"; import { expandIconStyle, inputIconStyle } from "../../styles";
import { useDebouncedValue } from "@mantine/hooks";
export const targetPoolDisplayLimit = 20; export const targetPoolDisplayLimit = 20;
// Should be defined as a constant here instead of inline as a value
// to avoid unnecessary re-renders. Otherwise the empty array has
// a different reference on each render and causes subsequent memoized
// computations to re-run as long as no state filter is selected.
const emptyHealthFilter: string[] = [];
export default function TargetsPage() { export default function TargetsPage() {
// Load the list of all available scrape pools. // Load the list of all available scrape pools.
const { const {
@ -48,12 +55,13 @@ export default function TargetsPage() {
const [scrapePool, setScrapePool] = useQueryParam("pool", StringParam); const [scrapePool, setScrapePool] = useQueryParam("pool", StringParam);
const [healthFilter, setHealthFilter] = useQueryParam( const [healthFilter, setHealthFilter] = useQueryParam(
"health", "health",
withDefault(ArrayParam, []) withDefault(ArrayParam, emptyHealthFilter)
); );
const [searchFilter, setSearchFilter] = useQueryParam( const [searchFilter, setSearchFilter] = useQueryParam(
"search", "search",
withDefault(StringParam, "") withDefault(StringParam, "")
); );
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250);
const { collapsedPools, showLimitAlert } = useAppSelector( const { collapsedPools, showLimitAlert } = useAppSelector(
(state) => state.targetsPage (state) => state.targetsPage
@ -147,7 +155,7 @@ export default function TargetsPage() {
poolNames={scrapePools} poolNames={scrapePools}
selectedPool={(limited && scrapePools[0]) || scrapePool || null} selectedPool={(limited && scrapePools[0]) || scrapePool || null}
healthFilter={healthFilter as string[]} healthFilter={healthFilter as string[]}
searchFilter={searchFilter} searchFilter={debouncedSearch}
/> />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>