Search code examples
javascriptreactjsreact-nativevictory-charts

React Native fails to render mutliple chart components


I've run into a long running stumper. I have a chart component in react native. I want to render a series of charts using some apis data. Here is a fragment of the containing elements. The data is being collected and rendered as expected, until it's fed into the chart.

<View className={'w-full'}>
  {chartWidgets &&
    chartWidgets.length > 0 &&
    chartWidgets?.map((w: PageWidget<IQuickGraphWidget>, i) => {
      return (
        <View className={'w-full mb-4'} key={i}>
          {w?.widget?.settings?.graph?.split(':')[1] && (
            <ChartWidget
              enabled={true}
              widgetData={w}
              key={i}
              onSelectControl={() => {}}
              pointData={{}}
              graphId={w?.widget?.settings?.graph?.split(':')[1]}
              pos={i}
            />
          )}
        </View>
      );
    })}
</View>

below is the chart component. In my test scenario I want to render 3 charts, each charts having an array of on an average 1000 to 2000 points to render. When I get over 2 charts the render of the 3rd or further charts is always broken.

/** Vendor */
import React, {useEffect, useRef, useState} from 'react';
import {
  Pressable,
  View,
  Text,
  ActivityIndicator,
  TextInput,
} from 'react-native';
import {useQueries, useQuery} from '@tanstack/react-query';
import Modal from 'react-native-modal';
import {RadioGroup} from 'react-native-radio-buttons-group';
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome';
import {
  faEllipsisVertical,
  faLineChart,
} from '@fortawesome/free-solid-svg-icons';
import {useNavigation} from '@react-navigation/native';
import {StackNavigationProp} from '@react-navigation/stack';

/** Lib */
import {
  someApiEndpoint
} from 'someApiEndpoint';
import {cardShadow} from '../../utils/nativeStyles';

/** State */
import {useStateContext} from '../../stateManager';
import {VictoryLineChart} from './VictoryLineChart';
import {VictoryBarChart} from './VictoryBarChart';

export const ChartWidget: React.FC<any> = ({
  widgetData,
  pointData,
  handleOnSelectControl,
  enabled = true,
  graphId = '',
  inView = false,
  triggerModal = false,
  pos = 0,
}) => {
  /** Variables */
  const navigation = useNavigation<StackNavigationProp<any>>();
  const {appState} = useStateContext();

  const defaultEndDate = new Date();
  const defaultStartDate = new Date();

  defaultStartDate.setDate(defaultEndDate.getDate() - 1);

  const [startDate, setStartDate] = useState<Date | null>(null);
  const [endDate, setEndDate] = useState(defaultEndDate);

  const timeRangeOptions: any[] = [
    {id: '1', label: '1 Hour', value: 1},
    {id: '2', label: '12 Hours', value: 12},
    {id: '3', label: '1 Day', value: 24},
    {id: '4', label: '1 Week', value: 168},
    {id: '5', label: '1 Month', value: 720},
    {id: '6', label: '1 Year', value: 8760},
  ];

  const [selectedTimeRangeOption, setSelectedTimeRangeOption] = useState('3');
  const [scaleMinValue, setScaleMinValue] = useState('');
  const [scaleMaxValue, setScaleMaxValue] = useState('');
  const [showModal, setShowModal] = useState(false);
  const [modalSettingType, setModalSettingType] = useState('');
  const [initDateRange, setInitDateRange] = useState(false);

  const [chartSettings, setChartSettings] = useState<any | null>(null);
  const [chartData, setChartData] = useState<any | null>(null);

  const [isVisible, setIsVisible] = useState(enabled);
  const [shouldRefetch, setShouldRefetch] = useState(false);
  const [isLoading, setIsLoading] = useState(true);

  /** Utils */
  const parseDate = (dt): string => {
    const padL = (nr, len = 2, chr = '0') => `${nr}`.padStart(2, chr);

    const returnString = `${padL(dt.getFullYear())}-${padL(
      dt.getMonth() + 1,
    )}-${dt.getDate()} ${padL(dt.getHours())}:${padL(dt.getMinutes())}:${padL(
      dt.getSeconds(),
    )}`;

    return returnString.trim();
  };

  const getSelectedRangeLabel = () => {
    const option = timeRangeOptions.find(i => i.id === selectedTimeRangeOption);
    return option?.label || '';
  };

  const setDefaultStartDateRangeBySetting = defaultTimePeriod => {
    const defaultStartfromSettings = new Date();
    if (!startDate) {
      if (defaultTimePeriod === 604800) {
        setSelectedTimeRangeOption('4');
        defaultStartfromSettings.setDate(defaultEndDate.getDate() - 7);
      } else {
        defaultStartfromSettings.setDate(defaultEndDate.getDate() - 1);
      }
      setStartDate(defaultStartfromSettings);
      setInitDateRange(true);
      return defaultStartfromSettings;
    }
    setInitDateRange(true);
    return startDate;
  };

  const checkDisableForward = (): boolean => {
    const now = new Date();
    now.setMinutes(0);
    now.setSeconds(0);
    now.setMilliseconds(0);
    return endDate > now;
  };

  const getSettings = async (gId: number) => {
    setIsLoading(true);
    const settings = await someApiEndpoint(
      appState?.siteId,
      gId,
    );
    setChartSettings(settings);
    getData(graphId, settings);
  };

  const getData = async (gId, settings: any | null = null) => {
    setIsLoading(true);
    let queryStartDate: Date | null = startDate;
    if (isNaN(parseInt(gId)) || !settings?.web_quick_graph) {
      return null;
    }
    if (settings?.web_quick_graph && !queryStartDate) {
      queryStartDate = setDefaultStartDateRangeBySetting(
        settings.web_quick_graph?.settings?.default_time_period,
      );
    }
    
    const res: any = await someApiEndpoint();
    setChartData(res);
  };

  /** Hooks */
  useEffect(() => {
    if (graphId) {
      getSettings(graphId);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [graphId]);

  useEffect(() => {
    if (initDateRange) {
      getData(graphId, chartSettings);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [startDate, endDate]);

  // refetch when a user preference is changed
  useEffect(() => {
    if (shouldRefetch) {
      setShouldRefetch(false);

      getSettings(graphId);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [shouldRefetch]);

  useEffect(() => {
    if (showModal) {
      return;
    }

    if (chartSettings) {
      const listSetting =
        chartSettings?.web_quick_graph?.settings?.auto_display || false;
      const visible = inView ? true : listSetting;

      setIsVisible(visible);

      if (!visible) {
        return;
      }
    }

    if (!!chartSettings && !!chartData) {
      if (chartSettings?.web_quick_graph?.user_scale) {
        setScaleMinValue(
          chartSettings?.web_quick_graph?.user_scale?.minimum.toString(),
        );
        setScaleMaxValue(
          chartSettings?.web_quick_graph?.user_scale?.maximum.toString(),
        );
      } else if (chartSettings?.web_quick_graph?.value_axes) {
        setScaleMinValue(
          chartSettings?.web_quick_graph?.value_axes[0].minimum.toString(),
        );
        setScaleMaxValue(
          chartSettings?.web_quick_graph?.value_axes[0].maximum.toString(),
        );
      } else {
        let largest = -Infinity;
        let smallest = Infinity;

        for (const item of chartData?.data) {
          if (Array.isArray(item?.g) && item?.g?.length > 0) {
            for (const value of item.g) {
              if (value > largest) {
                largest = value;
              }

              if (value < smallest) {
                smallest = value;
              }
            }
          }
        }

        setScaleMinValue(smallest.toString());
        setScaleMaxValue(largest.toString());
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [chartSettings, chartData]);

  /** Handlers */
  const handleOnCancel = () => {};
  consthandleOnOpenModal = () => {};
  const handleOnSelectOption = val => {};
  const handleOnSetScale = () => {};
  const handleOnTimeline = () => {};
  const handleOnBack = () => {};
  const handleOnForward = () => {};
  const handleOnNavigateToChart = () => {};

  if (!isVisible || !chartSettings || !chartData) {
    return <></>;
  }

  return (
    <View
      className="w-full flex flex-1 bg-white"
      style={!inView ? cardShadow : {}}>
      {!inView && (
        <View className="flex flex-row justify-between w-full border-b border-ricado-gray mb-2 p-4">
          <Text className="text-ricado-green text-sm text-left pt-1">
            {widgetData?.widget?.settings?.title}
          </Text>
          <View className="flex flex-row">
            <Pressable onPress={handleOnNavigateToChart} className="mt-2 mr-4">
              <Text>
                <FontAwesomeIcon icon={faLineChart} />
              </Text>
            </Pressable>
            <Pressable onPress={handleOnOpenModal} className="mt-2">
              <Text>
                <FontAwesomeIcon icon={faEllipsisVertical} />
              </Text>
            </Pressable>
          </View>
        </View>
      )}

      <View className={'w-full relative'} style={{minHeight: 300}}>
        {chartData && chartSettings?.web_quick_graph?.type === 'line' && (
          <VictoryLineChart
            chartData={chartData}
            chartSettings={chartSettings}
          />
        )}

        {chartData && chartSettings?.web_quick_graph?.type === 'bar' && (
          <VictoryBarChart
            chartData={chartData}
            chartSettings={chartSettings}
          />
        )}
      </View>

      <View className="flex flex-row justify-between w-full border-y border-ricado-gray mt-2 p-4">
        <Pressable onPress={handleOnBack}>
          <Text className="text-black uppercase text-sm">
            {'\u25C0'} Back {getSelectedRangeLabel()}
          </Text>
        </Pressable>
        <Pressable onPress={handleOnForward} disabled={checkDisableForward()}>
          <Text
            className={`${
              checkDisableForward() ? 'text-gray-400' : 'text-black'
            } text-sm uppercase`}>
            Forward {getSelectedRangeLabel()} {'\u25B6'}
          </Text>
        </Pressable>
      </View>
    </View>
  );
};

so far I've tried: victory charts native, victory XL, amcharts5 in a webview, echarts, gifted chartds, multiple component and data fetching refactors (too many to list), useMemo, useRef, useCallback, Staggering renders, reducing data points, etc.

All have the same bug, data rendered fine when printed out there only and issue when rendering more than 2 charts.

Research tells me and issue with reactSVG but in the latest iteration I've swapped to victoryXL which replaces reactSVG with Skia and the problem remains. Have I just missing something simple?

Up to 2 instances of the charts will render but more than that it consistently fails. Not allowed to screenshot the failing render but I mean it draws a couple of gridlines of the chat component is broken including the header and footer which are just simple reactNative elements.

Sometimes they all just bunch up with the mostly rendered charts stacked and the container element just missing.

Often rotating the screen can fix it on a re render. sometimes (but it must re-render first time)

Remove the chart and the rest of the component and the data comes out as expected so I am convinced this is all chart related. No error message is being produced in the console.

Edit: this is a tricky one, assume I've been through a troubleshooting process and have identified the issue and already tried working through it with chatGPT and all the rudimentary things like Memoizing etc. Worth a note as well: the charts don't live update, they pull their data one time.


Solution

  • I'll answer my own question:

    We ultimately found that our use case wasn't going to work with react-svg due to hitting performance limitations of the rendering strategy.

    we ended converting to Victory Native XL and manually filling in the missing features we required with Shopify Skia.

    Additionally Skia allowed us to load the charts into mansonry layout also provided by Shopify running of Skia and it also automatically managed the redraws meaning we could include our charts inside view areas that we repainting often.

    at the time of writing we converted all charts and layouts to:

    "@shopify/flash-list": "^1.6.3",
    "@shopify/react-native-skia": "^1.2.3",