Skip to content

fix(motion): read lazy preset keyframes after the measure step - #347

Draft
eliranhe wants to merge 2 commits into
masterfrom
fix/motion-lazy-keyframes
Draft

eliranhe wants to merge 2 commits into
masterfrom
fix/motion-lazy-keyframes

Conversation

@eliranhe

@eliranhe eliranhe commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

Bug fix (motion): presets that compute their keyframes from measured values get keyframes from before the measurement. Cross loops sit still, and the scroll range of BgZoom and BgFake3D ignores the component's height.

Some presets return keyframes (and BgZoom/BgFake3D also endOffsetAdd) as getters. They read values that the preset's dom.measure() callback sets in the next fastdom frame. getWebAnimation() already waits for that: it calls setKeyframes() and sets the ranges inside fastdom.mutate().

Since #281, getEffectsData() calls normalizeKeyframes(effect.keyframes) right away, which runs the getter before the measure. A getter returns a new array on every read, so the keyframes === effect.keyframes check always fails, and { ...effect, keyframes } then copies every getter as the value it has at that moment. The mutate step gets those early values:

preset (WAAPI path) before after
Cross keyframe offsets 0, 0, 0, 1, 1 (the element starts outside its parent and never moves) 0, 0.64, 0.64, 1, 1
BgZoom (in) range end calc(100svh + 0px) calc(100svh + 800px)
BgFake3D parallax range end calc(100svh + 0px) calc(100svh + 800px)

These numbers come from the real presets run through getWebAnimation() in jsdom, with stubbed layout (an 800px tall component for the background presets) and fastdom queued the way a browser frame runs it.

Fix: getEffectsData() passes the preset's object through untouched. Keyframe names are normalized where they're used: inside the fastdom.mutate() in webAnimations.ts, and in getCSSAnimation(). The CSS path calls the presets' style(), which doesn't measure, so its output is unchanged.

Related Issue

Found in the Wix viewer while moving sites from interact v1 to v2: page-load Cross loops that ran under v1 never play under v2. In the investigation that found this, patching the same change into the bundle made the Cross loops on two affected sites run again, with offsets of 0.408 and 0.502 instead of 0.

Checklist

  • I have read the Contributing Guide
  • I have added/updated tests for my changes (if applicable)
  • I have updated documentation/rules/skills (if applicable): not applicable, no API change

Tests

Two new specs in packages/motion/test/motion.spec.ts › getWebAnimation(). Each one registers a preset whose getter depends on a value its dom.measure() sets, and queues fastdom so the measure runs after getWebAnimation() returns:

  • should read lazy keyframes after the preset measured the target: setKeyframes() gets offset 0.4. Without the fix, it gets 0.
  • should read lazy scrub ranges after the preset measured the target: the range end is cover calc(0% + 200px). Without the fix, it's cover calc(0% + 0px).

The existing kebab-case normalization specs still pass. With master merged in: yarn build, yarn test (all packages), yarn lint, yarn format:check and the motion e2e suite (Chromium, 38 tests) pass.

Browser validation

A real browser (Chrome 155), a published Wix Studio site with Cross page-load loops (reference kept internally). The page's interact bundle was swapped by request interception, so the same page ran three builds: the published 2.5.20 bundle, this branch without the fix, and this branch. The first two behave identically. Cross here is direction: right, iterations: infinite.

Case Build Keyframe offsets playState / currentTime over 6 s Movement
Loop in view at load (1317px wide) before 0, 0, 0, 1, 1 running, +6.1 s Only the entry half of the path: slides in from the left, never leaves through the right edge
after 0, 0.501, 0.501, 1, 1 running, +6.1 s Full path: leaves right from its resting position, re-enters from the left
Loop below the fold, scrolled into view before 0, 0, 0, 1, 1 paused, currentTime stays 0 None: the 0 offsets park the element left of its parent (right edge at x=0, intersection ratio 0), so interact's viewEnter observer (default threshold 0.2) never fires
after 0, 0.753, 0.753, 1, 1 running, +6.1 s Moves (x: 512, 855, 1197, then re-enters at -145 and continues)

0.753 matches (parentWidth - left) / (parentWidth + width) = (1280 - 12) / (1280 + 405).

Not part of this change: interact pauses a 'state' effect once its source is out of view. A running loop scrolled fully out of the viewport is paused with currentTime frozen, with the fix and without it. A loop that is still below the fold at load is likewise paused until it first enters the viewport.

🤖 Generated with Claude Code

eliranhe and others added 2 commits October 21, 2026 18:55
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant