From ecb63dd1b5eeeb933887d9422d9b9ebe60b878b4 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 28 Jan 2026 21:12:39 +0100 Subject: [PATCH 1/2] Fix ScrollAnimation completing to nearest boundary on destroy Complete animation to 0 or 1 when component is destroyed mid-animation, preventing elements from being left in incomplete states during fast scrolling. Fix: #444 Co-authored-by: Claude --- .../AbstractScrollAnimation.spec.ts | 75 +++++++++++++++++++ .../AbstractScrollAnimation.ts | 27 ++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/tests/ScrollAnimation/AbstractScrollAnimation.spec.ts b/packages/tests/ScrollAnimation/AbstractScrollAnimation.spec.ts index 11006b7b..f8ac1b51 100644 --- a/packages/tests/ScrollAnimation/AbstractScrollAnimation.spec.ts +++ b/packages/tests/ScrollAnimation/AbstractScrollAnimation.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AbstractScrollAnimation } from '@studiometa/ui'; +import { nextTick } from '@studiometa/js-toolkit/utils'; import { h, mount, destroy } from '#test-utils'; class TestScrollAnimation extends AbstractScrollAnimation { @@ -126,4 +127,78 @@ describe('AbstractScrollAnimation', () => { animation.render(0.75); expect(progressSpy).toHaveBeenCalledWith(0.75); }); + + it('should track progress in render method', () => { + const progressSpy = vi.fn(); + const mockAnimation = { progress: progressSpy }; + + Object.defineProperty(animation, 'animation', { + value: mockAnimation, + configurable: true, + }); + + expect(animation.progress).toBe(0); + animation.render(0.75); + expect(animation.progress).toBe(0.75); + }); + + it('should restore animation state on mount', async () => { + const testElement = h('div', { + dataOptionFrom: JSON.stringify({ opacity: 0 }), + dataOptionTo: JSON.stringify({ opacity: 1 }), + }); + const testAnimation = new TestScrollAnimation(testElement); + await mount(testAnimation); + + // Simulate partial progress closer to end + testAnimation.render(0.6); + expect(testAnimation.progress).toBe(0.6); + + // Destroy rounds progress to nearest boundary (1) + await destroy(testAnimation); + await nextTick(); + expect(testAnimation.progress).toBe(1); + + // Remount restores the completed progress + await mount(testAnimation); + expect(testAnimation.progress).toBe(1); + }); + + it('should complete animation to nearest boundary on destroy', async () => { + const testElement = h('div', { + dataOptionFrom: JSON.stringify({ opacity: 0 }), + dataOptionTo: JSON.stringify({ opacity: 1 }), + }); + const testAnimation = new TestScrollAnimation(testElement); + await mount(testAnimation); + + // Simulate partial progress closer to 1 + testAnimation.render(0.8); + expect(testAnimation.progress).toBe(0.8); + + // Destroy should trigger completion to nearest boundary (1) + await destroy(testAnimation); + await nextTick(); + + expect(testAnimation.progress).toBe(1); + }); + + it('should complete animation to 0 when progress is closer to start', async () => { + const testElement = h('div', { + dataOptionFrom: JSON.stringify({ opacity: 0 }), + dataOptionTo: JSON.stringify({ opacity: 1 }), + }); + const testAnimation = new TestScrollAnimation(testElement); + await mount(testAnimation); + + // Simulate partial progress closer to 0 + testAnimation.render(0.3); + expect(testAnimation.progress).toBe(0.3); + + // Destroy should trigger completion to nearest boundary (0) + await destroy(testAnimation); + await nextTick(); + + expect(testAnimation.progress).toBe(0); + }); }); diff --git a/packages/ui/ScrollAnimation/AbstractScrollAnimation.ts b/packages/ui/ScrollAnimation/AbstractScrollAnimation.ts index c1a4bba7..2b6e76e0 100644 --- a/packages/ui/ScrollAnimation/AbstractScrollAnimation.ts +++ b/packages/ui/ScrollAnimation/AbstractScrollAnimation.ts @@ -1,6 +1,6 @@ import { Base, withFreezedOptions } from '@studiometa/js-toolkit'; import type { BaseProps, BaseConfig, ScrollInViewProps } from '@studiometa/js-toolkit'; -import { map, clamp01, animate } from '@studiometa/js-toolkit/utils'; +import { map, clamp01, animate, nextTick } from '@studiometa/js-toolkit/utils'; import type { Keyframe } from '@studiometa/js-toolkit/utils'; export interface AbstractScrollAnimationProps extends BaseProps { @@ -47,6 +47,30 @@ export class AbstractScrollAnimation< }, }; + /** + * Current animation progress (0 to 1). + */ + progress = 0; + + /** + * Constructor. + */ + constructor(element: HTMLElement) { + super(element); + + // Restore animation state on mount + this.$on('mounted', () => { + this.render(this.progress); + }); + + // Complete animation to nearest boundary on destroy + this.$on('destroyed', () => { + nextTick(() => { + this.render(Math.round(this.progress)); + }); + }); + } + /** * Get the target element for the animation. */ @@ -103,6 +127,7 @@ export class AbstractScrollAnimation< * Render the animation for the given progress. */ render(progress: number) { + this.progress = progress; this.animation.progress(progress); } } From 70aa09da921368ee3e7a40cbd90c5063988da506 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 28 Jan 2026 21:13:39 +0100 Subject: [PATCH 2/2] Update changelog Co-authored-by: Claude --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5464cf4f..8cf11569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **ScrollAnimation:** add a `withScrollAnimationDebug` decorator ([#494](https://github.com/studiometa/ui/pull/494)) +### Fixed + +- **ScrollAnimation:** complete animation to nearest boundary on destroy ([#444](https://github.com/studiometa/ui/issues/444), [#496](https://github.com/studiometa/ui/pull/496), [ecb63dd](https://github.com/studiometa/ui/commit/ecb63dd)) + ### Changed - **ScrollAnimation:** refactor components into `ScrollAnimationTimeline` and `ScrollAnimationTarget` ([#441](https://github.com/studiometa/ui/issues/441) [#494](https://github.com/studiometa/ui/pull/494), [a5d0e29](https://github.com/studiometa/ui/commit/a5d0e29))