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