From d982d42b96f597498df9be9cec0e48e302d91baa Mon Sep 17 00:00:00 2001 From: SEPURI-SAI-KRISHNA Date: Sat, 15 Aug 2026 07:32:22 +0530 Subject: [PATCH] fix(tooltip): clear pending timers on dispose --- src/component/tooltip/TooltipRichContent.ts | 3 + src/component/tooltip/TooltipView.ts | 7 ++ .../ut/spec/component/tooltip/dispose.test.ts | 91 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 test/ut/spec/component/tooltip/dispose.test.ts diff --git a/src/component/tooltip/TooltipRichContent.ts b/src/component/tooltip/TooltipRichContent.ts index d8f038dcbf..c402fd469e 100644 --- a/src/component/tooltip/TooltipRichContent.ts +++ b/src/component/tooltip/TooltipRichContent.ts @@ -213,6 +213,9 @@ class TooltipRichContent { } dispose() { + // Keep it consistent with `TooltipHTMLContent#dispose`, so that a pending + // `hideDelay` timer does not outlive the disposed chart. + clearTimeout(this._hideTimeout); this._zr.remove(this.el); } } diff --git a/src/component/tooltip/TooltipView.ts b/src/component/tooltip/TooltipView.ts index 044a12ffca..ff9ee8101b 100644 --- a/src/component/tooltip/TooltipView.ts +++ b/src/component/tooltip/TooltipView.ts @@ -1046,6 +1046,13 @@ class TooltipView extends ComponentView { } dispose(ecModel: GlobalModel, api: ExtensionAPI) { + // The pending timers must be cleared before the early return below, and + // before the members they rely on are reset to null. Otherwise a delayed + // callback (scheduled by `tooltip.showDelay` or by a refresh) would run + // against a disposed view and throw. + clearTimeout(this._showTimout); + clearTimeout(this._refreshUpdateTimeout); + if (env.node || !api.getDom()) { return; } diff --git a/test/ut/spec/component/tooltip/dispose.test.ts b/test/ut/spec/component/tooltip/dispose.test.ts new file mode 100644 index 0000000000..243224d4dd --- /dev/null +++ b/test/ut/spec/component/tooltip/dispose.test.ts @@ -0,0 +1,91 @@ +/* +* 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 { each } from 'zrender/src/core/util'; +import { createChart } from '../../../core/utHelper'; +import { EChartsType } from '../../../../../src/echarts'; + +describe('tooltip_dispose', function () { + + beforeEach(function () { + jest.useFakeTimers(); + }); + + afterEach(function () { + jest.useRealTimers(); + }); + + function createChartWithTooltip(tooltipOption: unknown): EChartsType { + const chart = createChart({width: 400, height: 300}); + chart.setOption({ + animation: false, + tooltip: tooltipOption, + xAxis: {type: 'category', data: ['a', 'b', 'c']}, + yAxis: {type: 'value'}, + series: [{type: 'line', data: [1, 2, 3]}] + }); + return chart; + } + + // A `showDelay` timer scheduled before `dispose` used to survive the disposal + // and then run against a torn-down view, throwing + // "Cannot read properties of null (reading 'setEnterable')". + each([ + {renderMode: 'html', trigger: 'axis'}, + {renderMode: 'html', trigger: 'item'}, + {renderMode: 'richText', trigger: 'axis'}, + {renderMode: 'richText', trigger: 'item'} + ], function (caseOpt) { + it('should not run a pending showDelay timer after dispose' + + ` (${caseOpt.renderMode}, ${caseOpt.trigger})`, function () { + + const chart = createChartWithTooltip({ + trigger: caseOpt.trigger, + renderMode: caseOpt.renderMode, + showDelay: 500 + }); + + chart.dispatchAction({type: 'showTip', seriesIndex: 0, dataIndex: 1}); + expect(jest.getTimerCount()).toBeGreaterThan(0); + + chart.dispose(); + expect(jest.getTimerCount()).toBe(0); + + expect(function () { + jest.runAllTimers(); + }).not.toThrow(); + }); + }); + + it('should clear a pending hideDelay timer on dispose (richText)', function () { + const chart = createChartWithTooltip({ + trigger: 'axis', + renderMode: 'richText', + hideDelay: 500 + }); + + chart.dispatchAction({type: 'showTip', seriesIndex: 0, dataIndex: 1}); + chart.dispatchAction({type: 'hideTip'}); + expect(jest.getTimerCount()).toBeGreaterThan(0); + + chart.dispose(); + expect(jest.getTimerCount()).toBe(0); + }); + +});