Closed domfarolino closed 2 months ago
From looking into this more I've determined that my thinking was wrong! I was protesting against the the inspect abort()
handler (a) being run for explicit user-initiated unsubscriptions, but (b) not running for internally abort subscriptions, like when take(1)
gets exhausted and internally aborts its subscription to the source Observable.
But it looks like the original proposal for this API did not entail this, which is good! So this can be closed; I'll finish spec'ing this operator soon.
The WPT PR https://github.com/web-platform-tests/wpt/pull/44480 proposed some original tests for the
do()
operator — nowinspect()
. It proposed anabort()
callback (in the dictionary forinspect()
) that would be called only on explicit unsubscription, i.e., when theAbortController
associated with a subscription is aborted. It is not called whenerror()
orcomplete()
is called: https://github.com/web-platform-tests/wpt/pull/44480/files#diff-f21c17324eba5c76ded6707d6a82fa569293f1fc099af6b2a25efadd34049643R390.Apparently this matches RxJS's
tap()
operator behavior, but I don't think it makes as much sense with a token-based cancellation mechanism. Right now it isn't possible for aSubscriber
to distinguish between (a) unsubscription via producer-initiatedcomplete()
/error()
, and (b) unsubscription via upstream abortion of a passed-in AbortSignal.Subscriber#signal
gets aborted in both cases, and all teardowns run in both cases[^1].So it seems weird to give
inspect()
a new ability that other Observables generally don't have — a hook that runs only on so-called consumer-initiated unsubscription, and not during the general teardown phase. Not only would this introduce an inconsistency, but it's also weird layering-wise. When you subscribe to an Observable we immediately create aSubscriber
object whosesignal
member is a composite signal based on two input AbortSignals:signal
member ofSubscribeOptions
(i.e., what the "user" uses to "unsubscribe")complete()
is called.AbortSignals are great because they compose really easily like this, allowing us to encapsulate all of this behavior inside of a single composite AbortSignal (Subscriber#signal) that gets exposed to the source Observable. We don't have to keep references to any of the far-away input signals, or have specific logic tied to any one of them.
But the way
inspect()
is proposed (via tests, matching how it works in RxJS) seems to require that we break this encapsulation. We'd have to tie the logic that calls theabort()
callback, specifically to the (1) signal above so we catch consumer-initiated unsubscriptions, and not any aborts caused by (2) above etc. For example:Once
source
emits two values,take(2)
callscomplete()
on its Subscriber. This aborts thetake()
Subscriber's internal signal, which was one of the input signals tosource
's subscription. Now imagine there was aninspect()
beforetake(2)
; should itsabort()
handler get triggered in this case?Right now, because of how AbortSignals compose, the internal-
take()
-initiated unsubscription fromsource
is indistinguishable from true upstream unsubscription viasignal
. I personally don't think we should change this.Imagine a more complicated example, where
inspect()
is further away from the subscriptionsignal
:This triggers a number of internal subscriptions, and by the time we subscribe to the
inspect()
Observable, theSubscriber#signal
down there is far-removed (but composed from) the ancestor one passed intosubscribe()
. If we wantedinspect()
'sabort()
callback to be based on that far ancestor signal (and notSubscriber#signal
), theninspect()
would need to internally have access to that higher-level signal. In that case it feels like we're fighting against how AbortSignal is designed, since it would require keeping an explicit reference to that input signal around for a bit, and tying downstream behavior to it.Proposal
My proposal is to rename
abort()
toteardown()
, and have it get fired whenever theSubscriber#signal
for that Observable gets aborted, just like everything else. It would essentially be a teardown forinspect()
which I think makes a lot of sense. It would run whenevercomplete()
anderror()
run, but that's how other Observables work in general, so I don't think that's an issue or otherwise inconsistent. In fact I think it makes things more consistent.[^1]: To distinguish between these, you would have to just keep track of whether you explicitly called
complete()
orerror()
yourself or not, from within your teardown callbacks.