Closed Bug 1448747 Opened 7 years ago Closed 2 years ago

Neutralize FastClick

Categories

(Web Compatibility :: Interventions, enhancement, P2)

enhancement

Tracking

(Not tracked)

RESOLVED FIXED

People

(Reporter: karlcow, Unassigned, NeedInfo)

References

()

Details

Opening this issue to understand if we can do anything with regards to websites still supporting fastClick. seeAlso https://miketaylr.com/posts/2017/10/fast-click-more-like-thing-of-the-past-click.html The list of websites which have been reported and sporting a fastClick issue https://github.com/webcompat/web-bugs/issues?utf8=%E2%9C%93&q=+is%3Aissue+label%3Atype-fastclick+
Priority: -- → P2

I'm revisiting this, as we still fairly regularly discover sites that are using FastClick. I don't think there's a tenable way to detect FastClick on all sites automatically, but given that the list of known sites with this breakage seems manageable, I think it should be viable to maintain a JS injection like this on the known sites for now:

(function() {
  const proto = CSS2Properties.prototype;
  const descriptor = Object.getOwnPropertyDescriptor(proto, "touchAction");
  const { get } = descriptor;
  descriptor.get = function() {
    try {
      throw Error()
    } catch (e) {
      if (e.stack?.includes("notNeeded")) {
        return "none";
      }
    }
    return get.call(this);
  };
  Object.defineProperty(proto, "touchAction", descriptor);
})();

This is based on the code seen at https://github.com/ftlabs/fastclick/blob/main/lib/fastclick.js#L810.

It might be possible to simplify it, especially on select sites (such as simply having msTouchAction's getter always return "none"), but this is the safest more generalized method I can see which makes sure that if a site otherwise checks the values of touchAction, msTouchAction, or the other things that the notNeeded function of FastClick checks, that we aren't likely to step on their toes as well.

It's likely that there will be sites which uglify the code further, breaking the check for notNeeded, and if I find a site like that I'll probably just use a simplified version on those sites, as there are limits to what we can do to fix websites' bugs for them.

Severity: normal → S3
Status: NEW → RESOLVED
Closed: 2 years ago
Resolution: --- → FIXED

For the record, the reason FastClick breaks selects on Fenix is because it preventDefaults the mousedown and touchend events when you tap on a select box. It doesn't do this on Chrome/Safari because there are user-agent string sniffs specifically to avoid doing so, so sadly the only way to fix this bug is to override the UA string on affected sites so FastClick passes its notNeeded test.

Actually, there is a possibility here for us to maybe work around FastClick, but it would require Gecko to be crafty. While FastClick cancels the touchend event when a user taps on a select element, it also creates a mousedown event on the same select at the same coordinates, and sets forwardedTouchEvent=true on the JS event object just before dispatching it. So I wonder if we could do something like this during the dispatchEvent, specifically for mousedown events (and only if touch events are enabled):

  • check if the event is dispatched on a select, has forwardedTouchEvent==true and is untrusted.
  • check if the same select isn't open, because it has had a touchend event canceled with preventDefault just now, sharing the same client/screen coordinates (copied from changedTouches[0], which is what FastClick is doing)
  • go ahead and open the select box (ie, trigger the default, just-prevented action).

@Emilio, do you think something like this might be remotely feasible? We know there is a long tail of sites so affected by FastClick, since the list we're aware of keeps growing and rarely shrinks. If a more general fix like this might work, then I think it's worth considering (otherwise the best we can do is wait for people to discover the broken sites and make site-specific interventions).

Flags: needinfo?(emilio.alvarez96)

(In reply to Thomas Wisniewski [:twisniewski] from comment #4)

Actually, there is a possibility here for us to maybe work around FastClick, but it would require Gecko to be crafty. While FastClick cancels the touchend event when a user taps on a select element, it also creates a mousedown event on the same select at the same coordinates, and sets forwardedTouchEvent=true on the JS event object just before dispatching it. So I wonder if we could do something like this during the dispatchEvent, specifically for mousedown events (and only if touch events are enabled):

  • check if the event is dispatched on a select, has forwardedTouchEvent==true and is untrusted.

The <select> event listener is here. What we do is listening to the mousedown triggered by touchdown.

We could consider changing or hacking around that code, seems doable... I assume forwardedTouchEvent would be the event.detail? I think we'd need to be careful to avoid triggering getters etc on the object, but assuming there's a mechanism for getting that without side effects it seems doable, would be a matter of hacking around that code and see what fastclick is doing...

Flags: needinfo?(emilio.alvarez96)

Sure, this is what FastClick is basically doing (one source is here):

    e.prototype.sendClick = function (e, t) {
      var n,
      r;
      if (document.activeElement && document.activeElement !== e) {
        document.activeElement.blur()
      }
      r = t.changedTouches[0];
      n = document.createEvent('MouseEvents');
      n.initMouseEvent(
        this.determineEventType(e),
        true,
        true,
        window,
        1,
        r.screenX,
        r.screenY,
        r.clientX,
        r.clientY,
        false,
        false,
        false,
        false,
        0,
        null
      );
      n.forwardedTouchEvent = true;
      e.dispatchEvent(n)
    };

Where sendClick is called with e being the select, and r being a touchend event. It's called during the touchend handler, which also prevents the default:

    e.prototype.onTouchEnd = function (e) {
      // snip a bunch of code
      if (!this.needsClick(l)) { // where `l` is of course the select element
        e.preventDefault();
        this.sendClick(l, e)
      }

And for completeness, this is needsClick:

    e.prototype.needsClick = function (e) {
      switch (e.nodeName.toLowerCase()) {
        case 'button':
        case 'select':
        case 'textarea':
          if (e.disabled) {
            return true
          }
          break;
        case 'input':
          if (r && e.type === 'file' || e.disabled) {
            return true
          }
          break;
        case 'label':
        case 'iframe':
        case 'video':
          return true
      }
      return /\bneedsclick\b/.test(e.className)
    };

So basically it prevents the touchend, blurs the select, and fires a mousedown on it instead in lieu of the touchend.

Oh, so forwardedTouchEvent is on the object itself. Does something like this work?

diff --git a/layout/forms/HTMLSelectEventListener.cpp b/layout/forms/HTMLSelectEventListener.cpp
index dffabe1bc7451..72b6159f38adc 100644
--- a/layout/forms/HTMLSelectEventListener.cpp
+++ b/layout/forms/HTMLSelectEventListener.cpp
@@ -402,8 +402,32 @@ static void FireDropDownEvent(HTMLSelectElement* aElement, bool aShow,
                                       CanBubble::eYes, Cancelable::eNo);
 }
 
+static bool IsFastClickMouseDownEvent(dom::Event* aMouseEvent) {
+  if (aMouseEvent->IsTrusted()) {
+    return false;
+  }
+
+  JS::Rooted<JSObject*> wrapper(aMouseEvent->GetWrapper());
+  if (!wrapper) {
+    return false;
+  }
+
+  AutoJSAPI jsapi;
+  if (!jsapi.Init(aMouseEvent->GetParentObject())) {
+    return false;
+  }
+
+  JSContext* cx = jsapi.cx();
+  bool found = false;
+  return JS_HasProperty(cx, wrapper, "forwardedTouchEvent", &found) && found;
+}
+
 nsresult HTMLSelectEventListener::MouseDown(dom::Event* aMouseEvent) {
-  NS_ASSERTION(aMouseEvent != nullptr, "aMouseEvent is null.");
+  NS_ASSERTION(aMouseEvent, "aMouseEvent is null.");
+
+  if (mIsCombobox && IsFastClickMouseDownEvent(aMouseEvent)) {
+    // Hackety hack?
+  }
 
   MouseEvent* mouseEvent = aMouseEvent->AsMouseEvent();
   NS_ENSURE_TRUE(mouseEvent, NS_ERROR_FAILURE);
Flags: needinfo?(twisniewski)

I think so, though of course it might be safer to only do the "hackety hack" part if we just prevented default on a trusted touchend with the select box as its target.

Also I've found one more wrinkle: it's not just select boxes which are affected, but also <a> and buttons which submit forms (or any other preventable default, really). And in the case of non-selects, FastClick fires a click instead of a mousedown. But the principle is the same, so it might still be doable.

Flags: needinfo?(twisniewski)

Are you able to try to poke at it and get it to work? I'm a bit swamped with other stuff so it might be a bit until I get to this. Just want to make sure it doesn't fall through the cracks.

Flags: needinfo?(twisniewski)

I don't have much time now either, but I'll keep it on my radar and try to poke at it, yes. As long as there's a reasonable chance we'd accept something like this, then I feel it's justifiable effort :)

You need to log in before you can comment on or make changes to this bug.