From 0be9c05a3e64e1bb422a7ec8e76be8b0349f5eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Piaggio?= Date: Fri, 13 Dec 2024 18:16:54 -0300 Subject: [PATCH] migrate to monadic hooks --- build.sbt | 4 +- .../crystal/react/hooks/UseAsyncEffect.scala | 42 ++- .../crystal/react/hooks/UseEffectResult.scala | 111 ++++++-- .../react/hooks/UseEffectStreamResource.scala | 112 ++++++-- .../react/hooks/UseEffectWhenDepsReady.scala | 77 +++++- .../crystal/react/hooks/UseResource.scala | 42 ++- .../crystal/react/hooks/UseSerialState.scala | 17 +- .../react/hooks/UseSerialStateView.scala | 15 +- .../crystal/react/hooks/UseShadowRef.scala | 23 +- .../crystal/react/hooks/UseSingleEffect.scala | 37 ++- .../react/hooks/UseStateCallback.scala | 40 +-- .../crystal/react/hooks/UseStateView.scala | 28 +- .../react/hooks/UseStateViewWithReuse.scala | 17 +- .../react/hooks/UseStreamResource.scala | 244 ++++++++++++++++-- .../react/hooks/UseThrottlingStateView.scala | 22 +- .../scala/crystal/react/hooks/package.scala | 53 +++- project/Settings.scala | 2 +- 17 files changed, 705 insertions(+), 181 deletions(-) diff --git a/build.sbt b/build.sbt index a6684d78..aac194ea 100644 --- a/build.sbt +++ b/build.sbt @@ -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") diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseAsyncEffect.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseAsyncEffect.scala index 21283cae..142ffb84 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseAsyncEffect.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseAsyncEffect.scala @@ -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]) { diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectResult.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectResult.scala index 3a0fb48d..fad962b8 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectResult.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectResult.scala @@ -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]) { @@ -313,4 +389,3 @@ object UseEffectResult { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectStreamResource.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectStreamResource.scala index de7575a9..7e102407 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectStreamResource.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectStreamResource.scala @@ -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]) { @@ -312,4 +385,3 @@ object UseEffectStreamResource { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectWhenDepsReady.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectWhenDepsReady.scala index 3e66c922..2bff32f0 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectWhenDepsReady.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseEffectWhenDepsReady.scala @@ -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]) { diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseResource.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseResource.scala index 3657f886..8e6c1578 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseResource.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseResource.scala @@ -10,18 +10,35 @@ import japgolly.scalajs.react.* import japgolly.scalajs.react.hooks.CustomHook import japgolly.scalajs.react.util.DefaultEffects.Async as DefaultA -object UseResource { - def hook[D: Reusability, A] = CustomHook[WithDeps[D, Resource[DefaultA, A]]] - .useState(Pot.pending[A]) - .useAsyncEffectWithDepsBy((props, _) => props.deps): (props, state) => - deps => - (for { - resource <- props.fromDeps(deps).allocated - (value, close) = resource - _ <- state.setStateAsync(value.ready) - } yield close) - .handleErrorWith(t => state.setStateAsync(Pot.error(t)).as(DefaultA.delay(()))) - .buildReturning((_, state) => state.value) +object UseResource: + /** + * Open a `Resource[Async, A]` on mount or when dependencies change, and close it on unmount or + * when dependencies change. Provided as a `Pot[A]`. Will rerender when the `Pot` state changes. + */ + final def useResource[D: Reusability, A](deps: => D)( + resource: D => Resource[DefaultA, A] + ): HookResult[Pot[A]] = + for + state <- useState(Pot.pending[A]) + _ <- useAsyncEffectWithDeps(deps): deps => + (for + (value, close) <- resource(deps).allocated + _ <- state.setStateAsync(value.ready) + yield close).handleErrorWith: t => + state.setStateAsync(Pot.error(t)).as(DefaultA.delay(())) + yield state.value + + /** + * Open a `Resource[Async, A]` on mount and close it on unmount. Provided as a `Pot[A]`. Will + * rerender when the `Pot` state changes. + */ + final inline def useResourceOnMount[A](resource: Resource[DefaultA, A]): HookResult[Pot[A]] = + useResource(())(_ => resource) + + // *** The rest is to support builder-style hooks *** // + + private def hook[D: Reusability, A]: CustomHook[WithDeps[D, Resource[DefaultA, A]], Pot[A]] = + CustomHook.fromHookResult(input => useResource(input.deps)(input.fromDeps)) object HooksApiExt { sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { @@ -114,4 +131,3 @@ object UseResource { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseSerialState.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseSerialState.scala index 0ba66781..742b099c 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseSerialState.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseSerialState.scala @@ -10,7 +10,7 @@ import japgolly.scalajs.react.util.DefaultEffects.Sync as DefaultS case class UseSerialState[A] protected[hooks] ( private val state: Hooks.UseState[SerialState[A]] -) { +): lazy val value: Reusable[A] = Reusable.implicitly(state.value).map(_.value) val modState: Reusable[(A => A) => DefaultS[Unit]] = @@ -18,12 +18,16 @@ case class UseSerialState[A] protected[hooks] ( val setState: Reusable[A => DefaultS[Unit]] = state.modState.map(mod => a => mod(_.update(_ => a))) -} -object UseSerialState { - def hook[A] = CustomHook[A] - .useStateBy(initialValue => SerialState.initial(initialValue)) - .buildReturning((_, serialState) => UseSerialState(serialState)) +object UseSerialState: + /** Creates component state that is reused while it's not updated. */ + final def useSerialState[A](initialValue: => A): HookResult[UseSerialState[A]] = + useState(SerialState.initial(initialValue)).map(UseSerialState(_)) + + // *** The rest is to support builder-style hooks *** // + + private def hook[A]: CustomHook[A, UseSerialState[A]] = + CustomHook.fromHookResult(useSerialState(_)) given [A]: Reusability[UseSerialState[A]] = Reusability.by(_.state.value) @@ -74,4 +78,3 @@ object UseSerialState { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseSerialStateView.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseSerialStateView.scala index c8d4566c..57d593e5 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseSerialStateView.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseSerialStateView.scala @@ -8,12 +8,16 @@ import crystal.react.reuse.* import japgolly.scalajs.react.* import japgolly.scalajs.react.hooks.CustomHook -object UseSerialStateView { - def hook[A]: CustomHook[A, ReuseView[A]] = CustomHook[A] - .useStateViewBy(initialValue => SerialState.initial(initialValue)) - .buildReturning((_, serialStateView) => +object UseSerialStateView: + /** Creates component state as a View that is reused while it's not updated. */ + final def useSerialStateView[A](initialValue: => A): HookResult[ReuseView[A]] = + useStateView(SerialState.initial(initialValue)).map: serialStateView => Reuse.by(serialStateView.get.serial)(serialStateView.zoom(_.value)(mod => _.update(mod))) - ) + + // *** The rest is to support builder-style hooks *** // + + private def hook[A]: CustomHook[A, ReuseView[A]] = + CustomHook.fromHookResult(useSerialStateView(_)) object HooksApiExt { sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { @@ -63,4 +67,3 @@ object UseSerialStateView { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseShadowRef.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseShadowRef.scala index 8edd426a..543c5c37 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseShadowRef.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseShadowRef.scala @@ -7,14 +7,20 @@ import japgolly.scalajs.react.* import japgolly.scalajs.react.hooks.CustomHook import japgolly.scalajs.react.hooks.Hooks -object UseShadowRef { - def hook[A]: CustomHook[A, NonEmptyRef.Get[A]] = - CustomHook[A] - .useRefBy(identity) // current - .useEffectBy: (value, currentRef) => - currentRef.set(value) - .buildReturning: (_, currentRef) => - currentRef +object UseShadowRef: + /** + * Keeps a value in a ref. Useful for effectful get from a stable callback. + */ + final def useShadowRef[A](value: => A): HookResult[NonEmptyRef.Get[A]] = + for + currentRef <- useRef(value) + _ <- useEffect(currentRef.set(value)) + yield currentRef + + // *** The rest is to support builder-style hooks *** // + + private def hook[A]: CustomHook[A, NonEmptyRef.Get[A]] = + CustomHook.fromHookResult(useShadowRef(_)) object HooksApiExt { sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { @@ -58,4 +64,3 @@ object UseShadowRef { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseSingleEffect.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseSingleEffect.scala index 631d96d3..49781e46 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseSingleEffect.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseSingleEffect.scala @@ -17,8 +17,7 @@ import japgolly.scalajs.react.util.DefaultEffects.Async as DefaultA class UseSingleEffect[F[_]]( latch: Ref[F, Option[Deferred[F, UnitFiber[F]]]], // latch released as effect starts, holds fiber cleanup: Ref[F, Option[F[Unit]]] // cleanup of the currently running effect -)(using F: Async[F], parF: Parallel[F], monoid: Monoid[F[Unit]]) { - +)(using F: Async[F], parF: Parallel[F], monoid: Monoid[F[Unit]]): private def endOldEffect(oldLatch: Deferred[F, UnitFiber[F]]): F[Unit] = // 1) We ensure the effect of the last call has started by waiting for the latch. oldLatch.get.flatMap: oldFiber => @@ -53,17 +52,30 @@ class UseSingleEffect[F[_]]( // Worst case scenario, cancel will be called on it, which will do nothing. def submit[G](effect: G)(using EffectWithCleanup[G, F]) = switchTo(effect.normalize) -} -object UseSingleEffect { - val hook = CustomHook[Unit] - .useMemo(()): _ => - new UseSingleEffect( - Ref.unsafe[DefaultA, Option[Deferred[DefaultA, UnitFiber[DefaultA]]]](none), - Ref.unsafe[DefaultA, Option[DefaultA[Unit]]](none) - ) - .useEffectOnMountBy((_, singleEffect) => CallbackTo(singleEffect.cancel)) // Cleanup on unmount - .buildReturning((_, singleEffect) => singleEffect) +object UseSingleEffect: + /** + * Provides a context in which to run a single effect at a time. When a new effect is submitted, + * the previous one is canceled. Also cancels the effect on unmount. + * + * A submitted effect can be explicitly canceled too. + */ + final def useSingleEffect: HookResult[Reusable[UseSingleEffect[DefaultA]]] = + for + singleEffect <- + useMemo(()): _ => + new UseSingleEffect( + Ref.unsafe[DefaultA, Option[Deferred[DefaultA, UnitFiber[DefaultA]]]](none), + Ref.unsafe[DefaultA, Option[DefaultA[Unit]]](none) + ) + _ <- + useEffectOnMount(CallbackTo(singleEffect.cancel)) + yield singleEffect + + // *** The rest is to support builder-style hooks *** // + + private val hook: CustomHook[Unit, Reusable[UseSingleEffect[DefaultA]]] = + CustomHook.fromHookResult(useSingleEffect) object HooksApiExt { sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { @@ -89,4 +101,3 @@ object UseSingleEffect { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseStateCallback.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseStateCallback.scala index d25d49d8..77d81c02 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseStateCallback.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseStateCallback.scala @@ -10,22 +10,31 @@ import japgolly.scalajs.react.util.DefaultEffects.Sync as DefaultS import scala.collection.immutable.Queue -object UseStateCallback { - def hook[A] = - CustomHook[Hooks.UseState[A]] - .useRef(Queue.empty[A => DefaultS[Unit]]) +object UseStateCallback: + /** + * Given a state, allows registering callbacks which are triggered when the state changes. + */ + final def useStateCallback[A]( + state: => Hooks.UseState[A] + ): HookResult[Reusable[(A => DefaultS[Unit]) => DefaultS[Unit]]] = + for + delayedCallbacks <- useRef(Queue.empty[A => DefaultS[Unit]]) // Credit to japgolly for this implementation; this is copied from StateSnapshot. - .useEffectBy: (state, delayedCallbacks) => - val cbs = delayedCallbacks.value - if (cbs.isEmpty) - DefaultS.empty - else - delayedCallbacks.set(Queue.empty) >> - DefaultS.runAll(cbs.toList.map(_(state.value))*) - .useCallbackBy: (_, delayedCallbacks) => - (cb: A => DefaultS[Unit]) => delayedCallbacks.mod(_.enqueue(cb)) - .buildReturning: (_, _, onNextStateChange) => - onNextStateChange + _ <- useEffect: + val cbs = delayedCallbacks.value + if (cbs.isEmpty) + DefaultS.empty + else + delayedCallbacks.set(Queue.empty) >> + DefaultS.runAll(cbs.toList.map(_(state.value))*) + onNextStateChange <- useCallback: (cb: A => DefaultS[Unit]) => + delayedCallbacks.mod(_.enqueue(cb)) + yield onNextStateChange + + // *** The rest is to support builder-style hooks *** // + + private def hook[A]: CustomHook[Hooks.UseState[A], Reusable[(A => Callback) => Callback]] = + CustomHook.fromHookResult(useStateCallback(_)) object HooksApiExt { sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { @@ -81,4 +90,3 @@ object UseStateCallback { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseStateView.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseStateView.scala index 665ce825..47460631 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseStateView.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseStateView.scala @@ -8,20 +8,23 @@ import japgolly.scalajs.react.* import japgolly.scalajs.react.hooks.CustomHook import japgolly.scalajs.react.util.DefaultEffects.Sync as DefaultS -object UseStateView { - def hook[A]: CustomHook[A, View[A]] = - CustomHook[A] - .useStateBy(initialValue => initialValue) - .useShadowRef((_, state) => state.value) - .useStateCallbackBy((_, state, _) => state) - .useCallbackWithDepsBy((_, state, _, onNextStateChange) => - (state.modState, onNextStateChange) - ): (initialValue, _, stateRef, _) => - (modState, onNextStateChange) => +object UseStateView: + /** Creates component state as a View */ + final def useStateView[A](initialValue: => A): HookResult[View[A]] = + for + state <- useState(initialValue) + stateRef <- useShadowRef(state.value) + onNextStateChange <- useStateCallback(state) + modCB <- + useCallbackWithDeps((state.modState, onNextStateChange)): (modState, onNextStateChange) => (f: A => A, cb: (A, A) => DefaultS[Unit]) => stateRef.get >>= (previous => onNextStateChange(cb(previous, _)) >> modState(f)) - .buildReturning: (_, state, _, _, modCB) => - View[A](state.value, modCB) + yield View[A](state.value, modCB) + + // *** The rest is to support builder-style hooks *** // + + private def hook[A]: CustomHook[A, View[A]] = + CustomHook.fromHookResult(useStateView(_)) object HooksApiExt { sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { @@ -69,4 +72,3 @@ object UseStateView { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseStateViewWithReuse.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseStateViewWithReuse.scala index 159f6252..eedb1746 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseStateViewWithReuse.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseStateViewWithReuse.scala @@ -10,11 +10,17 @@ import japgolly.scalajs.react.hooks.CustomHook import scala.reflect.ClassTag -object UseStateViewWithReuse { - def hook[A: ClassTag: Reusability]: CustomHook[A, ReuseView[A]] = - CustomHook[A] - .useStateViewBy(initialValue => initialValue) - .buildReturning((_, view) => view.reuseByValue) +object UseStateViewWithReuse: + /** Creates component state as a View */ + final def useStateViewWithReuse[A: ClassTag: Reusability]( + initialValue: => A + ): HookResult[ReuseView[A]] = + useStateView(initialValue).map(_.reuseByValue) + + // *** The rest is to support builder-style hooks *** // + + private def hook[A: ClassTag: Reusability]: CustomHook[A, ReuseView[A]] = + CustomHook.fromHookResult(useStateViewWithReuse(_)) object HooksApiExt { sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { @@ -64,4 +70,3 @@ object UseStateViewWithReuse { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseStreamResource.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseStreamResource.scala index f932d633..9c5363b4 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseStreamResource.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseStreamResource.scala @@ -15,18 +15,17 @@ import japgolly.scalajs.react.util.DefaultEffects.Sync as DefaultS import scala.reflect.ClassTag -object UseStreamResource { +object UseStreamResource: private def buildStreamResource[D, A]( - props: WithDeps[D, StreamResource[A]], - setState: PotOption[A] => DefaultA[Unit] + streamResource: D => StreamResource[A], + setState: PotOption[A] => DefaultA[Unit] ): D => Resource[DefaultA, fs2.Stream[DefaultA, Unit]] = (deps: D) => Resource .eval(setState(PotOption.pending)) .flatMap: _ => - props - .fromDeps(deps) + streamResource(deps) .map: stream => fs2.Stream.eval(setState(PotOption.ReadyNone)) ++ stream @@ -34,28 +33,218 @@ object UseStreamResource { .handleErrorWith: t => fs2.Stream.eval(setState(PotOption.error(t))) - def hook[D: Reusability, A] = - CustomHook[WithDeps[D, StreamResource[A]]] - .useState(PotOption.pending[A]) - .useEffectStreamResourceWithDepsBy((props, _) => props.deps): (props, state) => - buildStreamResource(props, state.setStateAsync) - .buildReturning((_, state) => state.value) - - def hookView[D: Reusability, A] = - CustomHook[WithDeps[D, StreamResource[A]]] - .useStateView(PotOption.pending[A]) - .useEffectStreamResourceWithDepsBy((props, _) => props.deps): (props, state) => - buildStreamResource(props, state.set(_).to[DefaultA]) - .buildReturning: (_, state) => - state.toPotOptionView - - def hookReuseView[D: Reusability, A: ClassTag: Reusability] = - CustomHook[WithDeps[D, StreamResource[A]]] - .useStateViewWithReuse(PotOption.pending[A]) - .useEffectStreamResourceWithDepsBy((props, _) => props.deps): (props, state) => - buildStreamResource(props, state.set(_).to[DefaultA]) - .buildReturning: (_, state) => - state.map(_.toPotOptionView) + // START useStreamResource + + /** + * Open a `Resource[Async, fs.Stream[Async, A]]` on mount or when dependencies change, and drain + * the stream by creating a fiber. Provides pulled values as a `PotOption[A]`. Will rerender when + * the `PotOption` state changes. The fiber will be cancelled on unmount or deps change. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final def useStreamResource[D: Reusability, A](deps: => D)( + streamResource: D => StreamResource[A] + ): HookResult[PotOption[A]] = + for + state <- useState(PotOption.pending[A]) + _ <- useEffectStreamResourceWithDeps(deps): + buildStreamResource(streamResource, state.setStateAsync) + yield state.value + + /** + * Open a `Resource[Async, fs.Stream[Async, A]]` on mount, and drain the stream by creating a + * fiber. Provides pulled values as a `PotOption[A]`. Will rerender when the `PotOption` state + * changes. The fiber will be cancelled on unmount. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final inline def useStreamResourceOnMount[A]( + streamResource: StreamResource[A] + ): HookResult[PotOption[A]] = + useStreamResource(())(_ => streamResource) + + /** + * Drain a `fs2.Stream[Async, A]` by creating a fiber on mount or when deps change. Provides + * pulled values as a `PotOption[A]`. Will rerender when the `PotOption` state changes. The fiber + * will be cancelled on unmount or deps change. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final inline def useStream[D: Reusability, A](deps: => D)( + stream: D => fs2.Stream[DefaultA, A] + ): HookResult[PotOption[A]] = + useStreamResource(deps)(deps => Resource.pure(stream(deps))) + + /** + * Drain a `fs2.Stream[Async, A]` by creating a fiber on mount. Provides pulled values as a + * `PotOption[A]`. Will rerender when the `PotOption` state changes. The fiber will be cancelled + * on unmount. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final inline def useStreamOnMount[A](stream: fs2.Stream[DefaultA, A]): HookResult[PotOption[A]] = + useStreamResourceOnMount(Resource.pure(stream)) + + // END useStreamResource + + // START useStreamResourceView + + /** + * Open a `Resource[Async, fs.Stream[Async, A]]` on mount or when dependencies change, and drain + * the stream by creating a fiber. Provides pulled values as a `PotOption[View[A]]` so that the + * value can also be changed locally. Will rerender when the `PotOption` state changes. The fiber + * will be cancelled on unmount or deps change. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final def useStreamResourceView[D: Reusability, A](deps: => D)( + streamResource: D => StreamResource[A] + ): HookResult[PotOption[View[A]]] = + for + state <- useStateView(PotOption.pending[A]) + _ <- useEffectStreamResourceWithDeps(deps): + buildStreamResource(streamResource, state.set(_).to[DefaultA]) + yield state.toPotOptionView + + /** + * Open a `Resource[Async, fs.Stream[Async, A]]` on mount, and drain the stream by creating a + * fiber. Provides pulled values as a `PotOption[View[A]]` so that the value can also be changed + * locally. Will rerender when the `PotOption` state changes. The fiber will be cancelled on + * unmount. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final inline def useStreamResourceViewOnMount[A]( + streamResource: StreamResource[A] + ): HookResult[PotOption[View[A]]] = + useStreamResourceView(())(_ => streamResource) + + /** + * Drain a `fs2.Stream[Async, A]` by creating a fiber on mount or when deps change. Provides + * pulled values as a `PotOption[View[A]]` so that the value can also be changed locally. Will + * rerender when the `PotOption` state changes. The fiber will be cancelled on unmount or deps + * change. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final inline def useStreamView[D: Reusability, A](deps: => D)( + stream: D => fs2.Stream[DefaultA, A] + ): HookResult[PotOption[View[A]]] = + useStreamResourceView(deps)(deps => Resource.pure(stream(deps))) + + /** + * Drain a `fs2.Stream[Async, A]` by creating a fiber on mount. Provides pulled values as a + * `PotOption[View[A]]` so that the value can also be changed locally. Will rerender when the + * `PotOption` state changes. The fiber will be cancelled on unmount. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final inline def useStreamViewOnMount[A]( + stream: fs2.Stream[DefaultA, A] + ): HookResult[PotOption[View[A]]] = + useStreamResourceViewOnMount(Resource.pure(stream)) + + // END useStreamResourceView + + // START useStreamResourceViewWithReuse + + /** + * Open a `Resource[Async, fs.Stream[Async, A]]` on mount or when dependencies change, and drain + * the stream by creating a fiber. Provides pulled values as a `Reuse[PotOption[View[A]]` so that + * the value can also be changed locally, reusable by value. Will rerender when the `PotOption` + * state changes. The fiber will be cancelled on unmount or deps change. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final def useStreamResourceViewWithReuse[D: Reusability, A: ClassTag: Reusability]( + deps: => D + )( + streamResource: D => StreamResource[A] + ): HookResult[Reuse[PotOption[View[A]]]] = + for + state <- useStateViewWithReuse(PotOption.pending[A]) + _ <- useEffectStreamResourceWithDeps(deps): + buildStreamResource(streamResource, state.set(_).to[DefaultA]) + yield state.map(_.toPotOptionView) + + /** + * Open a `Resource[Async, fs.Stream[Async, A]]` on mount, and drain the stream by creating a + * fiber. Provides pulled values as a `Reuse[PotOption[View[A]]` so that the value can also be + * changed locally, reusable by value. Will rerender when the `PotOption` state changes. The fiber + * will be cancelled on unmount. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final inline def useStreamResourceViewWithReuseOnMount[A: ClassTag: Reusability]( + streamResource: StreamResource[A] + ): HookResult[Reuse[PotOption[View[A]]]] = + useStreamResourceViewWithReuse(())(_ => streamResource) + + /** + * Drain a `fs2.Stream[Async, A]` by creating a fiber on mount or when deps change. Provides + * pulled values as a `Reuse[PotOption[View[A]]` so that the value can also be changed locally, + * reusable by value. Will rerender when the `PotOption` state changes. The fiber will be + * cancelled on unmount or deps change. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final inline def useStreamViewWithReuse[D: Reusability, A: ClassTag: Reusability]( + deps: => D + )( + stream: D => fs2.Stream[DefaultA, A] + ): HookResult[Reuse[PotOption[View[A]]]] = + useStreamResourceViewWithReuse(deps)(deps => Resource.pure(stream(deps))) + + /** + * Drain a `fs2.Stream[Async, A]` by creating a fiber on mount. Provides pulled values as a + * `Reuse[PotOption[View[A]]` so that the value can also be changed locally, reusable by value. + * Will rerender when the `PotOption` state changes. The fiber will be cancelled on unmount. + * + * The value will be `Pending` when the stream hasn't been mounted yet, `ReadyNone` when the + * stream is mounted but no value received yet, and `ReadySome(a)` when `a` is the last value + * received. + */ + final inline def useStreamViewWithReuseOnMount[A: ClassTag: Reusability]( + stream: fs2.Stream[DefaultA, A] + ): HookResult[Reuse[PotOption[View[A]]]] = + useStreamResourceViewWithReuseOnMount(Resource.pure(stream)) + + // END useStreamResourceViewWithReuse + + // *** The rest is to support builder-style hooks *** // + + private def hook[D: Reusability, A]: CustomHook[WithDeps[D, StreamResource[A]], PotOption[A]] = + CustomHook.fromHookResult(input => useStreamResource(input.deps)(input.fromDeps)) + + private def hookView[D: Reusability, A] + : CustomHook[WithDeps[D, StreamResource[A]], PotOption[View[A]]] = + CustomHook.fromHookResult(input => useStreamResourceView(input.deps)(input.fromDeps)) + + private def hookReuseView[D: Reusability, A: ClassTag: Reusability] + : CustomHook[WithDeps[D, StreamResource[A]], Reuse[PotOption[View[A]]]] = + CustomHook.fromHookResult(input => useStreamResourceViewWithReuse(input.deps)(input.fromDeps)) object HooksApiExt { sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { @@ -719,4 +908,3 @@ object UseStreamResource { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseThrottlingStateView.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseThrottlingStateView.scala index cfe898e0..7a41e28f 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseThrottlingStateView.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseThrottlingStateView.scala @@ -11,13 +11,20 @@ import japgolly.scalajs.react.hooks.CustomHook import scala.concurrent.duration.FiniteDuration -object UseThrottlingStateView { - def hook[A]: CustomHook[(A, FiniteDuration), Pot[ThrottlingView[A]]] = - CustomHook[(A, FiniteDuration)] - .useStateViewBy(props => props._1) - .useEffectResultOnMountBy((props, _) => ViewThrottler[A](props._2)) - .buildReturning: (_, view, throttler) => - throttler.map(_.throttle(view)) +object UseThrottlingStateView: + /** Creates component state as a `ThrottlingView`. See `ViewThrottler[A]`. */ + final def useThrottlingStateView[A]( + input: (A, FiniteDuration) + ): HookResult[Pot[ThrottlingView[A]]] = + for + view <- useStateView(input._1) + throttler <- useEffectResultOnMount(ViewThrottler[A](input._2)) + yield throttler.map(_.throttle(view)) + + // *** The rest is to support builder-style hooks *** // + + private def hook[A]: CustomHook[(A, FiniteDuration), Pot[ThrottlingView[A]]] = + CustomHook.fromHookResult(useThrottlingStateView(_)) object HooksApiExt { sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { @@ -68,4 +75,3 @@ object UseThrottlingStateView { } object syntax extends HooksApiExt -} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/package.scala b/modules/core/js/src/main/scala/crystal/react/hooks/package.scala index 59d7eb48..50692803 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/package.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/package.scala @@ -10,11 +10,46 @@ import crystal.Pot import crystal.react.reuse.* import japgolly.scalajs.react.util.DefaultEffects.Async as DefaultA -export UseSingleEffect.syntax.*, UseSerialState.syntax.*, UseStateCallback.syntax.*, - UseStateView.syntax.*, UseStateViewWithReuse.syntax.*, UseSerialStateView.syntax.*, - UseAsyncEffect.syntax.*, UseEffectResult.syntax.*, UseResource.syntax.*, - UseStreamResource.syntax.*, UseEffectWhenDepsReady.syntax.*, UseEffectStreamResource.syntax.*, - UseShadowRef.syntax.*, UseThrottlingStateView.syntax.* +export UseSingleEffect.useSingleEffect, UseSerialState.useSerialState, + UseStateCallback.useStateCallback, UseShadowRef.useShadowRef, UseStateView.useStateView, + UseStateViewWithReuse.useStateViewWithReuse, UseSerialStateView.useSerialStateView, + UseAsyncEffect.{useAsyncEffect, useAsyncEffectOnMount, useAsyncEffectWithDeps}, UseEffectResult.{ + useEffectKeepResultWhenDepsReady, + useEffectKeepResultWhenDepsReadyOrChange, + useEffectKeepResultWithDeps, + useEffectResultOnMount, + useEffectResultWhenDepsReady, + useEffectResultWhenDepsReadyOrChange, + useEffectResultWithDeps +}, UseResource.{useResource, useResourceOnMount}, UseThrottlingStateView.useThrottlingStateView, + UseEffectStreamResource.{ + useEffectStream, + useEffectStreamOnMount, + useEffectStreamResource, + useEffectStreamResourceOnMount, + useEffectStreamResourceWhenDepsReady, + useEffectStreamResourceWithDeps, + useEffectStreamWhenDepsReady, + useEffectStreamWithDeps +}, UseStreamResource.{ + useStream, + useStreamOnMount, + useStreamResource, + useStreamResourceOnMount, + useStreamResourceView, + useStreamResourceViewOnMount, + useStreamResourceViewWithReuse, + useStreamResourceViewWithReuseOnMount, + useStreamView, + useStreamViewOnMount, + useStreamViewWithReuse, + useStreamViewWithReuseOnMount +}, UseEffectWhenDepsReady.{ + useAsyncEffectWhenDepsReady, + useAsyncEffectWhenDepsReadyOrChange, + useEffectWhenDepsReady, + useEffectWhenDepsReadyOrChange +} type UnitFiber[F[_]] = Fiber[F, Throwable, Unit] @@ -23,6 +58,14 @@ type StreamResource[A] = Resource[DefaultA, fs2.Stream[DefaultA, A]] protected[hooks] type NeverReuse = Reuse[Unit] protected[hooks] val NeverReuse: NeverReuse = ().reuseNever +// *** The rest is to support builder-style hooks *** // + +export UseSingleEffect.syntax.*, UseSerialState.syntax.*, UseStateCallback.syntax.*, + UseStateView.syntax.*, UseStateViewWithReuse.syntax.*, UseSerialStateView.syntax.*, + UseAsyncEffect.syntax.*, UseEffectResult.syntax.*, UseResource.syntax.*, + UseStreamResource.syntax.*, UseEffectWhenDepsReady.syntax.*, UseEffectStreamResource.syntax.*, + UseShadowRef.syntax.*, UseThrottlingStateView.syntax.* + protected[hooks] case class WithDeps[D, A](deps: D, fromDeps: D => A) protected[hooks] enum WithPotDeps[D, A, R]( diff --git a/project/Settings.scala b/project/Settings.scala index 407175ec..0f5ad17a 100644 --- a/project/Settings.scala +++ b/project/Settings.scala @@ -17,7 +17,7 @@ object Settings { val mUnitScalacheck = "1.0.0" val mUnitCatsEffect = "2.0.0" val scalaCheck = "1.18.1" - val scalajsReact = "3.0.0-beta6" + val scalajsReact = "3.0.0-beta8" } object Libraries {