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.
This commit is contained in:
Justineo
2025-10-11 21:26:49 +08:00
committed by GU Yiling
parent 2a97adfbd2
commit c9746c2c2f
12 changed files with 402 additions and 172 deletions

View File

@ -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).

View File

@ -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` 对象的属性名或数组索引(数组索引使用数字形式)。

View File

@ -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<ChartInstance | null>(null);
const loading = shallowRef(false);
const loaded = shallowRef(false);
const flightData = shallowRef<FlightDataset | null>(null);
const loadingOptions: LoadingOptions = {
text: "",
@ -50,29 +50,39 @@ const loadingOptions: LoadingOptions = {
zlevel: 0,
};
const initOptions: InitOptions = {
renderer: "canvas",
};
function load(): void {
loaded.value = true;
loading.value = true;
import("../data/flight.json").then(({ default: rawData }) => {
if (!isFlightDataset(rawData)) {
loading.value = false;
return;
async function load(): Promise<FlightDataset> {
if (flightData.value) {
return flightData.value;
}
loading.value = true;
const { default: data } = await import("../data/flight.json");
loading.value = false;
if (!isFlightDataset(data)) {
throw new Error("Invalid flight dataset");
}
flightData.value = data;
return data;
}
async function render(): Promise<void> {
let data = flightData.value;
if (!data) {
data = await load();
}
const getAirportCoord = (index: number): [number, number] => [
rawData.airports[index][3],
rawData.airports[index][4],
data.airports[index][3],
data.airports[index][4],
];
type Route = [[number, number], [number, number]];
const routes = rawData.routes.map<Route>(([, from, to]) => {
const routes = data.routes.map<Route>(([, from, to]) => {
const fromCoord = getAirportCoord(from);
const toCoord = getAirportCoord(to);
return [fromCoord, toCoord];
@ -91,9 +101,9 @@ function load(): void {
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];
const route = data.routes[dataIndex];
const fromName = data.airports[route[1]][1];
const toName = data.airports[route[2]][1];
return `${fromName} > ${toName}`;
},
},
@ -123,7 +133,6 @@ function load(): void {
},
],
} satisfies Option);
});
}
</script>
@ -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.
</p>
<p class="actions">
<button :disabled="loaded" @click="load">Load</button>
<button :disabled="loading" @click="render">Load</button>
</p>
</template>
</VExample>

View File

@ -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,23 +286,21 @@ export default defineComponent({
lastSignature = undefined;
}
let unwatchOption: (() => void) | null = null;
watch(
manualUpdate,
(manualUpdate) => {
if (typeof unwatchOption === "function") {
unwatchOption();
unwatchOption = null;
}
if (!manualUpdate) {
unwatchOption = watch(
() => props.option,
(option) => {
if (!option) {
lastSignature = undefined;
return;
}
if (manualUpdate.value) {
warn(
"`option` prop changes are ignored when `manual-update` is `true`.",
);
return;
}
if (!chart.value) {
return;
}
@ -308,15 +309,9 @@ export default defineComponent({
},
{ deep: true },
);
}
},
{
immediate: true,
},
);
watch(
realInitOptions,
[manualUpdate, realInitOptions],
() => {
cleanup();
init();

View File

@ -26,25 +26,26 @@ export type PublicMethods = Pick<EChartsType, MethodName>;
export function usePublicAPI(
chart: Ref<EChartsType | undefined>,
): PublicMethods {
function makePublicMethod<T extends MethodName>(
name: T,
): (...args: Parameters<EChartsType[T]>) => ReturnType<EChartsType[T]> {
return (...args) => {
function makePublicMethod<T extends MethodName>(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<MethodName, unknown>,
) as PublicMethods;
return methods as PublicMethods;
}
return makePublicMethods();
return methods;
}

View File

@ -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<string, unknown> {
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<string, unknown>;
// 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<string, unknown>,
seg: string,
): Record<string, unknown> | undefined => {
const next = parent[seg];
if (Array.isArray(next)) {
parent[seg] = [...next];
return parent[seg] as Record<string, unknown>;
}
if (isObject(next)) {
parent[seg] = { ...next };
return parent[seg] as Record<string, unknown>;
}
if (next === undefined) {
parent[seg] = isValidArrayIndex(seg) ? [] : {};
return parent[seg] as Record<string, unknown>;
}
// 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<string, unknown> | 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

View File

@ -1,3 +1,5 @@
import { warn as vueWarn } from "vue";
type Attrs = Record<string, any>;
export function isBrowser(): boolean {
@ -34,10 +36,14 @@ export function isSameSet<T>(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<T>(a: T[], b: T[]): boolean {
export function isPlainObject(v: unknown): v is Record<string, unknown> {
return v != null && typeof v === "object" && !Array.isArray(v);
}
const LOG_PREFIX = "[vue-echarts]";
export function warn(message: string): void {
vueWarn(`${LOG_PREFIX} ${message}`);
}

View File

@ -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<any>();
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<InitOptions>({ renderer: "canvas" });
const exposed = shallowRef<any>();
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<Required<Option>>({
title: { text: "base" },
series: [{ type: "bar", data: [1] }],
});
const initOptions = ref<InitOptions>({ renderer: "canvas" });
const exposed = shallowRef<any>();
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<any>();
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<Option>({
series: [{ type: "bar", data: [1, 2, 3] }],
});
const exposed = shallowRef<any>();
renderChart(() => ({ option: option.value }), exposed);
await nextTick();
chartStub.setOption.mockClear();
(option.value!.series as any)[0].data.push(4);
await nextTick();
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject({
series: [{ data: [1, 2, 3, 4] }],
});
});
it("honors override.replaceMerge in update options", async () => {
const option = ref({ series: [{ type: "bar", data: [1] }] });
const exposed = shallowRef<any>();
@ -674,12 +818,28 @@ describe("ECharts component", () => {
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
// Toggle to manual mode; watcher should be cleaned up (unwatchOption branch)
const firstStub = chartStub;
const replacementStub = enqueueChart();
manual.value = true;
chartStub = replacementStub;
await nextTick();
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
expect(replacementStub.setOption).toHaveBeenCalledTimes(1);
chartStub.setOption.mockClear();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
// noop
});
chartStub.setOption.mockClear();
option.value = { title: { text: "reactive-2" } } as any;
await nextTick();
expect(chartStub.setOption).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalled();
expect(warnSpy.mock.calls[0][0]).toContain(
"[vue-echarts] `option` prop changes are ignored when `manual-update` is `true`.",
);
warnSpy.mockRestore();
});
});

View File

@ -16,6 +16,7 @@ export function createEChartsModule() {
export interface ChartStub {
setOption: ReturnType<typeof vi.fn>;
getOption: ReturnType<typeof vi.fn>;
resize: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
isDisposed: ReturnType<typeof vi.fn>;
@ -36,9 +37,15 @@ export function createChartStub(): ChartStub {
on: vi.fn(),
off: vi.fn(),
};
let lastOption: unknown;
const setOption = vi.fn((option: unknown) => {
lastOption = option;
});
return {
setOption: vi.fn(),
setOption,
getOption: vi.fn(() => lastOption),
resize: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn(() => false),

View File

@ -188,7 +188,7 @@ describe("useSlotOption", () => {
const patched: any = exposed.value!.patchOption({});
const flattened = warnSpy.mock.calls.flat().join(" ");
expect(flattened).toContain("Invalid vue-echarts slot name: legend");
expect(flattened).toContain("[vue-echarts] Invalid slot name: legend");
expect(patched.legend).toBeUndefined();
expect(changeSpy).not.toHaveBeenCalled();
});
@ -224,6 +224,20 @@ describe("useSlotOption", () => {
expect(container?.textContent).toBe("series-0");
});
it("skips slot patch when path is blocked by non-object", async () => {
const { exposed } = renderSlotComponent(() => ({
"tooltip-series-0": () => [h("span", "series-0")],
}));
await nextTick();
const option = { series: 1 as any };
const patched = exposed.value!.patchOption(option);
expect(patched.series).toBe(1);
expect(typeof patched.series).toBe("number");
});
it("creates array shells when target slot path is missing", async () => {
const { exposed } = renderSlotComponent(() => ({
"tooltip-series-1": () => [h("span", "series-1")],

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { buildSignature, planUpdate } from "../src/smart-update";
import { buildSignature, planUpdate } from "../src/update";
import type { EChartsOption } from "echarts";
describe("smart-update", () => {