Example APIs that could leverage Observables
@domenic suggested it might be good to track platform APIs for which observables would work well.
A few that come to the top of mind for me:
-
interval(number): Observable<number>- A setInterval, and you'd get either a counter or a high-precision timestamp in it (similar to whatrequestAnimationFramedoes). -
animationFrames(): Observable<number>- basically an animation loop -
timer(number): Observable<void>- justsetTimeout. Useful fortakeUntil, creating timeouts, and many other things. Arguablyinterval(number).take(1)is the same though. RxJS's implementation also allows aDateto be passed to fire the timer at a specific date/time (although it has some limitations and I don't think it's used very often).
Rather timer(number): Promise<void> - it's not a case for Observable.
The native observers are probably good candidates for this too: MutationObserver, IntersectionObserver, ResizeObserver. All of these could vend Observables and the many operators on the Observable API would probably be super useful there as well. In fact this is something @smaug---- briefly brought up at TPAC 2023 as good possible integration points for this API in the future.
MutationObserver, IntersectionObserver, ResizeObserver, PerformanceObserver, PressureObserver, ReportingObserver, are all good candidates but need some thought about the API. Prompted by https://x.com/domfarolino/status/1815474679513276641 I'll list my thoughts here instead:
All constructors take a callback which recieves the respective records, but in the case of an Observable they'd be the next() value, which causes a conflict. This means we either:
- Put the
[bikeshedMeReturnAnObservable]()method on the prototype, and either:- never call the constructor callback.
- make it optional, maybe error if a callback is supplied and
[bikeshedMeReturnAnObservable]()is called. - call the callback each time alongside
next(), which is maybe the weirdest.
- Make
[bikeshedMeReturnAnObservable]()a static method on each of the observer objects. - Attaching methods on
Node.prototype, e.g.Node#observeResizes()/observeMutations()/observeIntersections(). This makes sense as the *Observer objects themsevles are inert until they get "attached" to a node.
The callbacks always return an Array of entries, batched to intervals. Maybe it makes sense for observables to only recieve one at a time?
Lastly, I think a big concern with these observers is that takeRecords() is quite an important aspect of some of these APIs to ensure records are collected, for example when trying to tear down one of the observers without missing records. I'm not sure the best way to represent that. Subclassing Observable/Subscriber seems excessive for this?
This means we either:
I like the latter two options you list here (static method, possibly named observe(), or observeResizes() etc.). The first one is too awkward in my opinion. An XObserver with no callback is basically storing no state so it doesn't really make sense to use instance methods.
Subclassing Observable/Subscriber seems excessive for this?
I think it might work well. Unlike promises, where subclassing is a huge mess because we made their internals so exposed, subclassing for Observable might be more realistic.
Someone would need to work out the details though. Probably worth a separate issue.
The DOM Observer style APIs were specifically designed to allow the browser to efficiently queue and filter records. I don't think Observable makes sense there because it it encourages a single observer for every element (or observation) and doing userland filtering instead of browser internal filtering.
Also the intent was for setTimeout, setInterval and requestAnimationFrame APIs to all be replaced by https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/postTask which can handle all those situations and supports abort signal and dynamic priorities.
I don't think another set of APIs should be added that duplicates the scheduler API, though the scheduler API could be evolved (ex. to have a version that doesn't use a callback).
Sort of a shame, postTask isn't very ergonomic if you need to cancel the task though, because it's promise-based. So you're forced to handle a rejection or get noisy error logs.
In any case, it probably couldn't replace setInterval with that API. setInterval will schedule tasks to run at specific moments, trying to limit "drift". You get "drift" when you try to use something like setTimeout (or postTask) to recursively schedule an interval.
For example, this code will slowly drift off of emitting once a second because of how long the work takes between scheduling... Even if you move the run() before the callback(), it will drift just from the overhead of scheduling each task alone. Which is one of the reasons why setInterval exists.
function recursiveInterval(callback, ms) {
let id = 0;
const run = () => {
id = setTimeout(() => {
callback();
run();
}, ms);
}
return () => clearTimeout(id);
}
recursiveInterval(() => {
// do some work.
for (let i = 0; i < 1e7; i++) {}
console.log(new Date().toString())
}, 1000)
For requestAnimationFrame, I'd say Scheduler.animationFrames would make an excellent observable API for scheduling an animation loop.
const startTime = performance.now();
Scheduler.animationFrames
.map((now) => now - startTime)
.subscribe((elapsedTime) => {
// move something based on elapsed time.
});
For setInterval, maybe Scheduler.interval(ms: number) would be a good observable API.
const thirtyMinutes = 30 * 60 * 1000;
const pollingInterval = Scheduler.interval(thirtyMinutes);
pollingInterval.flatMap(async function* () {
const response = await fetch('/get/data');
if (response.ok) {
yield response.json()
} else {
console.error('Error polling for data');
}
})
.subscribe(results => {
updateSomeList(results);
});
In your example you're not immediately scheduling the next iteration. setTimeout is async, call it immediately upon entering run instead. That's all the browser does inside setInterval too, it immediately schedules the next task (with offset adjustment) before running the callback.
function recursiveInterval(callback, ms) {
let id = 0;
const run = () => {
id = setTimeout(() => {
run();
callback();
}, ms);
}
return () => clearTimeout(id);
}
That'll reduce the drift considerably. Note that browsers already do firing alignment on timers to conserve power, and timers will also skew because of event loop business, but it is true that browsers try to align the to the requested repeat interval:
https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/timer.cc;l=155;drc=48ee6c4ee320c1bcc4f7d01d5c293e6d41ecf648
you could do that yourself and get identical behavior to the browser, but I wouldn't expect folks to figure that out. You gave that feedback over here:
https://github.com/WICG/scheduling-apis/issues/8#issuecomment-614948585
Hopefully Scott will add repeat to the scheduler.