diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md new file mode 100644 index 000000000000..e07de39d8108 --- /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: Adds `requestAnimationFrame` to `DelayedFreeze` so the screen freeze is deferred until after the current frame is painted. Without this, screens can be frozen while transitional UI states are still visible, causing a visual flicker. +- 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 000000000000..34b81d29b89f --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.15.4+001+delay-freeze-until-paint.patch @@ -0,0 +1,23 @@ +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 +@@ -12,12 +12,17 @@ function DelayedFreeze({ freeze, children }: FreezeWrapperProps) { + // flag used for determining whether freeze should be enabled + const [freezeState, setFreezeState] = React.useState(false); + ++ // We use requestAnimationFrame to ensure the current frame is fully painted ++ // before scheduling the freeze. This prevents freezing the screen while ++ // transitional UI states (e.g. opacity from useSingleExecution) are still visible. + React.useEffect(() => { ++ let rafID: number; + const id = setTimeout(() => { +- setFreezeState(freeze); ++ rafID = requestAnimationFrame(() => setFreezeState(freeze)); + }, 0); + 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 000000000000..726fe73f8ecc --- /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 ( + { + if (!TooltipSense.isActive() || !isScreenBlurred) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setFrozen(isScreenBlurred); + } + + // When a tooltip is active, defer freezing by one frame so it can dismiss first. + // Otherwise the frozen tree can't hide the tooltip portal rendered on . + const id = requestAnimationFrame(() => setFrozen(isScreenBlurred)); + return () => { + cancelAnimationFrame(id); + }; + }, [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 730e269d507a..495ca5c25d39 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 87d8141437a5..eb0c95880c86 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 72c272305de0..779bfbd03da7 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 4e95f50ca45d..267cfc26ae48 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 1c8f5655ff0d..87c461a69442 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();