From c9746c2c2f8719119ee657c1a2bb75fab5f4a3d7 Mon Sep 17 00:00:00 2001
From: Justineo
Date: Sat, 11 Oct 2025 21:26:49 +0800
Subject: [PATCH] fix: refactor slot implementation and improve types
- Improved types.
- Refactored slot implementation to make it more robust.
- Modified the `setOption` behavior to ensure it respects the `manual-update` flag.
- Renamed the `smart-update` files to `update`.
- Improved warnings.
- Added more tests.
---
README.md | 8 +-
README.zh-Hans.md | 12 +-
demo/examples/ManualChart.vue | 148 ++++++++-------
src/ECharts.ts | 71 ++++---
src/composables/api.ts | 29 +--
src/composables/slot.ts | 89 ++++++---
src/{smart-update.ts => update.ts} | 0
src/utils.ts | 16 +-
tests/echarts.test.ts | 174 +++++++++++++++++-
tests/helpers/mock.ts | 9 +-
tests/slot.test.ts | 16 +-
.../{smart-update.test.ts => update.test.ts} | 2 +-
12 files changed, 402 insertions(+), 172 deletions(-)
rename src/{smart-update.ts => update.ts} (100%)
rename tests/{smart-update.test.ts => update.test.ts} (99%)
diff --git a/README.md b/README.md
index 3d3af9c..8dd920c 100644
--- a/README.md
+++ b/README.md
@@ -155,7 +155,7 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
#### Smart Update
- If you supply `update-options` (via prop or injection), Vue ECharts forwards it directly to `setOption` and skips the planner.
- - Manual `setOption` calls (only available when `manual-update` is `true`) behave like native ECharts, honouring only the per-call override you pass in.
+ - Manual `setOption` calls (only available when `manual-update` is `true`) behave like native ECharts, honouring only the per-call override you pass in and are not carried across re-initializations.
- Otherwise, Vue ECharts analyses the change: removed objects become `null`, removed arrays become `[]` with `replaceMerge`, ID/anonymous deletions trigger `replaceMerge`, and risky changes fall back to `notMerge: true`.
- `update-options: object`
@@ -184,7 +184,7 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
- `manual-update: boolean` (default: `false`)
- For performance critical scenarios (having a large dataset) we'd better bypass Vue's reactivity system for `option` prop. By specifying `manual-update` prop with `true` and not providing `option` prop, the dataset won't be watched any more. After doing so, you need to retrieve the component instance with `ref` and manually call `setOption` method to update the chart (manual `setOption` calls are ignored when `manual-update` is `false`).
+ Handy for performance-sensitive charts (large or high-frequency updates). When set to `true`, Vue only uses the `option` prop for the initial render; later prop changes do nothing and you must drive updates via `setOption` on a template ref. If the chart re-initializes (for example due to `init-options` changes, flipping `manual-update`, or a remount), the manual state is discarded and the chart is rendered again from the current `option` value.
### Events
@@ -245,7 +245,7 @@ Vue ECharts support the following events:
- `zr:dblclick`
- `zr:contextmenu`
-See supported events [here →](https://echarts.apache.org/en/api.html#events)
+See supported events in the [ECharts API reference →](https://echarts.apache.org/en/api.html#events)
#### Native DOM Events
@@ -339,7 +339,7 @@ export default {
Vue ECharts allows you to define ECharts option's [`tooltip.formatter`](https://echarts.apache.org/en/option.html#tooltip.formatter) and [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/en/option.html#toolbox.feature.dataView.optionToContent) callbacks via Vue slots instead of defining them in your `option` object. This simplifies custom HTMLElement rendering using familiar Vue templating.
-**Slot Naming Convention**
+#### Slot Naming Convention
- Slot names begin with `tooltip`/`dataView`, followed by hyphen-separated path segments to the target.
- Each segment corresponds to an `option` property name or an array index (for arrays, use the numeric index).
diff --git a/README.zh-Hans.md b/README.zh-Hans.md
index 2101233..19dabda 100644
--- a/README.zh-Hans.md
+++ b/README.zh-Hans.md
@@ -155,7 +155,7 @@ app.component('VChart', VueECharts)
#### 智能更新
- 如果提供了 `update-options`(或通过 inject 注入),Vue ECharts 会直接把它传给 `setOption`,不会执行智能计划。
- - 手动调用 `setOption`(仅当 `manual-update` 为 `true` 时可用)与原生 ECharts 保持一致,只使用本次调用传入的参数。
+ - 手动调用 `setOption`(仅当 `manual-update` 为 `true` 时可用)与原生 ECharts 保持一致,只使用本次调用传入的参数,重新初始化后不会保留这些调用的效果。
- 其他情况下,Vue ECharts 会分析差异:删除的对象写入 `null`,删除的数组写入 `[]` 并加入 `replaceMerge`,ID/匿名项减少时追加 `replaceMerge`,风险较高的变更会退回 `notMerge: true`。
- `update-options: object`
@@ -182,9 +182,9 @@ app.component('VChart', VueECharts)
Inject 键名:`LOADING_OPTIONS_KEY`。
-- `manual-update: boolean`(默认值`false`)
+- `manual-update: boolean`(默认值 `false`)
- 在性能敏感(数据量很大)的场景下,我们最好对于 `option` prop 绕过 Vue 的响应式系统。当将 `manual-update` 指定为 `true` 且不传入 `option` prop 时,数据将不会被监听。此时需要用 `ref` 获取组件实例并手动调用 `setOption` 来更新图表(当 `manual-update` 为 `false` 时,手动调用 `setOption` 会被忽略)。
+ 适用于性能敏感的场景(例如 `option` 很大或更新频繁)。设为 `true` 时,`option` 只参与首次渲染,后续的 prop 变更不会触发图表更新,需要你通过模板 `ref` 手动调用 `setOption`。如果图表因为修改 `init-options`、切换 `manual-update` 或重新挂载而被重新初始化,之前通过 `setOption` 写入的状态会丢失,并重新使用当前的 `option` 值渲染。
### 事件
@@ -245,7 +245,7 @@ Vue ECharts 支持如下事件:
- `zr:dblclick`
- `zr:contextmenu`
-请参考支持的事件列表。[前往 →](https://echarts.apache.org/zh/api.html#events)
+更多事件说明可参考 [ECharts 官方事件文档 →](https://echarts.apache.org/zh/api.html#events)
#### 原生 DOM 事件
@@ -330,7 +330,7 @@ export default {
- `dispose` [→](https://echarts.apache.org/zh/api.html#echartsInstance.dispose)
> [!NOTE]
-> 如下 ECharts 实例方法没有被暴露,因为它们的功能已经通过组件 [props](#props) 提供了:
+> 如下 ECharts 实例方法没有被暴露,因为它们的功能已经通过组件 [prop](#props) 提供了:
>
> - [`showLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.showLoading) / [`hideLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.hideLoading):请使用 `loading` 和 `loading-options` prop。
> - [`setTheme`](https://echarts.apache.org/zh/api.html#echartsInstance.setTheme):请使用 `theme` prop。
@@ -339,7 +339,7 @@ export default {
Vue ECharts 允许你通过 Vue 插槽来定义 ECharts 配置中的 [`tooltip.formatter`](https://echarts.apache.org/zh/option.html#tooltip.formatter) 和 [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/zh/option.html#toolbox.feature.dataView.optionToContent) 回调,而无需在 `option` 对象中定义它们。你可以使用熟悉的 Vue 模板语法来编写自定义提示框或数据视图中的内容。
-**插槽命名约定**
+#### 插槽命名约定
- 插槽名称以 `tooltip`/`dataView` 开头,后面跟随用连字符分隔的路径片段,用于定位目标。
- 每个路径片段对应 `option` 对象的属性名或数组索引(数组索引使用数字形式)。
diff --git a/demo/examples/ManualChart.vue b/demo/examples/ManualChart.vue
index 24a4ba0..c1c8dee 100644
--- a/demo/examples/ManualChart.vue
+++ b/demo/examples/ManualChart.vue
@@ -7,7 +7,7 @@ import {
TooltipComponent,
} from "echarts/components";
import { shallowRef } from "vue";
-import type { InitOptions, LoadingOptions, Option } from "../../src/types";
+import type { LoadingOptions, Option } from "../../src/types";
import VChart from "../../src/ECharts";
import VExample from "./Example.vue";
import worldMap from "../data/world.json";
@@ -40,7 +40,7 @@ function isFlightDataset(value: unknown): value is FlightDataset {
const chart = shallowRef(null);
const loading = shallowRef(false);
-const loaded = shallowRef(false);
+const flightData = shallowRef(null);
const loadingOptions: LoadingOptions = {
text: "",
@@ -50,80 +50,89 @@ const loadingOptions: LoadingOptions = {
zlevel: 0,
};
-const initOptions: InitOptions = {
- renderer: "canvas",
-};
+async function load(): Promise {
+ if (flightData.value) {
+ return flightData.value;
+ }
-function load(): void {
- loaded.value = true;
loading.value = true;
- import("../data/flight.json").then(({ default: rawData }) => {
- if (!isFlightDataset(rawData)) {
- loading.value = false;
- return;
- }
+ const { default: data } = await import("../data/flight.json");
- loading.value = false;
+ loading.value = false;
- const getAirportCoord = (index: number): [number, number] => [
- rawData.airports[index][3],
- rawData.airports[index][4],
- ];
+ if (!isFlightDataset(data)) {
+ throw new Error("Invalid flight dataset");
+ }
- type Route = [[number, number], [number, number]];
- const routes = rawData.routes.map(([, from, to]) => {
- const fromCoord = getAirportCoord(from);
- const toCoord = getAirportCoord(to);
- return [fromCoord, toCoord];
- });
+ flightData.value = data;
- chart.value?.setOption({
- textStyle: { ...DEMO_TEXT_STYLE },
- title: {
- text: "World Flights",
- top: "5%",
- left: "center",
- textStyle: {
- color: "#eee",
- },
- },
- backgroundColor: "#003",
- tooltip: {
- formatter({ dataIndex }: { dataIndex: number }) {
- const route = rawData.routes[dataIndex];
- const fromName = rawData.airports[route[1]][1];
- const toName = rawData.airports[route[2]][1];
- return `${fromName} > ${toName}`;
- },
- },
- geo: {
- map: "world",
- top: "15%",
- right: "5%",
- bottom: "5%",
- left: "5%",
- silent: true,
- itemStyle: {
- borderColor: "#003",
- color: "#005",
- },
- },
- series: [
- {
- type: "lines",
- coordinateSystem: "geo",
- data: routes,
- lineStyle: {
- opacity: 0.05,
- width: 0.5,
- curveness: 0.3,
- },
- blendMode: "lighter",
- },
- ],
- } satisfies Option);
+ return data;
+}
+
+async function render(): Promise {
+ let data = flightData.value;
+ if (!data) {
+ data = await load();
+ }
+
+ const getAirportCoord = (index: number): [number, number] => [
+ data.airports[index][3],
+ data.airports[index][4],
+ ];
+
+ type Route = [[number, number], [number, number]];
+ const routes = data.routes.map(([, from, to]) => {
+ const fromCoord = getAirportCoord(from);
+ const toCoord = getAirportCoord(to);
+ return [fromCoord, toCoord];
});
+
+ chart.value?.setOption({
+ textStyle: { ...DEMO_TEXT_STYLE },
+ title: {
+ text: "World Flights",
+ top: "5%",
+ left: "center",
+ textStyle: {
+ color: "#eee",
+ },
+ },
+ backgroundColor: "#003",
+ tooltip: {
+ formatter({ dataIndex }: { dataIndex: number }) {
+ const route = data.routes[dataIndex];
+ const fromName = data.airports[route[1]][1];
+ const toName = data.airports[route[2]][1];
+ return `${fromName} > ${toName}`;
+ },
+ },
+ geo: {
+ map: "world",
+ top: "15%",
+ right: "5%",
+ bottom: "5%",
+ left: "5%",
+ silent: true,
+ itemStyle: {
+ borderColor: "#003",
+ color: "#005",
+ },
+ },
+ series: [
+ {
+ type: "lines",
+ coordinateSystem: "geo",
+ data: routes,
+ lineStyle: {
+ opacity: 0.05,
+ width: 0.5,
+ curveness: 0.3,
+ },
+ blendMode: "lighter",
+ },
+ ],
+ } satisfies Option);
}
@@ -134,7 +143,6 @@ function load(): void {
autoresize
:loading="loading"
:loading-options="loadingOptions"
- :init-options="initOptions"
style="background-color: #003"
manual-update
/>
@@ -144,7 +152,7 @@ function load(): void {
use cases.
-
+
diff --git a/src/ECharts.ts b/src/ECharts.ts
index 7af763a..31c6490 100644
--- a/src/ECharts.ts
+++ b/src/ECharts.ts
@@ -11,7 +11,6 @@ import {
nextTick,
watchEffect,
toValue,
- warn,
} from "vue";
import { init as initChart } from "echarts/core";
import type { EChartsOption } from "echarts";
@@ -25,10 +24,10 @@ import {
useSlotOption,
} from "./composables";
import type { PublicMethods, SlotsTypes } from "./composables";
-import { isOn, omitOn } from "./utils";
+import { isOn, omitOn, warn } from "./utils";
import { register, TAG_NAME } from "./wc";
-import { planUpdate } from "./smart-update";
-import type { Signature, UpdatePlan } from "./smart-update";
+import { planUpdate } from "./update";
+import type { Signature, UpdatePlan } from "./update";
import type { PropType, InjectionKey } from "vue";
import type {
@@ -81,7 +80,6 @@ export default defineComponent({
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
- const realOption = computed(() => props.option || {});
const realTheme = computed(() => props.theme || toValue(defaultTheme));
const realInitOptions = computed(
() => props.initOptions || toValue(defaultInitOptions) || undefined,
@@ -186,7 +184,7 @@ export default defineComponent({
listeners.set({ event, zr, once }, attrs[key]);
});
- function init(option?: Option, manual = false, override?: UpdateOptions) {
+ function init() {
if (!root.value) {
return;
}
@@ -235,10 +233,17 @@ export default defineComponent({
}
function commit() {
- const opt = option || realOption.value;
- if (opt) {
- applyOption(instance, opt, override, manual);
- override = undefined;
+ const { option } = props;
+
+ if (manualUpdate.value) {
+ if (option) {
+ applyOption(instance, option, undefined, true);
+ }
+ return;
+ }
+
+ if (option) {
+ applyOption(instance, option);
}
}
@@ -259,9 +264,7 @@ export default defineComponent({
lazyUpdate?: boolean,
) => {
if (!props.manualUpdate) {
- warn(
- "[vue-echarts] setOption is only available when manual-update is true.",
- );
+ warn("`setOption` is only available when `manual-update` is `true`.");
return;
}
@@ -283,40 +286,32 @@ export default defineComponent({
lastSignature = undefined;
}
- let unwatchOption: (() => void) | null = null;
watch(
- manualUpdate,
- (manualUpdate) => {
- if (typeof unwatchOption === "function") {
- unwatchOption();
- unwatchOption = null;
+ () => props.option,
+ (option) => {
+ if (!option) {
+ lastSignature = undefined;
+ return;
}
- if (!manualUpdate) {
- unwatchOption = watch(
- () => props.option,
- (option) => {
- if (!option) {
- lastSignature = undefined;
- return;
- }
- if (!chart.value) {
- return;
- }
-
- applyOption(chart.value, option);
- },
- { deep: true },
+ if (manualUpdate.value) {
+ warn(
+ "`option` prop changes are ignored when `manual-update` is `true`.",
);
+ return;
}
+
+ if (!chart.value) {
+ return;
+ }
+
+ applyOption(chart.value, option);
},
- {
- immediate: true,
- },
+ { deep: true },
);
watch(
- realInitOptions,
+ [manualUpdate, realInitOptions],
() => {
cleanup();
init();
diff --git a/src/composables/api.ts b/src/composables/api.ts
index 0351713..79803c0 100644
--- a/src/composables/api.ts
+++ b/src/composables/api.ts
@@ -26,25 +26,26 @@ export type PublicMethods = Pick;
export function usePublicAPI(
chart: Ref,
): PublicMethods {
- function makePublicMethod(
- name: T,
- ): (...args: Parameters) => ReturnType {
- return (...args) => {
+ function makePublicMethod(name: T): EChartsType[T] {
+ // Return a function that matches the signature of EChartsType[T]
+ const fn = function (this: unknown, ...args: unknown[]): unknown {
if (!chart.value) {
throw new Error("ECharts is not initialized yet.");
}
- return (chart.value[name] as any).apply(chart.value, args);
+ // Use Reflect.apply to call the method with proper context
+ return Reflect.apply(chart.value[name], chart.value, args);
};
+ return fn as EChartsType[T];
}
- function makePublicMethods(): PublicMethods {
- const methods = Object.create(null);
- METHOD_NAMES.forEach((name) => {
- methods[name] = makePublicMethod(name);
- });
+ // Build the methods object with proper typing
+ const methods = METHOD_NAMES.reduce(
+ (acc, name) => {
+ acc[name] = makePublicMethod(name);
+ return acc;
+ },
+ {} as Record,
+ ) as PublicMethods;
- return methods as PublicMethods;
- }
-
- return makePublicMethods();
+ return methods;
}
diff --git a/src/composables/slot.ts b/src/composables/slot.ts
index e8ce0d6..eb40057 100644
--- a/src/composables/slot.ts
+++ b/src/composables/slot.ts
@@ -6,11 +6,10 @@ import {
onMounted,
shallowRef,
shallowReactive,
- warn,
} from "vue";
import type { Slots, SlotsType } from "vue";
import type { Option } from "../types";
-import { isBrowser, isValidArrayIndex, isSameSet } from "../utils";
+import { isBrowser, isValidArrayIndex, isSameSet, warn } from "../utils";
import type { TooltipComponentFormatterCallbackParams } from "echarts";
const SLOT_OPTION_PATHS = {
@@ -52,7 +51,11 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
return h(
"div",
{
- ref: (el) => (containers[slotName] = el as HTMLElement),
+ ref: (el) => {
+ if (el instanceof HTMLElement) {
+ containers[slotName] = el;
+ }
+ },
style: { display: "contents" },
},
slotContent,
@@ -62,38 +65,68 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
: undefined;
};
- // Shallow-clone the option along the path and override the target callback
+ // Helper to check if a value is a plain object
+ function isObject(val: unknown): val is Record {
+ return val !== null && typeof val === "object" && !Array.isArray(val);
+ }
+
+ // Shallow-clone the option along each path and override the target callback
function patchOption(src: Option): Option {
- const root = { ...src };
+ const root = { ...src } as Record;
+
+ // Ensure the child at `seg` is a writable container (cloned or newly created).
+ // Returns the child container, or undefined if traversal is blocked by a primitive.
+ const ensureChild = (
+ parent: Record,
+ seg: string,
+ ): Record | undefined => {
+ const next = parent[seg];
+
+ if (Array.isArray(next)) {
+ parent[seg] = [...next];
+ return parent[seg] as Record;
+ }
+ if (isObject(next)) {
+ parent[seg] = { ...next };
+ return parent[seg] as Record;
+ }
+ if (next === undefined) {
+ parent[seg] = isValidArrayIndex(seg) ? [] : {};
+ return parent[seg] as Record;
+ }
+ // Blocked by a non-container value
+ return undefined;
+ };
Object.keys(slots)
.filter((key) => {
- const isValidSlot = isValidSlotName(key);
- if (!isValidSlot) {
- warn(`Invalid vue-echarts slot name: ${key}`);
+ const valid = isValidSlotName(key);
+ if (!valid) {
+ warn(`Invalid slot name: ${key}`);
}
- return isValidSlot;
+ return valid;
})
.forEach((key) => {
- const path = key.split("-");
- const prefix = path.shift() as SlotPrefix;
- path.push(...SLOT_OPTION_PATHS[prefix]);
-
- let cur: any = root;
- for (let i = 0; i < path.length - 1; i++) {
- const seg = path[i];
- const next = cur[seg];
-
- // Shallow-clone the link; create empty shell if missing
- cur[seg] = next
- ? Array.isArray(next)
- ? [...next]
- : { ...next }
- : isValidArrayIndex(seg)
- ? []
- : {};
- cur = cur[seg];
+ const [prefix, ...rest] = key.split("-") as [SlotPrefix, ...string[]];
+ const tail = SLOT_OPTION_PATHS[prefix];
+ if (!tail) {
+ return;
}
+
+ const path = [...rest, ...tail];
+ if (path.length === 0) {
+ return;
+ }
+
+ // Traverse to the parent of the leaf, cloning or creating along the way
+ let cur: Record | undefined = root;
+ for (let i = 0; i < path.length - 1; i++) {
+ cur = cur && ensureChild(cur, path[i]);
+ if (!cur) {
+ return; // Blocked by a primitive — skip this key
+ }
+ }
+
cur[path[path.length - 1]] = (p: unknown) => {
initialized[key] = true;
params[key] = p;
@@ -101,7 +134,7 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
};
});
- return root;
+ return root as Option;
}
// `slots` is not reactive, so we need to watch it manually
diff --git a/src/smart-update.ts b/src/update.ts
similarity index 100%
rename from src/smart-update.ts
rename to src/update.ts
diff --git a/src/utils.ts b/src/utils.ts
index dd75110..6cd2441 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,3 +1,5 @@
+import { warn as vueWarn } from "vue";
+
type Attrs = Record;
export function isBrowser(): boolean {
@@ -34,10 +36,14 @@ export function isSameSet(a: T[], b: T[]): boolean {
const setA = new Set(a);
const setB = new Set(b);
- if (setA.size !== setB.size) return false;
+ if (setA.size !== setB.size) {
+ return false;
+ }
for (const val of setA) {
- if (!setB.has(val)) return false;
+ if (!setB.has(val)) {
+ return false;
+ }
}
return true;
@@ -46,3 +52,9 @@ export function isSameSet(a: T[], b: T[]): boolean {
export function isPlainObject(v: unknown): v is Record {
return v != null && typeof v === "object" && !Array.isArray(v);
}
+
+const LOG_PREFIX = "[vue-echarts]";
+
+export function warn(message: string): void {
+ vueWarn(`${LOG_PREFIX} ${message}`);
+}
diff --git a/tests/echarts.test.ts b/tests/echarts.test.ts
index 9bf49a8..bcf61a6 100644
--- a/tests/echarts.test.ts
+++ b/tests/echarts.test.ts
@@ -7,7 +7,7 @@ import {
resetECharts,
type ChartStub,
} from "./helpers/mock";
-import type { UpdateOptions } from "../src/types";
+import type { InitOptions, Option, UpdateOptions } from "../src/types";
import { withConsoleWarn } from "./helpers/dom";
import ECharts, { UPDATE_OPTIONS_KEY } from "../src/ECharts";
import { renderChart } from "./helpers/renderChart";
@@ -74,9 +74,9 @@ describe("ECharts component", () => {
const manualOption = { series: [{ type: "bar", data: [1, 2, 3] }] };
exposed.value.setOption(manualOption);
- expect(chartStub.setOption).toHaveBeenCalledTimes(2);
- expect(chartStub.setOption.mock.calls[1][0]).toMatchObject(manualOption);
- expect(chartStub.setOption.mock.calls[1][1]).toEqual({});
+ expect(chartStub.setOption).toHaveBeenCalledTimes(1);
+ expect(chartStub.setOption.mock.calls[0][0]).toMatchObject(manualOption);
+ expect(chartStub.setOption.mock.calls[0][1]).toEqual({});
});
it("ignores setOption when manual-update is false", async () => {
@@ -91,11 +91,113 @@ describe("ECharts component", () => {
exposed.value.setOption({ title: { text: "ignored" } }, true);
expect(chartStub.setOption).toHaveBeenCalledTimes(initialCalls);
expect(warnSpy).toHaveBeenCalledWith(
- expect.stringContaining("[vue-echarts] setOption is only available"),
+ expect.stringContaining(
+ "[vue-echarts] `setOption` is only available when `manual-update` is `true`.",
+ ),
);
});
});
+ it("warns when option prop changes in manual-update mode", async () => {
+ const option = ref({ title: { text: "initial" } });
+ const exposed = shallowRef();
+
+ renderChart(() => ({ option: option.value, manualUpdate: true }), exposed);
+ await nextTick();
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
+ // noop
+ });
+
+ option.value = { title: { text: "next" } };
+ await nextTick();
+
+ expect(warnSpy).toHaveBeenCalled();
+ expect(warnSpy.mock.calls[0][0]).toContain(
+ "[vue-echarts] `option` prop changes are ignored when `manual-update` is `true`.",
+ );
+
+ warnSpy.mockRestore();
+ });
+
+ it("does not replay manual option after initOptions-triggered reinit", async () => {
+ const initOptions = ref({ renderer: "canvas" });
+ const exposed = shallowRef();
+
+ renderChart(
+ () => ({ manualUpdate: true, initOptions: initOptions.value }),
+ exposed,
+ );
+ await nextTick();
+
+ const manualOption: Option = {
+ title: { text: "manual" },
+ series: [{ type: "bar", data: [1, 2, 3] }],
+ };
+
+ exposed.value.setOption(manualOption);
+ expect(chartStub.setOption).toHaveBeenCalledTimes(1);
+ expect(chartStub.setOption.mock.calls[0][0]).toMatchObject(manualOption);
+
+ const firstStub = chartStub;
+ const replacementStub = enqueueChart();
+ chartStub = replacementStub;
+
+ initOptions.value = { renderer: "svg" as const };
+ await nextTick();
+
+ expect(firstStub.dispose).toHaveBeenCalledTimes(1);
+ expect(replacementStub.setOption).not.toHaveBeenCalled();
+ });
+
+ it("re-initializes manual chart from option prop after reinit", async () => {
+ const option = ref>({
+ title: { text: "base" },
+ series: [{ type: "bar", data: [1] }],
+ });
+ const initOptions = ref({ renderer: "canvas" });
+ const exposed = shallowRef();
+
+ renderChart(
+ () => ({
+ option: option.value,
+ manualUpdate: true,
+ initOptions: initOptions.value,
+ }),
+ exposed,
+ );
+ await nextTick();
+
+ expect(chartStub.setOption).toHaveBeenCalledTimes(1);
+ expect(chartStub.setOption.mock.calls[0][0]).toMatchObject({
+ title: { text: "base" },
+ });
+
+ chartStub.setOption.mockClear();
+
+ const manualOption: Option = {
+ title: { text: "manual" },
+ series: [{ type: "bar", data: [2] }],
+ };
+
+ exposed.value.setOption(manualOption);
+ expect(chartStub.setOption).toHaveBeenCalledTimes(1);
+ expect(chartStub.setOption.mock.calls[0][0]).toMatchObject(manualOption);
+
+ const firstStub = chartStub;
+ const replacementStub = enqueueChart();
+ chartStub = replacementStub;
+
+ initOptions.value = { renderer: "svg" as const };
+ await nextTick();
+
+ expect(firstStub.dispose).toHaveBeenCalledTimes(1);
+ expect(replacementStub.setOption).toHaveBeenCalledTimes(1);
+ expect(replacementStub.setOption.mock.calls[0][0]).toMatchObject({
+ title: { text: "base" },
+ });
+ });
+
it("passes theme and initOptions props and reacts to theme changes", async () => {
const option = ref({ title: { text: "brew" } });
const theme = ref("dark");
@@ -173,6 +275,7 @@ describe("ECharts component", () => {
const option = ref({ title: { text: "initial" } });
const manualUpdate = ref(true);
const exposed = shallowRef();
+ const firstStub = chartStub;
renderChart(
() => ({
@@ -183,14 +286,33 @@ describe("ECharts component", () => {
);
await nextTick();
- expect(chartStub.setOption).toHaveBeenCalledTimes(1);
+ expect(firstStub.setOption).toHaveBeenCalledTimes(1);
+ expect(firstStub.setOption.mock.calls[0][0]).toMatchObject({
+ title: { text: "initial" },
+ });
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
+ // noop
+ });
option.value = { title: { text: "manual" } };
await nextTick();
- expect(chartStub.setOption).toHaveBeenCalledTimes(1);
+ expect(firstStub.setOption).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalled();
+ expect(warnSpy.mock.calls[0][0]).toContain(
+ "[vue-echarts] `option` prop changes are ignored when `manual-update` is `true`.",
+ );
+ warnSpy.mockClear();
+ const replacementStub = enqueueChart();
manualUpdate.value = false;
+ chartStub = replacementStub;
await nextTick();
+ expect(firstStub.dispose).toHaveBeenCalledTimes(1);
+ expect(replacementStub.setOption).toHaveBeenCalledTimes(1);
+ expect(replacementStub.setOption.mock.calls[0][0]).toMatchObject({
+ title: { text: "manual" },
+ });
option.value = { title: { text: "reactive" } };
await nextTick();
@@ -199,6 +321,8 @@ describe("ECharts component", () => {
expect(chartStub.setOption.mock.calls[1][0]).toMatchObject({
title: { text: "reactive" },
});
+
+ warnSpy.mockRestore();
});
it("uses injected updateOptions defaults when not provided via props", async () => {
@@ -511,6 +635,26 @@ describe("ECharts component", () => {
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
});
+ it("applies option when nested data mutates", async () => {
+ const option = ref