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.03k stars 2.54k forks source link

[HOLD for payment 2023-06-01] [HOLD for payment 2023-05-23] [$2000] Fast clicking any button 'n' times execute 'n' times #14572

Closed kavimuru closed 1 year ago

kavimuru commented 1 year 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!


HOLD on https://github.com/Expensify/App/pull/17404

Action Performed:

  1. Click on settings and go to workspaces
  2. Fast Click the new workspace button ‘n’ times. Let’s say triple-click the button

Expected Result:

  1. Not to be able to fast click any button, after the first click the button should be disabled or have no effect until the initial click effects are done. i.e. Clicking ‘New Workspace’ button multiple times should just create a single workspace.

Note: The issue is reproducible with all buttons e.g. the IOU's I'll settle up elsewhere button as reported on https://github.com/Expensify/App/issues/17167.

Actual Result:

Clicking ‘New workspace’ button ‘n’ number of times creates ‘n’ number of workspaces

If you follow the same step from 1-4 on web or mweb , then clicking the new workspace button ‘n’ number of times just creates a single workspace and not ‘n’ number of workspaces

Workaround:

unknown

Platforms:

Which of our officially supported platforms is this issue occurring on?

Version Number: 1.2.59-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: Email or phone of affected tester (no customers): Logs: https://stackoverflow.com/c/expensify/questions/4856 Notes/Photos/Videos:

https://user-images.githubusercontent.com/43996225/214705874-d2b1fe24-327f-45d0-ae28-1b9c6d142cdf.mp4

https://user-images.githubusercontent.com/43996225/214707380-d4d32ef9-8634-41b9-ac81-b13861680387.mp4

Expensify/Expensify Issue URL: Issue reported by: @priya-zha Slack conversation: https://expensify.slack.com/archives/C049HHMV9SM/p1674643973375769

View all open jobs on GitHub

Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~01a7c5579c82436a8d
  • Upwork Job ID: 1619020532492161024
  • Last Price Increase: 2023-04-25
fedirjh commented 1 year ago

@mananjadhav PR is ready for review !!

MelvinBot commented 1 year ago

@mananjadhav, @slafortune, @fedirjh, @roryabraham Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

MelvinBot commented 1 year ago

@mananjadhav, @slafortune, @fedirjh, @roryabraham Huh... This is 4 days overdue. Who can take care of this?

MelvinBot commented 1 year ago

@mananjadhav, @slafortune, @fedirjh, @roryabraham Now this issue is 8 days overdue. Are you sure this should be a Daily? Feel free to change it!

mananjadhav commented 1 year ago

I think we're blocked here because the original solution doesn't work for the offline behavior. @fedirjh if we don't have an alternative, then we should it open it back for other proposals. The debounce solution was already recommended earlier and we decided not to opt for it.

cubuspl42 commented 1 year ago

@mananjadhav Or throttle! I summed up in my proposal why debounce is not the best choice 🙃

But aside of that, no matter if we stick to debounce or throttle, I'll remind that...

The only catch is that the throttling window has to be adjusted to take the before-mentioned performance situation into consideration.

That window would probably need to be wider that 1000 ms. Or even wider than 2000 ms. Please correct me if I'm wrong, but that's what was my conclusion when I tested this approach.

cubuspl42 commented 1 year ago

@rushatgabhane

When the debounce time is 250ms -> all button clicks that happen within 250ms will trigger a single onPress callback only.

please correct if I'm wrong.

Well, conceptually you're right, but in practice what you're saying is wrong in the context of this issue. It works like that in general, but in React Native with blocking calls, in practice, if your debounce time is 250 ms, two clicks within 50 ms can still be both processed, if we block for more than 250 ms. This is probably caused by the way interactions are queued, if I were to guess.

And as far as I can tell, we block the thread for much more than 250 ms when creating a workspace.

So like I said before, we can either adjust the debouncing/throttling window (which looks bad, but reflects another problem, not really introducing a new one) or optimize the code so it doesn't block for so long. But I don't have any good ideas for the second part, and the scope might be huge.

slafortune commented 1 year ago

@roryabraham @mananjadhav what do you think the best path forward is for this issue?

mananjadhav commented 1 year ago

@slafortune I think we're open for better proposals here.

@cubuspl42 I wonder why would throttle work if debounce fails?

mananjadhav commented 1 year ago

@slafortune Also I am not sure why it says Internal. I think we should apply External label and also Help Wanted

cubuspl42 commented 1 year ago

@mananjadhav Which aspect of "failing" are you talking about?

  1. throttle has a better characteristic than debounce for this use case
  2. Unrelated: Because of the performance issue, both debounce and throttle, and any time-dependent solution for what it's worth, need to adjust their thresholds to take the performance issue into account

I'm not changing my position, just to be clear, I already mentioned that it's a separate thing:

But aside of that, no matter if we stick to debounce or throttle, I'll remind that...

cubuspl42 commented 1 year ago

To be honest, I think that the performance issue is a totally separate thing, and I would still apply throttle here. I'm not sure if there's any process for performance issues in this project; I know that the user isn't directly affected, but blocking the JS thread for thousands of milliseconds isn't perfect

Actually, I have an idea how to improve the situation by combining throttle and delay. It won't be perfect, but I think that it can work in practice! I'll update my proposal in a few moments.

Edit: The idea looks promising, but I'm struggling to run Expensify in release build for testing performance, which is crucial in case of this issue.

Edit 2: Managed to build in release mode, so far my observations are still promising! Stay tuned.

cubuspl42 commented 1 year ago

Proposal

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

When a button that triggers navigation is pressed (in particular, the "New workspace" button), there is an opportunity to press it more times before the navigation actually takes place. In the case of the "New workspace" button, this window is very wide (up to thousands of milliseconds), because the JavaScript thread gets blocked in the same task the button is pressed. Likely, this is the main reason why the problem was ever reported. Still, if we actually double-press some navigation-triggering buttons really fast, similar behavior can be observed without performance.

What is the root cause of that problem?

I can see two root causes:

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

A part of the perfect solution would be fixing the performance problem, but I wasn't able to point a single place responsible for blocking the JS thread. That would make it hard to double-press the button, but not impossible.

Inspired by the overall discussion and conclusions from my previous proposal revisions, I propose to add a property disableOnInteractions to our Button class, which disables the buttons while interactions (navigation) takes place:

    // Slightly change the semantics of isDisabled:
    /** Indicates whether the button should be disabled because of some external logic */
    isDisabled: PropTypes.bool,

    // Add this new property:
    /** Indicates whether the button should be disabled when interactions take place */
    disableOnInteractions: PropTypes.bool,

The crucial changes to the Button implementation:

class Button extends Component {
    constructor(props) {
        super(props);

        this.state = {
            isDisabledOnInteractions: false,
        };

        this.renderContent = this.renderContent.bind(this);
    }

    // ...

    // New helper getter:
    get isEffectivelyDisabled() {
        return this.props.isDisabled || this.state.isDisabledOnInteractions;
    }

    // ...

    render() {
        return (
            <Pressable
                onPress={(e) => {
                    // ...

                    if (this.props.disableOnInteractions) {
                        this.setState({
                            isDisabledOnInteractions: true,
                        });

                        this.props.onPress(e);

                        // Use runAfterInteractions to disable the button temporarily:
                        InteractionManager.runAfterInteractions(() => {
                            this.setState({
                                isDisabledOnInteractions: false,
                            });
                        });
                    } else {
                        this.props.onPress(e);
                    }
                }}
                // ...
                // Use isEffectivelyDisabled here and in other places that
                // previously referred to `props.isDisabled`:
                disabled={this.props.isLoading || this.isEffectivelyDisabled}
                // ...
            >
               {/* ... */}
            </Pressable>
        );
    }
}

What alternative solutions did you explore?

Previously, I explored the possibility to work around the performance problem of the "New workspace" button itself, but still left a narrow window for fast double presses. That solution is available in the edit history.

MelvinBot commented 1 year ago

Triggered auto assignment to @dylanexpensify (External), see https://stackoverflow.com/c/expensify/questions/8582 for more details.

MelvinBot commented 1 year ago

Current assignee @mananjadhav is eligible for the External assigner, not assigning anyone new.

MelvinBot commented 1 year ago

Current assignee @roryabraham is eligible for the External assigner, not assigning anyone new.

redstar504 commented 1 year ago

Proposal

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

Pressing New Workspace multiple times subsequently creates multiple workspaces.

What is the root cause of that problem?

Since navigation to the workspace page happens after the workspace creation completes, it leaves an opportunity window for creating additional workspaces by repeatedly pressing the button. This is a result of the small period of processing time the creation of a new workspace requires, and the lack of a condition that prevents it.

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

My initial thought was using state to store an isButtonPressed boolean, and then conditioning on it in the onPress handler, but after reading through the thread I learned of this issue:

If you use state in react components to disable the button status, you can't avoid triple button click events. Because react state is only changed when the component is re-rendered. But the event listeners can be called several times before re-rendering! So you can't avoid the issue using react state.

So as an alternative, what if we used an IIFE and a closure to replace the handler with an empty function after it's called the first time? Called directly it initializes the handler, but the function it creates is callable only once, and no-ops on successive calls.

// WorkspacesListPage.js
....

setPressHandler() {
    this.handlePress = (() => {
        let onceCallable = () => {
            Policy.createWorkspace();
            onceCallable = f => f;
        };
        return () => onceCallable();
    })();
}

We would call setPressHandler in the constructor (or useEffect) to initialize the handler before the component is mounted, and again in componentDidUpdate (or useEffect with a dependency, or a navigation listener) to reset it, since reversing through the navigation stack doesn't remount the component.

The Button for creating the workspace would use onPress={this.handlePress} which is only functionable once. This effectively prevents multiple presses of the button from creating multiple workspaces without relying on state.

What alternative solutions did you explore? (Optional)

rushatgabhane commented 1 year ago

that is clever

mdneyazahmad commented 1 year ago

Proposal

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

Clicking on "New workspace" button multiple times quickly, creates that number of workspaces.

What is the root cause of that problem?

Policy.createWorkspace is not protected with a loading flag.

https://github.com/Expensify/App/blob/8661157d0ea0c505fc4f51cea82921a73ac7d1dc/src/pages/workspace/WorkspacesListPage.js#L199

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

We can create a state variable to track function execution state, and only allow the execution if the previous function is fully executed. We might need to promisify Policy.createWorkspace, but it is working without that too.

  1. Add a state variable isCreatingWorkspace
  2. Create a method createWorkspace and pass this to Button onPress prop.
  3. Execute the function only when its not in creating state.
    createWorkspace() {
        // this check should be here, react guarantee to flush `setState` inside browser event boundary 
        if (this.state.isCreatingWorkspace) {
            return;
        }

        this.setState({isCreatingWorkspace: true});
        Policy.createWorkspace();
        this.setState({isCreatingWorkspace: true});
    }

Source: https://stackoverflow.com/questions/35315872/reactjs-prevent-multiple-times-button-press

What alternative solutions did you explore? (Optional)

None

mdneyazahmad commented 1 year ago

I am sorry. I made a mistake while testing my proposal, and thought It's working, but I still believe that will work after we promisify Policy.createWorkspace.

situchan commented 1 year ago

Proposal

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

Clicking New workspace button 'n' times calls create workspace 'n' times on Workspaces list page

What is the root cause of that problem?

Before navigating to new workspace page, button onPress event can still be triggered when click it multiple times quickly, even after creating workspace. This happens because navigation takes time and it gives enough chance for user to click button.

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

We already prevented multiple IOU requests in https://github.com/Expensify/App/pull/13733. We can use same approach here. So define local flag variable (class member), something like this.isCreatingWorkspace and call Policy.createWorkspace() only when this flag is false. In detail: https://github.com/Expensify/App/blob/1d3f0695c79c0bad8d6cf7402536025ce1272a54/src/pages/workspace/WorkspacesListPage.js#L199

hoangzinh commented 1 year ago

Proposal

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

Clicking New workspace button ‘n’ number of times creates ‘n’ number of workspaces immediately

What is the root cause of that problem?

There are 2 problems:

  1. The button to create work space, it's not disabled during creating work space time https://github.com/Expensify/App/blob/f327eb0c3c73d13c1820ea62a3ee1a94cc023801/src/pages/workspace/WorkspacesListPage.js#L199

  2. Even if we disable before and enable after calling Policy.createWorkspace, when the screen is navigating to work space that just created, there are small amount of transition time that user can press the button https://github.com/Expensify/App/blob/f327eb0c3c73d13c1820ea62a3ee1a94cc023801/src/libs/actions/Policy.js#L950-L956

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

To solve each problem, I suggest that:

  1. Add an addition isCreatingWorkspace state to component WorkspacesListPage, then disable button if isCreating state is true. It will help improve better UX where creating work space take a lot of time.

  2. To deal with transition affect, we can use InteractionManager.runAfterInteractions, which is using in several places in our codebase

More details, we should update those LOC by:

<Button
    isDisabled={this.state.isCreatingWorkspace}
    success
    text={this.props.translate('workspace.new.newWorkspace')}
    onPress={() => {
        this.setState({isCreatingWorkspace: true});
        Policy.createWorkspace();
        InteractionManager.runAfterInteractions(() => {
            this.setState({isCreatingWorkspace: false});
        });
    }}
/>

What alternative solutions did you explore? (Optional)

If my proposal above is not strong enough, we can define an additional flag isRequestCreatingWorkspace in the action lib Policy here. We will turn on/turn off this flag when enter the method or when the method is finished

MelvinBot commented 1 year ago

@mananjadhav, @slafortune, @fedirjh, @roryabraham Whoops! This issue is 2 days overdue. Let's get this updated quick!

rushatgabhane commented 1 year ago

do we want callstack's help on this? @roryabraham @mananjadhav

situchan commented 1 year ago

can you please help review my proposal?

MelvinBot commented 1 year ago

@mananjadhav, @slafortune, @fedirjh, @roryabraham Still overdue 6 days?! Let's take care of this!

MelvinBot commented 1 year ago

@mananjadhav, @slafortune, @fedirjh, @roryabraham Now this issue is 8 days overdue. Are you sure this should be a Daily? Feel free to change it!

slafortune commented 1 year ago

@roryabraham @mananjadhav what do you think the best path forward is for this issue?

hoangzinh commented 1 year ago

can we review my proposal here https://github.com/Expensify/App/issues/14572#issuecomment-1490165910 ? I think it's align with what we did in other places

MelvinBot commented 1 year ago

@mananjadhav, @slafortune, @roryabraham Whoops! This issue is 2 days overdue. Let's get this updated quick!

getusha commented 1 year ago

Is this issue open for new proposals?

mountiny commented 1 year ago

@mananjadhav Any updates here, it would be great to consolidate this I will also post in slack for more help since this is a big issue.

MelvinBot commented 1 year ago

📣 @s77rt You have been assigned to this job by @mountiny! Please apply to this job in Upwork and leave a comment on the Github issue letting us know when we can expect a PR to be ready for review 🧑‍💻 Keep in mind: Code of Conduct | Contributing 📖

rushatgabhane commented 1 year ago

What are we trying to do here?

Create a generic solution that any page with a button can use? Or only fix this issue for workspace similar to how we fixed it for IOU?

mountiny commented 1 year ago

Create a generic solution that any page with a button can use? Or only fix this issue for workspace similar to how we fixed it for IOU

Any issue with similar problem. We experience it here with Send money too https://github.com/Expensify/App/issues/17167

@s77rt can you please propose update issue body to make sure it addresses any button with this problem? thank you!

rushatgabhane commented 1 year ago

Cool, thanks!

for proposal https://github.com/Expensify/App/issues/14572#issuecomment-1484123677 @situchan any thoughts on how we can create a generic solution that will work for all buttons ?

s77rt commented 1 year ago

Fast clicking any button 'n' times execute 'n' times

cc @mountiny

Action Performed:

  1. Open android app
  2. Click on settings and go to workspaces
  3. Fast Click the new workspace button ‘n’ times. Let’s say triple-click the button

Expected Result:

Not to be able to fast click any button, after the first click the button should be disabled or have no effect until the initial click effects are done. i.e. Clicking ‘New Workspace’ button multiple times should just create a single workspace.

The issue is reproducible with all buttons e.g. the IOU's I'll settle up elsewhere button as reported on https://github.com/Expensify/App/issues/17167.

Actual Result:

Clicking ‘New workspace’ button ‘n’ number of times creates ‘n’ number of workspaces

mountiny commented 1 year ago

Thanks! Updated everything, lets go through the proposals @s77rt 🙇

priyeshshah11 commented 1 year ago

Maybe we can somehow build on top of this proposal of mine from three months ago? It has a generic solution that will solve this bug in multiple places.

rushatgabhane commented 1 year ago

@priyeshshah11 sure! But IIRC, we had some issues with using state because there can be a duplicate button click in the time it takes for the state to update

you wanna update your proposal to take that into account?

mountiny commented 1 year ago

Also i think it should be fine to use hooks now too

situchan commented 1 year ago

We can't use state. User can click number of times in android while state change, especially on heavy device.

I don't think there's general solution to fix all buttons throughout the app. Should be case by case. So if the page that has button is functional component, use ref hook (ref.value) or useSharedValue, if class component, use stateless member variable (this). class example: https://github.com/Expensify/App/pull/13733/files#diff-d7b964ad00daabd50674375196edbc54b915728ee08b5e5f86adbc79d358238bR461-R465 function example: https://github.com/Expensify/App/pull/17326/files#diff-f265a5ae9cea30af21ecbd34b52aa47636dd70e948f45c95246a41fc62398580R312-R315

mountiny commented 1 year ago

Well there will be no class components soon so we should focus on fixing this with functional components in mind so we dont waste efforts.

narefyev91 commented 1 year ago

Proposal

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

Pressing sending button multiple times sending money multiple times on the next screen (https://github.com/Expensify/App/issues/17167)

What is the root cause of that problem?

The button does not have any disabling logic to prevent multiple clicks during execution

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

We need simply prevent user of clicking multiple times on the button - in other words make onPress non actionable Possible and elegant solution (to avoid any not needed setState) - using ref. In which we will put - true when the button was clicked first time And have a condition check - if true already in ref - do nothing. Example (from https://github.com/Expensify/App/issues/17167):

onConfirm={(selectedParticipants) => { if (buttonRef.current) { return; } buttonRef.current = true; createTransaction(selectedParticipants); ReportScrollManager.scrollToBottom(); }} It can be done as either global or local solution - depends on how we would like to fix it

What alternative solutions did you explore? (Optional)

s77rt commented 1 year ago

@narefyev91 Thanks for the proposal. Can you please elaborate how we can make this a global solution?

s77rt commented 1 year ago

@situchan I like idea of just setting a boolean variable in the onPress callback but the challenge is how to reset it. Did you explore some options here?

s77rt commented 1 year ago

@priyeshshah11 I would really prefer if we can find a solution without the use of promises, I feel it's being over used in App a bit. The reason we can't go with a promise here is that we will have to make every function that is called on the onPress callback return a promise (and every function that is called within) e.g. check the SettlementButton on MoneyRequestConfirmationList, we will have to make confirm, onSendMoney, onConfirm, up to the whatever comes before in that hierarchy return a promise which is just unpractical.

s77rt commented 1 year ago

We may be jumping into the solution a little early. Why the bug is only reproducible on Android, we had this bug recently https://github.com/Expensify/App/issues/14258 and it was also reproducible on Android only.

narefyev91 commented 1 year ago

@s77rt Sure let me explain - onPress logic in 100% cases it's something we can move in function like onPress={onHandlePressFunction} which can be something like this

function onHandlePressFunction(e) {
...anything which needed to be executed / any logic
}

And to prevent button in clicking we need to use ref logic

if (buttonRef.current) { return; }
buttonRef.current = true;

We can combine both logic in one

onPress={(e) => {
if (buttonRef.current) { return; }
buttonRef.current = true;
onHandlePressFunction(e)
}}

not all buttons should be disabled after one click - in that case we can add some prop to control which button should use ref logic and which should not. We also should define ref only inside onGlobalPress function - because it's controlling execution. I tested only on one screen, but for sure it may be some workarounds on other screens. Global onPress function will be looks something like:

const newRef = React.createRef();
function onGlobalPress(props, onPress, shouldBlock) {
    if (newRef.current) {return ;}
    if (shouldBlock) {
        newRef.current = true;
    }
    return onPress(props);
}

Execution will be something like

          onSendMoney={(paymentMethodType) => {
              onGlobalPress(paymentMethodType, () => {
                  sendMoney(paymentMethodType);
                  ReportScrollManager.scrollToBottom();
              }, true)
          }}
narefyev91 commented 1 year ago

We may be jumping into the solution a little early. Why the bug is only reproducible on Android, we had this bug recently #14258 and it was also reproducible on Android only.

it's also happened on IOS for me as well, not sure why no one add IOS to bug description