This is an automated email from the ASF dual-hosted git repository.
kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 5bb54cc96bc fix(echarts): preserve dataZoom range across
setOption(notMerge) (#40173)
5bb54cc96bc is described below
commit 5bb54cc96bc277425104b4366840e4ced3f3e438
Author: jesperct <[email protected]>
AuthorDate: Wed May 20 12:33:29 2026 -0300
fix(echarts): preserve dataZoom range across setOption(notMerge) (#40173)
---
.../plugin-chart-echarts/src/components/Echart.tsx | 45 +++++
.../test/components/Echart.test.tsx | 192 +++++++++++++++++++++
2 files changed, 237 insertions(+)
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
index 3b85b5326ba..97b55f2a9a7 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -56,6 +56,7 @@ import {
VisualMapComponent,
LegendComponent,
DataZoomComponent,
+ type DataZoomComponentOption,
ToolboxComponent,
GraphicComponent,
AriaComponent,
@@ -280,12 +281,56 @@ function Echart(
const notMerge = !isDashboardRefreshing;
chartRef.current?.dispatchAction({ type: 'hideTip' });
+ // setOption(notMerge:true) replaces the dataZoom config, dropping any
+ // range the user has engaged. Preserve it across the call.
+ const previousZoom = notMerge
+ ? (chartRef.current?.getOption() as { dataZoom?:
DataZoomComponentOption[] })
+ ?.dataZoom
+ : undefined;
chartRef.current?.setOption(themedEchartOptions, {
notMerge,
replaceMerge: notMerge ? undefined : ['series'],
// lazyUpdate defers render, causing tooltip crashes on stale shapes
(#39247)
lazyUpdate: false,
});
+ if (previousZoom?.length) {
+ // Skip restore when the new option reshapes dataZoom (different count
+ // means index-based restore could land on the wrong component).
+ const newZoom = (
+ chartRef.current?.getOption() as {
+ dataZoom?: DataZoomComponentOption[];
+ }
+ )?.dataZoom;
+ if (newZoom?.length === previousZoom.length) {
+ const batch = previousZoom
+ .map((dz, dataZoomIndex) => ({
+ dataZoomIndex,
+ start: dz.start,
+ end: dz.end,
+ startValue: dz.startValue,
+ endValue: dz.endValue,
+ }))
+ .filter(b => {
+ const hasAny =
+ b.start !== undefined ||
+ b.end !== undefined ||
+ b.startValue !== undefined ||
+ b.endValue !== undefined;
+ if (!hasAny) return false;
+ // Default full-range zoom is functionally identical to the
+ // fresh state setOption already produces — skip the dispatch.
+ const isDefaultRange =
+ b.start === 0 &&
+ b.end === 100 &&
+ b.startValue === undefined &&
+ b.endValue === undefined;
+ return !isDefaultRange;
+ });
+ if (batch.length) {
+ chartRef.current?.dispatchAction({ type: 'dataZoom', batch });
+ }
+ }
+ }
}
// eslint-disable-next-line react-hooks/exhaustive-deps --
isDashboardRefreshing intentionally excluded to prevent extra setOption calls
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme,
vizType]);
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/test/components/Echart.test.tsx
b/superset-frontend/plugins/plugin-chart-echarts/test/components/Echart.test.tsx
new file mode 100644
index 00000000000..b5e025cd7e9
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-echarts/test/components/Echart.test.tsx
@@ -0,0 +1,192 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { EChartsCoreOption } from 'echarts/core';
+import { render, waitFor } from 'spec/helpers/testing-library';
+
+const setOption = jest.fn();
+const on = jest.fn();
+const off = jest.fn();
+const resize = jest.fn();
+const dispose = jest.fn();
+const dispatchAction = jest.fn();
+const getOption = jest.fn();
+
+const mockInstance = {
+ setOption,
+ on,
+ off,
+ resize,
+ dispose,
+ dispatchAction,
+ getOption,
+ getZr: () => ({ on: jest.fn(), off: jest.fn() }),
+};
+
+jest.mock('echarts/core', () => ({
+ __esModule: true,
+ use: jest.fn(),
+ init: jest.fn(() => mockInstance),
+ registerLocale: jest.fn(),
+}));
+jest.mock('echarts/charts', () => ({}));
+jest.mock('echarts/renderers', () => ({}));
+jest.mock('echarts/components', () => ({}));
+jest.mock('echarts/features', () => ({}));
+
+// eslint-disable-next-line import/first
+import Echart from '../../src/components/Echart';
+
+const renderEchart = (echartOptions: EChartsCoreOption) => {
+ const refs = { divRef: undefined };
+ return render(
+ <Echart
+ width={400}
+ height={300}
+ echartOptions={echartOptions}
+ refs={refs}
+ />,
+ { useRedux: true, useTheme: true },
+ );
+};
+
+beforeEach(() => {
+ setOption.mockClear();
+ on.mockClear();
+ off.mockClear();
+ resize.mockClear();
+ dispatchAction.mockClear();
+ getOption.mockReset();
+});
+
+test('preserves user dataZoom range across setOption(notMerge)', async () => {
+ // After the user has zoomed, ECharts reports the current dataZoom range
+ // via getOption().dataZoom. We expect Echart to capture this before
+ // setOption replaces the option payload, then restore it via dispatchAction.
+ getOption.mockReturnValue({
+ dataZoom: [{ start: 12, end: 48 }],
+ });
+
+ const { rerender } = renderEchart({ xAxis: {}, series: [] });
+
+ // Trigger another setOption call by changing the echartOptions reference
+ rerender(
+ <Echart
+ width={400}
+ height={300}
+ echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
+ refs={{ divRef: undefined }}
+ />,
+ );
+
+ await waitFor(() =>
+ expect(dispatchAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'dataZoom',
+ batch: [
+ expect.objectContaining({ dataZoomIndex: 0, start: 12, end: 48 }),
+ ],
+ }),
+ ),
+ );
+});
+
+test('does not restore when no prior zoom range exists', async () => {
+ // Fresh chart with no engaged zoom: dataZoom config has no start/end.
+ getOption.mockReturnValue({
+ dataZoom: [{ type: 'slider', show: true }],
+ });
+
+ const { rerender } = renderEchart({ xAxis: {}, series: [] });
+ await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
+
+ rerender(
+ <Echart
+ width={400}
+ height={300}
+ echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
+ refs={{ divRef: undefined }}
+ />,
+ );
+
+ await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
+ const dataZoomCalls = dispatchAction.mock.calls.filter(
+ ([action]) => action?.type === 'dataZoom',
+ );
+ expect(dataZoomCalls).toHaveLength(0);
+});
+
+test('does not restore when prior zoom is at default full range', async () => {
+ // ECharts populates start:0/end:100 on slider dataZoom by default, so
+ // every untouched timeseries would otherwise dispatch a redundant action
+ // on each re-render. Skip the dispatch when the range is just the default.
+ getOption.mockReturnValue({
+ dataZoom: [{ type: 'slider', show: true, start: 0, end: 100 }],
+ });
+
+ const { rerender } = renderEchart({ xAxis: {}, series: [] });
+ await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
+
+ rerender(
+ <Echart
+ width={400}
+ height={300}
+ echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
+ refs={{ divRef: undefined }}
+ />,
+ );
+
+ await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
+ const dataZoomCalls = dispatchAction.mock.calls.filter(
+ ([action]) => action?.type === 'dataZoom',
+ );
+ expect(dataZoomCalls).toHaveLength(0);
+});
+
+test('does not restore when the new option reshapes dataZoom', async () => {
+ // 1st render starts with no engaged zoom; 2nd render captures an engaged
+ // range but the post-setOption dataZoom has a different count, so
+ // index-based restore could write to the wrong component. Skip in that case.
+ getOption
+ // 1st render: previousZoom + newZoom (no engaged values, nothing to
dispatch)
+ .mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
+ .mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
+ // 2nd render: previousZoom has user range, but newZoom has 2 entries
+ .mockReturnValueOnce({ dataZoom: [{ start: 12, end: 48 }] })
+ .mockReturnValueOnce({
+ dataZoom: [{ start: 12, end: 48 }, { type: 'inside' }],
+ });
+
+ const { rerender } = renderEchart({ xAxis: {}, series: [] });
+ await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
+
+ rerender(
+ <Echart
+ width={400}
+ height={300}
+ echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
+ refs={{ divRef: undefined }}
+ />,
+ );
+
+ await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
+ const dataZoomCalls = dispatchAction.mock.calls.filter(
+ ([action]) => action?.type === 'dataZoom',
+ );
+ expect(dataZoomCalls).toHaveLength(0);
+});