APP-2974: Fix composer not scrollable when Reduced Motion is on (#11628)

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Oleksii Bulenok
2026-09-02 18:31:22 +02:00
committed by GitHub
parent 99715b9a67
commit 87329397a4
9 changed files with 147 additions and 545 deletions
+29
View File
@@ -1,3 +1,19 @@
diff --git a/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp b/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
index a2d707ce76ceb35456eb075d06473ac98ad8b5eb..be2e3d66031d2bbd6208a7cdc1700a9daefb1267 100644
--- a/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
+++ b/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
@@ -24,10 +24,7 @@ namespace expo {
return;
}
- workletRuntime->executeSync([func = std::move(func)](jsi::Runtime &rt) -> jsi::Value {
- func(rt);
- return jsi::Value::undefined();
- });
+ workletRuntime->runSync(func);
}
} // namespace expo
diff --git a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8454eab96 100644
--- a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
@@ -13,3 +29,16 @@ index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8
// Check for Content-Type
val skipContentTypes = listOf(
"text/event-stream", // Server Sent Events
diff --git a/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm b/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
index 126545fae12dc3af71e0ea382b976ef430e62d17..d97163a2e9a9db09f43786bc919f2fa229f21901 100644
--- a/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
+++ b/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
@@ -233,7 +233,7 @@ - (void)executeWorkletWithRuntimeHandle:(id)runtimeHandle
return;
}
- workletRuntime->executeSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value {
+ workletRuntime->runSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value {
return callWorklet(rt, worklet, arguments);
});
}
+12
View File
@@ -3,3 +3,15 @@
### Android: bitdrift interceptor
Fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools.
### iOS + Android: worklets `runSync` migration
Backport of https://github.com/expo/expo/pull/49366 ("[sdk-57] Backport
WorkletRuntime runSync migration"). react-native-worklets 0.12 removed the
deprecated `WorkletRuntime::executeSync`, so the worklets adapters in
`ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm` and
`android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp` fail to compile
against it. The patch swaps both call sites to `runSync` (available since
worklets 0.7.0). Required for the react-native-reanimated 4.6.0 /
react-native-worklets 0.12.1 upgrade; drop once expo-modules-core ships a
version containing that PR.
-283
View File
@@ -1,283 +0,0 @@
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
index 8603591..20d042b 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
@@ -62,11 +62,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
const std::shared_ptr<const ContextContainer> &contextContainer,
jsi::Runtime &uiRuntime,
- const std::shared_ptr<UIScheduler> &uiScheduler
+ const std::shared_ptr<UIScheduler> &uiScheduler,
+ const std::shared_ptr<facebook::react::UIManager> &uiManager
#ifdef ANDROID
,
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
- const std::shared_ptr<facebook::react::UIManager> &uiManager,
const std::shared_ptr<facebook::react::CallInvoker> &jsInvoker
#endif
)
@@ -74,11 +74,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
contextContainer_(contextContainer),
componentDescriptorRegistry_(componentDescriptorRegistry),
uiRuntime_(uiRuntime),
- uiScheduler_(uiScheduler)
+ uiScheduler_(uiScheduler),
+ uiManager_(uiManager)
#ifdef ANDROID
,
preserveMountedTags_(filterUnmountedTagsFunction),
- uiManager_(uiManager),
jsInvoker_(jsInvoker)
#endif
{
@@ -98,10 +98,10 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
SharedComponentDescriptorRegistry componentDescriptorRegistry_;
jsi::Runtime &uiRuntime_;
const std::shared_ptr<UIScheduler> uiScheduler_;
+ std::shared_ptr<facebook::react::UIManager> uiManager_;
PreserveMountedTagsFunction preserveMountedTags_;
#ifdef ANDROID
- std::shared_ptr<facebook::react::UIManager> uiManager_;
std::shared_ptr<facebook::react::CallInvoker> jsInvoker_;
void restoreOpacityInCaseOfFlakyEnteringAnimation(SurfaceId surfaceId) const;
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
index fcc677f..115971a 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
@@ -67,11 +67,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
const std::shared_ptr<const ContextContainer> &contextContainer,
jsi::Runtime &uiRuntime,
- const std::shared_ptr<UIScheduler> &uiScheduler
+ const std::shared_ptr<UIScheduler> &uiScheduler,
+ const std::shared_ptr<UIManager> &uiManager
#ifdef ANDROID
,
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
- const std::shared_ptr<UIManager> &uiManager,
const std::shared_ptr<CallInvoker> &jsInvoker
#endif
)
@@ -80,11 +80,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
componentDescriptorRegistry,
contextContainer,
uiRuntime,
- uiScheduler
+ uiScheduler,
+ uiManager
#ifdef ANDROID
,
filterUnmountedTagsFunction,
- uiManager,
jsInvoker
#endif
),
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
index df53d8d..735f138 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
@@ -1,6 +1,7 @@
#include <reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h>
#include <react/debug/react_native_assert.h>
+#include <react/renderer/mounting/ShadowTree.h>
#include <react/renderer/mounting/ShadowViewMutation.h>
#include <memory>
@@ -60,14 +61,37 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Legacy::pullTransaction
parseRemoveMutations(movedViews, mutations, roots);
- auto shouldAnimate = !surfacesToRemove_.contains(surfaceId);
- surfacesToRemove_.erase(surfaceId);
+ // Consume the teardown mark only on the transaction that actually clears
+ // the root — pulls emitted for animation frames must not eat it early.
+ auto shouldAnimate = true;
+ const auto removesRootChildren = std::ranges::any_of(mutations, [surfaceId](const auto &mutation) {
+ return mutation.type == ShadowViewMutation::Remove && mutation.parentTag == surfaceId;
+ });
+ if (removesRootChildren) {
+ shouldAnimate = surfacesToRemove_.erase(surfaceId) == 0;
+ }
handleRemovals(filteredMutations, roots, deadNodes, shouldAnimate);
handleUpdatesAndEnterings(filteredMutations, movedViews, mutations, propsParserContext, surfaceId);
addOngoingAnimations(surfaceId, filteredMutations);
+ // The LayoutAnimationDriver can emit a final keyframe update in the same
+ // transaction as the deferred Remove/Delete it withheld for a delete
+ // animation. We emit removals before updates, so such an update would
+ // otherwise reach the mounting layer after its view was deleted.
+ std::unordered_set<Tag> deletedTags;
+ for (const auto &mutation : filteredMutations) {
+ if (mutation.type == ShadowViewMutation::Delete) {
+ deletedTags.insert(mutation.oldChildShadowView.tag);
+ }
+ }
+ if (!deletedTags.empty()) {
+ std::erase_if(filteredMutations, [&deletedTags](const auto &mutation) {
+ return mutation.type == ShadowViewMutation::Update && deletedTags.contains(mutation.newChildShadowView.tag);
+ });
+ }
+
return MountingTransaction{surfaceId, transactionNumber, std::move(filteredMutations), telemetry};
}
@@ -998,23 +1022,22 @@ inline bool MutationNode::isMutationNode() {
return true;
}
-// UIManagerAnimationDelegate
-
-void LayoutAnimationsProxy_Legacy::uiManagerDidConfigureNextLayoutAnimation(
- jsi::Runtime &runtime,
- const RawValue &config,
- const jsi::Value &successCallbackValue,
- const jsi::Value &failureCallbackValue) const {}
+// UIManagerCommitHook
-void LayoutAnimationsProxy_Legacy::setComponentDescriptorRegistry(
- const SharedComponentDescriptorRegistry &componentDescriptorRegistry) {}
-
-bool LayoutAnimationsProxy_Legacy::shouldAnimateFrame() const {
- return false;
-}
-
-void LayoutAnimationsProxy_Legacy::stopSurface(SurfaceId surfaceId) {
- surfacesToRemove_.insert(surfaceId);
+// Surface teardown commits an empty root (SurfaceHandler::stop) before the
+// teardown transaction is pulled — mark it so pullTransaction skips exit
+// animations. Reading the ShadowTreeRegistry here instead would deadlock (#8579).
+RootShadowNode::Unshared LayoutAnimationsProxy_Legacy::shadowTreeWillCommit(
+ const ShadowTree &shadowTree,
+ const RootShadowNode::Shared & /*oldRootShadowNode*/,
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept {
+ auto lock = std::unique_lock<std::recursive_mutex>(mutex);
+ if (newRootShadowNode->getChildren().empty()) {
+ surfacesToRemove_.insert(shadowTree.getSurfaceId());
+ } else {
+ surfacesToRemove_.erase(shadowTree.getSurfaceId());
+ }
+ return newRootShadowNode;
}
} // namespace reanimated
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
index 57cc134..1a2966c 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
@@ -3,8 +3,8 @@
#include <react/renderer/componentregistry/ComponentDescriptorFactory.h>
#include <react/renderer/mounting/MountingOverrideDelegate.h>
#include <react/renderer/scheduler/Scheduler.h>
-#include <react/renderer/uimanager/UIManagerAnimationDelegate.h>
#include <react/renderer/uimanager/UIManagerBinding.h>
+#include <react/renderer/uimanager/UIManagerCommitHook.h>
#include <reanimated/Compat/WorkletsApi.h>
#include <reanimated/LayoutAnimations/LayoutAnimationsManager.h>
#include <reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h>
@@ -102,7 +102,7 @@ struct SurfaceContext {
};
struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
- public UIManagerAnimationDelegate,
+ public UIManagerCommitHook,
public std::enable_shared_from_this<LayoutAnimationsProxy_Legacy> {
mutable std::unordered_map<Tag, std::shared_ptr<Node>> nodeForTag_;
mutable std::recursive_mutex mutex;
@@ -116,11 +116,11 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
const std::shared_ptr<const ContextContainer> &contextContainer,
jsi::Runtime &uiRuntime,
- const std::shared_ptr<UIScheduler> &uiScheduler
+ const std::shared_ptr<UIScheduler> &uiScheduler,
+ const std::shared_ptr<UIManager> &uiManager
#ifdef ANDROID
,
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
- const std::shared_ptr<UIManager> &uiManager,
const std::shared_ptr<CallInvoker> &jsInvoker
#endif
)
@@ -129,14 +129,19 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
componentDescriptorRegistry,
contextContainer,
uiRuntime,
- uiScheduler
+ uiScheduler,
+ uiManager
#ifdef ANDROID
,
filterUnmountedTagsFunction,
- uiManager,
jsInvoker
#endif
) {
+ uiManager->registerCommitHook(*this);
+ }
+
+ ~LayoutAnimationsProxy_Legacy() override {
+ uiManager_->unregisterCommitHook(*this);
}
void startEnteringAnimation(const int tag, ShadowViewMutation &mutation) const;
@@ -206,19 +211,15 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
const TransactionTelemetry &telemetry,
ShadowViewMutationList mutations) const override;
- // UIManagerAnimationDelegate
-
- void uiManagerDidConfigureNextLayoutAnimation(
- jsi::Runtime &runtime,
- const RawValue &config,
- const jsi::Value &successCallbackValue,
- const jsi::Value &failureCallbackValue) const override;
-
- void setComponentDescriptorRegistry(const SharedComponentDescriptorRegistry &componentDescriptorRegistry) override;
+ // UIManagerCommitHook
- bool shouldAnimateFrame() const override;
+ void commitHookWasRegistered(const UIManager &uiManager) noexcept override {}
+ void commitHookWasUnregistered(const UIManager &uiManager) noexcept override {}
- void stopSurface(SurfaceId surfaceId) override;
+ RootShadowNode::Unshared shadowTreeWillCommit(
+ const ShadowTree &shadowTree,
+ const RootShadowNode::Shared &oldRootShadowNode,
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept override;
};
} // namespace reanimated
diff --git a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
index 2b68ff7..d08b1ae 100644
--- a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
+++ b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
@@ -1235,22 +1235,19 @@ void ReanimatedModuleProxy::initializeLayoutAnimationsProxy() {
#endif
layoutAnimationsProxy_ = std::move(layoutAnimationsProxyExperimental);
} else {
- auto layoutAnimationsProxyLegacy = std::make_shared<LayoutAnimationsProxy_Legacy>(
+ layoutAnimationsProxy_ = std::make_shared<LayoutAnimationsProxy_Legacy>(
layoutAnimationsManager_,
componentDescriptorRegistry,
scheduler->getContextContainer(),
getJSIRuntimeFromWorkletRuntime(uiRuntime_),
- uiScheduler_
+ uiScheduler_,
+ uiManager_
#ifdef ANDROID
,
filterUnmountedTagsFunction_,
- uiManager_,
jsInvoker_
#endif
);
- // TODO (future): support in experimental
- uiManager_->setAnimationDelegate(layoutAnimationsProxyLegacy.get());
- layoutAnimationsProxy_ = std::move(layoutAnimationsProxyLegacy);
}
}
}
@@ -1,27 +0,0 @@
# react-native-reanimated@4.5.3.patch
Backport of https://github.com/software-mansion/react-native-reanimated/pull/9901
("refactor(LayoutAnimations): stop taking over UIManagerAnimationDelegate").
Reanimated's legacy `LayoutAnimationsProxy_Legacy` registered itself as the
`UIManagerAnimationDelegate` only to receive `stopSurface`. Occupying that slot
overwrites the `LayoutAnimationDriver` that React Native installs there, which
silently breaks `LayoutAnimation.configureNext` for the whole app.
The patch makes the proxy detect surface teardown itself via a
`UIManagerCommitHook` (a commit with an empty root marks the surface in
`surfacesToRemove_`), frees the animation-delegate slot, and drops final
keyframe `Update` mutations for views deleted in the same transaction (a
deterministic `configureNext` delete-animation crash found in this app).
`uiManager` moves from Android-only to shared constructor args since the hook
registration needs it on both platforms.
Only the `packages/react-native-reanimated` part of the PR is included (the
`apps/fabric-example` hunk is not part of the published package), and the hunks
were rebased onto the 4.5.3 release sources.
Note that upstream's own `pullTransaction` rework in 4.5.3 (the new
`reconcileContradictedRemovals`) covers a different case - a `Create`/`Insert`
contradicting a *withheld* exit removal - and does not subsume the deleted-tag
`Update` filter here, which guards against the `LayoutAnimationDriver` final
keyframe. That driver only runs at all once this patch frees the delegate slot.
@@ -1,88 +0,0 @@
diff --git a/lib/module/threads.js b/lib/module/threads.js
index c17e314..71f3cf7 100644
--- a/lib/module/threads.js
+++ b/lib/module/threads.js
@@ -1,7 +1,6 @@
'use strict';
import { IS_JEST } from './platformChecker';
-import { mockedRequestAnimationFrame } from "./runLoop/uiRuntime/mockedRequestAnimationFrame.js";
export function scheduleOnUI(worklet, ...args) {
enqueueUI(worklet, args);
}
@@ -23,38 +22,50 @@ export function scheduleOnRN(fun, ...args) {
queueMicrotask(args.length ? () => fun(...args) : fun);
}
export function runOnUIAsync(worklet, ...args) {
- return new Promise(resolve => {
- enqueueUI(worklet, args, resolve);
+ return new Promise((resolve, reject) => {
+ enqueueUI(worklet, args, resolve, reject);
});
}
let runOnUIQueue = [];
-function enqueueUI(worklet, args, resolve) {
- if (IS_JEST) {
- mockedRequestAnimationFrame(() => {
- const result = worklet(...args);
- resolve?.(result);
- });
- } else {
- const job = [worklet, args, resolve];
- runOnUIQueue.push(job);
- if (runOnUIQueue.length === 1) {
+function enqueueUI(worklet, args, resolve, reject) {
+ const job = [worklet, args, resolve, reject];
+ runOnUIQueue.push(job);
+ if (runOnUIQueue.length === 1) {
+ if (IS_JEST) {
flushUIQueue();
+ } else {
+ queueMicrotask(flushUIQueue);
}
}
}
+let offset = 0;
function flushUIQueue() {
- queueMicrotask(() => {
- const queue = runOnUIQueue;
- runOnUIQueue = [];
- requestAnimationFrameImpl(() => {
- queue.forEach(([workletFunction, workletArgs, jobResolve]) => {
- const result = workletFunction(...workletArgs);
- if (jobResolve) {
- jobResolve(result);
+ const queue = runOnUIQueue;
+ runOnUIQueue = [];
+ requestAnimationFrame(() => {
+ offset = 0;
+ while (queue.length > offset) {
+ try {
+ drainUIQueue(queue);
+ } catch (e) {
+ const [, , , jobReject] = queue[offset - 1];
+ if (jobReject) {
+ jobReject(e);
+ } else {
+ console.error(e);
}
- });
- });
+ }
+ }
});
}
-const requestAnimationFrameImpl = !globalThis.requestAnimationFrame ? mockedRequestAnimationFrame : globalThis.requestAnimationFrame;
-//# sourceMappingURL=threads.js.map
\ No newline at end of file
+function drainUIQueue(queue) {
+ while (queue.length > offset) {
+ const [workletFunction, workletArgs, jobResolve] = queue[offset];
+ offset++;
+ const result = workletFunction(...workletArgs);
+ if (jobResolve) {
+ jobResolve(result);
+ }
+ }
+}
+//# sourceMappingURL=threads.js.map
@@ -1,37 +0,0 @@
# react-native-worklets@0.11.3.patch
Backport of https://github.com/software-mansion/react-native-reanimated/pull/10167
("fix(Worklets): web scheduleOnUI implementation on errors").
## The bug
On web, `scheduleOnUI`/`runOnUI` batch their callbacks per animation frame and
run them with `queue.forEach(...)`. If any callback in the batch throws,
`forEach` aborts immediately and every callback still queued after it is
silently dropped - it never runs, and any `runOnUIAsync` promise for it never
resolves or rejects.
Reanimated's own internals rely on those callbacks running in order (e.g. to
populate `frameCallbackRegistry`), so a single throwing worklet in a batch can
leave later, unrelated frame callbacks referencing state that was never set
up, surfacing as:
```
TypeError: can't access property "startTime", this.frameCallbackRegistry.get(...) is undefined
```
## The fix
Replace the `forEach` batch runner with a `while` loop (`drainUIQueue`) that
tracks its position via an `offset`, wrapped in a `try`/`catch`. A throw now
only aborts the *current* callback: the loop resumes at the next queued job
instead of abandoning the rest of the batch. Errors are routed to the
matching `runOnUIAsync` promise's `reject` (a new second argument threaded
through `enqueueUI`) if there is one, or `console.error`-ed otherwise, rather
than crashing the whole frame.
Only `lib/module/threads.js` (the compiled web entry point actually loaded by
the app's webpack build) is patched - `src/threads.ts` is unused here since
this repo's web build resolves the package's `module` field, and native
platforms use the separate `threads.native.ts` implementation untouched by
this PR.