fix(app): clean up native animation and layout lifecycles (#7914)

Worklets 0.10.4 and Reanimated 4.5.5 while keeping the stale-view guard from #7907; backport the Enriched Markdown settled-row measurement fix (upstream #924); release each bottom sheet's Dimensions subscription on unmount.

(cherry picked from commit e277656cbd)
This commit is contained in:
Mohamed Boudra
2026-10-26 11:07:42 +01:00
parent 4cbbac563d
commit 3b67e20d89
8 changed files with 226 additions and 51 deletions
+9 -42
View File
@@ -30269,9 +30269,9 @@
}
},
"node_modules/react-native-reanimated": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
"version": "4.5.5",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.5.tgz",
"integrity": "sha512-xtJXZRZ1vkec1AIUVG02St2sbyZS5jc7227TlDu/HonqUYPeZAfiC4RI2jqL1dVMNBu1+7oBx/c9QkjF2kg6Lg==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.3.1",
@@ -30280,7 +30280,7 @@
"peerDependencies": {
"react": "*",
"react-native": "0.83 - 0.86",
"react-native-worklets": "0.10.x"
"react-native-worklets": "0.10.x - 0.11.x"
}
},
"node_modules/react-native-safe-area-context": {
@@ -30406,9 +30406,9 @@
}
},
"node_modules/react-native-worklets": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.4.tgz",
"integrity": "sha512-gbk0CxMrGywEp3SJAHO+2JVaCWtl4WyAYBIxLZy6Y113+mHHuq3FuT6iV+VcaBuJzuuN34mtdZ4j5JIlBDzmeA==",
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.27.1",
@@ -35780,14 +35780,14 @@
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.1",
"react-native-reanimated": "4.5.5",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1",
"react-native-worklets": "0.10.4",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -41012,39 +41012,6 @@
}
}
},
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native-worklets": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.13.0.tgz",
"integrity": "sha512-1OrhAeAKaZF4XrBa4YbPQbKzNOpjh52HDlId8e6ete1OCHNdAlrQHzG4gznJ225EAj3dgQXHViJvK51M9B52jQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/generator": "^7.27.1",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.28.6",
"@babel/plugin-transform-classes": "^7.28.6",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
"@babel/plugin-transform-optional-chaining": "^7.28.6",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
"@babel/preset-typescript": "^7.28.5",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"semver": "^7.7.4",
"whatwg-fetch": "^3.6.20"
},
"peerDependencies": {
"@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "0.86 - 0.88"
}
},
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+2 -2
View File
@@ -154,7 +154,7 @@
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-worklets": "0.10.1"
"react-native-reanimated": "4.5.5",
"react-native-worklets": "0.10.4"
}
}
+10 -2
View File
@@ -126,14 +126,14 @@
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.1",
"react-native-reanimated": "4.5.5",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1",
"react-native-worklets": "0.10.4",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -198,6 +198,14 @@
],
"enableVideo": false
},
"expo": {
"install": {
"exclude": [
"react-native-reanimated",
"react-native-worklets"
]
}
},
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
@@ -58,6 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
});
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout";
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
/** Renders whatever the portal is currently holding, from its own place in the tree. */
@@ -200,3 +201,35 @@ describe("IsolatedBottomSheetModal context bridging", () => {
).toEqual(["false", "true"]);
});
});
describe("bottom sheet layout subscription", () => {
it("stops updating a dismissed sheet when the window resizes", () => {
const container = document.createElement("div");
const root = createRoot(container);
let layout: ReturnType<typeof useAnimatedLayout> | undefined;
const viewport = document.documentElement;
const originalWidth = Object.getOwnPropertyDescriptor(viewport, "clientWidth");
function resize(width: number) {
Object.defineProperty(viewport, "clientWidth", { configurable: true, value: width });
act(() => window.dispatchEvent(new Event("resize")));
}
function LayoutProbe() {
layout = useAnimatedLayout(undefined, 0, 0);
return null;
}
try {
act(() => root.render(<LayoutProbe />));
if (!layout) throw new Error("Sheet layout did not mount");
const mountedLayout = layout;
resize(640);
expect(mountedLayout.get().window.width).toBe(640);
act(() => root.render(null));
resize(960);
expect(mountedLayout.get().window.width).toBe(640);
} finally {
act(() => root.unmount());
if (originalWidth) Object.defineProperty(viewport, "clientWidth", originalWidth);
else Reflect.deleteProperty(viewport, "clientWidth");
}
});
});
+62
View File
@@ -13,6 +13,27 @@ index ee51e76..dc1bc17 100644
if (_providedOnChange) {
_providedOnChange(_index, _position, _type);
}
diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js
index 973d05c..b484b5f 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js
@@ -86,7 +86,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s
});
}, [state, verticalInset, modal]);
(0, _react.useEffect)(() => {
- _reactNative.Dimensions.addEventListener('change', ({
+ const subscription = _reactNative.Dimensions.addEventListener('change', ({
window
}) => {
state.modify(_state => {
@@ -96,6 +96,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s
return _state;
});
});
+ return () => subscription.remove();
}, [state]);
//#endregion
diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js
index e44ad86..96555ef 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js
@@ -54,6 +75,27 @@ index 94fabdb..60ca66a 100644
if (_providedOnChange) {
_providedOnChange(_index, _position, _type);
}
diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js
index b0eec93..67713a2 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js
+++ b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js
@@ -82,7 +82,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m
});
}, [state, verticalInset, modal]);
useEffect(() => {
- Dimensions.addEventListener('change', ({
+ const subscription = Dimensions.addEventListener('change', ({
window
}) => {
state.modify(_state => {
@@ -92,6 +92,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m
return _state;
});
});
+ return () => subscription.remove();
}, [state]);
//#endregion
diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/index.js b/node_modules/@gorhom/bottom-sheet/lib/module/index.js
index 8259637..eb057fe 100644
--- a/node_modules/@gorhom/bottom-sheet/lib/module/index.js
@@ -101,6 +143,26 @@ index 2954b3f..7260656 100644
if (_providedOnChange) {
_providedOnChange(_index, _position, _type);
diff --git a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts
index 68c674d..564833c 100644
--- a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts
+++ b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts
@@ -106,13 +106,14 @@ export function useAnimatedLayout(
[state, verticalInset, modal]
);
useEffect(() => {
- Dimensions.addEventListener('change', ({ window }) => {
+ const subscription = Dimensions.addEventListener('change', ({ window }) => {
state.modify(_state => {
'worklet';
_state.window = window;
return _state;
});
});
+ return () => subscription.remove();
}, [state]);
//#endregion
diff --git a/node_modules/@gorhom/bottom-sheet/src/index.ts b/node_modules/@gorhom/bottom-sheet/src/index.ts
index 7aaea57..6a5f5a4 100644
--- a/node_modules/@gorhom/bottom-sheet/src/index.ts
@@ -40,9 +40,10 @@ index 0a1c6c2..5935112 100644
struct OnLinkLongPress {
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt
index c12937c..8105833 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt
+++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt
@@ -95,7 +95,7 @@
@@ -95,7 +95,7 @@ class EnrichedMarkdown(
private var accessibilityLabels = AccessibilityLabels()
private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY
@@ -51,7 +52,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c
private var onLinkLongPressCallback: ((String) -> Unit)? = null
private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null
private var onLatexErrorCallback: LatexErrorReporter? = null
@@ -296,7 +296,7 @@
@@ -296,7 +296,7 @@ class EnrichedMarkdown(
}
}
@@ -60,7 +61,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c
onLinkPressCallback = callback
}
@@ -405,7 +405,7 @@
@@ -405,7 +405,7 @@ class EnrichedMarkdown(
val codeLinkRegex = inlineCodeLinkRegex
val renderId = ++currentRenderId
@@ -69,7 +70,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c
try {
val filtered =
if (isStreaming) {
@@ -418,13 +418,13 @@
@@ -418,13 +418,13 @@ class EnrichedMarkdown(
if (renderableMarkdown.isEmpty()) {
postToMain(renderId) { applyRenderedSegments(emptyList(), false) }
@@ -85,7 +86,19 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c
}
val segments = splitASTIntoSegments(ast)
@@ -646,8 +646,12 @@
@@ -478,6 +478,11 @@ class EnrichedMarkdown(
if (forceHeight || topologyChanged || heightBefore != heightAfter) {
MeasurementStore.invalidate(id)
requestLayout()
+ stateWrapper?.let {
+ val state = Arguments.createMap()
+ state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter)
+ it.updateState(state)
+ }
}
}
}
@@ -646,8 +651,12 @@ class EnrichedMarkdown(
renderId: Long,
action: () -> Unit,
) {
@@ -3138,3 +3151,89 @@ new file mode 100644
+ }
+ }
+}
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp
index a430d1a..b30a017 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp
+++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp
@@ -22,6 +22,14 @@ void MarkdownContainerShadowNode::dirtyLayoutIfNeeded() {
}
}
+// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild
+// re-clones its children without new props, and dirtying those clones re-measures every settled message
+// whenever the list above grows.
+bool MarkdownContainerShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/,
+ const ShadowNodeFragment &fragment) const {
+ return fragment.props != nullptr;
+}
+
Size MarkdownContainerShadowNode::measureContent(const LayoutContext &layoutContext,
const LayoutConstraints &layoutConstraints) const {
return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints);
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h
index dea8bb2..6c4bd20 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h
+++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h
@@ -19,6 +19,8 @@ public:
MarkdownContainerShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment)
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
+ forceHeightRecalculationCounter_ =
+ static_cast<const MarkdownContainerShadowNode &>(sourceShadowNode).forceHeightRecalculationCounter_;
dirtyLayoutIfNeeded();
}
@@ -35,6 +37,10 @@ public:
Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override;
+protected:
+ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode,
+ const ShadowNodeFragment &fragment) const override;
+
private:
int forceHeightRecalculationCounter_{0};
std::shared_ptr<MarkdownContainerMeasurementManager> measurementsManager_;
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp
index 3ca259b..ab51055 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp
+++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp
@@ -22,6 +22,14 @@ void MarkdownTextShadowNode::dirtyLayoutIfNeeded() {
}
}
+// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild
+// re-clones its children without new props, and dirtying those clones re-measures every settled message
+// whenever the list above grows.
+bool MarkdownTextShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/,
+ const ShadowNodeFragment &fragment) const {
+ return fragment.props != nullptr;
+}
+
Size MarkdownTextShadowNode::measureContent(const LayoutContext &layoutContext,
const LayoutConstraints &layoutConstraints) const {
return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints);
diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h
index eaa3279..05a02eb 100644
--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h
+++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h
@@ -22,6 +22,8 @@ public:
MarkdownTextShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment)
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
+ forceHeightRecalculationCounter_ =
+ static_cast<const MarkdownTextShadowNode &>(sourceShadowNode).forceHeightRecalculationCounter_;
dirtyLayoutIfNeeded();
}
@@ -39,6 +41,10 @@ public:
Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override;
+protected:
+ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode,
+ const ShadowNodeFragment &fragment) const override;
+
private:
int forceHeightRecalculationCounter_{0};
std::shared_ptr<MarkdownTextMeasurementManager> measurementsManager_;
+6
View File
@@ -14,6 +14,9 @@ const patchedPackages = [
nodeModulesPath: "node_modules/expo-modules-core",
patchPrefix: "expo-modules-core+",
},
// Backport https://github.com/software-mansion/enriched-markdown/pull/924: preserve settled
// Android measurements across parent clones. Remove those hunks when upgrading past
// 1.2.0-nightly-20261019-926ef06f0; reconcile the other local patch hunks separately.
{
nodeModulesPath: "packages/app/node_modules/react-native-enriched-markdown",
patchPrefix: "react-native-enriched-markdown+",
@@ -45,6 +48,9 @@ const patchedPackages = [
// modal stack (see packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx), and
// keeps a dismissing modal's status through its final onChange(-1), so a modal with
// enableDismissOnClose=false that unmounts while presented still leaves the portal.
// Dimensions subscription cleanup: https://github.com/gorhom/react-native-bottom-sheet/issues/2734
// and https://github.com/gorhom/react-native-bottom-sheet/pull/2751. Remove the useAnimatedLayout
// hunks once an installed release includes the cleanup; retain the other local hunks.
{
nodeModulesPath: "node_modules/@gorhom/bottom-sheet",
patchPrefix: "@gorhom+bottom-sheet+",