Skip to content

Commit 22fe43d

Browse files
authored
Merge pull request #821 from gemini-hlsw/revision
Add tests, improve execution of throtting logic, fix README, fix stale effects in useEffectResult
2 parents e05cebe + b2b698e commit 22fe43d

21 files changed

Lines changed: 1256 additions & 94 deletions

CLAUDE.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Overview
6+
7+
`crystal` is a Scala library that provides a toolbelt for building reactive UI apps in Scala. It
8+
targets Scala 3 and cross-compiles to both the JVM and Scala.js. Publishing (organization,
9+
versioning, Maven Central release) is handled by the `sbt-lucuma-lib` plugin under the `edu.gemini`
10+
organization — the `com.rpiaggio` coordinate in the README badge is outdated. The README has
11+
extensive, authoritative API documentation — read it before changing public APIs.
12+
13+
Core abstractions:
14+
- **`Pot[A]` / `PotOption[A]`** (`modules/core/shared`): sum types for delayed/requested values
15+
(`Pending` / `Ready` / `Error`, plus `ReadyNone` for `PotOption`).
16+
- **`ViewF[F, A]`** and variants `ViewOptF` / `ViewListF` (`viewF.scala`): a value plus an
17+
effectful modify callback `(A => A) => F[Unit]`, with `zoom` (via Monocle optics) to drill into
18+
substructure. `View[A]` is the `Callback`-fixed alias used by the react layer.
19+
- **`Reuse[A]`** (`modules/core/js`): attaches a hidden `B` with a `Reusability[B]` to a value so
20+
that types without a natural `Reusability` (functions, `VdomNode`) can participate in reuse.
21+
- **Throttling**: `Throttler`, `ThrottlingViewF`, `ViewThrottlerF`, `Deglitcher` coordinate UI vs.
22+
server-driven updates.
23+
- **scalajs-react hooks** (`modules/core/js/.../react/hooks`): monadic hooks integrating
24+
`cats-effect` `IO` and `fs2.Stream` with scalajs-react (e.g. `useSingleEffect`, `useStreamView`,
25+
`useStreamResource`, `useEffectStream`, `useSignalStream`). New hooks are added only in their
26+
monadic form; builder-style is being phased out.
27+
28+
The library assumes scalajs-react's `core-bundle-cb_io` bundle (sync effect `CallbackTo`, async
29+
effect `IO`).
30+
31+
## Module structure
32+
33+
Three cross-projects (JVM + JS), defined in `build.sbt`:
34+
- **`core`** (`modules/core`): the library. `shared/` holds platform-agnostic code (`Pot`, `ViewF`,
35+
throttling); `js/` holds the scalajs-react integration (`react/`, hooks, `Reuse`). There is no
36+
meaningful `jvm/`-only source — the JS layer is where react lives.
37+
- **`testkit`** (`modules/testkit`): ScalaCheck `Arbitrary`/`Eq` instances for the core types,
38+
published for downstream test use. Depends on `core`.
39+
- **`tests`** (`modules/tests`): the test suite (MUnit + discipline law checks). Not published.
40+
JS tests run in a JSDOM environment. Depends on `testkit`.
41+
42+
## Build, test, lint
43+
44+
The build uses sbt via the bundled `sbt-launch.jar` and the `sbt-lucuma-lib` plugin (which derives
45+
versioning, CI config, scalafmt/scalafix config, and publishing). Dependency versions are
46+
centralized in `project/Settings.scala`. JS tests require `npm ci` first (provides `jsdom`, `react`,
47+
`react-dom`).
48+
49+
```bash
50+
sbt compile # compile all (JVM + JS)
51+
sbt test # run all tests (JVM + JS)
52+
sbt rootJVM/test # only JVM tests
53+
sbt rootJS/test # only JS tests (requires `npm ci` first)
54+
sbt "tests / Test / testOnly crystal.PotSpec" # a single test suite
55+
sbt scalafmtAll # format
56+
sbt scalafmtCheckAll # verify formatting (CI gate)
57+
sbt "scalafixAll OrganizeImports" # organize imports (the configured scalafix rule)
58+
```
59+
60+
Notes:
61+
- Scala 3 only (`3.3.8` cross-version in `build.sbt`). `maxColumn = 100`; vertical alignment is on.
62+
- Don't hand-edit `.scalafmt*.conf` / `.scalafix*.conf` — they are generated by `sbt-lucuma`.
63+
- CI (`.github/workflows/ci.yml`) sets up Node 24 + `npm ci` only for the `rootJS` matrix project.
64+
- Both `core` and `testkit` are published; treat their public APIs as compatibility-sensitive.

README.md

Lines changed: 44 additions & 46 deletions
Large diffs are not rendered by default.

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
imports = [ typelevel-nix.typelevelShell ];
2525
typelevelShell = {
2626
nodejs.enable = true;
27-
jdk.package = pkgs.jdk17;
27+
jdk.package = pkgs.jdk25;
2828
};
2929
};
3030
}

modules/core/js/src/main/scala/crystal/react/hooks/UseEffectResult.scala

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
package crystal.react.hooks
55

6+
import cats.effect.Outcome
67
import cats.syntax.all.*
78
import crystal.*
89
import crystal.react.*
@@ -31,30 +32,42 @@ object UseEffectResult:
3132
val depsOpt: Option[D] = effect.deps.toOption
3233

3334
private def doRefresh[A](
34-
effect: DefaultA[A],
35-
set: Pot[A] => DefaultS[Unit],
36-
setRunning: Boolean => DefaultS[Unit]
35+
effect: DefaultA[A],
36+
set: Pot[A] => DefaultS[Unit],
37+
setRunning: Boolean => DefaultS[Unit],
38+
singleEffect: UseSingleEffect[DefaultA]
3739
)(
38-
keep: Boolean
40+
keep: Boolean
3941
): DefaultS[Unit] =
4042
set(Pot.pending).unless(keep).void >>
4143
setRunning(true) >>
42-
(effect >>= (a => set(a.ready).toAsync))
43-
.handleErrorWith(t => set(Pot.error(t)).toAsync)
44-
.guarantee(setRunning(false).toAsync)
44+
// Route through `singleEffect` so a new refresh (deps change or manual) cancels the previous
45+
// in-flight effect, preventing a stale result from overwriting a newer one. `setRunning(false)`
46+
// must not run on cancellation: the new run already set `isRunning` to `true`, so a cancelled
47+
// run clearing it would leave the flag wrong for the duration of the new effect.
48+
singleEffect
49+
.submit:
50+
(effect >>= (a => set(a.ready).toAsync))
51+
.handleErrorWith(t => set(Pot.error(t)).toAsync)
52+
.guaranteeCase:
53+
case Outcome.Canceled() => ().pure[DefaultA]
54+
case _ => setRunning(false).toAsync
4555
.runAsyncAndForget
4656

4757
// Provides functionality for all the flavors
4858
private def hookBuilder[D, A, R: Reusability](
4959
deps: Pot[D]
5060
)(effect: D => DefaultA[A], keep: Boolean, reuseBy: Option[R]): HookResult[UseEffectResult[A]] =
5161
for
52-
state <- useSerialStateView(Pot.pending[A])
53-
isRunning <- useState(false)
54-
effectOpt <- useMemo(reuseBy): _ => // Memo Option[effect]
55-
deps.toOption.map(effect)
56-
refresh = effectOpt.map(_.foldMap(doRefresh(_, state.set, isRunning.setState)))
57-
_ <- useEffectWithDeps(refresh)(_(keep))
62+
state <- useSerialStateView(Pot.pending[A])
63+
isRunning <- useState(false)
64+
singleEffect <- useSingleEffect
65+
effectOpt <- useMemo(reuseBy): _ => // Memo Option[effect]
66+
deps.toOption.map(effect)
67+
refresh = effectOpt.map(
68+
_.foldMap(doRefresh(_, state.set, isRunning.setState, singleEffect.value))
69+
)
70+
_ <- useEffectWithDeps(refresh)(_(keep))
5871
yield UseEffectResult(state.map(_.toPotView), isRunning.value, refresh, keep)
5972

6073
/**

modules/core/shared/src/main/scala/crystal/Throttler.scala

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,38 @@ import scala.concurrent.duration.FiniteDuration
1717
* be discarded if they are submitted while another effect is running, except for the last one.
1818
*/
1919
class Throttler[F[_]: Temporal] private (
20-
spacedBy: FiniteDuration,
21-
isRunning: Ref[F, Boolean], // true if an effect is running
22-
queued: Ref[F, Option[F[Unit]]]
20+
spacedBy: FiniteDuration,
21+
state: Ref[F, Throttler.State[F]] // running flag and last queued effect, updated atomically
2322
)(using Monoid[F[Unit]]):
23+
import Throttler.State
24+
2425
def submit(f: F[Unit]): F[Unit] =
25-
(queued.set(none) >> isRunning.getAndSet(true)).uncancelable
26-
.flatMap:
27-
case false =>
28-
(Temporal[F].sleep(spacedBy).both(f) >> // Completes when both complete.
29-
(isRunning.set(false) >> queued.getAndSet(none)).uncancelable
30-
.flatMap(_.map(submit).orEmpty)).start.void // Execute everything in background.
31-
case true =>
32-
queued.set(f.some)
26+
state
27+
.modify: // If not running, start now; otherwise queue f.
28+
case State(false, _) => (State(true, none), startRun(f))
29+
case State(true, _) => (State(true, f.some), Monoid[F[Unit]].empty)
30+
.flatten
31+
32+
// Runs `f`, spaced by at least `spacedBy`, in the background; then drains the last queued effect.
33+
private def startRun(f: F[Unit]): F[Unit] =
34+
(Temporal[F].sleep(spacedBy).both(f) >> // Completes when both complete.
35+
drain).start.void // Execute everything in background.
36+
37+
// Atomically clears the running flag, re-submitting the last queued effect, if any.
38+
private val drain: F[Unit] =
39+
state
40+
.modify: // If there is a queued effect, start it; otherwise, clear the running flag.
41+
case State(_, Some(next)) => (State(true, none), startRun(next))
42+
case State(_, None) => (State(false, none), Monoid[F[Unit]].empty)
43+
.flatten
3344

3445
object Throttler:
46+
// `isRunning` and `queuedEffect` are kept in a single `Ref` so that "is something running?" and "enqueue
47+
// the last effect" happen as one atomic transition.
48+
private case class State[F[_]](isRunning: Boolean = false, queuedEffect: Option[F[Unit]] = none)
49+
3550
def apply[F[_]: Temporal](spacedBy: FiniteDuration)(using Monoid[F[Unit]]): F[Throttler[F]] =
36-
for
37-
isRunning <- Ref.of(false)
38-
queued <- Ref.of(none)
39-
yield new Throttler(spacedBy, isRunning, queued)
51+
Ref.of[F, State[F]](State()).map(new Throttler(spacedBy, _))
4052

4153
def unsafe[F[_]: Temporal: Sync](spacedBy: FiniteDuration)(using Monoid[F[Unit]]): Throttler[F] =
42-
new Throttler(spacedBy, Ref.unsafe(false), Ref.unsafe(none))
54+
new Throttler(spacedBy, Ref.unsafe(State()))

modules/core/shared/src/main/scala/crystal/ViewThrottlerF.scala

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import cats.effect.Temporal
88
import cats.effect.syntax.all.*
99
import cats.syntax.all.*
1010
import cats.~>
11+
import monocle.Focus
12+
import monocle.Lens
1113

1214
import scala.concurrent.duration.FiniteDuration
1315

@@ -28,39 +30,40 @@ import scala.concurrent.duration.FiniteDuration
2830
* G The async effect. This will be used for concurrency.
2931
*/
3032
final class ViewThrottlerF[F[_], G[_], A] private (
31-
waitUntil: Ref[G, FiniteDuration],
32-
nextUpdate: Ref[G, Option[ViewThrottlerF.ModCBType[G, A]]],
33+
state: Ref[G, ViewThrottlerF.State[G, A]],
3334
timeout: FiniteDuration,
3435
syncToAsync: F ~> G,
3536
dispatchAsync: G[Unit] => F[Unit]
3637
)(using G: Temporal[G]) {
38+
import ViewThrottlerF.State
3739

3840
private def throttle: F[Unit] =
3941
dispatchAsync:
40-
G.monotonic.flatMap(now => waitUntil.set(now + timeout))
42+
G.monotonic.flatMap(now => state.update(State.waitUntil[G, A].replace(now + timeout)))
4143

4244
private def throttlerView(view: ViewF[F, A]): ViewF[F, A] =
4345
view.withOnMod(_ => throttle)
4446

4547
private def attemptSet(modCB: (A => A, (A, A) => G[Unit]) => G[Unit]): G[Unit] =
46-
(waitUntil.get, G.monotonic).flatMapN: (waitUntil, now) =>
47-
if (waitUntil > now)
48-
(G.sleep(waitUntil - now) >> attemptSet(modCB)).start.void
49-
else
50-
nextUpdate
51-
.flatModify: accumModCb =>
52-
(none, accumModCb.map((mod, cb) => modCB(mod, cb)).getOrElse(G.unit))
53-
.flatTap(_ => G.unit)
48+
G.monotonic.flatMap: now =>
49+
state
50+
.modify: s =>
51+
if (s.waitUntil > now)
52+
(s, (G.sleep(s.waitUntil - now) >> attemptSet(modCB)).start.void)
53+
else
54+
(State.nextUpdate[G, A].replace(none)(s),
55+
s.nextUpdate.map((mod, cb) => modCB(mod, cb)).getOrElse(G.unit)
56+
)
57+
.flatten
5458

5559
private def throttledView(view: ViewF[F, A]): ViewF[G, A] =
5660
new ViewF[G, A](
5761
get = view.get,
5862
modCB = (mod, cb) =>
59-
nextUpdate.update: x =>
60-
x match
61-
case None => (mod, cb).some
62-
case Some(oldMod, _) => (oldMod.andThen(mod), cb).some // We only keep last CB
63-
>>
63+
state.update(State.nextUpdate[G, A].modify {
64+
case None => (mod, cb).some
65+
case Some(oldMod, _) => (oldMod.andThen(mod), cb).some // We only keep last CB
66+
}) >>
6467
attemptSet: (newMod, newCB) =>
6568
syncToAsync(view.modCB(newMod, (oldA, newA) => dispatchAsync(newCB(oldA, newA))))
6669
)
@@ -70,13 +73,25 @@ final class ViewThrottlerF[F[_], G[_], A] private (
7073
}
7174

7275
object ViewThrottlerF {
76+
// The type of ViewF.modCB's parameters, to avoid repeating everywhere.
7377
private type ModCBType[F[_], A] = (A => A, (A, A) => F[Unit])
7478

79+
// `waitUntil` (pause deadline) and `nextUpdate` (last accumulated update) are kept in a single
80+
// `Ref` so that, in `attemptSet`, deciding whether the pause has elapsed and taking the pending
81+
// update happen as one atomic transition.
82+
private case class State[G[_], A](waitUntil: FiniteDuration, nextUpdate: Option[ModCBType[G, A]])
83+
private object State:
84+
def waitUntil[G[_], A]: Lens[State[G, A], FiniteDuration] =
85+
Focus[State[G, A]](_.waitUntil)
86+
def nextUpdate[G[_], A]: Lens[State[G, A], Option[ModCBType[G, A]]] =
87+
Focus[State[G, A]](_.nextUpdate)
88+
7589
def apply[F[_], G[_], A](
7690
timeout: FiniteDuration,
7791
syncToAsync: F ~> G,
7892
dispatchAsync: G[Unit] => F[Unit]
7993
)(using G: Temporal[G]): G[ViewThrottlerF[F, G, A]] =
80-
(G.monotonic.flatMap(G.ref(_)), G.ref(none[ModCBType[G, A]]))
81-
.mapN(new ViewThrottlerF(_, _, timeout, syncToAsync, dispatchAsync))
94+
G.monotonic
95+
.flatMap(now => G.ref(State[G, A](now, none)))
96+
.map(new ViewThrottlerF(_, timeout, syncToAsync, dispatchAsync))
8297
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
2+
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause
3+
4+
package crystal.react.hooks
5+
6+
import cats.effect.Deferred
7+
import cats.effect.IO
8+
import japgolly.scalajs.react.*
9+
import japgolly.scalajs.react.test.ReactTestUtils
10+
import japgolly.scalajs.react.testing_library.dom.Simulate
11+
import japgolly.scalajs.react.vdom.html_<^.*
12+
import munit.CatsEffectSuite
13+
import org.scalajs.dom
14+
15+
import scala.concurrent.duration.*
16+
17+
// Exercises `useAsyncEffect` against its README description: "Version of `useEffect` that avoids
18+
// race conditions when executing async effects [...] by cancelling the previous instance of the
19+
// effect before executing a new one."
20+
class UseAsyncEffectSpec extends CatsEffectSuite:
21+
import ReactTestUtils.*
22+
23+
test("useAsyncEffect - cancels the previous effect when deps change"):
24+
val log = cats.effect.Ref.unsafe[IO, List[Int]](Nil)
25+
val buttonRef = Ref[dom.HTMLButtonElement]
26+
27+
val comp = ScalaFnComponent[Unit]: _ =>
28+
for
29+
dep <- useState(0)
30+
// dep 0's effect is slow; dep 1's is fast, so dep 0 would record *after* dep 1 if not
31+
// cancelled on the deps change.
32+
_ <- useAsyncEffectWithDeps(dep.value): d =>
33+
IO.sleep(if d == 0 then 100.millis else 10.millis) >> log.update(d :: _)
34+
yield <.button("x", ^.onClick --> dep.modState(_ + 1)).withRef(buttonRef)
35+
36+
withRendered(comp()): _ =>
37+
for
38+
// No pre-click wait needed: on the deps change, `useSingleEffect` waits for dep 0's effect
39+
// to have started, then cancels it mid-sleep.
40+
_ <- act_(Simulate.click(buttonRef.unsafeGet())) // dep -> 1, cancels dep 0's effect
41+
// Wait past dep 0's would-be completion (100ms) to confirm it never recorded.
42+
_ <- act(IO.sleep(120.millis))
43+
r <- log.get
44+
_ = assertEquals(r, List(1)) // only dep 1 ran; dep 0 was cancelled
45+
yield ()
46+
47+
test("useAsyncEffect - runs the cleanup effect on deps change"):
48+
val log = cats.effect.Ref.unsafe[IO, List[String]](Nil)
49+
val started1 = Deferred.unsafe[IO, Unit] // signalled when the dep-1 effect has run
50+
val buttonRef = Ref[dom.HTMLButtonElement]
51+
52+
val comp = ScalaFnComponent[Unit]: _ =>
53+
for
54+
dep <- useState(0)
55+
_ <- useAsyncEffectWithDeps(dep.value): d =>
56+
(log.update(s"start$d" :: _) >>
57+
(if d == 1 then started1.complete(()).void else IO.unit))
58+
.as(log.update(s"cleanup$d" :: _))
59+
yield <.button("x", ^.onClick --> dep.modState(_ + 1)).withRef(buttonRef)
60+
61+
withRendered(comp()): _ =>
62+
for
63+
_ <- act_(Simulate.click(buttonRef.unsafeGet()))
64+
// Wait deterministically until the dep-1 effect has run. `useSingleEffect`'s latch keeps the
65+
// order, so by then `start0 -> cleanup0 -> start1` have all happened.
66+
_ <- act(started1.get)
67+
r <- log.get
68+
_ = assertEquals(r.reverse, List("start0", "cleanup0", "start1"))
69+
yield ()
70+
71+
test("useAsyncEffectOnMount - runs the effect on mount"):
72+
val ran = Deferred.unsafe[IO, Unit]
73+
74+
val comp = ScalaFnComponent[Unit]: _ =>
75+
for _ <- useAsyncEffectOnMount(ran.complete(()).void)
76+
yield EmptyVdom
77+
78+
// `act(ran.get)` only completes if the on-mount effect ran.
79+
withRendered(comp())(_ => act(ran.get))

modules/tests/js/src/test/scala/crystal/react/hooks/UseEffectResultSpec.scala

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,29 @@ class UseEffectResultSpec extends CatsEffectSuite:
360360
_ = d.innerHTML.assert("(Reusable(Ready(3)),false)")
361361
yield ()
362362
yield ()
363+
364+
test("useEffectResultWithDeps - deps change cancels in-flight effect (no stale overwrite)"):
365+
val buttonRef = Ref[dom.HTMLButtonElement]
366+
367+
// The first deps' effect is slow and the second deps' effect is fast, so the first one would
368+
// resolve *after* the second. If the in-flight effect is not cancelled when deps change, its
369+
// stale result overwrites the newer one.
370+
val comp = ScalaFnComponent[Unit]: _ =>
371+
for
372+
s <- useState(0)
373+
v <- useEffectResultWithDeps(s.value): d =>
374+
if d === 0 then IO.sleep(100.millis).as(10) else IO.sleep(10.millis).as(20)
375+
yield <.button((v.value, v.isRunning).toString, ^.onClick --> s.modState(_ + 1))
376+
.withRef(buttonRef)
377+
378+
withRendered(comp()): d =>
379+
d.innerHTML.assert("(Reusable(Pending),true)")
380+
for
381+
_ <- act(IO.sleep(20.millis)) // slow effect (deps 0) still running
382+
_ <- act_(Simulate.click(buttonRef.unsafeGet())) // change deps -> start fast effect
383+
_ = d.innerHTML.assert("(Reusable(Pending),true)")
384+
_ <- act(IO.sleep(10.millis)) // fast effect (deps 1) completes
385+
_ = d.innerHTML.assert("(Reusable(Ready(20)),false)")
386+
_ <- act(IO.sleep(100.millis)) // slow effect (deps 0) would complete
387+
_ = d.innerHTML.assert("(Reusable(Ready(20)),false)") // must NOT be overwritten by stale 10
388+
yield ()

0 commit comments

Comments
 (0)