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