diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/ChartBlockModel.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/ChartBlockModel.tsx index b7f710e2c7c..65a3f48e072 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/ChartBlockModel.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/ChartBlockModel.tsx @@ -37,6 +37,8 @@ import { const NO_PREVIEW_SNAPSHOT = Symbol('NO_PREVIEW_SNAPSHOT'); +type ChartEventCleanup = () => void | Promise; + type ChartBlockModelStructure = { subModels: { page: ChildPageModel; @@ -94,6 +96,8 @@ export class ChartBlockModel extends DataBlockModel { private __onResourceRefresh = () => this.renderChart(); private __eventsBoundChart?: EChartsType; private __eventsBoundRaw?: string; + private chartEventsCleanup?: ChartEventCleanup; + private __eventsApplyToken = 0; private lastRefreshSnapshot: ChartDirtyRefreshSnapshot | null = null; private dirtyRefreshing = false; @@ -213,6 +217,31 @@ export class ChartBlockModel extends DataBlockModel { } } + private resetEventsBound() { + this.__eventsBoundChart = undefined; + this.__eventsBoundRaw = undefined; + } + + private async runChartEventCleanups(cleanups: ChartEventCleanup[]) { + for (const cleanup of cleanups.slice().reverse()) { + try { + await cleanup(); + } catch (error) { + console.error('Chart events cleanup error:', error); + } + } + } + + private async cleanupChartEvents() { + const cleanup = this.chartEventsCleanup; + this.chartEventsCleanup = undefined; + this.resetEventsBound(); + + if (cleanup) { + await this.runChartEventCleanups([cleanup]); + } + } + async buildQueryRequest(query: any) { if (!query || query?.mode === 'sql') { return query; @@ -513,48 +542,74 @@ export class ChartBlockModel extends DataBlockModel { // 应用事件配置(仅设置,不负责渲染) async applyEvents(raw?: string, chartInstance?: EChartsType) { - if (!raw) return; + const applyToken = ++this.__eventsApplyToken; + + if (!raw) { + await this.cleanupChartEvents(); + return; + } if (chartInstance) { - await this.runChartEvents(raw, chartInstance); + await this.runChartEvents(raw, chartInstance, applyToken); return; } const chart = (this.context.chartRef as any).current as EChartsType | null; if (chart) { - await this.runChartEvents(raw, chart); + await this.runChartEvents(raw, chart, applyToken); return; } this.context.onRefReady(this.context.chartRef, async () => { + if (applyToken !== this.__eventsApplyToken) { + return; + } + const currentChart = (this.context.chartRef as any).current as EChartsType | null; - if (currentChart) { - await this.runChartEvents(raw, currentChart); + if (currentChart && applyToken === this.__eventsApplyToken) { + await this.runChartEvents(raw, currentChart, applyToken); } }); } - private async runChartEvents(raw: string, chart: EChartsType) { + private async runChartEvents(raw: string, chart: EChartsType, applyToken: number) { if (this.shouldSkipApplyEvents(raw, chart)) { return; } + await this.cleanupChartEvents(); + if (applyToken !== this.__eventsApplyToken) { + return; + } + this.markEventsBound(raw, chart); + const cleanups: ChartEventCleanup[] = []; try { - const { success, error, timeout } = await this.context.runjs(raw, { + const { success, value, error, timeout } = await this.context.runjs(raw, { chart, }); if (success) { + if (typeof value === 'function') { + cleanups.push(value); + } + if (applyToken !== this.__eventsApplyToken) { + this.clearEventsBound(raw, chart); + await this.runChartEventCleanups(cleanups); + return; + } + this.chartEventsCleanup = cleanups.length ? () => this.runChartEventCleanups(cleanups) : undefined; return; } this.clearEventsBound(raw, chart); + await this.runChartEventCleanups(cleanups); if (error || timeout) { console.error('applyEvents runjs error:', error || 'timeout'); } } catch (error) { this.clearEventsBound(raw, chart); + await this.runChartEventCleanups(cleanups); throw error; } } @@ -739,9 +794,7 @@ ChartBlockModel.registerFlow({ }); // 事件部分 - if (chart.events?.raw) { - await ctx.model.applyEvents(chart.events?.raw); - } + await ctx.model.applyEvents(chart.events?.raw); } catch (error) { console.error('ChartBlockModel chartSettings configure flow handler() error:', error); } diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/EventsPanel.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/EventsPanel.tsx index fac286a3421..69b39af8793 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/EventsPanel.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/EventsPanel.tsx @@ -14,14 +14,15 @@ import { useT } from '../../locale'; import { FunctionOutlined } from '@ant-design/icons'; import { observer, useFlowSettingsContext } from '@nocobase/flow-engine'; -const DEFAULT_EVENTS_RAW = `// chart.off('click'); -// chart.on('click', 'series', function() { +const DEFAULT_EVENTS_RAW = `// const handler = function() { // ctx.openView(ctx.model.uid + '-1', { // mode: 'dialog', // size: 'large', // defineProperties: {}, // inject context into the new view // }); -// }); +// }; +// chart.on('click', 'series', handler); +// return () => chart.off('click', handler); `; const getFormValues = (ctx: any) => ctx.getStepFormValues('chartSettings', 'configure') || {}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/__tests__/ChartBlockModel.dirtyRefresh.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/__tests__/ChartBlockModel.dirtyRefresh.test.ts index 7317dd9dc22..3a0021f870d 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/__tests__/ChartBlockModel.dirtyRefresh.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client-v2/flow/models/__tests__/ChartBlockModel.dirtyRefresh.test.ts @@ -244,8 +244,124 @@ describe('ChartBlockModel chart events binding', () => { await model.applyEvents(raw, chartB); expect(runjs).toHaveBeenCalledTimes(2); - expect(runjs).toHaveBeenNthCalledWith(1, raw, { chart: chartA }); - expect(runjs).toHaveBeenNthCalledWith(2, raw, { chart: chartB }); + expect(runjs).toHaveBeenNthCalledWith(1, raw, expect.objectContaining({ chart: chartA })); + expect(runjs).toHaveBeenNthCalledWith(2, raw, expect.objectContaining({ chart: chartB })); + }); + + it('runs the previous cleanup function before applying different raw events', async () => { + const { model } = setupModel({ + mode: 'builder', + collectionPath: ['main', 'orders'], + }); + const firstCleanup = vi.fn(); + const secondCleanup = vi.fn(); + const chart = {} as any; + + vi.spyOn(model.context, 'runjs').mockImplementation(async (raw: string) => { + return { success: true, value: raw === 'first' ? firstCleanup : secondCleanup }; + }); + + await model.applyEvents('first', chart); + await model.applyEvents('second', chart); + + expect(firstCleanup).toHaveBeenCalledTimes(1); + expect(secondCleanup).not.toHaveBeenCalled(); + }); + + it('runs returned cleanup functions when events are cleared', async () => { + const { model } = setupModel({ + mode: 'builder', + collectionPath: ['main', 'orders'], + }); + const cleanup = vi.fn(); + const chart = {} as any; + + vi.spyOn(model.context, 'runjs').mockResolvedValue({ success: true, value: cleanup }); + + await model.applyEvents('return cleanup;', chart); + await model.applyEvents(undefined, chart); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('ignores stale onRefReady callbacks after chart events are cleared', async () => { + const { model } = setupModel({ + mode: 'builder', + collectionPath: ['main', 'orders'], + }); + const chartRef = { current: null as any }; + const chart = {} as any; + let readyCallback: (() => Promise) | undefined; + const runjs = vi.spyOn(model.context, 'runjs').mockResolvedValue({ success: true, value: undefined }); + + model.context.defineProperty('chartRef', { value: chartRef }); + vi.spyOn(model.context, 'onRefReady').mockImplementation((_ref: any, callback: any) => { + readyCallback = callback; + }); + + await model.applyEvents('chart.on("click", () => {})'); + await model.applyEvents(undefined); + + chartRef.current = chart; + await readyCallback?.(); + + expect(runjs).not.toHaveBeenCalled(); + }); + + it('ignores stale onRefReady callbacks after chart events are replaced', async () => { + const { model } = setupModel({ + mode: 'builder', + collectionPath: ['main', 'orders'], + }); + const chartRef = { current: null as any }; + const chart = {} as any; + const readyCallbacks: (() => Promise)[] = []; + const runjs = vi.spyOn(model.context, 'runjs').mockResolvedValue({ success: true, value: undefined }); + + model.context.defineProperty('chartRef', { value: chartRef }); + vi.spyOn(model.context, 'onRefReady').mockImplementation((_ref: any, callback: any) => { + readyCallbacks.push(callback); + }); + + await model.applyEvents('first'); + await model.applyEvents('second'); + + chartRef.current = chart; + await readyCallbacks[0]?.(); + await readyCallbacks[1]?.(); + + expect(runjs).toHaveBeenCalledTimes(1); + expect(runjs).toHaveBeenCalledWith('second', expect.objectContaining({ chart })); + }); + + it('does not run stale chart events after asynchronous cleanup finishes', async () => { + const { model } = setupModel({ + mode: 'builder', + collectionPath: ['main', 'orders'], + }); + const chart = {} as any; + let resolveCleanup: (() => void) | undefined; + const cleanup = vi.fn( + () => + new Promise((resolve) => { + resolveCleanup = resolve; + }), + ); + const runjs = vi + .spyOn(model.context, 'runjs') + .mockResolvedValueOnce({ success: true, value: cleanup }) + .mockResolvedValueOnce({ success: true, value: undefined }); + + await model.applyEvents('active', chart); + const staleApply = model.applyEvents('stale', chart); + await Promise.resolve(); + expect(cleanup).toHaveBeenCalledTimes(1); + const clearApply = model.applyEvents(undefined, chart); + resolveCleanup?.(); + await Promise.all([staleApply, clearApply]); + + expect(runjs).toHaveBeenCalledTimes(1); + expect(runjs).toHaveBeenCalledWith('active', expect.objectContaining({ chart })); }); it('clears the bound marker when chart events throw so the same chart can retry', async () => {