Code Monkey home page Code Monkey logo

observable's Introduction

Observable

This is the explainer for the Observable API proposal for more ergonomic and composable event handling.

Introduction

EventTarget integration

This proposal adds an .on() method to EventTarget that becomes a better addEventListener(); specifically it returns a new Observable that adds a new event listener to the target when its subscribe() method is called. The Observable calls the subscriber's next() handler with each event.

Observables turn event handling, filtering, and termination, into an explicit, declarative flow that's easier to understand and compose than today's imperative version, which often requires nested calls to addEventListener() and hard-to-follow callback chains.

Example 1

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

Example 2

// Automatic, declarative unsubscription via the takeUntil method:
element.on('mousemove')
  .takeUntil(document.on('mouseup'))
  .subscribe({next: e =>  });

// Since reduce and some other terminators return promises, they also play
// well with async functions:
await element.on('mousemove')
  .takeUntil(element.on('mouseup'))
  .reduce((soFar, e) => );
Imperative version
// Imperative
const controller = new AbortController();
element.addEventListener(
	'mousemove',
	(e) => {
		element.addEventListener('mouseup', (e) => controller.abort());
		console.log(e);
	},
	{ signal: controller.signal },
);

Example 3

Tracking all link clicks within a container (example):

container
	.on('click')
	.filter((e) => e.target.closest('a'))
	.subscribe({
		next: (e) => {
			// …
		},
	});

Example 4

Find the maximum Y coordinate while the mouse is held down (example):

const maxY = await element
	.on('mousemove')
	.takeUntil(element.on('mouseup'))
	.map((e) => e.clientY)
	.reduce((soFar, y) => Math.max(soFar, y), 0);

Example 5

Multiplexing a WebSocket, such that a subscription message is send on connection, and an unsubscription message is send to the server when the user unsubscribes.

const socket = new WebSocket('wss://example.com');

function multiplex({ startMsg, stopMsg, match }) {
  if (socket.readyState !== WebSocket.OPEN) {
    return socket
      .on('open')
      .flatMap(() => multiplex({ startMsg, stopMsg, match }));
  } else {
    socket.send(JSON.stringify(startMsg));
    return socket
      .on('message')
      .filter(match)
      .takeUntil(socket.on('close'))
      .takeUntil(socket.on('error'))
      .map((e) => JSON.parse(e.data))
      .finally(() => {
        socket.send(JSON.stringify(stopMsg));
      });
  }
}

function streamStock(ticker) {
  return multiplex({
    startMsg: { ticker, type: 'sub' },
    stopMsg: { ticker, type: 'unsub' },
    match: (data) => data.ticker === ticker,
  });
}

const googTrades = streamStock('GOOG');
const nflxTrades = streamStock('NFLX');

const googController = new AbortController();
const googSubscription = googTrades.subscribe({next: updateView}, {signal: googController.signal});
const nflxSubscription = nflxTrades.subscribe({next: updateView, ...});

// And the stream can disconnect later, which
// automatically sends the unsubscription message
// to the server.
googController.abort();
Imperative version
// Imperative
function multiplex({ startMsg, stopMsg, match }) {
	const start = (callback) => {
		const teardowns = [];

		if (socket.readyState !== WebSocket.OPEN) {
			const openHandler = () => start({ startMsg, stopMsg, match })(callback);
			socket.addEventListener('open', openHandler);
			teardowns.push(() => {
				socket.removeEventListener('open', openHandler);
			});
		} else {
			socket.send(JSON.stringify(startMsg));
			const messageHandler = (e) => {
				const data = JSON.parse(e.data);
				if (match(data)) {
					callback(data);
				}
			};
			socket.addEventListener('message', messageHandler);
			teardowns.push(() => {
				socket.send(JSON.stringify(stopMsg));
				socket.removeEventListener('message', messageHandler);
			});
		}

		const finalize = () => {
			teardowns.forEach((t) => t());
		};

		socket.addEventListener('close', finalize);
		teardowns.push(() => socket.removeEventListener('close', finalize));
		socket.addEventListener('error', finalize);
		teardowns.push(() => socket.removeEventListener('error', finalize));

		return finalize;
	};

	return start;
}

function streamStock(ticker) {
	return multiplex({
		startMsg: { ticker, type: 'sub' },
		stopMsg: { ticker, type: 'unsub' },
		match: (data) => data.ticker === ticker,
	});
}

const googTrades = streamStock('GOOG');
const nflxTrades = streamStock('NFLX');

const unsubGoogTrades = googTrades(updateView);
const unsubNflxTrades = nflxTrades(updateView);

// And the stream can disconnect later, which
// automatically sends the unsubscription message
// to the server.
unsubGoogTrades();

Example 6

Here we're leveraging observables to match a secret code, which is a pattern of keys the user might hit while using an app:

const pattern = [
	'ArrowUp',
	'ArrowUp',
	'ArrowDown',
	'ArrowDown',
	'ArrowLeft',
	'ArrowRight',
	'ArrowLeft',
	'ArrowRight',
	'b',
	'a',
	'b',
	'a',
	'Enter',
];

const keys = document.on('keydown').map((e) => e.key);
keys
	.flatMap((firstKey) => {
		if (firstKey === pattern[0]) {
			return keys
				.take(pattern.length - 1)
				.every((k, i) => k === pattern[i + 1]);
		}
	})
	.filter((matched) => matched)
	.subscribe({
		next: (_) => {
			console.log('Secret code matched!');
		},
	});
Imperative version
const pattern = [...];

// Imperative
document.addEventListener('keydown', e => {
  const key = e.key;
  if (key === pattern[0]) {
    let i = 1;
    const handler = (e) => {
      const nextKey = e.key;
      if (nextKey !== pattern[i++]) {
        document.removeEventListener('keydown', handler)
      } else if (pattern.length === i) {
        console.log('Secret code matched!');
        document.removeEventListener('keydown', handler)
      }
    }
    document.addEventListener('keydown', handler)
  }
})

The Observable API

Observables are first-class objects representing composable, repeated events. They're like Promises but for multiple events, and specifically with EventTarget integration, they are to events what Promises are to callbacks. They can be:

  • Created by script or by platform APIs, and passed to anyone interested in consuming events via subscribe()
  • Fed to operators like Observable.map(), to be composed & transformed without a web of nested callbacks

Better yet, the transition from event handlers ➡️ Observables is simpler than that of callbacks ➡️ Promises, since Observables integrate nicely on top of EventTarget, the de facto way of subscribing to events from the platform and custom script. As a result, developers can use Observables without migrating tons of code on the platform, since it's an easy drop-in wherever you're handling events today.

The proposed API shape can be found in https://wicg.github.io/observable/#core-infrastructure.

The creator of an Observable passes in a callback that gets invoked synchronously whenever subscribe() is called. The subscribe() method can be called any number of times, and the callback it invokes sets up a new "subscription" by registering the caller of subscribe() as a Observer. With this in place, the Observable can signal any number of events to the Observer via the next() callback, optionally followed by a single call to either complete() or error(), signaling that the stream of data is finished.

const observable = new Observable((subscriber) => {
	let i = 0;
	setInterval(() => {
		if (i >= 10) subscriber.complete();
		else subscriber.next(i++);
	}, 2000);
});

observable.subscribe({
	// Print each value the Observable produces.
	next: console.log,
});

While custom Observables can be useful on their own, the primary use case they unlock is with event handling. Observables returned by the new EventTarget#on() method are created natively with an internal callback that uses the same underlying mechanism as addEventListener(). Therefore calling subscribe() essentially registers a new event listener whose events are exposed through the Observer handler functions and are composable with the various combinators available to all Observables.

Constructing & converting objects to Observables

Observables can be created by their native constructor, as demonstrated above, or by the Observable.from() static method. This method constructs a native Observable from objects that are any of the following, in this order:

  • Observable (in which case it just returns the given object)
  • AsyncIterable (anything with Symbol.asyncIterator)
  • Iterable (anything with Symbol.iterator)
  • Promise (or any thenable)

Furthermore, any method on the platform that wishes to accept an Observable as a Web IDL argument, or return one from a callback whose return type is Observable can do so with any of the above objects as well, that get automatically converted to an Observable. We can accomplish this in one of two ways that we'll finalize in the Observable specification:

  1. By making the Observable type a special Web IDL type that performs this ECMAScript Object ➡️ Web IDL conversion automatically, like Web IDL does for other types.
  2. Require methods and callbacks that work with Observables to specify the type any, and have the corresponding spec prose immediately invoke a conversion algorithm that the Observable specification will supply. This is similar to what the Streams Standard does with async iterables today.

The conversation in #60 leans towards option (1).

Lazy, synchronous delivery

Crucially, Observables are "lazy" in that they do not start emitting data until they are subscribed to, nor do they queue any data before subscription. They can also start emitting data synchronously during subscription, unlike Promises which always queue microtasks when invoking .then() handlers. Consider this example:

el.on('click').subscribe({next: () => console.log('One')});
el.on('click').find(() => {}).then(() => console.log('Three'));
el.click();
console.log('Two');
// Logs "One" "Two" "Three"

Firehose of synchronous data

By using AbortController, you can unsubscribe from an Observable even as it synchronously emits data during subscription:

// An observable that synchronously emits unlimited data during subscription.
let observable = new Observable((subscriber) => {
	let i = 0;
	while (true) {
		subscriber.next(i++);
	}
});

let controller = new AbortController();
observable.subscribe({
	next: (data) => {
		if (data > 100) controller.abort();
	}}, {signal: controller.signal},
});

Teardown

It is critical for an Observable subscriber to be able to register an arbitrary teardown callback to clean up any resources relevant to the subscription. The teardown can be registered from within the subscription callback passed into the Observable constructor. When run (upon subscribing), the subscription callback can register a teardown function via subscriber.addTeardown().

If the subscriber has already been aborted (i.e., subscriber.signal.aborted is true), then the given teardown callback is invoked immediately from within addTeardown(). Otherwise, it is invoked synchronously:

  • From complete(), after the subscriber's complete handler (if any) is invoked
  • From error(), after the subscriber's error handler (if any) is invoked
  • The signal passed to the subscription is aborted by the user.

Operators

We propose the following operators in addition to the Observable interface:

  • takeUntil(Observable)
    • Returns an observable that mirrors the one that this method is called on, until the input observable emits its first value
  • finally()
    • Like Promise.finally(), it takes a callback which gets fired after the observable completes in any way (complete()/error()).
    • Returns an Observable that mirrors the source observable exactly. The callback passed to finally is fired when a subscription to the resulting observable is terminated for any reason. Either immediately after the source completes or errors, or when the consumer unsubscribes by aborting the subscription.

Versions of the above are often present in userland implementations of observables as they are useful for observable-specific reasons, but in addition to these we offer a set of common operators that follow existing platform precedent and can greatly increase utility and adoption. These exist on other iterables, and are derived from TC39's iterator helpers proposal which adds the following methods to Iterator.prototype:

  • map()
  • filter()
  • take()
  • drop()
  • flatMap()
  • reduce()
  • toArray()
  • forEach()
  • some()
  • every()
  • find()

And the following method statically on the Iterator constructor:

  • from()

We expect userland libraries to provide more niche operators that integrate with the Observable API central to this proposal, potentially shipping natively if they get enough momentum to graduate to the platform. But for this initial proposal, we'd like to restrict the set of operators to those that follow the precedent stated above, similar to how web platform APIs that are declared Setlike and Maplike have native properties inspired by TC39's Map and Set objects. Therefore we'd consider most discussion of expanding this set as out-of-scope for the initial proposal, suitable for discussion in an appendix. Any long tail of operators could conceivably follow along if there is support for the native Observable API presented in this explainer.

Note that the operators every(), find(), some(), and reduce() return Promises whose scheduling differs from that of Observables, which sometimes means event handlers that call e.preventDefault() will run too late. See the Concerns section which goes into more detail.

Background & landscape

To illustrate how Observables fit into the current landscape of other reactive primitives, see the below table which is an attempt at combining two other tables that classify reactive primitives by their interaction with producers & consumers:

Singular Plural
Spatial Temporal Spatial Temporal
Push Value Promise Observable
Pull Function Async iterator Iterable Async iterator

History

Observables were first proposed to the platform in TC39 in May of 2015. The proposal failed to gain traction, in part due to some opposition that the API was suitable to be a language-level primitive. In an attempt to renew the proposal at a higher level of abstraction, a WHATWG DOM issue was filed in December of 2017. Despite ample developer demand, lots of discussion, and no strong objectors, the DOM Observables proposal sat mostly still for several years (with some flux in the API design) due to a lack of implementer prioritization.

Later in 2019, an attempt at reviving the proposal was made back at the original TC39 repository, which involved some API simplifications and added support for the synchronous "firehose" problem.

This repository is an attempt to again breathe life into the Observable proposal with the hope of shipping a version of it to the Web Platform.

Userland libraries

In prior discussion, Ben Lesh has listed several custom userland implementations of observable primitives, of which RxJS is the most popular with "47,000,000+ downloads per week."

  • RxJS: Started as a reference implementation of the TC39 proposal, is nearly identical to this proposal's observable.
  • Relay: A mostly identical contract with the addition of start and unsubscribe events for observation and acquiring the Subscription prior to the return.
  • tRPC: A nearly identical implemention of observable to this proposal.
  • XState: uses an observable interface in several places in their library, in particular for their Actor type, to allow subscriptions to changes in state, as shown in their useActor hook. Using an identical observable is also a documented part of access state machine changes when using XState with SolidJS.
  • SolidJS: An identical interface to this proposal is exposed for users to use.
  • Apollo GraphQL: Actually re-exporting from zen-observable as their own thing, giving some freedom to reimplement on their own or pivot to something like RxJS observable at some point.
  • zen-observable: A reference implementation of the TC39 observable proposal. Nearly identical to this proposal.
  • React Router: Uses a { subscribe(callback: (value: T) => void): () => void } pattern in their Router and DeferredData code. This was pointed out by maintainers as being inspired by Observable.
  • Preact Uses a { subscribe(callback: (value: T) => void): () => void } interface for their signals.
  • TanStack: Uses a subscribable interface that matches { subscribe(callback: (value: T) => void): () => void } in several places
  • Redux: Implements an observable that is nearly identical to this proposal's observable as a means of subscribing to changes to a store.
  • Svelte: Supports subscribing to observables that fit this exact contract, and also exports and uses a subscribable contract for stores like { subscribe(callback: (value: T) => void): () => void }.
  • Dexie.js: Has an observable implementation that is used for creating live queries to IndexedDB.
  • MobX: Uses similar interface to Observable internally for observation: { observe_(callback: (value: T)): () => void }.

UI Frameworks Supporting Observables

  • Svelte: Directly supports implicit subscription and unsubscription to observables simply by binding to them in templates.
  • Angular: Directly supports implicit subscription and unsubscription to observables using their | async "async pipe" functionality in templates.
  • Vue: maintains a dedicated library specifically for using Vue with RxJS observables.
  • Cycle.js: A UI framework built entirely around observables

Given the extensive prior art in this area, there exists a public "Observable Contract".

Additionally many JavaScript APIs been trying to adhere to the contract defined by the TC39 proposal from 2015. To that end, there is a library, symbol-observable, that ponyfills (polyfills) Symbol.observable to help with interoperability between observable types that adheres to exactly the interface defined here. symbol-observable has 479 dependent packages on npm, and is downloaded more than 13,000,000 times per week. This means that there are a minimum of 479 packages on npm that are using the observable contract in some way.

This is similar to how Promises/A+ specification that was developed before Promises were adopted into ES2015 as a first-class language primitive.

Concerns

One of the main concerns expressed in the original WHATWG DOM thread has to do with Promise-ifying APIs on Observable, such as the proposed first(). The potential footgun here with microtask scheduling and event integration. Specifically, the following innocent-looking code would not always work:

element
	.on('click')
	.first()
	.then((e) => {
		e.preventDefault();
		// Do something custom...
	});

If Observable#first() returns a Promise that resolves when the first event is fired on an EventTarget, then the user-supplied Promise .then() handler will run:

  • ✅ Synchronously after event firing, for events triggered by the user
  • ❌ Asynchronously after event firing, for all events triggered by script (i.e., element.click())
    • This means e.preventDefault() will have happened too late and effectively been ignored
To understand why this is the case, you must understand how and when the microtask queue is flushed (and thus how microtasks, including Promise resolution handlers, are invoked).

In WebIDL after a callback is invoked, the HTML algorithm clean up after running script is called, and this algorithm calls perform a microtask checkpoint if and only if the JavaScript stack is empty.

Concretely, that means for element.click() in the above example, the following steps occur:

  1. To run element.click(), a JavaScript execution context is first pushed onto the stack
  2. To run the internal click event listener callback (the one created natively by the Observable#from() implementation), another JavaScript execution context is pushed onto the stack, as WebIDL prepares to run the internal callback
  3. The internal callback runs, which immediately resolves the Promise returned by Observable#first(); now the microtask queue contains the Promise's user-supplied then() handler which will cancel the event once it runs
  4. The top-most execution context is removed from the stack, and the microtask queue cannot be flushed, because there is still JavaScript on the stack.
  5. After the internal click event callback is executed, the rest of the event path continues since event was not canceled during or immediately after the callback. The event does whatever it would normally do (submit the form, alert() the user, etc.)
  6. Finally, the JavaScript containing element.click() is finished, and the final execution context is popped from the stack and the microtask queue is flushed. The user-supplied .then() handler is run, which attempts to cancel the event too late

Two things mitigate this concern. First, there is a very simple workaround to always avoid the case where your e.preventDefault() might run too late:

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

...or if Observable had a .do() method (see whatwg/dom#544 (comment)):

element
	.on('click')
	.do((e) => e.preventDefault())
	.first();

...or by modifying the semantics of first() to take a callback that produces a value that the returned Promise resolves to:

el.on('submit')
	.first((e) => e.preventDefault())
	.then(doMoreStuff);

Second, this "quirk" already exists in today's thriving Observable ecosystem, and there are no serious concerns or reports from that community that developers are consistently running into this. This gives some confidence that baking this behavior into the web platform will not be dangerous.

Standards venue

There's been much discussion about which standards venue should ultimately host an Observables proposal. The venue is not inconsequential, as it effectively decides whether Observables becomes a language-level primitive like Promises, that ship in all JavaScript browser engines, or a web platform primitive with likely (but technically optional) consideration in other environments like Node.js (see AbortController for example).

Observables purposefully integrate frictionlessly with the main event-emitting interface (EventTarget) and cancellation primitive (AbortController) that live in the Web platform. As proposed here, observables join this existing strongly-connected component from the DOM Standard: Observables depend on AbortController/AbortSignal, which depend on EventTarget, and EventTarget depends on both Observables and AbortController/AbortSignal. Because we feel that Observables fits in best where its supporting primitives live, the WHATWG standards venue is probably the best place to advance this proposal. Additionally, non-Web ECMAScript embedders like Node.js and Deno would still be able to adopt Observables, and are even likely to, given their commitment to Web platform aborting and events.

This does not preclude future standardization of event-emitting and cancellation primitives in TC39 in the future, something Observables could theoretically be layered on top of later. But for now, we are motivated to make progress in WHATWG.

In attempt to avoid relitigating this discussion, we'd urge the reader to see the following discussion comments:

User needs

Observables are designed to make event handling more ergonomic and composable. As such, their impact on end users is indirect, largely coming in the form of users having to download less JavaScript to implement patterns that developers currently use third-party libraries for. As stated above in the explainer, there is a thriving userland Observables ecosystem which results in loads of excessive bytes being downloaded every day.

In an attempt to codify the strong userland precedent of the Observable API, this proposal would save dozens of custom implementations from being downloaded every day.

Additionally, as an API like EventTarget, AbortController, and one related to Promises, it enables developers to build less-complicated event handling flows by constructing them declaratively, which may enable them to build more sound user experiences on the Web.

Authors:

observable's People

Contributors

adamochayon avatar appsforartists avatar bakkot avatar benlesh avatar cwilso avatar dfahlander avatar domfarolino avatar keithamus avatar kimstacy avatar totati avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

observable's Issues

Feedback from me with a Node perspective

Hey, Ben sent me this proposal, we're likely happy to adopt it in Node though I'll raise it internally with the project for feedback. I'm in the TSC now so I need to be more careful with accidentally sounding like I speak for the whole project which I don't.

In terms of API I think it's important to consider compatibility with https://github.com/tc39/proposal-iterator-helpers and https://github.com/tc39/proposal-explicit-resource-management .

Regarding the "history" section, I don't think TC39 was ever opposed to the proposal they just had concerns though WHATWG may be a better standards body for it given the deep web platform integration (e.g AbortSignal support would be hard through TC39).

Regarding "prior art" - I think stuff like MobX/Vue.reactive etc are the same thing but it's a hard case to make @headinthebox makes it (here).

Regarding concerns with promises - I'm wondering if being able to preventDefault/stopPropagation is actually important for these promise returning events. I've seen this concern raised many times when discussing streams in Node and in practice we shipped the iterator-helpers proposal and streams are otherwise mostly synchronous and no one noticed or complained in practice so from my PoV it's not a problem in practice (and neither is firehose to be fair).

Use a restricted version of `AddEventListenerOptions`?

The current explainer takes an AddEventListenerOptions dictionary on EventTarget#on, which gives us:

  • capture
  • passive
  • once
  • signal

I think capture and passive definitely make sense for Observables, but once and signal feel weird. The once parameter feels redundant with take(1) (or maybe first() which is currently commented-out in our IDL sketch, but maybe it'll make it into the final one...), and signal definitely seems redundant with with the signal that subscribe would get in subscribe({signal}), and it isn't really clear how the "outer" signal (passed in via AddEventListenerOptions) would even work... I don't think it could be used to remove the event listener, because that would be what the subscriber's signal would be used for. Maybe it'd be used to somehow "cancel" many subscribers to a single observable all at once? Not sure how that'd work. My proposal is that we get rid of these two members from our proposal by using a restricted dictionary that doesn't include them.

`takeUntil` vs `extendableEvent.waitUntil`

takeUntil taking an Observable is kind of handy, and matches RxJS, but it's inconsistent with the web platform's existing waitUntil method, which takes a Promise.

I would hope they could be made consistent, either by having takeUntil only accept a Promise (and using the first method to get a Promise for the next value), or by having both methods accept either a Promise or an Observable. (RxJS's takeUntil can actually take either, since it will implicitly convert a Promise into an Observable when passed as an argument here.)

Personally I like the first of these options better - I'm not a big fan of overloads - but it would be an inconsistency with RxJS.

`on()` collides with all sorts of code in the wild.

Lots of libraries and runtimes use on() as a generic event handling mechanism. This name might cause problems for them updating and as such cause unnecessary friction. It would be good to see if there is another name that is less prevalent in the same design space that wouldn't collide with Cloudflare Workers, Bun, Node.js, etc. and the libraries written for them (Note: all of these have some form of EventTarget, but often mix with EventEmitter's on in the wild or for backwards compatibility).

Include `finally` in the methods.

finally is important, as it behaves like promise's finally, in that it will fire a passed handler if the observable errors or completes... however it will also fire if the user unsubscribes from the stream. It's basically just a way to ensure some related teardown happens. Finally is implemented like so:

finally(finalizer: () => void): {
  return new Observable((subscriber) => {
    const subscription = source.subscribe(subscriber)
    return () => {
      subscription.unsubscribe();
      finalizer();
    }
  })
}

Observables can be made async iterable by using a buffer

RxJS observables are async iterable (implemented using an unbounded buffer).

There's some good discussion of considerations and tradeoffs linked from that thread. Something to consider here.

// click-and-drag handler
element.addEventListener('mousedown', async () => {
  for await (let { clientX, clientY } of element.on('mousemove').takeUntil(element.on('mouseup')) {
    console.log('moved mouse to', clientX, clientY);
    if (clientX > threshold) {
      break; // triggers `return` handler from the async iterator, which automatically unsubscribes
    }
  }
  console.log('mouse up or exceeded threshold');
});

is pretty cute.

Web Socket, SSE, or streaming fetch example?

Another place this becomes useful is with streaming data over a connection like a web socket:

const socket = new WebSocket('wss://example.com/whatever')

const socketData = socket.on('message')
  .map(e => JSON.parse(e.data))

/**
 * Multiplex different chat window streams over the same connection
 */
async function openChatWindow(id: number) {
  await socket.on('open').first()

  socket.send(JSON.stringify({ openChat: id }))

  return socketData.filter(data => data.windowId === id)
}

Return types of some operators seem off

  • toArray() is specced to return an Observable, but per its name, I think it should return an Array (like iterator helpers) or a Promise<sequence<any>> (like async iterator helpers).

  • forEach() is specced to return an Observable, but I would expect it to either return undefined (like Array.prototype / iterator helpers) or Promise<undefined> (like async iterator helpers)

Be careful not to over-claim in "Userland libraries"

I believe several of the "observable" implementations listed, don't conform to the observable contract referenced. It would be good to separate out the ones that do, from the ones that are vaguely in a similar conceptual area. The former give support directly to the proposed design in the explainer; the latter just tell us that this is a problem area worth solving, but perhaps with a completely different API.

Should the platform encourage stream-style handling of events?

I'm all for making event handling more ergonomic--it makes complete sense to me to have an object representing events from a target, that can be subscribed to multiple times, and disposed.

At the same time, I'm not sure if treating these events as a stream, with the iterator helper methods present to manipulate them, is the best model to encourage. I like the parallelism with existing APIs, but this proposal might be a bit too much. What if this API omitted the iterator helper APIs, and instead focused on subscribe, takeUntil, finally, and AbortSignal integration, to avoid this potential confusion?

At Bloomberg, when we've tried to use this programming style through Observable libraries, we've had trouble understanding and maintaining the resulting code (and this was not in the context of Angular). The concern is less about whether it can be used well in some contexts, and more about whether it is something to encourage broadly.

Iterator helper-like methods on Observables encourage processing events in a pipeline of calculations, as opposed to writing the function for each event out more explicitly, as addEventListener currently does. This pipeline is sometimes used to implement aspects of reactive UI. If there are patterns popular with RxJS today which you are not encouraging, it could be useful to explain this and guide people towards patterns which you do want to encourage.

Most people who I've discussed this proposal with had similar feelings, amounting to something like [not quoting anyone in particular; it describes my feelings too so I may be biased]

Meh, we don't really like this programming model, but there isn't anything too horrible about this existing. Mostly, we've found usages of combinator libraries like this to be over-engineered and difficult to maintain, so we'll try to not use this too much.

When a new primitive is added to the web platform, it nudges everyone towards adopting that vocabulary and paradigm - and evaluating the higher-order consequences that arise is not intuitive. What might make us more confident is more experience using this new, proposed API. This could be done with some more experience using a JS implementation/polyfill, or even just some larger worked examples.

(Related issue: #41)

Bikeshed: next/complete/error vs. other names

I suspect this has been discussed in the literature before, so I apologize for re-raising it. But it would be worthwhile to document it somewhere in the explainer.

Basically, are next/complete/error the best names? Ones that immediately come to mind from a JS language design perspective are next/return/throw. There's also an argument to be made for yield/return/throw.

One related idea to keep in mind is how promises chose resolve|fullfill / reject, instead of return / throw. There are a few reasons for this:

  • The subtle differences between resolve and fulfill meant neither was a great map onto "return".
  • Back in the days of ES3 when Promises/A+ was first being created, you couldn't use keywords like throw/return as property names, so instead of Promise["throw"]() we did Promise.reject().
    • Even today, it'd be annoying to have to do new Promise((rreturn, tthrow) => { ... }) to avoid the still-existing prohibition on using keywords as variable names.
  • Keeping the namespaces distinct helps give us a short vocabulary to talk about "async throwing" and "async returning", with the single words "rejecting" and "resolving".

I'm not sure any of those arguments are super-compelling, but maybe they set a historical precedent.

"Observable is a glorified function" is confusing framing

Maybe there is some abstract sense in which this is true, but it makes no sense to me. The natural followup questions with this framing are:

  • Can it be constructed, or just called?

  • Is it an async function, a generator function, a plain function...?

  • Why do we need these when we already have functions?

And then

Additionally, it has extra "safety" by telling the user exactly when:

just makes it seem like some of the most important features of observables, don't even fit into this model?


I would suggest describing how observables work, independent of any analogy with functions. IIUC the main points to communicate are:

  • The creator of the observable can signal zero or more values, followed by either one "complete" signal or one "error" signal. The error signal comes with an error value, which is traditionally a subclass of Error.

  • The creator's code to generate this sequence-of-values is called lazily, meaning, it happens once subscribe() is called.

  • The creator's code to generate this sequence of values is called potentially multiple times, whenever subscribe() is called.

I would then very quickly move into making this concrete via events, possibly with code examples. I.e., you can call subscribe() as many times as you want, and every time, it calls "the creator's code" which subscribes to the EventTarget. (Using the same mechanism, under the hood, as addEventListener() does.) This makes it more clear why the above properties are desirable properties, for practical purposes. (Although you need to do a bit more work to explain the complete and error signals.)

Is `Observable` the right name?

Now this is a kind of crazy question maybe but thought I'd ask it, I understand Observable is a very common name in the community right now - but is it the right one?

Iterable, Thenable, Iterator, generator - these are all protocols or interfaces. It could be argued, or mistaken by a newcomer, that Observable fits into this category - a protocol or interface that allows something to be "observed".

Change Promise returning methods to return Observable (except forEach)

Promises bearing events have problems

Because promises force scheduling, any API that returns a promise runs the risk of confusing users because:

  1. It causes subscription, where other methods do not.
  2. The promise fires asynchronously, meaning it's too late to e.preventDefault().

Basically, we should have everything accept forEach return an Observable.

Semantics of forEach

forEach would still synchronously execute its callback, but the completion being a promise would mean completion is scheduled. This is okay, because completion doesn't bear a value.

const et = new EventTarget();
  
const testOver = et.on('test').take(1).forEach(e => {
  // This is fine.
  e.preventDefault();
  console.log('handled!');
})
.then(() => {
  console.log('forEach resolved as done');
});
  
console.log('before dispatch');
et.dispatchEvent(new Event('test'));
console.log('after dispatch');

// Logs
// "before dispatch"
// "handled!"
// "after dispatch"
// "forEach resolved as done"

Is it worth showing possible APIs that could be built with observable?

There are some APIs that we don't have that Observable might be really good for... for example, dealing with "upload then download" sort of XHR and fetch semantics is frankly ugly most of the time. Where a single stream of something shaped like { status, progress, data } or the like makes for a pretty interesting/ergonomic way to build UIs on top of such things. WebSockets are another one... they start connecting, then open, or close (clean or unclean), or error, they get messages, etc. All of that could be sent through one stream of events, and it makes it so the developer doesn't have to know the dance between different events.

Alternative cancellation?

One thing that is true about Observable is it's possible for an observable to cancel an observable.

It's worth proposing that at no extra cost, you can get any event fired to cancel if you have observable in your eco system.

Consider the following:

const closeSocket = document.querySelector('#close-socket');
const socket = new WebSocket(url);

const ac = new AbortController();
closeSocket.on('click').take(1).forEach(() => {
  ac.abort();
})

socket.on('open')
 .flatMap(() => socket.on('message'))
 .map(e => JSON.parse(e.data))
 .subscribe({
   next: console.log,
   signal: ac.signal,
 });

This could just as easily be:

const closeSocket = document.querySelector('#close-socket');
const socket = new WebSocket(url);

socket.on('open')
 .flatMap(() => socket.on('message'))
 .map(e => JSON.parse(e.data))
 .takeUntil(closeSocket.on('click'))
 .subscribe({
   next: console.log,
 })

Or even this:

const closeSocket = document.querySelector('#close-socket');
const socket = new WebSocket(url);

socket.on('open')
 .flatMap(() => socket.on('message'))
 .map(e => JSON.parse(e.data))
 .subscribe({
   next: console.log,
   signal: closeSocket.on('click')
 })

It's also arguable that AbortSignal itself could be "subscribable" given that it only has one event.

Debugging Observables

When we’ve tried to use Observables libraries in Bloomberg, we’ve had some trouble reasoning about this code and debugging it. Not being able to step through the code, knowing which of multiple streams are stopped/started, or thinking about streams inside of streams, blows the majority of people's mental capacity budget, especially when maintaining code written by someone else.

Strong DevTools support could really help with a lot of these issues. I know it’s not something that goes in the actual specification, but having a plan here would be really helpful in understanding the realities of using this feature. Chrome has blocked shipping other features on adequate DevTools support (e.g., async/await). Do you have any particular plans related to Observables?

TC39 is also open to this proposal as part of JS

No action required, I just want to note that this proposal meets the two requests that were made in TC39 that I was aware of:

  • @domenic asked that observables tie into a unified concept of cancellation. This proposal meets that with AbortSignal integration.
  • I asked that this proposal be based on explicit cooperation with DOM/HTML folks, that it not be done by TC39 in a vacuum. This is resolved here, by the explicit integration proposed in this repository. (I was also concerned about the intelligibility of the programming model, which we can discuss in another issue.)

Historically, the proposal was held back in TC39 because no one presented a version which solved both issues--no one put the proposal on the TC39 agenda since 2016. As with cancellation, TC39 did not reject this proposal or deem it out of scope. Anyway, at this point, I'm happy with this proposal proceeding in whichever venue is most convenient for proponents, now that we have WinterCG to bridge the gap between other runtime environments, and an improved culture on all sides of taking more stakeholders' concerns into account.

Do we really need observables in the platform?

Hi there! The title is a little rude, but the question is important. Of course Observable is a common techic, but there are few problems for a proposal. When we work in the context of a worldwide platform without possibilities for breaking changes, we should choose new primitives very carefully. I think "Observable" is not worth it.

The profits of observables:

  • The code looks "nice" in some cases
  • Modules coupling are better, but the proposed limited set of features could be not enough for that.

The difficulties of observables:

  • Totally new semantics, which are hard for newbies. All this "pull vs push" talk. We already have callbacks, promises, async/await, events, streams, and generators. Isn't that enough?
  • There will always be a shortage of operators. Defenetly.
  • We don't know how it should work. The most popular RxJS library publishes a new major version almost every year. So, why do we think the current proposed API will be enough for us in the future? This question is more important for the web platform.

The last topic is the most confusing for me personally. I have been researching and developing reactive primitives for more than 5 years already, and what I'm sure of is that we still don't know enough about it.

A lot of problems developers encounter are due to the glitch problem. There are many ways to handle it, but there is no universal solution. Here is a brief overview from "Angular Reactivity with Signals" topic:

reactive algorithms

Even Angular recently moved a part of "reactive work" from observables to the new (or old?) signals. Is this the beginning or the end of the journey?

A good reactive platform should cover a lot of cases: glitches, priority scheduling, contextual execution, aborting, and error handling. And there are no standards in these questions.

So, okay, we will add the current proposal to the platform. Will it still be relevant after 5 or 10 years?

IMHO we are still not ready for this.

TC39 iterator helpers?

With the TC39 (Currently Stage 3) proposal for iterator helpers: https://github.com/tc39/proposal-iterator-helpers, this addition becomes much, much more useful. Take a drag and drop, for example:

element.on('mousedown').flatMap(e => {
  const { left, top } = element.getBoundingClientRect()
  const { clientX: startX, clientY: startY } = e;
  return document.on('mousemove')
    .takeUntil(document.on('mouseup'))
    .map(e => [
      left + e.clientX - startX,
      top + e.clientY - startY
    ])
})
.subscribe(([updateX, updateY]) => {
  element.style.transform = `translate3d(${updateX)px, ${updateY}px, 0)`;
})

Example APIs that could leverage Observables

@domenic suggested it might be good to track platform APIs for which observables would work well.

A few that come to the top of mind for me:

  1. interval(number): Observable<number> - A setInterval, and you'd get either a counter or a high-precision timestamp in it (similar to what requestAnimationFrame does).
  2. animationFrames(): Observable<number> - basically an animation loop
  3. timer(number): Observable<void> - just setTimeout. Useful for takeUntil, creating timeouts, and many other things. Arguably interval(number).take(1) is the same though. RxJS's implementation also allows a Date to be passed to fire the timer at a specific date/time (although it has some limitations and I don't think it's used very often).

The constructor doesn't have a way to register teardown.

This is pretty critical to the design of Observable. There needs to be some sort of way to register setup and teardown during subscription. In RxJS there's a few paths, but the most well-known/understood one is this:

new Observable(subscriber => {
  // Setup here
  let n = 0
  const id = setInterval(() => subscriber.next(n++), 1000)
 
  return () => {
    // Teardown here
    clearInterval(id)
  }
})

However, if we're leaning on DOM APIs like AbortSignal we can use that here as well. It's just admittedly more ugly:

new Observable(subscriber => {
  // Setup here
  let n = 0
  const id = setInterval(() => subscriber.next(n++), 1000)
 
  subscriber.signal.addEventListener('abort', () => {
    // Teardown here
    clearInterval(id)
  })
})

Alternatively, it could come in as the second argument, as it's a very commonly accessed piece of functionality:

new Observable((subscriber, signal) => {
  // Setup here
  let n = 0
  const id = setInterval(() => subscriber.next(n++), 1000)
 
  signal.addEventListener('abort', () => {
    // Teardown here
    clearInterval(id)
  })
})

Finally, the Subscriber itself could implement event listener, and that might help clean things up:

new Observable((subscriber) => {
  // Setup here
  let n = 0
  const id = setInterval(() => subscriber.next(n++), 1000)
 
  subscriber.addEventListener('teardown', () => {
    // Teardown here
    clearInterval(id)
  })
})

I prefer something like the fourth option, because one of the guarantees observable is supposed to provide is that when you error() or complete() or unsubscribe(), the teardown is called. "abort" is maybe a misnomer.

Methods that accept Observable could accept anything Observable.from does

Given there's an issue to possibly add Observable.from to this API #28, and I think that's a good add, it's worth mentioning that in RxJS anywhere we accept an Observable we also accept anything that Observable.from would accept.

This is stemming from my comment about takeUntil here.

Basically anywhere we accept an Observable or a function returns an Observable that the functionality is processing (takeUntil or flatMap for example), it's totally fine to also allow anything that Observable.from could accept and just do that conversion for the user. It cleans up a lot of code to allow this.

Is there any concern about the difference in constructor semantics from `Promise`?

To preface, I understand that RxJS and other Observable implementations are widely used and very popular, and that a lot of people have been very successfully using Observables for very long time in many applications.

But there's a difference between using a library and using a web platform primitive. And I do worry a little that people will expect the Observable constructor to behave similarly to the Promise constructor, in particular being executed synchronously and only once for every .then() callback. Observables don't really work that way, as the callback parameter is called when subscribe is called, an unlimited number of times. This is the much discussed cold vs hot distinction.

So for example, maybe it's really easy to write:

const observable = new Observable((subscriber) => {
  subsriber.next(someReallyExpensiveComputation());
});

And people comfortable with promises will think that's only going to be executed once and then each subscriber gets the expensive value, when in reality the expensive computation happens once for every subscriber.

This is less of a concern for Observables that the platform creates for something like event listeners, where the browser is in charge of the observable instantiation and the rest of the API is just really offering a better listener pattern.

Include `switchMap` operator?

The explainer includes flatMap, but not switchMap. I think switchMap should be included because it is essential in a common pattern:

  • given a source (e.g. the value of an input)
  • execute an asynchronous action when the source emits
  • and if the previous execution has not completed yet, cancel it.

A simple example is a search bar that triggers a network request, but should cancel the previous one when the user types too quickly.

I don't think I have ever used the rxjs flatMap operator, but I'm using switchMap for the pattern describe above all the time.


From @benlesh: I created a duplicate issue (#90) for this accidentally, so I'll include the use cases and text I posted there here:

switchMap was mentioned at TPAC as being an API of interest. It's a method that is especially useful for composing events in the browser because of its inherent cancellation. If API bloat is a concern, I would gladly remove toArray(), some(), find() et al, as those are minimally useful with observables.

Use cases are things like:

Use Case: Lookahead Search

In the below scenario, the result of the fetch would simply be ignored.

It's plausible that we could provide a signal along with the switchMap
callback, as well. In fact, I'd almost encourage that, as it might make fetch
more useful. But I think we'd want to discuss the ergonomics there, and that could
be debated and added later. (For example, we might want to have it automatically
swallow DOMExceptions named AbortError?)

const input = document.getElementById('search');
const resultList = document.getElementById('result-list');

input.on('input')
  .switchMap(async (e) => {
    const response = await fetch(`/search?q={e.target.value}`);

    if (!response.ok) {
      console.warn(`Search response error: ${response.status} ${response.statusText}`
      return;
    }
  
    return response.json();
  })
  .subscribe((results) => {
    resultList.innerHTML = results
      .map(result => `<li><a href="${result.href}">${result.text}</a></li>`)
      .join('')
  })

Use Case: Changing Connections

Below, every time a URL is changed in an URL input, if it's a valid URL,
it will disconnect from the previous web socket and connect to a new one.

function streamData(url) {
  return new Observable(subscriber => {
    const ws = new WebSocket(url);
    ws.onmessage = e => subscriber.next(e);
    subscriber.addTeardown(() => ws.close());
  })
}

const urlInput = document.getElementById('url-input');

urlInput.on('input')
  .filter(e => e.target.checkValidity())
  .switchMap(e => streamData(e.target.value))
  .subscribe(console.log);

Better examples

Right now the examples in the event target section are pretty bare; they're mostly copied from other threads, and I think it would be good to write the imperative counterparts to each for illustration purposes, or just include more and better examples in general. Regardless, they should be elaborated on more, since they were scrapped together quickly.

How important is the "firehose" problem?

A 2019 attempt at reviving the old TC39 proposal involved an API simplification that incidentally allowed Observables to handle the "synchronous firehose of data" problem. That is, it allows a subscriber to unsubscribe (via AbortController, in that proposal) while an Observable is synchronously pushing perhaps a "firehose" of data to the next() handler before subscribe() returns (see this example).

My understanding is that neither RxJS nor zen-observable currently support this, but if we're generalizing the API for the platform maybe we should consider it? Designing Observables such that they handle this seems nice, as they can unsubscribe via token based cancelation in the next() handler and even before subscribing is complete if the producer pushes "enough" (or "too much") data.

On the other hand, it could get a little clunky. With this approach, subscribe() returns nothing and the assumption is that your only interaction with a "subscription" is via the AbortController that you must create before subscribing. That's not terrible, but it could get worse if we ever wanted to give more functionality to a "subscription" beyond just cancellation. In that world we might need to introduce a Subscription object that subscribe() returns, but then you'd have two objects to keep track of for any given subscription: the AbortController that you created before calling subscribe() and then the Subscription returned from subscribe().

That, plus the fact that two major Observable libraries today don't support this (IIUC) might mean there is not enough appetite to cater our design to it. Thoughts?

Way for single event subscription?

I think this proposal is mostly for repeated events, but is there corresponding way of once: true in addEventListener?

Something like:

await element.on("click").takeImmediately()

Promise APIs should accept configuration options with `signal`

Right now all of the methods that return promises don't accept a means of cancellation.

They should really allow people to abort those promises, which I suppose should reject the returned promise in the same manner that fetch does. (With an AbortError or a DOMException?)

Examples suggest the order of arguments of `reduce` is backwards

Example 4 has:

const maxY = await element.on('mousemove')
                          .takeUntil(element.on('mouseup'))
                          .map(e => e.clientY)
                          .reduce((y, soFar) => Math.max(y, soFar), 0);

The second argument of reduce is consumed as soFar, and in context "so far" represents the maximum y value the pointer has visited so far during the operation.

But that's the opposite order from the standard Array.reduce function, where the first argument is the accumulator and the second is the value currently being visited.

(I do realize that in this particular example (and indeed the others provided) the order of the two arguments doesn't actually matter, but that's beside the point.)

Other examples have the same issue.

The reduce method is later characterized:

Promise<any> reduce(Function accumulator, optional any);

I don't know exactly what this representation format is, but it suggests to me that the first argument is indeed intended to be the accumulator.

`subscribe(fn)` and/or `subscribe(observer)`

Most of the observable use in the wild boils down to someObservable.subscribe(function), but someObservable.subscribe(observer) is necessary for a variety of reasons. While most of the time people don't care about error handlers and complete handlers, they're still important. Having one object that can carry those things is beneficial for things like shared observation.

If we wanted to support either a function (the most common path) or an observer, we could use this pattern:

source.subscribe(console.log, { signal });
source.subscribe({
  next: console.log,
  error: console.error,
  complete: () => console.log('done'),
}, { signal });

The above pattern aligns very well with all known Observable implementations in the wild. As the majority of them take only one argument, which is usually a function.

Prior art for this is on EventTarget itself, where both of these work:

button.addEventListener('click', (e) => console.log('clicked'))
button.addEventListener('click', {
  handleEvent() {
    console.log('clicked');
  }
})

However, I realize there's an argument to be made that forEach accomplishes the goal for subscription with just a function. Just as a principal, I feel like mixing next, error, complete and signal might be mixing concerns in a way that isn't beneficial. Also subscribe(fn) is still the most common path for observable subscription in user-land, as it's more ergonomic and it doesn't allocate a promise that no one will use (ala forEach).

Related to shared observation, having the signal argument on the Observer is a bit problematic. Here's an example why:

class LoggingObserver {
  #totalMessages = 0;

  log(type, value) {
    this.#totalMessages++;
    const messageNumber = this.#totalMessages;
    console.log(`(${messageNumber}) ${type}: ${value}`)
  }

  next(value) {
    this.log('next', value);
  }
  
  error(error) {
    this.log('error', error);
  }
  
  complete() {
    this.log('complete');
  }
}


const observer = new LoggingObserver();

// This will NOT work because the handlers aren't bound properly
const ac1 = new AbortController();
source1.subscribe({ ...observer, signal: ac1.signal });
const ac2 = new AbortController();
source2.subscribe({ ...observer, signal: ac2.signal });


// A design like this would work fine.
const ac1 = new AbortController();
source1.subscribe(observer, { signal: ac1.signal });
const ac2 = new AbortController();
source2.subscribe(observer, { signal: ac2.signal });

Lean harder on the promises analogy

The most compelling story I find, for web platform folks, is "observables are to events as promises are to callbacks". Everyone loves how by making an async result into a first-class object, promises unlocked a lot. In short:

  • They can be passed between different parts of the program, which don't need to care about how they were originally generated in order to then() the promise / subscribe() to the observable

  • They can be fed to combinators, like Promise.all() / observable.map()

And, even better than the callbacks -> promises transition, because of how EventTarget is already everywhere and we've found the perfect way to slot into EventTarget, this won't be a case of having to migrate the whole web platform. We'll just get this nice upgrade to first-class objects, for free, whenever the web developer wants to switch their code!

I think this selling point should be relatively high in the readme.

The TAG is going to ask you about "user needs"

See, e.g., this sort of exchange: w3ctag/design-reviews#760 (comment)

I don't know what the correct answer is here, personally. Maybe just mention that like things such as promises or EventTarget, this enables developers to build websites (particularly ones involving repeated events, which are a common pattern as you can see from ... all web specs ever). And users like websites.

Explainer structure needs to be more compelling

Suggested ordering:

  • Introduction that goes straight to the code, i.e. what is currently the "EventTarget integration" section.

  • The observable API

    • EventTarget integration: this section can be short because most of it's covered in the intro

    • The Observable object: explain in more detail observable as a primitive, how you create your own

    • Operators and combinators

  • Background

    • Where do observables fit in the landscape of async/etc. primitives? (What is currently "Introduction"

    • History

    • Userland libraries

  • Concerns

  • Standards venue

  • Authors

future methods on iterator helpers

@michaelficarra and I have been thinking about what other iterator helper methods we'd like to pursue, and have the following extremely tentative list. This doesn't represent the opinion of TC39 as a whole - it's just what we are personally interested in. This is not necessarily actionable for you; I just wanted to give a heads up.

  • zip / zipWith / (maybe) zipLongest
  • chunks / windows (chunks would group outputs into a chunk every n items, windows would give you a sliding window of the last n items)
  • concat
  • join (like on Array.prototype)
  • tap
  • some sort of cleanup (analogous to this proposal's finally; ideally these would be designed in concert)
  • of (like on Array)
  • takeWhile / dropWhile
  • scan
  • into (i.e. function(arg) { return arg(this); } - handy for chaining / custom operators; RxJS spells this pipe)

`drop` should probably be `skip`.

I've never seen skip called drop before. I'm not completely against it, but I do find it odd. Is there a precedent that's being followed there?

Pick an initial set of combinators and defend them

The explainer claims

In any case it is important to realize that operators are not the meat of this proposal, as thy could conceivably follow along at any time provided there is support for the actual native Observable API, which is what this proposal principally focuses on.

I disagree. Without a good set of combinators, observables wouldn't be worth shipping. If you're pulling in a combinator library anyway, then you might as well pull in an observable library.

I think this explainer should pick an initial set of combinators and defend them. I think the list drawn from the iterator helpers proposal is good. You should then:

  • Make sure all your examples only use combinators in that list
  • Include them in the IDL block / observable API section
  • Be clear which are promise-returning (and thus suffer from "Concerns") vs. not
  • Figure out if there are any that are not from iterator helpers, but are a really good idea for observable-specific reasons. E.g., first().
  • Relegate discussion about expanding the set to an appendix. Perhaps, stress that you'd prefer to follow the lead of the rest of the platform, e.g. TC39's designs for iterators and arrays, similar to how web platform maplikes and setlikes follow TC39's designs for Maps and Sets.

Investigate the performance of going through native <-> JS boundary in the API

Filing this just as a reminder of discussions which happened during TPAC.

Event handling is a very hot code path.
When using the proposed API, there might be quite a few calls through native <-> JS and that might slow down processing, since it would be harder for JITs to optimize the relevant code and crossing the language boundary adds always overhead.

I think `catch` may be a requirement

We currently aren't providing a way to gracefully handle an error. We do have error handling in the subscription/observer itself, but no way to have the subscription "recover" from a fatal error.

Since subscriptions have ended the moment an error is emitted from an observable, the catch method behaves a lot like catch on promise, meaning that it returns an observable that will switch to whatever observable you map to in its handler in the event of an error.

For example:

// Given this observable that will always error:
const source = button1.on('click').map((e, i) => {
  if (i === 2) {
    throw new Error('boom!');
  }
  return e;
});


// If we catch the error and return a different observable,
// the new observable takes over the subscription.
source.catch((err) => {
  console.error(err);
  // let another button take over
  return button2.on('click');
})
.subscribe({
  next: console.log,
  error: () => { /* never called */ }
});

// If we catch the error and return the same observable, you can
// resume or "retry" that observable
source.catch((err) => {
  console.log(err);
  return source;
})
.subscribe({
  next: console.log,
  error: () => { /* never called */ }
});

// If we catch the error and throw it again, or throw any other error
// The resulting observable still errors
source.catch((err) => {
  throw new Error('Different error!');
})
.subscribe({
  next: console.log,
  error: console.error
});

Why not more closely follow RxJS?

I am confused as this proposal looks like what RxJS was doing before it refactored to use pipe and OperatorFunctions to process data.

Don’t you think learning from the progress done on RxJS would be best? What was the reason why we changed to pipe?

Also, interoperability with RxJS would be crucial for any proposal as I think that community would be your best supporters.

On using `AbortSignal` instead of `Subscription`

I'm TOTALLY okay with it, and it solves a lot of problems one might have with the "synchronous firehose" issue that comes with dealing with potentially synchronous observables.

However, if we're doing that, you'd want to have the signal passed through the subscriber in the constructor in some way, so you can send it along to any inner subscriptions.

It's also worth noting that registering teardown logic with AbortSignal is cumbersome and very slow. It might be nice to have a better way to do that.

Finally, if we're going that route, the "teardown return" in the constructor is no longer necessary.

Examples:

// An observable to stream from a socket
const streamFromSocket = new Observable(subscriber => {
  const socket = new WebSocket('wss://example.com');
  socket.on('open').subscribe({
    next: () => socket.send('start'),
    signal: subscriber.signal,
  });
  socket.on('message').subscribe(subscriber)
})

const ac = new AbortController();

streamFromSocket.subscribe({
  next: console.log,
  signal: ac.signal,
})


// An observable to stream from someone's custom type
const customData = new Observable(subscriber => {
  const customDataSource = new CustomDataSource();
  customDataSource.onData = e => subscriber.next(e);

  // This is REALLY unergonomic and doesn't account for
  // subscribers that are already "closed".
  subscriber.signal.addEventListener('abort', () => {
    customDataSource.destroy()
  })
})

We may wish to add a custom method to Subscriber that does that abort registration for users:

// An observable to stream from someone's custom type
const customData = new Observable(subscriber => {
  const customDataSource = new CustomDataSource();
  customDataSource.onData = e => subscriber.next(e);

  subscriber.addTeardown(() => {
    customDataSource.destroy()
  })
})

Why do some operators return Promises?

I think people may look at that long operator list and wonder whether this is the MVP list of operators or not.

So some justification for why this list of operators is the right one would be good.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.