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),