apache / apache/echarts

[Bug] Axis tooltip throws "reading 'getDataParams'" when a merged setOption removes series

Open
#21,732 1 comment 0 reactions 0 assignees View on GitHub
en pending
Dominant language
TypeScript
Stars
67.3k
Forks
19.8k
Avg merge
11d 14h
Merged PRs (30d)
8

Description

### Version

6.1.0 (also on master @ 30076ae)

### Link to Minimal Reproduction

https://github.com/JoshuaKGoldberg/repros/tree/echarts-tooltip-stale-series-index

### Steps to Reproduce

```shell
npm install
npm run build
npm run repro:headless
```

Or `npm run repro:browser` to drive it by hand. The repro is:

1. `echarts.init` a chart with `tooltip: {trigger: 'axis'}`, a category `xAxis`, and 5 `line` series (`animation: false`).
2. Hover the middle of the chart so the axis tooltip shows.
3. Without moving the pointer off the chart, `setOption` the same option with only 1 series, in merge mode:

```js
chart.setOption(optionWithOneSeries, {
lazyUpdate: false,
notMerge: false,
replaceMerge: ['series', 'xAxis', 'yAxis'],
});
```

### Current Behavior

An uncaught `TypeError` is thrown a tick later:

```plaintext
Uncaught TypeError: Cannot read properties of undefined (reading 'getDataParams')
at TooltipView._showAxisTooltip
at TooltipView._tryShow
at TooltipView.manuallyShowTip
at
```

Poking deeper:

1. `TooltipView` caches the last hovered pointer state in `_lastX` / `_lastY` / `_lastDataByCoordSys`, and `_lastDataByCoordSys` holds raw `seriesIndex` numbers.
2. Every `setOption` runs `TooltipView.render()` → [`_keepShow()`](https://github.com/apache/echarts/blob/6.1.0/src/component/tooltip/TooltipView.ts#L240-L274), which, when `_lastX`/`_lastY` are set and `triggerOn` is neither `'none'` nor `'click'`, schedules `setTimeout(() => self.manuallyShowTip(..., {x: _lastX, y: _lastY, dataByCoordSys: _lastDataByCoordSys}))` to re-show the tooltip after the update.
3. The new option has fewer series than those cached indices refer to. [`GlobalModel.getSeriesByIndex`](https://github.com/apache/echarts/blob/6.1.0/src/model/Global.ts#L764-L766) is just `this._componentsMap.get('series')[seriesIndex]`, so an out-of-range index yields `undefined`, and [`_showAxisTooltip`](https://github.com/apache/echarts/blob/6.1.0/src/component/tooltip/TooltipView.ts#L579-L582) dereferences it unguarded:

```ts
each(axisItem.seriesDataIndices, function (idxItem) {
const series = ecModel.getSeriesByIndex(idxItem.seriesIndex);
const dataIndex = idxItem.dataIndexInside;
const cbParams = series.getDataParams(dataIndex) as TooltipCallbackDataParams; // series is undefined
```

This only reproduces under merge mode. With `setOption(option, true)` the tooltip component view is disposed and recreated, so `_lastX` is cleared and nothing throws. It needs the combination the repro uses: the `tooltip` component is **merged** (the view survives with its stale cache) while `series` is **replaced** (the cached indices go out of range).

It is not fatal but:

- The stale tooltip stays on screen listing all 5 removed series with their old values.
- Every subsequent `setOption` throws again while the pointer sits still (3 updates → 3 errors).
- Moving the pointer recovers it: the tooltip re-renders with the remaining series and no further errors occur.

Because the throw comes from a `setTimeout`, it can't be caught by application code.
It lands as an uncaught error, so any app with error reporting sees it as a user-visible crash even though the chart survives.
That's how I landed here!

### Expected Behavior

The re-shown tooltip skips series that no longer exist instead of throwing.

ECharts already applies exactly this guard at the sibling call site that builds axis-pointer label params, [`src/component/axisPointer/viewHelper.ts#L177-L180`](https://github.com/apache/echarts/blob/6.1.0/src/component/axisPointer/viewHelper.ts#L177-L180):

```ts
zrUtil.each(seriesDataIndices, function (idxItem) {
const series = ecModel.getSeriesByIndex(idxItem.seriesIndex);
const dataIndex = idxItem.dataIndexInside;
const dataParams = series && series.getDataParams(dataIndex);
dataParams && params.seriesData.push(dataParams);
});
```

### Environment

- **OS**: macOS 15
- **Browser**: Chromium 151 (also reproduces in headless Chromium via Playwright)
- **Framework**: none (plain `echarts.init`)

### Any additional comments?

A few lines above the crash, [`TooltipView.ts#L556-L562`](https://github.com/apache/echarts/blob/6.1.0/src/component/tooltip/TooltipView.ts#L556-L562) dereferences `axisModel` *before* its own null check:

```ts
const axisModel = ecModel.getComponent(axisItem.axisDim + 'Axis', axisItem.axisIndex) as AxisBaseModel;
const axisValue = axisItem.value;
const axis = axisModel.axis; // throws if axisModel is undefined
const axisValueParsed = axis.scale.parse(axisValue);
if (!axisModel || axisValue == null) { // too late
return;
}
```

The same staleness that removes a series can remove an axis, so `getComponent` can return `undefined` here.

Related but distinct:

* #19827: same `_keepShow` re-show path, but a null tooltip DOM on the *item* tooltip path
* #21535: `getDataParams` crashing from `findEventDispatcher` mousemove on a disposed series

Contributor guide

Open the contributing guide

Research direction

Start with src/component/tooltip/TooltipView.ts, especially _keepShow() and _showAxisTooltip(), and compare the existing guard in src/component/axisPointer/viewHelper.ts. Run the linked reproduction with npm install, npm run build, and npm run repro:headless; done means merged series or axes removed by setOption no longer cause an uncaught error and stale tooltip entries are skipped.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
data-visualization
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.