Skip to content

Commit d8a8378

Browse files
scttcpercodex
andauthored
build(charts): Reduce ECharts bundle size (#122806)
Some dashboard helpers imported the all-inclusive ECharts entry point just to connect chart groups. That registered every chart feature globally and pulled it into the startup bundle. Switches those helpers to `echarts/core` and gives `BaseChart` an explicit registry for the seven series types, components, SVG/canvas renderers, and legacy grid behavior we currently rely on. | Startup vendor chunk | Raw | Gzip | |---|---:|---:| | Before | 3,936.5 KiB | 1,251.3 KiB | | After | 3,599.1 KiB | 1,146.0 KiB | | Saved | 337.5 KiB | 105.3 KiB | The main risk is missing a registration when adding a new ECharts feature; this audits every current series, component, and action and keeps both renderers plus `LegacyGridContainLabel` to avoid behavior changes. The production source map no longer contains `echarts/index.js`; ECharts modules drop from 501 to 309. --------- Co-authored-by: OpenAI Codex <noreply@openai.com>
1 parent 788f96a commit d8a8378

8 files changed

Lines changed: 69 additions & 37 deletions

File tree

knip.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const config: KnipConfig = {
7575
'odiff-bin', // raw binary consumed by Python backend, not a JS import
7676
'run-on-changed', // CLI used by the eslint CI job (.github/workflows/frontend.yml), not a JS import
7777
'@swc-contrib/mut-cjs-exports', // used in jest config
78+
'zrender', // used in echarts
7879
],
7980
// Knip's Less compiler expects the extension in `project`; styles are handled by Rspack,
8081
// so do not report them as unused files.

static/app/components/charts/baseChart.tsx

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
1-
import 'echarts/lib/component/grid';
2-
import 'echarts/lib/component/graphic';
3-
import 'echarts/lib/component/toolbox';
4-
import 'echarts/lib/component/brush';
5-
import 'echarts/lib/component/visualMap';
61
import 'echarts/theme/v5.js';
7-
import 'zrender/lib/svg/svg';
8-
// Canvas backend. Explicit so `renderer: 'canvas'` never silently relies on
9-
// another module importing the full `echarts` bundle.
10-
import 'zrender/lib/canvas/canvas';
112

123
import {useEffect, useId, useMemo, useRef} from 'react';
134
import type {Theme} from '@emotion/react';
@@ -29,8 +20,33 @@ import type {
2920
YAXisComponentOption,
3021
} from 'echarts';
3122
import ReactEchartsCore from 'echarts-for-react/lib/core';
32-
import {AriaComponent} from 'echarts/components';
23+
import {
24+
BarChart,
25+
CustomChart,
26+
HeatmapChart,
27+
LineChart,
28+
PieChart,
29+
ScatterChart,
30+
TreemapChart,
31+
} from 'echarts/charts';
32+
import {
33+
AriaComponent,
34+
AxisPointerComponent,
35+
BrushComponent,
36+
DataZoomInsideComponent,
37+
GraphicComponent,
38+
GridComponent,
39+
LegendComponent,
40+
MarkAreaComponent,
41+
MarkLineComponent,
42+
MarkPointComponent,
43+
ToolboxComponent,
44+
TooltipComponent,
45+
VisualMapComponent,
46+
} from 'echarts/components';
3347
import * as echarts from 'echarts/core';
48+
import {LegacyGridContainLabel} from 'echarts/features';
49+
import {CanvasRenderer, SVGRenderer} from 'echarts/renderers';
3450
import type {CallbackDataParams} from 'echarts/types/dist/shared';
3551

3652
import {markLine} from 'sentry/components/charts/components/markLine';
@@ -85,7 +101,34 @@ const handleClick = (clickSeries: any, instance: ECharts) => {
85101
}
86102
};
87103

88-
echarts.use(AriaComponent);
104+
// Keep registrations explicit so importing a small API such as `connect` never
105+
// pulls in ECharts' all-inclusive, side-effectful entry point. The legacy grid
106+
// layout preserves the containLabel behavior from the previous full import.
107+
echarts.use([
108+
AriaComponent,
109+
AxisPointerComponent,
110+
BarChart,
111+
BrushComponent,
112+
CanvasRenderer,
113+
CustomChart,
114+
DataZoomInsideComponent,
115+
GraphicComponent,
116+
GridComponent,
117+
HeatmapChart,
118+
LegacyGridContainLabel,
119+
LegendComponent,
120+
LineChart,
121+
MarkAreaComponent,
122+
MarkLineComponent,
123+
MarkPointComponent,
124+
PieChart,
125+
ScatterChart,
126+
SVGRenderer,
127+
ToolboxComponent,
128+
TooltipComponent,
129+
TreemapChart,
130+
VisualMapComponent,
131+
]);
89132

90133
type ReactEchartProps = React.ComponentProps<typeof ReactEchartsCore>;
91134
type ReactEChartOpts = NonNullable<ReactEchartProps['opts']>;

static/app/components/charts/useChartXRangeSelection.spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ describe('useChartXRangeSelection', () => {
8686

8787
it('should disconnect chart group when chartsGroupName is provided', () => {
8888
const disconnectSpy = jest.fn();
89-
jest.spyOn(require('echarts'), 'disconnect').mockImplementation(disconnectSpy);
89+
jest.spyOn(require('echarts/core'), 'disconnect').mockImplementation(disconnectSpy);
9090

9191
const {result} = renderHook(() =>
9292
useChartXRangeSelection({
@@ -255,7 +255,7 @@ describe('useChartXRangeSelection', () => {
255255

256256
it('should reconnect chart group after brush ends', async () => {
257257
const connectSpy = jest.fn();
258-
jest.spyOn(require('echarts'), 'connect').mockImplementation(connectSpy);
258+
jest.spyOn(require('echarts/core'), 'connect').mockImplementation(connectSpy);
259259

260260
const mockEchartsInstance = {
261261
...mockChartInstance,

static/app/components/charts/useChartXRangeSelection.tsx

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,9 @@ import {
88
} from 'react';
99
import {createPortal} from 'react-dom';
1010
import type {BrushComponentOption, EChartsOption, ToolboxComponentOption} from 'echarts';
11-
// FOOTGUN: this imports the full `echarts` bundle (not `echarts/core`), whose
12-
// entry calls `use()` on *every* feature — including `LegacyGridContainLabel`.
13-
// ECharts' feature registry is a process-wide singleton shared with
14-
// `echarts/core`, so this flips every `grid.containLabel` chart in the app
15-
// (e.g., anything built on `BaseChart`) onto the legacy grid layout, which
16-
// reserves space differently than ECharts 6's default `outerBounds` layout.
17-
// If you switch this to `echarts/core`, audit `containLabel` charts for shifted
18-
// axis padding. See heatMapWidget/utils/heatMapAxes.tsx for one place this bit us.
19-
import * as echarts from 'echarts';
2011
import type EChartsReact from 'echarts-for-react';
2112
import type {EChartsInstance} from 'echarts-for-react';
13+
import {connect, disconnect} from 'echarts/core';
2214

2315
import {getToolBox} from 'sentry/components/charts/components/toolBox';
2416
import type {EChartBrushEndHandler, EChartBrushStartHandler} from 'sentry/types/echarts';
@@ -197,7 +189,7 @@ export function useChartXRangeSelection({
197189
// box drawn for all of the charts in the group. We are going for chart specific box selections,
198190
// so we disconnect the group while drawing.
199191
if (chartsGroupName) {
200-
echarts?.disconnect(chartsGroupName);
192+
disconnect(chartsGroupName);
201193
}
202194

203195
chartInstance.dispatchAction({type: 'hideTip'});
@@ -430,7 +422,7 @@ export function useChartXRangeSelection({
430422
// We re-connect the group after drawing the box, so that the cursor is synced across all charts again.
431423
// Check the onBrushStart handler for more details.
432424
if (chartsGroupName) {
433-
echarts?.connect(chartsGroupName);
425+
connect(chartsGroupName);
434426
}
435427
}
436428

static/app/views/dashboards/contexts/widgetSyncContext.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type {ReactNode} from 'react';
22
import {createContext, useCallback, useContext, useEffect, useMemo, useRef} from 'react';
33
import type {EChartsType} from 'echarts';
4-
import * as echarts from 'echarts';
4+
import {connect, getInstanceByDom} from 'echarts/core';
55

66
import {uniqueId} from 'sentry/utils/guid';
77

@@ -38,7 +38,7 @@ export function WidgetSyncContextProvider({
3838
if (!entry.target.isConnected) {
3939
continue;
4040
}
41-
const chart = echarts.getInstanceByDom(entry.target as HTMLElement);
41+
const chart = getInstanceByDom(entry.target as HTMLElement);
4242
if (!chart) {
4343
continue;
4444
}
@@ -50,7 +50,7 @@ export function WidgetSyncContextProvider({
5050
}
5151
}
5252

53-
echarts?.connect(stableGroupName);
53+
connect(stableGroupName);
5454
});
5555
}
5656
return observerRef.current;
@@ -74,7 +74,7 @@ export function WidgetSyncContextProvider({
7474

7575
// Set the group immediately for charts that may already be visible
7676
chart.group = stableGroupName;
77-
echarts?.connect(stableGroupName);
77+
connect(stableGroupName);
7878

7979
// Return a function to unregister the chart
8080
return () => {

static/app/views/dashboards/dashboard.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {forceCheck} from 'react-lazyload';
77
import {useTheme, type Theme} from '@emotion/react';
88
import styled from '@emotion/styled';
99
import * as Sentry from '@sentry/react';
10+
import {connect} from 'echarts/core';
1011
import cloneDeep from 'lodash/cloneDeep';
1112
import debounce from 'lodash/debounce';
1213

@@ -55,7 +56,7 @@ import {
5556
import {SortableWidget} from './sortableWidget';
5657
import type {DashboardDetails, Widget} from './types';
5758
import {DashboardFilterKeys} from './types';
58-
import {connectDashboardCharts, getMergedDashboardFilters} from './utils';
59+
import {getMergedDashboardFilters} from './utils';
5960
import type {WidgetLegendSelectionState} from './widgetLegendSelectionState';
6061

6162
export const DRAG_HANDLE_CLASS = 'widget-drag';
@@ -197,7 +198,7 @@ function DashboardInner({
197198
useEffect(() => {
198199
window.addEventListener('resize', debouncedHandleResize);
199200

200-
connectDashboardCharts(DASHBOARD_CHART_GROUP);
201+
connect(DASHBOARD_CHART_GROUP);
201202
trackEngagementAnalytics(
202203
dashboard.widgets,
203204
organization,

static/app/views/dashboards/utils.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import {connect} from 'echarts';
21
import type {Location} from 'history';
32
import cloneDeep from 'lodash/cloneDeep';
43
import isEqual from 'lodash/isEqual';
@@ -549,10 +548,6 @@ export function dashboardFiltersToString(
549548
return dashboardFilterConditions;
550549
}
551550

552-
export function connectDashboardCharts(groupName: string) {
553-
connect?.(groupName);
554-
}
555-
556551
export function hasDatasetSelector(_organization: Organization): boolean {
557552
return true;
558553
}

static/app/views/dashboards/widgets/heatMapWidget/utils/heatMapAxes.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWi
1919
* in the coordinate system to place the cells.
2020
*
2121
* `axisLabel: {show: false}` looks redundant with `show: false`, but it isn't:
22-
* the app also loads the full `echarts` bundle (via `useChartXRangeSelection`),
23-
* which registers the legacy `containLabel` layout. That layout reserves grid
22+
* `BaseChart` registers the legacy `containLabel` layout to preserve existing
23+
* chart spacing. That layout reserves grid
2424
* space for an axis's labels based on `axisLabel.show` alone — it ignores the
2525
* axis-level `show` — so without this the hidden category axis pads the chart
2626
* with room for its (never-rendered) bucket-boundary labels.

0 commit comments

Comments
 (0)