You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/10 19:28:13 UTC

(superset) branch master updated: fix(calendar): clean up d3-tip tooltips (#41589)

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

rusackas 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 d62a9eeda6c fix(calendar): clean up d3-tip tooltips (#41589)
d62a9eeda6c is described below

commit d62a9eeda6cb6e48dc09cfcaaa4c20808d7621f6
Author: Joe Li <jo...@preset.io>
AuthorDate: Mon Aug 10 12:28:06 2026 -0700

    fix(calendar): clean up d3-tip tooltips (#41589)
    
    Co-authored-by: Claude Opus 5 <no...@anthropic.com>
---
 superset-frontend/jest.config.js                   |   2 +-
 .../src/chart/components/reactify.tsx              |  47 +-
 .../test/chart/components/reactify.test.tsx        |  38 +-
 .../plugins/plugin-chart-calendar/src/Calendar.ts  |  15 +-
 .../plugin-chart-calendar/src/ReactCalendar.tsx    |   9 +-
 .../plugins/plugin-chart-calendar/src/tooltip.ts   |  20 +
 .../src/vendor/cal-heatmap.ts                      |  13 +-
 .../test/ReactCalendar.test.tsx                    | 585 +++++++++++++++++++++
 .../plugin-chart-calendar/test/tooltip.test.ts     | 136 +++++
 9 files changed, 836 insertions(+), 29 deletions(-)

diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js
index 60e45db2f34..a1741493093 100644
--- a/superset-frontend/jest.config.js
+++ b/superset-frontend/jest.config.js
@@ -77,7 +77,7 @@ module.exports = {
     // @ant-design/colors and @ant-design/fast-color are allowed through because
     // @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
     // from its CJS output, so babel-jest must transform those files.
-    'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparse [...]
+    'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities [...]
   ],
   preset: 'ts-jest',
   transform: {
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
index 80552d4ee8b..e2119c98d50 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
@@ -49,9 +49,14 @@ export type ReactifyProps = {
   className?: string;
 };
 
+export interface ReactifyUnmountContext<Props extends object = object> {
+  container?: HTMLDivElement;
+  props: Readonly<Props & ReactifyProps>;
+}
+
 // TODO: add more React lifecycle callbacks as needed
-export type LifeCycleCallbacks = {
-  componentWillUnmount?: () => void;
+export type LifeCycleCallbacks<Props extends object = object> = {
+  componentWillUnmount?: (this: ReactifyUnmountContext<Props>) => void;
 };
 
 export interface RenderFuncType<Props> {
@@ -76,7 +81,7 @@ export type ReactifiedComponent<Props> = ForwardRefExoticComponent<
 // `ReactifiedComponent<Props>` explicitly.
 export default function reactify<Props extends object>(
   renderFn: RenderFuncType<Props>,
-  callbacks?: LifeCycleCallbacks,
+  callbacks?: LifeCycleCallbacks<Props>,
 ): ComponentType<Props & ReactifyProps> {
   const ReactifiedComponent = forwardRef<
     ReactifiedComponentRef,
@@ -89,8 +94,10 @@ export default function reactify<Props extends object>(
     // assignment only happens for committed renders (safe under Concurrent
     // Mode) and is in place before the passive unmount effect reads it.
     const propsRef = useRef(props);
+    const committedContainerRef = useRef<HTMLDivElement>();
     useLayoutEffect(() => {
       propsRef.current = props;
+      committedContainerRef.current = containerRef.current ?? undefined;
     });
 
     // Expose container via ref for external access
@@ -104,6 +111,22 @@ export default function reactify<Props extends object>(
       [],
     );
 
+    // Cleanup on unmount
+    useEffect(
+      () => () => {
+        if (callbacks?.componentWillUnmount) {
+          // Preserve the legacy `this.props` access pattern and snapshot the
+          // last committed container because React clears refs before passive
+          // effect cleanup runs on unmount.
+          callbacks.componentWillUnmount.call({
+            container: committedContainerRef.current,
+            props: propsRef.current,
+          });
+        }
+      },
+      [],
+    );
+
     // Execute renderFn on mount and every update (mimics componentDidMount + componentDidUpdate)
     useEffect(() => {
       if (containerRef.current) {
@@ -118,24 +141,6 @@ export default function reactify<Props extends object>(
       }
     });
 
-    // Cleanup on unmount
-    useEffect(
-      () => () => {
-        if (callbacks?.componentWillUnmount) {
-          // Preserve legacy behavior where `this` was a component instance
-          // exposing `props`. The class version cleared `this.container`
-          // before invoking componentWillUnmount, so mirror that here to
-          // prevent callbacks from touching a DOM node that's being torn
-          // down.
-          callbacks.componentWillUnmount.call({
-            container: undefined,
-            props: propsRef.current,
-          });
-        }
-      },
-      [],
-    );
-
     const { id, className } = props;
 
     return <div ref={containerRef} id={id} className={className} />;
diff --git a/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx b/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx
index f06eb34c742..daa9795eecd 100644
--- a/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx
@@ -45,7 +45,16 @@ describe('reactify(renderFn)', () => {
     content: 'ghi',
   };
 
-  const willUnmountCb = jest.fn();
+  let latestUnmountContext:
+    | {
+        container?: HTMLDivElement;
+        props?: { content?: string; id?: string };
+      }
+    | undefined;
+
+  const willUnmountCb = jest.fn(function captureUnmountContext() {
+    latestUnmountContext = this as typeof latestUnmountContext;
+  });
 
   const TheChart = reactify(renderFn);
   const TheChartWithWillUnmountHook = reactify(renderFn, {
@@ -66,12 +75,22 @@ describe('reactify(renderFn)', () => {
   }
 
   function AnotherTestComponent() {
-    return <TheChartWithWillUnmountHook id="another_test" />;
+    const [content, setContent] = useState('abc');
+
+    useEffect(() => {
+      const timer = setTimeout(() => {
+        setContent('def');
+      }, 10);
+      return () => clearTimeout(timer);
+    }, []);
+
+    return <TheChartWithWillUnmountHook id="another_test" content={content} />;
   }
 
   beforeEach(() => {
     (renderFn as jest.Mock).mockClear();
     willUnmountCb.mockClear();
+    latestUnmountContext = undefined;
   });
 
   test('returns a React component and re-renders on prop changes', async () => {
@@ -126,9 +145,22 @@ describe('reactify(renderFn)', () => {
     expect(anotherRenderFn).toHaveBeenCalled();
     unmount();
   });
-  test('calls willUnmount hook when it is provided', () => {
+  test('calls willUnmount hook with the committed container and latest props', async () => {
     const { unmount } = render(<AnotherTestComponent />);
+
+    await waitFor(() => {
+      expect(screen.getByText('def')).toBeInTheDocument();
+    });
+
+    const committedContainer = screen.getByText('def').parentElement;
+
     unmount();
+
     expect(willUnmountCb).toHaveBeenCalledTimes(1);
+    expect(latestUnmountContext?.props).toMatchObject({
+      id: 'another_test',
+      content: 'def',
+    });
+    expect(latestUnmountContext?.container).toBe(committedContainer);
   });
 });
diff --git a/superset-frontend/plugins/plugin-chart-calendar/src/Calendar.ts b/superset-frontend/plugins/plugin-chart-calendar/src/Calendar.ts
index 0cec9b15882..e36b1b97349 100644
--- a/superset-frontend/plugins/plugin-chart-calendar/src/Calendar.ts
+++ b/superset-frontend/plugins/plugin-chart-calendar/src/Calendar.ts
@@ -28,6 +28,14 @@ import { convertUTCTimestampToLocal } from './utils';
 // Define a minimal constructor interface for use in this file.
 interface CalHeatMapInstance {
   init(config: Record<string, unknown>): void;
+  destroy(): null;
+}
+
+const calendarInstances = new WeakMap<HTMLElement, CalHeatMapInstance[]>();
+
+export function destroyCalendarInstances(element: HTMLElement) {
+  calendarInstances.get(element)?.forEach(calendar => calendar.destroy());
+  calendarInstances.delete(element);
 }
 const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
 
@@ -82,6 +90,10 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
     colorRangeStart,
   } = props;
 
+  destroyCalendarInstances(element);
+  const instances: CalHeatMapInstance[] = [];
+  calendarInstances.set(element, instances);
+
   const container = d3Select(element)
     .classed('superset-legacy-chart-calendar', true)
     .style('height', height);
@@ -120,12 +132,13 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
     const colorScheme = getSequentialSchemeRegistry().get(linearColorScheme);
     const colorScale = colorScheme
       ? colorScheme.createLinearScale(extents)
-      : (_v: number) => '#ccc'; // fallback if scheme not found
+      : () => '#ccc'; // fallback if scheme not found
 
     const legend = d3Range(steps).map(i => extents[0] + step * i);
     const legendColors = legend.map(x => colorScale(x));
 
     const cal = new CalHeatMap();
+    instances.push(cal);
     cal.init({
       start: convertUTCTimestampToLocal(data.start),
       data: timestamps,
diff --git a/superset-frontend/plugins/plugin-chart-calendar/src/ReactCalendar.tsx b/superset-frontend/plugins/plugin-chart-calendar/src/ReactCalendar.tsx
index fdcf5880c16..9621925a77c 100644
--- a/superset-frontend/plugins/plugin-chart-calendar/src/ReactCalendar.tsx
+++ b/superset-frontend/plugins/plugin-chart-calendar/src/ReactCalendar.tsx
@@ -19,7 +19,13 @@
 import { reactify } from '@superset-ui/core';
 import { styled, css, useTheme } from '@apache-superset/core/theme';
 import { Global } from '@emotion/react';
-import Component from './Calendar';
+import Component, { destroyCalendarInstances } from './Calendar';
+
+function componentWillUnmount(this: { container?: HTMLDivElement }) {
+  if (this.container) {
+    destroyCalendarInstances(this.container);
+  }
+}
 
 // Type-erase the render function to allow flexible prop spreading in the wrapper.
 // The Calendar render function has typed props, but the wrapper passes props via spread
@@ -29,6 +35,7 @@ const ReactComponent = reactify(
     container: HTMLDivElement,
     props: Record<string, unknown>,
   ) => void,
+  { componentWillUnmount },
 );
 
 interface CalendarWrapperProps {
diff --git a/superset-frontend/plugins/plugin-chart-calendar/src/tooltip.ts b/superset-frontend/plugins/plugin-chart-calendar/src/tooltip.ts
new file mode 100644
index 00000000000..cc2d84ae5eb
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-calendar/src/tooltip.ts
@@ -0,0 +1,20 @@
+/**
+ * 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.
+ */
+
+export const CALENDAR_TOOLTIP_CLASS = 'superset-legacy-chart-calendar-tooltip';
diff --git a/superset-frontend/plugins/plugin-chart-calendar/src/vendor/cal-heatmap.ts b/superset-frontend/plugins/plugin-chart-calendar/src/vendor/cal-heatmap.ts
index 6a4301eab75..734bd2c66a7 100644
--- a/superset-frontend/plugins/plugin-chart-calendar/src/vendor/cal-heatmap.ts
+++ b/superset-frontend/plugins/plugin-chart-calendar/src/vendor/cal-heatmap.ts
@@ -12,6 +12,7 @@
 import d3tip from 'd3-tip';
 import { t } from '@apache-superset/core/translation';
 import { getContrastingColor } from '@superset-ui/core';
+import { CALENDAR_TOOLTIP_CLASS } from '../tooltip';
 
 var d3 = typeof require === 'function' ? require('d3') : window.d3;
 
@@ -22,7 +23,7 @@ var CalHeatMap = function () {
 
   var self = this;
   self.tip = d3tip()
-    .attr('class', 'd3-tip')
+    .attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
     .direction('n')
     .offset([-5, 0])
     .html(
@@ -33,7 +34,7 @@ var CalHeatMap = function () {
     `,
     );
   self.legendTip = d3tip()
-    .attr('class', 'd3-tip')
+    .attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
     .direction('n')
     .offset([-5, 0])
     .html(d => self.options.valueFormatter(d));
@@ -3444,6 +3445,14 @@ CalHeatMap.prototype = {
   destroy: function (callback) {
     'use strict';
 
+    this.tip.destroy();
+    this.legendTip.destroy();
+
+    // init() can fail validation before creating the calendar root.
+    if (!this.root) {
+      return null;
+    }
+
     this.root
       .transition()
       .duration(this.options.animationDuration)
diff --git a/superset-frontend/plugins/plugin-chart-calendar/test/ReactCalendar.test.tsx b/superset-frontend/plugins/plugin-chart-calendar/test/ReactCalendar.test.tsx
new file mode 100644
index 00000000000..e4084af5531
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-calendar/test/ReactCalendar.test.tsx
@@ -0,0 +1,585 @@
+/**
+ * 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 { Component, memo, StrictMode, type ReactNode } from 'react';
+import {
+  act,
+  cleanup,
+  render,
+  screen,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
+
+interface MockCalHeatMapConfig {
+  itemSelector: Element;
+}
+
+type MetricNameInput = string | string[];
+
+let mockNextInstanceId = 0;
+let mockNextOwnerId = 0;
+let mockInitCallCount = 0;
+let mockThrowOnInitCall: number | null = null;
+let mockDestroyCallCount = 0;
+let mockDestroyedInstanceIds: string[] = [];
+
+const mockTheme = {
+  colorBgElevated: '#ffffff',
+};
+
+jest.mock('../src/vendor/cal-heatmap', () => ({
+  __esModule: true,
+  default: class MockCalHeatMap {
+    private instanceId?: string;
+
+    private tooltips: HTMLElement[] = [];
+
+    init(config: MockCalHeatMapConfig) {
+      const {
+        CALENDAR_TOOLTIP_CLASS: mockCalendarTooltipClass,
+      } = require('../src/tooltip');
+
+      mockInitCallCount += 1;
+      if (mockThrowOnInitCall === mockInitCallCount) {
+        throw new Error('Mock CalHeatMap init failure');
+      }
+
+      mockNextInstanceId += 1;
+      this.instanceId = String(mockNextInstanceId);
+
+      const owner = config.itemSelector.closest(
+        '.superset-legacy-chart-calendar',
+      ) as HTMLElement | null;
+      if (!owner) {
+        throw new Error('Expected tooltip owner calendar container');
+      }
+
+      if (!owner.dataset.tooltipOwner) {
+        mockNextOwnerId += 1;
+        owner.dataset.tooltipOwner = `calendar-owner-${mockNextOwnerId}`;
+      }
+
+      const { tooltipOwner } = owner.dataset;
+      if (!tooltipOwner) {
+        throw new Error('Expected owner-specific tooltip marker');
+      }
+
+      this.tooltips = [0, 1].map(index => {
+        const tooltip = global.document.createElement('div');
+        tooltip.className = ['d3-tip', mockCalendarTooltipClass].join(' ');
+        tooltip.dataset.tooltipOwner = tooltipOwner;
+        tooltip.dataset.tooltipInstance = this.instanceId;
+        tooltip.dataset.tooltipIndex = String(index);
+        tooltip.textContent = `${tooltipOwner}-tooltip-${this.instanceId}-${index}`;
+        global.document.body.appendChild(tooltip);
+        return tooltip;
+      });
+    }
+
+    destroy() {
+      mockDestroyCallCount += 1;
+      if (this.instanceId) {
+        mockDestroyedInstanceIds.push(this.instanceId);
+      }
+      this.tooltips.forEach(tooltip => tooltip.remove());
+      this.tooltips = [];
+      return null;
+    }
+  },
+}));
+
+const ReactCalendar = require('../src/ReactCalendar').default;
+const Calendar = require('../src/Calendar').default;
+
+interface CalendarHarnessProps {
+  firstMetricNames: MetricNameInput;
+  secondMetricNames?: MetricNameInput;
+  showFirstCalendar?: boolean;
+}
+
+interface CalendarErrorBoundaryProps {
+  children: ReactNode;
+}
+
+interface CalendarErrorBoundaryState {
+  hasError: boolean;
+}
+
+const CALENDAR_START = 1704067200000;
+
+function createCalendarProps(metricNames: MetricNameInput) {
+  const normalizedMetricNames = Array.isArray(metricNames)
+    ? metricNames
+    : [metricNames];
+
+  return {
+    data: {
+      data: Object.fromEntries(
+        normalizedMetricNames.map((metricName, index) => [
+          metricName,
+          {
+            [String(CALENDAR_START)]: index * 2 + 1,
+            [String(CALENDAR_START + 86400000)]: index * 2 + 2,
+          },
+        ]),
+      ),
+      domain: 'month',
+      range: 1,
+      start: CALENDAR_START,
+      subdomain: 'day',
+    },
+    height: 160,
+    domainGranularity: 'month',
+    linearColorScheme: 'schemeRdYlBu',
+    showLegend: false,
+    showMetricName: true,
+    showValues: false,
+    steps: 3,
+    subdomainGranularity: 'day',
+    timeFormatter: (value: number | string) => String(value),
+    valueFormatter: (value: number) => String(value),
+    verboseMap: Object.fromEntries(
+      normalizedMetricNames.map(metricName => [metricName, metricName]),
+    ),
+  };
+}
+
+class CalendarErrorBoundary extends Component<
+  CalendarErrorBoundaryProps,
+  CalendarErrorBoundaryState
+> {
+  state: CalendarErrorBoundaryState = {
+    hasError: false,
+  };
+
+  static getDerivedStateFromError() {
+    return { hasError: true };
+  }
+
+  render() {
+    if (this.state.hasError) {
+      return <div>Calendar fallback</div>;
+    }
+
+    return this.props.children;
+  }
+}
+
+const StableSecondCalendar = memo(function StableSecondCalendar({
+  metricNames,
+}: {
+  metricNames: MetricNameInput;
+}) {
+  return (
+    <div data-test="calendar-second">
+      <ReactCalendar {...createCalendarProps(metricNames)} />
+    </div>
+  );
+});
+
+function CalendarHarness({
+  firstMetricNames,
+  secondMetricNames = 'second-metric',
+  showFirstCalendar = true,
+}: CalendarHarnessProps) {
+  return (
+    <>
+      {showFirstCalendar ? (
+        <div data-test="calendar-first">
+          <ReactCalendar {...createCalendarProps(firstMetricNames)} />
+        </div>
+      ) : null}
+      <StableSecondCalendar metricNames={secondMetricNames} />
+    </>
+  );
+}
+
+function getCalendarOwner(testId: string) {
+  const owner = screen
+    .getByTestId(testId)
+    .querySelector('.superset-legacy-chart-calendar');
+
+  if (!(owner instanceof HTMLElement)) {
+    throw new Error(`Expected mounted calendar owner for ${testId}`);
+  }
+
+  return owner;
+}
+
+function getTooltipOwnerId(owner: HTMLElement) {
+  const tooltipOwnerId = owner.dataset.tooltipOwner;
+
+  if (!tooltipOwnerId) {
+    throw new Error('Expected mounted calendar to have a tooltip owner id');
+  }
+
+  return tooltipOwnerId;
+}
+
+function getOwnerTooltips(tooltipOwnerId: string) {
+  return Array.from(
+    document.querySelectorAll<HTMLElement>(
+      `.d3-tip[data-tooltip-owner="${tooltipOwnerId}"]`,
+    ),
+  );
+}
+
+function getCalendarTooltips() {
+  return Array.from(
+    document.querySelectorAll<HTMLElement>(`.${CALENDAR_TOOLTIP_CLASS}`),
+  );
+}
+
+function getTooltipInstanceIds(tooltips: HTMLElement[]) {
+  return Array.from(
+    new Set(tooltips.map(tooltip => tooltip.dataset.tooltipInstance)),
+  )
+    .filter((tooltipInstanceId): tooltipInstanceId is string =>
+      Boolean(tooltipInstanceId),
+    )
+    .sort();
+}
+
+function getSingleTooltipInstanceId(tooltips: HTMLElement[]) {
+  const tooltipInstanceIds = getTooltipInstanceIds(tooltips);
+
+  expect(tooltipInstanceIds).toHaveLength(1);
+
+  return tooltipInstanceIds[0];
+}
+
+function flushPendingTimersIfNeeded() {
+  try {
+    act(() => {
+      jest.runOnlyPendingTimers();
+    });
+  } catch {
+    // Ignore tests that did not opt into fake timers.
+  }
+}
+
+afterEach(() => {
+  cleanup();
+  flushPendingTimersIfNeeded();
+  jest.useRealTimers();
+  mockNextInstanceId = 0;
+  mockNextOwnerId = 0;
+  mockInitCallCount = 0;
+  mockThrowOnInitCall = null;
+  mockDestroyCallCount = 0;
+  mockDestroyedInstanceIds = [];
+  document.body.innerHTML = '';
+});
+
+test('rerender and unmount clean up only the affected calendar tooltips', () => {
+  jest.useFakeTimers();
+
+  const { rerender, unmount } = render(
+    <CalendarHarness firstMetricNames="first-metric-initial" />,
+  );
+
+  const firstCalendarOwner = getCalendarOwner('calendar-first');
+  const secondCalendarOwner = getCalendarOwner('calendar-second');
+
+  const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
+  const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
+
+  const firstInitialTooltips = getOwnerTooltips(firstTooltipOwnerId);
+  const secondInitialTooltips = getOwnerTooltips(secondTooltipOwnerId);
+
+  expect(firstInitialTooltips).toHaveLength(2);
+  expect(secondInitialTooltips).toHaveLength(2);
+  expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
+    4,
+  );
+
+  const firstInitialInstanceId =
+    getSingleTooltipInstanceId(firstInitialTooltips);
+  const secondInitialInstanceId = getSingleTooltipInstanceId(
+    secondInitialTooltips,
+  );
+
+  rerender(<CalendarHarness firstMetricNames="first-metric-rerendered" />);
+
+  const firstRerenderedTooltips = getOwnerTooltips(firstTooltipOwnerId);
+  const secondPreservedTooltips = getOwnerTooltips(secondTooltipOwnerId);
+
+  expect(firstRerenderedTooltips).toHaveLength(2);
+  expect(secondPreservedTooltips).toHaveLength(2);
+  expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
+    4,
+  );
+  expect(firstInitialTooltips.every(tooltip => !tooltip.isConnected)).toBe(
+    true,
+  );
+  expect(secondInitialTooltips.every(tooltip => tooltip.isConnected)).toBe(
+    true,
+  );
+
+  expect(getSingleTooltipInstanceId(firstRerenderedTooltips)).not.toEqual(
+    firstInitialInstanceId,
+  );
+  expect(getSingleTooltipInstanceId(secondPreservedTooltips)).toEqual(
+    secondInitialInstanceId,
+  );
+
+  rerender(
+    <CalendarHarness
+      firstMetricNames="first-metric-rerendered"
+      showFirstCalendar={false}
+    />,
+  );
+
+  expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
+  expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
+  expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
+    2,
+  );
+  expect(
+    getSingleTooltipInstanceId(getOwnerTooltips(secondTooltipOwnerId)),
+  ).toEqual(secondInitialInstanceId);
+
+  unmount();
+});
+
+test('multi-metric calendar rerender and unmount clean up every owned tooltip while preserving siblings', () => {
+  jest.useFakeTimers();
+
+  const { rerender, unmount } = render(
+    <CalendarHarness
+      firstMetricNames={['first-metric-a', 'first-metric-b']}
+      secondMetricNames="second-metric-initial"
+    />,
+  );
+
+  const firstCalendarOwner = getCalendarOwner('calendar-first');
+  const secondCalendarOwner = getCalendarOwner('calendar-second');
+
+  const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
+  const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
+
+  const firstInitialTooltips = getOwnerTooltips(firstTooltipOwnerId);
+  const secondInitialTooltips = getOwnerTooltips(secondTooltipOwnerId);
+
+  expect(firstInitialTooltips).toHaveLength(4);
+  expect(secondInitialTooltips).toHaveLength(2);
+  expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
+    6,
+  );
+
+  const firstInitialInstanceIds = getTooltipInstanceIds(firstInitialTooltips);
+  const secondInitialInstanceIds = getTooltipInstanceIds(secondInitialTooltips);
+
+  expect(firstInitialInstanceIds).toHaveLength(2);
+  expect(secondInitialInstanceIds).toHaveLength(1);
+
+  rerender(
+    <CalendarHarness
+      firstMetricNames={['first-metric-c', 'first-metric-d']}
+      secondMetricNames="second-metric-initial"
+    />,
+  );
+
+  const firstRerenderedTooltips = getOwnerTooltips(firstTooltipOwnerId);
+  const secondPreservedTooltips = getOwnerTooltips(secondTooltipOwnerId);
+
+  expect(firstRerenderedTooltips).toHaveLength(4);
+  expect(secondPreservedTooltips).toHaveLength(2);
+  expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
+    6,
+  );
+  expect(firstInitialTooltips.every(tooltip => !tooltip.isConnected)).toBe(
+    true,
+  );
+  expect(secondInitialTooltips.every(tooltip => tooltip.isConnected)).toBe(
+    true,
+  );
+
+  expect(getTooltipInstanceIds(firstRerenderedTooltips)).toHaveLength(2);
+  expect(getTooltipInstanceIds(firstRerenderedTooltips)).not.toEqual(
+    firstInitialInstanceIds,
+  );
+  expect(getTooltipInstanceIds(secondPreservedTooltips)).toEqual(
+    secondInitialInstanceIds,
+  );
+
+  rerender(
+    <CalendarHarness
+      firstMetricNames={['first-metric-c', 'first-metric-d']}
+      secondMetricNames="second-metric-initial"
+      showFirstCalendar={false}
+    />,
+  );
+
+  expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
+  expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
+  expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
+    2,
+  );
+  expect(getTooltipInstanceIds(getOwnerTooltips(secondTooltipOwnerId))).toEqual(
+    secondInitialInstanceIds,
+  );
+
+  unmount();
+});
+
+test('Calendar destroys previously initialized metric instances after a later metric init failure', () => {
+  const calendarOwner = document.createElement('div');
+  document.body.appendChild(calendarOwner);
+
+  mockThrowOnInitCall = 2;
+
+  expect(() => {
+    Calendar(calendarOwner, {
+      ...createCalendarProps(['failing-metric-a', 'failing-metric-b']),
+      theme: mockTheme,
+    });
+  }).toThrow('Mock CalHeatMap init failure');
+
+  const failedRenderTooltips = getOwnerTooltips(
+    getTooltipOwnerId(calendarOwner),
+  );
+  const failedRenderInstanceIds = getTooltipInstanceIds(failedRenderTooltips);
+
+  expect(failedRenderTooltips).toHaveLength(2);
+  expect(failedRenderInstanceIds).toHaveLength(1);
+
+  mockThrowOnInitCall = null;
+  Calendar(calendarOwner, {
+    ...createCalendarProps('recovered-metric'),
+    theme: mockTheme,
+  });
+
+  const recoveredTooltips = getOwnerTooltips(getTooltipOwnerId(calendarOwner));
+
+  expect(failedRenderTooltips.every(tooltip => !tooltip.isConnected)).toBe(
+    true,
+  );
+  expect(recoveredTooltips).toHaveLength(2);
+  expect(getTooltipInstanceIds(recoveredTooltips)).not.toEqual(
+    failedRenderInstanceIds,
+  );
+});
+
+test('surviving calendar rerender preserves sibling cleanup after another calendar unmounts', () => {
+  jest.useFakeTimers();
+
+  const { rerender, unmount } = render(
+    <CalendarHarness
+      firstMetricNames="first-metric-initial"
+      secondMetricNames="second-metric-initial"
+    />,
+  );
+
+  const firstCalendarOwner = getCalendarOwner('calendar-first');
+  const secondCalendarOwner = getCalendarOwner('calendar-second');
+
+  const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
+  const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
+
+  const secondInitialInstanceId = getSingleTooltipInstanceId(
+    getOwnerTooltips(secondTooltipOwnerId),
+  );
+
+  rerender(
+    <CalendarHarness
+      firstMetricNames="first-metric-initial"
+      secondMetricNames="second-metric-initial"
+      showFirstCalendar={false}
+    />,
+  );
+
+  expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
+  expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
+  expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
+    2,
+  );
+
+  rerender(
+    <CalendarHarness
+      firstMetricNames="first-metric-initial"
+      secondMetricNames="second-metric-rerendered"
+      showFirstCalendar={false}
+    />,
+  );
+
+  const secondRerenderedTooltips = getOwnerTooltips(secondTooltipOwnerId);
+
+  expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
+  expect(secondRerenderedTooltips).toHaveLength(2);
+  expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
+    2,
+  );
+  expect(getSingleTooltipInstanceId(secondRerenderedTooltips)).not.toEqual(
+    secondInitialInstanceId,
+  );
+
+  unmount();
+});
+
+test('StrictMode unmount cleans up calendar tooltips for multi-metric calendars', () => {
+  const { unmount } = render(
+    <StrictMode>
+      <ReactCalendar
+        {...createCalendarProps(['strict-metric-a', 'strict-metric-b'])}
+      />
+    </StrictMode>,
+  );
+
+  expect(getCalendarTooltips()).toHaveLength(4);
+
+  unmount();
+
+  expect(getCalendarTooltips()).toHaveLength(0);
+});
+
+test('ErrorBoundary fallback destroys partially initialized tooltips after a later init failure', async () => {
+  const consoleErrorSpy = jest
+    .spyOn(console, 'error')
+    .mockImplementation(() => undefined);
+
+  mockThrowOnInitCall = 2;
+
+  try {
+    const { unmount } = render(
+      <CalendarErrorBoundary>
+        <ReactCalendar
+          {...createCalendarProps(['failing-metric-a', 'failing-metric-b'])}
+        />
+      </CalendarErrorBoundary>,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Calendar fallback')).toBeInTheDocument();
+    });
+
+    await waitFor(() => {
+      expect(getCalendarTooltips()).toHaveLength(0);
+    });
+
+    expect(mockDestroyCallCount).toBe(2);
+    expect(mockDestroyedInstanceIds).toEqual(['1']);
+
+    unmount();
+
+    expect(getCalendarTooltips()).toHaveLength(0);
+  } finally {
+    consoleErrorSpy.mockRestore();
+  }
+});
diff --git a/superset-frontend/plugins/plugin-chart-calendar/test/tooltip.test.ts b/superset-frontend/plugins/plugin-chart-calendar/test/tooltip.test.ts
new file mode 100644
index 00000000000..810e948dae4
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-calendar/test/tooltip.test.ts
@@ -0,0 +1,136 @@
+/**
+ * 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 { CALENDAR_TOOLTIP_CLASS } from '../src/tooltip';
+
+// The vendor file is @ts-nocheck, so its export lacks type info.
+// Mirror the minimal constructor interface defined in Calendar.ts.
+interface CalHeatMapInstance {
+  init(config: Record<string, unknown>): void;
+  destroy(): null;
+}
+
+const createSVGPointDescriptor = Object.getOwnPropertyDescriptor(
+  window.SVGSVGElement.prototype,
+  'createSVGPoint',
+);
+
+function installCreateSVGPointMock() {
+  Object.defineProperty(window.SVGSVGElement.prototype, 'createSVGPoint', {
+    configurable: true,
+    value: () => ({
+      matrixTransform: () => ({ x: 0, y: 0 }),
+    }),
+  });
+}
+
+function restoreCreateSVGPointMock() {
+  if (createSVGPointDescriptor) {
+    Object.defineProperty(
+      window.SVGSVGElement.prototype,
+      'createSVGPoint',
+      createSVGPointDescriptor,
+    );
+  } else {
+    delete (
+      window.SVGSVGElement.prototype as Partial<
+        Pick<SVGSVGElement, 'createSVGPoint'>
+      >
+    ).createSVGPoint;
+  }
+}
+
+function getCalendarTooltips() {
+  return Array.from(
+    document.querySelectorAll<HTMLElement>(`.${CALENDAR_TOOLTIP_CLASS}`),
+  );
+}
+
+afterEach(() => {
+  jest.resetModules();
+  restoreCreateSVGPointMock();
+  document.body.innerHTML = '';
+});
+
+test('CalHeatMap destroy tolerates partially initialized instances', () => {
+  let CalHeatMap!: new () => CalHeatMapInstance;
+  jest.isolateModules(() => {
+    // eslint-disable-next-line global-require
+    CalHeatMap = require('../src/vendor/cal-heatmap')
+      .default as new () => CalHeatMapInstance;
+  });
+
+  const partiallyInitializedHeatmap = new CalHeatMap();
+
+  expect(() => partiallyInitializedHeatmap.destroy()).not.toThrow();
+  expect(getCalendarTooltips()).toHaveLength(0);
+});
+
+test('CalHeatMap destroy removes only the destroyed instance tooltips', () => {
+  installCreateSVGPointMock();
+
+  let CalHeatMap!: new () => CalHeatMapInstance;
+  jest.isolateModules(() => {
+    // eslint-disable-next-line global-require
+    CalHeatMap = require('../src/vendor/cal-heatmap')
+      .default as new () => CalHeatMapInstance;
+  });
+
+  const firstCalendar = document.createElement('div');
+  const secondCalendar = document.createElement('div');
+  document.body.append(firstCalendar, secondCalendar);
+
+  const firstHeatmap = new CalHeatMap();
+  firstHeatmap.init({
+    itemSelector: firstCalendar,
+    paintOnLoad: false,
+    tooltip: true,
+    valueFormatter: String,
+    timeFormatter: String,
+  });
+
+  const firstTooltips = getCalendarTooltips();
+  expect(firstTooltips).toHaveLength(2);
+
+  const secondHeatmap = new CalHeatMap();
+  secondHeatmap.init({
+    itemSelector: secondCalendar,
+    paintOnLoad: false,
+    tooltip: true,
+    valueFormatter: String,
+    timeFormatter: String,
+  });
+
+  const allTooltips = getCalendarTooltips();
+  const secondTooltips = allTooltips.filter(
+    tooltip => !firstTooltips.includes(tooltip),
+  );
+
+  expect(secondTooltips).toHaveLength(2);
+
+  firstHeatmap.destroy();
+
+  expect(firstTooltips.every(tooltip => !tooltip.isConnected)).toBe(true);
+  expect(secondTooltips.every(tooltip => tooltip.isConnected)).toBe(true);
+  expect(getCalendarTooltips()).toHaveLength(2);
+
+  secondHeatmap.destroy();
+
+  expect(getCalendarTooltips()).toHaveLength(0);
+});