Skip to content

Commit

Permalink
Fixed #254: Runtime is less CPU intensive
Browse files Browse the repository at this point in the history
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 f297cb7.

Imperative version, but it kinda works.

Tidying up comments

Use the model Ref to use all updates

Replaced imperative impl with CE
  • Loading branch information
davesmith00000 committed Apr 9, 2024
1 parent f1f1eb3 commit 0980f66
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 84 deletions.
3 changes: 0 additions & 3 deletions tyrian/js/src/main/scala/tyrian/runtime/ModelHolder.scala

This file was deleted.

89 changes: 89 additions & 0 deletions tyrian/js/src/main/scala/tyrian/runtime/Renderer.scala
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions tyrian/js/src/main/scala/tyrian/runtime/RendererState.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package tyrian.runtime

enum RendererState derives CanEqual:
case Idle
case Running(lastTriggered: Long)
17 changes: 3 additions & 14 deletions tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
140 changes: 73 additions & 67 deletions tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
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
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](
Expand All @@ -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))

0 comments on commit 0980f66

Please sign in to comment.