Summary
Updating a MotionValue inside a (layout) effect during Strict Mode's mount→unmount→remount cycle leaves a useTransform-derived value permanently stuck at its stale value.
Root cause
useCombineMotionValues recomputes via two paths: a sync updateValue() in the render body, and an async frame.preRender(updateValue) on the source's change, cancelled by cancelFrame in the effect cleanup.
|
const updateValue = () => value.set(combineValues()) |
|
|
|
/** |
|
* Synchronously update the motion value with the latest values during the render. |
|
* This ensures that within a React render, the styles applied to the DOM are up-to-date. |
|
*/ |
|
updateValue() |
|
|
|
/** |
|
* Subscribe to all motion values found within the template. Whenever any of them change, |
|
* schedule an update. |
|
*/ |
|
useIsomorphicLayoutEffect(() => { |
|
const scheduleUpdate = () => frame.preRender(updateValue, false, true) |
|
const subscriptions = values.map((v) => v.on("change", scheduleUpdate)) |
|
|
|
return () => { |
|
subscriptions.forEach((unsubscribe) => unsubscribe()) |
|
cancelFrame(updateValue) |
|
} |
|
}) |
Failing sequence under Strict Mode:
- First effect sets source →
change → updateValue scheduled to next frame (still stale).
- Strict Mode cleanup runs
cancelFrame → pending task cancelled before it flushes (remount is continuous, no frame in between).
- Remount re-subscribes but doesn't re-render, so the sync fallback never runs again.
- Effect sets the same value →
set short-circuits → no change → nothing re-scheduled.
Result: derived value stays at f(oldValue) while source is newValue.
React 19 behavior changes
This bug disappears in React 19:
In React 18, Strict Mode's mount→unmount→remount runs synchronously inside the commit phase, so the whole effect → cleanup → effect sequence finishes before any frame. Derived value's update task is canceled and never runs.
In React 19, the double-invoke was moved into the passive-effects phase. The order becomes effect → rAF → cleanup → effect. The derived value update task can run before cleanup so the bug disappears.
Reproduction
https://codesandbox.io/p/sandbox/snowy-river-qq5y9c
Expected vs actual
Expected: doubled equals 20. Actual: doubled equals 0, never recovers.
Summary
Updating a
MotionValueinside a (layout) effect during Strict Mode's mount→unmount→remount cycle leaves auseTransform-derived value permanently stuck at its stale value.Root cause
useCombineMotionValuesrecomputes via two paths: a syncupdateValue()in the render body, and an asyncframe.preRender(updateValue)on the source'schange, cancelled bycancelFramein the effect cleanup.motion/packages/framer-motion/src/value/use-combine-values.ts
Lines 22 to 42 in 6183324
Failing sequence under Strict Mode:
change→updateValuescheduled to next frame (still stale).cancelFrame→ pending task cancelled before it flushes (remount is continuous, no frame in between).setshort-circuits → nochange→ nothing re-scheduled.Result: derived value stays at
f(oldValue)while source isnewValue.React 19 behavior changes
This bug disappears in React 19:
In React 18, Strict Mode's mount→unmount→remount runs synchronously inside the commit phase, so the whole effect → cleanup → effect sequence finishes before any frame. Derived value's update task is canceled and never runs.
In React 19, the double-invoke was moved into the passive-effects phase. The order becomes effect → rAF → cleanup → effect. The derived value update task can run before cleanup so the bug disappears.
Reproduction
https://codesandbox.io/p/sandbox/snowy-river-qq5y9c
Expected vs actual
Expected:
doubledequals 20. Actual:doubledequals 0, never recovers.