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

Introduce once and passive events to actions #232

Merged
merged 4 commits into from
Aug 6, 2019

Conversation

adrienpoly
Copy link
Member

@adrienpoly adrienpoly commented Mar 22, 2019

As per the discussion in this thread, this pull request introduces two new options for events attached to an action

  • once
  • passive
addEventListener('click', action, {passive: false, once: false})

The proposed API is the following

<!--passive: true-->
<div data-action="touchmove.passive->controller#method"></div>
<div data-action="touchmove@window.passive->controller#method"></div>

<!--once: true-->
<button data-action="click.once->controller#vote"></button>
<div data-action="keydown@window.once->controller#delete"></div>

Defaults events

Defaults events with options are not supported

an action like this

<button data-action="controller#method">

must be written like this to pass an option:

<button data-action="click.once->controller#method">

considerations:
I see two possible syntaxes to support default event with options

<button data-action="once.controller#method">
<button data-action="controller#method.once">
// and this would be generalized 
<div data-action="touchmove->controller#method.passive"></div>
<div data-action="touchmove@window->controller#method.passive"></div>

open to discussion....

Tests

I added a new test case for those events in src/tests/cases/event_options_tests.ts. The tests for once events are ok IMHO but I am having a hard time testing the passive event

When I test in a real app the passive event I can get it to work but when I log as below an action in my test suite I always get non-passive events.

logPassive(event: Event) {
    event.preventDefault()
    if (event.defaultPrevented) {
     //this is not a passive event
      this.recordAction("passive", event, false)
    } else {
      //this is a passive event
      this.recordAction("passive", event, true)
    }
  }

Will keep investigating, maybe I am missing something here. Could it be that the chrome headless version does not support passive event???

Example

once a dev-build is available (not sure how new build are trigged) here is a test example
https://glitch.com/edit/#!/stimulus-event-options?path=src/controllers/options_controller.js:15:23

Still to do…

  • fix test for passive events
  • do we need to handle default event with a simpler API?
  • key/value syntax to support all event listener options?
  • Polyfill for ie11? potential solution but it does not support handleEvent
  • documentation

@kaspermeyer
Copy link

kaspermeyer commented Mar 22, 2019

Nice work! I was wondering if it would be possible to expand the API a bit to ensure that more than one event listener option can be added per action and provide support for other options such as bubbles, cancelable and composed?

I'm not sure if it should be a whitelisted set of properties or if any arbitrary property should be accepted. Also, allowing custom values per option makes sense for properties like bubbles which are true by default.

I considered using a quoteless JSON format, but it doesn't feel very easy on the eyes:

<div data="event{bubbles:false,once:true}->controller#action">

Perhaps it could be along the lines of:

<div data="event{bubbles:false|once:true}->controller#action">

@adrienpoly
Copy link
Member Author

Thanks

I was wondering if it would be possible to expand the API a bit to ensure that more than one event listener option can be added per action and provide support for other options such as bubbles, cancelable and composed?

I am not sure to really understand, maybe I am missing somehting here but passive and once are direct options of the eventListener and the eventListener is defined inside Stimulus with a a current hard coded value of false for both. So it would make sens to pass them as an option.

For Bubbles, cancelable they are options of the event by itself not the listener, Stimulus don't create events, it mostly listen to events and dispatch actions. So I am not really sure of what would be the use of defining bubbles/cancelable at the action level?

@kaspermeyer
Copy link

You're totally right, I'm sorry about the confusion! I got the options of the event listener and the events themselves mixed up 😕 Thanks for taking the time to sort it out.

@javan
Copy link
Contributor

javan commented Mar 25, 2019

There are still good reasons for providing a key/value syntax to support all event listener options. For example, touchmove is passive: true by default in Chrome and you may want to negate that.

@adrienpoly
Copy link
Member Author

ok thanks, both for your inputs
how about .not_passive to explicitly set it to false and to keep a short syntax?

Does it makes sens to have not_once?
Does it makes sens to be able to combine them .passive.once?

@kaspermeyer
Copy link

I'm tempted to suggest antonyms for the options like passive / active and once / always, but it might be too ambiguous since passive / once represents actual option names and active / always does not.

I know Sam mentioned that he liked the dot notation, but I'm wondering if it's too close to the target descriptor? The JSON notation event{passive:false,once:true}->controller#action (or a variation of it) doesn't conflict with existing descriptor patterns, it solves the problem of negating values without ambiguity, but it's a bit verbose. What do you think?

@sstephenson
Copy link
Contributor

Here’s my syntax suggestion:

<!--passive: true-->
<div data-action="touchmove[passive]->controller#method"></div>
<div data-action="touchmove[passive]@window->controller#method"></div>

<!--once: true-->
<button data-action="click[once]->controller#vote"></button>
<div data-action="keydown[once]@window->controller#delete"></div>
<button data-action="[once]controller#vote"></button>

<!--passive: false-->
<div data-action="touchmove[!passive]->controller#method"></div>

<!--passive: false, once: true-->
<div data-action="touchmove[!passive][once]->controller#method"></div>
  • Options are boolean tags attached to the right-hand side of the event name (before the event target specifier, if one is present).
  • An option with a true value is written as [option]; an option with a false value, as [!option].
  • If the action observes a default event, options can be attached to the left-hand side of the controller identifier: [option]controller#method.
  • Square brackets are visually calmer than curly braces, but they don’t look like function arguments, as parentheses might.

@sstephenson
Copy link
Contributor

Thinking through my proposal a bit more, there’d really be only four new tokens to match: [passive], [!passive], [once], and [capture]. (I can’t see any reason you’d need to specify once: false or capture: false since they’re always always the default, regardless of the event type.)

@ngan
Copy link

ngan commented Mar 29, 2019

How about: touchmove(once,!passive)->controller#method? Using parens makes it look like arguments. Regex should not mind space in front or after ,.

@sstephenson
Copy link
Contributor

In this case, I think parens are a bad idea because they look like arguments.

A common question is how to pass arguments to action methods. I can see how someone might confuse a parenthesized option syntax with arguments being passed to the action itself.

@adrienpoly
Copy link
Member Author

adrienpoly commented Mar 29, 2019

After some additional thought on this, I think within the various current proposals we have too much syntax variants for this feature:

  • standard syntax <div data-action="touchmove[passive]->controller#method"></div>
  • default events <button data-action="[once]controller#vote"></button>
  • global events <div data-action="keydown[once]@window->controller#delete"></div>

It makes it hard to remember where and how to put the options

Suggestion use the same syntax as the addEventListener

At the end what we are doing is specifying all of the arguments of the event listener. My idea is to keep the order and syntax as close as possible to the addEventListener

Screen Shot 2019-03-29 at 8 41 48 PM

Keeping the same order would translate to such a syntax

Screen Shot 2019-03-29 at 8 41 57 PM

Moving the options to the end simplifies the syntaxes, options always at the end whether there is a global event or a default event and we keep the same order so it is easy to remember.

Bracket syntax

<!--passive: true-->
<div data-action="touchmove->controller#method[passive]"></div>
<div data-action="touchmove@window->controller#method[passive]"></div>

<!--once: true-->
<button data-action="click->controller#vote[once]"></button>
<button data-action="controller#vote[once]"></button>

<!--passive: false-->
<div data-action="touchmove->controller#method[!passive]"></div>

<!--passive: false, once: true-->
<div data-action="touchmove->controller#method[!passive][once]"></div>

Dot syntax

<!--passive: true-->
<div data-action="touchmove->controller#method.passive"></div>
<div data-action="touchmove@window->controller#method.passive"></div>

<!--once: true-->
<button data-action="click->controller#vote.once"></button>
<button data-action="controller#vote.once"></button>

<!--passive: false-->
<div data-action="touchmove->controller#method!passive"></div>

<!--passive: false, once: true-->
<div data-action="touchmove->controller#method!passive.once"></div>
<div data-action="touchmove->controller#method.once!passive"></div>

@kaspermeyer
Copy link

I like the idea of moving the suffix to the action and reduce the number of variations. That might have been why I initially mixed up the event and listener options: they were declared at the event part of the descriptor instead of at the action (which plays the role of a listener).

@sstephenson
Copy link
Contributor

Fair point about matching the argument order of addEventListener.

If we put options on the right, I don’t think we should use brackets or dots. They both look too much like property accessors. A colon works nicely (and evokes CSS pseudo-selector syntax):

<!--passive: true-->
<div data-action="touchmove->controller#method:passive"></div>
<div data-action="touchmove@window->controller#method:passive"></div>

<!--once: true-->
<button data-action="click->controller#vote:once"></button>
<button data-action="controller#vote:once"></button>

<!--passive: false-->
<div data-action="touchmove->controller#method:passive"></div>

<!--passive: false, once: true-->
<div data-action="touchmove->controller#method:!passive:once"></div>

@adrienpoly
Copy link
Member Author

ok thanks, @sstephenson looks like we all together came up to a nice plan. Will update the code accordingly

@conormuirhead
Copy link

Just wanted to pop in and say that it's neat to see how y'all worked through this together. 👏 Great job, and thanks for doing this kind of work out in the open so others like me can benefit!

@adrienpoly
Copy link
Member Author

adrienpoly commented Apr 9, 2019

I have updated the code to reflect this new syntax.

allowed tokens are "passive", "!passive", "once", "capture"

Several comments:

  1. eventListenerMap: Took me some time to figure it out but I had to differentiate the key for the eventListenerMap on both the event name and the option. Otherwise, when chaining multiple actions with the same event name all subsequent actions would have the same option as the first one

A typical example would be a button to vote, first click increase a counter with stopPropagation and subsequent clicks display an alert message

<button data-action="controller#vote:once controller#alert"></button>

To do so I modified the key of the eventListenerMap to being a combination of the event name and options 👉 JSON.stringify({ eventName, ...eventOptions}).
All test pass (locally!) but this is probably a sensitive part that needs to be reviewed carefully

  1. Testing passive events: initially I wasn't able to test passive events. I finally discovered those lines of code that were preventing testing whether an action is passive or not. Those lines probably have a good reason to be here so, for now, I created a duplicate of this function just to test the passive events.

  2. Polyfill: all tests pass locally but fails in the CI on some browser. I tried adding this polyfill but while it improves the compatibility it does still fail a lot. Does anyone have a recommended alternative to polyfill once and passive options?
    Edited: looks like the issue with the polyfill is that it doesn't support handleEvent related issue. One solution could be to explicitly provide a callback function and not an object to the eventListener?

@adrienpoly
Copy link
Member Author

We can get all tests green by using the Github Polyfill AND explicitly passing the handleEvent function to the addEventListener

@javan
Copy link
Contributor

javan commented Apr 15, 2019

We can get all tests green by using the Github Polyfill AND explicitly passing the handleEvent function to the addEventListener

I'd rather not make code changes to work around an incomplete polyfill. Perhaps we can instead update the polyfill to support the EventListener interface?

@adrienpoly
Copy link
Member Author

I'd rather not make code changes to work around an incomplete polyfill. Perhaps we can instead update the polyfill to support the EventListener interface?

Ok @javan I ll try to open a PR on the respective repo with the support for EventListner I think I see how this could be added. Not sure however the PR would be merged anytime soon.

@javan
Copy link
Contributor

javan commented May 3, 2019

I opened github/eventlistener-polyfill#13 to add EventListener interface support. Unclear if that project being maintained though.

@adrienpoly
Copy link
Member Author

Thanks @javan I was caught up on some other projects and didn't have time yet to look into it. That is great.

Do you want that I update this branch with the polyfill code directly into @stimulus/polyfill just to confirm that with this solution we pass all tests on the CI?

We can then decide whether we provide our own polyfill or wait for the PR to be merged and published

@javan
Copy link
Contributor

javan commented May 3, 2019

No need to add the code directly. You can apply this change to test with my fork:

View diff
diff --git a/packages/@stimulus/core/src/event_listener.ts b/packages/@stimulus/core/src/event_listener.ts
index 018e2bf..3254fe2 100644
--- a/packages/@stimulus/core/src/event_listener.ts
+++ b/packages/@stimulus/core/src/event_listener.ts
@@ -14,11 +14,11 @@ export class EventListener implements EventListenerObject {
   }
 
   connect() {
-    this.eventTarget.addEventListener(this.eventName, this.handleEvent, this.eventOptions)
+    this.eventTarget.addEventListener(this.eventName, this, this.eventOptions)
   }
 
   disconnect() {
-    this.eventTarget.removeEventListener(this.eventName, this.handleEvent, false)
+    this.eventTarget.removeEventListener(this.eventName, this, false)
   }
 
   // Binding observer delegate
@@ -33,7 +33,7 @@ export class EventListener implements EventListenerObject {
     this.unorderedBindings.delete(binding)
   }
 
-  handleEvent = (event: Event) => {
+  handleEvent(event: Event) {
     const extendedEvent = extendEvent(event)
     for (const binding of this.bindings) {
       if (extendedEvent.immediatePropagationStopped) {
diff --git a/packages/@stimulus/polyfills/index.js b/packages/@stimulus/polyfills/index.js
index 0280022..e3b67d6 100644
--- a/packages/@stimulus/polyfills/index.js
+++ b/packages/@stimulus/polyfills/index.js
@@ -7,4 +7,4 @@ import "core-js/fn/promise"
 import "core-js/fn/set"
 import "element-closest"
 import "mutation-observer-inner-html-shim"
-import "eventlistener-polyfill"
+import "eventlistener-polyfill/src"
diff --git a/packages/@stimulus/polyfills/package.json b/packages/@stimulus/polyfills/package.json
index b6459f5..0d6e819 100644
--- a/packages/@stimulus/polyfills/package.json
+++ b/packages/@stimulus/polyfills/package.json
@@ -13,7 +13,7 @@
     "core-js": "^2.5.3",
     "element-closest": "^2.0.2",
     "mutation-observer-inner-html-shim": "^1.0.0",
-    "eventlistener-polyfill": "^1.0.4"
+    "eventlistener-polyfill": "javan/eventlistener-polyfill#event-listener-interface"
   },
   "publishConfig": {
     "access": "public"

@adrienpoly
Copy link
Member Author

All green 🚀

@adrienpoly adrienpoly changed the title [WIP] introduce once and passive events to actions Introduce once and passive events to actions May 3, 2019
@adrienpoly
Copy link
Member Author

Thanks @javan for your contribution to this polyfill. I have updated the Stimulus/Polyfill package to point to this latest release and it works perfectly 😄

Copy link
Contributor

@sstephenson sstephenson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lovely work on this, @adrienpoly.

I can think of one other case that might be problematic.

Every action contains an index, a number representing the position of the descriptor token inside the data-action value.

Whenever the value of a data-action attribute changes, Stimulus destroys the bindings previously associated with the action and then binds the new actions. Under the hood, that means event listeners are removed and re-installed.

What will happen in this scenario?

  1. Load an element with a once action:
    <button data-action="click->controller#a:once">
  2. Click the button; the a method is called.
  3. Click the button again; nothing happens, as expected.
  4. Modify the button’s data-action attribute with setAttribute():
    <button data-action="click->controller#b click->controller#a:once">
  5. Click the button again. The b method is called, as expected. Will the a method be called, even though it is declared as once and has logically already been called for this element and event?

packages/@stimulus/core/src/dispatcher.ts Outdated Show resolved Hide resolved
packages/@stimulus/core/src/event_listener.ts Outdated Show resolved Hide resolved
packages/@stimulus/core/src/event_listener.ts Show resolved Hide resolved
docs/reference/actions.md Outdated Show resolved Hide resolved
packages/@stimulus/core/src/action_descriptor.ts Outdated Show resolved Hide resolved
packages/@stimulus/test/src/dom_test_case.ts Outdated Show resolved Hide resolved
@adrienpoly adrienpoly force-pushed the passive_events branch 2 times, most recently from d1c57d7 to 6dcaa6f Compare June 11, 2019 14:43
@adrienpoly adrienpoly force-pushed the passive_events branch 6 times, most recently from cdf9c9e to fe95734 Compare June 11, 2019 15:19
@javan
Copy link
Contributor

javan commented Jun 11, 2019

@adrienpoly, you can rebase your branch with upstream master to fix these CI errors:

Screen Shot 2019-06-11 at 11 20 13 AM

They were resolved in a0fded1.

@adrienpoly adrienpoly force-pushed the passive_events branch 5 times, most recently from 5168148 to 293349c Compare June 11, 2019 16:40
@adrienpoly
Copy link
Member Author

@sstephenson I have updated the code with all your reviews, there is one comment from Javan that I have not yet added as it is causing some errors in the CI on IE that I don't fully understand right now. I will look into this a bit later

I added a test for your edge case.
The test pass without any change..... So I think we are good unless my test does not fully reflect your case?

@javan javan merged commit 2e8ca0c into hotwired:master Aug 6, 2019
@javan
Copy link
Contributor

javan commented Aug 6, 2019

Thank you, @adrienpoly!!

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

Successfully merging this pull request may close these issues.

None yet

6 participants