-
Notifications
You must be signed in to change notification settings - Fork 16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Ref Counted Observable discussion #178
Comments
This type exists in RxJS-land as the result of
|
Thanks for filing this before I could get to it! I do think this type tends to be a nice middle ground that provides general parity with userland Observables in terms of scheduling & coldness, as well as a fix to the userland issues around unpredictability and extra side-effects incurred with multiple subscriptions to the same Observable. I want to clarify something that was discussed offline regarding Observable teardown & re-subscription. We discussed what should happen with an Observable that has gone from >=1 subscriber, to 0 subscribers, and back to >=1 subscriber. One thing that was mentioned was that this final
In what way is it configurable? I read this as: you can configure the Observable to sometimes make teardown happen immediately / synchronously, and other times delayed by, say, a microtask. That feels a little unpredictable though. How does Rx, or other userland libraries handle this in the ref-counted producer type? I feel like if users sometimes need synchronous teardown and sometimes not, this could be provided by the userland code itself. That said, I'm not entirely opposed to just baking in strict microtask-delayed unsubscription timing. Footnotes
|
IMO this is the most reasonable semantic.
Since event listeners are the initial primary use for observables from within the platform using *(I am less familiar with current uses of RxJS etc though) |
I'm almost completely sold on the ref-counted observable after some deep thought and experimentation. But it does limit the type quite a bit in a few ways. A very, very common thing through my experience, and what research I've done, is that people want the ability to "replay" the most recent value to new subscribers. This one:
A use case we have at work is we have a shared worker that is getting a stream of messages from a service, and when new web views start up, they have to subscribe to the stream of messages, but they don't want to wait for the next message, because it might be several minutes before one arrives. So they've created a (non-RxJS) "observable" that simply caches the previous value and sends it to new subscribers. That's not possible with the proposed ref-counted observable. A more web-platform based use case would be observable-based APIs around things similar to // Hypothetically... an API like this:
const elementOnScreen = IntersectionObserver.observe(element, { root: null }).map((entry) => entry.isIntersecting);
elementOnScreen.subscribe(handler, { signal });
// and later, somewhere else...
elementOnScreen.subscribe(handler, { signal });
// Which to get anything even close to parity we'd have to do something like this:
const callbacks = new Set();
let isIntersecting = false;
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.target === element && entry.isIntersecting !== isIntersecting) {
isIntersecting = entry.isIntersecting;
for (const callback of callbacks) {
// Average developers screw this up.
// If the callback throws, we don't want to
// break the loop and cause producer interference.
// Observable handles this.
try {
callback(isIntersecting);
} catch (error) {
reportError(error);
}
}
break;
}
}
}, { root: null })
observer.observe(element);
function isElementOnScreen(callback, { signal }) {
callbacks.add(callback);
callback(isIntersecting);
signal.addEventListener('abort', () => {
callbacks.delete(callback);
if (callbacks.size === 0) {
// ref count zero
observer.unobserve(element);
}
}, { once: true });
} The problem is we need a way to configure |
For RxJS it's just an option like
If it's ref-counted, it's already unpredictable. Any given consumer can't "know" they're the last one to end their subscription. For this API, I think it should be configurable in the |
In performance APIs, you play all the buffered values and not only the last one. And then this requires some way to manage/empty the buffer etc... Also buffering only the last event would have implications as you're secretly retaining that value from garbage collection. I would expect to do this kind of things as some sort of a composition rather than a default behavior, like element.when("click").buffer(1).subscribe(...) |
@noamr |
I don't understand why it "can't" but I don't have the bandwidth to dig deeper unfortunately so will take your word for it. Seems to me that observables try to create a unified API to things that are in fact subtly different (event targets, promises, different kinds of observers like interaction/resize/performance/mutation). Perhaps it would be good to have some examples of how observable integration would look like in those cases, with emphasis on things like buffering and subscribe/unsubscribe semantics. (*Perhaps these examples exist already, I'm not familiar enough with all past conversations) |
The result of |
I'm not sure I follow. Does that not work? |
@domfarolino How could this be implemented on top of a ref-counted observable? Here it is with a cold observable: ColdObservable.prototype.buffer = function (bufferSize) {
let buffer = [];
const subscribers = new Set();
let abortController = null;
return new ColdObservable((subscriber) => {
subscribers.add(subscriber);
subscriber.addTeardown(() => {
subscribers.delete(subscriber);
if (subscribers.size === 0) {
// last unsubscription, disconnect from source
abortController.abort();
abortController = null;
}
});
// Notify the new subscriber with whatever is in the buffer.
for (const value of buffer) {
subscriber.next(value);
}
if (subscribers.size === 1) {
// First subscription, connect to the source.
abortController = new AbortController();
this.subscribe({
next: (value) => {
buffer.push(value);
if (buffer.length > bufferSize) buffer.shift();
for (const subscriber of subscribers) {
subscriber.next(value);
}
},
error: (error) => {
buffer = []
for (const subscriber of subscribers) {
subscriber.error(error);
}
subscribers.clear();
},
complete: () => {
buffer = [];
for (const subscriber of subscribers) {
subscriber.complete();
}
subscribers.clear();
}
}, { signal: abortController.signal })
}
});
} The problem with an always-ref-counted observable is that the |
The design I had in mind would be that you'd have a |
EditTo clarify, the producer would only see a single subscriber, but the implementation would keep track of a number of observers, and whenever any new observers joined, the Observable would be responsible for pushing the single value it holds to the new observer. (I misused the word |
I guess that would be a bit strange because it would only buffer if you have subscribers, and the first subscriber wouldn't get any buffered events. |
LOL... I'm so sorry, I'm not following what the idea is. Generally speaking, a ref-counted observable is implemented pretty much the same way a cold-observable is... with the difference being that it has an internal subscriber that forwards everything to a list of external subscribers. So... if your observable looks like this: const secondClock = new Observable((internalSubscriber) => {
const id = setInterval(() => internalSubscriber.next(Date.now()), 1000);
internalSubscriber.addTeardown(() => clearInterval(id));
}) and you subscribe like this: secondClock.subscribe(console.log);
secondClock.subscribe(console.log); You're going to add two Subscribers, one for each Further, when consumer subscribers unsubscribe (abort) they are removed from the list, if the list gets to length/size 0, then the |
Okay, so I put together a ref-counted Observable example in a StackBlitz It should show the behavior. It will hopefully also demonstrate how there's not really a way to send a previously buffered value to a new consumer with the default interface. |
I also like the "ref count" approach to the Observable API, because it's always difficult to explain to new developers when, how and why they must use "share" etc. on an observable. Even after years of using rxjs with Angular I sometimes make this error by myself. Most of the time I get not hit by this error, because most of the time there's only one subscriber - and then the problem doesn't occur. But sometimes you add another subscriber in the future and then things get suddenly weird. That's why I have some state manament thing to wrap sideeffects like calling an http resource etc., because I have to do it nevertheless to handle errors. And I understand the problem with not being able to "buffer". So here are some thoughts from me: Just some random thoughts on this - I may be completely of the track... 😎 |
Thanks a lot @flensrocker! This is great feedback to be getting. From a purist perspective, I like the idea of separating state management (like a buffer of past events) out from this proposal, so I'm definitely wondering if we can get away with not having a |
Really the only issue I have at all with the proposal as it stands, the ONLY issue, is that there's no way to create an observable that "replays" previous values to new subscribers. MAYBE people could subclass it like this? class BufferedObservable extends Observable {
constructor(subscriberCall, options) {
super(subscriberCall);
this.#bufferSize = Math.max(0, options?.bufferSize ?? Infinity)
this.#innerSource = this.do((value) => {
this.#buffer.push(value);
if (this.#buffer.length > this.#bufferSize) this.#buffer.shift();
});
}
subscribe(subscriber, options) {
const next = typeof subscriber === 'function' ? subscriber : subscriber?.next ? subscriber.next.bind(subscribeer) : null;
if (typeof next === 'function') {
for (const value of this.#buffer) {
next(value);
}
}
return this.#innerSource.subscribe(subscriber, options);
}
// do this for EVERY method?
map(...args) {
return this.#innerSource.map(...args);
}
} Overall it sort of sucks, for what is a fairly common use case. |
Or we could just add a buffer option to the Observable constructor? |
... or a subclass, kind of like how streams have subclasses for different buffering options |
After TPAC last week, it was determined that the ideal type of observable might be a ref-counted observable. The ref-counted observable will function roughly as follows:
The idea behind this type is to address concerns from one issue (#170), where an RxJS user thought it was confusing that sometimes observables have side effects, and sometimes they didn't. This would guarantee that for N subscribers, there would be at most one side effect.
The text was updated successfully, but these errors were encountered: