Repository: highcharts/highcharts
Pull request #22906: bugfix/22892-packedbubble-hover-error
Author: login: MarkusBarstad
Labels: name: Product: Highcharts
name: Changelog: Bugfix
description: Use on PR to add description as a bugfix in the generated changelog.
Created: 2025-04-07T07:53:31Z | Merged: 2025-04-10T06:00:10Z

Fixed #22892, hovering a changing packed bubble series could result in error thrown.


NOTE:
- The way to trigger this issue is to hover the chart while setInterval is continuously updating the chart, triggering a console error.
- How should a unit-test for this look?
- Should we do the setInterval thing while using testController to hover about the series?

Linked issue #22892: Packedbubble chart crashes when quickly hovering over the chart during it's update with `splitSeries: true`
#### Expected behaviour
The chart renders normally even when hovering over plot area and no error is logged to the console.

#### Actual behaviour
The chart crashes and an error `Cannot read properties of undefined (reading 'appendChild')` is thrown in console.

#### Live demo with steps to reproduce
The issue happens only when `layoutAlgorithm.splitSeries` is enabled.
https://jsfiddle.net/BlackLabel/u8a4Lz5m/

#### Product version
Regression since v8.2.0

_Internal note:_ unable to bisect.

<!-- ASANA TASK ID (do not delete): 1209869315024324 -->
Linked issue comments: user: login: wootera
created_at: 2025-04-04T17:36:32Z
body: Hi @jedrzejruta - thanks for creating this ticket!

Let me ask you, what is an average ETA on bugs, like this?
author_association: NONE
user: login: jedrzejruta
created_at: 2025-04-06T21:00:35Z
body: Hi @wootera!

We've logged this issue in our backlog and will evaluate it as part of our ongoing prioritization process. There isn't a fixed ETA for particular, hovewer it's been forwarded to the product team and we will provide updates here when available.
author_association: CONTRIBUTOR
user: login: MarkusBarstad
created_at: 2025-04-07T08:11:42Z
body: @wootera Hey! Here is a workaround you can use while we fix this issue: https://jsfiddle.net/mkb93/gqvpy1ak/3/


author_association: CONTRIBUTOR
user: login: wootera
created_at: 2025-04-07T09:16:17Z
body: Hi @MarkusBarstad - thanks for your suggestion, but we are using HighchartsReact, so the definition of the chart comes declaratively in our components...
author_association: NONE
user: login: MarkusBarstad
created_at: 2025-04-07T11:36:14Z
body: @wootera , I see. You can see a way of implementing the workaround here: https://stackblitz.com/edit/react-starter-typescript-4toekq7b?file=App.tsx

In any case, this bugfix should be part of next release!
author_association: CONTRIBUTOR

Diff:
diff --git a/samples/unit-tests/series-packedbubble/update/demo.js b/samples/unit-tests/series-packedbubble/update/demo.js
index a9e6a2247ee..18978a815e9 100644
--- a/samples/unit-tests/series-packedbubble/update/demo.js
+++ b/samples/unit-tests/series-packedbubble/update/demo.js
@@ -112,3 +112,65 @@ QUnit.test('Series update', function (assert) {
         chart plot height, #20264.`
     );
 });
+
+
+QUnit.test('Testing hovering while updating, #22892', function (assert) {
+    let count = 0,
+        clock = null;
+
+    try {
+        clock = TestUtilities.lolexInstall();
+
+        const getChartOptions = count => ({
+                chart: {
+                    type: 'packedbubble',
+                    animation: false
+                },
+                plotOptions: {
+                    packedbubble: {
+                        layoutAlgorithm: {
+                            enableSimulation: false,
+                            splitSeries: true
+                        }
+                    }
+                },
+                series: [{
+                    data: (() => {
+                        const data = [];
+                        for (let i = count; i; --i) {
+                            data.push(i);
+                        }
+                        return data;
+                    })()
+                }]
+            }),
+            done = assert.async(),
+            chart = Highcharts.chart('container', getChartOptions(count)),
+            hoverX = chart.plotLeft + (chart.chartWidth / 2),
+            hoverY = chart.plotTop + (chart.chartHeight / 2),
+            tc = new TestController(chart),
+
+            // Change chart config while simulating mouse hovering
+            // the center of the series
+            interval = setInterval(() => {
+                chart.update(getChartOptions(++count), true, true);
+
+                // We know we will hit either a bubble or the parentBubble
+                tc.moveTo(hoverX, hoverY);
+
+                if (count === 8) {
+                // Check that hovering worked
+                    assert.strictEqual(
+                        chart.hoverPoint.state,
+                        'hover',
+                        'Hovering a changing packed bubble series should work'
+                    );
+                    clearInterval(interval);
+                    done();
+                }
+            }, 50);
+        TestUtilities.lolexRunAndUninstall(clock);
+    } finally {
+        TestUtilities.lolexUninstall(clock);
+    }
+});
diff --git a/ts/Series/Bubble/BubblePoint.ts b/ts/Series/Bubble/BubblePoint.ts
index b7b0715549c..7d1ccbcc964 100644
--- a/ts/Series/Bubble/BubblePoint.ts
+++ b/ts/Series/Bubble/BubblePoint.ts
@@ -72,13 +72,14 @@ class BubblePoint extends ScatterPoint {
 
         if (this.series.chart.inverted) {
             const pos = this.pos() || [0, 0],
-                { xAxis, yAxis, chart } = this.series;
+                { xAxis, yAxis, chart } = this.series,
+                diameter = computedSize * 2;
 
             return chart.renderer.symbols.circle(
-                xAxis.len - pos[1] - computedSize,
-                yAxis.len - pos[0] - computedSize,
-                computedSize * 2,
-                computedSize * 2
+                (xAxis?.len || 0) - pos[1] - computedSize,
+                (yAxis?.len || 0) - pos[0] - computedSize,
+                diameter,
+                diameter
             );
         }
 
diff --git a/ts/Series/PackedBubble/PackedBubblePoint.ts b/ts/Series/PackedBubble/PackedBubblePoint.ts
index ea3c505e4b0..720f41de28e 100644
--- a/ts/Series/PackedBubble/PackedBubblePoint.ts
+++ b/ts/Series/PackedBubble/PackedBubblePoint.ts
@@ -21,6 +21,7 @@ import type { DragNodesPoint } from '../DragNodesComposition';
 import type NetworkgraphPoint from '../Networkgraph/NetworkgraphPoint';
 import type PackedBubblePointOptions from './PackedBubblePointOptions';
 import type PackedBubbleSeries from './PackedBubbleSeries';
+import type { StatesOptionsKey } from '../../Core/Series/StatesOptions';
 
 import Chart from '../../Core/Chart/Chart.js';
 import Point from '../../Core/Series/Point.js';
@@ -147,6 +148,15 @@ class PackedBubblePoint extends BubblePoint implements DragNodesPoint {
         }
     }
 
+    public setState(
+        state?: StatesOptionsKey,
+        move?: boolean
+    ): void {
+        if (this?.graphic?.parentGroup?.element) {
+            super.setState(state, move);
+        }
+    }
+
     /* eslint-enable valid-jsdoc */
 
 }


Reviews:
user: login: goransle
body: Not 100% sure about the test, but I would try to set something up using [assert.async](https://qunitjs.com/api/assert/async/) and an interval. Possibly it could be manual test if QUnit or Karma doesn't like it 🤔 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-07T08:22:53Z
user: login: goransle
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T08:23:35Z
user: login: goransle
body: Looks good!

state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-08T12:56:03Z
user: login: TorsteinHonsi
body: Thanks!

About the test, `setInterval` and `setTimeout` are generally unstable in tests because we don't know for sure that the browser has enough resources to process each step. For animation tests, we use lolex, which hijacks the browser's timers and runs them in a synchronous and controlled way. See for example [3d/column-animation](https://github.com/highcharts/highcharts/blob/master/samples/unit-tests/3d/column-animation/demo.js) for how this is set up using our TestUtilities wrappers for lolex. A little disclaimer though that I am not sure how this will play with the packed bubble simulation.
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T07:15:29Z
user: login: TorsteinHonsi
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-10T05:59:38Z

Review comments:
diff_hunk: @@ -25,6 +25,7 @@ import type PackedBubbleSeries from './PackedBubbleSeries';
 import Chart from '../../Core/Chart/Chart.js';
 import Point from '../../Core/Series/Point.js';
 import SeriesRegistry from '../../Core/Series/SeriesRegistry.js';
+import { StatesOptionsKey } from '../../Core/Series/StatesOptions';
path: ts/Series/PackedBubble/PackedBubblePoint.ts
user: login: goransle
body: Should ideally be `import type { StatesOptionsKey } ...`
created_at: 2025-04-07T08:18:11Z
author_association: MEMBER
diff_hunk: @@ -147,6 +148,15 @@ class PackedBubblePoint extends BubblePoint implements DragNodesPoint {
         }
     }
 
+    public setState(
+        state?: (StatesOptionsKey|''),
path: ts/Series/PackedBubble/PackedBubblePoint.ts
user: login: goransle
body: Is the `|''` part superfluous? Seem likes the StatesOptionsKey type should already include it
created_at: 2025-04-07T08:19:56Z
author_association: MEMBER
diff_hunk: @@ -25,6 +25,7 @@ import type PackedBubbleSeries from './PackedBubbleSeries';
 import Chart from '../../Core/Chart/Chart.js';
 import Point from '../../Core/Series/Point.js';
 import SeriesRegistry from '../../Core/Series/SeriesRegistry.js';
+import { StatesOptionsKey } from '../../Core/Series/StatesOptions';
path: ts/Series/PackedBubble/PackedBubblePoint.ts
user: login: goransle
body: (And moved up with the other type imports)
created_at: 2025-04-07T08:23:35Z
author_association: MEMBER

PR comments:
user: login: highsoft-bot
created_at: 2025-04-07T07:58:17Z
body: ### File size comparison
Sizes for compiled+gzipped (bold) and compiled files.
| | master | candidate | difference |
|-------------|-------------:|-------------:|-------------:|
| highmaps.js | **124.6 kB**<br>345.9 kB | **124.6 kB**<br>345.9 kB | **6 B**<br>14 B |
author_association: COLLABORATOR
user: login: highsoft-bot
created_at: 2025-04-07T08:02:03Z
body: ## Visual test results - No difference found


author_association: COLLABORATOR
user: login: MarkusBarstad
created_at: 2025-04-08T11:29:59Z
body: Ok, @goransle , got a test! Unfortunately I had to make a seperate QUnit test. Not sure I am using `assert.async` correctly but it does not appear to cause any issues at least 🤔 
author_association: CONTRIBUTOR
user: login: MarkusBarstad
created_at: 2025-04-09T12:31:19Z
body: @TorsteinHonsi Alright, using lolex now, seems to play fine with packedbubble (simulation/animation is disabled, like the original issue demo)

The one failing test is `samples/highcharts/boost/arearange/` , I suspect its unrelated since it is not failing locally
author_association: CONTRIBUTOR