Skip to content

Commit

Permalink
migrate to monadic hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
rpiaggio committed Dec 23, 2024
1 parent 887950e commit 0be9c05
Show file tree
Hide file tree
Showing 17 changed files with 705 additions and 181 deletions.
4 changes: 2 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Global / onChangedBuildSource := ReloadOnSourceChanges

ThisBuild / crossScalaVersions := List("3.5.2")
ThisBuild / tlBaseVersion := "0.46"
ThisBuild / crossScalaVersions := List("3.6.2")
ThisBuild / tlBaseVersion := "0.47"

ThisBuild / tlCiReleaseBranches := Seq("master")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,43 @@ import japgolly.scalajs.react.hooks.CustomHook
import japgolly.scalajs.react.util.DefaultEffects.Async as DefaultA

object UseAsyncEffect {
def hook[G, D: Reusability](using EffectWithCleanup[G, DefaultA]) =
CustomHook[WithDeps[D, G]].useSingleEffect
.useEffectWithDepsBy((props, _) => props.deps): (props, dispatcher) =>
deps => dispatcher.submit(props.fromDeps(deps).normalize)
.build

/**
* Run async effect and cancel previously running instances, thus avoiding race conditions. Allows
* returning a cleanup effect.
*/
final def useAsyncEffectWithDeps[G, D: Reusability](deps: => D)(effect: D => G)(using
G: EffectWithCleanup[G, DefaultA]
): HookResult[Unit] =
// hookBuilder(WithDeps(deps, effect))
useSingleEffect.flatMap: dispatcher =>
useEffectWithDeps(deps): deps =>
dispatcher.submit(effect(deps).normalize)

/**
* Run async effect and cancel previously running instances, thus avoiding race conditions. Allows
* returning a cleanup effect.
*/
final inline def useAsyncEffect[G](effect: => G)(using
G: EffectWithCleanup[G, DefaultA]
): HookResult[Unit] =
useAsyncEffectWithDeps(NeverReuse)((_: Reuse[Unit]) => effect)

/**
* Run async effect and cancel previously running instances, thus avoiding race conditions. Allows
* returning a cleanup effect.
*/
final inline def useAsyncEffectOnMount[G](effect: => G)(using
G: EffectWithCleanup[G, DefaultA]
): HookResult[Unit] = // () has Reusability = always.
useAsyncEffectWithDeps(())((_: Unit) => effect)

// *** The rest is to support builder-style hooks *** //

private def hook[G, D: Reusability](using
EffectWithCleanup[G, DefaultA]
): CustomHook[WithDeps[D, G], Unit] =
CustomHook.fromHookResult(input => useAsyncEffectWithDeps(input.deps)(input.fromDeps))

object HooksApiExt {
sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,105 @@ import japgolly.scalajs.react.*
import japgolly.scalajs.react.hooks.CustomHook
import japgolly.scalajs.react.util.DefaultEffects.Async as DefaultA

object UseEffectResult {
object UseEffectResult:
private case class Input[D, A, R: Reusability](
effect: WithPotDeps[D, DefaultA[A], R],
keep: Boolean
):
val depsOpt: Option[D] = effect.deps.toOption

private def hook[D, A, R: Reusability] =
CustomHook[Input[D, A, R]]
.useState(Pot.pending[A])
.useMemoBy((props, _) => props.effect.reuseValue): (props, _) => // Memo Option[effect]
_ => props.depsOpt.map(props.effect.fromDeps)
.useEffectWithDepsBy((_, _, effectOpt) => effectOpt): (props, state, _) => // Set to Pending
_ => state.setState(Pot.pending).unless(props.keep).void
.useAsyncEffectWithDepsBy((_, _, effectOpt) => effectOpt): (_, state, _) => // Run effect
_.value.foldMap: effect =>
(for
a <- effect
_ <- state.setStateAsync(a.ready)
yield ()).handleErrorWith: t =>
state.setStateAsync(Pot.error(t))
.buildReturning((_, state, _) => state.value)

// Provides functionality for all the flavors
private def hookBuilder[D, A, R: Reusability](
deps: Pot[D]
)(effect: D => DefaultA[A], keep: Boolean, reuseBy: Option[R]): HookResult[Pot[A]] =
for
state <- useState(Pot.pending[A])
effectOpt <- useMemo(reuseBy): _ => // Memo Option[effect]
deps.toOption.map(effect)
_ <- useEffectWithDeps(effectOpt): _ => // Set to Pending
state.setState(Pot.pending).unless(keep).void
_ <- useAsyncEffectWithDeps(effectOpt): // Run effect
_.value.foldMap: effect =>
(for
a <- effect
_ <- state.setStateAsync(a.ready)
yield ()).handleErrorWith: t =>
state.setStateAsync(Pot.error(t))
yield state.value

/**
* Runs an async effect and stores the result in a state, which is provided as a `Pot[A]`. When
* dependencies change, reverts to `Pending` while executing the new effect.
*/
final inline def useEffectResultWithDeps[D: Reusability, A](
deps: => D
)(effect: D => DefaultA[A]): HookResult[Pot[A]] =
hookBuilder(deps.ready)(effect, keep = false, deps.some)

/**
* Runs an async effect whenever `Pot` dependencies transition into a `Ready` state (but not when
* they change once `Ready`) and stores the result in a state, which is provided as a `Pot[A]`.
* When dependencies change, reverts to `Pending` while executing the new effect or while waiting
* for them to become `Ready` again. For multiple dependencies, use `(par1, par2, ...).tupled`.
*/
final inline def useEffectResultWhenDepsReady[D, A](
deps: => Pot[D]
)(effect: D => DefaultA[A]): HookResult[Pot[A]] =
hookBuilder(deps)(effect, keep = false, deps.toOption.void)

/**
* Runs an async effect when `Pot` dependencies transition into a `Ready` state or change once
* `Ready` and stores the result in a state, which is provided as a `Pot[A]`. When dependencies
* change, reverts to `Pending` while executing the new effect or while waiting for them to become
* `Ready` again. For multiple dependencies, use `(par1, par2, ...).tupled`.
*/
final inline def useEffectResultWhenDepsReadyOrChange[D: Reusability, A](
deps: => Pot[D]
)(effect: D => DefaultA[A]): HookResult[Pot[A]] =
hookBuilder(deps)(effect, keep = false, deps.toOption)

/**
* Runs an async effect and stores the result in a state, which is provided as a `Pot[A]`. When
* dependencies change, keeps the old value while executing the new effect.
*/
final inline def useEffectKeepResultWithDeps[D: Reusability, A](
deps: => D
)(effect: D => DefaultA[A]): HookResult[Pot[A]] =
hookBuilder(deps.ready)(effect, keep = true, deps.some)

/**
* Runs an async effect whenever `Pot` dependencies transition into a `Ready` state (but not when
* they change once `Ready`) and stores the result in a state, which is provided as a `Pot[A]`.
* When dependencies change, keeps the old value while executing the new effect or while waiting
* for them to become `Ready` again. For multiple dependencies, use `(par1, par2, ...).tupled`.
*/
final inline def useEffectKeepResultWhenDepsReady[D, A](
deps: => Pot[D]
)(effect: D => DefaultA[A]): HookResult[Pot[A]] =
hookBuilder(deps)(effect, keep = true, deps.toOption.void)

/**
* Runs an async effect whenever `Pot` dependencies transition into a `Ready` state or change once
* `Ready` and stores the result in a state, which is provided as a `Pot[A]`. When dependencies
* change, keeps the old value while executing the new effect or while waiting for them to become
* `Ready` again. For multiple dependencies, use `(par1, par2, ...).tupled`.
*/
final inline def useEffectKeepResultWhenDepsReadyOrChange[D: Reusability, A](
deps: => Pot[D]
)(effect: D => DefaultA[A]): HookResult[Pot[A]] =
hookBuilder(deps)(effect, keep = true, deps.toOption)

/**
* Runs an async effect and stores the result in a state, which is provided as a `Pot[A]`.
*/
final inline def useEffectResultOnMount[A](effect: => DefaultA[A]): HookResult[Pot[A]] =
useEffectResultWithDeps(())(_ => effect) // () has Reusability = always.

// *** The rest is to support builder-style hooks *** //

private def hook[D, A, R: Reusability]: CustomHook[Input[D, A, R], Pot[A]] =
CustomHook.fromHookResult: input =>
hookBuilder(input.effect.deps)(input.effect.fromDeps, input.keep, input.effect.reuseValue)
object HooksApiExt {
sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) {

Expand Down Expand Up @@ -313,4 +389,3 @@ object UseEffectResult {
}

object syntax extends HooksApiExt
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,98 @@ import japgolly.scalajs.react.*
import japgolly.scalajs.react.hooks.CustomHook
import japgolly.scalajs.react.util.DefaultEffects.Async as DefaultA

object UseEffectStreamResource {

protected def hook[D: Reusability] =
CustomHook[WithDeps[D, StreamResource[Unit]]]
.useAsyncEffectWithDepsBy(props => props.deps): props =>
deps =>
for
latch <- Deferred[DefaultA, Unit] // Latch for stream termination.
(_, close) <- props
.fromDeps(deps)
.flatMap: stream =>
(stream.compile.drain >> latch.complete(())).background.void
.allocated
supervisor <- (latch.get >> close).start // Close the resource if the stream terminates.
yield
// Cleanup closes resource and cancels the supervisor, unless resource is already closed.
(supervisor.cancel >> close).when:
latch.tryGet.map(_.isEmpty)
.build
object UseEffectStreamResource:
/**
* Open a `Resource[Async, fs.Stream[Async, Unit]]` on mount or when dependencies change, and
* drain the stream by creating a fiber. The fiber will be cancelled and the resource closed on
* unmount or deps change.
*/
final def useEffectStreamResourceWithDeps[D: Reusability](deps: => D)(
effectStreamResource: D => StreamResource[Unit]
): HookResult[Unit] =
useAsyncEffectWithDeps(deps): depsValue =>
for
latch <- Deferred[DefaultA, Unit] // Latch for stream termination.
(_, close) <- effectStreamResource(deps)
.flatMap: stream =>
(stream.compile.drain >> latch.complete(())).background.void
.allocated
supervisor <- (latch.get >> close).start // Close the resource if the stream terminates.
yield
// Cleanup closes resource and cancels the supervisor, unless resource is already closed.
(supervisor.cancel >> close).when:
latch.tryGet.map(_.isEmpty)

/**
* Open a `Resource[Async, fs.Stream[Async, Unit]]` on each render, and drain the stream by
* creating a fiber. If there was another fiber executing from the previous render, it will be
* cancelled and its resource closed.
*/
final inline def useEffectStreamResource(
effectStreamResource: => StreamResource[Unit]
): HookResult[Unit] =
useEffectStreamResourceWithDeps(NeverReuse)(_ => effectStreamResource)

/**
* Open a `Resource[Async, fs.Stream[Async, Unit]]` on mount, and drain the stream by creating a
* fiber. The fiber will be cancelled and the resource closed on unmount.
*/
final inline def useEffectStreamResourceOnMount(
effectStreamResource: => StreamResource[Unit]
): HookResult[Unit] = // () has Reusability = always.
useEffectStreamResourceWithDeps(())(_ => effectStreamResource)

/**
* Open a `Resource[Async, fs.Stream[Async, Unit]]` when a `Pot` dependency becomes `Ready`, and
* drain the stream by creating a fiber. The fiber will be cancelled and the resource closed on
* unmount or if the dependency transitions to `Pending` or `Error`.
*/
final def useEffectStreamResourceWhenDepsReady[D](
deps: => Pot[D]
)(effectStreamResource: D => StreamResource[Unit]): HookResult[Unit] =
useEffectStreamResourceWithDeps(deps.toOption.void)(_ =>
deps.toOption.map(effectStreamResource).orEmpty
)

/**
* Drain a `fs2.Stream[Async, Unit]` by creating a fiber on mount or when deps change.The fiber
* will be cancelled on unmount or deps change.
*/
final inline def useEffectStreamWithDeps[D: Reusability](deps: => D)(
effectStream: D => fs2.Stream[DefaultA, Unit]
): HookResult[Unit] =
useEffectStreamResourceWithDeps(deps)(deps => Resource.pure(effectStream(deps)))

/**
* Drain a `fs2.Stream[Async, Unit]` by creating a fiber on each render. If there was another
* fiber executing from the previous render, it will be cancelled.
*/
final inline def useEffectStream(effectStream: fs2.Stream[DefaultA, Unit]): HookResult[Unit] =
useEffectStreamWithDeps(NeverReuse)(_ => effectStream)

/**
* Drain a `fs2.Stream[Async, Unit]` by creating a fiber when a `Pot` dependency becomes `Ready`.
* The fiber will be cancelled on unmount or if the dependency transitions to `Pending` or
* `Error`.
*/
final inline def useEffectStreamWhenDepsReady[D](
deps: => Pot[D]
)(effectStream: D => fs2.Stream[DefaultA, Unit]): HookResult[Unit] =
useEffectStreamWithDeps(deps.toOption.void)(_ => deps.toOption.map(effectStream).orEmpty)

/**
* Drain a `fs2.Stream[Async, Unit]` by creating a fiber on mount. The fiber will be cancelled on
* unmount.
*/
final inline def useEffectStreamOnMount(
effectStream: => fs2.Stream[DefaultA, Unit]
): HookResult[Unit] =
useEffectStreamResourceOnMount(Resource.pure(effectStream))

// *** The rest is to support builder-style hooks *** //

private def hook[D: Reusability]: CustomHook[WithDeps[D, StreamResource[Unit]], Unit] =
CustomHook.fromHookResult(input => useEffectStreamResourceWithDeps(input.deps)(input.fromDeps))

object HooksApiExt {
sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) {
Expand Down Expand Up @@ -312,4 +385,3 @@ object UseEffectStreamResource {
}

object syntax extends HooksApiExt
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,73 @@ import japgolly.scalajs.react.hooks.Hooks.UseEffectArg
import japgolly.scalajs.react.util.DefaultEffects.Async as DefaultA

object UseEffectWhenDepsReady:
// Provides functionality for all the flavors
private def hookBuilder[D, A: UseEffectArg: Monoid, R: Reusability](deps: => Pot[D])(
effect: D => A,
reuseBy: Option[R]
): HookResult[Unit] =
useEffectWithDeps(reuseBy): _ =>
deps.map(effect).toOption.orEmpty

def hook[D, A: UseEffectArg: Monoid, R: Reusability] =
CustomHook[WithPotDeps[D, A, R]]
.useEffectWithDepsBy(props => props.reuseValue): props =>
_ => props.deps.toOption.map(props.fromDeps).orEmpty
.build

def asyncHook[G, D, R: Reusability](using EffectWithCleanup[G, DefaultA]) =
CustomHook[WithPotDeps[D, G, R]]
.useAsyncEffectWithDepsBy(props => props.reuseValue): props =>
_ => props.deps.toOption.map(props.fromDeps(_).normalize).orEmpty
.build
/**
* Effect that runs whenever `Pot` dependencies transition into a `Ready` state (but not when they
* change once `Ready`). For multiple dependencies, use `(par1, par2, ...).tupled`. Dependencies
* are passed unpacked to the effect bulding function.
*/
final inline def useEffectWhenDepsReady[D, A: UseEffectArg: Monoid](
deps: => Pot[D]
)(effect: D => A): HookResult[Unit] =
hookBuilder(deps)(effect, deps.toOption.void)

/**
* Effect that runs when `Pot` dependencies transition into a `Ready` state or change once
* `Ready`. For multiple dependencies, use `(par1, par2, ...).tupled`. Dependencies are passed
* unpacked to the effect bulding function.
*/
final inline def useEffectWhenDepsReadyOrChange[D: Reusability, A: UseEffectArg: Monoid](
deps: => Pot[D]
)(effect: D => A): HookResult[Unit] =
hookBuilder(deps)(effect, deps.toOption)

// Provides functionality for all the flavors
private def asyncHookBuilder[D, G, R: Reusability](deps: => Pot[D])(
effect: D => G,
reuseBy: Option[R]
)(using EffectWithCleanup[G, DefaultA]): HookResult[Unit] =
useAsyncEffectWithDeps(reuseBy): _ =>
deps.map(effect(_).normalize).toOption.orEmpty

/**
* Effect that runs whenever `Pot` dependencies transition into a `Ready` state (but not when they
* change once `Ready`). For multiple dependencies, use `(par1, par2, ...).tupled`. Dependencies
* are passed unpacked to the effect bulding function.
*/
final inline def useAsyncEffectWhenDepsReady[D, G](
deps: => Pot[D]
)(effect: D => G)(using EffectWithCleanup[G, DefaultA]): HookResult[Unit] =
asyncHookBuilder(deps)(effect, deps.toOption.void)

/**
* Effect that runs when `Pot` dependencies transition into a `Ready` state or change once
* `Ready`. For multiple dependencies, use `(par1, par2, ...).tupled`. Dependencies are passed
* unpacked to the effect bulding function.
*/
final inline def useAsyncEffectWhenDepsReadyOrChange[D: Reusability, G](
deps: => Pot[D]
)(effect: D => G)(using EffectWithCleanup[G, DefaultA]): HookResult[Unit] =
asyncHookBuilder(deps)(effect, deps.toOption)

// *** The rest is to support builder-style hooks *** //

private def hook[D, A: UseEffectArg: Monoid, R: Reusability]
: CustomHook[WithPotDeps[D, A, R], Unit] =
CustomHook.fromHookResult(input => hookBuilder(input.deps)(input.fromDeps, input.reuseValue))

private def asyncHook[G, D, R: Reusability](using
EffectWithCleanup[G, DefaultA]
): CustomHook[WithPotDeps[D, G, R], Unit] =
CustomHook.fromHookResult: input =>
asyncHookBuilder(input.deps)(input.fromDeps, input.reuseValue)

object HooksApiExt {
sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) {
Expand Down
Loading

0 comments on commit 0be9c05

Please sign in to comment.