Repository: recharts/recharts (TypeScript)

Issue:
Stacked BarChart + Brush throws an error
## Do you want to request a feature or report a bug?
Report a bug

## What is the current behavior?
When adding a `<Brush />` to a stacked BarChart like `<Bar stackId="a" ... />` and dragging the slider around, the application crashes with a "value is undefined" error.

## If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem via codesandbox or similar (template: https://codesandbox.io/p/sandbox/simple-line-chart-v25dl4).

1. Take the StackBlitz from this Recharts example as the base: https://recharts.org/en-US/examples/StackedBarChart#stackblitz-open-project
2. Add a brush like `<Brush dataKey="name" />` to the chart
3. Start dragging the front of the brush or move the slider of the brush
4. The "value is undefined" error is thrown

## What is the expected behavior?
I would expect this use case to not throw an error as this exact constellation has been working just fine with Recharts v2.15.4

## Which versions of Recharts, and which browser / OS are affected by this issue? Did this work in previous versions of Recharts?
The issue occurs with Recharts v3.0.0, the last working version is Recharts v2.15.4
Browser / OS: Firefox 139.0.4 / Windows 11



Discussion:
Thanks for the report, can reproduce
I think I have a fix, I'll try to push later today



Patch:
diff --git a/src/cartesian/Bar.tsx b/src/cartesian/Bar.tsx
index 5d23841ff0..347d7f3377 100644
--- a/src/cartesian/Bar.tsx
+++ b/src/cartesian/Bar.tsx
@@ -617,7 +617,6 @@ export function computeBarRectangles({
   xAxisTicks,
   yAxisTicks,
   stackedData,
-  dataStartIndex,
   displayedData,
   offset,
   cells,
@@ -631,7 +630,6 @@ export function computeBarRectangles({
   xAxisTicks: TickItem[];
   yAxisTicks: TickItem[];
   stackedData: Series<Record<number, number>, DataKey<any>> | undefined;
-  dataStartIndex: number;
   offset: ChartOffset;
   displayedData: any[];
   cells: ReadonlyArray<ReactElement> | undefined;
@@ -645,7 +643,8 @@ export function computeBarRectangles({
     let value, x, y, width, height, background;
 
     if (stackedData) {
-      value = truncateByDomain(stackedData[dataStartIndex + index], stackedDomain);
+      // we don't need to use dataStartIndex here, because stackedData is already sliced from the selector
+      value = truncateByDomain(stackedData[index], stackedDomain);
     } else {
       value = getValueByDataKey(entry, dataKey);
 
diff --git a/src/state/selectors/areaSelectors.ts b/src/state/selectors/areaSelectors.ts
index afadb8904c..3e4f0846d3 100644
--- a/src/state/selectors/areaSelectors.ts
+++ b/src/state/selectors/areaSelectors.ts
@@ -7,6 +7,7 @@ import {
   selectStackGroups,
   selectTicksOfGraphicalItem,
   selectUnfilteredCartesianItems,
+  StackDataPoint,
   StackGroup,
 } from './axisSelectors';
 import { RechartsRootState } from '../store';
@@ -81,7 +82,7 @@ const selectGraphicalItemStackedData = (
   if (stackId == null) {
     return undefined;
   }
-  const groups: ReadonlyArray<Series<Record<string, unknown>, DataKey<any>>> = stackGroups[stackId]?.stackedData;
+  const groups: ReadonlyArray<Series<StackDataPoint, DataKey<any>>> = stackGroups[stackId]?.stackedData;
   return groups?.find(v => v.key === dataKey);
 };
 
diff --git a/src/state/selectors/axisSelectors.ts b/src/state/selectors/axisSelectors.ts
index 8601b1a44a..756c034c55 100644
--- a/src/state/selectors/axisSelectors.ts
+++ b/src/state/selectors/axisSelectors.ts
@@ -526,8 +526,10 @@ export function getErrorDomainByDataKey(
   );
 }
 
+export type StackDataPoint = [number, number];
+
 export type StackGroup = {
-  readonly stackedData: ReadonlyArray<Series<Record<string, unknown>, DataKey<any>>>;
+  readonly stackedData: ReadonlyArray<Series<StackDataPoint, DataKey<any>>>;
   readonly graphicalItems: ReadonlyArray<MaybeStackedGraphicalItem>;
 };
 
diff --git a/src/state/selectors/barSelectors.ts b/src/state/selectors/barSelectors.ts
index 3f1bf8771a..c244a449ba 100644
--- a/src/state/selectors/barSelectors.ts
+++ b/src/state/selectors/barSelectors.ts
@@ -9,6 +9,7 @@ import {
   selectStackGroups,
   selectTicksOfGraphicalItem,
   selectUnfilteredCartesianItems,
+  StackDataPoint,
   StackGroup,
 } from './axisSelectors';
 import { AxisId } from '../cartesianAxisSlice';
@@ -442,7 +443,7 @@ export const selectBarPosition: (
 export const combineStackedData = (
   stackGroups: Record<StackId, StackGroup> | undefined,
   barSettings: MaybeStackedGraphicalItem | undefined,
-): Series<Record<number, number>, DataKey<any>> | undefined => {
+): Series<StackDataPoint, DataKey<any>> | undefined => {
   if (!stackGroups || barSettings?.dataKey == null) {
     return undefined;
   }
@@ -458,8 +459,7 @@ export const combineStackedData = (
   if (!stackedData) {
     return undefined;
   }
-  // @ts-expect-error bar assumes numeric stacks, d3 returns string, unknown
-  const stack: Series<Record<number, number>, DataKey<any>> = stackedData.find(sd => sd.key === barSettings.dataKey);
+  const stack: Series<StackDataPoint, DataKey<any>> = stackedData.find(sd => sd.key === barSettings.dataKey);
   return stack;
 };
 
@@ -570,7 +570,6 @@ export const selectBarRectangles: (
       xAxisTicks,
       yAxisTicks,
       stackedData,
-      dataStartIndex,
       displayedData,
       offset,
       cells,


Test patch:
diff --git a/test/cartesian/Brush.spec.tsx b/test/cartesian/Brush.spec.tsx
index 5df6cb775e..420bcfa3ba 100644
--- a/test/cartesian/Brush.spec.tsx
+++ b/test/cartesian/Brush.spec.tsx
@@ -1,8 +1,8 @@
 import React, { useState } from 'react';
-import { describe, test, expect, vi } from 'vitest';
+import { describe, expect, test, vi } from 'vitest';
 import { fireEvent, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import { Brush, LineChart, Line, BarChart, ComposedChart, ReferenceLine, Customized, BrushProps } from '../../src';
+import { BarChart, Brush, BrushProps, ComposedChart, Customized, Line, LineChart, ReferenceLine } from '../../src';
 import { assertNotNull } from '../helper/assertNotNull';
 import { useAppSelector } from '../../src/state/hooks';
 import { selectAxisRangeWithReverse, selectDisplayedData } from '../../src/state/selectors/axisSelectors';
@@ -11,26 +11,7 @@ import { useViewBox } from '../../src/context/chartLayoutContext';
 import { createSelectorTestCase } from '../helper/createSelectorTestCase';
 import { selectChartDataWithIndexes } from '../../src/state/selectors/dataSelectors';
 import { useIsPanorama } from '../../src/context/PanoramaContext';
-
-type ExpectedBrush = {
-  x: string;
-  y: string;
-  width: string;
-  height: string;
-};
-
-function expectBrush(container: Element, expected: ExpectedBrush) {
-  assertNotNull(container);
-  const brush = container.querySelector('.recharts-brush rect');
-  assertNotNull(brush);
-  const actual = {
-    x: brush.getAttribute('x'),
-    y: brush.getAttribute('y'),
-    width: brush.getAttribute('width'),
-    height: brush.getAttribute('height'),
-  };
-  expect(actual).toEqual(expected);
-}
+import { expectBrush } from '../helper/expectBrush';
 
 describe('<Brush />', () => {
   const data = [
diff --git a/test/cartesian/Brush.stacked.spec.tsx b/test/cartesian/Brush.stacked.spec.tsx
new file mode 100644
index 0000000000..2338a2b058
--- /dev/null
+++ b/test/cartesian/Brush.stacked.spec.tsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import { fireEvent } from '@testing-library/react';
+import { describe } from 'vitest';
+import { createSelectorTestCase } from '../helper/createSelectorTestCase';
+import { BarChart, Bar, Brush } from '../../src';
+import { PageData } from '../_data';
+import { expectBrush } from '../helper/expectBrush';
+import { assertNotNull } from '../helper/assertNotNull';
+import { selectStackGroups } from '../../src/state/selectors/axisSelectors';
+
+describe('Brush in a stacked chart', () => {
+  const renderTestCase = createSelectorTestCase(({ children }) => (
+    <BarChart width={500} height={300} data={PageData}>
+      <Bar dataKey="pv" stackId="a" />
+      <Bar dataKey="uv" stackId="a" />
+      <Brush dataKey="name" />
+      {children}
+    </BarChart>
+  ));
+
+  describe('before moving the brush traveller', () => {
+    it('should render Brush', () => {
+      const { container } = renderTestCase();
+      expectBrush(container, {
+        height: '40',
+        width: '490',
+        x: '5',
+        y: '255',
+      });
+    });
+
+    it('should select stack groups', () => {
+      const { spy } = renderTestCase(state => selectStackGroups(state, 'xAxis', 0, false));
+      expect(spy).toHaveBeenLastCalledWith({
+        a: {
+          graphicalItems: [
+            {
+              barSize: undefined,
+              data: null,
+              dataKey: 'pv',
+              errorBars: [],
+              hide: false,
+              isPanorama: false,
+              stackId: 'a',
+              type: 'bar',
+              xAxisId: 0,
+              yAxisId: 0,
+              zAxisId: 0,
+            },
+            {
+              barSize: undefined,
+              data: null,
+              dataKey: 'uv',
+              errorBars: [],
+              hide: false,
+              isPanorama: false,
+              stackId: 'a',
+              type: 'bar',
+              xAxisId: 0,
+              yAxisId: 0,
+              zAxisId: 0,
+            },
+          ],
+          stackedData: expect.toBeRechartsStackedData([
+            [
+              // 6 elements for each stack, because PageData has 6 items
+              [0, 2400],
+              [0, 4567],
+              [0, 1398],
+              [0, 9800],
+              [0, 3908],
+              [0, 4800],
+            ],
+            [
+              [2400, 2800],
+              [4567, 4867],
+              [1398, 1698],
+              [9800, 10000],
+              [3908, 4186],
+              [4800, 4989],
+            ],
+          ]),
+        },
+      });
+    });
+  });
+
+  describe('after moving the brush traveller', () => {
+    function prime(container: Element) {
+      const traveller = container.querySelector('.recharts-brush-traveller');
+      assertNotNull(traveller);
+
+      fireEvent.mouseDown(traveller);
+      fireEvent.mouseMove(traveller, { clientX: 100, clientY: 0 });
+    }
+
+    it('should display fewer bars after moving the brush traveller', () => {
+      /* Reproduces https://github.com/recharts/recharts/issues/6006 */
+      const { container } = renderTestCase();
+      prime(container);
+      // Check if the brush has updated correctly
+      expectBrush(container, {
+        height: '40',
+        width: '490',
+        x: '5',
+        y: '255',
+      });
+    });
+
+    it('should select stack groups', () => {
+      const { container, spy } = renderTestCase(state => selectStackGroups(state, 'xAxis', 0, false));
+      prime(container);
+      expect(spy).toHaveBeenLastCalledWith({
+        a: {
+          graphicalItems: [
+            {
+              barSize: undefined,
+              data: null,
+              dataKey: 'pv',
+              errorBars: [],
+              hide: false,
+              isPanorama: false,
+              stackId: 'a',
+              type: 'bar',
+              xAxisId: 0,
+              yAxisId: 0,
+              zAxisId: 0,
+            },
+            {
+              barSize: undefined,
+              data: null,
+              dataKey: 'uv',
+              errorBars: [],
+              hide: false,
+              isPanorama: false,
+              stackId: 'a',
+              type: 'bar',
+              xAxisId: 0,
+              yAxisId: 0,
+              zAxisId: 0,
+            },
+          ],
+          stackedData: expect.toBeRechartsStackedData([
+            [
+              // 5 items only, because the brush has been moved
+              [0, 4567],
+              [0, 1398],
+              [0, 9800],
+              [0, 3908],
+              [0, 4800],
+            ],
+            [
+              [4567, 4867],
+              [1398, 1698],
+              [9800, 10000],
+              [3908, 4186],
+              [4800, 4989],
+            ],
+          ]),
+        },
+      });
+    });
+  });
+});
diff --git a/test/helper/expectBrush.tsx b/test/helper/expectBrush.tsx
new file mode 100644
index 0000000000..33e57062c8
--- /dev/null
+++ b/test/helper/expectBrush.tsx
@@ -0,0 +1,22 @@
+import { expect } from 'vitest';
+import { assertNotNull } from './assertNotNull';
+
+type ExpectedBrush = {
+  x: string;
+  y: string;
+  width: string;
+  height: string;
+};
+
+export function expectBrush(container: Element, expected: ExpectedBrush) {
+  assertNotNull(container);
+  const brush = container.querySelector('.recharts-brush rect');
+  assertNotNull(brush);
+  const actual = {
+    x: brush.getAttribute('x'),
+    y: brush.getAttribute('y'),
+    width: brush.getAttribute('width'),
+    height: brush.getAttribute('height'),
+  };
+  expect(actual).toEqual(expected);
+}
diff --git a/test/helper/expectStackGroups.ts b/test/helper/expectStackGroups.ts
new file mode 100644
index 0000000000..f93da70d92
--- /dev/null
+++ b/test/helper/expectStackGroups.ts
@@ -0,0 +1,84 @@
+import { SeriesPoint } from 'victory-vendor/d3-shape';
+import { StackDataPoint, StackGroup } from '../../src/state/selectors/axisSelectors';
+
+/*
+ * Almost the same type as StackData, but with the data
+ * being just an array of arrays of objects instead of a d3-stack object.
+ */
+type ExpectedStackedData = ReadonlyArray<ReadonlyArray<StackDataPoint>>;
+
+function seriesPointToTuple(seriesPoint: SeriesPoint<unknown>): StackDataPoint {
+  return [seriesPoint[0], seriesPoint[1]];
+}
+
+function seriesToArray(series: StackGroup['stackedData']): ExpectedStackedData {
+  return series.map((s): ReadonlyArray<StackDataPoint> => {
+    const array: Array<StackDataPoint> = [];
+    s.forEach(item => {
+      array.push(seriesPointToTuple(item));
+    });
+    return array;
+  });
+}
+
+/**
+ * Comparing stack data for equality.
+ * This is tricky because we use d3-stack to create the stack groups, and d3 likes to add static properties to arrays.
+ * This is fine for JavaScript, but jest/vitest produce confusing output because if you do expect(stackGroup).toEqual(expectedArray),
+ * you get a test failure that says "expected Array to equal Array", and it also says "serializes to the same string".
+ * Entirely unhelpful.
+ *
+ * So here comes this function. This will compare the arrays as if they were plain arrays, ignoring any static properties.
+ *
+ * @param received the received value, which should be an array of arrays of objects
+ * @param expected the expected value, which should be an array of arrays of objects
+ * @throws Error if the received value is not an array
+ * @throws Error if the received value does not match the expected value
+ * @returns void
+ */
+export function expectStackedData(received: unknown, expected: ExpectedStackedData): void {
+  if (!Array.isArray(received)) {
+    throw new Error(`stackedData error: expected ${received} to be an array`);
+  }
+  expect(seriesToArray(received)).toEqual(expected);
+}
+
+function rechartsStackedDataMatcher(received: unknown, expected: ExpectedStackedData) {
+  try {
+    expectStackedData(received, expected);
+    return {
+      pass: true,
+      message: () => 'Expected stack groups to not match',
+    };
+  } catch (e) {
+    // Uncomment the `debugger` here to see the actual error message, or place a breakpoint in your IDE
+    // debugger;
+    return {
+      pass: false,
+      message: e.message,
+    };
+  }
+}
+
+expect.extend({
+  toBeRechartsStackedData: rechartsStackedDataMatcher,
+});
+
+interface CustomMatchers {
+  /**
+   * This matcher will check that the received object is a Recharts stacked data object
+   * with the expected data.
+   *
+   * Unfortunately I cannot figure out how to make the actual error message display in the test output.
+   * If you know how to, please improve.
+   *
+   * @param expectedStackedData expected stacked data
+   */
+  toBeRechartsStackedData: (expectedStackedData: ExpectedStackedData) => void;
+}
+
+declare module 'vitest' {
+  // this is what the documentation tells me to do https://vitest.dev/guide/extending-matchers
+  // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+  interface AsymmetricMatchersContaining extends CustomMatchers {}
+}
diff --git a/test/state/selectors/axisSelectors.spec.tsx b/test/state/selectors/axisSelectors.spec.tsx
index 2f8a1ece6c..9a3ac798ac 100644
--- a/test/state/selectors/axisSelectors.spec.tsx
+++ b/test/state/selectors/axisSelectors.spec.tsx
@@ -2898,101 +2898,104 @@ describe('selectStackGroups', () => {
         <Comp />
       </BarChart>,
     );
-    // This fails because d3 likes to put extra properties in the stack array that vitest doesn't serialize.
-    // expect(stackGroupsSpy).toHaveBeenLastCalledWith({
-    //   a: {
-    //     graphicalItems: [
-    //       {
-    //         data: null,
-    //         dataKey: 'uv',
-    //         errorBars: [],
-    //         hide: false,
-    //         stackId: 'a',
-    //         type: 'bar',
-    //         xAxisId: 0,
-    //         yAxisId: 0,
-    //         zAxisId: 0,
-    //       },
-    //       {
-    //         data: null,
-    //         dataKey: 'pv',
-    //         errorBars: [],
-    //         hide: false,
-    //         stackId: 'a',
-    //         type: 'bar',
-    //         xAxisId: 0,
-    //         yAxisId: 0,
-    //         zAxisId: 0,
-    //       },
-    //     ],
-    //     stackedData: [
-    //       [
-    //         [0, 590],
-    //         [0, 590],
-    //         [0, 868],
-    //         [0, 1397],
-    //         [0, 1480],
-    //         [0, 1520],
-    //         [0, 1400],
-    //       ],
-    //       [
-    //         [590, 1390],
-    //         [590, 1390],
-    //         [868, 1835],
-    //         [1397, 2495],
-    //         [1480, 2680],
-    //         [1520, 2628],
-    //         [1400, 2080],
-    //       ],
-    //     ],
-    //   },
-    //   b: {
-    //     graphicalItems: [
-    //       {
-    //         data: null,
-    //         dataKey: 'uv',
-    //         errorBars: [],
-    //         hide: false,
-    //         stackId: 'b',
-    //         type: 'bar',
-    //         xAxisId: 0,
-    //         yAxisId: 0,
-    //         zAxisId: 0,
-    //       },
-    //       {
-    //         data: null,
-    //         dataKey: 'amt',
-    //         errorBars: [],
-    //         hide: false,
-    //         stackId: 'b',
-    //         type: 'bar',
-    //         xAxisId: 0,
-    //         yAxisId: 0,
-    //         zAxisId: 0,
-    //       },
-    //     ],
-    //     stackedData: [
-    //       [
-    //         [0, 590],
-    //         [0, 590],
-    //         [0, 868],
-    //         [0, 1397],
-    //         [0, 1480],
-    //         [0, 1520],
-    //         [0, 1400],
-    //       ],
-    //       [
-    //         [590, 1990],
-    //         [590, 1990],
-    //         [868, 2374],
-    //         [1397, 2386],
-    //         [1480, 2708],
-    //         [1520, 2620],
-    //         [1400, 3100],
-    //       ],
-    //     ],
-    //   },
-    // });
+    expect(stackGroupsSpy).toHaveBeenLastCalledWith({
+      a: {
+        graphicalItems: [
+          {
+            barSize: undefined,
+            data: null,
+            dataKey: 'uv',
+            errorBars: [],
+            hide: false,
+            isPanorama: false,
+            stackId: 'a',
+            type: 'bar',
+            xAxisId: 0,
+            yAxisId: 0,
+            zAxisId: 0,
+          },
+          {
+            barSize: undefined,
+            data: null,
+            dataKey: 'pv',
+            errorBars: [],
+            hide: false,
+            isPanorama: false,
+            stackId: 'a',
+            type: 'bar',
+            xAxisId: 0,
+            yAxisId: 0,
+            zAxisId: 0,
+          },
+        ],
+        stackedData: expect.toBeRechartsStackedData([
+          [
+            [0, 400],
+            [0, 300],
+            [0, 300],
+            [0, 200],
+            [0, 278],
+            [0, 189],
+          ],
+          [
+            [400, 2800],
+            [300, 4867],
+            [300, 1698],
+            [200, 10000],
+            [278, 4186],
+            [189, 4989],
+          ],
+        ]),
+      },
+      b: {
+        graphicalItems: [
+          {
+            barSize: undefined,
+            data: null,
+            dataKey: 'uv',
+            errorBars: [],
+            hide: false,
+            isPanorama: false,
+            stackId: 'b',
+            type: 'bar',
+            xAxisId: 0,
+            yAxisId: 0,
+            zAxisId: 0,
+          },
+          {
+            barSize: undefined,
+            data: null,
+            dataKey: 'amt',
+            errorBars: [],
+            hide: false,
+            isPanorama: false,
+            stackId: 'b',
+            type: 'bar',
+            xAxisId: 0,
+            yAxisId: 0,
+            zAxisId: 0,
+          },
+        ],
+        stackedData: expect.toBeRechartsStackedData([
+          [
+            [0, 400],
+            [0, 300],
+            [0, 300],
+            [0, 200],
+            [0, 278],
+            [0, 189],
+          ],
+          [
+            [400, 2800],
+            [300, 2700],
+            [300, 2700],
+            [200, 2600],
+            [278, 2678],
+            [189, 2589],
+          ],
+        ]),
+      },
+    });
     expect(stackGroupsSpy).toHaveBeenCalledTimes(2);
   });
 
diff --git a/vitest.config.mts b/vitest.config.mts
index c6886acfdd..8e5f381135 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -9,7 +9,7 @@ export default defineConfig({
   test: {
     environment: 'jsdom',
     globals: true,
-    setupFiles: ['test/vitest.setup.ts', 'test/helper/toBeRechartsScale.ts'],
+    setupFiles: ['test/vitest.setup.ts', 'test/helper/toBeRechartsScale.ts', 'test/helper/expectStackGroups.ts'],
     globalSetup: './test/vitest.global-setup.ts',
     exclude: ['node_modules', 'dist', '.idea', '.git', '.cache', 'build', 'scripts', '.stryker-tmp'],
     coverage: {