Callback function for ClickAwayListener stop midway on execution

Viewed 490

I am using Material-UI ClickAwayListener and react-router for my app. The bug I encountered is that when the callback for ClickAwayListener get executed, it is stopped mid-way for a useEffect to run and after that it resume running. This behavior is not expected from a callback. The callback should be fully executed before the useEffect can run. Below are the code I create to demonstrate the problem and this is the demo for the code

import React, { useEffect, useState } from "react";
import ClickAwayListener from "@material-ui/core/ClickAwayListener";

import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link,
  useHistory,
  useParams
} from "react-router-dom";

export default function BasicExample() {
  return (
    <Router>
      <div>
        <Switch>
          <Route exact path="/">
            <ButtonPage />
          </Route>
          {/*Main focus route here*/ }
          <Route path="/:routeId">
            <Home />
          </Route>
        </Switch>
      </div>
    </Router>
  );
}

// Main focus here
function Home() {
  const history = useHistory();
  const { routeId } = useParams();

  const [count, setCount] = useState(0);

  const handleClick1 = () => {
    history.push("/route1");
  };
  const handleClick2 = () => {
    history.push("/route2");
  };

  // useEffect run on re-render and re-mount
  useEffect(() => {
    console.log("Re-render or remount");
  });
  
  useEffect(() => {
    console.log("Run on route change from inside useEffect");
  }, [routeId]);

  const handleClickAway = () => {
    console.log("First line in handle click away");
    setCount(count + 1);
    console.log("Second line in handle click away");
  };

  return (
    <div>
      <button onClick={handleClick1}>Route 1 </button>
      <button onClick={handleClick2}>Route 2 </button>
      <ClickAwayListener onClickAway={handleClickAway}>
        <div style={{ height: 100, width: 100, backgroundColor: "green" }}>
          Hello here
        </div>
      </ClickAwayListener>
    </div>
  );
}

// Just a component such that home route can navigate
// Not important for question
function ButtonPage() {
  const history = useHistory();

  const handleClick1 = () => {
    history.push("/route1");
  };
  const handleClick2 = () => {
    history.push("/route2");
  };

  return (
    <div>
      <button onClick={handleClick1}>Route 1 </button>
      <button onClick={handleClick2}>Route 2 </button>
    </div>
  );
}

In specific, when I click outside the ClickAwayListener the handleClickAway run normally and the logging message is

First line in handle click away
Second line in handle click away
Re-render or remount 

Until I choose to click on the button that navigate to other route. Here thing get weird: handleClickAway run the first logging line, then the useEffect run and print its logging, then handleClickAway resume and print its second line. So if I do so this is the logging

First line in handle click away
Re-render or remount
Run on route change from inside useEffect
Second line in handle click away

After doing some test on this bug I figured out that the thing that cause this bug is the setCount inside the handleClickAway. If I remove this line the function handleClickAway will run as expected for all cases. My conclusion is that, changing component state, or should I say, perform any actions that cause component to re-render inside handleClickAwayin combination with route navigation can cause this bug.

This behavior is strange, because as far as I know, there is no way for a normal, non-promise related callback to stop mid-way like this. I guess the ClickAwayListener somehow make handleClickAway into Promise or something. But even then, there is no reason for it to stop at the setCount and let useEffect run? Can someone explain this to me?

Edit 1: As @Rex Pan point out in this answer, it looks like the handleClickAway has useEffect run inside it. This is the only conclusion I can make from reading the trace stack. But this behavior only happen when the user click to a link that navigate to different route. Clicking to other area beside that doesn't cause this bug. Can someone explain me why and how this happen?

Edit 2: After reading @Rex Pan answer I did some more investigation. It looks like to me that his answer is correct, but only apply for the ClickAwayListener callback. So the code below will produce the same "bug" as above

export default function ClickAway() {
  const classes = useStyles();
  const [count, setCount] = React.useState(0);

  useEffect(() => {
    console.log("Test in useEffect");
  });

  const handleClickAway = () => {
    console.log("Count is " + count);
    setCount(count + 1);
    setCount(count + 10);
    console.log("Count is " + count);
  };

  return (
    <ClickAwayListener onClickAway={handleClickAway}>
      <div className={classes.root}>Click away</div>
    </ClickAwayListener>
  );
}

However, his explanation won't work in other case, like below code

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("Run inside useEffect");
  });

  const handleClick = () => {
    console.log("First line");
    setCount(count + 1);
    console.log("Second line");
    setCount(count + 100);
    console.log("Third line");
  };

  return (
    <>
      <div>Count is: {count} </div>
      <button onClick={handleClick}>Click</button>
    </>
  );
}

I am trying to read on the source code of ClickAwayListner but so far haven't find the location that cause this behavior. Can someone point this out?

3 Answers

Here's why this is happening. The useEffect uses routeId as one of its props. You're likely navigating when the handleClickAway fires, so the useEffect is triggered and then logs to your console.

Also, are you sure it is stopping the execution, and not just logging in between asynchronously? Generally useEffects do not block the execution of any other piece of code.

A simple way to test this would be by delaying the logging in the useEffect. If it is not blocking the execution, your other logs should show first and then this one shows later.

useEffect(() => {
   setTimeout(() => console.log('delayed log'), 3000)
}, [routeId])

Answering your comment:

According to this article, any asynchronous call is done outside the call stack, so it won't actually block the execution of your code.

In a nutshell, the asynchronous implementation in Javascript is done through a call stack, call back queue and Web API and event loop.

React is likely doing the same with useEffect, so it doesn't mean that it stopped the execution of your callback to run the useEffect, just that it ran it alongside it.

In the article itself he uses a timeout example like I did here and then explains how it is handled in the stack:

In Javascript, All instructions are put on a call stack. When the stack arrives at setTimeout, the engine sees it as a Web API instruction and pops it out and sends it to Web API. Once the Web API is done with the execution, it will arrive at the call back queue.

The setCount() cause a render which will flush the useEffect() from previous render. This causing the useEffect()s is called before setCount() return to handleClickAway().

handleClickAway()
    console.log("First line in handle click away");
    setCount(count + 1);
        ...
            effect0000000()
                console.log("Re-render or remount");
            effect_on_routeId()
                console.log("Run on route change");
    console.log("Second line in handle click away");

You could verify by add the function name and new Error() and check the callstack.

useEffect(function effect0000000() {
    console.log(new Error())
    console.log("Re-render or remount");
  });

  useEffect(function effect_on_routeId() {
    console.log(new Error())
    console.log("Run on route change");
  }, [routeId]);

  const handleClickAway = function handleClickAway() {
    console.log("First line in handle click away");
    setCount(count + 1);
    console.log("Second line in handle click away");
  };

enter image description here

If you click outside,

  1. the handleClickAway() is called, log First line in handle click away
  2. the setCount() is called and React will re-render 2 which will schedule the useEffect() bind to 2
  3. Home() returned, setCount() returned
  4. handleClickAway() log Second line in handle click away
  5. useEffect() bind to 2 is called as scheduled

enter image description here

When route button is click

  1. history.push() cause a render 2, which will schedule useEffect() bind to this = 2
  2. handleClickAway() called and log First line in handle click away
  3. the setCount() is called
  4. React need to flush (called) the previous effect (which bind to this = 2) before next render
  5. React call Home() for render 3 which will schedule the useEffect() bind to 3
  6. Home() returned, setCount() returned
  7. handleClickAway() log Second line in handle click away
  8. useEffect() bind to 3 is called as scheduled

enter image description here

when you click on a route button, two events are fired simultaneously and there is a race condition between history.push() and setcount() inside of the two handlers and therefore two independent rerenders are happening and each rerender fires an effect. so its not a bug and two handler functions are doing their jobs at the same time and each of those functions executes all the codes inside of it.

Related