Skip to content

Commit 87a7401

Browse files
authored
Merge pull request #742 from japgolly/topic/rateLimit
Add rate limiting to Callback & AsyncCallback
2 parents 9f39d36 + b044448 commit 87a7401

File tree

15 files changed

+340
-155
lines changed

15 files changed

+340
-155
lines changed

core/src/main/scala/japgolly/scalajs/react/AsyncCallback.scala

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package japgolly.scalajs.react
22

3-
import japgolly.scalajs.react.internal.{SyncPromise, catchAll, identityFn, newJsPromise}
3+
import japgolly.scalajs.react.internal.{RateLimit, SyncPromise, catchAll, identityFn, newJsPromise}
44
import java.time.Duration
55
import scala.collection.compat._
66
import scala.concurrent.duration.FiniteDuration
@@ -399,6 +399,63 @@ final class AsyncCallback[A] private[AsyncCallback] (val completeWith: (Try[A] =
399399
def unless_(cond: => Boolean): AsyncCallback[Unit] =
400400
when_(!cond)
401401

402+
/** Limits the number of invocations in a given amount of time.
403+
*
404+
* @return Some if invocation was allowed, None if rejected/rate-limited
405+
*/
406+
def rateLimit(window: Duration): AsyncCallback[Option[A]] =
407+
rateLimitMs(window.toMillis)
408+
409+
/** Limits the number of invocations in a given amount of time.
410+
*
411+
* @return Some if invocation was allowed, None if rejected/rate-limited
412+
*/
413+
def rateLimit(window: FiniteDuration): AsyncCallback[Option[A]] =
414+
rateLimitMs(window.toMillis)
415+
416+
/** Limits the number of invocations in a given amount of time.
417+
*
418+
* @return Some if invocation was allowed, None if rejected/rate-limited
419+
*/
420+
def rateLimit(window: Duration, maxPerWindow: Int): AsyncCallback[Option[A]] =
421+
rateLimitMs(window.toMillis, maxPerWindow)
422+
423+
/** Limits the number of invocations in a given amount of time.
424+
*
425+
* @return Some if invocation was allowed, None if rejected/rate-limited
426+
*/
427+
def rateLimit(window: FiniteDuration, maxPerWindow: Int): AsyncCallback[Option[A]] =
428+
rateLimitMs(window.toMillis, maxPerWindow)
429+
430+
/** Limits the number of invocations in a given amount of time.
431+
*
432+
* @return Some if invocation was allowed, None if rejected/rate-limited
433+
*/
434+
def rateLimitMs(windowMs: Long, maxPerWindow: Int = 1): AsyncCallback[Option[A]] =
435+
_rateLimitMs(windowMs, maxPerWindow, RateLimit.realClock)
436+
437+
private[react] def _rateLimitMs(windowMs: Long, maxPerWindow: Int, clock: RateLimit.Clock): AsyncCallback[Option[A]] =
438+
if (windowMs <= 0 || maxPerWindow <= 0)
439+
AsyncCallback.pure(None)
440+
else {
441+
val limited =
442+
RateLimit.fn(
443+
run = completeWith,
444+
windowMs = windowMs,
445+
maxPerWindow = maxPerWindow,
446+
clock = clock,
447+
)
448+
val miss = Try(None)
449+
AsyncCallback { f =>
450+
Callback {
451+
limited(ta => f(ta.map(Some(_)))) match {
452+
case Some(cb) => cb.runNow()
453+
case None => f(miss)
454+
}
455+
}
456+
}
457+
}
458+
402459
/** Log to the console before this callback starts, and after it completes.
403460
*
404461
* Does not change the result.

core/src/main/scala/japgolly/scalajs/react/Callback.scala

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import scala.scalajs.js
1010
import scala.scalajs.js.{UndefOr, undefined, Function0 => JFn0, Function1 => JFn1}
1111
import scala.scalajs.js.timers.{RawTimers, SetIntervalHandle, SetTimeoutHandle}
1212
import scala.util.{Failure, Success, Try}
13-
import japgolly.scalajs.react.internal.{catchAll, identityFn, Trampoline}
13+
import japgolly.scalajs.react.internal.{RateLimit, Trampoline, catchAll, identityFn}
1414
import java.time.Duration
1515
import CallbackTo.MapGuard
1616

@@ -572,6 +572,48 @@ final class CallbackTo[A] private[react] (private[CallbackTo] val trampoline: Tr
572572
def unless_(cond: => Boolean): Callback =
573573
when_(!cond)
574574

575+
/** Limits the number of invocations in a given amount of time.
576+
*
577+
* @return Some if invocation was allowed, None if rejected/rate-limited
578+
*/
579+
def rateLimit(window: Duration): CallbackTo[Option[A]] =
580+
rateLimitMs(window.toMillis)
581+
582+
/** Limits the number of invocations in a given amount of time.
583+
*
584+
* @return Some if invocation was allowed, None if rejected/rate-limited
585+
*/
586+
def rateLimit(window: FiniteDuration): CallbackTo[Option[A]] =
587+
rateLimitMs(window.toMillis)
588+
589+
/** Limits the number of invocations in a given amount of time.
590+
*
591+
* @return Some if invocation was allowed, None if rejected/rate-limited
592+
*/
593+
def rateLimit(window: Duration, maxPerWindow: Int): CallbackTo[Option[A]] =
594+
rateLimitMs(window.toMillis, maxPerWindow)
595+
596+
/** Limits the number of invocations in a given amount of time.
597+
*
598+
* @return Some if invocation was allowed, None if rejected/rate-limited
599+
*/
600+
def rateLimit(window: FiniteDuration, maxPerWindow: Int): CallbackTo[Option[A]] =
601+
rateLimitMs(window.toMillis, maxPerWindow)
602+
603+
/** Limits the number of invocations in a given amount of time.
604+
*
605+
* @return Some if invocation was allowed, None if rejected/rate-limited
606+
*/
607+
def rateLimitMs(windowMs: Long, maxPerWindow: Int = 1): CallbackTo[Option[A]] =
608+
if (windowMs <= 0 || maxPerWindow <= 0)
609+
CallbackTo.pure(None)
610+
else
611+
CallbackTo.lift(RateLimit.fn0(
612+
run = toScalaFn,
613+
maxPerWindow = maxPerWindow,
614+
windowMs = windowMs,
615+
))
616+
575617
/** Log to the console before this callback starts, and after it completes.
576618
*
577619
* Does not change the result.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package japgolly.scalajs.react.internal
2+
3+
import scala.scalajs.js
4+
5+
object RateLimit {
6+
7+
type Clock = () => Long
8+
9+
val realClock: Clock =
10+
() => System.currentTimeMillis()
11+
12+
def fn[A, B](run: A => B, maxPerWindow: Int, windowMs: Long, clock: Clock = realClock): A => Option[B] = {
13+
val windowRuns = new js.Array[Long]
14+
15+
a => {
16+
val now = clock()
17+
val windowStart = now - windowMs
18+
19+
// prune current window
20+
while (windowRuns.nonEmpty && windowRuns(0) < windowStart)
21+
windowRuns.shift()
22+
23+
if (windowRuns.length >= maxPerWindow)
24+
None
25+
else {
26+
windowRuns.push(now)
27+
Some(run(a))
28+
}
29+
}
30+
}
31+
32+
def fn0[A](run: () => A, maxPerWindow: Int, windowMs: Long, clock: Clock = realClock): () => Option[A] = {
33+
val f = fn[Unit, A](
34+
run = _ => run(),
35+
windowMs = windowMs,
36+
maxPerWindow = maxPerWindow,
37+
clock = clock,
38+
)
39+
() => f(())
40+
}
41+
42+
}

doc/changelog/1.7.3.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# 1.7.3
22

3+
* Add rate-limiting to `Callback` and `AsyncCallback`:
4+
* `def rateLimit(window: Duration, maxPerWindow: Int = 1): {CallbackTo,AsyncCallback}[Option[A]]`
5+
* `def rateLimitMs(windowMs: Long, maxPerWindow: Int = 1): {CallbackTo,AsyncCallback}[Option[A]]`
6+
7+
For example, if you wanted to limit a callback to a max of 5 times per second it would look like this:
8+
9+
```scala
10+
callback.rateLimit(1.second, 5)
11+
```
12+
313
* Add implicit `Reusability` instances for:
414
* `SetIntervalHandle`
515
* `SetTimeoutHandle`

project/Build.scala

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ object ScalajsReact {
2323
val DisciplineScalaTest = "1.0.1"
2424
val KindProjector = "0.11.0"
2525
val MacroParadise = "2.1.1"
26+
val Microlibs = "2.3"
2627
val MonocleCats = "2.0.5"
2728
val MonocleScalaz = "1.6.3"
2829
val MTest = "0.7.4"
@@ -284,11 +285,12 @@ object ScalajsReact {
284285
"-Xlint:adapted-args"
285286
),
286287
libraryDependencies ++= Seq(
287-
"com.github.japgolly.nyaya" %%% "nyaya-prop" % Ver.Nyaya % Test,
288-
"com.github.japgolly.nyaya" %%% "nyaya-gen" % Ver.Nyaya % Test,
289-
"com.github.japgolly.nyaya" %%% "nyaya-test" % Ver.Nyaya % Test,
290-
"com.github.julien-truffaut" %%% "monocle-macro" % Ver.MonocleScalaz % Test,
291-
"org.scala-js" %%% "scalajs-java-time" % Ver.ScalaJsTime % Test),
288+
"com.github.japgolly.microlibs" %%% "test-util" % Ver.Microlibs % Test,
289+
"com.github.japgolly.nyaya" %%% "nyaya-prop" % Ver.Nyaya % Test,
290+
"com.github.japgolly.nyaya" %%% "nyaya-gen" % Ver.Nyaya % Test,
291+
"com.github.japgolly.nyaya" %%% "nyaya-test" % Ver.Nyaya % Test,
292+
"com.github.julien-truffaut" %%% "monocle-macro" % Ver.MonocleScalaz % Test,
293+
"org.scala-js" %%% "scalajs-java-time" % Ver.ScalaJsTime % Test),
292294
jsDependencies ++= Seq(
293295
"org.webjars.bower" % "sizzle" % Ver.SizzleJs % Test / "sizzle.min.js" commonJSName "Sizzle",
294296
(ProvidedJS / "component-es6.js" dependsOn ReactDom.dev) % Test,

test/src/test/scala/japgolly/scalajs/react/MiscTest.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ object MiscTest extends TestSuite {
2121

2222
@Lenses
2323
case class StrInt(str: String, int: Int)
24-
implicit def equalStrInt: Equal[StrInt] = Equal.equalA
24+
implicit def equalStrInt: UnivEq[StrInt] = UnivEq.force
2525

2626
@Lenses
2727
case class StrIntWrap(strInt: StrInt)
28-
implicit def equalStrIntWrap: Equal[StrIntWrap] = Equal.equalA
28+
implicit def equalStrIntWrap: UnivEq[StrIntWrap] = UnivEq.force
2929

3030
val witnessOptionCallbackToCallback: Option[Callback] => Callback =
3131
_.getOrEmpty
@@ -208,7 +208,7 @@ object MiscTest extends TestSuite {
208208
}
209209

210210
"durationFromDOMHighResTimeStamp" - {
211-
assertEq(JsUtil.durationFromDOMHighResTimeStamp(3), Duration.ofMillis(3))(Equal.equalA)
211+
assertEq(JsUtil.durationFromDOMHighResTimeStamp(3), Duration.ofMillis(3))
212212
}
213213

214214
"static" - {

test/src/test/scala/japgolly/scalajs/react/StateSnapshotTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ object StateSnapshotTest extends TestSuite {
4545
final case class X(int: Int, str: String)
4646

4747
object X {
48-
implicit def equal: Equal[X] = Equal.equalA
48+
implicit def equal: UnivEq[X] = UnivEq.force
4949
implicit val reusability: Reusability[X] = Reusability.derive
5050
}
5151

test/src/test/scala/japgolly/scalajs/react/core/RawComponentEs6Test.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ object RawComponentEs6PTest extends TestSuite {
9797
this.b - x.b,
9898
this.c - x.c)
9999
}
100-
implicit def equalProps = Equal.equalA[Props]
100+
implicit def equalProps = UnivEq.force[Props]
101101

102102
var mountCountA = 0
103103
var mountCountB = 0
@@ -184,8 +184,8 @@ object RawComponentEs6STest extends TestSuite {
184184
case class State(num1: Int, s2: State2)
185185
case class State2(num2: Int, num3: Int)
186186

187-
implicit val equalState: Equal[State] = Equal.equalA
188-
implicit val equalState2: Equal[State2] = Equal.equalA
187+
implicit val equalState: UnivEq[State] = UnivEq.force
188+
implicit val equalState2: UnivEq[State2] = UnivEq.force
189189

190190
@nowarn("cat=unused")
191191
class RawComp(ctorProps: Box[Unit]) extends raw.React.Component[Box[Unit], Box[State]] {

test/src/test/scala/japgolly/scalajs/react/core/ScalaComponentTest.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ object ScalaComponentPTest extends TestSuite {
8686
this.b - x.b,
8787
this.c - x.c)
8888
}
89-
implicit def equalProps = Equal.equalA[Props]
89+
implicit def equalProps = UnivEq.force[Props]
9090

9191
var mountCountA = 0
9292
var mountCountB = 0
@@ -321,8 +321,8 @@ object ScalaComponentSTest extends TestSuite {
321321
case class State(num1: Int, s2: State2)
322322
case class State2(num2: Int, num3: Int)
323323

324-
implicit val equalState: Equal[State] = Equal.equalA
325-
implicit val equalState2: Equal[State2] = Equal.equalA
324+
implicit val equalState: UnivEq[State] = UnivEq.force
325+
implicit val equalState2: UnivEq[State2] = UnivEq.force
326326

327327
class Backend($: BackendScope[Int, State]) {
328328
val inc: Callback =

0 commit comments

Comments
 (0)