Browse Source

ref(ddm): Summary table performance (#62414)

Reducing render times by:
- Hoisting context usage to higher components.
- Creating stable widget mutation callbacks.
- Adding structural sharing to widgets array.
- Memoizing a few performance heavy components.
ArthurKnaus 1 year ago
parent
commit
40d181ca53

+ 28 - 2
static/app/utils/metrics/index.tsx

@@ -1,3 +1,4 @@
+import {useCallback, useRef} from 'react';
 import {InjectedRouter} from 'react-router';
 import moment from 'moment';
 import * as qs from 'query-string';
@@ -47,6 +48,7 @@ import {
   parseField,
   parseMRI,
 } from 'sentry/utils/metrics/mri';
+import useRouter from 'sentry/utils/useRouter';
 
 import {DateString, PageFilters} from '../../types/core';
 
@@ -461,12 +463,17 @@ export function isAllowedOp(op: string) {
   return !['max_timestamp', 'min_timestamp', 'histogram'].includes(op);
 }
 
-export function updateQuery(router: InjectedRouter, partialQuery: Record<string, any>) {
+export function updateQuery(
+  router: InjectedRouter,
+  queryUpdater:
+    | Record<string, any>
+    | ((query: Record<string, any>) => Record<string, any>)
+) {
   router.push({
     ...router.location,
     query: {
       ...router.location.query,
-      ...partialQuery,
+      ...queryUpdater,
     },
   });
 }
@@ -478,6 +485,25 @@ export function clearQuery(router: InjectedRouter) {
   });
 }
 
+export function useInstantRef<T>(value: T) {
+  const ref = useRef(value);
+  ref.current = value;
+  return ref;
+}
+
+export function useUpdateQuery() {
+  const router = useRouter();
+  // Store the router in a ref so that we can use it in the callback
+  // without needing to generate a new callback every time the location changes
+  const routerRef = useInstantRef(router);
+  return useCallback(
+    (partialQuery: Record<string, any>) => {
+      updateQuery(routerRef.current, partialQuery);
+    },
+    [routerRef]
+  );
+}
+
 // TODO(ddm): there has to be a nicer way to do this
 export function getSeriesName(
   group: MetricsGroup,

+ 16 - 5
static/app/views/ddm/chart.tsx

@@ -13,16 +13,18 @@ import {ReactEchartsRef} from 'sentry/types/echarts';
 import mergeRefs from 'sentry/utils/mergeRefs';
 import {formatMetricsUsingUnitAndOp, MetricDisplayType} from 'sentry/utils/metrics';
 import useRouter from 'sentry/utils/useRouter';
-import {useFocusAreaBrush} from 'sentry/views/ddm/chartBrush';
+import {FocusArea, useFocusAreaBrush} from 'sentry/views/ddm/chartBrush';
 import {DDM_CHART_GROUP} from 'sentry/views/ddm/constants';
-import {useDDMContext} from 'sentry/views/ddm/context';
 
 import {getFormatter} from '../../components/charts/components/tooltip';
 
 import {Series} from './widget';
 
 type ChartProps = {
+  addFocusArea: (area: FocusArea) => void;
   displayType: MetricDisplayType;
+  focusArea: FocusArea | null;
+  removeFocusArea: () => void;
   series: Series[];
   widgetIndex: number;
   operation?: string;
@@ -34,7 +36,18 @@ type ChartProps = {
 echarts.use(CanvasRenderer);
 
 export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
-  ({series, displayType, operation, widgetIndex}, forwardedRef) => {
+  (
+    {
+      series,
+      displayType,
+      operation,
+      widgetIndex,
+      addFocusArea,
+      focusArea,
+      removeFocusArea,
+    },
+    forwardedRef
+  ) => {
     const router = useRouter();
     const chartRef = useRef<ReactEchartsRef>(null);
 
@@ -42,8 +55,6 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
       isDisabled: false,
     });
 
-    const {focusArea, addFocusArea, removeFocusArea} = useDDMContext();
-
     const handleZoom = useCallback(
       (range: DateTimeObject) => {
         updateDateTime(range, router, {save: true});

+ 62 - 49
static/app/views/ddm/context.tsx

@@ -12,7 +12,8 @@ import {
   defaultMetricDisplayType,
   MetricDisplayType,
   MetricWidgetQueryParams,
-  updateQuery,
+  useInstantRef,
+  useUpdateQuery,
 } from 'sentry/utils/metrics';
 import {parseMRI} from 'sentry/utils/metrics/mri';
 import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
@@ -21,6 +22,7 @@ import usePageFilters from 'sentry/utils/usePageFilters';
 import useRouter from 'sentry/utils/useRouter';
 import {FocusArea} from 'sentry/views/ddm/chartBrush';
 import {DEFAULT_SORT_STATE} from 'sentry/views/ddm/constants';
+import {useStructuralSharing} from 'sentry/views/ddm/useStructuralSharing';
 
 interface DDMContextValue {
   addFocusArea: (area: FocusArea) => void;
@@ -70,72 +72,82 @@ const emptyWidget: MetricWidgetQueryParams = {
 
 export function useMetricWidgets() {
   const router = useRouter();
+  const updateQuery = useUpdateQuery();
+
+  const widgets = useStructuralSharing(
+    useMemo<MetricWidgetQueryParams[]>(() => {
+      const currentWidgets = JSON.parse(
+        router.location.query.widgets ?? JSON.stringify([emptyWidget])
+      );
+
+      return currentWidgets.map((widget: MetricWidgetQueryParams) => {
+        return {
+          mri: widget.mri,
+          op: widget.op,
+          query: widget.query,
+          groupBy: decodeList(widget.groupBy),
+          displayType: widget.displayType ?? defaultMetricDisplayType,
+          focusedSeries: widget.focusedSeries,
+          showSummaryTable: widget.showSummaryTable ?? true, // temporary default
+          powerUserMode: widget.powerUserMode,
+          sort: widget.sort ?? DEFAULT_SORT_STATE,
+          title: widget.title,
+        };
+      });
+    }, [router.location.query.widgets])
+  );
 
-  const widgets = useMemo<MetricWidgetQueryParams[]>(() => {
-    const currentWidgets = JSON.parse(
-      router.location.query.widgets ?? JSON.stringify([emptyWidget])
-    );
-
-    return currentWidgets.map((widget: MetricWidgetQueryParams) => {
-      return {
-        mri: widget.mri,
-        op: widget.op,
-        query: widget.query,
-        groupBy: decodeList(widget.groupBy),
-        displayType: widget.displayType ?? defaultMetricDisplayType,
-        focusedSeries: widget.focusedSeries,
-        showSummaryTable: widget.showSummaryTable ?? true, // temporary default
-        powerUserMode: widget.powerUserMode,
-        sort: widget.sort ?? DEFAULT_SORT_STATE,
-        title: widget.title,
-      };
-    });
-  }, [router.location.query.widgets]);
+  // We want to have it as a ref, so that we can use it in the setWidget callback
+  // without needing to generate a new callback every time the location changes
+  const currentWidgetsRef = useInstantRef(widgets);
 
   const setWidgets = useCallback(
-    (newWidgets: MetricWidgetQueryParams[]) => {
-      updateQuery(router, {
-        widgets: JSON.stringify(newWidgets),
+    (newWidgets: React.SetStateAction<MetricWidgetQueryParams[]>) => {
+      const currentWidgets = currentWidgetsRef.current;
+      updateQuery({
+        widgets: JSON.stringify(
+          typeof newWidgets === 'function' ? newWidgets(currentWidgets) : newWidgets
+        ),
       });
     },
-    [router]
+    [updateQuery, currentWidgetsRef]
   );
 
   const updateWidget = useCallback(
     (index: number, data: Partial<MetricWidgetQueryParams>) => {
-      const widgetsCopy = [...widgets];
-      widgetsCopy[index] = {...widgets[index], ...data};
-
-      setWidgets(widgetsCopy);
+      setWidgets(currentWidgets => {
+        const newWidgets = [...currentWidgets];
+        newWidgets[index] = {...currentWidgets[index], ...data};
+        return newWidgets;
+      });
     },
-    [widgets, setWidgets]
+    [setWidgets]
   );
 
   const addWidget = useCallback(() => {
-    const widgetsCopy = [...widgets];
-    widgetsCopy.push(emptyWidget);
-
-    setWidgets(widgetsCopy);
-  }, [widgets, setWidgets]);
+    setWidgets(currentWidgets => [...currentWidgets, emptyWidget]);
+  }, [setWidgets]);
 
   const removeWidget = useCallback(
     (index: number) => {
-      const widgetsCopy = [...widgets];
-      widgetsCopy.splice(index, 1);
-
-      setWidgets(widgetsCopy);
+      setWidgets(currentWidgets => {
+        const newWidgets = [...currentWidgets];
+        newWidgets.splice(index, 1);
+        return newWidgets;
+      });
     },
-    [setWidgets, widgets]
+    [setWidgets]
   );
 
   const duplicateWidget = useCallback(
     (index: number) => {
-      const widgetsCopy = [...widgets];
-      widgetsCopy.splice(index, 0, widgets[index]);
-
-      setWidgets(widgetsCopy);
+      setWidgets(currentWidgets => {
+        const newWidgets = [...currentWidgets];
+        newWidgets.splice(index, 0, currentWidgets[index]);
+        return newWidgets;
+      });
     },
-    [setWidgets, widgets]
+    [setWidgets]
   );
 
   return {
@@ -149,6 +161,7 @@ export function useMetricWidgets() {
 
 export function DDMContextProvider({children}: {children: React.ReactNode}) {
   const router = useRouter();
+  const updateQuery = useUpdateQuery();
 
   const [selectedWidgetIndex, setSelectedWidgetIndex] = useState(0);
   const [focusArea, setFocusArea] = useState<FocusArea | null>(null);
@@ -174,15 +187,15 @@ export function DDMContextProvider({children}: {children: React.ReactNode}) {
     (area: FocusArea) => {
       setFocusArea(area);
       setSelectedWidgetIndex(area.widgetIndex);
-      updateQuery(router, {focusArea: JSON.stringify(area)});
+      updateQuery({focusArea: JSON.stringify(area)});
     },
-    [router]
+    [updateQuery]
   );
 
   const handleRemoveFocusArea = useCallback(() => {
     setFocusArea(null);
-    updateQuery(router, {focusArea: null});
-  }, [router]);
+    updateQuery({focusArea: null});
+  }, [updateQuery]);
 
   // Load focus area from URL
   useEffect(() => {

+ 4 - 1
static/app/views/ddm/metricsExplorer.tsx

@@ -37,7 +37,10 @@ export default function MetricsExplorer() {
       }}
       projects={[]}
       environments={[]}
-      numberOfSiblings={0}
+      hasSiblings={false}
+      addFocusArea={() => {}}
+      removeFocusArea={() => {}}
+      focusArea={null}
     />
   );
 }

+ 3 - 3
static/app/views/ddm/queryBuilder.tsx

@@ -1,4 +1,4 @@
-import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
+import {Fragment, memo, useCallback, useEffect, useMemo, useState} from 'react';
 import styled from '@emotion/styled';
 
 import {navigateTo} from 'sentry/actionCreators/navigation';
@@ -53,7 +53,7 @@ function stopPropagation(e: React.MouseEvent) {
   e.stopPropagation();
 }
 
-export function QueryBuilder({
+export const QueryBuilder = memo(function QueryBuilder({
   metricsQuery,
   projects,
   displayType,
@@ -245,7 +245,7 @@ export function QueryBuilder({
       </QueryBuilderRow>
     </QueryBuilderWrapper>
   );
-}
+});
 
 interface MetricSearchBarProps extends Partial<SmartSearchBarProps> {
   onChange: (value: string) => void;

+ 14 - 3
static/app/views/ddm/scratchpad.tsx

@@ -17,8 +17,16 @@ import {useDDMContext} from 'sentry/views/ddm/context';
 import {MetricWidget} from './widget';
 
 export function MetricScratchpad() {
-  const {setSelectedWidgetIndex, selectedWidgetIndex, widgets, updateWidget, addWidget} =
-    useDDMContext();
+  const {
+    setSelectedWidgetIndex,
+    selectedWidgetIndex,
+    widgets,
+    updateWidget,
+    addWidget,
+    focusArea,
+    addFocusArea,
+    removeFocusArea,
+  } = useDDMContext();
   const {selection} = usePageFilters();
   const organization = useOrganization();
 
@@ -46,12 +54,15 @@ export function MetricScratchpad() {
           index={index}
           onSelect={setSelectedWidgetIndex}
           isSelected={selectedWidgetIndex === index}
-          numberOfSiblings={widgets.length - 1}
+          hasSiblings={widgets.length > 1}
           onChange={handleChange}
           widget={widget}
           datetime={selection.datetime}
           projects={selection.projects}
           environments={selection.environments}
+          addFocusArea={addFocusArea}
+          removeFocusArea={removeFocusArea}
+          focusArea={focusArea}
         />
       ))}
       <AddWidgetPanel

+ 17 - 16
static/app/views/ddm/summaryTable.tsx

@@ -1,4 +1,4 @@
-import {Fragment, useCallback} from 'react';
+import {Fragment, memo, useCallback} from 'react';
 import styled from '@emotion/styled';
 import colorFn from 'color';
 
@@ -13,12 +13,11 @@ import {getUtcDateString} from 'sentry/utils/dates';
 import {formatMetricsUsingUnitAndOp, SortState} from 'sentry/utils/metrics';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
-import useRouter from 'sentry/utils/useRouter';
 import {DEFAULT_SORT_STATE} from 'sentry/views/ddm/constants';
 import {Series} from 'sentry/views/ddm/widget';
 import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
 
-export function SummaryTable({
+export const SummaryTable = memo(function SummaryTable({
   series,
   operation,
   onRowClick,
@@ -37,10 +36,6 @@ export function SummaryTable({
   const organization = useOrganization();
 
   const hasActions = series.some(s => s.release || s.transaction);
-
-  const router = useRouter();
-  const {start, end, statsPeriod, project, environment} = router.location.query;
-
   const hasMultipleSeries = series.length > 1;
 
   const changeSort = useCallback(
@@ -80,11 +75,11 @@ export function SummaryTable({
         release
       )}/`,
       query: {
-        pageStart: start,
-        pageEnd: end,
-        pageStatsPeriod: statsPeriod,
-        project,
-        environment,
+        pageStart: selection.datetime.start,
+        pageEnd: selection.datetime.end,
+        pageStatsPeriod: selection.datetime.period,
+        project: selection.projects,
+        environment: selection.environments,
       },
     };
   };
@@ -191,7 +186,15 @@ export function SummaryTable({
                   }}
                 >
                   <Cell>
-                    <ColorDot color={color} isHidden={!!hidden} />
+                    <ColorDot
+                      color={color}
+                      isHidden={!!hidden}
+                      style={{
+                        backgroundColor: hidden
+                          ? 'transparent'
+                          : colorFn(color).alpha(1).string(),
+                      }}
+                    />
                   </Cell>
                   <TextOverflowCell>{name}</TextOverflowCell>
                   {/* TODO(ddm): Add a tooltip with the full value, don't add on click in case users want to copy the value */}
@@ -232,7 +235,7 @@ export function SummaryTable({
       </TableBodyWrapper>
     </SummaryTableWrapper>
   );
-}
+});
 
 function SortableHeaderCell({
   sortState,
@@ -335,8 +338,6 @@ const TextOverflowCell = styled(Cell)`
 `;
 
 const ColorDot = styled(`div`)<{color: string; isHidden: boolean}>`
-  background-color: ${p =>
-    p.isHidden ? 'transparent' : colorFn(p.color).alpha(1).string()};
   border: 1px solid ${p => p.color};
   border-radius: 50%;
   width: ${space(1)};

+ 51 - 0
static/app/views/ddm/useStructuralSharing.spec.tsx

@@ -0,0 +1,51 @@
+import {structuralSharing} from './useStructuralSharing';
+
+describe('structuralSharing', () => {
+  it('should return the same object if nothing changed', () => {
+    const obj = {a: 1, b: 2};
+    expect(structuralSharing(obj, {...obj})).toBe(obj);
+  });
+
+  it('should return a new object if something changed', () => {
+    const obj = {a: 1, b: 2};
+    expect(structuralSharing(obj, {...obj, a: 2})).not.toBe(obj);
+    expect(structuralSharing(obj, {...obj, a: 2})).toEqual({a: 2, b: 2});
+  });
+
+  it('should return the same array if nothing changed', () => {
+    const arr = [1, 2, 3];
+    expect(structuralSharing(arr, [...[1, 2, 3]])).toBe(arr);
+  });
+
+  it('should remove array elements', () => {
+    const arr = [1, 2, 3];
+    expect(structuralSharing(arr, [1, 2])).not.toBe(arr);
+    expect(structuralSharing(arr, [1, 2])).toEqual([1, 2]);
+  });
+
+  it('should return a new array if something changed', () => {
+    const arr = [1, 2, 3];
+    expect(structuralSharing(arr, [...[1, 2, 4]])).not.toBe(arr);
+    expect(structuralSharing(arr, [...[1, 2, 4]])).toEqual([1, 2, 4]);
+  });
+
+  it('should handle changes in nested objects', () => {
+    const obj = {a: {b: 1}, c: {d: 2}};
+    const newObj = structuralSharing(obj, {...obj, a: {b: 2}});
+    expect(newObj).toEqual({a: {b: 2}, c: {d: 2}});
+    expect(newObj).not.toBe(obj);
+    expect(newObj.a).not.toBe(obj.a);
+    expect(newObj.a.b).toBe(2);
+    expect(newObj.c).toBe(obj.c);
+  });
+
+  it('should handle changes in nested arrays', () => {
+    const arr = [{a: 1}, {b: 2}];
+    const newArr = structuralSharing(arr, [arr[0], {b: 3}, {c: 4}]);
+    expect(newArr).toEqual([{a: 1}, {b: 3}, {c: 4}]);
+    expect(newArr).not.toBe(arr);
+    expect(newArr[0]).toBe(arr[0]);
+    expect(newArr[1]).not.toBe(arr[1]);
+    expect(newArr[2]).not.toBe(arr[2]);
+  });
+});

+ 66 - 0
static/app/views/ddm/useStructuralSharing.tsx

@@ -0,0 +1,66 @@
+import {useMemo, useRef} from 'react';
+
+/**
+ * Check if two objects have the same keys
+ */
+const checkSameKeys = (obj1: any, obj2: any) => {
+  const keys1 = new Set(Object.keys(obj1));
+  const keys2 = new Set(Object.keys(obj2));
+  if (keys1.size !== keys2.size) {
+    return false;
+  }
+  for (const key in keys1) {
+    if (!keys2.has(key)) {
+      return false;
+    }
+  }
+  return true;
+};
+
+/**
+ * Merge oldVlaue and newValue while trying to preserve references of unchanged objects / arrays
+ */
+export function structuralSharing<T>(oldValue: T, newValue: T): T {
+  if (oldValue === newValue) {
+    return oldValue;
+  }
+
+  if (Array.isArray(oldValue) && Array.isArray(newValue)) {
+    let hasChanges = oldValue.length !== newValue.length;
+    const newArray = newValue.map((item, index) => {
+      const newItem = structuralSharing(oldValue[index], item);
+      if (newItem !== oldValue[index]) {
+        hasChanges = true;
+      }
+      return newItem;
+    });
+    return hasChanges ? (newArray as any) : oldValue;
+  }
+
+  if (oldValue === null || newValue === null) {
+    return newValue;
+  }
+
+  if (typeof oldValue === 'object' && typeof newValue === 'object') {
+    let hasChanges = !checkSameKeys(oldValue, newValue);
+    const newObj = Object.keys(newValue).reduce((acc, key) => {
+      acc[key] = structuralSharing(oldValue[key], newValue[key]);
+      if (acc[key] !== oldValue[key]) {
+        hasChanges = true;
+      }
+      return acc;
+    }, {});
+    return hasChanges ? (newObj as any) : oldValue;
+  }
+
+  return newValue;
+}
+
+export const useStructuralSharing = (value: any) => {
+  const previeousValue = useRef<any>(value);
+  return useMemo(() => {
+    const newValue = structuralSharing(previeousValue.current, value);
+    previeousValue.current = newValue;
+    return newValue;
+  }, [value]);
+};

+ 46 - 48
static/app/views/ddm/widget.tsx

@@ -8,7 +8,6 @@ import Alert from 'sentry/components/alert';
 import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
 import EmptyMessage from 'sentry/components/emptyMessage';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
-import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import Panel from 'sentry/components/panels/panel';
 import PanelBody from 'sentry/components/panels/panelBody';
 import {IconSearch} from 'sentry/icons';
@@ -25,6 +24,7 @@ import {parseMRI} from 'sentry/utils/metrics/mri';
 import {useMetricsDataZoom} from 'sentry/utils/metrics/useMetricsData';
 import theme from 'sentry/utils/theme';
 import {MetricChart} from 'sentry/views/ddm/chart';
+import {FocusArea} from 'sentry/views/ddm/chartBrush';
 import {MetricWidgetContextMenu} from 'sentry/views/ddm/contextMenu';
 import {QueryBuilder} from 'sentry/views/ddm/queryBuilder';
 import {SummaryTable} from 'sentry/views/ddm/summaryTable';
@@ -41,19 +41,26 @@ export const MetricWidget = memo(
     isSelected,
     onSelect,
     onChange,
-    numberOfSiblings,
+    hasSiblings,
+    addFocusArea,
+    removeFocusArea,
+    focusArea,
   }: {
+    addFocusArea: (area: FocusArea) => void;
     datetime: PageFilters['datetime'];
     environments: PageFilters['environments'];
+    focusArea: FocusArea | null;
+    hasSiblings: boolean;
     index: number;
     isSelected: boolean;
-    numberOfSiblings: number;
     onChange: (index: number, data: Partial<MetricWidgetQueryParams>) => void;
     onSelect: (index: number) => void;
     projects: PageFilters['projects'];
+    removeFocusArea: () => void;
     widget: MetricWidgetQueryParams;
   }) => {
     const [isEdit, setIsEdit] = useState(true);
+
     const handleChange = useCallback(
       (data: Partial<MetricWidgetQueryParams>) => {
         onChange(index, data);
@@ -80,7 +87,16 @@ export const MetricWidget = memo(
         environments,
         title: widget.title,
       }),
-      [widget, projects, datetime, environments]
+      [
+        widget.mri,
+        widget.query,
+        widget.op,
+        widget.groupBy,
+        widget.title,
+        projects,
+        datetime,
+        environments,
+      ]
     );
 
     const shouldDisplayEditControls = (isEdit && isSelected) || !metricsQuery.mri;
@@ -88,8 +104,8 @@ export const MetricWidget = memo(
     return (
       <MetricWidgetPanel
         // show the selection border only if we have more widgets than one
-        isHighlighted={isSelected && !!numberOfSiblings}
-        isHighlightable={!!numberOfSiblings}
+        isHighlighted={isSelected && !!hasSiblings}
+        isHighlightable={!!hasSiblings}
         onClick={() => onSelect(index)}
       >
         <PanelBody>
@@ -117,6 +133,9 @@ export const MetricWidget = memo(
               projects={projects}
               environments={environments}
               onChange={handleChange}
+              addFocusArea={addFocusArea}
+              focusArea={focusArea}
+              removeFocusArea={removeFocusArea}
               {...widget}
             />
           ) : (
@@ -136,12 +155,14 @@ export const MetricWidget = memo(
 
 const MetricWidgetHeader = styled('div')`
   display: flex;
-
   justify-content: space-between;
 `;
 
 interface MetricWidgetProps extends MetricWidgetQueryParams {
+  addFocusArea: (area: FocusArea) => void;
+  focusArea: FocusArea | null;
   onChange: (data: Partial<MetricWidgetQueryParams>) => void;
+  removeFocusArea: () => void;
   widgetIndex: number;
 }
 
@@ -152,6 +173,9 @@ const MetricWidgetBody = memo(
     focusedSeries,
     sort,
     widgetIndex,
+    addFocusArea,
+    focusArea,
+    removeFocusArea,
     ...metricsQuery
   }: MetricWidgetProps & PageFilters) => {
     const {mri, op, query, groupBy, projects, environments, datetime} = metricsQuery;
@@ -192,16 +216,23 @@ const MetricWidgetBody = memo(
       [focusedSeries, onChange, setHoveredSeries]
     );
 
-    const chartSeries = useMemo(
-      () =>
+    const chartSeries = useMemo(() => {
+      return (
         data &&
         getChartSeries(data, {
           mri,
           focusedSeries,
           groupBy: metricsQuery.groupBy,
           displayType,
-        }),
-      [data, displayType, focusedSeries, metricsQuery.groupBy, mri]
+        })
+      );
+    }, [data, displayType, focusedSeries, metricsQuery.groupBy, mri]);
+
+    const handleSortChange = useCallback(
+      newSort => {
+        onChange({sort: newSort});
+      },
+      [onChange]
     );
 
     if (!chartSeries || !data || isError) {
@@ -237,15 +268,15 @@ const MetricWidgetBody = memo(
           series={chartSeries}
           displayType={displayType}
           operation={metricsQuery.op}
-          {...normalizeChartTimeParams(data)}
           widgetIndex={widgetIndex}
+          addFocusArea={addFocusArea}
+          focusArea={focusArea}
+          removeFocusArea={removeFocusArea}
         />
         {metricsQuery.showSummaryTable && (
           <SummaryTable
             series={chartSeries}
-            onSortChange={newSort => {
-              onChange({sort: newSort});
-            }}
+            onSortChange={handleSortChange}
             sort={sort}
             operation={metricsQuery.op}
             onRowClick={toggleSeriesVisibility}
@@ -342,39 +373,6 @@ function getChartColorPalette(displayType: MetricDisplayType, length: number) {
   return palette.toReversed();
 }
 
-function normalizeChartTimeParams(data: MetricsApiResponse) {
-  const {
-    start,
-    end,
-    utc: utcString,
-    statsPeriod,
-  } = normalizeDateTimeParams(data, {
-    allowEmptyPeriod: true,
-    allowAbsoluteDatetime: true,
-    allowAbsolutePageDatetime: true,
-  });
-
-  const utc = utcString === 'true';
-
-  if (start && end) {
-    return utc
-      ? {
-          start: moment.utc(start).format(),
-          end: moment.utc(end).format(),
-          utc,
-        }
-      : {
-          start: moment(start).utc().format(),
-          end: moment(end).utc().format(),
-          utc,
-        };
-  }
-
-  return {
-    period: statsPeriod ?? '90d',
-  };
-}
-
 export type Series = {
   color: string;
   data: {name: number; value: number}[];