From 7f4f46517547df838cc7e3cbd3c49d9f637682f3 Mon Sep 17 00:00:00 2001 From: xile611 Date: Tue, 15 Sep 2026 17:33:37 +0800 Subject: [PATCH 1/4] fix: unify glyph state resolution and preserve legacy ordering --- .../unit/graphic/glyph-state.test.ts | 80 ++++++++++++- packages/vrender-core/src/graphic/glyph.ts | 106 +++++++++++------- packages/vrender-core/src/graphic/graphic.ts | 22 ++-- .../src/graphic/state/state-definition.ts | 2 + .../src/graphic/state/state-engine.ts | 5 + 5 files changed, 168 insertions(+), 47 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts index 137dd434c..3687dbada 100644 --- a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts @@ -1,5 +1,6 @@ import { createGlyph } from '../../../src/graphic/glyph'; import { createRect } from '../../../src/graphic/rect'; +import { createGroup } from '../../../src/graphic/group'; describe('Glyph state', () => { const createTestGlyph = () => { @@ -115,7 +116,7 @@ describe('Glyph state', () => { expect(glyph.normalAttrs).toEqual((glyph as any).baseAttributes); }); - test('should differ from normal graphic states by reading glyphStates instead of states', () => { + test('explicit glyphStates take precedence over standard local definitions', () => { const { glyph } = createTestGlyph(); (glyph as any).states = { hover: { @@ -135,4 +136,81 @@ describe('Glyph state', () => { expect(glyph.attribute.stroke).toBe('glyph-state'); }); + + test('removes state-only keys and restores the latest base attributes', () => { + const { glyph } = createTestGlyph(); + glyph.glyphStates = { + selected: { attributes: { fillOpacity: 0.25, stroke: 'red' }, subAttributes: [] } + }; + glyph.setStates(['selected'], false); + expect(glyph.attribute.fillOpacity).toBe(0.25); + expect(glyph.baseAttributes.fillOpacity).toBeUndefined(); + glyph.setAttribute('stroke', 'orange'); + expect(glyph.attribute.stroke).toBe('red'); + glyph.setStates([], false); + expect(glyph.attribute.stroke).toBe('orange'); + expect(glyph.attribute.fillOpacity).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(glyph.attribute, 'fillOpacity')).toBe(false); + }); + + test('refreshes a proxy-only state without clearing it first', () => { + const { glyph } = createTestGlyph(); + let opacity = 0.2; + glyph.glyphStateProxy = () => ({ attributes: { fillOpacity: opacity }, subAttributes: [] }); + glyph.setStates(['selected'], { animate: false }); + opacity = 0.8; + glyph.setStates(['selected'], { animate: false }); + expect(glyph.currentStates).toEqual(['selected']); + expect(glyph.effectiveStates).toEqual(['selected']); + expect(glyph.resolvedStatePatch.fillOpacity).toBe(0.8); + expect(glyph.attribute.fillOpacity).toBe(0.8); + expect(glyph.baseAttributes.fillOpacity).toBeUndefined(); + }); + + test('preserves legacy input order and stateSort without mutating the input', () => { + const { glyph } = createTestGlyph(); + glyph.glyphStates = { + a: { attributes: { stroke: 'red' }, subAttributes: [] }, + z: { attributes: { stroke: 'blue' }, subAttributes: [] } + }; + glyph.useStates(['z', 'a'], false); + expect(glyph.attribute.stroke).toBe('red'); + glyph.useStates(['a', 'z'], false); + expect(glyph.attribute.stroke).toBe('blue'); + (glyph as any).stateSort = (a: string, b: string) => b.localeCompare(a); + const states = ['a', 'z']; + const proxy = jest.fn((name: string) => glyph.glyphStates[name]); + glyph.glyphStateProxy = proxy; + glyph.setStates(states, { animate: false }); + expect(glyph.attribute.stroke).toBe('red'); + expect(proxy).toHaveBeenCalledWith('a', ['z', 'a']); + expect(states).toEqual(['a', 'z']); + }); + + test('uses Group definitions unless explicit legacy inputs own the glyph', () => { + const { glyph } = createTestGlyph(); + const group = createGroup({}); + group.sharedStateDefinitions = { + hover: { stroke: 'shared' }, + selected: { fillOpacity: 0.4 } + }; + group.add(glyph); + glyph.states = { hover: { stroke: 'local' } }; + glyph.setStates(['hover'], false); + expect(glyph.attribute.stroke).toBe('shared'); + glyph.glyphStates = { hover: { attributes: { stroke: 'legacy' }, subAttributes: [] } }; + glyph.setStates(['hover', 'selected'], { animate: false }); + expect(glyph.attribute.stroke).toBe('legacy'); + expect(glyph.attribute.fillOpacity).toBeUndefined(); + glyph.glyphStateProxy = () => undefined; + glyph.setStates(['hover'], { animate: false }); + expect(glyph.attribute.stroke).toBe('black'); + glyph.glyphStateProxy = undefined; + glyph.glyphStates = undefined; + glyph.setStates(['hover', 'selected'], { animate: false }); + expect(glyph.attribute.stroke).toBe('shared'); + expect(glyph.attribute.fillOpacity).toBe(0.4); + glyph.clearStates(false); + expect(glyph.registeredActiveScopes).toBeUndefined(); + }); }); diff --git a/packages/vrender-core/src/graphic/glyph.ts b/packages/vrender-core/src/graphic/glyph.ts index 14a629996..d113ec48a 100644 --- a/packages/vrender-core/src/graphic/glyph.ts +++ b/packages/vrender-core/src/graphic/glyph.ts @@ -8,6 +8,9 @@ import type { IGraphicAttribute, ISetAttributeContext } from '../interface'; +import { StateDefinitionCompiler } from './state/state-definition-compiler'; +import type { CompiledStateDefinition, StateDefinition, StateDefinitionsInput } from './state/state-definition'; +import type { SharedStateScope } from './state/shared-state-scope'; import { getTheme } from './theme'; import { GLYPH_NUMBER_TYPE } from './constants'; @@ -30,6 +33,10 @@ export class Glyph extends Graphic implements IGlyph { subAttributes: Partial[]; }; protected declare subGraphic: IGraphic[]; + private legacyDefinitionsSource?: Glyph['glyphStates']; + private legacyProxySource?: Glyph['glyphStateProxy']; + private legacyDefinitions?: StateDefinitionsInput; + private legacyCompiledDefinitions?: Map>; static NOWORK_ANIMATE_ATTR = NOWORK_ANIMATE_ATTR; @@ -189,54 +196,75 @@ export class Glyph extends Graphic implements IGlyph { return false; } - useStates(states: string[], hasAnimation?: boolean): void { - if (!states.length) { - this.clearStates(hasAnimation); - return; + protected hasLegacyStateDefinitions(): boolean { + if (this.glyphStateProxy) { + return true; } - const previousStates = this.currentStates ? this.currentStates.slice() : []; - - const isChange = - this.currentStates?.length !== states.length || - states.some((stateName, index) => this.currentStates[index] !== stateName); - if (!isChange) { - return; + for (const name in this.glyphStates) { + if (Object.prototype.hasOwnProperty.call(this.glyphStates, name)) { + return true; + } } + return false; + } - this.stopStateAnimates(); + protected syncSharedStateScopeBindingFromTree( + markDirty: boolean = true, + inheritedSharedStateScope?: SharedStateScope> | null + ): boolean { + // Legacy Glyph definitions historically own the whole state surface. + return this.hasLegacyStateDefinitions() + ? this.syncSharedStateScopeBinding(undefined, markDirty) + : super.syncSharedStateScopeBindingFromTree(markDirty, inheritedSharedStateScope); + } - if (this.stateSort) { - states = states.sort(this.stateSort); + protected resolveEffectiveCompiledDefinitions(stateNames: readonly string[] = []) { + if (!this.hasLegacyStateDefinitions()) { + this.legacyDefinitions = undefined; + this.legacyCompiledDefinitions = undefined; + return super.resolveEffectiveCompiledDefinitions(stateNames); } - const stateAttrs = {}; - states.forEach(stateName => { - const attrs = this.glyphStateProxy ? this.glyphStateProxy(stateName, states) : this.glyphStates[stateName]; - - if (attrs) { - Object.assign(stateAttrs, attrs.attributes); + this.syncSharedStateScopeBindingFromTree(false); + let changed = false; + if ( + !this.legacyDefinitions || + this.legacyDefinitionsSource !== this.glyphStates || + this.legacyProxySource !== this.glyphStateProxy + ) { + this.legacyDefinitionsSource = this.glyphStates; + this.legacyProxySource = this.glyphStateProxy; + this.legacyDefinitions = {}; + for (const name of Object.keys(this.glyphStates ?? {})) { + this.legacyDefinitions[name] = this.createLegacyStateDefinition(name); } - }); - - if (!this.beforeStateUpdate(stateAttrs, previousStates, states, hasAnimation, false)) { - return; + changed = true; } - - this.currentStates = states; - this.applyStateAttrs(stateAttrs, states, hasAnimation); + if (this.glyphStateProxy) { + const addDefinition = (name: string) => { + if (!Object.prototype.hasOwnProperty.call(this.legacyDefinitions, name)) { + this.legacyDefinitions[name] = this.createLegacyStateDefinition(name); + changed = true; + } + }; + this.currentStates?.forEach(addDefinition); + stateNames.forEach(addDefinition); + } + if (changed) { + this.legacyCompiledDefinitions = new StateDefinitionCompiler().compile( + this.legacyDefinitions + ); + } + return { compiledDefinitions: this.legacyCompiledDefinitions, stateOrder: 'input' as const }; } - clearStates(hasAnimation?: boolean) { - this.stopStateAnimates(); - const previousStates = this.currentStates ? this.currentStates.slice() : []; - if (this.hasState() && this.normalAttrs) { - if (!this.beforeStateUpdate(this.normalAttrs, previousStates, [], hasAnimation, true)) { - return; - } - this.currentStates = []; - this.applyStateAttrs(this.normalAttrs, this.currentStates, hasAnimation, true); - } else { - this.currentStates = []; - } + private createLegacyStateDefinition(name: string): StateDefinition { + return this.glyphStateProxy + ? { + name, + resolver: ({ graphic, activeStates }) => + (graphic as Glyph).glyphStateProxy(name, activeStates as string[])?.attributes + } + : { name, patch: this.glyphStates[name].attributes }; } clone(): IGraphic> { diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index e6d184533..67eb93c13 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -463,6 +463,7 @@ export abstract class Graphic = Partial; protected stateEngineCompiledDefinitions?: Map>; protected stateEngineStateSort?: (stateA: string, stateB: string) => number; + protected stateEngineStateOrder?: 'input'; protected stateEngineMergeMode?: StateMergeMode; protected stateTransitionOrchestrator?: StateTransitionOrchestrator; protected localStateDefinitionsSource?: StateDefinitionsInput; @@ -686,8 +687,9 @@ export abstract class Graphic = Partial>; + stateOrder?: 'input'; } { this.syncSharedStateScopeBindingFromTree(false); const boundScope = this.boundSharedStateScope; @@ -2082,8 +2084,11 @@ export abstract class Graphic = Partial = this.getStateResolveBaseAttrs()) { - const { compiledDefinitions } = this.resolveEffectiveCompiledDefinitions(); + protected ensureStateEngine( + stateResolveBaseAttrs: Partial = this.getStateResolveBaseAttrs(), + stateNames: readonly string[] = this.currentStates ?? EMPTY_STATE_NAMES + ) { + const { compiledDefinitions, stateOrder } = this.resolveEffectiveCompiledDefinitions(stateNames); this.compiledStateDefinitions = compiledDefinitions; if (!compiledDefinitions) { @@ -2093,15 +2098,18 @@ export abstract class Graphic = Partial({ compiledDefinitions, stateSort: this.stateSort, + stateOrder, mergeMode: this.stateMergeMode }); this.stateEngineCompiledDefinitions = compiledDefinitions; this.stateEngineStateSort = this.stateSort; + this.stateEngineStateOrder = stateOrder; this.stateEngineMergeMode = this.stateMergeMode; } @@ -2157,7 +2165,7 @@ export abstract class Graphic = Partial = this.getStateResolveBaseAttrs() ): GraphicStateTransition { - const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs); + const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs, states); return stateEngine ? this.toGraphicStateTransition(stateEngine.applyStates(states)) : this.resolveLocalUseStatesTransition(states); @@ -2171,7 +2179,7 @@ export abstract class Graphic = Partial = Partial = Partial { const stateResolveBaseAttrs = this.getStateResolveBaseAttrs(); - const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs); + const stateEngine = this.ensureStateEngine(stateResolveBaseAttrs, states); if (forceResolverRefresh) { stateEngine?.invalidateResolverCache(); } diff --git a/packages/vrender-core/src/graphic/state/state-definition.ts b/packages/vrender-core/src/graphic/state/state-definition.ts index df70c2ded..3874bdd63 100644 --- a/packages/vrender-core/src/graphic/state/state-definition.ts +++ b/packages/vrender-core/src/graphic/state/state-definition.ts @@ -46,5 +46,7 @@ export type StateDefinitionsInput = Record = Record> { compiledDefinitions: Map>; stateSort?: (a: string, b: string) => number; + /** @internal Legacy Glyph inputs merge in requested order, after stateSort. */ + stateOrder?: 'input'; mergeMode?: StateMergeMode; } diff --git a/packages/vrender-core/src/graphic/state/state-engine.ts b/packages/vrender-core/src/graphic/state/state-engine.ts index 1046f428e..6434c8054 100644 --- a/packages/vrender-core/src/graphic/state/state-engine.ts +++ b/packages/vrender-core/src/graphic/state/state-engine.ts @@ -37,6 +37,7 @@ function deepMerge(base: Record, value: Record): Recor export class StateEngine = Record> { private readonly compiledDefinitions: Map>; private readonly stateSort?: (a: string, b: string) => number; + private readonly stateOrder?: 'input'; private readonly mergeMode: 'shallow' | 'deep'; private _activeStates: string[] = []; @@ -52,6 +53,7 @@ export class StateEngine = Record> { constructor(options: IStateEngineOptions) { this.compiledDefinitions = options.compiledDefinitions; this.stateSort = options.stateSort; + this.stateOrder = options.stateOrder; this.mergeMode = options.mergeMode ?? 'shallow'; } @@ -195,6 +197,9 @@ export class StateEngine = Record> { } private sortStates(states: string[]): string[] { + if (this.stateOrder === 'input') { + return this.stateSort ? states.sort(this.stateSort) : states; + } const withDefinition: string[] = []; const withoutDefinition: string[] = []; From 0fc68ae6011d88df59f77633161c7453a6d90b56 Mon Sep 17 00:00:00 2001 From: xile611 Date: Tue, 15 Sep 2026 17:40:24 +0800 Subject: [PATCH 2/4] fix: synchronize glyph children across attribute and animation commits --- .../fix-glyph-state-20260915.json | 10 ++ .../state-engine/GLYPH_STATE_CONTRACT.md | 25 +++ .../unit/animation-runtime-attribute.test.ts | 37 ++++ .../unit/graphic/glyph-update.test.ts | 127 ++++++++++++++ .../unit/graphic/state-animation.test.ts | 2 +- packages/vrender-core/src/graphic/glyph.ts | 166 ++++++++++-------- packages/vrender-core/src/graphic/graphic.ts | 35 +++- .../src/interface/graphic/glyph.ts | 14 +- 8 files changed, 343 insertions(+), 73 deletions(-) create mode 100644 common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json create mode 100644 docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md create mode 100644 packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts diff --git a/common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json b/common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json new file mode 100644 index 000000000..2efe59151 --- /dev/null +++ b/common/changes/@visactor/vrender-core/fix-glyph-state-20260915.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender-core", + "comment": "统一 Glyph 状态生命周期,保留旧状态覆盖顺序,补齐派生子图形同步与属性撤销,并修复内部中断状态动画污染基础属性的问题。", + "type": "patch" + } + ], + "packageName": "@visactor/vrender-core" +} diff --git a/docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md b/docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md new file mode 100644 index 000000000..23f758b5f --- /dev/null +++ b/docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md @@ -0,0 +1,25 @@ +# Glyph 状态与派生属性契约 + +Glyph 与普通 Graphic 共用 `baseAttributes + resolvedStatePatch -> attribute`、同状态刷新、状态动画及清空路径。 + +## 定义来源 + +- 配置 `glyphStateProxy` 时,由 proxy 决定完整状态贡献;返回空值不回退 `glyphStates` 或 Group。 +- 无 proxy、配置非空 `glyphStates` 时,读取其 `.attributes`;`subAttributes` 不自动传播。 +- 旧输入按目标状态列表顺序合并,配置 `stateSort` 时先排序,后面的状态覆盖前面的状态;不修改调用方数组。 +- 没有旧输入时,完全使用标准状态定义与 Group-first、priority/rank 规则。旧输入与 Group 不隐式逐状态混合。 +- 动态值变化但状态名不变时,用 `setStates(names, { animate: false })` 刷新;需要动画时同时设置 `animate` 和 `animateSameStatePatchChange`。 + +## 派生图形 + +子图形和编码上下文准备好后调用 `setSubGraphicEncoder(encoder)`。注册时立即同步一次,后续回调读取已提交的 `glyph.attribute`,包括基础更新、状态恢复及动画中间帧。编码器仅修改子图形,不修改宿主属性或宿主状态。 + +`commitSubGraphicAttributes(child, patch, removedKeys, context)` 在一次提交中更新值并删除已经撤销的 own keys,同时维护子图形基础属性、状态、更新标记及继承关系。上层负责输出键归属;删除后可重新读取当前宿主继承值。不要直接删除 `child.attribute` 的键,也不要用写入 `undefined` 代替属性删除。 + +更新顺序为:宿主提交、继承绑定、派生同步、外部通知。`skipUpdateCallback` 跳过观察回调和服务通知,但不跳过派生同步;编码器应将 context 传给子图形提交。`onUpdate` 属于观察回调。 + +clone 保留已编码外观,不复制宿主编码器;独立使用的 clone 应自行注册。release 解除编码器、子图形继承关系并释放子图形。 + +## 动画中断 + +内部切换/取消状态停止旧动画后,由状态系统恢复静态真值,不将旧动画终值提交为基础属性。公开 `animate.stop('start' | 'end' | attrs)` 仍是显式静态提交 API。 diff --git a/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts b/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts index 12fa7853f..8fd5a8739 100644 --- a/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts +++ b/packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts @@ -2,6 +2,7 @@ import { application, AttributeUpdateType, createGroup, + createGlyph, createLine, createRect, createSymbol, @@ -124,6 +125,42 @@ describe('D3 pre-handoff animation runtime', () => { jest.restoreAllMocks(); }); + test('Glyph children follow actual animation frames and interrupted state restoration', () => { + const { group, ticker, graphicService } = createStageHarness('glyph-state-runtime'); + const glyph = createGlyph({ width: 20, fill: 'blue' }); + const child = createRect({ height: 10 }); + bindGraphicService(glyph, graphicService); + bindGraphicService(child, graphicService); + glyph.setSubGraphic([child]); + glyph.setSubGraphicEncoder((g, context) => + g.commitSubGraphicAttributes(child, { width: g.attribute.width }, undefined, context) + ); + group.appendChild(glyph); + glyph.states = { selected: { width: 60 } }; + glyph.stateAnimateConfig = { duration: 100, easing: 'linear' }; + glyph.useStates(['selected'], true); + expect(child.attribute.width).toBe(20); + tick(ticker, 50); + expect(child.attribute.width).toBeCloseTo(40); + expect(glyph.baseAttributes.width).toBe(20); + tick(ticker, 50); + expect(child.attribute.width).toBeCloseTo(60); + glyph.clearStates(true); + tick(ticker, 50); + expect(child.attribute.width).toBeCloseTo(40); + tick(ticker, 50); + expect(child.attribute.width).toBe(20); + glyph.useStates(['selected'], true); + tick(ticker, 25); + expect(child.attribute.width).toBeCloseTo(30); + glyph.clearStates(false); + expect({ host: glyph.attribute.width, base: glyph.baseAttributes.width }).toEqual({ host: 20, base: 20 }); + expect(child.attribute.width).toBe(20); + tick(ticker, 100); + expect(child.attribute.width).toBe(20); + expect(glyph.baseAttributes.width).toBe(20); + }); + test('state animation updates graphic.attribute over time without polluting baseAttributes', () => { const { group, ticker, graphicService } = createStageHarness('state-runtime'); const rect = createAnimatedRect(graphicService); diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts new file mode 100644 index 000000000..2fce9b116 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts @@ -0,0 +1,127 @@ +import { createGlyph } from '../../../src/graphic/glyph'; +import { createRect } from '../../../src/graphic/rect'; +import { UpdateTag } from '../../../src/common/enums'; + +const createFixture = () => { + const glyph = createGlyph({ fill: 'red', width: 20 }); + const child = createRect({ height: 10 }); + const service = { onAttributeUpdate: jest.fn(), onSetStage: jest.fn() }; + [glyph, child].forEach(g => jest.spyOn(g as any, 'getGraphicService').mockReturnValue(service)); + glyph.setSubGraphic([child]); + return { glyph, child, service }; +}; + +describe('Glyph derived attributes', () => { + test('encodes the initial, state, base update and restored values before observers', () => { + const { glyph, child } = createFixture(); + const encoder = jest.fn(g => child.setAttribute('width', g.attribute.width)); + glyph.setSubGraphicEncoder(encoder); + expect(child.attribute.width).toBe(20); + const seen: number[] = []; + glyph.onUpdate(() => seen.push(child.attribute.width)); + glyph.states = { selected: { width: 40 } }; + glyph.setStates(['selected'], false); + glyph.setAttribute('width', 30); + glyph.clearStates(false); + expect(seen).toEqual([40, 40, 30]); + expect(glyph.baseAttributes.width).toBe(30); + }); + + test('silent writes still encode but do not notify observers or services', () => { + const { glyph, child, service } = createFixture(); + glyph.setSubGraphicEncoder((g, context) => { + g.commitSubGraphicAttributes(child, { width: g.attribute.width }, undefined, context); + }); + service.onAttributeUpdate.mockClear(); + const observer = jest.fn(); + glyph.onUpdate(observer); + glyph.addEventListener('afterAttributeUpdate', observer); + child.addEventListener('afterAttributeUpdate', observer); + glyph.setAttributes({ width: 50 }, false, { skipUpdateCallback: true }); + expect(child.attribute.width).toBe(50); + expect(observer).not.toHaveBeenCalled(); + expect(service.onAttributeUpdate).not.toHaveBeenCalled(); + }); + + test('keeps inheritance through host and child state surfaces and detach', () => { + const { glyph, child } = createFixture(); + glyph.states = { hover: { fill: 'blue' } }; + glyph.useStates(['hover'], false); + expect(child.attribute.fill).toBe('blue'); + child.states = { selected: { lineWidth: 4 } }; + child.useStates(['selected'], false); + expect(child.attribute.fill).toBe('blue'); + child.setAttribute('height', 15); + expect(child.attribute.fill).toBe('blue'); + glyph.clearStates(false); + expect(child.attribute.fill).toBe('red'); + child.clearStates(false); + expect(child.attribute.fill).toBe('red'); + glyph.setSubGraphic([]); + expect(child.glyphHost).toBeNull(); + expect(child.attribute.fill).toBeUndefined(); + expect(child.baseAttributes.fill).toBeUndefined(); + }); + + test('removes only owned keys atomically and keeps child state and base truth', () => { + const { glyph, child, service } = createFixture(); + child.setAttributes({ fill: 'orange', lineWidth: 2 }); + child.states = { selected: { fill: 'green' } }; + child.setStates(['selected'], false); + glyph.setAttribute('fill', 'blue'); + service.onAttributeUpdate.mockClear(); + glyph.commitSubGraphicAttributes(child, { width: 9 }, ['fill']); + expect(child.attribute.fill).toBe('green'); + expect(child.attribute.width).toBe(9); + expect(child.attribute.lineWidth).toBe(2); + expect(service.onAttributeUpdate).toHaveBeenCalledTimes(1); + child.clearStates(false); + expect(child.attribute.fill).toBe('blue'); + expect(Object.prototype.hasOwnProperty.call(child.baseAttributes, 'fill')).toBe(false); + }); + + test('inherited paint state changes do not invalidate child geometry', () => { + const { glyph, child } = createFixture(); + glyph.states = { hover: { fill: 'blue', fillOpacity: 0.5 } }; + (glyph as any)._updateTag = 0; + (child as any)._updateTag = 0; + glyph.setStates(['hover'], false); + expect(child.attribute.fill).toBe('blue'); + expect((child as any)._updateTag & UpdateTag.UPDATE_PAINT).not.toBe(0); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + expect((glyph as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + glyph.clearStates(false); + (glyph as any)._updateTag = 0; + (child as any)._updateTag = 0; + glyph.setAttribute('fill', 'purple'); + expect(child.attribute.fill).toBe('purple'); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + expect((glyph as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + }); + + test('clone callbacks are independent and initAttributes resynchronizes children', () => { + const { glyph, child, service } = createFixture(); + const encode = jest.fn((g, context) => { + g.commitSubGraphicAttributes(g.getSubGraphic()[0], { width: g.attribute.width }, undefined, context); + }); + glyph.setSubGraphicEncoder(encode); + const clone = glyph.clone() as typeof glyph; + [clone, ...clone.getSubGraphic()].forEach(g => jest.spyOn(g as any, 'getGraphicService').mockReturnValue(service)); + clone.setAttributes({ x: 10 }, false, { skipUpdateCallback: true }); + expect(encode).toHaveBeenCalledTimes(1); + clone.setSubGraphicEncoder(encode); + clone.initAttributes({ width: 60 }); + expect(clone.getSubGraphic()[0].attribute).toMatchObject({ width: 60 }); + expect(child.attribute.width).toBe(20); + }); + + test('release clears encoder references and detaches children', () => { + const { glyph, child } = createFixture(); + glyph.setSubGraphicEncoder(jest.fn()); + glyph.release(); + expect(glyph.getSubGraphic()).toEqual([]); + expect(child.glyphHost).toBeNull(); + expect(child.releaseStatus).toBe('released'); + expect((glyph as any).subGraphicEncoder).toBeUndefined(); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts index 5997c0a72..6384c08b6 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts @@ -217,7 +217,7 @@ describe('Graphic state animation integration', () => { graphic.useStates(['hover'], false); - expect((graphic as any).stopAnimationState).toHaveBeenCalledWith('state', 'end'); + expect((graphic as any).stopAnimationState).toHaveBeenCalledWith('state', undefined); }); test('should allow partial animation config overrides', () => { diff --git a/packages/vrender-core/src/graphic/glyph.ts b/packages/vrender-core/src/graphic/glyph.ts index d113ec48a..14a30554a 100644 --- a/packages/vrender-core/src/graphic/glyph.ts +++ b/packages/vrender-core/src/graphic/glyph.ts @@ -1,4 +1,4 @@ -import type { AABBBounds, IAABBBounds, IPointLike } from '@visactor/vutils'; +import type { AABBBounds, IAABBBounds } from '@visactor/vutils'; import { Graphic, NOWORK_ANIMATE_ATTR } from './graphic'; import type { GraphicType, @@ -12,6 +12,7 @@ import { StateDefinitionCompiler } from './state/state-definition-compiler'; import type { CompiledStateDefinition, StateDefinition, StateDefinitionsInput } from './state/state-definition'; import type { SharedStateScope } from './state/shared-state-scope'; import { getTheme } from './theme'; +import { UpdateCategory } from './state/attribute-update-classifier'; import { GLYPH_NUMBER_TYPE } from './constants'; export class Glyph extends Graphic implements IGlyph { @@ -33,6 +34,7 @@ export class Glyph extends Graphic implements IGlyph { subAttributes: Partial[]; }; protected declare subGraphic: IGraphic[]; + private subGraphicEncoder?: (g: IGlyph, context?: ISetAttributeContext) => void; private legacyDefinitionsSource?: Glyph['glyphStates']; private legacyProxySource?: Glyph['glyphStateProxy']; private legacyDefinitions?: StateDefinitionsInput; @@ -53,16 +55,17 @@ export class Glyph extends Graphic implements IGlyph { this.subGraphic = subGraphic; subGraphic.forEach(g => { g.glyphHost = this; - Object.setPrototypeOf(g.attribute, this.attribute); + Graphic.bindGlyphAttributes(g as Graphic, this.attribute); }); this.valid = this.isValid(); this.addUpdateBoundTag(); + this.subGraphicEncoder?.(this); } protected detachSubGraphic() { this.subGraphic.forEach(g => { g.glyphHost = null; - Object.setPrototypeOf(g.attribute, {}); + Graphic.bindGlyphAttributes(g as Graphic, Object.prototype); }); } @@ -82,84 +85,94 @@ export class Glyph extends Graphic implements IGlyph { return true; } - setAttribute(key: string, value: any, forceUpdateTag?: boolean, context?: ISetAttributeContext) { - super.setAttribute(key, value, forceUpdateTag, context); - this.subGraphic.forEach(g => { - g.addUpdateShapeAndBoundsTag(); - g.addUpdatePositionTag(); - }); + setSubGraphicEncoder(encoder?: (g: IGlyph, context?: ISetAttributeContext) => void): void { + this.subGraphicEncoder = encoder; + encoder?.(this); } - setAttributes( - params: Partial, - forceUpdateTag: boolean = false, + commitSubGraphicAttributes( + subGraphic: IGraphic, + patch: Record, + removedKeys?: readonly string[], context?: ISetAttributeContext - ) { - super.setAttributes(params, forceUpdateTag, context); - this.subGraphic.forEach(g => { - g.addUpdateShapeAndBoundsTag(); - g.addUpdatePositionTag(); - }); - } - - translate(x: number, y: number) { - super.translate(x, y); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; - } - - translateTo(x: number, y: number) { - super.translateTo(x, y); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + ): void { + Graphic.commitDerivedAttributePatch(subGraphic as Graphic, patch, removedKeys, context); } - scale(scaleX: number, scaleY: number, scaleCenter?: IPointLike) { - super.scale(scaleX, scaleY, scaleCenter); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + onAttributeUpdate(context?: ISetAttributeContext): void { + if (this.glyphHost) { + Graphic.bindGlyphAttributes(this, this.glyphHost.attribute); + } + for (const child of this.subGraphic) { + Graphic.bindGlyphAttributes(child as Graphic, this.attribute); + } + this.subGraphicEncoder?.(this, context); + if (!context?.skipUpdateCallback) { + this._onUpdate?.(this); + } + super.onAttributeUpdate(context); } - scaleTo(scaleX: number, scaleY: number) { - super.scaleTo(scaleX, scaleY); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + protected submitUpdateByCategory(category: UpdateCategory, forceUpdateTag: boolean = false): void { + super.submitUpdateByCategory(category, forceUpdateTag); + for (const child of this.subGraphic) { + if (forceUpdateTag || category & UpdateCategory.SHAPE) { + child.addUpdateShapeAndBoundsTag(); + } else if (category & UpdateCategory.BOUNDS) { + child.addUpdateBoundTag(); + } + if (category & UpdateCategory.PAINT) { + child.addUpdatePaintTag(); + } + if (forceUpdateTag || category & UpdateCategory.TRANSFORM) { + child.addUpdatePositionTag(); + } + if (forceUpdateTag || category & UpdateCategory.LAYOUT) { + child.addUpdateLayoutTag(); + } + } } - rotate(angle: number) { - super.rotate(angle); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + // Glyph forwards inherited invalidation to children, so its base fast path must + // classify changed keys too. Ordinary Graphic setters keep their existing path. + protected commitBaseAttributesByTouchedKeys( + params: Partial, + forceUpdateTag: boolean = false, + context?: ISetAttributeContext + ): void { + const base = this.getBaseAttributesStorage(); + let category = UpdateCategory.NONE; + let hasKeys = false; + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) { + continue; + } + hasKeys = true; + const prev = (base as any)[key]; + const next = (params as any)[key]; + if (prev !== next) { + category = this.mergeAttributeDeltaCategory(category, key, prev, next); + } + (base as any)[key] = next; + } + if (!hasKeys) { + return; + } + this.attribute = base; + this._baseAttributes = undefined; + this.attributeMayContainTransientAttrs = false; + this.valid = this.isValid(); + this.submitUpdateByCategory(category, forceUpdateTag); + this.onAttributeUpdate(context); } - rotateTo(angle: number) { - super.rotate(angle); - - this.subGraphic.forEach(g => { - g.addUpdatePositionTag(); - g.addUpdateBoundTag(); - }); - return this; + protected commitBaseAttributeBySingleKey( + key: string, + value: any, + forceUpdateTag: boolean = false, + context?: ISetAttributeContext + ): void { + this.commitBaseAttributesByTouchedKeys({ [key]: value }, forceUpdateTag, context); } getGraphicTheme(): Required { @@ -273,6 +286,19 @@ export class Glyph extends Graphic implements IGlyph { return glyph; } + release(): void { + super.release(); + this.subGraphicEncoder = undefined; + this._onUpdate = undefined; + this.legacyDefinitions = undefined; + this.legacyCompiledDefinitions = undefined; + this.legacyDefinitionsSource = undefined; + this.legacyProxySource = undefined; + this.detachSubGraphic(); + this.subGraphic.forEach(child => child.release()); + this.subGraphic = []; + } + getNoWorkAnimateAttr(): Record { return Glyph.NOWORK_ANIMATE_ATTR; } diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index 67eb93c13..9fb126967 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -2022,7 +2022,38 @@ export abstract class Graphic = Partial, + removedKeys?: readonly string[], + context?: ISetAttributeContext + ): void { + if (!removedKeys?.length) { + graphic.setAttributes(patch, false, context); + return; + } + graphic.detachAttributeFromBaseAttributes(); + const base = graphic.getBaseAttributesStorage() as Record; + removedKeys.forEach(key => delete base[key]); + graphic.applyBaseAttributes(patch); + graphic.commitBaseAttributeMutation(false, context); + } + onAttributeUpdate(context?: ISetAttributeContext) { + if (this.glyphHost) { + Graphic.bindGlyphAttributes(this, this.glyphHost.attribute); + } if (context && context.skipUpdateCallback) { return; } @@ -2440,7 +2471,9 @@ export abstract class Graphic = Partial = Partial void) => void; + /** Observes committed attributes after derived children are synchronized. Honors skipUpdateCallback. */ onUpdate: (cb: (g: this) => void) => void; + + /** Bind after children/context are ready. Runs once immediately and after each host attribute commit. */ + setSubGraphicEncoder: (encoder?: (g: IGlyph, context?: ISetAttributeContext) => void) => void; + + /** Atomically apply derived values and remove owned keys, preserving child state/base truth. */ + commitSubGraphicAttributes: ( + subGraphic: IGraphic, + patch: Record, + removedKeys?: readonly string[], + context?: ISetAttributeContext + ) => void; } From 0965416c1450a60997ed97fc4aa78b46b086f27a Mon Sep 17 00:00:00 2001 From: xile611 Date: Tue, 15 Sep 2026 17:53:49 +0800 Subject: [PATCH 3/4] fix: preserve paint-only invalidation for glyph-derived attributes --- .../unit/graphic/glyph-state.test.ts | 14 ++++++++ .../unit/graphic/glyph-update.test.ts | 11 ++++++ packages/vrender-core/src/graphic/glyph.ts | 25 +------------ packages/vrender-core/src/graphic/graphic.ts | 36 +++++++++++++++++++ 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts index 3687dbada..cbdd6b827 100644 --- a/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts @@ -1,3 +1,4 @@ +import { StateDefinitionCompiler } from '../../../src/graphic/state/state-definition-compiler'; import { createGlyph } from '../../../src/graphic/glyph'; import { createRect } from '../../../src/graphic/rect'; import { createGroup } from '../../../src/graphic/group'; @@ -213,4 +214,17 @@ describe('Glyph state', () => { glyph.clearStates(false); expect(glyph.registeredActiveScopes).toBeUndefined(); }); + test('repeated legacy state switches reuse compiled definitions', () => { + const { glyph } = createTestGlyph(); + glyph.glyphStateProxy = name => ({ attributes: { fill: name === 'hover' ? 'red' : 'blue' }, subAttributes: [] }); + glyph.useStates(['hover', 'selected'], false); + const compile = jest.spyOn(StateDefinitionCompiler.prototype, 'compile'); + for (let i = 0; i < 20; i++) { + glyph.useStates(['selected', 'hover'], false); + glyph.useStates(['hover', 'selected'], false); + glyph.clearStates(false); + } + expect(compile).not.toHaveBeenCalled(); + compile.mockRestore(); + }); }); diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts index 2fce9b116..4348b9e5e 100644 --- a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts @@ -99,6 +99,17 @@ describe('Glyph derived attributes', () => { expect((glyph as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); }); + test('derived paint patches do not invalidate child geometry', () => { + const { glyph, child } = createFixture(); + (child as any)._updateTag = 0; + glyph.commitSubGraphicAttributes(child, { fill: 'gray', fillOpacity: 0.5 }); + expect(child.attribute.fill).toBe('gray'); + expect((child as any)._updateTag & UpdateTag.UPDATE_PAINT).not.toBe(0); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).toBe(0); + glyph.commitSubGraphicAttributes(child, { width: 30 }); + expect((child as any)._updateTag & UpdateTag.UPDATE_SHAPE_AND_BOUNDS).not.toBe(0); + }); + test('clone callbacks are independent and initAttributes resynchronizes children', () => { const { glyph, child, service } = createFixture(); const encode = jest.fn((g, context) => { diff --git a/packages/vrender-core/src/graphic/glyph.ts b/packages/vrender-core/src/graphic/glyph.ts index 14a30554a..e009cc6fb 100644 --- a/packages/vrender-core/src/graphic/glyph.ts +++ b/packages/vrender-core/src/graphic/glyph.ts @@ -140,30 +140,7 @@ export class Glyph extends Graphic implements IGlyph { forceUpdateTag: boolean = false, context?: ISetAttributeContext ): void { - const base = this.getBaseAttributesStorage(); - let category = UpdateCategory.NONE; - let hasKeys = false; - for (const key in params) { - if (!Object.prototype.hasOwnProperty.call(params, key)) { - continue; - } - hasKeys = true; - const prev = (base as any)[key]; - const next = (params as any)[key]; - if (prev !== next) { - category = this.mergeAttributeDeltaCategory(category, key, prev, next); - } - (base as any)[key] = next; - } - if (!hasKeys) { - return; - } - this.attribute = base; - this._baseAttributes = undefined; - this.attributeMayContainTransientAttrs = false; - this.valid = this.isValid(); - this.submitUpdateByCategory(category, forceUpdateTag); - this.onAttributeUpdate(context); + this.commitBaseAttributesByCategory(params, forceUpdateTag, context); } protected commitBaseAttributeBySingleKey( diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index 9fb126967..ff7d86fd2 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -978,11 +978,47 @@ export abstract class Graphic = Partial, + forceUpdateTag: boolean = false, + context?: ISetAttributeContext + ): void { + const base = this.getBaseAttributesStorage(); + let category = UpdateCategory.NONE; + let hasKeys = false; + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) { + continue; + } + hasKeys = true; + const prev = (base as any)[key]; + const next = (params as any)[key]; + if (prev !== next) { + category = this.mergeAttributeDeltaCategory(category, key, prev, next); + } + (base as any)[key] = next; + } + if (!hasKeys) { + return; + } + this.attribute = base as T; + this._baseAttributes = undefined; + this.attributeMayContainTransientAttrs = false; + this.valid = this.isValid(); + this.submitUpdateByCategory(category, forceUpdateTag); + this.onAttributeUpdate(context); + } + protected commitBaseAttributesByTouchedKeys( params: Partial, forceUpdateTag: boolean = false, context?: ISetAttributeContext ): void { + if (this.glyphHost) { + this.commitBaseAttributesByCategory(params, forceUpdateTag, context); + return; + } const source = params as Record; const baseAttributes = this.getBaseAttributesStorage() as Record; let hasKeys = false; From de63027fc99b1034693d6d6eadcb9e25a2d56f05 Mon Sep 17 00:00:00 2001 From: xile611 Date: Wed, 16 Sep 2026 17:18:10 +0800 Subject: [PATCH 4/4] fix: invalidate glyph geometry and offset caches --- .../unit/graphic/glyph-update.test.ts | 133 ++++++++++++++++++ .../graphic/state-update-category.test.ts | 18 +++ packages/vrender-core/src/graphic/glyph.ts | 15 +- packages/vrender-core/src/graphic/graphic.ts | 5 + .../state/attribute-update-classifier.ts | 2 + 5 files changed, 172 insertions(+), 1 deletion(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts index 4348b9e5e..cc19ea773 100644 --- a/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/glyph-update.test.ts @@ -1,6 +1,10 @@ import { createGlyph } from '../../../src/graphic/glyph'; import { createRect } from '../../../src/graphic/rect'; import { UpdateTag } from '../../../src/common/enums'; +import { application } from '../../../src/application'; +import { DefaultGraphicService } from '../../../src/graphic/graphic-service/graphic-service'; +import { createPath } from '../../../src/graphic/path'; +import { createCircle } from '../../../src/graphic/circle'; const createFixture = () => { const glyph = createGlyph({ fill: 'red', width: 20 }); @@ -136,3 +140,132 @@ describe('Glyph derived attributes', () => { expect((glyph as any).subGraphicEncoder).toBeUndefined(); }); }); + +describe('Glyph cached geometry', () => { + let previousService: typeof application.graphicService; + + beforeEach(() => { + previousService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + }); + + afterEach(() => { + application.graphicService = previousService; + }); + + describe.each(['attributes', 'derived', 'host'])('%s offset updates', writer => { + test.each(['dx', 'dy'])('refreshes cached matrices and bounds for %s', key => { + const glyph = createGlyph({}); + const child = createRect({ width: 10, height: 10 }); + glyph.setSubGraphic([child]); + const readPosition = () => ({ + matrix: key === 'dx' ? child.transMatrix.e : child.transMatrix.f, + child: key === 'dx' ? child.AABBBounds.x1 : child.AABBBounds.y1, + glyph: key === 'dx' ? glyph.AABBBounds.x1 : glyph.AABBBounds.y1 + }); + expect(readPosition()).toEqual({ matrix: 0, child: 0, glyph: 0 }); + + if (writer === 'derived') { + glyph.commitSubGraphicAttributes(child, { [key]: 20 }); + } else if (writer === 'host') { + glyph.setAttributes({ [key]: 20 }); + } else { + child.setAttributes({ [key]: 20 }); + } + + expect(child.attribute[key]).toBe(20); + expect(readPosition()).toEqual({ matrix: 20, child: 20, glyph: 20 }); + }); + }); + + describe.each(['single', 'batch', 'state'])('%s inherited geometry updates', writer => { + [ + { + key: 'path', + initial: 'M0 0H10V10H0Z', + next: 'M0 0H30V10H0Z', + initialWidth: 10, + nextWidth: 30, + createChild: () => createPath({}) + }, + { + key: 'radius', + initial: 10, + next: 30, + initialWidth: 20, + nextWidth: 60, + createChild: () => createCircle({}) + } + ].forEach(({ key, initial, next, initialWidth, nextWidth, createChild }) => { + test(`refreshes child and host geometry for ${key}`, () => { + const glyph = createGlyph({ [key]: initial }); + const child = createChild(); + glyph.setSubGraphic([child]); + // Revalidate after inheritance is bound, including Path's required path attribute. + child.setAttribute('fill', 'red'); + const expectWidths = (width: number) => { + expect(child.AABBBounds.width()).toBe(width); + expect(glyph.AABBBounds.width()).toBe(width); + if ('getParsedPathShape' in child) { + expect(child.getParsedPathShape().getBounds().width()).toBe(width); + } + }; + expectWidths(initialWidth); + + if (writer === 'single') { + glyph.setAttribute(key, next); + } else if (writer === 'batch') { + glyph.setAttributes({ [key]: next }); + } else { + glyph.states = { expanded: { [key]: next } }; + glyph.setStates(['expanded'], false); + } + + expect(child.attribute[key]).toBe(next); + expectWidths(nextWidth); + if (writer === 'state') { + expect(glyph.baseAttributes[key]).toBe(initial); + glyph.clearStates(false); + expect(child.attribute[key]).toBe(initial); + expectWidths(initialWidth); + } + }); + }); + }); + + test.each(['host', 'derived', 'state'])('%s paint updates preserve warmed geometry caches', writer => { + const glyph = createGlyph({ fill: 'red', fillOpacity: 1 }); + const child = createRect({ width: 10, height: 10 }); + glyph.setSubGraphic([child]); + const graphics = [glyph, child]; + const readGeometry = () => + graphics.map(graphic => ({ + width: graphic.AABBBounds.width(), + x: graphic.transMatrix.e, + y: graphic.transMatrix.f, + boundsUpdates: (graphic as any).updateAABBBoundsStamp + })); + const initialGeometry = readGeometry(); + graphics.forEach(graphic => ((graphic as any)._updateTag = UpdateTag.NONE)); + const paint = { fill: 'blue', fillOpacity: 0.5 }; + + if (writer === 'host') { + glyph.setAttributes(paint); + } else if (writer === 'derived') { + glyph.commitSubGraphicAttributes(child, paint); + } else { + glyph.states = { hover: paint }; + glyph.setStates(['hover'], false); + } + + expect(child.attribute.fill).toBe('blue'); + expect(child.attribute.fillOpacity).toBe(0.5); + expect((child as any)._updateTag & UpdateTag.UPDATE_PAINT).not.toBe(0); + graphics.forEach(graphic => { + expect( + (graphic as any)._updateTag & (UpdateTag.UPDATE_SHAPE_AND_BOUNDS | UpdateTag.UPDATE_GLOBAL_LOCAL_MATRIX) + ).toBe(0); + }); + expect(readGeometry()).toEqual(initialGeometry); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts index 8950ed0e5..52434be94 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-update-category.test.ts @@ -44,6 +44,24 @@ describe('Graphic state update categories', () => { expect(((graphic as any)._updateTag & UpdateTag.UPDATE_PAINT) === UpdateTag.UPDATE_PAINT).toBe(true); }); + test.each(['dx', 'dy'])('should refresh the cached %s transform on state entry and clear', key => { + const graphic = createGraphic(); + const base = { ...graphic.baseAttributes }; + const readOffset = () => (key === 'dx' ? graphic.transMatrix.e : graphic.transMatrix.f); + expect(readOffset()).toBe(0); + graphic.states = { shifted: { [key]: 20 } }; + + graphic.setStates(['shifted'], false); + expect(graphic.attribute[key]).toBe(20); + expect(readOffset()).toBe(20); + expect((graphic as any)._updateTag & UpdateTag.UPDATE_BOUNDS).not.toBe(0); + expect(graphic.baseAttributes).toEqual(base); + + graphic.clearStates(false); + expect(readOffset()).toBe(0); + expect(graphic.baseAttributes).toEqual(base); + }); + test('should dirty cached global bounds for paint-only updates without upgrading to bounds', () => { const graphic = createGraphic(); const graphicServiceHooks = { diff --git a/packages/vrender-core/src/graphic/glyph.ts b/packages/vrender-core/src/graphic/glyph.ts index e009cc6fb..a945f7536 100644 --- a/packages/vrender-core/src/graphic/glyph.ts +++ b/packages/vrender-core/src/graphic/glyph.ts @@ -12,7 +12,7 @@ import { StateDefinitionCompiler } from './state/state-definition-compiler'; import type { CompiledStateDefinition, StateDefinition, StateDefinitionsInput } from './state/state-definition'; import type { SharedStateScope } from './state/shared-state-scope'; import { getTheme } from './theme'; -import { UpdateCategory } from './state/attribute-update-classifier'; +import { ATTRIBUTE_CATEGORY, UpdateCategory } from './state/attribute-update-classifier'; import { GLYPH_NUMBER_TYPE } from './constants'; export class Glyph extends Graphic implements IGlyph { @@ -180,9 +180,22 @@ export class Glyph extends Graphic implements IGlyph { } protected needUpdateTags(keys: string[]): boolean { + for (const key of keys) { + if (this.needUpdateTag(key)) { + return true; + } + } return false; } protected needUpdateTag(key: string): boolean { + if (ATTRIBUTE_CATEGORY[key] === UpdateCategory.PAINT) { + return false; + } + for (const child of this.subGraphic) { + if (Graphic.needsShapeUpdate(child as Graphic, key)) { + return true; + } + } return false; } diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index ff7d86fd2..53679d98c 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -1857,6 +1857,11 @@ export abstract class Graphic = Partial = { shadowColor: UpdateCategory.PAINT, x: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, y: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, + dx: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, + dy: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, scaleX: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, scaleY: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS, angle: UpdateCategory.TRANSFORM | UpdateCategory.BOUNDS,