Skip to content

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

Description

@JoshuaKGoldberg

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

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:
chart.setOption(optionWithOneSeries, {
  lazyUpdate: false,
  notMerge: false,
  replaceMerge: ['series', 'xAxis', 'yAxis'],
});

Current Behavior

An uncaught TypeError is thrown a tick later:

Uncaught TypeError: Cannot read properties of undefined (reading 'getDataParams')
    at TooltipView._showAxisTooltip
    at TooltipView._tryShow
    at TooltipView.manuallyShowTip
    at <setTimeout callback in TooltipView._keepShow>

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(), 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 is just this._componentsMap.get('series')[seriesIndex], so an out-of-range index yields undefined, and _showAxisTooltip dereferences it unguarded:
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:

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 dereferences axisModel before its own null check:

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:

Metadata

Metadata

Assignees

No one assigned

    Labels

    enThis issue is in EnglishpendingWe are not sure about whether this is a bug/new feature.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions