From 0980f6601543b491806b934629b83203c001ea9f Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Mon, 25 Mar 2024 08:33:38 +0000 Subject: [PATCH] Fixed #254: Runtime is less CPU intensive Remove ModelHolder A model update triggers redraw Remove unused argument Call redraw on a rendering class Record last triggered time Minor refactoring Anemic Renderer Revert "Anemic Renderer" This reverts commit f297cb7daba332b88a3cbe055f66ad965e231e31. Imperative version, but it kinda works. Tidying up comments Use the model Ref to use all updates Replaced imperative impl with CE --- .../scala/tyrian/runtime/ModelHolder.scala | 3 - .../main/scala/tyrian/runtime/Renderer.scala | 89 +++++++++++ .../scala/tyrian/runtime/RendererState.scala | 5 + .../main/scala/tyrian/runtime/Rendering.scala | 17 +-- .../scala/tyrian/runtime/TyrianRuntime.scala | 140 +++++++++--------- 5 files changed, 170 insertions(+), 84 deletions(-) delete mode 100644 tyrian/js/src/main/scala/tyrian/runtime/ModelHolder.scala create mode 100644 tyrian/js/src/main/scala/tyrian/runtime/Renderer.scala create mode 100644 tyrian/js/src/main/scala/tyrian/runtime/RendererState.scala diff --git a/tyrian/js/src/main/scala/tyrian/runtime/ModelHolder.scala b/tyrian/js/src/main/scala/tyrian/runtime/ModelHolder.scala deleted file mode 100644 index 95e6077a..00000000 --- a/tyrian/js/src/main/scala/tyrian/runtime/ModelHolder.scala +++ /dev/null @@ -1,3 +0,0 @@ -package tyrian.runtime - -final case class ModelHolder[Model](model: Model, updated: Boolean) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Renderer.scala b/tyrian/js/src/main/scala/tyrian/runtime/Renderer.scala new file mode 100644 index 00000000..1e6be345 --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/runtime/Renderer.scala @@ -0,0 +1,89 @@ +package tyrian.runtime + +import cats.effect.kernel.Async +import cats.effect.kernel.Clock +import cats.effect.kernel.Ref +import cats.effect.std.Dispatcher +import cats.syntax.all.* +import org.scalajs.dom +import snabbdom.VNode +import tyrian.Html +import tyrian.Location + +final case class Renderer(vnode: VNode, state: RendererState): + + def runningAt(t: Long): Renderer = + this.copy(state = RendererState.Running(t)) + +object Renderer: + + def init[F[_]](vnode: VNode)(using F: Async[F]): F[Ref[F, Renderer]] = + F.ref( + Renderer(vnode, RendererState.Idle) + ) + + private val timeout: Long = 1000 + + // This function gets called on every model update + def redraw[F[_], Model, Msg]( + dispatcher: Dispatcher[F], + renderer: Ref[F, Renderer], + model: Ref[F, Model], + view: Model => Html[Msg], + onMsg: Msg => Unit, + router: Location => Msg + )(using F: Async[F], clock: Clock[F]): F[Unit] = + clock.realTime.flatMap { time => + renderer.modify { r => + r.state match + case RendererState.Idle => + // If the render state is idle, update the last triggered time and begin. + r.runningAt(time.toMillis) -> + F.delay( + dom.window.requestAnimationFrame(_ => + render(dispatcher, renderer, model, view, onMsg, router)(time.toMillis) + ) + ).void + + case RendererState.Running(_) => + // If the render state is running, just update the triggered time. + r.runningAt(time.toMillis) -> F.unit + } + }.flatten + + private def render[F[_], Model, Msg]( + dispatcher: Dispatcher[F], + renderer: Ref[F, Renderer], + model: Ref[F, Model], + view: Model => Html[Msg], + onMsg: Msg => Unit, + router: Location => Msg + )(t: Long)(using F: Async[F], clock: Clock[F]): Unit = + dispatcher.unsafeRunAndForget { + for { + time <- clock.realTime.map(_.toMillis) + m <- model.get + + res <- renderer.modify { r => + r.state match + case RendererState.Idle => + // Something has gone wrong, do nothing. + r -> F.unit + + case RendererState.Running(lastTriggered) => + // If nothing has happened, set to idle and do not loop + if t - lastTriggered >= timeout then r.copy(state = RendererState.Idle) -> F.unit + else + // Otherwise, re-render and set the state appropriately + r.copy( + vnode = Rendering.render(r.vnode, m, view, onMsg, router) + ) -> + F.delay( + // Loop + dom.window.requestAnimationFrame(_ => + render(dispatcher, renderer, model, view, onMsg, router)(time) + ) + ).void + }.flatten + } yield res + } diff --git a/tyrian/js/src/main/scala/tyrian/runtime/RendererState.scala b/tyrian/js/src/main/scala/tyrian/runtime/RendererState.scala new file mode 100644 index 00000000..e774261f --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/runtime/RendererState.scala @@ -0,0 +1,5 @@ +package tyrian.runtime + +enum RendererState derives CanEqual: + case Idle + case Running(lastTriggered: Long) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala index 1eb99c2c..ca4f998f 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala @@ -3,20 +3,9 @@ package tyrian.runtime import org.scalajs.dom import org.scalajs.dom.Element import org.scalajs.dom.window -import snabbdom._ -import snabbdom.modules._ -import tyrian.Attr -import tyrian.Attribute -import tyrian.Empty -import tyrian.Event -import tyrian.Html -import tyrian.Location -import tyrian.NamedAttribute -import tyrian.PropertyBoolean -import tyrian.PropertyString -import tyrian.RawTag -import tyrian.Tag -import tyrian.Text +import snabbdom.* +import snabbdom.modules.* +import tyrian.* import scala.scalajs.js diff --git a/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala b/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala index 93c965c5..5e149e3b 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala @@ -1,6 +1,8 @@ package tyrian.runtime import cats.effect.kernel.Async +import cats.effect.kernel.Clock +import cats.effect.kernel.Ref import cats.effect.std.AtomicCell import cats.effect.std.Dispatcher import cats.effect.std.Queue @@ -8,15 +10,12 @@ import cats.effect.syntax.all.* import cats.syntax.all.* import org.scalajs.dom import org.scalajs.dom.Element -import snabbdom.VNode import snabbdom.toVNode import tyrian.Cmd import tyrian.Html import tyrian.Location import tyrian.Sub -import scala.annotation.nowarn - object TyrianRuntime: def apply[F[_], Model, Msg]( @@ -27,72 +26,79 @@ object TyrianRuntime: update: Model => Msg => (Model, Cmd[F, Msg]), view: Model => Html[Msg], subscriptions: Model => Sub[F, Msg] - )(using F: Async[F]): F[Nothing] = Dispatcher.sequential[F].use { dispatcher => - (F.ref(ModelHolder(initModel, true)), AtomicCell[F].of(List.empty[(String, F[Unit])]), Queue.unbounded[F, Msg]) - .flatMapN { (model, currentSubs, msgQueue) => - - def runCmd(cmd: Cmd[F, Msg]): F[Unit] = - CmdHelper.cmdToTaskList(cmd).foldMapM { task => - task.handleError(_ => None).flatMap(_.traverse_(msgQueue.offer(_))).start.void - } - - def runSub(sub: Sub[F, Msg]): F[Unit] = - currentSubs.evalUpdate { oldSubs => - val allSubs = SubHelper.flatten(sub) - val (stillAlive, discarded) = SubHelper.aliveAndDead(allSubs, oldSubs) - - val newSubs = SubHelper - .findNewSubs(allSubs, stillAlive.map(_._1), Nil) - .traverse(SubHelper.runObserve(_) { result => - dispatcher.unsafeRunAndForget( - result.toOption.flatten.foldMapM(msgQueue.offer(_).void) - ) - }) - - discarded.foldMapM(_.start.void) *> newSubs.map(_ ++ stillAlive) + )(using F: Async[F]): F[Nothing] = + Dispatcher.sequential[F].use { dispatcher => + val loop = mainLoop(dispatcher, router, initCmd, update, view, subscriptions) + val model = F.ref(initModel) + val currentSubs = AtomicCell[F].of(List.empty[(String, F[Unit])]) + val msgQueue = Queue.unbounded[F, Msg] + val renderer = Renderer.init(toVNode(node)) + + (model, currentSubs, msgQueue, renderer).flatMapN(loop) + } + + def mainLoop[F[_], Model, Msg]( + dispatcher: Dispatcher[F], + router: Location => Msg, + initCmd: Cmd[F, Msg], + update: Model => Msg => (Model, Cmd[F, Msg]), + view: Model => Html[Msg], + subscriptions: Model => Sub[F, Msg] + )( + model: Ref[F, Model], + currentSubs: AtomicCell[F, List[(String, F[Unit])]], + msgQueue: Queue[F, Msg], + renderer: Ref[F, Renderer] + )(using F: Async[F], clock: Clock[F]): F[Nothing] = + val runCmd: Cmd[F, Msg] => F[Unit] = runCommands(msgQueue) + val runSub: Sub[F, Msg] => F[Unit] = runSubscriptions(currentSubs, msgQueue, dispatcher) + val onMsg: Msg => Unit = postMsg(dispatcher, msgQueue) + + val msgLoop: F[Nothing] = + msgQueue.take.flatMap { msg => + for { + cmdsAndSubs <- model.modify { oldModel => + val (newModel, cmd) = update(oldModel)(msg) + val sub = subscriptions(newModel) + + (newModel, (cmd, sub)) } - // end runSub - - val msgLoop = msgQueue.take.flatMap { msg => - model - .modify { case ModelHolder(oldModel, _) => - val (newModel, cmd) = update(oldModel)(msg) - val sub = subscriptions(newModel) - (ModelHolder(newModel, true), (cmd, sub)) - } - .flatMap { (cmd, sub) => - runCmd(cmd) *> runSub(sub) - } - .void - }.foreverM - // end msgLoop - - val renderLoop = - val onMsg = (msg: Msg) => dispatcher.unsafeRunAndForget(msgQueue.offer(msg)) - @nowarn("msg=discarded") - val requestAnimationFrame = F.async_ { cb => - dom.window.requestAnimationFrame(_ => cb(Either.unit)) - () + _ <- runCmd(cmdsAndSubs._1) *> runSub(cmdsAndSubs._2) + _ <- Renderer.redraw(dispatcher, renderer, model, view, onMsg, router) + } yield () + }.foreverM + + msgLoop.background.surround { + runCmd(initCmd) *> F.never + } + + def runCommands[F[_], Msg](msgQueue: Queue[F, Msg])(cmd: Cmd[F, Msg])(using F: Async[F]): F[Unit] = + CmdHelper.cmdToTaskList(cmd).foldMapM { task => + task.handleError(_ => None).flatMap(_.traverse_(msgQueue.offer(_))).start.void + } + + def runSubscriptions[F[_], Msg]( + currentSubs: AtomicCell[F, List[(String, F[Unit])]], + msgQueue: Queue[F, Msg], + dispatcher: Dispatcher[F] + )(sub: Sub[F, Msg])(using F: Async[F]): F[Unit] = + currentSubs.evalUpdate { oldSubs => + val allSubs = SubHelper.flatten(sub) + val (stillAlive, discarded) = SubHelper.aliveAndDead(allSubs, oldSubs) + + val newSubs = SubHelper + .findNewSubs(allSubs, stillAlive.map(_._1), Nil) + .traverse( + SubHelper.runObserve(_) { result => + dispatcher.unsafeRunAndForget( + result.toOption.flatten.foldMapM(msgQueue.offer(_).void) + ) } + ) - def redraw(vnode: VNode) = - model.getAndUpdate(m => ModelHolder(m.model, false)).flatMap { m => - if m.updated then F.delay(Rendering.render(vnode, m.model, view, onMsg, router)) - else F.pure(vnode) - } - - def loop(vnode: VNode): F[Nothing] = - requestAnimationFrame *> redraw(vnode).flatMap(loop(_)) - - F.delay(toVNode(node)).flatMap(loop) - // end renderLoop - - renderLoop.background.surround { - msgLoop.background.surround { - runCmd(initCmd) *> F.never - } - } - } + discarded.foldMapM(_.start.void) *> newSubs.map(_ ++ stillAlive) + } - } + def postMsg[F[_], Msg](dispatcher: Dispatcher[F], msgQueue: Queue[F, Msg]): Msg => Unit = + msg => dispatcher.unsafeRunAndForget(msgQueue.offer(msg))