From ae98cc798be36684059400a441b59a260e0e54e3 Mon Sep 17 00:00:00 2001 From: TheSisb Date: Tue, 2 Jun 2026 10:56:11 -0500 Subject: [PATCH] Guard reselect against empty/undefined calcdata entries reselect runs unconditionally at the end of every redraw sequence (newPlot / react / relayout / resize). Its helper epmtySplomSelectionBatch iterates gd.calcdata and dereferences cd[i][0] with no guard, so a transient empty or undefined calcdata entry throws "Cannot read properties of undefined (reading '0')" and kills the chart even when no selection exists. The sibling supplyDefaultsUpdateCalc already guards the same access with (oldCalcdata[i] || [])[0]; make epmtySplomSelectionBatch and determineSearchTraces equally defensive by skipping empty/undefined entries. Add a jasmine spec covering both cases. --- src/components/selections/select.js | 6 ++++++ test/jasmine/tests/select_test.js | 30 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/components/selections/select.js b/src/components/selections/select.js index e44734665ad..1ad28507160 100644 --- a/src/components/selections/select.js +++ b/src/components/selections/select.js @@ -790,6 +790,8 @@ function determineSearchTraces(gd, xAxes, yAxes, subplot) { for(i = 0; i < gd.calcdata.length; i++) { cd = gd.calcdata[i]; + // guard against a transient empty/undefined calcdata entry (see epmtySplomSelectionBatch) + if(!cd || !cd[0]) continue; trace = cd[0].trace; if(trace.visible !== true || !trace._module || !trace._module.selectPoints) continue; @@ -1286,6 +1288,10 @@ function epmtySplomSelectionBatch(gd) { if(!cd) return; for(var i = 0; i < cd.length; i++) { + // calcdata can transiently hold an empty/undefined entry (e.g. during + // react/relayout diffing); reselect runs on every redraw, so skip + // instead of throwing. See supplyDefaultsUpdateCalc for the same guard. + if(!cd[i] || !cd[i][0]) continue; var cd0 = cd[i][0]; var trace = cd0.trace; var splomScenes = gd._fullLayout._splomScenes; diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 2261a72b24b..7e380a095a3 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -3,6 +3,7 @@ var d3SelectAll = require('../../strict-d3').selectAll; var Plotly = require('../../../lib/index'); var Lib = require('../../../src/lib'); +var Registry = require('../../../src/registry'); var click = require('../assets/click'); var doubleClick = require('../assets/double_click'); var DBLCLICKDELAY = require('../../../src/plot_api/plot_config').dfltConfig.doubleClickDelay; @@ -3600,6 +3601,35 @@ describe('Test that selection styles propagate to range-slider plot:', function( }); }); +describe('Test reselect with malformed calcdata:', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + // reselect runs unconditionally at the end of every redraw sequence + // (newPlot / react / relayout / resize) and dereferences gd.calcdata[i][0]. + // calcdata can transiently hold an empty or undefined entry, which used to + // throw "Cannot read properties of undefined (reading '0')" and kill the + // chart even when no selection exists. + it('should not throw on an empty or undefined calcdata entry', function(done) { + var reselect = Registry.getComponentMethod('selections', 'reselect'); + + Plotly.newPlot(gd, [{y: [1, 2, 3]}, {y: [2, 3, 4]}]) + .then(function() { + gd.calcdata[1] = undefined; + expect(function() { reselect(gd); }).not.toThrow(); + + gd.calcdata[0] = []; + expect(function() { reselect(gd); }).not.toThrow(); + }) + .then(done, done.fail); + }); +}); + // to make sure none of the above tests fail with extraneous invisible traces, // add a bunch of them here function addInvisible(fig, canHaveLegend) {