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);
+});