Implement diff on simple non-nested view

This commit is contained in:
Paul-Henri Froidmont 2025-08-05 02:23:59 +02:00
parent ed71df15f3
commit 0aa10b3114
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
3 changed files with 113 additions and 56 deletions

View file

@ -4,3 +4,4 @@ import mill.*, scalalib.*
object core extends ScalaModule: object core extends ScalaModule:
def scalaVersion = "3.7.2" def scalaVersion = "3.7.2"
def scalacOptions = Seq("-Wunused:all") def scalacOptions = Seq("-Wunused:all")
def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44")

View file

@ -1,41 +1,71 @@
import scala.collection.immutable.ArraySeq import scala.collection.immutable.ArraySeq
import zio.json.*
import zio.json.ast.*
import scala.collection.mutable.ListBuffer
@main @main
def main = def main =
val temlate = Template(TestLiveView.render) val r = TestLiveView.render(MyModel("Initial title"))
println(temlate.init(MyModel("Initial title"))) println(JsonWriter.toJson(r.buildClientStateInit))
println(temlate.update(MyModel("Updated title"))) r.update(MyModel("Updated title"))
println(JsonWriter.toJson(r.buildClientStateDiff))
r.update(MyModel("Updated title"))
println(JsonWriter.toJson(r.buildClientStateDiff))
trait LiveView[Model]: trait LiveView[Model]:
val model = Dyn[Model, Model](identity) val model: Dyn[Model, Model] = Dyn.id
def render: HtmlTag[Model] def view: HtmlTag[Model]
final case class MyModel(title: String) final case class MyModel(title: String)
object TestLiveView extends LiveView[MyModel]: object TestLiveView extends LiveView[MyModel]:
def render: HtmlTag[MyModel] = val view: HtmlTag[MyModel] =
div( div(
div("some text"), div("before"),
model(_.title) model(_.title),
div("after")
) )
class Dyn[I, O](f: I => O): object JsonWriter:
private var last: Option[O] = None def toJson(init: ClientState.Init): String =
Json
.Obj("s" -> Json.Arr(init.static.map(Json.Str(_))*))
.merge(
Json.Obj(
init.dynamic.zipWithIndex.map((v, i) => i.toString -> Json.Str(v))*
)
)
.toJsonPretty
def toJson(diff: ClientState.Diff): String =
Json
.Obj(
"diff" ->
Json.Obj(
diff.dynamic.map((i, v) => i.toString -> Json.Str(v))*
)
)
.toJsonPretty
def apply[O2](f2: O => O2): Dyn[I, O2] = Dyn(f.andThen(f2)) class RenderedDyn[I, O](d: Dyn[I, O], init: I):
private var value: O = d.run(init)
private var updated: Boolean = false
def forceUpate(v: I): O = def wasUpdated: Boolean = updated
val newValue = f(v) def currentValue: O = value
last = Some(newValue)
newValue
def update(v: I): Option[O] = def update(v: I): Unit =
val newValue = f(v) val newValue = d.run(v)
last match if value == newValue then updated = false
case Some(lastValue) if lastValue == newValue => None else
case _ => value = newValue
last = Some(newValue) updated = true
last
opaque type Dyn[I, O] = I => O
extension [I, O](d: Dyn[I, O])
def apply[O2](f: O => O2): Dyn[I, O2] = d.andThen(f)
def run(v: I): O = d(v)
object Dyn:
def id[T]: Dyn[T, T] = identity
enum Mod[T]: enum Mod[T]:
case Tag(v: HtmlTag[T]) case Tag(v: HtmlTag[T])
@ -46,43 +76,68 @@ given [T]: Conversion[HtmlTag[T], Mod[T]] = Mod.Tag(_)
given [T]: Conversion[String, Mod[T]] = Mod.Text(_) given [T]: Conversion[String, Mod[T]] = Mod.Text(_)
given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_) given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_)
class Template[Model]( object ClientState:
private val static: ArraySeq[String], final case class Init(static: Seq[String], dynamic: Seq[String])
private val dynamic: ArraySeq[Dyn[Model, String]]
):
def init(model: Model): Template.InitialState =
Template.InitialState(
static,
dynamic.map(_.forceUpate(model))
)
def update(model: Model): Template.Diff =
Template.Diff(
dynamic.zipWithIndex.flatMap((dyn, i) => dyn.update(model).map(i -> _))
)
object Template:
final case class InitialState(static: Seq[String], dynamic: Seq[String])
final case class Diff(dynamic: Seq[(Int, String)]) final case class Diff(dynamic: Seq[(Int, String)])
def apply[Model](tag: HtmlTag[Model]) =
val (static, dynamic) = buildTag(tag)
new Template(static.to(ArraySeq), dynamic.to(ArraySeq))
def buildMod[Model](mod: Mod[Model]): (List[String], List[Dyn[Model, String]]) = extension [Model](lv: LiveView[Model])
mod match def render(model: Model): RenderedLiveView[Model] =
case Mod.Tag(v) => buildTag(v) RenderedLiveView(lv.view, model)
case Mod.Text(v) => (List(v), List.empty)
case Mod.DynText[Model](v) => (List.empty, List(v))
def buildTag[Model]( class RenderedLiveView[Model] private (
tag: HtmlTag[Model] private val static: ArraySeq[String],
): (List[String], List[Dyn[Model, String]]) = private val dynamic: ArraySeq[
val modsBuilt: List[(List[String], List[Dyn[Model, String]])] = RenderedDyn[Model, String] // | RenderedLiveView[Model]
tag.mods.map(buildMod) ]
val static = ):
List(s"<${tag.name}>") ++ def update(model: Model): Unit =
modsBuilt.flatMap(_._1) ++ dynamic.foreach(_.update(model))
List(s"</${tag.name}>")
val dynamic = modsBuilt.flatMap(_._2) def buildClientStateInit: ClientState.Init =
(static, dynamic) ClientState.Init(
static,
dynamic.map(_.currentValue)
)
def buildClientStateDiff: ClientState.Diff =
ClientState.Diff(
dynamic.zipWithIndex.collect {
case (dyn, i) if dyn.wasUpdated => i -> dyn.currentValue
}
)
object RenderedLiveView:
def apply[Model](tag: HtmlTag[Model], model: Model) =
val static = ListBuffer.empty[String]
val dynamic = ListBuffer.empty[RenderedDyn[Model, String]]
var staticFragment = ""
for elem <- buildTag(tag, model) do
elem match
case s: String =>
staticFragment += s
case d: Dyn[Model, String] =>
static.append(staticFragment)
staticFragment = ""
dynamic.append(RenderedDyn(d, model))
if staticFragment.nonEmpty then static.append(staticFragment)
new RenderedLiveView(static.to(ArraySeq), dynamic.to(ArraySeq))
private def buildTag[Model](
tag: HtmlTag[Model],
model: Model
): List[String | Dyn[Model, String]] =
(s"<${tag.name}>"
:: tag.mods.flatMap(buildMod(_, model))) :+
(s"</${tag.name}>")
private def buildMod[Model](
mod: Mod[Model],
model: Model
): List[String | Dyn[Model, String]] =
mod match
case Mod.Tag(v) => buildTag(v, model)
case Mod.Text(v) => List(v)
case Mod.DynText[Model](v) => List(v)
trait HtmlTag[Model]: trait HtmlTag[Model]:
def name: String def name: String

View file

@ -42,6 +42,7 @@
mill mill
pkgs.scalafmt pkgs.scalafmt
]; ];
shellHook = "mill --bsp-install";
}; };
} }
); );