Skip to content

Commit f9ebf2b

Browse files
committed
fix(animate): keep interrupted state animations on latest patch
State changes can arrive before the previous state animation ends. Old state animation could still write or restore once more. Removed state attrs then moved in the wrong direction before takeover. The state animation target now includes attrs removed from the previous patch. Synthetic state-to-state transitions stop the old executor without restore. The new transition starts from the current visual frame. Constraint: Animation frames must stay transient under D3 static truth. Rejected: Commit state animation frames to baseAttributes | breaks state ownership. Rejected: Let stale state animations continue | leaves one stale write frame. Confidence: high Scope-risk: moderate Directive: Keep the interrupted state-stack regression before changing this path. Tested: vrender-animate full unit suite Tested: vrender-core state and attribute targeted suites Tested: rush compile -t @visactor/vrender-animate Tested: rush build -t @visactor/vrender and CJS state-sequence smoke Tested: rush test --only tag:package
1 parent 08d596d commit f9ebf2b

5 files changed

Lines changed: 175 additions & 4 deletions

File tree

packages/vrender-animate/__tests__/unit/animation-runtime-attribute.test.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,134 @@ describe('D3 pre-handoff animation runtime', () => {
121121
expect((rect as any).baseAttributes.opacity).toBe(1);
122122
});
123123

124+
test('state animation ends at the resolved state style without committing it to baseAttributes', () => {
125+
const { group, ticker, graphicService } = createStageHarness('state-runtime-end-style');
126+
const rect = createAnimatedRect(graphicService);
127+
rect.setAttribute('lineWidth', 1);
128+
rect.setFinalAttributes({ ...rect.attribute });
129+
group.appendChild(rect);
130+
131+
rect.states = {
132+
a: {
133+
opacity: 0.35,
134+
lineWidth: 6
135+
}
136+
} as any;
137+
rect.stateAnimateConfig = {
138+
duration: 100,
139+
easing: 'linear'
140+
} as any;
141+
142+
rect.useStates(['a'], true);
143+
144+
expect(rect.currentStates).toEqual(['a']);
145+
expect(rect.resolvedStatePatch).toEqual({
146+
opacity: 0.35,
147+
lineWidth: 6
148+
});
149+
expect(rect.attribute.opacity).toBe(1);
150+
expect(rect.attribute.lineWidth).toBe(1);
151+
expect((rect as any).baseAttributes.opacity).toBe(1);
152+
expect((rect as any).baseAttributes.lineWidth).toBe(1);
153+
expect(rect.getFinalAttribute().opacity).toBe(0.35);
154+
expect(rect.getFinalAttribute().lineWidth).toBe(6);
155+
156+
tick(ticker, 50);
157+
expect(rect.attribute.opacity).toBeCloseTo(0.675, 5);
158+
expect(rect.attribute.lineWidth).toBeCloseTo(3.5, 5);
159+
expect((rect as any).baseAttributes.opacity).toBe(1);
160+
expect((rect as any).baseAttributes.lineWidth).toBe(1);
161+
162+
tick(ticker, 50);
163+
expect(rect.attribute.opacity).toBeCloseTo(0.35, 5);
164+
expect(rect.attribute.lineWidth).toBeCloseTo(6, 5);
165+
expect(rect.getFinalAttribute().opacity).toBeCloseTo(0.35, 5);
166+
expect(rect.getFinalAttribute().lineWidth).toBeCloseTo(6, 5);
167+
expect((rect as any).baseAttributes.opacity).toBe(1);
168+
expect((rect as any).baseAttributes.lineWidth).toBe(1);
169+
expect(rect.currentStates).toEqual(['a']);
170+
expect(rect.resolvedStatePatch).toEqual({
171+
opacity: 0.35,
172+
lineWidth: 6
173+
});
174+
});
175+
176+
test('interrupted state animations resolve [] to [a] to [a,b] to [a] to [] without stale state styles', () => {
177+
const { group, ticker, graphicService } = createStageHarness('state-runtime-interrupted-stack');
178+
const rect = createAnimatedRect(graphicService);
179+
rect.setAttribute('lineWidth', 1);
180+
rect.setFinalAttributes({ ...rect.attribute });
181+
group.appendChild(rect);
182+
183+
rect.states = {
184+
a: {
185+
opacity: 0.2
186+
},
187+
b: {
188+
lineWidth: 5
189+
}
190+
} as any;
191+
rect.stateAnimateConfig = {
192+
duration: 100,
193+
easing: 'linear'
194+
} as any;
195+
196+
rect.useStates(['a'], true);
197+
tick(ticker, 25);
198+
expect(rect.attribute.opacity).toBeCloseTo(0.8, 5);
199+
expect(rect.attribute.lineWidth).toBe(1);
200+
expect(rect.currentStates).toEqual(['a']);
201+
expect(rect.resolvedStatePatch).toEqual({
202+
opacity: 0.2
203+
});
204+
205+
rect.useStates(['a', 'b'], true);
206+
tick(ticker, 25);
207+
expect(rect.attribute.opacity).toBeGreaterThan(0.2);
208+
expect(rect.attribute.opacity).toBeLessThan(0.8);
209+
expect(rect.attribute.lineWidth).toBeGreaterThan(1);
210+
expect(rect.attribute.lineWidth).toBeLessThan(5);
211+
expect(rect.currentStates).toEqual(['a', 'b']);
212+
expect(rect.resolvedStatePatch).toEqual({
213+
opacity: 0.2,
214+
lineWidth: 5
215+
});
216+
const lineWidthWithB = rect.attribute.lineWidth;
217+
218+
rect.useStates(['a'], true);
219+
tick(ticker, 25);
220+
expect(rect.attribute.opacity).toBeGreaterThan(0.2);
221+
expect(rect.attribute.opacity).toBeLessThan(0.8);
222+
expect(rect.attribute.lineWidth).toBeGreaterThan(1);
223+
expect(rect.attribute.lineWidth).toBeLessThan(lineWidthWithB);
224+
expect(rect.getFinalAttribute().opacity).toBe(0.2);
225+
expect(rect.getFinalAttribute().lineWidth).toBe(1);
226+
expect(rect.currentStates).toEqual(['a']);
227+
expect(rect.resolvedStatePatch).toEqual({
228+
opacity: 0.2
229+
});
230+
const lineWidthAfterRemovingB = rect.attribute.lineWidth;
231+
232+
rect.useStates([], true);
233+
tick(ticker, 25);
234+
expect(rect.attribute.opacity).toBeGreaterThan(0.2);
235+
expect(rect.attribute.opacity).toBeLessThan(1);
236+
expect(rect.attribute.lineWidth).toBeGreaterThan(1);
237+
expect(rect.attribute.lineWidth).toBeLessThan(lineWidthAfterRemovingB);
238+
expect(rect.currentStates).toEqual([]);
239+
expect(rect.resolvedStatePatch).toBeUndefined();
240+
241+
tick(ticker, 100);
242+
expect(rect.attribute.opacity).toBe(1);
243+
expect(rect.attribute.lineWidth).toBe(1);
244+
expect(rect.getFinalAttribute().opacity).toBe(1);
245+
expect(rect.getFinalAttribute().lineWidth).toBe(1);
246+
expect((rect as any).baseAttributes.opacity).toBe(1);
247+
expect((rect as any).baseAttributes.lineWidth).toBe(1);
248+
expect(rect.currentStates).toEqual([]);
249+
expect(rect.resolvedStatePatch).toBeUndefined();
250+
});
251+
124252
test('animate.to restores static truth after completion and keeps baseAttributes untouched', () => {
125253
const { group, ticker, graphicService } = createStageHarness('self-to');
126254
const rect = createAnimatedRect(graphicService);
@@ -399,7 +527,7 @@ describe('D3 pre-handoff animation runtime', () => {
399527

400528
rect.useStates(['selected'], true);
401529
tick(ticker, 50);
402-
expect(rect.attribute.opacity).toBeCloseTo(0.8, 5);
530+
expect(rect.attribute.opacity).toBeCloseTo(0.7, 5);
403531

404532
tick(ticker, 50);
405533
expect(rect.attribute.opacity).toBeCloseTo(0.8, 5);

packages/vrender-animate/src/animate.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,10 @@ export class Animate implements IAnimate {
137137
}
138138
this.onRemove(() => {
139139
this.stop();
140-
if (typeof trackerTarget.restoreStaticAttribute === 'function') {
140+
if (
141+
!(this as any).__skipRestoreStaticAttributeOnRemove &&
142+
typeof trackerTarget.restoreStaticAttribute === 'function'
143+
) {
141144
trackerTarget.restoreStaticAttribute();
142145
}
143146
if (typeof trackerTarget.untrackAnimate === 'function') {

packages/vrender-animate/src/executor/animate-executor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,10 @@ export class AnimateExecutor implements IAnimateExecutor {
785785
while (this._animates.length > 0) {
786786
const animate = this._animates.pop();
787787
// 不执行回调时 标记动画为结束状态
788-
callEnd === false && (animate.status = AnimateStatus.END);
788+
if (callEnd === false) {
789+
animate.status = AnimateStatus.END;
790+
(animate as any).__skipRestoreStaticAttributeOnRemove = true;
791+
}
789792
animate?.stop(type);
790793
}
791794

packages/vrender-animate/src/state/animation-states-registry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ export class AnimationTransitionRegistry {
147147
allowTransition: true,
148148
stopOriginalTransition: false
149149
}));
150+
// graphic state changes reuse the synthetic "state" animation state. A newer
151+
// resolved state patch must take over from the current visual frame instead
152+
// of letting the stale patch write one more frame on the next tick.
153+
this.registerTransition('state', 'state', () => ({
154+
allowTransition: true,
155+
stopOriginalTransition: true
156+
}));
150157
// state动画碰到disappear动画,会停止,也会被覆盖
151158
this.registerTransition('state', 'disappear', () => ({
152159
allowTransition: true,

packages/vrender-core/src/graphic/graphic.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,29 @@ export abstract class Graphic<T extends Partial<IGraphicAttribute> = Partial<IGr
765765
return snapshot;
766766
}
767767

768+
protected buildStateAnimationTargetAttrs(
769+
resolvedStateAttrs: Partial<T>,
770+
previousResolvedStatePatch?: Partial<T>
771+
): Partial<T> {
772+
const targetAttrs = cloneAttributeValue(resolvedStateAttrs) as Record<string, any>;
773+
774+
if (!previousResolvedStatePatch) {
775+
return targetAttrs as Partial<T>;
776+
}
777+
778+
const snapshot = this.buildStaticAttributeSnapshot() as Record<string, any>;
779+
Object.keys(previousResolvedStatePatch).forEach(key => {
780+
if (Object.prototype.hasOwnProperty.call(targetAttrs, key)) {
781+
return;
782+
}
783+
targetAttrs[key] = Object.prototype.hasOwnProperty.call(snapshot, key)
784+
? cloneAttributeValue(snapshot[key])
785+
: this.getDefaultAttribute(key);
786+
});
787+
788+
return targetAttrs as Partial<T>;
789+
}
790+
768791
protected syncObjectToSnapshot(target: Record<string, any>, snapshot: Record<string, any>): AttributeDelta {
769792
const delta: AttributeDelta = new Map();
770793
const keySet = new Set<string>([...Object.keys(target), ...Object.keys(snapshot)]);
@@ -1742,6 +1765,9 @@ export abstract class Graphic<T extends Partial<IGraphicAttribute> = Partial<IGr
17421765
}
17431766

17441767
const previousStates = this.currentStates ? this.currentStates.slice() : [];
1768+
const previousResolvedStatePatch = this.resolvedStatePatch
1769+
? cloneAttributeValue(this.resolvedStatePatch)
1770+
: undefined;
17451771
const stateResolveBaseAttrs = (this.baseAttributes ?? this.attribute) as Partial<T>;
17461772
const stateModel = this.createStateModel();
17471773
this.stateEngine?.setResolveContext(this, stateResolveBaseAttrs);
@@ -1778,7 +1804,11 @@ export abstract class Graphic<T extends Partial<IGraphicAttribute> = Partial<IGr
17781804
});
17791805
if (hasAnimation) {
17801806
this._syncFinalAttributeFromStaticTruth();
1781-
this.applyStateAttrs(resolvedStateAttrs, transition.states, hasAnimation);
1807+
this.applyStateAttrs(
1808+
this.buildStateAnimationTargetAttrs(resolvedStateAttrs, previousResolvedStatePatch),
1809+
transition.states,
1810+
hasAnimation
1811+
);
17821812
} else {
17831813
this.stopStateAnimates();
17841814
this._restoreAttributeFromStaticTruth({ type: AttributeUpdateType.STATE });

0 commit comments

Comments
 (0)