diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md new file mode 100644 index 0000000000000..58353e4f7d10c --- /dev/null +++ b/patches/react-native-screens/details.md @@ -0,0 +1,8 @@ +# `react-native-screens` patches + +### [react-native-screens+4.15.4+001+delay-freeze-until-paint.patch](react-native-screens+4.15.4+001+delay-freeze-until-paint.patch) + +- Reason: Delays `DelayedFreeze` before freezing a screen. On iOS (Fabric), freezing a screen while an animation is still in flight (e.g. a popover closing or a swipe-back gesture finishing) blocks React rendering and can cause the app to become unresponsive. +- Upstream PR/issue: N/A +- E/App issue: https://github.com/Expensify/App/issues/33725 +- PR introducing patch: https://github.com/Expensify/App/pull/82764 diff --git a/patches/react-native-screens/react-native-screens+4.15.4+001+delay-freeze-until-paint.patch b/patches/react-native-screens/react-native-screens+4.15.4+001+delay-freeze-until-paint.patch new file mode 100644 index 0000000000000..8afcd08548f92 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.15.4+001+delay-freeze-until-paint.patch @@ -0,0 +1,36 @@ +diff --git a/node_modules/react-native-screens/src/components/helpers/DelayedFreeze.tsx b/node_modules/react-native-screens/src/components/helpers/DelayedFreeze.tsx +index 443da63..72517fd 100644 +--- a/node_modules/react-native-screens/src/components/helpers/DelayedFreeze.tsx ++++ b/node_modules/react-native-screens/src/components/helpers/DelayedFreeze.tsx +@@ -7,17 +7,27 @@ interface FreezeWrapperProps { + children: React.ReactNode; + } + ++// Delay freezing long enough for screen transition and modal/popover dismiss ++// animations to complete. On iOS, freezing a screen while an animation ++// is still in flight (e.g. a popover closing or a swipe-back gesture finishing) ++// blocks React rendering and can cause the app to become unresponsive. ++const FREEZE_DELAY_MS = 400; ++ + // This component allows one more render before freezing the screen. + // Allows activityState to reach the native side and useIsFocused to work correctly. + function DelayedFreeze({ freeze, children }: FreezeWrapperProps) { + // flag used for determining whether freeze should be enabled + const [freezeState, setFreezeState] = React.useState(false); + +- React.useEffect(() => { +- const id = setTimeout(() => { +- setFreezeState(freeze); +- }, 0); ++ React.useEffect(() => { ++ let rafID: number; ++ // Wait for FREEZE_DELAY_MS, then schedule the state update after the next ++ // frame paint so transitional UI states are fully flushed before suspending. ++ const id = setTimeout(() => { ++ rafID = requestAnimationFrame(() => setFreezeState(freeze)); ++ }, FREEZE_DELAY_MS); + return () => { + clearTimeout(id); ++ cancelAnimationFrame(rafID); + }; + }, [freeze]); diff --git a/patches/react-navigation/@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch b/patches/react-navigation/@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch new file mode 100644 index 0000000000000..726fe73f8eccd --- /dev/null +++ b/patches/react-navigation/@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch @@ -0,0 +1,36 @@ +diff --git a/node_modules/@react-navigation/native-stack/lib/module/views/NativeStackView.native.js b/node_modules/@react-navigation/native-stack/lib/module/views/NativeStackView.native.js +index 35dfc05..1758a24 100644 +--- a/node_modules/@react-navigation/native-stack/lib/module/views/NativeStackView.native.js ++++ b/node_modules/@react-navigation/native-stack/lib/module/views/NativeStackView.native.js +@@ -376,9 +376,9 @@ export function NativeStackView({ + const isModal = modalRouteKeys.includes(route.key); + const isPreloaded = preloadedDescriptors[route.key] !== undefined && descriptors[route.key] === undefined; + +- // On Fabric, when screen is frozen, animated and reanimated values are not updated +- // due to component being unmounted. To avoid this, we don't freeze the previous screen there +- const shouldFreeze = isFabric() ? !isPreloaded && !isFocused && !isBelowFocused : !isPreloaded && !isFocused; ++ // Freezing the screen below the focused one is safe on Fabric because ++ // DelayedFreeze defers it to the next macrotask, and transition animations run on the UI thread. ++ const shouldFreeze = !isPreloaded && !isFocused; + return /*#__PURE__*/_jsx(SceneView, { + index: index, + focused: isFocused, +diff --git a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx +index ca15b1d..3191475 100644 +--- a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx ++++ b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx +@@ -542,11 +542,9 @@ export function NativeStackView({ + preloadedDescriptors[route.key] !== undefined && + descriptors[route.key] === undefined; + +- // On Fabric, when screen is frozen, animated and reanimated values are not updated +- // due to component being unmounted. To avoid this, we don't freeze the previous screen there +- const shouldFreeze = isFabric() +- ? !isPreloaded && !isFocused && !isBelowFocused +- : !isPreloaded && !isFocused; ++ // Freezing the screen below the focused one is safe on Fabric because ++ // DelayedFreeze defers it to the next macrotask, and transition animations run on the UI thread. ++ const shouldFreeze = !isPreloaded && !isFocused; + + return ( + ; @@ -42,11 +43,14 @@ export default function useKeyboardShortcut(shortcut: Shortcut, callback: (e?: G shouldStopPropagation = false, } = config; + const {registerFreezeDefer} = useScreenFreezeContext(); + useEffect(() => { if (!isActive) { return () => {}; } + const unregisterFreezeDefer = registerFreezeDefer(); const unsubscribe = KeyboardShortcut.subscribe( shortcut.shortcutKey, callback, @@ -62,6 +66,7 @@ export default function useKeyboardShortcut(shortcut: Shortcut, callback: (e?: G return () => { unsubscribe(); + unregisterFreezeDefer(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isActive, callback, captureOnInputs, excludedNodes, priority, shortcut.descriptionKey, shortcut.modifiers.join(), shortcut.shortcutKey, shouldBubble, shouldPreventDefault]); diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx index 4483297f6a1b6..96607dc9b1d5d 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx @@ -41,6 +41,7 @@ const SplitNavigatorComponent = createPlatformStackNavigatorComponent('SplitNavi defaultScreenOptions: defaultPlatformStackScreenOptions, useCustomState: useCustomSplitNavigatorState, NavigationContentWrapper: SidebarSpacerWrapper, + freezeNonTopScreens: true, }); function createSplitNavigator< diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeContext.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeContext.ts new file mode 100644 index 0000000000000..529febe6bda6c --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeContext.ts @@ -0,0 +1,23 @@ +import {createContext, useContext} from 'react'; + +type ScreenFreezeContextType = { + /** + * Register a freeze defer — signals that this component has cleanup work + * (e.g., keyboard shortcut unsubscribing) that must run before the screen is frozen. + * When any defers are registered, ScreenFreezeWrapper delays freezing by one frame + * so cleanup can execute first. + * Returns an unregister function. + */ + registerFreezeDefer: () => () => void; +}; + +const ScreenFreezeContext = createContext({ + registerFreezeDefer: () => () => {}, +}); + +function useScreenFreezeContext() { + return useContext(ScreenFreezeContext); +} + +export default ScreenFreezeContext; +export {useScreenFreezeContext}; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx new file mode 100644 index 0000000000000..f84cc104a8d87 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx @@ -0,0 +1,59 @@ +import React, {useLayoutEffect, useRef, useState} from 'react'; +import {Freeze} from 'react-freeze'; +import TooltipSense from '@components/Tooltip/TooltipSense'; +import {areAllModalsHidden} from '@userActions/Modal'; +import ScreenFreezeContext from './ScreenFreezeContext'; + +type ScreenFreezeWrapperProps = { + /** Whether the screen is not currently visible to the user */ + isScreenBlurred: boolean; + + /** The screen content to freeze when blurred */ + children: React.ReactNode; +}; + +function ScreenFreezeWrapper({isScreenBlurred, children}: ScreenFreezeWrapperProps) { + const [frozen, setFrozen] = useState(false); + const freezeDeferCountRef = useRef(0); + + const registerFreezeDefer = () => { + freezeDeferCountRef.current++; + return () => { + freezeDeferCountRef.current--; + }; + }; + + const contextValue = {registerFreezeDefer}; + + // Decouple the Suspense render task so it won't be interrupted by React's concurrent mode + // and stuck in an infinite loop + useLayoutEffect(() => { + // When unfreezing, always apply immediately so the screen is visible right away. + if (!isScreenBlurred) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setFrozen(false); + return; + } + + // When there are active freeze defers (e.g. keyboard shortcuts that need to unsubscribe), + // or when a modal/tooltip is still open, defer the freezing by one frame. + if (freezeDeferCountRef.current > 0 || TooltipSense.isActive() || !areAllModalsHidden()) { + const id = requestAnimationFrame(() => setFrozen(isScreenBlurred)); + return () => { + cancelAnimationFrame(id); + }; + } + + // No blockers or overlays — freeze immediately. + // eslint-disable-next-line react-hooks/set-state-in-effect + setFrozen(isScreenBlurred); + }, [isScreenBlurred]); + + return ( + + {children} + + ); +} + +export default ScreenFreezeWrapper; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index 730e269d507af..495ca5c25d390 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -13,6 +13,7 @@ import type { PlatformStackNavigatorProps, PlatformStackRouterOptions, } from '@libs/Navigation/PlatformStackNavigation/types'; +import ScreenFreezeWrapper from './ScreenFreezeWrapper'; function createPlatformStackNavigatorComponent( displayName: string, @@ -24,6 +25,7 @@ function createPlatformStackNavigatorComponent undefined); + const freezeNonTopScreens = options?.freezeNonTopScreens; function PlatformNavigator({ id, @@ -100,6 +102,25 @@ function createPlatformStackNavigatorComponent {descriptor.render()}, + }; + } + wrappedDescriptors = result; + } + const Content = useMemo( () => ( @@ -108,7 +129,7 @@ function createPlatformStackNavigatorComponent @@ -119,7 +140,7 @@ function createPlatformStackNavigatorComponent ), - [NavigationContent, customCodePropsWithCustomState, describe, descriptors, mappedState, navigation, props], + [NavigationContent, customCodePropsWithCustomState, describe, wrappedDescriptors, mappedState, navigation, props], ); // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts b/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts index 87d8141437a5d..eb0c95880c869 100644 --- a/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts +++ b/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts @@ -56,6 +56,7 @@ type CreatePlatformStackNavigatorComponentOptions; ExtraContent?: ExtraContent; NavigationContentWrapper?: NavigationContentWrapper; + freezeNonTopScreens?: boolean; }; export type {CustomCodeProps, CustomStateHookProps, CustomEffectsHookProps, CreatePlatformStackNavigatorComponentOptions, ExtraContentProps}; diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index b30a17fcdbc59..f884b9bc2837b 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -166,11 +166,12 @@ function AttachmentModalBaseContent({ return; } - if (onConfirm) { - onConfirm(Object.assign(files ?? {}, {source} as FileObject)); - } - onClose?.(); + + // Defer onConfirm to the next frame so the target screen has time to unfreeze and re-mount its refs (e.g. composerRef.clearWorklet) + requestAnimationFrame(() => { + onConfirm?.(Object.assign(files ?? {}, {source} as FileObject)); + }); }, [isConfirmButtonDisabled, onConfirm, onClose, files, source]); // Close the modal when the escape key is pressed diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index 4e95f50ca45d6..267cfc26ae480 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -142,7 +142,7 @@ function WorkspaceInviteMessageComponent({ } if ((backTo as string)?.endsWith('members')) { - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal()); + Navigation.dismissModal(); return; } diff --git a/src/setup/index.ts b/src/setup/index.ts index 6f8aec7bb1e8f..1d557e0553836 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -2,6 +2,7 @@ import toSortedPolyfill from 'array.prototype.tosorted'; import {I18nManager} from 'react-native'; import Config from 'react-native-config'; import Onyx from 'react-native-onyx'; +import {enableFreeze} from 'react-native-screens'; import intlPolyfill from '@libs/IntlPolyfill'; import {setDeviceID} from '@userActions/Device'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -14,6 +15,10 @@ import telemetry from './telemetry'; const enableDevTools = Config?.USE_REDUX_DEVTOOLS ? Config.USE_REDUX_DEVTOOLS === 'true' : true; export default function () { + // Enable screen freezing on mobile to prevent unnecessary re-renders on screens that are not visible to the user. + // This is a no-op on web — for web, we use ScreenFreezeWrapper in SplitNavigator instead. + enableFreeze(true); + telemetry(); toSortedPolyfill.shim();