Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
43fa954
Freeze non-top screens within SplitNavigator on web to prevent unnece…
VickyStash Feb 17, 2026
273e9c1
Add ScreenFreezeWrapper
VickyStash Feb 18, 2026
b7f25fe
Merge branch 'main' into VickyStash/refactor/freeze-non-top-screens
VickyStash Feb 18, 2026
13e4629
Freeze screen below focused on mobile
VickyStash Feb 20, 2026
74563bc
Merge branch 'main' into VickyStash/refactor/freeze-non-top-screens
VickyStash Feb 20, 2026
11821ab
Merge branch 'main' into VickyStash/refactor/freeze-non-top-screens
VickyStash Feb 23, 2026
fbf4646
Merge branch 'main' into VickyStash/refactor/freeze-non-top-screens
VickyStash Feb 26, 2026
7f9b9e7
Update the patch to change the NativeStackView.native.tsx too for con…
VickyStash Feb 27, 2026
ab31e39
Add comments
VickyStash Feb 27, 2026
176b708
Merge branch 'main' into VickyStash/refactor/freeze-non-top-screens
VickyStash Mar 2, 2026
9df11aa
Fix crash on attachment sending
VickyStash Mar 3, 2026
76d2a9b
Fix text briefly greyed out when swiping back
VickyStash Mar 3, 2026
ad30958
Fix tooltip remains displayed when navigate to thread page
VickyStash Mar 4, 2026
971bd23
Use startTransition in DelayedFreeze to avoid blocking queued callbacks
VickyStash Mar 4, 2026
cfe0d14
Revert "Use startTransition in DelayedFreeze to avoid blocking queued…
VickyStash Mar 4, 2026
181b060
Fix navigation after inviting a member
VickyStash Mar 4, 2026
a72359c
Merge branch 'main' into VickyStash/refactor/freeze-non-top-screens
VickyStash Mar 4, 2026
16ecb00
Fix crash on attachment sending
VickyStash Mar 5, 2026
b0e8908
Merge branch 'main' into VickyStash/refactor/freeze-non-top-screens
fabioh8010 Mar 9, 2026
16bb598
Merge branch 'main' into VickyStash/refactor/freeze-non-top-screens
VickyStash Mar 11, 2026
b16e8f9
Remove redundant memoization
VickyStash Mar 11, 2026
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: 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
Original file line number Diff line number Diff line change
@@ -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]);

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
Comment thread
Julesssss marked this conversation as resolved.
@@ -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
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,35 @@
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 */
isScreenBlurred: boolean;

/** The screen content to freeze when blurred */
children: React.ReactNode;
Comment thread
VickyStash marked this conversation as resolved.
};

function ScreenFreezeWrapper({isScreenBlurred, children}: ScreenFreezeWrapperProps) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ CONSISTENCY-3 (docs)

The ScreenFreezeWrapper component duplicates the core freeze-with-deferred-Suspense pattern already implemented in the existing FreezeWrapper at src/libs/Navigation/AppNavigator/FreezeWrapper/index.tsx. Both components:

  1. Accept an isScreenBlurred signal (prop vs internal state)
  2. Use useLayoutEffect to decouple the Suspense render task and avoid concurrent mode infinite loops
  3. Pass the deferred boolean to <Freeze>

Consider extracting the shared freeze logic into a single reusable component. For example, refactor the existing FreezeWrapper/index.tsx to use ScreenFreezeWrapper internally for its deferred freeze logic, or extract the useState + useLayoutEffect + <Freeze> pattern into a shared component that both consumers can import.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems unnecessary I think. Slightly different cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I would leave it as is for now.

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(() => {
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 <body>.
const id = requestAnimationFrame(() => setFrozen(isScreenBlurred));
return () => {
cancelAnimationFrame(id);
};
}, [isScreenBlurred]);

return <Freeze freeze={frozen}>{children}</Freeze>;
}

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not able to reproduce iOS - App doesn't return to members page after inviting a user to a workspace even without this change.

Screen.Recording.2026-03-07.at.4.32.01.AM.mov

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@situchan Please, try to follow these steps:

  1. Create a new workspace
  2. Navigate to members tab > Invite member
  3. Enter an email address of the user you have never used before (random email) > Next
  4. Tap on invite in the confirmation page

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I tried but still not reproducible.
Though I am not sure about the root cause.
Does it mean that requestAnimationFrame callback is never triggered?

Btw not blocker

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