Fix useScroll inner motion components bound to wrong timeline#3684
Conversation
…omponents When useScroll is given a target ref and a preset offset, motion components nested inside that target were ending up bound to a generic ScrollTimeline (default cover-range) instead of the intended ViewTimeline (contain range). The visible symptom: opacity already advanced when the section scrolls into view, and never reaching its end value. Cause: makeAccelerateConfig's factory reads `target.current` synchronously. Refs attach child-first during commit, so when an inner motion component's bindToMotionValue runs, the outer target ref has not been populated yet — getTimeline() falls through to ScrollTimeline + default range. Fix: defer the scroll() call inside the factory by one microtask, so all ref callbacks in the same commit have fired before getTimeline reads target.current. Adds a regression test in real Chrome (Electron's WAAPI semantics differ), asserting both that the inner motion component receives a ViewTimeline with "contain" range, and that the WAAPI-driven computed opacity matches the JS scrollInfo path across the scroll range. Fixes #3658
Greptile SummaryThis PR fixes a race condition in
Confidence Score: 4/5The fix is safe to merge — it correctly defers timeline binding until after refs are populated, with a clean cancel path for early unmounts. The core change in dev/react/src/tests/scroll-view-timeline-transformed-parent.tsx — the Important Files Changed
Sequence DiagramsequenceDiagram
participant React as React Commit Phase
participant Child as Child motion component
participant Parent as Target ref (parent div)
participant MQ as Microtask Queue
participant Scroll as scroll() / getTimeline()
Note over React: Before fix — synchronous path
React->>Child: Attach child ref & call bindToMotionValue
Child->>Scroll: factory() → scroll() called immediately
Note over Scroll: target.current is null → ScrollTimeline ❌
React->>Parent: Attach parent/target ref (too late)
Note over React: After fix — deferred path
React->>Child: Attach child ref & call bindToMotionValue
Child->>MQ: factory() → queueMicrotask(scroll call)
React->>Parent: Attach parent/target ref ✓
Note over React: Commit complete
MQ->>Scroll: Microtask fires → scroll() with target.current set
Note over Scroll: target.current is Element → ViewTimeline ✓
Reviews (1): Last reviewed commit: "Fix useScroll acceleration binding wrong..." | Re-trigger Greptile |
| {words.map((word, i) => { | ||
| const start = i / words.length | ||
| const end = start + 1 / words.length | ||
| const opacity = useTransform( | ||
| scrollYProgress, | ||
| [start, end], | ||
| [0.2, 1] | ||
| ) | ||
| return ( | ||
| <motion.span | ||
| key={i} | ||
| style={{ opacity, color: "cyan" }} | ||
| > | ||
| {word} | ||
| </motion.span> | ||
| ) | ||
| })} |
There was a problem hiding this comment.
Rules of Hooks violation in test component
useTransform is called inside words.map(...), which is a loop. React's Rules of Hooks prohibit calling hooks inside loops because the call count must be stable across renders. In practice this holds here since text is a static prop with a fixed word count, but if text ever changes to a different word count (e.g. in a future variant of this test), React will throw "Rendered more/fewer hooks than previous render." Consider extracting the per-word animation into a small child component so each hook call site is at the top level of a function component.
Reverts the useMotionRef ordering from 1449483 ("Fix ViewTimeline ref timing") and the register() split from 8b8aacf (#3682). The microtask deferral in makeAccelerateConfig (previous commit) handles the original ViewTimeline ref-timing motivation in a way that also covers nested motion components inside the target — which 1449483's reordering never did. Restores the long-standing order: onMount → mount(instance) → external ref. mount() once again sets current + visualElementStore inline. The visualElementStore.get-from-external-ref guarantee asserted by the test added in #3682 is preserved by this order: mount populates the store before the external ref callback runs. Closes the regression #3682's reorder caused for Framer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- use-scroll.ts: switch the factory's deferral from raw queueMicrotask + a cancelled flag to motion-dom's microtask.read / cancelMicrotask. Same scheduling semantics, real cancellation, matches the existing pattern in projection/node/create-projection-node.ts. - dev test page: drop multi-paragraph file header, type FullRangeProbe's prop as MotionValue<number>, drop unused as-any casts, hoist duplicated hero style. - cypress spec: drop stale "side-by-side plain vs scaled" header (the page has only one target now), drop dead readProgress helper, drop the TimelineRangeOffset stringify dance in favour of asserting rangeName directly, collapse stops/labels/lines bookkeeping to a number[] and assert max drift inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sync lockfile with package.json updates landed via recent dependabot merges on main (next 15.5.10 → 15.5.15 etc). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI runs Cypress in its bundled Electron, whose Chromium predates ViewTimeline. Without ViewTimeline support, useScroll falls back to the JS scroll path so there's no WAAPI animation on the probe to inspect — the assertion has nothing to test. Skip the timeline-attachment test when window.ViewTimeline is absent (matching the conditional pattern in scroll-view-timeline.ts). The WAAPI-vs-JS parity test still runs unconditionally; in Electron it trivially passes because both paths fall back to JS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
useScroll's acceleration factory readstarget.currentsynchronously, but refs attach child-first during commit — so motion components inside the target run theirbindToMotionValuefirst, when the target ref is still null.getTimeline()to return a genericScrollTimeline(defaultcoverrange) instead of the intendedViewTimelinewithcontainrange.useScrollwith"start"/"end"string offsets broken withposition: stickyinside transformed parent since 12.37.0 #3658: nested-motion opacity already advanced when the section scrolled into view, and never reached its end value.Closes #3658. Supersedes #3659 — that PR reverted only the string-offset path; the same bug also reproduced with the array-form
[[0,0],[1,1]]offset that was acceleration-eligible since the original ViewTimeline support landed.Fix
Defer the
scroll()call insidemakeAccelerateConfig's factory by one microtask. By the time the microtask runs, all ref callbacks queued in the same commit have fired, sotarget.currentis populated beforegetTimeline()inspects it.The deferral is one microtask, well before the next paint, so no visual flash.
Test plan
scroll-view-timeline-transformed-parent.tsthat mirrors the reporter's repro structure (literal port).getAnimations()[0].timelineis aViewTimelineandrangeStart/rangeEndarecontain ....getComputedStyle(opacity)against JS scrollInfo progress at six scroll positions; max drift must be < 0.01.expected 'ScrollTimeline' to equal 'ViewTimeline').scroll-view-timeline.ts(3/3)scroll-target-transform.ts(2/2)scroll-accelerate.ts(3/3)use-scroll.test.tsxjest unit tests pass (6/6).Why real Chrome
Electron's WAAPI/ViewTimeline implementation differs from production Chrome — the regression surface is compositor behavior, so the cypress run must use
--browser chrome.🤖 Generated with Claude Code