From 3a08aae1edffb8a11038c6bf7b2b418b5132c9e8 Mon Sep 17 00:00:00 2001 From: "chendaxin.tk" Date: Sun, 13 Sep 2026 11:09:40 +0800 Subject: [PATCH 1/3] fix: rebuild background when spec.background and spec.theme change together `updateSpec` compared `background` only in the `else` branch of the theme check, so a call that changes both `spec.background` and `spec.theme` produced `changeTheme: true` without `reMake` / `changeBackground`. `stage.background` was still updated through `_setCurrentTheme()`, but the background mark built from the previous `{ fill }` spec was never rebuilt and kept covering the whole viewBox, so nothing changed on the canvas. `BaseMark._compileProduct` also dereferenced `option.group` bare while the two other uses of `option` in the same method are guarded. `BaseChart.compileBackground()` calls `this._backgroundMark.compile()` with no option, so a chart whose `background` is a mark spec throws `Cannot read properties of undefined (reading 'group')` on the second compile (`reCompile` without `reMake`), for example when only the legend changes. fix #4649 --- .../__tests__/unit/core/update-spec.test.ts | 100 ++++++++++++++++++ packages/vchart/src/core/vchart.ts | 4 +- packages/vchart/src/mark/base/base-mark.ts | 2 +- 3 files changed, 104 insertions(+), 2 deletions(-) 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/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 { From 547eaecdf496778b5a6dff25078769ebbe20cb6f Mon Sep 17 00:00:00 2001 From: "chendaxin.tk" Date: Sun, 13 Sep 2026 11:10:43 +0800 Subject: [PATCH 2/3] fix: drop the word cloud shape afterRender tap when the series is released MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The word cloud shape layout is asynchronous. When it finishes, `onLayoutFinished` pushes an `afterWordcloudShapeDraw` tap onto `stage.hooks.afterRender`, and that tap only unregisters itself once it is actually called. If the series is released in between — any `updateSpec` that triggers a reMake — `CompilableBase.release()` sets `_option` to `null`, and the next render throws `Cannot read properties of null (reading 'globalInstance')` from inside the hook, which aborts the whole render. Keep a reference to the registered tap on the series, remove it in `release()` and before registering a new one, and let the tap body tolerate a released series. --- ...ecompile-and-wordcloud-tap_2026-09-13.json | 21 +++++ .../series/word-cloud-shape-release.test.ts | 81 +++++++++++++++++++ packages/vchart/src/series/word-cloud/base.ts | 43 +++++++--- 3 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 common/changes/@visactor/vchart/fix-background-recompile-and-wordcloud-tap_2026-09-13.json create mode 100644 packages/vchart/__tests__/unit/series/word-cloud-shape-release.test.ts 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/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/series/word-cloud/base.ts b/packages/vchart/src/series/word-cloud/base.ts index 297182a371..2919c9955a 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,21 +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({ + + this._afterWordcloudShapeDrawTap = { stage, fn: afterWordcloudShapeDraw }; + stage.hooks.afterRender.taps.push({ type: 'sync', name: 'afterWordcloudShapeDraw', fn: afterWordcloudShapeDraw @@ -552,7 +563,21 @@ export class BaseWordCloudSeries item.fn !== tap.fn); + } + } + release() { + this._removeAfterWordcloudShapeDrawTap(); super.release(); this._wordMeasureCache?.clear(); this._wordMeasureCache = undefined; From eebadbc24b3fffbc9f0333a28fb6c2ce8095bcc1 Mon Sep 17 00:00:00 2001 From: "chendaxin.tk" Date: Mon, 14 Sep 2026 10:44:06 +0800 Subject: [PATCH 3/3] refactor: manage the word cloud shape tap through tap/unTap Use the standard `SyncHook.tap` / `SyncHook.unTap` API instead of pushing to and filtering `hooks.afterRender.taps` by hand, matching how `Compiler` cleans up its own stage hooks. `unTap` keeps the `fn` argument so a stage hosting more than one word cloud series only drops its own callback. --- packages/vchart/src/series/word-cloud/base.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/vchart/src/series/word-cloud/base.ts b/packages/vchart/src/series/word-cloud/base.ts index 2919c9955a..31a5591569 100644 --- a/packages/vchart/src/series/word-cloud/base.ts +++ b/packages/vchart/src/series/word-cloud/base.ts @@ -419,11 +419,7 @@ export class BaseWordCloudSeries item.fn !== tap.fn); - } + // 带上 fn:同一个 stage 上可能有多个词云系列,只按名字清会误删别人的回调 + tap.stage?.hooks?.afterRender?.unTap('afterWordcloudShapeDraw', tap.fn); } release() {