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

feat: DOM Events #10100

Open
wants to merge 46 commits into
base: main
Choose a base branch
from
Open

feat: DOM Events #10100

wants to merge 46 commits into from

Conversation

shirakaba
Copy link
Contributor

@shirakaba shirakaba commented Nov 20, 2022

PR Checklist

What is the current behavior?

The current Events system notifies only the direct target of the event (and any global event listeners). Gestures work a little bit differently to other events, as they are optimised for using without redundantly creating extra gesture listeners when existing ones could be reused.

What is the new behavior?

In summary, Events (including gestures) are now based on DOM EventTarget, and fully support capturing, bubbling, and cancelling.

I'll go into detail below.

Event propagation

Below I'll compare the behaviour before (as a refresher) and after this PR.

  1. Assume the following app (comments indicate the names of the instances of these classes, for the sake of distinguishing the two):
<!-- rootView -->
<Page>
  <!-- myStack -->
  <StackLayout>
    <!-- myButton -->
    <Button/>
  </StackLayout>
</Page>
  1. Notify the following PropertyChangeData:
myButton.notify({
  eventName: 'propertyChange',
  object: myButton,
  propertyName: 'whatever',
  oldValue: 0,
  value: 1,
});
  1. Thereafter the following behaviour would be exhibited:
  • Before:
    • handles a 'propertyChangeFirst' event on any class-level (global) listeners: Button;
    • handles a 'propertyChange' event on the target itself: myButton;
    • handles a 'propertyChange' event on any class-level (global) listeners: Button .
  • After:
    • handles a 'propertyChangeFirst' event on any class-level (global) listeners: Button;
    • handles a 'propertyChange' event on each target in the capturing path: [rootView, myStack];
    • handles a 'propertyChange' event on the target itself: myButton;
    • handles a 'propertyChange' event on each target in the bubbling path: [myStack, rootView];
    • handles a 'propertyChange' event on any class-level (global) listeners: Button.

Performance impact

Not yet measured, but hopefully we can put something together after cutting an early-access release.

Speed

The new Event propagation obviously performs more work than before, but it's still pretty trivial stuff (just walking a linked list of nodes and evaluating for each one whether there are any eligible listeners for the event currently being dispatched).

Memory usage

A little more memory will be used than before, as the callbacks are now wrapped in a DOMEvent instance. I think this is a small cost.

Room for optimisation

I've written this PR first and foremost to be readable, so there is certainly scope to do a second pass on it to optimise any hot paths. I think the algorithms are sound, so any significant savings would likely have to be a culmination of small optimisations.

Events API

Changes to Observable

The following changes were made in order to support class Observer implements EventTarget:

  • Observable methods:
    • addEventListener:
      • extra argument: options?: AddEventListenerOptions | boolean
    • off:
      • extra argument: options?: EventListenerOptions | boolean
    • on:
      • extra argument: options?: AddEventListenerOptions | boolean
    • once:
      • extra argument: options?: AddEventListenerOptions | boolean
    • removeEventListener:
      • extra argument: options?: EventListenerOptions | boolean
    • prototype.addEventListener:
      • extra argument: options?: AddEventListenerOptions | boolean
      • wider type for callback: EventListenerOrEventListenerObject | ((data: EventData) => void)
    • prototype.dispatchEvent:
      • added for the first time
    • prototype.notify:
      • extra argument: options?: CustomEventInit
    • prototype.off:
      • extra argument: options?: EventListenerOptions | boolean
    • prototype.on:
      • extra argument: options?: AddEventListenerOptions | boolean
    • prototype.once:
      • extra argument: options?: AddEventListenerOptions | boolean
    • prototype.removeEventListener:
      • extra argument: options?: EventListenerOptions | boolean
      • wider type for callback: EventListenerOrEventListenerObject | ((data: EventData) => void)

The presence of thisArg means that these APIs don't map exactly to DOM, but perhaps due to the various optional params or non-strict mode, TypeScript is satisfied that the class implements EventTarget.

Although the typings claim to support EventListenerOrEventListenerObject, for the sake of backwards compatibility, we handle the callback strictly as if it were (data: EventData) => void - so please don't pass in (event: Event) => void as it won't go well! My hope would be to migrate to the latter in a major version update, however.

You may ask, then "how do I get hold of the DOM Event, then?" if the callback gives you an EventData. See the following section for that.

New features

EventData callbacks are now wrapped in a DOM Event. To access the DOM Event for the currently-running callback, use this API for now (in a future release, as a breaking change, my intention would be to pass the DOM Event instead of the EventData in the callback):

const callback = (data: EventData) => {
  // How to access the DOM Event wrapping a callback:
  //
  // This unstable API guarantees to return you the DOM
  // Event backing the currently-running callback.
  const domEvent = DOMEvent.unstable_currentEvent!;

  // Feel your new DOM powers!
  domEvent.stopPropagation();
};

Propagation of DOM Events can be stopped via calling domEvent.stopPropagation() or domEvent.stopImmediatePropagation() as per the DOM Events spec.

Native effects relating to the event can be cancelled by calling domEvent.preventDefault() as per the DOM Events spec. However, for now, all events are created as non-cancellable, as cancellability will have to be implemented on a case-by-case basis.

Events can be listened for in either the bubbling or capture phases, based on the options passed into Observable.prototype.addEventListener().

Breaking changes

Removal/restriction of some internal APIs

The listener records in Observable, ViewBase, and GesturesObserver were exposed to external consumers - these are now made private, with certain APIs for accessing them removed. I think it is unlikely that these will have been used outside of Core, in any case.

Corrections to EventData and callback typings

In the course of this PR, I fixed many long-incorrect event listener typings and simplified the EventData typings. So all the old mentions of object?: Partial<Observable> are now simply object: Observable. This is a good thing, but may require some adjustments for existing NativeScript projects.

Deprecation notices

I plan to follow up this PR with another one that removes the static EventTarget-like methods, namely:

Observable.addEventListener
Observable.removeEventListener
Observable.off
Observable.on
Observable.once

This is because they deviate from the EventTarget spec while adding little value. The only usage of it in Core is for Application - in a follow-up PR, Application will be refactored into an Observable to remove the need for it. If this functionality is needed for a particular class after all, consider extending that class and overriding the notify method to your needs.

I would hope also to deprecate thisArg, but we will have to discuss how to go about that.

@cla-bot cla-bot bot added the cla: yes label Nov 20, 2022
Copy link
Member

@rigor789 rigor789 left a comment

Choose a reason for hiding this comment

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

This is looking great already! I've given it a read and noted a few things for consideration. I'll try to give this a more thorough look and try it as well.

Awesome job 🎉 🥳

packages/core/data/dom-events/dom-event.ts Show resolved Hide resolved
packages/core/data/observable/index.ts Outdated Show resolved Hide resolved
packages/core/data/dom-events/dom-event.ts Outdated Show resolved Hide resolved
packages/core/data/dom-events/dom-event.ts Show resolved Hide resolved
packages/core/data/dom-events/dom-event.ts Outdated Show resolved Hide resolved
@shirakaba shirakaba force-pushed the feat/eventtarget branch 3 times, most recently from cdabf8e to de169c2 Compare November 23, 2022 07:27
@shirakaba shirakaba force-pushed the feat/eventtarget branch 2 times, most recently from be58333 to 6128cc7 Compare November 23, 2022 09:20
@NathanWalker NathanWalker added this to the 9.0 milestone Nov 28, 2022
@shirakaba shirakaba force-pushed the feat/eventtarget branch 2 times, most recently from 6e7899b to f22db38 Compare November 29, 2022 11:44
@shirakaba shirakaba marked this pull request as ready for review November 29, 2022 17:24
@nx-cloud
Copy link

nx-cloud bot commented Nov 29, 2022

☁️ Nx Cloud Report

CI is running/has finished running commands for commit 96f841d. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this branch


✅ Successfully ran 2 targets

Sent with 💌 from NxCloud.

@shirakaba
Copy link
Contributor Author

shirakaba commented Dec 21, 2022

Performance

It's as optimised as I can make it

I've profiled the DOMEvent class as thoroughly as I can, and I can find no wasted logic at this point. You can see comments on just about every line showing the approaches I've compared (I can either remove these later or keep them in place to protect against the code being blindly refactored in future).

Yes, it's slower than before

To be clear, the DOM Events implementation is slower than the original single-target notification system. That's to be expected when we have multiple targets rather than just one.

How much slower?

If you call Observable.prototype.setProperty a million times on a simple view tree with a single listener on the targeted Observable, it would take this amount of time on average to run the whole batch:

Test to run 1,000,000 times Average batch time (ms)
Calling a function that simply adds one number to another 100
Old events 635
New events (fully featured) 3,750
New events (capturing phase disabled, bubbling phase enabled) 2,040
New events (capturing and bubbling phases disabled, i.e. just the target) 1,730

So even if some flavours of NativeScript (e.g. Core) wanted to forever opt out of bubbling/capturing, it would still take 3x the time of the original.

But, in absolute terms, the fully-featured version still only takes 3.750 microseconds to dispatch an event. For some perspective, this is equivalent to running 38 functions in sequence that simply add one number to another. For another comparison, in the grand scheme of human reaction speed (measured in milliseconds) - and this may be flawed reasoning - you'd have to be firing thousands of events in one go to block the main thread perceptibly.

Given that it easily brings in 10x the functionality, I think a 6x slowdown factor is defendable, and even more so as it paves the way for DOM conformance and JS runtime alignment. But maybe the reviewers have some ideas for making it faster!

How to profile for yourself

I've included two apps in the PR (which, again can be removed or refactored) to regression-test performance - they're called new (based on the current source) and old (based on a pinned version of Core). For now, they consist of two demos:

  1. scrolling: does nothing just yet, but is intended to be a playground for testing gestures. As I mentioned in Discord, I found I can't get scroll events to emit even on stable NativeScript Core, so I need some help on that still.
  2. properties demo, which (ignore the "manual profiling" switch which does nothing right now) upon pressing the "Automated profiling" button, runs Observable.prototype.setProperty one million times.

Just click the "Automated profiling" button and read the console to run the profiling.

@shirakaba shirakaba mentioned this pull request Jan 21, 2023
5 tasks
@NathanWalker NathanWalker modified the milestones: 8.5, 8.6 Mar 17, 2023
@NathanWalker NathanWalker modified the milestones: 8.6, 9.0 Sep 29, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants