React: Moving to React Portal after touchstart swallows future touch events

Created on 27 Jun 2018  路  11Comments  路  Source: facebook/react

Do you want to request a feature or report a bug?

Bug 馃悶

What is the current behavior?

When you move a component into a React Portal in response to a touchstart the touchmove and touchend events are swallowed for the rest of the interaction

If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem. Your bug will get fixed much faster if we can run your code and it doesn't have dependencies other than React. Paste the link to your JSFiddle (https://jsfiddle.net/Luktwrdm/) or CodeSandbox (https://codesandbox.io/s/new) example below:

Example: https://codesandbox.io/s/nr75vkklnm

You will either need to be on touch device, or enable touch sensors in your browser to see this

Steps:

  1. add a onTouchStart listener to a component
  2. in response to onTouchStart move the component into a Portal
  3. touchmove events and the touchend events are then blocked for the rest of the touch interaction

If the component is already in a component before the touchstart event then the events are emitted correctly: https://codesandbox.io/s/v54x54vp5

I have also created a vanilla js example that has a portal implementation. It moves the element into a portal after touch start. It is correctly allowing touch touchmove and touchend events: https://codesandbox.io/s/r4mn0yj6po

What is the expected behavior?

That the touchmove and touchend events are published

Which versions of React, and which browser / OS are affected by this issue? Did this work in previous versions of React?

Reproduced bug in Firefox and Chrome

React version: tested on 16.1, 16.3 and 16.4.1

DOM Discussion

Most helpful comment

This is indeed how the DOM behaves normally. The issue here is that touch events have a locked target: When a touchstart event occurs on a given target, all further touch event have the same target (you can read up on this in the spec).

MDN has a pretty good explanation for this issue:

Note that if the target element is removed from the document, events will still be targeted at it, and hence won't necessarily bubble up to the window or document anymore. If there is any risk of an element being removed while it is being touched, the best practice is to attach the touch listeners directly to the target.

Now, as Dan pointed out, the issue is that rendering inside a portal will create a new DOM element (since the element will have a different parent, the reconciler will always create a new element). And since the original element is no longer attached to the same document, events won鈥檛 bubble there anymore. In your vanilla example, this is not the case since the element is moved instead of recreated. Check out this example that shows the same issue.

As a workaround you can probably add native event listeners to the DOM node using componentDidMount and remove them on touchend/touchcancel. Or use pointer events which do not suffer from that issue since there, the target will always be the latest intersection (like with mouse events) - make sure any eventual polyfill handles this correctly though.

All 11 comments

Is that a bug though, or just how the DOM works? The element is technically a different one unless I'm missing something.

This is indeed how the DOM behaves normally. The issue here is that touch events have a locked target: When a touchstart event occurs on a given target, all further touch event have the same target (you can read up on this in the spec).

MDN has a pretty good explanation for this issue:

Note that if the target element is removed from the document, events will still be targeted at it, and hence won't necessarily bubble up to the window or document anymore. If there is any risk of an element being removed while it is being touched, the best practice is to attach the touch listeners directly to the target.

Now, as Dan pointed out, the issue is that rendering inside a portal will create a new DOM element (since the element will have a different parent, the reconciler will always create a new element). And since the original element is no longer attached to the same document, events won鈥檛 bubble there anymore. In your vanilla example, this is not the case since the element is moved instead of recreated. Check out this example that shows the same issue.

As a workaround you can probably add native event listeners to the DOM node using componentDidMount and remove them on touchend/touchcancel. Or use pointer events which do not suffer from that issue since there, the target will always be the latest intersection (like with mouse events) - make sure any eventual polyfill handles this correctly though.

@philipp-spiess Is there any action we can take on the React side? I'm considering closing this, but I wanted to check first.

@nhunzaker Nope, I think this works as intended.

Okay. Thanks for giving a fantastic break down of the problem and possible solutions! I'll close this out.

Thanks @philipp-spiess for the detail!! It is much appreciated. I will see if I can work around this DOM behaviour on my end

You are right, my example was moving a DOM element rather than creating a new one - which is where the problem was.

Side point: codesandbox is great for sharing these things 馃憤

Also, thanks for following up @gaearon

Linking for historical purposes: #3965

For those needing a solution for drag/drop and dealing with the touch target changing parents - I wrote up a potential/hacky solution here - https://gist.github.com/parris/dda613e3ae78f14eb2dc9fa0f4bfce3d in short attaching events during touchstart directly to the target seems to work for some reason.

I can confirm that attaching events to the event.target still publish after the element is removed. We will be shipping this change in react-beautiful-dnd soon https://github.com/atlassian/react-beautiful-dnd/issues/1225

Was this page helpful?
0 / 5 - 0 ratings