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

Improving ergonomics of events with Observable #544

Open
benlesh opened this issue Dec 13, 2017 · 117 comments
Open

Improving ergonomics of events with Observable #544

benlesh opened this issue Dec 13, 2017 · 117 comments
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest topic: events

Comments

@benlesh
Copy link

benlesh commented Dec 13, 2017

Observable has been at stage 1 in the TC-39 for over a year now. Under the circumstances we are considering standardizing Observable in the WHATWG. We believe that standardizing Observable in the WHATWG may have the following advantages:

  • Get Observable to web developers more quickly.
  • Allow for a more full-featured proposal that will address more developer pain points.
  • Address concerns raised in the TC-39 that there has not been sufficient consultation with implementers.

The goal of this thread is to gauge implementer interest in Observable. Observable can offer the following benefits to web developers:

  1. First-class objects representing composable repeated events, similar to how promises represent one-time events
  2. Ergonomic unsubscription that plays well with AbortSignal/AbortController
  3. Good integration with promises and async/await

Integrating Observable into the DOM

We propose that the "on" method on EventTarget should return an Observable.

partial interface EventTarget {
  Observable on(DOMString type, optional AddEventListenerOptions options);
};

[Constructor(/* details elided */)]
interface Observable {
  AbortController subscribe(Function next, optional Function complete, optional Function error);
  AbortController subscribe(Observer observer); // TODO this overload is not quite valid
  Promise<void> forEach(Function callback, optional AbortSignal signal);
 
  Observable takeUntil(Observable stopNotifier);
  Promise<any> first(optional AbortSignal signal);

  Observable filter(Function callback);
  Observable map(Function callback);
  // rest of Array methods
  // - Observable-returning: filter, map, slice?
  // - Promise-returning: every, find, findIndex?, includes, indexOf?, some, reduce
};

dictionary Observer { Function next; Function error; Function complete; };

The on method becomes a "better addEventListener", in that it returns an Observable, which has a few benefits:

// filtering and mapping:
element.on("click").
    filter(e => e.target.matches(".foo")).
    map(e => ({x: e.clientX, y: e.clientY })).
    subscribe(handleClickAtPoint);

// conversion to promises for one-time events
document.on("DOMContentLoaded").first().then(e => );

// ergonomic unsubscription via AbortControllers
const controller = element.on("input").subscribe(e => );
controller.abort();

// or automatic/declarative unsubscription via the takeUntil method:
element.on("mousemove").
    takeUntil(document.on("mouseup")).
    subscribe(etc => );

// since reduce and other terminators return promises, they also play
// well with async functions:
await element.on("mousemove").
    takeUntil(element.on("mouseup")).
    reduce((e, soFar) => );

We were hoping to get a sense from the whatwg/dom community: what do you think of this? We have interest from Chrome; are other browsers interested?

If there's interest, we're happy to work on fleshing this out into a fuller proposal. What would be the next steps for that?

@annevk annevk added the needs implementer interest Moving the issue forward requires implementers to express interest label Dec 13, 2017
@annevk
Copy link
Member

annevk commented Dec 13, 2017

Thanks @benlesh for starting this. There've been mumblings about a better event API for years and with Chrome's interest this might well be a good direction to go in.

Apart from browser implementers, I'd also be interested to hear from @jasnell @TimothyGu to get some perspective from Node.js.

Another thing that would be interesting to know is what the various frameworks and libraries do here and whether this would make their job easier.

From what I remember discussing this before one problem with this API is that it does not work with preventDefault(). So if you frequently need to override an action (e.g., link clicks), you can't use this API.

cc @smaug---- @cdumez @travisleithead @ajklein

@davidkpiano
Copy link

From what I remember discussing this before one problem with this API is that it does not work with preventDefault(). So if you frequently need to override an action (e.g., link clicks), you can't use this API.

Couldn't this be covered with EventListenerOptions options?

element.on('click', { preventDefault: true })
  .filter(/* ... */)
  // etc.

@gsans
Copy link

gsans commented Dec 13, 2017

Cool! I propose to call them DOMservables

@matthewp
Copy link

matthewp commented Dec 13, 2017

@annevk Can you explain the preventDefault() problem in more detail? From @benlesh's examples, I would think you could call it in any of the filter() or map() callbacks. Is there a reason why you could not?

@domenic
Copy link
Member

domenic commented Dec 13, 2017

I think @annevk is remembering an async-ified version, perhaps based on async iterators. But observables are much closer to (really, isomorphic to) the EventTarget model already, so don't have this problem. In particular, they can call their next callback in the same turn as the event was triggered, so e.preventDefault() will work fine.

It really is just A Better addEventListener (TM). ^_^

@annevk
Copy link
Member

annevk commented Dec 13, 2017

(It seems my concern indeed only applies to promise returning methods, such as first().)

@appsforartists
Copy link

@benlesh Can you speak more to why the subscribe(observer) signature isn't valid? That's how all the observable implementations I've seen currently work, but they aren't written in C or Rust.

@domenic
Copy link
Member

domenic commented Dec 13, 2017

In web APIs, per the rules of Web IDL, it's not possible to distinguish between a function and a dictionary. (Since functions can have properties too.) So it's just disallowed in all web specs currently. Figuring out how or whether to allow both o.subscribe(fn) and o.subscribe({ next }) is the TODO.

To be clear, the tricky case is

function fn() { console.log("1"); }
fn.next = () => { console.log("2") };

o.subscribe(fn);

Which does this call? Sure, we could make a decision one way or another, but so far in web APIs the decision has been to just disallow this case from ever occurring by not allowing specs that have such overloads, So whatever we do here will need to be a bit novel.

This is all a relatively minor point though, IMO. Perhaps we should move it to https://github.com/heycam/webidl/issues.

@benlesh
Copy link
Author

benlesh commented Dec 13, 2017

Another thing that would be interesting to know is what the various frameworks and libraries do here and whether this would make their job easier.

I can't speak for frameworks, directly. Perhaps @IgorMinar or @mhevery can jump in for that, but for RxJS's part, whenever anyone goes to build an app using only RxJS and the DOM, one of the most common things they need from RxJS is fromEvent, which this would completely replace. I would also definitely love to see an RxJS that was simply a collection of operators built on top of a native Observable we didn't always have to ship.

@appsforartists
Copy link

Thanks for the clarification.

It's interesting to me that TC39 and WHATWG both have the ability to add JS APIs, but with different constraints. The TC39 proposal decides what to do based on if the first param is callable. If the TC39 proposal was stage 4, the browsers would be implementing that behavior, right? (Or maybe the TC39 proposal was supposed to be in WebIDL too and violated this. I hadn't heard about that, but I'm not a TC39 member either).

@keithamus
Copy link

keithamus commented Dec 13, 2017

FWIW in practice the DOM already makes the distinction of function-vs-object in the case of addEventListener:

const handle = e => console.log('main!', e)
handle.handleEvent = e => console.log('property!', e)
document.body.addEventListener('click', handle)

// Logs with `main!`

(Not suggesting WebIDL can't make the distinction, just pointing out there is a precedent here)

@jhusain
Copy link

jhusain commented Dec 13, 2017

@annevk concerns about the ability to call preventDefault() when using Promise returning methods are valid. Mutation use cases could be addressed with a do method which allows side effects to be interleaved.

button.on(“click”).do(e => e.preventDefault()).first()

This method is included in most userland Observable implementations.

@benlesh
Copy link
Author

benlesh commented Dec 13, 2017

@jhusain it could also be handled with map, although it would drive some purists crazy:

button.on('click').map(e => (e.preventDefault(), e)).first()

@appsforartists
Copy link

The biggest thing I see missing from this proposal is an official way to use userland operators. @benlesh, do you think pipe is ready to be included?

To the question about frameworks/libraries, as the author of a userland implementation, there are three core types that I'm interested in:

  • Observable
  • MemorylessSubject - It's both an Observer and an Observable. Calls to its next method are passed to all the observers who have subscribed.
  • Subject - In addition to being both an Observer and and Observable, it remembers its most recent emission and calls new subscribers with it immediately.

If Observable is standardized without the Subjects, userland libraries will still ship them. Still, any standardization is good for the whole ecosystem - it means operator authors know what interface they must implement to be interoperable with the ecosystem.

Furthermore, if Observable is standardized, I expect it will become common knowledge among web authors (just like promises, generators, etc.) That will make it easier for frameworks to depend on it without having to worry about intimidating their users with a steep learning curve - understanding observables becomes part of the job.

@jhusain
Copy link

jhusain commented Dec 13, 2017

@benlesh Using map would be less ergonomic for this use case, because developers would be obligated to also return the function argument.

button.on(“click”).map(e => {
  e.preventDefaut():
  return e;
}).first()

The do method would allow preventDefault() to be included in a function expression, and would consequently be more terse. Given how common this use case may be, the do method may be justified.

@johanneslumpe
Copy link

@appsforartists Nitpick, but a Subject should not retain its current value. Rather than having a Memoryless subject, I think it should be the other way around: having a Subject that can retain its latest value, like BehaviorSubject in rxjs. Adding functionality on top of the common base, instead of removing it.

@appsforartists
Copy link

Not to bikeshed, but the name of that method seems to keep flipping between do and tap. I don't know the reasoning for the changes, but I suspect tap is easier for users to disambiguate from do blocks and do expressions.

@appsforartists
Copy link

@johanneslumpe Agree that the information architecture is a bit wonky. I think I originally learned Subject has a value, so I think of the other as MemorylessSubject, but that doesn't mean we should standardize that way.

@emilio-martinez
Copy link

emilio-martinez commented Dec 13, 2017

@jhusain If I may, I believe you're nitpicking a bit too much on that point.

First, the benefits and ergonomics of having the listener removed due to the first() operator are much greater than the possible ergonomics lost on preventDefault(). In fact, it's the ergonomics for adding+removing event listeners which would make this API so rich.

Second, calling preventDefault() would probably not happen quite as you mention in your example. I believe it would be more like

button.on('click').first().map(e =>
  e.preventDefault();
  ... do more stuff with event
})

@benlesh
Copy link
Author

benlesh commented Dec 13, 2017

@jhusain I completely agree. I was just demonstrating that if there was a concern over method proliferation, it's possible with existing proposed methods. (And I know about the return requirement, notice the sly use of the comma in my example)

@benlesh
Copy link
Author

benlesh commented Dec 13, 2017

Not to bikeshed, but the name of that method seems to keep flipping between do and tap.

@appsforartists, that's an RxJS implementation thing. Unrelated to this issue.

@TimothyGu
Copy link
Member

For Node.js to accept (and implement) this, the on() method name must be changed, given that the Node.js EventEmitter class has on() as an alias for addEventListener(). People who program both for Node.js and for the web will just get more confused between the different behaviors on different platforms.

@benlesh
Copy link
Author

benlesh commented Dec 13, 2017

@appsforartists this proposal is really meant to meet needs around events in the DOM, and happens to ship with a nice, powerful primitive. We should keep it to that and not over-complicate it.

@domenic
Copy link
Member

domenic commented Dec 13, 2017

@TimothyGu Node could switch on the number of parameters provided to the on() method. That is, assuming Node wants to do direct integration into their EventEmitter like the web platform does.

@hrajchert
Copy link

Congrats all for bringing this proposal 🎉. I just want to address a point that I think the current implementation of RxJs is missing.

Currently, both Promises and Observables can only be typed on the value, not on the error. And I know talking about types (TypeScript or flow) is a strange thing to do in an Observable proposal, but the underlying reason is a subtle one, and the behaviour of how Observables handle different error situations is paramount.

The problem, as stated here, arrise when handling functions that throws errors, for example

Observable
  .of(1)
  .map(_ => {throw 'ups'})
  .fork(
       x => x,
       err => err // The error will be the string 'ups', but the type checker can only type it as any
  );

We can't avoid to handle these types of error as either your functions or functions that you call may throw, but on the other hand this type of error handling goes against of how we should do it functionally. It would be better to use a chainable method like .switchMap and Observable.throw or in the case of promises .then and Promise.reject.

For that reason, both Promises and Observables can only be typed on error as any and sadly it is the correct type. Luckly I think there are at least two possible solutions, one that is relevant to the Observable proposal.

One solution would be to try catch all methods that may throw and wrap the possible error into a class that extends from Error, for example

class UncaughtError extends Error {
}

which would make the following example possible

Observable
  .of(1)
  .map(_ => {throw 'ups'})
  .switchMap(_ => Observable.throw(new DatabaseError())
  .fork(
       x => x,
       err => err // The error type would be UncaughtError | DatabaseError
  );

Note that UncaughtError is always a posibility both if you have a function that throws or not but DatabaseError could be infered from the types of Observable.throw and switchMap.

Very recently I created a Promise like library called Task (WIP) that takes this into account and allow us to type both success and error cases. So far the results where more than satisfying.

The other solution I can think of would be if TypeScript or flow implements typed exceptions, but I don't think is the path their plans.

I hope you take this situations under consideration and thanks for the great work

Cheers!

@TimothyGu
Copy link
Member

Node could switch on the number of parameters provided to the on() method.

The IDL proposed in the OP allows a second options-style parameter.

But I'd also like to point out jQuery's on() as evidence for that the name simply has too much historical burden to it.

@keithamus
Copy link

At the risk starting the bikeshedding wars around the API; would .observe() be a reasonable name for this?

@benlesh
Copy link
Author

benlesh commented Dec 13, 2017

At the risk starting the bikeshedding wars around the API; would .observe() be a reasonable name for this?

@keithamus Certainly! But there's already a huge amount of industry momentum around subscribe as the primary API here. There's also some interesting nuance around what this method does. In all cases, it does observe, but in many cases it not only observes, but it also sets up the production of values. In this particular use case (EventTarget), it's only really observing, so the name "observe" makes total sense, but we would be sacrificing a name that makes a little more sense for other use cases of the Observable type as a primitive.

@TimothyGu
Copy link
Member

To be clear, anything other than on would be fine with me. I’ll leave y’all to determine what’s best other than that :)

@slikts
Copy link

slikts commented Oct 22, 2018

Observables would add ergonomics to EventTarget in two ways: having a standard interface that allows reusable abstractions, and adding pull semantics at the stream level. The issue there is that observables are just a stage-1 proposal, so they're not standard and won't be for years at best, and also that stream-level pull semantics are limited compared to value level.

Async iterables are now a standard stream primitive in the language, and they offer value-level pull semantics using promise generation, which integrates nicely with async functions and other language constructs and is familiar to users.

Converting the EventTarget push streams to iterables requires buffering, but there is a standard solution for that in Streams API, or even a simplified last-value cache could be used (which removes the risk of leaking with the tradeoff of having to be consumed serially).

The workaround to use Event.preventDefault() is straightforwardly just using the current API to add a separate listener. It's not good to have a special case, but it's also arguably the minority of use cases that need it.

Instead of waiting for observables for years, async iteration can be supported now. It'd enable a pattern like this:

for await (const event of element.addEventListener('click')) {
  console.log(event);
}

Calling @@asyncIterator() would add the listener, and the standard return() and throw() hooks can be used to remove the listener.

@jakearchibald
Copy link
Collaborator

The issue there is that observables are just a stage-1 proposal

See the OP - this is about speccing observables in DOM, not in TC39.

but there is a standard solution for that in Streams API

See #544 (comment)

@annevk annevk added the addition/proposal New features or enhancements label Apr 11, 2019
@benlesh
Copy link
Author

benlesh commented Oct 14, 2019

Of note: I think we could simplify this proposal quite a bit if we moved to something more like I'm referencing here: tc39/proposal-observable#201

@annevk
Copy link
Member

annevk commented Oct 14, 2019

For DOM it remains problematic I think that forEach(), first(), and last() return a promise as that means you cannot cancel the event from a handler.

@pemrouz's https://github.com/tc39/proposal-emitter does not have that downside.

@keithamus
Copy link

What's stopping those methods returning an Observable? Then cancellation can propagate right?

@loreanvictor
Copy link

loreanvictor commented May 20, 2020

This might be a bit off-topic, but what about a stronger integration with DOM beyond events? From my perspective, Observables can not only contribute to ergonomics of DOM event handling, but also with ergonomics of creating dynamic DOM content. Observables are the ideal type for reflecting values changing overtime, so if DOM elements could bind to them (which would be analogous to setting attributes to values that do not change overtime, e.g. plain strings), it could greatly simplify the computation and tooling required for creating dynamic UIs.

What I mean is something like this:

partial interface Node {
  void bindTextContent(Observable<string> observable, optional AbortSignal signal);
};

partial interface Element {
  void bindAttribute(string name, Observable<string> observable, optional AbortSignal signal);
  void bindAttribute(string name, Observable<bool> observable, optional AbortSignal signal);
};
// the element will become a dynamic counter
element.bindTextContent(timer(0, 1000));

// the element will change class each second
element.bindAttribute('class', 
  interval(1000).pipe(
    map(x => x % 2 == 0?'red':'blue')
  )
);

// the element would disappear and reappear each second
// the `hidden` attribute basically gets removed when the observable value is `false`
element.bindAttribute('hidden', interval(1000).pipe(map(x => x % 2 ==0))); 

Of course this raises some further questions, most importantly when to subscribe and when to cancel that subscription. I guess (but am not sure) that automatically subscribing to bound Observables when the element is rendered on main document and unsubscribing when the element is being cleaned up, combined with the fact that one could clean up the subscription earlier via the AbortSignal, should suffice. But still, this sort of would add a concept of a life cycle to DOM elements, so it is noteworthy.

And again, sorry if this is off-topic, but I assume that this issue is one of the main reasons for pushing forward standardization of Observables, and wanted to help by providing further use-case via further integration with DOM.

@benjamingr
Copy link
Member

benjamingr commented Jan 31, 2021

Hey, I think it might be interesting to revisit this now that AddEventListenerOptions includes signal with a really smallish API. Maybe something like:

partial interface EventTarget {
  // since stuff in AddEventListenerOptions like `once` is relevant after subscribing it should probably live there?
  // Also, since the name `on` was objected to 🤷 
  Observable observe(DOMString type);
};

[Constructor(/* details elided */)]
interface Observable {
  // unsubscribing already works with AddEventListenerOptions
  void subscribe(Function next, optional Function complete, optional Function error, optional AddEventListenerOptions);
  Promise<void> forEach(Function callback, optional AddEventListenerOptions);
};

It might be even simpler to remove one of these (subscribe or forEach).

In terms of Node.js - I don't see where/why Node would use this since our EventTargets are only used in AbortController and MessagePort and we don't have a DOM tree - but as more event target APIs ship (web sockets for example) we might have more compelling use cases? (I don't speak for Node in this statement and represent only my own opinion).

(I am personally a bit overextended - with the .follow document taking way longer than I expected it to mostly because I'm having to learn and research a lot in order to write it).

@Jamesernator
Copy link

I'm not particularly convinced ReadableStream should be ruled out as a candidate for this. Modern web APIs have overwhemingly being using promises which don't support synchronous response at all.

ReadableStream and AsyncIterator are implemented on top of promise, meaning that they're also tied to the same scheduling restrictions. Restrictions that make them not viable as an abstraction for eventing, as events can be subscribed to, dispatched and torn down synchronously.

Just to point out something, the only thing that can't be done synchronously is to receive a value. Streams (just like async functions) call basically everything synchronously, it's just that you can't receive the result until some future tick.

e.g.:

  • new ReadableStream({ start, pull, cancel }) calls start synchronously
  • reader.read() calls pull synchronously (if needed)
  • stream.cancel() calls cancel synchronously

The only real problem, and this is also shared by promises in general, is that if the promise is already resolved you can't abort it. Although as this is a problem for promises in general I don't think it's that great of an idea to create new primitives just to sidestep it in a few cases.

Obviously there's also the whole .preventDefault/.stopPropagation things that can't really be made asynchronous as-is, although it'd probably be worth investigating whether this suggestion is workable, if not we could just tack on a synchronous step e.g.:

const clickEvents = element.events("click", {
  // just a small sync handler for enabling .preventDefault/.stopPropagation
  syncHandler: (event) => { if (someCondition) event.preventDefault() },
});

for await (const event of clickEvents) {
  // Do the complicated work here
}

One particular advantage of using ReadableStream is that because it's specified to be async iterable, when proposals such as the iterators helpers proposal land any such methods will automatically be gained by the corresponding iterator for ReadableStream (as per WebIDL).

@domfarolino
Copy link
Member

Hey everyone, I know this thread has been quiet for a while, but I've been looking at this proposal from an implementer perspective and I have a few questions, some of which stem from the fact that I think I only recently wrapped my head around the desired semantics here, so bear with me :)

  1. Why are Observables needed? Why is synchronous delivery important?
    • I understand that the big difference between Observables and things like Streams / async iterators is:
    • At the risk of sounding dumb, how critical are some of these constraints? I'm trying to figure out why an Observable's magic cannot be recovered by composing together other things like Streams, when you can use tee() on Streams to recover the multi-consumer behavior and generally build "Push" systems out of "Pull" systems. That leaves the synchronous delivery aspect, and I'd love to understand why it's important. When trying to figure this out, I was directed to https://surma.dev/things/streams-for-reactive-programming/ which described Surma's toy observables-with-streams library. Naturally, the biggest difference between his Observables & true Observables is the synchronous delivery of data. I was surprised to find that it didn't cause any problems with the simple web app he wrote with his library, but I'd love to understand why today's existing, complex use-cases of real Observables could not tolerate that behavior, and what's so important about synchronous delivery?
  2. Is "subscriber" the same as "observer"?
    • It seems like it, but the argument in the Observable constructor's callback is usually called subscriber (when we call subscriber.next(foo)), yet the thing passed in to the Observable#subscribe() method is called an Observer.
      I know that these do not reference the same underlying JS objects, but conceptually-speaking are these the same thing? Or is there another actor involved here called Subscriber that I'm missing?
  3. How big of a concern really, are the Promise-ifying APIs on Observable?
    • Much ink has been spilled (c, c, c, c, c, c, c) about how the microtask queue is consulted synchronously after browser-initiated event callbacks, but not for JS-initiated ones. The consequence is that JS-initiated events cannot be canceled (among other things) from within Promise callbacks when Promise-ifying an Observable's result (is "result" the right word?).
    • @jakearchibald and others mention that this is kind of a big deal. Obviously by implementing Observables in the browser itself, we'd be opening up their usage (and their Promise-ifying APIs) to people unfamiliar with Observables today who might find themselves a new footgun. But to estimate how much of a footgun it is, I'm interested in hearing if the reactive JS community has ever seen this as a footgun? Is this one of those things where everyone getting started with Observables has that moment where they say "Ah, I too used to get tripped up when trying to cancel my events from Promise-ifying Observable methods, until I figured it out". That's a bit contrived of course, but I'm trying to figure out if this has generally been a gotcha for newcomers or not?
    • I ask because we're fortunate to have a robust set of community-curated Observable implementations in userland that already have this potential issue that the browser would not be introducing. Maybe we can estimate how much of a gotcha this might be, by learning how much of a gotcha it already is.
  4. How should I think about whatwg/dom#544 vs tc39/proposal-observable#201 (per #issuecomment-541763071)?
    • I apologize for not having read the entire Simplification of Observable API tc39/proposal-observable#201 thread yet. Is the ideal proposal/API the old one, or the "simplified" one? What I'm getting at is: was the 2019 simplification a last-ditch effort to try and get it pushed through TC39, that made API compromises in exchange for process support, or is it a strict improvement that we'd pursue if starting over from scratch today with unlimited implementer/standards support?

    • There is some discussion in the Simplification of Observable API tc39/proposal-observable#201 simplification about how it's better suited for the "firehose" problem where an observable synchronously (I guess that goes without saying) produces a bunch of values that the Observer can luckily stop, given a token-based cancellation mechanism. How coupled is (a) the simplification proposal with (b) this cancellation mechanism? Are they orthogonal? Can the initial API proposal here just have the Observable constructor's subscribe function take in something like an AbortSignal that you pass in when calling subscribe()? I guess it might look like:

      const obs = new Observable(subscriber => {
        for (i = 0; i < 9999999; ++i) {
          subscriber.next();
      
          if (subscriber.signal.aborted)
            break;
        }
      });
      
      AbortController controller;
      obs.subscribe({
        next: val => {
          if (val > 100)
            controller.abort();
        },
        signal: controller.signal,
      });

      I'm just trying to tease out the cancelation vs the API simplification, and see if they are decoupled at all.

@benlesh
Copy link
Author

benlesh commented Feb 17, 2023

@domfarolino So that's a LOT of questions, and I'll try to answer as many as I can. There are a couple of things worth noting here:

  1. This was a proposal made on Google's behalf when I was at Google :) (I still believe in it, though)
  2. Note that there's no part of this that is the Observable constructor. At the time it was thought to be too contentious to introduce a way to create observables from scratch (although I do think that the platform would benefit from this primitive). However, I'm amenable to adding a way to create observables given new versions of them pop up in the wild in many notable libraries.

Why are Observables needed? Why is synchronous delivery important?

Observables as a primitive

Observables, at a basic level, are more primitive than Promises, Streams, or Async Iterables. They are, for lack of a better way to put it: "Specialized functions with some safety added".

Observables could be in many shapes, and don't need to be confined to what you've seen above in this proposal. Consider that a straw man.

If we wanted to build the ideal PRIMITIVE Observable:

  1. Some ability to define a "setup" or "init" function: A way to tie some data producer (Or a "subject" to some "observer", this is the "observer pattern") synchronously when you "subscribe" (call the specialized function, aka "observe", "listen", et al in the wild)
  2. A way to notify a "consumer" of each new value as soon as possible Such that when your data producing "subject" notifies, you can immediately notify whatever code cares. (In the wild this could be registering a listener, or handler, or observer, et al).
  3. A way to notify a consumer that the producer is done producing values successfully
  4. A way to notify a consumer than there was an error and the producer will stop sending values.
  5. A means of the consumer telling the producer it no longer wants to receive values (cancellation, aborting, unsubscribing, et al)
  6. Critically important safety if we're allowing people to create their own observables: A means of tearing down or finalizing the data producer in any terminal case above (the producer is done, there was an error, or the consumer no longer cares).
  7. Critically important safety if we're allowing people to _create their own observables: The consumer cannot be notified of anything after finalization (due to the producer being done, erroring, or unsubscribing)
  8. A completely new call to the setup or initialization per subscription. (Note that you can still multicast with this via closure.)

Observables in the DOM

In the DOM, we don't necessarily need the whole primitive (although it would be the most useful thing to have). Instead what the DOM needs is the consumer side of the contract. To make this really powerful, the ideal API is to have the whole primitive though.

how critical are some of these constraints?

If you want to be able to accurately model what we can do with EventTarget, observables must be able to emit sychronously.

console.log('start');
eventTarget.addEventListener('whatever', console.log);
eventTarget.dispatchEvent(new Event('whatever'));
eventTarget.removeEventListener('whatever', console.log)
console.log('stop');

// synchronously logs:
// "start"
// [object Object]
// "stop"

I was surprised to find that it didn't cause any problems with the simple web app he wrote with his library

I'm not surprised. I'm amenable to having the observable primitive be asynchronous... however, it's decidedly LESS primitive once you force scheduling:

  1. If it's synchronous, you can always compose scheduling in. If it's already scheduling, you can't force it to be synchronous.
  2. If it's not synchronous, it doesn't make a good cancellation notifier. (You'll notice that this proposal is trying to use Observable to cancel itself).

However, if it IS synchronous, there are definitely some ergonomics gotchas when working with it. For example if you're trying to write an operator in RxJS that may terminate a source (like takeUntil etc), you have to be careful to check to see if the cancellation notifier notified synchronously before you even subscribe to the source. And what if you wanted to write a takeOneAfterThisNotifies sort of operator? It gets harrier. This is why I'm amenable to Observable being asynchronous. However, I will say it does make it less useful (and probably more resource intensive) if it schedules (Again, the cancellation example above)

Is "subscriber" the same as "observer"?

No. Observer would be an interface that really amounts to a few named handlers ({ next, error, complete }). Where Subscriber implements that interface, and generally "wraps" some observer to add the safety in I talked about above. Making sure you don't call next after error, for example. Or binding the observation to the subscription so it can teardown.

How big of a concern really, are the Promise-ifying APIs on Observable?

These things have been available for use for quite some time, pretty much since the dawn of Promises, and I haven't seen any real-world issues arise out of it. That said, promisifying observables is more of an ergonomics thing, since JavaScript has gone "all in" on Promise-related APIs (async/wait, for example). The entire world is used to the lack of cancellation in Promises. That said, I could take the promise features or leave them. But again, I've never seen promise conversion be the issue for observables. In fact, we're going to add [Symbol.asyncIterator] handling to RxJS in the next version. The observable primitive doesn't need them. It needs to simply exist so people stop reinventing it and shipping it out in their libraries.

How should I think about #544 vs tc39/proposal-observable#201 (per #544 (comment))?

Think of it like this: @benlesh sees a pressing need to ship a real Observable primitive to the JavaScript community en masse, and I've tweaked my proposals several times over the years to try to reach an audience with the gatekeepers.

Some facts:

  1. When the TC39 proposal was made, RxJS (even the older microsoft version) was under a million downloads a month. RxJS is now at 47,000,000+ downloads per week.
  2. Several popular libraries have completely reinvented observables in their own code bases in various shapes, some of them as direct ports of RxJS's observable that were then altered, others just organically inventing their own (and maybe not really realizing it was an observable):
  • React Router
  • XState
  • MobX
  • Relay
  • Recoil
  • Apollo GraphQL
  • Redux
  • Vue
  • Svelte
  • tRPC
  • RxJS
  1. The debugging story for a native observable would be SO MUCH BETTER than what we can currently do with any of these homegrown "community driven" implementation.

I'm tired. I don't want to be the sole arbiter of the most popular Observable implementation. It's a primitive type. It should be in our platforms by any means necessary. We're shipping countless bytes over the wire to send these observables out to everyone, where if the actual primitive existed I believe more library implementors would use them. Especially if they performed well because they were native, and they had a better debugging story in dev tools (because they're native). I want RxJS to be a cute little library of helper methods over a native type that becomes slowly irrelevant as those features grow in the native platforms. (Thus completing the long-running statement "RxJS is lodash for events").

What would I find to be acceptable criteria for a good Observable type in the platform?

See the requirements above. No "operators". Doesn't need any promise conversion stuff. That's all just "nice to have". This would be fine:

const source = new Observable(subscriber => {
   let n = 0;
   const id = setInterval(() => subscriber.next(n++), 1000);
   return () => clearInterval(id);
});

const ac = new AbortController()
const signal = ac.signal;

source.takeUntil(signal.on('abort')).subscribe(console.log);

But for the sake of this proposal: The main thing it was tryingto do is make events more ergonomic. Like:

eventTarget.on('click').takeUntil(signal.on('abort')).subscribe(() => console.log('clicked'))

Ironically, @benjamingr , a LONG time supporter of RxJS may have killed any chance the community had at getting Observable on a platform when he added { signal } to addEventListener. 😄

eventTarget.addEventListener('click', () => console.log('clicked'), { signal }); // This works today.

The down side? The API we have above with addEventListener doesn't compose. It can't communicate when it's "done". It doesn't stop when there was an "error". Use cases that are covered by a real observable like dragging something aren't as straightforward. Imagine this:

div.on('mousedown').flatMap(downEvent => {
  const divX = div.getBoundingClientRect().left;
  const startX = downEvent.clientX;
  return document.on('mousemove')
    .map(moveEvent => divX + moveEvent.clientX - startX)
    .takeUntil(document.on('mouseup'))
})
.subscribe(x => {
  div.style.transform = `translate3d(${x}px, 0, 0)`;
})

It would be pretty cool to be able to do that without RxJS. The imperative version of that is easier to mess up in my opinion. And note that outside of takeUntil, the other methods are things that are already found in other JavaScript "iterable" sorts of things. (Observable is the "inverse" of an iterable, BTW... which is another reason it's synchronous, if we wanted to be nerds about it)

This WHATWG proposal was painted as the best chance Observable ever had. My discussions with @domenic years ago signaled that he didn't believe it belonged in the ECMAScript standard (so presumably it's blocked there), and it was his determination that proposing it here was the best idea.


I hope this answers all of your questions, @domfarolino, and I hope you found this helpful. Please feel free to ask more. You know where to find me. (My github notifications are a hot mess because of RxJS, and I'm not paid to work on anything in open source or on GitHub, so if you ping me here I might not see)

@domfarolino
Copy link
Member

Thanks so much for the very thorough response @benlesh. Some replies below:

Note that there's no part of this that is the Observable constructor. At the time it was thought to be too contentious to introduce a way to create observables from scratch (although I do think that the platform would benefit from this primitive). However, I'm amenable to adding a way to create observables given new versions of them pop up in the wild in many notable libraries.

Interesting. We of course could move incrementally, introducing Observable-returning APIs before making Observables themselves fully constructible by JS, however I do see value in providing the full API as a primitive up front. https://w3ctag.github.io/design-principles/#constructors recommends something similar, which is good news too.

Critically important safety if we're allowing people to create their own observables: A means of tearing down or finalizing the data producer in any terminal case above (the producer is done, there was an error, or the consumer no longer cares).

I agree with this requirement, but it doesn't seem unique to user-constructed observables, right? For example, if unsubscribing from the platform-constructed Observable returned by EventTarget::on() did not remove the subscription from the list of event listeners, that seems like a leak. Are there additional concerns here that are unique to user-constructed Observables?

Observables in the DOM

If you want to be able to accurately model what we can do with EventTarget, observables must be able to emit sychronously.

Thanks, the impact of being synchronous makes sense here, especially for events and cancelation. I agree that the sync semantics here are ideal and give the most flexibility.

Subscriber implements that interface, and generally "wraps" some observer to add the safety in I talked about above.

Exactly, this is what I was asking about. Just wanted to make sure I understood the relationship between the two.

Or binding the observation to the subscription so it can teardown.

Can you clarify this (who exactly is tearing down here)? I understand that if complete()/error() is called on the Subscriber/wrapped Observer, we'd want to unregister "the subscription" from the event source for clean-up (it doesn't make sense to keep listening for events and having the "safe" Subscriber object just not call next() forever). Is that what you mean?


RE promises: It's good to know that this hasn't been seen as fraught territory so far in the Observable-using community, though I do agree that for something like an Observables MVP, the Promise-ifying APIs could probably be initially left out.

@bleistivt
Copy link

bleistivt commented Feb 18, 2023

For developer ergonomics to improve, Observable has to ship with a complete operator library in my opinion. A standard Observable without operators would only benefit library authors and could just as well be a userland standard.

Ironically, benjamingr , a LONG time supporter of RxJS may have killed any chance the community had at getting Observable on a platform when he added { signal } to addEventListener.

This might be a case of "worse is better" since it covers the large majority of use cases where no complex combination of event streams etc. is required.

This is how the drag example above looks using AbortSignal:

div.addEventListener("mousedown", (downEvent) => {
    const divX = div.getBoundingClientRect().left
    const startX = downEvent.clientX
    const abortController = new AbortController()
    
    document.addEventListener("mousemove", (moveEvent) => {
        const x = divX + moveEvent.clientX - startX
        div.style.transform = `translate3d(${x}px, 0, 0)`
    }, {signal: abortController.signal})
    
    document.addEventListener("mouseup", () => abortController.abort(), {once: true})
})

Sure, the API is not as nice, but I would argue that it is easier to understand for most developers today and works in all modern browsers. The order of events is also preserved when reading the code (mousedown, mousemove, mouseup).

There is a pitfall however: it is easy to create a memory leak by forgetting to add { once } or { signal } .
This might be avoidable with a helper like abortController.abortOn(target: EventTarget, eventName: string).

Once observables are part of the language, developers will expect common operators to be present.
I don't really see how ergonomics are improved if everyone still has to import a library for operators.

After advocating for addEventListener over on* and querySelectorAll over jQuery for years, tutorials using event streams and EventTarget.on + "import this operator library" as the new best way would be highly confusing for learners.

@benjamingr
Copy link
Member

For what it's worth I disagree with:

Ironically, benjamingr , a LONG time supporter of RxJS may have killed any chance the community had at getting Observable on a platform when he added { signal } to addEventListener.

I acknowledge it covers one thing observables did better than EventTarget (and we absolutely should keep improving EventTarget), but it doesn't really compare I think?

You can't map events with EventTarget (you can however wrap them in an async iterator and .map that). When you have synchronous events that matter (like a trusted user event triggering opening a window - where it has to be synchronous) - you can't use async iterators (micro-tick scheduling) and the semantics are clearly push based. Like you said - nothing composes, this has always been the bigger deal for me.

The thing that has been blocking observables to my knowledge for the last few years is that they were not presented to the committee and concerns raised and research did not progress because no one is funding it. I don't expect you to spend another 1000 hours for free on this, but let's remember we can still have observables in JavaScript if someone funds the work to do it or does it. I think WhatWG's and TC39's concerns can be addressed to everyone's happiness.

@domfarolino
Copy link
Member

Hey everyone, just to follow up on this thread: @benlesh and I have been working on this recently over at https://github.com/domfarolino/observable where we have a more formal explainer for this proposal, so feel free to engage over there if you'd like! At some point with a few more details ironed out, I'd like to send an Intent-to-Prototype out for Chromium to express formal interest.

@ljharb
Copy link

ljharb commented Jul 28, 2023

@domfarolino as i recall, the main signal TC39 was missing was browser interest, so if Chrome is interested, it is definitely worth bringing back to TC39.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest topic: events
Development

No branches or pull requests