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:

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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