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

React shouldn't bind wheel or touch events to the document. #1254

Closed
joshduck opened this issue Mar 12, 2014 · 36 comments
Closed

React shouldn't bind wheel or touch events to the document. #1254

joshduck opened this issue Mar 12, 2014 · 36 comments

Comments

@joshduck
Copy link
Contributor

React binds touchmove, touchstart, touchend, touchcancel and wheel handlers to the document. When the user tries to scroll the browser needs to execute these event handlers to ensure event.preventDefault() was not called. This means scrolling will stall while JavaScript is executing.

Chrome has a fast-path that checks whether the coordinate where the touch event happens has touch listeners (https://plus.google.com/+RickByers/posts/cmzrtyBYPQc). If there are no listeners Chrome can allow the scroll to happen even if the main thread is blocked on JavaScript.

We should bind our listeners for these events directly to the node which requires it. That event handler can then dispatch the event to the standard React top level event system. Then it will bubble/capture just like everything else and be visible to all event plugins.

@sophiebits
Copy link
Collaborator

This would also definitely fix our initializeTouchEvents problem. I don't know the perf impacts of not bubbling to document though.

@zpao
Copy link
Member

zpao commented Mar 12, 2014

make-it-so-captain

@SanderSpies
Copy link
Contributor

Anyone picking this one up? Otherwise, I would like to give it a go :-).

@possibilities
Copy link

It would also fix the problem I'm having here:

http://jsfiddle.net/BSJPd/1/

Arguably not a bug but unexpected and undesirable for me.

@petehunt
Copy link
Contributor

@SanderSpies you're exactly who I had in mind for this! :)

@SanderSpies
Copy link
Contributor

petehunt: great :)

Potential scenario's I'm seeing:

  • elements with listeners at the same level.

So for these event types there will be more then one event listener

  • child element listens to same event as parent element.

I guess we always want to use the parent element

  • during lifetime the dom changes, and therefore also the element which needs to listen.

Which would imply we would also need to remove/add event listeners on the fly, not sure if we already do that

I'm thinking of adding something like 'bindLocally' at the event plugins level like:

onTouchMove: {
bindLocally: true,
phasedRegistrationNames: {
bubbled: keyOf({onDragOver: true}),
captured: keyOf({onDragOverCapture: true})
}
}

Good idea? Bad idea? Missing stuff here?

@sophiebits
Copy link
Collaborator

It's really a function of the topLevelType topTouchMove, not the event that you wrote there. For naming, delegate: false might be clearer.

Right now I don't believe we ever remove listeners.

@SanderSpies
Copy link
Contributor

@spicyj I wrote that code a bit too hastily I guess. Should have been touchMove, also notice the onDragOver + onDragOverCapture that I forgot to correct...

@SanderSpies
Copy link
Contributor

Currently the event listeners are attached before React inserts the nodes into the DOM. So I was wondering how I should handle this.

I was thinking of letting ReactEventEmitter.listenTo create a 'bag' for local events, that is later used by a new function (addLocalEventListeners?) which is called by ReactComponent._mountComponentIntoNode.

Thoughts on this?

@sophiebits
Copy link
Collaborator

Are you sure it's before the DOM nodes are created? Regardless, probably you want to tie it to the transaction -- you can see how ReactReconcileTransaction stores a queue of componentDidMount callbacks (ON_DOM_READY_QUEUEING/ReactMountReady) and a queue of listeners to put (PUT_LISTENER_QUEUEING/ReactPutListenerQueue). If the put listener queue can't easily be modified to do what you want (though I'm guessing it can), you can make a new transaction wrapper and add it to the list there.

@u9520107
Copy link

u9520107 commented Aug 5, 2015

Is this still being investigated?
In my project, I have a component that uses scrollable div's with roughly 300 direct child, which nests maybe 6 to 7 children. While putting translateZ(0) to force composition layer seems to be able to boost scroll performance to 60 fps. As soon as the document wheel event is attached, the performance is crippled. Even after the component that relies on wheel events is dismounted, the wheel listener on document would live on, reducing the scroll performance to around 30-40 fps.

@nolanlawson
Copy link
Contributor

nolanlawson commented Sep 1, 2016

This problem was also noticed by former Edge/IE engineer Justin Rogers, who now works with y'all at Facebook. 😃 React's technique of adding a global wheel listener on the document defeats optimizations that certain browsers (notably Safari and Edge) have implemented to avoid jank that only needs to occur for inner elements, not for the entire document.

To demonstrate, I put together a little sample application based on create-react-app. There's a setInterval() in there that janks the main thread for 1000 milliseconds every 2000 milliseconds. This is to simulate a very active page with lots of JS. (Update: bumped it to 3000/4000 to make it more obvious.)

There are three versions of the app: 1) no wheel events, 2) a React onWheel event on the inner scrollable div (which adds a global wheel event to the document), and 3) a regular DOM wheel event on the inner scrollable div. In all cases, the listener itself is just lodash.noop.

In Firefox and Chrome, there is no difference between (2) and (3). However, in Edge and Safari, React's technique of adding the global wheel listener has the unfortunate side-effect of causing the entire page to jank while scrolling, rather than just when you're scrolling the inner div.

Note that this optimization only applies to certain kinds of scroll events like mousewheel scrolling, not necessarily two-finger touchpad scrolling, clicking-and-dragging the scrollbar, or keyboard-scrolling. So you may need to use a real mouse to reproduce. It also may apply to scroll, touchstart, touchmove, etc., but I only tested wheel, since the others have their own performance characteristics and may only be impacted on touchscreen devices. It's worth investigating, but for this demo I focused on wheel.

It's kind of a subtle issue, so I also made a quick video demonstration: https://youtu.be/6Ckepx3wPPE

@nolanlawson
Copy link
Contributor

nolanlawson commented Sep 1, 2016

Update: added onTouchStart/touchstart to the demo as well, and confirmed that this causes unnecessarily janky touch-scrolling in all mobile browsers (iOS Safari, Android Chrome, Android Firefox, Edge Mobile).

I compiled the results into a table for those who want to see a browser-by-browser breakdown.

@gaearon
Copy link
Collaborator

gaearon commented Sep 1, 2016

We just published a code dive into React event system (thanks @spicyj and @kentcdodds!)
This would be helpful to anyone who’d like to dig into this.

@nhunzaker
Copy link
Contributor

Just read this article and found my way here. I've got some bandwidth now that the number input issue is more or less wrapped up.

Is anyone working on this? Anything I should keep in mind before I get started?

@nhunzaker
Copy link
Contributor

Working on a fix here:
master...nhunzaker:nh-scroll-fix

This change allows ReactBrowserEventEmitter, which is responsible for attaching event listeners, to make the decision on a case-by-case basis as to what events should be attached to the document vs the element itself.

I still haven't gotten into touch events or serious wheel testing, but I can confirm that it gets rid of the window scroll jank in @nolanlawson v2 example

Time to find a mouse with a wheel!

@sebmarkbage
Copy link
Collaborator

@nhunzaker We should chat about this strategy. There are several considerations here.

  1. We want to minimize the amount of work we do during initial render which includes minimizing the checks we do on events. Currently we do way too much work just to figure out that we've already attached an event.

  2. We noticed in Attach event listeners at the root of the tree instead of document #8117 that moving event listeners locally had many unexpected consequences on other parts of the system so it was harder than it initially seemed. E.g. it can break how the select event plugin works. We need to make sure that all that stuff plays nicely together.

@nhunzaker
Copy link
Contributor

Absolutely. Is reactiflux, the discord room, best? Really any medium is fine.

Re 1: Are there any known bottlenecks that you could point me to?

Re 2: Totally, and hard to test. It would be good to get some DOM fixtures in place as we uncover issues. That was going to be my next step here.

@sebmarkbage
Copy link
Collaborator

sebmarkbage commented Mar 30, 2017

Probably best to start here. I just wanted to highlight those issue so you're aware.

  1. ensureListeningTo and these steps in ReactBrowserEventEmitter are currently executed for every event listener in the entire page. That's several slowish DOM operations, map lookups, hasOwnProperties and property look ups PER every listener before we can show the first screen. In theory, if we attached listeners to every event at the document eagerly, we wouldn't have to do any of that for any event listener.

  2. I think wheel specifically is probably pretty safe but I believe at least one platform it might be possible to change the value in a <select /> with the wheel which might or might not be related.

@nhunzaker
Copy link
Contributor

nhunzaker commented Mar 31, 2017

@sebmarkbage I recently had a few PRs merged that remove some of the work in BrowserEventEmitter doing support checks we don't need. Next I'm going to see what kind of savings we actually get by just assigning an event listener for every supported event.

Without changing things too much, I want to figure out what's slow, eliminate that work, then evaluate reworking ReactBrowserEventEmitter with less moving parts (and I better understand what's happening).

I'm also curious if we can reduce the number of calls to ensureListeningTo for special cases in ReactDomComponent

Is that a reasonable approach?

@nolanlawson
Copy link
Contributor

Restricting the set of "prefer local listeners" to wheel, touchstart, and touchmove is probably a good start. All of those events can block scrolling in Edge/Safari/etc. but only if done globally.

FWIW, in case you don't want to go to the trouble of buying a mouse with a wheel, I've found that two-fingered scrolling on a touchpad usually exhibits identical behavior.

@nhunzaker
Copy link
Contributor

@nolanlawson Thank you for the guidance! I have a mouse with a wheel (thank goodness for conference swag), but I wasn't sure about two-fingered scrolling, that's really good to know.

@sebmarkbage The only hiccup I'm hitting now is that to-string rendering (at least running tests against the feature flag: useCreateElement: false throws an error because the node to attach the listener for doesn't exist yet.

ensureListeningTo, which runs to make sure a top-level event listener is attached to the document, looks something like this:

function ensureListeningTo(inst, registrationName, transaction) {
  if (transaction instanceof ReactServerRenderingTransaction) {
    return;
  }

  listenTo(registrationName, getDocument(inst), getNode(inst));
}

The problem is that getNode(inst) fails some times when the useCreateElement feature flag is set to false. You see this warning:

Invariant Violation: React DOM tree root should always have a node reference.

So I had a few questions/ideas:

  1. Is setting useCreateElement: false still the best way to test server-side rendering?
  2. What about doing enqueuing event listener attachment post mount, like:
transaction.getReactMountReady().enqueue(attachListeners, this);

If we did this, what do you think about moving all of the local listener logic out of ReactDOMComponent and into ReactBrowserEventEmitter? For example, this section here:

https://github.com/facebook/react/blob/master/src/renderers/dom/stack/client/ReactDOMComponent.js#L254-L310

Then, possibly, we could eliminate the unmount work here:

https://github.com/facebook/react/blob/master/src/renderers/dom/stack/client/ReactDOMComponent.js#L1107-L1159

@sebmarkbage
Copy link
Collaborator

useCreateElement: false is what happens when you render into a server rendered tree on the client. We're in need of imminently rewriting that whole algorithm and likely need to switch strategies for Fiber anyway.

@philipp-spiess
Copy link
Contributor

Mentioning #2043 here as "probably related". 🙂

@Nandez89
Copy link

This might be related to #14856 or become more important since that chrome change.

@stale
Copy link

stale bot commented Jan 10, 2020

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contribution.

@stale stale bot added the Resolution: Stale Automatically closed due to inactivity label Jan 10, 2020
@stale stale bot removed the Resolution: Stale Automatically closed due to inactivity label Jan 10, 2020
@gaearon
Copy link
Collaborator

gaearon commented Jul 24, 2020

This has been implemented and will go out in React 17.

@gaearon gaearon closed this as completed Jul 24, 2020
josephsavona pushed a commit that referenced this issue May 15, 2024
--- 

#1254 added inference for hooks loaded from globals. This is the only time we 
need to generate a type equation assigning `lval` to a resolved`Hook` type. 

@gsathya Would love to get your feedback here on the change. From my 
understanding, this change is technically incorrect, since the type equation we 
generate should be dependent on the `callee` type (i.e. `Hook` if callee is a 
hook, `Function` if callee is a function). 

Would the next step be to consolidate `Hook` and `Function` types? 

```js 

type Function { 

... 

isHook: boolean, // set by inference 

} 

type FunctionSignature { 

isHook: boolean, // set when adding to ShapeRegistry 

} 

```
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