Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/taro-vchart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"cjs"
],
"scripts": {
"test": "node scripts/test-spec-equal.cjs",
"build": "bundle --clean",
"build:tt": "taro build --type tt",
"build:lark": "taro build --type lark",
Expand Down
65 changes: 65 additions & 0 deletions packages/taro-vchart/scripts/test-spec-equal.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const typescript = require('typescript');

const source = fs.readFileSync(path.join(__dirname, '../src/utils/spec-equal.ts'), 'utf8');
const { outputText, diagnostics = [] } = typescript.transpileModule(source, {
compilerOptions: {
module: typescript.ModuleKind.CommonJS,
target: typescript.ScriptTarget.ES2015
},
reportDiagnostics: true
});
assert.equal(diagnostics.length, 0, 'spec comparator should transpile without diagnostics');

const compiledModule = { exports: {} };
new Function('exports', 'require', 'module', outputText)(compiledModule.exports, require, compiledModule);
const { isSpecEqual } = compiledModule.exports;

const firstFormatter = () => 'first';
const secondFormatter = () => 'second';
assert.equal(isSpecEqual({ formatter: firstFormatter }, { formatter: secondFormatter }), false);
assert.equal(isSpecEqual({ formatter: firstFormatter }, { formatter: firstFormatter }), true);
assert.equal(isSpecEqual({ axis: { visible: true } }, { axis: { visible: true } }), true);
assert.equal(isSpecEqual({ seriesField: undefined }, {}), true);
assert.equal(isSpecEqual({}, { seriesField: undefined }), true);
assert.equal(
isSpecEqual({ seriesField: undefined }, { seriesField: 'value' }),
false
);
assert.equal(isSpecEqual({ date: new Date(0) }, { date: new Date(0) }), true);
assert.equal(isSpecEqual({ date: new Date(0) }, { date: new Date(1) }), false);

class Row {
constructor(name, value) {
this.name = name;
this.value = value;
}
}
assert.equal(isSpecEqual({ data: [new Row('A', 3)] }, { data: [new Row('A', 3)] }), true);
assert.equal(isSpecEqual({ data: [new Row('A', 3)] }, { data: [new Row('A', 4)] }), false);

function cyclicSpec(value) {
const spec = { value };
spec.self = spec;
return spec;
}
assert.equal(isSpecEqual(cyclicSpec(1), cyclicSpec(1)), true);
assert.equal(isSpecEqual(cyclicSpec(1), cyclicSpec(2)), false);

function cyclicData(twoNodes) {
const first = { value: 1 };
if (twoNodes) {
const second = { value: 1 };
first.next = second;
second.next = first;
} else {
first.next = first;
}
return { data: first };
}
assert.equal(isSpecEqual(cyclicData(true), cyclicData(false)), false);
assert.equal(isSpecEqual(cyclicData(false), cyclicData(true)), false);

console.log('Taro VChart spec equality checks passed');
5 changes: 3 additions & 2 deletions packages/taro-vchart/src/components/general-chart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
style_cs_canvas,
style_cs_canvas_hidden,
style_cs_tooltip_canvas,
style_container
style_container,
isSpecEqual
} from '../../utils';

import { IChartProps, IVChart, IDomRef, RenderMode } from '../../typings';
Expand Down Expand Up @@ -83,7 +84,7 @@ export class GeneralChart extends React.Component<GeneralChartProps> {
if (
this.ttCanvas &&
this.ttCanvas.chartInstance &&
JSON.stringify(prevProps.spec) !== JSON.stringify(this.props.spec)
!isSpecEqual(prevProps.spec, this.props.spec)
) {
this.ttCanvas.updateSpec(this.props);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/taro-vchart/src/components/web-chart/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { isSpecEqual } from '../../utils';
import { IChartProps, IVChart, RenderMode } from '../../typings';

export interface WebChartProps extends IChartProps {
Expand Down Expand Up @@ -48,7 +49,7 @@ export class WebChart extends React.Component<WebChartProps> {

const { spec, onChartReady } = this.props;

if (JSON.stringify(prevProps.spec) !== JSON.stringify(spec)) {
if (!isSpecEqual(prevProps.spec, spec)) {
this.vchart.updateSpec(spec, true);
onChartReady && onChartReady(this.vchart);
}
Expand Down
1 change: 1 addition & 0 deletions packages/taro-vchart/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { TTCanvas } from './tt-canvas';
export { isSpecEqual } from './spec-equal';

export { style_cs_canvas, style_cs_canvas_hidden, style_cs_tooltip_canvas, style_container } from './container-styles';
98 changes: 98 additions & 0 deletions packages/taro-vchart/src/utils/spec-equal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
function isComparableObject(value: object): boolean {
const prototype = Object.getPrototypeOf(value);
return (
prototype === Object.prototype ||
prototype === null ||
Object.keys(value).length > 0
);
}

function equalSpecValue(
left: unknown,
right: unknown,
active: WeakMap<object, object>,
activeReverse: WeakMap<object, object>
): boolean {
if (left === right) return true;
if (typeof left === 'number' && typeof right === 'number' && Number.isNaN(left) && Number.isNaN(right)) {
return true;
}
if (typeof left !== typeof right || left === null || right === null) return false;
if (typeof left !== 'object' || typeof right !== 'object') return false;

if (left instanceof Date || right instanceof Date) {
if (!(left instanceof Date) || !(right instanceof Date)) return false;
const leftTime = left.getTime();
const rightTime = right.getTime();
return leftTime === rightTime || (Number.isNaN(leftTime) && Number.isNaN(rightTime));
}

const leftIsArray = Array.isArray(left);
if (leftIsArray !== Array.isArray(right)) return false;
if (!leftIsArray && (!isComparableObject(left) || !isComparableObject(right))) return false;

const leftObject = left as object;
const rightObject = right as object;
const activeRight = active.get(leftObject);
if (activeRight) return activeRight === rightObject;

@visactor-review-bot visactor-review-bot Bot Oct 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Distinguish cyclic data graphs that share a right-hand node

active only tracks left-to-right pairs, so two different active left nodes may map to the same right node. For data values containing a two-node cycle versus a one-node self-cycle with identical scalar fields, isSpecEqual(oldSpec, newSpec) returns true while the reverse comparison returns false (reproduced using the actual comparator under Node 22). Since the wrappers call updateSpec only when this returns false, replacing such cyclic data is silently ignored. Track the reverse active mapping too, so changed cycle topology cannot compare equal. Full chart integration was not runnable without workspace dependencies.


Confirmed fixed (2759f72): The active reverse map rejects mapping two left-hand cycle nodes onto one right-hand node. The original two-node to self-cycle replacement compares unequal in both directions, while unchanged graph topology remains eligible for equality; both wrapper update guards consume this result.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 9a4fd7f. Active cycle pairs are now tracked in both directions, and regression cases verify two-node versus self-cycle topology is unequal in either comparison direction.

const activeLeft = activeReverse.get(rightObject);
if (activeLeft) return activeLeft === leftObject;
active.set(leftObject, rightObject);
activeReverse.set(rightObject, leftObject);
const untrack = () => {
active.delete(leftObject);
activeReverse.delete(rightObject);
};

if (leftIsArray) {
const leftArray = left as unknown[];
const rightArray = right as unknown[];
if (leftArray.length !== rightArray.length) {
untrack();
return false;
}
for (let index = leftArray.length - 1; index >= 0; index--) {
if (!equalSpecValue(leftArray[index], rightArray[index], active, activeReverse)) {
untrack();
return false;
}
}
untrack();
return true;
}

const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
const rightKeys = Object.keys(rightRecord);
let leftDefinedKeyCount = 0;
let rightDefinedKeyCount = 0;
for (const key of leftKeys) {
if (leftRecord[key] !== undefined) leftDefinedKeyCount++;
}
for (const key of rightKeys) {
if (rightRecord[key] !== undefined) rightDefinedKeyCount++;
}
if (leftDefinedKeyCount !== rightDefinedKeyCount) {
untrack();
return false;
}
for (const key of leftKeys) {
const leftValue = leftRecord[key];
if (leftValue === undefined) continue;
if (
!Object.prototype.hasOwnProperty.call(rightRecord, key) ||
!equalSpecValue(leftValue, rightRecord[key], active, activeReverse)
) {
untrack();
return false;
}
}
untrack();
return true;
}

/** Compare chart specs without serializing callback values or cyclic data. */
export function isSpecEqual(left: unknown, right: unknown): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

建议移除这个包内自定义比较器,参照 react-vchart 的 BaseChart,在两个 Taro wrapper 中使用 import { isEqual } from '@visactor/vutils',以 !isEqual(prevProps.spec, spec) 判断是否更新。isEqual 默认按引用比较函数(不要开启 skipFunction),已能解决本 PR 的 callback-only 更新遗漏。请同时为 taro-vchart 显式声明该依赖,并更新相应锁文件。

需要区分循环引用这一目标:我在 Node.js 22 下验证了仓库锁定的 @visactor/vutils@1.0.28,比较两个独立的自循环对象仍会抛出 RangeError: Maximum call stack size exceeded,因此不能把直接替换为 isEqual 宣称为完整修复循环引用。如果本 PR 继续覆盖循环引用,请先在 vutils 公共工具层补齐相应能力和回归测试,再升级依赖复用,并验证 react-vchart 的兼容性及性能;或者将本 PR 收敛为函数更新修复,循环引用另行跟踪,同时调整 Fixes #4656 和说明。

这与仓库 AGENTS.md 中优先复用公共工具、能力不足时修正上游契约的原则一致,也能避免 React/Taro 长期维护不同的 spec 比较语义。

return equalSpecValue(left, right, new WeakMap(), new WeakMap());
}
Loading