React: Bug: infinite loop when a set state hook is called in a global handler

Created on 28 Jan 2020  路  9Comments  路  Source: facebook/react

To be honest, I am not 100% sure it's a bug. Perhaps my code is badly written but I don't know why it's not working or how to rewrite it in the proper way.

React version: 16.12.0

Steps To Reproduce

function Hello() {
    const [s, setS] = React.useState(1);
    const print = () => {
      setS(s + 1);
      setTimeout(() => {
        window.print();
      }, 600);
      window.onafterprint = () => {
        setS(s - 1);
      }
    }
    return <div>{s}<button onClick={print}>print</button></div>;
}

  1. Open this jsfiddle https://jsfiddle.net/z4ku39t2/2 or try the code above
  2. Click Print button and cancel the print dialog

Link to code example: https://jsfiddle.net/z4ku39t2/2

The current behavior

When the setS is called in the onafterprint handler, the app enters an infinite loop with 100% cpu usage so you won't be able to do anything on the page. The profiler shows that it happens inside React.

The expected behavior

The setS successfully modifies the state and the component re-renders.

Browser: Version 79.0.3945.130 (Official Build) (64-bit)

DOM Bug

Most helpful comment

Looks this this was actually fixed for React 17 in #19220.

Last published build from master before #19220 was 0.0.0-33c3af284 which still hangs while it no longer freezes with ##19220.

Closing since it the description fits.

All 9 comments

I can confirm and reproduce this bug.

Interesting. Looks like this has been reported before for people using the react-to-print package (but not in this repository):

Looks like that library just calls props.onAfterPrint synchronously right after calling print(). I think this is because print() used to be a blocking method for most browsers, but it no longer seems to be for Chrome nor Safari.

I'm not very familiar with our DOM event system so I don't know why this behavior would be happening, but as a temporary workaround for the issue, you can use a setTimeout in onAfterPrint:
https://codesandbox.io/s/goofy-cerf-yh84o

The print function is still sync in Chrome but not in Safari and that was our workaround for this (use case: modify some state for printing and revert after printing). It seems like the following mitigates the problem:

      window.onafterprint = () => {
        setTimeout(() => {
            setS(s - 1);
        }, 100);
      }

The print function is still sync in Chrome

This does not match my testing a few minutes ago.

Also, a duration of 100 shouldn't be necessary. 0 should work. I think you just need to move the state update outside of the context of the onafterprint function.

I believe the problem is happening is an event loop, right?

In the first call of setTimeout it goes to the Call Stack which in turn calls the Web App and starts the timer and after the timer ends it throws it to a Callback Quee.

However the javascript reading continues and reads below and calling the onafterprint performs the same.

And after that the event looping detects that there is something in the queue and executes setTimeout.

Example:
https://miro.medium.com/max/1468/1*LvbUhFpMUeN9xoaazrp_gQ.jpeg

Here talking a little more about the topic.

https://www.youtube.com/watch?v=8aGhZQkoFbQ

The behavior seems to be caused by the way react-dom's dev version handles errors in user-provided callbacks.

For context:
https://github.com/facebook/react/blob/da834083cccb6ef942f701c6b6cecc78213196a8/packages/shared/invokeGuardedCallbackImpl.js#L42-L49

Relevant to this issue:
https://github.com/facebook/react/blob/da834083cccb6ef942f701c6b6cecc78213196a8/packages/shared/invokeGuardedCallbackImpl.js#L86-L180

Simply put, to play nicer with devtools, user-provided callbacks are not called directly. Instead a listener to a synthetic event is setup (L175), with its handler calling the user-provided callback (L130). Then the event is immediately dispatched (L180).

For some reason, when a callback is invoked from within a beforeprint or afterprint handler, the callCallback handler (L112) is never executed, despite both fakeNode.addEventListener(evtType, callCallback, false); and fakeNode.dispatchEvent(evt); being run. didError is never set to true, and the syncQueue keeps having the same callback being added to it, resulting in the infinite loop.

Replacing this part:
https://github.com/facebook/react/blob/da834083cccb6ef942f701c6b6cecc78213196a8/packages/shared/invokeGuardedCallbackImpl.js#L175-L180

... with just:

callCallback();

... prevents the faulty behavior from occuring.

It's also worth nothing that this does not happen in production since callbacks are called directly.

Working around this proves to be difficult, especially when trying to change state in response to beforeprint. Any changes to the DOM need to happen synchronously within the beforeprint handler for them to show up on the printed page. For this reason the state change can't be delayed using setTimeout or rAF. Changing the state, then making a delayed call to window.print() only works if you are initiating the print process programmatically (Ctrl/Cmd+P or the browser's menu 'Print' function can only be intercepted using beforeprint).

I'm also having this issue, in conjunction with printing, and can corroborate @idmadj's analysis. There's an infinite loop in the dev implementation for invokeGuardedCallback. The solutions above don't work since my app uses media query listeners in JS (which are attached to window) to change some aspects of the page before printing.

Interestingly, a workaround in my case seems to be changing window.print() to ReactDOM.unstable_batchedUpdates(() => window.print()).


Further investigating reveals that this only seems to be an issue when programmatically invoking window.print().

https://codesandbox.io/s/nervous-liskov-3mqby

Opening up the page outside of the sandbox, and pressing Ctrl+P reveals that the callback is invoked synchronously, and using window.print() does not invoke the event listener at all.

Looks this this was actually fixed for React 17 in #19220.

Looks this this was actually fixed for React 17 in #19220.

Last published build from master before #19220 was 0.0.0-33c3af284 which still hangs while it no longer freezes with ##19220.

Closing since it the description fits.

Was this page helpful?
0 / 5 - 0 ratings