Expensify / App

Welcome to New Expensify: a complete re-imagination of financial collaboration, centered around chat. Help us build the next generation of Expensify by sharing feedback and contributing to the code.
https://new.expensify.com
MIT License
3.32k stars 2.76k forks source link

[$250] Web- User avatar & animation do not load after switching from Inbox to Settings with animation #47041

Open IuliiaHerets opened 1 month ago

IuliiaHerets commented 1 month ago

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:

  1. Go to staging.new.expensify.com
  2. Go to Account settings.
  3. Go to any page with animation in the header (Preferences/Security/Troubleshoot/About/Save the world).
  4. Go to Inbox.
  5. Switch back to Account settings.

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
  • Upwork Job URL: https://www.upwork.com/jobs/~01721123276553a8a9
  • Upwork Job ID: 1821551089457405095
  • Last Price Increase: 2024-08-24
  • Automatic offers:
    • dominictb | Contributor | 103887884
Issue OwnerCurrent Issue Owner: @ntdiary
melvin-bot[bot] commented 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.

IuliiaHerets commented 1 month ago

We think that this bug might be related to #vip-vsb

IuliiaHerets commented 1 month ago

@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

melvin-bot[bot] commented 1 month ago

Job added to Upwork: https://www.upwork.com/jobs/~01721123276553a8a9

melvin-bot[bot] commented 1 month ago

Triggered auto assignment to Contributor-plus team member for initial proposal review - @ntdiary (External)

codewaseem commented 1 month ago

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 is continuously re-rendering, which could be the underlying cause of performance issues and the heating problem noted in issue #36645.

Screenshot 2024-08-09 at 18 33 38

cI've been unable to pinpoint the exact cause of these unnecessary re-renders, but I suspect it mainly stems from react-native-onyx/withOnyx(). See : https://github.com/Expensify/App/issues/47041#issuecomment-2289223687

kaushiktd commented 1 month ago

@puneetlath after pull new code i don't reproduce this issue screen-capture.webm

ntdiary commented 1 month ago

Interesting, I can still reproduce it ๐Ÿ˜‚

https://github.com/user-attachments/assets/ca4f5c4f-ed15-470b-9931-ae3217931cce

ntdiary commented 1 month ago

The Expensify component is rendering repeatedly, and we haven't got a complete proposal.

JKobrynski commented 1 month ago

Hi, I'm Julian from Callstack - expert agency - and I would like to work on this issue.

JKobrynski commented 1 month ago

@codewaseem I've removed withOnyx from Expensify.tsx and used useOnyx instead and I can still reproduce the issue, I will keep digging!

codewaseem commented 4 weeks ago

@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.

dominictb commented 4 weeks ago

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?

JKobrynski commented 4 weeks ago

@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

dominictb commented 4 weeks ago

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.

puneetlath commented 3 weeks ago

@dominictb sure, go ahead.

dominictb commented 3 weeks ago

@puneetlath please re-apply the help wanted label so I could post the proposal. Thank you!

puneetlath commented 3 weeks ago

Done.

melvin-bot[bot] commented 3 weeks ago

๐Ÿ“ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐Ÿ’ธ

dominictb commented 3 weeks ago

Edited by proposal-police: This proposal was edited at {2023-10-05T14:35:00Z}.

Proposal

Please re-state the problem that we are trying to solve in this issue.

User avatar and the animation do not load after switching from Inbox to Settings with animation.

What is the root cause of that problem?

The root cause is within FreezeWrapper component that wraps the SidebarScreen here. This explains the following:

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 with an infinite promise in case 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:

This 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

What changes do you think we should make in order to solve the problem?

We have some options:

What alternative solutions did you explore? (Optional)

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.

dominictb commented 3 weeks ago

https://github.com/Expensify/App/issues/47041#issuecomment-2294923394 proposal updated with more information

ntdiary commented 3 weeks ago

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. :)

dominictb commented 3 weeks ago

@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:

ntdiary commented 3 weeks ago

@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. ๐Ÿ˜„

dominictb commented 3 weeks ago

Thank you @ntdiary! Meanwhile, I'll try to look further to see if I could get more detailed analysis.

dominictb commented 3 weeks ago

Proposal update to add alternative solution, which is more elegant (I think).

ntdiary commented 3 weeks ago

This issue is a bit challenging , here is a brief update for now:

  1. The flushSync approach can load the animation, but considering Reactโ€™s official note, this approach is not our first choice.
  2. Rewrite isScreenBlurred using useMemo, it can also load the animation, but Suspense stops working (i.e., the freeze prop is still false).
  3. add a 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. :)

dominictb commented 3 weeks ago

@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

ntdiary commented 3 weeks ago

@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

ntdiary commented 3 weeks ago

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:

image
dominictb commented 3 weeks ago

@ntdiary I couldn't reproduce what you shown above for useMemo approach. From my side it works as expected.

image
dominictb commented 3 weeks ago

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.

melvin-bot[bot] commented 3 weeks ago

@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 commented 3 weeks ago

@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:

  1. open the security page and reload the page,
  2. click the inbox button to navigate to the inbox page,
  3. then click the avatar at the bottom to navigate back to the security page.
ntdiary commented 3 weeks ago

And based on #47041 (comment), 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.

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.

melvin-bot[bot] commented 2 weeks ago

๐Ÿ“ฃ It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? ๐Ÿ’ธ

melvin-bot[bot] commented 2 weeks ago

@puneetlath, @ntdiary, @JKobrynski Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

muttmuure commented 2 weeks ago

Are we close to any solutions here?

dominictb commented 2 weeks ago

https://github.com/Expensify/App/issues/47041#issuecomment-2307058243 I will need to test this out.

ntdiary commented 2 weeks ago

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:

  1. Use flushSync (possibly with setImmediate as well).
  2. Pause the animation when its page is not at the top of navigation stack.
  3. 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.

will provide more details tomorrow.

dominictb commented 2 weeks ago

@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;
ntdiary commented 2 weeks ago

OK, I think we can move forward with @dominictb's proposal. Now we have two options:

  1. Use flushSync to wrap setIsScreenBlurred(shouldSetScreenBlurred(navigationIndex)).
  2. Add a new 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

melvin-bot[bot] commented 2 weeks ago

Current assignee @puneetlath is eligible for the choreEngineerContributorManagement assigner, not assigning anyone new.

ntdiary commented 2 weeks ago

Hi, @puneetlath, Do you have any different thoughts on this comment? ๐Ÿ˜„

puneetlath commented 2 weeks ago

Woof, tough one. I've asked someone from SWM to weigh in, since they are the makers of react-freeze.

j-piasecki commented 1 week ago

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.

ntdiary commented 1 week ago
  1. Pause the animation when its page is not at the top of navigation stack.
  2. 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

puneetlath commented 1 week ago

I think that delay isn't a big deal as a temporary thing while we try to fix the root issue.

dominictb commented 1 week ago

@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

image

while using extra useLayoutEffect in my solution will introduce 14 re-renders max

image

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.

ntdiary commented 1 week ago

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

https://github.com/Expensify/App/blob/e60b8210a1f5a2471d8aa9d84d0e461774b791d6/src/components/Section/index.tsx#L160-L161