From 43fa95469836af52610255b1b8511ec463857351 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 17 Feb 2026 17:20:11 +0100 Subject: [PATCH 01/13] Freeze non-top screens within SplitNavigator on web to prevent unnecessary re-renders --- .../createSplitNavigator/index.tsx | 1 + .../index.tsx | 25 +++++++++++++++++-- .../types/NavigatorComponent.ts | 1 + 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx index 4483297f6a1b..96607dc9b1d5 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/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index 730e269d507a..b848c0ffc776 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 { + if (!freezeNonTopScreens) { + return descriptors; + } + + const topRouteKey = state.routes[state.index]?.key; + const result: typeof descriptors = {}; + for (const [key, descriptor] of Object.entries(descriptors)) { + const isOnTop = key === topRouteKey; + const isPersistent = persistentScreens?.includes(descriptor.route.name); + const isScreenBlurred = !isOnTop && !isPersistent; + result[key] = { + ...descriptor, + render: () => {descriptor.render()}, + }; + } + return result; + }, [descriptors, persistentScreens, state]); + 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}; From 273e9c1f7fc87d530090975ae6618bd4e4ed70c2 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 18 Feb 2026 10:05:29 +0100 Subject: [PATCH 02/13] Add ScreenFreezeWrapper --- .../ScreenFreezeWrapper.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx new file mode 100644 index 000000000000..4697a3f41f3c --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx @@ -0,0 +1,22 @@ +import React, {useLayoutEffect, useState} from 'react'; +import {Freeze} from 'react-freeze'; + +type ScreenFreezeWrapperProps = { + isScreenBlurred: boolean; + children: React.ReactNode; +}; + +function ScreenFreezeWrapper({isScreenBlurred, children}: ScreenFreezeWrapperProps) { + const [frozen, setFrozen] = useState(false); + + // Decouple the Suspense render task so it won't be interrupted by React's concurrent mode + // and stuck in an infinite loop + useLayoutEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setFrozen(isScreenBlurred); + }, [isScreenBlurred]); + + return {children}; +} + +export default ScreenFreezeWrapper; From 13e4629dd1621c3398c56a7616397250883c98d3 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 20 Feb 2026 11:01:02 +0100 Subject: [PATCH 03/13] Freeze screen below focused on mobile --- ...+7.3.14+003+freeze-screen-below-focused.patch | 16 ++++++++++++++++ patches/react-navigation/details.md | 10 +++++++++- src/setup/index.ts | 5 +++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 patches/react-navigation/@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch 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..21c056fe388e --- /dev/null +++ b/patches/react-navigation/@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch @@ -0,0 +1,16 @@ +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 +--- 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 @@ + 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, \ No newline at end of file diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 353fac20ab40..5d7ef976c7b8 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -39,6 +39,14 @@ - PR Introducing Patch: [#37891](https://github.com/Expensify/App/pull/37891) - PR Updating Patch: [#64155](https://github.com/Expensify/App/pull/64155) +### [@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch](@react-navigation+native-stack+7.3.14+003+freeze-screen-below-focused.patch) + +- Reason: Removes the `isBelowFocused` exception on Fabric that prevented freezing the screen directly below the focused one. This is now safe because `DelayedFreeze` in `react-native-screens` defers freezing to the next macrotask, and native-stack transition animations run on the UI thread independently of the React tree. +- Upstream PR/issue: N/A +- E/App issue: N/A +- PR Introducing Patch: https://github.com/Expensify/App/pull/82764 +- PR Updating Patch: N/A + ### [@react-navigation+native+7.1.10+001+initial.patch](@react-navigation+native+7.1.10+001+initial.patch) - Reason: Allows us to use some more advanced navigation actions without messing up the browser history @@ -75,4 +83,4 @@ - Upstream PR/issue: N/A - E/App issue: [#65709](https://github.com/Expensify/App/issues/65211) - PR Introducing Patch: [#65836](https://github.com/Expensify/App/pull/66890) -- PR Updating Patch: N/A \ No newline at end of file +- PR Updating Patch: N/A 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(); From 7f9b9e789f67e9d180beef6d1bd6d2a69be3ae02 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Fri, 27 Feb 2026 09:28:34 +0100 Subject: [PATCH 04/13] Update the patch to change the NativeStackView.native.tsx too for consistency --- ...3.14+003+freeze-screen-below-focused.patch | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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 index 21c056fe388e..726fe73f8ecc 100644 --- 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 @@ -1,10 +1,11 @@ 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 @@ +@@ -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; @@ -13,4 +14,23 @@ diff --git a/node_modules/@react-navigation/native-stack/lib/module/views/Native + const shouldFreeze = !isPreloaded && !isFocused; return /*#__PURE__*/_jsx(SceneView, { index: index, - focused: isFocused, \ No newline at end of file + 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 ( + Date: Fri, 27 Feb 2026 09:47:10 +0100 Subject: [PATCH 05/13] Add comments --- .../ScreenFreezeWrapper.tsx | 3 +++ .../createPlatformStackNavigatorComponent/index.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx index 4697a3f41f3c..405fa5e71673 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx @@ -2,7 +2,10 @@ import React, {useLayoutEffect, useState} from 'react'; import {Freeze} from 'react-freeze'; type ScreenFreezeWrapperProps = { + /** Whether the screen is not currently visible to the user */ isScreenBlurred: boolean; + + /** The screen content to freeze when blurred */ children: React.ReactNode; }; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index b848c0ffc776..ae3de2578cad 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -102,6 +102,9 @@ function createPlatformStackNavigatorComponent { if (!freezeNonTopScreens) { return descriptors; From 9df11aa50b3018576a19b806bbb66b503398b330 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 3 Mar 2026 17:13:52 +0100 Subject: [PATCH 06/13] Fix crash on attachment sending --- .../AttachmentModalBaseContent/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index 72c272305de0..97d870f7986c 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -166,11 +166,11 @@ function AttachmentModalBaseContent({ return; } + onClose?.(); + if (onConfirm) { onConfirm(Object.assign(files ?? {}, {source} as FileObject)); } - - onClose?.(); }, [isConfirmButtonDisabled, onConfirm, onClose, files, source]); // Close the modal when the escape key is pressed From 76d2a9b18f65e6b0a66b764ce89f374c84385e98 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Tue, 3 Mar 2026 18:31:24 +0100 Subject: [PATCH 07/13] Fix text briefly greyed out when swiping back --- patches/react-native-screens/details.md | 8 +++++++ ...+4.15.4+001+delay-freeze-until-paint.patch | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 patches/react-native-screens/details.md create mode 100644 patches/react-native-screens/react-native-screens+4.15.4+001+delay-freeze-until-paint.patch 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]); + From ad30958aed8eb042929810bfa9139f8519450ef2 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 4 Mar 2026 11:25:14 +0100 Subject: [PATCH 08/13] Fix tooltip remains displayed when navigate to thread page --- .../ScreenFreezeWrapper.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx index 405fa5e71673..ca6e1e2ba641 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeWrapper.tsx @@ -1,5 +1,6 @@ import React, {useLayoutEffect, useState} from 'react'; import {Freeze} from 'react-freeze'; +import TooltipSense from '@components/Tooltip/TooltipSense'; type ScreenFreezeWrapperProps = { /** Whether the screen is not currently visible to the user */ @@ -15,8 +16,17 @@ function ScreenFreezeWrapper({isScreenBlurred, children}: ScreenFreezeWrapperPro // Decouple the Suspense render task so it won't be interrupted by React's concurrent mode // and stuck in an infinite loop useLayoutEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setFrozen(isScreenBlurred); + 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}; From 971bd23474c62acfe688ba3e6cd7aafabb168b00 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 4 Mar 2026 16:00:45 +0100 Subject: [PATCH 09/13] Use startTransition in DelayedFreeze to avoid blocking queued callbacks --- patches/react-native-screens/details.md | 4 ++-- ...eens+4.15.4+001+low-priority-freeze.patch} | 23 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) rename patches/react-native-screens/{react-native-screens+4.15.4+001+delay-freeze-until-paint.patch => react-native-screens+4.15.4+001+low-priority-freeze.patch} (53%) diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index e07de39d8108..dab9b6c4c87b 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -1,8 +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) +### [react-native-screens+4.15.4+001+low-priority-freeze.patch](react-native-screens+4.15.4+001+low-priority-freeze.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. +- Reason: Wraps the freeze state update in `startTransition` so it is treated as a low-priority update. Without this, the Suspense boundary used by react-freeze can starve pending macrotasks (setTimeout, rAF), blocking queued callbacks from firing. - 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+low-priority-freeze.patch similarity index 53% rename from patches/react-native-screens/react-native-screens+4.15.4+001+delay-freeze-until-paint.patch rename to patches/react-native-screens/react-native-screens+4.15.4+001+low-priority-freeze.patch index 34b81d29b89f..6984709d81cb 100644 --- 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+low-priority-freeze.patch @@ -1,23 +1,20 @@ 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 +index 443da63..a1b2c3d 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) { +@@ -12,11 +12,16 @@ 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. + ++ // We use startTransition to mark the freeze as a low-priority update so it ++ // doesn't starve pending macrotasks (setTimeout, rAF). Without this, the ++ // Suspense boundary used by react-freeze can block queued callbacks from firing. React.useEffect(() => { -+ let rafID: number; const id = setTimeout(() => { - setFreezeState(freeze); -+ rafID = requestAnimationFrame(() => setFreezeState(freeze)); ++ React.startTransition(() => { ++ setFreezeState(freeze); ++ }); }, 0); return () => { - clearTimeout(id); -+ cancelAnimationFrame(rafID); - }; - }, [freeze]); - + clearTimeout(id); \ No newline at end of file From cfe0d141cb1e0ce50d0602552cebcd5eefdd7f46 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 4 Mar 2026 16:30:40 +0100 Subject: [PATCH 10/13] Revert "Use startTransition in DelayedFreeze to avoid blocking queued callbacks" This reverts commit 971bd23474c62acfe688ba3e6cd7aafabb168b00. --- patches/react-native-screens/details.md | 4 ++-- ...4.15.4+001+delay-freeze-until-paint.patch} | 23 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) rename patches/react-native-screens/{react-native-screens+4.15.4+001+low-priority-freeze.patch => react-native-screens+4.15.4+001+delay-freeze-until-paint.patch} (53%) diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md index dab9b6c4c87b..e07de39d8108 100644 --- a/patches/react-native-screens/details.md +++ b/patches/react-native-screens/details.md @@ -1,8 +1,8 @@ # `react-native-screens` patches -### [react-native-screens+4.15.4+001+low-priority-freeze.patch](react-native-screens+4.15.4+001+low-priority-freeze.patch) +### [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: Wraps the freeze state update in `startTransition` so it is treated as a low-priority update. Without this, the Suspense boundary used by react-freeze can starve pending macrotasks (setTimeout, rAF), blocking queued callbacks from firing. +- 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+low-priority-freeze.patch b/patches/react-native-screens/react-native-screens+4.15.4+001+delay-freeze-until-paint.patch similarity index 53% rename from patches/react-native-screens/react-native-screens+4.15.4+001+low-priority-freeze.patch rename to patches/react-native-screens/react-native-screens+4.15.4+001+delay-freeze-until-paint.patch index 6984709d81cb..34b81d29b89f 100644 --- a/patches/react-native-screens/react-native-screens+4.15.4+001+low-priority-freeze.patch +++ b/patches/react-native-screens/react-native-screens+4.15.4+001+delay-freeze-until-paint.patch @@ -1,20 +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..a1b2c3d 100644 +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,11 +12,16 @@ function DelayedFreeze({ freeze, children }: FreezeWrapperProps) { +@@ -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 startTransition to mark the freeze as a low-priority update so it -+ // doesn't starve pending macrotasks (setTimeout, rAF). Without this, the -+ // Suspense boundary used by react-freeze can block queued callbacks from firing. + ++ // 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); -+ React.startTransition(() => { -+ setFreezeState(freeze); -+ }); ++ rafID = requestAnimationFrame(() => setFreezeState(freeze)); }, 0); return () => { - clearTimeout(id); \ No newline at end of file + clearTimeout(id); ++ cancelAnimationFrame(rafID); + }; + }, [freeze]); + From 181b06078f56f4e15d0280b119a22a06ca9007b6 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 4 Mar 2026 17:29:29 +0100 Subject: [PATCH 11/13] Fix navigation after inviting a member --- src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; } From 16ecb005ac1f21ff0fdc6e210676bba3f219f71f Mon Sep 17 00:00:00 2001 From: VickyStash Date: Thu, 5 Mar 2026 09:47:25 +0100 Subject: [PATCH 12/13] Fix crash on attachment sending --- .../AttachmentModalBaseContent/index.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index 97d870f7986c..779bfbd03da7 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -168,9 +168,10 @@ function AttachmentModalBaseContent({ onClose?.(); - if (onConfirm) { - onConfirm(Object.assign(files ?? {}, {source} as FileObject)); - } + // 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 From b16e8f914663a1631d214429cb432de193e00303 Mon Sep 17 00:00:00 2001 From: VickyStash Date: Wed, 11 Mar 2026 08:05:25 +0100 Subject: [PATCH 13/13] Remove redundant memoization --- .../createPlatformStackNavigatorComponent/index.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index ae3de2578cad..495ca5c25d39 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -105,11 +105,8 @@ function createPlatformStackNavigatorComponent { - if (!freezeNonTopScreens) { - return descriptors; - } - + let wrappedDescriptors = descriptors; + if (freezeNonTopScreens) { const topRouteKey = state.routes[state.index]?.key; const result: typeof descriptors = {}; for (const [key, descriptor] of Object.entries(descriptors)) { @@ -121,8 +118,8 @@ function createPlatformStackNavigatorComponent {descriptor.render()}, }; } - return result; - }, [descriptors, persistentScreens, state]); + wrappedDescriptors = result; + } const Content = useMemo( () => (