Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions patches/react-native-screens/details.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +29 to +31

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Unfreeze screens immediately when focus returns

This effect now waits FREEZE_DELAY_MS for all freeze transitions, not just when entering the frozen state. When a route becomes focused again (freeze flips true -> false), setFreezeState(false) is delayed by ~400ms, so the newly focused screen can remain suspended and miss immediate UI updates/taps after back/pop navigation. The delay should only apply when freeze is true, while unfreezing should happen immediately.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In DelayedFreeze if freeze is false it always applied right away, https://github.com/software-mansion/react-native-screens/blob/63b3baab65a1fd36da04ae426f98ad460217e1e0/src/components/helpers/DelayedFreeze.tsx#L24

  return <Freeze freeze={freeze ? freezeState : false}>{children}</Freeze>;

return () => {
clearTimeout(id);
+ cancelAnimationFrame(rafID);
};
}, [freeze]);
Original file line number Diff line number Diff line change
@@ -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 (
<SceneView
10 changes: 9 additions & 1 deletion patches/react-navigation/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
- PR Updating Patch: N/A
5 changes: 5 additions & 0 deletions src/hooks/useKeyboardShortcut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {useEffect} from 'react';
import type {GestureResponderEvent} from 'react-native';
import type {ValueOf} from 'type-fest';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import {useScreenFreezeContext} from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/ScreenFreezeContext';
import CONST from '@src/CONST';

type Shortcut = ValueOf<typeof CONST.KEYBOARD_SHORTCUTS>;
Expand Down Expand Up @@ -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,
Expand All @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const SplitNavigatorComponent = createPlatformStackNavigatorComponent('SplitNavi
defaultScreenOptions: defaultPlatformStackScreenOptions,
useCustomState: useCustomSplitNavigatorState,
NavigationContentWrapper: SidebarSpacerWrapper,
freezeNonTopScreens: true,
});

function createSplitNavigator<
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ScreenFreezeContextType>({
registerFreezeDefer: () => () => {},
});

function useScreenFreezeContext() {
return useContext(ScreenFreezeContext);
}

export default ScreenFreezeContext;
export {useScreenFreezeContext};
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-5 (docs)

The eslint-disable-next-line react-hooks/set-state-in-effect suppression lacks an explicit justification for why the lint rule is safe to bypass here. The preceding comment describes the intended behavior but does not explain why calling setFrozen synchronously inside useLayoutEffect will not cause re-render loops.

Add a comment directly explaining why the rule is safe to disable, for example:

// eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronous unfreeze is intentional; the early return prevents infinite loops since isScreenBlurred is the only dependency and won't change as a result of this setState.
setFrozen(false);

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-5 (docs)

The eslint-disable-next-line react-hooks/set-state-in-effect suppression lacks an explicit justification for why the lint rule is safe to bypass here. The preceding comment describes the intent but does not explain why calling setFrozen inside useLayoutEffect will not cause re-render loops.

Add a comment directly explaining why the rule is safe to disable, for example:

// eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronous freeze is safe; isScreenBlurred is the only dependency and setFrozen(true) won't trigger further isScreenBlurred changes.
setFrozen(isScreenBlurred);

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

setFrozen(isScreenBlurred);
}, [isScreenBlurred]);

return (
<ScreenFreezeContext.Provider value={contextValue}>
<Freeze freeze={frozen}>{children}</Freeze>
</ScreenFreezeContext.Provider>
);
}

export default ScreenFreezeWrapper;
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
PlatformStackNavigatorProps,
PlatformStackRouterOptions,
} from '@libs/Navigation/PlatformStackNavigation/types';
import ScreenFreezeWrapper from './ScreenFreezeWrapper';

function createPlatformStackNavigatorComponent<RouterOptions extends PlatformStackRouterOptions = PlatformStackRouterOptions>(
displayName: string,
Expand All @@ -24,6 +25,7 @@ function createPlatformStackNavigatorComponent<RouterOptions extends PlatformSta
const ExtraContent = options?.ExtraContent;
const NavigationContentWrapper = options?.NavigationContentWrapper;
const useCustomEffects = options?.useCustomEffects ?? (() => undefined);
const freezeNonTopScreens = options?.freezeNonTopScreens;

function PlatformNavigator({
id,
Expand Down Expand Up @@ -100,6 +102,25 @@ function createPlatformStackNavigatorComponent<RouterOptions extends PlatformSta
};
}, [persistentScreens, state]);

// Wrap each screen's render function with ScreenFreezeWrapper to freeze non-top screens.
// This prevents off-screen components from re-rendering.
// Persistent screens (e.g. sidebar) are excluded from freezing so they stay interactive.
let wrappedDescriptors = descriptors;
if (freezeNonTopScreens) {
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: () => <ScreenFreezeWrapper isScreenBlurred={isScreenBlurred}>{descriptor.render()}</ScreenFreezeWrapper>,
};
}
wrappedDescriptors = result;
}

const Content = useMemo(
() => (
<NavigationContent>
Expand All @@ -108,7 +129,7 @@ function createPlatformStackNavigatorComponent<RouterOptions extends PlatformSta
{...props}
direction="ltr"
state={mappedState}
descriptors={descriptors}
descriptors={wrappedDescriptors}
navigation={navigation}
describe={describe}
/>
Expand All @@ -119,7 +140,7 @@ function createPlatformStackNavigatorComponent<RouterOptions extends PlatformSta
)}
</NavigationContent>
),
[NavigationContent, customCodePropsWithCustomState, describe, descriptors, mappedState, navigation, props],
[NavigationContent, customCodePropsWithCustomState, describe, wrappedDescriptors, mappedState, navigation, props],
);

// eslint-disable-next-line react/jsx-props-no-spreading
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type CreatePlatformStackNavigatorComponentOptions<RouterOptions extends Platform
useCustomEffects?: CustomEffectsHook<ParamList>;
ExtraContent?: ExtraContent;
NavigationContentWrapper?: NavigationContentWrapper;
freezeNonTopScreens?: boolean;
};

export type {CustomCodeProps, CustomStateHookProps, CustomEffectsHookProps, CreatePlatformStackNavigatorComponentOptions, ExtraContentProps};
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ function WorkspaceInviteMessageComponent({
}

if ((backTo as string)?.endsWith('members')) {
Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal());
Navigation.dismissModal();
return;
}

Expand Down
5 changes: 5 additions & 0 deletions src/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down
Loading