react: Chrome 73 breaks wheel events

Similar to #8968, but for the wheel and mousewheel events. They are now passive by default for root elements in Chrome 73 (currently beta) which means React apps that have custom scrolling/zooming behaviors will run into issues.

The quick fix may be to manually add event listeners with {passive: false} but has the React team considered if this should be configurable for the React event handler?

Blog post from the Chrome team here: https://developers.google.com/web/updates/2019/02/scrolling-intervention

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Reactions: 45
  • Comments: 40 (8 by maintainers)

Commits related to this issue

Most upvoted comments

@byronwall I strongly believe the Chrome team thinks very carefully and thoughtfully about the changes they make, and I don’t believe it is right to call into question what they should or should not be doing without the full holistic picture of the drive behind the change and the expected outcome. It might be nice to get direct input here from someone who can provide a larger context that can help drive the path forward in a healthy way.

We shouldn’t undermine changes Chrome believes are important for improving site performance.

Chrome shouldn’t be changing default options that have such a significant impact on usability. Every site that relies on default {passive:false} behavior is now broken. Even if React pushes a fix, this is still broken for existing sites. The pace at which Chrome is willing to break standards is staggering. The passive option wasn’t in the wild until Chrome 51 (June 2016) and now the default has been changed. Consider me unsympathetic to doing what “Chrome believes is important”.

This is a temporary fix I’ve been using in the load file index.js.

import React from 'react';
import ReactDOM from 'react-dom';
import App from './pages/App';

const EVENTS_TO_MODIFY = ['touchstart', 'touchmove', 'touchend', 'touchcancel', 'wheel'];

const originalAddEventListener = document.addEventListener.bind();
document.addEventListener = (type, listener, options, wantsUntrusted) => {
  let modOptions = options;
  if (EVENTS_TO_MODIFY.includes(type)) {
    if (typeof options === 'boolean') {
      modOptions = {
        capture: options,
        passive: false,
      };
    } else if (typeof options === 'object') {
      modOptions = {
        passive: false,
        ...options,
      };
    }
  }

  return originalAddEventListener(type, listener, modOptions, wantsUntrusted);
};

const originalRemoveEventListener = document.removeEventListener.bind();
document.removeEventListener = (type, listener, options) => {
  let modOptions = options;
  if (EVENTS_TO_MODIFY.includes(type)) {
    if (typeof options === 'boolean') {
      modOptions = {
        capture: options,
        passive: false,
      };
    } else if (typeof options === 'object') {
      modOptions = {
        passive: false,
        ...options,
      };
    }
  }
  return originalRemoveEventListener(type, listener, modOptions);
};

ReactDOM.render(<App />, document.getElementById('root'));

Ran into this problem with trying to prevent browser zoom on control + wheel and performing a scaling action in our app instead, since native events didn’t fit our use case I ended up with the following solution.

const MyComponent = () => {
    const [scale, setScale] = useState(10);

    useEffect(() => {
        const cancelWheel = (event) => event.preventDefault();

        document.body.addEventListener('wheel', cancelWheel, {passive: false});

        return () => {
            document.body.removeEventListener('wheel', cancelWheel);
        }
    }, []);

    const onWheelEvent = (event) => {
        setScale(scale + event.deltaY);
    };

    return <div onWheel={onWheelEvent} />
};

The use effect will bind a non passive event that prevents browser zoom and cleans itself up on unmount, then leaves your react event listeners to do their thing.

Sure thing, here’s a repro:

https://codesandbox.io/s/6zn44nmjvn

In Chrome stable / Safari / etc the box will move but the rest of the page will remain static. In Chrome 73, the entire page rubber bands as you scroll around (the box also moves). Since rubber banding only happens with a trackpad, make sure to test it with one.

Also note all the red errors in the console due to the intervention.

It’s not fair to blame Chrome at all, it’s an issue with React that binds listeners to the root element, Chrome’s reasoning that most of web applications (if used DOM API correctly) won’t be affected.

It’s important for people who reach this page to know that they have the right to demand the fix from React.

I understand the performance concerns that led React team to decide to bind listeners to root elements, but a better design would probably be this: let the developer decide whether he wants to attach the listener to root element (the default behavior) or to attach it to the original element (onClick and onElementClick for example)

Let me summarize the issue:

  • Chrome changed the event to be passive by default.
  • React does not let you customize passive-ness of built-in events, so they became passive too.
  • We won’t change the default since Chrome’s change is better for web overall.

Therefore, there are two solutions. Either React adds some way to control passive-ness of events (tracked in https://github.com/facebook/react/issues/6436). Or you use native event listeners for the range of use cases where you really need this.

Since using native event listeners already works (and has always worked) I don’t think there’s anything actionable left in this issue. Whatever API React could theoretically provide, it won’t give you the same amount of flexibility that using the browser API directly would give you. So it seems like a reasonable solution in the meantime.

Talking with @sophiebits: it sounds like we should hold off on making a change until we clamp down a passive event listener API.

We shouldn’t undermine changes Chrome believes are important for improving site performance. If they have found that most scroll/wheel event listeners should be passive, that extends to React apps as well. We’re shouldn’t contribute to making apps slower for most cases when they don’t need to be.

Until a passive event API is available to React, one way to work around this is to use a ref to attach a non-passive event listener. (Edit: this is a work in progress. See https://github.com/facebook/react/pull/15036).

I’m not super familiar with using TypeScript with hooks, but I’ve done my best to form @blixt’s example to use a ref to attach a passive listener:

https://codesandbox.io/s/zzqxp1yvy3

I imagine others will come to the issue board confused about this change, so it’ll be important to have a clear path forward for future issues. Are there ways to improve on the solution above?

more than 98% of such listeners do not call preventDefault()

Translated: we’re comfortable breaking 2% of those sites which currently rely on default behavior…

My main issue is with Chrome and not React. Having said that, it’s sensible to “unbreak” React applications relying on previously default behavior while a firm API is proposed for declaring passive events. The push for potential performance gains should be balanced against backward compatibility. In this case Chrome has overstepped. Going against their grain until the full API is in place seems a minor transgression.

Forcing wheel events to be impassive would create additional breaking changes for React users

What breaking changes are there? Until a week ago this was the default behavior. It is still the default behavior for non-Chrome browsers. Forgive me if I am missing the larger picture here?

@catamphetamine Sorry to hide your comment, but it doesn’t help us come to a resolution and I want to keep this issue focused for others coming to the React issue board that might be confused.

The Chrome team didn’t do this in a vacuum, and took the time to research this thoroughly, as linked in the original issue description (https://developers.google.com/web/updates/2019/02/scrolling-intervention):

Our goal with this change is to reduce the time it takes to update the display after the user starts scrolling by wheel or touchpad without developers needing to change code. Our metrics show that 75% of the wheel and mousewheel event listeners that are registered on root targets (window, document, or body) do not specify any values for the passive option and more than 98% of such listeners do not call preventDefault().

Much like React, Chrome is in a position to improve user experience across a broad base of users. Both teams are aligned in this goal, even if coordinating on a change like this could have been smoother.

Forcing wheel events to be impassive would create additional breaking changes for React users, while undermining performance improvements on the platform. Let’s figure out the best API for passive event handlers in React moving forward. However in the interim, let’s also come up with a good general purpose solution so that it’s easier for React users to handle this change.

@gaearon Please reopen, since a proper fixes has not yet been provided. As long as this issue is closed, it might get overlooked or assumed to be solved by contributors.

This happens to me on Firefox now as well.

What a dumb gotcha. And the justification is just as dumb: “We don’t need to fix the framework cuz you can use the escape hatch in the framework to make it work right!” Then why am I bothering with your framework?

Chrome changed the event to be passive by default.

Incorrect, wheel event is only passive on root elements:

In Chrome 73, we are changing the wheel and mousewheel listeners registered on root targets (window, document, or body) to be passive by default.

https://developers.google.com/web/updates/2019/02/scrolling-intervention

React does not let you customize passive-ness of built-in events, so they became passive too.

Unrelated. We are using React to create non-root elements, so wheel event MUST continue to be non-passive by default to conform Web standards.

We won’t change the default since Chrome’s change is better for web overall.

You are doing the opposite of how web browsers work

Since using native event listeners already works (and has always worked) I don’t think there’s anything actionable left in this issue.

Native event listeners work, so React should also work.

Is there a solution yet? It’s really important that React doesn’t break existing browser functionality, and peventDefault is one of those. On top of that, I see a LOT of hacking around this issue, and most of that hacking is super ugly. We shouldn’t encourage things like that to be neccesary. We shouldn’t need to work against the framework, but with it.

So if there’s a proper/clean solution, please provide one. For the length of time that this issue has been ongoing, frankly I bloody well expect one. How hard can it be? Other event can be preventDefault’ed perfectly fine.

I might be “out of league” and I’m probably not seeing the big picture here, but can’t we just have an API change? onWheel is not the only event where we want to have more control, but scroll also as well sometimes.

A general support for addEventListeners’s options param would solve this problem once and for all.

My idea would be to add support to accept arrays for event props.

onWheel={[myCallback, { passive: false }]}

This would give us fine-grained control over any event options, and it can keep the default behavior.

Incorrect, scroll event is only passive on root elements

Thank you, I’m aware of that.

At the time the change was introduced, React was subscribing on the document. So at the time Chrome made that change, it did change the behavior for React applications globally. The impact of the change was globally improved scrolling performance on the web. Even though React has switched to using event listeners at the mount point (which is, for most React apps, lower but not much lower than the document), we’ve kept the passive behavior in React 17 to honor the intent (and impact) of Chrome’s change. Since otherwise we’d effectively undo much of the performance improvement. The thinking is that since React 17 came a year after the change was made, apps using React have already implemented workarounds.

I understand your point of view but I hope you see where I’m going with this as well. If not, I’m not sure I can do much to convince you. But the workaround using native event listeners still works.

So we changed from “make it correct, make it fast, make it pretty”, to “make it fast, make it correct, make it pretty” now?

Urgh… That’s not the direction things should go in. Correctness is more important than performance, because you can make a default correct system fast by optimizing it, but it’s ridiculous to make a default incorrect system fast, because the incorrect thing doesn’t actually solve your problem.

we’ve kept the passive behavior in React 17 to honor the intent (and impact) of Chrome’s change

Chrome team has numbers to support them, and they didn’t change non-root elements. Do you have any data shows that most React users that are using wheel events don’t want to call preventDefault in it? At least everyone in this thread (and everyone +1) were surprised when it doesn’t work like native DOM.

EDIT: This part actually has already been discussed in the thread. I’m sorry to repeat.

The thinking is that since React 17 came a year after the change was made, apps using React have already implemented workarounds.

So changing it back to non-passive won’t break anything. It’s a good chance to make the change.

But the workaround using native event listeners still works.

The “workaround” requires users to search the issue tracker (and in “closed” category now), at least it should be in the documentation.

Ok, read this whole thread, didn’t find what I was hoping to find: away to tell React to NOT use passive event on a specific element, or at least some way to tell React to bind a specific event on that element instead of on the root element (as a delegated event)

So, I want to stop the page from scrolling while the wheel (mouse for that matter) is being used over a specific range input field (<input type="range"/>) and I hacked it like so:

https://stackoverflow.com/a/65795791/104380

Here’s my copied answer from stackoverflow:

const wheelTimeout = useRef()

const onWheel = e => {
    // ... some code I needed ...

    // while wheel is moving, do not release the lock
    clearTimeout(wheelTimeout.current)

    // flag indicating to lock page scrolling (setTimeout returns a number)
    wheelTimeout.current = setTimeout(() => {
      wheelTimeout.current = false
    }, 300)
}

// block the body from scrolling (or any other element)
useEffect(() => {
    const cancelWheel = e => wheelTimeout.current && e.preventDefault()
    document.body.addEventListener('wheel', cancelWheel, {passive:false})
    return () => document.body.removeEventListener('wheel', cancelWheel)
}, [])

Where onWheel is a callback for <input type="range" wheel={onWheel} />

All works perfectly now. @yspektor solution is great but my fault was I put the code in componentDidMount not in index where is invoked ReactDom.Render() Cheers 👍

const checkType = (type, options) => {
    if (!PASSIVE_EVENTS.includes(type)) return null;

    const modOptions = {
        boolean: {
            capture: options,
            passive: false,
        },
        object: {
            ...options,
            passive: false,
        },
    };

    return modOptions[typeof options];
};

const addEventListener = document.addEventListener.bind();
document.addEventListener = (type, listener, options, wantsUntrusted) => (
    addEventListener(type, listener, checkType(type, options) || options, wantsUntrusted)
);

const removeEventListener = document.removeEventListener.bind();
document.removeEventListener = (type, listener, options) => (
    removeEventListener(type, listener, checkType(type, options) || options)
);

This Chrome update is causing us problems. I’m building a react component library that is intended to be used by the general public, so polluting the global scope by modifying native methods on the document object is probably not going to be a valid option for us. Unfortunately resorting to native listeners doesn’t work for us either since some of our components are using portals and native events don’t propagate up the virtual dom through portals the same way that React events do.

Any suggestions?

How long will it take for the issue to be fixed? What is the priority? None of the workarounds is 100% acceptable. Writing libraries using native events will be cumbersome for plenty of reasons. I have just faced the same issue as @Spenc84

@byronwall I can see how websites are broken anyway be it requiring the manual addition of { passive: false } or updating React version and rebuilding the bundle: any solution requires equal developer intervention. And in many cases the devs are long gone and no one knows how stuff was built or works. So it’s a really bizarre situation. I guess it better be the { passive: false } fix then instead of React fixing Chrome stuff. I agree that Chrome team did indeed break some part of the internet just because they like it more: they’ve simply grown too confident in their software monopoly. Go Firefox, what can I say.

@cherscarlett

I strongly believe the Chrome team thinks very carefully and thoughtfully about the changes they make, and I don’t believe it is right to call into question what they should or should not be doing without the full holistic picture of the drive behind the change and the expected outcome.

One must not question the existence of God because one’s mind is vanishingly small compared to the mind of God and so one can’t possibly ever grasp a hint of His divine majesty. If God tells you do something you must do that without questioning or hesitation, otherwise you’re a heretic and must be judged by the Holy Inquisition and later bunt alive to clean your soul of the sins and earn forgiveness because God loves all of his children.