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

EnxDev 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 e403d9b0742 chore(lint): convert legacy plugin chart components to 
function components (#39452)
e403d9b0742 is described below

commit e403d9b07424daa02ac88925a71e91e2f8b75314
Author: Evan Rusackas <[email protected]>
AuthorDate: Sun Jul 12 15:37:46 2026 -0700

    chore(lint): convert legacy plugin chart components to function components 
(#39452)
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
    Co-authored-by: Enzo Martellucci <[email protected]>
---
 .../src/HorizonChart.tsx                           | 101 ++--
 .../legacy-plugin-chart-horizon/src/HorizonRow.tsx | 268 +++++-----
 .../test/HorizonChart.test.tsx                     |  51 ++
 .../test/HorizonRow.test.tsx                       | 102 ++++
 .../src/PairedTTest.tsx                            |  77 ++-
 .../src/TTestTable.tsx                             | 545 ++++++++++++---------
 .../test/TTestTable.test.tsx                       | 365 ++++++++++++++
 .../src/chart/WordCloud.tsx                        | 273 ++++++-----
 .../test/WordCloud.test.tsx                        | 161 ++++++
 9 files changed, 1324 insertions(+), 619 deletions(-)

diff --git 
a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx 
b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx
index a95cae279dd..9a10d4cd8b6 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx
@@ -16,8 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
-import { PureComponent } from 'react';
+import { memo, useMemo } from 'react';
 import { extent as d3Extent } from 'd3-array';
 import { ensureIsArray } from '@superset-ui/core';
 import { styled } from '@apache-superset/core/theme';
@@ -45,18 +44,6 @@ interface HorizonChartProps {
   offsetX?: number;
 }
 
-const defaultProps: Partial<HorizonChartProps> = {
-  className: '',
-  width: 800,
-  height: 600,
-  seriesHeight: 20,
-  bands: Math.floor(DEFAULT_COLORS.length / 2),
-  colors: DEFAULT_COLORS,
-  colorScale: 'series',
-  mode: 'offset',
-  offsetX: 0,
-};
-
 const StyledDiv = styled.div`
   ${({ theme }) => `
     .superset-legacy-chart-horizon {
@@ -80,24 +67,19 @@ const StyledDiv = styled.div`
   `}
 `;
 
-class HorizonChart extends PureComponent<HorizonChartProps> {
-  static defaultProps = defaultProps;
-
-  render() {
-    const {
-      className,
-      width,
-      height,
-      data,
-      seriesHeight,
-      bands,
-      colors,
-      colorScale,
-      mode,
-      offsetX,
-    } = this.props;
-
-    let yDomain: [number, number] | undefined;
+function HorizonChart({
+  className = '',
+  width = 800,
+  height = 600,
+  seriesHeight = 20,
+  data,
+  bands = Math.floor(DEFAULT_COLORS.length / 2),
+  colors = DEFAULT_COLORS,
+  colorScale = 'series',
+  mode = 'offset',
+  offsetX = 0,
+}: HorizonChartProps) {
+  const yDomain = useMemo((): [number, number] | undefined => {
     if (colorScale === 'overall') {
       const allValues = data.reduce<DataValue[]>(
         (acc, current) => acc.concat(current.values),
@@ -106,35 +88,36 @@ class HorizonChart extends 
PureComponent<HorizonChartProps> {
       const rawExtent = d3Extent(allValues, d => d.y);
       // Only set yDomain if we have valid min and max values
       if (rawExtent[0] != null && rawExtent[1] != null) {
-        yDomain = [rawExtent[0], rawExtent[1]];
+        return [rawExtent[0], rawExtent[1]];
       }
     }
+    return undefined;
+  }, [colorScale, data]);
 
-    return (
-      <StyledDiv>
-        <div
-          className={`superset-legacy-chart-horizon ${className}`}
-          style={{ height }}
-        >
-          {data.map(row => (
-            <HorizonRow
-              key={row.key.join(',')}
-              width={width}
-              height={seriesHeight}
-              title={ensureIsArray(row.key).join(', ')}
-              data={row.values}
-              bands={bands}
-              colors={colors}
-              colorScale={colorScale}
-              mode={mode}
-              offsetX={offsetX}
-              yDomain={yDomain}
-            />
-          ))}
-        </div>
-      </StyledDiv>
-    );
-  }
+  return (
+    <StyledDiv>
+      <div
+        className={`superset-legacy-chart-horizon ${className}`}
+        style={{ height }}
+      >
+        {data.map(row => (
+          <HorizonRow
+            key={row.key.join(',')}
+            width={width}
+            height={seriesHeight}
+            title={ensureIsArray(row.key).join(', ')}
+            data={row.values}
+            bands={bands}
+            colors={colors}
+            colorScale={colorScale}
+            mode={mode}
+            offsetX={offsetX}
+            yDomain={yDomain}
+          />
+        ))}
+      </div>
+    </StyledDiv>
+  );
 }
 
-export default HorizonChart;
+export default memo(HorizonChart);
diff --git 
a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx 
b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
index f399d850d7c..5431674b7e9 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
@@ -17,9 +17,7 @@
  * under the License.
  */
 /* eslint-disable no-continue, no-bitwise */
-/* eslint-disable react/jsx-sort-default-props */
-/* eslint-disable react/sort-prop-types */
-import { PureComponent } from 'react';
+import { useRef, useEffect, useCallback, memo } from 'react';
 import { extent as d3Extent } from 'd3-array';
 import { scaleLinear } from 'd3-scale';
 
@@ -52,162 +50,140 @@ interface HorizonRowProps {
   yDomain?: [number, number];
 }
 
-const defaultProps: Partial<HorizonRowProps> = {
-  className: '',
-  width: 800,
-  height: 20,
-  bands: DEFAULT_COLORS.length >> 1,
-  colors: DEFAULT_COLORS,
-  colorScale: 'series',
-  mode: 'offset',
-  offsetX: 0,
-  title: '',
-  yDomain: undefined,
-};
-
-class HorizonRow extends PureComponent<HorizonRowProps> {
-  static defaultProps = defaultProps;
-
-  private canvas: HTMLCanvasElement | null = null;
-
-  componentDidMount() {
-    this.drawChart();
-  }
-
-  componentDidUpdate() {
-    this.drawChart();
-  }
-
-  componentWillUnmount() {
-    this.canvas = null;
-  }
-
-  drawChart() {
-    if (this.canvas) {
-      const {
-        data: rawData,
-        yDomain,
-        width = 800,
-        height = 20,
-        bands = DEFAULT_COLORS.length >> 1,
-        colors = DEFAULT_COLORS,
-        colorScale,
-        offsetX = 0,
-        mode,
-      } = this.props;
-
-      const data =
-        colorScale === 'change'
-          ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
-          : rawData;
-
-      const context = this.canvas.getContext('2d');
-      if (!context) return;
-      context.imageSmoothingEnabled = false;
-      context.clearRect(0, 0, width, height);
-      // Reset transform
-      context.setTransform(1, 0, 0, 1, 0, 0);
-      context.translate(0.5, 0.5);
-
-      const step = width / data.length;
-      // the data frame currently being shown:
-      const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
-      const endIndex = Math.floor(
-        Math.min(data.length, startIndex + width / step),
-      );
-
-      // skip drawing if there's no data to be drawn
-      if (startIndex > data.length) {
-        return;
+function HorizonRow({
+  className = '',
+  width = 800,
+  height = 20,
+  data: rawData,
+  bands = DEFAULT_COLORS.length >> 1,
+  colors = DEFAULT_COLORS,
+  colorScale = 'series',
+  mode = 'offset',
+  offsetX = 0,
+  title = '',
+  yDomain,
+}: HorizonRowProps) {
+  const canvasRef = useRef<HTMLCanvasElement | null>(null);
+
+  const drawChart = useCallback(() => {
+    const canvas = canvasRef.current;
+    if (!canvas) return;
+
+    const data =
+      colorScale === 'change' && rawData.length > 0
+        ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
+        : rawData;
+
+    const context = canvas.getContext('2d');
+    if (!context) return;
+    context.imageSmoothingEnabled = false;
+    context.clearRect(0, 0, width, height);
+    // Reset transform
+    context.setTransform(1, 0, 0, 1, 0, 0);
+    context.translate(0.5, 0.5);
+
+    const step = width / data.length;
+    // the data frame currently being shown:
+    const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
+    const endIndex = Math.floor(
+      Math.min(data.length, startIndex + width / step),
+    );
+
+    // skip drawing if there's no data to be drawn
+    if (startIndex > data.length) {
+      return;
+    }
+
+    // Create y-scale
+    const [min, max] =
+      yDomain || (d3Extent(data, d => d.y) as [number, number]);
+    const y = scaleLinear()
+      .domain([0, Math.max(-min, max)])
+      .range([0, height]);
+
+    // we are drawing positive & negative bands separately to avoid mutating 
canvas state
+    // http://www.html5rocks.com/en/tutorials/canvas/performance/
+    let hasNegative = false;
+    // draw positive bands
+    let value: number;
+    let bExtents: number;
+    for (let b = 0; b < bands; b += 1) {
+      context.fillStyle = colors[bands + b];
+
+      // Adjust the range based on the current band index.
+      bExtents = (b + 1 - bands) * height;
+      y.range([bands * height + bExtents, bExtents]);
+
+      // only the current data frame is being drawn i.e. what's visible:
+      for (let i = startIndex; i < endIndex; i += 1) {
+        value = data[i].y;
+        if (value <= 0) {
+          hasNegative = true;
+          continue;
+        }
+        if (value !== undefined) {
+          context.fillRect(
+            offsetX + i * step,
+            y(value)!,
+            step + 1,
+            y(0)! - y(value)!,
+          );
+        }
+      }
+    }
+
+    // draw negative bands
+    if (hasNegative) {
+      // mirror the negative bands, by flipping the canvas
+      if (mode === 'offset') {
+        context.translate(0, height);
+        context.scale(1, -1);
       }
 
-      // Create y-scale
-      const [min, max] =
-        yDomain || (d3Extent(data, d => d.y) as [number, number]);
-      const y = scaleLinear()
-        .domain([0, Math.max(-min, max)])
-        .range([0, height]);
-
-      // we are drawing positive & negative bands separately to avoid mutating 
canvas state
-      // http://www.html5rocks.com/en/tutorials/canvas/performance/
-      let hasNegative = false;
-      // draw positive bands
-      let value: number;
-      let bExtents: number;
       for (let b = 0; b < bands; b += 1) {
-        context.fillStyle = colors[bands + b];
+        context.fillStyle = colors[bands - b - 1];
 
         // Adjust the range based on the current band index.
         bExtents = (b + 1 - bands) * height;
         y.range([bands * height + bExtents, bExtents]);
 
         // only the current data frame is being drawn i.e. what's visible:
-        for (let i = startIndex; i < endIndex; i += 1) {
-          value = data[i].y;
-          if (value <= 0) {
-            hasNegative = true;
+        for (let ii = startIndex; ii < endIndex; ii += 1) {
+          value = data[ii].y;
+          if (value >= 0) {
             continue;
           }
-          if (value !== undefined) {
-            context.fillRect(
-              offsetX + i * step,
-              y(value)!,
-              step + 1,
-              y(0)! - y(value)!,
-            );
-          }
-        }
-      }
-
-      // draw negative bands
-      if (hasNegative) {
-        // mirror the negative bands, by flipping the canvas
-        if (mode === 'offset') {
-          context.translate(0, height);
-          context.scale(1, -1);
-        }
-
-        for (let b = 0; b < bands; b += 1) {
-          context.fillStyle = colors[bands - b - 1];
-
-          // Adjust the range based on the current band index.
-          bExtents = (b + 1 - bands) * height;
-          y.range([bands * height + bExtents, bExtents]);
-
-          // only the current data frame is being drawn i.e. what's visible:
-          for (let ii = startIndex; ii < endIndex; ii += 1) {
-            value = data[ii].y;
-            if (value >= 0) {
-              continue;
-            }
-            context.fillRect(
-              offsetX + ii * step,
-              y(-value)!,
-              step + 1,
-              y(0)! - y(-value)!,
-            );
-          }
+          context.fillRect(
+            offsetX + ii * step,
+            y(-value)!,
+            step + 1,
+            y(0)! - y(-value)!,
+          );
         }
       }
     }
-  }
-
-  render() {
-    const { className, title, width, height } = this.props;
-
-    return (
-      <div className={`horizon-row ${className}`}>
-        <span className="title">{title}</span>
-        <canvas
-          ref={c => {
-            this.canvas = c;
-          }}
-          width={width}
-          height={height}
-        />
-      </div>
-    );
-  }
+  }, [
+    rawData,
+    yDomain,
+    width,
+    height,
+    bands,
+    colors,
+    colorScale,
+    offsetX,
+    mode,
+  ]);
+
+  useEffect(() => {
+    drawChart();
+  }, [drawChart]);
+
+  return (
+    <div className={`horizon-row ${className}`}>
+      <span className="title">{title}</span>
+      <canvas ref={canvasRef} width={width} height={height} />
+    </div>
+  );
 }
 
-export default HorizonRow;
+export default memo(HorizonRow);
diff --git 
a/superset-frontend/plugins/legacy-plugin-chart-horizon/test/HorizonChart.test.tsx
 
b/superset-frontend/plugins/legacy-plugin-chart-horizon/test/HorizonChart.test.tsx
new file mode 100644
index 00000000000..cd27c5e973a
--- /dev/null
+++ 
b/superset-frontend/plugins/legacy-plugin-chart-horizon/test/HorizonChart.test.tsx
@@ -0,0 +1,51 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen } from 'spec/helpers/testing-library';
+import HorizonChart from '../src/HorizonChart';
+
+beforeAll(() => {
+  // jsdom has no 2d context; HorizonRow guards on a null context
+  jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
+});
+
+const data = [
+  { key: ['series', 'a'], values: [{ y: 1 }, { y: 2 }] },
+  { key: ['series', 'b'], values: [{ y: 3 }, { y: 4 }] },
+];
+
+test('renders one row per data series with joined titles', () => {
+  const { container } = render(<HorizonChart data={data} />);
+
+  expect(container.querySelectorAll('.horizon-row')).toHaveLength(2);
+  expect(screen.getByText('series, a')).toBeInTheDocument();
+  expect(screen.getByText('series, b')).toBeInTheDocument();
+});
+
+test('applies the chart height to the container', () => {
+  const { container } = render(<HorizonChart data={data} height={300} />);
+
+  const chart = container.querySelector('.superset-legacy-chart-horizon');
+  expect(chart).toHaveStyle({ height: '300px' });
+});
+
+test('renders nothing but the container for empty data', () => {
+  const { container } = render(<HorizonChart data={[]} />);
+
+  expect(container.querySelectorAll('.horizon-row')).toHaveLength(0);
+});
diff --git 
a/superset-frontend/plugins/legacy-plugin-chart-horizon/test/HorizonRow.test.tsx
 
b/superset-frontend/plugins/legacy-plugin-chart-horizon/test/HorizonRow.test.tsx
new file mode 100644
index 00000000000..940e070d069
--- /dev/null
+++ 
b/superset-frontend/plugins/legacy-plugin-chart-horizon/test/HorizonRow.test.tsx
@@ -0,0 +1,102 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import HorizonRow from '../src/HorizonRow';
+
+const mockContext = {
+  imageSmoothingEnabled: false,
+  fillStyle: '',
+  clearRect: jest.fn(),
+  setTransform: jest.fn(),
+  translate: jest.fn(),
+  scale: jest.fn(),
+  fillRect: jest.fn(),
+};
+
+beforeAll(() => {
+  jest
+    .spyOn(HTMLCanvasElement.prototype, 'getContext')
+    .mockReturnValue(mockContext as unknown as CanvasRenderingContext2D);
+});
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+const positiveData = [{ y: 1 }, { y: 2 }, { y: 3 }];
+
+test('renders the title and a canvas of the given size', () => {
+  const { container } = render(
+    <HorizonRow data={positiveData} title="series-a" width={100} height={20} 
/>,
+  );
+
+  expect(screen.getByText('series-a')).toBeInTheDocument();
+  const canvas = container.querySelector('canvas');
+  expect(canvas).toHaveAttribute('width', '100');
+  expect(canvas).toHaveAttribute('height', '20');
+});
+
+test('draws positive bands to the canvas on mount', () => {
+  render(<HorizonRow data={positiveData} width={100} height={20} />);
+
+  expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 100, 20);
+  expect(mockContext.fillRect).toHaveBeenCalled();
+  // No negative values, so the canvas is never flipped
+  expect(mockContext.scale).not.toHaveBeenCalled();
+});
+
+test('flips the canvas to mirror negative bands in offset mode', () => {
+  render(
+    <HorizonRow
+      data={[{ y: -1 }, { y: 2 }]}
+      mode="offset"
+      width={100}
+      height={20}
+    />,
+  );
+
+  expect(mockContext.translate).toHaveBeenCalledWith(0, 20);
+  expect(mockContext.scale).toHaveBeenCalledWith(1, -1);
+  expect(mockContext.fillRect).toHaveBeenCalled();
+});
+
+test('redraws when the data prop changes', () => {
+  const { rerender } = render(
+    <HorizonRow data={positiveData} width={100} height={20} />,
+  );
+  const drawsAfterMount = mockContext.clearRect.mock.calls.length;
+
+  rerender(<HorizonRow data={[{ y: 5 }, { y: 6 }]} width={100} height={20} />);
+
+  expect(mockContext.clearRect.mock.calls.length).toBeGreaterThan(
+    drawsAfterMount,
+  );
+});
+
+test('memo bailout skips redraw when props are unchanged', () => {
+  const { rerender } = render(
+    <HorizonRow data={positiveData} width={100} height={20} />,
+  );
+  const drawsAfterMount = mockContext.clearRect.mock.calls.length;
+
+  rerender(<HorizonRow data={positiveData} width={100} height={20} />);
+
+  expect(mockContext.clearRect.mock.calls.length).toBe(drawsAfterMount);
+});
diff --git 
a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
 
b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
index c7a1edfa897..fd339dfb164 100644
--- 
a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
+++ 
b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
@@ -17,30 +17,23 @@
  * under the License.
  */
 /* eslint-disable react/no-array-index-key */
-import { PureComponent } from 'react';
+import { memo } from 'react';
 import { styled } from '@apache-superset/core/theme';
 import TTestTable, { DataEntry } from './TTestTable';
 
 interface PairedTTestProps {
-  alpha: number;
-  className: string;
+  alpha?: number;
+  className?: string;
   data: Record<string, DataEntry[]>;
   groups: string[];
-  liftValPrec: number;
+  liftValPrec?: number;
   metrics: string[];
-  pValPrec: number;
+  pValPrec?: number;
 }
 
-const defaultProps = {
-  alpha: 0.05,
-  className: '',
-  liftValPrec: 4,
-  pValPrec: 6,
-};
-
 const StyledDiv = styled.div`
   ${({ theme }) => `
-    .superset-legacy-chart-paired_ttest .scrollbar-container {
+    .superset-legacy-chart-paired-t-test .scrollbar-container {
       overflow: auto;
     }
 
@@ -114,35 +107,37 @@ const StyledDiv = styled.div`
   `}
 `;
 
-class PairedTTest extends PureComponent<PairedTTestProps> {
-  static defaultProps = defaultProps;
-
-  render() {
-    const { className, metrics, groups, data, alpha, pValPrec, liftValPrec } =
-      this.props;
-
-    return (
-      <StyledDiv>
-        <div className={`superset-legacy-chart-paired-t-test ${className}`}>
-          <div className="paired-ttest-table">
-            <div className="scrollbar-content">
-              {metrics.map((metric, i) => (
-                <TTestTable
-                  key={i}
-                  metric={metric}
-                  groups={groups}
-                  data={data[metric]}
-                  alpha={alpha}
-                  pValPrec={Math.min(pValPrec, 32)}
-                  liftValPrec={Math.min(liftValPrec, 32)}
-                />
-              ))}
-            </div>
+function PairedTTest({
+  alpha = 0.05,
+  className = '',
+  data,
+  groups,
+  liftValPrec = 4,
+  metrics,
+  pValPrec = 6,
+}: PairedTTestProps) {
+  return (
+    <StyledDiv>
+      <div className={`superset-legacy-chart-paired-t-test ${className}`}>
+        <div className="paired-ttest-table">
+          <div className="scrollbar-content">
+            {metrics.map((metric, i) => (
+              <TTestTable
+                key={i}
+                metric={metric}
+                groups={groups}
+                data={data[metric]}
+                alpha={alpha}
+                pValPrec={Math.min(pValPrec, 32)}
+                liftValPrec={Math.min(liftValPrec, 32)}
+              />
+            ))}
           </div>
         </div>
-      </StyledDiv>
-    );
-  }
+      </div>
+    </StyledDiv>
+  );
 }
 
-export default PairedTTest;
+// memo preserves the shallow-prop render bailout of the PureComponent original
+export default memo(PairedTTest);
diff --git 
a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
 
b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
index 95922fd32de..ca0881558e2 100644
--- 
a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
+++ 
b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
@@ -18,7 +18,7 @@
  */
 /* eslint-disable react/no-array-index-key, react/jsx-no-bind */
 import dist from 'distributions';
-import { Component } from 'react';
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
 import { Table, Tr, Td, Thead, Th } from 'reactable';
 
 interface DataPointValue {
@@ -32,279 +32,338 @@ export interface DataEntry {
 }
 
 interface TTestTableProps {
-  alpha: number;
+  alpha?: number;
   data: DataEntry[];
   groups: string[];
-  liftValPrec: number;
+  liftValPrec?: number;
   metric: string;
-  pValPrec: number;
+  pValPrec?: number;
 }
 
-interface TTestTableState {
-  control: number;
-  liftValues: (string | number)[];
-  pValues: (string | number)[];
-}
-
-const defaultProps = {
-  alpha: 0.05,
-  liftValPrec: 4,
-  pValPrec: 6,
-};
+function TTestTable({
+  alpha = 0.05,
+  data,
+  groups,
+  liftValPrec = 4,
+  metric,
+  pValPrec = 6,
+}: TTestTableProps) {
+  const [control, setControl] = useState(0);
+  // Mirrors `control` so the data-change effect can read the latest value
+  // without re-running (and recomputing) after every row click
+  const controlRef = useRef(0);
+  const [liftValues, setLiftValues] = useState<(string | number)[]>([]);
+  const [pValues, setPValues] = useState<(string | number)[]>([]);
 
-class TTestTable extends Component<TTestTableProps, TTestTableState> {
-  static defaultProps = defaultProps;
-
-  constructor(props: TTestTableProps) {
-    super(props);
-    this.state = {
-      control: 0,
-      liftValues: [],
-      pValues: [],
-    };
-  }
+  const computeLift = useCallback(
+    (values: DataPointValue[], controlValues: DataPointValue[]): string => {
+      // Compute the lift value between two time series
+      let sumValues = 0;
+      let sumControl = 0;
+      values.forEach((value, i) => {
+        sumValues += value.y;
+        sumControl += controlValues[i].y;
+      });
 
-  componentDidMount() {
-    const { control } = this.state;
-    this.computeTTest(control); // initially populate table
-  }
-
-  getLiftStatus(row: number): string {
-    const { control, liftValues } = this.state;
-    // Get a css class name for coloring
-    if (row === control) {
-      return 'control';
-    }
-    const liftVal = liftValues[row];
-    if (Number.isNaN(liftVal) || !Number.isFinite(liftVal)) {
-      return 'invalid'; // infinite or NaN values
-    }
-
-    return Number(liftVal) >= 0 ? 'true' : 'false'; // green on true, red on 
false
-  }
+      // A zero control sum yields "Infinity" (or "NaN" for 0/0) via toFixed,
+      // matching the original class component's output
+      return (((sumValues - sumControl) / sumControl) * 100).toFixed(
+        liftValPrec,
+      );
+    },
+    [liftValPrec],
+  );
 
-  getPValueStatus(row: number): string {
-    const { control, pValues } = this.state;
-    if (row === control) {
-      return 'control';
-    }
-    const pVal = pValues[row];
-    if (Number.isNaN(pVal) || !Number.isFinite(pVal)) {
-      return 'invalid';
-    }
+  const computePValue = useCallback(
+    (
+      values: DataPointValue[],
+      controlValues: DataPointValue[],
+    ): string | number => {
+      // Compute the p-value from Student's t-test
+      // between two time series
+      let diffSum = 0;
+      let diffSqSum = 0;
+      let finiteCount = 0;
+      values.forEach((value, i) => {
+        const diff = controlValues[i].y - value.y;
+        /* eslint-disable-next-line */
+        if (isFinite(diff)) {
+          finiteCount += 1;
+          diffSum += diff;
+          diffSqSum += diff * diff;
+        }
+      });
+      const tvalue = -Math.abs(
+        diffSum *
+          Math.sqrt(
+            (finiteCount - 1) / (finiteCount * diffSqSum - diffSum * diffSum),
+          ),
+      );
+      try {
+        return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)).toFixed(
+          pValPrec,
+        ); // two-sided test
+      } catch (error) {
+        return NaN;
+      }
+    },
+    [pValPrec],
+  );
 
-    return ''; // p-values won't normally be colored
-  }
+  const computeTTest = useCallback(
+    (controlIndex: number) => {
+      // Compute lift and p-values for each row
+      // against the selected control
+      const newPValues: (string | number)[] = [];
+      const newLiftValues: (string | number)[] = [];
+      if (!data) {
+        return;
+      }
+      for (let i = 0; i < data.length; i += 1) {
+        if (i === controlIndex) {
+          newPValues.push('control');
+          newLiftValues.push('control');
+        } else {
+          newPValues.push(
+            computePValue(data[i].values, data[controlIndex].values),
+          );
+          newLiftValues.push(
+            computeLift(data[i].values, data[controlIndex].values),
+          );
+        }
+      }
+      setControl(controlIndex);
+      controlRef.current = controlIndex;
+      setLiftValues(newLiftValues);
+      setPValues(newPValues);
+    },
+    [data, computeLift, computePValue],
+  );
 
-  getSignificance(row: number): string | boolean {
-    const { control, pValues } = this.state;
-    const { alpha } = this.props;
-    // Color significant as green, else red
-    if (row === control) {
-      return 'control';
+  // Recompute table when data changes, keeping control index in range.
+  // Row clicks call computeTTest directly, so `control` is read via a ref
+  // here to avoid a duplicate recompute after each click.
+  useEffect(() => {
+    if (!data || data.length === 0) {
+      setControl(0);
+      controlRef.current = 0;
+      setLiftValues([]);
+      setPValues([]);
+      return;
     }
 
-    // p-values significant below set threshold
-    return Number(pValues[row]) <= alpha;
-  }
+    computeTTest(Math.min(controlRef.current, data.length - 1));
+  }, [computeTTest, data]);
 
-  computeLift(values: DataPointValue[], control: DataPointValue[]): string {
-    const { liftValPrec } = this.props;
-    // Compute the lift value between two time series
-    let sumValues = 0;
-    let sumControl = 0;
-    values.forEach((value, i) => {
-      sumValues += value.y;
-      sumControl += control[i].y;
-    });
+  const getLiftStatus = useCallback(
+    (row: number): string => {
+      // Get a css class name for coloring
+      if (row === control) {
+        return 'control';
+      }
+      const liftVal = liftValues[row];
+      const numericLiftVal = Number(liftVal);
+      if (Number.isNaN(numericLiftVal) || !Number.isFinite(numericLiftVal)) {
+        return 'invalid'; // infinite or NaN values
+      }
 
-    return (((sumValues - sumControl) / sumControl) * 
100).toFixed(liftValPrec);
-  }
+      return numericLiftVal >= 0 ? 'true' : 'false'; // green on true, red on 
false
+    },
+    [control, liftValues],
+  );
 
-  computePValue(
-    values: DataPointValue[],
-    control: DataPointValue[],
-  ): string | number {
-    const { pValPrec } = this.props;
-    // Compute the p-value from Student's t-test
-    // between two time series
-    let diffSum = 0;
-    let diffSqSum = 0;
-    let finiteCount = 0;
-    values.forEach((value, i) => {
-      const diff = control[i].y - value.y;
-      /* eslint-disable-next-line */
-      if (isFinite(diff)) {
-        finiteCount += 1;
-        diffSum += diff;
-        diffSqSum += diff * diff;
+  const getPValueStatus = useCallback(
+    (row: number): string => {
+      if (row === control) {
+        return 'control';
+      }
+      const pVal = pValues[row];
+      const numericPVal = Number(pVal);
+      if (Number.isNaN(numericPVal) || !Number.isFinite(numericPVal)) {
+        return 'invalid';
       }
-    });
-    const tvalue = -Math.abs(
-      diffSum *
-        Math.sqrt(
-          (finiteCount - 1) / (finiteCount * diffSqSum - diffSum * diffSum),
-        ),
-    );
-    try {
-      return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)).toFixed(
-        pValPrec,
-      ); // two-sided test
-    } catch (error) {
-      return NaN;
-    }
-  }
 
-  computeTTest(control: number) {
-    // Compute lift and p-values for each row
-    // against the selected control
-    const { data } = this.props;
-    const pValues: (string | number)[] = [];
-    const liftValues: (string | number)[] = [];
-    if (!data) {
-      return;
-    }
-    for (let i = 0; i < data.length; i += 1) {
-      if (i === control) {
-        pValues.push('control');
-        liftValues.push('control');
-      } else {
-        pValues.push(this.computePValue(data[i].values, data[control].values));
-        liftValues.push(this.computeLift(data[i].values, 
data[control].values));
+      return ''; // p-values won't normally be colored
+    },
+    [control, pValues],
+  );
+
+  const getSignificance = useCallback(
+    (row: number): string | boolean => {
+      // Color significant as green, else red
+      if (row === control) {
+        return 'control';
       }
-    }
-    this.setState({ control, liftValues, pValues });
-  }
 
-  render() {
-    const { data, metric, groups } = this.props;
-    const { control, liftValues, pValues } = this.state;
+      // p-values significant below set threshold
+      return Number(pValues[row]) <= alpha;
+    },
+    [control, pValues, alpha],
+  );
 
-    if (!Array.isArray(groups) || groups.length === 0) {
-      throw new Error('Group by param is required');
-    }
+  const handleRowClick = useCallback(
+    (rowIndex: number) => {
+      computeTTest(rowIndex);
+    },
+    [computeTTest],
+  );
 
-    // Render column header for each group
-    const columns = groups.map((group, i) => (
-      <Th key={i} column={group}>
-        {group}
-      </Th>
-    ));
-    const numGroups = groups.length;
-    // Columns for p-value, lift-value, and significance (true/false)
-    columns.push(
-      <Th key={numGroups + 1} column="pValue">
-        p-value
-      </Th>,
-    );
-    columns.push(
-      <Th key={numGroups + 2} column="liftValue">
-        Lift %
-      </Th>,
-    );
-    columns.push(
-      <Th key={numGroups + 3} column="significant">
-        Significant
-      </Th>,
-    );
-    const rows = data.map((entry, i) => {
-      const values = groups.map(
-        (
-          group,
-          j, // group names
-        ) => <Td key={j} column={group} data={entry.group[j]} />,
-      );
-      values.push(
-        <Td
-          key={numGroups + 1}
-          className={this.getPValueStatus(i)}
-          column="pValue"
-          data={pValues[i]}
-        />,
-      );
-      values.push(
-        <Td
-          key={numGroups + 2}
-          className={this.getLiftStatus(i)}
-          column="liftValue"
-          data={liftValues[i]}
-        />,
-      );
-      values.push(
-        <Td
-          key={numGroups + 3}
-          className={this.getSignificance(i).toString()}
-          column="significant"
-          data={this.getSignificance(i)}
-        />,
-      );
+  // When sorted ascending, 'control' will always be at top
+  type SortConfigItem =
+    string | { column: string; sortFunction: (a: string, b: string) => number 
};
 
-      return (
-        <Tr
-          key={i}
-          className={i === control ? 'control' : ''}
-          onClick={this.computeTTest.bind(this, i)}
-        >
-          {values}
-        </Tr>
-      );
-    });
-    // When sorted ascending, 'control' will always be at top
-    type SortConfigItem =
-      | string
-      | { column: string; sortFunction: (a: string, b: string) => number };
-    const sortConfig: SortConfigItem[] = (groups as SortConfigItem[]).concat([
-      {
-        column: 'pValue',
-        sortFunction: (a: string, b: string) => {
-          if (a === 'control') {
-            return -1;
-          }
-          if (b === 'control') {
-            return 1;
-          }
+  const sortConfig: SortConfigItem[] = useMemo(
+    () =>
+      (groups as SortConfigItem[]).concat([
+        {
+          column: 'pValue',
+          sortFunction: (a: string, b: string) => {
+            if (a === 'control') {
+              return -1;
+            }
+            if (b === 'control') {
+              return 1;
+            }
+            if (a === b) {
+              return 0;
+            }
 
-          return a > b ? 1 : -1; // p-values ascending
+            return a > b ? 1 : -1; // p-values ascending
+          },
         },
-      },
-      {
-        column: 'liftValue',
-        sortFunction: (a: string, b: string) => {
-          if (a === 'control') {
-            return -1;
-          }
-          if (b === 'control') {
-            return 1;
-          }
+        {
+          column: 'liftValue',
+          sortFunction: (a: string, b: string) => {
+            if (a === 'control') {
+              return -1;
+            }
+            if (b === 'control') {
+              return 1;
+            }
 
-          return parseFloat(a) > parseFloat(b) ? -1 : 1; // lift values 
descending
+            const liftA = parseFloat(a);
+            const liftB = parseFloat(b);
+            const aFinite = Number.isFinite(liftA);
+            const bFinite = Number.isFinite(liftB);
+            // Non-finite (Infinity/NaN) lift values sort ahead of finite ones,
+            // consistently regardless of comparison order, to avoid an
+            // antisymmetric comparator
+            if (!aFinite && !bFinite) {
+              return 0;
+            }
+            if (!aFinite) {
+              return -1;
+            }
+            if (!bFinite) {
+              return 1;
+            }
+            if (liftA === liftB) {
+              return 0;
+            }
+
+            return liftA > liftB ? -1 : 1; // lift values descending
+          },
         },
-      },
-      {
-        column: 'significant',
-        sortFunction: (a: string, b: string) => {
-          if (a === 'control') {
-            return -1;
-          }
-          if (b === 'control') {
-            return 1;
-          }
+        {
+          column: 'significant',
+          sortFunction: (a: string, b: string) => {
+            if (a === 'control') {
+              return -1;
+            }
+            if (b === 'control') {
+              return 1;
+            }
+            if (a === b) {
+              return 0;
+            }
 
-          return a > b ? -1 : 1; // significant values first
+            return a > b ? -1 : 1; // significant values first
+          },
         },
-      },
-    ]);
+      ]),
+    [groups],
+  );
+
+  if (!Array.isArray(groups) || groups.length === 0) {
+    throw new Error('Group by param is required');
+  }
+
+  // Render column header for each group
+  const columns = groups.map((group, i) => (
+    <Th key={i} column={group}>
+      {group}
+    </Th>
+  ));
+  const numGroups = groups.length;
+  // Columns for p-value, lift-value, and significance (true/false)
+  columns.push(
+    <Th key={numGroups + 1} column="pValue">
+      p-value
+    </Th>,
+  );
+  columns.push(
+    <Th key={numGroups + 2} column="liftValue">
+      Lift %
+    </Th>,
+  );
+  columns.push(
+    <Th key={numGroups + 3} column="significant">
+      Significant
+    </Th>,
+  );
+
+  const rows = (data ?? []).map((entry, i) => {
+    const values = groups.map(
+      (
+        group,
+        j, // group names
+      ) => <Td key={j} column={group} data={entry.group[j]} />,
+    );
+    values.push(
+      <Td
+        key={numGroups + 1}
+        className={getPValueStatus(i)}
+        column="pValue"
+        data={pValues[i]}
+      />,
+    );
+    values.push(
+      <Td
+        key={numGroups + 2}
+        className={getLiftStatus(i)}
+        column="liftValue"
+        data={liftValues[i]}
+      />,
+    );
+    values.push(
+      <Td
+        key={numGroups + 3}
+        className={getSignificance(i).toString()}
+        column="significant"
+        data={getSignificance(i)}
+      />,
+    );
 
     return (
-      <div>
-        <h3>{metric}</h3>
-        <Table className="table" id={`table_${metric}`} sortable={sortConfig}>
-          <Thead>{columns}</Thead>
-          {rows}
-        </Table>
-      </div>
+      <Tr
+        key={i}
+        className={i === control ? 'control' : ''}
+        onClick={() => handleRowClick(i)}
+      >
+        {values}
+      </Tr>
     );
-  }
+  });
+
+  return (
+    <div>
+      <h3>{metric}</h3>
+      <Table className="table" id={`table_${metric}`} sortable={sortConfig}>
+        <Thead>{columns}</Thead>
+        {rows}
+      </Table>
+    </div>
+  );
 }
 
 export default TTestTable;
diff --git 
a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/TTestTable.test.tsx
 
b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/TTestTable.test.tsx
new file mode 100644
index 00000000000..b8e6dd725e1
--- /dev/null
+++ 
b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/TTestTable.test.tsx
@@ -0,0 +1,365 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import TTestTable from '../src/TTestTable';
+import type { DataEntry } from '../src/TTestTable';
+
+// Mock the distributions module to return a predictable cdf value.
+// cdf returns 0.01 so that p-value = 2 * 0.01 = 0.02
+jest.mock('distributions', () => {
+  class MockStudentt {
+    cdf(_x: number): number {
+      return 0.01;
+    }
+  }
+  return {
+    __esModule: true,
+    default: { Studentt: MockStudentt },
+  };
+});
+
+const mockData: DataEntry[] = [
+  {
+    group: ['group-A'],
+    values: [
+      { x: 1, y: 10 },
+      { x: 2, y: 20 },
+    ],
+  },
+  {
+    group: ['group-B'],
+    values: [
+      { x: 1, y: 15 },
+      { x: 2, y: 25 },
+    ],
+  },
+];
+
+const defaultProps = {
+  alpha: 0.05,
+  data: mockData,
+  groups: ['category'],
+  liftValPrec: 4,
+  metric: 'revenue',
+  pValPrec: 6,
+};
+
+test('renders the metric name as an h3 heading', async () => {
+  render(<TTestTable {...defaultProps} />);
+
+  await waitFor(() => {
+    expect(screen.getByText('revenue')).toBeInTheDocument();
+  });
+
+  const heading = screen.getByText('revenue');
+  expect(heading.tagName).toBe('H3');
+});
+
+test('renders a table with the correct column headers', async () => {
+  render(<TTestTable {...defaultProps} />);
+
+  await waitFor(() => {
+    expect(screen.getByRole('table')).toBeInTheDocument();
+  });
+
+  expect(screen.getByText('category')).toBeInTheDocument();
+  expect(screen.getByText('p-value')).toBeInTheDocument();
+  expect(screen.getByText('Lift %')).toBeInTheDocument();
+  expect(screen.getByText('Significant')).toBeInTheDocument();
+});
+
+test('renders group columns matching the groups prop', async () => {
+  const multiGroupData: DataEntry[] = [
+    {
+      group: ['group-A', 'sub-1'],
+      values: [{ x: 1, y: 10 }],
+    },
+    {
+      group: ['group-B', 'sub-2'],
+      values: [{ x: 1, y: 15 }],
+    },
+  ];
+
+  render(
+    <TTestTable
+      {...defaultProps}
+      groups={['category', 'subcategory']}
+      data={multiGroupData}
+    />,
+  );
+
+  await waitFor(() => {
+    expect(screen.getByRole('table')).toBeInTheDocument();
+  });
+
+  expect(screen.getByText('category')).toBeInTheDocument();
+  expect(screen.getByText('subcategory')).toBeInTheDocument();
+});
+
+test('first row is treated as control by default and shows "control" for 
p-value and lift columns', async () => {
+  render(<TTestTable {...defaultProps} />);
+
+  // After componentDidMount, the first row should be control
+  await waitFor(() => {
+    const controlTexts = screen.getAllByText('control');
+    // The control row has "control" in pValue and liftValue columns
+    expect(controlTexts.length).toBeGreaterThanOrEqual(2);
+  });
+});
+
+test('computes lift values correctly for non-control rows', async () => {
+  // Control (group-A): sum of y = 10 + 20 = 30
+  // group-B: sum of y = 15 + 25 = 40
+  // Lift = ((40 - 30) / 30) * 100 = 33.3333%
+  // With liftValPrec=4 => "33.3333"
+  render(<TTestTable {...defaultProps} />);
+
+  await waitFor(() => {
+    expect(screen.getByText('33.3333')).toBeInTheDocument();
+  });
+});
+
+test('computes p-value using the mocked distributions module', async () => {
+  // Mock cdf returns 0.01, so p-value = 2 * 0.01 = 0.02
+  // With pValPrec=6 => "0.020000"
+  render(<TTestTable {...defaultProps} />);
+
+  await waitFor(() => {
+    expect(screen.getByText('0.020000')).toBeInTheDocument();
+  });
+});
+
+test('marks non-control row as significant when p-value is below alpha', async 
() => {
+  // p-value = 0.02 < alpha = 0.05, so significance is true
+  render(<TTestTable {...defaultProps} />);
+
+  await waitFor(() => {
+    expect(screen.getByText('true')).toBeInTheDocument();
+  });
+});
+
+test('marks non-control row as not significant when p-value is above alpha', 
async () => {
+  // p-value = 0.02 > alpha = 0.01, so significance is false
+  render(<TTestTable {...defaultProps} alpha={0.01} />);
+
+  await waitFor(() => {
+    expect(screen.getByText('false')).toBeInTheDocument();
+  });
+});
+
+test('renders Infinity lift when control values sum to zero', async () => {
+  const zeroControlData: DataEntry[] = [
+    {
+      group: ['zero-group'],
+      values: [
+        { x: 1, y: 0 },
+        { x: 2, y: 0 },
+      ],
+    },
+    {
+      group: ['other-group'],
+      values: [
+        { x: 1, y: 10 },
+        { x: 2, y: 20 },
+      ],
+    },
+  ];
+
+  render(<TTestTable {...defaultProps} data={zeroControlData} />);
+
+  // The lift computation: ((sumValues - sumControl) / sumControl) * 100
+  // = ((30 - 0) / 0) * 100 = Infinity
+  // Infinity.toFixed(4) returns "Infinity", and the component renders it.
+  // getLiftStatus classifies this as "invalid" (non-finite).
+  await waitFor(() => {
+    expect(screen.getByText('Infinity')).toBeInTheDocument();
+  });
+  expect(screen.getByText('Infinity')).toHaveClass('invalid');
+});
+
+test('colors finite lift cells with true/false status classes', async () => {
+  render(<TTestTable {...defaultProps} />);
+
+  // group-B lift vs control group-A: ((40 - 30) / 30) * 100 = 33.3333 (>= 0)
+  await waitFor(() => {
+    expect(screen.getByText('33.3333')).toBeInTheDocument();
+  });
+  expect(screen.getByText('33.3333')).toHaveClass('true');
+});
+
+test('recomputes and clamps the control index when data shrinks', async () => {
+  const threeRowData: DataEntry[] = [
+    { group: ['row-0'], values: [{ x: 1, y: 10 }] },
+    { group: ['row-1'], values: [{ x: 1, y: 20 }] },
+    { group: ['row-2'], values: [{ x: 1, y: 40 }] },
+  ];
+  const { rerender } = render(
+    <TTestTable {...defaultProps} data={threeRowData} />,
+  );
+
+  // Make the last row (index 2) the control
+  await waitFor(() => {
+    expect(screen.getByText('row-2')).toBeInTheDocument();
+  });
+  fireEvent.click(screen.getByText('row-2').closest('tr')!);
+
+  // row-0 lift vs row-2 control: ((10 - 40) / 40) * 100 = -75.0000
+  await waitFor(() => {
+    expect(screen.getByText('-75.0000')).toBeInTheDocument();
+  });
+
+  // Shrink the data so the control index (2) is out of range
+  rerender(<TTestTable {...defaultProps} data={threeRowData.slice(0, 2)} />);
+
+  // Control clamps to the last remaining row (index 1) and values recompute:
+  // row-0 lift vs row-1 control: ((10 - 20) / 20) * 100 = -50.0000
+  await waitFor(() => {
+    expect(screen.getByText('-50.0000')).toBeInTheDocument();
+  });
+  expect(screen.queryByText('row-2')).not.toBeInTheDocument();
+});
+
+test('sorts a non-finite lift value deterministically regardless of comparison 
order', async () => {
+  // Control sum is non-zero (finite denominator), so row-normal gets a
+  // finite lift while row-huge overflows to a non-finite ("Infinity") lift,
+  // exercising a mixed finite/non-finite comparison.
+  const mixedLiftData: DataEntry[] = [
+    { group: ['control'], values: [{ x: 1, y: 100 }] },
+    { group: ['row-normal'], values: [{ x: 1, y: 200 }] },
+    { group: ['row-huge'], values: [{ x: 1, y: 1e309 }] },
+  ];
+
+  render(
+    <TTestTable {...defaultProps} data={mixedLiftData} groups={['group']} />,
+  );
+
+  await waitFor(() => {
+    expect(screen.getByText('Infinity')).toBeInTheDocument();
+    expect(screen.getByText('100.0000')).toBeInTheDocument();
+  });
+
+  fireEvent.click(screen.getByText('Lift %'));
+
+  await waitFor(() => {
+    const rowLabels = screen
+      .getAllByRole('row')
+      .slice(1) // drop the header row
+      .map(row => row.textContent);
+    // Sorting is antisymmetric and stable: the same relative order results
+    // whichever pair of rows is compared first, with the non-finite value
+    // consistently ordered ahead of the finite one.
+    expect(rowLabels).toEqual([
+      expect.stringContaining('control'),
+      expect.stringContaining('row-huge'),
+      expect.stringContaining('row-normal'),
+    ]);
+  });
+});
+
+test('throws an error when groups array is empty', () => {
+  // Suppress React error boundary console output for this test
+  const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+  expect(() => {
+    render(<TTestTable {...defaultProps} groups={[]} />);
+  }).toThrow('Group by param is required');
+
+  consoleSpy.mockRestore();
+});
+
+test('clicking a non-control row changes it to the new control', async () => {
+  render(<TTestTable {...defaultProps} />);
+
+  // Wait for initial render with group-A as control
+  await waitFor(() => {
+    expect(screen.getByText('group-A')).toBeInTheDocument();
+    expect(screen.getByText('group-B')).toBeInTheDocument();
+  });
+
+  // Initially group-A is control, so its row shows "control" in p-value and 
lift columns.
+  // The non-control row (group-B) shows computed values.
+  await waitFor(() => {
+    expect(screen.getByText('33.3333')).toBeInTheDocument();
+  });
+
+  // Click the group-B row to make it the new control.
+  // The row containing "group-B" text is what we need to click.
+  const groupBCell = screen.getByText('group-B');
+  const groupBRow = groupBCell.closest('tr');
+  expect(groupBRow).not.toBeNull();
+  fireEvent.click(groupBRow!);
+
+  // After clicking, group-B becomes control.
+  // group-A lift: ((30 - 40) / 40) * 100 = -25.0000
+  await waitFor(() => {
+    expect(screen.getByText('-25.0000')).toBeInTheDocument();
+  });
+});
+
+test('renders group name data in the table cells', async () => {
+  render(<TTestTable {...defaultProps} />);
+
+  await waitFor(() => {
+    expect(screen.getByText('group-A')).toBeInTheDocument();
+    expect(screen.getByText('group-B')).toBeInTheDocument();
+  });
+});
+
+test('renders with three data rows and computes values for each non-control 
row', async () => {
+  const threeRowData: DataEntry[] = [
+    {
+      group: ['control-group'],
+      values: [
+        { x: 1, y: 10 },
+        { x: 2, y: 10 },
+      ],
+    },
+    {
+      group: ['test-group-1'],
+      values: [
+        { x: 1, y: 15 },
+        { x: 2, y: 15 },
+      ],
+    },
+    {
+      group: ['test-group-2'],
+      values: [
+        { x: 1, y: 20 },
+        { x: 2, y: 20 },
+      ],
+    },
+  ];
+
+  render(<TTestTable {...defaultProps} data={threeRowData} />);
+
+  await waitFor(() => {
+    expect(screen.getByText('control-group')).toBeInTheDocument();
+    expect(screen.getByText('test-group-1')).toBeInTheDocument();
+    expect(screen.getByText('test-group-2')).toBeInTheDocument();
+  });
+
+  // control-group: sum = 20
+  // test-group-1: sum = 30, lift = ((30-20)/20)*100 = 50.0000
+  // test-group-2: sum = 40, lift = ((40-20)/20)*100 = 100.0000
+  await waitFor(() => {
+    expect(screen.getByText('50.0000')).toBeInTheDocument();
+    expect(screen.getByText('100.0000')).toBeInTheDocument();
+  });
+});
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx
index 58f831fca4a..c4f77ffb643 100644
--- a/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx
+++ b/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { PureComponent } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 
'react';
 import cloudLayout from 'd3-cloud';
 import { scaleLinear } from 'd3-scale';
 import { seed, CategoricalColorNamespace } from '@superset-ui/core';
@@ -81,18 +81,7 @@ export interface WordCloudProps extends WordCloudVisualProps 
{
   colorScheme: string;
 }
 
-export interface WordCloudState {
-  words: Word[];
-  scaleFactor: number;
-}
-
-const defaultProps: Required<WordCloudVisualProps> = {
-  encoding: {},
-  rotation: 'flat',
-};
-
-type FullWordCloudProps = WordCloudProps &
-  typeof defaultProps & { theme: SupersetTheme };
+type FullWordCloudProps = WordCloudProps & { theme: SupersetTheme };
 
 const SCALE_FACTOR_STEP = 0.5;
 const MAX_SCALE_FACTOR = 3;
@@ -196,61 +185,86 @@ class SimpleEncoder {
   }
 }
 
-class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
-  static defaultProps = defaultProps;
-
-  isComponentMounted = false;
-
-  createEncoder = (encoding?: Partial<WordCloudEncoding>): SimpleEncoder =>
-    new SimpleEncoder(encoding ?? {}, {
-      color: this.props.theme.colorTextLabel,
-      fontFamily: this.props.theme.fontFamily,
-      fontSize: 20,
-      fontWeight: 'bold',
-      text: '',
-    });
-
-  constructor(props: FullWordCloudProps) {
-    super(props);
-    this.state = {
-      words: [],
-      scaleFactor: 1,
-    };
-    this.setWords = this.setWords.bind(this);
-  }
-
-  componentDidMount() {
-    this.isComponentMounted = true;
-    this.update();
-  }
-
-  componentDidUpdate(prevProps: WordCloudProps) {
-    const { data, encoding, width, height, rotation } = this.props;
-    if (
-      !isEqual(prevProps.data, data) ||
-      !isEqual(prevProps.encoding, encoding) ||
-      prevProps.width !== width ||
-      prevProps.height !== height ||
-      prevProps.rotation !== rotation
-    ) {
-      this.update();
-    }
-  }
-
-  componentWillUnmount() {
-    this.isComponentMounted = false;
-  }
-
-  setWords(words: Word[]) {
-    if (this.isComponentMounted) {
-      this.setState({ words });
-    }
-  }
-
-  update() {
-    const { data, encoding } = this.props;
+function WordCloud({
+  data,
+  encoding = {},
+  width,
+  height,
+  rotation = 'flat',
+  sliceId,
+  colorScheme,
+  theme,
+}: FullWordCloudProps) {
+  const [words, setWords] = useState<Word[]>([]);
+  const [scaleFactor, setScaleFactor] = useState(1);
+  const isMountedRef = useRef(true);
+
+  // Store previous props for comparison
+  const prevPropsRef = useRef<{
+    data: PlainObject[];
+    encoding: Partial<WordCloudEncoding>;
+    width: number;
+    height: number;
+    rotation: RotationType;
+  } | null>(null);
+
+  const createEncoder = useCallback(
+    (enc?: Partial<WordCloudEncoding>): SimpleEncoder =>
+      new SimpleEncoder(enc ?? {}, {
+        color: theme.colorTextLabel,
+        fontFamily: theme.fontFamily,
+        fontSize: 20,
+        fontWeight: 'bold',
+        text: '',
+      }),
+    [theme.colorTextLabel, theme.fontFamily],
+  );
+
+  const commitLayoutIfMounted = useCallback(
+    (newWords: Word[], newScaleFactor: number) => {
+      if (isMountedRef.current) {
+        setWords(newWords);
+        // Persist the accepted scale factor so the SVG viewBox matches the
+        // canvas the layout was computed for (otherwise enlarged layouts clip)
+        setScaleFactor(newScaleFactor);
+      }
+    },
+    [],
+  );
+
+  const generateCloud = useCallback(
+    (
+      encoder: SimpleEncoder,
+      currentScaleFactor: number,
+      isValid: (word: Word[]) => boolean,
+    ) => {
+      cloudLayout()
+        .size([width * currentScaleFactor, height * currentScaleFactor])
+        .words(data.map((d: Word) => ({ ...d })))
+        .padding(5)
+        .rotate(ROTATION[rotation] || ROTATION.flat)
+        .text((d: PlainObject) => encoder.getText(d))
+        .font((d: PlainObject) => encoder.getFontFamily(d))
+        .fontWeight((d: PlainObject) => encoder.getFontWeight(d))
+        .fontSize((d: PlainObject) => encoder.getFontSize(d))
+        .on('end', (cloudWords: Word[]) => {
+          if (isValid(cloudWords) || currentScaleFactor > MAX_SCALE_FACTOR) {
+            commitLayoutIfMounted(cloudWords, currentScaleFactor);
+          } else {
+            generateCloud(
+              encoder,
+              currentScaleFactor + SCALE_FACTOR_STEP,
+              isValid,
+            );
+          }
+        })
+        .start();
+    },
+    [data, width, height, rotation, commitLayoutIfMounted],
+  );
 
-    const encoder = this.createEncoder(encoding);
+  const update = useCallback(() => {
+    const encoder = createEncoder(encoding);
     encoder.setDomainFromDataset(data);
 
     const sortedData = [...data].sort(
@@ -262,73 +276,72 @@ class WordCloud extends PureComponent<FullWordCloudProps, 
WordCloudState> {
     );
     const topResults = sortedData.slice(0, topResultsCount);
 
-    this.generateCloud(encoder, 1, (words: Word[]) =>
+    generateCloud(encoder, 1, (cloudWords: Word[]) =>
       topResults.every((d: PlainObject) =>
-        words.find(({ text }) => encoder.getText(d) === text),
+        cloudWords.find(({ text }) => encoder.getText(d) === text),
       ),
     );
-  }
+  }, [data, encoding, createEncoder, generateCloud]);
 
-  generateCloud(
-    encoder: SimpleEncoder,
-    scaleFactor: number,
-    isValid: (word: Word[]) => boolean,
-  ) {
-    const { data, width, height, rotation } = this.props;
-
-    cloudLayout()
-      .size([width * scaleFactor, height * scaleFactor])
-      .words(data.map((d: Word) => ({ ...d })))
-      .padding(5)
-      .rotate(ROTATION[rotation] || ROTATION.flat)
-      .text((d: PlainObject) => encoder.getText(d))
-      .font((d: PlainObject) => encoder.getFontFamily(d))
-      .fontWeight((d: PlainObject) => encoder.getFontWeight(d))
-      .fontSize((d: PlainObject) => encoder.getFontSize(d))
-      .on('end', (words: Word[]) => {
-        if (isValid(words) || scaleFactor > MAX_SCALE_FACTOR) {
-          this.setWords(words);
-        } else {
-          this.generateCloud(encoder, scaleFactor + SCALE_FACTOR_STEP, 
isValid);
-        }
-      })
-      .start();
-  }
+  // Component mount/unmount tracking
+  useEffect(() => {
+    isMountedRef.current = true;
+    return () => {
+      isMountedRef.current = false;
+    };
+  }, []);
 
-  render() {
-    const { scaleFactor, words } = this.state;
-    const { width, height, encoding, sliceId, colorScheme } = this.props;
-
-    const encoder = this.createEncoder(encoding);
-
-    const colorFn = CategoricalColorNamespace.getScale(colorScheme);
-    const viewBoxWidth = width * scaleFactor;
-    const viewBoxHeight = height * scaleFactor;
-
-    return (
-      <svg
-        width={width}
-        height={height}
-        viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} 
${viewBoxHeight}`}
-      >
-        <g>
-          {words.map(w => (
-            <text
-              key={w.text}
-              fontSize={`${w.size}px`}
-              fontWeight={w.weight}
-              fontFamily={w.font}
-              fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
-              textAnchor="middle"
-              transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
-            >
-              {w.text}
-            </text>
-          ))}
-        </g>
-      </svg>
-    );
-  }
+  // Initial update on mount and when dependencies change
+  useEffect(() => {
+    const prevProps = prevPropsRef.current;
+    const shouldUpdate =
+      !prevProps ||
+      !isEqual(prevProps.data, data) ||
+      !isEqual(prevProps.encoding, encoding) ||
+      prevProps.width !== width ||
+      prevProps.height !== height ||
+      prevProps.rotation !== rotation;
+
+    if (shouldUpdate) {
+      update();
+    }
+
+    prevPropsRef.current = { data, encoding, width, height, rotation };
+  }, [data, encoding, width, height, rotation, update]);
+
+  const encoder = useMemo(
+    () => createEncoder(encoding),
+    [createEncoder, encoding],
+  );
+
+  const colorFn = CategoricalColorNamespace.getScale(colorScheme);
+  const viewBoxWidth = width * scaleFactor;
+  const viewBoxHeight = height * scaleFactor;
+
+  return (
+    <svg
+      width={width}
+      height={height}
+      viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} 
${viewBoxHeight}`}
+    >
+      <g>
+        {words.map(w => (
+          <text
+            key={w.text}
+            fontSize={`${w.size}px`}
+            fontWeight={w.weight}
+            fontFamily={w.font}
+            fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
+            textAnchor="middle"
+            transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
+          >
+            {w.text}
+          </text>
+        ))}
+      </g>
+    </svg>
+  );
 }
 
-export default withTheme(WordCloud);
+// memo preserves the shallow-prop render bailout of the PureComponent original
+export default withTheme(memo(WordCloud));
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/test/WordCloud.test.tsx 
b/superset-frontend/plugins/plugin-chart-word-cloud/test/WordCloud.test.tsx
new file mode 100644
index 00000000000..4217c57813e
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-word-cloud/test/WordCloud.test.tsx
@@ -0,0 +1,161 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen } from 'spec/helpers/testing-library';
+import WordCloud from '../src/chart/WordCloud';
+import type { PlainObject, Word } from '../src/chart/WordCloud';
+
+// Shared state for the d3-cloud mock; `mock` prefix allows use inside
+// the hoisted jest.mock factory
+const mockCloudState = {
+  sizes: [] as Array<[number, number]>,
+  // When true, the first layout run returns an incomplete word set,
+  // forcing the component to retry at a larger scale factor
+  dropFirstLayout: false,
+};
+
+jest.mock('d3-cloud', () =>
+  jest.fn(() => {
+    const config: {
+      size?: [number, number];
+      words?: Word[];
+      text?: (d: PlainObject) => string;
+      end?: (words: Word[]) => void;
+    } = {};
+    const layout = {
+      size(s: [number, number]) {
+        config.size = s;
+        return layout;
+      },
+      words(w: Word[]) {
+        config.words = w;
+        return layout;
+      },
+      padding() {
+        return layout;
+      },
+      rotate() {
+        return layout;
+      },
+      text(fn: (d: PlainObject) => string) {
+        config.text = fn;
+        return layout;
+      },
+      font() {
+        return layout;
+      },
+      fontWeight() {
+        return layout;
+      },
+      fontSize() {
+        return layout;
+      },
+      on(_event: string, cb: (words: Word[]) => void) {
+        config.end = cb;
+        return layout;
+      },
+      start() {
+        mockCloudState.sizes.push(config.size ?? [0, 0]);
+        const out = (config.words ?? []).map((w, i) => ({
+          ...w,
+          text: config.text?.(w as PlainObject) ?? '',
+          size: 20,
+          x: i * 10,
+          y: i * 10,
+          rotate: 0,
+          font: 'Arial',
+          weight: 'bold',
+        }));
+        const isFirstRun = mockCloudState.sizes.length === 1;
+        config.end?.(
+          mockCloudState.dropFirstLayout && isFirstRun ? out.slice(0, 1) : out,
+        );
+        return layout;
+      },
+    };
+    return layout;
+  }),
+);
+
+beforeEach(() => {
+  mockCloudState.sizes = [];
+  mockCloudState.dropFirstLayout = false;
+});
+
+const defaultProps = {
+  data: [{ text: 'alpha' }, { text: 'beta' }, { text: 'gamma' }],
+  encoding: { text: { field: 'text' } },
+  width: 200,
+  height: 100,
+  sliceId: 1,
+  colorScheme: 'supersetColors',
+};
+
+test('renders a text element for each word', () => {
+  render(<WordCloud {...defaultProps} />);
+
+  expect(screen.getByText('alpha')).toBeInTheDocument();
+  expect(screen.getByText('beta')).toBeInTheDocument();
+  expect(screen.getByText('gamma')).toBeInTheDocument();
+});
+
+test('uses a 1x viewBox when the first layout fits all top words', () => {
+  const { container } = render(<WordCloud {...defaultProps} />);
+
+  expect(mockCloudState.sizes).toEqual([[200, 100]]);
+  expect(container.querySelector('svg')).toHaveAttribute(
+    'viewBox',
+    '-100 -50 200 100',
+  );
+});
+
+test('retries at a larger scale factor and matches the viewBox to it', () => {
+  mockCloudState.dropFirstLayout = true;
+
+  const { container } = render(<WordCloud {...defaultProps} />);
+
+  // First run at 1x drops words, so the layout escalates to 1.5x
+  expect(mockCloudState.sizes).toEqual([
+    [200, 100],
+    [300, 150],
+  ]);
+  expect(container.querySelector('svg')).toHaveAttribute(
+    'viewBox',
+    '-150 -75 300 150',
+  );
+});
+
+test('recomputes the layout when data changes', () => {
+  const { rerender } = render(<WordCloud {...defaultProps} />);
+  expect(mockCloudState.sizes).toHaveLength(1);
+
+  rerender(<WordCloud {...defaultProps} data={[{ text: 'delta' }]} />);
+
+  expect(mockCloudState.sizes).toHaveLength(2);
+  expect(screen.getByText('delta')).toBeInTheDocument();
+  expect(screen.queryByText('alpha')).not.toBeInTheDocument();
+});
+
+test('does not recompute the layout for an unchanged rerender', () => {
+  const { rerender } = render(<WordCloud {...defaultProps} />);
+  expect(mockCloudState.sizes).toHaveLength(1);
+
+  rerender(<WordCloud {...defaultProps} />);
+
+  expect(mockCloudState.sizes).toHaveLength(1);
+});

Reply via email to