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

Don't we need Events/Observer Pattern first? #111

Open
rbuckton opened this issue Apr 1, 2024 · 6 comments
Open

Don't we need Events/Observer Pattern first? #111

rbuckton opened this issue Apr 1, 2024 · 6 comments

Comments

@rbuckton
Copy link

rbuckton commented Apr 1, 2024

The core functionality of Signals depend on the general principle of the Observer Pattern/Events. Adopting Signals before a standard event mechanism feels like putting the cart before the horse.

IMO, the only reason we don't have a standard event pub/sub mechanism in ECMA262 is that there already is one in the DOM (EventTarget) and NodeJS (EventEmitter). But the lack of a common, general purpose event system has led to situations like AbortSignal dragging along EventTarget's complexity along with it to every non-DOM implementation. We continue to tack on new built-in and host-provided functionality that could be served by a common event system and end up with numerous, disparate ways of handling the same underlying mechanisms:

  • EventTarget (addEventListener/removeEventListener/raiseEvent using function identity)
  • EventEmitter (on/off/fire using function identity)
  • FinalizationRegistry (regisister/unregister with a token)
  • AbortSignal (uses EventTarget but also has privileged subscriptions for DOM built-ins, abort)
  • Promise (then, resolve, reject)
  • Observable (either as proposed to ES or WHATWG, subscribe/unsubscribe)
  • Generators (yield, next(x))
  • setTimeout/clearTimeout et al (numeric handle)
  • and now Signal/Watcher (watch/unwatch with signal object identity)

I'd prefer to see some sort of observer pattern/events mechanism in the core specification before something more advanced like Signals.

@mfwgenerics
Copy link

mfwgenerics commented Apr 1, 2024

+1 I would be sad if we saw ECMA standardized signals before a lower-level observer primitive. I'd like to see dependency tracking and glitch-free propagation guarantees handled at the observer layer rather than the signals layer.

@rbuckton
Copy link
Author

rbuckton commented Apr 1, 2024

I would be happy with either a lower-level building block here, or even a standardized, symbol-based event protocol that could sit on top of EventTarget/EventEmitter, assuming we could iron out a minimal variant consistent with both.

For example, a bare-bones event protocol might look something like:

interface Evented {
  [Symbol.listen](eventKey: unknown, handler: (...args: any[]) => void): Disposable;
}

Where a Disposable is used for deregistration as a means for an implementation to wrap the underlying deregistration mechanism with a consistent API, e.g.:

EventTarget.prototype[Symbol.listen] = function (eventKey, handler) {
  this.addEventListener(eventKey, handler);
  return { [Symbol.dispose]: () => { this.removeEventListener(eventKey, handler); } };  
};

Alternatively, if function identity-based registration is preferred, we could have a simple protocol like this:

interface Evented {
  [Symbol.listen](eventKey, handler): void;
  [Symbol.unlisten](eventKey, handler): void;
  [Symbol.notify](eventKey, ...args): void;
}

It's more consistent with EventTarget/EventEmitter, though less flexible.

Several years ago I sketched a design for protocol-based events + syntax like so:

class Button {
  event click;
  event mousedown;
  event mouseup;
  
  #handleClick(m) {
    this::click({ x: m.x, y: m.y }); // looks up an event key and invokes it's handlers
  }
}

const btn = new Button();
const handler = e => { };
btn::click += handler;
btn::click -= handler;

which might be transposed into something like:

class Button {
  #events = new Map([
    ["click", new Set()],
    ["mousedown", new Set()],
    ["mouseup", new Set()]
  ]);
  [Symbol.notify](eventKey, ...args) { ... }
  [Symbol.listen](eventKey, handler) { ... }
  [Symbol.unlisten](eventKey, handler) { ... }
  
  #handleClick(m) {
    this[Symbol.notify]("click", { x: m.x, y: m.y });
  }
}

const btn = new Button();
const handler = e => { };
btn[Symbol.listen]("click", handler);
btn[Symbol.unlisten]("click", handler);

I used :: since there was an ancient precedent set by IE/JScript where you were allowed to write

function window::onload(e) {}

to attach events.

@daKmoR
Copy link

daKmoR commented Apr 2, 2024

I would be interested in something like a generic performant "not DOM related EventTarget".

Why?
We have an app where we have about 150k Objects/Class Instance for (clients, emails, contracts, users ...)
each of those fires an event whenever they change so "rendering" Web Component will "rerender"

As clients has multiple contracts and a single user can have multipel contracts we end up with about 400k "EventListener" on those Objects.

We tried using extends EventTarget but that really did not perform. Then we used a custom "EventTarget like solution" => e.g. exactly the same api but nothing to do with DOM and written in JS. Performance was night and day.

Summary:
150k Class Instances
400k Event Listener

using native EventTarget: ~25 seconds loading time
using custom EventTarget: ~1 seconds loading time

I would have assume the exact opposite - but it seems EventTarget is doing a lot more then just registering and dispatching Events... => so I assume a native "basic" EventTarget would be even faster then our custom implementation

@hlege
Copy link

hlege commented Apr 7, 2024

+1
I'm keen on having an EventBased Signal as well, perhaps something like new Signal.Event(). Additionally, a Signal base class would be more versatile if it exposes APIs like:

class EventSignal {
  connect(...);
  disconnect(...);
  dispose();
  emit(...);
}

class WriteableSignal {
  connect(...);
  disconnect(...);
  dispose();
  get();
  set();
  mutate(...); // should call an fn and after is notify all listener. should be use to update a complex state.
  peek();
  asReadonly();
}

class ReadonlySignal {
  connect(...);
  disconnect(...);
  dispose();
  get();
  peek();
}

This would enhance the usability and flexibility of the Signal class.

@backbone87
Copy link

I think the semantics for signals differ somewhat from that of a classical events/observer pattern. Signals need to be pulled to "do" anything while events are usually pushed. While they could use the same structural interface it may even be confusing for the user since usually interfaces and naming conventions set expectations around their behavior.

@littledan
Copy link
Member

littledan commented Apr 8, 2024

The place where an event/observer pattern comes up with Signals is in the Watcher API. It would be great to follow some broader pattern if it can satisfy the following requirements:

  • Needs to be able to batch several events for efficiency
  • Needs to allow the provider of the callback to delay processing of a batch
  • Needs to avoid too many unnecessary memory allocations, ideally with the number of allocations scaling slower than the number of events (e.g. as FinalizationRegistry does)
  • Needs to be invoked synchronously during some other JS code
  • No particular need to cancel an event; no need for bubbling; no need for any data attached; only one type of event
  • [Somewhat unique] Needs to be able to pull off the queued-up events separately from the notification that there are events to consider. The notification will come when there's just one, and more may queue up before the actual processing happens.

Overall, the Observer pattern might be a relatively good fit (as @alxhub proposed several months ago), especially if we permit ourselves the tweak of using the Computed signals directly as the events, rather than wrapping them an extra time. I don't think Events or Observables would meet some of these efficiency goals, especially when it comes to batching or memory allocation.

Computed and State signals do not follow any sort of event/observable/observer pattern, since they are not about explicit subscription/disposal or triggering at particular times. They are generally intended to be used for data dependency graphs. Using them in an event/observable fashion quickly leads to the classic "glitch" patterns and is somehow "push-based". The graph itself has to be based around a "pull-based" organizing principle for it to work.

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

No branches or pull requests

6 participants