throttle-bracket v0.5.0
throttle-bracket is a simple transaction/effect batching and throttling
system. It can create a "transactional" context (bracket) within which multiple
effectful calls are batched together and flushed at the end (throttle).
This package essentially isolates the concept of transactional batching, an inherent feature in popular UI frameworks with state management capabilities such as React and MobX. This package provides the minimal batching/flushing mechanism that can be used in other contexts, and in framework agnostic ways.
Concept
Suppose we have the following entities:
- Action
Acalls handlerX - Action
Bcalls handlerX - Action
Ccalls handlerX
Suppose further that handler X has the following characteristics:
- It is expensive to perform (like rendering, publishing, or other IO)
- If called in succession, the effect of the latter always and immediately neutralizes the utility of the effect of the former (again, like rendering and publishing most up to date information).
In this situation, calling actions A, B and C in succession can
fairly quick blow-up in efficiency.
To overcome this problem, we throttle handler X to fire only once in a
any given "transaction" Then, we define a "transaction bracket" which
includes calling A, B and C. The following happens:
- Enter transaction
- Run action
- Call
A.Acalls throttled handlerX- Throttled handler
Xgets registered for flushing
- Throttled handler
- Call
B.Bcalls throttled handlerX- Throttled handler
Xgets registered for flushing
- Throttled handler
- Call
C.Ccalls throttled handlerX- Throttled handler
Xgets registered for flushing
- Throttled handler
- Call
- Flush each handler only once.
- Call
Xonly once
- Call
- Run action
Example
import { bracket, throttle } from "throttle-bracket";
const X = throttle(() => console.log("I'm expensive."));
const A = () => { console.log("A called"); X(); };
const B = () => { console.log("B called"); X(); };
const C = () => { console.log("C called"); X(); };
bracket(() => { A(); B(); C(); })();
// logs synchronously:
// "A called"
// "B called"
// "C called"
// "I'm expensive."Brackets should be put as close to the input side of IO as possible, while expensive operations ultimately reacting to these inputs should be throttled.
In React, all your on* callbacks on components are essentially "bracketed"
deep in the framework, so it is trasparent, and multiple render requests are
batched and flushed (throttled) automatically. (Such is the wonder of frameworks!)
This package provides you the ability to the same thing elsewhere.
Bracket-free asynchronous transactions
If you don't mind that throttled callbacks are flushed asynchronously (with
Promise.resolve()), you can use throttleAsync. instead of throttle.
The advantage is that it requries no bracket, because the synchronous window
implicitly makes up the transaction bracket. This is still sufficient for
effects requiring repaints in the browser, because an animation frame comes
after all promise resolutions.
Note that a throttleAsync-wrapped function will ignore bracket and will
still be flushed asynchronously even if called in a bracket context.
import { throttleAsync } from "throttle-bracket";
const X = throttleAsync(() => console.log("I'm expensive."));
const A = () => { console.log("A called"); X(); };
const B = () => { console.log("B called"); X(); };
const C = () => { console.log("C called"); X(); };
// No bracketing needed
A(); B(); C();
// logs:
// "A called"
// "B called"
// "C called"
// Then asynchronously (await Promise.resolve()), you will get this:
// "I'm expensive."Nested brackets
You can nest brackets. It is not consequential, which means you don't need to worry about whether terrible things would happen.
const fn1 = bracket(() => { A(); B(); C(); });
const fn2 = bracket(fn1);
fn2(); // all good!Effect recursion
It can be that a throttled effect then invokes another throttled effect while itself being flushed. In this case, the flushing phase is itself a transactional bracket. So all such calls will be flushed synchronously and immediately in the next cycle, again and again.
Effect recursion is one way that the same throttled function may be called multiple times during a flush. The package will check if too many iterations have been reached, which would likely indicate unintended infinite synchronous recursion.
Causal isolation
It can be that the throttled effects are isolated into multiple "tiers" of causality. For instance, in the context of a UI, a set of effects updating some intermediate computations should be isolated from a set of effects that perform presentation. Without such isolation, the causally posterior set of effects would be mixed with the prior set, resulting in the posterior set being potentially called multiple times over multiple rounds of flushing.
To illustrate this, let's suppose that all f* functions below are intermediate
computations and should be fully flushed before all g* functions, that are
presentation functions.
import { throttle } from 'throttle-bracket'
const f1 = throttle(() => { g1(); f3(); f2(); });
const f2 = throttle(() => { f3(); g1(); g2(); });
const f3 = throttle(() => { g1(); g2(); });
const g1 = throttle(() => { /*... */ })
const g2 = throttle(() => { /*... */ })First, let's imagine two alternative scenarios. The first scenario is without
throttle at all. If we call f1(), we would have calls in the following order:
- (
f1) g1f3g1g2
f2f3g1g2
g1g2
This results in multiple calls to g1 and g2, which is highly undesirable.
The second scenario is to use throttle everywhere:
- (
f1) queuesg1,f3,f2- flush (
g1,f3,f2)g1f3queuesg1,g2f2queuesf3,g1,g2
- flush (
g1,g2,f3)g1g2f3queuesg1,g2
- flush (
g1,g2)g1g2
- flush (
Because of the lack of isolation, the g* functions are being queued multiple
times during recursive flushing. throttle alone therefore cannot achieve
what we want, i.e. all f*s call before all g*s.
Isolation can be achieved like this:
// create a throttler at a "later" isolation level.
const throttleG = throttle.later();
const g1 = throttleG(() => { /*... */ })
const g2 = throttleG(() => { /*... */ })throttle.later creates a new throttler function that uses a "higher"
flush isolation level. All throttled functions of the "lower" isolation level
will first be flushed to stability, then the higher isolation level will flush.
throttle.later itself can be used to create successively higher isolation
levels (throttle.later().later(), and so on).
Now, with throttle.later(), we have two isolation levels.
- (
f1) queuesf3,f2into level 1, andg1into level 2 ("later")- flush level 1 (
f3,f2)f3queuesg1,g2into level 2f2queuesf3into level 1, andg1,g2into level 2
- flush level 1 (
f3)f3queuesg1,g2into level 2
- level one is now stable (nothing more to flush), so we move on
- flush level 2 (
g1,g2)g1g2
- flush level 1 (
Here, invoking f1() will guarantee that f2 and f3 will be fired before
g1 and g2. Notably, g1 and g2 are now fired once only.
I don't like singletons
Since batching requires the use of some register of callbacks to be saved and then flushed, you may wonder where it that register lies, and (Oh Horror!), if the register is a singleton somewhere.
Using throttle/bracket/asyncThrottle does makes use of the built-in
transaction system singletons provided by the package. It usually should be
good enough for most uses.
If, for whatever reason, you need multiple independent transaction systems (I'm not sure why you would), or you just feel irked by presumtuous frameworks providing singletons, you can instantiate transaction systems yourself:
import { sync, async } from 'throttle-bracket';
const [throttle, bracket] = sync();
const throttleAsync = async()