Open IuliiaHerets opened 1 month ago
Triggered auto assignment to @puneetlath (Bug
), see https://stackoverflow.com/c/expensify/questions/14418 for more details. Please add this bug to a GH project, as outlined in the SO.
We think that this bug might be related to #vip-vsb
@puneetlath FYI I haven't added the External label as I wasn't 100% sure about this issue. Please take a look and add the label if you agree it's a bug and can be handled by external contributors
Job added to Upwork: https://www.upwork.com/jobs/~01721123276553a8a9
Triggered auto assignment to Contributor-plus team member for initial proposal review - @ntdiary (External
)
It seems that this bug is caused by unnecessary state updates, leading to a much more complicated issue than it initially appears. There's an infinite re-rendering loop occurring when navigating from Settings -> Inbox -> Settings.
The top-level component
cI've been unable to pinpoint the exact cause of these unnecessary re-renders, but I suspect it mainly stems from
See : https://github.com/Expensify/App/issues/47041#issuecomment-2289223687react-native-onyx/withOnyx()
.
@puneetlath after pull new code i don't reproduce this issue screen-capture.webm
Interesting, I can still reproduce it ๐
https://github.com/user-attachments/assets/ca4f5c4f-ed15-470b-9931-ae3217931cce
The Expensify
component is rendering repeatedly, and we haven't got a complete proposal.
Hi, I'm Julian from Callstack - expert agency - and I would like to work on this issue.
@codewaseem I've removed withOnyx
from Expensify.tsx and used useOnyx
instead and I can still reproduce the issue, I will keep digging!
@JKobrynski, ignore my previous comment. I think the root cause is from LottieView (lottie-react-native). I think it doesn't get cleaned up when we navigate away from the settings page. When we return back to the settings page again, it causes infinite renders. If you turn off animation by removing the autoPlay prop, it doesn't cause infinite rerenders anymore.
I tried manually starting and stopping the animation with the ref. Used setTimeout, requestIdleCallback, etc, but nothing seemed to work.
This issue come up while I'm working on another issue and I want to post a proposal on this one. Can we reapply the Help Wanted
label?
@dominictb I applied a patch to react-native-web
based on your proposal as well as the alternative solution, and none of them worked for me in this exact scenario. I think there is a different reason to it
No, I mean I figured out the root cause of this issue while working on that issue. The root cause is different, and I want to post a proposal here instead of tampering with the other issue.
@dominictb sure, go ahead.
@puneetlath please re-apply the help wanted label so I could post the proposal. Thank you!
Done.
๐ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐ธ
Edited by proposal-police: This proposal was edited at {2023-10-05T14:35:00Z}.
User avatar and the animation do not load after switching from Inbox to Settings with animation.
The root cause is within FreezeWrapper
component that wraps the SidebarScreen
here. This explains the following:
Search
and Settings
tab.FreezeWrapper
here, the issue fix itself.Inside FreezeWrapper
here, we have a setState
call (setIsScreenBlurred(shouldSetScreenBlurred(navigationIndex));
) in the navigation state listener. Since we are running on concurrent mode, React will automatically batch the state update operations to perform in 1 render, and that means setIsScreenBlurred(shouldSetScreenBlurred(navigationIndex))
doesn't immediately take effect.
Freeze
component in react-freeze
is just a simple wrapper, which returns a freeze=true
, and renders children
in case freeze=false
. In this page, it mentioned
Suspense trees are always consistent: If a component suspends before itโs fully added to the tree, React will not add it to the tree in an incomplete state or fire its effects. Instead, React will throw away the new tree completely, wait for the asynchronous operation to finish, and then retry rendering again from scratch. React will render the retry attempt concurrently, and without blocking the browser.
Since in here, the Suspense with the infinite unresolved promise is returned during re-render (and the whole subtree of children
is suspended), and React can now interrupt the render phase during concurrent mode, this means React some how throw away the render task and retry the rendering attempt again while the asynchronous operation (the unresolved promise) cannot be finished. I'm not sure this is a bug or an intentional design of React in this case, nor I couldn't pinpoint the exact implementation details of this in React reconciler, but this seems to explain well the following point:
flushSync
like this flushSync(() => setIsScreenBlurred(true))
(which force React to flush the existing pending setStates and render immediately without interruption) it won't cause this issue. console.log
inside the component function body, it could goes up to hundreds of thousands, but if we try to useEffect(() => {console.log...})
then the log would appear once or twice. This could indicate the rendering phase is retried multiple times without being committedThis issue is worse (the number of rendering retries is like a thousands or even non-stop) if we land on Lottie animation, but it could goes up to a hundred-ish times for settings/profile page, which we can easily spot by seeing a delay in profile image rendering
https://github.com/user-attachments/assets/a02aaac6-60a7-47e5-952d-2e866394e9ff
We have some options:
use flushSync
in here (Not recommended by React)
https://github.com/Expensify/App/issues/47041#issuecomment-2315050257 use useLayoutEffect
to decouple the Suspense
render task
Alternative solution: The principle is that we will need to extract the setIsScreenBlurred
to a separate batch, so that it won't affect other render task. One way mentioned above is to flushSync
, i.e: prioritizing the setIsScreenBlurred
over any other render task. The other option is to deprioritize setIsScreenBlurred
by using useDeferredValue hook
// FreezeWrapper.tsx
const [isScreenBlurred, setIsScreenBlurred] = useState(false);
....
const deferredBlurred = useDeferredValue(isScreenBlurred)
...
return <Freeze freeze={!isFocused && deferredBlurred && !keepVisible}>{children}</Freeze>;
Note: We might also consider to upgrade the react-freeze
to the latest version for this https://github.com/software-mansion/react-freeze/pull/34 as well. This doesn't singly solve the above issue though, as we still need to update the implementation of FreezeWrapper
.
https://github.com/Expensify/App/issues/47041#issuecomment-2294923394 proposal updated with more information
Under review.
I tested the solution of rewriting FreezeWrapper
using useMemo
, and it seems to work well! However, I still hope we can dig deeper into the underlying cause of the rendering problem, e.g., if React dropped the render task, why was only the SVG element not rendered? This might help us address the problem at its core. :)
@ntdiary I don't have the answer on the detailed level like how React reconcile the tree and batch the state updates, but in general, I could give the following explanation: we only saw some elements but not all, are rendered with some delay here, and this is because React batch those renders/re-renders together. That means: we have a batch including setIsScreenBlurred(true)
and other setState
operations, and that batch is being dropped and retried by React multiple times, what we saw will look like a delay state update: the component will be re-rendered multiple times without success. So to answer your question
if React dropped the render task, why was only the SVG element not rendered?
it is because the rendering on Lottie
or SVG
component is in the same batch as `setIsScreenBlurred(true)
I'm not sure how React will batch the render task, but any solution that extract the setIsScreenBlurred(true)
and other render tasks into separate batches will work in this case. For instance:
flushSync
works.
Rewrite the FreezeWrapper
so that FreezeWrapper
will be re-rendered because of the useNavigation
/useIsFocused
hook (which is using useContext
under the hood)
We can update in individual component to ensure the criteria extract the setIsScreenBlurred(true)
and other render tasks into separate batches will also work. For example, to avoid Expensify.tsx
to re-render multiple times, we can replace the lastVisitedUrl
in withOnyx
with useOnyx
(lastVisitedTime
is being changed in handleStateChanged
NavigationRoot
listener, and the handleStateChanged
listener is being triggered in useEffect
, also, withOnyx
use this.setState
operation under the hood, so there's a high chance that this state update will be batched with setIsScreenBlurred(true)
). We can also tries to update the Lottie
component with the same idea as well (I haven't tried it).
@dominictb, don't worry, I'm still debugging the underlying code, I was also hoping to find a more detailed description of the underlying aspects of your solution so that we can move forward with more confidence. ๐
Thank you @ntdiary! Meanwhile, I'll try to look further to see if I could get more detailed analysis.
Proposal update to add alternative solution, which is more elegant (I think).
This issue is a bit challenging , here is a brief update for now:
flushSync
approach can load the animation, but considering Reactโs official note, this approach is not our first choice.isScreenBlurred
using useMemo
, it can also load the animation, but Suspense
stops working (i.e., the freeze
prop is still false
).deferredBlurred
with the useDeferredValue
approach, this can also load the animation, but FreezeWrapper
is still stuck in a rendering loop, even though we canโt see this behavior, it would be better to address it if possible.Additionally, Iโve nearly pinpointed the underlying rendering task conflict and will double check it tomorrow, and then provide a more detailed description. :)
@ntdiary about your second point, can you double check and provide a screenshot that the freeze prop is false? I checked many times and it always returned true.
For your third point, this is expected and should not affect the performance. The point is that the re-render of the FreezeWrapper will happen, but will be stopped by React for other tasks before picking it up again in the background, hence you can see the FreezeWrapper function is called many times, but it doesn't affect the overall performance of the app. More info: https://react.dev/reference/react/useDeferredValue
@dominictb, here are the videos I tested with useMemo
and useDeferredValue
approaches, they both load the animation, but Suspense
isn't working. Is there anything missing in the code and steps I used? ๐ค
https://github.com/user-attachments/assets/d191070d-4e81-443a-9c9f-a8a5a2493882
https://github.com/user-attachments/assets/de946a7b-5156-43b2-ad8a-b682082a00a0
Here are some of my findings on the underlying issue:
The root cause of the infinite rendering is the conflict between the looped autoplay of the off-screen svg animation and the Suspense
state update in FreezeWrapper
.
When we navigate from the security page to inbox page, the security page is only hidden and not unmounted, so the SVG animation will always play indefinitely (using requestAnimationFrame
), causing the corresponding React component state inside LottieView
to be updated indefinitely as well.
When we click the bottom avatar and navigate to the security page again, a new security page will be created, and during this click event loop, the Lottie
empty view is rendered and committed first, followed by setting freeze
to true in FreezeWrapper
and loading the new security page SVG animation.
However, due to the nature of the Suspense
component, React delays the commit of this rendering, and the delay time determined by an internal algorithm. Most of the time, this delay is longer than the interval between frames, so when the next frame animation is emitted, React cancels the previous commit task due to the new task, and reschedules a new commit task, thus getting stuck in an infinite loop.
Finally, I think we need to ensure not only that the animation is displayed but also that FreezeWrapper does not enter an infinite rendering loop, otherwise, freeze may not work as expected.
My breakpoint debugging screenshot:
@ntdiary I couldn't reproduce what you shown above for useMemo
approach. From my side it works as expected.
And based on https://github.com/Expensify/App/issues/47041#issuecomment-2305205107, then useDeferredValue
also doesn't actually enforce the Suspense
, as the rendering task is continuously discarded and picked up. We don't see the app lag/freeze is because it happened in the background.
@puneetlath @ntdiary @JKobrynski this issue was created 2 weeks ago. Are we close to approving a proposal? If not, what's blocking us from getting this issue assigned? Don't hesitate to create a thread in #expensify-open-source to align faster in real time. Thanks!
@ntdiary I couldn't reproduce what you shown above for useMemo approach. From my side it works as expected.
@dominictb, I can always reproduce it the first time I switch back to the security
page. The steps are:
inbox
button to navigate to the inbox page, And based on #47041 (comment), then
useDeferredValue
also doesn't actually enforce theSuspense
, as the rendering task is continuously discarded and picked up. We don't see the app lag/freeze is because it happened in the background.
Eh, what I mean is, Ideally, the render of the BaseSidebarScreen
component should be frozen after navigating to the security page. Although useDeferredValue
can load the svg animation, it doesn't achieve this goal.
๐ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐ธ
@puneetlath, @ntdiary, @JKobrynski Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
Are we close to any solutions here?
https://github.com/Expensify/App/issues/47041#issuecomment-2307058243 I will need to test this out.
Are we close to any solutions here?
It's still under discussion. Personally, I think there are three directions worth considering, but each has its own pros and cons:
flushSync
(possibly with setImmediate
as well).will provide more details tomorrow.
@ntdiary there's another potential solution: use useLayoutEffect
.
The idea is that when we setIsScreenBlurred
, we shouldn't force the Suspense
to be rendered immediately in the same render batch. We can use useLayoutEffect
to achieve the following: In the next render cycle (after setIsScreenBlurred
is true), we will setFreezed(true)
to render the Suspense
. In this case, Suspense
will be rendered without being interrupted by any other render task
Code change (already tested and confirmed)
import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native';
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {Freeze} from 'react-freeze';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import shouldSetScreenBlurred from './shouldSetScreenBlurred';
type FreezeWrapperProps = ChildrenProps & {
/** Prop to disable freeze */
keepVisible?: boolean;
};
function FreezeWrapper({keepVisible = false, children}: FreezeWrapperProps) {
const [isScreenBlurred, setIsScreenBlurred] = useState(false);
const [freezed, setFreezed] = useState(false);
// we need to know the screen index to determine if the screen can be frozen
const screenIndexRef = useRef<number | null>(null);
const isFocused = useIsFocused();
const navigation = useNavigation();
const currentRoute = useRoute();
useEffect(() => {
const index = navigation.getState()?.routes.findIndex((route) => route.key === currentRoute.key) ?? 0;
screenIndexRef.current = index;
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const unsubscribe = navigation.addListener('state', () => {
const navigationIndex = (navigation.getState()?.index ?? 0) - (screenIndexRef.current ?? 0);
setIsScreenBlurred(shouldSetScreenBlurred(navigationIndex));
});
return () => unsubscribe();
}, [isFocused, isScreenBlurred, navigation]);
useLayoutEffect(() => {
setFreezed(!isFocused && isScreenBlurred && !keepVisible);
}, [isFocused, isScreenBlurred, keepVisible]);
return <Freeze freeze={freezed}>{children}</Freeze>;
}
FreezeWrapper.displayName = 'FreezeWrapper';
export default FreezeWrapper;
OK, I think we can move forward with @dominictb's proposal. Now we have two options:
flushSync
to wrap setIsScreenBlurred(shouldSetScreenBlurred(navigationIndex))
.freezed
state and use useLayoutEffect
to commit changes in FreezeWrapper
.This issue only occurs on the web platform, so I suggest fixing it specifically for the web platform. We could even create a new FreezeWrapper
specifically for the SidebarScreen
to prevent this sync variant from being accidentally used elsewhere.
For option 1, although React states that using flushSync
is uncommon, I think this situation is an exception, we need to ensure that the rendering of Suspense
can be committed, and it's also relatively simple to wrap it with a platform-specific util function (note: this api is available in the react-dom
lib).
For option 2, adding a new state would lead to more re-renders compared to option 1, and there would be more repetitive code when writing code for different platforms.
๐ ๐ ๐ C+ reviewed
Current assignee @puneetlath is eligible for the choreEngineerContributorManagement assigner, not assigning anyone new.
Hi, @puneetlath, Do you have any different thoughts on this comment? ๐
Woof, tough one. I've asked someone from SWM to weigh in, since they are the makers of react-freeze.
I think that going with useLayoutEffect
might be a better approach, since it's able to interrupt rendering there shouldn't be much of a difference (besides maybe one more render happening due to another state variable). Iโd also keep the change only for web to be sure that it doesn't break anything on native.
Nonetheless, I believe solving the root of the problem (the animation playing indefinitely in the background causing renders to fail) would be better while this can be used as a temporary fix.
- Pause the animation when its page is not at the top of navigation stack.
- If all of our animations are autoplay and loop, meaning we don't care about the animation state, we can try to decouple the svg animations from React tree updates.
I tried two other methods before, and they both can address this issue. However, the animation loading is slightly delayed, because React always tries to schedule a timeout task for Suspense
, any new rendering tasks that come up during this delay will cancel this timeout task and reschedule a new timeout task. ๐
If we think this delay isn't a big deal, I believe we can continue exploring how to address the animation infinite playing problem. ๐
https://github.com/user-attachments/assets/8270d439-4b54-4ac9-bd13-e13db849580b
I think that delay isn't a big deal as a temporary thing while we try to fix the root issue.
@puneetlath @ntdiary @j-piasecki I don't think fixing the Lottie animation will completely resolve the problem. We should fix the FreezeWrapper as my proposal denotes.
Just to compare: Even when we land on a settings page without Lottie animation (like settings/profile
)
The original implementation of FreezeWrapper
will cause 80+ renders on the settings page
while using extra useLayoutEffect
in my solution will introduce 14 re-renders max
Also, you can see in this video: https://github.com/Expensify/App/issues/47041#issuecomment-2321431386, the avatar has a noticeable delay during rendering (it shows grayish background, and then the image appear), but when using my solution, that the rendering of avatar is almost immediately.
So, in general, I think the Lottie animation get entangled with the React concurrent rendering algorithm and cause the app to freeze due to infinite re-renders, but the root cause is still in the way FreezeWrapper
works causing lots of unnecessary re-rendering, and my solution would not only improve the overall performance but also avoid any extra debugging and fixing efforts in case there's some other animation library messing up with React concurrent rendering algorithm.
the avatar has a noticeable delay during rendering
Yeah, both the avatar and svg animation will have a delay. Neither flushSync
nor useLayoutEffect
have this delay, which is one of the reasons I was willing to recommend these two solutions before. :)
The original implementation of FreezeWrapper will cause 80+ renders on the settings page
Interesting test. @dominictb, can you please share your testing steps or implementation? I've only observed 25 renderings here. Additionally, I still haven't figured out why useLayoutEffect
reduces so many renders (don't worry, this doesn't affect my recommendation of the approach ๐).
BTW, because we use autoPlay
and loop
prop for the Lottie component, the code here will trigger infinite updates to the React tree:
setState(newState);
If you havenโt already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!
Version Number: v9.0.18-1 Reproducible in staging?: Y Reproducible in production?: Y If this was caught during regression testing, add the test name, ID and link from TestRail: Exp https://github.com/Expensify/App/pull/46885 Email or phone of affected tester (no customers): applausetester+kh050806@applause.expensifail.com Issue reported by: Applause Internal Team
Action Performed:
Expected Result:
User avatar and the animation will load after switching from Inbox to Settings with animation.
Actual Result:
User avatar and the animation do not load after switching from Inbox to Settings with animation.
Workaround:
Unknown
Platforms:
Which of our officially supported platforms is this issue occurring on?
Screenshots/Videos
https://github.com/user-attachments/assets/d7a16a4f-9317-4ade-a06e-0862087eefc6
Add any screenshot/video evidence
View all open jobs on GitHub
Upwork Automation - Do Not Edit
Issue Owner
Current Issue Owner: @ntdiary