Skip to content

fix: Skip redundant point setState calls when highlighting column series - #277

Merged
ernst-dev merged 2 commits into
mainfrom
fix/column-highlight-redundant-setstate
Nov 2, 2026
Merged

ernst-dev merged 2 commits into
mainfrom
fix/column-highlight-redundant-setstate

Conversation

@ernst-dev

@ernst-dev ernst-dev commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Description

Hovering a column chart re-applies the highlight state to every column point on each hover. On a 24 × 180 stacked column chart (4,320 points) that is 4,320 forced point.setState calls per hover, ~100 ms per hover.

Root cause: why columns only

  • Column series use a custom per-point highlight, because native Highcharts highlights whole x-groups for column series with a shared tooltip (Column-based highlight for shared tooltip highcharts/highcharts#23076, still open as a feature request). highlightChartPoint and highlightChartGroup therefore looped over every point of every column series on each hover.
  • Each of those calls used setState(state, true). The move = true argument bypasses Highcharts' own "already has this state" early return, so every unchanged point was redrawn.
  • Line, area and scatter series take a series-level path only (series.setState): no per-point loop, and 0 point setState calls per hover. Pie charts do loop per point, but have few points, and they need the forced call because the shared halo follows the last setState.

So the cost comes from our implementation (a full loop and a forced re-apply), not from a Highcharts bug. The only Highcharts-side item is the missing column-group highlight option (#23076), which is what made the workaround necessary. That is a possible upstream follow-up, not a bug report.

Fix

Approach suggested in review.

  • The column loops in highlightChartPoint and highlightChartGroup still visit every column point. After the first highlight following a render, they call setState(state, false) instead of setState(state, true), so Highcharts returns early for points whose state is unchanged and only redraws the points that actually change.
  • A columnStatesSynced flag tracks this. It is reset in onChartRender, on every render, so the next highlight re-applies every column point with move = true. This is needed after setData and other updates, where Highcharts may have reset or recreated points.
  • Pie and other series are unchanged: they keep move = true and the existing code paths.

Tests

One new unit test, "forces column point state updates only on the first highlight after render", in src/core/__tests__/chart-core-highlight.test.tsx. It spies on Point.prototype.setState on a chart with two column series and checks the move argument:

  • The first highlight after the initial render calls setState with move = true only. The next highlight uses move = false only.
  • After a re-render with new data, the first highlight uses move = true again, and the following one uses move = false.

The test fails when the reset in onChartRender is removed, when move is always false, and on main.

The column group highlight rendering is already covered by the existing "highlights a single group/stack, stacked=$stacked" test. There is no separate re-render restore test on main; the new test covers that the first highlight after a re-render re-applies every column point. Pie behaviour is unchanged, so this PR adds no pie tests.

Verification

  • Unit tests: 445 of 445 pass (run on the installed Highcharts 12.6.2).
  • Step-by-step equivalence against main on Highcharts 12.5.0, for column (single, stacked, grouped), line, area, scatter, pie and donut, plus core API scenarios for column and pie: 760 steps, 0 mismatches. Control: with the onChartRender reset removed, the same harness reports 28 mismatches.
  • Perf on a 24 × 180 stacked column chart, main against this PR:
    • Hover median: ~103 ms against ~62 ms.
    • Hover p95: 117 ms against 69 ms.
    • 20-move drag: 2,259 ms against 1,564 ms.
    • setState time per hover: 36.2 ms against 1.2 ms.

How has this been tested?

Unit tests (full suite), tsc, eslint, prettier, and the equivalence and perf runs above.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Review checklist

  • Changes are covered by new tests.
  • No changes to public API.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Pie-chart highlighting can leave the shared halo on the wrong item when returning to a previously highlighted point, requiring unconditional state application and a regression test.

1 open issue
What changed in this PR

This PR reduces redundant point-state updates in chart highlighting while preserving state restoration after data changes and re-renders.

Changes:

  • Skip state applications when both Highcharts and stored point states match the target.
  • Force state reapplication when restoring highlights after re-render.
  • Add tests for call counts, rendered highlights, data changes, pinned tooltips, and legend interactions.
File Description
src/​core/​chart-api/​chart-extra-highlight.ts Adds redundant-state checks and forced highlight restoration.
src/​core/​__tests__/​chart-core-highlight.test.tsx Adds column highlight performance and visual-state regression tests.

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

There are no active review issues for the change to reduce redundant column-highlight state updates.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@ernst-dev
ernst-dev marked this pull request as ready for review October 28, 2026 12:43
@ernst-dev
ernst-dev requested a review from a team as a code owner October 28, 2026 12:43
@ernst-dev
ernst-dev requested review from pan-kot and vikaskale2722 and removed request for a team and vikaskale2722 October 28, 2026 12:43
// The setState(state, true) call re-applies the state in the DOM even when it is unchanged, so for column series
// it is skipped for points that already have the target state, both in Highcharts and in the stored state for the
// current point key. A missing or different stored state means the point might have been redrawn (e.g. after its
// data changed). Other series types are always updated, because shared series graphics (such as the pie halo)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have other examples than pie halo? If only pie chart is an outlier - should we aim to make the optimisation for every other series - so not just "column"?

expect(rendered()).toEqual(highlighted(1, 0));
});

test("re-applies rendered point state after re-render", () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this different from "restores point highlight after re-render"?

},
);

describe("column point state", () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have less new tests? These go very deep into implementation detail and can be tricky to maintain. I am not even sure if we need one that validates highlight for the column series as there is an existing "highlights a single group/stack, stacked=$stacked" test. As a minimal regression cov - we can include a test that validates that setState is only called once per point - which is exactly the change introduced by the PR.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The highlight cache remains valid after series visibility changes without redraw, causing the incremental update to leave newly shown series points incorrectly undimmed.

1 open issue

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are identified for the incremental column-highlighting change.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

// the state after re-render, and for pie charts, where the shared halo follows the last setState call.
private setPointState(point: Highcharts.Point, state: "" | Highcharts.PointStateValue) {
(point.setState as PointSetStateWithLock)[SET_STATE_LOCK] = false;
point.setState(state, true);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For column series it is not enough to change the state on the series - so we have to go over many points. I noticed that if we change the 2nd argument here to false - the performance greatly improves: Highcharts avoids some extra work in this case. I believe we can use it to our advantage for column series, e.g.:

public onChartRender() {
    this.columnStatesSynced = false;
    //...
}

public highlightChartGroup = (points: readonly Highcharts.Point[]) => {
  // ...
  for (const s of getChartSeries(this.context.chart())) {
    // ...    
    if (s.type === "column") {
      // ...
      this.setPointState(d, includedPoints.has(d) ? "" : "inactive", !this.columnStatesSynced);
    }
    this.columnStatesSynced = true;
};

public highlightChartPoint = (point: Highcharts.Point) => {
  // same changes
};

private setPointState(point: Highcharts.Point, state: "" | Highcharts.PointStateValue, move = true) {
    (point.setState as PointSetStateWithLock)[SET_STATE_LOCK] = false;
    point.setState(state, move);
    this.updateStoredPointState(point, state);
    (point.setState as PointSetStateWithLock)[SET_STATE_LOCK] = true;
}

Could you please check if that or a similar approach works? This introduces less complexity and seems to produce about the same result.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Column highlighting still updates every point rather than only changed points, and regression tests are missing for consecutive hovers and render invalidation.

2 open issues

Review effort: Balanced

private columnStatesSynced = false;

public onChartRender() {
this.columnStatesSynced = false;
private setPointState(point: Highcharts.Point, state: "" | Highcharts.PointStateValue, move = true) {
(point.setState as PointSetStateWithLock)[SET_STATE_LOCK] = false;
point.setState(state, true);
point.setState(state, move);

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues were identified for the column-highlight synchronization change.

2 open issues

Review effort: Balanced

@ernst-dev
ernst-dev added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 2b77ace Nov 2, 2026
45 of 46 checks passed
@ernst-dev
ernst-dev deleted the fix/column-highlight-redundant-setstate branch November 2, 2026 10:09

This branch was successfully deployed

1 active deployment
dev-pages — 5e071b3c Deployed Oct 30, 2026 by ernst-dev via deploy / deploy #1326
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants