Skip to content

fix(LayoutAnimations): serialize config updates with pullTransaction - #10373

Merged
bartlomiejbloniarz merged 1 commit into
yzm/05-android-js-pull-cleanupfrom
yzm/06-config-pull-boundary
Sep 15, 2026
Merged

bartlomiejbloniarz merged 1 commit into
yzm/05-android-js-pull-cleanupfrom
yzm/06-config-pull-boundary

Conversation

@bartlomiejbloniarz

@bartlomiejbloniarz bartlomiejbloniarz commented Aug 21, 2026

Copy link
Copy Markdown
Member

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:

obraz

Here are the drawings for the new flow. Didn't want to spam the description:
AI analysis

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.
ConfigSpamExample.tsx — paste into apps/common-app/src/apps/reanimated/examples/EmptyExample.tsx and open the Empty example.
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',
  },
});

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 976e0af1-ebd2-4f55-aa50-bf45a31e43ce

📥 Commits

Reviewing files that changed from the base of the PR and between e073386 and f140fbf.

📒 Files selected for processing (1)
  • packages/react-native-reanimated/CHANGELOG.md

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The 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)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: serializing LayoutAnimations configuration updates with pullTransaction.
Description check ✅ Passed The description directly explains the configuration race, the batching and locking changes, the performance caveat, and the planned stress test.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.cpp (1)

470-486: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Bruce 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 pushes Update(currentView → finalView). The second mutation uses the stale currentView as its old view, and its new view finalView discards the opacity that the first mutation just applied. The final props are still correct because finalView carries 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5cc9135 and 98ae530.

📒 Files selected for processing (4)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp
  • packages/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.

@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch 2 times, most recently from 99dcbf0 to 4aa97c0 Compare August 24, 2026 15:35
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch from 4aa97c0 to 68c4d5a Compare August 24, 2026 15:38
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch 2 times, most recently from 9f260ff to e073386 Compare August 24, 2026 15:51
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch from e073386 to 2e6e620 Compare August 24, 2026 15:53
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch 2 times, most recently from 9750233 to f140fbf Compare August 24, 2026 16:03
@bartlomiejbloniarz

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bartlomiejbloniarz

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bartlomiejbloniarz

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch from f140fbf to a233bc2 Compare August 25, 2026 13:57
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch 2 times, most recently from 078e062 to 7893569 Compare August 31, 2026 10:00
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch from 7893569 to b054f83 Compare August 31, 2026 12:28
@bartlomiejbloniarz
bartlomiejbloniarz marked this pull request as ready for review August 31, 2026 13:37
@bartlomiejbloniarz
bartlomiejbloniarz marked this pull request as draft September 7, 2026 09:06
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch 2 times, most recently from 35811e8 to ee0e4fb Compare September 14, 2026 08:18
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch from ee0e4fb to 4388c4e Compare September 14, 2026 09:42
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch 2 times, most recently from 71f6a1b to 3cbe700 Compare September 14, 2026 10:36
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch from 3cbe700 to 5fc89de Compare September 14, 2026 11:05
@bartlomiejbloniarz
bartlomiejbloniarz marked this pull request as ready for review September 14, 2026 11:37
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.
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the yzm/06-config-pull-boundary branch from 5fc89de to 92e6968 Compare September 14, 2026 11:41
@bartlomiejbloniarz
bartlomiejbloniarz merged commit 4b59eba into main Sep 15, 2026
40 of 55 checks passed
@bartlomiejbloniarz
bartlomiejbloniarz deleted the yzm/06-config-pull-boundary branch September 15, 2026 09:01
LKuchno pushed a commit that referenced this pull request Sep 16, 2026
…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>
bartlomiejbloniarz added a commit that referenced this pull request Sep 16, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants