Skip to content

Observables and AsyncContext #141

@nicolo-ribaudo

Description

@nicolo-ribaudo

https://github.com/WICG/observable

I've had a good discussion with @domfarolino at TPAC about how the two proposals should interact.

This is what a basic observable looks like:

const obs = new Observable(s => {
  later(() => s.next("foo"));
});

obs.subscribe({
  next(v) { console.log(v) }
});
obs.subscribe({
  next(v) { alert(v) }
});
  1. When you create a new Observable nothing happens. The "constructor callback" is only called when somebody subscribes.
  2. The first obs.subscribe call executes the "constructor callback"
  3. The second obs.subscribe call does nothing other than registering itself as a subscriber of the observable.
  4. The s.next("foo") call will iterate through all the subscribers, and call their next(v) {} methods

The proposal also has utilities to manipulate observables, which are implemented on top of the above and thus would automatically inherit AsyncContext semantics from the above base case. For example, Observable.prototype.map is effectively

function map(fn) {
  return new Observable((s) => {
    this.subscribe({ next: val => s.next(fn(val)) })
  });

}

There are two questions here for AsyncContext, each with two possible answers:

  1. In what context does the constructor callback run?
    1. the one from the new Observable call
    2. the one from the first .subscribe() call (which is what would happen by default if Observables are unaware of AsyncContext)
  2. In what context do the next() callbacks run?
    1. The one from the corresponding .subscribe() call
    2. the one from the s.next("foo") call (which is what would happen by default if Observables are unaware of AsyncContext)

It is not unlikely that whatever that later(() => …) logic is it will propagate from the constructor callback to s.next("foo"). This means that 1.b + 2.b is not an option, because we wouldn't want the context from a .subscribe() call to be passed to the other subscriber.

We ended up discarding option 1.b completely because the constructor callback running is not because of any specific .subscribe() call. Even though technically it is the first one that causes that code to run, removing any of the .subscribe() call would just move the responsibility to a different one.

2.a and 2.b both seemed similarly ok. We recommend 2.b because:

  • it's conceptually simpler, since it's what happens by default if Observables do not manipulate AsyncContext
  • when using helpers like .map, it matches the JS iterator helpers behavior: for iterators the context propagates from the .next() that pulls values into the callbacks that manipulate them, while for observables it propagates from the .next() that pushes values into the callbacks that manipulate them
  • when tracing, it makes it possible trace how an individual value propagates through a chain of observables

To recap:

  • the new Observable constructor captures a snapshot and uses it to run the constructor callback
  • the next() callbacks of subscribers run in the context that comes from the s.next() call. Note that this is a synchronous process, so it just happens by default

We also noticed that for all currently proposed built-in internal observables (such as the one used by EventTarget.prototype.when, or by Obserable.prototype.map), the constructor callback does not directly call into user code or into async APIs that would capture the AsyncContext. This means that the context it runs it is not actually observable, so in those cases the constructor does not need to capture a snapshot.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions