From 1d34dc78ad4173b2737b2de03660dc3f857b9f94 Mon Sep 17 00:00:00 2001 From: Dechante Chang Date: Sun, 12 Jul 2026 13:25:55 -0400 Subject: [PATCH 1/2] Don't run tooltip content callbacks when all items are filtered out The title callback (and the other content callbacks) ran with an empty tooltip item array when the filter callback removed every active item, and the empty tooltip was still shown. Hide the tooltip instead, like when nothing is active. Fixes #12274 --- src/plugins/plugin.tooltip.js | 9 ++++++-- test/specs/plugin.tooltip.tests.js | 35 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/plugins/plugin.tooltip.js b/src/plugins/plugin.tooltip.js index b39681ce2ca..f3717b05031 100644 --- a/src/plugins/plugin.tooltip.js +++ b/src/plugins/plugin.tooltip.js @@ -641,7 +641,13 @@ export class Tooltip extends Element { let properties; let tooltipItems = []; - if (!active.length) { + if (active.length) { + tooltipItems = this._createItems(options); + } + + // Hide the tooltip when nothing is active or every item was filtered + // out, so content callbacks are not invoked with an empty item array + if (!tooltipItems.length) { if (this.opacity !== 0) { properties = { opacity: 0 @@ -649,7 +655,6 @@ export class Tooltip extends Element { } } else { const position = positioners[options.position].call(this, active, this._eventPosition); - tooltipItems = this._createItems(options); this.title = this.getTitle(tooltipItems, options); this.beforeBody = this.getBeforeBody(tooltipItems, options); diff --git a/test/specs/plugin.tooltip.tests.js b/test/specs/plugin.tooltip.tests.js index 94ae45b7246..e2b9dbc31cb 100644 --- a/test/specs/plugin.tooltip.tests.js +++ b/test/specs/plugin.tooltip.tests.js @@ -831,6 +831,41 @@ describe('Plugin.Tooltip', function() { })); }); + it('should hide the tooltip and not run content callbacks when all items are filtered out', async function() { + var titleCallback = jasmine.createSpy('titleCallback'); + var chart = window.acquireChart({ + type: 'line', + data: { + datasets: [{ + label: 'Dataset 1', + data: [10, 20, 30] + }], + labels: ['Point 1', 'Point 2', 'Point 3'] + }, + options: { + plugins: { + tooltip: { + mode: 'index', + filter: function() { + return false; + }, + callbacks: { + title: titleCallback + } + } + } + } + }); + + var meta = chart.getDatasetMeta(0); + var point = meta.data[1]; + + await jasmine.triggerMouseEvent(chart, 'mousemove', point); + + expect(titleCallback).not.toHaveBeenCalled(); + expect(chart.tooltip.opacity).toBe(0); + }); + it('should set the caretPadding based on a config setting', async function() { var chart = window.acquireChart({ type: 'line', From 93e4b3cd1300d75856577bd4b352f183e59cbd12 Mon Sep 17 00:00:00 2001 From: Dechante Chang Date: Sun, 12 Jul 2026 18:21:03 -0400 Subject: [PATCH 2/2] Keep tooltip drawable and caret anchored when all items are filtered out When items were filtered after being visible, the stale body could be drawn with the freshly cleared label colors during fade-out, crashing _drawColorBox. Clear the content in that case and anchor the caret to the positioner result so an identical event is no longer reported as a position change. --- src/plugins/plugin.tooltip.js | 15 +++++++ test/specs/plugin.tooltip.tests.js | 71 ++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/plugins/plugin.tooltip.js b/src/plugins/plugin.tooltip.js index f3717b05031..10a7cd9450c 100644 --- a/src/plugins/plugin.tooltip.js +++ b/src/plugins/plugin.tooltip.js @@ -648,6 +648,21 @@ export class Tooltip extends Element { // Hide the tooltip when nothing is active or every item was filtered // out, so content callbacks are not invoked with an empty item array if (!tooltipItems.length) { + if (active.length) { + // Every item was filtered out: clear the content so the previous + // body is not drawn with the cleared label colors while fading out, + // and anchor the caret so an identical event is not a position change + this.title = []; + this.beforeBody = []; + this.body = []; + this.afterBody = []; + this.footer = []; + const position = positioners[options.position].call(this, active, this._eventPosition); + if (position !== false) { + this.caretX = position.x; + this.caretY = position.y; + } + } if (this.opacity !== 0) { properties = { opacity: 0 diff --git a/test/specs/plugin.tooltip.tests.js b/test/specs/plugin.tooltip.tests.js index e2b9dbc31cb..4873cef54f8 100644 --- a/test/specs/plugin.tooltip.tests.js +++ b/test/specs/plugin.tooltip.tests.js @@ -866,6 +866,77 @@ describe('Plugin.Tooltip', function() { expect(chart.tooltip.opacity).toBe(0); }); + it('should stay drawable while fading out after all items become filtered', async function() { + var chart = window.acquireChart({ + type: 'line', + data: { + datasets: [{ + label: 'Dataset 1', + data: [10, 20, 30] + }], + labels: ['Point 1', 'Point 2', 'Point 3'] + }, + options: { + plugins: { + tooltip: { + mode: 'index', + filter: function(item) { + return item.dataIndex !== 2; + } + } + } + } + }); + + var meta = chart.getDatasetMeta(0); + + // Show the tooltip on an accepted item, then move to a filtered one + await jasmine.triggerMouseEvent(chart, 'mousemove', meta.data[1]); + expect(chart.tooltip.body.length).toBe(1); + await jasmine.triggerMouseEvent(chart, 'mousemove', meta.data[2]); + + expect(chart.tooltip.body.length).toBe(0); + + // Drawing mid fade-out must not throw with the cleared label colors + chart.tooltip.opacity = 0.5; + expect(function() { + chart.tooltip.draw(chart.ctx); + }).not.toThrow(); + }); + + it('should not report a position change for repeated events on filtered items', async function() { + var chart = window.acquireChart({ + type: 'line', + data: { + datasets: [{ + label: 'Dataset 1', + data: [10, 20, 30] + }], + labels: ['Point 1', 'Point 2', 'Point 3'] + }, + options: { + plugins: { + tooltip: { + mode: 'index', + filter: function() { + return false; + } + } + } + } + }); + + var meta = chart.getDatasetMeta(0); + var point = meta.data[1]; + + await jasmine.triggerMouseEvent(chart, 'mousemove', point); + + var updateSpy = spyOn(chart.tooltip, 'update').and.callThrough(); + await jasmine.triggerMouseEvent(chart, 'mousemove', point); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + it('should set the caretPadding based on a config setting', async function() { var chart = window.acquireChart({ type: 'line',