fix(LayoutAnimations): serialize config updates with pullTransaction - #10373
bartlomiejbloniarz merged 1 commit into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughThe PR queues layout-animation configuration updates behind mutexes and passes serialized configurations through legacy and experimental proxies. It adds retargeting, surface-state cleanup, reparenting, animation cancellation, and final-frame reconciliation. It refactors transaction processing and registry-based proxy construction. The common app adds configuration and cleanup-ordering examples, enables the iOS modal example, and updates the changelog. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
5cc9135 to
98ae530
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.cpp (1)
470-486: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueBruce thinks the two update mutations for one tag are the real smoking gun here.
On non-Android the opacity branch pushes
Update(currentView → restoredView). The reconciliation branch then pushesUpdate(currentView → finalView). The second mutation uses the stalecurrentViewas its old view, and its new viewfinalViewdiscards the opacity that the first mutation just applied. The final props are still correct becausefinalViewcarries React's committed opacity, so this is a redundancy rather than a defect. Merge both effects into one mutation to keep the emitted pair consistent.♻️ Proposed consolidation
auto &animation = completedAnimation.animation; + auto restoredView = animation.finalView; if (!completedAnimation.shouldRemove && animation.opacity) { `#ifdef` ANDROID opacityRestorations.push_back(OpacityRestoration{ .shadowView = animation.finalView, .opacity = *animation.opacity, }); `#else` - auto restoredView = cloneViewWithOpacity(animation.currentView, *animation.opacity, propsParserContext); - mutations.push_back(ShadowViewMutation::UpdateMutation(animation.currentView, restoredView, animation.parentTag)); + restoredView = cloneViewWithOpacity(animation.finalView, *animation.opacity, propsParserContext); + mutations.push_back(ShadowViewMutation::UpdateMutation(animation.currentView, restoredView, animation.parentTag)); `#endif` } - if (!completedAnimation.shouldRemove && needsFinalFrameReconciliation(animation.type) && - animation.currentView.layoutMetrics != animation.finalView.layoutMetrics) { - mutations.push_back( - ShadowViewMutation::UpdateMutation(animation.currentView, animation.finalView, animation.parentTag)); - }The exact shape depends on whether the opacity restore must precede the frame reconciliation, so adjust as needed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: f071c793-ce1c-40bb-add5-880f2e0a17f1
📒 Files selected for processing (4)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.cpppackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.hpackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpppackages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.
99dcbf0 to
4aa97c0
Compare
4aa97c0 to
68c4d5a
Compare
9f260ff to
e073386
Compare
e073386 to
2e6e620
Compare
9750233 to
f140fbf
Compare
|
@coderabbitai review |
|
|
@coderabbitai review |
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
f140fbf to
a233bc2
Compare
078e062 to
7893569
Compare
7893569 to
b054f83
Compare
35811e8 to
ee0e4fb
Compare
ee0e4fb to
4388c4e
Compare
71f6a1b to
3cbe700
Compare
3cbe700 to
5fc89de
Compare
Publish changed layout, exiting, and shared transition configs from getSnapshotBeforeUpdate so they reach native before the same commit is pulled. Re-register configs when the wrapped component replaces its host. Apply native config batches at the start of each pull and hold the config lock through animation decisions.
5fc89de to
92e6968
Compare
…10373) Before this change, `configureAnimationBatch` could update animation configuration while `pullTransaction` was already making animation decisions. Different parts of one pull could observe different configs. A retargeted animation could also use a different config from the one that started it. Now updates collect in `pendingConfigUpdates_`. `lockAndFlushConfigUpdates` applies the full batch once at the start of `pullTransaction` and keeps the config stable for the rest of that pull. This gives every pull one config boundary and one consistent set of animation rules. #### Caveat `animationsMutex_` is shared by all surfaces. If two surfaces call `pullTransaction` at the same time, one surface can wait while the other completes its config-dependent work. This can make multi-surface setups slower. This does not change the result of either pull. Each pull uses one stable config state and captures its configs before releasing `configLock`. Most mobile apps use one Fabric surface, so this contention should be uncommon. We can separate config state by surface later if needed. #### Threading analysis This is how it breaks: <img width="1291" height="510" alt="obraz" src="https://github.com/user-attachments/assets/0722e828-de91-4ec2-b5df-b7f74dbc2de4" /> Here are the drawings for the new flow. Didn't want to spam the description: [AI analysis](https://github.com/user-attachments/files/31315354/commit-06-serialize-layout-animation-configs-with-pull.html) #### Test Plan - Run the config-spam stress screen below with Legacy and Experimental layout animations. - Verify that repeated config changes do not cause frame jumps or incorrect final layouts. <details> <summary><code>ConfigSpamExample.tsx</code> — paste into <code>apps/common-app/src/apps/reanimated/examples/EmptyExample.tsx</code> and open the <code>Empty</code> example.</summary> ```tsx import React, { useEffect, useState } from 'react'; import { Pressable, SafeAreaView, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import Animated, { FadeIn, FadeOut, LayoutAnimationConfig, LinearTransition, SequencedTransition, } from 'react-native-reanimated'; const ITEMS = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']; const TRANSITIONS = [ LinearTransition.duration(200), LinearTransition.duration(600), SequencedTransition.duration(400), undefined, ] as const; const SPAM_INTERVAL_MS = 60; export default function ConfigSpamExample() { const [items, setItems] = useState(ITEMS); const [spamming, setSpamming] = useState(false); const [tick, setTick] = useState(0); useEffect(() => { if (!spamming) { return; } const id = setInterval(() => setTick((prev) => prev + 1), SPAM_INTERVAL_MS); return () => clearInterval(id); }, [spamming]); const addItem = () => { let i = 1; while (items.includes(`Item ${i}`)) { i++; } setItems([...items, `Item ${i}`]); }; const reorderItems = () => { setItems((prevItems) => [...prevItems].sort(() => Math.random() - 0.5)); }; const resetOrder = () => { setItems((prevItems) => [...prevItems].sort( (left, right) => parseInt(left.match(/\d+$/)![0], 10) - parseInt(right.match(/\d+$/)![0], 10) ) ); }; return ( <LayoutAnimationConfig skipEntering> <SafeAreaView style={styles.container}> <View style={styles.menu}> <Text style={styles.infoText}>Press an item to remove it</Text> <TouchableOpacity onPress={() => setSpamming((prev) => !prev)}> <Text style={styles.buttonText}> {spamming ? 'Stop configs' : 'Spam configs'} </Text> </TouchableOpacity> <View style={styles.row}> <TouchableOpacity onPress={addItem}> <Text style={styles.buttonText}>Add item</Text> </TouchableOpacity> <TouchableOpacity onPress={reorderItems}> <Text style={styles.buttonText}>Reorder</Text> </TouchableOpacity> <TouchableOpacity onPress={resetOrder}> <Text style={styles.buttonText}>Reset order</Text> </TouchableOpacity> </View> </View> <View style={styles.list}> {items.map((item) => ( <Animated.View key={item} layout={TRANSITIONS[tick % TRANSITIONS.length]} entering={tick % 2 === 0 ? FadeIn : undefined} exiting={tick % 2 === 0 ? FadeOut : undefined}> <Pressable onPress={() => setItems((prevItems) => prevItems.filter((other) => other !== item) ) } style={styles.listItem}> <Text style={styles.itemText}>{item}</Text> </Pressable> </Animated.View> ))} </View> </SafeAreaView> </LayoutAnimationConfig> ); } const styles = StyleSheet.create({ container: { flex: 1, }, row: { flexDirection: 'row', gap: 16, alignItems: 'center', }, list: { padding: 16, gap: 16, }, listItem: { padding: 20, backgroundColor: '#b58df1', }, itemText: { color: 'white', fontSize: 22, }, menu: { padding: 16, alignItems: 'center', justifyContent: 'center', gap: 8, }, infoText: { color: '#222534', fontSize: 18, }, buttonText: { fontSize: 18, fontWeight: 'bold', color: '#b58df1', }, }); ``` </details>
…r config or order changes (#10537) Before this change, a layout animation kept driving a view from the config it started with, but nothing remembered that config. When the `layout` prop was removed or replaced while the animation ran, the next layout change for that view found no config, so its Update mutation went straight to the host while the animation kept writing its own frames underneath. The moved-item path in `updateLightTree` also reinserted a reordered view at `LightNode::previous`, which is stale once an animation has advanced the view, and a completed animation left the view at its last animated frame instead of the frame React mounted. On Android, where configs from the same commit already land in time (#10373), this showed up as an item parked at its neighbour's frame or invisible after a reorder with configs changing. Now `LayoutAnimation` stores the config it started with. `getRetargetLayoutAnimationConfig` returns it when the prop is gone, so the running animation retargets to the new layout in both proxies. A start enqueued for a tag that already has a pending start of the same type updates that start in place. When a view moves inside the same parent, `updateLightTree` reinserts it at its animated frame: the tracked animation view, the old view from this transaction's Update, or the insertion view when nothing is pending. Entering animations follow layout changes to their target through `updateEnteringAnimationTarget`, and `cleanupCompletedAnimations` reconciles a completed animation with the final mounted frame instead of only restoring opacity. `styleAnimation.ts` keeps an explicit zero initial value when an animation restarts. Cross-parent moves keep their previous behavior here; converting animated frames between parents is the next PR in the stack. #### Test Plan - Open `[LA] List item layout animation`, enable layout animations, reorder repeatedly with every transition type, add an item and reorder again. All items stay visible, iOS and Android. - Run the config-spam stress screen below, enable config spamming, then storm `Reorder` while adding and removing items, and finish with `Reset order`. Items settle in consecutive slots with none missing. Before this change this failed about one run in three on Android. - Run the entering layout change screen below and exercise each control; entering views end at their final layout. - Run the remaining `[LA]` examples on iOS and Android. <details> <summary><code>ConfigSpamExample.tsx</code> — paste into <code>apps/common-app/src/apps/reanimated/examples/EmptyExample.tsx</code> and open the <code>Empty</code> example.</summary> ```tsx import React, { useEffect, useState } from 'react'; import { Pressable, SafeAreaView, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import Animated, { FadeIn, FadeOut, LayoutAnimationConfig, LinearTransition, SequencedTransition, } from 'react-native-reanimated'; const ITEMS = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']; const TRANSITIONS = [ LinearTransition.duration(200), LinearTransition.duration(600), SequencedTransition.duration(400), undefined, ] as const; const SPAM_INTERVAL_MS = 60; export default function ConfigSpamExample() { const [items, setItems] = useState(ITEMS); const [spamming, setSpamming] = useState(false); const [tick, setTick] = useState(0); useEffect(() => { if (!spamming) { return; } const id = setInterval(() => setTick((prev) => prev + 1), SPAM_INTERVAL_MS); return () => clearInterval(id); }, [spamming]); const addItem = () => { let i = 1; while (items.includes(`Item ${i}`)) { i++; } setItems([...items, `Item ${i}`]); }; const reorderItems = () => { setItems((prevItems) => [...prevItems].sort(() => Math.random() - 0.5)); }; const resetOrder = () => { setItems((prevItems) => [...prevItems].sort( (left, right) => parseInt(left.match(/\d+$/)![0], 10) - parseInt(right.match(/\d+$/)![0], 10) ) ); }; return ( <LayoutAnimationConfig skipEntering> <SafeAreaView style={styles.container}> <View style={styles.menu}> <Text style={styles.infoText}>Press an item to remove it</Text> <TouchableOpacity onPress={() => setSpamming((prev) => !prev)}> <Text style={styles.buttonText}> {spamming ? 'Stop configs' : 'Spam configs'} </Text> </TouchableOpacity> <View style={styles.row}> <TouchableOpacity onPress={addItem}> <Text style={styles.buttonText}>Add item</Text> </TouchableOpacity> <TouchableOpacity onPress={reorderItems}> <Text style={styles.buttonText}>Reorder</Text> </TouchableOpacity> <TouchableOpacity onPress={resetOrder}> <Text style={styles.buttonText}>Reset order</Text> </TouchableOpacity> </View> </View> <View style={styles.list}> {items.map((item) => ( <Animated.View key={item} layout={TRANSITIONS[tick % TRANSITIONS.length]} entering={tick % 2 === 0 ? FadeIn : undefined} exiting={tick % 2 === 0 ? FadeOut : undefined}> <Pressable onPress={() => setItems((prevItems) => prevItems.filter((other) => other !== item) ) } style={styles.listItem}> <Text style={styles.itemText}>{item}</Text> </Pressable> </Animated.View> ))} </View> </SafeAreaView> </LayoutAnimationConfig> ); } const styles = StyleSheet.create({ container: { flex: 1, }, row: { flexDirection: 'row', gap: 16, alignItems: 'center', }, list: { padding: 16, gap: 16, }, listItem: { padding: 20, backgroundColor: '#b58df1', }, itemText: { color: 'white', fontSize: 22, }, menu: { padding: 16, alignItems: 'center', justifyContent: 'center', gap: 8, }, infoText: { color: '#222534', fontSize: 18, }, buttonText: { fontSize: 18, fontWeight: 'bold', color: '#b58df1', }, }); ``` </details> <details> <summary><code>EnteringLayoutChangeExample.tsx</code> — paste into <code>apps/common-app/src/apps/reanimated/examples/EmptyExample.tsx</code> and open the <code>Empty</code> example.</summary> ```tsx import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Button, ScrollView, StyleSheet, Text, View } from 'react-native'; import Animated, { BounceIn, FadeIn, FadeInDown, FlipInEasyX, SlideInDown, ZoomIn, } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; const PRESETS = { FadeIn, FadeInDown, ZoomIn, BounceIn, FlipInEasyX, SlideInDown, }; const DURATION = 3000; const HEADER_HEIGHT = 64; const ROW_HEIGHT = 44; const ROW_GAP = 12; function EnteringLayoutChange({ correctionDelay, }: { correctionDelay: number; }) { const [headerHeight, setHeaderHeight] = useState(0); const [completions, setCompletions] = useState<string[]>([]); const onFinish = useCallback((name: string, finished: boolean) => { setCompletions((previous) => [ ...previous, `${name}: ${finished ? 'finished' : 'cancelled'}`, ]); }, []); const animations = useMemo( () => Object.entries(PRESETS).map(([name, preset]) => ({ name, entering: preset.duration(DURATION).withCallback((finished) => { 'worklet'; scheduleOnRN(onFinish, name, finished === true); }), })), [onFinish] ); useEffect(() => { const timeout = setTimeout( () => setHeaderHeight(HEADER_HEIGHT), correctionDelay ); return () => clearTimeout(timeout); }, [correctionDelay]); return ( <View> <Text testID="entering-header-status" style={styles.status}> Header: {headerHeight} pt · correction after {correctionDelay} ms </Text> <View collapsable={false} style={styles.stage}> <View style={[styles.header, { height: headerHeight }]}> <Text>Fake header</Text> </View> {animations.map(({ name, entering }) => ( <Animated.View key={name} testID={`entering-${name}`} entering={entering} style={styles.card}> <Text>{name}</Text> </Animated.View> ))} {animations.map(({ name }, index) => ( <View key={name} style={[ styles.target, { top: headerHeight + index * (ROW_HEIGHT + ROW_GAP) }, ]}> <Text style={styles.targetText}>Target</Text> </View> ))} </View> <Text testID="entering-completions" style={styles.status}> Callbacks: {completions.length}/{animations.length} </Text> <Text>{completions.join('\n')}</Text> </View> ); } export default function EnteringLayoutChangeExample() { const [run, setRun] = useState(0); const [correctionDelay, setCorrectionDelay] = useState(1000); function replay(delay: number) { setCorrectionDelay(delay); setRun((previous) => previous + 1); } return ( <ScrollView contentContainerStyle={styles.container}> <Text style={styles.title}>Layout change during entering</Text> <Text> Enter for 3 seconds while a header grows by 64 pt. The target markers move immediately. Watch whether each card follows the correction while its entering animation continues, and where it finishes. </Text> <Button testID="entering-replay-midway" title="Replay · correction at 1 s" onPress={() => replay(1000)} /> <Button testID="entering-replay-early" title="Replay · correction at 16 ms" onPress={() => replay(16)} /> <EnteringLayoutChange key={run} correctionDelay={correctionDelay} /> </ScrollView> ); } const styles = StyleSheet.create({ container: { padding: 16, gap: 12 }, title: { fontSize: 20, fontWeight: '600' }, status: { marginVertical: 12 }, stage: { height: HEADER_HEIGHT + Object.keys(PRESETS).length * (ROW_HEIGHT + ROW_GAP), }, header: { backgroundColor: '#bcebd4', justifyContent: 'center', alignItems: 'center', overflow: 'hidden', }, card: { height: ROW_HEIGHT, marginBottom: ROW_GAP, width: '72%', backgroundColor: '#b58df1', borderRadius: 8, alignItems: 'center', justifyContent: 'center', }, target: { position: 'absolute', left: '80%', right: 0, height: ROW_HEIGHT, borderLeftWidth: 3, borderColor: '#238c59', justifyContent: 'center', }, targetText: { marginLeft: 6, color: '#238c59' }, }); ``` </details> #### Recordings Config-spam stress screen, spamming on, reorder storm with adds and removals, then `Reset order`. <table> <tr><th>Before (Android, stack tip)</th><th>After (Android)</th><th>After (iOS)</th></tr> <tr><td><video src="https://github.com/user-attachments/assets/6049fea0-121b-407d-97f5-062d3c27c8db" controls width="100%"></video></td><td><video src="https://github.com/user-attachments/assets/2a038729-c345-4318-9433-0791970d7460" controls width="100%"></video></td><td><video src="https://github.com/user-attachments/assets/6c93ab32-61d3-4de8-b3f4-7c07ceefda24" controls width="100%"></video></td></tr> <tr><td>One item ends invisible at its neighbour's frame and the list sits one slot low.</td><td>15/15 runs settle in consecutive slots.</td><td>10/10 runs settle in consecutive slots.</td></tr> </table> #10471 steps on `[LA] List item layout animation` with `LinearTransition`; the other five transition types passed the same way. <table> <tr><th>After (Android)</th><th>After (iOS)</th></tr> <tr><td><video src="https://github.com/user-attachments/assets/7b01edf4-a69d-43d7-9462-7c428aac2517" controls width="100%"></video></td><td><video src="https://github.com/user-attachments/assets/ed7a3c1e-d164-457f-9a9b-86ef6d20e3c6" controls width="100%"></video></td></tr> </table> Fixes #10471.
Before this change,
configureAnimationBatchcould update animation configuration whilepullTransactionwas already making animation decisions. Different parts of one pull could observe different configs. A retargeted animation could also use a different config from the one that started it.Now updates collect in
pendingConfigUpdates_.lockAndFlushConfigUpdatesapplies the full batch once at the start ofpullTransactionand keeps the config stable for the rest of that pull. This gives every pull one config boundary and one consistent set of animation rules.Caveat
animationsMutex_is shared by all surfaces. If two surfaces callpullTransactionat the same time, one surface can wait while the other completes its config-dependent work. This can make multi-surface setups slower.This does not change the result of either pull. Each pull uses one stable config state and captures its configs before releasing
configLock. Most mobile apps use one Fabric surface, so this contention should be uncommon. We can separate config state by surface later if needed.Threading analysis
This is how it breaks:
Here are the drawings for the new flow. Didn't want to spam the description:
AI analysis
Test Plan
ConfigSpamExample.tsx— paste intoapps/common-app/src/apps/reanimated/examples/EmptyExample.tsxand open theEmptyexample.