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) }
});
- When you create a
new Observable nothing happens. The "constructor callback" is only called when somebody subscribes.
- The first
obs.subscribe call executes the "constructor callback"
- The second
obs.subscribe call does nothing other than registering itself as a subscriber of the observable.
- 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:
- In what context does the constructor callback run?
- the one from the
new Observable call
- the one from the first
.subscribe() call (which is what would happen by default if Observables are unaware of AsyncContext)
- In what context do the
next() callbacks run?
- The one from the corresponding
.subscribe() call
- 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.
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:
new Observablenothing happens. The "constructor callback" is only called when somebody subscribes.obs.subscribecall executes the "constructor callback"obs.subscribecall does nothing other than registering itself as a subscriber of the observable.s.next("foo")call will iterate through all the subscribers, and call theirnext(v) {}methodsThe 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.mapis effectivelyThere are two questions here for AsyncContext, each with two possible answers:
new Observablecall.subscribe()call (which is what would happen by default if Observables are unaware of AsyncContext)next()callbacks run?.subscribe()calls.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 tos.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:
.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 themTo recap:
new Observableconstructor captures a snapshot and uses it to run the constructor callbacknext()callbacks of subscribers run in the context that comes from thes.next()call. Note that this is a synchronous process, so it just happens by defaultWe also noticed that for all currently proposed built-in internal observables (such as the one used by
EventTarget.prototype.when, or byObserable.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.