Repository navigation
fix: compare Taro chart specs safely #4723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
db3d83f
22658e4
9a4fd7f
2759f72
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); |
| 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'; |
| 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; | ||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 建议移除这个包内自定义比较器,参照 react-vchart 的 BaseChart,在两个 Taro wrapper 中使用 需要区分循环引用这一目标:我在 Node.js 22 下验证了仓库锁定的 这与仓库 AGENTS.md 中优先复用公共工具、能力不足时修正上游契约的原则一致,也能避免 React/Taro 长期维护不同的 spec 比较语义。 |
||
| return equalSpecValue(left, right, new WeakMap(), new WeakMap()); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
activeonly 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 callupdateSpeconly 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.
There was a problem hiding this comment.
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.