diff --git a/common/changes/@visactor/vchart/fix-background-recompile-and-wordcloud-tap_2026-09-13.json b/common/changes/@visactor/vchart/fix-background-recompile-and-wordcloud-tap_2026-09-13.json new file mode 100644 index 0000000000..306b1c45c2 --- /dev/null +++ b/common/changes/@visactor/vchart/fix-background-recompile-and-wordcloud-tap_2026-09-13.json @@ -0,0 +1,21 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart", + "comment": "fix: rebuild background when spec.background and spec.theme change in the same updateSpec", + "type": "patch" + }, + { + "packageName": "@visactor/vchart", + "comment": "fix: avoid crash when recompiling a mark without a compile option", + "type": "patch" + }, + { + "packageName": "@visactor/vchart", + "comment": "fix: remove the word cloud shape afterRender tap when the series is released", + "type": "patch" + } + ], + "packageName": "@visactor/vchart", + "email": "chendaxin.tk@bytedance.com" +} diff --git a/packages/vchart/__tests__/unit/core/update-spec.test.ts b/packages/vchart/__tests__/unit/core/update-spec.test.ts index 03c9008043..533c954531 100644 --- a/packages/vchart/__tests__/unit/core/update-spec.test.ts +++ b/packages/vchart/__tests__/unit/core/update-spec.test.ts @@ -3990,3 +3990,103 @@ describe('vchart updateSpec of different indicator', () => { }); }); }); + +describe('vchart updateSpec of background', () => { + let container: HTMLElement; + let dom: HTMLElement; + let vchart: VChart; + + const gradientBackground = { + fill: { + gradient: 'linear', + x0: 0, + y0: 0, + x1: 1, + y1: 0, + stops: [ + { offset: 0, color: '#ff9f43' }, + { offset: 1, color: '#ee5253' } + ] + } + }; + + const baseSpec = { + type: 'bar', + data: [ + { + id: 'bar', + values: [ + { x: 'A', y: 10, type: 'a' }, + { x: 'B', y: 20, type: 'a' } + ] + } + ], + xField: 'x', + yField: 'y', + seriesField: 'type', + legends: [{ visible: true }], + animation: false + }; + + beforeAll(() => { + container = createDiv(); + dom = createDiv(container); + dom.id = 'container'; + container.style.position = 'fixed'; + container.style.width = '500px'; + container.style.height = '500px'; + container.style.top = '0px'; + container.style.left = '0px'; + }); + + afterEach(() => { + vchart?.release(); + }); + + afterAll(() => { + removeDom(container); + }); + + it('should remake background when background and theme change in the same updateSpec', () => { + const spec = { + ...baseSpec, + background: gradientBackground, + theme: { background: 'transparent' } + } as unknown as IBarChartSpec; + + vchart = new VChart(spec, { dom, animation: false }); + vchart.renderSync(); + + const updateRes = (vchart as any)._updateSpec( + { + ...spec, + background: '#4e83fd', + theme: { background: '#ffffff' } + }, + false + ); + + expect(updateRes.changeTheme).toBe(true); + expect(updateRes.changeBackground).toBe(true); + expect(updateRes.reMake).toBe(true); + }); + + it('should not throw when recompiling a chart whose background is a mark spec', () => { + const spec = { + ...baseSpec, + background: gradientBackground, + theme: { background: 'transparent' } + } as unknown as IBarChartSpec; + + vchart = new VChart(spec, { dom, animation: false }); + vchart.renderSync(); + + // 只改图例:走 reCompile 而不是 reMake,chart 级的 background mark 会被二次 compile + const nextSpec = { ...spec, legends: [{ visible: false }] } as unknown as IBarChartSpec; + const updateRes = (vchart as any)._updateSpec(nextSpec, false); + expect(updateRes.reCompile).toBe(true); + expect(updateRes.reMake).toBe(false); + + expect(() => (vchart as any)._updateCustomConfigAndRecompile(updateRes)).not.toThrow(); + }); +}); diff --git a/packages/vchart/__tests__/unit/series/word-cloud-shape-release.test.ts b/packages/vchart/__tests__/unit/series/word-cloud-shape-release.test.ts new file mode 100644 index 0000000000..0f0e9f9355 --- /dev/null +++ b/packages/vchart/__tests__/unit/series/word-cloud-shape-release.test.ts @@ -0,0 +1,81 @@ +import { VChart } from '../../../src/vchart-all'; +import { createDiv, removeDom } from '../../util/dom'; + +type Tap = { name: string; fn: () => void }; + +const SVG_MASK = + ''; + +const spec = { + type: 'wordCloud', + nameField: 'name', + valueField: 'value', + maskShape: SVG_MASK, + data: [ + { + id: 'wordCloud', + values: [ + { name: 'foo', value: 30 }, + { name: 'bar', value: 20 }, + { name: 'baz', value: 10 } + ] + } + ], + animation: false +}; + +describe('word cloud shape afterRender tap', () => { + let container: HTMLElement; + let dom: HTMLElement; + let vchart: VChart; + + const getWordCloudTaps = () => + ((vchart.getStage() as any).hooks.afterRender.taps as Tap[]).filter(tap => tap.name === 'afterWordcloudShapeDraw'); + + const createChart = () => { + vchart = new VChart(spec as any, { dom, animation: false }); + vchart.renderSync(); + return vchart.getChart().getAllSeries()[0] as any; + }; + + beforeAll(() => { + container = createDiv(); + dom = createDiv(container); + dom.id = 'container'; + container.style.position = 'fixed'; + container.style.width = '500px'; + container.style.height = '500px'; + container.style.top = '0px'; + container.style.left = '0px'; + }); + + afterEach(() => { + vchart?.release(); + }); + + afterAll(() => { + removeDom(container); + }); + + it('should be removed together with the series', () => { + const series = createChart(); + series._wordCloudShapeTransformOption().onLayoutFinished(); + expect(getWordCloudTaps().length).toBe(1); + + const tap = getWordCloudTaps()[0]; + series.release(); + + expect(getWordCloudTaps().length).toBe(0); + // series 已经 release,_option 为空,这个 tap 即使被别处留住也不能再抛错 + expect(() => tap.fn()).not.toThrow(); + }); + + it('should not be registered twice when the layout finishes more than once', () => { + const series = createChart(); + const option = series._wordCloudShapeTransformOption(); + option.onLayoutFinished(); + option.onLayoutFinished(); + + expect(getWordCloudTaps().length).toBe(1); + }); +}); diff --git a/packages/vchart/src/core/vchart.ts b/packages/vchart/src/core/vchart.ts index 412bc50e6c..7450eb151d 100644 --- a/packages/vchart/src/core/vchart.ts +++ b/packages/vchart/src/core/vchart.ts @@ -1172,7 +1172,9 @@ export class VChart implements IVChart { // setCurrentTheme 会导致 chart 实例的 reInit。 // 只要模块从 vchart 实例获取与 spec 相关的信息,都会出现错误,它们已经不匹配了 // this._setCurrentTheme(); - } else if (!isEqual(this._spec.background, lastSpec.background)) { + } + // background 与 theme 互相独立,同一次 updateSpec 里两者一起变时也要重建背景 mark + if (!isEqual(this._spec.background, lastSpec.background)) { result.reMake = true; result.changeBackground = true; } diff --git a/packages/vchart/src/mark/base/base-mark.ts b/packages/vchart/src/mark/base/base-mark.ts index 8df921673a..4bfe9c1d54 100644 --- a/packages/vchart/src/mark/base/base-mark.ts +++ b/packages/vchart/src/mark/base/base-mark.ts @@ -297,7 +297,7 @@ export class BaseMark extends GrammarItem implements IMar } return; } else if (isValid(product)) { - if (option.group && product.parent !== option.group) { + if (option?.group && product.parent !== option.group) { option.group.appendChild(product); } } else { diff --git a/packages/vchart/src/series/word-cloud/base.ts b/packages/vchart/src/series/word-cloud/base.ts index 297182a371..31a5591569 100644 --- a/packages/vchart/src/series/word-cloud/base.ts +++ b/packages/vchart/src/series/word-cloud/base.ts @@ -40,6 +40,7 @@ import { LinearScale } from '@visactor/vscale'; import type { GeometricMaskShape, TextShapeMask } from '@visactor/vlayouts'; import type { ITransformSpec } from '../../compile/interface'; import { createImage, vglobal } from '../../vrender-bridge'; +import type { IStage } from '@visactor/vrender-core'; import { getTextBounds } from '@visactor/vrender-core/text'; import { wordCloud } from '../../theme/builtin/common/series/word-cloud'; import { LayoutZIndex } from '../../constant/layout'; @@ -78,6 +79,8 @@ export class BaseWordCloudSeries void }; protected _wordCloudConfig?: WordCloudConfigType; protected _wordCloudShapeConfig?: WordCloudShapeConfigType; @@ -394,25 +397,29 @@ export class BaseWordCloudSeries { + // 布局是异步的,跑完时 series 可能已经被 release,此时 _option 已置空 + const stage = this._option?.globalInstance?.getStage(); + if (!stage) { + return; + } + this._removeAfterWordcloudShapeDrawTap(); + const afterWordcloudShapeDraw = () => { // 需要等到真正渲染完成 - this._option.globalInstance.getStage().hooks.afterRender.taps = this._option.globalInstance - .getStage() - .hooks.afterRender.taps.filter(tap => tap.fn !== afterWordcloudShapeDraw); + this._removeAfterWordcloudShapeDrawTap(); + const globalInstance = this._option?.globalInstance; + if (!globalInstance) { + return; + } this._option.dispatchEvent?.(ChartEvent.afterWordcloudShapeDraw, { - instance: this._option.globalInstance + instance: globalInstance }); - this._option.globalInstance - .getChart() - .getOption() - .performanceHook?.afterWordcloudShapeDraw?.(this._option.globalInstance); + globalInstance.getChart().getOption().performanceHook?.afterWordcloudShapeDraw?.(globalInstance); }; - this._option.globalInstance.getStage().hooks.afterRender.taps.push({ - type: 'sync', - name: 'afterWordcloudShapeDraw', - fn: afterWordcloudShapeDraw - }); + + this._afterWordcloudShapeDrawTap = { stage, fn: afterWordcloudShapeDraw }; + stage.hooks.afterRender.tap('afterWordcloudShapeDraw', afterWordcloudShapeDraw); }, dataIndexKey: DEFAULT_DATA_KEY, text: wordSpec.formatMethod @@ -552,7 +559,19 @@ export class BaseWordCloudSeries