From c4b983f295b073992f4a588e3d535612f69a514c Mon Sep 17 00:00:00 2001 From: nguyenyou Date: Thu, 24 Sep 2026 11:46:47 +0700 Subject: [PATCH 1/2] Perf: Update composite keys (cls) in one pass, without intermediate collections Co-Authored-By: Claude Opus 5.5 --- .../raquo/laminar/codecs/CompositeCodec.scala | 58 ++++++++++++++++++- .../raquo/laminar/nodes/ReactiveElement.scala | 13 +++-- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala b/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala index 610f4315..e3024bc0 100644 --- a/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala +++ b/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala @@ -1,8 +1,7 @@ package com.raquo.laminar.codecs -import com.raquo.ew.ewArray - import scala.scalajs.js.JSStringOps._ +import scala.scalajs.js.| /** Such codecs are used for space-separated or comma-separated values, e.g. in the `cls` attr. */ class CompositeCodec(separator: String) extends Codec[Iterable[String], String] { @@ -14,6 +13,45 @@ class CompositeCodec(separator: String) extends Codec[Iterable[String], String] override def encode(scalaValue: Iterable[String]): String = { scalaValue.mkString(separator) } + + /** Same result as `encode(decode(domValue).filterNot(removeItems.contains) ++ addItems)`, + * computed in one pass without intermediate collections – this runs on every + * composite key update, e.g. every `cls <-- signal` emission. + * + * @param domValue raw DOM value, `()` if the attribute is not set + * @param addItems must be normalized + */ + def encodeUpdated( + domValue: String | Unit, + removeItems: List[String], + addItems: List[String] + ): String = { + var result = "" + var isFirst = true + val append = (item: String) => { + if (isFirst) { + result = item + isFirst = false + } else { + result = result + separator + item + } + } + domValue.foreach { value => + if (value.nonEmpty) { + val items = value.jsSplit(separator) + var i = 0 + while (i < items.length) { + val item = items(i) + if (item.nonEmpty && !removeItems.contains(item)) { + append(item) + } + i += 1 + } + } + } + addItems.foreach(append) + result + } } object CompositeCodec { @@ -29,8 +67,22 @@ object CompositeCodec { def normalize(items: String, separator: String): List[String] = { if (items.isEmpty) { Nil + } else if (items.indexOf(separator) == -1) { + // Fast path for the most common case: a single item, e.g. `cls := "active"` + items :: Nil } else { - items.jsSplit(separator).ew.filter(_.nonEmpty).asScalaJs.toList + val splitItems = items.jsSplit(separator) + // Iterate backwards to build the List by prepending, preserving order + var result: List[String] = Nil + var i = splitItems.length - 1 + while (i >= 0) { + val item = splitItems(i) + if (item.nonEmpty) { + result = item :: result + } + i -= 1 + } + result } } } diff --git a/src/main/scala/com/raquo/laminar/nodes/ReactiveElement.scala b/src/main/scala/com/raquo/laminar/nodes/ReactiveElement.scala index 78d718a7..4fa477f2 100644 --- a/src/main/scala/com/raquo/laminar/nodes/ReactiveElement.scala +++ b/src/main/scala/com/raquo/laminar/nodes/ReactiveElement.scala @@ -127,13 +127,14 @@ with ParentNode[Ref] { val itemsToAdd = addItems.distinct val itemsToRemoveFromDom = removeItems.filterNot(itemHasAnotherReason) - val newItems = _compositeValues - .getOrElse(key, Nil) + val newItems = keyItemsWithReason .filterNot(t => removeItems.contains(t._1) && t._2 == reason) ++ itemsToAdd.map((_, reason)) - val domValues = key.getRawDomValue(this).map(key.codec.decode).getOrElse(Nil) - - val nextDomValues = domValues.filterNot(itemsToRemoveFromDom.contains) ++ itemsToAdd.filterNot(itemHasAnotherReason) + val nextDomValue = key.codec.encodeUpdated( + domValue = key.getRawDomValue(this), + removeItems = itemsToRemoveFromDom, + addItems = itemsToAdd.filterNot(itemHasAnotherReason) + ) // 1. Update Laminar's internal structure _compositeValues = _compositeValues.updated(key, newItems) @@ -142,7 +143,7 @@ with ParentNode[Ref] { // #Note this logic is compatible with third parties setting classes on Laminar elements // using raw JS methods as long as they don't remove classes managed by Laminar or add // classes that were also added by Laminar. - key.setRawDomValue(this, key.codec.encode(nextDomValues)) + key.setRawDomValue(this, nextDomValue) } val tag: Tag[ReactiveElement[Ref]] From 1210af000e5695b461fb0d3fb52df2591c3ded4b Mon Sep 17 00:00:00 2001 From: nguyenyou Date: Thu, 24 Sep 2026 11:59:32 +0700 Subject: [PATCH 2/2] Fix: Preserve custom composite codecs during updates Keep the one-pass path for default keys while custom codecs continue to use their overridden decode and encode methods. Co-Authored-By: Codex GPT-6 Astra --- .../raquo/laminar/codecs/CompositeCodec.scala | 17 ++++++++--- .../com/raquo/laminar/keys/CompositeKey.scala | 4 +-- .../laminar/tests/CompositeKeySpec.scala | 29 +++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala b/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala index e3024bc0..e917c899 100644 --- a/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala +++ b/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala @@ -1,7 +1,7 @@ package com.raquo.laminar.codecs -import scala.scalajs.js.JSStringOps._ import scala.scalajs.js.| +import scala.scalajs.js.JSStringOps._ /** Such codecs are used for space-separated or comma-separated values, e.g. in the `cls` attr. */ class CompositeCodec(separator: String) extends Codec[Iterable[String], String] { @@ -14,9 +14,7 @@ class CompositeCodec(separator: String) extends Codec[Iterable[String], String] scalaValue.mkString(separator) } - /** Same result as `encode(decode(domValue).filterNot(removeItems.contains) ++ addItems)`, - * computed in one pass without intermediate collections – this runs on every - * composite key update, e.g. every `cls <-- signal` emission. + /** Updates a composite value using this codec's decode and encode methods. * * @param domValue raw DOM value, `()` if the attribute is not set * @param addItems must be normalized @@ -25,6 +23,17 @@ class CompositeCodec(separator: String) extends Codec[Iterable[String], String] domValue: String | Unit, removeItems: List[String], addItems: List[String] + ): String = { + encode(domValue.map(decode).getOrElse(Nil).filterNot(removeItems.contains) ++ addItems) + } +} + +final private[laminar] class DefaultCompositeCodec(separator: String) extends CompositeCodec(separator) { + + override def encodeUpdated( + domValue: String | Unit, + removeItems: List[String], + addItems: List[String] ): String = { var result = "" var isFirst = true diff --git a/src/main/scala/com/raquo/laminar/keys/CompositeKey.scala b/src/main/scala/com/raquo/laminar/keys/CompositeKey.scala index b700debb..70d3394e 100644 --- a/src/main/scala/com/raquo/laminar/keys/CompositeKey.scala +++ b/src/main/scala/com/raquo/laminar/keys/CompositeKey.scala @@ -2,7 +2,7 @@ package com.raquo.laminar.keys import com.raquo.airstream.core.Source import com.raquo.laminar.api.{MapValueMapper, StringOptionValueMapper, StringSeqValueMapper, StringValueMapper} -import com.raquo.laminar.codecs.CompositeCodec +import com.raquo.laminar.codecs.{CompositeCodec, DefaultCompositeCodec} import com.raquo.laminar.modifiers.{CompositeKeySetter, CompositeKeyUpdater} import com.raquo.laminar.nodes.ReactiveElement @@ -32,7 +32,7 @@ abstract class CompositeKey[ // val separator: String - val codec: CompositeCodec = new CompositeCodec(separator) + val codec: CompositeCodec = new DefaultCompositeCodec(separator) def :=(items: String): CompositeKeySetter[Self, El] = { addStaticItems(StringValueMapper.toNormalizedList(items, separator)) diff --git a/src/test/scala/com/raquo/laminar/tests/CompositeKeySpec.scala b/src/test/scala/com/raquo/laminar/tests/CompositeKeySpec.scala index 17ce1a4b..d6cb1c4f 100644 --- a/src/test/scala/com/raquo/laminar/tests/CompositeKeySpec.scala +++ b/src/test/scala/com/raquo/laminar/tests/CompositeKeySpec.scala @@ -1,10 +1,39 @@ package com.raquo.laminar.tests import com.raquo.laminar.api.L._ +import com.raquo.laminar.codecs.CompositeCodec +import com.raquo.laminar.keys.CompositeAttr +import com.raquo.laminar.nodes.ReactiveElement import com.raquo.laminar.utils.UnitSpec class CompositeKeySpec extends UnitSpec { + it("custom composite codec uses its encode and decode overrides") { + val customKey = new CompositeAttr[ReactiveElement.Base]("data-custom", " ") { + override val codec: CompositeCodec = new CompositeCodec(" ") { + override def decode(domValue: String): List[String] = { + super.decode(domValue.stripPrefix("(").stripSuffix(")")) + } + + override def encode(scalaValue: Iterable[String]): String = { + "(" + super.encode(scalaValue) + ")" + } + } + } + val item = Var("bar") + val el = div(customKey := "foo", customKey <-- item) + mount(el) + + withClue("Initial binding:") { + assert(el.ref.getAttribute("data-custom") == "(foo bar)") + } + + withClue("Reactive update:") { + item.set("baz") + assert(el.ref.getAttribute("data-custom") == "(foo baz)") + } + } + it("cls - simple static modifiers") { val el = div(className := "foo") mount(el)