From 2d705f4e036e23c5283bcf4e95f9e1f09f72cacf Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Tue, 16 Jun 2026 11:13:32 +0800 Subject: [PATCH] fix: prevent pre-render event replay duplication When updateSpec changes an empty initial spec into a real chart type, VChart enters the reMake path before any chart/model has been initialized. User callbacks registered before updateSpec are already in the dispatcher, so replaying _userEvents adds the same callback to the bubble queue a second time. Keep replay behavior for real remakes where an existing chart release can clear dispatcher state, but skip replay when there was no prior chart to release. Constraint: updateSpec can reMake before any chart/model exists Rejected: Bubble-level dedupe | same callback can use distinct event queries Confidence: high Scope-risk: narrow Directive: Replay _userEvents only after releasing a prior chart Tested: npm test -- __tests__/unit/core/vchart.test.ts --runInBand Tested: npm test -- __tests__/unit/event/event.test.ts --runInBand Tested: npm run compile Tested: eslint changed files (0 errors; existing warnings) Not-tested: full repository test suite --- .../vchart/__tests__/unit/core/vchart.test.ts | 42 +++++++++++++++++++ packages/vchart/src/core/vchart.ts | 14 ++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/vchart/__tests__/unit/core/vchart.test.ts b/packages/vchart/__tests__/unit/core/vchart.test.ts index c31a5dbdc1..30c3be7d5b 100644 --- a/packages/vchart/__tests__/unit/core/vchart.test.ts +++ b/packages/vchart/__tests__/unit/core/vchart.test.ts @@ -7,6 +7,8 @@ import type { IAreaSeriesSpec } from '../../../src/series/area/interface'; import type { IPoint } from '../../../src/typings'; import { polarToCartesian } from '@visactor/vutils'; import type { IMarkGraphic } from '../../../src/mark/interface'; +import type { BaseEventParams } from '../../../src/event/interface'; +import type { IChartSpec } from '../../../src/typings/spec/common'; describe('VChart', () => { describe('render and update', () => { @@ -230,6 +232,46 @@ describe('VChart', () => { expect(vchart.getChart()?.getAllSeries()[0].getRawData()?.latestData.length).toBe(data.length); }); + it('does not duplicate user event registered before updateSpec initializes chart', () => { + const spec: IBarChartSpec = { + type: 'bar', + direction: 'horizontal', + data: [ + { + id: 'barData', + values: [ + { cat: '类目一', value: 80 }, + { cat: '类目二', value: 52 } + ] + } + ], + yField: 'cat', + xField: 'value' + }; + const eventSpy = jest.fn(); + const emptySpec: IChartSpec = { type: '' }; + const eventParams: BaseEventParams = { + source: 'chart', + model: { type: 'bar' } as unknown as BaseEventParams['model'], + event: { + stopPropagation: jest.fn(), + preventDefault: jest.fn() + } as unknown as BaseEventParams['event'], + item: null as unknown as BaseEventParams['item'], + datum: null as unknown as BaseEventParams['datum'] + }; + + vchart = new VChart(emptySpec, { + renderCanvas: canvasDom, + animation: false + }); + vchart.on('click', { source: 'chart', level: 'model' }, eventSpy); + vchart.updateSpecSync(spec); + vchart.event.emit('click', eventParams, 'model'); + + expect(eventSpy).toBeCalledTimes(1); + }); + it('updateViewBox', async () => { const spec: ICommonChartSpec = { type: 'common', diff --git a/packages/vchart/src/core/vchart.ts b/packages/vchart/src/core/vchart.ts index 7471f449fc..b1265e144f 100644 --- a/packages/vchart/src/core/vchart.ts +++ b/packages/vchart/src/core/vchart.ts @@ -453,7 +453,7 @@ export class VChart implements IVChart { // 设置全局字体 this._setFontFamilyTheme(this.getTheme('fontFamily') as string); this._initDataSet(this._option.dataSet); - this._autoSize = isTrueBrowseEnv ? (spec.autoFit ?? this._option.autoFit ?? true) : false; + this._autoSize = isTrueBrowseEnv ? spec.autoFit ?? this._option.autoFit ?? true : false; this._bindResizeEvent(); this._bindViewEvent(); this._initChartPlugin(); @@ -676,6 +676,8 @@ export class VChart implements IVChart { } protected _reCompile(updateResult: IUpdateSpecResult, morphConfig?: IMorphConfig) { + const shouldRestoreUserEvents = updateResult.reMake && !!this._chart; + if (updateResult.reMake) { this._releaseData(); this._initDataSet(); @@ -702,7 +704,9 @@ export class VChart implements IVChart { // chart 内部事件 模块自己必须删除 // 内部模块删除事件时,调用了event Dispatcher.release() 导致用户事件被一起删除 // 外部事件现在需要重新添加 - this._userEvents.forEach(e => this._event?.on(e.eType as any, e.query as any, e.handler as any)); + if (shouldRestoreUserEvents) { + this._userEvents.forEach(e => this._event?.on(e.eType as any, e.query as any, e.handler as any)); + } } else if (updateResult.reCompile) { // recompile // 清除之前的所有 compile 内容 @@ -1477,8 +1481,8 @@ export class VChart implements IVChart { isObject(specTheme) && specTheme.type ? specTheme.type : isObject(optionTheme) && optionTheme.type - ? optionTheme.type - : this._currentThemeName + ? optionTheme.type + : this._currentThemeName ), getThemeObject(optionTheme), getThemeObject(specTheme) @@ -1512,7 +1516,7 @@ export class VChart implements IVChart { } const lasAutoSize = this._autoSize; - this._autoSize = isTrueBrowser(this._option.mode) ? (this._spec.autoFit ?? this._option.autoFit ?? true) : false; + this._autoSize = isTrueBrowser(this._option.mode) ? this._spec.autoFit ?? this._option.autoFit ?? true : false; if (this._autoSize !== lasAutoSize) { resize = true; }