Skip to content

Commit 54fed05

Browse files
zeyapfacebook-github-bot
authored andcommitted
Defer animation start time in FrameAnimationDriver (#56929)
Summary: ## Changelog: [Internal] [Fixed] - Defer animation start time in FrameAnimationDriver **Problem**: In complex apps, if animation is started in commit phase (the case if animation starts in useLayoutEffect, or from ViewTransition event handlers), it'll skip initial frames — the user sees the animation snap to an intermediate position. This happens because `FrameAnimationDriver` anchors its start time on the first `runAnimationStep` call, but the UI thread may be busy with layout/mount work for several frames before the view actually composites. The elapsed wall-clock time advances, causing `frameIndex` to jump ahead. **Why**: `startFrameTimeMs_` is set to the Choreographer frame time on the first tick. If the UI thread is blocked processing a heavy tree (many views mounting), subsequent ticks arrive much later — `timeDeltaMs` jumps and the animation skips to a mid-point. - Every major framework solves this: Flutter uses lazy start (`_startTime ??= timeStamp` on first actual tick), Android native uses `CALLBACK_COMMIT` to adjust post-traversal, and CSS View Transitions spec defers start until post-composite. **Fix**: On the very first `update()` call, output the starting value (frame 0) and reset `startFrameTimeMs_ = -1`. This causes the base class to re-anchor on the next `runAnimationStep`, so elapsed time is measured from the first frame that has actually been rendered — not from when `startAnimatingNode` was dispatched. The flag disables itself after one use, so all subsequent frames use pure elapsed-time with no behavioral change. Differential Revision: D106007152
1 parent f373636 commit 54fed05

3 files changed

Lines changed: 247 additions & 15 deletions

File tree

packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.cpp

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,34 @@ void FrameAnimationDriver::onConfigChanged() {
4949
frames_.push_back(frameValue);
5050
}
5151
toValue_ = config_["toValue"].asDouble();
52+
auto deferIt = config_.find("deferredStart");
53+
deferredStart_ = deferIt == config_.items().end() || deferIt->second.asBool();
5254
}
5355

54-
bool FrameAnimationDriver::update(double timeDeltaMs, bool /*restarting*/) {
56+
bool FrameAnimationDriver::update(double timeDeltaMs, bool restarting) {
5557
if (auto node =
5658
manager_->getAnimatedNode<ValueAnimatedNode>(animatedValueTag_)) {
5759
if (!startValue_) {
5860
startValue_ = node->getRawValue();
5961
}
6062

63+
if (deferredStart_ && restarting) {
64+
// On the very first update after start: output the starting value
65+
// (frame 0) and defer the time anchor. The base class will re-anchor
66+
// startFrameTimeMs_ on the next call, so elapsed time is measured
67+
// from the first frame that has actually been rendered — not from
68+
// when startAnimatingNode was dispatched.
69+
//
70+
// This prevents skipping initial frames when the UI thread is busy
71+
// with layout/mount work between animation start and first composite.
72+
node->setRawValue(
73+
startValue_.value() + frames_[0] * (toValue_ - startValue_.value()));
74+
markNodeUpdated(node->tag());
75+
startFrameTimeMs_ = -1;
76+
deferredStart_ = false;
77+
return false;
78+
}
79+
6180
const auto startIndex =
6281
static_cast<size_t>(std::round(timeDeltaMs / SingleFrameIntervalMs));
6382
assert(startIndex >= 0);

packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class FrameAnimationDriver : public AnimationDriver {
3535
std::vector<double> frames_{};
3636
double toValue_{0};
3737
std::optional<double> startValue_{};
38+
bool deferredStart_{true};
3839
};
3940

4041
} // namespace facebook::react

packages/react-native/ReactCommon/react/renderer/animated/tests/AnimationDriverTests.cpp

Lines changed: 226 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,29 @@ TEST_F(AnimationDriverTests, framesAnimation) {
4242

4343
const double startTimeInTick = 12345;
4444

45+
// Frame 1: deferred start outputs frame 0 and re-anchors timing
4546
runAnimationFrame(startTimeInTick);
4647
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);
4748

48-
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 2.5);
49+
// Frame 2: re-anchor completes, timeDelta=0 → still frame 0
50+
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs);
51+
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);
52+
53+
// Subsequent frames are measured from the re-anchored start
54+
runAnimationFrame(
55+
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 2.5);
4956
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 65);
5057

51-
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 3);
58+
runAnimationFrame(
59+
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 3);
5260
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 90);
5361

54-
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 4);
62+
runAnimationFrame(
63+
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 4);
5564
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue);
5665

57-
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 10);
66+
runAnimationFrame(
67+
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 10);
5868
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue);
5969
}
6070

@@ -84,12 +94,14 @@ TEST_F(AnimationDriverTests, framesAnimationReconfigurationClearsFrames) {
8494

8595
const double startTimeInTick = 12345;
8696

87-
// Run first frame
97+
// Deferred start frame + re-anchor frame
8898
runAnimationFrame(startTimeInTick);
8999
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);
100+
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs);
101+
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);
90102

91-
// Reconfigure the same animation (same animationId) with new frames
92-
// This triggers updateConfig on the existing FrameAnimationDriver
103+
// Reconfigure the same animation (same animationId) with new frames.
104+
// This triggers updateConfig → onConfigChanged → deferredStart_ = true again.
93105
const auto frames2 = folly::dynamic::array(0.0f, 0.5f, 1.0f);
94106
const auto toValue2 = 200;
95107
nodesManager_->startAnimatingNode(
@@ -99,22 +111,222 @@ TEST_F(AnimationDriverTests, framesAnimationReconfigurationClearsFrames) {
99111
"toValue", toValue2),
100112
std::nullopt);
101113

102-
// Reset animation timing
103114
const double newStartTimeInTick = 20000;
104115

105-
// Run animation at halfway point (1 frame into 3-frame animation)
116+
// Deferred start frame for reconfigured animation
106117
runAnimationFrame(newStartTimeInTick);
107-
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 1);
118+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
119+
120+
// Re-anchor frame
121+
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs);
122+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
108123

109124
// At frame 1 of 3 frames (50% progress), value should be approximately:
110125
// startValue (0) + 0.5 * (toValue2 - startValue) = 0 + 0.5 * 200 = 100
111126
// If frames accumulated (5 + 3 = 8 frames), we'd be at wrong position
112-
// Use ceil rounding so 100.00x becomes 100.01
113-
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 100.01);
127+
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 2);
128+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 100, 0.01);
114129

115130
// Complete the animation
116-
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 2);
117-
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue2);
131+
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 3);
132+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue2);
133+
}
134+
135+
TEST_F(AnimationDriverTests, framesAnimationDeferredStartPreventsSkipping) {
136+
// Deferred start outputs frame 0 on the first update and re-anchors
137+
// startFrameTimeMs_ so the second update also sees timeDelta=0.
138+
// Without the fix the second frame would already be at value 25.
139+
initNodesManager();
140+
141+
auto rootTag = getNextRootViewTag();
142+
143+
auto valueNodeTag = ++rootTag;
144+
nodesManager_->createAnimatedNode(
145+
valueNodeTag,
146+
folly::dynamic::object("type", "value")("value", 0)("offset", 0));
147+
148+
const auto animationId = 1;
149+
const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
150+
const auto toValue = 100;
151+
nodesManager_->startAnimatingNode(
152+
animationId,
153+
valueNodeTag,
154+
folly::dynamic::object("type", "frames")("frames", frames)(
155+
"toValue", toValue),
156+
std::nullopt);
157+
158+
const double t = 12345;
159+
160+
// Frame 1: both with and without fix, timeDelta=0 → value=0
161+
runAnimationFrame(t);
162+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
163+
164+
// Frame 2: WITHOUT fix timeDelta=SI → value≈25.
165+
// WITH fix the deferred start re-anchored startFrameTimeMs_, so
166+
// timeDelta=0 → value=0. This assertion fails without the fix.
167+
runAnimationFrame(t + SingleFrameIntervalMs);
168+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
169+
170+
// Frame 3: now timeDelta=SI from the re-anchored start
171+
runAnimationFrame(t + SingleFrameIntervalMs * 2);
172+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01);
173+
174+
// Frame 4
175+
runAnimationFrame(t + SingleFrameIntervalMs * 3);
176+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 50, 0.01);
177+
178+
// Complete
179+
runAnimationFrame(t + SingleFrameIntervalMs * 5);
180+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue);
181+
}
182+
183+
TEST_F(
184+
AnimationDriverTests,
185+
framesAnimationDoesNotStartBeforeEndOfEventLoopTick) {
186+
// Validates that animations don't start before the end of the current event
187+
// loop tick, even if the main thread progresses after they've been set up.
188+
//
189+
// Scenario: animation is started, but the UI thread is busy with
190+
// layout/mount work for several frames (~200ms) before the view composites.
191+
// Without deferred start, the animation would skip ahead to the 200ms mark.
192+
// With deferred start, the animation re-anchors and starts cleanly from
193+
// frame 0 regardless of how much wall-clock time elapsed.
194+
initNodesManager();
195+
196+
auto rootTag = getNextRootViewTag();
197+
198+
auto valueNodeTag = ++rootTag;
199+
nodesManager_->createAnimatedNode(
200+
valueNodeTag,
201+
folly::dynamic::object("type", "value")("value", 0)("offset", 0));
202+
203+
const auto animationId = 1;
204+
const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
205+
const auto toValue = 100;
206+
nodesManager_->startAnimatingNode(
207+
animationId,
208+
valueNodeTag,
209+
folly::dynamic::object("type", "frames")("frames", frames)(
210+
"toValue", toValue),
211+
std::nullopt);
212+
213+
const double setupTime = 10000;
214+
215+
// First runAnimationFrame happens right after setup — this is the first
216+
// Choreographer tick that processes the animation. The deferred start
217+
// outputs frame 0 and resets the time anchor.
218+
runAnimationFrame(setupTime);
219+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
220+
221+
// Main thread is now busy with heavy layout/mount work for ~200ms
222+
// (about 12 frame intervals). The next Choreographer callback arrives
223+
// much later. Without deferred start, timeDelta would be 200ms and
224+
// the animation would skip to the 200ms mark. With it, re-anchoring
225+
// ensures timeDelta=0.
226+
const double afterBusyPeriod = setupTime + 200;
227+
runAnimationFrame(afterBusyPeriod);
228+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0)
229+
<< "Animation should not have progressed during the busy period";
230+
231+
// From here, animation progresses normally frame by frame
232+
runAnimationFrame(afterBusyPeriod + SingleFrameIntervalMs);
233+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01);
234+
235+
runAnimationFrame(afterBusyPeriod + SingleFrameIntervalMs * 2);
236+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 50, 0.01);
237+
238+
runAnimationFrame(afterBusyPeriod + SingleFrameIntervalMs * 3);
239+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 75, 0.01);
240+
241+
runAnimationFrame(afterBusyPeriod + SingleFrameIntervalMs * 4);
242+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue);
243+
}
244+
245+
TEST_F(
246+
AnimationDriverTests,
247+
framesAnimationDoesNotStartBeforeEndOfEventLoopTick_multipleFrameGap) {
248+
// Same scenario but the first runAnimationFrame itself arrives late
249+
// (500ms after animation was set up) — simulating the case where the
250+
// entire event loop tick is blocked by heavy JS/native work and
251+
// Choreographer catches up later.
252+
initNodesManager();
253+
254+
auto rootTag = getNextRootViewTag();
255+
256+
auto valueNodeTag = ++rootTag;
257+
nodesManager_->createAnimatedNode(
258+
valueNodeTag,
259+
folly::dynamic::object("type", "value")("value", 0)("offset", 0));
260+
261+
const auto animationId = 1;
262+
const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
263+
const auto toValue = 100;
264+
nodesManager_->startAnimatingNode(
265+
animationId,
266+
valueNodeTag,
267+
folly::dynamic::object("type", "frames")("frames", frames)(
268+
"toValue", toValue),
269+
std::nullopt);
270+
271+
// Simulate: 500ms passes before the first animation frame runs.
272+
// The animation must still start at value 0 — it should never
273+
// retroactively apply elapsed time.
274+
const double lateFirstFrame = 50000;
275+
runAnimationFrame(lateFirstFrame);
276+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0)
277+
<< "Animation must not skip ahead even if first frame arrives late";
278+
279+
// Second frame shortly after — re-anchor means timeDelta=0 still
280+
runAnimationFrame(lateFirstFrame + SingleFrameIntervalMs);
281+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0)
282+
<< "Re-anchored start means second frame also sees timeDelta=0";
283+
284+
// Normal progression from the re-anchored point
285+
runAnimationFrame(lateFirstFrame + SingleFrameIntervalMs * 2);
286+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01);
287+
288+
runAnimationFrame(lateFirstFrame + SingleFrameIntervalMs * 4);
289+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 75, 0.01);
290+
291+
runAnimationFrame(lateFirstFrame + SingleFrameIntervalMs * 5);
292+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue);
293+
}
294+
295+
TEST_F(AnimationDriverTests, framesAnimationDeferredStartOptOut) {
296+
// When deferredStart is false, the animation starts immediately without
297+
// the extra re-anchor frame.
298+
initNodesManager();
299+
300+
auto rootTag = getNextRootViewTag();
301+
302+
auto valueNodeTag = ++rootTag;
303+
nodesManager_->createAnimatedNode(
304+
valueNodeTag,
305+
folly::dynamic::object("type", "value")("value", 0)("offset", 0));
306+
307+
const auto animationId = 1;
308+
const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
309+
const auto toValue = 100;
310+
nodesManager_->startAnimatingNode(
311+
animationId,
312+
valueNodeTag,
313+
folly::dynamic::object("type", "frames")("frames", frames)(
314+
"toValue", toValue)("deferredStart", false),
315+
std::nullopt);
316+
317+
const double t = 12345;
318+
319+
// Frame 1: timeDelta=0, value=0 (no deferred start delay)
320+
runAnimationFrame(t);
321+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
322+
323+
// Frame 2: timeDelta=SI, value≈25 (animation progresses immediately)
324+
runAnimationFrame(t + SingleFrameIntervalMs);
325+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01);
326+
327+
// Complete
328+
runAnimationFrame(t + SingleFrameIntervalMs * 4);
329+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue);
118330
}
119331

120332
} // namespace facebook::react

0 commit comments

Comments
 (0)