diff --git a/docs/superpowers/plans/2026-09-20-empty-state-attribute-invalidation.md b/docs/superpowers/plans/2026-09-20-empty-state-attribute-invalidation.md new file mode 100644 index 000000000..978daaf26 --- /dev/null +++ b/docs/superpowers/plans/2026-09-20-empty-state-attribute-invalidation.md @@ -0,0 +1,113 @@ +# 空状态后的单属性更新失效 Implementation Plan + +> **For agentic workers:** 按任务顺序在当前会话执行,每步更新复选框。设计与实施已由用户确认,无需再次申请执行许可。 + +**Goal:** 修复空状态下共享属性的单属性写入失效,恢复图例文字截断。 + +**Architecture:** 在 `Graphic.setAttribute` 的已有慢路径复用 `detachAttributeFromBaseAttributes()`,确保写入前保留可比较的旧生效值。保持普通单属性快速路径与空状态提前返回逻辑。 + +**Tech Stack:** TypeScript、Rush、Jest、Electron Canvas、GitHub CLI。 + +## Global Constraints + +- 静态真值主路径是 `baseAttributes + resolvedStatePatch -> attribute`。 +- 普通图元继续共享属性存储;空状态继续跳过不必要的提交。 +- 不修改图例布局算法、公开类型或依赖版本。 +- 文档优先使用中文。 + +## Task 1:公共更新入口与回归验证 + +**Files:** +- Modify: `packages/vrender-core/src/graphic/graphic.ts` +- Test: `packages/vrender-core/__tests__/unit/graphic/attribute-layer-core.test.ts` +- Test: `packages/vrender-components/__tests__/electron/legend/discrete.test.ts` + +**Interfaces:** 使用现有 `setAttribute(key, value)`、`addState('selected', false, false)`、`clearStates(false)`;不增加公开接口。 + +- [x] 在属性分层测试中覆盖空 `selected` 状态后的宽度和填充更新。使用以下调用顺序,分别断言几何失效和纯绘制失效,并检查重复写入及清除状态后的基础值: + +```ts +const graphic = createGraphic(); +graphic.states = { selected: {} }; +graphic.addState('selected', false, false); +(graphic as any)._updateTag = UpdateTag.NONE; +graphic.setAttribute('width', 30); +expect(graphic.attribute.width).toBe(30); +expect(graphic.shouldUpdateShape()).toBe(true); +graphic.clearStates(false); +expect(graphic.attribute.width).toBe(30); +``` + +- [x] 在 Electron 图例测试创建带 value 的固定宽度图例,确保 label 经首次测量后被截断: + +```ts +const legend = new DiscreteLegend({ + item: { width: 62, value: { alignRight: true } }, + items: [{ label: 'test1', value: 'average:16', shape: { fill: 'blue' } }] +}); +stage.defaultLayer.add(legend as unknown as IGraphic); +stage.render(); +const label = legend.find(node => node.name === 'legendItemLabel', true) as IText; +expect(label.clipedText).not.toBe('test1'); +expect(label.AABBBounds.width()).toBeLessThanOrEqual(label.attribute.maxLineWidth); +``` + +- [x] 运行上述两个测试文件,确认新增用例在修复前失败,保存失败结果。 +- [x] 在 `setAttribute` 慢路径写入前补齐: + +```ts +this.detachAttributeFromBaseAttributes(); +const nextAttrs = { [key]: value } as Partial; +this.applyBaseAttributes(nextAttrs); +this.commitBaseAttributeMutation(!!forceUpdateTag, context); +``` + +- [x] 重跑新增用例及 core 源码测试;运行相关动画测试和 Electron 图例测试。 + +```sh +cd packages/vrender-core && node_modules/.bin/jest -c jest.config.js --runInBand +``` + +包内实际执行可使用已有 `node_modules/.bin/jest -c jest.config.js --runInBand`;组件图例使用 `-c jest.electron.config.js --runTestsByPath __tests__/electron/legend/discrete.test.ts`。 + +- [x] 编译 core 与组件依赖链,检查改动文件 ESLint 和 `git diff --check`;提交钩子负责 Prettier。 +- [x] 临时基准比较修复前后:10k 图元普通单属性写入、空状态后首次写入、已分离后的持续写入;交替顺序运行多轮,报告中位数。基准脚本和原始数据放入临时目录,不作为公共测试接口。 + +## Task 2:提交与 PR + +**Files:** 更新本计划的执行记录;PR 正文沿用仓库中文模板。 + +- [x] 自查运行时代码仅修改公共写入边界,测试锁定有效 API 行为;确认未夹带其他工作区修改。 +- [ ] 提交修复和测试,推送 `codex/fix-empty-state-attribute-invalidation`。 +- [ ] 创建指向 `main` 的 PR,说明触发用例、原始提交、修复机制、测试和性能验证结果。原用例属于 chartspace4,使用完整链接明确产品,避免误当作 VRender Bug Server 用例。 +- [ ] 核对 PR 的 base/head、文件列表和远端提交,返回 PR 链接。 + +## 执行记录 + +设计和方案已在此前讨论中确认;本计划在修改运行时代码前完成自查,后续记录实际结果。 + +### 实现和功能验证 + +运行时代码仅新增一次 `detachAttributeFromBaseAttributes()` 调用,位置为 `setAttribute` 慢路径的首次写入之前。 + +修复前,新增的 width/x/fill 三个属性失效测试均失败(期望失效标记分别为 3、49、64,实际为 0);新增 Electron 用例仍得到未截断的 `test1`,已有图例宽度测试也失败。修复后: + +- core 全量源码测试:112 个测试文件、716 个测试通过,既有 3 个性能用例默认跳过。 +- `animation-runtime-attribute.test.ts` 和 `graphic-state-extension.test.ts`:50 个测试通过。 +- Electron `legend/discrete.test.ts`:6 个测试全部通过,包含初始化截断和重新选择后的截断。 +- `rush compile -t @visactor/vrender-components`:core、animate、kits、components 及构建工具依赖链通过。 +- 改动的三个 TypeScript 文件 ESLint 通过;`git diff --check` 通过。 + +### 10k 图元属性更新基准 + +环境为 macOS arm64、Node v24.19.0。直接加载本地源码,使用空 graphic service 排除渲染开销;对照版本仅移除新增分离调用。每种场景使用 10,000 个矩形,交替顺序执行,预热 3 轮,记录 7 轮中位数。 + +| 场景 | 写入次数 | 修复前 | 修复后 | +| --- | ---: | ---: | ---: | +| 无状态普通更新 | 200,000 | 7.256 ms | 7.391 ms | +| 空状态后首次更新 | 10,000 | 13.442 ms | 14.389 ms | +| 已分离后的持续更新 | 200,000 | 202.485 ms | 200.400 ms | + +首次更新增加约 0.947 ms,同时正确标记需要更新形状的图元数从 0 恢复为 10,000。其余两类中位数变化为 +1.85% / -1.03%,未观察到明显持续开销变化。这是属性更新微基准,不代表 Canvas 或多 chart 的端到端帧率。 + +本地基准脚本与原始数据:`/tmp/vrender-empty-state-benchmark.cjs`、`/tmp/vrender-empty-state-benchmark.json`。提交和 PR 的外部状态以 Git 历史及最终交付链接为准。 diff --git a/docs/superpowers/specs/2026-09-20-empty-state-attribute-invalidation-design.md b/docs/superpowers/specs/2026-09-20-empty-state-attribute-invalidation-design.md new file mode 100644 index 000000000..0e7bc949b --- /dev/null +++ b/docs/superpowers/specs/2026-09-20-empty-state-attribute-invalidation-design.md @@ -0,0 +1,28 @@ +# 空状态后的单属性更新失效修复设计 + +## 目标与证据 + +修复 `Graphic.setAttribute` 在空状态下首次修改共享属性时丢失增量失效通知的问题,恢复图例文本截断,并保留无状态共享存储和空状态提交优化。 + +原始用例:。 +VRender 提交 `0f5ead77a` 为 `useStates` 增加空状态提前返回,使 `selected: {}` 下 `attribute` 和 `baseAttributes` 保持共享。图例先测量 `test1`,再写入 `maxLineWidth`;单属性慢路径直接写共享对象,后续差异检测无法发现变化,沿用未截断缓存。此前用同一构建分别回退提前返回、补齐写入前分离,均恢复 `t…`。 + +## 已确认方案 + +用户已确认从公共属性更新契约修复,并授权制定计划、创建分支、实现和提交 PR。 + +在 `Graphic.setAttribute` 已有慢路径中,写入前调用 `detachAttributeFromBaseAttributes()`,与 `_setAttributes` 和 `commitInternalBaseAttributes` 保持一致。分离只在两者实际共享时发生;普通单属性快速路径保持原状。 + +图例初始化强制分离会将底层缺陷留给其他调用方,并增加组件分配成本;回退空状态优化会恢复不必要的状态提交。因此本次保留这两处现有行为,在公共写入边界修复。 + +## 验收条件 + +- 空 `selected` 状态后的 `setAttribute` 能正确触发几何或纯绘制失效,更新基础值和生效值,清除状态后保留新基础值。 +- 重复写入相同值不产生多余失效;已有无状态共享存储和空状态无提交测试仍通过。 +- 真实 Canvas 图例中,固定宽度的 label/value 布局能截断超宽 label,并在交互状态切换后保持正确。 +- core 源码测试、相关动画测试和组件 Electron 图例测试通过;core 与组件依赖链编译、改动文件 lint 通过。 +- 对 10k 图元的普通更新、空状态后首次更新和持续更新提供针对性性能数据,区分必要的首次分离成本与常规路径成本。 + +## 范围 + +修改 core 公共属性更新入口并补充 core/组件回归测试;不修改图例布局算法、状态优化、公开类型或依赖版本。基于 `origin/main` 创建 `codex/fix-empty-state-attribute-invalidation`,PR 目标为 `main`。 diff --git a/packages/vrender-components/__tests__/electron/legend/discrete.test.ts b/packages/vrender-components/__tests__/electron/legend/discrete.test.ts index 1fc0e3150..e4f030236 100644 --- a/packages/vrender-components/__tests__/electron/legend/discrete.test.ts +++ b/packages/vrender-components/__tests__/electron/legend/discrete.test.ts @@ -73,6 +73,34 @@ describe('DiscreteLegend', () => { // }); }); + it('should clip a measured label when an empty selected state is active', () => { + const legend = new DiscreteLegend({ + item: { + width: 62, + value: { alignRight: true } + }, + items: [{ label: 'test1', value: 'average:16', shape: { fill: 'blue' } }] + }); + + stage.defaultLayer.add(legend as unknown as IGraphic); + stage.render(); + + const label = legend.find(node => node.name === 'legendItemLabel', true) as IText; + expect(label.currentStates).toEqual(['selected']); + expect(label.attribute.maxLineWidth).toBeGreaterThan(0); + expect(label.clipedText).not.toBe('test1'); + expect(label.AABBBounds.width()).toBeLessThanOrEqual(label.attribute.maxLineWidth); + + legend.setSelected([]); + legend.setSelected(['test1']); + stage.render(); + + expect(label.clipedText).not.toBe('test1'); + expect(label.AABBBounds.width()).toBeLessThanOrEqual(label.attribute.maxLineWidth); + stage.defaultLayer.removeChild(legend as unknown as IGraphic); + legend.release(); + }); + it('should reuse static state definitions without coupling item selection', () => { const legend = new DiscreteLegend({ select: true, diff --git a/packages/vrender-core/__tests__/unit/graphic/attribute-layer-core.test.ts b/packages/vrender-core/__tests__/unit/graphic/attribute-layer-core.test.ts index ba418f2cf..9961fd5b4 100644 --- a/packages/vrender-core/__tests__/unit/graphic/attribute-layer-core.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/attribute-layer-core.test.ts @@ -162,6 +162,34 @@ describe('Graphic attribute layering', () => { expect((graphic as any).baseAttributes.shadowBlur).toBe(shadowBlur); }); + test.each([ + { key: 'width', value: 30, expectedTag: UpdateTag.UPDATE_SHAPE_AND_BOUNDS }, + { key: 'x', value: 12, expectedTag: UpdateTag.UPDATE_BOUNDS | UpdateTag.UPDATE_GLOBAL_LOCAL_MATRIX }, + { key: 'fill', value: 'red', expectedTag: UpdateTag.UPDATE_PAINT } + ])('should preserve single attribute invalidation after an empty state: %p', ({ key, value, expectedTag }) => { + const graphic = createGraphic(); + graphic.states = { selected: {} }; + graphic.addState('selected', false, false); + (graphic as any)._updateTag = UpdateTag.NONE; + + graphic.setAttribute(key, value); + + expect(graphic.attribute[key]).toBe(value); + expect((graphic as any).baseAttributes[key]).toBe(value); + expect((graphic as any)._updateTag).toBe(expectedTag); + expect(graphic.currentStates).toEqual(['selected']); + + (graphic as any)._updateTag = UpdateTag.NONE; + graphic.setAttribute(key, value); + + expect((graphic as any)._updateTag).toBe(UpdateTag.NONE); + + graphic.clearStates(false); + + expect(graphic.attribute[key]).toBe(value); + expect((graphic as any).baseAttributes[key]).toBe(value); + }); + test('should adopt init attributes onto the shared static storage path', () => { const graphic = createGraphic(); const syncSpy = jest.spyOn(graphic as any, '_syncAttribute'); diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index 53679d98c..ce5f67aa1 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -1841,6 +1841,7 @@ export abstract class Graphic = Partial; this.applyBaseAttributes(nextAttrs); this.commitBaseAttributeMutation(!!forceUpdateTag, context);