feat(data-visualization): support chart event cleanup (#10034)

* feat(data-visualization): support chart event cleanup

* fix(data-visualization): ignore stale chart event runs

* fix(data-visualization): skip stale chart events after cleanup
This commit is contained in:
YANG QIA
2026-07-08 23:47:37 +08:00
committed by GitHub
parent bb88100d19
commit 440e8dc606
3 changed files with 185 additions and 15 deletions
@@ -37,6 +37,8 @@ import {
const NO_PREVIEW_SNAPSHOT = Symbol('NO_PREVIEW_SNAPSHOT');
type ChartEventCleanup = () => void | Promise<void>;
type ChartBlockModelStructure = {
subModels: {
page: ChildPageModel;
@@ -94,6 +96,8 @@ export class ChartBlockModel extends DataBlockModel<ChartBlockModelStructure> {
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<ChartBlockModelStructure> {
}
}
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<ChartBlockModelStructure> {
// 应用事件配置(仅设置,不负责渲染)
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);
}
@@ -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') || {};
@@ -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<void>) | 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<void>)[] = [];
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<void>((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 () => {