You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2026/04/15 17:30:45 UTC

(superset) branch master updated: fix(table): fix cross-filter not clearing on second click in Interactive Table (#39253)

This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new c2d96e0dce1 fix(table): fix cross-filter not clearing on second click in Interactive Table (#39253)
c2d96e0dce1 is described below

commit c2d96e0dce1910de1b6af086ba2b08a02ca0d2f5
Author: Maxime Beauchemin <ma...@gmail.com>
AuthorDate: Wed Apr 15 10:30:36 2026 -0700

    fix(table): fix cross-filter not clearing on second click in Interactive Table (#39253)
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
 .../src/components/ThemedAgGridReact/index.tsx     |   1 +
 .../src/AgGridTable/index.tsx                      |  25 ++-
 .../src/AgGridTableChart.tsx                       |  89 +++++++---
 .../src/utils/getCellClass.ts                      |   4 -
 .../src/utils/getCrossFilterDataMask.ts            |  49 ++++++
 .../src/PivotTableChart.tsx                        |  48 +++--
 .../plugins/plugin-chart-table/src/TableChart.tsx  |  11 +-
 .../plugin-chart-table/test/TableChart.test.tsx    | 194 +++++++++++++++++++++
 8 files changed, 368 insertions(+), 53 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx
index 2b027080365..a44c7b26c71 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx
@@ -214,6 +214,7 @@ export type {
   CellClassParams,
   IMenuActionParams,
   IHeaderParams,
+  SelectionChangedEvent,
   SortModelItem,
   ValueFormatterParams,
   ValueGetterParams,
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx
index 4b6bd9e276f..5659077765d 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/index.tsx
@@ -40,7 +40,7 @@ import {
   GridReadyEvent,
   GridState,
   CellClickedEvent,
-  IMenuActionParams,
+  SelectionChangedEvent,
 } from '@superset-ui/core/components/ThemedAgGridReact';
 import { t } from '@apache-superset/core/translation';
 import {
@@ -96,8 +96,9 @@ export interface AgGridTableProps {
   percentMetrics: string[];
   serverPageLength: number;
   hasServerPageLengthChanged: boolean;
-  handleCrossFilter: (event: CellClickedEvent | IMenuActionParams) => void;
-  isActiveFilterValue: (key: string, val: DataRecordValue) => boolean;
+  handleCellClicked: (event: CellClickedEvent) => void;
+  handleSelectionChanged: (event: SelectionChangedEvent) => void;
+  filters?: Record<string, DataRecordValue[]> | null;
   renderTimeComparisonDropdown: () => JSX.Element | null;
   cleanedTotals: DataRecord;
   showTotals: boolean;
@@ -135,8 +136,9 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
     percentMetrics,
     serverPageLength,
     hasServerPageLengthChanged,
-    handleCrossFilter,
-    isActiveFilterValue,
+    handleCellClicked,
+    handleSelectionChanged,
+    filters,
     renderTimeComparisonDropdown,
     cleanedTotals,
     showTotals,
@@ -422,6 +424,15 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
       }
     }, [width]);
 
+    useEffect(() => {
+      if (
+        (!filters || Object.keys(filters).length === 0) &&
+        gridRef.current?.api?.getSelectedRows().length
+      ) {
+        gridRef.current.api.deselectAll();
+      }
+    }, [filters]);
+
     const onGridReady = (params: GridReadyEvent) => {
       // This will make columns fill the grid width
       params.api.sizeColumnsToFit();
@@ -500,7 +511,8 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
           onColumnGroupOpened={params => params.api.sizeColumnsToFit()}
           rowSelection="multiple"
           animateRows
-          onCellClicked={handleCrossFilter}
+          onCellClicked={handleCellClicked}
+          onSelectionChanged={handleSelectionChanged}
           onFilterChanged={handleFilterChanged}
           onStateUpdated={handleGridStateChange}
           initialState={gridInitialState}
@@ -592,7 +604,6 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
             initialSortState: getInitialSortState(
               serverPaginationData?.sortBy || [],
             ),
-            isActiveFilterValue,
             lastFilteredColumn: serverPaginationData?.lastFilteredColumn,
             lastFilteredInputPosition:
               serverPaginationData?.lastFilteredInputPosition,
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx
index 0673bdd483a..2695b530dfe 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx
@@ -23,12 +23,12 @@ import {
   getTimeFormatterForGranularity,
 } from '@superset-ui/core';
 import { GenericDataType } from '@apache-superset/core/common';
-import { useCallback, useEffect, useState, useMemo } from 'react';
+import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
 import { isEqual } from 'lodash';
 
 import {
   CellClickedEvent,
-  IMenuActionParams,
+  SelectionChangedEvent,
 } from '@superset-ui/core/components/ThemedAgGridReact';
 import {
   AgGridTableChartTransformedProps,
@@ -40,7 +40,7 @@ import AgGridDataTable from './AgGridTable';
 import { updateTableOwnState } from './utils/externalAPIs';
 import TimeComparisonVisibility from './AgGridTable/components/TimeComparisonVisibility';
 import { useColDefs } from './utils/useColDefs';
-import { getCrossFilterDataMask } from './utils/getCrossFilterDataMask';
+import { buildSelectionCrossFilterDataMask } from './utils/getCrossFilterDataMask';
 import { StyledChartContainer } from './styles';
 import type { FilterState } from './utils/filterStateManager';
 
@@ -248,7 +248,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
 
   const isActiveFilterValue = useCallback(
     function isActiveFilterValue(key: string, val: DataRecordValue) {
-      return !!filters && filters[key]?.includes(val);
+      if (!filters || !filters[key]) return false;
+      return filters[key].some(filterVal => {
+        if (filterVal === val) return true;
+        if (filterVal instanceof Date && val instanceof Date) {
+          return filterVal.getTime() === val.getTime();
+        }
+        return false;
+      });
     },
     [filters],
   );
@@ -263,37 +270,68 @@ export default function TableChart<D extends DataRecord = DataRecord>(
     [timeGrain, isRawRecords],
   );
 
-  const toggleFilter = useCallback(
-    (event: CellClickedEvent | IMenuActionParams) => {
+  const activeColumnRef = useRef<string | null>(null);
+
+  const handleCellClicked = useCallback(
+    (event: CellClickedEvent) => {
+      if (!emitCrossFilters || !event.column) return;
+      const colDef = event.column.getColDef();
+      if (colDef.context?.isMetric || colDef.context?.isPercentMetric) return;
+
+      const key = event.column.getColId();
+      activeColumnRef.current = key;
+
+      // Re-click on already-filtered single selection → untoggle
+      // AG Grid doesn't change selection when re-clicking the same row,
+      // so onSelectionChanged won't fire — handle clear directly here
+      const selectedNodes = event.api.getSelectedNodes();
       if (
-        emitCrossFilters &&
-        event.column &&
-        !(
-          event.column.getColDef().context?.isMetric ||
-          event.column.getColDef().context?.isPercentMetric
-        )
+        selectedNodes.length === 1 &&
+        selectedNodes[0] === event.node &&
+        isActiveFilterValue(key, event.value)
       ) {
-        const crossFilterProps = {
-          key: event.column.getColId(),
-          value: event.value,
-          filters,
-          timeGrain,
-          isActiveFilterValue,
-          timestampFormatter,
-        };
-        setDataMask(getCrossFilterDataMask(crossFilterProps).dataMask);
+        event.node.setSelected(false);
+        setDataMask(
+          buildSelectionCrossFilterDataMask({
+            key,
+            values: [],
+            timeGrain,
+            timestampFormatter,
+          }).dataMask,
+        );
       }
     },
     [
       emitCrossFilters,
+      isActiveFilterValue,
       setDataMask,
-      filters,
       timeGrain,
-      isActiveFilterValue,
       timestampFormatter,
     ],
   );
 
+  const handleSelectionChanged = useCallback(
+    (event: SelectionChangedEvent) => {
+      if (!emitCrossFilters || !activeColumnRef.current) return;
+
+      const key = activeColumnRef.current;
+      const selectedRows = event.api.getSelectedRows();
+      const values = selectedRows
+        .map(row => row[key] as DataRecordValue)
+        .filter(v => v != null);
+
+      setDataMask(
+        buildSelectionCrossFilterDataMask({
+          key,
+          values,
+          timeGrain,
+          timestampFormatter,
+        }).dataMask,
+      );
+    },
+    [emitCrossFilters, setDataMask, timeGrain, timestampFormatter],
+  );
+
   const handleServerPaginationChange = useCallback(
     (pageNumber: number, pageSize: number) => {
       const modifiedOwnState = {
@@ -395,11 +433,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
         onFilterChanged={handleFilterChanged}
         metricColumns={metricColumns}
         id={slice_id}
-        handleCrossFilter={toggleFilter}
+        handleCellClicked={handleCellClicked}
+        handleSelectionChanged={handleSelectionChanged}
+        filters={filters}
         percentMetrics={percentMetrics}
         serverPageLength={serverPageLength}
         hasServerPageLengthChanged={hasServerPageLengthChanged}
-        isActiveFilterValue={isActiveFilterValue}
         renderTimeComparisonDropdown={
           isUsingTimeComparison ? renderTimeComparisonVisibility : () => null
         }
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellClass.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellClass.ts
index fec452ef581..534357756b9 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellClass.ts
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCellClass.ts
@@ -27,15 +27,11 @@ type GetCellClassParams = CellClassParams & {
 
 const getCellClass = (params: GetCellClassParams) => {
   const { col, emitCrossFilters } = params;
-  const isActiveFilterValue = params?.context?.isActiveFilterValue;
   let className = '';
   if (emitCrossFilters) {
     if (!col?.isMetric) {
       className += ' dt-is-filter';
     }
-    if (isActiveFilterValue?.(col?.key, params?.value)) {
-      className += ' dt-is-active-filter';
-    }
     if (col?.config?.truncateLongCells) {
       className += ' dt-truncate-cell';
     }
diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCrossFilterDataMask.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCrossFilterDataMask.ts
index 6cf09e9be7e..ef404606931 100644
--- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCrossFilterDataMask.ts
+++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/getCrossFilterDataMask.ts
@@ -34,6 +34,55 @@ type GetCrossFilterDataMaskProps = {
   timestampFormatter: (value: DataRecordValue) => string;
 };
 
+type BuildSelectionCrossFilterProps = {
+  key: string;
+  values: DataRecordValue[];
+  timeGrain?: TimeGranularity;
+  timestampFormatter: (value: DataRecordValue) => string;
+};
+
+export const buildSelectionCrossFilterDataMask = ({
+  key,
+  values,
+  timeGrain,
+  timestampFormatter,
+}: BuildSelectionCrossFilterProps) => {
+  if (values.length === 0) {
+    return {
+      dataMask: {
+        extraFormData: { filters: [] },
+        filterState: { label: null, value: null, filters: null },
+      },
+    };
+  }
+
+  const updatedFilters: DataRecordFilters = { [key]: values };
+  const isTimestamp = key === DTTM_ALIAS;
+  const label = values
+    .map(v => (isTimestamp ? timestampFormatter(v) : v))
+    .join(', ');
+
+  return {
+    dataMask: {
+      extraFormData: {
+        filters: [
+          {
+            col: key,
+            op: 'IN' as const,
+            val: values.map(el => (el instanceof Date ? el.getTime() : el!)),
+            grain: isTimestamp ? timeGrain : undefined,
+          },
+        ],
+      },
+      filterState: {
+        label,
+        value: [values],
+        filters: updatedFilters,
+      },
+    },
+  };
+};
+
 export const getCrossFilterDataMask = ({
   key,
   value,
diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx
index ef1dbb9a998..56ed2cb5c90 100644
--- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx
@@ -439,11 +439,22 @@ export default function PivotTableChart(props: PivotTableProps) {
     [groupbyColumnsRaw, groupbyRowsRaw, setDataMask],
   );
 
+  const isActiveFilterValue = useCallback(
+    (key: string, val: DataRecordValue) => {
+      if (!selectedFilters || !selectedFilters[key]) return false;
+      return selectedFilters[key].some((filterVal: DataRecordValue) => {
+        if (filterVal === val) return true;
+        if (filterVal instanceof Date && val instanceof Date) {
+          return filterVal.getTime() === val.getTime();
+        }
+        return false;
+      });
+    },
+    [selectedFilters],
+  );
+
   const getCrossFilterDataMask = useCallback(
     (value: { [key: string]: string }) => {
-      const isActiveFilterValue = (key: string, val: DataRecordValue) =>
-        !!selectedFilters && selectedFilters[key]?.includes(val);
-
       if (!value) {
         return undefined;
       }
@@ -500,7 +511,7 @@ export default function PivotTableChart(props: PivotTableProps) {
         isCurrentValueSelected: isActiveFilterValue(key, val),
       };
     },
-    [groupbyColumnsRaw, groupbyRowsRaw, selectedFilters],
+    [groupbyColumnsRaw, groupbyRowsRaw, isActiveFilterValue, selectedFilters],
   );
 
   const toggleFilter = useCallback(
@@ -521,9 +532,6 @@ export default function PivotTableChart(props: PivotTableProps) {
         return;
       }
 
-      const isActiveFilterValue = (key: string, val: DataRecordValue) =>
-        !!selectedFilters && selectedFilters[key]?.includes(val);
-
       const filtersCopy = { ...filters };
       delete filtersCopy[METRIC_KEY];
 
@@ -533,16 +541,24 @@ export default function PivotTableChart(props: PivotTableProps) {
       }
 
       const [key, val] = filtersEntries[filtersEntries.length - 1];
+      const isMultiSelect = e.metaKey || e.ctrlKey;
 
       let updatedFilters = { ...selectedFilters };
-      // multi select
-      // if (selectedFilters && isActiveFilterValue(key, val)) {
-      //   updatedFilters[key] = selectedFilters[key].filter((x: DataRecordValue) => x !== val);
-      // } else {
-      //   updatedFilters[key] = [...(selectedFilters?.[key] || []), val];
-      // }
-      // single select
-      if (selectedFilters && isActiveFilterValue(key, val)) {
+      if (isMultiSelect) {
+        if (isActiveFilterValue(key, val)) {
+          updatedFilters[key] = (selectedFilters?.[key] || []).filter(
+            (x: DataRecordValue) => {
+              if (x === val) return false;
+              if (x instanceof Date && val instanceof Date) {
+                return x.getTime() !== val.getTime();
+              }
+              return true;
+            },
+          );
+        } else {
+          updatedFilters[key] = [...(selectedFilters?.[key] || []), val];
+        }
+      } else if (isActiveFilterValue(key, val)) {
         updatedFilters = {};
       } else {
         updatedFilters = {
@@ -557,7 +573,7 @@ export default function PivotTableChart(props: PivotTableProps) {
       }
       handleChange(updatedFilters);
     },
-    [emitCrossFilters, selectedFilters, handleChange],
+    [emitCrossFilters, isActiveFilterValue, selectedFilters, handleChange],
   );
 
   const tableOptions = useMemo(
diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
index 9faa71d8455..58f8a529a31 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
@@ -415,7 +415,16 @@ export default function TableChart<D extends DataRecord = DataRecord>(
 
   const isActiveFilterValue = useCallback(
     function isActiveFilterValue(key: string, val: DataRecordValue) {
-      return !!filters && filters[key]?.includes(val);
+      if (!filters || !filters[key]) return false;
+      return filters[key].some(filterVal => {
+        if (filterVal === val) return true;
+        // DateWithFormatter extends Date — compare by time value
+        // since memoization cache misses can create new instances
+        if (filterVal instanceof Date && val instanceof Date) {
+          return filterVal.getTime() === val.getTime();
+        }
+        return false;
+      });
     },
     [filters],
   );
diff --git a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
index 7cc0ef9239a..6988f9afc18 100644
--- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
@@ -1741,6 +1741,200 @@ describe('plugin-chart-table', () => {
         );
       });
 
+      test('clicking a cell emits cross-filter, clicking again clears it', () => {
+        const setDataMask = jest.fn();
+        const props = transformProps({
+          ...testData.basic,
+          hooks: { setDataMask },
+          emitCrossFilters: true,
+        });
+        const { rerender } = render(
+          <ProviderWrapper>
+            <TableChart
+              {...props}
+              emitCrossFilters
+              setDataMask={setDataMask}
+              sticky={false}
+            />
+          </ProviderWrapper>,
+        );
+
+        // Click a string cell to apply cross-filter
+        const nameCell = screen.getByText('Michael');
+        fireEvent.click(nameCell);
+
+        // Find the cross-filter call (not the ownState call)
+        const crossFilterCall = setDataMask.mock.calls.find(
+          (call: any[]) => call[0]?.filterState?.filters,
+        );
+        expect(crossFilterCall).toBeDefined();
+        const firstCallArg = crossFilterCall![0];
+        // Should set the filter
+        expect(firstCallArg.filterState.filters).toEqual({
+          name: ['Michael'],
+        });
+        expect(firstCallArg.extraFormData.filters).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              col: 'name',
+              op: 'IN',
+              val: ['Michael'],
+            }),
+          ]),
+        );
+
+        // Now simulate Redux updating the filters prop (as would happen in dashboard)
+        setDataMask.mockClear();
+        rerender(
+          <ProviderWrapper>
+            <TableChart
+              {...props}
+              emitCrossFilters
+              setDataMask={setDataMask}
+              filters={{ name: ['Michael'] }}
+              sticky={false}
+            />
+          </ProviderWrapper>,
+        );
+
+        // The cell should now have the active filter class
+        const activeCells = document.querySelectorAll('.dt-is-active-filter');
+        expect(activeCells.length).toBeGreaterThan(0);
+
+        // Click same cell again to clear cross-filter
+        setDataMask.mockClear();
+        const sameCellAgain = screen.getByText('Michael');
+        fireEvent.click(sameCellAgain);
+
+        // Find the cross-filter clearing call
+        const clearCall = setDataMask.mock.calls.find(
+          (call: any[]) => call[0]?.filterState !== undefined,
+        );
+        expect(clearCall).toBeDefined();
+        const secondCallArg = clearCall![0];
+        // Should clear the filter
+        expect(secondCallArg.filterState.filters).toBeNull();
+        expect(secondCallArg.extraFormData.filters).toEqual([]);
+      });
+
+      test('cross-filter toggle works with DateWithFormatter values', () => {
+        const setDataMask = jest.fn();
+        const props = transformProps({
+          ...testData.basic,
+          hooks: { setDataMask },
+          emitCrossFilters: true,
+        });
+
+        // The data has a __timestamp column with DateWithFormatter values
+        // after processDataRecords. Let's verify this.
+        const timestampVal = props.data[0].__timestamp;
+        expect(timestampVal).toBeInstanceOf(DateWithFormatter);
+
+        const { rerender } = render(
+          <ProviderWrapper>
+            <TableChart
+              {...props}
+              emitCrossFilters
+              setDataMask={setDataMask}
+              sticky={false}
+            />
+          </ProviderWrapper>,
+        );
+
+        // Click a timestamp cell - find it by text content
+        const timestampCell = screen.getByText('2020-01-01 12:34:56');
+        fireEvent.click(timestampCell);
+
+        const crossFilterCall = setDataMask.mock.calls.find(
+          (call: any[]) => call[0]?.filterState?.filters,
+        );
+        expect(crossFilterCall).toBeDefined();
+        const firstCallArg = crossFilterCall![0];
+
+        // Now re-render with the filters from the first click
+        // This simulates what happens via Redux in the real app
+        setDataMask.mockClear();
+        rerender(
+          <ProviderWrapper>
+            <TableChart
+              {...props}
+              emitCrossFilters
+              setDataMask={setDataMask}
+              filters={firstCallArg.filterState.filters}
+              sticky={false}
+            />
+          </ProviderWrapper>,
+        );
+
+        // The timestamp cell should be active
+        const activeCells = document.querySelectorAll('.dt-is-active-filter');
+        expect(activeCells.length).toBeGreaterThan(0);
+
+        // Click the same timestamp cell again to clear
+        setDataMask.mockClear();
+        const sameCell = screen.getByText('2020-01-01 12:34:56');
+        fireEvent.click(sameCell);
+
+        const clearCall = setDataMask.mock.calls.find(
+          (call: any[]) => call[0]?.filterState !== undefined,
+        );
+        expect(clearCall).toBeDefined();
+        // Should CLEAR the filter (not re-apply it)
+        expect(clearCall![0].filterState.filters).toBeNull();
+        expect(clearCall![0].extraFormData.filters).toEqual([]);
+      });
+
+      test('cross-filter toggle clears when DateWithFormatter references differ', () => {
+        // Regression test: when memoizeOne cache misses between renders,
+        // new DateWithFormatter instances are created with different references.
+        // isActiveFilterValue must compare by time value, not reference.
+        const setDataMask = jest.fn();
+        const props = transformProps({
+          ...testData.basic,
+          hooks: { setDataMask },
+          emitCrossFilters: true,
+        });
+
+        const timestampVal = props.data[0].__timestamp as DateWithFormatter;
+        expect(timestampVal).toBeInstanceOf(DateWithFormatter);
+
+        // Build filters with a DIFFERENT DateWithFormatter instance (same time value)
+        const filterKey = '__timestamp';
+        const differentRef = new DateWithFormatter(timestampVal.input, {
+          formatter: timestampVal.formatter,
+        });
+        expect(differentRef).not.toBe(timestampVal); // different reference
+        expect(differentRef.getTime()).toBe(timestampVal.getTime()); // same time
+
+        const { container } = render(
+          <ProviderWrapper>
+            <TableChart
+              {...props}
+              emitCrossFilters
+              setDataMask={setDataMask}
+              filters={{ [filterKey]: [differentRef] }}
+              sticky={false}
+            />
+          </ProviderWrapper>,
+        );
+
+        // The cell should show active filter despite different reference
+        const activeCells = container.querySelectorAll('.dt-is-active-filter');
+        expect(activeCells.length).toBeGreaterThan(0);
+
+        // Clicking should CLEAR the filter, not re-apply it
+        setDataMask.mockClear();
+        const timestampCell = screen.getByText('2020-01-01 12:34:56');
+        fireEvent.click(timestampCell);
+
+        const clearCall = setDataMask.mock.calls.find(
+          (call: any[]) => call[0]?.filterState !== undefined,
+        );
+        expect(clearCall).toBeDefined();
+        expect(clearCall![0].filterState.filters).toBeNull();
+        expect(clearCall![0].extraFormData.filters).toEqual([]);
+      });
+
       test('recalculates totals when user filters data', async () => {
         const formDataWithTotals = {
           ...testData.basic.formData,