Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chrome 73 breaks wheel events #14856

Closed
blixt opened this issue Feb 14, 2019 · 40 comments
Closed

Chrome 73 breaks wheel events #14856

blixt opened this issue Feb 14, 2019 · 40 comments

Comments

@blixt
Copy link

blixt commented Feb 14, 2019

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

@gaearon
Copy link
Collaborator

gaearon commented Feb 14, 2019

We’ve definitely considered adding support for passive events and that’s on our longer term roadmap, but we’d rather not rush such an important API addition because of the Chrome intervention timings.

@gaearon
Copy link
Collaborator

gaearon commented Feb 14, 2019

Do you mind creating a small repro case to verify it's broken now?

@blixt
Copy link
Author

blixt commented Feb 14, 2019

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.

@nhunzaker
Copy link
Contributor

The quick fix may be to manually add event listeners with {passive: false} [...]

I have mixed feelings on it, but I wonder if this is the best approach. We don't need to feature check for event options support either because all scroll events are captured anyway so the object coercing to true is fine:

trapCapturedEvent(TOP_SCROLL, mountAt);

This at least keeps the React behavior consistent while passive events are being figured out, but definitely presses the need to add more configuration to events.

@nhunzaker
Copy link
Contributor

nhunzaker commented Mar 27, 2019

For me, at least on Linux and ChromeOS, this is easier to reproduce with an actual mouse with a mousewheel (it could be that OSX emits mouse wheel events for trackpads).

I added a linear gradient to the background to better illustrate what's going on (event.preventDefault() should prevent scrolling on the parent, but it doesn't):
https://codesandbox.io/s/x3m6m17jop

Here's a vanilla example that uses { passive: false } to demonstrate the intended behavior:
https://codepen.io/nhunzaker/pen/rREpGo?editors=1010

The solution could be to just pass { passive: false } . I can prep a PR for that, even if we decide to hold off.

@nhunzaker
Copy link
Contributor

I think this goes against the wishes of the Chrome team, but it's the only solution I can think of to maintain existing behavior while a passive event API is being figured out.

@nhunzaker
Copy link
Contributor

nhunzaker commented Mar 28, 2019

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 #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?

@byronwall
Copy link

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

@cherscarlett
Copy link

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

@catamphetamine

This comment has been minimized.

@nhunzaker
Copy link
Contributor

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

@byronwall
Copy link

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
Copy link
Contributor

catamphetamine commented Mar 28, 2019

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

@yev-yev-yev
Copy link

yev-yev-yev commented Mar 29, 2019

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

@kheyse-werk
Copy link

Is it possible we can leave the removeEventListener as is since it does only look at the capture option (and not the passive option) according to https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener#Matching_event_listeners_for_removal

While addEventListener() will let you add the same listener more than once for the same type if the options are different, the only option removeEventListener() checks is the capture/useCapture flag. Its value must match for removeEventListener() to match, but the other values don't.

@yev-yev-yev
Copy link

@kheyse-oqton It's probably fine, but it is generally best practice to add and remove event listeners with identical options.

@aleczratiu
Copy link

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 = {
        ...options,
        passive: false,
      };
    }
  }

  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 = {
        ...options,
        passive: false,
      };
    }
  }
  return originalRemoveEventListener(type, listener, modOptions);
};

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

I have a issue when the listeners are parsed. I think some listeners are not eliminated from the document.body or something like this, because something is strange, at mousemove event, some functions are executed. These listeners are from stripe or braintree I guess and this running a xhr request on mousemove. When I remove your solution, it works well.

@Spenc84
Copy link

Spenc84 commented Apr 3, 2019

@yspektor That's a great temporary solution, though when defining modOptions you probably ought to include passive before ...options (eg. { passive: false, ...options }). That way it is still possible for third party libraries to add and remove passive listeners to and from the document as needed.

@AlexRatiu Try reversing the order of passive and ...options in the modOptions declaration (in both functions) and see if that solves your problem. If any third-party libraries add passive listeners to the document prior to the execution of your code, then they will be unable to remove them when needed at a later point.

@Spenc84
Copy link

Spenc84 commented Apr 3, 2019

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?

brianc added a commit to cruise-automation/webviz that referenced this issue Apr 12, 2019
Recently chrome changed the behavior of wheel listeners to be passive by default.  Passive listeners cannot cancel event propagation.  When a wheel event propagates outside of the canvas it can drastically slow down the browser as the parent elements try to compute whether or not they can scroll.  This change uses manual event listener management to add active listeners, which is inline w/ the behavior for chrome before version 73.

Test plan: use mousewheel. Notice there are no console.warn messages about passive listeners anymore.

Semver impact: this is semver patch.  Bugfix only.

Further reading: facebook/react#14856
facebook/react#6436
@patrickmccallum
Copy link

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.

@joelle-o-world
Copy link

@patrickmccallum I like the simplicity of this solution. But won't it end up preventDefaulting on all wheel events, not just those handled by MyComponent?

@patrickmccallum
Copy link

@patrickmccallum I like the simplicity of this solution. But won't it end up preventDefaulting on all wheel events, not just those handled by MyComponent?

Yep, that's correct. It's valid in our use case since we want to be able to trigger an app zoom from anywhere on the page, but it would stop you from scrolling for example a list that overflows. To fix that you'd just attach an if statement to check if the ctrl key is down in the event in cancelWheel.

// Prevent browser zooming
const cancelWheel = (event: WheelEvent): void => {
    if (event.ctrlKey) {
        event.preventDefault();
    }
};

I haven't looked into it much, but this could actually now be fixed in React 17 as per this blog post.

@yairEO
Copy link

yairEO commented Jan 19, 2021

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} />

@gaearon
Copy link
Collaborator

gaearon commented Mar 24, 2021

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

@gaearon gaearon closed this as completed Mar 24, 2021
@yume-chan
Copy link

yume-chan commented Mar 25, 2021

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.

@gaearon
Copy link
Collaborator

gaearon commented Mar 25, 2021

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.

@yume-chan
Copy link

yume-chan commented Mar 25, 2021

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.

@gaearon
Copy link
Collaborator

gaearon commented Mar 25, 2021

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

It will undo the spirit of Chrome's performance-motivated change for the websites using React. Since a lot of React websites are full-screen apps that mount close to the root, I would expect the same performance drop that the change was originally meant to solve. I agree that Chrome's change was disruptive, but at least it has achieved its purpose. It seems backwards to undo that performance improvement now that people have already implemented workarounds (and the most common cases indeed don't need active wheel listeners).

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

Yes, it's entirely fair that this should be documented. We're working on new docs now so I'll make a note to mention this. Also happy to review a PR if someone wants to send one now.

Another thing we could do is to provide a warning on the synthetic wheel event when you call preventDefault on it that is more specific. That would also be a welcome PR that would, I think, resolve this part of your concern.

@gaearon
Copy link
Collaborator

gaearon commented Mar 25, 2021

As for discoverability, all three top results for "chrome wheel events preventdefault" are relevant so I don't expect that closing the issue has any impact on this.

@tobia
Copy link

tobia commented Apr 29, 2021

@yairEO That is a horrible, horrible hack. It makes me cry.

That being said, it's the only solution that worked for me, so I have encapsulated it in a hook:

function useWheelHack(timeout = 300) {
  const wheelTimeout = useRef()

  // block the body from scrolling while wheelTimeout is set
  useEffect(() => {
    const maybeCancelWheel = (e) => wheelTimeout.current && e.preventDefault()
    document.body.addEventListener('wheel', maybeCancelWheel, { passive: false })
    return () => document.body.removeEventListener('wheel', maybeCancelWheel)
  }, [])

  // return a function that can be used to prevent scrolling for timeout ms
  return () => {
    clearTimeout(wheelTimeout.current)
    wheelTimeout.current = setTimeout(() => {
      wheelTimeout.current = false
    }, timeout)
  }
}

Usage:

const preventWheelDefault = useWheelHack()

return (
  <someElement
    onWheel={(event) => {
      preventWheelDefault()
      // do other stuff here...
    }}
  />
)

@Spenc84
Copy link

Spenc84 commented Apr 29, 2021

This isn't always accurate:

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

The primary issue I had a year ago is that native event listeners do not actually work the way that you usually need them to when used by an ancestor of a component that is rendered inside a portal created by ReactDOM.createPortal(). In these cases a synthetic React event that is generated inside the portal will properly propagate up through React's virtual DOM and can thus be addressed by an ancestor of the portaled component using a React event listener. If that same ancestor attempts to use a native event listener instead it won't ever fire off since the native event bubbles up through the actual DOM tree instead of the virtual DOM tree.

This ended up being a huge issue for the component library that I was working on at that time which resulted in us needing to scrap our original plan and settle for sub-optimal alternatives to what we were trying to accomplish. Prior to React adding in a way to control the passive-ness of events, is there anything else you could recommend to get around this particular problem?

@wintercounter
Copy link

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.

@thany
Copy link

thany commented Jun 29, 2021

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.

@somethingelseentirely
Copy link

somethingelseentirely commented Jul 13, 2021

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.

@ColonelThirtyTwo
Copy link

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?

@thany
Copy link

thany commented Sep 21, 2021

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

klembot pushed a commit to klembot/twinejs that referenced this issue May 1, 2022
Replaces the mouse wheel action in 2.3. There are problems with preventing
default on it: see facebook/react#14856

Using a keyboard shortcut seems better anyway since not everyone has a mouse
wheel.
@fjw
Copy link

fjw commented Aug 10, 2023

Sadly this timeout hack doesn't fully work on firefox for me.
I hope there will be a better solution. This really makes React Events inferior to native events...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests