From fbfa8862f3ed579579ed49439d4979950aa5a9ce Mon Sep 17 00:00:00 2001 From: sergioperezcheco Date: Wed, 7 Oct 2026 12:56:34 +0800 Subject: [PATCH] fix: cancel stale dataset animations on non-animated updates Assisted-by: Hermes Agent Signed-off-by: sergioperezcheco --- src/core/core.datasetController.js | 31 +++++++++- test/specs/core.animations.tests.js | 87 +++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/core/core.datasetController.js b/src/core/core.datasetController.js index 145a2b6a11e..4939124e9f0 100644 --- a/src/core/core.datasetController.js +++ b/src/core/core.datasetController.js @@ -226,6 +226,22 @@ function clearStacks(meta, items) { } } +/** + * @param {{ $animations?: Record, options?: object }} target + */ +function cancelAnimations(target) { + if (!target) { + return; + } + const animations = target.$animations || {}; + for (const animation of Object.values(animations)) { + animation.cancel(); + } + if (target.options) { + cancelAnimations(target.options); + } +} + const isDirectUpdateMode = (mode) => mode === 'reset' || mode === 'none'; const cloneIfNotShared = (cached, shared) => shared ? cached : Object.assign({}, cached); const createStack = (canStack, meta, chart) => canStack && !meta.hidden && meta._stacked @@ -700,6 +716,13 @@ export default class DatasetController { */ _update(mode) { const meta = this._cachedMeta; + if (mode === 'none') { + for (const element of meta.data) { + cancelAnimations(element); + } + cancelAnimations(meta.dataset); + cancelAnimations(this._sharedOptions); + } this.update(mode || 'default'); meta._clip = toClip(valueOrDefault(this.options.clip, defaultClip(meta.xScale, meta.yScale, this.getMaxOverflow()))); } @@ -908,8 +931,12 @@ export default class DatasetController { * @protected */ updateSharedOptions(sharedOptions, mode, newOptions) { - if (sharedOptions && !isDirectUpdateMode(mode)) { - this._resolveAnimations(undefined, mode).update(sharedOptions, newOptions); + if (sharedOptions) { + if (mode === 'none') { + Object.assign(sharedOptions, newOptions); + } else if (!isDirectUpdateMode(mode)) { + this._resolveAnimations(undefined, mode).update(sharedOptions, newOptions); + } } } diff --git a/test/specs/core.animations.tests.js b/test/specs/core.animations.tests.js index 6fddb20445f..fa07af1872b 100644 --- a/test/specs/core.animations.tests.js +++ b/test/specs/core.animations.tests.js @@ -1,4 +1,91 @@ describe('Chart.animations', function() { + it('should preserve a non-animated update across rendered animation frames', function(done) { + const chart = acquireChart({ + type: 'line', + data: {datasets: [{data: [2]}]}, + options: {animation: {duration: 100}, scales: {y: {min: 0, max: 10}}} + }); + chart.stop(); + chart.data.datasets[0].data[0] = 8; + chart.update(); + chart.data.datasets[0].data[0] = 4; + chart.update('none'); + setTimeout(function() { + expect(chart.getDatasetMeta(0).data[0].y).toBeCloseTo(chart.scales.y.getPixelForValue(4), 6); + done(); + }, 200); + }); + + ['line', 'scatter', 'bar'].forEach(function(type) { + it('should preserve a non-animated ' + type + ' update during an animation', function() { + const chart = acquireChart({ + type, + data: {datasets: [{data: [{x: 1, y: 2}]}]}, + options: { + animation: {duration: 1000}, + scales: {x: {type: 'linear', min: 0, max: 2}, y: {min: 0, max: 10}} + } + }); + chart.stop(); + chart.data.datasets[0].data[0].y = 8; + chart.update(); + const point = chart.getDatasetMeta(0).data[0]; + const animation = point.$animations.y; + expect(animation.active()).toBeTrue(); + + chart.data.datasets[0].data[0].y = 4; + chart.update('none'); + const expected = chart.scales.y.getPixelForValue(4); + expect(point.y).toBeCloseTo(expected, 6); + if (animation.active()) { + animation.tick(Date.now() + 2000); + } + expect(point.y).toBeCloseTo(expected, 6); + expect(animation.active()).toBeFalse(); + }); + }); + + it('should only cancel animations for datasets updated with none', function() { + const chart = acquireChart({ + type: 'line', + data: {datasets: [{data: [2]}, {data: [3]}]}, + options: {animation: {duration: 1000}, scales: {y: {min: 0, max: 10}}} + }); + chart.stop(); + chart.data.datasets[0].data[0] = 8; + chart.data.datasets[1].data[0] = 9; + chart.update(); + const first = chart.getDatasetMeta(0).data[0]; + const second = chart.getDatasetMeta(1).data[0]; + chart.data.datasets[0].data[0] = 4; + chart.update(function(context) { + return context.datasetIndex === 0 ? 'none' : 'default'; + }); + expect(first.$animations.y.active()).toBeFalse(); + expect(first.y).toBeCloseTo(chart.scales.y.getPixelForValue(4), 6); + expect(second.$animations.y.active()).toBeTrue(); + }); + + it('should cancel shared option animations before a non-animated update', function() { + const chart = acquireChart({ + type: 'line', + data: {datasets: [{data: [2, 3], pointRadius: 3}]}, + options: {animation: {duration: 1000}} + }); + chart.stop(); + chart.data.datasets[0].pointRadius = 10; + chart.update(); + const controller = chart.getDatasetMeta(0).controller; + const animation = controller._sharedOptions.$animations.radius; + expect(animation.active()).toBeTrue(); + chart.data.datasets[0].pointRadius = 5; + chart.update('none'); + expect(animation.active()).toBeFalse(); + for (const point of chart.getDatasetMeta(0).data) { + expect(point.options.radius).toBe(5); + } + }); + it('should override property collection with property', function() { const chart = {}; const anims = new Chart.Animations(chart, {