From 8759131bba01d01319b4b03144108260d89d2061 Mon Sep 17 00:00:00 2001 From: Eliran Hezkia Date: Wed, 21 Oct 2026 18:55:51 +0300 Subject: [PATCH] fix(motion): read lazy preset keyframes after the measure step getEffectsData() normalized effect.keyframes as soon as it built the effect data, so presets whose keyframes (and ranges) are getters over values set in their dom.measure() callback were read before the measure. The getter returns a new array on each read, so the result was always spread into a copy, which froze every getter at its pre-measure value. Cross loops came out with zero offsets, and BgZoom/BgFake3D scroll ranges ignored the component height. Pass the preset's object through untouched and normalize keyframe names where they are consumed: inside the fastdom.mutate() that sets the WAAPI keyframes, and in getCSSAnimation(). Co-Authored-By: Claude Opus 5.5 --- packages/motion/src/api/common.ts | 10 +-- packages/motion/src/api/cssAnimations.ts | 4 +- packages/motion/src/api/webAnimations.ts | 5 +- packages/motion/test/motion.spec.ts | 98 ++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/packages/motion/src/api/common.ts b/packages/motion/src/api/common.ts index fb037b39..b9612e99 100644 --- a/packages/motion/src/api/common.ts +++ b/packages/motion/src/api/common.ts @@ -8,7 +8,7 @@ import type { TriggerVariant, UnitLengthPercentage, } from '../types'; -import { getCssUnits, getEasing, normalizeKeyframes } from '../utils'; +import { getCssUnits, getEasing } from '../utils'; import fastdom from 'fastdom'; import { getRegisteredEffect } from './registry'; @@ -119,12 +119,10 @@ function getEffectsData( } } - // keyframe property names may be authored in camelCase or kebab-case, - // WAAPI only understands camelCase - const keyframes = normalizeKeyframes(effect.keyframes); - return { - effect: keyframes === effect.keyframes ? effect : { ...effect, keyframes }, + // pass the preset's object as is - its keyframes and ranges may be getters + // that must only be read after its measure() callbacks ran + effect, options: effectOptions, id: effectId && `${effectId}-${index + 1}`, part: effect.part, diff --git a/packages/motion/src/api/cssAnimations.ts b/packages/motion/src/api/cssAnimations.ts index 6aa78dd3..20f37478 100644 --- a/packages/motion/src/api/cssAnimations.ts +++ b/packages/motion/src/api/cssAnimations.ts @@ -6,7 +6,7 @@ import type { SequenceOptions, TriggerVariant, } from '../types'; -import { getJsEasingInCSS } from '../utils'; +import { getJsEasingInCSS, normalizeKeyframes } from '../utils'; import { getEffectsData, getRanges, getNamedEffect, isNotAScrubTrigger } from './common'; function getAnimationTarget(target: string | null, part: string | undefined) { @@ -96,7 +96,7 @@ function getCSSAnimation( composition: item.options.composite, custom: item.effect.custom, name: item.effect.name, - keyframes: item.effect.keyframes, + keyframes: normalizeKeyframes(item.effect.keyframes), id: item.id && `${item.id}-${index + 1}`, animationTimeline: isViewProgress ? `--${trigger?.id}` : '', animationRange: start || end ? `${start} ${end}` : '', diff --git a/packages/motion/src/api/webAnimations.ts b/packages/motion/src/api/webAnimations.ts index bc7e7ecb..a01e5a0c 100644 --- a/packages/motion/src/api/webAnimations.ts +++ b/packages/motion/src/api/webAnimations.ts @@ -11,6 +11,7 @@ import type { } from '../types'; import { CustomAnimation } from '../CustomAnimation'; import { AnimationGroup } from '../AnimationGroup'; +import { normalizeKeyframes } from '../utils'; import { getElement, getElementMotionPart, @@ -137,7 +138,9 @@ function getWebAnimation( keyframeEffect.updateTiming(effect.timing as OptionalEffectTiming); } - keyframeEffect.setKeyframes(effect.keyframes); + // keyframe property names may be authored in camelCase or kebab-case, + // WAAPI only understands camelCase + keyframeEffect.setKeyframes(normalizeKeyframes(effect.keyframes)); }); const timingOptions = diff --git a/packages/motion/test/motion.spec.ts b/packages/motion/test/motion.spec.ts index 1b5e2b1c..3dcea6ba 100644 --- a/packages/motion/test/motion.spec.ts +++ b/packages/motion/test/motion.spec.ts @@ -910,6 +910,104 @@ describe('motion.ts', () => { (AnimationGroup as Mock).mockRestore(); }); + test('should read lazy keyframes after the preset measured the target', async () => { + registerEffects({ + LazyKeyframes: { + web: (options: AnimationOptions, dom: any) => { + let offset = 0; + dom.measure(() => { + offset = 0.4; + }); + + return [ + { + ...options, + name: 'lazy-keyframes', + get keyframes() { + return [ + { offset: 0, opacity: 0 }, + { offset, opacity: 1 }, + ]; + }, + }, + ]; + }, + }, + } as any); + + const fastdom = (await import('fastdom')).default; + // queue fastdom work like a browser frame does, so the measure runs after getWebAnimation() returns + const frame: Array<() => void> = []; + (fastdom.measure as Mock).mockImplementation((fn) => frame.push(fn)); + (fastdom.mutate as Mock).mockImplementation((fn) => frame.push(fn)); + + getWebAnimation(document.createElement('div'), { + namedEffect: { type: 'LazyKeyframes', id: 'lazy' }, + duration: 1000, + } as AnimationOptions); + frame.forEach((fn) => fn()); + + expect(mockKeyframeEffect.setKeyframes).toHaveBeenCalledWith([ + { offset: 0, opacity: 0 }, + { offset: 0.4, opacity: 1 }, + ]); + + (fastdom.measure as Mock).mockRestore(); + (fastdom.mutate as Mock).mockRestore(); + }); + + test('should read lazy scrub ranges after the preset measured the target', async () => { + registerEffects({ + LazyRange: { + web: (options: AnimationOptions, dom: any) => { + let height = 0; + dom.measure(() => { + height = 200; + }); + + // like BgZoom and BgFake3D, both the keyframes and the range end are getters + return [ + { + ...options, + name: 'lazy-range', + get keyframes() { + return [{ opacity: 0 }, { opacity: 1 }]; + }, + startOffset: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + endOffset: { name: 'cover', offset: { value: 0, unit: 'percentage' } }, + get endOffsetAdd() { + return `${height}px`; + }, + }, + ]; + }, + }, + } as any); + + const { AnimationGroup } = await import('../src/AnimationGroup'); + const fastdom = (await import('fastdom')).default; + const frame: Array<() => void> = []; + (fastdom.measure as Mock).mockImplementation((fn) => frame.push(fn)); + (fastdom.mutate as Mock).mockImplementation((fn) => frame.push(fn)); + + const target = document.createElement('div'); + getWebAnimation( + target, + { namedEffect: { type: 'LazyRange', id: 'lazy' } } as AnimationOptions, + { + trigger: 'view-progress', + element: target, + }, + ); + frame.forEach((fn) => fn()); + + const [animation] = (AnimationGroup as Mock).mock.calls[0][0]; + expect(animation.rangeEnd).toBe('cover calc(0% + 200px)'); + + (fastdom.measure as Mock).mockRestore(); + (fastdom.mutate as Mock).mockRestore(); + }); + test('should handle custom ownerDocument', async () => { const customDocument = { getElementById: vi.fn(() => mockElement),