Open domfarolino opened 1 week ago
Teardowns should be executed during finalization of observation. They are roughly what you'd put in a "finally" block if we had syntax for observable in JavaScript. Since the result of the finally()
method is a new observable that is "downstream" from the source, I would expect the source's upstream finalization to be complete before the downstream could trigger it's finally.
In fact, once an upstream source is complete or errored, or when it's notified of abort, the downstream consumer should not be able to act until finalization is complete.
Abort notifications should be sent upstream immediately.
During abort, I'd expect the following order of operations within a given subscription:
Similarly in a situation where the subscription was errored or completed by the producer, I would expect:
So as prior art here, we can look at generators or async generators:
With generators set up as so:
function* a() {
try { yield 'a'; } finally { console.log('finally a'); }
}
function* b() {
try { yield* a(); } finally { console.log('finally b'); }
}
function* c() {
try { yield* b(); } finally { console.log('finally c'); }
}
If you create an instance of the generator and prime it:
const g = c();
g.next(); // primed { value: 'c', done: false }
And then you either next (resulting in a completion notification of { done: true }
), or you return()
which is signaling that you're done with the generator (similar to an abort
in observable), you'll see the order in which the finally blocks are logged.
c.next(); // { done: true }
// logs
// "finally a"
// "finally b"
// "finally c"
or
c.return();
// logs
// "finally a"
// "finally b"
// "finally c"
Other prior art: Promise.prototype.finally
Promise.resolve(1)
.finally(() => console.log('one'))
.finally(() => console.log('two'))
// Logs
// "one"
// "two"
Yeah... the observable implementation needs to abort the upstream subscription before it fires the finalizer.
Similarly, it should abort upstream subscriptions as soon as it knows it can in the case of "complete" or "error" being called in the subscriber.
TL;DR: On unsubscription (i.e.,
abort()
), what should the timing of thefinally()
operator's callback be with respect to the source Observable's teardowns?When writing the spec for, and implementing, the currently-unspecified
finally()
operator, I was reviewing the proposed web platform tests and had a comment about this bit: https://github.com/web-platform-tests/wpt/pull/44482/files#diff-b1a6cf94fa2c551227215a7556fbc3114006fca82549ba48a27f3dafe2564f01R186. This made me think more about the timing of thefinally()
operator's callback with respect to the source Observable's teardowns.Background: unsubscription teardown semantics
When you subscribe (with an
AbortSignal
) to an Observable returned by an operator, two things happen:subscribe()
.So in the following code:
There are three
AbortSignals
involved:ac.signal
.take()
s Subscriber'sSubscriber#signal
. (It is purely internal and not web accessible).subscriber
above.If you call
ac.abort()
, then the abort signals are aborted in the order they're listed above. That's because DOM defines that a parent signal gets fully aborted before any of its dependents: https://dom.spec.whatwg.org/#abortsignal-signal-abort. Practically speaking, this means if you added anabort
event listener toac.signal
andsubscriber.signal
, theac.signal
one would fire first, and thesubscriber.signal
one would fire last.Integrating
finally()
Now consider the requirement that the
finally()
callback needs to be called when a consumer unsubscribes from an Observable:The simplest way to make this happen is just to add
finally()
's callback to its Subscriber's teardown list. This essentially meansfinally()
is syntactic sugar around that operator's Subscriber'saddTeardown()
method. But this would break the ordering suggested in the test: https://github.com/web-platform-tests/wpt/pull/44482/files#diff-b1a6cf94fa2c551227215a7556fbc3114006fca82549ba48a27f3dafe2564f01R186. Specifically, in the following example:If the finally callback were part of its intermediate Subscriber's teardown list, then it would necessarily run before any of the source Observable's teardowns, since the intermediate signal (associated with
finally()
) aborts before its dependent signal (the one associated with the source Observable's Subscriber). Is this OK? It doesn't match RxJS's behavior, wherefinalize()
callbacks execute after the teardown callback runs.If we reallllly wanted to, we could probably match RxJS's behavior by building some bespoke infrastructure on
Subscriber
, or rejiggering around the dependencies of AbortSignals. But I'm really not sure what that would look like, or if it is even desirable at all. It would break the general precedent with AbortSignal.I lean towards the code example / output immediately above, but I'd like to get opinions from others, to know if this is a real problem.