Skip to content

fix(Reanimated): carry synchronous prop values in commits - #10480

Closed
pawicao wants to merge 16 commits into
@pawicao/lighttree-sync-update-propsfrom
@pawicao/sync-props-commit-consistency
Closed

pawicao wants to merge 16 commits into
@pawicao/lighttree-sync-update-propsfrom
@pawicao/sync-props-commit-consistency

Conversation

@pawicao

@pawicao pawicao commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Replaces the light tree overlay from #10416 with one rule: commits carry the values the synchronous path applied. This closes the snapshot bug the overlay patched and a second bug that needs no shared transition at all: a later commit wrote a stale transform back to the native view for 8-10 frames (RCTViewComponentView diffs incoming props against the view's own _props, which the synchronous path refreshed).

How to read this PR: the first commit reverts the overlay. Every commit after it is the new approach, so git diff from the first commit to the head shows the approach against a plain "flags work together" main.

How it works

  1. applySynchronousUpdates records each applied batch as pending values in the existing UpdatesRegistryManager, grouped by surface.
  2. Commits that happen anyway carry them, and only where a commit can reach a native view. Mounting writes props to a view only when its props object changed, so an untouched node cannot be reverted by a commit. A Reanimated commit therefore carries the pending values of the nodes in its batch, of pending nodes below a node in its batch (a changed ancestor can reparent them natively), and of views with a layout, entering or exiting animation config (their shadow props feed animation frames). The commit hook does the same for non-React-source commits: it carries a pending node only when the new root gives it new props, a new place, or an ancestor with new props. Registry flush commits carry every pending value. Sync-only frames still make no commits.
  3. Each pending key has a version. A successful Reanimated commit clears only keys covered by that attempt. A React commit records one receipt per pending node. The receipt holds the props object the hook created for that node and the keys it covers. The receipt clears its keys when a main root carries that props object: the old root of a later non-React commit, the old root of a Reanimated commit, or the mounted root. Props identity survives root clones, state progression and branch merges. Newer writes survive either cleanup. Settled eviction, registry flushes, and node or surface removal also clear pending values.
  4. Synchronous writes and props-only Updates retarget a running layout animation instead of restarting it (legacy-proxy parity), and the retarget survives the last animation frame.
  5. The write-time light tree feed stays: it covers the window before the first commit. A layout-only mount update keeps the props the light node already holds, since the mutation carries no new prop values; only updates with a new props object replace them. The overlay, skipOverlayTags, and the lock-inverting eviction relay are gone.

Verified

  1. iOS simulator, sync flag on, frame-analyzed recordings: the new SynchronousPropsOverwriteExample shows zero stale-transform frames across four presses (was 8-10 per press); both LightTreeErasure buttons start from the green frame on fresh mounts.
  2. Five adversarial review rounds (Codex gpt-6-astra, high effort) drove out and closed: sync-only commits, layout-animation restarts, CSS reset ownership, eviction over-clearing, unmount leaks, last-frame retarget loss, cancelled-flush loss, restoration-opacity taint. Final round: pass.
  3. Builds on iOS and Android; Android keeps its sync flag off by default.
  4. The node-keyed receipts (c5ad53ac4e) passed local checks with minimal React Native stand-ins: a state commit before a branch merge, a state commit before mount, ReactRevisionMerge, a later root clone, concurrent and cancelled attempts, and newer synchronous writes. Compiles on iOS with synchronous updates on and off and on Android with them on; the Android clang-tidy lint passes.
  5. The selective carry (56cabb3d0c) went through seven Codex gpt-6-astra review rounds and six independent Claude Fable review rounds until both agreed; it was verified on the iOS simulator with the same frame analysis: the overwrite example, light tree erasure, the sticky header, and the two performance screens.

Known limits

  1. A revision committed before a sync write but mounted after it shows the older value in the light tree until the next commit (sub-frame window, no known repro).
  2. Legacy layout animations proxy (shared element transitions off): a view with a running entering animation and no layout config that gets a layout-changing update keeps animating from the props captured before it, and a synchronous write followed directly by React removal starts the exiting animation from the old view. Both are unchanged from before this PR, because the synchronous path never fed the legacy proxy.
  3. React Native flags that write props without a props change: enableViewCulling re-creates a culled node from its shadow props, and Android enableAccumulatedUpdatesInRawPropsAndroid writes props on every insert. Both are off by default in 0.87. The carry rules cover reparenting caused by a changed ancestor; culling is not covered.
  4. getViewProp and measure read committed shadow values, not the values the synchronous path applied. Unchanged by this PR.
  5. Deferred: release-build and Android measurements, and a sticky-header manual pass with settling animations.

Commit compatibility

The cleanup keys each receipt on the props object of the pending node, not on the root. See the receipt.

  1. React commit branching. Supported. With enableFabricCommitBranching on, React merges a revision through a new root with source ReactRevisionMerge. The hook handles that source as a React commit when synchronous updates are on, so the merged tree gets fresh registry values. A React-branch commit never acknowledges its old root, because that root can be a staged branch. See React Native's merge and the hook.
  2. A later commit hook replaces the root. Supported. A hook that returns a new root but keeps the props objects of the pending nodes still matches the receipts. This also covers React Native's own state progression and layout-info clones.
  3. A later commit hook replaces the props object of a pending node. Not supported. The receipt no longer matches, so the pending keys stay in the store until a Reanimated commit, eviction or removal clears them. iOS props expose no general value comparison, so the fix cannot confirm equal values in a new props object without one comparison per prop.

Animation Backend remains outside the scope of this change.

Performance (baseline vs branch head, Instruments Time Profiler, iOS simulator, debug)

Baseline is 2b0d4b545a, the first commit of this PR, which only lifts the flag exclusion. Sync flag and shared element transitions on in both builds. One 15 s Time Profiler recording per run, two runs per cell, inclusive sample weights, 1 sample is about 1 ms. Both scenarios keep values pending on purpose: withRepeat never settles. The scenario screens are SynchronousPropsPerfCommitStreamExample and SynchronousPropsPerfStickyHeaderExample. Neither scenario contains a React commit, so the React receipt path costs nothing here.

Scenario A - 40 sync transforms animate while one height animation commits every frame (idle, no interaction):

Inclusive CPU, ms Baseline run 1 Baseline run 2 Branch run 1 Branch run 2 Change of means
App CPU total 6 869 6 307 7 019 6 841 +5 %
Main-thread CPU 6 782 6 257 7 004 6 821 +6 %
cloneShadowTreeWithNewProps 299 285 228 207 -25 %
Pending store (record, attach, collect, clear, ack) 0 0 80 74 new
getAncestors - - 5 10 new
Light tree feed (applySynchronousProps) 1 354 1 295 1 402 1 349 +4 %
pullTransaction 488 455 560 557 +18 %
synchronouslyUpdateUIProps 1 918 1 777 2 019 1 943 +7 %

The commit of the height animation now clones only the node it touches. Before the selective carry, the same scenario measured +38 % with 2 s of extra clones.

Scenario B - sticky header with 20 sync-animated rows, 10 scripted swipes (state-commit stream):

Inclusive CPU, ms Baseline run 1 Baseline run 2 Branch run 1 Branch run 2 Change of means
App CPU total 8 336 7 610 8 935 9 421 +15 %
Main-thread CPU 7 335 6 625 7 907 8 215 +16 %
Commit hook (shadowTreeWillCommit) 6 9 221 189 +198
cloneShadowTreeWithNewProps 5 5 7 4 +1
Pending store (record, attach, collect, clear, ack) 0 0 289 247 new
getAncestors - - 145 131 new
Light tree feed (applySynchronousProps) 1 563 1 456 1 910 1 902 +26 %
pullTransaction 213 207 199 258 +9 %
synchronouslyUpdateUIProps 1 782 1 896 2 545 2 601 +40 %

The header stays pinned and scrolling stays responsive in both builds. State commits no longer clone the rows; the hook cost is now the per-row check of the ancestor chain. The sync apply path and the light tree feed are the same code in both builds; their larger numbers in this run come from more animation frames during the scripted scroll, not from this PR. Before the selective carry, the same scenario measured +24 % with 1.5 s of extra clones and 0.9 s of extra mount diffing.

Traces, per-run JSON and the analysis script are in the local .tmp/perf3/ directory of the measuring worktree, not in the repository.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview 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
📝 Walkthrough

Walkthrough

The change adds synchronous-prop classification and per-surface pending-prop tracking. Commit handling carries pending props into shadow trees and retries cancelled flushes. Layout animation proxies merge synchronous props into the light tree and retarget ongoing animations. Native tests cover acknowledgment, versioning, stale writes, and surface removal. The common app adds and registers synchronous-prop and shared-transition examples.

Priority: ➖ Normal — Schedule the synchronous-props commit change because it alters Reanimated’s cross-platform commit, registry, and layout-animation behavior across multiple runtime paths.

Merge Risk: 🟡 Moderate · up to be34c

Conflicting React commits can overwrite synchronous visual values, and disabling synchronous updates does not prevent direct native updates. Both behaviors affect the feature’s core rendering contract and should be corrected before merge.

🚥 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.
Description check ✅ Passed The description directly explains the synchronous prop commit changes, cleanup rules, animation behavior, testing, performance, and known limits.
Title check ✅ Passed The title clearly summarizes the main change: preserving synchronous prop values across commits.

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.

This reverts commit f1075fe. Review found two defects in the overlay:
a stale entry could override a newer committed value when a mixed batch
routed the synchronous keys through the shadow tree, and the eviction
relay took the registry lock and the proxy lock in an order that a custom
layout-animation config calling setNativeProps could invert. The next
commits fix the erasure at its source instead: commits carry the
synchronous values, so the light tree receives correct props without a
patch layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pawicao
pawicao force-pushed the @pawicao/sync-props-commit-consistency branch from 89e8edd to 5e57d99 Compare September 8, 2026 14:45

@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.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 9d9fadce-1fc9-4ad0-a2bb-25977a23fdf4

📥 Commits

Reviewing files that changed from the base of the PR and between 61e25de and 89e8edd.

📒 Files selected for processing (23)
  • apps/common-app/src/apps/reanimated/examples/SharedElementTransitions/AnimatedTransform.tsx
  • apps/common-app/src/apps/reanimated/examples/SharedElementTransitions/LightTreeErasure.tsx
  • apps/common-app/src/apps/reanimated/examples/SynchronousPropsOverwriteExample.tsx
  • apps/common-app/src/apps/reanimated/examples/index.ts
  • packages/react-native-reanimated/CHANGELOG.md
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/ReanimatedCommitHook.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/ReanimatedCommitHook.h
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/SynchronousProps.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/SynchronousProps.h
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/UpdatesRegistryManager.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/UpdatesRegistryManager.h
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyRegistry.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyRegistry.h
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsUtils.h
  • packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.h
  • packages/react-native-reanimated/android/build.gradle.kts
  • packages/react-native-reanimated/scripts/reanimated_utils.rb
💤 Files with no reviewable changes (2)
  • packages/react-native-reanimated/scripts/reanimated_utils.rb
  • packages/react-native-reanimated/android/build.gradle.kts

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

pawicao and others added 4 commits September 8, 2026 16:50
The experimental proxy started a LAYOUT animation for every Update on a
configured view, even when only props changed. Mirror the legacy proxy:
start on a frame change, retarget a running animation otherwise. The
retarget rebases the cached animation style onto the new target props, so
the final emitted Update carries the change even when no progress
callback runs afterwards. The opacity that the restoration inserts into
the reused style object is marked, so it never overrides a retargeted
value.

The next commit attaches synchronous values to commits, which produces
exactly such props-only Updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The synchronous path applies props straight to native views, so committed
trees lag behind them. Any commit that clones a lagging view then carries
stale props: the mounting layer writes them back to the view (visible
transform jumps for 8-10 frames), and the layout animations proxy copies
them into its light tree, which breaks shared-transition snapshots.

Record every applied synchronous batch as pending values in the
UpdatesRegistryManager. Commits that happen anyway attach them: Reanimated
commits take the pending values of their surfaces, and the commit hook
attaches them to non-React-source commits, which clone from trees that
never saw the synchronous values. A pending value leaves the store when a
Reanimated commit succeeds with it, when settled eviction hands it to
React, when its CSS registry gives it up (a registry flush commit carries
it first, retried after a commit pause), or when its node unmounts.

The write-time light-tree feed stays: it covers the window before the
first commit, which no commit can cover because the shared-transition
snapshot is read before the same transaction updates the light tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One button moves a box through the synchronous path and resizes it 100 ms
later through the shadow path. Without commit-carried synchronous values,
the resize commit wrote the stale transform back to the native view for
8-10 frames on iOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arries

During a state-commit stream (a sticky-header scroll, for example) the
commit hook cloned the same pending values onto every commit, although
each decorated commit's clones keep the props pointers and therefore
already carry them. That cost 40 percent extra app CPU in a profile with
20 never-settling synchronous animations under scripted scrolling.

Track the pending-store version in the manager and, per surface, the last
root the hook produced. When the next commit builds on that root and the
version is unchanged, the base already carries the values and the hook
returns the tree untouched. A failed commit never matches (its root never
becomes a base), so the hook attaches again - correctness needs no commit
acknowledgement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pawicao
pawicao force-pushed the @pawicao/sync-props-commit-consistency branch from 5e57d99 to 58bab99 Compare September 8, 2026 14:52

@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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp (1)

417-417: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Retarget active animations after synchronous prop updates.

applySynchronousProps updates only LightNode::current.props. Active frames use layoutAnimation.finalView and cached UpdateValues.newProps, which can still contain the old props. The next progress frame can therefore overwrite the synchronous native value. Update finalView.props and rebase the cached UpdateValues.newProps with the cloned props, as updateOngoingAnimationTarget does.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: fdfd1b33-763e-44ad-984f-3575e717a5eb

📥 Commits

Reviewing files that changed from the base of the PR and between 5e57d99 and 58bab99.

📒 Files selected for processing (7)
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/ReanimatedCommitHook.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/ReanimatedCommitHook.h
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/UpdatesRegistryManager.h
  • 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/LayoutAnimationsUtils.h
  • packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp

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

pawicao and others added 2 commits September 8, 2026 17:05
Registries flush in order and the pending store merges them per tag and
key, so a CSS registry can replace an animated value before the animated
registry evicts its older one. The eviction then erased the newer pending
value by key alone.

Reported by CodeRabbit on #10480.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On Android node->current accumulates the diffed raw props of every
Update, while a mutation carries only the latest diff - and Android
mounting applies exactly the raw props it receives. Retargeting from the
mutation could therefore drop earlier keys from emitted animation frames.
The restart path already uses the merged view.

Reported by CodeRabbit on #10480.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp (1)

445-446: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Retarget active animations after synchronous prop updates.

LayoutAnimationsProxy_Experimental::applySynchronousProps updates node->current.props but skips updateOngoingAnimationTarget. When an active animation has a matching tag, the next addOngoingAnimations pass can use stale finalView or cached props and overwrite the synchronous value.

     node->current.props = getComponentDescriptorForShadowView(node->current)
                               .cloneProps(propsParserContext, node->current.props, RawProps(std::move(rawProps)));
+    if (layoutAnimations_.contains(shadowNodeFamily->getTag())) {
+      updateOngoingAnimationTarget(shadowNodeFamily->getTag(), node->current);
+    }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e185163e-e000-4dc1-8cbd-9c42b55756d8

📥 Commits

Reviewing files that changed from the base of the PR and between 58bab99 and 7df38ef.

📒 Files selected for processing (3)
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/UpdatesRegistryManager.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/UpdatesRegistryManager.cpp

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

@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.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 4cbb3698-3ea7-42b6-8fc5-34c3a57d9838

📥 Commits

Reviewing files that changed from the base of the PR and between 7df38ef and be34c6e.

📒 Files selected for processing (19)
  • packages/react-native-reanimated/CHANGELOG.md
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/ReanimatedCommitHook.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/ReanimatedMountHook.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/UpdatesRegistryManager.cpp
  • packages/react-native-reanimated/Common/cpp/reanimated/Fabric/updates/UpdatesRegistryManager.h
  • 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
  • packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
  • packages/react-native-reanimated/__tests__/native/README.md
  • packages/react-native-reanimated/__tests__/native/stubs/react/debug/react_native_assert.h
  • packages/react-native-reanimated/__tests__/native/stubs/reanimated/CSS/InterpolatorRegistry.h
  • packages/react-native-reanimated/__tests__/native/stubs/reanimated/CSS/registries/StaticPropsRegistry.h
  • packages/react-native-reanimated/__tests__/native/stubs/reanimated/Fabric/ShadowTreeCloner.h
  • packages/react-native-reanimated/__tests__/native/stubs/reanimated/Fabric/updates/UpdatesRegistry.h
  • packages/react-native-reanimated/__tests__/native/stubs/reanimated/Tools/FeatureFlags.h
  • packages/react-native-reanimated/__tests__/native/synchronousProps.cpp
  • packages/react-native-reanimated/package.json
  • packages/react-native-reanimated/scripts/test-native.mjs
💤 Files with no reviewable changes (1)
  • packages/react-native-reanimated/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h

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

A React commit hook records which pending synchronous keys it covers.
The receipt was keyed on the root object. Branch merges, layout-info
clones and later commit hooks replace the root, so the receipt never
matched and stale pending values came back in later commits.

Keep one receipt per pending node, keyed on the Props object the hook
created. Props identity survives root clones, state progression and
branch merges. Acknowledge receipts against main roots only: the old
root of every non-React commit and of the Reanimated transaction, and
the mounted root. Handle ReactRevisionMerge as a React commit when
synchronous updates are on, so the merged tree gets fresh registry
values.

A later hook that replaces the Props object of the same node is still
not covered.
… can reach a view

Every Reanimated commit and every non-React commit cloned all nodes with
pending synchronous values. With 40 synchronous transforms and one
height animation that commits each frame, app CPU rose 38 percent over
the baseline; a sticky header scroll with 20 synchronous rows rose 24
percent.

Mounting writes props to a native view only when the props object
changed, so an untouched node cannot be reverted by a commit. A
Reanimated commit now carries the pending values of the nodes in its
batch, of pending nodes below a batch node (a changed ancestor can
reparent them), and of views with a layout, entering or exiting
animation config, because their shadow props feed animation frames.
The commit hook carries a pending node into a non-React commit only
when the new root gives it new props, a new place, or an ancestor with
new props. Selection runs in two passes so a carried ancestor always
brings its pending descendants; the subtree walk has a visit budget
with a complete fallback. Clearing on success covers only the carried
families. Registry flush commits still carry everything.

The light tree keeps a node's props on a layout-only update, since such
a mutation carries no new prop values and the light node already holds
the synchronous feed. The legacy proxy retargets a running animation
and its saved opacity when it forwards a props-only update.

Measured on the same scenarios: app CPU +5 percent and +15 percent,
with the remaining scroll cost mostly in animation frames outside this
change.
pawicao added a commit that referenced this pull request Sep 10, 2026
…10503)

> [!NOTE]
> This pull request was authored by AI on behalf of @pawicao.

## Summary

Supersedes #10480 with a 23-line change that uses only existing state.

With the synchronous prop updates on, a Reanimated commit for a view
clones props from a shadow tree that never saw the values the
synchronous path applied. On iOS, `RCTViewComponentView` diffs the
incoming props against the view's own `_props`, so the commit writes the
stale transform back to the layer for several frames. A layout-only
Update mutation for the same reason replaced the light-tree props that
`applySynchronousProps` had merged, so a shared element transition
started from the untransformed position.

The fix:

1. `UpdatesRegistryManager::addRegistryProps` appends each registry's
current props to the families already in a Reanimated commit. The
registry holds a view's latest values until React has them
(`UpdatesRegistry::flush` stores the batch before it is partitioned; the
settled-props tick erases an entry only after React received it), so no
second store is needed.
2. `commitUpdates` calls it once per commit, under the registry lock,
before the commit loop, only in the branch that commits a batch. The
flush branch already carries every registry value through
`collectProps`.
3. `updateLightTree` keeps the light node's props when an Update
mutation carries the same Props object. Mounting writes props to a view
only when that object changed, so such an Update cannot have changed the
native view.

No view enters a commit because of this change, no sync-only commit is
added, and the commit hook is untouched. Compared with #10480, this
drops the pending store, per-key versions, commit receipts, root
identity checks, descendant scans, the non-React hook path and the
layout-animation retargeting; every one of them rested on a review
hypothesis, none on a device reproduction.

## Test plan

Set `IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS: true` in
`apps/fabric-example/package.json` (`ENABLE_SHARED_ELEMENT_TRANSITIONS`
stays on), run `pod install`, build `FabricExample` for the iOS
simulator.

Overwrite case. Mount this screen and press the button several times,
1-2 s apart. Expected: the box keeps its new position when its height
changes. On the base, the box jumps back to the old position for 3-8
frames after each press.

```tsx
import { Button, StyleSheet, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';

export default function Overwrite() {
  const offset = useSharedValue(0);
  const height = useSharedValue(100);
  const transformStyle = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }] }));
  const heightStyle = useAnimatedStyle(() => ({ height: height.value }));
  return (
    <View style={{ padding: 20, gap: 20 }}>
      <Button
        title="Move, then resize"
        onPress={() => {
          offset.value = offset.value === 0 ? 100 : 0;
          setTimeout(() => {
            height.value = height.value === 100 ? 150 : 100;
          }, 100);
        }}
      />
      <Animated.View style={[{ width: 100, backgroundColor: 'purple' }, transformStyle, heightStyle]} />
    </View>
  );
}
```

Shared transition case. In the `[SET] Light Tree Erasure Repro` example
on the base branch, press `shift, erase and go (regression)` on a fresh
mount: the transition must start from the green frame. On the base it
starts from the red frame in most fresh-mount runs; the runs that pass
are the ones where the 500 ms settled-props tick lands inside the 250 ms
window before navigation.

Measured (matched Debug builds, 30 fps recordings, per-frame pixel
analysis): overwrite base 4/4 presses stale, patched 0/4; erasure base
4/6 fresh-mount runs wrong, patched 0/6 (two iterations); sticky header
and a React parent-layout change unchanged; Android Pixel 9a clean with
the flag off and on. Cost: one extra props parse per family that commits
a layout prop in a frame; on 300 such views per frame, `mergeProps` +49%
and 3 percentage points more repeated frames on a fixture that is
already over budget on the base.

## Changelog

- [ ] I added an entry to the `Unpublished` section of each changed
package's `CHANGELOG.md`, or this PR does not change
`react-native-reanimated` or `react-native-worklets`.

No separate entry, as on #10480: the behavior ships under the #10416
entry that this branch is stacked on.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@pawicao pawicao closed this Sep 10, 2026
pawicao added a commit that referenced this pull request Sep 10, 2026
…10503)

> [!NOTE]
> This pull request was authored by AI on behalf of @pawicao.

Supersedes #10480 with a 23-line change that uses only existing state.

With the synchronous prop updates on, a Reanimated commit for a view
clones props from a shadow tree that never saw the values the
synchronous path applied. On iOS, `RCTViewComponentView` diffs the
incoming props against the view's own `_props`, so the commit writes the
stale transform back to the layer for several frames. A layout-only
Update mutation for the same reason replaced the light-tree props that
`applySynchronousProps` had merged, so a shared element transition
started from the untransformed position.

The fix:

1. `UpdatesRegistryManager::addRegistryProps` appends each registry's
current props to the families already in a Reanimated commit. The
registry holds a view's latest values until React has them
(`UpdatesRegistry::flush` stores the batch before it is partitioned; the
settled-props tick erases an entry only after React received it), so no
second store is needed.
2. `commitUpdates` calls it once per commit, under the registry lock,
before the commit loop, only in the branch that commits a batch. The
flush branch already carries every registry value through
`collectProps`.
3. `updateLightTree` keeps the light node's props when an Update
mutation carries the same Props object. Mounting writes props to a view
only when that object changed, so such an Update cannot have changed the
native view.

No view enters a commit because of this change, no sync-only commit is
added, and the commit hook is untouched. Compared with #10480, this
drops the pending store, per-key versions, commit receipts, root
identity checks, descendant scans, the non-React hook path and the
layout-animation retargeting; every one of them rested on a review
hypothesis, none on a device reproduction.

Set `IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS: true` in
`apps/fabric-example/package.json` (`ENABLE_SHARED_ELEMENT_TRANSITIONS`
stays on), run `pod install`, build `FabricExample` for the iOS
simulator.

Overwrite case. Mount this screen and press the button several times,
1-2 s apart. Expected: the box keeps its new position when its height
changes. On the base, the box jumps back to the old position for 3-8
frames after each press.

```tsx
import { Button, StyleSheet, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';

export default function Overwrite() {
  const offset = useSharedValue(0);
  const height = useSharedValue(100);
  const transformStyle = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }] }));
  const heightStyle = useAnimatedStyle(() => ({ height: height.value }));
  return (
    <View style={{ padding: 20, gap: 20 }}>
      <Button
        title="Move, then resize"
        onPress={() => {
          offset.value = offset.value === 0 ? 100 : 0;
          setTimeout(() => {
            height.value = height.value === 100 ? 150 : 100;
          }, 100);
        }}
      />
      <Animated.View style={[{ width: 100, backgroundColor: 'purple' }, transformStyle, heightStyle]} />
    </View>
  );
}
```

Shared transition case. In the `[SET] Light Tree Erasure Repro` example
on the base branch, press `shift, erase and go (regression)` on a fresh
mount: the transition must start from the green frame. On the base it
starts from the red frame in most fresh-mount runs; the runs that pass
are the ones where the 500 ms settled-props tick lands inside the 250 ms
window before navigation.

Measured (matched Debug builds, 30 fps recordings, per-frame pixel
analysis): overwrite base 4/4 presses stale, patched 0/4; erasure base
4/6 fresh-mount runs wrong, patched 0/6 (two iterations); sticky header
and a React parent-layout change unchanged; Android Pixel 9a clean with
the flag off and on. Cost: one extra props parse per family that commits
a layout prop in a frame; on 300 such views per frame, `mergeProps` +49%
and 3 percentage points more repeated frames on a fixture that is
already over budget on the base.

- [ ] I added an entry to the `Unpublished` section of each changed
package's `CHANGELOG.md`, or this PR does not change
`react-native-reanimated` or `react-native-worklets`.

No separate entry, as on #10480: the behavior ships under the #10416
entry that this branch is stacked on.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant