Rework diff aware state management allowing an API similar to Laminar

This commit is contained in:
Paul-Henri Froidmont 2025-08-19 21:23:29 +02:00
parent 8f19ccfbb4
commit cff02a4c96
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
17 changed files with 857 additions and 710 deletions

View file

@ -7,7 +7,13 @@ import mill.api.Task.Simple
trait Common extends ScalaModule:
def scalaVersion = "3.7.2"
def scalacOptions = Seq("-Wunused:all", "-preview", "-feature", "-language:implicitConversions")
def scalacOptions = Seq(
"-Wunused:all",
"-preview",
"-feature",
"-language:implicitConversions",
"-Wvalue-discard"
)
object core extends Common:
def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44")

View file

@ -1,41 +1,27 @@
import scalive.*
final case class MyModel(cls: String = "text-xs", bool: Boolean = true)
final case class MyModel(
cls: String = "text-xs",
bool: Boolean = true,
elems: List[Elem] = List.empty)
final case class Elem(name: String, age: Int)
class TestView extends LiveView[TestView.Cmd]:
class TestView(initialModel: MyModel) extends LiveView[TestView.Cmd]:
import TestView.Cmd.*
private val textCls = Dyn[String]
private val someBool = Dyn[Boolean]
private val elems = Dyn[List[Elem]]
private val modelVar = Var[MyModel](initialModel)
def mount(state: LiveState): LiveState =
state
.set(textCls, "text-xs")
.set(someBool, true)
.set(
elems,
List(
Elem("a", 10),
Elem("b", 15),
Elem("c", 20)
)
)
def handleCommand(cmd: TestView.Cmd): Unit =
cmd match
case UpdateModel(f) => modelVar.update(f)
def handleCommand(cmd: TestView.Cmd, state: LiveState): LiveState = cmd match
case UpdateElems(es) => state.set(elems, es)
case UpdateBool(b) => state.set(someBool, b)
case UpdateTextCls(cls) => state.set(textCls, cls)
val render =
val el: HtmlElement =
div(
idAttr := "42",
cls := textCls,
draggable := someBool,
disabled := someBool,
idAttr := "42",
cls := modelVar(_.cls),
disabled := modelVar(_.bool),
ul(
elems.splitByIndex(elem =>
modelVar(_.elems).splitByIndex((_, elem) =>
li(
"Nom: ",
elem(_.name),
@ -45,10 +31,7 @@ class TestView extends LiveView[TestView.Cmd]:
)
)
)
end TestView
object TestView:
enum Cmd:
case UpdateElems(es: List[Elem])
case UpdateBool(b: Boolean)
case UpdateTextCls(cls: String)
case UpdateModel(f: MyModel => MyModel)

View file

@ -2,61 +2,82 @@ import scalive.*
@main
def main =
val s = Socket(TestView())
val initModel = MyModel(elems =
List(
Elem("a", 10),
Elem("b", 15),
Elem("c", 30)
)
)
val s = Socket(TestView(initModel))
println("Init")
println(s.renderHtml)
s.syncClient
s.syncClient
println("Edit first and last")
println("Edit class attribue")
s.receiveCommand(
TestView.Cmd.UpdateTextCls("text-lg")
TestView.Cmd.UpdateModel(_.copy(cls = "text-lg"))
)
s.syncClient
println("Edit first and last")
s.receiveCommand(
TestView.Cmd.UpdateElems(
List(
Elem("x", 10),
Elem("b", 15),
Elem("c", 99)
TestView.Cmd.UpdateModel(
_.copy(elems =
List(
Elem("x", 10),
Elem("b", 15),
Elem("c", 99)
)
)
)
)
s.syncClient
println(s.renderHtml)
println("Add one")
s.receiveCommand(
TestView.Cmd.UpdateElems(
List(
Elem("x", 10),
Elem("b", 15),
Elem("c", 99),
Elem("d", 35)
TestView.Cmd.UpdateModel(
_.copy(elems =
List(
Elem("x", 10),
Elem("b", 15),
Elem("c", 99),
Elem("d", 35)
)
)
)
)
s.syncClient
println(s.renderHtml)
//
// println("Remove first")
// lv.update(
// MyModel(
// List(
// NestedModel("b", 15),
// NestedModel("c", 99),
// NestedModel("d", 35)
// )
// )
// )
// println(lv.diff.toJsonPretty)
// println(HtmlBuilder.build(lv))
//
// println("Remove all")
// lv.update(
// MyModel(List.empty, "text-lg", bool = false)
// )
// println(lv.diff.toJsonPretty)
// println(HtmlBuilder.build(lv))
println("Remove first")
s.receiveCommand(
TestView.Cmd.UpdateModel(
_.copy(elems =
List(
Elem("b", 15),
Elem("c", 99),
Elem("d", 35)
)
)
)
)
s.syncClient
println(s.renderHtml)
println("Remove all")
s.receiveCommand(
TestView.Cmd.UpdateModel(
_.copy(
cls = "text-lg",
bool = false,
elems = List.empty
)
)
)
s.syncClient
s.syncClient
println(s.renderHtml)
end main

View file

@ -0,0 +1,50 @@
package scalive
import zio.Chunk
import zio.json.JsonEncoder
import zio.json.ast.Json
enum Diff:
case Tag(
static: Seq[String] = Seq.empty,
dynamic: Seq[Diff.Dynamic] = Seq.empty)
case Split(
static: Seq[String] = Seq.empty,
entries: Seq[Diff.Dynamic] = Seq.empty)
case Value(value: String)
case Dynamic(key: String, diff: Diff)
case Deleted
object Diff:
given JsonEncoder[Diff] = JsonEncoder[Json].contramap(toJson)
private def toJson(diff: Diff): Json =
diff match
case Diff.Tag(static, dynamic) =>
Json.Obj(
Option
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk)
.appendedAll(
dynamic.map(d => d.key -> toJson(d.diff))
)
)
case Diff.Split(static, entries) =>
Json.Obj(
Option
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk)
.appendedAll(
Option.when(entries.nonEmpty)(
"d" ->
Json.Obj(
entries.map(d => d.key -> toJson(d.diff))*
)
)
)
)
case Diff.Value(value) => Json.Str(value)
case Diff.Dynamic(index, diff) =>
Json.Obj(index.toString -> toJson(diff))
case Diff.Deleted => Json.Bool(false)
end Diff

View file

@ -1,53 +1,62 @@
package scalive
import zio.Chunk
import zio.json.ast.Json
import scalive.Mod.Attr
import scalive.Mod.Content
object DiffBuilder:
def build(rendered: Rendered, fingerprint: Fingerprint): Json =
val nestedFingerprintIter = fingerprint.nested.iterator
Json.Obj(
Option
.when(rendered.static.nonEmpty && fingerprint.value != rendered.fingerprint)(
"s" -> Json.Arr(rendered.static.map(Json.Str(_))*)
)
.to(Chunk)
.appendedAll(
rendered.dynamic.zipWithIndex
.map((render, index) => index.toString -> build(render(true), nestedFingerprintIter))
).filterNot(_._2 == Json.Obj.empty)
def build(el: HtmlElement, trackUpdates: Boolean = true): Diff =
build(
static = if trackUpdates then Seq.empty else el.static,
dynamicMods = el.dynamicMods,
trackUpdates = trackUpdates
)
private def build(comp: Comprehension, fingerprint: Fingerprint): Json =
val nestedFingerprintIter = fingerprint.nested.iterator
Json.Obj(
Option
.when(comp.static.nonEmpty && fingerprint.value != comp.fingerprint)(
"s" -> Json.Arr(comp.static.map(Json.Str(_))*)
)
.to(Chunk)
.appendedAll(
Option.when(comp.entries.nonEmpty)(
"d" ->
Json.Arr(
comp.entries.map(render =>
Json.Obj(
render(true).zipWithIndex
.map((dyn, index) =>
index.toString -> build(dyn, nestedFingerprintIter)
).filterNot(_._2 == Json.Obj.empty)*
)
)*
private def build(static: Seq[String], dynamicMods: Seq[DynamicMod], trackUpdates: Boolean)
: Diff =
Diff.Tag(
static = static,
dynamic =
buildDynamic(dynamicMods, trackUpdates).zipWithIndex.collect { case (Some(diff), index) =>
Diff.Dynamic(index.toString, diff)
}
)
private def buildDynamic(dynamicMods: Seq[DynamicMod], trackUpdates: Boolean): Seq[Option[Diff]] =
dynamicMods.flatMap {
case Attr.Dyn(attr, value) =>
List(value.render(trackUpdates).map(v => Diff.Value(v.toString)))
case Attr.DynValueAsPresence(attr, value) =>
List(value.render(trackUpdates).map(v => Diff.Value(if v then s" ${attr.name}" else "")))
case Content.Tag(el) => buildDynamic(el.dynamicMods, trackUpdates)
case Content.DynText(dyn) => List(dyn.render(trackUpdates).map(Diff.Value(_)))
case Content.DynElement(dyn) => ???
case Content.DynOptionElement(dyn) =>
List(dyn.render(trackUpdates) match
// Element is added
case Some(Some(el)) => Some(build(el, trackUpdates = false))
// Element is removed
case Some(None) => Some(Diff.Deleted)
// Element is updated if present
case None => dyn.currentValue.map(build(_, trackUpdates)))
case Content.DynElementColl(dyn) => ???
case Content.DynSplit(splitVar) =>
val entries = splitVar.render(trackUpdates)
if entries.isEmpty then List.empty
else
val static =
entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty)
List(
Some(
Diff.Split(
static = if trackUpdates then Seq.empty else static,
entries = entries.map {
case (key, Some(el)) =>
Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates))
case (key, None) => Diff.Dynamic(key.toString, Diff.Deleted)
}
)
)
)
)
)
}
private def build(dyn: RenderedDyn, fingerprintIter: Iterator[Fingerprint]): Json =
dyn match
case Some(s: String) => Json.Str(s)
case Some(r: Rendered) => build(r, fingerprintIter.nextOption.getOrElse(Fingerprint.empty))
case Some(c: Comprehension) =>
build(c, fingerprintIter.nextOption.getOrElse(Fingerprint.empty))
case None => Json.Obj.empty
end DiffBuilder

136
core/src/scalive/Dyn.scala Normal file
View file

@ -0,0 +1,136 @@
package scalive
import scala.collection.mutable
/** Represents a mutable dynamic value that keeps track of its own state and whether it was changed
* or not. It doesn't observe its parent, therefore sync() must be called manually for updates to
* propagate. Likewise, setUnchanged must be called to reset the change tracking once the latest
* diff has been sent to the client.
*
* Observables are not used on purpose to avoid the complexity of managing their cleanup. The
* tradeoff of micro managing the updates is acceptable as it is only done internally.
*/
sealed trait Dyn[T]:
private[scalive] def currentValue: T
private[scalive] def changed: Boolean
def apply[T2](f: T => T2): Dyn[T2]
def when(zoom: T => Boolean)(el: HtmlElement): Mod =
this match
case v: Var[T] => Mod.Content.DynOptionElement(v.apply(i => Option.when(zoom(i))(el)))
case v: DerivedVar[?, T] =>
Mod.Content.DynOptionElement(v.apply(i => Option.when(zoom(i))(el)))
inline def whenNot(f: T => Boolean)(el: HtmlElement): Mod =
when(f.andThen(!_))(el)
private[scalive] def render(trackUpdates: Boolean): Option[T]
/** Dynamic values do not observe their parent state, sync() must be called manually to update the
* currentValue.
*/
private[scalive] def sync(): Unit
private[scalive] def setUnchanged(): Unit
private[scalive] def callOnEveryChild(f: T => Unit): Unit
extension [T](parent: Dyn[List[T]])
def splitByIndex(project: (Int, Dyn[T]) => HtmlElement): Mod =
Mod.Content.DynSplit(
new SplitVar(
parent.apply(_.zipWithIndex),
key = _._2,
project = (index, v) => project(index, v(_._1))
)
)
class Var[T] private (initial: T) extends Dyn[T]:
private[scalive] var currentValue: T = initial
private[scalive] var changed: Boolean = true
def set(value: T): Unit =
if value != currentValue then
changed = true
currentValue = value
def update(f: T => T): Unit = set(f(currentValue))
def apply[T2](f: T => T2): DerivedVar[T, T2] = new DerivedVar(this, f)
private[scalive] def render(trackUpdates: Boolean): Option[T] =
if !trackUpdates || changed then Some(currentValue)
else None
private[scalive] def setUnchanged(): Unit = changed = false
private[scalive] inline def sync(): Unit = ()
private[scalive] def callOnEveryChild(f: T => Unit): Unit = f(currentValue)
object Var:
def apply[T](initial: T): Var[T] = new Var(initial)
class DerivedVar[I, O] private[scalive] (parent: Var[I], f: I => O) extends Dyn[O]:
private[scalive] var currentValue: O = f(parent.currentValue)
private[scalive] var changed: Boolean = true
def apply[O2](zoom: O => O2): DerivedVar[I, O2] =
new DerivedVar(parent, f.andThen(zoom))
private[scalive] def sync(): Unit =
if parent.changed then
val value = f(parent.currentValue)
if value != currentValue then
changed = true
currentValue = value
private[scalive] def render(trackUpdates: Boolean): Option[O] =
if !trackUpdates || changed then Some(currentValue)
else None
private[scalive] def setUnchanged(): Unit =
changed = false
parent.setUnchanged()
private[scalive] def callOnEveryChild(f: O => Unit): Unit = f(currentValue)
class SplitVar[I, O, Key](
parent: Dyn[List[I]],
key: I => Key,
project: (Key, Dyn[I]) => O):
// Deleted elements have value none
private val memoized: mutable.Map[Key, Option[(Var[I], O)]] =
mutable.Map.empty
private[scalive] def sync(): Unit =
parent.sync()
if parent.changed then
// We keep track of the key to set deleted ones to None
val nextKeys = mutable.HashSet.empty[Key]
parent.currentValue.foreach(input =>
val entryKey = key(input)
nextKeys += entryKey
memoized.updateWith(entryKey) {
// Update matching key
case varAndOutput @ Some(Some((entryVar, _))) =>
entryVar.set(input)
varAndOutput
// Create new item
case Some(None) | None =>
val newVar = Var(input)
Some(Some(newVar, project(entryKey, newVar)))
}
)
memoized.keys.foreach(k => if !nextKeys.contains(k) then memoized.update(k, None))
private[scalive] def render(trackUpdates: Boolean): List[(Key, Option[O])] =
memoized.collect {
case (k, Some(entryVar, output)) if !trackUpdates || entryVar.changed => (k, Some(output))
case (k, None) => (k, None)
}.toList
private[scalive] def setUnchanged(): Unit =
parent.setUnchanged()
// Remove previously deleted
memoized.filterInPlace((_, v) => v.nonEmpty)
// Usefull to call setUnchanged when the output is an HtmlElement as only the caller can know the type
private[scalive] def callOnEveryChild(f: O => Unit): Unit =
memoized.values.foreach(_.foreach((_, output) => f(output)))
end SplitVar

View file

@ -1,53 +0,0 @@
package scalive
import java.nio.ByteBuffer
import java.security.MessageDigest
final case class Fingerprint(value: Long, nested: Seq[Fingerprint])
object Fingerprint:
val empty = Fingerprint(0, Seq.empty)
def apply(rendered: Rendered): Fingerprint =
Fingerprint(
rendered.fingerprint,
rendered.dynamic.flatMap(render => apply(render(false)))
)
def apply(comp: Comprehension): Fingerprint = Fingerprint(comp.fingerprint, Seq.empty)
def apply(dyn: RenderedDyn): Option[Fingerprint] =
dyn match
case Some(r: Rendered) => Some(apply(r))
case Some(c: Comprehension) => Some(apply(c))
case Some(_: String) => None
case None => None
val dynText = digest("text")
val dynAttr = digest("attr")
val dynAttrValueAsPresence = digest("attrValueAsPresence")
val dynWhen = digest("when")
val dynSplit = digest("split")
def digest(s: String): Array[Byte] =
MessageDigest.getInstance("MD5").digest(s.getBytes)
def digest(el: HtmlElement): Array[Byte] =
val md = MessageDigest.getInstance("MD5")
el.static.foreach(s => md.update(s.getBytes))
el.mods.foreach {
case Mod.Text(_) => ()
case Mod.StaticAttr(_, _) => ()
case Mod.StaticAttrValueAsPresence(_, _) => ()
case Mod.DynAttr(_, _) => md.update(Fingerprint.dynAttr)
case Mod.DynAttrValueAsPresence(_, _) => md.update(Fingerprint.dynAttrValueAsPresence)
case Mod.DynText(_) => md.update(Fingerprint.dynText)
case Mod.When(_, _) => md.update(Fingerprint.dynWhen)
case Mod.Split(_, project) => md.update(digest(project(Dyn.apply)))
case Mod.Tag(el) => Right(digest(el))
}
md.digest()
def apply(el: HtmlElement): Long = digestToFingerprint(digest(el))
private def digestToFingerprint(digest: Array[Byte]): Long =
ByteBuffer.wrap(digest, 0, 8).getLong
end Fingerprint

View file

@ -1,28 +1,45 @@
package scalive
import java.io.StringWriter
import scalive.Mod.Attr
import scalive.Mod.Content
object HtmlBuilder:
def build(rendered: Rendered, isRoot: Boolean = false): String =
def build(el: HtmlElement, isRoot: Boolean = false): String =
val strw = new StringWriter()
if isRoot then strw.append("<!doctype html>")
build(rendered.static, rendered.dynamic, strw)
if isRoot then strw.write("<!doctype html>")
build(el.static, el.dynamicMods, strw)
strw.toString()
private def build(
static: Seq[String],
dynamic: Seq[Boolean => RenderedDyn],
dynamic: Seq[(Mod.Attr | Mod.Content) & DynamicMod],
strw: StringWriter
): Unit =
for i <- dynamic.indices do
strw.append(static(i))
dynamic(i)(false).foreach {
case s: String => strw.append(s)
case r: Rendered => build(r)
case c: Comprehension => build(c, strw)
}
strw.append(static.last)
strw.write(static(i))
dynamic(i) match
case Attr.Dyn(attr, value) =>
strw.write(value.render(false).map(attr.codec.encode).getOrElse(""))
case Attr.DynValueAsPresence(attr, value) =>
strw.write(
value.render(false).map(if _ then s" ${attr.name}" else "").getOrElse("")
)
case Content.Tag(el) => build(el.static, el.dynamicMods, strw)
case Content.DynText(dyn) => strw.write(dyn.render(false).getOrElse(""))
case Content.DynElement(dyn) => ???
case Content.DynOptionElement(dyn) =>
dyn.render(false).foreach(_.foreach(el => build(el.static, el.dynamicMods, strw)))
case Content.DynElementColl(dyn) => ???
case Content.DynSplit(splitVar) =>
val entries = splitVar.render(false)
val staticOpt = entries.collectFirst { case (_, Some(el)) => el.static }
entries.foreach {
case (_, Some(entryEl)) =>
build(staticOpt.getOrElse(Nil), entryEl.dynamicMods, strw)
case _ => ()
}
strw.write(static.last)
private def build(comp: Comprehension, strw: StringWriter): Unit =
comp.entries.foreach(entry => build(comp.static, entry(false).map(d => _ => d), strw))
end HtmlBuilder

View file

@ -2,9 +2,27 @@ package scalive
import scalive.codecs.BooleanAsAttrPresenceCodec
import scalive.codecs.Codec
import scalive.Mod.Attr
import scalive.Mod.Content
class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
lazy val static = Rendered.buildStatic(this)
def static: Seq[String] = StaticBuilder.build(this)
def attrMods: Seq[Mod.Attr] =
mods.collect { case mod: Mod.Attr => mod }
def contentMods: Seq[Mod.Content] =
mods.collect { case mod: Mod.Content => mod }
def dynamicMods: Seq[(Mod.Attr | Mod.Content) & DynamicMod] =
dynamicAttrMods ++ dynamicContentMods.flatMap {
case Content.Tag(el) => el.dynamicMods
case mod => List(mod)
}
def dynamicAttrMods: Seq[Mod.Attr & DynamicMod] =
mods.collect { case mod: (Mod.Attr & DynamicMod) => mod }
def dynamicContentMods: Seq[Mod.Content & DynamicMod] =
mods.collect { case mod: (Mod.Content & DynamicMod) => mod }
private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll())
private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged())
class HtmlTag(val name: String, val void: Boolean = false):
def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector)
@ -12,53 +30,87 @@ class HtmlTag(val name: String, val void: Boolean = false):
class HtmlAttr[V](val name: String, val codec: Codec[V, String]):
private inline def isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec
def :=(value: V): Mod =
def :=(value: V): Mod.Attr =
if isBooleanAsAttrPresence then
Mod.StaticAttrValueAsPresence(
Mod.Attr.StaticValueAsPresence(
this.asInstanceOf[HtmlAttr[Boolean]],
value.asInstanceOf[Boolean]
)
else Mod.StaticAttr(this, codec.encode(value))
else Mod.Attr.Static(this, codec.encode(value))
def :=(value: Dyn[V]): Mod =
def :=(value: Dyn[V]): Mod.Attr =
if isBooleanAsAttrPresence then
Mod.DynAttrValueAsPresence(
Mod.Attr.DynValueAsPresence(
this.asInstanceOf[HtmlAttr[Boolean]],
value.asInstanceOf[Dyn[Boolean]]
)
else Mod.DynAttr(this, value)
else Mod.Attr.Dyn(this, value)
enum Mod:
case Text(text: String)
case StaticAttr(attr: HtmlAttr[?], value: String)
case StaticAttrValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean)
case DynAttr[T](attr: HtmlAttr[T], value: Dyn[T])
case DynAttrValueAsPresence(attr: HtmlAttr[Boolean], value: Dyn[Boolean])
case Tag(el: HtmlElement)
case DynText(dyn: Dyn[String])
case When(dyn: Dyn[Boolean], el: HtmlElement)
case Split[T](
dynList: Dyn[List[T]],
project: Dyn[T] => HtmlElement)
sealed trait Mod
sealed trait StaticMod extends Mod
sealed trait DynamicMod extends Mod
final case class Dyn[T] private[scalive] (key: LiveState.Key, f: key.Type => T):
def render(state: LiveState, trackUpdates: Boolean): Option[T] =
val entry = state(key)
if !trackUpdates | entry.changed then Some(f(entry.value))
else None
object Mod:
enum Attr extends Mod:
case Static(attr: HtmlAttr[?], value: String) extends Attr with StaticMod
case StaticValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean) extends Attr with StaticMod
case Dyn[T](attr: HtmlAttr[T], value: scalive.Dyn[T]) extends Attr with DynamicMod
case DynValueAsPresence(attr: HtmlAttr[Boolean], value: scalive.Dyn[Boolean])
extends Attr
with DynamicMod
def map[T2](f2: T => T2): Dyn[T2] = Dyn(key, f.andThen(f2))
enum Content extends Mod:
case Text(text: String) extends Content with StaticMod
case Tag(el: HtmlElement) extends Content with StaticMod with DynamicMod
case DynText(dyn: Dyn[String]) extends Content with DynamicMod
case DynElement(dyn: Dyn[HtmlElement]) extends Content with DynamicMod
// TODO support arbitrary collection
case DynOptionElement(dyn: Dyn[Option[HtmlElement]]) extends Content with DynamicMod
case DynElementColl(dyn: Dyn[Iterable[HtmlElement]]) extends Content with DynamicMod
case DynSplit(v: SplitVar[?, HtmlElement, ?]) extends Content with DynamicMod
inline def apply[T2](f2: T => T2): Dyn[T2] = map(f2)
extension (mod: Mod)
private[scalive] def setAllUnchanged(): Unit =
mod match
case Attr.Static(_, _) => ()
case Attr.StaticValueAsPresence(_, _) => ()
case Attr.Dyn(_, value) => value.setUnchanged()
case Attr.DynValueAsPresence(attr, value) => value.setUnchanged()
case Content.Text(text) => ()
case Content.Tag(el) => el.setAllUnchanged()
case Content.DynText(dyn) => dyn.setUnchanged()
case Content.DynElement(dyn) =>
dyn.setUnchanged()
dyn.callOnEveryChild(_.setAllUnchanged())
case Content.DynOptionElement(dyn) =>
dyn.setUnchanged()
dyn.callOnEveryChild(_.foreach(_.setAllUnchanged()))
case Content.DynElementColl(dyn) =>
dyn.setUnchanged()
dyn.callOnEveryChild(_.foreach(_.setAllUnchanged()))
case Content.DynSplit(v) =>
v.setUnchanged()
v.callOnEveryChild(_.setAllUnchanged())
def when(f2: T => Boolean)(el: HtmlElement): Mod.When = Mod.When(map(f2), el)
inline def whenNot(f2: T => Boolean)(el: HtmlElement): Mod.When =
when(f2.andThen(!_))(el)
extension [T](dyn: Dyn[List[T]])
def splitByIndex(project: Dyn[T] => HtmlElement): Mod.Split[T] =
Mod.Split(dyn, project)
object Dyn:
def apply[T]: Dyn[T] = Dyn(LiveState.Key[T], identity)
private[scalive] def syncAll(): Unit =
mod match
case Attr.Static(_, _) => ()
case Attr.StaticValueAsPresence(_, _) => ()
case Attr.Dyn(_, value) => value.sync()
case Attr.DynValueAsPresence(attr, value) => value.sync()
case Content.Text(text) => ()
case Content.Tag(el) => el.syncAll()
case Content.DynText(dyn) => dyn.sync()
case Content.DynElement(dyn) =>
dyn.sync()
dyn.callOnEveryChild(_.syncAll())
case Content.DynOptionElement(dyn) =>
dyn.sync()
dyn.callOnEveryChild(_.foreach(_.syncAll()))
case Content.DynElementColl(dyn) =>
dyn.sync()
dyn.callOnEveryChild(_.foreach(_.syncAll()))
case Content.DynSplit(v) =>
v.sync()
v.callOnEveryChild(_.syncAll())
end extension

View file

@ -1,32 +0,0 @@
package scalive
final case class LiveState private (val data: Map[LiveState.Key, LiveState.Entry[Any]]):
def get(k: LiveState.Key): Option[LiveState.Entry[k.Type]] =
data.get(k).asInstanceOf[Option[LiveState.Entry[k.Type]]]
def set[T](k: Dyn[T], v: T): LiveState =
copy(data = data.updated(k.key, LiveState.Entry(true, v)))
def apply(k: LiveState.Key): LiveState.Entry[k.Type] =
get(k).getOrElse(throw new IllegalArgumentException("An assign of type"))
def update(k: LiveState.Key, update: k.Type => k.Type): LiveState =
copy(data =
data.updatedWith(k)(
_.asInstanceOf[Option[LiveState.Entry[k.Type]]]
.map(e => LiveState.Entry(true, update(e.value)))
)
)
def remove(k: LiveState.Key): LiveState = copy(data = data - k)
def setAllUnchanged: LiveState =
LiveState(data.view.mapValues(_.copy(changed = false)).toMap)
object LiveState:
final case class Entry[T](changed: Boolean, value: T)
val empty = LiveState(Map.empty)
class Key:
type Type
def toDyn: Dyn[Type] = Dyn(this, identity)
def toDyn[T](f: Type => T): Dyn[T] = Dyn(this, f)
object Key:
def apply[T] = new Key:
type Type = T

View file

@ -1,6 +1,5 @@
package scalive
trait LiveView[Cmd]:
def mount(state: LiveState): LiveState
def handleCommand(cmd: Cmd, state: LiveState): LiveState
def render: HtmlElement
def handleCommand(cmd: Cmd): Unit
val el: HtmlElement

View file

@ -1,122 +0,0 @@
package scalive
import scala.annotation.nowarn
import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ListBuffer
type RenderedDyn = Option[String | Rendered | Comprehension]
final case class Rendered(
// val root: Boolean,
val fingerprint: Long,
val static: Seq[String],
val dynamic: Seq[Boolean => RenderedDyn])
final case class Comprehension(
// val hasKey: Boolean,
val fingerprint: Long,
val static: Seq[String],
val entries: Seq[Boolean => Seq[RenderedDyn]])
object Rendered:
def render(el: HtmlElement, state: LiveState): Rendered =
Rendered(Fingerprint.apply(el), el.static, buildDynamicRendered(el, state))
def render[T](mod: Mod.DynAttr[T], state: LiveState): Boolean => RenderedDyn =
trackUpdates => mod.value.render(state, trackUpdates).map(mod.attr.codec.encode)
def render(mod: Mod.DynAttrValueAsPresence, state: LiveState): Boolean => RenderedDyn =
trackUpdates =>
mod.value.render(state, trackUpdates).map(if _ then s" ${mod.attr.name}" else "")
def render(mod: Mod.DynText, state: LiveState): Boolean => RenderedDyn =
trackUpdates => mod.dyn.render(state, trackUpdates)
def render(mod: Mod.When, state: LiveState): Boolean => RenderedDyn =
trackUpdates =>
mod.dyn
.render(state, trackUpdates)
.collect { case true => render(mod.el, state) }
def render(mod: Mod.Split[Any], state: LiveState): Boolean => RenderedDyn =
trackUpdates =>
mod.dynList
.render(state, trackUpdates)
.collect {
case items if items.nonEmpty =>
val el = mod.project(Dyn.apply)
Comprehension(
Fingerprint.apply(el),
el.static,
items.map(item =>
val localDyn = Dyn[Any]
val localState = LiveState.empty.set(localDyn, item)
val localElem = mod.project(localDyn)
trackElemUpdates =>
buildDynamicRendered(localElem, localState).map(_(trackElemUpdates))
)
)
}
def buildStatic(el: HtmlElement): ArraySeq[String] =
buildStaticFragments(el).flatten.to(ArraySeq)
private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] =
val (attrs, children) = buildStaticFragmentsByType(el)
val static = ListBuffer.empty[Option[String]]
var staticFragment = s"<${el.tag.name}"
for attr <- attrs do
attr match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += (if el.tag.void then "/>" else ">")
for child <- children do
child match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += (if el.tag.void then "" else s"</${el.tag.name}>")
static.append(Some(staticFragment))
static.toSeq
@nowarn("cat=unchecked")
private def buildStaticFragmentsByType(el: HtmlElement)
: (attrs: Seq[Option[String]], children: Seq[Option[String]]) =
val (attrs, children) = el.mods.partitionMap {
case Mod.StaticAttr(attr, value) => Left(List(Some(s""" ${attr.name}="$value"""")))
case Mod.StaticAttrValueAsPresence(attr, true) => Left(List(Some(s" ${attr.name}")))
case Mod.StaticAttrValueAsPresence(attr, false) => Left(List.empty)
case Mod.DynAttr(attr, _) =>
Left(List(Some(s""" ${attr.name}=""""), None, Some('"'.toString)))
case Mod.DynAttrValueAsPresence(attr, _) =>
Left(List(Some(""), None, Some("")))
case Mod.Tag(el) => Right(buildStaticFragments(el))
case Mod.Text(text) => Right(List(Some(text)))
case Mod.DynText(_) => Right(List(None))
case Mod.When(_, _) => Right(List(None))
case Mod.Split[Any](_, _) => Right(List(None))
}
(attrs.flatten, children.flatten)
@nowarn("cat=unchecked")
def buildDynamicRendered(
el: HtmlElement,
state: LiveState
): Seq[Boolean => RenderedDyn] =
val (attrs, children) = el.mods.partitionMap {
case Mod.Text(_) => Right(List.empty)
case Mod.StaticAttr(_, _) => Left(List.empty)
case Mod.StaticAttrValueAsPresence(_, _) => Left(List.empty)
case mod: Mod.DynAttr[?] => Right(List(Rendered.render(mod, state)))
case mod: Mod.DynAttrValueAsPresence => Right(List(Rendered.render(mod, state)))
case Mod.Tag(el) => Right(buildDynamicRendered(el, state))
case mod: Mod.DynText => Right(List(Rendered.render(mod, state)))
case mod: Mod.When => Right(List(Rendered.render(mod, state)))
case mod: Mod.Split[Any] => Right(List(Rendered.render(mod, state)))
}
attrs.flatten ++ children.flatten
end Rendered

View file

@ -3,6 +3,6 @@ import scalive.defs.complex.ComplexHtmlKeys
import scalive.defs.tags.HtmlTags
package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys:
implicit def stringToMod(v: String): Mod = Mod.Text(v)
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Tag(el)
implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.DynText(d)
implicit def stringToMod(v: String): Mod = Mod.Content.Text(v)
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el)
implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.Content.DynText(d)

View file

@ -3,18 +3,21 @@ package scalive
import zio.json.*
final case class Socket[Cmd](lv: LiveView[Cmd]):
private var state: LiveState = lv.mount(LiveState.empty)
private var fingerprint: Fingerprint = Fingerprint.empty
val id: String = "scl-123"
lv.el.syncAll()
private var clientInitialized = false
val id: String = "scl-123"
def receiveCommand(cmd: Cmd): Unit =
state = lv.handleCommand(cmd, state)
lv.handleCommand(cmd)
def renderHtml: String =
HtmlBuilder.build(Rendered.render(lv.render, state), isRoot = true)
lv.el.syncAll()
HtmlBuilder.build(lv.el, isRoot = true)
def syncClient: Unit =
val r = Rendered.render(lv.render, state)
println(DiffBuilder.build(r, fingerprint).toJsonPretty)
fingerprint = Fingerprint(r)
state = state.setAllUnchanged
lv.el.syncAll()
println(DiffBuilder.build(lv.el, trackUpdates = clientInitialized).toJsonPretty)
clientInitialized = true
lv.el.setAllUnchanged()

View file

@ -0,0 +1,53 @@
package scalive
import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ListBuffer
import scalive.Mod.Attr
import scalive.Mod.Content
object StaticBuilder:
def build(el: HtmlElement): ArraySeq[String] =
buildStaticFragments(el).flatten.to(ArraySeq)
private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] =
val attrs = el.attrMods.flatMap {
case Attr.Static(attr, value) => List(Some(s""" ${attr.name}="$value""""))
case Attr.StaticValueAsPresence(attr, value) => List(Some(s" ${attr.name}"))
case Attr.Dyn(attr, value) => List(Some(s""" ${attr.name}=""""), None, Some('"'.toString))
case Attr.DynValueAsPresence(attr, value) => List(Some(""), None, Some(""))
}
val children = el.contentMods.flatMap {
case Content.Text(text) => List(Some(text))
case Content.Tag(el) => buildStaticFragments(el)
case Content.DynText(_) => List(None)
case Content.DynElement(_) => List(None)
case Content.DynOptionElement(_) => List(None)
case Content.DynElementColl(_) => List(None)
case Content.DynSplit(_) => List(None)
}
val static = ListBuffer.empty[Option[String]]
var staticFragment = s"<${el.tag.name}"
for attr <- attrs do
attr match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += (if el.tag.void then "/>" else ">")
for child <- children do
child match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += (if el.tag.void then "" else s"</${el.tag.name}>")
static.append(Some(staticFragment))
static.toSeq
end buildStaticFragments
end StaticBuilder

View file

@ -8,326 +8,352 @@ object LiveViewSpec extends TestSuite:
final case class TestModel(
title: String = "title value",
otherString: String = "other string value",
bool: Boolean = false,
nestedTitle: String = "nested title value",
cls: String = "text-sm",
items: List[NestedModel] = List.empty)
final case class NestedModel(name: String, age: Int)
final case class UpdateCmd(f: TestModel => TestModel)
// def assertEqualsJson(actual: Diff, expected: Json) =
// assert(actual.toJsonPretty == expected.toJsonPretty)
def assertEqualsDiff(el: HtmlElement, expected: Json, trackChanges: Boolean = true) =
el.syncAll()
val actual = DiffBuilder.build(el, trackUpdates = trackChanges)
assert(actual.toJsonPretty == expected.toJsonPretty)
val emptyDiff = Json.Obj.empty
val tests = Tests {
// test("Static only") {
// val lv =
// LiveView(
// new View:
// type Model = Unit
// val render = div("Static string")
// ,
// ()
// )
// test("init") {
// assertEqualsJson(
// lv.fullDiff,
// Json.Obj(
// "s" -> Json.Arr(Json.Str("<div>Static string</div>"))
// )
// )
// }
// test("diff") {
// assertEqualsJson(lv.diff, emptyDiff)
// }
// }
//
// test("Dynamic string") {
// val lv =
// LiveView(
// new View:
// type Model = TestModel
// val render =
// div(model(_.title))
// ,
// TestModel()
// )
// test("init") {
// assertEqualsJson(
// lv.fullDiff,
// Json
// .Obj(
// "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
// "0" -> Json.Str("title value")
// )
// )
// }
// test("diff no update") {
// assertEqualsJson(lv.diff, emptyDiff)
// }
// test("diff with update") {
// lv.update(TestModel(title = "title updated"))
// assertEqualsJson(
// lv.diff,
// Json.Obj("0" -> Json.Str("title updated"))
// )
// }
// test("diff with update and no change") {
// lv.update(TestModel(title = "title updated"))
// lv.update(TestModel(title = "title updated"))
// assertEqualsJson(lv.diff, emptyDiff)
// }
// }
//
// test("Dynamic attribute") {
// val lv =
// LiveView(
// new View:
// type Model = TestModel
// val render =
// div(cls := model(_.cls))
// ,
// TestModel()
// )
// test("init") {
// assertEqualsJson(
// lv.fullDiff,
// Json
// .Obj(
// "s" -> Json
// .Arr(Json.Str("<div class=\""), Json.Str("\"></div>")),
// "0" -> Json.Str("text-sm")
// )
// )
// }
// test("diff no update") {
// assertEqualsJson(lv.diff, emptyDiff)
// }
// test("diff with update") {
// lv.update(TestModel(cls = "text-md"))
// assertEqualsJson(
// lv.diff,
// Json.Obj("0" -> Json.Str("text-md"))
// )
// }
// test("diff with update and no change") {
// lv.update(TestModel(cls = "text-md"))
// lv.update(TestModel(cls = "text-md"))
// assertEqualsJson(lv.diff, emptyDiff)
// }
// }
//
// test("when mod") {
// val lv =
// LiveView(
// new View:
// type Model = TestModel
// val render =
// div(
// model.when(_.bool)(
// div("static string", model(_.nestedTitle))
// )
// )
// ,
// TestModel()
// )
// test("init") {
// assertEqualsJson(
// lv.fullDiff,
// Json
// .Obj(
// "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
// "0" -> Json.Bool(false)
// )
// )
// }
// test("diff no update") {
// assertEqualsJson(lv.diff, emptyDiff)
// }
// test("diff with unrelated update") {
// lv.update(TestModel(title = "title updated"))
// assertEqualsJson(lv.diff, emptyDiff)
// }
// test("diff when true") {
// lv.update(TestModel(bool = true))
// assertEqualsJson(
// lv.diff,
// Json.Obj(
// "0" ->
// Json
// .Obj(
// "s" -> Json
// .Arr(Json.Str("<div>static string"), Json.Str("</div>")),
// "0" -> Json.Str("nested title value")
// )
// )
// )
// }
// test("diff when nested change") {
// lv.update(TestModel(bool = true))
// lv.update(TestModel(bool = true, nestedTitle = "nested title updated"))
// assertEqualsJson(
// lv.diff,
// Json.Obj(
// "0" ->
// Json
// .Obj(
// "0" -> Json.Str("nested title updated")
// )
// )
// )
// }
// }
//
// test("splitByIndex mod") {
// val initModel =
// TestModel(
// items = List(
// NestedModel("a", 10),
// NestedModel("b", 15),
// NestedModel("c", 20)
// )
// )
// val lv =
// LiveView(
// new View:
// type Model = TestModel
// val render =
// div(
// ul(
// model.splitByIndex(_.items)(elem =>
// li(
// "Nom: ",
// elem(_.name),
// " Age: ",
// elem(_.age.toString)
// )
// )
// )
// )
// ,
// initModel
// )
// test("init") {
// assertEqualsJson(
// lv.fullDiff,
// Json
// .Obj(
// "s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
// "0" -> Json.Obj(
// "s" -> Json.Arr(
// Json.Str("<li>Nom: "),
// Json.Str(" Age: "),
// Json.Str("</li>")
// ),
// "d" -> Json.Obj(
// "0" -> Json.Obj(
// "0" -> Json.Str("a"),
// "1" -> Json.Str("10")
// ),
// "1" -> Json.Obj(
// "0" -> Json.Str("b"),
// "1" -> Json.Str("15")
// ),
// "2" -> Json.Obj(
// "0" -> Json.Str("c"),
// "1" -> Json.Str("20")
// )
// )
// )
// )
// )
// }
// test("diff no update") {
// assertEqualsJson(lv.diff, emptyDiff)
// }
// test("diff with unrelated update") {
// lv.update(initModel.copy(title = "title updated"))
// assertEqualsJson(lv.diff, emptyDiff)
// }
// test("diff with item changed") {
// lv.update(
// initModel.copy(items = initModel.items.updated(2, NestedModel("c", 99)))
// )
// assertEqualsJson(
// lv.diff,
// Json.Obj(
// "0" ->
// Json
// .Obj(
// "d" -> Json.Obj(
// "2" -> Json.Obj(
// "1" -> Json.Str("99")
// )
// )
// )
// )
// )
// }
// test("diff with item added") {
// lv.update(
// initModel.copy(items = initModel.items.appended(NestedModel("d", 35)))
// )
// assertEqualsJson(
// lv.diff,
// Json.Obj(
// "0" ->
// Json
// .Obj(
// "d" -> Json.Obj(
// "3" -> Json.Obj(
// "0" -> Json.Str("d"),
// "1" -> Json.Str("35")
// )
// )
// )
// )
// )
// }
// test("diff with first item removed") {
// lv.update(
// initModel.copy(items = initModel.items.tail)
// )
// assertEqualsJson(
// lv.diff,
// Json.Obj(
// "0" ->
// Json
// .Obj(
// "d" -> Json.Obj(
// "0" -> Json.Obj(
// "0" -> Json.Str("b"),
// "1" -> Json.Str("15")
// ),
// "1" -> Json.Obj(
// "0" -> Json.Str("c"),
// "1" -> Json.Str("20")
// ),
// "2" -> Json.Bool(false)
// )
// )
// )
// )
// }
// test("diff all removed") {
// lv.update(initModel.copy(items = List.empty))
// assertEqualsJson(
// lv.diff,
// Json.Obj(
// "0" ->
// Json
// .Obj(
// "d" -> Json.Obj(
// "0" -> Json.Bool(false),
// "1" -> Json.Bool(false),
// "2" -> Json.Bool(false)
// )
// )
// )
// )
//
// }
// }
test("Static only") {
val lv =
new LiveView[Unit]:
val el = div("Static string")
def handleCommand(cmd: Unit): Unit = ()
lv.el.syncAll()
test("init") {
assertEqualsDiff(
lv.el,
Json.Obj(
"s" -> Json.Arr(Json.Str("<div>Static string</div>"))
),
trackChanges = false
)
}
test("diff") {
assertEqualsDiff(lv.el, emptyDiff)
}
}
test("Dynamic string") {
val lv =
new LiveView[UpdateCmd]:
val model = Var(TestModel())
val el =
div(
h1(model(_.title)),
p(model(_.otherString))
)
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
lv.el.syncAll()
lv.el.setAllUnchanged()
test("init") {
assertEqualsDiff(
lv.el,
Json
.Obj(
"s" -> Json.Arr(Json.Str("<div><h1>"), Json.Str("</h1><p>"), Json.Str("</p></div>")),
"0" -> Json.Str("title value"),
"1" -> Json.Str("other string value")
),
trackChanges = false
)
}
test("diff no update") {
assertEqualsDiff(lv.el, emptyDiff)
}
test("diff with update") {
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
assertEqualsDiff(
lv.el,
Json.Obj("0" -> Json.Str("title updated"))
)
}
test("diff with update and no change") {
lv.handleCommand(UpdateCmd(_.copy(title = "title value")))
assertEqualsDiff(lv.el, emptyDiff)
}
test("diff with update in multiple commands") {
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
lv.handleCommand(UpdateCmd(_.copy(otherString = "other string updated")))
assertEqualsDiff(
lv.el,
Json
.Obj(
"0" -> Json.Str("title updated"),
"1" -> Json.Str("other string updated")
)
)
}
}
test("Dynamic attribute") {
val lv =
new LiveView[UpdateCmd]:
val model = Var(TestModel())
val el =
div(cls := model(_.cls))
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
lv.el.syncAll()
lv.el.setAllUnchanged()
test("init") {
assertEqualsDiff(
lv.el,
Json
.Obj(
"s" -> Json
.Arr(Json.Str("<div class=\""), Json.Str("\"></div>")),
"0" -> Json.Str("text-sm")
),
trackChanges = false
)
}
test("diff no update") {
assertEqualsDiff(lv.el, emptyDiff)
}
test("diff with update") {
lv.handleCommand(UpdateCmd(_.copy(cls = "text-md")))
assertEqualsDiff(
lv.el,
Json.Obj("0" -> Json.Str("text-md"))
)
}
}
test("when mod") {
val lv =
new LiveView[UpdateCmd]:
val model = Var(TestModel())
val el =
div(
model.when(_.bool)(
div("static string", model(_.nestedTitle))
)
)
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
lv.el.syncAll()
lv.el.setAllUnchanged()
test("init") {
assertEqualsDiff(
lv.el,
Json
.Obj(
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
"0" -> Json.Bool(false)
),
trackChanges = false
)
}
test("diff no update") {
assertEqualsDiff(lv.el, emptyDiff)
}
test("diff with unrelated update") {
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
assertEqualsDiff(lv.el, emptyDiff)
}
test("diff when true and nested update") {
lv.handleCommand(UpdateCmd(_.copy(bool = true)))
assertEqualsDiff(
lv.el,
Json.Obj(
"0" ->
Json
.Obj(
"s" -> Json
.Arr(Json.Str("<div>static string"), Json.Str("</div>")),
"0" -> Json.Str("nested title value")
)
)
)
}
test("diff when nested change") {
lv.handleCommand(UpdateCmd(_.copy(bool = true)))
lv.el.syncAll()
lv.el.setAllUnchanged()
lv.handleCommand(UpdateCmd(_.copy(bool = true, nestedTitle = "nested title updated")))
assertEqualsDiff(
lv.el,
Json.Obj(
"0" ->
Json
.Obj(
"0" -> Json.Str("nested title updated")
)
)
)
}
}
test("splitByIndex mod") {
val initModel = TestModel(
items = List(
NestedModel("a", 10),
NestedModel("b", 15),
NestedModel("c", 20)
)
)
val lv =
new LiveView[UpdateCmd]:
val model = Var(initModel)
val el =
div(
ul(
model(_.items).splitByIndex((_, elem) =>
li(
"Nom: ",
elem(_.name),
" Age: ",
elem(_.age.toString)
)
)
)
)
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
lv.el.syncAll()
lv.el.setAllUnchanged()
test("init") {
assertEqualsDiff(
lv.el,
Json
.Obj(
"s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
"0" -> Json.Obj(
"s" -> Json.Arr(
Json.Str("<li>Nom: "),
Json.Str(" Age: "),
Json.Str("</li>")
),
"d" -> Json.Obj(
"0" -> Json.Obj(
"0" -> Json.Str("a"),
"1" -> Json.Str("10")
),
"1" -> Json.Obj(
"0" -> Json.Str("b"),
"1" -> Json.Str("15")
),
"2" -> Json.Obj(
"0" -> Json.Str("c"),
"1" -> Json.Str("20")
)
)
)
),
trackChanges = false
)
}
test("diff no update") {
assertEqualsDiff(lv.el, emptyDiff)
}
test("diff with unrelated update") {
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
assertEqualsDiff(lv.el, emptyDiff)
}
test("diff with item changed") {
lv.handleCommand(
UpdateCmd(_.copy(items = initModel.items.updated(2, NestedModel("c", 99))))
)
assertEqualsDiff(
lv.el,
Json.Obj(
"0" ->
Json
.Obj(
"d" -> Json.Obj(
"2" -> Json.Obj(
"1" -> Json.Str("99")
)
)
)
)
)
}
test("diff with item added") {
lv.handleCommand(
UpdateCmd(
_.copy(items = initModel.items.appended(NestedModel("d", 35)))
)
)
assertEqualsDiff(
lv.el,
Json.Obj(
"0" ->
Json
.Obj(
"d" -> Json.Obj(
"3" -> Json.Obj(
"0" -> Json.Str("d"),
"1" -> Json.Str("35")
)
)
)
)
)
}
test("diff with first item removed") {
lv.handleCommand(
UpdateCmd(
_.copy(items = initModel.items.tail)
)
)
assertEqualsDiff(
lv.el,
Json.Obj(
"0" ->
Json
.Obj(
"d" -> Json.Obj(
"0" -> Json.Obj(
"0" -> Json.Str("b"),
"1" -> Json.Str("15")
),
"1" -> Json.Obj(
"0" -> Json.Str("c"),
"1" -> Json.Str("20")
),
"2" -> Json.Bool(false)
)
)
)
)
}
test("diff all removed") {
lv.handleCommand(UpdateCmd(_.copy(items = List.empty)))
assertEqualsDiff(
lv.el,
Json.Obj(
"0" ->
Json
.Obj(
"d" -> Json.Obj(
"0" -> Json.Bool(false),
"1" -> Json.Bool(false),
"2" -> Json.Bool(false)
)
)
)
)
}
}
}
end LiveViewSpec

View file

@ -4,24 +4,11 @@ import zio.*
import zio.http.ChannelEvent.{ExceptionCaught, Read, UserEvent, UserEventTriggered}
import zio.http.*
import zio.http.codec.PathCodec.string
import zio.http.template.Html
object Example extends ZIOAppDefault:
val s = Socket(
TestView,
LiveState.empty.set(
TestView.model,
MyModel(
List(
NestedModel("a", 10),
NestedModel("b", 15),
NestedModel("c", 20)
)
)
)
)
val s = Socket(new TestView())
val socketApp: WebSocketApp[Any] =
Handler.webSocket { channel =>
@ -79,14 +66,26 @@ end Example
final case class MyModel(elems: List[NestedModel], cls: String = "text-xs")
final case class NestedModel(name: String, age: Int)
object TestView extends LiveView:
val model = LiveState.Key[MyModel]
val render =
class TestView extends LiveView[Nothing]:
val model = Var(
MyModel(
List(
NestedModel("a", 10),
NestedModel("b", 15),
NestedModel("c", 20)
)
)
)
def handleCommand(cmd: Nothing): Unit = ()
val el =
div(
idAttr := "42",
cls := model(_.cls),
ul(
model(_.elems).splitByIndex(elem =>
model(_.elems).splitByIndex((_, elem) =>
li(
"Nom: ",
elem(_.name),