diff --git a/build.sbt b/build.sbt index 546206de..56557f21 100644 --- a/build.sbt +++ b/build.sbt @@ -19,7 +19,7 @@ ThisBuild / githubWorkflowJavaVersions := Seq(JavaSpec.temurin("17")) ThisBuild / tlJdkRelease := Some(8) val CatsVersion = "2.9.0" -val CatsEffectVersion = "3.4.6" +val CatsEffectVersion = "3.5.0-RC1" val Fs2Version = "3.6.1" val Fs2DomVersion = "0.2.0-RC1" val MonocleVersion = "3.2.0" @@ -128,7 +128,8 @@ lazy val unidocs = project .enablePlugins(ScalaJSPlugin, TypelevelUnidocPlugin) .settings( name := "calico-docs", - ScalaUnidoc / unidoc / unidocProjectFilter := inProjects(frp.js, calico, router) + ScalaUnidoc / unidoc / unidocProjectFilter := inProjects(frp.js, calico, router), + ScalaUnidoc / unidoc / fullClasspath := (todoMvc / Compile / fullClasspath).value ) lazy val jsdocs = project.dependsOn(calico, router).enablePlugins(ScalaJSPlugin) diff --git a/calico/src/main/scala/calico/html/Children.scala b/calico/src/main/scala/calico/html/Children.scala index ef566d97..ba1a3113 100644 --- a/calico/src/main/scala/calico/html/Children.scala +++ b/calico/src/main/scala/calico/html/Children.scala @@ -93,7 +93,7 @@ private trait ChildrenModifiers[F[_]](using F: Async[F]): n.appendChild(dom.document.createComment("")) } } - _ <- tail + update = tail .foreach { children => hs.swap(children) { (prev, next) => F.delay { @@ -104,7 +104,7 @@ private trait ChildrenModifiers[F[_]](using F: Async[F]): } .compile .drain - .cedeBackground + _ <- (F.cede *> update).background yield () final class KeyedChildren[F[_], K] private[calico] (f: K => Resource[F, fs2.dom.Node[F]]): @@ -128,7 +128,7 @@ private trait KeyedChildrenModifiers[F[_]](using F: Async[F]): val n = _n.asInstanceOf[dom.Node] inline def build(k: K) = m.build(k).asInstanceOf[Resource[F, dom.Node]] for - (head, tail) <- m.keys.getAndDiscreteUpdates + (head, tail) <- m.keys.changes.getAndDiscreteUpdates active <- Resource.makeFull[F, Ref[F, mutable.Map[K, (dom.Node, F[Unit])]]] { poll => def go(keys: List[K], active: mutable.Map[K, (dom.Node, F[Unit])]): F[Unit] = if keys.isEmpty then F.unit @@ -143,12 +143,10 @@ private trait KeyedChildrenModifiers[F[_]](using F: Async[F]): .flatTap(active => go(head, active).onCancel(traverse_(active.values)(_._2))) .flatMap(F.ref(_)) }( - _.get.flatMap(ns => traverse_(ns.values)(_._2)).evalOn(unsafe.MacrotaskExecutor) + F.cede *> _.get.flatMap(ns => traverse_(ns.values)(_._2)) ) sentinel <- Resource.eval(F.delay(n.appendChild(dom.document.createComment("")))) - _ <- tail - .dropWhile(_ === head) - .changes + update = tail .foreach { keys => F.uncancelable { poll => active.get.flatMap { currentNodes => @@ -173,7 +171,7 @@ private trait KeyedChildrenModifiers[F[_]](using F: Async[F]): } (active.set(nextNodes) *> acquireNewNodes *> renderNextNodes).guarantee( - releaseOldNodes.evalOn(unsafe.MacrotaskExecutor) + F.cede *> releaseOldNodes ) }.flatten } @@ -181,5 +179,5 @@ private trait KeyedChildrenModifiers[F[_]](using F: Async[F]): } .compile .drain - .cedeBackground + _ <- (F.cede *> update).background yield () diff --git a/calico/src/main/scala/calico/html/DomHotswap.scala b/calico/src/main/scala/calico/html/DomHotswap.scala index ba842f5d..0cc56e63 100644 --- a/calico/src/main/scala/calico/html/DomHotswap.scala +++ b/calico/src/main/scala/calico/html/DomHotswap.scala @@ -17,17 +17,17 @@ package calico package html -import cats.effect.kernel.Async +import cats.effect.kernel.Concurrent import cats.effect.kernel.Resource import cats.effect.syntax.all.* import cats.syntax.all.* -private[calico] abstract class DomHotswap[F[_], A]: +private abstract class DomHotswap[F[_], A]: def swap(next: Resource[F, A])(render: (A, A) => F[Unit]): F[Unit] -private[calico] object DomHotswap: +private object DomHotswap: def apply[F[_], A](init: Resource[F, A])( - using F: Async[F] + using F: Concurrent[F] ): Resource[F, (DomHotswap[F, A], A)] = Resource.make(init.allocated.flatMap(F.ref(_)))(_.get.flatMap(_._2)).evalMap { active => val hs = new DomHotswap[F, A]: @@ -36,8 +36,7 @@ private[calico] object DomHotswap: nextAllocated <- poll(next.allocated) (oldA, oldFinalizer) <- active.getAndSet(nextAllocated) newA = nextAllocated._1 - _ <- render(oldA, newA) - _ <- oldFinalizer.evalOn(unsafe.MacrotaskExecutor) + _ <- render(oldA, newA) *> F.cede *> oldFinalizer yield () } diff --git a/calico/src/main/scala/calico/html/Modifier.scala b/calico/src/main/scala/calico/html/Modifier.scala index 94628ce7..79d50831 100644 --- a/calico/src/main/scala/calico/html/Modifier.scala +++ b/calico/src/main/scala/calico/html/Modifier.scala @@ -43,22 +43,21 @@ object Modifier: def contramap[A, B](fa: Modifier[Id, Any, A])(f: B => A) = fa.contramap(f) - private[html] def forSignal[F[_]: Async, E, M, V](signal: M => Signal[F, V])( - mkModify: (M, E) => V => F[Unit]): Modifier[F, E, M] = (m, e) => + private[html] def forSignal[F[_], E, M, V](signal: M => Signal[F, V])( + mkModify: (M, E) => V => F[Unit])(using F: Async[F]): Modifier[F, E, M] = (m, e) => signal(m).getAndDiscreteUpdates.flatMap { (head, tail) => val modify = mkModify(m, e) Resource.eval(modify(head)) *> - tail.foreach(modify(_)).compile.drain.cedeBackground.void + (F.cede *> tail.foreach(modify(_)).compile.drain).background.void } - private[html] def forSignalResource[F[_]: Async, E, M, V]( - signal: M => Resource[F, Signal[F, V]])( - mkModify: (M, E) => V => F[Unit]): Modifier[F, E, M] = (m, e) => + private[html] def forSignalResource[F[_], E, M, V](signal: M => Resource[F, Signal[F, V]])( + mkModify: (M, E) => V => F[Unit])(using F: Async[F]): Modifier[F, E, M] = (m, e) => signal(m).flatMap { sig => sig.getAndDiscreteUpdates.flatMap { (head, tail) => val modify = mkModify(m, e) Resource.eval(modify(head)) *> - tail.foreach(modify(_)).compile.drain.cedeBackground.void + (F.cede *> tail.foreach(modify(_)).compile.drain).background.void } } @@ -89,7 +88,7 @@ private trait Modifiers[F[_]](using F: Async[F]): Resource .eval(F.delay(e.appendChild(dom.document.createTextNode(head)))) .flatMap { n => - tail.foreach(t => F.delay(n.textContent = t)).compile.drain.cedeBackground + (F.cede *> tail.foreach(t => F.delay(n.textContent = t)).compile.drain).background } .void } @@ -126,11 +125,10 @@ private trait Modifiers[F[_]](using F: Async[F]): n2s.getAndDiscreteUpdates.flatMap { (head, tail) => DomHotswap(head).flatMap { (hs, n2) => F.delay(n.appendChild(n2)).toResource *> - tail + (F.cede *> tail .foreach(hs.swap(_)((n2, n3) => F.delay(n.replaceChild(n3, n2)))) .compile - .drain - .cedeBackground + .drain).background }.void } diff --git a/calico/src/main/scala/calico/html/Prop.scala b/calico/src/main/scala/calico/html/Prop.scala index 1b9f94ce..4c3dbb32 100644 --- a/calico/src/main/scala/calico/html/Prop.scala +++ b/calico/src/main/scala/calico/html/Prop.scala @@ -24,6 +24,7 @@ import cats.FunctorFilter import cats.Id import cats.effect.kernel.Async import cats.effect.kernel.Resource +import cats.effect.syntax.all.* import cats.syntax.all.* import fs2.Pipe import fs2.Stream @@ -178,7 +179,7 @@ private trait EventPropModifiers[F[_]](using F: Async[F]): inline given forPipeEventProp[T <: fs2.dom.Node[F]]: Modifier[F, T, PipeModifier[F]] = _forPipeEventProp.asInstanceOf[Modifier[F, T, PipeModifier[F]]] private val _forPipeEventProp: Modifier[F, dom.EventTarget, PipeModifier[F]] = - (m, t) => fs2.dom.events(t, m.key).through(m.sink).compile.drain.cedeBackground.void + (m, t) => (F.cede *> fs2.dom.events(t, m.key).through(m.sink).compile.drain).background.void final class ClassProp[F[_]] private[calico] extends Prop[F, List[String], String]( diff --git a/calico/src/main/scala/calico/syntax.scala b/calico/src/main/scala/calico/syntax.scala index 83f6225d..419e4827 100644 --- a/calico/src/main/scala/calico/syntax.scala +++ b/calico/src/main/scala/calico/syntax.scala @@ -47,22 +47,6 @@ extension [F[_]](component: Resource[F, fs2.dom.Node[F]]) def renderInto(root: fs2.dom.Node[F])(using Functor[F], Dom[F]): Resource[F, Unit] = component.flatMap { e => Resource.make(root.appendChild(e))(_ => root.removeChild(e)) } -extension [F[_], A](fa: F[A]) - private[calico] def cedeBackground( - using F: Async[F]): Resource[F, F[Outcome[F, Throwable, A]]] = - F.executionContext.toResource.flatMap { ec => - Resource - .make(F.deferred[Fiber[F, Throwable, A]])(_.get.flatMap(_.cancel)) - .evalTap { deferred => - fa.start - .flatMap(deferred.complete(_)) - .evalOn(ec) - .startOn(unsafe.MacrotaskExecutor) - .start - } - .map(_.get.flatMap(_.join)) - } - extension [F[_], A](sigRef: SignallingRef[F, A]) def zoom[B](lens: Lens[A, B])(using Functor[F]): SignallingRef[F, B] = SignallingRef.lens[F, A, B](sigRef)(lens.get(_), a => b => lens.replace(b)(a)) diff --git a/calico/src/main/scala/calico/unsafe/MacrotaskExecutor.scala b/calico/src/main/scala/calico/unsafe/MacrotaskExecutor.scala deleted file mode 100644 index 0e8b7aba..00000000 --- a/calico/src/main/scala/calico/unsafe/MacrotaskExecutor.scala +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2022 Arman Bilge - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package calico.unsafe - -export org.scalajs.macrotaskexecutor.MacrotaskExecutor diff --git a/calico/src/main/scala/calico/unsafe/MicrotaskExecutor.scala b/calico/src/main/scala/calico/unsafe/MicrotaskExecutor.scala deleted file mode 100644 index ce6e0e25..00000000 --- a/calico/src/main/scala/calico/unsafe/MicrotaskExecutor.scala +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2022 Arman Bilge - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package calico.unsafe - -import scala.concurrent.ExecutionContext -import scala.scalajs.js -import scala.scalajs.js.annotation.JSGlobal - -private[calico] object MicrotaskExecutor extends ExecutionContext: - - def execute(runnable: Runnable): Unit = queueMicrotask(() => runnable.run()) - - def reportFailure(cause: Throwable): Unit = cause.printStackTrace() - - @JSGlobal("queueMicrotask") - @js.native - private def queueMicrotask(function: js.Function0[Any]): Unit = js.native diff --git a/calico/src/main/scala/calico/unsafe/runtime.scala b/calico/src/main/scala/calico/unsafe/runtime.scala index 6c46b307..cfc8ee8d 100644 --- a/calico/src/main/scala/calico/unsafe/runtime.scala +++ b/calico/src/main/scala/calico/unsafe/runtime.scala @@ -18,12 +18,13 @@ package calico.unsafe import cats.effect.unsafe.IORuntime import cats.effect.unsafe.IORuntimeConfig -import cats.effect.unsafe.Scheduler -given IORuntime = IORuntime( - MicrotaskExecutor, - MicrotaskExecutor, - Scheduler.createDefaultScheduler()._1, - () => (), - IORuntimeConfig() -) +given IORuntime = // never auto-cede, to prevent glitchy rendering + val ec = IORuntime.createBatchingMacrotaskExecutor(Int.MaxValue) + IORuntime( + ec, + ec, + IORuntime.defaultScheduler, + () => (), + IORuntimeConfig(1, Int.MaxValue) + ) diff --git a/docs/index.md b/docs/index.md index 68c45dea..db7648d9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -118,19 +118,15 @@ A JavaScript webapp typically has a flow like: Notice that this scheduling strategy guarantees glitch-free rendering. Because all tasks triggered by an event must complete before the view re-renders, the user will never see inconsistent state in the UI. -However, there are certain situations where running a task with high-priority may not be desirable and you would prefer that it runs in the "background" while your application continues to be responsive. This is relevant only if you are doing an expensive calculation or processing task; for example, there is no need to explicitly background I/O tasks since they operate via the event-driven flow described above. - -In these cases, you should break that expensive task into smaller steps and schedule it as a so-called _macrotask_: +However, there are certain situations where you may want the browser to re-render in the middle of a task. In these cases, simply sequence an `IO.cede` operation. This will temporarily yield control flow back to the browser so that it may re-render the UI, before resuming the task. ```scala -import calico.unsafe.MacrotaskExecutor - -val expensiveTask = IO(smallStep1()) *> IO(smallStep2()) *> IO(smallStep3()) *> ... -expensiveTask.evalOn(MacrotaskExecutor) +updateComponentA *> // doesn't render yet + updateComponentB *> // still didn't render + IO.cede *> // re-render now + doOtherStuff *> ... // do non-view-related work ``` -The [`MacrotaskExecutor`](https://github.com/scala-js/scala-js-macrotask-executor) schedules macrotasks with equal priority to event processing and UI rendering. Conceptually, it is somewhat analogous to using `IO.blocking(...)` on the JVM, in that running these tasks on a separate `ExecutionContext` preserves fairness and enables your application to continue responding to incoming events. Conversely, forgetting to use `.evalOn(MacrotaskExecutor)` or `IO.blocking(...)` could cause your application to become unresponsive. - -However, I suspect situations where you need to use the `MacrotaskExecutor` in webapp are rare. If you truly have a long-running, compute-intensive task that you do not want to compromise the responsiveness of your application, you should consider running it in a background thread via a [WebWorker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) instead. +Explicitly inserting an `IO.cede` can be a useful strategy to improve your app’s UX, by re-rendering as soon as you are done updating the view, and deferring other work until after the re-render. This will make your UI more responsive. -To learn more about microtasks and macrotasks I recommend [this article about the JavaScript event loop](https://javascript.info/event-loop). +To learn more I recommend [this article about the JavaScript event loop](https://javascript.info/event-loop). diff --git a/router/src/main/scala/calico/router/Router.scala b/router/src/main/scala/calico/router/Router.scala index 9e9bd111..af1a8a5f 100644 --- a/router/src/main/scala/calico/router/Router.scala +++ b/router/src/main/scala/calico/router/Router.scala @@ -16,7 +16,7 @@ package calico.router -import cats.effect.kernel.Async +import cats.effect.kernel.Concurrent import cats.effect.kernel.RefSink import cats.effect.kernel.Resource import cats.effect.kernel.Unique @@ -80,7 +80,7 @@ abstract class Router[F[_]] private (): Resource.eval(routes).flatMap(dispatch) object Router: - def apply[F[_]: Dom](window: Window[F])(using F: Async[F]): F[Router[F]] = + def apply[F[_]: Dom](window: Window[F])(using F: Concurrent[F]): F[Router[F]] = Topic[F, Uri].map { gps => val history = window.history[Unit] new: @@ -120,7 +120,8 @@ object Router: F.uncancelable { _ => container.removeChild(oldChild) *> currentRoute.set(None) *> - finalizer.evalOn(MacrotaskExecutor) + F.cede *> + finalizer } case (None, Some(route)) => F.uncancelable { poll => @@ -138,7 +139,7 @@ object Router: case ((sink, child), newFinalizer) => container.replaceChild(child, oldChild) *> currentRoute.set(Some((route.key, child, sink, newFinalizer))) - } *> oldFinalizer.evalOn(MacrotaskExecutor) + } *> F.cede *> oldFinalizer } } } diff --git a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala index 1f054caf..be19c1f7 100644 --- a/todo-mvc/src/main/scala/todomvc/TodoMvc.scala +++ b/todo-mvc/src/main/scala/todomvc/TodoMvc.scala @@ -204,10 +204,10 @@ object TodoStore: _ <- mapRef .discrete - .foreach(todos => window.localStorage.setItem(key, todos.asJson.noSpaces)) + .foreach(todos => IO.cede *> window.localStorage.setItem(key, todos.asJson.noSpaces)) .compile .drain - .backgroundOn(calico.unsafe.MacrotaskExecutor) + .background yield new TodoStore(mapRef, IO.realTime.map(_.toMillis)) case class Todo(text: String, completed: Boolean) derives Codec.AsObject