Intermediate diff representation

This commit is contained in:
Paul-Henri Froidmont 2025-08-12 03:30:56 +02:00
parent 13c15cc289
commit 844a1f7953
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
5 changed files with 147 additions and 74 deletions

View file

@ -0,0 +1,91 @@
package scalive
import zio.json.ast.*
enum Diff:
case Mod(
static: Seq[String] = Seq.empty,
dynamic: Seq[Diff.Dynamic] = Seq.empty
)
case Split(
static: Seq[String] = Seq.empty,
dynamic: Seq[Diff.Dynamic] = Seq.empty
)
case Static(value: String)
case Dynamic(index: Int, diff: Diff)
case Deleted
object DiffEngine:
def buildInitJson(lv: RenderedLiveView[?]): Json =
JsonAstBuilder
.diffToJson(
buildDiffValue(lv.static, lv.dynamic, includeUnchanged = true)
)
def buildDiffJson(lv: RenderedLiveView[?]): Json =
Json
.Obj(
"diff" -> JsonAstBuilder.diffToJson(
buildDiffValue(static = Seq.empty, lv.dynamic)
)
)
private def buildDiffValue(
static: Seq[String],
dynamic: Seq[RenderedMod[?]],
includeUnchanged: Boolean = false
): Diff.Mod =
Diff.Mod(
static = static,
dynamic = dynamic.zipWithIndex
.filter(includeUnchanged || _._1.wasUpdated)
.map {
case (v: RenderedMod.Dynamic[?, ?], i) =>
Diff.Dynamic(i, Diff.Static(v.currentValue.toString))
case (v: RenderedMod.When[?], i) =>
if v.displayed then
if includeUnchanged || v.cond.wasUpdated then
Diff.Dynamic(
i,
buildDiffValue(
v.nested.static,
v.nested.dynamic,
includeUnchanged = true
)
)
else
Diff.Dynamic(
i,
buildDiffValue(
static = Seq.empty,
v.nested.dynamic,
includeUnchanged
)
)
else Diff.Dynamic(i, Diff.Deleted)
case (v: RenderedMod.Split[?, ?], i) =>
Diff.Dynamic(
i,
Diff.Split(
static = if includeUnchanged then v.static else Seq.empty,
dynamic = v.dynamic.toList.zipWithIndex
.filter(includeUnchanged || _._1.exists(_.wasUpdated))
.map[Diff.Dynamic]((mods, i) =>
Diff.Dynamic(
i,
buildDiffValue(
static = Seq.empty,
dynamic = mods,
includeUnchanged
)
)
)
.appendedAll(
v.removedIndexes
.map(i => Diff.Dynamic(i, Diff.Deleted))
)
)
)
}
)

View file

@ -1,52 +1,36 @@
package scalive package scalive
import zio.Chunk
import zio.json.ast.* import zio.json.ast.*
object JsonAstBuilder: object JsonAstBuilder:
def buildInit(static: Seq[String], dynamic: Seq[RenderedMod[?]]): Json =
Json
.Obj("s" -> Json.Arr(static.map(Json.Str(_))*))
.merge(buildDiffValue(dynamic, includeUnchanged = true))
def buildDiff(dynamic: Seq[RenderedMod[?]]): Json = def diffToJson(diff: Diff): Json =
Json.Obj("diff" -> buildDiffValue(dynamic)) diff match
case Diff.Mod(static, dynamic) =>
private def buildDiffValue(
dynamic: Seq[RenderedMod[?]],
includeUnchanged: Boolean = false
): Json =
Json.Obj( Json.Obj(
dynamic.zipWithIndex.filter(includeUnchanged || _._1.wasUpdated).map { Option
case (v: RenderedMod.Dynamic[?, ?], i) => .when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
i.toString -> Json.Str(v.currentValue.toString) .to(Chunk)
case (v: RenderedMod.When[?], i) => .appendedAll(
if v.displayed then dynamic.map(d => d.index.toString -> diffToJson(d.diff))
if includeUnchanged || v.cond.wasUpdated then
i.toString -> v.nested.buildInitJson
else
i.toString -> buildDiffValue(v.nested.dynamic, includeUnchanged)
else i.toString -> Json.Bool(false)
case (v: RenderedMod.Split[?, ?], i) =>
i.toString ->
Json
.Obj(
(Option
.when(includeUnchanged)(
"s" -> Json.Arr(v.static.map(Json.Str(_))*)
) )
.toList ++ )
List( case Diff.Split(static, dynamic) =>
Json.Obj(
Option
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk)
.appendedAll(
Option.when(dynamic.nonEmpty)(
"d" -> "d" ->
Json.Obj( Json.Obj(
(v.dynamic.toList.zipWithIndex dynamic.map(d => d.index.toString -> diffToJson(d.diff))*
.filter(includeUnchanged || _._1.exists(_.wasUpdated))
.map((mods, i) =>
i.toString -> buildDiffValue(mods, includeUnchanged)
) ++
v.removedIndexes
.map(i => i.toString -> Json.Bool(false)))*
) )
))*
) )
}*
) )
)
case Diff.Static(value) => Json.Str(value)
case Diff.Dynamic(index, diff) =>
Json.Obj(index.toString -> diffToJson(diff))
case Diff.Deleted => Json.Bool(false)

View file

@ -1,12 +1,12 @@
package scalive package scalive
import scala.collection.mutable.ListBuffer
import scala.collection.immutable.ArraySeq
import zio.json.ast.Json
import scala.collection.mutable.ArrayBuffer
import scalive.LiveViewRenderer.buildStatic
import scalive.LiveViewRenderer.buildDynamic import scalive.LiveViewRenderer.buildDynamic
import scalive.LiveViewRenderer.buildStatic
import scala.annotation.nowarn import scala.annotation.nowarn
import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ArrayBuffer
import scala.collection.mutable.ListBuffer
class RenderedLiveView[Model] private[scalive] ( class RenderedLiveView[Model] private[scalive] (
val static: ArraySeq[String], val static: ArraySeq[String],
@ -15,8 +15,6 @@ class RenderedLiveView[Model] private[scalive] (
def update(model: Model): Unit = def update(model: Model): Unit =
dynamic.foreach(_.update(model)) dynamic.foreach(_.update(model))
def wasUpdated: Boolean = dynamic.exists(_.wasUpdated) def wasUpdated: Boolean = dynamic.exists(_.wasUpdated)
def buildInitJson: Json = JsonAstBuilder.buildInit(static, dynamic)
def buildDiffJson: Json = JsonAstBuilder.buildDiff(dynamic)
sealed trait RenderedMod[Model]: sealed trait RenderedMod[Model]:
def update(model: Model): Unit def update(model: Model): Unit

View file

@ -15,7 +15,7 @@ def main =
) )
) )
) )
println(r.buildInitJson.toJsonPretty) println(DiffEngine.buildInitJson(r).toJsonPretty)
println("Edit first and last") println("Edit first and last")
r.update( r.update(
MyModel( MyModel(
@ -26,7 +26,7 @@ def main =
) )
) )
) )
println(r.buildDiffJson.toJsonPretty) println(DiffEngine.buildDiffJson(r).toJsonPretty)
println("Add one") println("Add one")
r.update( r.update(
MyModel( MyModel(
@ -38,7 +38,7 @@ def main =
) )
) )
) )
println(r.buildDiffJson.toJsonPretty) println(DiffEngine.buildDiffJson(r).toJsonPretty)
println("Remove first") println("Remove first")
r.update( r.update(
MyModel( MyModel(
@ -49,12 +49,12 @@ def main =
) )
) )
) )
println(r.buildDiffJson.toJsonPretty) println(DiffEngine.buildDiffJson(r).toJsonPretty)
println("Remove all") println("Remove all")
r.update( r.update(
MyModel(List.empty) MyModel(List.empty)
) )
println(r.buildDiffJson.toJsonPretty) println(DiffEngine.buildDiffJson(r).toJsonPretty)
final case class MyModel(elems: List[NestedModel]) final case class MyModel(elems: List[NestedModel])
final case class NestedModel(name: String, age: Int) final case class NestedModel(name: String, age: Int)

View file

@ -32,14 +32,14 @@ object LiveViewSpec extends TestSuite:
) )
test("init") { test("init") {
assertEqualsJson( assertEqualsJson(
lv.buildInitJson, DiffEngine.buildInitJson(lv),
Json.Obj( Json.Obj(
"s" -> Json.Arr(Json.Str("<div>Static string</div>")) "s" -> Json.Arr(Json.Str("<div>Static string</div>"))
) )
) )
} }
test("diff") { test("diff") {
assertEqualsJson(lv.buildDiffJson, emptyDiff) assertEqualsJson(DiffEngine.buildDiffJson(lv), emptyDiff)
} }
} }
@ -54,7 +54,7 @@ object LiveViewSpec extends TestSuite:
) )
test("init") { test("init") {
assertEqualsJson( assertEqualsJson(
lv.buildInitJson, DiffEngine.buildInitJson(lv),
Json Json
.Obj( .Obj(
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")), "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
@ -63,12 +63,12 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff no update") { test("diff no update") {
assertEqualsJson(lv.buildDiffJson, emptyDiff) assertEqualsJson(DiffEngine.buildDiffJson(lv), emptyDiff)
} }
test("diff with update") { test("diff with update") {
lv.update(TestModel(title = "title updated")) lv.update(TestModel(title = "title updated"))
assertEqualsJson( assertEqualsJson(
lv.buildDiffJson, DiffEngine.buildDiffJson(lv),
Json.Obj( Json.Obj(
"diff" -> Json.Obj("0" -> Json.Str("title updated")) "diff" -> Json.Obj("0" -> Json.Str("title updated"))
) )
@ -77,7 +77,7 @@ object LiveViewSpec extends TestSuite:
test("diff with update and no change") { test("diff with update and no change") {
lv.update(TestModel(title = "title updated")) lv.update(TestModel(title = "title updated"))
lv.update(TestModel(title = "title updated")) lv.update(TestModel(title = "title updated"))
assertEqualsJson(lv.buildDiffJson, emptyDiff) assertEqualsJson(DiffEngine.buildDiffJson(lv), emptyDiff)
} }
} }
@ -96,7 +96,7 @@ object LiveViewSpec extends TestSuite:
) )
test("init") { test("init") {
assertEqualsJson( assertEqualsJson(
lv.buildInitJson, DiffEngine.buildInitJson(lv),
Json Json
.Obj( .Obj(
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")), "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
@ -105,16 +105,16 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff no update") { test("diff no update") {
assertEqualsJson(lv.buildDiffJson, emptyDiff) assertEqualsJson(DiffEngine.buildDiffJson(lv), emptyDiff)
} }
test("diff with unrelated update") { test("diff with unrelated update") {
lv.update(TestModel(title = "title updated")) lv.update(TestModel(title = "title updated"))
assertEqualsJson(lv.buildDiffJson, emptyDiff) assertEqualsJson(DiffEngine.buildDiffJson(lv), emptyDiff)
} }
test("diff when true") { test("diff when true") {
lv.update(TestModel(bool = true)) lv.update(TestModel(bool = true))
assertEqualsJson( assertEqualsJson(
lv.buildDiffJson, DiffEngine.buildDiffJson(lv),
Json.Obj( Json.Obj(
"diff" -> Json.Obj( "diff" -> Json.Obj(
"0" -> "0" ->
@ -132,7 +132,7 @@ object LiveViewSpec extends TestSuite:
lv.update(TestModel(bool = true)) lv.update(TestModel(bool = true))
lv.update(TestModel(bool = true, nestedTitle = "nested title updated")) lv.update(TestModel(bool = true, nestedTitle = "nested title updated"))
assertEqualsJson( assertEqualsJson(
lv.buildDiffJson, DiffEngine.buildDiffJson(lv),
Json.Obj( Json.Obj(
"diff" -> Json.Obj( "diff" -> Json.Obj(
"0" -> "0" ->
@ -176,7 +176,7 @@ object LiveViewSpec extends TestSuite:
) )
test("init") { test("init") {
assertEqualsJson( assertEqualsJson(
lv.buildInitJson, DiffEngine.buildInitJson(lv),
Json Json
.Obj( .Obj(
"s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")), "s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
@ -205,11 +205,11 @@ object LiveViewSpec extends TestSuite:
) )
} }
test("diff no update") { test("diff no update") {
assertEqualsJson(lv.buildDiffJson, emptyDiff) assertEqualsJson(DiffEngine.buildDiffJson(lv), emptyDiff)
} }
test("diff with unrelated update") { test("diff with unrelated update") {
lv.update(initModel.copy(title = "title updated")) lv.update(initModel.copy(title = "title updated"))
assertEqualsJson(lv.buildDiffJson, emptyDiff) assertEqualsJson(DiffEngine.buildDiffJson(lv), emptyDiff)
} }
test("diff with item changed") { test("diff with item changed") {
lv.update( lv.update(
@ -218,7 +218,7 @@ object LiveViewSpec extends TestSuite:
) )
) )
assertEqualsJson( assertEqualsJson(
lv.buildDiffJson, DiffEngine.buildDiffJson(lv),
Json.Obj( Json.Obj(
"diff" -> Json.Obj( "diff" -> Json.Obj(
"0" -> "0" ->
@ -239,7 +239,7 @@ object LiveViewSpec extends TestSuite:
initModel.copy(items = initModel.items.appended(NestedModel("d", 35))) initModel.copy(items = initModel.items.appended(NestedModel("d", 35)))
) )
assertEqualsJson( assertEqualsJson(
lv.buildDiffJson, DiffEngine.buildDiffJson(lv),
Json.Obj( Json.Obj(
"diff" -> Json.Obj( "diff" -> Json.Obj(
"0" -> "0" ->
@ -261,7 +261,7 @@ object LiveViewSpec extends TestSuite:
initModel.copy(items = initModel.items.tail) initModel.copy(items = initModel.items.tail)
) )
assertEqualsJson( assertEqualsJson(
lv.buildDiffJson, DiffEngine.buildDiffJson(lv),
Json.Obj( Json.Obj(
"diff" -> Json.Obj( "diff" -> Json.Obj(
"0" -> "0" ->
@ -286,7 +286,7 @@ object LiveViewSpec extends TestSuite:
test("diff all removed") { test("diff all removed") {
lv.update(initModel.copy(items = List.empty)) lv.update(initModel.copy(items = List.empty))
assertEqualsJson( assertEqualsJson(
lv.buildDiffJson, DiffEngine.buildDiffJson(lv),
Json.Obj( Json.Obj(
"diff" -> Json.Obj( "diff" -> Json.Obj(
"0" -> "0" ->