diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 52eae6db9777..a812cad8f3b8 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -426,6 +426,7 @@ const config: ExpoConfig = { "./plugins/withAndroidModernPopupMenu.cjs", "./plugins/withAndroidModernAlertDialog.cjs", "./plugins/withAndroidPredictiveBackCompat.cjs", + "./plugins/withAndroidNativeScreenBack.cjs", "./plugins/withAndroidTabletOrientation.cjs", ...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []), ], diff --git a/apps/mobile/plugins/withAndroidNativeScreenBack.cjs b/apps/mobile/plugins/withAndroidNativeScreenBack.cjs new file mode 100644 index 000000000000..3f0772b71018 --- /dev/null +++ b/apps/mobile/plugins/withAndroidNativeScreenBack.cjs @@ -0,0 +1,159 @@ +const { withMainActivity } = require("expo/config-plugins"); + +// Android back normally reaches the navigator through JS: the press is queued +// on the JS thread, React Navigation pops, and only then does the screen stack +// animate. While JS is busy (a thread loading or syncing) back does nothing +// until that work finishes. This callback pops the top screen on the UI thread +// instead when the screen opted in (native-stack's +// unstable_nativeBackDismissalEnabled), the same native dismissal an iOS swipe +// back uses: JS learns of it through onDismissed and updates its state then. +// Everything else still goes to JS, including back while in-window UI that +// handles back in JS is on screen (marked with JS_BACK_HANDLER_NATIVE_ID). + +const JS_BACK_HANDLER_NATIVE_ID = "t3-js-back-handler"; + +const IMPORTS = ` +import android.view.View +import android.view.ViewGroup +import com.facebook.react.uimanager.util.ReactFindViewUtil +import com.swmansion.rnscreens.ScreenStack +import com.swmansion.rnscreens.ScreenStackFragmentWrapper`; + +const CALLBACK_PROPERTY = ` + // Pops an opted-in screen without waiting for JS; everything else is handed + // on to React Native's callback. Registered in onPostCreate, after React + // Native's own, so it runs first; added by withAndroidNativeScreenBack. + private val nativeScreenBackCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val target = nativeScreenBackTarget() + if (target != null) { + target.dismissFromContainer() + return + } + isEnabled = false + try { + onBackPressedDispatcher.onBackPressed() + } finally { + isEnabled = true + } + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + onBackPressedDispatcher.addCallback(this, nativeScreenBackCallback) + } + + // The top screen of the innermost stack on screen, if it may be popped natively. + private fun nativeScreenBackTarget(): ScreenStackFragmentWrapper? { + // Wide layouts show more than one stack at a time; leave those to JS. + if (resources.configuration.smallestScreenWidthDp >= 600) return null + // In-window UI that handles back in JS (an open menu) closes first. It is + // marked on the view itself, so the check holds from its first frame. + if (ReactFindViewUtil.findView(window.decorView, JS_BACK_HANDLER_NATIVE_ID) != null) return null + var stack = findScreenStack(window.decorView, Int.MAX_VALUE) ?: return null + while (true) { + val top = stack.topScreen ?: return null + val nested = findScreenStack(top, NESTED_SCREEN_STACK_DEPTH) + if (nested?.topScreen != null) { + stack = nested + continue + } + if (!top.nativeBackButtonDismissalEnabled || stack.rootScreen === top) return null + return top.fragmentWrapper as? ScreenStackFragmentWrapper + } + } + + private fun findScreenStack(root: View, maxDepth: Int): ScreenStack? { + val views = ArrayDeque() + val depths = ArrayDeque() + views.addLast(root) + depths.addLast(0) + while (views.isNotEmpty()) { + val view = views.removeFirst() + val depth = depths.removeFirst() + if (view !== root && view is ScreenStack) return view + if (view is ViewGroup && depth < maxDepth) { + for (index in 0 until view.childCount) { + views.addLast(view.getChildAt(index)) + depths.addLast(depth + 1) + } + } + } + return null + } + + private companion object { + // A nested navigator's stack sits a few views inside its screen. + const val NESTED_SCREEN_STACK_DEPTH = 10 + // Matches src/lib/androidNativeBack.ts. + const val JS_BACK_HANDLER_NATIVE_ID = "${JS_BACK_HANDLER_NATIVE_ID}" + } +`; + +// The default action ends in ComponentActivity.onBackPressed(), which +// re-enters the dispatcher; with this callback enabled it would be handed +// straight back to JS instead of backgrounding the app. +const INVOKE_DEFAULT_WRAPPER = `override fun invokeDefaultOnBackPressed() { + nativeScreenBackCallback.isEnabled = false + try { + invokeDefaultOnBackPressedAfterNativeScreenBack() + } finally { + nativeScreenBackCallback.isEnabled = true + } + } + + private fun invokeDefaultOnBackPressedAfterNativeScreenBack() {`; + +function insertAfter(contents, anchor, insertion, description) { + const index = contents.indexOf(anchor); + if (index === -1) { + throw new Error( + `withAndroidNativeScreenBack: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`, + ); + } + const end = index + anchor.length; + return contents.slice(0, end) + insertion + contents.slice(end); +} + +module.exports = function withAndroidNativeScreenBack(config) { + return withMainActivity(config, (nextConfig) => { + let contents = nextConfig.modResults.contents; + if (nextConfig.modResults.language !== "kt") { + throw new Error("withAndroidNativeScreenBack: MainActivity must be Kotlin."); + } + if (contents.includes("nativeScreenBackCallback")) { + return nextConfig; + } + + let imports = IMPORTS; + if (!contents.includes("import androidx.activity.OnBackPressedCallback")) { + imports = `\nimport androidx.activity.OnBackPressedCallback${imports}`; + } + contents = insertAfter( + contents, + "import android.os.Bundle", + imports, + "the android.os.Bundle import", + ); + contents = insertAfter( + contents, + "class MainActivity : ReactActivity() {", + CALLBACK_PROPERTY, + "the MainActivity class declaration", + ); + + if (!contents.includes("override fun invokeDefaultOnBackPressed() {")) { + throw new Error( + "withAndroidNativeScreenBack: could not find invokeDefaultOnBackPressed in MainActivity — the Expo template changed; update the plugin anchors.", + ); + } + contents = contents.replace( + "override fun invokeDefaultOnBackPressed() {", + INVOKE_DEFAULT_WRAPPER, + ); + + nextConfig.modResults.contents = contents; + return nextConfig; + }); +}; diff --git a/apps/mobile/plugins/withAndroidNativeScreenBack.test.mjs b/apps/mobile/plugins/withAndroidNativeScreenBack.test.mjs new file mode 100644 index 000000000000..b758d7359bc3 --- /dev/null +++ b/apps/mobile/plugins/withAndroidNativeScreenBack.test.mjs @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { JS_BACK_HANDLER_NATIVE_ID } from "../src/lib/androidNativeBack"; +import withAndroidNativeScreenBack from "./withAndroidNativeScreenBack.cjs"; +import withAndroidPredictiveBackCompat from "./withAndroidPredictiveBackCompat.cjs"; + +const mainActivity = `package com.t3tools.t3code +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(null) + } + + override fun invokeDefaultOnBackPressed() { + super.invokeDefaultOnBackPressed() + } +}`; + +async function transform(plugin, contents, extraConfig = {}) { + const config = plugin({ name: "Test", slug: "test", ...extraConfig }); + const result = await config.mods.android.mainActivity({ + ...config, + modRequest: { platform: "android", modName: "mainActivity", introspect: false }, + modResults: { language: "kt", contents }, + }); + return result.modResults.contents; +} + +describe("Android native screen back generation", () => { + it("registers the callback after onCreate and dismisses opted-in screens natively", async () => { + const result = await transform(withAndroidNativeScreenBack, mainActivity); + expect(result).toContain("import androidx.activity.OnBackPressedCallback"); + expect(result).toContain("import com.swmansion.rnscreens.ScreenStack"); + expect(result).toContain("override fun onPostCreate(savedInstanceState: Bundle?) {"); + expect(result).toContain("onBackPressedDispatcher.addCallback(this, nativeScreenBackCallback)"); + expect(result).toContain("target.dismissFromContainer()"); + expect(result).toContain("top.nativeBackButtonDismissalEnabled"); + }); + + it("leaves back to JS while in-window UI marked for JS back is on screen", async () => { + const result = await transform(withAndroidNativeScreenBack, mainActivity); + expect(result).toContain("import com.facebook.react.uimanager.util.ReactFindViewUtil"); + expect(result).toContain( + "if (ReactFindViewUtil.findView(window.decorView, JS_BACK_HANDLER_NATIVE_ID) != null) return null", + ); + expect(result).toContain( + `const val JS_BACK_HANDLER_NATIVE_ID = "${JS_BACK_HANDLER_NATIVE_ID}"`, + ); + }); + + it("turns the callback off while the default back action runs", async () => { + const result = await transform(withAndroidNativeScreenBack, mainActivity); + const wrapper = result.slice(result.indexOf("override fun invokeDefaultOnBackPressed() {")); + expect(wrapper.indexOf("nativeScreenBackCallback.isEnabled = false")).toBeLessThan( + wrapper.indexOf("invokeDefaultOnBackPressedAfterNativeScreenBack()"), + ); + expect(result).toContain( + "private fun invokeDefaultOnBackPressedAfterNativeScreenBack() {\n super.invokeDefaultOnBackPressed()", + ); + }); + + it("wraps the predictive back compat wrapper instead of replacing it", async () => { + const compat = await transform(withAndroidPredictiveBackCompat, mainActivity, { + android: { predictiveBackGestureEnabled: true }, + }); + const result = await transform(withAndroidNativeScreenBack, compat); + expect(result.match(/import androidx\.activity\.OnBackPressedCallback/g)).toHaveLength(1); + expect(result).toContain("predictiveBackCompatCallback.isEnabled = false"); + expect(result).toContain("nativeScreenBackCallback.isEnabled = false"); + expect(result).toContain("private fun invokeDefaultOnBackPressedLegacy() {"); + }); + + it("does not duplicate code on subsequent prebuilds", async () => { + const generated = await transform(withAndroidNativeScreenBack, mainActivity); + expect(await transform(withAndroidNativeScreenBack, generated)).toBe(generated); + }); + + it("fails visibly when the Expo MainActivity template changes", async () => { + await expect( + transform(withAndroidNativeScreenBack, "import android.os.Bundle\nclass MainActivity {}"), + ).rejects.toThrow("could not find the MainActivity class declaration"); + }); +}); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7f508f486ce3..bb744e76dab9 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -617,7 +617,9 @@ const RootStackConfig = createNativeStackNavigator({ Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: THREAD_LINKING_PREFIX, - options: GLASS_HEADER_OPTIONS, + // Android back pops the thread without waiting for JS; see + // withAndroidNativeScreenBack. + options: { ...GLASS_HEADER_OPTIONS, unstable_nativeBackDismissalEnabled: true }, }), ThreadTerminal: createNativeStackScreen({ screen: ThreadTerminalRouteScreen, diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 4c465a9f9ef4..f90b4cd0f4b0 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -6,6 +6,7 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; +import { JS_BACK_HANDLER_NATIVE_ID } from "../lib/androidNativeBack"; import { OverlayPortal } from "./OverlayPortal"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { MaterialMenuPopup } from "./MaterialMenuPopup"; @@ -193,10 +194,13 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { )} {anchor === null ? null : ( + {/* The nativeID keeps native back (withAndroidNativeScreenBack) from + popping the screen while the in-window menu takes back. */} diff --git a/apps/mobile/src/lib/androidNativeBack.ts b/apps/mobile/src/lib/androidNativeBack.ts new file mode 100644 index 000000000000..3f7f9d0972bb --- /dev/null +++ b/apps/mobile/src/lib/androidNativeBack.ts @@ -0,0 +1,6 @@ +/** + * nativeID for in-window UI that handles Android back in JS. While a view with + * this ID is on screen, withAndroidNativeScreenBack hands back to JS instead of + * popping the screen natively, so back closes that UI first. + */ +export const JS_BACK_HANDLER_NATIVE_ID = "t3-js-back-handler"; diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch index d2c6bc918881..b55c9a1da585 100644 --- a/patches/@react-navigation%2Fnative-stack@7.17.6.patch +++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch @@ -1,8 +1,25 @@ diff --git a/lib/module/views/NativeStackView.native.js b/lib/module/views/NativeStackView.native.js -index c342e90bffde9ad5044b6167bdde2e19be219270..5d3e4407aa1bb68b873cf164adf5a9f0b487bb0b 100644 +index c342e90bffde9ad5044b6167bdde2e19be219270..1108d1df6aabb3608077c27072f16e9389efaba0 100644 --- a/lib/module/views/NativeStackView.native.js +++ b/lib/module/views/NativeStackView.native.js -@@ -370,6 +370,17 @@ export function NativeStackView({ +@@ -82,6 +82,7 @@ const SceneView = ({ + statusBarTranslucent, + statusBarBackgroundColor, + unstable_sheetFooter, ++ unstable_nativeBackDismissalEnabled, + scrollEdgeEffects, + freezeOnBlur, + contentStyle +@@ -280,7 +281,7 @@ const SceneView = ({ + onGestureCancel: onGestureCancel, + onSheetDetentChanged: onSheetDetentChanged, + gestureResponseDistance: gestureResponseDistance, +- nativeBackButtonDismissalEnabled: false // on Android ++ nativeBackButtonDismissalEnabled: unstable_nativeBackDismissalEnabled === true // on Android + , + onHeaderBackButtonClicked: onHeaderBackButtonClicked, + preventNativeDismiss: isRemovePrevented // on iOS +@@ -370,6 +371,17 @@ export function NativeStackView({ return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { children: /*#__PURE__*/_jsx(ScreenStack, { style: styles.container, @@ -21,7 +38,7 @@ index c342e90bffde9ad5044b6167bdde2e19be219270..5d3e4407aa1bb68b873cf164adf5a9f0 const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key]; const isFocused = state.index === index; diff --git a/lib/module/views/useHeaderConfigProps.js b/lib/module/views/useHeaderConfigProps.js -index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d6f55b11e 100644 +index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..14df44af9a3ea5f25727418bc64601e8b2ab9c22 100644 --- a/lib/module/views/useHeaderConfigProps.js +++ b/lib/module/views/useHeaderConfigProps.js @@ -19,6 +19,12 @@ const processBarButtonItems = (items, colors, fonts) => { @@ -134,10 +151,26 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts), experimental_userInterfaceStyle: dark ? 'dark' : 'light' diff --git a/lib/typescript/src/types.d.ts b/lib/typescript/src/types.d.ts -index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb2151a712bf0 100644 +index 2f1351a89f0f2e67e854dc81e392198326b71834..6f1a077812eff57df382437c8f526f33f6abe270 100644 --- a/lib/typescript/src/types.d.ts +++ b/lib/typescript/src/types.d.ts -@@ -1091,6 +1091,13 @@ export type NativeStackHeaderItemSpacing = { +@@ -530,6 +530,15 @@ export type NativeStackNavigationOptions = { + * @platform ios + */ + gestureResponseDistance?: ScreenProps['gestureResponseDistance']; ++ /** ++ * Lets a back press dismiss this screen natively on Android, without waiting ++ * for JS. The navigator learns about it through `onDismissed`, like an iOS ++ * swipe back. Needs an app-side back callback that performs the dismissal. ++ * Defaults to `false`. ++ * ++ * @platform android ++ */ ++ unstable_nativeBackDismissalEnabled?: boolean; + /** + * The type of animation to use when this screen replaces another screen. Defaults to `pop`. + * +@@ -1091,6 +1100,13 @@ export type NativeStackHeaderItemSpacing = { */ export type NativeStackHeaderItemCustom = { type: 'custom'; @@ -152,10 +185,26 @@ index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb215 * A React Element to display as the item. */ diff --git a/src/types.tsx b/src/types.tsx -index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644 +index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..1495ef2e489f28ecb73684b7244d82a529a34fd7 100644 --- a/src/types.tsx +++ b/src/types.tsx -@@ -1156,6 +1156,13 @@ export type NativeStackHeaderItemSpacing = { +@@ -582,6 +582,15 @@ export type NativeStackNavigationOptions = { + * @platform ios + */ + gestureResponseDistance?: ScreenProps['gestureResponseDistance']; ++ /** ++ * Lets a back press dismiss this screen natively on Android, without waiting ++ * for JS. The navigator learns about it through `onDismissed`, like an iOS ++ * swipe back. Needs an app-side back callback that performs the dismissal. ++ * Defaults to `false`. ++ * ++ * @platform android ++ */ ++ unstable_nativeBackDismissalEnabled?: boolean; + /** + * The type of animation to use when this screen replaces another screen. Defaults to `pop`. + * +@@ -1156,6 +1165,13 @@ export type NativeStackHeaderItemSpacing = { */ export type NativeStackHeaderItemCustom = { type: 'custom'; @@ -169,6 +218,29 @@ index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9e /** * A React Element to display as the item. */ +diff --git a/src/views/NativeStackView.native.tsx b/src/views/NativeStackView.native.tsx +index 416cd90057cea362371a4308dd9985b086c5ccaa..bd4bad83c2ffeffff1e816260007b6538e2440f2 100644 +--- a/src/views/NativeStackView.native.tsx ++++ b/src/views/NativeStackView.native.tsx +@@ -134,6 +134,7 @@ const SceneView = ({ + statusBarTranslucent, + statusBarBackgroundColor, + unstable_sheetFooter, ++ unstable_nativeBackDismissalEnabled, + scrollEdgeEffects, + freezeOnBlur, + contentStyle, +@@ -406,7 +407,9 @@ const SceneView = ({ + onGestureCancel={onGestureCancel} + onSheetDetentChanged={onSheetDetentChanged} + gestureResponseDistance={gestureResponseDistance} +- nativeBackButtonDismissalEnabled={false} // on Android ++ nativeBackButtonDismissalEnabled={ ++ unstable_nativeBackDismissalEnabled === true ++ } // on Android + onHeaderBackButtonClicked={onHeaderBackButtonClicked} + preventNativeDismiss={isRemovePrevented} // on iOS + scrollEdgeEffects={{ diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644 --- a/src/views/useHeaderConfigProps.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f579bfa8a5a8..2a7f885fb209 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,7 +99,7 @@ patchedDependencies: '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd - '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 + '@react-navigation/native-stack@7.17.6': 3a8e9a3427ab03b8ce2e3666ed89560521e04602643d7a56dc98060dcf97ab57 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 effect@4.0.0-rc.115: 0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a @@ -279,7 +279,7 @@ importers: version: 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(f640afa6cd4b9a1dc5422cbafdc82f53) + version: 7.17.6(patch_hash=3a8e9a3427ab03b8ce2e3666ed89560521e04602643d7a56dc98060dcf97ab57)(f640afa6cd4b9a1dc5422cbafdc82f53) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -14968,7 +14968,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(f640afa6cd4b9a1dc5422cbafdc82f53)': + '@react-navigation/native-stack@7.17.6(patch_hash=3a8e9a3427ab03b8ce2e3666ed89560521e04602643d7a56dc98060dcf97ab57)(f640afa6cd4b9a1dc5422cbafdc82f53)': dependencies: '@react-navigation/elements': 2.9.26(c10301b6e0c42fc6434d2b643197a81e) '@react-navigation/native': 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)